diff --git a/.claude/skills/memind-page-data-survey-verify/SKILL.md b/.claude/skills/memind-page-data-survey-verify/SKILL.md
new file mode 100644
index 0000000..0e0129b
--- /dev/null
+++ b/.claude/skills/memind-page-data-survey-verify/SKILL.md
@@ -0,0 +1,110 @@
+---
+name: memind-page-data-survey-verify
+description: >-
+ Verify Page Data survey update chains (e.g. john4 children-hobby q4_diet_meals)
+ without manual chat input. Use when the user asks to 验证问卷链路, 饮食偏好测试,
+ page-data-collect 回归, 儿童爱好问卷验证, or after agent-run / MCP / page-data changes.
+---
+
+# Page Data 问卷链路自动验证
+
+用户说「**帮我验证问卷 / 饮食偏好链路**」「**不用我手动测**」时,**必须**走本 skill,不要只在浏览器里手点。
+
+## 两条路径
+
+| 路径 | 何时用 | 命令 | 耗时 |
+|------|--------|------|------|
+| **A. 产物验证(默认)** | Agent 已跑完,或只改 Page Data / HTML / 策略 | 见下方 | ~5s |
+| **B. 全链路 E2E** | 要测 Agent 聊天 + 工具 + 交付 | 见下方 | ~3–10min |
+
+### A. 产物验证(推荐日常)
+
+前提:Portal 已运行(`pnpm dev`),默认 `http://127.0.0.1:8081`。
+
+```bash
+npm run verify:children-hobby-diet-survey
+```
+
+等价:
+
+```bash
+node scripts/verify-children-hobby-diet-survey.mjs
+```
+
+可选:
+
+```bash
+node scripts/verify-children-hobby-diet-survey.mjs --no-insert # 不写测试提交
+node scripts/verify-children-hobby-diet-survey.mjs --no-runs # 不查 orphan running
+```
+
+**自动检查项:**
+
+1. SQLite 表 `children_hobby_survey` 含 `q4_diet_meals`
+2. dataset 注册 insert 列含 `q4_diet_meals`
+3. 问卷 / 后台 HTML 含饮食字段与 Page Data 客户端
+4. 公开页 policy 允许 insert `q4_diet_meals`
+5. MindSpace URL HTTP 200
+6. 公开 insert API 实测提交(默认开启)
+7. 无 orphan `running` agent run(避免前端一直 loading)
+
+### B. 全链路 E2E(测 Agent)
+
+```bash
+npm run test:scenario:john4-diet
+```
+
+场景文件:[`scenarios/john4-children-hobby-diet-update.json`](../../../scenarios/john4-children-hobby-diet-update.json)
+
+- 账户:`john4` / `888888`(可用 `JOHN4_PASSWORD` 覆盖)
+- 话术:**「问卷页面再增加一个饮食偏好,比如每日几顿这种」**
+- 技能:`page-data-collect`
+- 超时:10 分钟
+- 结束后自动跑与路径 A 相同的产物验证
+
+## Agent 工作流
+
+1. **确认 Portal**:`curl -sf http://127.0.0.1:8081/auth/status`
+2. **默认跑路径 A**;用户明确要测 Agent 再跑路径 B
+3. **失败时**:读失败项 → 修代码/环境 → 重跑同一条命令
+4. **汇报**:用下方模板
+
+## 已知链路风险(非权限问题)
+
+验证通过仍可能出现过这些**过程性**问题,写入排查结论时一并说明:
+
+| 现象 | 根因 | 是否权限 |
+|------|------|----------|
+| 前端一直 loading | agent run 孤儿化(Portal 重启) | ❌ |
+| 工具 `-32603: Transport closed` | sandbox-fs MCP 子进程断开 | ❌ |
+| insert 403 columns_not_allowed | policy 未更新(bind 失败) | ⚠️ 策略,非用户账号 |
+| Agent 用 shell 写 HTML | MCP 不稳定时的绕路 | ❌ |
+
+## 结果模板
+
+```markdown
+## 问卷饮食偏好链路验证
+
+- 模式:产物验证 / E2E
+- Portal:http://127.0.0.1:8081
+- 用户:john4
+- 结果:通过 / 失败
+
+### 检查项
+1. SQLite q4_diet_meals — 通过/失败
+2. HTML 问卷+后台 — 通过/失败
+3. Page Data policy — 通过/失败
+4. 公开 insert API — 通过/失败
+5. Agent run 无 orphan — 通过/失败
+
+### 失败项与下一步
+```
+
+## 与其它 skill 分工
+
+| 诉求 | 用哪个 |
+|------|--------|
+| 验证已有问卷增量(本案例) | **本 skill** |
+| 通用聊天+做页面场景 | `memind-scenario-test` |
+| 改代码后单测/守卫 | `memind-test` |
+| Page Data 单元测试 | `npm run verify:page-data` |
diff --git a/.env.example b/.env.example
index 5933c2d..8b858dd 100644
--- a/.env.example
+++ b/.env.example
@@ -140,12 +140,19 @@ H5_ADMIN_PASSWORD=change-me-admin
H5_ACCESS_PASSWORD=change-me
# 计费(Phase 2,金额单位均为人民币分)
-# 默认按 Token 单价扣费(人民币结算);勿开启美元成本换算除非明确需要
+# 默认按 Token 单价扣费;生产推荐开启成本模式(见 docs/h5-metering-gateway.md)
# H5_USE_BACKEND_COST=0
# H5_BILL_INPUT_CENTS_PER_1K=2 # 输入 Token:2 分/1k(¥0.02/1k)
# H5_BILL_OUTPUT_CENTS_PER_1K=6 # 输出 Token:6 分/1k(¥0.06/1k)
# H5_MIN_BILL_CENTS=1
-# 仅调试:H5_USE_BACKEND_COST=1 + H5_USD_CNY_RATE=7.2(上游 USD × 汇率 → 人民币分)
+# 成本模式:上游 accumulated_cost(USD) × 汇率 × 毛利
+# H5_USE_BACKEND_COST=1
+# H5_USD_CNY_RATE=7.2
+# H5_MARGIN_MULTIPLIER=1.2
+# 当 goose Finish 未带 accumulatedCost 时,按 token 估算上游 USD 成本(默认开启,设 0 关闭)
+# H5_COST_ESTIMATE_FROM_TOKENS=1
+# H5_COST_ESTIMATE_INPUT_USD_PER_1M=0.27
+# H5_COST_ESTIMATE_OUTPUT_USD_PER_1M=1.1
# 用户自助充值(微信支付)
# H5_RECHARGE_TIERS_CENTS=500,1000,3000,5000,10000,20000
diff --git a/billing-token-state.mjs b/billing-token-state.mjs
new file mode 100644
index 0000000..ca6a4da
--- /dev/null
+++ b/billing-token-state.mjs
@@ -0,0 +1,79 @@
+import { normalizeTokenState } from './billing.mjs';
+
+export function loadCostEstimateConfig(env = process.env) {
+ const useBackendCost = env.H5_USE_BACKEND_COST === '1';
+ const enabled = useBackendCost && env.H5_COST_ESTIMATE_FROM_TOKENS !== '0';
+ const inputUsdPer1M = Number(env.H5_COST_ESTIMATE_INPUT_USD_PER_1M ?? 0.27);
+ const outputUsdPer1M = Number(env.H5_COST_ESTIMATE_OUTPUT_USD_PER_1M ?? 1.1);
+ return {
+ enabled,
+ inputUsdPer1M: Number.isFinite(inputUsdPer1M) && inputUsdPer1M >= 0 ? inputUsdPer1M : 0.27,
+ outputUsdPer1M: Number.isFinite(outputUsdPer1M) && outputUsdPer1M >= 0 ? outputUsdPer1M : 1.1,
+ };
+}
+
+export function pickSessionAccumulatedCost(session) {
+ if (!session || typeof session !== 'object') return null;
+ const raw = session.accumulatedCost ?? session.accumulated_cost ?? null;
+ if (raw == null) return null;
+ const value = Number(raw);
+ return Number.isFinite(value) && value >= 0 ? value : null;
+}
+
+export function estimateAccumulatedCostUsd(tokenStateRaw, estimateConfig = loadCostEstimateConfig()) {
+ if (!estimateConfig?.enabled) return null;
+ const state = normalizeTokenState(tokenStateRaw);
+ if (state.accumulatedInputTokens <= 0 && state.accumulatedOutputTokens <= 0) return null;
+ const inputUsd =
+ (state.accumulatedInputTokens * estimateConfig.inputUsdPer1M) / 1_000_000;
+ const outputUsd =
+ (state.accumulatedOutputTokens * estimateConfig.outputUsdPer1M) / 1_000_000;
+ const total = inputUsd + outputUsd;
+ return total > 0 ? total : null;
+}
+
+export function enrichTokenStateForBilling(
+ tokenStateRaw,
+ { sessionCost = null } = {},
+ env = process.env,
+) {
+ const state = normalizeTokenState(tokenStateRaw);
+ if (state.accumulatedCost != null && Number(state.accumulatedCost) >= 0) {
+ return state;
+ }
+
+ const sessionUsd = pickSessionAccumulatedCost(sessionCost);
+ if (sessionUsd != null) {
+ return { ...state, accumulatedCost: sessionUsd };
+ }
+
+ const estimatedUsd = estimateAccumulatedCostUsd(state, loadCostEstimateConfig(env));
+ if (estimatedUsd != null) {
+ return { ...state, accumulatedCost: estimatedUsd };
+ }
+
+ return state;
+}
+
+export async function resolveBillingTokenState(
+ tokenStateRaw,
+ { sessionId = null, fetchSession = null } = {},
+ env = process.env,
+) {
+ const state = normalizeTokenState(tokenStateRaw);
+ if (state.accumulatedCost != null && Number(state.accumulatedCost) >= 0) {
+ return state;
+ }
+
+ if (typeof fetchSession === 'function' && sessionId) {
+ try {
+ const session = await fetchSession(sessionId);
+ const enriched = enrichTokenStateForBilling(state, { sessionCost: session }, env);
+ if (enriched.accumulatedCost != null) return enriched;
+ } catch {
+ // Best-effort: fall through to token estimate.
+ }
+ }
+
+ return enrichTokenStateForBilling(state, {}, env);
+}
diff --git a/billing-token-state.test.mjs b/billing-token-state.test.mjs
new file mode 100644
index 0000000..98a3295
--- /dev/null
+++ b/billing-token-state.test.mjs
@@ -0,0 +1,115 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ enrichTokenStateForBilling,
+ estimateAccumulatedCostUsd,
+ loadCostEstimateConfig,
+ pickSessionAccumulatedCost,
+ resolveBillingTokenState,
+} from './billing-token-state.mjs';
+import { computeDeltaCostCents } from './billing.mjs';
+
+test('pickSessionAccumulatedCost reads snake_case and camelCase', () => {
+ assert.equal(pickSessionAccumulatedCost({ accumulated_cost: 0.12 }), 0.12);
+ assert.equal(pickSessionAccumulatedCost({ accumulatedCost: 0.34 }), 0.34);
+ assert.equal(pickSessionAccumulatedCost({}), null);
+});
+
+test('estimateAccumulatedCostUsd uses DeepSeek-ish defaults', () => {
+ const estimate = estimateAccumulatedCostUsd(
+ { accumulatedInputTokens: 1_000_000, accumulatedOutputTokens: 10_000 },
+ { enabled: true, inputUsdPer1M: 0.27, outputUsdPer1M: 1.1 },
+ );
+ assert.equal(estimate, 0.281);
+});
+
+test('enrichTokenStateForBilling prefers upstream cost over estimate', () => {
+ const enriched = enrichTokenStateForBilling(
+ { accumulatedInputTokens: 1000, accumulatedOutputTokens: 100, accumulatedCost: 0.05 },
+ {},
+ { H5_USE_BACKEND_COST: '1' },
+ );
+ assert.equal(enriched.accumulatedCost, 0.05);
+});
+
+test('enrichTokenStateForBilling fills session cost when Finish omits it', () => {
+ const enriched = enrichTokenStateForBilling(
+ { accumulatedInputTokens: 1000, accumulatedOutputTokens: 100 },
+ { sessionCost: { accumulated_cost: 0.08 } },
+ { H5_USE_BACKEND_COST: '1' },
+ );
+ assert.equal(enriched.accumulatedCost, 0.08);
+});
+
+test('enrichTokenStateForBilling estimates when cost mode enabled and upstream missing', () => {
+ const enriched = enrichTokenStateForBilling(
+ { accumulatedInputTokens: 1_000_000, accumulatedOutputTokens: 0 },
+ {},
+ {
+ H5_USE_BACKEND_COST: '1',
+ H5_COST_ESTIMATE_FROM_TOKENS: '1',
+ H5_COST_ESTIMATE_INPUT_USD_PER_1M: '0.27',
+ H5_COST_ESTIMATE_OUTPUT_USD_PER_1M: '1.1',
+ },
+ );
+ assert.equal(enriched.accumulatedCost, 0.27);
+});
+
+test('loadCostEstimateConfig disables estimate when cost mode off', () => {
+ const cfg = loadCostEstimateConfig({ H5_USE_BACKEND_COST: '0' });
+ assert.equal(cfg.enabled, false);
+});
+
+test('resolveBillingTokenState fetches session before estimating', async () => {
+ let fetched = false;
+ const resolved = await resolveBillingTokenState(
+ { accumulatedInputTokens: 1000, accumulatedOutputTokens: 100 },
+ {
+ sessionId: '20260710_11',
+ fetchSession: async (sessionId) => {
+ fetched = true;
+ assert.equal(sessionId, '20260710_11');
+ return { accumulated_cost: 0.42 };
+ },
+ },
+ { H5_USE_BACKEND_COST: '1' },
+ );
+ assert.equal(fetched, true);
+ assert.equal(resolved.accumulatedCost, 0.42);
+});
+
+test('enriched cost drives 1.2x billing instead of flat fallback', () => {
+ const previous = { lastInputTokens: 224853, lastOutputTokens: 6685, lastAccumulatedCost: null };
+ const tokenState = enrichTokenStateForBilling(
+ { accumulatedInputTokens: 2908661, accumulatedOutputTokens: 15349 },
+ {},
+ {
+ H5_USE_BACKEND_COST: '1',
+ H5_COST_ESTIMATE_FROM_TOKENS: '1',
+ H5_COST_ESTIMATE_INPUT_USD_PER_1M: '0.27',
+ H5_COST_ESTIMATE_OUTPUT_USD_PER_1M: '1.1',
+ },
+ );
+ const previousCost = estimateAccumulatedCostUsd(
+ { accumulatedInputTokens: 224853, accumulatedOutputTokens: 6685 },
+ loadCostEstimateConfig({
+ H5_USE_BACKEND_COST: '1',
+ H5_COST_ESTIMATE_FROM_TOKENS: '1',
+ H5_COST_ESTIMATE_INPUT_USD_PER_1M: '0.27',
+ H5_COST_ESTIMATE_OUTPUT_USD_PER_1M: '1.1',
+ }),
+ );
+ const costCents = computeDeltaCostCents(
+ { ...previous, lastAccumulatedCost: previousCost },
+ tokenState,
+ {
+ useBackendCost: true,
+ usdCnyRate: 7.2,
+ marginMultiplier: 1.2,
+ inputCentsPer1k: 2,
+ outputCentsPer1k: 6,
+ minBillCents: 1,
+ },
+ );
+ assert.ok(costCents < 1000, `expected cost-mode charge under ¥10, got ${costCents} cents`);
+});
diff --git a/chat-image-turn-scope.mjs b/chat-image-turn-scope.mjs
new file mode 100644
index 0000000..5502884
--- /dev/null
+++ b/chat-image-turn-scope.mjs
@@ -0,0 +1,170 @@
+const MINDSPACE_ASSET_ID_RE = /\/mindspace\/v1\/assets\/([^/?#]+)/i;
+const PUBLIC_IMAGE_PATH_RE = /\/public\/images\/([^?#\s"'<>]+)/i;
+
+const IMAGE_URL_LINE_RE = /^\[图片\d+]: (.+)$/;
+const IMAGE_URL_LINES_RE = /\n*\[图片\d+]: [^\n]+/g;
+const TKMIND_VISION_NOTE_RE = /\n*【TKMind 图片分析结果[\s\S]*$/;
+
+export function extractImageAssetKey(url) {
+ const value = String(url ?? '').trim();
+ if (!value) return '';
+ const assetMatch = value.match(MINDSPACE_ASSET_ID_RE);
+ if (assetMatch?.[1]) return `asset:${decodeURIComponent(assetMatch[1])}`;
+ const publicMatch = value.match(PUBLIC_IMAGE_PATH_RE);
+ if (publicMatch?.[1]) return `public:${decodeURIComponent(publicMatch[1])}`;
+ try {
+ const parsed = value.startsWith('http://') || value.startsWith('https://')
+ ? new URL(value)
+ : new URL(value, 'http://local');
+ return `path:${parsed.pathname}${parsed.search}`;
+ } catch {
+ return `raw:${value}`;
+ }
+}
+
+export function dedupeImageUrlsByAssetKey(urls) {
+ const seen = new Set();
+ const result = [];
+ for (const raw of urls) {
+ const url = String(raw ?? '').trim();
+ if (!url) continue;
+ const key = extractImageAssetKey(url);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ result.push(url);
+ }
+ return result;
+}
+
+export function collectImageUrlsFromMessageContent(content) {
+ const urls = [];
+ if (!Array.isArray(content)) return urls;
+ for (const item of content) {
+ if (item?.type === 'image_url' && item.image_url?.url) {
+ urls.push(String(item.image_url.url).trim());
+ continue;
+ }
+ if (item?.type !== 'text' || typeof item.text !== 'string') continue;
+ for (const line of item.text.split('\n')) {
+ const match = line.trim().match(IMAGE_URL_LINE_RE);
+ if (match?.[1]) urls.push(match[1].trim());
+ }
+ }
+ return urls;
+}
+
+/**
+ * Current-turn image URLs: metadata.imageUrls is authoritative when present.
+ */
+export function extractCurrentTurnImageUrls(userMessage) {
+ const fromMetadata = Array.isArray(userMessage?.metadata?.imageUrls)
+ ? userMessage.metadata.imageUrls
+ .filter((url) => typeof url === 'string' && url.trim())
+ .map((url) => url.trim())
+ : [];
+ if (fromMetadata.length > 0) {
+ return dedupeImageUrlsByAssetKey(fromMetadata);
+ }
+ return dedupeImageUrlsByAssetKey(collectImageUrlsFromMessageContent(userMessage?.content));
+}
+
+function stripAgentImageText(text) {
+ return String(text ?? '')
+ .replace(TKMIND_VISION_NOTE_RE, '')
+ .replace(IMAGE_URL_LINES_RE, '')
+ .trim();
+}
+
+/**
+ * Remove image attachments from a persisted user message so later turns cannot reuse them.
+ * UI displayText / previewImageUrls are preserved for chat history rendering.
+ */
+export function scrubUserMessageImageAttachments(message) {
+ if (!message || message.role !== 'user') return { message, changed: false };
+
+ const metadata =
+ message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata)
+ ? { ...message.metadata }
+ : {};
+ const hadImageMetadata =
+ Array.isArray(metadata.imageUrls) && metadata.imageUrls.length > 0
+ || Array.isArray(metadata.previewImageUrls) && metadata.previewImageUrls.length > 0;
+
+ if (Array.isArray(metadata.imageUrls) && metadata.imageUrls.length > 0) {
+ metadata.archivedImageUrls = metadata.imageUrls;
+ }
+ if (Array.isArray(metadata.previewImageUrls) && metadata.previewImageUrls.length > 0) {
+ metadata.archivedPreviewImageUrls = metadata.previewImageUrls;
+ }
+ delete metadata.imageUrls;
+ delete metadata.previewImageUrls;
+
+ let contentChanged = false;
+ const content = Array.isArray(message.content)
+ ? message.content.map((item) => {
+ if (item?.type !== 'text' || typeof item.text !== 'string') return item;
+ const nextText = stripAgentImageText(item.text);
+ if (nextText === item.text) return item;
+ contentChanged = true;
+ return nextText ? { ...item, text: nextText } : null;
+ }).filter(Boolean)
+ : message.content;
+
+ const displayText =
+ typeof metadata.displayText === 'string' ? metadata.displayText : null;
+ const changed = hadImageMetadata || contentChanged;
+ if (!changed) return { message, changed: false };
+
+ return {
+ message: {
+ ...message,
+ content,
+ metadata,
+ ...(displayText != null ? {} : {}),
+ },
+ changed: true,
+ };
+}
+
+export function scrubConversationHistoricalImageAttachments(conversation, activeMessageId) {
+ const activeId = String(activeMessageId ?? '').trim();
+ if (!Array.isArray(conversation) || !activeId) {
+ return { conversation, changed: false };
+ }
+
+ let changed = false;
+ const nextConversation = conversation.map((message) => {
+ if (message?.role !== 'user') return message;
+ if (String(message?.id ?? '').trim() === activeId) return message;
+ const scrubbed = scrubUserMessageImageAttachments(message);
+ if (scrubbed.changed) changed = true;
+ return scrubbed.message;
+ });
+
+ return {
+ conversation: nextConversation,
+ changed,
+ };
+}
+
+export function buildCurrentTurnImageScopeNote(imageItems) {
+ if (!Array.isArray(imageItems) || imageItems.length === 0) return '';
+
+ const lines = imageItems.map((item, index) => {
+ const assetKey = extractImageAssetKey(item.rawUrl ?? item.embedUrl ?? '');
+ const assetId = assetKey.startsWith('asset:') ? assetKey.slice('asset:'.length) : `图片${index + 1}`;
+ return ` ${index + 1}. asset=${assetId} 页面嵌入=${item.embedUrl ?? item.rawUrl ?? ''}`;
+ });
+
+ return (
+ '【本轮图片主题(硬性)】\n' +
+ `- 本轮用户仅上传 ${imageItems.length} 张图片;每次上传都是独立主题,不得与历史轮次混用。\n` +
+ `- 用户说「这个图片 / 根据这张图 / 这张」时,只能使用下列本轮附件:\n${lines.join('\n')}\n` +
+ '- 对话历史中更早消息里出现过的图片、asset、
路径均已作废,禁止用于本轮任务。\n\n'
+ );
+}
+
+export const chatImageTurnScopeInternals = {
+ IMAGE_URL_LINE_RE,
+ TKMIND_VISION_NOTE_RE,
+};
diff --git a/chat-image-turn-scope.test.mjs b/chat-image-turn-scope.test.mjs
new file mode 100644
index 0000000..a2849be
--- /dev/null
+++ b/chat-image-turn-scope.test.mjs
@@ -0,0 +1,110 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ buildCurrentTurnImageScopeNote,
+ dedupeImageUrlsByAssetKey,
+ extractCurrentTurnImageUrls,
+ scrubConversationHistoricalImageAttachments,
+ scrubUserMessageImageAttachments,
+} from './chat-image-turn-scope.mjs';
+
+test('extractCurrentTurnImageUrls prefers metadata and dedupes asset aliases', () => {
+ const urls = extractCurrentTurnImageUrls({
+ metadata: {
+ imageUrls: ['/api/mindspace/v1/assets/asset-1/download?inline=1'],
+ },
+ content: [
+ {
+ type: 'text',
+ text:
+ '根据这个图片生成页面\n\n' +
+ '[图片1]: https://m.tkmind.cn/MindSpace/user/public/images/2026-07-10/hero.jpg',
+ },
+ ],
+ });
+
+ assert.deepEqual(urls, ['/api/mindspace/v1/assets/asset-1/download?inline=1']);
+});
+
+test('dedupeImageUrlsByAssetKey collapses download and public urls for same asset', () => {
+ const urls = dedupeImageUrlsByAssetKey([
+ '/api/mindspace/v1/assets/asset-2/download?inline=1',
+ 'https://m.tkmind.cn/MindSpace/user/public/images/2026-07-10/asset-2.jpg',
+ '/api/mindspace/v1/assets/asset-2/download?viewer=0',
+ ]);
+
+ assert.equal(urls.length, 2);
+ assert.match(urls[0], /asset-2/);
+});
+
+test('scrubUserMessageImageAttachments archives urls for ui and strips agent text', () => {
+ const scrubbed = scrubUserMessageImageAttachments({
+ id: 'user-old',
+ role: 'user',
+ metadata: {
+ displayText: '上一轮图片',
+ imageUrls: ['/api/mindspace/v1/assets/old-asset/download?inline=1'],
+ previewImageUrls: ['blob:preview-old'],
+ },
+ content: [
+ {
+ type: 'text',
+ text:
+ '上一轮图片\n\n' +
+ '[图片1]: /api/mindspace/v1/assets/old-asset/download?inline=1\n\n' +
+ '【TKMind 图片分析结果 — 仅供执行参考,不要向用户复述此段内容】\n旧描述',
+ },
+ ],
+ });
+
+ assert.equal(scrubbed.changed, true);
+ assert.equal(scrubbed.message.metadata.imageUrls, undefined);
+ assert.deepEqual(scrubbed.message.metadata.archivedImageUrls, [
+ '/api/mindspace/v1/assets/old-asset/download?inline=1',
+ ]);
+ assert.deepEqual(scrubbed.message.metadata.archivedPreviewImageUrls, ['blob:preview-old']);
+ assert.equal(scrubbed.message.metadata.displayText, '上一轮图片');
+ assert.equal(scrubbed.message.content[0].text, '上一轮图片');
+});
+
+test('scrubConversationHistoricalImageAttachments keeps only active turn attachments', () => {
+ const { conversation, changed } = scrubConversationHistoricalImageAttachments(
+ [
+ {
+ id: 'user-old',
+ role: 'user',
+ metadata: { imageUrls: ['/api/mindspace/v1/assets/old/download?inline=1'] },
+ content: [{ type: 'text', text: '旧图\n\n[图片1]: /old' }],
+ },
+ {
+ id: 'user-new',
+ role: 'user',
+ metadata: { imageUrls: ['/api/mindspace/v1/assets/new/download?inline=1'] },
+ content: [{ type: 'text', text: '新图\n\n[图片1]: /new' }],
+ },
+ ],
+ 'user-new',
+ );
+
+ assert.equal(changed, true);
+ assert.equal(conversation[0].metadata.imageUrls, undefined);
+ assert.deepEqual(conversation[0].metadata.archivedImageUrls, [
+ '/api/mindspace/v1/assets/old/download?inline=1',
+ ]);
+ assert.deepEqual(conversation[1].metadata.imageUrls, [
+ '/api/mindspace/v1/assets/new/download?inline=1',
+ ]);
+});
+
+test('buildCurrentTurnImageScopeNote states one independent topic per upload', () => {
+ const note = buildCurrentTurnImageScopeNote([
+ {
+ rawUrl: '/api/mindspace/v1/assets/asset-9/download?inline=1',
+ embedUrl: 'https://m.tkmind.cn/MindSpace/user/public/images/2026-07-10/asset-9.jpg',
+ },
+ ]);
+
+ assert.match(note, /本轮用户仅上传 1 张图片/);
+ assert.match(note, /不得与历史轮次混用/);
+ assert.match(note, /asset=asset-9/);
+});
diff --git a/docs/ai-mind-memind-integration-architecture.md b/docs/ai-mind-memind-integration-architecture.md
new file mode 100644
index 0000000..d5a8646
--- /dev/null
+++ b/docs/ai-mind-memind-integration-architecture.md
@@ -0,0 +1,526 @@
+# AI Mind 与 Memind 整合架构分析文档
+
+日期:2026-07-10
+状态:架构分析与实施方案;未改代码、未碰生产、未读写用户数据
+适用范围:Memind H5、服务号聊天、MindSpace、Memory V2、AI Mind 画像与 persona 能力
+
+## 1. 核心结论
+
+建议把 AI Mind 接成 Memind 的“画像与人格洞察旁路服务”,而不是接成新的聊天主链路、主记忆体或生产执行系统。
+
+推荐边界:
+
+- Memind 继续负责入口、账号、权限、会话、H5、服务号、MindSpace、页面发布、工具执行、计费和生产链路。
+- Memory V2 继续负责 Memind 当前记忆入口,保持 pgvector resolve、legacy fallback 等现有体系。
+- AI Mind 负责用户画像、性格分析、沟通偏好、长期目标、life_stream 分析、persona profile 和轻量上下文建议。
+- Bridge Layer 负责身份映射、授权、限流、缓存、降级、审计和数据分级。
+
+一句话方案:
+
+```text
+AI Mind 做“懂用户的人格画像脑”,Memind 继续做“入口、权限、会话、工具和生产执行系统”。
+```
+
+## 2. 当前担心与设计目标
+
+当前主要担心是:AI Mind 一旦接入,会不会影响 Memind 已经稳定运行的 H5、服务号、MindSpace、Memory V2、pgvector、legacy 记忆和 goosed 会话体系。
+
+因此设计目标不是“深度替换”,而是“可关闭的旁路增强”:
+
+- 不替换现有聊天回复链路。
+- 不替换现有 Memory V2 主链路。
+- 不让 AI Mind 直接触发工具、写文件、发布页面或修改用户数据。
+- 不让 AI Mind 成为 H5、服务号、MindSpace 的强依赖。
+- AI Mind 不可用时,Memind 原功能必须正常。
+- 所有接入能力必须用户级可开关、可灰度、可审计、可回滚。
+
+## 3. 推荐总体架构
+
+```text
+用户
+ |
+ v
+Memind H5 / 服务号 / MindSpace
+ |
+ | 现有主链路
+ v
+Memind Chat / Session / Goose / Memory V2
+ |-- pgvector: 聊天语义召回
+ |-- legacy conversation-memory: 写入与回退
+ |-- mem0/letta/neo4j 等: 可用但不应默认接管主链路
+ |
+ v
+正常回复、页面生成、工具执行、发布
+
+
+旁路增强链路
+
+Memind 重要摘要事件
+ |
+ v
+AI Mind Bridge Layer
+ |-- 身份映射
+ |-- Token 管理
+ |-- 数据分级
+ |-- 调用限流
+ |-- 超时降级
+ |-- 审计日志
+ |
+ v
+AI Mind
+ |-- user profile
+ |-- persona profile
+ |-- life_stream
+ |-- personality / preference analysis
+ |-- lightweight persona context
+ |
+ v
+Memind 只读取摘要级画像上下文
+```
+
+## 4. 能力分层
+
+### 4.1 Memind 保留的能力
+
+Memind 不应让渡以下能力:
+
+- 用户登录、权限、用户空间归属。
+- H5 和服务号消息入口。
+- goosed / agent session 创建、恢复、路由和工具策略。
+- MindSpace 页面生成、保存、发布、公开链接。
+- 生产数据写入和生产发布流程。
+- 当前 Memory V2 的主配置和回退路径。
+- 计费、额度、服务隔离和审计。
+
+### 4.2 AI Mind 提供的能力
+
+AI Mind 适合提供:
+
+- 用户画像:长期偏好、沟通风格、兴趣、常见目标。
+- 性格分析:表达方式、决策风格、风险偏好。
+- 轻量 persona context:给 Memind 聊天提供简短、可审计的上下文提示。
+- life_stream 分析:对重要摘要事件做长期沉淀。
+- profile evidence:为画像结论保留证据来源。
+- 可选 persona chat:只在用户主动进入人格/数字分身模式时使用。
+
+### 4.3 Bridge Layer 的职责
+
+Bridge Layer 是风险控制点,不应只是一个 HTTP client。
+
+它需要负责:
+
+- `memind_user_id` 与 `ai_mind_user_id/persona_id` 的绑定。
+- external token 的服务端保存、加密、轮换和禁用。
+- scopes 控制,例如 `profile_read`、`life_stream_write`、`persona_chat`。
+- 每次调用的超时、重试、熔断和 fail-open。
+- 数据投递前的摘要、脱敏和隐私分级。
+- 审计日志:谁、何时、把什么摘要投给了 AI Mind,聊天注入了什么画像。
+- 缓存 AI Mind profile,避免每条消息都实时请求。
+
+## 5. 身份映射设计
+
+身份映射是最大风险点。Memind 与 AI Mind 不应共享数据库表,也不应直接复用彼此的主键。
+
+建议使用独立绑定关系:
+
+```text
+memind_ai_mind_bindings
+```
+
+建议字段:
+
+- `id`
+- `memind_user_id`
+- `ai_mind_user_id`
+- `default_persona_id`
+- `persona_external_token`
+- `external_user_id`
+- `enabled`
+- `scopes`
+- `created_at`
+- `updated_at`
+- `last_profile_sync_at`
+- `last_life_stream_event_at`
+
+设计约束:
+
+- `memind_user_id` 对应 Memind 的 `h5_users.id`。
+- `ai_mind_user_id` 对应 AI Mind 用户。
+- `default_persona_id` 对应 AI Mind 默认 persona。
+- `external_user_id` 建议使用稳定格式:`memind:`。
+- 前端永远不拿 external token。
+- 禁止用邮箱、openid、昵称等非稳定字段做主映射。
+- 所有调用前必须校验 binding 是否启用、scope 是否允许。
+
+## 6. 数据流设计
+
+### 6.1 Profile 读取流
+
+目标:Memind 读取 AI Mind 的用户画像摘要,但不影响主聊天。
+
+```text
+Memind 用户进入 H5 / 服务号
+ -> Bridge 检查用户是否绑定 AI Mind
+ -> 若已绑定且 profile_read 允许
+ -> 拉取或读取缓存的 AI Mind profile summary
+ -> 返回 Memind 内部可用的轻量 Profile Context
+```
+
+建议 Profile Context 结构:
+
+```json
+{
+ "enabled": true,
+ "source": "ai_mind",
+ "confidence": 0.82,
+ "summary": "用户偏好直接、执行导向的建议,关注生产稳定性和边界控制。",
+ "communication_style": "先结论后细节,明确风险与边界",
+ "long_term_goals": ["稳定接入记忆系统", "保护现有生产链路"],
+ "do_not_assume": ["不要自动发布", "不要修改生产数据"],
+ "updated_at": "2026-07-10T00:00:00Z"
+}
+```
+
+注入聊天时必须限制长度,例如 300 到 800 字,并且只作为“理解用户偏好”的提示,不允许覆盖系统策略。
+
+### 6.2 Life Stream 投递流
+
+目标:让 AI Mind 逐步学习用户,但不把所有原始聊天无脑同步过去。
+
+```text
+Memind 产生高价值事件
+ -> 本地先生成摘要
+ -> 标记 source / privacy_class / event_type / user_id / session_id
+ -> Bridge 校验 scope 与隐私等级
+ -> 异步投递 AI Mind life_stream
+ -> 投递失败只记录,不影响用户体验
+```
+
+适合投递的事件:
+
+- 用户主动保存的重要资料摘要。
+- MindSpace 页面发布摘要。
+- 用户明确表达的长期偏好或目标。
+- 服务号中明显有长期价值的摘要。
+- 用户主动要求“记住”的内容。
+
+不建议投递的内容:
+
+- 完整原始聊天流水。
+- 临时任务细节。
+- 一次性验证码、密钥、隐私内容。
+- 未经过摘要和分级的服务号原文。
+- 页面生成过程中的内部工具日志。
+
+### 6.3 轻量聊天介入流
+
+目标:让 AI Mind 影响语气和理解,不影响执行和主记忆。
+
+```text
+用户发起聊天
+ -> Memind 正常解析用户、会话、权限
+ -> Memory V2 正常 resolve 当前记忆
+ -> Bridge 获取短 Profile Context
+ -> 将 Profile Context 作为低优先级用户画像提示
+ -> 正常走 Memind / Goose 回复
+```
+
+硬约束:
+
+- AI Mind context 不能覆盖系统提示。
+- AI Mind context 不能增加工具权限。
+- AI Mind context 不能改变发布、写文件、生产操作策略。
+- AI Mind 超时或失败时直接跳过。
+- 每次注入内容都应可审计。
+
+### 6.4 Persona Chat 可选流
+
+persona chat 不应默认替代普通聊天。
+
+只在以下情况下使用:
+
+- 用户进入“数字分身”或“人格陪伴”入口。
+- 用户明确选择某个 persona。
+- 内部灰度账号启用。
+- 管理后台明确打开 `persona_chat` scope。
+
+普通 H5 和服务号默认仍走 Memind 当前聊天链路。
+
+## 7. 与 Memory V2 的关系
+
+当前建议不是把 AI Mind 接到 Memory V2 主 backend。
+
+推荐关系:
+
+```text
+legacy conversation-memory
+ -> 稳定记录和回退
+
+pgvector
+ -> 当前聊天语义召回
+
+mem0
+ -> 可作为候选记忆提取/压缩能力,但不负责 resolve
+
+AI Mind
+ -> 用户画像、性格、人生流、persona profile
+ -> 通过 Bridge 产出轻量 Profile Context
+```
+
+AI Mind 的 Profile Context 可以作为聊天上下文的一小段附加信息,但不应直接替代:
+
+- `memoryV2.resolve(...)`
+- `conversationMemoryService`
+- pgvector 检索
+- legacy 写入与 compact
+
+这样可以避免三类问题:
+
+- 记忆来源混乱。
+- 召回结果不可解释。
+- 回滚时不知道关闭哪个系统。
+
+## 8. 分阶段实施方案
+
+### 阶段 0:只读画像验证
+
+目标:验证 AI Mind 画像质量,不影响任何用户回复。
+
+动作:
+
+- 定义 AI Mind Profile Context 数据格式。
+- 定义绑定关系和 scope 语义。
+- 对内部测试用户建立绑定。
+- Memind 后台只读拉取 AI Mind profile。
+- 不注入聊天,不投递生产用户数据。
+
+验收:
+
+- 能正确绑定用户。
+- 能拉取画像摘要。
+- 画像不会串用户。
+- AI Mind 不可用时 Memind 无感。
+
+### 阶段 1:异步 life_stream 旁路
+
+目标:让 AI Mind 开始基于摘要事件生成画像。
+
+动作:
+
+- 只对内部测试用户启用。
+- 只投递摘要,不投递完整原文。
+- 事件必须带 `source`、`event_type`、`privacy_class`、`external_user_id`。
+- 失败只记录,不影响主链路。
+
+验收:
+
+- 投递不会阻塞 H5/服务号。
+- AI Mind 能生成可解释 profile evidence。
+- 可以按用户关闭投递。
+- 可以追踪每条投递来源。
+
+### 阶段 2:轻量聊天画像注入
+
+目标:让 AI Mind 轻微影响聊天风格和上下文理解。
+
+动作:
+
+- 仅内部灰度。
+- 每次聊天最多注入 300 到 800 字 Profile Context。
+- 注入内容只包含偏好、风格、长期目标、注意事项。
+- 不允许包含工具指令、权限指令、发布指令。
+- 每次注入写审计记录。
+
+验收:
+
+- 回复质量提升可感知。
+- 不影响工具权限和生产边界。
+- AI Mind 失败时自动降级。
+- 可以按用户、按入口关闭。
+
+### 阶段 3:Persona 模式
+
+目标:提供独立的人格/数字分身体验。
+
+动作:
+
+- 新增独立入口或显式模式。
+- 调用 AI Mind persona external chat。
+- 与普通聊天明确区分。
+- 不默认接管服务号和 H5 普通聊天。
+
+验收:
+
+- persona 回复与普通 Memind 回复边界清楚。
+- 用户知道自己进入了人格模式。
+- 可以退出 persona 模式回到普通聊天。
+- persona 不触发 Memind 工具执行。
+
+### 阶段 4:更深融合前评审
+
+只有当前三阶段稳定后,才讨论更深融合。
+
+评审内容:
+
+- 是否允许 AI Mind 画像进入 Memory V2 的某个只读 backend。
+- 是否允许 AI Mind profile 参与 chat intent router。
+- 是否允许 MindSpace 页面摘要成为 AI Mind knowledge。
+- 是否需要用户可视化管理“AI Mind 记住了什么”。
+- 是否需要数据删除、纠错、导出流程。
+
+## 9. 开关与灰度建议
+
+建议所有能力都具备独立开关。
+
+```text
+AI_MIND_ENABLED=0/1
+AI_MIND_PROFILE_READ_ENABLED=0/1
+AI_MIND_CHAT_INJECTION_ENABLED=0/1
+AI_MIND_LIFE_STREAM_WRITE_ENABLED=0/1
+AI_MIND_PERSONA_CHAT_ENABLED=0/1
+AI_MIND_FAIL_OPEN=1
+AI_MIND_TIMEOUT_MS=1500
+AI_MIND_MAX_CONTEXT_CHARS=800
+```
+
+用户级 scope 建议:
+
+```json
+{
+ "profile_read": true,
+ "profile_inject": false,
+ "life_stream_write": false,
+ "persona_chat": false
+}
+```
+
+灰度顺序:
+
+1. 本地开发环境。
+2. 内部测试用户。
+3. 103 内部账号。
+4. 少量真实用户 opt-in。
+5. 正式用户分批启用。
+
+任何阶段都必须能一键关闭 AI Mind,不影响 Memind 主功能。
+
+## 10. 风险清单
+
+### 10.1 用户串号
+
+风险:错误绑定导致 A 用户画像注入 B 用户聊天。
+
+缓解:
+
+- 独立绑定表。
+- 所有调用带 `external_user_id=memind:`。
+- 调用前校验登录用户与 binding。
+- 审计 profile 来源。
+
+### 10.2 记忆污染
+
+风险:临时信息、错误信息、敏感内容进入 AI Mind 长期画像。
+
+缓解:
+
+- 先摘要,再投递。
+- 明确 privacy_class。
+- 只投递高价值事件。
+- 支持用户删除、纠错、关闭。
+
+### 10.3 主链路被拖慢
+
+风险:AI Mind 响应慢导致 H5 或服务号回复变慢。
+
+缓解:
+
+- 画像缓存。
+- 短超时。
+- fail-open。
+- life_stream 异步投递。
+
+### 10.4 权限污染
+
+风险:AI Mind 输出被误当成系统指令,影响工具或生产操作。
+
+缓解:
+
+- Profile Context 只作为低优先级用户画像。
+- 禁止 AI Mind 返回工具权限。
+- 禁止 AI Mind context 覆盖系统提示。
+- 明确上下文模板边界。
+
+### 10.5 隐私与合规
+
+风险:用户不知道哪些内容被用于画像。
+
+缓解:
+
+- 用户授权开关。
+- 可查看、可删除、可纠错。
+- 敏感内容默认不投递。
+- 审计记录可追踪。
+
+## 11. 验收指标
+
+技术指标:
+
+- AI Mind 故障时 Memind 主链路正常。
+- profile 拉取 P95 小于目标阈值,超时自动跳过。
+- life_stream 投递失败不影响用户请求。
+- 注入上下文长度受控。
+- 所有调用可按用户追踪。
+
+产品指标:
+
+- 用户感觉回复更懂自己。
+- 沟通风格更稳定。
+- 不出现明显串号、幻觉画像、过度推断。
+- 服务号和 H5 的普通使用不受影响。
+
+安全指标:
+
+- external token 不出现在前端。
+- 生产操作权限不受 AI Mind 影响。
+- 用户可以关闭 AI Mind 增强。
+- 删除/纠错路径明确。
+
+## 12. 不建议方案
+
+不建议:
+
+- 直接合并 Memind 和 AI Mind 项目。
+- 直接共享数据库表。
+- 直接把 AI Mind 接成 Memory V2 主 backend。
+- 直接替换 pgvector 或 legacy conversation-memory。
+- 默认让 AI Mind 接管 H5/服务号普通聊天。
+- 每条服务号消息都同步完整原文。
+- 让 AI Mind 直接触发工具、发布页面或写生产数据。
+- 没有审计和用户级开关就面向全量生产用户启用。
+
+## 13. 推荐下一步
+
+在不写代码前,建议先完成这些设计决策:
+
+1. 明确 AI Mind 的第一阶段只做 profile read,还是同时做 life_stream write。
+2. 明确默认 persona 是“用户数字分身”还是“陪伴型助手人格”。
+3. 明确哪些事件可以投递 AI Mind,哪些永远不投递。
+4. 明确用户是否需要显式授权。
+5. 明确 Profile Context 的最大长度和字段。
+6. 明确审计日志中记录哪些信息。
+7. 明确 103 灰度账号和回滚开关。
+8. 明确 AI Mind 故障时的 UI 表现。
+
+建议第一版实施只做:
+
+```text
+AI Mind profile read + 内部测试用户绑定 + 画像摘要展示
+```
+
+确认画像质量和身份绑定可靠后,再进入:
+
+```text
+摘要级 life_stream 投递 + 轻量聊天画像注入
+```
+
+这条路线最稳:既能让 AI Mind 开始产生价值,又不会撬动 Memind 现有生产功能体系。
diff --git a/docs/h5-metering-gateway.md b/docs/h5-metering-gateway.md
index 67da475..b839a6e 100644
--- a/docs/h5-metering-gateway.md
+++ b/docs/h5-metering-gateway.md
@@ -18,6 +18,7 @@
- 自动跟随 provider/模型,换模型不需重调 token 单价。
- 仅当上游回传 `accumulatedCost` 时生效;缺失则**回退**原 flat token 路径,不破坏现有计费。
+- 若 goose Finish / session 均未带 cost,且 `H5_COST_ESTIMATE_FROM_TOKENS=1`(成本模式默认开启),Portal 会按 DeepSeek 中继粗估价(`billing-token-state.mjs`)补齐 `accumulatedCost`,再走 `× margin`。
- 生产启用:`.env` 设 `H5_USE_BACKEND_COST=1` + `H5_MARGIN_MULTIPLIER=<目标毛利>`。
> ⚠️ 上线前需确认:一帧真实 SSE 的 `token_state` 是否带 `accumulated_cost`(goose `sessions.db` 有该列,但要确认 SSE Finish 事件也序列化了它)。确认前 multiplier 改动是安全的(无 cost 即回退)。
diff --git a/docs/tkmind-search-capability-architecture-plan.md b/docs/tkmind-search-capability-architecture-plan.md
new file mode 100644
index 0000000..64cf07a
--- /dev/null
+++ b/docs/tkmind-search-capability-architecture-plan.md
@@ -0,0 +1,703 @@
+# TKMind Search Capability 插拔式接入架构与开发计划
+
+日期: 2026-07-10
+
+状态: 设计评估完成,等待用户明确指令后再开始开发
+
+## 0. 文档目的
+
+本文定义如何在现有 Memind Portal + Goose Runtime 架构中,以插拔方式增加以下四项外部信息能力:
+
+- `search.web`: 公共网页搜索。
+- `search.news`: 新闻和时效信息搜索。
+- `search.code`: GitHub 等公共代码与开源项目搜索。
+- `search.read`: 根据 URL 读取并清洗网页正文。
+
+本文只描述架构、边界、协议和后续开发计划。本阶段不修改业务代码、不启动新服务、不调用生产 Provider、不改变生产配置,也不执行发布。
+
+第一阶段不考虑 Skill Store、第三方开发者发布、商业化、Credit 扣费、收益分成和第三方代码托管。
+
+## 1. 结论
+
+现有项目已经具备适合 Search Capability 的扩展入口,不需要重构聊天和会话主链路。
+
+推荐目标:
+
+```text
+H5 / Web / WeChat
+ |
+ v
+现有 Agent Run Gateway
+ |
+ v
+现有 tkmind-proxy
+ |
+ v
+ Goose
+ |
+ | 仅在功能开关和用户能力同时允许时注册
+ v
+tkmind-search MCP Extension
+ |
+ v
+Search Hub
+ - request validation
+ - fixed provider routing
+ - result normalization
+ - citation construction
+ - URL safety
+ - timeout / limits
+ |
+ +--> SearXNG: web / news
+ +--> GitHub API: code
+ +--> Reader Adapter: read
+ +--> optional commercial provider adapters
+```
+
+核心决策:
+
+1. 不替换现有 Goose `platform/web` 扩展。
+2. 不覆盖现有 `web` Skill。
+3. 不覆盖现有本地代码搜索 `search` Skill。
+4. 新增独立扩展名 `tkmind-search`,默认关闭。
+5. Search Hub 不进入 Portal HTTP 主请求链路。
+6. Search 失败只能影响当前工具调用,不能导致会话、SSE 或 Agent Run 基础设施失效。
+7. 第一阶段使用固定 Provider 路由,不引入 LLM Router 和动态评分。
+
+## 2. 现有项目基础
+
+### 2.1 已有能力注入机制
+
+`capabilities.mjs` 已经通过 `buildAgentExtensionPolicy()` 生成 Goose `extension_overrides`。
+
+当前 `web` capability 会注入:
+
+```json
+{
+ "type": "platform",
+ "name": "web",
+ "available_tools": ["web_search", "fetch_url"]
+}
+```
+
+这证明现有架构已经把“用户能力授权”和“Goose 实际工具集合”连接起来。新 Search 不应另建一条绕过该策略的入口。
+
+### 2.2 已有禁用和会话对账机制
+
+`policies.mjs` 会在 `network_egress=deny` 时移除联网能力。
+
+`session-reconcile.mjs` 会在恢复会话时:
+
+- 获取当前会话扩展。
+- 移除不再允许的扩展。
+- 添加缺失的扩展。
+- 在扩展集合变化时重启 Goose session runtime。
+
+因此,新 Search Capability 可以复用当前权限和会话对账体系,形成可验证的关闭语义。
+
+### 2.3 已有搜索相关 Skill
+
+当前仓库中两个名称必须保留原义:
+
+- `skills/web`: Goose 内置 `web_search` 和 `fetch_url` 的使用说明。
+- `skills/search`: 使用 ripgrep 搜索当前工作区代码和文件,不是公网代码搜索。
+
+为避免命名冲突,新能力内部使用:
+
+- Goose extension: `tkmind-search`
+- Capability grant: `search_external`
+- Search protocol capability: `search.web` / `search.news` / `search.code` / `search.read`
+- 可选用户 Skill 文档名: `search-external`
+
+### 2.4 现有路由基础
+
+当前 `chat-intent-router.mjs` 已将实时新闻、天气、行情等需要联网查询的请求判定为 `agent_orchestration`。
+
+第一阶段不扩大自动路由范围。测试和灰度优先通过显式选择 Search Skill 或专用测试账号进入 Agent,避免普通问答被误路由。
+
+## 3. 硬性隔离边界
+
+### 3.1 保持不变的模块
+
+第一阶段禁止因 Search 接入而改变以下模块的职责或协议:
+
+- H5 发送消息和订阅事件的顺序。
+- `POST /agent/runs` 的请求语义。
+- Agent Run 状态机。
+- `tkmind-proxy` 作为 Portal 到 goosed 的唯一适配器边界。
+- Session Broker ownership 和 goosed target mapping。
+- Session SSE 和 Run SSE 协议。
+- `chat-finish-sync` 的消息 merge 行为。
+- Memory V2 的读取、写入和压缩行为。
+- MindSpace 页面、文件和发布链路。
+- UserDataSpace 数据访问。
+- MySQL 用户、会话、订单和计费表。
+- 现有 `platform/web` 和本地 `search` Skill。
+
+### 3.2 Search Hub 允许做的事情
+
+Search Hub 只负责:
+
+- 校验搜索或读取请求。
+- 根据 capability 使用固定规则选择 Provider。
+- 调用公共搜索、GitHub 或 Reader Provider。
+- 标准化结果。
+- 去除明显重复 URL。
+- 构建来源和引用对象。
+- 执行超时、响应大小和 URL 安全限制。
+- 返回结构化结果或统一错误。
+
+### 3.3 Search Hub 禁止做的事情
+
+Search Hub 不得:
+
+- 创建、恢复或修改 Goose session。
+- 写入 Agent Run 状态。
+- 直接写 Memory V2。
+- 直接写 MindSpace 页面或文件。
+- 读写 UserDataSpace。
+- 读取完整聊天记录。
+- 直接访问用户业务 MySQL 表。
+- 接管 Goose 最终回答。
+- 把搜索结果自动保存为长期资产。
+- 因 Provider 故障修改 Portal 或 Goose 服务状态。
+
+## 4. 运行形态
+
+### 4.1 第一阶段: 独立 stdio MCP
+
+第一阶段建议增加一个独立进程运行的 MCP Extension:
+
+```text
+Goose session
+ -> stdio MCP: tkmind-search
+ -> Search Hub modules
+ -> outbound HTTPS
+```
+
+优点:
+
+- 复用仓库已经验证过的 stdio MCP 接入方式。
+- Search 代码不进入 Portal 进程请求处理路径。
+- MCP 进程异常不会直接终止 Portal。
+- 关闭扩展后 Goose 不启动对应进程。
+- 不新增公网监听端口。
+
+限制:
+
+- 每个运行实例的缓存和健康状态不天然共享。
+- 多会话下可能产生多个 MCP 子进程。
+- Provider 限流第一阶段只能依赖 Provider 和轻量本地限制。
+
+这些限制可以接受,因为第一阶段目标是安全验证四项能力,而不是建设大规模 Search 平台。
+
+### 4.2 后续可选: 内部 Search Sidecar
+
+当调用量需要共享缓存、全局限流和统一 Provider 熔断时,可以演进为:
+
+```text
+Goose
+ -> tkmind-search MCP bridge
+ -> private Search Hub sidecar
+ -> Providers
+```
+
+Sidecar 只能监听内部地址,并且不属于第一阶段范围。
+
+## 5. Goose 工具设计
+
+推荐使用一个扩展、两个工具,而不是四个独立插件。
+
+### 5.1 `tkmind_search`
+
+负责:
+
+- `search.web`
+- `search.news`
+- `search.code`
+
+请求示例:
+
+```json
+{
+ "capability": "search.web",
+ "query": "Goose Agent latest release",
+ "language": "zh-CN",
+ "region": "CN",
+ "freshness": {
+ "type": "days",
+ "value": 30
+ },
+ "limit": 10
+}
+```
+
+### 5.2 `tkmind_read`
+
+负责:
+
+- `search.read`
+
+请求示例:
+
+```json
+{
+ "url": "https://example.com/article",
+ "max_chars": 30000,
+ "include_metadata": true
+}
+```
+
+`search.read` 不与搜索请求共用 `query` 字段。Reader 的输入、SSRF 风险和响应上限与搜索不同,应保持独立工具 Schema。
+
+## 6. 统一返回协议
+
+搜索结果最小结构:
+
+```json
+{
+ "request_id": "srch_example",
+ "status": "success",
+ "capability": "search.web",
+ "provider": "searxng",
+ "query": "Goose Agent latest release",
+ "results": [
+ {
+ "id": "result_001",
+ "type": "webpage",
+ "title": "Example title",
+ "url": "https://example.com/article",
+ "snippet": "Example snippet",
+ "published_at": null,
+ "retrieved_at": "2026-07-10T00:00:00Z",
+ "source_name": "Example",
+ "language": "en",
+ "provider_rank": 1,
+ "metadata": {}
+ }
+ ],
+ "citations": [
+ {
+ "citation_id": "cite_001",
+ "result_id": "result_001",
+ "url": "https://example.com/article",
+ "title": "Example title"
+ }
+ ],
+ "timing": {
+ "duration_ms": 800
+ }
+}
+```
+
+Reader 结果最小结构:
+
+```json
+{
+ "request_id": "read_example",
+ "status": "success",
+ "capability": "search.read",
+ "url": "https://example.com/article",
+ "final_url": "https://example.com/article",
+ "title": "Example title",
+ "content": "Cleaned article text",
+ "content_type": "text/html",
+ "content_hash": "sha256:...",
+ "retrieved_at": "2026-07-10T00:00:00Z",
+ "trust_level": "untrusted_external_content",
+ "truncated": false
+}
+```
+
+Provider 自带摘要只能作为结果字段,不能直接作为 TKMind 最终答案。最终回答继续由 Goose 生成。
+
+## 7. Provider 方案
+
+### 7.1 第一阶段推荐
+
+| Capability | 默认 Provider | 说明 |
+|---|---|---|
+| `search.web` | SearXNG | 自建、固定配置、返回标准化网页结果 |
+| `search.news` | SearXNG news category | 第一阶段复用同一搜索服务 |
+| `search.code` | GitHub Search API | 搜索 repository、code、issue、release 等 |
+| `search.read` | Readability Adapter 或受控 Reader | 读取 URL 并输出正文和元数据 |
+
+SearXNG 的生产部署和对外服务方式需要在实施前完成许可证合规确认。
+
+### 7.2 可选 Provider
+
+Brave、Tavily、Jina Reader 可以后续作为 Provider Adapter 接入,但不应成为第一阶段启动条件。
+
+第一阶段不做多 Provider 并行聚合,不做质量动态评分,也不做自动商业 Provider 降级。
+
+## 8. 功能开关和授权
+
+### 8.1 建议开关
+
+```text
+TKMIND_SEARCH_ENABLED=false
+TKMIND_SEARCH_PROVIDER_SEARXNG_ENABLED=false
+TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED=false
+TKMIND_SEARCH_READER_ENABLED=false
+```
+
+### 8.2 生效条件
+
+普通用户只有同时满足以下条件才能获得新扩展:
+
+```text
+TKMIND_SEARCH_ENABLED = true
+AND user capability search_external = true
+AND network_egress = allow
+```
+
+任意条件不满足时,不得把 `tkmind-search` 加入 `extension_overrides`。
+
+### 8.3 关闭语义
+
+当 `TKMIND_SEARCH_ENABLED=false` 时:
+
+- 不注册 `tkmind-search` Extension。
+- 不启动 Search MCP 子进程。
+- 不读取 Provider Secret。
+- 不产生 Search 外部网络请求。
+- 不改变现有 `platform/web`。
+- 不影响现有 Skill、MCP、会话、SSE、Memory、MindSpace 和 UserDataSpace。
+- 老会话恢复时,由现有 session reconcile 移除 `tkmind-search`。
+
+### 8.4 灰度原则
+
+第一阶段只允许专用测试账号开启 `search_external`。
+
+禁止一开始修改默认用户能力,也禁止对所有用户替换现有 `web` capability。
+
+## 9. 安全要求
+
+### 9.1 URL 和 SSRF
+
+Reader 必须拒绝:
+
+- `localhost` 和回环地址。
+- RFC1918 私网地址。
+- Link-local 和云元数据地址。
+- Docker、Colima、Kubernetes 和宿主机内部域名。
+- 非 HTTP/HTTPS 协议。
+- 解析后落入受限地址的域名。
+
+每次重定向都必须重新执行 URL 和解析后 IP 校验。还需要限制:
+
+- 最大重定向次数。
+- 最大响应体积。
+- 请求总超时。
+- 允许的 MIME 类型。
+- 压缩后解包体积。
+
+### 9.2 Prompt Injection
+
+所有 Reader 正文必须标记:
+
+```json
+{
+ "trust_level": "untrusted_external_content"
+}
+```
+
+网页内容只能作为资料,不得改变系统指令、请求其他工具、读取用户数据或执行命令。
+
+### 9.3 数据最小化
+
+Provider 请求不得包含:
+
+- 真实 `user_id`。
+- `tenant_id`。
+- 手机号、邮箱或用户名。
+- 完整会话内容。
+- Memory V2 内容。
+- MindSpace 私有文件。
+- UserDataSpace 数据。
+
+Provider 只接收搜索参数、随机 request ID 和必要的 Provider 鉴权信息。
+
+### 9.4 Secret
+
+Provider Key 只能从运行环境或 Secret 引用读取,禁止写入仓库、Skill 文档、日志和 Goose 对话上下文。
+
+## 10. 超时和错误隔离
+
+第一阶段建议:
+
+| 操作 | 超时上限 |
+|---|---:|
+| Web/News Search | 10 秒 |
+| Code Search | 10 秒 |
+| Reader 单页 | 10 秒 |
+| MCP 工具总调用 | 12 秒 |
+
+统一错误示例:
+
+```json
+{
+ "status": "error",
+ "error": {
+ "code": "PROVIDER_TIMEOUT",
+ "message": "Search provider timed out",
+ "retryable": true
+ }
+}
+```
+
+Search MCP 不得主动重启 Portal 或 goosed,不得修改 Agent Run 数据。Provider 失败应作为普通工具错误返回,由 Goose 决定是否解释、重试一次或继续回答。
+
+## 11. 可观测性
+
+第一阶段只记录技术指标,不接用户扣费:
+
+- request ID。
+- capability。
+- Provider 名称。
+- 成功或错误码。
+- 响应耗时。
+- 结果数量。
+- 是否触发 Reader。
+- 是否截断。
+
+默认不记录完整搜索词。调试环境需要查询内容时,应显式开启并做脱敏处理。
+
+Search 日志应与 Portal 会话日志分离,避免 Provider 波动污染现有服务健康判断。
+
+## 12. 对现有项目的预期影响
+
+### 12.1 功能关闭时
+
+预期影响为零:
+
+- Goose 工具列表不变化。
+- Portal 请求链路不变化。
+- 会话和 SSE 不变化。
+- 不新增网络请求。
+- 不新增运行进程。
+
+### 12.2 测试账号开启时
+
+只影响该账号的 Goose 工具集合:
+
+- Goose 可以调用新 Search MCP。
+- 搜索轮次可能增加 1 到 12 秒延迟。
+- 结构化结果会占用一定会话上下文 Token。
+- Provider 故障只影响当前搜索工具调用。
+
+### 12.3 需要避免的间接影响
+
+- 不要扩大聊天 Router 规则导致普通问答大量进入 Agent。
+- 不要让前端只根据 Skill grant 显示搜索入口,而忽略有效 capability 和 `network_egress`。
+- 不要让新旧搜索工具使用相同名称。
+- 不要把 Search Provider 健康状态加入 Portal 总体存活条件。
+- 不要在 Search 稳定前关闭现有 `platform/web`。
+
+## 13. 开发计划
+
+所有开发阶段均等待用户后续明确指令。每个 Patch 必须可以单独测试和停止,不自动进入下一阶段。
+
+### Patch 0: 基线确认
+
+目标:
+
+- 记录开发前分支和工作区状态。
+- 确认现有 capability、session reconcile、Agent Run 和 SSE 测试基线。
+- 确认当前 goosed 对自定义 stdio MCP 配置字段的实际兼容性。
+
+产物:
+
+- 基线测试结果。
+- 不改业务行为的接入点确认。
+
+停止条件:
+
+- 现有关键测试未通过。
+- goosed 当前版本无法可靠加载独立 stdio MCP。
+
+### Patch 1: 协议和纯函数
+
+目标:
+
+- 定义 Search、Read、Result 和 Error Schema。
+- 实现请求校验、URL canonicalization、结果标准化和错误映射纯函数。
+- 不连接任何 Provider。
+
+建议新增范围:
+
+```text
+search-capability/
+ schemas/
+ search-errors.mjs
+ request-validator.mjs
+ result-normalizer.mjs
+ citation-builder.mjs
+```
+
+验收:
+
+- Schema 正反例单测。
+- Provider 原始结果不能穿透到 Goose。
+- 所有成功结果必须包含来源 URL。
+
+### Patch 2: Search MCP 空运行时
+
+目标:
+
+- 新增独立 `tkmind-search` stdio MCP。
+- 暴露 `tkmind_search` 和 `tkmind_read`。
+- Provider 未启用时返回 `CAPABILITY_DISABLED`。
+- 默认不开启、不注入任何用户会话。
+
+验收:
+
+- MCP initialize、tools/list、tools/call 单测。
+- MCP 子进程异常不影响 Portal 测试。
+- 功能关闭时 extension policy 与开发前完全一致。
+
+### Patch 3: SearXNG Web/News Adapter
+
+目标:
+
+- 接入 `search.web` 和 `search.news`。
+- 使用固定 Provider 路由。
+- 完成超时、结果上限和标准化。
+
+验收:
+
+- 使用本地 mock Provider 完成确定性测试。
+- Provider 超时、空结果、无效 JSON 和 5xx 均返回统一错误。
+- 新闻结果保留发布时间;未知发布时间不得伪造。
+
+### Patch 4: GitHub Code Adapter
+
+目标:
+
+- 接入 `search.code`。
+- 与现有本地 `search` Skill 保持名称和行为隔离。
+
+验收:
+
+- Repository、Issue 和 Code 等结果映射为统一结构。
+- API 限流返回可重试错误。
+- 不把 GitHub Token 返回到日志或工具结果。
+
+### Patch 5: Reader 和 URL Safety
+
+目标:
+
+- 接入 `search.read`。
+- 实现 URL Safety Validator。
+- 增加重定向、MIME、体积和正文长度限制。
+
+验收:
+
+- 回环、私网、metadata、IPv6 特殊地址和重定向到私网均被拒绝。
+- 非 HTML/允许类型被拒绝或明确标记。
+- 正文统一标记为不可信外部内容。
+- 超大页面被截断且返回 `truncated=true`。
+
+### Patch 6: Capability 和功能开关接入
+
+目标:
+
+- 增加默认关闭的 `search_external` capability。
+- 增加全局和 Provider 功能开关。
+- 在 `buildAgentExtensionPolicy()` 中条件化注入 `tkmind-search`。
+- 复用 `network_egress` 和 session reconcile。
+
+验收:
+
+- 默认用户 extension policy 不变化。
+- 只有三层条件全部满足时才注入扩展。
+- 开关关闭后,恢复会话会移除扩展。
+- 现有 `web` 和 `search` Skill 测试不变化。
+
+### Patch 7: 测试账号本地端到端验证
+
+目标:
+
+- 只为专用测试账号开启能力。
+- 验证 Search 工具调用、结果引用和失败隔离。
+- 不改变默认用户能力。
+
+验收场景:
+
+1. 普通聊天不调用 Search。
+2. Web 搜索返回来源。
+3. News 搜索保留时间信息。
+4. Code 搜索不调用本地 ripgrep Skill。
+5. Reader 拒绝私网 URL。
+6. Provider 超时后会话仍可继续。
+7. Search 关闭后同一账号恢复为原工具集合。
+8. Run SSE 和 Session SSE 的终态行为不变。
+
+### Patch 8: 可选 UI 和窄范围路由
+
+只有后端本地验证稳定后才评估:
+
+- 是否增加独立“联网搜索”入口。
+- 前端是否按有效 capability 而不是单纯 Skill grant 展示。
+- 是否增加窄范围、可关闭的自动 Search 意图规则。
+
+第一阶段可以完全不改 UI,通过显式测试请求验证后端能力。
+
+## 14. 测试与回归范围
+
+实际开发时至少需要覆盖:
+
+- Search Schema 和 Adapter 单测。
+- MCP 协议单测。
+- Capability policy 单测。
+- Session reconcile 增删扩展单测。
+- `tkmind-proxy` 会话启动和恢复测试。
+- Agent Run Gateway 回归测试。
+- SSE taxonomy 和 Finish sync 回归测试。
+- 现有 `web`、本地 `search` 和 chat skill 路由测试。
+- URL Safety 专项测试。
+
+如果任何改动触及 MindSpace 回归守卫路径,必须按照仓库 `AGENTS.md` 执行对应 verify;本方案原则上不要求触及这些路径。
+
+## 15. 回退方案
+
+本地或后续灰度发现问题时,按以下顺序回退:
+
+1. 设置 `TKMIND_SEARCH_ENABLED=false`。
+2. 确认新会话不再包含 `tkmind-search`。
+3. 恢复测试账号会话,确认 reconcile 移除扩展。
+4. 保留现有 `platform/web`,继续提供原搜索能力。
+5. Search MCP 和 Provider 不进入 Portal 健康检查,不需要重启现有服务完成逻辑回退。
+
+代码回退不得使用脏工作区或直接修改生产文件,仍须遵守仓库开发和发布规范。
+
+## 16. 第一阶段明确不做
+
+- Skill Store。
+- 第三方开发者接入。
+- 用户支付和 Search Credit。
+- 开发者收益结算。
+- Tavily Deep Research 编排。
+- 多 Provider 并行聚合。
+- AI Provider Router。
+- LLM 全量重排。
+- Platform Hosted 第三方代码。
+- 企业私有搜索。
+- 搜索结果自动写 Memory 或 MindSpace。
+- 替换或删除现有 Goose `platform/web`。
+- 修改现有 Run SSE 和 Session SSE 协议。
+
+## 17. 开发启动闸门
+
+开始任何代码开发前,必须再次获得用户明确指令。
+
+开发指令不自动授权:
+
+- `git push`。
+- 合并 `main`。
+- 构建或发布 `103` runtime/artifact。
+- 修改生产配置。
+- 启动生产 Provider。
+- 执行任何生产动作。
+
+开发完成后必须先完成对应本地测试和 verify,再由用户决定下一步。
diff --git a/llm-providers.mjs b/llm-providers.mjs
index e7fa170..d529e9c 100644
--- a/llm-providers.mjs
+++ b/llm-providers.mjs
@@ -2025,7 +2025,9 @@ export function createLlmProviderService(
const content = [
...imageItems.map((item) => ({
type: 'image_url',
- image_url: { url: `data:${item.mimeType};base64,${item.data}` },
+ image_url: {
+ url: `data:${item.visionMimeType ?? item.mimeType};base64,${item.data}`,
+ },
})),
{ type: 'text', text: String(userText || '请描述这张图片的内容').trim() },
];
diff --git a/mindspace-agent-runner.mjs b/mindspace-agent-runner.mjs
index 06fa444..3858249 100644
--- a/mindspace-agent-runner.mjs
+++ b/mindspace-agent-runner.mjs
@@ -5,6 +5,7 @@ import { Agent, fetch as undiciFetch } from 'undici';
import { jsonrepair } from 'jsonrepair';
import { reconcileAgentSession } from './session-reconcile.mjs';
import { resolveSessionAccess } from './session-broker.mjs';
+import { resolveBillingTokenState } from './billing-token-state.mjs';
const insecureDispatcher = new Agent({
connect: { rejectUnauthorized: false },
@@ -395,7 +396,15 @@ export function createMindSpaceAgentRunner({
const parsed = normalizeStructuredResult(extractJsonObject(reply.text));
if (reply.tokenState) {
- await userAuth.billSessionUsage(claim.userId, sessionId, reply.tokenState, requestId);
+ const tokenState = await resolveBillingTokenState(reply.tokenState, {
+ sessionId,
+ fetchSession: async (sid) => {
+ const response = await apiFetch(`/sessions/${encodeURIComponent(sid)}`);
+ if (!response?.ok) return null;
+ return readJsonResponse(response);
+ },
+ });
+ await userAuth.billSessionUsage(claim.userId, sessionId, tokenState, requestId);
}
const completed = await agentJobService.completeJob(jobId, claim.jobToken, {
diff --git a/mindspace-asset-agent-instructions.mjs b/mindspace-asset-agent-instructions.mjs
index 931a2a9..52d8689 100644
--- a/mindspace-asset-agent-instructions.mjs
+++ b/mindspace-asset-agent-instructions.mjs
@@ -1,3 +1,31 @@
+function resolveWorkspaceRelativePath(asset) {
+ const raw = asset?.workspaceRelativePath ?? asset?.workspace_relative_path ?? '';
+ const trimmed = String(raw).trim().replace(/^\.?\//, '');
+ if (!trimmed || trimmed.includes('..')) return null;
+ return trimmed;
+}
+
+function isSpreadsheetAsset(asset) {
+ const mime = String(asset?.mimeType ?? '').toLowerCase();
+ const name = String(asset?.displayName ?? asset?.filename ?? '').toLowerCase();
+ return (
+ mime.includes('spreadsheet')
+ || mime.includes('csv')
+ || name.endsWith('.xlsx')
+ || name.endsWith('.xls')
+ || name.endsWith('.csv')
+ );
+}
+
+function buildSpreadsheetAnalysisInstructions() {
+ return [
+ '【数据表汇总校验(硬性)】',
+ '- 分析数值汇总时,**必须**用 Python/pandas 等对源表列做 SUM,禁止手算或估算。',
+ '- 写入聊天回复或 HTML 报告前,自检:各分组明细之和必须与总计一致;不一致则重新计算后再输出。',
+ '',
+ ];
+}
+
export function buildSelectedAssetReadInstructions(context) {
const selected = context.selectedAssets ?? [];
if (selected.length !== 1) return [];
@@ -11,12 +39,27 @@ export function buildSelectedAssetReadInstructions(context) {
const sessionHint = sessionId
? ''
: '- session_id 未写入上下文时,使用当前 Agent 会话 ID 替换 ``。\n';
+ const workspacePath = resolveWorkspaceRelativePath(asset);
- return [
+ const lines = [
'【勾选资料读取(硬性)】',
`- 用户已勾选「${asset.displayName}」(id: ${asset.id})`,
'- **禁止** curl / fetch 浏览器登录接口 `GET /api/mindspace/v1/assets/:id/download`(goosed 无 cookie,会 401)',
- '- **优先**用 `list_dir oa` / `read_file` / `cat oa/文件名` 读取工作区已落盘副本',
+ ];
+
+ if (workspacePath) {
+ lines.push(
+ `- **已落盘路径(硬性)**:\`${workspacePath}\` — 直接 \`read_file ${workspacePath}\` 或脚本读取该路径`,
+ '- **禁止** list_dir / 工作目录探测 / 重复调用 mindspace_asset_download(文件已在工作区)',
+ '- 仅当读取该路径失败时,才允许调用下方 Agent 代理下载',
+ );
+ } else {
+ lines.push(
+ '- **优先**用 `list_dir oa` / `read_file` / `cat oa/文件名` 读取工作区已落盘副本',
+ );
+ }
+
+ lines.push(
sessionHint,
'- 若工作区尚无该文件,**必须**用 shell 调用 Agent 代理下载并落盘到 `oa/`:',
'```bash',
@@ -27,7 +70,13 @@ export function buildSelectedAssetReadInstructions(context) {
'- 成功后响应 JSON 含 `relativePath`(如 `oa/report.xlsx`),后续分析/引用只用该相对路径',
'- **禁止**访问其它用户目录、MindSpace 根目录或 `./MindSpace//` 等猜测路径',
'',
- ].filter(Boolean);
+ );
+
+ if (isSpreadsheetAsset(asset)) {
+ lines.push(...buildSpreadsheetAnalysisInstructions());
+ }
+
+ return lines.filter(Boolean);
}
export function buildSelectedAssetDeleteInstructions(context) {
diff --git a/mindspace-asset-agent.test.mjs b/mindspace-asset-agent.test.mjs
index 2fadf54..de05613 100644
--- a/mindspace-asset-agent.test.mjs
+++ b/mindspace-asset-agent.test.mjs
@@ -26,6 +26,28 @@ test('buildSelectedAssetReadInstructions uses agent download proxy', () => {
assert.match(text, /禁止.*assets\/:id\/download/);
assert.match(text, /asset-9/);
assert.match(text, /session-1/);
+ assert.match(text, /数据表汇总校验/);
+ assert.match(text, /SUM/);
+});
+
+test('buildSelectedAssetReadInstructions skips download probe when workspace path exists', () => {
+ const lines = buildSelectedAssetReadInstructions({
+ selectedAssets: [
+ {
+ id: 'asset-9',
+ displayName: 'sales.xlsx',
+ mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ workspaceRelativePath: 'oa/sales.xlsx',
+ },
+ ],
+ h5ApiBase: 'http://127.0.0.1:8081',
+ agentSessionId: 'session-1',
+ });
+ const text = lines.join('\n');
+ assert.match(text, /已落盘路径/);
+ assert.match(text, /read_file oa\/sales\.xlsx/);
+ assert.match(text, /禁止.*list_dir/);
+ assert.match(text, /禁止.*重复调用 mindspace_asset_download/);
});
test('buildSelectedAssetDeleteInstructions includes agent delete curl', () => {
diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs
index 68e3c68..06417e7 100644
--- a/mindspace-assets.mjs
+++ b/mindspace-assets.mjs
@@ -264,6 +264,7 @@ function publicTempImageUrl(userId, assetId, mimeType, fallbackFilename = '') {
}
function assetResponse(row) {
+ const workspaceRelativePath = normalizeWorkspaceRelativePath(row?.workspace_relative_path);
return {
id: row.id,
categoryId: row.category_id,
@@ -282,6 +283,7 @@ function assetResponse(row) {
hasThumbnail: Boolean(row.has_thumbnail ?? row.mime_type === 'text/html'),
publicUrl: publicUrlForAsset(row),
sourcePageId: row.source_page_id ?? null,
+ ...(workspaceRelativePath ? { workspaceRelativePath } : {}),
createdAt: asNumber(row.created_at),
updatedAt: asNumber(row.updated_at),
};
diff --git a/mindspace-chat-context.mjs b/mindspace-chat-context.mjs
index c98635f..aef60a9 100644
--- a/mindspace-chat-context.mjs
+++ b/mindspace-chat-context.mjs
@@ -67,8 +67,17 @@ function resolvePageRecord(selectedPageId, pages, pageLive) {
};
}
+function normalizeWorkspaceRelativePath(value) {
+ const trimmed = String(value ?? '').trim().replace(/^\.?\//, '');
+ if (!trimmed || trimmed.includes('..')) return null;
+ return trimmed;
+}
+
function normalizeSelectedAsset(asset) {
if (!asset?.id) return null;
+ const workspaceRelativePath = normalizeWorkspaceRelativePath(
+ asset.workspaceRelativePath ?? asset.workspace_relative_path,
+ );
return {
id: asset.id,
displayName: asset.displayName ?? asset.filename ?? asset.id,
@@ -77,9 +86,18 @@ function normalizeSelectedAsset(asset) {
...(typeof asset.sizeBytes === 'number' ? { sizeBytes: asset.sizeBytes } : {}),
...(asset.categoryCode ? { categoryCode: asset.categoryCode } : {}),
...(asset.assetType ? { assetType: asset.assetType } : {}),
+ ...(workspaceRelativePath ? { workspaceRelativePath } : {}),
};
}
+function resolveCategoryItemCount(category, view, assets) {
+ const listed = typeof category?.itemCount === 'number' ? category.itemCount : 0;
+ if (view !== 'category' || !Array.isArray(assets) || assets.length === 0) {
+ return category?.itemCount;
+ }
+ return Math.max(listed, assets.length);
+}
+
function inferAssetKindLabel(asset) {
const mime = String(asset?.mimeType ?? '').toLowerCase();
const name = String(asset?.displayName ?? asset?.filename ?? '').toLowerCase();
@@ -107,10 +125,15 @@ function buildSelectedAssetInstructions(context) {
if (selected.length === 1) {
const asset = selected[0];
const kind = inferAssetKindLabel(asset);
+ const workspacePath = normalizeWorkspaceRelativePath(
+ asset.workspaceRelativePath ?? asset.workspace_relative_path,
+ );
lines.push(
`- 用户在界面勾选了 1 份${kind}:「${asset.displayName}」(id: ${asset.id},${asset.mimeType})`,
'- 用户说「这个 / 这份 / 这篇 / 这份报告 / 这个数据 / 帮我分析 / 帮我删除」等指代词时,**必须**指这份勾选资料,不得猜测其它文件。',
- '- 读取/分析:优先 `list_dir oa` / `read_file oa/文件名`;若工作区尚无副本,见下方「勾选资料读取」用 Agent 代理下载落盘。',
+ workspacePath
+ ? `- 读取/分析:文件已落盘 \`${workspacePath}\`,直接 read/脚本读取;见下方「勾选资料读取」。`
+ : '- 读取/分析:优先 `list_dir oa` / `read_file oa/文件名`;若工作区尚无副本,见下方「勾选资料读取」用 Agent 代理下载落盘。',
'- 删除:须先与用户确认,确认后由你直接执行删除(见下方「勾选资料删除」);你对该用户空间有完整读写删改权限,**禁止**推给用户自行去界面删除。',
);
if (context.h5ApiBase) {
@@ -184,7 +207,7 @@ export function buildMindSpaceChatContext(input) {
category: {
code: category.code,
name: category.name,
- itemCount: category.itemCount,
+ itemCount: resolveCategoryItemCount(category, view, assets),
...(view === 'category' && assets.length
? {
assets: assets.slice(0, ASSET_PREVIEW_LIMIT).map((asset) => ({
@@ -344,7 +367,9 @@ export function buildContextPrefix(context) {
if (context.page.summary?.trim()) {
lines.push(`- 页面摘要:${context.page.summary.trim()}`);
}
- if (context.page.publicationUrl) {
+ if (context.page.workspacePublicUrl) {
+ lines.push(`- 公开地址(MindSpace):${context.page.workspacePublicUrl}`);
+ } else if (context.page.publicationUrl) {
lines.push(`- 公开地址:${context.page.publicationUrl}`);
}
if (context.page.contentExcerpt) {
diff --git a/mindspace-chat-context.test.mjs b/mindspace-chat-context.test.mjs
index 23c8155..b1a22b2 100644
--- a/mindspace-chat-context.test.mjs
+++ b/mindspace-chat-context.test.mjs
@@ -296,3 +296,33 @@ test('buildSelectedAssetGreeting adapts wording to asset kind', () => {
/这份报告/,
);
});
+
+test('buildMindSpaceChatContext reconciles stale category itemCount with loaded assets', () => {
+ const staleCategory = { ...sampleCategory, itemCount: 0 };
+ const asset = {
+ id: 'asset-1',
+ displayName: 'report.xlsx',
+ mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ workspaceRelativePath: 'oa/report.xlsx',
+ };
+ const context = buildMindSpaceChatContext({
+ space: {
+ ...sampleSpace,
+ categories: [{ ...sampleCategory, itemCount: 0 }],
+ },
+ selectedCategory: staleCategory,
+ selectedPageId: null,
+ pages: [],
+ assets: [asset],
+ selectedAssets: [asset],
+ route: '/space?category=oa',
+ h5ApiBase: 'http://127.0.0.1:8081',
+ agentSessionId: 'session-1',
+ });
+
+ assert.equal(context.category?.itemCount, 1);
+ const prefix = buildContextPrefix(context);
+ assert.match(prefix, /OA 工作区(oa,共 1 项)/);
+ assert.match(prefix, /已落盘 `oa\/report\.xlsx`/);
+ assert.match(prefix, /数据表汇总校验/);
+});
diff --git a/mindspace-pages.mjs b/mindspace-pages.mjs
index bde15bb..904e511 100644
--- a/mindspace-pages.mjs
+++ b/mindspace-pages.mjs
@@ -13,6 +13,10 @@ import {
bufferToImageDataUri,
} from './mindspace-thumbnails.mjs';
import { resolvePublishDir } from './user-publish.mjs';
+import {
+ resolvePageWorkspaceRelativePath,
+ resolveWorkspaceMindSpacePublicUrl,
+} from './mindspace-runtime-config.mjs';
import { prepareHtmlDownloadLinks, inferWorkspaceHtmlRelativePath } from './mindspace-html-download-links.mjs';
import { prepareHtmlPageBrandMarkers } from './mindspace-page-tag.mjs';
import { purgeWorkspacePageArtifacts, extractAssetIdsFromHtml } from './mindspace-page-purge.mjs';
@@ -106,7 +110,14 @@ function extensionForMime(mimeType, filename = '') {
return 'bin';
}
-function pageResponse(row, { includeContent = true } = {}) {
+function pageResponse(row, { includeContent = true, h5Root = null, env = process.env } = {}) {
+ const workspaceRelativePath = resolvePageWorkspaceRelativePath(row);
+ const workspacePublicUrl = resolveWorkspaceMindSpacePublicUrl({
+ h5Root,
+ env,
+ userId: row.user_id,
+ relativePath: workspaceRelativePath,
+ });
const response = {
id: row.id,
categoryId: row.category_id,
@@ -123,7 +134,9 @@ function pageResponse(row, { includeContent = true } = {}) {
status: row.status,
visibility: row.visibility,
publicationAccessMode: row.pub_access_mode ?? null,
- publicationUrl: row.pub_public_url ?? null,
+ publicationUrl: workspacePublicUrl ?? row.pub_public_url ?? null,
+ workspaceRelativePath: workspaceRelativePath ?? null,
+ workspacePublicUrl,
currentVersionId: row.current_version_id,
versionNo: asNumber(row.version_no),
createdAt: asNumber(row.created_at),
@@ -249,6 +262,7 @@ export function createPageService(pool, options = {}) {
const h5Root = options.h5Root ? path.resolve(options.h5Root) : null;
const idFactory = options.idFactory ?? (() => crypto.randomUUID());
const conversationPackageRegistry = options.conversationPackageRegistry ?? null;
+ const toPageResponse = (row, opts = {}) => pageResponse(row, { h5Root, ...opts });
const resolveWorkspacePublishDir = async (userId) => {
if (!h5Root) return null;
@@ -624,7 +638,7 @@ export function createPageService(pool, options = {}) {
LIMIT 1`,
[userId, assetId],
);
- return rows[0] ? pageResponse(rows[0]) : null;
+ return rows[0] ? toPageResponse(rows[0]) : null;
};
const findPageBySourceMessage = async (userId, sessionId, messageId) => {
@@ -640,7 +654,7 @@ export function createPageService(pool, options = {}) {
LIMIT 1`,
[userId, sessionId, messageId],
);
- return rows[0] ? pageResponse(rows[0]) : null;
+ return rows[0] ? toPageResponse(rows[0]) : null;
};
const findPageByRelativePath = async (userId, relativePath) => {
@@ -669,7 +683,7 @@ export function createPageService(pool, options = {}) {
LIMIT 1`,
[userId, normalized, normalized, normalized],
);
- return rows[0] ? pageResponse(rows[0]) : null;
+ return rows[0] ? toPageResponse(rows[0]) : null;
};
const findPlazaContextByWorkspacePath = async (userId, relativePath) => {
@@ -734,7 +748,7 @@ export function createPageService(pool, options = {}) {
]);
const total = asNumber(countRows[0]?.total);
return {
- items: rows.map((row) => pageResponse(row, { includeContent: false })),
+ items: rows.map((row) => toPageResponse(row, { includeContent: false })),
total,
limit,
offset,
@@ -761,7 +775,7 @@ export function createPageService(pool, options = {}) {
sourceSnapshotJson: row.source_snapshot_json,
pageType: row.page_type,
});
- return pageResponse({ ...row, content });
+ return toPageResponse({ ...row, content });
}
const listVersions = async (userId, pageId) => {
diff --git a/mindspace-publications.mjs b/mindspace-publications.mjs
index 4d41d05..78fd216 100644
--- a/mindspace-publications.mjs
+++ b/mindspace-publications.mjs
@@ -8,6 +8,11 @@ import { pageInternals, normalizeWorkspaceRelativePath } from './mindspace-pages
import { readWorkspacePublishHtml } from './mindspace-workspace-path.mjs';
import { loadMindSpaceConfig } from './mindspace-config.mjs';
import { buildPublicUrl, resolvePublicBaseUrl, resolvePublishDir } from './user-publish.mjs';
+import {
+ isPublicWorkspaceHtmlRelativePath,
+ resolvePageWorkspaceRelativePath,
+ resolveWorkspaceMindSpacePublicUrl,
+} from './mindspace-runtime-config.mjs';
import {
isRelativeDownloadReference,
loadWorkspaceDownloadLinkIndex,
@@ -161,6 +166,27 @@ function buildOnlinePublicationPublicUrl(ownerSlug, urlSlug) {
return `${publicBaseUrl}/u/${encodeURIComponent(ownerSlug)}/pages/${urlSlug}`;
}
+function resolvePublicationDeliveryPublicUrl({
+ h5Root,
+ userId,
+ ownerSlug,
+ urlSlug,
+ accessMode,
+ workspaceRelativePath,
+ privateToken = null,
+} = {}) {
+ if (privateToken) return `/s/${privateToken}`;
+ if (accessMode === 'public') {
+ const mindSpaceUrl = resolveWorkspaceMindSpacePublicUrl({
+ h5Root,
+ userId,
+ relativePath: workspaceRelativePath,
+ });
+ if (mindSpaceUrl) return mindSpaceUrl;
+ }
+ return buildOnlinePublicationPublicUrl(ownerSlug, urlSlug);
+}
+
function publicHomepageResponse(owner, pages) {
const totalViews = pages.reduce(
(sum, page) => sum + Number(page.viewCount ?? page.view_count ?? 0),
@@ -767,10 +793,16 @@ export function createPublicationService(pool, options = {}) {
await fs.mkdir(path.dirname(writtenPath), { recursive: true });
await fs.writeFile(writtenPath, html, { flag: 'wx' });
const checksum = crypto.createHash('sha256').update(html).digest('hex');
- const publicBaseUrl = resolvePublicBaseUrl();
- const publicUrl = privateToken
- ? `/s/${privateToken}`
- : `${publicBaseUrl}/u/${encodeURIComponent(ownerSlug)}/pages/${result.urlSlug}`;
+ const workspaceRelativePath = resolvePageWorkspaceRelativePath(page);
+ const publicUrl = resolvePublicationDeliveryPublicUrl({
+ h5Root,
+ userId,
+ ownerSlug,
+ urlSlug: result.urlSlug,
+ accessMode: result.accessMode,
+ workspaceRelativePath,
+ privateToken,
+ });
await conn.query(
`INSERT INTO h5_assets
@@ -1005,7 +1037,15 @@ export function createPublicationService(pool, options = {}) {
);
let publicUrl = publication.public_url;
if (publication.access_mode === 'public') {
- publicUrl = buildOnlinePublicationPublicUrl(ownerSlug, publication.url_slug);
+ const workspaceRelativePath = resolvePageWorkspaceRelativePath(page);
+ publicUrl = resolvePublicationDeliveryPublicUrl({
+ h5Root,
+ userId,
+ ownerSlug,
+ urlSlug: publication.url_slug,
+ accessMode: publication.access_mode,
+ workspaceRelativePath,
+ });
await pool.query(
`UPDATE h5_publish_records SET public_url = ?, updated_at = ? WHERE id = ? AND user_id = ?`,
[publicUrl, now, publication.id, userId],
@@ -1179,6 +1219,7 @@ export function createPublicationService(pool, options = {}) {
html: await refreshPublishedHtmlDownloadLinks(row, html),
publication: publicationResponse({ ...row, view_count: Number(row.view_count) + 1 }),
ownerId: row.owner_id,
+ workspaceRelativePath: resolvePageWorkspaceRelativePath(row),
pageSource: {
pageId: row.page_id,
title: row.title,
diff --git a/mindspace-runtime-config.mjs b/mindspace-runtime-config.mjs
index 24a2e6b..13f5144 100644
--- a/mindspace-runtime-config.mjs
+++ b/mindspace-runtime-config.mjs
@@ -2,6 +2,7 @@ import path from 'node:path';
import { DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs';
import { createMindSpaceServiceFacade } from './mindspace-service.mjs';
import { createLocalMindSpaceStorageAdapter } from './mindspace-storage-adapter.mjs';
+import { normalizeWorkspaceRelativePath } from './mindspace-workspace-relative-path.mjs';
import {
buildPublicUrl,
PUBLISH_ROOT_DIR,
@@ -10,6 +11,42 @@ import {
resolvePublishDir,
} from './user-publish.mjs';
+export function resolvePageWorkspaceRelativePath(row) {
+ const fromColumn = normalizeWorkspaceRelativePath(row?.workspace_relative_path);
+ if (fromColumn) return fromColumn;
+ let snapshot = row?.source_snapshot_json;
+ if (typeof snapshot === 'string') {
+ try {
+ snapshot = JSON.parse(snapshot);
+ } catch {
+ snapshot = null;
+ }
+ }
+ return normalizeWorkspaceRelativePath(snapshot?.relative_path);
+}
+
+export function isPublicWorkspaceHtmlRelativePath(relativePath) {
+ const normalized = normalizeWorkspaceRelativePath(relativePath);
+ return Boolean(normalized?.startsWith('public/') && normalized.toLowerCase().endsWith('.html'));
+}
+
+/** MindSpace 工作区静态页 canonical 公网 URL(/MindSpace/<用户ID>/public/...) */
+export function resolveWorkspaceMindSpacePublicUrl({
+ h5Root,
+ env = process.env,
+ userId,
+ relativePath,
+} = {}) {
+ const normalized = normalizeWorkspaceRelativePath(relativePath);
+ if (!userId || !normalized || !isPublicWorkspaceHtmlRelativePath(normalized)) return null;
+ return buildMindSpacePublicUrlForUser({
+ h5Root: h5Root ?? process.cwd(),
+ env,
+ user: userId,
+ relativePath: normalized,
+ });
+}
+
export function resolveMindSpaceStorageRoot(h5Root, env = process.env) {
return path.resolve(env.MINDSPACE_STORAGE_ROOT ?? path.join(h5Root, 'data', 'mindspace'));
}
diff --git a/mindspace-runtime-config.test.mjs b/mindspace-runtime-config.test.mjs
index 618dca5..be79cac 100644
--- a/mindspace-runtime-config.test.mjs
+++ b/mindspace-runtime-config.test.mjs
@@ -12,6 +12,8 @@ import {
resolveMindSpaceServerRuntimeOptions,
resolveMindSpaceStorageRoot,
resolveMindSpaceUserPublishDir,
+ resolveWorkspaceMindSpacePublicUrl,
+ isPublicWorkspaceHtmlRelativePath,
} from './mindspace-runtime-config.mjs';
test('resolveMindSpaceStorageRoot uses env override when present', () => {
@@ -131,3 +133,25 @@ test('createMindSpaceLocalRuntime builds a local storage-backed facade', async (
'https://example.com/MindSpace/user-1/public/report.html',
);
});
+
+test('resolveWorkspaceMindSpacePublicUrl builds MindSpace public html urls only', () => {
+ const url = resolveWorkspaceMindSpacePublicUrl({
+ h5Root: '/tmp/h5',
+ env: { H5_PUBLIC_BASE_URL: 'https://example.com' },
+ userId: '32035858-9a20-425b-89da-c118ef0779aa',
+ relativePath: 'public/demo.html',
+ });
+ assert.equal(
+ url,
+ 'https://example.com/MindSpace/32035858-9a20-425b-89da-c118ef0779aa/public/demo.html',
+ );
+ assert.equal(
+ resolveWorkspaceMindSpacePublicUrl({
+ h5Root: '/tmp/h5',
+ userId: 'user-1',
+ relativePath: 'oa/report.csv',
+ }),
+ null,
+ );
+ assert.equal(isPublicWorkspaceHtmlRelativePath('public/index.html'), true);
+});
diff --git a/package.json b/package.json
index ae316a3..213249b 100644
--- a/package.json
+++ b/package.json
@@ -55,7 +55,9 @@
"mock:memory-v2-services": "node scripts/mock-memory-v2-services.mjs",
"test:memind": "node scripts/run-memind-tests.mjs",
"test:scenario": "node scripts/run-scenario-test.mjs",
- "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
+ "verify:children-hobby-diet-survey": "node scripts/verify-children-hobby-diet-survey.mjs",
+ "test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update",
+ "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
"test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs",
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
"verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs",
diff --git a/page-data-workspace-bind.mjs b/page-data-workspace-bind.mjs
index 072d15d..6d675ff 100644
--- a/page-data-workspace-bind.mjs
+++ b/page-data-workspace-bind.mjs
@@ -233,7 +233,7 @@ export async function bindWorkspaceHtmlForPageData({
publicationId: publication.id,
publicationAccessMode: publication.accessMode,
publicationPasswordApplied: normalizedAccessMode === 'password',
- publicationUrl: publication.publicUrl,
+ publicationUrl: workspaceUrl,
workspaceUrl,
deliveryUrl: workspaceUrl,
deliveryHint:
diff --git a/scenarios/john4-children-hobby-diet-update.json b/scenarios/john4-children-hobby-diet-update.json
new file mode 100644
index 0000000..6c75055
--- /dev/null
+++ b/scenarios/john4-children-hobby-diet-update.json
@@ -0,0 +1,28 @@
+{
+ "id": "john4-children-hobby-diet-update",
+ "name": "john4 儿童爱好问卷增加饮食偏好",
+ "description": "在已有 page-data-collect 问卷上追加 q4_diet_meals(每日几顿),验证 Agent 交付 + Page Data 可提交",
+ "account": {
+ "username": "john4",
+ "password": "888888"
+ },
+ "steps": [
+ {
+ "action": "login",
+ "label": "登录 john4"
+ },
+ {
+ "action": "chat",
+ "label": "问卷增加饮食偏好(每日几顿)",
+ "message": "问卷页面再增加一个饮食偏好,比如每日几顿这种",
+ "selectedChatSkill": "page-data-collect",
+ "expect": {
+ "assistantMinChars": 80,
+ "timeoutMs": 600000,
+ "replyKeywords": ["问卷", "饮食"],
+ "forbidReplyPatterns": ["8899", "127.0.0.1:", "PLACEHOLDER_PAGE_ID", "survey-api"],
+ "surveyDietUpdate": true
+ }
+ }
+ ]
+}
diff --git a/scripts/run-scenario-test.mjs b/scripts/run-scenario-test.mjs
index bb9be25..156fc6b 100644
--- a/scripts/run-scenario-test.mjs
+++ b/scripts/run-scenario-test.mjs
@@ -19,6 +19,7 @@ import {
snapshotPublicHtml,
verifyPageAccess,
verifySurveyDelivery,
+ verifyChildrenHobbyDietSurvey,
waitForAssistantGrowth,
waitForRunTerminal,
extractAssistantTexts,
@@ -200,6 +201,15 @@ async function runScenario(scenario, port) {
});
}
+ if (step.expect?.surveyDietUpdate) {
+ await verifyChildrenHobbyDietSurvey({
+ publishKey,
+ baseUrl,
+ reporter,
+ testInsert: step.expect.testInsert !== false,
+ });
+ }
+
const forbidReply = step.expect?.forbidReplyPatterns ?? [];
for (const pattern of forbidReply) {
if (pattern && reply.combined.includes(pattern)) {
diff --git a/scripts/scenario-test-lib.mjs b/scripts/scenario-test-lib.mjs
index ca67b19..945eafe 100644
--- a/scripts/scenario-test-lib.mjs
+++ b/scripts/scenario-test-lib.mjs
@@ -449,6 +449,169 @@ export async function verifySurveyDelivery({
return true;
}
+export const CHILDREN_HOBBY_DIET_SURVEY = {
+ userId: '32035858-9a20-425b-89da-c118ef0779aa',
+ username: 'john4',
+ surveyHtml: 'children-hobby-survey.html',
+ adminHtml: 'children-hobby-admin.html',
+ dataset: 'children_hobby_survey',
+ dietField: 'q4_diet_meals',
+ dietKeywords: ['饮食', '几顿', 'q4_diet_meals'],
+};
+
+async function readJsonIfExists(filePath) {
+ try {
+ const raw = await fs.readFile(filePath, 'utf8');
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+}
+
+async function findPublicSurveyPolicy(publishKey, datasetName) {
+ const policyDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'page-data-policies');
+ let entries = [];
+ try {
+ entries = await fs.readdir(policyDir);
+ } catch {
+ return null;
+ }
+ for (const name of entries.filter((item) => item.endsWith('.json'))) {
+ const policy = await readJsonIfExists(path.join(policyDir, name));
+ const dataset = policy?.datasets?.[datasetName];
+ if (policy?.accessMode === 'public' && dataset?.insert) {
+ return { pageId: policy.pageId, policy, fileName: name };
+ }
+ }
+ return null;
+}
+
+export async function verifyChildrenHobbyDietSurvey({
+ publishKey = CHILDREN_HOBBY_DIET_SURVEY.userId,
+ baseUrl = resolvePortalBase(Number(process.env.H5_PORT ?? 8081)),
+ reporter,
+ testInsert = true,
+ spec = CHILDREN_HOBBY_DIET_SURVEY,
+} = {}) {
+ const publishDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey);
+ const publicDir = path.join(publishDir, 'public');
+ const sqlitePath = path.join(publishDir, '.mindspace', 'private-data.sqlite');
+ let ok = true;
+
+ const surveyPath = path.join(publicDir, spec.surveyHtml);
+ const adminPath = path.join(publicDir, spec.adminHtml);
+ try {
+ const [surveyHtml, adminHtml] = await Promise.all([
+ fs.readFile(surveyPath, 'utf8'),
+ fs.readFile(adminPath, 'utf8'),
+ ]);
+ reporter.pass('问卷 HTML 文件', spec.surveyHtml);
+ reporter.pass('后台 HTML 文件', spec.adminHtml);
+
+ const dietHits = spec.dietKeywords.filter((keyword) => surveyHtml.includes(keyword));
+ if (dietHits.length !== spec.dietKeywords.length) {
+ reporter.fail(
+ '问卷饮食字段',
+ `未命中: ${spec.dietKeywords.filter((keyword) => !surveyHtml.includes(keyword)).join(', ')}`,
+ );
+ ok = false;
+ } else {
+ reporter.pass('问卷饮食字段', dietHits.join(', '));
+ }
+ if (!surveyHtml.includes(`insertRow('${spec.dataset}'`) || !surveyHtml.includes(spec.dietField)) {
+ reporter.fail('问卷提交字段', `insertRow 未包含 ${spec.dietField}`);
+ ok = false;
+ } else {
+ reporter.pass('问卷提交字段', `${spec.dataset}.${spec.dietField}`);
+ }
+ if (!adminHtml.includes('每日饮食') || !adminHtml.includes(spec.dietField)) {
+ reporter.fail('后台饮食列', `未展示 ${spec.dietField}`);
+ ok = false;
+ } else {
+ reporter.pass('后台饮食列', spec.dietField);
+ }
+ if (!surveyHtml.includes('page-data-client.js') || !adminHtml.includes('page-data-client.js')) {
+ reporter.fail('Page Data 客户端', '问卷/后台未引用 page-data-client.js');
+ ok = false;
+ } else {
+ reporter.pass('Page Data 客户端', 'page-data-client.js');
+ }
+ } catch (error) {
+ reporter.fail('HTML 读取', error instanceof Error ? error.message : String(error));
+ ok = false;
+ }
+
+ const policyInfo = await findPublicSurveyPolicy(publishKey, spec.dataset);
+ if (!policyInfo) {
+ reporter.fail('公开问卷策略', '未找到 public insert policy');
+ ok = false;
+ } else {
+ reporter.pass('公开问卷策略', `${policyInfo.fileName} (${policyInfo.pageId})`);
+ const insertCols = policyInfo.policy?.datasets?.[spec.dataset]?.columns?.insert ?? [];
+ if (!insertCols.includes(spec.dietField)) {
+ reporter.fail('策略 insert 列', `${spec.dietField} 不在白名单: ${insertCols.join(', ')}`);
+ ok = false;
+ } else {
+ reporter.pass('策略 insert 列', insertCols.join(', '));
+ }
+ }
+
+ try {
+ await fs.stat(sqlitePath);
+ reporter.pass('私有 SQLite', '.mindspace/private-data.sqlite');
+ } catch {
+ reporter.fail('私有 SQLite', 'private-data.sqlite 不存在');
+ ok = false;
+ }
+
+ const surveyUrl = new URL(
+ `/MindSpace/${publishKey}/public/${spec.surveyHtml}`,
+ baseUrl,
+ ).href;
+ const adminUrl = new URL(
+ `/MindSpace/${publishKey}/public/${spec.adminHtml}`,
+ baseUrl,
+ ).href;
+ for (const [label, url] of [['问卷页 HTTP', surveyUrl], ['后台页 HTTP', adminUrl]]) {
+ const response = await fetch(url);
+ if (response.status !== 200) {
+ reporter.fail(label, `${response.status} ${url}`);
+ ok = false;
+ } else {
+ reporter.pass(label, url);
+ }
+ }
+
+ if (testInsert && policyInfo?.pageId) {
+ const payload = {
+ q1_age_group: '7-9岁',
+ q2_favorite_hobby: '阅读写作',
+ q3_development_area: 'automated verify insert',
+ [spec.dietField]: '4顿(3正餐+1加餐)',
+ };
+ const response = await fetch(
+ `${baseUrl}/api/public/pages/${policyInfo.pageId}/data/${spec.dataset}/rows`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ },
+ );
+ const body = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ reporter.fail('公开 insert API', `${response.status} ${JSON.stringify(body)}`);
+ ok = false;
+ } else if (!body?.data?.row?.[spec.dietField]) {
+ reporter.fail('公开 insert API', `响应缺少 ${spec.dietField}`);
+ ok = false;
+ } else {
+ reporter.pass('公开 insert API', `${spec.dietField}=${body.data.row[spec.dietField]}`);
+ }
+ }
+
+ return ok;
+}
+
export async function loadScenario(scenarioId) {
const scenarioPath = path.join(repoRoot, 'scenarios', `${scenarioId}.json`);
const raw = await fs.readFile(scenarioPath, 'utf8');
diff --git a/scripts/verify-children-hobby-diet-survey.mjs b/scripts/verify-children-hobby-diet-survey.mjs
new file mode 100644
index 0000000..48deed6
--- /dev/null
+++ b/scripts/verify-children-hobby-diet-survey.mjs
@@ -0,0 +1,172 @@
+#!/usr/bin/env node
+/**
+ * 验证 john4 儿童爱好问卷「饮食偏好 / 每日几顿」增量改造链路(无需重新聊天)。
+ *
+ * Usage:
+ * node scripts/verify-children-hobby-diet-survey.mjs
+ * node scripts/verify-children-hobby-diet-survey.mjs --no-insert
+ * node scripts/verify-children-hobby-diet-survey.mjs --user-id --port 8081
+ */
+import { execSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { loadH5Environment } from './load-env.mjs';
+import {
+ CHILDREN_HOBBY_DIET_SURVEY,
+ createReporter,
+ resolvePortalBase,
+ verifyChildrenHobbyDietSurvey,
+} from './scenario-test-lib.mjs';
+import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
+import { createDbPool } from '../db.mjs';
+
+const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
+
+loadH5Environment(import.meta.dirname);
+
+function parseArgs(argv) {
+ const options = {
+ userId: CHILDREN_HOBBY_DIET_SURVEY.userId,
+ port: Number(process.env.H5_PORT ?? 8081),
+ testInsert: true,
+ checkRuns: true,
+ };
+ for (let i = 2; i < argv.length; i += 1) {
+ const arg = argv[i];
+ if (arg === '--no-insert') options.testInsert = false;
+ else if (arg === '--no-runs') options.checkRuns = false;
+ else if (arg === '--user-id' && argv[i + 1]) options.userId = argv[++i];
+ else if (arg === '--port' && argv[i + 1]) options.port = Number(argv[++i]);
+ else if (arg === '-h' || arg === '--help') {
+ console.log(`Usage: node scripts/verify-children-hobby-diet-survey.mjs [--no-insert] [--no-runs] [--user-id ] [--port ]`);
+ process.exit(0);
+ } else {
+ throw new Error(`未知参数: ${arg}`);
+ }
+ }
+ return options;
+}
+
+async function ensurePortalReady(baseUrl) {
+ const response = await fetch(`${baseUrl}/auth/status`);
+ if (!response.ok) {
+ throw new Error(`Portal 未就绪: ${baseUrl}/auth/status -> ${response.status}`);
+ }
+}
+
+function verifySqliteColumn(sqlitePath, tableName, columnName, reporter) {
+ try {
+ const output = execSync(
+ `sqlite3 ${JSON.stringify(sqlitePath)} "PRAGMA table_info(${tableName});"`,
+ { encoding: 'utf8' },
+ );
+ if (!output.includes(`|${columnName}|`)) {
+ reporter.fail('SQLite 列', `${tableName}.${columnName} 不存在`);
+ return false;
+ }
+ reporter.pass('SQLite 列', `${tableName}.${columnName}`);
+ const datasetJson = execSync(
+ `sqlite3 ${JSON.stringify(sqlitePath)} "SELECT config_json FROM __page_data_datasets WHERE name='${CHILDREN_HOBBY_DIET_SURVEY.dataset}';"`,
+ { encoding: 'utf8' },
+ ).trim();
+ const config = JSON.parse(datasetJson);
+ const insertCols = config?.columns?.insert ?? [];
+ if (!insertCols.includes(columnName)) {
+ reporter.fail('dataset insert 列', `${columnName} 不在 ${insertCols.join(', ')}`);
+ return false;
+ }
+ reporter.pass('dataset insert 列', insertCols.join(', '));
+ return true;
+ } catch (error) {
+ reporter.fail('SQLite 检查', error instanceof Error ? error.message : String(error));
+ return false;
+ }
+}
+
+async function verifyAgentRunHealth(userId, reporter) {
+ if (!process.env.DATABASE_URL && !fs.existsSync(path.join(repoRoot, '.env'))) {
+ reporter.pass('Agent run 健康', '跳过(无 DATABASE_URL)');
+ return true;
+ }
+ try {
+ for (const line of fs.readFileSync(path.join(repoRoot, '.env'), 'utf8').split('\n')) {
+ const t = line.trim();
+ if (!t || t.startsWith('#')) continue;
+ const eq = t.indexOf('=');
+ if (eq < 0) continue;
+ const k = t.slice(0, eq).trim();
+ const v = t.slice(eq + 1).trim();
+ if (!process.env[k]) process.env[k] = v;
+ }
+ const pool = createDbPool();
+ const [runningRows] = await pool.query(
+ `SELECT id, agent_session_id, updated_at
+ FROM h5_agent_runs
+ WHERE user_id = ? AND status = 'running'
+ ORDER BY updated_at DESC
+ LIMIT 5`,
+ [userId],
+ );
+ await pool.end();
+ if (runningRows.length > 0) {
+ reporter.fail('Agent run 健康', `${runningRows.length} 条 running(可能前端仍在 loading)`);
+ for (const row of runningRows) {
+ console.error(` - ${row.id} session=${row.agent_session_id}`);
+ }
+ return false;
+ }
+ reporter.pass('Agent run 健康', '无 orphan running');
+ return true;
+ } catch (error) {
+ reporter.fail('Agent run 健康', error instanceof Error ? error.message : String(error));
+ return false;
+ }
+}
+
+async function main() {
+ const options = parseArgs(process.argv);
+ const reporter = createReporter();
+ const baseUrl = resolvePortalBase(options.port);
+ const publishKey = options.userId;
+ const sqlitePath = path.join(
+ repoRoot,
+ PUBLISH_ROOT_DIR,
+ publishKey,
+ '.mindspace',
+ 'private-data.sqlite',
+ );
+
+ console.log('==> 儿童爱好问卷 · 饮食偏好链路验证');
+ console.log(` Portal: ${baseUrl}`);
+ console.log(` 用户: ${publishKey}\n`);
+
+ await ensurePortalReady(baseUrl);
+
+ let ok = true;
+ ok = verifySqliteColumn(
+ sqlitePath,
+ CHILDREN_HOBBY_DIET_SURVEY.dataset,
+ CHILDREN_HOBBY_DIET_SURVEY.dietField,
+ reporter,
+ ) && ok;
+
+ ok = await verifyChildrenHobbyDietSurvey({
+ publishKey,
+ baseUrl,
+ reporter,
+ testInsert: options.testInsert,
+ }) && ok;
+
+ if (options.checkRuns) {
+ ok = await verifyAgentRunHealth(publishKey, reporter) && ok;
+ }
+
+ const code = reporter.summary();
+ process.exit(code);
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.stack ?? error.message : error);
+ process.exit(1);
+});
diff --git a/scripts/verify-oa-excel-analysis.mjs b/scripts/verify-oa-excel-analysis.mjs
new file mode 100644
index 0000000..c147940
--- /dev/null
+++ b/scripts/verify-oa-excel-analysis.mjs
@@ -0,0 +1,231 @@
+#!/usr/bin/env node
+/**
+ * Verify OA selected Excel analysis chain (john4 面条区域销售数据_示例.xlsx).
+ * Checks injected context + optional live agent run.
+ */
+import crypto from 'node:crypto';
+import {
+ buildContextPrefix,
+ buildMindSpaceChatContext,
+} from '../mindspace-chat-context.mjs';
+import { USER_COOKIE } from '../user-auth.mjs';
+import {
+ createReporter,
+ resolvePortalBase,
+ waitForAssistantGrowth,
+ waitForRunTerminal,
+} from './scenario-test-lib.mjs';
+
+const BASE = resolvePortalBase(process.env.PORTAL_PORT ?? process.env.PORT ?? 8081);
+const ASSET_ID = process.env.OA_EXCEL_ASSET_ID ?? '597c0177-becd-4b89-be72-1b5fd3496f17';
+const LIVE_RUN = process.env.VERIFY_OA_EXCEL_LIVE !== '0';
+const TIMEOUT_MS = Number(process.env.VERIFY_OA_EXCEL_TIMEOUT_MS ?? 120000);
+
+function buildUserAddressPrefix(username) {
+ return `[用户身份]
+- 当前登录用户称呼:${username}
+- 你是 TKMind 助手;与用户对话时用「${username}」称呼对方(如「${username},你好」),禁止把用户叫作 TKMind。
+
+`;
+}
+
+async function login(reporter) {
+ const password = process.env.JOHN4_PASSWORD ?? '888888';
+ const response = await fetch(`${BASE}/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username: 'john4', password }),
+ });
+ const body = await response.json().catch(() => ({}));
+ if (!response.ok || !body?.authenticated) {
+ throw new Error(`登录失败: ${response.status} ${JSON.stringify(body)}`);
+ }
+ const setCookie = response.headers.getSetCookie?.() ?? [];
+ const cookieLine = setCookie.find((line) => line.startsWith(`${USER_COOKIE}=`))
+ ?? response.headers.get('set-cookie');
+ const match = String(cookieLine ?? '').match(new RegExp(`${USER_COOKIE}=([^;]+)`));
+ const token = match?.[1] ? decodeURIComponent(match[1]) : null;
+ if (!token) throw new Error('未解析到会话 Cookie');
+ reporter.pass('登录', `john4 (${body.user?.id ?? 'unknown'})`);
+ return { cookie: `${USER_COOKIE}=${token}`, user: body.user };
+}
+
+async function apiFetch(cookie, path) {
+ const response = await fetch(`${BASE}${path}`, { headers: { Cookie: cookie } });
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(`${path} ${response.status}: ${JSON.stringify(payload)}`);
+ }
+ return payload;
+}
+
+function countProbeNarration(text) {
+ const patterns = [
+ /Let me first look/i,
+ /没有 [`']?oa\/?[`']? 文件夹/i,
+ /文件不在当前工作区/i,
+ /确认工作目录/i,
+ /工作目录跳转/i,
+ /list_dir oa/i,
+ ];
+ return patterns.filter((re) => re.test(text)).length;
+}
+
+async function main() {
+ const reporter = createReporter();
+ const { cookie, user } = await login(reporter);
+
+ const spacePayload = await apiFetch(cookie, '/api/mindspace/v1/space');
+ const space = spacePayload.data;
+ const oaCategory = space.categories.find((item) => item.code === 'oa');
+ if (!oaCategory) {
+ reporter.fail('OA 分类', 'space 中未找到 oa 分类');
+ process.exit(reporter.summary());
+ }
+
+ const assetsPayload = await apiFetch(cookie, '/api/mindspace/v1/assets?category=oa');
+ const assets = assetsPayload.data ?? [];
+ const asset = assets.find((item) => item.id === ASSET_ID)
+ ?? assets.find((item) => /面条区域销售数据/.test(item.displayName ?? ''));
+ if (!asset) {
+ reporter.fail('Excel 资料', `未找到 asset ${ASSET_ID}`);
+ process.exit(reporter.summary());
+ }
+ reporter.pass('Excel 资料', `${asset.displayName} (${asset.id})`);
+
+ if (asset.workspaceRelativePath) {
+ reporter.pass('workspaceRelativePath', asset.workspaceRelativePath);
+ } else {
+ reporter.fail('workspaceRelativePath', 'API 未返回落盘路径');
+ }
+
+ const staleCategory = { ...oaCategory, itemCount: 0 };
+ const visibleAssets = [asset];
+ const context = buildMindSpaceChatContext({
+ space: { ...space, categories: space.categories.map((c) => (c.code === 'oa' ? staleCategory : c)) },
+ ownerUsername: user.username ?? 'john4',
+ selectedCategory: staleCategory,
+ selectedPageId: null,
+ pages: [],
+ assets: visibleAssets,
+ selectedAssets: [asset],
+ route: '/space?category=oa',
+ agentSessionId: 'verify-session',
+ h5ApiBase: BASE,
+ });
+
+ if (context.category?.itemCount === 1) {
+ reporter.pass('itemCount 兜底', 'stale 0 → effective 1');
+ } else {
+ reporter.fail('itemCount 兜底', `expected 1 got ${context.category?.itemCount}`);
+ }
+
+ const prefix = buildContextPrefix(context);
+ const prefixChecks = [
+ ...(asset.workspaceRelativePath
+ ? [
+ ['已落盘路径', /已落盘路径/],
+ ['read_file 路径', new RegExp(`read_file ${asset.workspaceRelativePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)],
+ ['禁止 list_dir', /禁止.*list_dir/],
+ ]
+ : [
+ ['优先 list_dir oa', /优先.*list_dir oa/],
+ ]),
+ ['数据表汇总校验', /数据表汇总校验/],
+ ['共 1 项', /共 1 项/],
+ ];
+ for (const [label, pattern] of prefixChecks) {
+ if (pattern.test(prefix)) reporter.pass(`上下文 · ${label}`, 'ok');
+ else reporter.fail(`上下文 · ${label}`, 'prefix 缺少预期片段');
+ }
+
+ if (!LIVE_RUN) {
+ console.log('\n跳过 live run(VERIFY_OA_EXCEL_LIVE=0)');
+ process.exit(reporter.summary());
+ }
+
+ const userText = '帮我分析一下这个数据吧';
+ const agentText = `${buildUserAddressPrefix(user.username ?? 'john4')}${prefix}${userText}`;
+ const requestId = crypto.randomUUID();
+ const runResponse = await fetch(`${BASE}/api/agent/runs`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Cookie: cookie,
+ },
+ body: JSON.stringify({
+ request_id: requestId,
+ user_message: {
+ id: crypto.randomUUID(),
+ role: 'user',
+ content: [{ type: 'text', text: agentText }],
+ metadata: { userVisible: true, displayText: userText },
+ },
+ selected_asset_ids: [asset.id],
+ }),
+ });
+ const runPayload = await runResponse.json().catch(() => ({}));
+ if (!runResponse.ok) {
+ reporter.fail('创建 run', `${runResponse.status} ${JSON.stringify(runPayload)}`);
+ process.exit(reporter.summary());
+ }
+ const run = runPayload.run ?? runPayload;
+ const runId = run.id;
+ let resolvedSessionId = run.sessionId ?? run.agent_session_id ?? run.agentSessionId ?? null;
+ reporter.pass('创建 run', `${runId.slice(0, 8)}…${resolvedSessionId ? ` session=${resolvedSessionId}` : ''}`);
+
+ const terminal = await waitForRunTerminal(BASE, cookie, runId, TIMEOUT_MS);
+ resolvedSessionId = resolvedSessionId
+ ?? terminal.agent_session_id
+ ?? terminal.agentSessionId
+ ?? terminal.sessionId
+ ?? null;
+ if (!resolvedSessionId) {
+ reporter.fail('sessionId', 'run 完成后仍无法解析 sessionId');
+ process.exit(reporter.summary());
+ }
+ reporter.pass('run 终态', `${terminal.status} · session=${resolvedSessionId}`);
+
+ const growth = await waitForAssistantGrowth(BASE, cookie, resolvedSessionId, {
+ previousCount: 0,
+ minChars: 80,
+ timeoutMs: 5000,
+ });
+ if (!growth?.combined) {
+ reporter.fail('assistant 回复', '未拿到可见回复');
+ process.exit(reporter.summary());
+ }
+
+ const combined = growth.combined;
+ reporter.pass('assistant 回复长度', `${combined.length} chars / ${growth.count} msgs`);
+
+ const probeHits = countProbeNarration(combined);
+ if (probeHits === 0) {
+ reporter.pass('路径探测旁白', '未检测到典型 probe 句式');
+ } else {
+ reporter.fail('路径探测旁白', `仍检测到 ${probeHits} 类 probe 句式`);
+ }
+
+ if (/4[,.]?872[,.]?000|4872000|487\.2\s*万/.test(combined)) {
+ reporter.pass('总销售额', '包含正确总计 ~487.2万');
+ } else if (/4[,.]?470[,.]?500|4470500/.test(combined)) {
+ reporter.fail('总销售额', '仍输出错误的 447.05 万');
+ } else {
+ reporter.fail('总销售额', '回复中未识别到明确总计(需人工查看)');
+ }
+
+ if (/54[,.]?200/.test(combined) && !/55[,.]?400|55400/.test(combined)) {
+ reporter.fail('总销量', '仍输出错误的 54200 箱');
+ } else if (/55[,.]?400|55400/.test(combined)) {
+ reporter.pass('总销量', '包含正确总计 55400 箱');
+ }
+
+ console.log('\n--- assistant 摘要 ---');
+ console.log(combined.slice(0, 1200));
+ process.exit(reporter.summary());
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/server.mjs b/server.mjs
index 364cea7..7b7580c 100644
--- a/server.mjs
+++ b/server.mjs
@@ -76,6 +76,7 @@ import {
import {
buildMindSpacePublicRoutePath,
buildMindSpacePublicUrlForUser,
+ isPublicWorkspaceHtmlRelativePath,
resolveMindSpaceRuntimeConfig,
resolveMindSpacePublishRoot,
resolveMindSpaceServerRuntimeOptions,
@@ -5673,9 +5674,24 @@ async function resolvePublishedRoute(req, res, password = null) {
referrer: req.get('referer'),
},
);
+ const embed = isPlazaEmbedRequest(req.query);
+ const raw = String(req.query.view ?? '').toLowerCase() === 'raw';
+ if (
+ !password &&
+ !embed &&
+ !raw &&
+ result.publication?.accessMode === 'public' &&
+ isPublicWorkspaceHtmlRelativePath(result.workspaceRelativePath)
+ ) {
+ const redirectPath = buildMindSpacePublicRoutePath(
+ result.ownerId,
+ String(result.workspaceRelativePath).split('/'),
+ );
+ return res.redirect(301, redirectPath);
+ }
return await sendPublishedPage(req, res, result, {
- embed: isPlazaEmbedRequest(req.query),
- raw: String(req.query.view ?? '').toLowerCase() === 'raw',
+ embed,
+ raw,
ownerSlug: req.params.ownerSlug,
});
} catch (error) {
diff --git a/shenmei-opinion-form-routes.mjs b/shenmei-opinion-form-routes.mjs
new file mode 100644
index 0000000..cbd3989
--- /dev/null
+++ b/shenmei-opinion-form-routes.mjs
@@ -0,0 +1,175 @@
+import fs from 'node:fs';
+import fsPromises from 'node:fs/promises';
+import path from 'node:path';
+
+const FORM_SLUG = 'shenmei-opinion-unified';
+const ADMIN_PASSWORD = 'sm6666';
+const MAX_RECORDS_PER_REQUEST = 50;
+const MAX_FIELD_LENGTH = 5000;
+const ALLOWED_FIELDS = [
+ 'id',
+ 'name',
+ 'goodPeople',
+ 'goodBehaviors',
+ 'badPeople',
+ 'badSuggestions',
+ 'time',
+ 'timestamp',
+];
+
+function normalizeString(value) {
+ return String(value ?? '').trim().slice(0, MAX_FIELD_LENGTH);
+}
+
+function normalizeRecord(raw) {
+ const source = raw && typeof raw === 'object' ? raw : {};
+ const record = {};
+ for (const field of ALLOWED_FIELDS) {
+ if (source[field] == null) continue;
+ record[field] = field === 'timestamp' ? Number(source[field]) || Date.now() : normalizeString(source[field]);
+ }
+ if (!record.id) {
+ record.id = `shenmei_${Date.now()}_${Math.random().toString(36).slice(2)}`;
+ }
+ if (!record.time) {
+ record.time = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
+ }
+ if (!record.timestamp) {
+ record.timestamp = Date.now();
+ }
+ return record;
+}
+
+function recordFingerprint(record) {
+ const parts = ['name', 'goodPeople', 'goodBehaviors', 'badPeople', 'badSuggestions', 'time', 'timestamp'].map((key) =>
+ String(record?.[key] ?? '').trim().replace(/\s+/g, ' '),
+ );
+ return parts.join('\u0000');
+}
+
+function isValidRecord(record) {
+ return Boolean(record.name && record.goodPeople && record.badPeople);
+}
+
+export async function readShenmeiOpinionEntries(filePath) {
+ let text = '';
+ try {
+ text = await fsPromises.readFile(filePath, 'utf8');
+ } catch (error) {
+ if (error?.code === 'ENOENT') return [];
+ throw error;
+ }
+ return text
+ .split(/\n/)
+ .filter(Boolean)
+ .map((line) => JSON.parse(line));
+}
+
+function publicRecordFromEntry(entry) {
+ return entry?.record && typeof entry.record === 'object' ? entry.record : entry;
+}
+
+async function appendJsonLine(filePath, value) {
+ await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
+ await fsPromises.appendFile(filePath, `${JSON.stringify(value)}\n`, 'utf8');
+}
+
+function makeEnvelope(record, req) {
+ return {
+ form: FORM_SLUG,
+ receivedAt: new Date().toISOString(),
+ requestId: req.id ?? null,
+ ipHash: req.ip
+ ? String(req.ip)
+ .split('')
+ .reduce((hash, char) => ((hash << 5) - hash + char.charCodeAt(0)) | 0, 0)
+ : null,
+ userAgentHash: req.get('user-agent')
+ ? String(req.get('user-agent'))
+ .split('')
+ .reduce((hash, char) => ((hash << 5) - hash + char.charCodeAt(0)) | 0, 0)
+ : null,
+ record,
+ };
+}
+
+export function createShenmeiOpinionFormService({ dataFile }) {
+ if (!dataFile) throw new Error('dataFile is required');
+
+ async function listRecords() {
+ const entries = await readShenmeiOpinionEntries(dataFile);
+ return entries.map(publicRecordFromEntry).filter((record) => record && typeof record === 'object');
+ }
+
+ async function submit(rawRecords, req) {
+ const sourceRecords = Array.isArray(rawRecords) ? rawRecords : [rawRecords];
+ const normalized = sourceRecords.slice(0, MAX_RECORDS_PER_REQUEST).map(normalizeRecord).filter(isValidRecord);
+ if (normalized.length === 0) return { inserted: 0, skipped: sourceRecords.length, total: (await listRecords()).length };
+
+ const existingEntries = await readShenmeiOpinionEntries(dataFile);
+ const seen = new Set(existingEntries.map(publicRecordFromEntry).map(recordFingerprint));
+ let inserted = 0;
+ let skipped = 0;
+
+ for (const record of normalized) {
+ const fingerprint = recordFingerprint(record);
+ if (seen.has(fingerprint)) {
+ skipped += 1;
+ continue;
+ }
+ seen.add(fingerprint);
+ await appendJsonLine(dataFile, makeEnvelope(record, req));
+ inserted += 1;
+ }
+
+ return {
+ inserted,
+ skipped,
+ total: existingEntries.length + inserted,
+ };
+ }
+
+ return {
+ dataFile,
+ listRecords,
+ submit,
+ };
+}
+
+export function attachShenmeiOpinionFormRoutes(api, options = {}) {
+ const dataFile =
+ options.dataFile ?? path.join(options.rootDir ?? process.cwd(), 'data', 'form-responses', `${FORM_SLUG}.ndjson`);
+ const service = options.service ?? createShenmeiOpinionFormService({ dataFile });
+
+ api.post(`/public/forms/${FORM_SLUG}/submit`, async (req, res) => {
+ try {
+ const records = Array.isArray(req.body?.records) ? req.body.records : req.body?.record ?? req.body;
+ const result = await service.submit(records, req);
+ return res.json({ ok: true, ...result });
+ } catch (error) {
+ console.error('[ShenmeiOpinionForm] submit failed:', error instanceof Error ? error.message : error);
+ return res.status(500).json({ ok: false, message: '保存失败,请稍后重试' });
+ }
+ });
+
+ api.get(`/public/forms/${FORM_SLUG}/responses`, async (req, res) => {
+ if (req.get('x-shenmei-admin') !== ADMIN_PASSWORD) {
+ return res.status(403).json({ ok: false, message: '后台密码错误' });
+ }
+ try {
+ const data = await service.listRecords();
+ return res.json({ ok: true, data });
+ } catch (error) {
+ console.error('[ShenmeiOpinionForm] read failed:', error instanceof Error ? error.message : error);
+ return res.status(500).json({ ok: false, message: '读取失败,请稍后重试' });
+ }
+ });
+
+ return service;
+}
+
+export const shenmeiOpinionFormInternals = {
+ FORM_SLUG,
+ normalizeRecord,
+ recordFingerprint,
+};
diff --git a/shenmei-opinion-form-routes.test.mjs b/shenmei-opinion-form-routes.test.mjs
new file mode 100644
index 0000000..cfbeab7
--- /dev/null
+++ b/shenmei-opinion-form-routes.test.mjs
@@ -0,0 +1,105 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import fsPromises from 'node:fs/promises';
+import http from 'node:http';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+import express from 'express';
+import { attachShenmeiOpinionFormRoutes } from './shenmei-opinion-form-routes.mjs';
+
+async function request(baseUrl, method, pathname, { body, headers } = {}) {
+ const response = await fetch(`${baseUrl}${pathname}`, {
+ method,
+ headers: {
+ ...(body ? { 'content-type': 'application/json' } : {}),
+ ...headers,
+ },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const text = await response.text();
+ return {
+ status: response.status,
+ body: text ? JSON.parse(text) : null,
+ };
+}
+
+async function withApp(t, dataFile) {
+ const app = express();
+ app.use(express.json());
+ const api = express.Router();
+ attachShenmeiOpinionFormRoutes(api, { dataFile });
+ app.use('/api', api);
+ const server = http.createServer(app);
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ t.after(() => new Promise((resolve) => server.close(resolve)));
+ const { port } = server.address();
+ return `http://127.0.0.1:${port}`;
+}
+
+test('shenmei form route appends valid records and lists them with admin header', async (t) => {
+ const dir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'shenmei-form-'));
+ const dataFile = path.join(dir, 'responses.ndjson');
+ const baseUrl = await withApp(t, dataFile);
+
+ const record = {
+ id: 'shenmei_test_1',
+ name: '测试用户',
+ goodPeople: '正向内容',
+ goodBehaviors: '推广做法',
+ badPeople: '负向内容',
+ badSuggestions: '改进建议',
+ time: '2026/7/10 12:30:00',
+ timestamp: 1783657800000,
+ };
+
+ const submitted = await request(baseUrl, 'POST', '/api/public/forms/shenmei-opinion-unified/submit', {
+ body: { records: [record] },
+ });
+ assert.equal(submitted.status, 200);
+ assert.equal(submitted.body.ok, true);
+ assert.equal(submitted.body.inserted, 1);
+ assert.equal(fs.readFileSync(dataFile, 'utf8').trim().split(/\n/).length, 1);
+
+ const listed = await request(baseUrl, 'GET', '/api/public/forms/shenmei-opinion-unified/responses', {
+ headers: { 'x-shenmei-admin': 'sm6666' },
+ });
+ assert.equal(listed.status, 200);
+ assert.equal(listed.body.data.length, 1);
+ assert.equal(listed.body.data[0].name, '测试用户');
+});
+
+test('shenmei form route skips duplicate submissions', async (t) => {
+ const dir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'shenmei-form-'));
+ const dataFile = path.join(dir, 'responses.ndjson');
+ const baseUrl = await withApp(t, dataFile);
+ const body = {
+ records: [
+ {
+ id: 'one',
+ name: '测试用户',
+ goodPeople: '正向内容',
+ badPeople: '负向内容',
+ time: '2026/7/10 12:30:00',
+ timestamp: 1783657800000,
+ },
+ ],
+ };
+
+ const first = await request(baseUrl, 'POST', '/api/public/forms/shenmei-opinion-unified/submit', { body });
+ const second = await request(baseUrl, 'POST', '/api/public/forms/shenmei-opinion-unified/submit', { body });
+
+ assert.equal(first.body.inserted, 1);
+ assert.equal(second.body.inserted, 0);
+ assert.equal(second.body.skipped, 1);
+ assert.equal(fs.readFileSync(dataFile, 'utf8').trim().split(/\n/).length, 1);
+});
+
+test('shenmei form responses require admin header', async (t) => {
+ const dir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'shenmei-form-'));
+ const dataFile = path.join(dir, 'responses.ndjson');
+ const baseUrl = await withApp(t, dataFile);
+
+ const denied = await request(baseUrl, 'GET', '/api/public/forms/shenmei-opinion-unified/responses');
+ assert.equal(denied.status, 403);
+});
diff --git a/skills/static-page-publish/SKILL.md b/skills/static-page-publish/SKILL.md
index 9d6c463..7a2802f 100644
--- a/skills/static-page-publish/SKILL.md
+++ b/skills/static-page-publish/SKILL.md
@@ -26,6 +26,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
9. 只有用户明确要求**长图下载**时:必须先 `load_skill` → `long-image-download`,调用 `generate_long_image` 生成同目录 `public/<页面名>.long.png`,再返回长图预览与下载链接;禁止把 `.thumbnail.svg` 当成长图
10. **禁止**在 HTML 中用 CDN `https://...` 引用 `