diff --git a/.cursor/rules/mindspace-publish-chat-finish-guards.mdc b/.cursor/rules/mindspace-publish-chat-finish-guards.mdc new file mode 100644 index 0000000..33aa92c --- /dev/null +++ b/.cursor/rules/mindspace-publish-chat-finish-guards.mdc @@ -0,0 +1,27 @@ +--- +description: MindSpace 公开页 edit_file 落盘与聊天 Finish 同步回归守卫(勿盲覆盖) +globs: mindspace-public-finish-sync.mjs,chat-finish-sync.mjs,conversation-display.mjs,src/hooks/useTKMindChat.ts,src/utils/message.ts,server.mjs,chat-skills.mjs +alwaysApply: false +--- + +# MindSpace 发布 & 聊天 Finish — 回归守卫 + +已验证行为(2026-06-29)。删改下列逻辑前必须先读 `docs/regression-guards/mindspace-publish-and-chat-finish.md` 并跑 `npm run verify:mindspace-publish-guards`。 + +## 公开 HTML + +- `edit_file` 的 `old_str`/`new_str` **必须**经 `mindspace-public-finish-sync.mjs` 落盘并**覆盖**已有 `public/*.html` +- 不得回退为仅处理 `write_file` 全量 `content` + +## 聊天 Finish + +- **禁止** `syncSessionMessages` / `UpdateConversation` 用服务端 snapshot **盲覆盖**本地已展示消息 +- Finish 同步 **必须**使用 `mergeConversationSnapshot`(见 `chat-finish-sync.mjs`),并在服务端条数落后时重试 +- **禁止**无 hint 时命中 session snapshot 缓存(需 `hint_mc` 与 `hint_ua` 同时匹配) +- 用户可见文案 **必须**优先 `metadata.displayText`;否则用 `deriveUserFacingText` 剥离 routing / skill / `[用户身份]` 等 agent-only 前缀 + +## 改完必跑 + +```bash +npm run verify:mindspace-publish-guards +``` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a3d43a4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# Agent 协作说明(Codex / Cloud / Cursor / 其它 AI 工具通用) + +本文件供**所有**在仓库内工作的 AI 编码助手阅读,不限于 Cursor。 + +## 必读:已验证回归守卫(勿轻易覆盖) + +下列行为经过长时间调试验证。**删改相关代码前必须先读文档并跑 verify**: + +| 主题 | 文档 | +|------|------| +| MindSpace 公开页 `edit_file` 落盘 + 聊天 Finish 不丢消息 | [docs/regression-guards/mindspace-publish-and-chat-finish.md](docs/regression-guards/mindspace-publish-and-chat-finish.md) | + +索引:[docs/regression-guards/README.md](docs/regression-guards/README.md) + +### 改相关文件前必跑 + +```bash +# 单测 + 源码静态守卫 +npm run verify:mindspace-publish-guards + +# 发 Portal runtime 后(含 bundle 检查) +npm run verify:mindspace-publish-guards:full +``` + +发版脚本(`scripts/release-prod.sh`、`scripts/release-portal-runtime-prod.sh`)在未 `--skip-tests` 时也会执行上述 verify。 + +### 受保护的关键路径 + +- `mindspace-public-finish-sync.mjs` — `edit_file` 必须 materialize 到 `public/*.html` +- `chat-finish-sync.mjs` — Finish / UpdateConversation **merge**,禁止盲覆盖 +- `conversation-display.mjs` + `src/utils/message.ts` — 用户消息不得展示 routing/skill 内部前缀 +- `src/hooks/useTKMindChat.ts` — `syncSessionMessages` merge + 重试 +- `server.mjs` — session snapshot 需 `hint_mc` **且** `hint_ua` 才走缓存 + +代码内搜索 `REGRESSION GUARD` 可定位所有内联说明。 + +### 相关单测(已接入 `npm test`) + +- `mindspace-public-finish-sync.test.mjs` +- `chat-finish-sync.test.mjs` +- `conversation-display.test.mjs` + +--- + +## Cursor 专用规则(其它工具请读上文文档) + +Cursor 额外加载:`.cursor/rules/mindspace-publish-chat-finish-guards.mdc` +内容与 `docs/regression-guards/mindspace-publish-and-chat-finish.md` 一致,仅为 Cursor 在编辑相关文件时自动提示。 + +--- + +## 仓库惯例 + +- 本地开发:`pnpm dev`(见 [README.md](README.md)) +- 生产隔离:[docs/service-isolation-runbook.md](docs/service-isolation-runbook.md) +- 发版须 Git commit,禁止本机直 rsync 到 103/105(见 README) diff --git a/README.md b/README.md index 18d1846..28a8230 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Memind 主应用与 Plaza 发现广场。**当前以本机 Mac 为主服务**,105 服务器已停用。 > **开发基线:** `v0.1.0-baseline`(2026-06-16)— 后续开发请从此 tag 创建分支,详见 [docs/BASELINE.md](docs/BASELINE.md)。 +> +> **AI 协作(Codex / Cloud / Cursor 等):** 请先读 [AGENTS.md](AGENTS.md) 与 [docs/regression-guards/](docs/regression-guards/README.md),内含不可轻易回退的已验证行为与 `verify:mindspace-publish-guards` 门禁。 ## 快速开始 @@ -60,6 +62,8 @@ pnpm open:plaza # 浏览器打开 https://plaza.tkmind.cn/plaza |------|------| | `pnpm dev` | 本仓库开发(不含 Plaza) | | `pnpm dev:all` | 兼容入口:全栈开发(含 Plaza),仅在需要联动 Plaza 时使用 | +| `npm run verify:mindspace-publish-guards` | MindSpace 发布 + 聊天 Finish **回归守卫**(改相关代码后必跑) | +| `npm run verify:mindspace-publish-guards:full` | 上者 + Portal runtime bundle 检查(发 runtime 后) | | `pnpm open:local-test` | 浏览器打开 H5 | | `pnpm start:plaza` | Plaza 正式模式 + Portal | | `pnpm dev:plaza` | Plaza 开发模式 + Portal | diff --git a/agent-run-routes.mjs b/agent-run-routes.mjs new file mode 100644 index 0000000..18e33f3 --- /dev/null +++ b/agent-run-routes.mjs @@ -0,0 +1,135 @@ +export function createPostAgentRunsHandler({ userAuth, agentRunGateway }) { + return async function postAgentRuns(request, response) { + try { + const sessionId = String(request.body?.session_id ?? '').trim() || null; + const requestId = String(request.body?.request_id ?? '').trim(); + const userMessage = request.body?.user_message ?? null; + if (!requestId) { + response.status(400).json({ message: '缺少 request_id' }); + return; + } + if (!userMessage) { + response.status(400).json({ message: '缺少 user_message' }); + return; + } + if (sessionId) { + const owns = await userAuth.ownsSession(request.currentUser.id, sessionId); + if (!owns) { + response.status(403).json({ message: '无权访问该会话' }); + return; + } + } + const run = await agentRunGateway.createRun(request.currentUser.id, { + sessionId, + requestId, + userMessage, + }); + response.status(202).json({ run }); + } catch (err) { + response.status(500).json({ + message: err instanceof Error ? err.message : '创建任务失败', + }); + } + }; +} + +export function createGetAgentRunHandler({ agentRunGateway }) { + return async function getAgentRun(request, response) { + try { + const run = await agentRunGateway.getRunForUser( + request.currentUser.id, + request.params.runId, + ); + if (!run) { + response.status(404).json({ message: '任务不存在' }); + return; + } + if (run.status !== 'succeeded' && run.status !== 'failed') { + agentRunGateway.dispatchRun(run.id); + } + response.json({ run }); + } catch (err) { + response.status(500).json({ + message: err instanceof Error ? err.message : '读取任务失败', + }); + } + }; +} + +export function createAgentRunEventsHandler({ + agentRunGateway, + pollIntervalMs = 1000, + keepaliveIntervalMs = 20000, +}) { + return async function getAgentRunEvents(request, response) { + const userId = request.currentUser.id; + const runId = request.params.runId; + let closed = false; + let lastPayload = null; + let pollTimer = null; + let keepaliveTimer = null; + + const cleanup = () => { + closed = true; + if (pollTimer) clearInterval(pollTimer); + if (keepaliveTimer) clearInterval(keepaliveTimer); + pollTimer = null; + keepaliveTimer = null; + }; + + const sendEvent = (event, data) => { + response.write(`event: ${event}\n`); + response.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + const publish = async (prefetchedRun = null) => { + if (closed) return; + try { + const run = prefetchedRun ?? await agentRunGateway.getRunForUser(userId, runId); + if (!run) { + sendEvent('error', { message: '任务不存在' }); + cleanup(); + response.end(); + return; + } + const nextPayload = JSON.stringify(run); + if (nextPayload !== lastPayload) { + lastPayload = nextPayload; + sendEvent('run', { run }); + } + if (run.status !== 'succeeded' && run.status !== 'failed') { + agentRunGateway.dispatchRun(run.id); + return; + } + cleanup(); + response.end(); + } catch (err) { + sendEvent('error', { + message: err instanceof Error ? err.message : '读取任务失败', + }); + cleanup(); + response.end(); + } + }; + + const firstRun = await agentRunGateway.getRunForUser(userId, runId); + if (!firstRun) { + response.status(404).json({ message: '任务不存在' }); + return; + } + + response.status(200); + response.setHeader('Content-Type', 'text/event-stream'); + response.setHeader('Cache-Control', 'no-cache'); + response.setHeader('Connection', 'keep-alive'); + + request.on('close', cleanup); + keepaliveTimer = setInterval(() => { + if (!closed) response.write(': keepalive\n\n'); + }, keepaliveIntervalMs); + pollTimer = setInterval(() => { + void publish(); + }, pollIntervalMs); + await publish(firstRun); + }; +} diff --git a/agent-run-routes.test.mjs b/agent-run-routes.test.mjs new file mode 100644 index 0000000..0e8b9ec --- /dev/null +++ b/agent-run-routes.test.mjs @@ -0,0 +1,254 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createAgentRunEventsHandler, + createGetAgentRunHandler, + createPostAgentRunsHandler, +} from './agent-run-routes.mjs'; + +function createResponseRecorder() { + return { + statusCode: 200, + body: null, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.body = payload; + return this; + }, + }; +} + +function createSseResponseRecorder() { + return { + statusCode: 200, + headers: {}, + chunks: [], + ended: false, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.body = payload; + return this; + }, + setHeader(key, value) { + this.headers[key] = value; + }, + write(chunk) { + this.chunks.push(chunk); + }, + end() { + this.ended = true; + }, + }; +} + +async function waitFor(predicate) { + for (let i = 0; i < 50; i += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.fail('condition was not met'); +} + +test('POST /agent/runs creates a run and returns 202', async () => { + const created = []; + const handler = createPostAgentRunsHandler({ + userAuth: { + async ownsSession() { + return true; + }, + }, + agentRunGateway: { + async createRun(userId, payload) { + created.push({ userId, payload }); + return { id: 'run-1', status: 'queued' }; + }, + }, + }); + const res = createResponseRecorder(); + + await handler( + { + currentUser: { id: 'user-1' }, + body: { + session_id: 'session-1', + request_id: 'req-1', + user_message: { role: 'user', content: [] }, + }, + }, + res, + ); + + assert.equal(res.statusCode, 202); + assert.deepEqual(res.body, { run: { id: 'run-1', status: 'queued' } }); + assert.deepEqual(created, [ + { + userId: 'user-1', + payload: { + sessionId: 'session-1', + requestId: 'req-1', + userMessage: { role: 'user', content: [] }, + }, + }, + ]); +}); + +test('POST /agent/runs rejects a session the user does not own', async () => { + const handler = createPostAgentRunsHandler({ + userAuth: { + async ownsSession() { + return false; + }, + }, + agentRunGateway: { + async createRun() { + throw new Error('should not be called'); + }, + }, + }); + const res = createResponseRecorder(); + + await handler( + { + currentUser: { id: 'user-1' }, + body: { + session_id: 'session-2', + request_id: 'req-1', + user_message: { role: 'user', content: [] }, + }, + }, + res, + ); + + assert.equal(res.statusCode, 403); + assert.deepEqual(res.body, { message: '无权访问该会话' }); +}); + +test('GET /agent/runs/:runId dispatches unfinished runs', async () => { + const dispatched = []; + const handler = createGetAgentRunHandler({ + agentRunGateway: { + async getRunForUser(userId, runId) { + assert.equal(userId, 'user-1'); + assert.equal(runId, 'run-1'); + return { id: 'run-1', status: 'running' }; + }, + dispatchRun(runId) { + dispatched.push(runId); + }, + }, + }); + const res = createResponseRecorder(); + + await handler( + { + currentUser: { id: 'user-1' }, + params: { runId: 'run-1' }, + }, + res, + ); + + assert.equal(res.statusCode, 200); + assert.deepEqual(res.body, { run: { id: 'run-1', status: 'running' } }); + assert.deepEqual(dispatched, ['run-1']); +}); + +test('GET /agent/runs/:runId returns 404 for missing runs', async () => { + const handler = createGetAgentRunHandler({ + agentRunGateway: { + async getRunForUser() { + return null; + }, + dispatchRun() {}, + }, + }); + const res = createResponseRecorder(); + + await handler( + { + currentUser: { id: 'user-1' }, + params: { runId: 'missing' }, + }, + res, + ); + + assert.equal(res.statusCode, 404); + assert.deepEqual(res.body, { message: '任务不存在' }); +}); + +test('GET /agent/runs/:runId/events streams the current run and closes on terminal state', async () => { + const handler = createAgentRunEventsHandler({ + agentRunGateway: { + async getRunForUser() { + return { id: 'run-1', status: 'succeeded', sessionId: 'session-1' }; + }, + dispatchRun() { + throw new Error('should not dispatch terminal run'); + }, + }, + pollIntervalMs: 5, + keepaliveIntervalMs: 50, + }); + const res = createSseResponseRecorder(); + const listeners = new Map(); + + await handler( + { + currentUser: { id: 'user-1' }, + params: { runId: 'run-1' }, + on(event, cb) { + listeners.set(event, cb); + }, + }, + res, + ); + + assert.equal(res.statusCode, 200); + assert.equal(res.headers['Content-Type'], 'text/event-stream'); + assert.match(res.chunks.join(''), /event: run/); + assert.match(res.chunks.join(''), /"status":"succeeded"/); + assert.equal(res.ended, true); +}); + +test('GET /agent/runs/:runId/events republishes changed run state before terminal close', async () => { + let readCount = 0; + const dispatched = []; + const handler = createAgentRunEventsHandler({ + agentRunGateway: { + async getRunForUser() { + readCount += 1; + if (readCount < 2) { + return { id: 'run-2', status: 'running', sessionId: null }; + } + return { id: 'run-2', status: 'succeeded', sessionId: 'session-2' }; + }, + dispatchRun(runId) { + dispatched.push(runId); + }, + }, + pollIntervalMs: 5, + keepaliveIntervalMs: 50, + }); + const res = createSseResponseRecorder(); + + await handler( + { + currentUser: { id: 'user-1' }, + params: { runId: 'run-2' }, + on() {}, + }, + res, + ); + + await waitFor(() => res.ended); + + const output = res.chunks.join(''); + assert.match(output, /"status":"running"/); + assert.match(output, /"status":"succeeded"/); + assert.deepEqual(dispatched, ['run-2']); +}); diff --git a/chat-finish-sync.mjs b/chat-finish-sync.mjs new file mode 100644 index 0000000..3afee3b --- /dev/null +++ b/chat-finish-sync.mjs @@ -0,0 +1,45 @@ +/** + * Chat Finish sync invariants (regression guard — do not simplify away). + * + * Context (2026-06): Finish could arrive before Goose persisted assistant turns. + * Blind replace with the server snapshot wiped the live chat and exposed agent-only + * prefixes (routing hints / skill prefaces) when displayText was missing. + * + * Required behaviour: + * - merge local streamed messages with server snapshot (never blind replace) + * - retry sync while server has fewer messages than the local view + * - user-facing text must strip agent-only prefixes when displayText is absent + * + * Tests: chat-finish-sync.test.mjs, conversation-display.test.mjs + * Verify: scripts/verify-chat-finish-sync.mjs (source guards) + * Docs: docs/regression-guards/mindspace-publish-and-chat-finish.md + */ + +/** @typedef {{ id?: string; role?: string; metadata?: Record; content?: unknown[] }} ChatMessage */ + +/** + * Merge an authoritative server conversation snapshot into the currently + * displayed messages without erasing what the user can already see. + * + * @param {ChatMessage[]} current + * @param {ChatMessage[]} incoming + */ +export function mergeConversationSnapshot(current, incoming) { + if (!Array.isArray(incoming) || incoming.length === 0) { + return Array.isArray(current) ? current : []; + } + const base = Array.isArray(current) ? current : []; + const incomingIds = new Set(incoming.map((message) => message?.id).filter(Boolean)); + const localOnly = base.filter((message) => message?.id && !incomingIds.has(message.id)); + return localOnly.length ? [...incoming, ...localOnly] : incoming; +} + +/** + * Finish-time sync: server may lag behind the streamed UI — keep the local tail. + * + * @param {ChatMessage[]} localMessages + * @param {ChatMessage[]} serverMessages + */ +export function mergeSessionMessagesAfterFinish(localMessages, serverMessages) { + return mergeConversationSnapshot(localMessages, serverMessages); +} diff --git a/chat-finish-sync.test.mjs b/chat-finish-sync.test.mjs new file mode 100644 index 0000000..4f65be0 --- /dev/null +++ b/chat-finish-sync.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + mergeConversationSnapshot, + mergeSessionMessagesAfterFinish, +} from './chat-finish-sync.mjs'; + +const msg = (id, role, text) => ({ + id, + role, + metadata: { userVisible: true }, + content: [{ type: 'text', text }], +}); + +test('mergeConversationSnapshot keeps local-only messages when server snapshot is shorter', () => { + const local = [ + msg('u1', 'user', '生成一个页面'), + msg('a1', 'assistant', '正在写入 public/demo.html…'), + msg('a2', 'assistant', '页面已生成'), + ]; + const server = [msg('u1', 'user', '生成一个页面')]; + const merged = mergeConversationSnapshot(local, server); + assert.equal(merged.length, 3); + assert.deepEqual( + merged.map((message) => message.id), + ['u1', 'a1', 'a2'], + ); +}); + +test('mergeConversationSnapshot prefers server content for shared message ids', () => { + const local = [msg('u1', 'user', '旧文案')]; + const server = [msg('u1', 'user', '新文案')]; + const merged = mergeConversationSnapshot(local, server); + assert.equal(merged.length, 1); + assert.equal(merged[0].content[0].text, '新文案'); +}); + +test('mergeConversationSnapshot returns current when incoming is empty', () => { + const local = [msg('u1', 'user', 'hello')]; + assert.deepEqual(mergeConversationSnapshot(local, []), local); +}); + +test('mergeSessionMessagesAfterFinish matches Finish sync merge semantics', () => { + const local = [ + msg('u1', 'user', '根据图片做主题页'), + msg('a1', 'assistant', '已完成'), + ]; + const server = [msg('u1', 'user', '根据图片做主题页')]; + assert.deepEqual( + mergeSessionMessagesAfterFinish(local, server).map((message) => message.id), + ['u1', 'a1'], + ); +}); diff --git a/chat-skills.mjs b/chat-skills.mjs index d9aab80..8d4ef9a 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -13,6 +13,12 @@ const WEB_NEWS_INTENT_PATTERNS = [ /(?:what(?:'s| is)?\s+the\s+latest|what(?:'s| is)?\s+trending)/i, ]; +const PAGE_GENERATION_INTENT_PATTERNS = [ + /(?:生成|做|制作|创建|设计|写|出)(?:一个|一份|个)?(?:H5|h5|HTML|html|网页|页面|活动页|宣传页|落地页|分享页)/u, + /(?:帮我|给我).*(?:H5|h5|HTML|html|网页|页面|活动页|宣传页|落地页|分享页)/u, + /(?:publish|create|generate|make|build|design).*(?:html|web\s?page|landing\s?page|h5)/i, +]; + export const CHAT_SKILL_DEFINITIONS = [ { id: 'web-search', @@ -103,7 +109,11 @@ export function buildChatSkillPrompt(promptKey, skillName) { case 'analyze': return '请深入分析以下内容,给出结构化解读:背景、关键发现、风险或机会、以及建议下一步:'; case 'generate-page': - return `请使用 ${skillName ?? PUBLISH_SKILL_NAME} 技能:在我的专属 MindSpace 发布目录生成静态 HTML 页面,并给出可公网访问的完整链接。`; + return ( + `请使用 ${skillName ?? PUBLISH_SKILL_NAME} 技能:在我的专属 MindSpace 发布目录生成静态 HTML 页面,并给出可公网访问的完整链接。` + + '必须先 load_skill,再用 write_file/edit_file 写入 public/页面.html;完成后直接返回 Markdown 可点击公网链接 `[页面标题](URL)`。' + + '禁止只给本地路径(如 hello/index.html),禁止询问是否还要发布,除非写文件工具实际失败。' + ); default: return ''; } @@ -124,6 +134,12 @@ export function filterChatSkills(options, ctx) { export function buildAutoChatSkillPrefix(text, grantedSkills = []) { const trimmed = String(text ?? '').trim(); if (!trimmed) return ''; + if ( + grantedSkills.includes(PUBLISH_SKILL_NAME) && + PAGE_GENERATION_INTENT_PATTERNS.some((pattern) => pattern.test(trimmed)) + ) { + return buildChatSkillPrompt('generate-page', PUBLISH_SKILL_NAME); + } if (!grantedSkills.includes('web')) return ''; if (WEB_NEWS_INTENT_PATTERNS.some((pattern) => pattern.test(trimmed))) { return buildWebNewsSkillPrompt('web'); @@ -131,3 +147,21 @@ export function buildAutoChatSkillPrefix(text, grantedSkills = []) { if (!WEB_INTENT_PATTERNS.some((pattern) => pattern.test(trimmed))) return ''; return buildChatSkillPrompt('web', 'web'); } + +export function stripKnownChatSkillPrompt(text) { + let next = String(text ?? '').trimStart(); + const candidates = []; + for (const definition of CHAT_SKILL_DEFINITIONS) { + if (!definition.promptKey) continue; + candidates.push(buildChatSkillPrompt(definition.promptKey, definition.skillName)); + } + candidates.push(buildWebNewsSkillPrompt('web')); + candidates.sort((a, b) => b.length - a.length); + for (const prompt of candidates) { + if (prompt && next.startsWith(prompt)) { + next = next.slice(prompt.length); + break; + } + } + return next.trim(); +} diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs index 103edb4..c33087d 100644 --- a/chat-skills.test.mjs +++ b/chat-skills.test.mjs @@ -57,3 +57,11 @@ test('buildAutoChatSkillPrefix enables web for news-like queries', () => { assert.equal(buildAutoChatSkillPrefix('帮我总结一下这段话', ['web', 'search']), ''); assert.equal(buildAutoChatSkillPrefix('帮我搜索今天热点新闻', ['search']), ''); }); + +test('buildAutoChatSkillPrefix enables publish for page generation requests', () => { + const prefix = buildAutoChatSkillPrefix('帮我做一个 Hello 糖的 H5 页面', ['static-page-publish']); + assert.match(prefix, /static-page-publish/); + assert.match(prefix, /write_file\/edit_file/); + assert.match(prefix, /禁止只给本地路径/); + assert.equal(buildAutoChatSkillPrefix('帮我做一个 Hello 糖的 H5 页面', []), ''); +}); diff --git a/conversation-display.mjs b/conversation-display.mjs new file mode 100644 index 0000000..5e0663a --- /dev/null +++ b/conversation-display.mjs @@ -0,0 +1,36 @@ +import { stripKnownChatSkillPrompt } from './chat-skills.mjs'; + +/** + * Strip agent-only prefixes from persisted user messages for UI display. + * Regression guard — see docs/regression-guards/mindspace-publish-and-chat-finish.md + */ + +export const TASK_ROUTING_HINT_RE = /^【TKMind 路由提示】[\s\S]*?\n\n/; +export const MINDSPACE_CONTEXT_RE = /^\[MindSpace 上下文\][\s\S]*?\n\n/; +const USER_IDENTITY_BLOCK_RE = /^\[用户身份\][\s\S]*?\n\n/; + +export function stripUserIdentityPrefix(text) { + return String(text ?? '').replace(USER_IDENTITY_BLOCK_RE, '').trimStart(); +} + +export function stripTaskRoutingHint(text) { + return String(text ?? '').replace(TASK_ROUTING_HINT_RE, '').trimStart(); +} + +export function stripMindSpaceContextPrefix(text) { + let next = String(text ?? ''); + while (MINDSPACE_CONTEXT_RE.test(next)) { + next = next.replace(MINDSPACE_CONTEXT_RE, '').trimStart(); + } + return next; +} + +/** Strip agent-only prefixes from persisted user message content for UI display. */ +export function deriveUserFacingText(text) { + let next = String(text ?? ''); + next = stripUserIdentityPrefix(next); + next = stripTaskRoutingHint(next); + next = stripMindSpaceContextPrefix(next); + next = stripKnownChatSkillPrompt(next); + return next.trim(); +} diff --git a/conversation-display.test.mjs b/conversation-display.test.mjs new file mode 100644 index 0000000..c07d924 --- /dev/null +++ b/conversation-display.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildAutoChatSkillPrefix, stripKnownChatSkillPrompt } from './chat-skills.mjs'; +import { deriveUserFacingText } from './conversation-display.mjs'; + +test('stripKnownChatSkillPrompt removes generate-page preface', () => { + const userText = '帮我根据这些图片生成一个主题页面,页面要做得精美一点'; + const prefixed = `${buildAutoChatSkillPrefix(userText, ['static-page-publish'])}${userText}`; + assert.match(prefixed, /static-page-publish/); + assert.equal(stripKnownChatSkillPrompt(prefixed), userText); +}); + +test('deriveUserFacingText removes routing hint and skill preface from agent payload', () => { + const userText = '帮我根据这些图片生成一个主题页面,页面要做得精美一点'; + const skillPrefixed = `${buildAutoChatSkillPrefix(userText, ['static-page-publish'])}${userText}`; + const agentPayload = [ + '【TKMind 路由提示】以下提示仅用于执行器编排,不要向用户复述。', + '当前代码任务建议优先委托给:aider。', + '原因:任务看起来更像局部补丁、小范围修复或较轻量的代码修改。', + '若首选执行器当前不可用或明显不适合,可回退到另一个已授权执行器,并在最终回复里简述原因。', + '', + skillPrefixed, + ].join('\n'); + assert.equal(deriveUserFacingText(agentPayload), userText); +}); diff --git a/docs/regression-guards/README.md b/docs/regression-guards/README.md new file mode 100644 index 0000000..2be27e2 --- /dev/null +++ b/docs/regression-guards/README.md @@ -0,0 +1,36 @@ +# 回归守卫索引 + +本目录记录**已验证、不可轻易回退**的行为。适用于 Codex、Cloud、Cursor 及任何人工作前的自检。 + +## 文档 + +| 文档 | 保护内容 | +|------|----------| +| [mindspace-publish-and-chat-finish.md](./mindspace-publish-and-chat-finish.md) | ① `edit_file` 覆盖 `public/*.html` ② Finish 后聊天不清空、不暴露 agent 内部前缀 | + +## 自动化 + +```bash +npm run verify:mindspace-publish-guards # 推荐:改相关代码后 +npm run verify:mindspace-publish-guards:full # 发 Portal runtime 后 +``` + +## 新增守卫时 + +1. 在本目录添加说明 md(症状 + 必须保留的行为 + checklist) +2. 添加/扩展 `*.test.mjs` 并接入 `npm test` +3. 在 `scripts/verify-*.mjs` 增加源码片段检查(如适用) +4. 更新根目录 [AGENTS.md](../../AGENTS.md) 索引 +5. (可选)在 `.cursor/rules/` 添加对应 `.mdc` 供 Cursor 自动提示 + +## 工具可见性 + +| 机制 | Cursor | Codex / Cloud / 其它 | +|------|--------|----------------------| +| 本目录 + `AGENTS.md` | ✅ | ✅(需读仓库文件) | +| `npm run verify:*` | ✅ | ✅ | +| `npm test` | ✅ | ✅ | +| 发版脚本门禁 | ✅ | ✅ | +| `.cursor/rules/*.mdc` | ✅ 自动 | ❌ 不自动 | + +**其它 AI 工具不会自动读 Cursor 规则**;请以 `AGENTS.md` 与本目录为准。 diff --git a/docs/regression-guards/mindspace-publish-and-chat-finish.md b/docs/regression-guards/mindspace-publish-and-chat-finish.md new file mode 100644 index 0000000..1bd3a36 --- /dev/null +++ b/docs/regression-guards/mindspace-publish-and-chat-finish.md @@ -0,0 +1,83 @@ +# MindSpace 发布与聊天 Finish 回归守卫 + +> **状态:已验证(2026-06-29)— 请勿在未更新测试/verify 的情况下删改下列行为。** + +本页记录两类好不容易调通的行为,以及如何用自动化手段防止后续「优化」误覆盖。 + +## 1. 公开 HTML:`edit_file` 必须落盘覆盖 + +### 症状 + +- Agent 用 `edit_file` 改已有 `public/*.html` 后,链接仍是旧内容 +- 不是缓存,是磁盘文件未被覆盖 + +### 必须保留 + +| 位置 | 行为 | +|------|------| +| `mindspace-public-finish-sync.mjs` | `edit_file` 读取 baseline + `old_str`/`new_str` patch,再 `writeFileSync` 覆盖 | +| `server.mjs` | Finish / SSE 流式事件调用 `materializePublicHtmlWritesFromSessionEvent` | + +### 守卫 + +- 单测:`mindspace-public-finish-sync.test.mjs` +- 源码 + runtime:`npm run verify:public-finish-sync-runtime` + +--- + +## 2. 聊天 Finish:禁止清空对话 / 禁止暴露内部前缀 + +### 症状 + +- 流式过程正常,**Finish 瞬间**聊天框刷新,只剩 1 条 user 消息 +- 该消息露出 `【TKMind 路由提示】`、`static-page-publish` skill 前缀等 **Agent 专用文案** +- 历史记录里仍有完整对话 → 说明是 **前端 Finish 同步** 问题,不是 Goose 丢数据 + +### 必须保留 + +| 位置 | 行为 | +|------|------| +| `src/hooks/useTKMindChat.ts` | `syncSessionMessages` 用 `mergeConversationSnapshot`,**禁止**盲覆盖;服务端条数不足时按 `FINISH_SYNC_RETRY_DELAYS_MS` 重试 | +| `server.mjs` | Session snapshot 缓存仅在 `hint_mc` **且** `hint_ua` 均提供且匹配时命中 | +| `conversation-display.mjs` + `src/utils/message.ts` | 用户消息无 `displayText` 时,用 `deriveUserFacingText` 剥掉 routing / skill / 用户身份前缀 | +| `chat-finish-sync.mjs` | 纯函数 merge 逻辑(被 TS 与单测共用) | + +### 守卫 + +- 单测:`chat-finish-sync.test.mjs`、`conversation-display.test.mjs` +- 源码静态检查:`npm run verify:chat-finish-sync` +- 文档:本文件 + +--- + +## 发版 / CI 必跑命令 + +```bash +# 专项回归(单测 + 源码守卫) +npm run verify:mindspace-publish-guards + +# 发版 Portal runtime 后(含 bundle 检查) +npm run verify:mindspace-publish-guards:full +``` + +`scripts/release-portal-runtime-prod.sh` 与 `scripts/release-prod.sh` 在未 `--skip-tests` 时会调用上述 verify。 + +--- + +## 修改这些区域时的 checklist + +- [ ] 是否仍 merge 本地与服务器消息(Finish / UpdateConversation)? +- [ ] 是否仍过滤/剥离用户消息中的 agent-only 前缀? +- [ ] `edit_file` 是否仍会 materialize 到 `MindSpace//public/`? +- [ ] 是否已跑 `npm run verify:mindspace-publish-guards`? +- [ ] 是否更新了本页或相关单测? + +--- + +## 相关 Cursor 规则 + +`.cursor/rules/mindspace-publish-chat-finish-guards.mdc` — Cursor 在编辑相关文件时自动提示上述约束。 + +## 其它 AI 工具(Codex / Cloud 等) + +**不会**自动读取 `.cursor/rules/`。请改为阅读仓库根目录 **[AGENTS.md](../../AGENTS.md)** 与本目录文档;verify 与单测对所有工具同样有效。 diff --git a/mindspace-public-finish-sync.mjs b/mindspace-public-finish-sync.mjs index 56be954..2f5a0b6 100644 --- a/mindspace-public-finish-sync.mjs +++ b/mindspace-public-finish-sync.mjs @@ -3,6 +3,12 @@ import path from 'node:path'; import { extractStaticPageLinks } from './mindspace-chat-save.mjs'; +/** + * Public HTML finish-sync invariants (regression guard — do not simplify away). + * edit_file patches must materialize onto MindSpace//public/*.html. + * See docs/regression-guards/mindspace-publish-and-chat-finish.md + */ + const PUBLIC_HTML_PATH_PATTERN = /(?:^|[^a-z0-9_./-])(public\/[a-z0-9][a-z0-9._/-]{0,255}\.html)\b/i; const PUBLIC_DOCX_HREF_PATTERN = /href=["']([^"'#?\s]+\.docx)["']/gi; @@ -119,7 +125,7 @@ function isWriteLikeHtmlTool(name, args) { const action = String(args?.action ?? '').toLowerCase(); const normalizedName = String(name ?? '').trim(); const writeLikeDeveloper = - (normalizedName === 'developer' && action === 'write') || + (normalizedName === 'developer' && (action === 'write' || action === 'edit')) || normalizedName === 'write' || normalizedName.endsWith('__write'); const writeLikeSandbox = @@ -131,7 +137,37 @@ function isWriteLikeHtmlTool(name, args) { return (writeLikeDeveloper && typeof args?.path === 'string') || writeLikeSandbox; } -export function extractPublicHtmlWriteArtifacts(messages = []) { +function isEditLikeHtmlTool(name, args) { + const normalizedName = String(name ?? '').trim(); + return ( + (normalizedName === 'edit_file' || + normalizedName.endsWith('__edit_file') || + (normalizedName === 'developer' && String(args?.action ?? '').toLowerCase() === 'edit')) && + typeof args?.path === 'string' + ); +} + +function readPublicHtmlBaseline(publishDir, relativePath) { + const root = path.resolve(String(publishDir ?? '')); + if (!root) return ''; + const destination = path.resolve(root, relativePath); + if (destination !== root && !destination.startsWith(`${root}${path.sep}`)) return ''; + if (!fs.existsSync(destination) || !fs.statSync(destination).isFile()) return ''; + try { + return fs.readFileSync(destination, 'utf8'); + } catch { + return ''; + } +} + +function applyPublicHtmlEdit(existing, args) { + if (typeof args?.new_str !== 'string') return null; + const oldStr = typeof args.old_str === 'string' ? args.old_str : ''; + if (oldStr && !existing.includes(oldStr)) return null; + return oldStr ? existing.replace(oldStr, args.new_str) : args.new_str; +} + +export function extractPublicHtmlWriteArtifacts(messages = [], { publishDir = null } = {}) { const artifacts = new Map(); for (const message of messages) { for (const item of message?.content ?? []) { @@ -143,12 +179,17 @@ export function extractPublicHtmlWriteArtifacts(messages = []) { if (!candidate.toLowerCase().endsWith('.html')) continue; const relativePath = normalizePublicHtmlRelativePath(candidate); if (!relativePath) continue; - const content = - typeof args.content === 'string' - ? args.content - : typeof args.new_str === 'string' && !args.old_str - ? args.new_str - : null; + let content = null; + if (typeof args.content === 'string') { + content = args.content; + } else if (isEditLikeHtmlTool(toolCall?.name, args)) { + const baseline = + artifacts.get(relativePath)?.content ?? + readPublicHtmlBaseline(publishDir, relativePath); + content = applyPublicHtmlEdit(baseline, args); + } else if (typeof args.new_str === 'string' && !args.old_str) { + content = args.new_str; + } if (content == null) continue; artifacts.set(relativePath, { relativePath, content }); } @@ -180,7 +221,7 @@ export function materializeMissingPublicHtmlWrites({ messages, publishDir }) { const materialized = []; const skipped = []; - for (const artifact of extractPublicHtmlWriteArtifacts(messages)) { + for (const artifact of extractPublicHtmlWriteArtifacts(messages, { publishDir: root })) { const destination = path.resolve(root, artifact.relativePath); if (destination !== root && !destination.startsWith(`${root}${path.sep}`)) { skipped.push(artifact.relativePath); @@ -203,11 +244,53 @@ export function materializeMissingPublicHtmlWrites({ messages, publishDir }) { return { materialized, skipped }; } -export function hasRecentOwnPublicHtmlReference(messages, currentUser, { recentCount = 80 } = {}) { +export function materializePublicHtmlWritesFromSessionEvent( + event, + { publishDir, recentCount = 20 } = {}, +) { + if (!event || !publishDir) { + return { materialized: [], skipped: [] }; + } + if (event.type === 'Message' && event.message) { + return materializeMissingPublicHtmlWrites({ + messages: [event.message], + publishDir, + }); + } + if (event.type === 'UpdateConversation' && Array.isArray(event.conversation)) { + return materializeMissingPublicHtmlWrites({ + messages: event.conversation.slice(-Math.max(1, recentCount)), + publishDir, + }); + } + return { materialized: [], skipped: [] }; +} + +function messageHasPublicHtmlToolRequest(message, { publishDir = null } = {}) { + if (extractPublicHtmlWriteArtifacts([message], { publishDir }).length > 0) { + return true; + } + for (const item of message?.content ?? []) { + if (item?.type !== 'toolRequest') continue; + const toolCall = item.toolCall?.value; + const args = toolCall?.arguments ?? {}; + if (!isWriteLikeHtmlTool(toolCall?.name, args)) continue; + const candidate = String(args.path ?? '').trim(); + if (!candidate.toLowerCase().endsWith('.html')) continue; + if (normalizePublicHtmlRelativePath(candidate)) return true; + } + return false; +} + +export function hasRecentOwnPublicHtmlReference( + messages, + currentUser, + { recentCount = 80, publishDir = null } = {}, +) { if (!Array.isArray(messages) || messages.length === 0 || !currentUser?.id) return false; const recentMessages = messages.slice(-Math.max(1, recentCount)); for (const message of recentMessages) { - if (extractPublicHtmlWriteArtifacts([message]).length > 0) { + if (messageHasPublicHtmlToolRequest(message, { publishDir })) { return true; } const text = messageText(message); @@ -233,7 +316,7 @@ export async function syncPublicHtmlAfterFinish({ publishDir, syncWorkspaceAssets, } = {}) { - if (!hasRecentOwnPublicHtmlReference(messages, currentUser)) { + if (!hasRecentOwnPublicHtmlReference(messages, currentUser, { publishDir })) { return { materialized: [], skipped: [], synced: false }; } diff --git a/mindspace-public-finish-sync.test.mjs b/mindspace-public-finish-sync.test.mjs index e9aa3dd..27b1342 100644 --- a/mindspace-public-finish-sync.test.mjs +++ b/mindspace-public-finish-sync.test.mjs @@ -4,7 +4,13 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { hasRecentOwnPublicHtmlReference, isStubPublicHtmlContent, materializeMissingPublicHtmlWrites, syncPublicDocxDownloads } from './mindspace-public-finish-sync.mjs'; +import { + hasRecentOwnPublicHtmlReference, + isStubPublicHtmlContent, + materializeMissingPublicHtmlWrites, + materializePublicHtmlWritesFromSessionEvent, + syncPublicDocxDownloads, +} from './mindspace-public-finish-sync.mjs'; const CURRENT_USER = { id: 'a6fb1e97-2b0f-447b-b138-4561d8e5c53e', username: 'john' }; @@ -324,3 +330,161 @@ test('materializeMissingPublicHtmlWrites skips existing real html when content i fs.rmSync(publishDir, { recursive: true, force: true }); } }); + +test('detects a recent edit_file tool request on an existing public html page', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-')); + try { + const htmlPath = path.join(publishDir, 'public/guide.html'); + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + fs.writeFileSync(htmlPath, '一二三', 'utf8'); + const messages = [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'edit_file', + arguments: { + path: 'public/guide.html', + old_str: '一二三', + new_str: '二三四', + }, + }, + }, + }, + ], + metadata: { userVisible: true }, + }, + ]; + assert.equal(hasRecentOwnPublicHtmlReference(messages, CURRENT_USER, { publishDir }), true); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + +test('materializeMissingPublicHtmlWrites applies edit_file patches onto existing public html', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-')); + try { + const htmlPath = path.join(publishDir, 'public/guide.html'); + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + fs.writeFileSync(htmlPath, 'Existing一二三', 'utf8'); + const result = materializeMissingPublicHtmlWrites({ + messages: [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'edit_file', + arguments: { + path: 'public/guide.html', + old_str: '一二三', + new_str: '二三四', + }, + }, + }, + }, + ], + }, + ], + publishDir, + }); + assert.deepEqual(result.materialized, ['public/guide.html']); + assert.match(fs.readFileSync(htmlPath, 'utf8'), /二三四/); + assert.doesNotMatch(fs.readFileSync(htmlPath, 'utf8'), /一二三/); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + +test('materializeMissingPublicHtmlWrites chains multiple edit_file patches for the same html', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-')); + try { + const htmlPath = path.join(publishDir, 'public/guide.html'); + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + fs.writeFileSync(htmlPath, '一 · 二三', 'utf8'); + const result = materializeMissingPublicHtmlWrites({ + messages: [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'edit_file', + arguments: { + path: 'public/guide.html', + old_str: '一', + new_str: '二', + }, + }, + }, + }, + { + type: 'toolRequest', + toolCall: { + value: { + name: 'edit_file', + arguments: { + path: 'public/guide.html', + old_str: '二三', + new_str: '三四', + }, + }, + }, + }, + ], + }, + ], + publishDir, + }); + assert.deepEqual(result.materialized, ['public/guide.html']); + assert.match(fs.readFileSync(htmlPath, 'utf8'), /二 · 三四/); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + +test('materializePublicHtmlWritesFromSessionEvent writes public html from message event before finish', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-event-sync-')); + try { + const result = materializePublicHtmlWritesFromSessionEvent( + { + type: 'Message', + message: { + id: 'assistant-1', + role: 'assistant', + metadata: { userVisible: true }, + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'write', + arguments: { + path: 'public/shenwu-gate.html', + content: '神口门', + }, + }, + }, + }, + ], + }, + }, + { publishDir }, + ); + + assert.deepEqual(result.materialized, ['public/shenwu-gate.html']); + assert.match( + fs.readFileSync(path.join(publishDir, 'public/shenwu-gate.html'), 'utf8'), + /神口门/, + ); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); diff --git a/package.json b/package.json index 8a7c33a..d069c2e 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,11 @@ "enrich:plaza": "node scripts/enrich-plaza-posts.mjs", "build": "vite build", "build:portal-runtime": "node scripts/build-portal-runtime.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 user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.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 message-stream.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", + "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-finish-sync.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 mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.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 message-stream.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", + "verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs", "verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs", + "verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs", + "verify:mindspace-publish-guards:full": "node scripts/verify-mindspace-publish-guards.mjs --with-runtime", "test:mindspace-e2e": "node scripts/mindspace-e2e.mjs", "test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs", "test:mindspace-publications-e2e": "node scripts/mindspace-publications-e2e.mjs", diff --git a/policies.mjs b/policies.mjs index e7fb538..556cb12 100644 --- a/policies.mjs +++ b/policies.mjs @@ -207,6 +207,7 @@ const USER_API_ALLOWLIST = [ { method: 'POST', pattern: /^\/agent\/resume$/ }, { method: 'POST', pattern: /^\/agent\/runs$/ }, { method: 'GET', pattern: /^\/agent\/runs\/[^/]+$/ }, + { method: 'GET', pattern: /^\/agent\/runs\/[^/]+\/events$/ }, { method: 'GET', pattern: /^\/sessions$/ }, { method: 'GET', pattern: /^\/sessions\/[^/]+$/ }, { method: 'DELETE', pattern: /^\/sessions\/[^/]+$/ }, diff --git a/policies.test.mjs b/policies.test.mjs index 61c8b0c..34bef1d 100644 --- a/policies.test.mjs +++ b/policies.test.mjs @@ -70,6 +70,11 @@ test('api lockdown blocks extension admin routes', () => { }); assert.equal(agentRunGet.allowed, true); + const agentRunEvents = evaluateProxyRequest('GET', '/agent/runs/run-1/events', { + api_lockdown: true, + }); + assert.equal(agentRunEvents.allowed, true); + const deleteAllowed = evaluateProxyRequest('DELETE', '/sessions/abc', { api_lockdown: true, }); diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index 364e587..f4c229b 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -121,7 +121,7 @@ if [[ "${SKIP_TESTS}" -ne 1 ]]; then ( cd "${ROOT}" npm test -- --test-name-pattern='publish|space|billing' >/dev/null - node --test mindspace-public-finish-sync.test.mjs >/dev/null + npm run verify:mindspace-publish-guards >/dev/null ) fi @@ -154,10 +154,10 @@ verify_runtime_artifact() { verify_runtime_artifact -say "验证公开 HTML 完成同步守卫" +say "验证 MindSpace 发布与聊天 Finish 回归守卫" ( cd "${ROOT}" - npm run verify:public-finish-sync-runtime + npm run verify:mindspace-publish-guards:full ) verify_remote_goosed_dependency() { diff --git a/scripts/release-prod.sh b/scripts/release-prod.sh index 9dff1eb..6f023cc 100755 --- a/scripts/release-prod.sh +++ b/scripts/release-prod.sh @@ -116,6 +116,7 @@ if [[ "${SKIP_TESTS}" -ne 1 ]]; then ( cd "${ROOT}" npm test -- --test-name-pattern='publish|space|billing' >/dev/null + npm run verify:mindspace-publish-guards >/dev/null ) fi diff --git a/scripts/verify-chat-finish-sync.mjs b/scripts/verify-chat-finish-sync.mjs new file mode 100644 index 0000000..bbb6015 --- /dev/null +++ b/scripts/verify-chat-finish-sync.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(new URL('..', import.meta.url).pathname); + +function read(relativePath) { + const absolutePath = path.join(root, relativePath); + assert.ok(fs.existsSync(absolutePath), `missing guarded file: ${relativePath}`); + return fs.readFileSync(absolutePath, 'utf8'); +} + +function assertIncludes(source, snippet, label) { + assert.ok(source.includes(snippet), `${label} must include guarded snippet: ${snippet}`); +} + +function assertExcludes(source, snippet, label) { + assert.ok(!source.includes(snippet), `${label} must not regress to: ${snippet}`); +} + +const useTKMindChat = read('src/hooks/useTKMindChat.ts'); +assertIncludes(useTKMindChat, 'FINISH_SYNC_RETRY_DELAYS_MS', 'useTKMindChat.ts'); +assertIncludes( + useTKMindChat, + 'mergeConversationSnapshot(localMessages, detail.messages)', + 'useTKMindChat.ts', +); +assertIncludes(useTKMindChat, 'chat-finish-sync.mjs', 'useTKMindChat.ts'); +assertExcludes( + useTKMindChat, + 'messagesRef.current = detail.messages;\n messageHistoryLoadedCountRef.current = detail.messages.length', + 'useTKMindChat.ts Finish sync', +); + +const messageTs = read('src/utils/message.ts'); +assertIncludes(messageTs, 'deriveUserFacingText', 'message.ts'); +assertIncludes(messageTs, 'chat-finish-sync.mjs', 'message.ts'); + +const conversationDisplay = read('conversation-display.mjs'); +assertIncludes(conversationDisplay, 'TASK_ROUTING_HINT_RE', 'conversation-display.mjs'); +assertIncludes(conversationDisplay, 'deriveUserFacingText', 'conversation-display.mjs'); + +const chatSkills = read('chat-skills.mjs'); +assertIncludes(chatSkills, 'stripKnownChatSkillPrompt', 'chat-skills.mjs'); + +const server = read('server.mjs'); +assertIncludes(server, 'canUseSnapshotCache', 'server.mjs'); +assertIncludes(server, 'hintMc != null && hintUa != null', 'server.mjs'); + +const publicFinishSync = read('mindspace-public-finish-sync.mjs'); +assertIncludes(publicFinishSync, 'applyPublicHtmlEdit', 'mindspace-public-finish-sync.mjs'); +assertIncludes(publicFinishSync, 'isEditLikeHtmlTool', 'mindspace-public-finish-sync.mjs'); + +console.log('chat finish sync source guards ok'); diff --git a/scripts/verify-mindspace-publish-guards.mjs b/scripts/verify-mindspace-publish-guards.mjs new file mode 100644 index 0000000..525887b --- /dev/null +++ b/scripts/verify-mindspace-publish-guards.mjs @@ -0,0 +1,35 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; + +const root = path.resolve(new URL('..', import.meta.url).pathname); +const withRuntime = + process.argv.includes('--with-runtime') || process.env.MINDSPACE_VERIFY_RUNTIME === '1'; + +function run(label, args) { + const result = spawnSync(process.execPath, args, { + cwd: root, + stdio: 'inherit', + }); + if (result.status !== 0) { + throw new Error(`${label} failed with exit code ${result.status ?? 'unknown'}`); + } +} + +run('MindSpace publish + chat finish unit tests', [ + '--test', + 'mindspace-public-finish-sync.test.mjs', + 'conversation-display.test.mjs', + 'chat-finish-sync.test.mjs', +]); + +run('chat finish sync source guards', ['scripts/verify-chat-finish-sync.mjs']); + +if (withRuntime) { + run('public finish sync runtime guard', ['scripts/verify-public-finish-sync-runtime.mjs']); +} + +console.log( + withRuntime + ? 'mindspace publish + chat finish regression guards ok (with runtime)' + : 'mindspace publish + chat finish regression guards ok', +); diff --git a/scripts/verify-public-finish-sync-runtime.mjs b/scripts/verify-public-finish-sync-runtime.mjs index 97b9ce5..6d842cb 100644 --- a/scripts/verify-public-finish-sync-runtime.mjs +++ b/scripts/verify-public-finish-sync-runtime.mjs @@ -65,8 +65,10 @@ const runtimeSource = fs.readFileSync(runtimeServer, 'utf8'); const requiredRuntimeSnippets = [ 'normalizedName === "write"', 'normalizedName.endsWith("__write")', + 'normalizedName === "edit_file"', + 'applyPublicHtmlEdit', 'recentCount = 80', - 'if (extractPublicHtmlWriteArtifacts([message]).length > 0) {', + 'if (extractPublicHtmlWriteArtifacts([message], { publishDir }).length > 0) {', ]; for (const snippet of requiredRuntimeSnippets) { diff --git a/server.mjs b/server.mjs index 8b47dc8..54e3b19 100644 --- a/server.mjs +++ b/server.mjs @@ -14,6 +14,11 @@ import { } from './auth.mjs'; import { createDbPool, initSchema, isDatabaseConfigured } from './db.mjs'; import { createAgentRunGateway } from './agent-run-gateway.mjs'; +import { + createAgentRunEventsHandler, + createGetAgentRunHandler, + createPostAgentRunsHandler, +} from './agent-run-routes.mjs'; import { createTkmindProxy, sanitizeSessionConversationPublicHtmlLinks } from './tkmind-proxy.mjs'; import { clearUserSessionCookie, @@ -78,7 +83,10 @@ import { resolveChatSaveAnalysis, resolveStaticHtmlContent, } from './mindspace-chat-save.mjs'; -import { syncPublicHtmlAfterFinish } from './mindspace-public-finish-sync.mjs'; +import { + materializePublicHtmlWritesFromSessionEvent, + syncPublicHtmlAfterFinish, +} from './mindspace-public-finish-sync.mjs'; import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs'; import { generateHtmlThumbnail } from './mindspace-thumbnails.mjs'; import { injectOgTags, injectWechatShareBridge } from './mindspace-og-tags.mjs'; @@ -3903,38 +3911,7 @@ api.post('/agent/runs', async (req, res, next) => { [ tkmindProxy.requireUser, tkmindProxy.ensureChatAllowed, - async (request, response) => { - try { - const sessionId = String(request.body?.session_id ?? '').trim() || null; - const requestId = String(request.body?.request_id ?? '').trim(); - const userMessage = request.body?.user_message ?? null; - if (!requestId) { - response.status(400).json({ message: '缺少 request_id' }); - return; - } - if (!userMessage) { - response.status(400).json({ message: '缺少 user_message' }); - return; - } - if (sessionId) { - const owns = await userAuth.ownsSession(request.currentUser.id, sessionId); - if (!owns) { - response.status(403).json({ message: '无权访问该会话' }); - return; - } - } - const run = await agentRunGateway.createRun(request.currentUser.id, { - sessionId, - requestId, - userMessage, - }); - response.status(202).json({ run }); - } catch (err) { - response.status(500).json({ - message: err instanceof Error ? err.message : '创建任务失败', - }); - } - }, + createPostAgentRunsHandler({ userAuth, agentRunGateway }), ], req, res, @@ -3948,26 +3925,21 @@ api.get('/agent/runs/:runId', async (req, res, next) => { return runHandlerChain( [ tkmindProxy.requireUser, - async (request, response) => { - try { - const run = await agentRunGateway.getRunForUser( - request.currentUser.id, - request.params.runId, - ); - if (!run) { - response.status(404).json({ message: '任务不存在' }); - return; - } - if (run.status !== 'succeeded' && run.status !== 'failed') { - agentRunGateway.dispatchRun(run.id); - } - response.json({ run }); - } catch (err) { - response.status(500).json({ - message: err instanceof Error ? err.message : '读取任务失败', - }); - } - }, + createGetAgentRunHandler({ agentRunGateway }), + ], + req, + res, + next, + ); +}); + +api.get('/agent/runs/:runId/events', async (req, res, next) => { + await userAuthReady; + if (!userAuth || !tkmindProxy || !agentRunGateway) return next(); + return runHandlerChain( + [ + tkmindProxy.requireUser, + createAgentRunEventsHandler({ agentRunGateway }), ], req, res, @@ -4004,9 +3976,11 @@ api.get('/sessions/:sessionId', async (req, res, next) => { if (sessionSnapshotService?.isEnabled()) { const snapshot = await sessionSnapshotService.get(sessionId); if (snapshot) { - const mcMatch = hintMc == null || snapshot.meta.synced_msg_count === hintMc; - const uaMatch = hintUa == null || snapshot.meta.source_updated_at === hintUa; - if (mcMatch && uaMatch) { + // REGRESSION GUARD: without both hints, stale snapshot can wipe mid-turn chat. + const canUseSnapshotCache = hintMc != null && hintUa != null; + const mcMatch = snapshot.meta.synced_msg_count === hintMc; + const uaMatch = snapshot.meta.source_updated_at === hintUa; + if (canUseSnapshotCache && mcMatch && uaMatch) { const sanitizedMessages = sanitizeSessionConversationPublicHtmlLinks( snapshot.messages, req.currentUser, @@ -4094,6 +4068,10 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => { if (!owns) { return res.status(403).json({ message: '无权访问该会话' }); } + const publishDir = resolvePublishDir(__dirname, { id: req.currentUser.id }); + const syncPublicHtmlDuringStream = (event) => { + materializePublicHtmlWritesFromSessionEvent(event, { publishDir }); + }; // After Finish, refresh the snapshot and persist any newly generated public // workspace HTML into the asset store before a later restart rebuilds the // workspace from DB-backed assets only. @@ -4122,7 +4100,7 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => { await syncPublicHtmlAfterFinish({ messages, currentUser: req.currentUser, - publishDir: resolvePublishDir(__dirname, { id: uid }), + publishDir, syncWorkspaceAssets: WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets ? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options) @@ -4130,7 +4108,10 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => { }); await syncUserGeneratedPages(uid); }; - return tkmindProxy.proxySessionEvents(req, res, sessionId, { onAfterFinish }); + return tkmindProxy.proxySessionEvents(req, res, sessionId, { + onAfterFinish, + onEvent: syncPublicHtmlDuringStream, + }); }); api.use(async (req, res, next) => { @@ -4141,6 +4122,13 @@ api.use(async (req, res, next) => { return sendError(res, req, 404, 'not_found', `接口不存在:${req.method} ${req.path}`); } + if (req.method === 'GET' && /^\/agent\/runs\/[^/]+\/events$/.test(req.path)) { + return res.status(503).json({ + message: 'Agent Run SSE 接口需在 Portal 本地处理,请确认 server.mjs 已更新并重启后端', + code: 'AGENT_RUNS_NATIVE_REQUIRED', + }); + } + const sessionMatch = req.path.match(/^\/sessions\/([^/]+)/); if (sessionMatch) { const sessionId = sessionMatch[1]; diff --git a/session-snapshot.mjs b/session-snapshot.mjs index b2de37e..10cd719 100644 --- a/session-snapshot.mjs +++ b/session-snapshot.mjs @@ -11,6 +11,8 @@ * conversation content, so it can be dropped/truncated safely at any time. */ +import { deriveUserFacingText } from './conversation-display.mjs'; + const DERIVED_TITLE_MAX_LEN = 40; function isGeneratedSessionName(name) { @@ -29,6 +31,9 @@ function messageSnippet(message) { .map((item) => item.text) .join('') .trim(); + if (message?.role === 'user') { + return deriveUserFacingText(fromContent); + } if (fromContent) return fromContent; const displayText = message?.metadata?.displayText; return typeof displayText === 'string' ? displayText.trim() : ''; diff --git a/src/api/client.ts b/src/api/client.ts index d0d2459..e6c2ead 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2217,6 +2217,79 @@ type SubscribeOptions = { onBalance?: (update: BalanceUpdate) => void; }; +export function subscribeAgentRunEvents( + runId: string, + onRun: (run: AgentRun) => void, + onError: (error: Error) => void, +): () => void { + let closed = false; + let activeController: AbortController | null = null; + + const run = async (controller: AbortController) => { + while (!closed && !controller.signal.aborted) { + try { + const res = await fetch(`${API}${AGENT_RUNS_PATH}/${encodeURIComponent(runId)}/events`, { + headers: { Accept: 'text/event-stream' }, + credentials: 'same-origin', + signal: controller.signal, + }); + + if (res.status === 401) { + notifyUnauthorized(); + throw new ApiError(401, 'SSE 未授权'); + } + + if (!res.ok || !res.body) { + throw new ApiError(res.status, `SSE failed: ${res.status}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (!closed && !controller.signal.aborted) { + const { done, value } = await reader.read(); + if (done) return; + buffer += decoder.decode(value, { stream: true }); + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + let eventName = 'message'; + let data = ''; + for (const line of chunk.split('\n')) { + if (line.startsWith('event:')) eventName = line.slice(6).trim(); + if (line.startsWith('data:')) data = line.slice(5).trim(); + } + if (!data) continue; + if (eventName === 'run') { + const payload = JSON.parse(data) as { run: AgentRun }; + onRun(payload.run); + continue; + } + if (eventName === 'error') { + const payload = JSON.parse(data) as { message?: string }; + throw new Error(payload.message || '后台任务失败'); + } + } + } + } catch (err) { + if (controller.signal.aborted || closed) return; + onError(err instanceof Error ? err : new Error(String(err))); + return; + } + } + }; + + activeController = new AbortController(); + void run(activeController); + + return () => { + closed = true; + activeController?.abort(); + }; +} + export function subscribeSessionEvents( sessionId: string, onEvent: (event: SessionEvent) => void, diff --git a/src/components/MindSpaceView.tsx b/src/components/MindSpaceView.tsx index 04fb5dc..e112384 100644 --- a/src/components/MindSpaceView.tsx +++ b/src/components/MindSpaceView.tsx @@ -950,7 +950,6 @@ export function MindSpaceView({ prevChatStateRef.current = chatState; const wasBusy = previous === 'streaming' || previous === 'waiting'; if (!wasBusy || chatState !== 'idle' || !selectedPageId || previewMode || pageFullscreenPreviewOpen) return; - setPageRefreshTrigger((value) => value + 1); void refreshPagesSilently(); }, [chatState, pageFullscreenPreviewOpen, previewMode, selectedPageId]); diff --git a/src/hooks/usePageEditSubChat.ts b/src/hooks/usePageEditSubChat.ts index 06973e0..b7978b8 100644 --- a/src/hooks/usePageEditSubChat.ts +++ b/src/hooks/usePageEditSubChat.ts @@ -6,10 +6,10 @@ import { confirmTool, createAgentRun, forkMindSpacePageEditSession, - getAgentRun, getMindSpace, loadSessionDetail, resumeSession, + subscribeAgentRunEvents, uploadMindSpaceAsset, subscribeSessionEvents, } from '../api/client'; @@ -28,19 +28,27 @@ import { pushMessage, } from '../utils/message'; -const AGENT_RUN_POLL_DELAY_MS = 1200; -const AGENT_RUN_MAX_POLLS = 100; - async function waitForAgentRun(runId: string) { - for (let attempt = 0; attempt < AGENT_RUN_MAX_POLLS; attempt += 1) { - const run = await getAgentRun(runId); - if (run.status === 'succeeded') return run; - if (run.status === 'failed') { - throw new Error(run.error || '后台任务失败,请稍后重试'); - } - await new Promise((resolve) => window.setTimeout(resolve, AGENT_RUN_POLL_DELAY_MS)); - } - throw new Error('后台任务仍在排队,请稍后刷新会话查看结果'); + return await new Promise((resolve, reject) => { + const unsubscribe = subscribeAgentRunEvents( + runId, + (run) => { + if (run.status === 'succeeded') { + unsubscribe(); + resolve(run); + return; + } + if (run.status === 'failed') { + unsubscribe(); + reject(new Error(run.error || '后台任务失败,请稍后重试')); + } + }, + (error) => { + unsubscribe(); + reject(error); + }, + ); + }); } function buildPageEditSummary(messages: Message[], pageTitle: string): string { diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index 0583fe8..d680d98 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -7,7 +7,6 @@ import { confirmTool, createAgentRun, deleteChatSession, - getAgentRun, getMindSpace, getMe, listNotifications, @@ -19,6 +18,7 @@ import { rememberProjectContext, uploadMindSpaceAsset, resumeSession, + subscribeAgentRunEvents, subscribeNotificationEvents, subscribeSessionEvents, syncUserMemory, @@ -45,13 +45,13 @@ import type { import { buildContextPrefix } from '../utils/mindspaceChatContext'; import { buildUserAddressPrefix } from '../utils/userAddress'; import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs'; +import { mergeConversationSnapshot } from '../../chat-finish-sync.mjs'; import { CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, } from '../utils/imageUpload'; import { buildAbsoluteAssetImageUrl } from '../utils/mindspaceCards'; import { buildUserMessage, - mergeConversationSnapshot, normalizeConversationMessages, getDisplayText, getToolConfirmation, @@ -69,21 +69,31 @@ import { const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用'; const REPLY_RECOVERY_SYNC_DELAYS_MS = [1500, 5000, 12000]; -const AGENT_RUN_POLL_DELAY_MS = 1200; -const AGENT_RUN_MAX_POLLS = 100; - +const FINISH_SYNC_RETRY_DELAYS_MS = [500, 1500, 3000]; +const ACTIVE_REQUEST_MISSING_GRACE_MS = 2500; export { INSUFFICIENT_BALANCE_NOTICE }; async function waitForAgentRun(runId: string) { - for (let attempt = 0; attempt < AGENT_RUN_MAX_POLLS; attempt += 1) { - const run = await getAgentRun(runId); - if (run.status === 'succeeded') return run; - if (run.status === 'failed') { - throw new Error(run.error || '后台任务失败,请稍后重试'); - } - await new Promise((resolve) => window.setTimeout(resolve, AGENT_RUN_POLL_DELAY_MS)); - } - throw new Error('后台任务仍在排队,请稍后刷新会话查看结果'); + return await new Promise((resolve, reject) => { + const unsubscribe = subscribeAgentRunEvents( + runId, + (run) => { + if (run.status === 'succeeded') { + unsubscribe(); + resolve(run); + return; + } + if (run.status === 'failed') { + unsubscribe(); + reject(new Error(run.error || '后台任务失败,请稍后重试')); + } + }, + (error) => { + unsubscribe(); + reject(error); + }, + ); + }); } function mergeMessagePages(older: Message[], current: Message[]): Message[] { @@ -137,6 +147,7 @@ export function useTKMindChat( const seenNotificationIdsRef = useRef>(new Set()); const activeRequestId = useRef(null); + const activeRequestMissingTimerRef = useRef | null>(null); const unsubscribeRef = useRef<(() => void) | null>(null); const connectTokenRef = useRef(0); const messagesRef = useRef([]); @@ -154,6 +165,12 @@ export function useTKMindChat( const onUserUpdateRef = useRef(onUserUpdate); const chatImageCategoryIdRef = useRef(null); + const clearActiveRequestMissingTimer = useCallback(() => { + if (!activeRequestMissingTimerRef.current) return; + window.clearTimeout(activeRequestMissingTimerRef.current); + activeRequestMissingTimerRef.current = null; + }, []); + const dismissNotice = useCallback(() => { const currentNotification = activeNotification; setNotice(null); @@ -519,24 +536,50 @@ export function useTKMindChat( }, [resolveChatImageUploadCategoryId]); const syncSessionMessages = useCallback(async (sessionId: string) => { + // REGRESSION GUARD: Finish may arrive before Goose persists assistant turns. + // Never blind-replace with server snapshot — merge + retry (see chat-finish-sync.mjs). try { + const localMessages = messagesRef.current; const currentLoadedCount = Math.max( messageHistoryLoadedCountRef.current, - messagesRef.current.length, + localMessages.length, appConfig.sessionMessagePageSize, ); - const detail = await loadSessionDetail(sessionId, undefined, { - before: 0, - limit: currentLoadedCount, - }); + const knownSession = + sessionRef.current?.id === sessionId + ? sessionRef.current + : sessionsRef.current.find((item) => item.id === sessionId); + const hints = { + messageCount: Math.max(knownSession?.message_count ?? 0, localMessages.length), + updatedAt: knownSession?.updated_at, + }; + const historyQuery = { before: 0, limit: currentLoadedCount }; + + const fetchDetail = () => + loadSessionDetail(sessionId, hints, historyQuery); + + let detail = await fetchDetail(); + let nextMessages = mergeConversationSnapshot(localMessages, detail.messages); + + for (const delay of FINISH_SYNC_RETRY_DELAYS_MS) { + if (detail.messages.length >= localMessages.length) break; + await new Promise((resolve) => window.setTimeout(resolve, delay)); + if (sessionRef.current?.id !== sessionId) return; + detail = await fetchDetail(); + nextMessages = mergeConversationSnapshot(localMessages, detail.messages); + } + setSessions((prev) => prependUnique(prev, toSessionSummary(detail.session))); if (sessionRef.current?.id !== sessionId) return; - messagesRef.current = detail.messages; - messageHistoryLoadedCountRef.current = detail.messages.length; - messageHistoryTotalRef.current = Math.max(Number(detail.page.total ?? detail.messages.length), detail.messages.length); - setMessageHistoryHasMore(detail.messages.length < messageHistoryTotalRef.current); + messagesRef.current = nextMessages; + messageHistoryLoadedCountRef.current = nextMessages.length; + messageHistoryTotalRef.current = Math.max( + Number(detail.page.total ?? nextMessages.length), + nextMessages.length, + ); + setMessageHistoryHasMore(nextMessages.length < messageHistoryTotalRef.current); setMessageHistoryTotal(messageHistoryTotalRef.current); - setMessages(detail.messages); + setMessages(nextMessages); setSession((current) => (current?.id === sessionId ? detail.session : current)); } catch (err) { console.warn('Session final sync failed:', err); @@ -550,6 +593,7 @@ export function useTKMindChat( const raw = event as SessionEvent & { chat_request_id?: string; request_id?: string }; const eventRequestId = raw.chat_request_id ?? raw.request_id; if (eventRequestId && eventRequestId !== requestId) return; + clearActiveRequestMissingTimer(); switch (event.type) { case 'Message': { @@ -674,7 +718,7 @@ export function useTKMindChat( return; } }, - [rememberRecentContext, syncSessionMessages], + [clearActiveRequestMissingTimer, rememberRecentContext, syncSessionMessages], ); const subscribeToSession = useCallback( @@ -687,15 +731,25 @@ export function useTKMindChat( if (event.type === 'ActiveRequests') { if (!rid && event.request_ids.length > 0) { // SSE reconnected while agent was running — adopt the active request. + clearActiveRequestMissingTimer(); activeRequestId.current = event.request_ids[0]; setChatState('streaming'); + } else if (rid && event.request_ids.includes(rid)) { + clearActiveRequestMissingTimer(); } else if (rid && !event.request_ids.includes(rid)) { - // Our request is no longer active — agent finished while SSE was paused. - // Sync the final state from the server. - activeRequestId.current = null; - setChatState('idle'); - setPendingTool(null); - void syncSessionMessages(sessionId); + // Goose can briefly report no active request between tool phases. + // Confirm the absence before turning the UI idle, otherwise MindSpace + // refreshes the page while tools are still mutating it. + if (!activeRequestMissingTimerRef.current) { + activeRequestMissingTimerRef.current = window.setTimeout(() => { + activeRequestMissingTimerRef.current = null; + if (activeRequestId.current !== rid) return; + activeRequestId.current = null; + setChatState('idle'); + setPendingTool(null); + void syncSessionMessages(sessionId); + }, ACTIVE_REQUEST_MISSING_GRACE_MS); + } } return; } @@ -729,13 +783,14 @@ export function useTKMindChat( }, ); }, - [notifyInsufficientBalance, processEvent], + [clearActiveRequestMissingTimer, notifyInsufficientBalance, processEvent], ); const resetSessionView = useCallback(() => { connectTokenRef.current += 1; unsubscribeRef.current?.(); unsubscribeRef.current = null; + clearActiveRequestMissingTimer(); setError(null); setPendingTool(null); activeRequestId.current = null; @@ -748,7 +803,7 @@ export function useTKMindChat( setMessageHistoryLoadingMore(false); setMessages([]); setChatState('connecting'); - }, []); + }, [clearActiveRequestMissingTimer]); const connectSession = useCallback( async ( @@ -759,6 +814,7 @@ export function useTKMindChat( const token = ++connectTokenRef.current; unsubscribeRef.current?.(); unsubscribeRef.current = null; + clearActiveRequestMissingTimer(); if (showLoading) { setChatState('loading'); @@ -802,7 +858,7 @@ export function useTKMindChat( subscribeToSession(sessionId); }, - [subscribeToSession], + [clearActiveRequestMissingTimer, subscribeToSession], ); const ensureProvider = useCallback(async (sessionId: string) => { @@ -942,9 +998,10 @@ export function useTKMindChat( cancelled = true; connectTokenRef.current += 1; unsubscribeRef.current?.(); + clearActiveRequestMissingTimer(); }; // Re-run when the signed-in user changes so session storage stays per-user. - }, [ensureProvider, loadProjectMemory, notifyInsufficientBalance, refreshSessions, user?.id]); + }, [clearActiveRequestMissingTimer, ensureProvider, loadProjectMemory, notifyInsufficientBalance, refreshSessions, user?.id]); useEffect(() => { const handleVisibility = () => { @@ -1083,6 +1140,7 @@ export function useTKMindChat( setError(err instanceof Error ? err.message : String(err)); setChatState('error'); } + clearActiveRequestMissingTimer(); activeRequestId.current = null; } }, @@ -1091,6 +1149,7 @@ export function useTKMindChat( session, chatState, grantedSkills, + clearActiveRequestMissingTimer, subscribeToSession, ensureProvider, loadProjectMemory, @@ -1104,10 +1163,11 @@ export function useTKMindChat( try { await cancelRequest(session.id, activeRequestId.current); } finally { + clearActiveRequestMissingTimer(); activeRequestId.current = null; setChatState('idle'); } - }, [session]); + }, [clearActiveRequestMissingTimer, session]); const approveTool = useCallback( async (allow: boolean) => { @@ -1125,6 +1185,7 @@ export function useTKMindChat( connectTokenRef.current += 1; unsubscribeRef.current?.(); unsubscribeRef.current = null; + clearActiveRequestMissingTimer(); clearStoredSessionId(userRef.current?.id); activeRequestId.current = null; messagesRef.current = []; @@ -1139,7 +1200,7 @@ export function useTKMindChat( setError(null); setPendingTool(null); setChatState('idle'); - }, [chatState]); + }, [chatState, clearActiveRequestMissingTimer]); const refreshProjectMemory = useCallback(async () => { if (!session || chatState === 'streaming' || chatState === 'waiting') return; diff --git a/src/utils/message.ts b/src/utils/message.ts index fe1656d..42575dc 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -1,5 +1,7 @@ import type { Message, MessageContent } from '../types'; import { mergeMessageContent } from '../../message-stream.mjs'; +import { mergeConversationSnapshot as mergeConversationSnapshotCore } from '../../chat-finish-sync.mjs'; +import { deriveUserFacingText } from '../../conversation-display.mjs'; import { stripUserAddressPrefix } from './userAddress'; const IMAGE_URL_LINE_RE = /^\[图片\d+]: (.+)$/; @@ -153,30 +155,39 @@ export function normalizeConversationMessages(messages: Message[]): Message[] { * Merge an authoritative server conversation snapshot into the currently * displayed messages without ever erasing what the user can already see. * + * Used by UpdateConversation SSE and Finish sync. Implementation lives in + * chat-finish-sync.mjs (shared with regression tests — do not inline elsewhere). + * * `UpdateConversation` snapshots arrive on every SSE (re)connect — which on * mobile happens constantly via the page visibility cycle. A mid-turn snapshot * can be shorter than the live view (e.g. assistant messages not yet flagged * userVisible), so a blind replace would blank an active conversation even - * though the backend session is alive and persisted. We therefore treat the - * snapshot as authoritative for content/order but keep any locally-displayed - * messages the snapshot hasn't caught up to yet (optimistic / in-flight tail). - * The authoritative full reload on `Finish` corrects any drift afterwards. + * though the backend session is alive and persisted. Finish sync also merges + * because Goose may persist assistant turns after the Finish event. */ export function mergeConversationSnapshot(current: Message[], incoming: Message[]): Message[] { - if (incoming.length === 0) return current; - const incomingIds = new Set(incoming.map((message) => message.id).filter(Boolean)); - const localOnly = current.filter((message) => message.id && !incomingIds.has(message.id)); - return localOnly.length ? [...incoming, ...localOnly] : incoming; + return mergeConversationSnapshotCore(current, incoming) as Message[]; } export function getDisplayText(message: Message): string { + // REGRESSION GUARD: never show agent-only routing/skill prefixes in the chat UI. + if (message.role === 'user') { + if ('displayText' in message.metadata && message.metadata.displayText != null) { + return stripImageUrlLines(message.metadata.displayText); + } + const raw = message.content + .filter((c): c is Extract => c.type === 'text') + .map((c) => c.text) + .join('\n'); + return stripImageUrlLines(deriveUserFacingText(raw)); + } if ('displayText' in message.metadata) { return stripImageUrlLines(message.metadata.displayText ?? ''); } const systemText = getSystemNotificationText(message); if (systemText) return systemText; const visible = getVisibleText(message); - return message.role === 'user' ? stripImageUrlLines(stripUserAddressPrefix(visible)) : visible; + return visible; } export function pushMessage(messages: Message[], incoming: Message): Message[] { diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index bd5ba54..5f3cfa2 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -258,7 +258,7 @@ export function sanitizeSessionConversationPublicHtmlLinks(conversation, current return conversation.map((message) => sanitizeSessionMessagePublicHtmlLinks(message, currentUser)); } -function createSessionEventSanitizer(currentUser) { +function createSessionEventSanitizer(currentUser, { onEvent } = {}) { let buffer = ''; const flushChunk = (controller, chunk) => { if (!chunk) return; @@ -277,6 +277,13 @@ function createSessionEventSanitizer(currentUser) { } catch { return line; } + if (typeof onEvent === 'function') { + try { + onEvent(event); + } catch { + // Ignore side-effect failures and keep SSE flowing to the client. + } + } if (event?.type === 'Message' && event.message) { event.message = sanitizeSessionMessagePublicHtmlLinks(event.message, currentUser); } else if (event?.type === 'UpdateConversation' && Array.isArray(event.conversation)) { @@ -1196,7 +1203,7 @@ export function createTkmindProxy({ }, ]; - const proxySessionEvents = async (req, res, sessionId, { onAfterFinish } = {}) => { + const proxySessionEvents = async (req, res, sessionId, { onAfterFinish, onEvent } = {}) => { try { const pathname = `/sessions/${sessionId}/events`; const sessionTarget = await resolveTarget(sessionId); @@ -1248,7 +1255,7 @@ export function createTkmindProxy({ }); const source = Readable.fromWeb(upstream.body); - const linkSanitizer = createSessionEventSanitizer(req.currentUser); + const linkSanitizer = createSessionEventSanitizer(req.currentUser, { onEvent }); // 每 20s 发一个注释行保持连接,防止 nginx/代理因静默超时切断 SSE const keepalive = setInterval(() => { @@ -1291,7 +1298,8 @@ export function createTkmindProxy({ } const isAgentRunPath = (req.method === 'POST' && pathname === '/agent/runs') - || (req.method === 'GET' && /^\/agent\/runs\/[^/]+$/.test(pathname)); + || (req.method === 'GET' && /^\/agent\/runs\/[^/]+$/.test(pathname)) + || (req.method === 'GET' && /^\/agent\/runs\/[^/]+\/events$/.test(pathname)); if (isAgentRunPath) { res.status(503).json({ message: 'Agent Run 接口需在 Portal 本地处理,请确认 server.mjs 已更新并重启后端',