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:
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 页面', []), '');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 与单测对所有工具同样有效。
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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\/[^/]+$/ },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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() : '';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 已更新并重启后端',
|
||||
|
||||
Reference in New Issue
Block a user