fix(mindspace): edit_file 落盘、Finish 聊天 merge 与回归守卫

- finish-sync 支持 edit_file 覆盖 public HTML
- Finish 同步 merge 本地流式消息,剥离 agent 内部前缀
- 新增 verify:mindspace-publish-guards 与 AGENTS.md 跨工具说明
- 发版脚本接入回归门禁;103 runtime 发布含备份回退

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-30 00:27:15 +08:00
parent 742aee7148
commit 98721371a4
31 changed files with 1440 additions and 141 deletions
@@ -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
```
+56
View File
@@ -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)
+4
View File
@@ -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 |
+135
View File
@@ -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);
};
}
+254
View File
@@ -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']);
});
+45
View File
@@ -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<string, unknown>; 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);
}
+54
View File
@@ -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'],
);
});
+35 -1
View File
@@ -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();
}
+8
View File
@@ -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 页面', []), '');
});
+36
View File
@@ -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();
}
+26
View File
@@ -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);
});
+36
View File
@@ -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` 与本目录为准。
@@ -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/<userId>/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 与单测对所有工具同样有效。
+95 -12
View File
@@ -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/<userId>/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 };
}
+165 -1
View File
@@ -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, '<body>一二三</body>', '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, '<!doctype html><title>Existing</title><body>一二三</body>', '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, '<body>一 · 二三</body>', '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: '<!doctype html><html><body>神口门</body></html>',
},
},
},
},
],
},
},
{ 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 });
}
});
+4 -1
View File
@@ -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",
+1
View File
@@ -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\/[^/]+$/ },
+5
View File
@@ -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,
});
+3 -3
View File
@@ -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() {
+1
View File
@@ -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
+54
View File
@@ -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');
@@ -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',
);
@@ -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) {
+46 -58
View File
@@ -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];
+5
View File
@@ -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() : '';
+73
View File
@@ -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,
-1
View File
@@ -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]);
+21 -13
View File
@@ -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 {
+98 -37
View File
@@ -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<Set<string>>(new Set());
const activeRequestId = useRef<string | null>(null);
const activeRequestMissingTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
const unsubscribeRef = useRef<(() => void) | null>(null);
const connectTokenRef = useRef(0);
const messagesRef = useRef<Message[]>([]);
@@ -154,6 +165,12 @@ export function useTKMindChat(
const onUserUpdateRef = useRef(onUserUpdate);
const chatImageCategoryIdRef = useRef<string | null>(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;
+20 -9
View File
@@ -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<MessageContent, { type: 'text' }> => 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[] {
+12 -4
View File
@@ -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 已更新并重启后端',