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://...` 引用 `