merge: goosed-mcp-release (schema fix + mcp paths + chat UI + runtime fixes)

This commit is contained in:
john
2026-06-28 06:59:21 +08:00
41 changed files with 3255 additions and 255 deletions
+2
View File
@@ -1,5 +1,7 @@
# 生产发布规则
> 每次生产发包前先读:[docs/发包必看.md](docs/发包必看.md)。那里记录了 2026-06-27 Portal/goosed 事故后的强制检查项。
1. `103` 是正式生产主机,`105` 是云侧入口/历史链路;**本机一律不允许直接 `rsync` 到 `103` 或 `105`**,也不允许在线改源码后继续运行。
2. **禁止 SSH 登录 `105` 直接修改业务代码**(含服务号菜单脚本 `scripts/wechat-mp-menu.mjs`)。105 上文件是部署产物;变更必须:本地 `test-memind` 修改 → Git commit → 正式发布 → 必要时在目标环境执行 API 同步。详见 [docs/105-server-operations.md](docs/105-server-operations.md)。
2. **Portal 生产必须是无源码 runtime 模式**:构建只发生在本机 Mac,产物是 `.runtime/portal/`;`103` 只接收 runtime artifact、继承持久目录、启动服务,**禁止**在 `103` 上 `npm install`、`npm run build` 或保留可运行源码树。
+14 -3
View File
@@ -3,10 +3,18 @@ import { fileURLToPath } from 'node:url';
import { resolveAgentGooseMode } from './policies.mjs';
/** Spawned as a separate Node process by goosed; must sit beside bundled portal runtime. */
export function resolveSandboxMcpServerPath() {
export function resolveSandboxMcpServerPath(overridePath) {
const normalized = String(overridePath ?? '').trim();
if (normalized) return normalized;
return path.join(path.dirname(fileURLToPath(import.meta.url)), 'mindspace-sandbox-mcp.mjs');
}
export function resolveSandboxMcpNodeExecPath(overridePath) {
const normalized = String(overridePath ?? '').trim();
if (normalized) return normalized;
return process.execPath;
}
export const CAPABILITY_CATALOG = [
{
key: 'shell',
@@ -307,7 +315,7 @@ export function buildAgentExtensionPolicy(
'工作区沙箱文件系统与用户私有数据空间。用户私有数据空间是当前用户唯一的 SQLite 数据库,适合问卷、表单、清单、调研数据和分析中间表;不要用于账号、计费、权限、审计、公开平台数据或跨用户数据。',
display_name: 'sandbox-fs',
bundled: false,
cmd: sandboxMcp.nodeExecPath ?? process.execPath,
cmd: resolveSandboxMcpNodeExecPath(sandboxMcp.nodeExecPath),
// sandboxRoot passed as argv[2] so it works even if goosed doesn't forward envs
args: [sandboxMcp.serverPath, sandboxMcp.sandboxRoot],
// envs (goosed field name) as belt-and-suspenders backup
@@ -350,7 +358,10 @@ export function buildAgentExtensionPolicy(
extensions.push(makeExtension('platform', 'summon', summonTools));
}
}
if (capabilities.code_sandbox) {
const enableCodeExecutionExtension = /^(1|true|yes)$/i.test(
process.env.TKMIND_ENABLE_CODE_EXECUTION_EXTENSION ?? '',
);
if (capabilities.code_sandbox && enableCodeExecutionExtension) {
extensions.push(makeExtension('platform', 'code_execution', []));
}
if (capabilities.chat_recall) {
+28
View File
@@ -8,6 +8,7 @@ import {
clampUserCapabilities,
DEFAULT_USER_CAPABILITIES,
normalizeCapabilityPatch,
resolveSandboxMcpNodeExecPath,
resolveSandboxMcpServerPath,
sandboxDeveloperTools,
sandboxMcpTools,
@@ -20,6 +21,18 @@ test('resolveSandboxMcpServerPath resolves to an existing MCP entry file', () =>
assert.ok(fs.existsSync(serverPath));
});
test('resolveSandboxMcpServerPath honors container-path override without host fs checks', () => {
assert.equal(
resolveSandboxMcpServerPath('/opt/portal/mindspace-sandbox-mcp.mjs'),
'/opt/portal/mindspace-sandbox-mcp.mjs',
);
});
test('resolveSandboxMcpNodeExecPath honors container-path override without host fs checks', () => {
assert.equal(resolveSandboxMcpNodeExecPath('/usr/local/bin/node'), '/usr/local/bin/node');
assert.equal(resolveSandboxMcpNodeExecPath(''), process.execPath);
});
test('default user policy blocks dangerous capabilities', () => {
assert.equal(DEFAULT_USER_CAPABILITIES.shell, false);
assert.equal(DEFAULT_USER_CAPABILITIES.filesystem, false);
@@ -222,6 +235,7 @@ test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of
const sandboxExt = policy.extensionOverrides.find((ext) => ext.name === 'sandbox-fs');
assert.ok(sandboxExt, 'sandbox-fs extension should be present');
assert.equal(sandboxExt.type, 'stdio');
assert.equal(sandboxExt.cmd, process.execPath);
assert.equal(sandboxExt.envs.SANDBOX_ROOT, '/opt/h5/MindSpace/abc123');
assert.equal(sandboxExt.args[1], '/opt/h5/MindSpace/abc123'); // also passed as argv[2]
assert.ok(sandboxExt.available_tools.includes('write_file'));
@@ -237,3 +251,17 @@ test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of
assert.ok(!allTools.includes('write'), 'built-in write should not be exposed');
assert.ok(!allTools.includes('shell'), 'shell should not be exposed');
});
test('sandboxMcp honors container node executable override', () => {
const caps = { ...DEFAULT_USER_CAPABILITIES, static_publish: true };
const policy = buildAgentExtensionPolicy(caps, {
sandboxMcp: {
serverPath: '/opt/h5/mindspace-sandbox-mcp.mjs',
sandboxRoot: '/opt/h5/MindSpace/abc123',
nodeExecPath: '/usr/local/bin/node',
},
});
const sandboxExt = policy.extensionOverrides.find((ext) => ext.name === 'sandbox-fs');
assert.equal(sandboxExt?.cmd, '/usr/local/bin/node');
});
+20 -1
View File
@@ -233,6 +233,7 @@ export async function migrateSchema(pool) {
`);
const publishColumns = [
['user_confirmed_at', 'BIGINT NULL AFTER expires_at'],
['plaza_view_count', 'BIGINT NOT NULL DEFAULT 0'],
['plaza_like_count', 'BIGINT NOT NULL DEFAULT 0'],
];
@@ -242,6 +243,13 @@ export async function migrateSchema(pool) {
}
}
if (!(await indexExists(pool, 'h5_publish_records', 'idx_h5_publish_auto_private'))) {
await pool.query(
`ALTER TABLE h5_publish_records
ADD KEY idx_h5_publish_auto_private (access_mode, status, user_confirmed_at, expires_at)`,
);
}
const plazaUserColumns = [
['plaza_post_count', 'INT UNSIGNED NOT NULL DEFAULT 0'],
['plaza_follower_count', 'INT UNSIGNED NOT NULL DEFAULT 0'],
@@ -282,6 +290,12 @@ export async function migrateSchema(pool) {
);
}
if (!(await columnExists(pool, 'h5_session_snapshots', 'display_title'))) {
await pool.query(
`ALTER TABLE h5_session_snapshots ADD COLUMN display_title VARCHAR(512) NOT NULL DEFAULT '' AFTER name`,
);
}
const oauthStateColumns = [
['intent', "VARCHAR(16) NOT NULL DEFAULT 'login' AFTER utm_campaign"],
['bind_user_id', 'CHAR(36) NULL AFTER intent'],
@@ -517,6 +531,7 @@ export async function migrateSchema(pool) {
agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
name VARCHAR(512) NOT NULL DEFAULT '',
display_title VARCHAR(512) NOT NULL DEFAULT '',
working_dir VARCHAR(1024) NOT NULL DEFAULT '',
created_at_str VARCHAR(64) NOT NULL DEFAULT '',
updated_at_str VARCHAR(64) NOT NULL DEFAULT '',
@@ -588,7 +603,11 @@ export async function migrateSchema(pool) {
export async function initSchema(pool) {
const schemaPath = path.join(__dirname, 'schema.sql');
const sql = fs.readFileSync(schemaPath, 'utf8');
const sql = fs
.readFileSync(schemaPath, 'utf8')
.split('\n')
.filter((line) => !line.trim().startsWith('--'))
.join('\n');
for (const statement of sql.split(';')) {
const trimmed = statement.trim();
if (trimmed) {
+1
View File
@@ -2,6 +2,7 @@
> 2026-06-26 起,103 / Studio 正式禁止 `rsync` 发布。
> Portal 生产必须是**无源码 runtime 模式**,唯一合法入口是 `bash scripts/release-portal-runtime-prod.sh`。
> 每次发包前先读:[发包必看](发包必看.md)。
## 当前规则
+275
View File
@@ -0,0 +1,275 @@
# 发包必看
> 适用范围:`test-memind` Portal runtime 发布、`goosed-prod` 镜像更新、103/105 生产链路排障。
> 这份文档是发布前强制阅读的事故经验清单。后续发包如果绕过这里,容易把 2026-06-27 的问题重新带回生产。
## 0. 先确认边界
- 生产 Portal 主机是 `103 / Studio`:`john@58.38.22.103`,默认 SSH 命令不要再写旧端口 `2222`。
- `105` 是云侧入口/历史链路,不是 Portal 源码真相;不要 SSH 到 105 直接改业务代码。
- Portal 生产目录:`/Users/john/Project/Memind`。
- goosed 生产目录:`/Users/john/Project/goosed-prod`。
- Portal 生产必须是无源码 runtime 模式,只能用 `scripts/release-portal-runtime-prod.sh` 发包。
- 禁止直接 `rsync` 到 103/105,禁止在 103 上 `npm install`、`npm run build` 后继续跑。
## 1. 发包前硬性检查
1. 当前代码必须已经包含以下修复,或位于它们之后:
- `fc5b50c fix: make goosed mcp paths container safe`
- `0420c42 fix: make schema bootstrap mysql safe`
2. release worktree 必须干净:
```bash
git status --short
```
3. 不允许把 `.runtime/portal/`、`node_modules`、本地构建缓存提交进 Git。
4. 如果 release worktree 需要临时复用主 worktree 的依赖,只能临时建 symlink,发布/测试后必须删除:
```bash
ln -s /Users/john/PycharmProjects/test/test-memind/node_modules /Users/john/PycharmProjects/test/test-memind-release-goosed-mcp/node_modules
# 测试或发布完成后:
rm -f /Users/john/PycharmProjects/test/test-memind-release-goosed-mcp/node_modules
```
5. 发包前至少跑:
```bash
node --test db.test.mjs capabilities.test.mjs llm-providers.test.mjs wechat-mp.test.mjs
```
## 2. Portal runtime 发布唯一流程
只使用:
```bash
bash scripts/release-portal-runtime-prod.sh --skip-tests --yes
```
这个脚本必须完成这些动作:
- 本机构建 `.runtime/portal`。
- 上传 artifact 到 103。
- 103 备份当前 `/Users/john/Project/Memind` 全目录。
- 103 单独备份持久目录:`.env`、`MindSpace/`、`data/`、`users/`、`.tailscale/`、`public/plaza-covers/`、`logs/`。
- 原子切换 live 目录。
- 更新 LaunchAgent 指向 `/Users/john/Project/Memind/scripts/run-memind-portal-prod.sh`。
- 健康检查 `http://127.0.0.1:8081/api/status` 返回 200。
发布成功后,记录脚本输出里的:
- `release_id`
- `archived_source`
- `full_backup`
- `persist_backup`
- `git_head`
## 3. Portal 发布后必须验收
在本机执行:
```bash
curl -k -i https://m.tkmind.cn/auth/login \
-H 'content-type: application/json' \
--data '{"username":"john","password":"wrong-password"}' | head -40
```
正确结果应该是 `401`,消息类似 `用户名或密码错误`。
如果返回 `503` 且消息是 `未配置用户数据库或访问密码`,说明用户系统 bootstrap 失败,不是简单健康检查能发现的问题。
在 103 执行:
```bash
ssh john@58.38.22.103 '
printf "portal="; curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8081/api/status
for p in 18006 18007 18008 18009; do
printf "goosed_${p}="
curl -k -s -o /dev/null -w "%{http_code}\n" https://127.0.0.1:${p}/status
done
pid=$(pgrep -f "node .*server.mjs" | head -1)
echo "portal_pid=${pid}"
ps eww -p "$pid" | tr " " "\n" | awk -F= "/^(DATABASE_URL|H5_PORT|TKMIND_API_TARGETS|GOOSED_MCP_NODE_PATH|GOOSED_MCP_SERVER_PATH)=/ {print \$1\"=SET\"}"
'
```
必须看到:
- `portal=200`
- `goosed_18006=200`
- `goosed_18007=200`
- `goosed_18008=200`
- `goosed_18009=200`
- `DATABASE_URL=SET`
- `TKMIND_API_TARGETS=SET`
- `GOOSED_MCP_NODE_PATH=SET`
- `GOOSED_MCP_SERVER_PATH=SET`
日志检查:
```bash
ssh john@58.38.22.103 '
tail -200 ~/Library/Logs/memind-portal.log |
egrep "User auth bootstrap failed|ER_PARSE_ERROR|Cannot find package|Failed to add extension|No such file|Unknown extension|Provider not set|not configured" || true
'
```
新启动之后不能继续出现上述错误。
## 4. 2026-06-27 事故复盘形成的禁止项
### 4.1 不要只看 `/api/status`
`/api/status` 返回 200 只能说明 Express 进程活着,不能说明登录、数据库、Agent 会话策略都正常。每次 Portal 发包必须额外测 `/auth/login`。
### 4.2 schema 必须兼容 MySQL
禁止在 `schema.sql` 里写 PostgreSQL partial index:
```sql
KEY idx_xxx (...) WHERE ...
```
MySQL 不支持这种写法。生产曾因此触发:
```text
User auth bootstrap failed
ER_PARSE_ERROR
未配置用户数据库或访问密码
```
`db.mjs` 现在使用 `splitSqlStatements()`,会跳过 SQL 注释里的分号;不要改回 `sql.split(';')`。
### 4.3 不要把本地 `node_modules` symlink 打进包
release worktree 如果有 `node_modules -> /Users/john/.../node_modules` symlink,构建脚本必须 realpath 后复制真实依赖。发布后必须确认线上:
```bash
ssh john@58.38.22.103 'cd /Users/john/Project/Memind && test -d node_modules/http-proxy-middleware && echo ok'
```
否则可能出现:
```text
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'http-proxy-middleware'
```
### 4.4 Portal 下发给 goosed 的 MCP 路径必须是容器内路径
推荐并默认使用:
```bash
GOOSED_MCP_NODE_PATH=/usr/local/bin/node
GOOSED_MCP_SERVER_PATH=/opt/portal/mindspace-sandbox-mcp.mjs
```
不要让新会话继续依赖宿主机路径:
```text
/opt/homebrew/Cellar/node@24/24.16.0/bin/node
/Users/john/Project/Memind/mindspace-sandbox-mcp.mjs
```
否则会触发:
```text
Failed to add extension: IO error: No such file or directory (os error 2)
```
### 4.5 `code_execution` 默认不能下发
当前 goosed 不认识 `code_execution` 扩展。除非确认 goosed 已支持,否则不要设置:
```bash
TKMIND_ENABLE_CODE_EXECUTION_EXTENSION=1
```
### 4.6 Provider 必须同步到全部 goosed target
Portal 现在使用:
```bash
TKMIND_API_TARGETS=https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009
```
发布或重建 goosed 容器后,要确认 18006-18009 都有 provider 配置。否则 Agent 会出现:
```text
Provider 'custom_deepseek' is not configured
```
注意:DeepSeek API key / provider secret 是运行配置,不应该烘焙进镜像。
## 5. goosed-prod 镜像更新必看
当前已验证镜像:
```text
tkmind/goosed:prod-20260627-2125-mcp
```
这个镜像必须保证以下路径在容器内存在:
```text
/usr/local/bin/node
/opt/homebrew/bin/node
/opt/homebrew/opt/node@24/bin/node
/opt/homebrew/Cellar/node@24/24.16.0/bin/node
/opt/portal/mindspace-sandbox-mcp.mjs
```
重建镜像后必须验:
```bash
ssh john@58.38.22.103 '
cd /Users/john/Project/goosed-prod
set -a
source .env
source .paths.env
export PORTAL_RUNTIME_DIR="$portal_runtime_dir" MINDSPACE_ROOT="$mindspace_root"
set +a
/opt/homebrew/bin/docker compose -f docker-compose.prod.yml ps
for c in goosed-prod-1 goosed-prod-2 goosed-prod-3 goosed-prod-4; do
echo "--- $c"
/opt/homebrew/bin/docker exec "$c" sh -lc "
ls -l /usr/local/bin/node \
/opt/homebrew/bin/node \
/opt/homebrew/opt/node@24/bin/node \
/opt/homebrew/Cellar/node@24/24.16.0/bin/node \
/opt/portal/mindspace-sandbox-mcp.mjs
"
done
'
```
重建容器后必须重新确认:
- 四个容器都是 `healthy`。
- 18006-18009 `/status` 都是 200。
- provider 已同步到四个 target。
- sandbox-fs smoke 能成功 `add extension` 并完成一次 `/reply`。
## 6. 出问题时先走这条判断链
1. `m.tkmind.cn/api/status` 是否 200?
2. `/auth/login` 是否进入 400/401,而不是 503?
3. 103 Portal 进程是否拿到了 `DATABASE_URL`?
4. `~/Library/Logs/memind-portal.log` 是否有 `User auth bootstrap failed`?
5. 18006-18009 是否全 200?
6. goosed 容器内 `/usr/local/bin/node` 和 `/opt/portal/mindspace-sandbox-mcp.mjs` 是否存在?
7. provider 是否同步到四个 target?
8. 如果是微信服务号或专属 Agent 报错,必须测真实业务路径,不要只看健康接口。
## 7. 发布完成后给用户的最小回报格式
发布完成后至少说明:
- release id
- git head
- full backup
- persisted backup
- Portal 健康结果
- `/auth/login` 结果
- 18006-18009 健康结果
- 是否发现并处理了日志里的错误关键词
不要只说“已发布成功”。
+23 -10
View File
@@ -861,9 +861,12 @@ function validateCustomPayload(payload) {
export function createLlmProviderService(
pool,
{ apiTarget, apiSecret, encryptionKey, apiFetchImpl = undiciFetch } = {},
{ apiTarget, apiTargets = [], apiSecret, encryptionKey, apiFetchImpl = undiciFetch } = {},
) {
const launchStates = new Map();
const syncTargets = [
...new Set([...(Array.isArray(apiTargets) ? apiTargets : []), apiTarget].filter(Boolean)),
];
function catalogItem(providerId) {
return catalogById[providerId] ?? null;
@@ -1742,19 +1745,29 @@ export function createLlmProviderService(
},
async syncSelectedToGoosed() {
let resolved = await resolveExecutorProvider('goose');
if (!resolved.ok) {
resolved = await resolveSelectedProvider();
}
if (!resolved.ok) {
return { ok: false, synced: false, message: resolved.message };
const targets = syncTargets.length ? syncTargets : [apiTarget].filter(Boolean);
let lastResolved = null;
for (const target of targets) {
const targetFetch = (url, init) => {
const pathname = `${url.pathname}${url.search}`;
return goosedApiFetch(target, apiSecret, pathname, init, apiFetchImpl);
};
let resolved = await resolveExecutorProvider('goose', 'default', targetFetch);
if (!resolved.ok) {
resolved = await resolveSelectedProvider(targetFetch);
}
if (!resolved.ok) {
return { ok: false, synced: false, target, message: resolved.message };
}
lastResolved = resolved;
}
return {
ok: true,
synced: true,
source: resolved.source,
providerId: resolved.providerId,
model: resolved.model,
targets,
source: lastResolved.source,
providerId: lastResolved.providerId,
model: lastResolved.model,
};
},
+66 -6
View File
@@ -487,14 +487,15 @@ test('launchExecutor rejects openhands headless without instruction', async () =
});
test('launchExecutor returns friendly error when command is missing', async () => {
const encryptedKey = encryptSecret('sk-x', 'unit-test-secret');
const keyRow = {
id: 'key-1',
provider_id: 'custom_deepseek',
provider_kind: 'builtin',
name: 'DeepSeek',
api_key_ciphertext: encryptSecret('sk-x', 'unit-test-secret').ciphertext,
api_key_iv: encryptSecret('sk-x', 'unit-test-secret').iv,
api_key_tag: encryptSecret('sk-x', 'unit-test-secret').tag,
api_key_ciphertext: encryptedKey.ciphertext,
api_key_iv: encryptedKey.iv,
api_key_tag: encryptedKey.tag,
default_model: 'deepseek-chat',
status: 'active',
is_selected: 1,
@@ -513,13 +514,13 @@ test('launchExecutor returns friendly error when command is missing', async () =
};
const pool = {
async query(sql) {
if (sql.includes('SELECT * FROM h5_llm_executor_bindings WHERE executor = ? AND purpose = ? LIMIT 1')) {
return [[bindingRow]];
}
if (sql.includes('SELECT * FROM h5_llm_executor_bindings')) return [[]];
if (sql.includes('SELECT * FROM h5_llm_provider_keys WHERE id = ?')) {
return [[keyRow]];
}
if (sql.includes('SELECT * FROM h5_llm_executor_bindings WHERE executor = ? AND purpose = ? LIMIT 1')) {
return [[bindingRow]];
}
throw new Error(`Unexpected SQL: ${sql}`);
},
};
@@ -597,6 +598,65 @@ test('syncProfileToGoosed writes provider, model and secret keys for builtin', a
);
});
test('syncSelectedToGoosed writes selected provider to every configured target', async () => {
const encrypted = encryptSecret('sk-test', 'unit-test-secret');
const row = {
id: 'key-deepseek',
provider_id: 'custom_deepseek',
provider_kind: 'builtin',
name: 'DeepSeek',
api_url: null,
base_path: null,
models_json: JSON.stringify(['deepseek-chat']),
goosed_provider_id: null,
engine: 'openai',
relay_provider: null,
api_key_ciphertext: encrypted.ciphertext,
api_key_iv: encrypted.iv,
api_key_tag: encrypted.tag,
default_model: 'deepseek-chat',
status: 'active',
is_selected: 1,
created_at: 1,
updated_at: 1,
};
const pool = {
async query(sql) {
if (sql.includes('SELECT * FROM h5_llm_executor_bindings')) return [[]];
if (sql.includes('is_selected = 1')) return [[row]];
throw new Error(`Unexpected SQL: ${sql}`);
},
};
const calls = [];
const mockFetch = async (url, init) => {
calls.push({ url: String(url), body: JSON.parse(init.body) });
if (String(url).includes('/chat/completions')) {
return {
ok: true,
json: async () => ({ choices: [{ message: { content: 'ok' } }] }),
text: async () => JSON.stringify({ choices: [{ message: { content: 'ok' } }] }),
};
}
return { ok: true, text: async () => '' };
};
const service = createLlmProviderService(pool, {
apiTarget: 'https://127.0.0.1:18006',
apiTargets: ['https://127.0.0.1:18006', 'https://127.0.0.1:18007'],
apiSecret: 'secret',
encryptionKey: 'unit-test-secret',
apiFetchImpl: mockFetch,
});
const result = await service.syncSelectedToGoosed();
assert.equal(result.ok, true);
assert.deepEqual(result.targets, ['https://127.0.0.1:18006', 'https://127.0.0.1:18007']);
assert.deepEqual(
calls
.filter((call) => call.url.includes('/config/upsert') && call.body.key === 'GOOSE_PROVIDER')
.map((call) => new URL(call.url).port),
['18006', '18007'],
);
});
test('syncProfileToGoosed tolerates missing /config/upsert endpoint', async () => {
const mockFetch = async (_url, init) => {
if (String(_url).includes('/config/custom-providers')) {
+1 -1
View File
@@ -265,7 +265,7 @@ test('completeUpload publishes public images to the public temp image library',
const asset = await service.completeUpload('user-1', upload.id);
assert.equal(asset.status, 'ready');
assert.match(asset.publicUrl, /^https:\/\/g2\.tkmind\.cn\/MindSpace\/user-1\/public\/\.tmp-images\/id-2\.png$/);
assert.match(asset.publicUrl, /^https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/\.tmp-images\/id-2\.png$/);
assert.equal(state.versions[0].storage_key, 'users/user-1/public/.tmp-images/id-2.png');
await assert.doesNotReject(() =>
fs.stat(path.join(storageRoot, 'users/user-1/public/.tmp-images/id-2.png')),
+61
View File
@@ -537,6 +537,22 @@ export function createPageService(pool, options = {}) {
return rows[0] ? pageResponse(rows[0]) : null;
};
const findPageBySourceMessage = async (userId, sessionId, messageId) => {
if (!sessionId || !messageId) return null;
const [rows] = await pool.query(
`SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
FROM h5_page_records p
JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'
WHERE p.user_id = ? AND p.source_session_id = ? AND p.source_message_id = ? AND p.status <> 'deleted'
ORDER BY p.updated_at DESC
LIMIT 1`,
[userId, sessionId, messageId],
);
return rows[0] ? pageResponse(rows[0]) : null;
};
const listPages = async (userId, filters = {}) => {
const clauses = [`p.user_id = ?`, `p.status <> 'deleted'`];
const params = [userId];
@@ -1148,6 +1164,7 @@ export function createPageService(pool, options = {}) {
createRedactedCopy: redactPage,
listPages,
findPageBySourceAsset,
findPageBySourceMessage,
getPage,
getDeletePreview,
deletePage,
@@ -1211,6 +1228,50 @@ export const pageInternals = {
buildPageDeleteSummary,
};
export async function inlinePrivateAssetsInHtml(pool, storageRoot, userId, html) {
const PATTERN =
/(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi;
const resolvedRoot = path.resolve(storageRoot);
const absoluteStoragePath = (key) => {
const resolved = path.resolve(resolvedRoot, key);
if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
throw Object.assign(new Error('路径越界'), { code: 'invalid_storage_path' });
}
return resolved;
};
const matches = [...String(html).matchAll(PATTERN)];
if (matches.length === 0) return { html, count: 0 };
const assetIds = [...new Set(matches.map((m) => m[1]).filter(Boolean))];
const [assets] = await pool.query(
`SELECT a.id, a.mime_type, a.original_filename, v.storage_key
FROM h5_assets a
JOIN h5_asset_versions v ON v.id = a.current_version_id
WHERE a.user_id = ? AND a.id IN (?) AND a.mime_type LIKE 'image/%'
AND a.status <> 'deleted'`,
[userId, assetIds],
);
const dataUriMap = new Map();
for (const asset of assets) {
try {
const buffer = await fs.readFile(absoluteStoragePath(asset.storage_key));
dataUriMap.set(asset.id, `data:${asset.mime_type};base64,${buffer.toString('base64')}`);
} catch {
// skip unreadable assets
}
}
let count = 0;
const result = String(html).replace(PATTERN, (_match, assetId) => {
const uri = dataUriMap.get(assetId);
if (!uri) return '';
count += 1;
return uri;
});
return { html: result, count };
}
export function buildPageDeleteSummary(preview) {
const lines = [
`页面「${preview.page.title}」及其 ${preview.page.versionCount} 个版本`,
+3
View File
@@ -40,6 +40,9 @@ function resolveSandboxed(p) {
if (resolved !== SANDBOX && !resolved.startsWith(SANDBOX + path.sep)) {
throw Object.assign(new Error(`路径越界:${p} 不在当前工作区内`), { code: 'EACCES' });
}
if (resolved === PRIVATE_DATA_DIR || resolved.startsWith(PRIVATE_DATA_DIR + path.sep)) {
throw Object.assign(new Error('禁止直接访问私有数据目录,请使用 private_data_* 工具'), { code: 'EACCES' });
}
return resolved;
}
+54 -2
View File
@@ -12,8 +12,10 @@
"@resvg/resvg-js": "^2.6.2",
"debug": "^4.4.3",
"express": "^4.21.2",
"framer-motion": "^12.42.0",
"http-proxy-middleware": "^3.0.3",
"jsonrepair": "^3.14.0",
"lucide-react": "^1.21.0",
"mysql2": "^3.22.5",
"pg": "^8.22.0",
"qrcode": "^1.5.4",
@@ -2558,6 +2560,33 @@
"node": ">= 0.6"
}
},
"node_modules/framer-motion": {
"version": "12.42.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.0.tgz",
"integrity": "sha512-wp7EJnfWaaEScVygKv3e20udoRz+LbtxScsuTkakAxfXmt+ReC6WyPW2nINRAGvd+hG9odwcjBLyOTPjH5pBRA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.42.0",
"motion-utils": "^12.39.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -2937,6 +2966,15 @@
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/lucide-react": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz",
"integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3019,6 +3057,21 @@
"node": ">= 0.6"
}
},
"node_modules/motion-dom": {
"version": "12.42.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.0.tgz",
"integrity": "sha512-M63h4n8R+quJdNhBwuLlgxM+OLYa9+I/T2pzDRboB9fLXRdbou+Gw7Zury+SkpaCyACP1JHSjHgZ1EgTkBr30w==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.39.0"
}
},
"node_modules/motion-utils": {
"version": "12.39.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3944,8 +3997,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-is": {
"version": "1.6.18",
+2
View File
@@ -54,8 +54,10 @@
"@resvg/resvg-js": "^2.6.2",
"debug": "^4.4.3",
"express": "^4.21.2",
"framer-motion": "^12.42.0",
"http-proxy-middleware": "^3.0.3",
"jsonrepair": "^3.14.0",
"lucide-react": "^1.21.0",
"mysql2": "^3.22.5",
"pg": "^8.22.0",
"qrcode": "^1.5.4",
+2 -1
View File
@@ -233,7 +233,7 @@ CREATE TABLE IF NOT EXISTS h5_publish_records (
updated_at BIGINT NOT NULL,
KEY idx_h5_publish_route (url_slug, status, published_at),
KEY idx_h5_publish_page (user_id, page_id, published_at),
KEY idx_h5_publish_auto_private (expires_at, user_confirmed_at) WHERE access_mode = 'public' AND user_confirmed_at IS NULL,
KEY idx_h5_publish_auto_private (access_mode, status, user_confirmed_at, expires_at),
UNIQUE KEY uq_h5_publish_token_hash (token_hash),
CONSTRAINT fk_h5_publish_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
CONSTRAINT fk_h5_publish_page FOREIGN KEY (page_id) REFERENCES h5_page_records(id) ON DELETE CASCADE,
@@ -660,6 +660,7 @@ CREATE TABLE IF NOT EXISTS h5_session_snapshots (
agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
name VARCHAR(512) NOT NULL DEFAULT '',
display_title VARCHAR(512) NOT NULL DEFAULT '',
working_dir VARCHAR(1024) NOT NULL DEFAULT '',
created_at_str VARCHAR(64) NOT NULL DEFAULT '',
updated_at_str VARCHAR(64) NOT NULL DEFAULT '',
+9 -5
View File
@@ -138,14 +138,15 @@ async function copyNodeModules() {
if (!(await exists(nodeModulesDir))) {
throw new Error(`缺少 node_modules 目录: ${nodeModulesDir}`);
}
const resolvedNodeModulesDir = await fs.realpath(nodeModulesDir);
console.log('==> 拷贝生产运行依赖 node_modules');
await copyDir(nodeModulesDir, path.join(runtimeRoot, 'node_modules'));
await rewriteNodeModulesSymlinks(path.join(runtimeRoot, 'node_modules'));
await copyDir(resolvedNodeModulesDir, path.join(runtimeRoot, 'node_modules'));
await rewriteNodeModulesSymlinks(path.join(runtimeRoot, 'node_modules'), resolvedNodeModulesDir);
}
async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir) {
async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir, sourceNodeModulesDir) {
console.log('==> 重写 node_modules 顶层符号链接为 runtime 内相对路径');
const localNodeModulesRoot = `${nodeModulesDir}${path.sep}`;
const localNodeModulesRoot = `${sourceNodeModulesDir}${path.sep}`;
async function visit(dir) {
const entries = await fs.readdir(dir);
@@ -199,8 +200,11 @@ async function writeMetadata() {
'export NODE_ENV=production',
'export H5_PORT="${H5_PORT:-8081}"',
'export H5_PUBLIC_BASE_URL="${H5_PUBLIC_BASE_URL:-https://m.tkmind.cn}"',
'export TKMIND_API_TARGETS="${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}"',
'export TKMIND_API_TARGET="${TKMIND_API_TARGET:-https://127.0.0.1:18006}"',
'export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1:-https://127.0.0.1:18007}"',
'export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-/usr/local/bin/node}"',
'export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-/opt/portal/mindspace-sandbox-mcp.mjs}"',
'',
'NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"',
'if [[ ! -x "${NODE_BIN}" ]]; then',
@@ -232,7 +236,7 @@ async function writeMetadata() {
'Key runtime differences must stay in .env, not in the artifact:',
' DATABASE_URL / MYSQL_*',
' H5_PUBLIC_BASE_URL',
' TKMIND_API_TARGET / TKMIND_API_TARGET_1',
' TKMIND_API_TARGETS / TKMIND_API_TARGET / TKMIND_API_TARGET_1',
' H5_USERS_ROOT / MINDSPACE_STORAGE_ROOT / MEMIND_SHARED_PUBLISH_ROOT',
'',
'Deployment and operations transport:',
+15 -2
View File
@@ -3,12 +3,24 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
DEFAULT_MONITOR_SCRIPT="$ROOT/scripts/monitor-goosed-fds.mjs"
if [[ -x "/Users/john/Project/tkmind_go/scripts/monitor-goosed-fds.mjs" ]]; then
DEFAULT_MONITOR_SCRIPT="/Users/john/Project/tkmind_go/scripts/monitor-goosed-fds.mjs"
fi
MONITOR_SCRIPT="${GOOSED_MONITOR_SCRIPT:-$DEFAULT_MONITOR_SCRIPT}"
MONITOR_ROOT="$(cd "$(dirname "$MONITOR_SCRIPT")/.." && pwd)"
PLIST="$HOME/Library/LaunchAgents/cn.tkmind.goosed-monitor.plist"
LOG="$HOME/Library/Logs/goosed-monitor.log"
GUI="gui/$(id -u)"
mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs"
if [[ ! -x "$MONITOR_SCRIPT" ]]; then
echo "monitor script not found or not executable: $MONITOR_SCRIPT" >&2
echo "Set GOOSED_MONITOR_SCRIPT to a stable goosed ops script path." >&2
exit 1
fi
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -19,10 +31,10 @@ cat > "$PLIST" <<EOF
<key>ProgramArguments</key>
<array>
<string>$NODE_BIN</string>
<string>$ROOT/scripts/monitor-goosed-fds.mjs</string>
<string>$MONITOR_SCRIPT</string>
</array>
<key>WorkingDirectory</key>
<string>$ROOT</string>
<string>$MONITOR_ROOT</string>
<key>StartInterval</key>
<integer>60</integer>
<key>RunAtLoad</key>
@@ -55,4 +67,5 @@ launchctl enable "$GUI/cn.tkmind.goosed-monitor"
launchctl kickstart -k "$GUI/cn.tkmind.goosed-monitor"
echo "installed $PLIST"
echo "script: $MONITOR_SCRIPT"
echo "log: $LOG"
+113
View File
@@ -80,7 +80,39 @@ need_cmd scp
need_cmd tar
need_cmd shasum
check_release_scope() {
local bypass="${ALLOW_PORTAL_RELEASE_SCOPE_BYPASS:-0}"
if [[ "${bypass}" == "1" ]]; then
say "跳过发版范围检查(ALLOW_PORTAL_RELEASE_SCOPE_BYPASS=1)"
return 0
fi
git -C "${ROOT}" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
local path
local blocked=()
while IFS= read -r path; do
[[ -n "${path}" ]] || continue
case "${path}" in
docker/*|scripts/start-goosed-local.sh|scripts/verify-goosed-docker.sh|docs/goosed-*.md|server.mjs.bak*|*.bak)
blocked+=("${path}")
;;
docs/local-dev.md|README.md)
blocked+=("${path}")
;;
esac
done < <(git -C "${ROOT}" status --short --untracked-files=all | sed -E 's/^.. //')
if [[ "${#blocked[@]}" -gt 0 ]]; then
echo "检测到默认不应并入 103 Portal 发版的本地改动:" >&2
printf ' - %s\n' "${blocked[@]}" >&2
echo "请先清理这些改动,或确认本次需求明确包含这些模块后再重试。" >&2
echo "如确需绕过,可临时设置 ALLOW_PORTAL_RELEASE_SCOPE_BYPASS=1。" >&2
exit 1
fi
}
say "本地预检查"
check_release_scope
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo portal-runtime-ssh-ok" >/dev/null
ssh -o BatchMode=yes "${HOST}" "test -d '${APP_DIR}' && test -f '${APP_DIR}/.env'" >/dev/null
@@ -121,6 +153,87 @@ verify_runtime_artifact() {
verify_runtime_artifact
verify_remote_goosed_dependency() {
say "检查 103 goosed 依赖"
ssh -o BatchMode=yes "${HOST}" 'bash -s' <<'REMOTE'
set -euo pipefail
missing=0
docker_bin="/opt/homebrew/bin/docker"
if [[ -x "${docker_bin}" ]] && "${docker_bin}" ps --format '{{.Names}}' 2>/dev/null | grep -q '^goosed-prod-1$'; then
for index in 1 2 3 4; do
container="goosed-prod-${index}"
host_port=$((18005 + index))
if ! "${docker_bin}" ps --format '{{.Names}} {{.Ports}} {{.Status}}' | grep -q "^${container} .*0.0.0.0:${host_port}->18006/tcp.*healthy"; then
echo "goosed dependency check failed: ${container} is not healthy on host port ${host_port}" >&2
missing=1
fi
if ! "${docker_bin}" exec "${container}" sh -lc 'test -x /usr/local/bin/node && test -f /opt/portal/mindspace-sandbox-mcp.mjs' >/dev/null 2>&1; then
echo "goosed dependency check failed: ${container} missing /usr/local/bin/node or /opt/portal/mindspace-sandbox-mcp.mjs" >&2
missing=1
fi
done
if [[ -f /Users/john/Project/Memind/.env ]]; then
portal_mcp_node="$(grep -E '^GOOSED_MCP_NODE_PATH=' /Users/john/Project/Memind/.env | tail -1 | cut -d= -f2- || true)"
portal_mcp_server="$(grep -E '^GOOSED_MCP_SERVER_PATH=' /Users/john/Project/Memind/.env | tail -1 | cut -d= -f2- || true)"
if [[ -z "${portal_mcp_node}" || -z "${portal_mcp_server}" ]]; then
echo "goosed dependency check failed: Portal .env must set GOOSED_MCP_NODE_PATH and GOOSED_MCP_SERVER_PATH for Docker goosed" >&2
missing=1
else
for index in 1 2 3 4; do
container="goosed-prod-${index}"
if ! "${docker_bin}" exec "${container}" sh -lc "test -x '${portal_mcp_node}' && test -f '${portal_mcp_server}'" >/dev/null 2>&1; then
echo "goosed dependency check failed: ${container} cannot resolve Portal .env MCP paths '${portal_mcp_node}' and '${portal_mcp_server}'" >&2
missing=1
fi
done
fi
fi
else
for port in 18006 18007; do
line="$(lsof -nP -iTCP:${port} -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print $1, $2, $9}')"
if [[ -z "${line}" ]]; then
echo "goosed dependency check failed: port ${port} has no listener" >&2
missing=1
continue
fi
command_name="${line%% *}"
case "${command_name}" in
goosed) ;;
*)
echo "goosed dependency check failed: port ${port} is listened by ${line}, not goosed" >&2
missing=1
;;
esac
done
if ! launchctl list 2>/dev/null | grep -q 'cn.tkmind.goosed-18006'; then
echo "goosed dependency check failed: launchd service cn.tkmind.goosed-18006 is not loaded" >&2
missing=1
fi
if ! launchctl list 2>/dev/null | grep -q 'cn.tkmind.goosed-18007'; then
echo "goosed dependency check failed: launchd service cn.tkmind.goosed-18007 is not loaded" >&2
missing=1
fi
if [[ ! -x /Users/john/Project/tkmind_go/scripts/run-goosed-local.sh ]]; then
echo "goosed dependency check failed: missing /Users/john/Project/tkmind_go/scripts/run-goosed-local.sh" >&2
missing=1
fi
if [[ ! -x /Users/john/Project/tkmind_go/target/release/goosed && ! -x /Users/john/Project/tkmind_go/target/debug/goosed ]]; then
echo "goosed dependency check failed: missing goosed binary under /Users/john/Project/tkmind_go/target" >&2
missing=1
fi
fi
exit "${missing}"
REMOTE
}
if [[ "${DRY_RUN}" -ne 1 ]]; then
verify_remote_goosed_dependency
fi
if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then
say "发布确认"
echo "目标主机: ${HOST}"
+1
View File
@@ -15,6 +15,7 @@ fi
export NODE_ENV=production
export H5_PORT="${H5_PORT:-8081}"
export H5_PUBLIC_BASE_URL="https://m.tkmind.cn"
export TKMIND_API_TARGETS="${TKMIND_API_TARGETS_OVERRIDE:-${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}}"
export TKMIND_API_TARGET="${TKMIND_API_TARGET_OVERRIDE:-https://127.0.0.1:18006}"
export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1_OVERRIDE:-https://127.0.0.1:18007}"
+77 -12
View File
@@ -1,6 +1,7 @@
import express from 'express';
import crypto from 'node:crypto';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import { createProxyMiddleware } from 'http-proxy-middleware';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -23,7 +24,7 @@ import {
} from './user-auth.mjs';
import { createWikiAuth } from './wiki-auth.mjs';
import { isLocalDevHostname } from './scripts/local-test-config.mjs';
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir } from './user-publish.mjs';
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir, resolvePublicBaseUrl } from './user-publish.mjs';
import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs';
import { attachRequestId, sendData, sendError } from './api-response.mjs';
@@ -32,7 +33,7 @@ import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs';
import { createMindSpaceService, DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs';
import { ensureMindSpaceConfig } from './mindspace-config.mjs';
import { createAssetService } from './mindspace-assets.mjs';
import { createPageService, pageInternals } from './mindspace-pages.mjs';
import { createPageService, pageInternals, inlinePrivateAssetsInHtml } from './mindspace-pages.mjs';
import { createPageLiveEditService } from './mindspace-page-live-edit.mjs';
import { createPageEditSessionService } from './mindspace-page-edit-session.mjs';
import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs';
@@ -117,12 +118,21 @@ function loadEnvFile(filePath) {
loadEnvFile(path.join(__dirname, '../../.env.local'));
loadEnvFile(path.join(__dirname, '.env'));
function parseApiTargets() {
const csvTargets = (process.env.TKMIND_API_TARGETS ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const legacyTargets = [
process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
process.env.TKMIND_API_TARGET_1,
].filter(Boolean);
return [...new Set([...csvTargets, ...legacyTargets])];
}
const PORT = Number(process.env.H5_PORT ?? 8081);
const API_TARGET = process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006';
const API_TARGETS = [
API_TARGET,
...(process.env.TKMIND_API_TARGET_1 ? [process.env.TKMIND_API_TARGET_1] : []),
];
const API_TARGETS = parseApiTargets();
const API_TARGET = API_TARGETS[0] ?? 'https://127.0.0.1:18006';
const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET;
const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD;
@@ -486,6 +496,7 @@ async function bootstrapUserAuth() {
await userAuth.ensureAdminUser();
llmProviderService = createLlmProviderService(pool, {
apiTarget: API_TARGET,
apiTargets: API_TARGETS,
apiSecret: API_SECRET,
});
wordFilterService = createWordFilterService(pool);
@@ -510,6 +521,7 @@ async function bootstrapUserAuth() {
userAuth,
llmProviderService,
subscriptionService,
sessionSnapshotService,
localFetchAsset: mindSpaceAssets
? async (userId, assetId) => {
const { asset, path: assetPath } = await mindSpaceAssets.readAsset(userId, assetId);
@@ -2496,6 +2508,11 @@ api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => {
thumbnailReady = false;
}
}
const existingPage = await mindSpacePages.findPageBySourceMessage(
req.currentUser.id,
sessionId,
messageId,
).catch(() => null);
return sendData(res, req, {
contentMode: analysis.contentMode,
links: analysis.links,
@@ -2514,12 +2531,52 @@ api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => {
filename: resolvedHtml?.filename ?? analysis.filename,
hasHtmlContent: Boolean(resolvedHtml),
privacyScan: scanContent(resolvedHtml?.content ?? source.content),
existingPage: existingPage
? { id: existingPage.id, title: existingPage.title, categoryCode: existingPage.categoryCode }
: null,
});
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body);
console.log('[quick-share] bundle resolved, hasHtml:', Boolean(bundle.resolvedHtml));
if (!bundle.resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
const storageRoot =
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
const { html: localizedHtml } = await inlinePrivateAssetsInHtml(
authPool,
storageRoot,
req.currentUser.id,
bundle.resolvedHtml.content,
);
const publishDir = resolvePublishDir(__dirname, req.currentUser);
const sharedDir = path.join(publishDir, PUBLIC_ZONE_DIR, 'shared');
await fsPromises.mkdir(sharedDir, { recursive: true });
const basename = bundle.resolvedHtml.filename.replace(/\.html$/i, '');
const filename = `${basename}-${crypto.randomUUID().slice(0, 8)}.html`;
const destPath = path.join(sharedDir, filename);
await fsPromises.writeFile(destPath, localizedHtml, 'utf8');
const publishKey = req.currentUser.id;
const publicUrl = `${resolvePublicBaseUrl()}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}/${PUBLIC_ZONE_DIR}/shared/${encodeURIComponent(filename)}`;
return res.status(201).json({ data: { publicUrl, filename } });
} catch (error) {
console.error('[quick-share] error:', error);
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
if (!mindSpacePages || !mindSpaceAssets) {
return res.status(503).json({ message: 'MindSpace 未启用' });
@@ -2625,11 +2682,19 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
}).catch(() => {});
}
const page = await mindSpacePages.createFromChat(req.currentUser.id, pageInput, {
sessionId: req.body.session_id,
messageId: req.body.message_id,
snapshot,
});
const replacePageId = req.body?.replace_page_id
? String(req.body.replace_page_id).trim()
: null;
let page;
if (replacePageId) {
page = await mindSpacePages.updatePage(req.currentUser.id, replacePageId, pageInput);
} else {
page = await mindSpacePages.createFromChat(req.currentUser.id, pageInput, {
sessionId: req.body.session_id,
messageId: req.body.message_id,
snapshot,
});
}
return res.status(201).json({ data: { kind: 'page', categoryCode: 'draft', page } });
} catch (error) {
return mindSpaceError(res, req, error);
+184 -6
View File
@@ -11,11 +11,78 @@
* conversation content, so it can be dropped/truncated safely at any time.
*/
const DERIVED_TITLE_MAX_LEN = 40;
function isGeneratedSessionName(name) {
return /^20\d{6}(?:[_-]\d+)?$/.test(String(name ?? '').trim());
}
function isDefaultSessionName(name) {
const trimmed = String(name ?? '').trim();
return !trimmed || trimmed === 'New Chat' || trimmed === '新对话' || isGeneratedSessionName(trimmed);
}
/** Extract a plain-text snippet from a stored normalized message. */
function messageSnippet(message) {
const fromContent = (message?.content ?? [])
.filter((item) => item?.type === 'text' && typeof item.text === 'string')
.map((item) => item.text)
.join('')
.trim();
if (fromContent) return fromContent;
const displayText = message?.metadata?.displayText;
return typeof displayText === 'string' ? displayText.trim() : '';
}
/** Build a one-line title from the first user message of a stored conversation. */
function deriveTitleFromMessages(messages) {
if (!Array.isArray(messages)) return '';
const firstUser = messages.find((m) => m?.role === 'user' && messageSnippet(m));
const text = (firstUser ? messageSnippet(firstUser) : '')
.replace(/\s+/g, ' ')
.trim();
if (!text) return '';
return text.length > DERIVED_TITLE_MAX_LEN
? `${text.slice(0, DERIVED_TITLE_MAX_LEN)}…`
: text;
}
function resolveDisplayTitle(session, messages) {
if (session?.user_set_name) {
const explicit = String(session.name ?? '').trim();
if (explicit) return explicit;
}
const recipeTitle = String(session?.recipe?.title ?? '').trim();
if (recipeTitle) return recipeTitle;
const sessionName = String(session?.name ?? '').trim();
if (sessionName && !isDefaultSessionName(sessionName)) return sessionName;
return deriveTitleFromMessages(messages);
}
export function createSessionSnapshotService(pool) {
function isEnabled() {
return process.env.SESSION_SNAPSHOT_CACHE_ENABLED !== '0';
}
async function persistDisplayTitle(sessionId, title) {
const normalized = String(title ?? '').trim();
if (!normalized || !pool) return;
try {
await pool.query(
`UPDATE h5_session_snapshots
SET display_title = ?
WHERE agent_session_id = ?
AND (display_title IS NULL OR display_title = '')`,
[normalized, sessionId],
);
} catch (err) {
console.warn('[snapshot] persistDisplayTitle failed:', err instanceof Error ? err.message : err);
}
}
/**
* Persist a snapshot for a session.
* @param {string} sessionId
@@ -29,6 +96,7 @@ export function createSessionSnapshotService(pool) {
const now = Date.now();
const sessionMeta = {
name: session.name ?? '',
display_title: resolveDisplayTitle(session, messages),
working_dir: session.working_dir ?? '',
created_at_str: session.created_at ?? '',
updated_at_str: session.updated_at ?? '',
@@ -37,13 +105,14 @@ export function createSessionSnapshotService(pool) {
};
await pool.query(
`INSERT INTO h5_session_snapshots
(agent_session_id, user_id, name, working_dir,
(agent_session_id, user_id, name, display_title, working_dir,
created_at_str, updated_at_str, user_set_name, recipe_json,
synced_msg_count, source_updated_at,
messages_json, synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
display_title = VALUES(display_title),
working_dir = VALUES(working_dir),
created_at_str = VALUES(created_at_str),
updated_at_str = VALUES(updated_at_str),
@@ -57,6 +126,7 @@ export function createSessionSnapshotService(pool) {
sessionId,
userId,
sessionMeta.name,
sessionMeta.display_title,
sessionMeta.working_dir,
sessionMeta.created_at_str,
sessionMeta.updated_at_str,
@@ -82,7 +152,7 @@ export function createSessionSnapshotService(pool) {
if (!isEnabled() || !pool) return null;
try {
const [rows] = await pool.query(
`SELECT agent_session_id, user_id, name, working_dir,
`SELECT agent_session_id, user_id, name, display_title, working_dir,
created_at_str, updated_at_str, user_set_name, recipe_json,
synced_msg_count, source_updated_at, messages_json, synced_at
FROM h5_session_snapshots
@@ -92,10 +162,26 @@ export function createSessionSnapshotService(pool) {
);
if (!rows.length) return null;
const row = rows[0];
const parsedMessages = JSON.parse(row.messages_json);
const storedDisplayTitle = String(row.display_title ?? '').trim();
const rawName = String(row.name ?? '').trim();
const fallbackDisplayTitle =
storedDisplayTitle ||
resolveDisplayTitle(
{
name: row.name,
recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null,
user_set_name: row.user_set_name === 1,
},
parsedMessages,
);
if (!storedDisplayTitle && fallbackDisplayTitle) {
void persistDisplayTitle(sessionId, fallbackDisplayTitle);
}
return {
session: {
id: sessionId,
name: row.name,
name: fallbackDisplayTitle || rawName,
working_dir: row.working_dir,
message_count: row.synced_msg_count,
created_at: row.created_at_str || undefined,
@@ -104,7 +190,7 @@ export function createSessionSnapshotService(pool) {
recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null,
conversation: null,
},
messages: JSON.parse(row.messages_json),
messages: parsedMessages,
meta: {
synced_msg_count: row.synced_msg_count,
source_updated_at: row.source_updated_at,
@@ -153,5 +239,97 @@ export function createSessionSnapshotService(pool) {
}
}
return { save, get, remove, refresh, isEnabled };
/**
* Derive display titles from the first user message for the given session ids.
* Used to give unnamed/default sessions a meaningful label in the history list.
* Returns a Map<sessionId, title>; sessions without a usable snapshot are omitted.
*/
async function getDerivedTitles(sessionIds) {
if (!isEnabled() || !pool) return new Map();
const ids = [...new Set((sessionIds ?? []).filter(Boolean))];
if (ids.length === 0) return new Map();
const titles = new Map();
try {
const placeholders = ids.map(() => '?').join(', ');
const [rows] = await pool.query(
`SELECT agent_session_id, display_title, name, recipe_json, user_set_name, messages_json
FROM h5_session_snapshots
WHERE agent_session_id IN (${placeholders})`,
ids,
);
for (const row of rows) {
try {
const parsedMessages = JSON.parse(row.messages_json);
const title =
String(row.display_title ?? '').trim() ||
resolveDisplayTitle(
{
name: row.name,
recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null,
user_set_name: row.user_set_name === 1,
},
parsedMessages,
);
if (title) {
titles.set(row.agent_session_id, title);
if (!String(row.display_title ?? '').trim()) {
void persistDisplayTitle(row.agent_session_id, title);
}
}
} catch {
// Skip rows with unparseable message payloads.
}
}
} catch (err) {
console.warn('[snapshot] getDerivedTitles failed:', err instanceof Error ? err.message : err);
}
return titles;
}
async function getHistorySummaries(sessionIds) {
if (!isEnabled() || !pool) return new Map();
const ids = [...new Set((sessionIds ?? []).filter(Boolean))];
if (ids.length === 0) return new Map();
const summaries = new Map();
try {
const placeholders = ids.map(() => '?').join(', ');
const [rows] = await pool.query(
`SELECT agent_session_id, display_title, name, recipe_json, user_set_name,
synced_msg_count, messages_json
FROM h5_session_snapshots
WHERE agent_session_id IN (${placeholders})`,
ids,
);
for (const row of rows) {
try {
const parsedMessages = JSON.parse(row.messages_json);
const title =
String(row.display_title ?? '').trim() ||
resolveDisplayTitle(
{
name: row.name,
recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null,
user_set_name: row.user_set_name === 1,
},
parsedMessages,
);
const messageCount = Number(row.synced_msg_count ?? 0);
summaries.set(row.agent_session_id, {
title,
messageCount,
});
if (title && !String(row.display_title ?? '').trim()) {
void persistDisplayTitle(row.agent_session_id, title);
}
} catch {
// Skip rows with unparseable payloads.
}
}
} catch (err) {
console.warn('[snapshot] getHistorySummaries failed:', err instanceof Error ? err.message : err);
}
return summaries;
}
return { save, get, remove, refresh, getDerivedTitles, getHistorySummaries, isEnabled };
}
+21
View File
@@ -726,6 +726,25 @@ export function buildChatSaveThumbnailUrl(input: {
return `/api/mindspace/v1/pages/chat-save-thumbnail?${params.toString()}`;
}
export async function quickShareFromChat(input: {
sessionId: string;
messageId: string;
selectedLinkIndex?: number;
}): Promise<{ publicUrl: string; filename: string }> {
const result = await apiFetch<{ data: { publicUrl: string; filename: string } }>(
'/mindspace/v1/pages/quick-share-from-chat',
{
method: 'POST',
body: JSON.stringify({
session_id: input.sessionId,
message_id: input.messageId,
selected_link_index: input.selectedLinkIndex ?? 0,
}),
},
);
return result.data;
}
export async function fetchPreviewAsset(path: string): Promise<string> {
let res: Response;
try {
@@ -754,6 +773,7 @@ export async function saveChatMessageAsPage(input: {
categoryCode?: MindSpaceSaveCategory;
selectedLinkIndex?: number;
acknowledgedFindingIds?: string[];
replacePageId?: string;
}): Promise<ChatSaveResult> {
const result = await apiFetch<{ data: ChatSaveResult }>(
'/mindspace/v1/pages/save-from-chat',
@@ -769,6 +789,7 @@ export async function saveChatMessageAsPage(input: {
selected_link_index: input.selectedLinkIndex ?? 0,
acknowledged_finding_ids: input.acknowledgedFindingIds,
page_type: input.templateId === 'report' ? 'report' : 'article',
replace_page_id: input.replacePageId,
}),
},
);
+56 -17
View File
@@ -12,6 +12,38 @@ import type { CapabilityMap, ChatState, Message, PortalUser, Session, ToolConfir
import type { MindSpaceSaveCategory } from '../types';
const MAX_PENDING_IMAGES = 6;
const CHAT_PLACEHOLDER_PROMPTS = [
'帮我写一篇温柔一点的小短文',
'讲个轻松的笑话,让我换换脑子',
'帮我看看今天的天气和出门建议',
'规划一份周末城市漫游攻略',
'把这组数据整理成一段分析结论',
'帮我总结一份会议纪要或文档',
'写一个有记忆点的产品介绍',
'设计一个简单好玩的小游戏',
'帮我做一份旅行行程和预算',
'把一段想法改成更专业的表达',
'给我起几个品牌名和一句 slogan',
'帮我写一封礼貌但坚定的邮件',
'整理一份学习计划和每日任务',
'分析一个产品页面哪里可以优化',
'把复杂概念讲得像聊天一样简单',
'帮我生成一份短视频脚本',
'写一段适合发朋友圈的文案',
'帮我拆解一个商业想法是否可行',
'把长文压缩成三条重点',
'做一个活动策划和执行清单',
'帮我准备一次面试的回答思路',
'写一个网页或小工具的需求说明',
'帮我把表格数据变成洞察报告',
'给孩子讲一个睡前故事',
'帮我规划一周健康饮食',
'把这段话翻译得自然一点',
'帮我做一份竞品对比',
'给一个新功能设计使用流程',
'帮我写一段代码并解释思路',
'整理一个今天就能开始的行动计划',
];
type PendingChatImage = {
id: string;
@@ -76,6 +108,9 @@ export function ChatPanel({
const [uploadingImage, setUploadingImage] = useState(false);
const [imageError, setImageError] = useState<string | null>(null);
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
const [randomPrompt] = useState(
() => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)],
);
const imageInputRef = useRef<HTMLInputElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef(input);
@@ -105,6 +140,7 @@ export function ChatPanel({
const canPublish = Boolean(capabilities?.static_publish) || hasPublishSkill;
const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, { grantedSkills, canPublish });
const compact = variant === 'compact';
const showHomeWelcome = !compact && messages.length === 0;
const placeholder = offlineBlocked
? '网络断开,恢复连接后可继续输入'
@@ -112,7 +148,7 @@ export function ChatPanel({
? '正在连接会话…'
: compact
? '在这里继续和 Agent 对话…'
: '输入任务,例如:列出当前目录文件';
: randomPrompt;
const mergeVoiceText = (spoken: string) => {
const base = voiceBaseRef.current.trimEnd();
@@ -321,7 +357,7 @@ export function ChatPanel({
return (
<>
<main className={compact ? 'space-chat-panel-body' : 'main'}>
<main className={compact ? 'space-chat-panel-body' : `main${showHomeWelcome ? ' main-home' : ''}`}>
<MessageList
messages={messages}
streaming={chatState === 'streaming'}
@@ -329,6 +365,7 @@ export function ChatPanel({
onSaveAsPage={(message) => setPageSource(message)}
publishUserId={user?.id}
publishUsername={user?.username}
sessionId={session?.id}
compact={compact}
/>
{!compact && (
@@ -369,7 +406,7 @@ export function ChatPanel({
/>
)}
<footer className={compact ? 'space-chat-panel-footer' : 'footer'}>
<footer className={compact ? 'space-chat-panel-footer' : `footer${showHomeWelcome ? ' footer-home' : ''}`}>
{voiceNotice && (
<div className="voice-notice" role="status">
{voiceNotice}
@@ -399,8 +436,8 @@ export function ChatPanel({
)}
</div>
)}
<div className="chat-input-row">
<div className="chat-input-shell">
<div className={`chat-input-row${showHomeWelcome ? ' chat-input-row-home' : ''}`}>
<div className={`chat-input-shell${showHomeWelcome ? ' chat-input-shell-home' : ''}`}>
{onUploadImage && (
<>
<button
@@ -449,7 +486,7 @@ export function ChatPanel({
stopSignal={voiceStopSignal}
/>
</div>
{chatSkills.length > 0 && (
{!showHomeWelcome && chatSkills.length > 0 && (
<ChatSkillPicker
skills={chatSkills}
disabled={voiceDisabled}
@@ -457,16 +494,18 @@ export function ChatPanel({
onPrefill={setInput}
/>
)}
<button
type="button"
className="chat-skill-trigger"
title="Design Style Generator"
disabled={voiceDisabled}
onClick={() => setDesignPanelOpen(true)}
>
<DesignBtnIcon className="chat-skill-trigger-icon" />
<span className="chat-skill-trigger-label">design</span>
</button>
{!showHomeWelcome && (
<button
type="button"
className="chat-skill-trigger"
title="Design Style Generator"
disabled={voiceDisabled}
onClick={() => setDesignPanelOpen(true)}
>
<DesignBtnIcon className="chat-skill-trigger-icon" />
<span className="chat-skill-trigger-label">design</span>
</button>
)}
{designPanelOpen && (
<DesignSkillPanel
onClose={() => setDesignPanelOpen(false)}
@@ -483,7 +522,7 @@ export function ChatPanel({
) : (
<button
type="button"
className="send-btn chat-input-action"
className={`send-btn chat-input-action${showHomeWelcome ? ' chat-input-action-home' : ''}`}
disabled={!canSubmit || voiceDisabled || uploadingImage}
onClick={() => void handleSubmit()}
>
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { buildChatSavePreviewFrameUrl, quickShareFromChat } from '../api/client';
type SharePhase =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'done'; publicUrl: string }
| { kind: 'error'; message: string };
export function ChatSharePreviewModal({
sessionId,
messageId,
selectedLinkIndex = 0,
onClose,
onSave,
}: {
sessionId: string;
messageId: string;
selectedLinkIndex?: number;
onClose: () => void;
onSave?: () => void;
}) {
const [phase, setPhase] = useState<SharePhase>({ kind: 'idle' });
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const previewUrl = buildChatSavePreviewFrameUrl({ sessionId, messageId, selectedLinkIndex });
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
useEffect(() => () => { if (copyTimer.current) clearTimeout(copyTimer.current); }, []);
const share = async () => {
if (phase.kind === 'loading') return;
setPhase({ kind: 'loading' });
try {
const result = await quickShareFromChat({ sessionId, messageId, selectedLinkIndex });
setPhase({ kind: 'done', publicUrl: result.publicUrl });
} catch (err) {
setPhase({ kind: 'error', message: err instanceof Error ? err.message : '公开分享失败,请重试' });
}
};
const copy = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 2000);
} catch { /* ignore */ }
};
const handleSave = () => {
onClose();
onSave?.();
};
return createPortal(
<div className="chat-share-preview-overlay" role="dialog" aria-modal="true" aria-label="页面预览">
<div className="chat-share-preview-header">
<span className="chat-share-preview-title">页面预览</span>
<button type="button" className="chat-share-preview-close" onClick={onClose} aria-label="关闭">✕</button>
</div>
<div className="chat-share-preview-body">
<iframe src={previewUrl} title="页面预览" sandbox="allow-same-origin allow-scripts allow-popups" className="chat-share-preview-frame" />
<div className="chat-share-side-actions">
{phase.kind === 'done' ? (
<div className="chat-share-result-card">
<p className="chat-share-result-label">✅ 已公开!</p>
<button type="button" className="chat-share-result-copy" onClick={() => void copy(phase.publicUrl)}>
{copied ? '已复制 ✓' : '复制链接'}
</button>
<a href={phase.publicUrl} target="_blank" rel="noreferrer" className="chat-share-result-open">
打开页面 →
</a>
</div>
) : (
<button
type="button"
className={`chat-share-side-btn chat-share-side-btn-primary${phase.kind === 'loading' ? ' is-loading' : ''}`}
disabled={phase.kind === 'loading'}
onClick={() => void share()}
title="将页面转为公开链接,私有图片自动转为公开地址"
>
{phase.kind === 'loading' ? '处理中…' : '🌐 公开分享'}
</button>
)}
{phase.kind === 'error' && <p className="chat-share-side-error">{phase.message}</p>}
{onSave && (
<button type="button" className="chat-share-side-btn" onClick={handleSave}>
📄 保存页面
</button>
)}
</div>
</div>
</div>,
document.body,
);
}
+4 -3
View File
@@ -72,6 +72,7 @@ export function ChatView({
}, [sidebarOpen, onSidebarOpen]);
const busy = chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting';
const showHomeWelcome = messages.length === 0;
const handleSelectSession = (sessionId: string) => {
void switchSession(sessionId);
@@ -85,7 +86,7 @@ export function ChatView({
const sessionTitle = session
? getSessionDisplayName(session)
: chatState === 'connecting'
: chatState === 'idle'
? '新对话'
: '连接中…';
@@ -128,8 +129,8 @@ export function ChatView({
onDelete={(sessionId) => void deleteSession(sessionId)}
/>
<div className="app">
<header className="header">
<div className={`app${showHomeWelcome ? ' app-home' : ''}`}>
<header className={`header${showHomeWelcome ? ' header-home' : ''}`}>
<div className="header-left">
<button
type="button"
+110
View File
@@ -0,0 +1,110 @@
import { motion } from 'framer-motion';
import {
PenSquare,
Code2,
BarChart3,
FileText,
Bot,
} from 'lucide-react';
import { TKMindAvatar } from './TKMindAvatar';
const features = [
{ icon: PenSquare, text: '写作创作' },
{ icon: Code2, text: '编程开发' },
{ icon: BarChart3, text: '数据分析' },
{ icon: FileText, text: '文档总结' },
{ icon: Bot, text: '自动执行' },
];
export function ChatWelcomePanel({ compact }: { compact?: boolean }) {
if (compact) {
return (
<div className="empty-state empty-state-compact">
<TKMindAvatar />
<h2>TKMind</h2>
<p>继续和空间里的 Agent 对话</p>
</div>
);
}
return (
<div className="welcome-panel">
<div className="welcome-panel-content">
<motion.div
initial={{ opacity: 0, y: -15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="welcome-panel-avatar-wrap"
>
<TKMindAvatar size="lg" className="welcome-panel-avatar" />
</motion.div>
<motion.h2
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15 }}
className="welcome-panel-brand"
>
TKMind智趣
</motion.h2>
<motion.h1
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
className="welcome-panel-headline"
>
<span className="welcome-panel-headline-base">你的全能</span>
<span className="welcome-panel-headline-gradient">AI 助手</span>
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="welcome-panel-subtitle"
>
想到什么,就发什么。
</motion.p>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.55 }}
className="welcome-panel-description"
>
写作创作、编程开发、数据分析、文档总结、自动执行任务,统统交给 TKMind 智趣。
</motion.p>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 }}
className="welcome-panel-actions"
>
{features.map((item) => {
const Icon = item.icon;
return (
<div key={item.text} className="welcome-panel-pill">
<span className="welcome-panel-pill-icon" aria-hidden="true">
<Icon size={22} strokeWidth={2} />
</span>
<span>{item.text}</span>
</div>
);
})}
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.95 }}
className="welcome-panel-hint"
>
<div className="welcome-panel-hint-arrow">↓</div>
<div className="text-base">在下方输入你的问题或任务</div>
</motion.div>
</div>
</div>
);
}
+43 -13
View File
@@ -58,10 +58,12 @@ export function HistorySidebar({
}: HistorySidebarProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(appConfig.sessionPageSize);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
useEffect(() => {
if (open) {
setVisibleCount(appConfig.sessionPageSize);
setPendingDeleteId(null);
}
}, [open, sessions.length]);
@@ -74,14 +76,22 @@ export function HistorySidebar({
}
}, [visibleCount, sessions.length]);
const handleDelete = useCallback(
(sessionId: string, label: string) => {
if (!window.confirm(`确定删除「${label}」?此操作不可恢复。`)) return;
const handleDeleteClick = useCallback((sessionId: string) => {
setPendingDeleteId(sessionId);
}, []);
const handleConfirmDelete = useCallback(
(sessionId: string) => {
setPendingDeleteId(null);
onDelete(sessionId);
},
[onDelete],
);
const handleCancelDelete = useCallback(() => {
setPendingDeleteId(null);
}, []);
if (!open) return null;
const visibleSessions = sessions.slice(0, visibleCount);
@@ -119,26 +129,46 @@ export function HistorySidebar({
<ul className="sidebar-list">
{group.sessions.map((item) => {
const label = getSessionListLabel(item);
const isPending = pendingDeleteId === item.id;
return (
<li key={item.id} className="sidebar-item-wrap">
<li key={item.id} className={`sidebar-item-wrap${isPending ? ' sidebar-item-confirming' : ''}`}>
<button
type="button"
className={`sidebar-item ${item.id === activeSessionId ? 'sidebar-item-active' : ''}`}
onClick={() => onSelect(item.id)}
onClick={() => { if (!isPending) onSelect(item.id); }}
>
<span className="sidebar-item-title">{label}</span>
<span className="sidebar-item-meta">
{formatSessionTime(item.updated_at ?? item.created_at)}
</span>
</button>
<button
type="button"
className="sidebar-item-delete"
aria-label={`删除 ${label}`}
onClick={() => handleDelete(item.id, label)}
>
<CloseIcon />
</button>
{isPending ? (
<div className="sidebar-item-confirm">
<button
type="button"
className="sidebar-confirm-yes"
onClick={() => handleConfirmDelete(item.id)}
>
删除
</button>
<button
type="button"
className="sidebar-confirm-no"
onClick={handleCancelDelete}
>
取消
</button>
</div>
) : (
<button
type="button"
className="sidebar-item-delete"
aria-label={`删除 ${label}`}
onClick={() => handleDeleteClick(item.id)}
>
<CloseIcon />
</button>
)}
</li>
);
})}
+74 -26
View File
@@ -8,6 +8,8 @@ import { filterText } from '../utils/wordFilter';
import { formatChatTime, shouldShowTimestamp } from '../utils/time';
import { TKMindAvatar } from './TKMindAvatar';
import { UserAvatar } from './UserAvatar';
import { ChatSharePreviewModal } from './ChatSharePreviewModal';
import { ChatWelcomePanel } from './ChatWelcomePanel';
function CopyIcon() {
return (
@@ -37,18 +39,58 @@ function CheckIcon() {
);
}
function ToolSpinnerIcon() {
return (
<span className="tool-badge-spinner" aria-hidden="true">
<span />
</span>
);
}
function ToolDoneIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M5 12.5l4.5 4.5L19 7.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ToolBadge({ message }: { message: Message }) {
const tools = message.content.filter((c) => c.type === 'toolRequest' || c.type === 'toolResponse');
if (tools.length === 0) return null;
const completed = tools.filter((t) => t.type === 'toolResponse').length;
const total = tools.length;
return (
<div className="tool-badges">
{tools.map((tool, i) => (
<span key={`${message.id}-${i}`} className="tool-badge">
{tool.type === 'toolRequest'
? `🔧 ${tool.toolCall.functionName}`
: `✓ 工具完成`}
</span>
))}
{tools.map((tool, i) => {
const isRequest = tool.type === 'toolRequest';
const toolName = isRequest ? String(tool.toolCall.functionName ?? '').trim() : '';
let label: string;
if (isRequest && !toolName) {
label = `正在加载 tools (${completed}/${total})`;
} else {
label = isRequest ? toolName : '工具完成';
}
return (
<span
key={`${message.id}-${i}`}
className={`tool-badge${isRequest ? ' is-pending' : ' is-complete'}`}
>
{isRequest ? <ToolSpinnerIcon /> : <ToolDoneIcon />}
<span>{label}</span>
</span>
);
})}
</div>
);
}
@@ -126,6 +168,7 @@ function MessageRow({
saveDisabled,
publishUserId,
publishUsername,
sessionId,
compact = false,
}: {
message: Message;
@@ -135,9 +178,11 @@ function MessageRow({
saveDisabled?: boolean;
publishUserId?: string;
publishUsername?: string;
sessionId?: string;
compact?: boolean;
}) {
const [actionsOpen, setActionsOpen] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const rawText = getDisplayText(message);
const text = filterText(rawText);
const saveActions = getMessageSaveActions(text, { userId: publishUserId, username: publishUsername });
@@ -227,15 +272,14 @@ function MessageRow({
>
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
</button>
{saveActions.previewUrl && (
<a
{saveActions.previewUrl && sessionId && message.id && (
<button
type="button"
className="msg-open-preview"
href={saveActions.previewUrl}
target="_blank"
rel="noreferrer"
onClick={() => setPreviewOpen(true)}
>
打开预览
</a>
</button>
)}
</>
)}
@@ -254,15 +298,14 @@ function MessageRow({
>
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
</button>
{saveActions.previewUrl && (
<a
{saveActions.previewUrl && sessionId && message.id && (
<button
type="button"
className="msg-open-preview"
href={saveActions.previewUrl}
target="_blank"
rel="noreferrer"
onClick={() => setPreviewOpen(true)}
>
打开预览
</a>
</button>
)}
</>
)}
@@ -278,6 +321,14 @@ function MessageRow({
title={onAvatarClick ? '点击更换头像' : undefined}
/>
)}
{previewOpen && sessionId && message.id && (
<ChatSharePreviewModal
sessionId={sessionId}
messageId={message.id}
onClose={() => setPreviewOpen(false)}
onSave={onSaveAsPage ? () => onSaveAsPage(message) : undefined}
/>
)}
</div>
);
}
@@ -289,6 +340,7 @@ export function MessageList({
onSaveAsPage,
publishUserId,
publishUsername,
sessionId,
compact = false,
}: {
messages: Message[];
@@ -297,19 +349,14 @@ export function MessageList({
onSaveAsPage?: (message: Message) => void;
publishUserId?: string;
publishUsername?: string;
sessionId?: string;
compact?: boolean;
}) {
const { avatarUrl } = useUserAvatar();
return (
<div className="message-list">
{messages.length === 0 && (
<div className={`empty-state${compact ? ' empty-state-compact' : ''}`}>
<TKMindAvatar />
<h2>TKMind</h2>
<p>{compact ? '继续和空间里的 Agent 对话' : '发送消息即可在本机执行 Agent 任务'}</p>
</div>
)}
{messages.length === 0 && <ChatWelcomePanel compact={compact} />}
{messages.map((message, index) => {
const prev = index > 0 ? messages[index - 1] : undefined;
const showTime = shouldShowTimestamp(message.created, prev?.created);
@@ -324,6 +371,7 @@ export function MessageList({
saveDisabled={streaming}
publishUserId={publishUserId}
publishUsername={publishUsername}
sessionId={sessionId}
compact={compact}
/>
</div>
+17
View File
@@ -54,11 +54,15 @@ export function MindSpaceFeedCard({
previewMode = false,
onClick,
actions,
checked,
onCheckChange,
}: {
page: MindSpacePage;
previewMode?: boolean;
onClick: () => void;
actions?: { label: string; onClick: (e: React.MouseEvent) => void }[];
checked?: boolean;
onCheckChange?: (checked: boolean) => void;
}) {
const typeLabel = feedTypeLabel(page);
const isHtml = page.contentFormat === 'html';
@@ -68,6 +72,19 @@ export function MindSpaceFeedCard({
<div className="mindspace-feed-card-wrap">
<button type="button" className="mindspace-feed-card" onClick={onClick}>
<div className="mindspace-feed-cover">
{onCheckChange !== undefined && (
<label
className="mindspace-feed-select"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={checked ?? false}
onChange={(e) => onCheckChange(e.target.checked)}
/>
<span>{checked ? '已选' : '选择'}</span>
</label>
)}
{isHtml ? (
<WorkCover
coverMode="thumbnail-first"
+92 -15
View File
@@ -476,6 +476,8 @@ export function MindSpaceView({
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const [bulkDeletingAssets, setBulkDeletingAssets] = useState(false);
const [selectedDraftPageIds, setSelectedDraftPageIds] = useState<string[]>([]);
const [bulkDeletingDraftPages, setBulkDeletingDraftPages] = useState(false);
const [newPageOpen, setNewPageOpen] = useState(false);
const [newPageTitle, setNewPageTitle] = useState('');
const [newPageContent, setNewPageContent] = useState('');
@@ -1335,6 +1337,55 @@ export function MindSpaceView({
}
};
const selectedDraftPageSet = useMemo(() => new Set(selectedDraftPageIds), [selectedDraftPageIds]);
const allDraftPagesSelected =
pages.length > 0 && pages.every((p) => selectedDraftPageSet.has(p.id));
const toggleDraftPageSelected = (pageId: string) => {
setSelectedDraftPageIds((ids) =>
ids.includes(pageId) ? ids.filter((id) => id !== pageId) : [...ids, pageId],
);
};
const toggleAllDraftPagesSelected = () => {
if (allDraftPagesSelected) {
setSelectedDraftPageIds([]);
} else {
setSelectedDraftPageIds(pages.map((p) => p.id));
}
};
const removeDraftPages = async () => {
if (selectedDraftPageIds.length === 0) return;
if (previewMode) {
setError('预览模式下无法删除页面');
return;
}
setBulkDeletingDraftPages(true);
setError(null);
const deletingIds = [...selectedDraftPageIds];
const failedIds: string[] = [];
const failedNames: string[] = [];
try {
for (const pageId of deletingIds) {
try {
await deleteMindSpacePage(pageId);
if (selectedPageId === pageId) closePage();
} catch {
failedIds.push(pageId);
failedNames.push(pages.find((p) => p.id === pageId)?.title ?? pageId);
}
}
setSelectedDraftPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
await load();
if (failedNames.length > 0) {
setError(`有 ${failedNames.length} 个页面删除失败:${failedNames.slice(0, 3).join('、')}`);
}
} finally {
setBulkDeletingDraftPages(false);
}
};
const previewAsset = useMemo(
() => assets.find((asset) => asset.id === previewAssetId) ?? null,
[assets, previewAssetId],
@@ -1362,11 +1413,6 @@ export function MindSpaceView({
发布到广场
</button>
)}
{canGenerateWithAgent(asset) && (
<button type="button" onClick={() => openAgentGenerator(asset)}>
AI 生成页面
</button>
)}
{asset.publicUrl && (
<a
className="mindspace-asset-share"
@@ -2004,16 +2050,47 @@ export function MindSpaceView({
{selectedCategory.code === 'draft' ? (
pages.length > 0 ? (
<div className="mindspace-feed-grid">
{pages.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
previewMode={previewMode}
onClick={() => showPage(page.id)}
/>
))}
</div>
<>
<div className="mindspace-asset-bulkbar">
<button
type="button"
onClick={toggleAllDraftPagesSelected}
disabled={bulkDeletingDraftPages}
>
{allDraftPagesSelected ? '取消全选' : '全选'}
</button>
<span>已选 {selectedDraftPageIds.length} 项</span>
{selectedDraftPageIds.length > 0 && (
<button
type="button"
onClick={() => setSelectedDraftPageIds([])}
disabled={bulkDeletingDraftPages}
>
清空
</button>
)}
<button
type="button"
className="mindspace-asset-bulk-delete"
onClick={() => void removeDraftPages()}
disabled={selectedDraftPageIds.length === 0 || bulkDeletingDraftPages}
>
{bulkDeletingDraftPages ? '删除中…' : '删除选中'}
</button>
</div>
<div className="mindspace-feed-grid">
{pages.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
previewMode={previewMode}
onClick={() => showPage(page.id)}
checked={selectedDraftPageSet.has(page.id)}
onCheckChange={() => toggleDraftPageSelected(page.id)}
/>
))}
</div>
</>
) : (
<div className="mindspace-empty">
<h2>还没有页面草稿</h2>
+64 -24
View File
@@ -68,6 +68,7 @@ export function PageSaveDialog({
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveNotice, setSaveNotice] = useState<string | null>(null);
const [duplicateResolved, setDuplicateResolved] = useState<'replace' | 'new' | null>(null);
const [privateAcknowledged, setPrivateAcknowledged] = useState(false);
const [thumbnailVersion, setThumbnailVersion] = useState(0);
const [previewScrollLock, setPreviewScrollLock] = useState(false);
@@ -151,7 +152,10 @@ export function PageSaveDialog({
const privateNeedsAck =
categoryCode === 'private' && privacyFindings.length > 0 && analysis?.privacyScan.allowed;
const save = async () => {
const existingPage = analysis?.existingPage ?? null;
const showDuplicatePrompt = existingPage !== null && duplicateResolved === null && !saveNotice;
const save = async (replacePageId?: string) => {
if (!title.trim()) return;
setSaving(true);
setSaveError(null);
@@ -168,10 +172,11 @@ export function PageSaveDialog({
categoryCode === 'private' && privateNeedsAck && privateAcknowledged
? privacyFindings.map((finding) => finding.id)
: undefined,
replacePageId,
});
if (result.kind === 'page') {
setSaveNotice(`已保存到${CATEGORY_LABELS[categoryCode]}`);
onSaved({ kind: 'page', categoryCode: 'draft', pageId: result.page.id });
setSaveNotice(replacePageId ? '已替换原有页面' : `已保存到${CATEGORY_LABELS[categoryCode]}`);
onSaved({ kind: 'page', categoryCode, pageId: result.page.id });
return;
}
setSaveNotice(`已保存到${CATEGORY_LABELS[result.categoryCode]}`);
@@ -202,7 +207,29 @@ export function PageSaveDialog({
{analysisLoading && <div className="page-save-state">正在识别页面内容…</div>}
{analysisError && <div className="page-save-error">{analysisError}</div>}
{!analysisLoading && analysis && (
{!analysisLoading && showDuplicatePrompt && (
<div className="page-save-duplicate">
<p>此消息已保存为「{existingPage!.title}」,请选择操作:</p>
<div className="page-save-duplicate-actions">
<button
type="button"
className="page-save-duplicate-btn"
onClick={() => setDuplicateResolved('replace')}
>
替换旧版本
</button>
<button
type="button"
className="page-save-duplicate-btn page-save-duplicate-btn-new"
onClick={() => setDuplicateResolved('new')}
>
另存为新页面
</button>
</div>
</div>
)}
{!analysisLoading && analysis && !showDuplicatePrompt && (
<div className="page-save-layout">
<div className="page-save-form">
{isStaticHtml && analysis && (
@@ -225,6 +252,7 @@ export function PageSaveDialog({
onChange={(event) => {
const nextIndex = Number(event.target.value);
setSelectedLinkIndex(nextIndex);
setDuplicateResolved(null);
void loadAnalysis(nextIndex);
}}
>
@@ -362,26 +390,38 @@ export function PageSaveDialog({
{saveError && <div className="page-save-error">{saveError}</div>}
</div>
<div className="page-save-actions">
<button type="button" onClick={onClose} disabled={saving}>
取消
</button>
<button
type="button"
className="page-save-primary"
onClick={() => void save()}
disabled={
saving ||
analysisLoading ||
!title.trim() ||
Boolean(analysisError) ||
privateBlocked ||
(privateNeedsAck && !privateAcknowledged)
}
>
{saving ? '正在保存…' : categoryCode === 'draft' ? '保存并进入编辑' : '保存到空间'}
</button>
</div>
{!showDuplicatePrompt && (
<div className="page-save-actions">
<button type="button" onClick={onClose} disabled={saving}>
取消
</button>
<button
type="button"
className="page-save-primary"
onClick={() =>
void save(
duplicateResolved === 'replace' && existingPage ? existingPage.id : undefined,
)
}
disabled={
saving ||
analysisLoading ||
!title.trim() ||
Boolean(analysisError) ||
privateBlocked ||
(privateNeedsAck && !privateAcknowledged)
}
>
{saving
? '正在保存…'
: duplicateResolved === 'replace'
? '替换并进入编辑'
: categoryCode === 'draft'
? '保存并进入编辑'
: '保存到空间'}
</button>
</div>
)}
</section>
</div>,
document.body,
+2 -2
View File
@@ -2,11 +2,11 @@ import tkmindAvatar from '../assets/tkmind-avatar.png';
type TKMindAvatarProps = {
className?: string;
size?: 'sm' | 'md';
size?: 'sm' | 'md' | 'lg';
};
export function TKMindAvatar({ className = '', size = 'sm' }: TKMindAvatarProps) {
const sizeClass = size === 'md' ? 'tkmind-avatar-md' : 'tkmind-avatar-sm';
const sizeClass = size === 'lg' ? 'tkmind-avatar-lg' : size === 'md' ? 'tkmind-avatar-md' : 'tkmind-avatar-sm';
return (
<div
+73 -53
View File
@@ -52,6 +52,7 @@ import {
import { buildAbsoluteAssetImageUrl } from '../utils/mindspaceCards';
import {
buildUserMessage,
mergeConversationSnapshot,
normalizeConversationMessages,
getDisplayText,
getToolConfirmation,
@@ -60,7 +61,12 @@ import {
isRelayServerErrorMessage,
pushMessage,
} from '../utils/message';
import { prependUnique, shouldShowNewChatTitle, sortAndTrim, touchSession } from '../utils/sessions';
import {
mergeSessionLists,
prependUnique,
shouldShowNewChatTitle,
touchSession,
} from '../utils/sessions';
const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用';
@@ -338,7 +344,7 @@ export function useTKMindChat(
setSessionsLoading(true);
try {
const items = await listSessions();
setSessions(sortAndTrim(items));
setSessions((prev) => mergeSessionLists(prev, items));
} catch (err) {
if (err instanceof ApiError && err.status === 0) {
setError('网络不可用,无法刷新历史列表');
@@ -385,6 +391,7 @@ export function useTKMindChat(
const syncSessionMessages = useCallback(async (sessionId: string) => {
try {
const detail = await loadSessionDetail(sessionId);
setSessions((prev) => prependUnique(prev, detail.session));
if (sessionRef.current?.id !== sessionId) return;
messagesRef.current = detail.messages;
setMessages(detail.messages);
@@ -467,12 +474,14 @@ export function useTKMindChat(
}
return;
}
case 'UpdateConversation':
messagesRef.current = normalizeConversationMessages(
case 'UpdateConversation': {
const snapshot = normalizeConversationMessages(
event.conversation.filter((m) => m.metadata?.userVisible),
);
messagesRef.current = mergeConversationSnapshot(messagesRef.current, snapshot);
setMessages(messagesRef.current);
return;
}
case 'Error':
setError(event.error);
setChatState('error');
@@ -519,11 +528,22 @@ export function useTKMindChat(
sessionId,
(event) => {
const rid = activeRequestId.current;
if (rid) processEvent(event, rid, sessionId);
else if (event.type === 'ActiveRequests' && event.request_ids.length > 0) {
activeRequestId.current = event.request_ids[0];
setChatState('streaming');
if (event.type === 'ActiveRequests') {
if (!rid && event.request_ids.length > 0) {
// SSE reconnected while agent was running — adopt the active request.
activeRequestId.current = event.request_ids[0];
setChatState('streaming');
} 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);
}
return;
}
if (rid) processEvent(event, rid, sessionId);
},
(err) => {
if (err instanceof ApiError && err.status === 401) return;
@@ -531,6 +551,8 @@ export function useTKMindChat(
notifyInsufficientBalance();
return;
}
// SSE 断连时如果 agent 还在跑,不打断 chatState,等重连后事件恢复
if (activeRequestId.current) return;
setError(err.message);
},
{
@@ -643,7 +665,6 @@ export function useTKMindChat(
setError(null);
const previousSessionId = readStoredSessionId(userRef.current?.id);
let sessionId: string | null = null;
let staleSession = false;
if (previousSessionId) {
@@ -667,20 +688,12 @@ export function useTKMindChat(
}
}
const freshSession = await startSession();
sessionId = freshSession.id;
writeStoredSessionId(userRef.current?.id, sessionId);
if (staleSession) {
setNotice('上次会话已失效,已为你新建对话');
}
if (cancelled) return;
await connectSessionRef.current(sessionId, {
skipResume: true,
seedSession: freshSession,
});
if (cancelled) return;
void loadProjectMemory(sessionId, false);
setChatState('idle');
void refreshSessions();
} catch (err) {
if (!cancelled) {
@@ -745,7 +758,7 @@ export function useTKMindChat(
imageUrls?: string[],
) => {
const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
if (!session || (!text.trim() && normalizedImageUrls.length === 0)) return;
if (!text.trim() && normalizedImageUrls.length === 0) return;
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
const trimmed = text.trim();
@@ -764,15 +777,41 @@ export function useTKMindChat(
activeRequestId.current = requestId;
messagesRef.current = [...messagesRef.current, userMessage];
setMessages(messagesRef.current);
setSessions((prev) => touchSession(prev, session.id, 1));
setChatState('streaming');
setError(null);
setPendingTool(null);
let activeSessionId = session?.id ?? null;
if (!activeSessionId) {
try {
const created = await startSession();
activeSessionId = created.id;
writeStoredSessionId(userRef.current?.id, created.id);
setSession(created);
setSessions((prev) => prependUnique(prev, created));
subscribeToSession(created.id);
void ensureProvider(created.id);
void loadProjectMemory(created.id, false);
void refreshSessions();
} catch (err) {
if (err instanceof ApiError && err.status === 402) {
notifyInsufficientBalance();
} else {
setError(err instanceof Error ? err.message : String(err));
setChatState('error');
}
activeRequestId.current = null;
return;
}
} else {
setSessions((prev) => touchSession(prev, activeSessionId!, 1));
}
try {
await sendReply(session.id, requestId, userMessage);
await sendReply(activeSessionId, requestId, userMessage);
} catch (err) {
setSessions((prev) => touchSession(prev, session.id, -1));
if (session) setSessions((prev) => touchSession(prev, activeSessionId!, -1));
if (err instanceof ApiError && err.status === 402) {
notifyInsufficientBalance();
} else {
@@ -782,7 +821,7 @@ export function useTKMindChat(
activeRequestId.current = null;
}
},
[notifyInsufficientBalance, session, chatState, grantedSkills],
[notifyInsufficientBalance, session, chatState, grantedSkills, subscribeToSession, ensureProvider, loadProjectMemory, refreshSessions],
);
const stop = useCallback(async () => {
@@ -808,37 +847,18 @@ export function useTKMindChat(
const newSession = useCallback(() => {
if (chatState === 'streaming') return;
resetSessionView();
connectTokenRef.current += 1;
unsubscribeRef.current?.();
unsubscribeRef.current = null;
clearStoredSessionId(userRef.current?.id);
activeRequestId.current = null;
messagesRef.current = [];
setMessages([]);
setSession(null);
const token = connectTokenRef.current;
void (async () => {
try {
const created = await startSession();
if (token !== connectTokenRef.current) return;
writeStoredSessionId(userRef.current?.id, created.id);
setSession(created);
setSessions((prev) => prependUnique(prev, created));
void loadProjectMemory(created.id, false);
void refreshSessions();
await ensureProvider(created.id);
if (token !== connectTokenRef.current) return;
await connectSession(created.id, { showLoading: false });
} catch (err) {
if (token !== connectTokenRef.current) return;
if (err instanceof ApiError && err.status === 402) {
notifyInsufficientBalance();
return;
}
setError(err instanceof Error ? err.message : String(err));
setChatState('error');
}
})();
}, [chatState, connectSession, ensureProvider, loadProjectMemory, notifyInsufficientBalance, refreshSessions, resetSessionView]);
setError(null);
setPendingTool(null);
setChatState('idle');
}, [chatState]);
const refreshProjectMemory = useCallback(async () => {
if (!session || chatState === 'streaming') return;
+880 -30
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -515,6 +515,7 @@ export type ChatSaveAnalysis = {
filename: string | null;
hasHtmlContent: boolean;
privacyScan: MindSpacePrivacyScan;
existingPage: { id: string; title: string; categoryCode: string } | null;
};
export type ChatSaveResult =
+20
View File
@@ -113,6 +113,26 @@ 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.
*
* `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.
*/
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;
}
export function getDisplayText(message: Message): string {
if ('displayText' in message.metadata) {
return message.metadata.displayText ?? '';
+46 -5
View File
@@ -3,9 +3,18 @@ import type { Session } from '../types';
export const DEFAULT_CHAT_TITLE = 'New Chat';
function isGeneratedSessionName(name: string): boolean {
return /^20\d{6}(?:[_-]\d+)?$/.test(name.trim());
}
function isDefaultSessionName(name: string): boolean {
const trimmed = name.trim();
return !trimmed || trimmed === DEFAULT_CHAT_TITLE || trimmed === '新对话' || isGeneratedSessionName(trimmed);
}
export function shouldShowNewChatTitle(session: Session): boolean {
if (session.recipe) return false;
return !session.user_set_name && session.message_count === 0;
return !session.user_set_name && session.message_count === 0 && isDefaultSessionName(session.name);
}
export function sortAndTrim(sessions: Session[]): Session[] {
@@ -18,9 +27,39 @@ export function sortAndTrim(sessions: Session[]): Session[] {
.slice(0, appConfig.sessionMaxCount);
}
export function hasMeaningfulSessionTitle(session: Session): boolean {
if (session.user_set_name) return Boolean(session.name.trim());
if (session.recipe?.title?.trim()) return true;
return !isDefaultSessionName(session.name);
}
export function mergeSessionRecord(existing: Session | undefined, incoming: Session): Session {
if (!existing) return incoming;
const keepExistingTitle =
hasMeaningfulSessionTitle(existing) && !hasMeaningfulSessionTitle(incoming);
if (!keepExistingTitle) return { ...existing, ...incoming, id: incoming.id };
return {
...existing,
...incoming,
id: incoming.id,
name: existing.name,
user_set_name: existing.user_set_name,
recipe: existing.recipe ?? incoming.recipe,
};
}
export function mergeSessionLists(prev: Session[], incoming: Session[]): Session[] {
const previousById = new Map(prev.map((item) => [item.id, item]));
return sortAndTrim(incoming.map((item) => mergeSessionRecord(previousById.get(item.id), item)));
}
export function prependUnique(prev: Session[], session: Session): Session[] {
if (prev.some((s) => s.id === session.id)) return sortAndTrim(prev);
return sortAndTrim([session, ...prev.filter((s) => s.id !== session.id)]);
return sortAndTrim([
mergeSessionRecord(prev.find((s) => s.id === session.id), session),
...prev.filter((s) => s.id !== session.id),
]);
}
export function touchSession(sessions: Session[], sessionId: string, messageDelta = 0): Session[] {
@@ -102,9 +141,11 @@ export function groupSessionsByDate(sessions: Session[]): SessionDateGroup[] {
return groups;
}
const DEFAULT_SESSION_NAMES = new Set([DEFAULT_CHAT_TITLE, '新对话']);
export function getSessionListLabel(session: Session): string {
const displayName = getSessionDisplayName(session);
if (displayName !== DEFAULT_CHAT_TITLE && displayName !== '新对话') {
const displayName = getSessionDisplayName(session).trim();
if (displayName && !DEFAULT_SESSION_NAMES.has(displayName) && !isGeneratedSessionName(displayName)) {
return displayName;
}
if (session.message_count > 0) {
+55 -5
View File
@@ -266,7 +266,7 @@ export async function buildVisionPayload({
};
}
export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth, llmProviderService, localFetchAsset, subscriptionService }) {
export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth, llmProviderService, localFetchAsset, subscriptionService, sessionSnapshotService }) {
const targets = apiTargets?.length ? apiTargets : apiTarget ? [apiTarget] : [];
const primaryTarget = targets[0] ?? apiTarget ?? '';
let rrIdx = 0;
@@ -296,6 +296,45 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
}
}
function isGeneratedSessionName(name) {
const normalized = typeof name === 'string' ? name.trim() : '';
return /^20\d{6}(?:[_-]\d+)?$/.test(normalized);
}
// A session "name" that carries no real topic: empty, or Goose's default
// placeholder before its describe step has run.
function hasDefaultSessionName(session) {
if (session?.user_set_name) return false;
const name = typeof session?.name === 'string' ? session.name.trim() : '';
return name === '' || name === 'New Chat' || name === '新对话' || isGeneratedSessionName(name);
}
// For sessions still carrying a default name, fill in a title derived from the
// first user message (kept in the snapshot cache) so the history list shows a
// meaningful label instead of "会话 <id>". Best-effort: never blocks the list.
async function enrichSessionHistory(sessions) {
if (!sessionSnapshotService?.isEnabled?.()) return;
const needsSummary = sessions.filter(
(session) => hasDefaultSessionName(session) || Number(session?.message_count ?? 0) === 0,
);
if (needsSummary.length === 0) return;
try {
const summaries = await sessionSnapshotService.getHistorySummaries(needsSummary.map((s) => s.id));
for (const session of needsSummary) {
const summary = summaries.get(session.id);
if (!summary) continue;
if (summary.title && hasDefaultSessionName(session)) {
session.name = summary.title;
}
if (Number(session?.message_count ?? 0) === 0 && Number(summary.messageCount ?? 0) > 0) {
session.message_count = Number(summary.messageCount);
}
}
} catch {
// Non-fatal: fall back to the client-side label.
}
}
async function pickTarget() {
if (targets.length <= 1) return primaryTarget;
for (let i = 0; i < targets.length; i += 1) {
@@ -632,7 +671,10 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
if (healthyTargets < targets.length) {
res.setHeader('X-TKMind-Degraded', '1');
}
res.json({ sessions: [...sessionsById.values()] });
const sessions = [...sessionsById.values()];
await enrichSessionHistory(sessions);
res.json({ sessions });
} catch (err) {
res.status(500).json({
message: sanitizeUserFacingProxyMessage(
@@ -725,6 +767,13 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
});
const source = Readable.fromWeb(upstream.body);
// 每 20s 发一个注释行保持连接,防止 nginx/代理因静默超时切断 SSE
const keepalive = setInterval(() => {
if (!res.writableEnded) res.write(': keepalive\n\n');
}, 20000);
const stopKeepalive = () => clearInterval(keepalive);
billingTransform.on('data', (chunk) => {
res.write(chunk);
if (pendingBalance != null) {
@@ -732,9 +781,10 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
pendingBalance = null;
}
});
billingTransform.on('end', () => res.end());
billingTransform.on('error', () => res.end());
source.on('error', () => res.end());
billingTransform.on('end', () => { stopKeepalive(); res.end(); });
billingTransform.on('error', () => { stopKeepalive(); res.end(); });
source.on('error', () => { stopKeepalive(); res.end(); });
req.on('close', stopKeepalive);
source.pipe(billingTransform);
} catch (err) {
res.status(502).json({
+7 -2
View File
@@ -1662,9 +1662,15 @@ export function createUserAuth(pool, options = {}) {
try {
const layout = await publishLayoutFor(user, { migrateLegacy: false });
sandboxMcp = {
serverPath: resolveSandboxMcpServerPath(),
// When goosed runs in a container its filesystem is split from the portal's,
// so the host paths the portal would otherwise send (node binary, MCP script)
// are not resolvable. These env overrides let the portal send container-canonical
// paths instead. Unset (e.g. local dev, co-located native goosed) => fall back to
// the portal's own paths, so behavior is unchanged.
serverPath: resolveSandboxMcpServerPath(process.env.GOOSED_MCP_SERVER_PATH),
sandboxRoot: layout.publishDir,
userId: user.id,
nodeExecPath: process.env.GOOSED_MCP_NODE_PATH,
};
} catch (err) {
console.warn('[getAgentSessionPolicy] sandbox MCP setup failed, falling back:', err?.message);
@@ -2708,7 +2714,6 @@ export function createUserAuth(pool, options = {}) {
policyCatalog: POLICY_CATALOG,
skillCatalog,
publicUser,
getUserById,
};
}
+1 -1
View File
@@ -57,7 +57,7 @@ test('publish dir and public url use stable user id', () => {
const skillPath = path.join(layout.publishDir, '.agents', 'skills', 'static-page-publish', 'SKILL.md');
assert.ok(fs.existsSync(skillPath));
const skillText = fs.readFileSync(skillPath, 'utf8');
assert.match(skillText, new RegExp(`g2\\.tkmind\\.cn/${PUBLISH_ROOT_DIR}/${USER_ID}/${PUBLIC_ZONE_DIR}/`));
assert.match(skillText, new RegExp(`m\\.tkmind\\.cn/${PUBLISH_ROOT_DIR}/${USER_ID}/${PUBLIC_ZONE_DIR}/`));
assert.match(skillText, /public\/report\.html/);
assert.match(skillText, /\[.*\]\(.*\)/);
assert.match(skillText, /mindspace-cover/);
+187 -10
View File
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fetch as undiciFetch } from 'undici';
import { developerToolsFromPolicy } from './capabilities.mjs';
import { mergeMessageContent } from './message-stream.mjs';
import { reconcileAgentSession } from './session-reconcile.mjs';
import { isScheduleIntent, parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs';
@@ -23,6 +24,16 @@ const DEFAULT_UNBOUND_TEXT = '先点这里完成绑定,再继续和专属 Agen
const DEFAULT_PROGRESS_DELAY_MS = 8000;
const PUBLIC_HTML_LINK_PATTERN =
/https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi;
const SESSION_WORKSPACE_TOOL_NAMES = new Set([
'write',
'edit',
'tree',
'read_file',
'write_file',
'edit_file',
'create_dir',
'list_dir',
]);
function parseXmlField(xml, field) {
const cdataMatch = xml.match(new RegExp(`<${field}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${field}>`));
@@ -182,6 +193,7 @@ async function executeSessionReply(apiFetch, sessionId, requestId, prompt, metad
const decoder = new TextDecoder();
let buffer = '';
let messages = [];
let hasScopedAssistantUpdate = false;
while (true) {
const { value, done } = await reader.read();
@@ -209,16 +221,26 @@ async function executeSessionReply(apiFetch, sessionId, requestId, prompt, metad
if (hasActionRequired) {
throw new Error('当前回复需要人工确认,公众号通道暂不支持');
}
if (event.message.role === 'assistant') hasScopedAssistantUpdate = true;
messages = pushMessage(messages, event.message);
} else if (event.type === 'UpdateConversation') {
messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible);
// Ignore unscoped snapshots until this request has yielded an assistant update.
// Otherwise a stale session snapshot can overwrite the current reply with a
// previous page/link from the same WeChat-dedicated session.
if (hasScopedAssistantUpdate) {
messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible);
}
} else if (event.type === 'Error') {
throw new Error(event.error || '任务执行失败');
} else if (event.type === 'Finish') {
const assistant = [...messages].reverse().find((item) => item.role === 'assistant');
if (!hasScopedAssistantUpdate || !assistant) {
throw new Error('本轮未收到可发送的新回复,请稍后重试');
}
return {
text: messageVisibleText(assistant),
tokenState: event.token_state ?? null,
messages,
};
}
}
@@ -253,6 +275,117 @@ function splitWechatText(text, maxBytes = WECHAT_CUSTOMER_TEXT_MAX_BYTES) {
export { splitWechatText, WECHAT_CUSTOMER_TEXT_MAX_BYTES };
function flattenSessionTools(extensions = []) {
return new Set(
extensions.flatMap((extension) =>
Array.isArray(extension?.available_tools) ? extension.available_tools : [],
),
);
}
function needsWorkspaceTools(sessionPolicy) {
return developerToolsFromPolicy(sessionPolicy).some((tool) => SESSION_WORKSPACE_TOOL_NAMES.has(tool));
}
async function readSessionExtensions(fetchForSession, sessionId) {
const payload = await readJsonResponse(await fetchForSession(sessionId, `/sessions/${sessionId}/extensions`));
return payload?.extensions ?? [];
}
async function sessionHasRequiredTools(fetchForSession, sessionId, sessionPolicy) {
if (!needsWorkspaceTools(sessionPolicy)) return true;
const desired = developerToolsFromPolicy(sessionPolicy).filter((tool) => SESSION_WORKSPACE_TOOL_NAMES.has(tool));
if (desired.length === 0) return true;
const available = flattenSessionTools(await readSessionExtensions(fetchForSession, sessionId));
return desired.some((tool) => available.has(tool));
}
function extractHtmlWriteTargets(messages = []) {
const targets = new Set();
for (const message of messages) {
for (const item of message?.content ?? []) {
if (item?.type !== 'toolRequest') continue;
const toolCall = item.toolCall?.value;
const name = toolCall?.name;
const args = toolCall?.arguments ?? {};
const action = String(args.action ?? '').toLowerCase();
const writeLikeDeveloper = name === 'developer' && action === 'write';
const writeLikeSandbox = (name === 'write_file' || name === 'edit_file') && typeof args.path === 'string';
if (!writeLikeDeveloper && !writeLikeSandbox) continue;
const candidate = String(args.path ?? '').trim();
if (candidate.toLowerCase().endsWith('.html')) targets.add(candidate);
}
}
return [...targets];
}
function looksLikeHtmlGenerationIntent(text) {
const normalized = String(text ?? '').trim().toLowerCase();
if (!normalized) return false;
return /(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/i.test(normalized);
}
function isBareCompletionText(text) {
const normalized = String(text ?? '').trim();
return /^(?:已完成|完成了|完成)$/u.test(normalized);
}
function hasAnyToolRequest(messages = []) {
return messages.some((message) =>
message?.content?.some((item) => item?.type === 'toolRequest'),
);
}
function isSuspiciousBareCompletionReply(reply, intent) {
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
if (!isBareCompletionText(reply?.text)) return false;
if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false;
return !hasAnyToolRequest(reply?.messages ?? []);
}
function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) {
const owner = path.basename(path.resolve(workingDir));
const normalized = relativePath.split(path.sep).map(encodeURIComponent).join('/');
return `${String(publicBaseUrl ?? '').replace(/\/+$/, '')}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(owner)}/${normalized}`;
}
function ensurePublicHtmlArtifact(htmlPath, workingDir) {
const workspaceRoot = path.resolve(workingDir);
const source = path.resolve(htmlPath);
if (source !== workspaceRoot && !source.startsWith(`${workspaceRoot}${path.sep}`)) return null;
if (!fs.existsSync(source) || !fs.statSync(source).isFile()) return null;
const publicRoot = path.join(workspaceRoot, 'public');
let publishedPath = source;
if (source !== publicRoot && !source.startsWith(`${publicRoot}${path.sep}`)) {
fs.mkdirSync(publicRoot, { recursive: true });
publishedPath = path.join(publicRoot, path.basename(source));
if (publishedPath !== source) fs.copyFileSync(source, publishedPath);
}
const relativePath = path.relative(workspaceRoot, publishedPath);
if (!relativePath || relativePath.startsWith('..')) return null;
return {
localPath: publishedPath,
relativePath,
};
}
async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl }) {
const baseText = String(reply?.text ?? '').trim();
const htmlTargets = extractHtmlWriteTargets(reply?.messages ?? []);
for (const target of htmlTargets) {
const artifact = ensurePublicHtmlArtifact(target, workingDir);
if (!artifact) continue;
const url = buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl);
if (baseText.includes(url)) return baseText;
return baseText
? `${baseText}\n\n查看页面:\n${url}`
: `查看页面:\n${url}`;
}
return baseText;
}
function stripInternalWechatUsername(text) {
return String(text ?? '').replace(/^\s*wx_[a-z0-9_]{4,64}\s*[,,、::]\s*/i, '');
}
@@ -387,6 +520,8 @@ export async function guardMissingPublicHtmlLinks(
return replacements.reduce((next, [from, to]) => next.replaceAll(from, to), value);
}
export { maybeAttachPublishedHtmlLink };
function isQuestionStatusProbe(text) {
return /^[??]+$/.test(String(text ?? '').trim());
}
@@ -1040,19 +1175,47 @@ export function createWechatMpService({
if (forceNew) {
await userAuth.clearWechatAgentRoute(config.appId, openid);
}
const workingDir = await userAuth.resolveWorkingDir(userId);
const sessionPolicy = await userAuth.getAgentSessionPolicy(userId);
const publishLayout = await userAuth.getUserPublishLayout(userId);
const addressName = resolveWechatAddressName(userContext);
const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid);
if (existingRoute?.agentSessionId) {
return existingRoute.agentSessionId;
await reconcileAgentSession(
(pathname, init) => fetchForSession(existingRoute.agentSessionId, pathname, init),
existingRoute.agentSessionId,
{
workingDir,
sessionPolicy,
sandboxConstraints: publishLayout?.constraints ?? null,
userContext: publishLayout
? {
userId,
displayName: addressName || publishLayout.displayName,
username: addressName || null,
slug: null,
}
: null,
tolerateInvalidWorkingDir: true,
},
);
const routeHasTools = await sessionHasRequiredTools(
fetchForSession,
existingRoute.agentSessionId,
sessionPolicy,
).catch(() => false);
if (routeHasTools) {
return existingRoute.agentSessionId;
}
await userAuth.clearWechatAgentRoute(config.appId, openid);
} else if (existingRoute?.status === 'disabled') {
await userAuth.clearWechatAgentRoute(config.appId, openid);
}
const gate = await userAuth.canUseChat(userId);
if (!gate.ok) {
throw new Error(gate.message || '当前用户无法使用聊天能力');
}
const workingDir = await userAuth.resolveWorkingDir(userId);
const sessionPolicy = await userAuth.getAgentSessionPolicy(userId);
const publishLayout = await userAuth.getUserPublishLayout(userId);
const started = await readJsonResponse(
await apiFetch('/agent/start', {
method: 'POST',
@@ -1073,7 +1236,6 @@ export function createWechatMpService({
// `/agent/start` already persists the owning user and goosed node via the
// portal proxy. Re-registering here without the node can overwrite the
// correct mapping back to node 0 in multi-goosed production.
const addressName = resolveWechatAddressName(userContext);
await reconcileAgentSession(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
@@ -1175,6 +1337,7 @@ export function createWechatMpService({
userContext: user,
forceNew,
});
const publishLayout = await userAuth.getUserPublishLayout(user.userId);
await ensureSessionProvider(sessionId);
await rememberWechatUserContext(sessionId, user);
let finished = false;
@@ -1220,15 +1383,22 @@ export function createWechatMpService({
buildWechatAgentPrompt(intent),
buildIntentMetadata(intent),
);
if (isSuspiciousBareCompletionReply(reply, intent)) {
throw new Error('stale_session_poisoned_completion');
}
if (reply.tokenState) {
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId);
}
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(reply.text), user);
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)),
publicBaseUrl: config.publicBaseUrl,
});
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user);
return { sessionId };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const mayBeStaleSession =
/403|404|not found|无权访问|session/i.test(message) && sessionId;
/403|404|not found|无权访问|session|stale_session_poisoned_completion/i.test(message) && sessionId;
if (mayBeStaleSession) {
sessionId = await ensureWechatAgentSession({
userId: user.userId,
@@ -1246,10 +1416,17 @@ export function createWechatMpService({
buildWechatAgentPrompt(intent),
buildIntentMetadata(intent),
);
if (isSuspiciousBareCompletionReply(reply, intent)) {
throw new Error('本轮命中了被旧指令污染的专属会话,请稍后重试');
}
if (reply.tokenState) {
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId);
}
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(reply.text), user);
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)),
publicBaseUrl: config.publicBaseUrl,
});
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user);
return { sessionId };
}
throw err;
+440
View File
@@ -7,6 +7,7 @@ import {
createWechatMpService,
guardMissingPublicHtmlLinks,
loadWechatMpConfig,
maybeAttachPublishedHtmlLink,
splitWechatText,
verifyWechatMpSignature,
verifyWechatMpUrlChallenge,
@@ -136,6 +137,48 @@ test('guardMissingPublicHtmlLinks blocks missing MindSpace public html links', a
assert.match(guarded, /missing\.html/);
});
test('maybeAttachPublishedHtmlLink copies generated root html into public and returns link', async (t) => {
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-public-');
const htmlPath = `${workspaceRoot}/hello.html`;
fs.writeFileSync(htmlPath, '<!doctype html><title>Hello</title>');
const text = await maybeAttachPublishedHtmlLink(
{
text: '已完成',
messages: [
{
role: 'assistant',
content: [
{
type: 'toolRequest',
toolCall: {
value: {
name: 'developer',
arguments: {
action: 'write',
path: htmlPath,
content: '<!doctype html><title>Hello</title>',
},
},
},
},
],
},
],
},
{
workingDir: workspaceRoot,
publicBaseUrl: 'https://m.tkmind.cn',
},
);
assert.equal(fs.existsSync(`${workspaceRoot}/public/hello.html`), true);
assert.match(
text,
/https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/hello\.html/,
);
});
test('wechat mp service splits long agent replies into multiple customer messages', async () => {
const token = 'token';
const timestamp = '1710000000';
@@ -367,6 +410,70 @@ test('wechat mp service strips markdown emphasis around outbound links', async (
assert.match(payload.text.content, /https:\/\/example\.com\/running-route\.html/);
});
test('wechat mp service does not send stale page links from unscoped conversation snapshots', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const wechatCalls = [];
const service = createBoundWechatService({
sessionApiFetch: async (sessionId, pathname) => {
assert.equal(sessionId, 'session-1');
if (pathname === '/sessions/session-1/events') {
return new Response(
[
'data: {"type":"UpdateConversation","conversation":[{"id":"user-old","role":"user","metadata":{"userVisible":true},"content":[{"type":"text","text":"帮我做餐厅推荐"}]},{"id":"assistant-old","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"页面已生成!餐厅推荐\\nhttps://g2.tkmind.cn/MindSpace/old/public/restaurant.html"}]}]}\n\n',
'data: {"type":"Finish","request_id":"req-stale","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/sessions/session-1/reply') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected api path: ${pathname}`);
},
wechatFetch: async (url, init = {}) => {
wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]);
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = () => 'req-stale';
try {
const result = await service.handleInboundMessage(inboundXml({ content: '帮我做上马计划' }), {
timestamp,
nonce,
signature: signatureFor(token, timestamp, nonce),
});
assert.equal(result.status, 200);
await result.task;
} finally {
crypto.randomUUID = originalRandomUuid;
}
const sendCalls = wechatCalls.filter(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
assert.equal(sendCalls.length, 1);
const payload = JSON.parse(sendCalls[0][2]);
assert.doesNotMatch(payload.text.content, /restaurant\.html/);
assert.match(payload.text.content, /本轮未收到可发送的新回复/);
});
test('loadWechatMpConfig requires full config and enable flag', () => {
const config = loadWechatMpConfig({
H5_WECHAT_MP_ENABLED: '1',
@@ -873,6 +980,339 @@ test('wechat mp service injects schedule skill instructions for reminder-like re
}
});
test('wechat mp service reconciles existing dedicated session before reply', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const calls = [];
let extensionsReadCount = 0;
const workingDir = '/Users/john/Project/Memind/MindSpace/user-1';
const extensionConfig = {
type: 'stdio',
name: 'sandbox-fs',
cmd: '/usr/local/bin/node',
args: ['/Users/john/Project/Memind/mindspace-sandbox-mcp.mjs', workingDir],
envs: { SANDBOX_ROOT: workingDir },
available_tools: ['list_dir'],
};
const service = createWechatMpService({
config: {
enabled: true,
appId: 'wx123',
appSecret: 'secret',
token,
publicBaseUrl: 'https://example.com',
bindPath: '/auth/wechat/authorize?intent=login',
ackText: 'ack',
unsupportedText: 'unsupported',
unboundTextPrefix: '请先绑定',
},
userAuth: {
async findWechatUserByOpenid() {
return { userId: 'user-1', status: 'active', nickname: '毕升' };
},
async getWechatAgentRoute() {
return { agentSessionId: 'session-1' };
},
async clearWechatAgentRoute() {},
async canUseChat() {
return { ok: true };
},
async resolveWorkingDir() {
return workingDir;
},
async getAgentSessionPolicy() {
return {
enableContextMemory: false,
extensionOverrides: [extensionConfig],
unrestricted: false,
gooseMode: 'auto',
};
},
async getUserPublishLayout() {
return { displayName: 'John', username: 'john', slug: 'john', constraints: 'base' };
},
async registerAgentSession() {},
async upsertWechatAgentRoute() {},
async billSessionUsage() {},
},
apiFetch: async (pathname, init = {}) => {
calls.push(['fallback', pathname, init.method ?? 'GET']);
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
},
sessionApiFetch: async (sessionId, pathname, init = {}) => {
assert.equal(sessionId, 'session-1');
calls.push([pathname, init.method ?? 'GET']);
if (pathname === '/sessions/session-1') {
return new Response(JSON.stringify({ working_dir: '/root/tkmind_go/ui/h5/MindSpace/user-1' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (pathname === '/agent/update_working_dir') {
const body = JSON.parse(init.body);
assert.equal(body.session_id, 'session-1');
assert.equal(body.working_dir, workingDir);
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/update_session') {
const body = JSON.parse(init.body);
assert.equal(body.session_id, 'session-1');
assert.equal(body.goose_mode, 'auto');
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/sessions/session-1/extensions') {
extensionsReadCount += 1;
const extensions =
extensionsReadCount > 1
? [{ name: 'sandbox-fs', available_tools: ['list_dir'] }]
: [];
return new Response(JSON.stringify({ extensions }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (pathname === '/agent/add_extension') {
const body = JSON.parse(init.body);
assert.equal(body.session_id, 'session-1');
assert.equal(body.config.name, 'sandbox-fs');
assert.equal(body.config.args[1], workingDir);
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/restart') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/harness_remember') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/sessions/session-1/events') {
return new Response(
[
'data: {"type":"Message","request_id":"req-1","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"收到"}]}}\n\n',
'data: {"type":"Finish","request_id":"req-1","token_state":{"inputTokens":1,"outputTokens":1}}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/sessions/session-1/reply') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected api path: ${pathname}`);
},
wechatFetch: async (url, init = {}) => {
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = () => 'req-1';
try {
const result = await service.handleInboundMessage(inboundXml({ content: '继续' }), {
timestamp,
nonce,
signature: signatureFor(token, timestamp, nonce),
});
assert.equal(result.status, 200);
await result.task;
assert.equal(calls.some(([pathname]) => pathname === '/agent/update_working_dir'), true);
assert.equal(calls.some(([pathname]) => pathname === '/agent/add_extension'), true);
assert.equal(calls.some(([pathname]) => pathname === '/sessions/session-1/reply'), true);
} finally {
crypto.randomUUID = originalRandomUuid;
}
});
test('wechat mp service recreates poisoned dedicated session after bare completion on html generation', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const wechatCalls = [];
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-poisoned-session-');
const htmlPath = `${workspaceRoot}/hello.html`;
let routeCleared = false;
let started = false;
const service = createWechatMpService({
config: {
enabled: true,
appId: 'wx123',
appSecret: 'secret',
token,
publicBaseUrl: 'https://m.tkmind.cn',
bindPath: '/auth/wechat/authorize?intent=login',
ackText: 'ack',
unsupportedText: 'unsupported',
unboundTextPrefix: '请先绑定',
progressDelayMs: 0,
},
userAuth: {
async findWechatUserByOpenid() {
return { userId: 'user-1', status: 'active', nickname: '毕升' };
},
async getWechatAgentRoute() {
return routeCleared ? null : { agentSessionId: 'session-1' };
},
async clearWechatAgentRoute() {
routeCleared = true;
},
async canUseChat() {
return { ok: true };
},
async resolveWorkingDir() {
return workspaceRoot;
},
async getAgentSessionPolicy() {
return {
enableContextMemory: false,
extensionOverrides: [
{
type: 'platform',
name: 'developer',
available_tools: ['write'],
},
],
unrestricted: false,
};
},
async getUserPublishLayout() {
return { publishDir: workspaceRoot, displayName: 'John', username: 'john', slug: 'john', constraints: null };
},
async registerAgentSession() {},
async upsertWechatAgentRoute({ appId, openid, userId, agentSessionId }) {
assert.equal(appId, 'wx123');
assert.equal(openid, 'openid-1');
assert.equal(userId, 'user-1');
assert.equal(agentSessionId, 'session-2');
},
async billSessionUsage() {},
async recordWechatMpMessage() {
return { inserted: true };
},
async finishWechatMpMessage() {},
async insertWechatMpMessageDetail() {},
},
apiFetch: async (pathname, init = {}) => {
if (pathname === '/agent/start') {
started = true;
const body = JSON.parse(init.body);
assert.equal(body.working_dir, workspaceRoot);
return new Response(JSON.stringify({ id: 'session-2' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected api path: ${pathname}`);
},
sessionApiFetch: async (sessionId, pathname, init = {}) => {
if (pathname === `/sessions/${sessionId}/extensions`) {
return new Response(JSON.stringify({ extensions: [{ name: 'developer', available_tools: ['write'] }] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (pathname === '/agent/update_working_dir' || pathname === '/agent/update_session' || pathname === '/agent/restart') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/add_extension') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === `/sessions/${sessionId}`) {
return new Response(JSON.stringify({ working_dir: workspaceRoot }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (pathname === `/sessions/${sessionId}/reply`) {
if (sessionId === 'session-2') {
fs.writeFileSync(htmlPath, '<!doctype html><title>Hello</title>');
}
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === `/sessions/${sessionId}/events`) {
if (sessionId === 'session-1') {
return new Response(
[
'data: {"type":"Message","request_id":"req-poisoned","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已完成"}]}}\n\n',
'data: {"type":"Finish","request_id":"req-poisoned","token_state":{"inputTokens":1,"outputTokens":1}}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
return new Response(
[
`data: {"type":"Message","request_id":"req-poisoned-retry","message":{"id":"assistant-tool","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"developer","arguments":{"action":"write","path":"${htmlPath.replace(/\\/g, '\\\\')}","content":"<!doctype html><title>Hello</title>"}}}}]}}\n\n`,
'data: {"type":"Message","request_id":"req-poisoned-retry","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已完成"}]}}\n\n',
'data: {"type":"Finish","request_id":"req-poisoned-retry","token_state":{"inputTokens":2,"outputTokens":2}}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
throw new Error(`unexpected session api path: ${sessionId} ${pathname}`);
},
wechatFetch: async (url, init = {}) => {
wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]);
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = (() => {
const ids = ['req-poisoned', 'req-poisoned-retry'];
return () => ids.shift() ?? 'req-poisoned-retry';
})();
try {
const result = await service.handleInboundMessage(
inboundXml({ content: '帮我生成一个简单的 hello 页面吧' }),
{
timestamp,
nonce,
signature: signatureFor(token, timestamp, nonce),
},
);
assert.equal(result.status, 200);
await result.task;
} finally {
crypto.randomUUID = originalRandomUuid;
}
assert.equal(routeCleared, true);
assert.equal(started, true);
const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
const payload = JSON.parse(sendCall[2]);
assert.match(payload.text.content, /已完成/);
assert.match(payload.text.content, /页面生成未完成|hello\.html/);
});
test('wechat mp service blocks schedule confirmation when no schedule item was written', async () => {
const token = 'token';
const timestamp = '1710000000';