merge: goosed-mcp-release (schema fix + mcp paths + chat UI + runtime fixes)
This commit is contained in:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
> 2026-06-26 起,103 / Studio 正式禁止 `rsync` 发布。
|
||||
> Portal 生产必须是**无源码 runtime 模式**,唯一合法入口是 `bash scripts/release-portal-runtime-prod.sh`。
|
||||
> 每次发包前先读:[发包必看](发包必看.md)。
|
||||
|
||||
## 当前规则
|
||||
|
||||
|
||||
+275
@@ -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
@@ -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
@@ -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')) {
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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} 个版本`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Generated
+54
-2
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 '',
|
||||
|
||||
@@ -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:',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 =
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user