feat: package portal updates for 103 release

This commit is contained in:
john
2026-06-27 18:13:44 +08:00
parent def9b0b5eb
commit 4bb4858e84
38 changed files with 2224 additions and 232 deletions
+1
View File
@@ -2,6 +2,7 @@ node_modules/
ops/node_modules/
dist/
ops/dist/
.runtime/portal/
.env
.env.local
+6 -5
View File
@@ -6,8 +6,9 @@
3. Portal 生产发布唯一合法路径是:本地已提交代码 -> 本机 `node scripts/build-portal-runtime.mjs` -> `bash scripts/release-portal-runtime-prod.sh` -> 上传 `103` -> 全量备份 + 持久目录备份 -> 原子切换 live 目录 -> 重启 Portal -> 健康检查。
4. `scripts/release-prod.sh`(源码包发布)已停用,不得再用于 Portal;`rsync_to_server.sh` 与任何面向 `105` 的直接同步脚本也只保留为禁用提示。
5. 发布包不得携带运行态资产;`.env`、`MindSpace/`、`data/`、`users/`、`.tailscale/`、`public/plaza-covers/`、`logs/` 只能从线上现有 live 目录继承。
6. runtime artifact 必须包含 `server.mjs` 与 `mindspace-sandbox-mcp.mjs`(`sandbox-fs` 扩展依赖的独立子进程入口)。
7. 每次生产发布前必须先做全量备份;发布失败必须自动回滚到切换前的 live 目录。
8. 发布清单必须记录:本地 commit、分支、发布时间、发布编号、是否含额外手工环境变更。
9. Portal 生产验证至少包含 `http://127.0.0.1:8081/api/status` 的 200 健康检查,并补充本次功能对应的业务路径验收;Plaza 仍按各自发布流程单独验收。
10. 生产热修复也不能绕过这套流程;“为了快”不是跳过备份、跳过 commit、跳过发布包的理由。
6. Portal 发版前必须先收口本地工作区;**与本次 103 Portal 目标无关的改动不得借道一起上线**。默认应从发版范围剔除:`docker/` 下的 goosed 镜像与 compose 文件、`scripts/start-goosed-local.sh`、`scripts/verify-goosed-docker.sh`、`docs/goosed-*.md`、`docs/local-dev.md`、`README.md` 中仅用于本地 Goose / Docker 联调的说明、任何 `*.bak` 备份文件、以及整个 `.runtime/portal/` 构建产物目录。只有当本次需求明确就是这些模块本身时,才允许把它们纳入发版。
7. runtime artifact 必须包含 `server.mjs` 与 `mindspace-sandbox-mcp.mjs`(`sandbox-fs` 扩展依赖的独立子进程入口)。
8. 每次生产发布前必须先做全量备份;发布失败必须自动回滚到切换前的 live 目录。
9. 发布清单必须记录:本地 commit、分支、发布时间、发布编号、是否含额外手工环境变更。
10. Portal 生产验证至少包含 `http://127.0.0.1:8081/api/status` 的 200 健康检查,并补充本次功能对应的业务路径验收;Plaza 仍按各自发布流程单独验收。
11. 生产热修复也不能绕过这套流程;“为了快”不是跳过备份、跳过 commit、跳过发布包的理由。
+4 -1
View File
@@ -350,7 +350,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) {
+20 -1
View File
@@ -233,6 +233,7 @@ export async function migrateSchema(pool) {
`);
const publishColumns = [
['user_confirmed_at', 'BIGINT NULL AFTER expires_at'],
['plaza_view_count', 'BIGINT NOT NULL DEFAULT 0'],
['plaza_like_count', 'BIGINT NOT NULL DEFAULT 0'],
];
@@ -242,6 +243,13 @@ export async function migrateSchema(pool) {
}
}
if (!(await indexExists(pool, 'h5_publish_records', 'idx_h5_publish_auto_private'))) {
await pool.query(
`ALTER TABLE h5_publish_records
ADD KEY idx_h5_publish_auto_private (access_mode, status, user_confirmed_at, expires_at)`,
);
}
const plazaUserColumns = [
['plaza_post_count', 'INT UNSIGNED NOT NULL DEFAULT 0'],
['plaza_follower_count', 'INT UNSIGNED NOT NULL DEFAULT 0'],
@@ -282,6 +290,12 @@ export async function migrateSchema(pool) {
);
}
if (!(await columnExists(pool, 'h5_session_snapshots', 'display_title'))) {
await pool.query(
`ALTER TABLE h5_session_snapshots ADD COLUMN display_title VARCHAR(512) NOT NULL DEFAULT '' AFTER name`,
);
}
const oauthStateColumns = [
['intent', "VARCHAR(16) NOT NULL DEFAULT 'login' AFTER utm_campaign"],
['bind_user_id', 'CHAR(36) NULL AFTER intent'],
@@ -517,6 +531,7 @@ export async function migrateSchema(pool) {
agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
name VARCHAR(512) NOT NULL DEFAULT '',
display_title VARCHAR(512) NOT NULL DEFAULT '',
working_dir VARCHAR(1024) NOT NULL DEFAULT '',
created_at_str VARCHAR(64) NOT NULL DEFAULT '',
updated_at_str VARCHAR(64) NOT NULL DEFAULT '',
@@ -588,7 +603,11 @@ export async function migrateSchema(pool) {
export async function initSchema(pool) {
const schemaPath = path.join(__dirname, 'schema.sql');
const sql = fs.readFileSync(schemaPath, 'utf8');
const sql = fs
.readFileSync(schemaPath, 'utf8')
.split('\n')
.filter((line) => !line.trim().startsWith('--'))
.join('\n');
for (const statement of sql.split(';')) {
const trimmed = statement.trim();
if (trimmed) {
+16
View File
@@ -31,6 +31,22 @@ bash scripts/release-portal-runtime-prod.sh
9. 启动 Portal,检查 `127.0.0.1:8081/api/status` 为 200。
10. 旧源码 live 目录移入 `archives/`,线上不再保留可运行源码树。
## 发版前收口
正式打 103 Portal 包之前,先确认本地工作区只保留“本次要上线的 Portal 改动”。下面这些内容默认**不应**并入 Portal 发版:
- `docker/` 下的 goosed 本地/生产镜像、compose、README、`.env.example`
- `scripts/start-goosed-local.sh`
- `scripts/verify-goosed-docker.sh`
- `docs/goosed-*.md`
- `docs/local-dev.md`、`README.md` 中只服务于本地 Goose / Docker 联调的说明
- `*.bak` 之类临时备份文件
- `.runtime/portal/` 整个构建产物目录
只有当本次需求明确就是这些文件所对应的模块时,才允许把它们一起带入发版 commit。默认场景下,103 Portal 发版应聚焦于业务代码、运行脚本、数据库变更,以及与本次线上目标直接相关的文档。
`bash scripts/release-portal-runtime-prod.sh` 现在会在本地预检查阶段直接拦截这些默认应排除的脏改动;只有在你明确知道自己在做什么时,才允许用 `ALLOW_PORTAL_RELEASE_SCOPE_BYPASS=1` 临时绕过。
## 禁止事项
- 禁止 `rsync_to_server.sh`
+7 -6
View File
@@ -487,14 +487,15 @@ test('launchExecutor rejects openhands headless without instruction', async () =
});
test('launchExecutor returns friendly error when command is missing', async () => {
const encryptedKey = encryptSecret('sk-x', 'unit-test-secret');
const keyRow = {
id: 'key-1',
provider_id: 'custom_deepseek',
provider_kind: 'builtin',
name: 'DeepSeek',
api_key_ciphertext: encryptSecret('sk-x', 'unit-test-secret').ciphertext,
api_key_iv: encryptSecret('sk-x', 'unit-test-secret').iv,
api_key_tag: encryptSecret('sk-x', 'unit-test-secret').tag,
api_key_ciphertext: encryptedKey.ciphertext,
api_key_iv: encryptedKey.iv,
api_key_tag: encryptedKey.tag,
default_model: 'deepseek-chat',
status: 'active',
is_selected: 1,
@@ -513,13 +514,13 @@ test('launchExecutor returns friendly error when command is missing', async () =
};
const pool = {
async query(sql) {
if (sql.includes('SELECT * FROM h5_llm_executor_bindings WHERE executor = ? AND purpose = ? LIMIT 1')) {
return [[bindingRow]];
}
if (sql.includes('SELECT * FROM h5_llm_executor_bindings')) return [[]];
if (sql.includes('SELECT * FROM h5_llm_provider_keys WHERE id = ?')) {
return [[keyRow]];
}
if (sql.includes('SELECT * FROM h5_llm_executor_bindings WHERE executor = ? AND purpose = ? LIMIT 1')) {
return [[bindingRow]];
}
throw new Error(`Unexpected SQL: ${sql}`);
},
};
+1 -1
View File
@@ -265,7 +265,7 @@ test('completeUpload publishes public images to the public temp image library',
const asset = await service.completeUpload('user-1', upload.id);
assert.equal(asset.status, 'ready');
assert.match(asset.publicUrl, /^https:\/\/g2\.tkmind\.cn\/MindSpace\/user-1\/public\/\.tmp-images\/id-2\.png$/);
assert.match(asset.publicUrl, /^https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/\.tmp-images\/id-2\.png$/);
assert.equal(state.versions[0].storage_key, 'users/user-1/public/.tmp-images/id-2.png');
await assert.doesNotReject(() =>
fs.stat(path.join(storageRoot, 'users/user-1/public/.tmp-images/id-2.png')),
+61
View File
@@ -537,6 +537,22 @@ export function createPageService(pool, options = {}) {
return rows[0] ? pageResponse(rows[0]) : null;
};
const findPageBySourceMessage = async (userId, sessionId, messageId) => {
if (!sessionId || !messageId) return null;
const [rows] = await pool.query(
`SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
FROM h5_page_records p
JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'
WHERE p.user_id = ? AND p.source_session_id = ? AND p.source_message_id = ? AND p.status <> 'deleted'
ORDER BY p.updated_at DESC
LIMIT 1`,
[userId, sessionId, messageId],
);
return rows[0] ? pageResponse(rows[0]) : null;
};
const listPages = async (userId, filters = {}) => {
const clauses = [`p.user_id = ?`, `p.status <> 'deleted'`];
const params = [userId];
@@ -1148,6 +1164,7 @@ export function createPageService(pool, options = {}) {
createRedactedCopy: redactPage,
listPages,
findPageBySourceAsset,
findPageBySourceMessage,
getPage,
getDeletePreview,
deletePage,
@@ -1211,6 +1228,50 @@ export const pageInternals = {
buildPageDeleteSummary,
};
export async function inlinePrivateAssetsInHtml(pool, storageRoot, userId, html) {
const PATTERN =
/(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi;
const resolvedRoot = path.resolve(storageRoot);
const absoluteStoragePath = (key) => {
const resolved = path.resolve(resolvedRoot, key);
if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
throw Object.assign(new Error('路径越界'), { code: 'invalid_storage_path' });
}
return resolved;
};
const matches = [...String(html).matchAll(PATTERN)];
if (matches.length === 0) return { html, count: 0 };
const assetIds = [...new Set(matches.map((m) => m[1]).filter(Boolean))];
const [assets] = await pool.query(
`SELECT a.id, a.mime_type, a.original_filename, v.storage_key
FROM h5_assets a
JOIN h5_asset_versions v ON v.id = a.current_version_id
WHERE a.user_id = ? AND a.id IN (?) AND a.mime_type LIKE 'image/%'
AND a.status <> 'deleted'`,
[userId, assetIds],
);
const dataUriMap = new Map();
for (const asset of assets) {
try {
const buffer = await fs.readFile(absoluteStoragePath(asset.storage_key));
dataUriMap.set(asset.id, `data:${asset.mime_type};base64,${buffer.toString('base64')}`);
} catch {
// skip unreadable assets
}
}
let count = 0;
const result = String(html).replace(PATTERN, (_match, assetId) => {
const uri = dataUriMap.get(assetId);
if (!uri) return '';
count += 1;
return uri;
});
return { html: result, count };
}
export function buildPageDeleteSummary(preview) {
const lines = [
`页面「${preview.page.title}」及其 ${preview.page.versionCount} 个版本`,
+3
View File
@@ -40,6 +40,9 @@ function resolveSandboxed(p) {
if (resolved !== SANDBOX && !resolved.startsWith(SANDBOX + path.sep)) {
throw Object.assign(new Error(`路径越界:${p} 不在当前工作区内`), { code: 'EACCES' });
}
if (resolved === PRIVATE_DATA_DIR || resolved.startsWith(PRIVATE_DATA_DIR + path.sep)) {
throw Object.assign(new Error('禁止直接访问私有数据目录,请使用 private_data_* 工具'), { code: 'EACCES' });
}
return resolved;
}
+54 -2
View File
@@ -12,8 +12,10 @@
"@resvg/resvg-js": "^2.6.2",
"debug": "^4.4.3",
"express": "^4.21.2",
"framer-motion": "^12.42.0",
"http-proxy-middleware": "^3.0.3",
"jsonrepair": "^3.14.0",
"lucide-react": "^1.21.0",
"mysql2": "^3.22.5",
"pg": "^8.22.0",
"qrcode": "^1.5.4",
@@ -2558,6 +2560,33 @@
"node": ">= 0.6"
}
},
"node_modules/framer-motion": {
"version": "12.42.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.0.tgz",
"integrity": "sha512-wp7EJnfWaaEScVygKv3e20udoRz+LbtxScsuTkakAxfXmt+ReC6WyPW2nINRAGvd+hG9odwcjBLyOTPjH5pBRA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.42.0",
"motion-utils": "^12.39.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -2937,6 +2966,15 @@
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/lucide-react": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz",
"integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3019,6 +3057,21 @@
"node": ">= 0.6"
}
},
"node_modules/motion-dom": {
"version": "12.42.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.0.tgz",
"integrity": "sha512-M63h4n8R+quJdNhBwuLlgxM+OLYa9+I/T2pzDRboB9fLXRdbou+Gw7Zury+SkpaCyACP1JHSjHgZ1EgTkBr30w==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.39.0"
}
},
"node_modules/motion-utils": {
"version": "12.39.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3944,8 +3997,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-is": {
"version": "1.6.18",
+2
View File
@@ -54,8 +54,10 @@
"@resvg/resvg-js": "^2.6.2",
"debug": "^4.4.3",
"express": "^4.21.2",
"framer-motion": "^12.42.0",
"http-proxy-middleware": "^3.0.3",
"jsonrepair": "^3.14.0",
"lucide-react": "^1.21.0",
"mysql2": "^3.22.5",
"pg": "^8.22.0",
"qrcode": "^1.5.4",
+2 -1
View File
@@ -233,7 +233,7 @@ CREATE TABLE IF NOT EXISTS h5_publish_records (
updated_at BIGINT NOT NULL,
KEY idx_h5_publish_route (url_slug, status, published_at),
KEY idx_h5_publish_page (user_id, page_id, published_at),
KEY idx_h5_publish_auto_private (expires_at, user_confirmed_at) WHERE access_mode = 'public' AND user_confirmed_at IS NULL,
KEY idx_h5_publish_auto_private (access_mode, status, user_confirmed_at, expires_at),
UNIQUE KEY uq_h5_publish_token_hash (token_hash),
CONSTRAINT fk_h5_publish_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
CONSTRAINT fk_h5_publish_page FOREIGN KEY (page_id) REFERENCES h5_page_records(id) ON DELETE CASCADE,
@@ -660,6 +660,7 @@ CREATE TABLE IF NOT EXISTS h5_session_snapshots (
agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
name VARCHAR(512) NOT NULL DEFAULT '',
display_title VARCHAR(512) NOT NULL DEFAULT '',
working_dir VARCHAR(1024) NOT NULL DEFAULT '',
created_at_str VARCHAR(64) NOT NULL DEFAULT '',
updated_at_str VARCHAR(64) NOT NULL DEFAULT '',
+2 -1
View File
@@ -199,6 +199,7 @@ 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}"',
'',
@@ -232,7 +233,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:',
+32
View File
@@ -80,7 +80,39 @@ need_cmd scp
need_cmd tar
need_cmd shasum
check_release_scope() {
local bypass="${ALLOW_PORTAL_RELEASE_SCOPE_BYPASS:-0}"
if [[ "${bypass}" == "1" ]]; then
say "跳过发版范围检查(ALLOW_PORTAL_RELEASE_SCOPE_BYPASS=1)"
return 0
fi
git -C "${ROOT}" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
local path
local blocked=()
while IFS= read -r path; do
[[ -n "${path}" ]] || continue
case "${path}" in
docker/*|scripts/start-goosed-local.sh|scripts/verify-goosed-docker.sh|docs/goosed-*.md|server.mjs.bak*|*.bak)
blocked+=("${path}")
;;
docs/local-dev.md|README.md)
blocked+=("${path}")
;;
esac
done < <(git -C "${ROOT}" status --short --untracked-files=all | sed -E 's/^.. //')
if [[ "${#blocked[@]}" -gt 0 ]]; then
echo "检测到默认不应并入 103 Portal 发版的本地改动:" >&2
printf ' - %s\n' "${blocked[@]}" >&2
echo "请先清理这些改动,或确认本次需求明确包含这些模块后再重试。" >&2
echo "如确需绕过,可临时设置 ALLOW_PORTAL_RELEASE_SCOPE_BYPASS=1。" >&2
exit 1
fi
}
say "本地预检查"
check_release_scope
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo portal-runtime-ssh-ok" >/dev/null
ssh -o BatchMode=yes "${HOST}" "test -d '${APP_DIR}' && test -f '${APP_DIR}/.env'" >/dev/null
+1
View File
@@ -15,6 +15,7 @@ fi
export NODE_ENV=production
export H5_PORT="${H5_PORT:-8081}"
export H5_PUBLIC_BASE_URL="https://m.tkmind.cn"
export TKMIND_API_TARGETS="${TKMIND_API_TARGETS_OVERRIDE:-${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}}"
export TKMIND_API_TARGET="${TKMIND_API_TARGET_OVERRIDE:-https://127.0.0.1:18006}"
export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1_OVERRIDE:-https://127.0.0.1:18007}"
+76 -12
View File
@@ -1,6 +1,7 @@
import express from 'express';
import crypto from 'node:crypto';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import { createProxyMiddleware } from 'http-proxy-middleware';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -23,7 +24,7 @@ import {
} from './user-auth.mjs';
import { createWikiAuth } from './wiki-auth.mjs';
import { isLocalDevHostname } from './scripts/local-test-config.mjs';
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir } from './user-publish.mjs';
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir, resolvePublicBaseUrl } from './user-publish.mjs';
import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs';
import { attachRequestId, sendData, sendError } from './api-response.mjs';
@@ -32,7 +33,7 @@ import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs';
import { createMindSpaceService, DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs';
import { ensureMindSpaceConfig } from './mindspace-config.mjs';
import { createAssetService } from './mindspace-assets.mjs';
import { createPageService, pageInternals } from './mindspace-pages.mjs';
import { createPageService, pageInternals, inlinePrivateAssetsInHtml } from './mindspace-pages.mjs';
import { createPageLiveEditService } from './mindspace-page-live-edit.mjs';
import { createPageEditSessionService } from './mindspace-page-edit-session.mjs';
import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs';
@@ -117,12 +118,21 @@ function loadEnvFile(filePath) {
loadEnvFile(path.join(__dirname, '../../.env.local'));
loadEnvFile(path.join(__dirname, '.env'));
function parseApiTargets() {
const csvTargets = (process.env.TKMIND_API_TARGETS ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const legacyTargets = [
process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
process.env.TKMIND_API_TARGET_1,
].filter(Boolean);
return [...new Set([...csvTargets, ...legacyTargets])];
}
const PORT = Number(process.env.H5_PORT ?? 8081);
const API_TARGET = process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006';
const API_TARGETS = [
API_TARGET,
...(process.env.TKMIND_API_TARGET_1 ? [process.env.TKMIND_API_TARGET_1] : []),
];
const API_TARGETS = parseApiTargets();
const API_TARGET = API_TARGETS[0] ?? 'https://127.0.0.1:18006';
const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET;
const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD;
@@ -510,6 +520,7 @@ async function bootstrapUserAuth() {
userAuth,
llmProviderService,
subscriptionService,
sessionSnapshotService,
localFetchAsset: mindSpaceAssets
? async (userId, assetId) => {
const { asset, path: assetPath } = await mindSpaceAssets.readAsset(userId, assetId);
@@ -2496,6 +2507,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 +2530,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 +2681,19 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
}).catch(() => {});
}
const page = await mindSpacePages.createFromChat(req.currentUser.id, pageInput, {
sessionId: req.body.session_id,
messageId: req.body.message_id,
snapshot,
});
const replacePageId = req.body?.replace_page_id
? String(req.body.replace_page_id).trim()
: null;
let page;
if (replacePageId) {
page = await mindSpacePages.updatePage(req.currentUser.id, replacePageId, pageInput);
} else {
page = await mindSpacePages.createFromChat(req.currentUser.id, pageInput, {
sessionId: req.body.session_id,
messageId: req.body.message_id,
snapshot,
});
}
return res.status(201).json({ data: { kind: 'page', categoryCode: 'draft', page } });
} catch (error) {
return mindSpaceError(res, req, error);
+184 -6
View File
@@ -11,11 +11,78 @@
* conversation content, so it can be dropped/truncated safely at any time.
*/
const DERIVED_TITLE_MAX_LEN = 40;
function isGeneratedSessionName(name) {
return /^20\d{6}(?:[_-]\d+)?$/.test(String(name ?? '').trim());
}
function isDefaultSessionName(name) {
const trimmed = String(name ?? '').trim();
return !trimmed || trimmed === 'New Chat' || trimmed === '新对话' || isGeneratedSessionName(trimmed);
}
/** Extract a plain-text snippet from a stored normalized message. */
function messageSnippet(message) {
const fromContent = (message?.content ?? [])
.filter((item) => item?.type === 'text' && typeof item.text === 'string')
.map((item) => item.text)
.join('')
.trim();
if (fromContent) return fromContent;
const displayText = message?.metadata?.displayText;
return typeof displayText === 'string' ? displayText.trim() : '';
}
/** Build a one-line title from the first user message of a stored conversation. */
function deriveTitleFromMessages(messages) {
if (!Array.isArray(messages)) return '';
const firstUser = messages.find((m) => m?.role === 'user' && messageSnippet(m));
const text = (firstUser ? messageSnippet(firstUser) : '')
.replace(/\s+/g, ' ')
.trim();
if (!text) return '';
return text.length > DERIVED_TITLE_MAX_LEN
? `${text.slice(0, DERIVED_TITLE_MAX_LEN)}…`
: text;
}
function resolveDisplayTitle(session, messages) {
if (session?.user_set_name) {
const explicit = String(session.name ?? '').trim();
if (explicit) return explicit;
}
const recipeTitle = String(session?.recipe?.title ?? '').trim();
if (recipeTitle) return recipeTitle;
const sessionName = String(session?.name ?? '').trim();
if (sessionName && !isDefaultSessionName(sessionName)) return sessionName;
return deriveTitleFromMessages(messages);
}
export function createSessionSnapshotService(pool) {
function isEnabled() {
return process.env.SESSION_SNAPSHOT_CACHE_ENABLED !== '0';
}
async function persistDisplayTitle(sessionId, title) {
const normalized = String(title ?? '').trim();
if (!normalized || !pool) return;
try {
await pool.query(
`UPDATE h5_session_snapshots
SET display_title = ?
WHERE agent_session_id = ?
AND (display_title IS NULL OR display_title = '')`,
[normalized, sessionId],
);
} catch (err) {
console.warn('[snapshot] persistDisplayTitle failed:', err instanceof Error ? err.message : err);
}
}
/**
* Persist a snapshot for a session.
* @param {string} sessionId
@@ -29,6 +96,7 @@ export function createSessionSnapshotService(pool) {
const now = Date.now();
const sessionMeta = {
name: session.name ?? '',
display_title: resolveDisplayTitle(session, messages),
working_dir: session.working_dir ?? '',
created_at_str: session.created_at ?? '',
updated_at_str: session.updated_at ?? '',
@@ -37,13 +105,14 @@ export function createSessionSnapshotService(pool) {
};
await pool.query(
`INSERT INTO h5_session_snapshots
(agent_session_id, user_id, name, working_dir,
(agent_session_id, user_id, name, display_title, working_dir,
created_at_str, updated_at_str, user_set_name, recipe_json,
synced_msg_count, source_updated_at,
messages_json, synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
display_title = VALUES(display_title),
working_dir = VALUES(working_dir),
created_at_str = VALUES(created_at_str),
updated_at_str = VALUES(updated_at_str),
@@ -57,6 +126,7 @@ export function createSessionSnapshotService(pool) {
sessionId,
userId,
sessionMeta.name,
sessionMeta.display_title,
sessionMeta.working_dir,
sessionMeta.created_at_str,
sessionMeta.updated_at_str,
@@ -82,7 +152,7 @@ export function createSessionSnapshotService(pool) {
if (!isEnabled() || !pool) return null;
try {
const [rows] = await pool.query(
`SELECT agent_session_id, user_id, name, working_dir,
`SELECT agent_session_id, user_id, name, display_title, working_dir,
created_at_str, updated_at_str, user_set_name, recipe_json,
synced_msg_count, source_updated_at, messages_json, synced_at
FROM h5_session_snapshots
@@ -92,10 +162,26 @@ export function createSessionSnapshotService(pool) {
);
if (!rows.length) return null;
const row = rows[0];
const parsedMessages = JSON.parse(row.messages_json);
const storedDisplayTitle = String(row.display_title ?? '').trim();
const rawName = String(row.name ?? '').trim();
const fallbackDisplayTitle =
storedDisplayTitle ||
resolveDisplayTitle(
{
name: row.name,
recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null,
user_set_name: row.user_set_name === 1,
},
parsedMessages,
);
if (!storedDisplayTitle && fallbackDisplayTitle) {
void persistDisplayTitle(sessionId, fallbackDisplayTitle);
}
return {
session: {
id: sessionId,
name: row.name,
name: fallbackDisplayTitle || rawName,
working_dir: row.working_dir,
message_count: row.synced_msg_count,
created_at: row.created_at_str || undefined,
@@ -104,7 +190,7 @@ export function createSessionSnapshotService(pool) {
recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null,
conversation: null,
},
messages: JSON.parse(row.messages_json),
messages: parsedMessages,
meta: {
synced_msg_count: row.synced_msg_count,
source_updated_at: row.source_updated_at,
@@ -153,5 +239,97 @@ export function createSessionSnapshotService(pool) {
}
}
return { save, get, remove, refresh, isEnabled };
/**
* Derive display titles from the first user message for the given session ids.
* Used to give unnamed/default sessions a meaningful label in the history list.
* Returns a Map<sessionId, title>; sessions without a usable snapshot are omitted.
*/
async function getDerivedTitles(sessionIds) {
if (!isEnabled() || !pool) return new Map();
const ids = [...new Set((sessionIds ?? []).filter(Boolean))];
if (ids.length === 0) return new Map();
const titles = new Map();
try {
const placeholders = ids.map(() => '?').join(', ');
const [rows] = await pool.query(
`SELECT agent_session_id, display_title, name, recipe_json, user_set_name, messages_json
FROM h5_session_snapshots
WHERE agent_session_id IN (${placeholders})`,
ids,
);
for (const row of rows) {
try {
const parsedMessages = JSON.parse(row.messages_json);
const title =
String(row.display_title ?? '').trim() ||
resolveDisplayTitle(
{
name: row.name,
recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null,
user_set_name: row.user_set_name === 1,
},
parsedMessages,
);
if (title) {
titles.set(row.agent_session_id, title);
if (!String(row.display_title ?? '').trim()) {
void persistDisplayTitle(row.agent_session_id, title);
}
}
} catch {
// Skip rows with unparseable message payloads.
}
}
} catch (err) {
console.warn('[snapshot] getDerivedTitles failed:', err instanceof Error ? err.message : err);
}
return titles;
}
async function getHistorySummaries(sessionIds) {
if (!isEnabled() || !pool) return new Map();
const ids = [...new Set((sessionIds ?? []).filter(Boolean))];
if (ids.length === 0) return new Map();
const summaries = new Map();
try {
const placeholders = ids.map(() => '?').join(', ');
const [rows] = await pool.query(
`SELECT agent_session_id, display_title, name, recipe_json, user_set_name,
synced_msg_count, messages_json
FROM h5_session_snapshots
WHERE agent_session_id IN (${placeholders})`,
ids,
);
for (const row of rows) {
try {
const parsedMessages = JSON.parse(row.messages_json);
const title =
String(row.display_title ?? '').trim() ||
resolveDisplayTitle(
{
name: row.name,
recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null,
user_set_name: row.user_set_name === 1,
},
parsedMessages,
);
const messageCount = Number(row.synced_msg_count ?? 0);
summaries.set(row.agent_session_id, {
title,
messageCount,
});
if (title && !String(row.display_title ?? '').trim()) {
void persistDisplayTitle(row.agent_session_id, title);
}
} catch {
// Skip rows with unparseable payloads.
}
}
} catch (err) {
console.warn('[snapshot] getHistorySummaries failed:', err instanceof Error ? err.message : err);
}
return summaries;
}
return { save, get, remove, refresh, getDerivedTitles, getHistorySummaries, isEnabled };
}
+21
View File
@@ -726,6 +726,25 @@ export function buildChatSaveThumbnailUrl(input: {
return `/api/mindspace/v1/pages/chat-save-thumbnail?${params.toString()}`;
}
export async function quickShareFromChat(input: {
sessionId: string;
messageId: string;
selectedLinkIndex?: number;
}): Promise<{ publicUrl: string; filename: string }> {
const result = await apiFetch<{ data: { publicUrl: string; filename: string } }>(
'/mindspace/v1/pages/quick-share-from-chat',
{
method: 'POST',
body: JSON.stringify({
session_id: input.sessionId,
message_id: input.messageId,
selected_link_index: input.selectedLinkIndex ?? 0,
}),
},
);
return result.data;
}
export async function fetchPreviewAsset(path: string): Promise<string> {
let res: Response;
try {
@@ -754,6 +773,7 @@ export async function saveChatMessageAsPage(input: {
categoryCode?: MindSpaceSaveCategory;
selectedLinkIndex?: number;
acknowledgedFindingIds?: string[];
replacePageId?: string;
}): Promise<ChatSaveResult> {
const result = await apiFetch<{ data: ChatSaveResult }>(
'/mindspace/v1/pages/save-from-chat',
@@ -769,6 +789,7 @@ export async function saveChatMessageAsPage(input: {
selected_link_index: input.selectedLinkIndex ?? 0,
acknowledged_finding_ids: input.acknowledgedFindingIds,
page_type: input.templateId === 'report' ? 'report' : 'article',
replace_page_id: input.replacePageId,
}),
},
);
+56 -17
View File
@@ -12,6 +12,38 @@ import type { CapabilityMap, ChatState, Message, PortalUser, Session, ToolConfir
import type { MindSpaceSaveCategory } from '../types';
const MAX_PENDING_IMAGES = 6;
const CHAT_PLACEHOLDER_PROMPTS = [
'帮我写一篇温柔一点的小短文',
'讲个轻松的笑话,让我换换脑子',
'帮我看看今天的天气和出门建议',
'规划一份周末城市漫游攻略',
'把这组数据整理成一段分析结论',
'帮我总结一份会议纪要或文档',
'写一个有记忆点的产品介绍',
'设计一个简单好玩的小游戏',
'帮我做一份旅行行程和预算',
'把一段想法改成更专业的表达',
'给我起几个品牌名和一句 slogan',
'帮我写一封礼貌但坚定的邮件',
'整理一份学习计划和每日任务',
'分析一个产品页面哪里可以优化',
'把复杂概念讲得像聊天一样简单',
'帮我生成一份短视频脚本',
'写一段适合发朋友圈的文案',
'帮我拆解一个商业想法是否可行',
'把长文压缩成三条重点',
'做一个活动策划和执行清单',
'帮我准备一次面试的回答思路',
'写一个网页或小工具的需求说明',
'帮我把表格数据变成洞察报告',
'给孩子讲一个睡前故事',
'帮我规划一周健康饮食',
'把这段话翻译得自然一点',
'帮我做一份竞品对比',
'给一个新功能设计使用流程',
'帮我写一段代码并解释思路',
'整理一个今天就能开始的行动计划',
];
type PendingChatImage = {
id: string;
@@ -76,6 +108,9 @@ export function ChatPanel({
const [uploadingImage, setUploadingImage] = useState(false);
const [imageError, setImageError] = useState<string | null>(null);
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
const [randomPrompt] = useState(
() => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)],
);
const imageInputRef = useRef<HTMLInputElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef(input);
@@ -105,6 +140,7 @@ export function ChatPanel({
const canPublish = Boolean(capabilities?.static_publish) || hasPublishSkill;
const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, { grantedSkills, canPublish });
const compact = variant === 'compact';
const showHomeWelcome = !compact && messages.length === 0;
const placeholder = offlineBlocked
? '网络断开,恢复连接后可继续输入'
@@ -112,7 +148,7 @@ export function ChatPanel({
? '正在连接会话…'
: compact
? '在这里继续和 Agent 对话…'
: '输入任务,例如:列出当前目录文件';
: randomPrompt;
const mergeVoiceText = (spoken: string) => {
const base = voiceBaseRef.current.trimEnd();
@@ -321,7 +357,7 @@ export function ChatPanel({
return (
<>
<main className={compact ? 'space-chat-panel-body' : 'main'}>
<main className={compact ? 'space-chat-panel-body' : `main${showHomeWelcome ? ' main-home' : ''}`}>
<MessageList
messages={messages}
streaming={chatState === 'streaming'}
@@ -329,6 +365,7 @@ export function ChatPanel({
onSaveAsPage={(message) => setPageSource(message)}
publishUserId={user?.id}
publishUsername={user?.username}
sessionId={session?.id}
compact={compact}
/>
{!compact && (
@@ -369,7 +406,7 @@ export function ChatPanel({
/>
)}
<footer className={compact ? 'space-chat-panel-footer' : 'footer'}>
<footer className={compact ? 'space-chat-panel-footer' : `footer${showHomeWelcome ? ' footer-home' : ''}`}>
{voiceNotice && (
<div className="voice-notice" role="status">
{voiceNotice}
@@ -399,8 +436,8 @@ export function ChatPanel({
)}
</div>
)}
<div className="chat-input-row">
<div className="chat-input-shell">
<div className={`chat-input-row${showHomeWelcome ? ' chat-input-row-home' : ''}`}>
<div className={`chat-input-shell${showHomeWelcome ? ' chat-input-shell-home' : ''}`}>
{onUploadImage && (
<>
<button
@@ -449,7 +486,7 @@ export function ChatPanel({
stopSignal={voiceStopSignal}
/>
</div>
{chatSkills.length > 0 && (
{!showHomeWelcome && chatSkills.length > 0 && (
<ChatSkillPicker
skills={chatSkills}
disabled={voiceDisabled}
@@ -457,16 +494,18 @@ export function ChatPanel({
onPrefill={setInput}
/>
)}
<button
type="button"
className="chat-skill-trigger"
title="Design Style Generator"
disabled={voiceDisabled}
onClick={() => setDesignPanelOpen(true)}
>
<DesignBtnIcon className="chat-skill-trigger-icon" />
<span className="chat-skill-trigger-label">design</span>
</button>
{!showHomeWelcome && (
<button
type="button"
className="chat-skill-trigger"
title="Design Style Generator"
disabled={voiceDisabled}
onClick={() => setDesignPanelOpen(true)}
>
<DesignBtnIcon className="chat-skill-trigger-icon" />
<span className="chat-skill-trigger-label">design</span>
</button>
)}
{designPanelOpen && (
<DesignSkillPanel
onClose={() => setDesignPanelOpen(false)}
@@ -483,7 +522,7 @@ export function ChatPanel({
) : (
<button
type="button"
className="send-btn chat-input-action"
className={`send-btn chat-input-action${showHomeWelcome ? ' chat-input-action-home' : ''}`}
disabled={!canSubmit || voiceDisabled || uploadingImage}
onClick={() => void handleSubmit()}
>
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { buildChatSavePreviewFrameUrl, quickShareFromChat } from '../api/client';
type SharePhase =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'done'; publicUrl: string }
| { kind: 'error'; message: string };
export function ChatSharePreviewModal({
sessionId,
messageId,
selectedLinkIndex = 0,
onClose,
onSave,
}: {
sessionId: string;
messageId: string;
selectedLinkIndex?: number;
onClose: () => void;
onSave?: () => void;
}) {
const [phase, setPhase] = useState<SharePhase>({ kind: 'idle' });
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const previewUrl = buildChatSavePreviewFrameUrl({ sessionId, messageId, selectedLinkIndex });
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
useEffect(() => () => { if (copyTimer.current) clearTimeout(copyTimer.current); }, []);
const share = async () => {
if (phase.kind === 'loading') return;
setPhase({ kind: 'loading' });
try {
const result = await quickShareFromChat({ sessionId, messageId, selectedLinkIndex });
setPhase({ kind: 'done', publicUrl: result.publicUrl });
} catch (err) {
setPhase({ kind: 'error', message: err instanceof Error ? err.message : '公开分享失败,请重试' });
}
};
const copy = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 2000);
} catch { /* ignore */ }
};
const handleSave = () => {
onClose();
onSave?.();
};
return createPortal(
<div className="chat-share-preview-overlay" role="dialog" aria-modal="true" aria-label="页面预览">
<div className="chat-share-preview-header">
<span className="chat-share-preview-title">页面预览</span>
<button type="button" className="chat-share-preview-close" onClick={onClose} aria-label="关闭">✕</button>
</div>
<div className="chat-share-preview-body">
<iframe src={previewUrl} title="页面预览" sandbox="allow-same-origin allow-scripts allow-popups" className="chat-share-preview-frame" />
<div className="chat-share-side-actions">
{phase.kind === 'done' ? (
<div className="chat-share-result-card">
<p className="chat-share-result-label">✅ 已公开!</p>
<button type="button" className="chat-share-result-copy" onClick={() => void copy(phase.publicUrl)}>
{copied ? '已复制 ✓' : '复制链接'}
</button>
<a href={phase.publicUrl} target="_blank" rel="noreferrer" className="chat-share-result-open">
打开页面 →
</a>
</div>
) : (
<button
type="button"
className={`chat-share-side-btn chat-share-side-btn-primary${phase.kind === 'loading' ? ' is-loading' : ''}`}
disabled={phase.kind === 'loading'}
onClick={() => void share()}
title="将页面转为公开链接,私有图片自动转为公开地址"
>
{phase.kind === 'loading' ? '处理中…' : '🌐 公开分享'}
</button>
)}
{phase.kind === 'error' && <p className="chat-share-side-error">{phase.message}</p>}
{onSave && (
<button type="button" className="chat-share-side-btn" onClick={handleSave}>
📄 保存页面
</button>
)}
</div>
</div>
</div>,
document.body,
);
}
+4 -3
View File
@@ -72,6 +72,7 @@ export function ChatView({
}, [sidebarOpen, onSidebarOpen]);
const busy = chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting';
const showHomeWelcome = messages.length === 0;
const handleSelectSession = (sessionId: string) => {
void switchSession(sessionId);
@@ -85,7 +86,7 @@ export function ChatView({
const sessionTitle = session
? getSessionDisplayName(session)
: chatState === 'connecting'
: chatState === 'idle'
? '新对话'
: '连接中…';
@@ -128,8 +129,8 @@ export function ChatView({
onDelete={(sessionId) => void deleteSession(sessionId)}
/>
<div className="app">
<header className="header">
<div className={`app${showHomeWelcome ? ' app-home' : ''}`}>
<header className={`header${showHomeWelcome ? ' header-home' : ''}`}>
<div className="header-left">
<button
type="button"
+110
View File
@@ -0,0 +1,110 @@
import { motion } from 'framer-motion';
import {
PenSquare,
Code2,
BarChart3,
FileText,
Bot,
} from 'lucide-react';
import { TKMindAvatar } from './TKMindAvatar';
const features = [
{ icon: PenSquare, text: '写作创作' },
{ icon: Code2, text: '编程开发' },
{ icon: BarChart3, text: '数据分析' },
{ icon: FileText, text: '文档总结' },
{ icon: Bot, text: '自动执行' },
];
export function ChatWelcomePanel({ compact }: { compact?: boolean }) {
if (compact) {
return (
<div className="empty-state empty-state-compact">
<TKMindAvatar />
<h2>TKMind</h2>
<p>继续和空间里的 Agent 对话</p>
</div>
);
}
return (
<div className="welcome-panel">
<div className="welcome-panel-content">
<motion.div
initial={{ opacity: 0, y: -15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="welcome-panel-avatar-wrap"
>
<TKMindAvatar size="lg" className="welcome-panel-avatar" />
</motion.div>
<motion.h2
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15 }}
className="welcome-panel-brand"
>
TKMind智趣
</motion.h2>
<motion.h1
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
className="welcome-panel-headline"
>
<span className="welcome-panel-headline-base">你的全能</span>
<span className="welcome-panel-headline-gradient">AI 助手</span>
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="welcome-panel-subtitle"
>
想到什么,就发什么。
</motion.p>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.55 }}
className="welcome-panel-description"
>
写作创作、编程开发、数据分析、文档总结、自动执行任务,统统交给 TKMind 智趣。
</motion.p>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 }}
className="welcome-panel-actions"
>
{features.map((item) => {
const Icon = item.icon;
return (
<div key={item.text} className="welcome-panel-pill">
<span className="welcome-panel-pill-icon" aria-hidden="true">
<Icon size={22} strokeWidth={2} />
</span>
<span>{item.text}</span>
</div>
);
})}
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.95 }}
className="welcome-panel-hint"
>
<div className="welcome-panel-hint-arrow">↓</div>
<div className="text-base">在下方输入你的问题或任务</div>
</motion.div>
</div>
</div>
);
}
+43 -13
View File
@@ -58,10 +58,12 @@ export function HistorySidebar({
}: HistorySidebarProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(appConfig.sessionPageSize);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
useEffect(() => {
if (open) {
setVisibleCount(appConfig.sessionPageSize);
setPendingDeleteId(null);
}
}, [open, sessions.length]);
@@ -74,14 +76,22 @@ export function HistorySidebar({
}
}, [visibleCount, sessions.length]);
const handleDelete = useCallback(
(sessionId: string, label: string) => {
if (!window.confirm(`确定删除「${label}」?此操作不可恢复。`)) return;
const handleDeleteClick = useCallback((sessionId: string) => {
setPendingDeleteId(sessionId);
}, []);
const handleConfirmDelete = useCallback(
(sessionId: string) => {
setPendingDeleteId(null);
onDelete(sessionId);
},
[onDelete],
);
const handleCancelDelete = useCallback(() => {
setPendingDeleteId(null);
}, []);
if (!open) return null;
const visibleSessions = sessions.slice(0, visibleCount);
@@ -119,26 +129,46 @@ export function HistorySidebar({
<ul className="sidebar-list">
{group.sessions.map((item) => {
const label = getSessionListLabel(item);
const isPending = pendingDeleteId === item.id;
return (
<li key={item.id} className="sidebar-item-wrap">
<li key={item.id} className={`sidebar-item-wrap${isPending ? ' sidebar-item-confirming' : ''}`}>
<button
type="button"
className={`sidebar-item ${item.id === activeSessionId ? 'sidebar-item-active' : ''}`}
onClick={() => onSelect(item.id)}
onClick={() => { if (!isPending) onSelect(item.id); }}
>
<span className="sidebar-item-title">{label}</span>
<span className="sidebar-item-meta">
{formatSessionTime(item.updated_at ?? item.created_at)}
</span>
</button>
<button
type="button"
className="sidebar-item-delete"
aria-label={`删除 ${label}`}
onClick={() => handleDelete(item.id, label)}
>
<CloseIcon />
</button>
{isPending ? (
<div className="sidebar-item-confirm">
<button
type="button"
className="sidebar-confirm-yes"
onClick={() => handleConfirmDelete(item.id)}
>
删除
</button>
<button
type="button"
className="sidebar-confirm-no"
onClick={handleCancelDelete}
>
取消
</button>
</div>
) : (
<button
type="button"
className="sidebar-item-delete"
aria-label={`删除 ${label}`}
onClick={() => handleDeleteClick(item.id)}
>
<CloseIcon />
</button>
)}
</li>
);
})}
+74 -26
View File
@@ -8,6 +8,8 @@ import { filterText } from '../utils/wordFilter';
import { formatChatTime, shouldShowTimestamp } from '../utils/time';
import { TKMindAvatar } from './TKMindAvatar';
import { UserAvatar } from './UserAvatar';
import { ChatSharePreviewModal } from './ChatSharePreviewModal';
import { ChatWelcomePanel } from './ChatWelcomePanel';
function CopyIcon() {
return (
@@ -37,18 +39,58 @@ function CheckIcon() {
);
}
function ToolSpinnerIcon() {
return (
<span className="tool-badge-spinner" aria-hidden="true">
<span />
</span>
);
}
function ToolDoneIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M5 12.5l4.5 4.5L19 7.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ToolBadge({ message }: { message: Message }) {
const tools = message.content.filter((c) => c.type === 'toolRequest' || c.type === 'toolResponse');
if (tools.length === 0) return null;
const completed = tools.filter((t) => t.type === 'toolResponse').length;
const total = tools.length;
return (
<div className="tool-badges">
{tools.map((tool, i) => (
<span key={`${message.id}-${i}`} className="tool-badge">
{tool.type === 'toolRequest'
? `🔧 ${tool.toolCall.functionName}`
: `✓ 工具完成`}
</span>
))}
{tools.map((tool, i) => {
const isRequest = tool.type === 'toolRequest';
const toolName = isRequest ? String(tool.toolCall.functionName ?? '').trim() : '';
let label: string;
if (isRequest && !toolName) {
label = `正在加载 tools (${completed}/${total})`;
} else {
label = isRequest ? toolName : '工具完成';
}
return (
<span
key={`${message.id}-${i}`}
className={`tool-badge${isRequest ? ' is-pending' : ' is-complete'}`}
>
{isRequest ? <ToolSpinnerIcon /> : <ToolDoneIcon />}
<span>{label}</span>
</span>
);
})}
</div>
);
}
@@ -126,6 +168,7 @@ function MessageRow({
saveDisabled,
publishUserId,
publishUsername,
sessionId,
compact = false,
}: {
message: Message;
@@ -135,9 +178,11 @@ function MessageRow({
saveDisabled?: boolean;
publishUserId?: string;
publishUsername?: string;
sessionId?: string;
compact?: boolean;
}) {
const [actionsOpen, setActionsOpen] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const rawText = getDisplayText(message);
const text = filterText(rawText);
const saveActions = getMessageSaveActions(text, { userId: publishUserId, username: publishUsername });
@@ -227,15 +272,14 @@ function MessageRow({
>
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
</button>
{saveActions.previewUrl && (
<a
{saveActions.previewUrl && sessionId && message.id && (
<button
type="button"
className="msg-open-preview"
href={saveActions.previewUrl}
target="_blank"
rel="noreferrer"
onClick={() => setPreviewOpen(true)}
>
打开预览
</a>
</button>
)}
</>
)}
@@ -254,15 +298,14 @@ function MessageRow({
>
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
</button>
{saveActions.previewUrl && (
<a
{saveActions.previewUrl && sessionId && message.id && (
<button
type="button"
className="msg-open-preview"
href={saveActions.previewUrl}
target="_blank"
rel="noreferrer"
onClick={() => setPreviewOpen(true)}
>
打开预览
</a>
</button>
)}
</>
)}
@@ -278,6 +321,14 @@ function MessageRow({
title={onAvatarClick ? '点击更换头像' : undefined}
/>
)}
{previewOpen && sessionId && message.id && (
<ChatSharePreviewModal
sessionId={sessionId}
messageId={message.id}
onClose={() => setPreviewOpen(false)}
onSave={onSaveAsPage ? () => onSaveAsPage(message) : undefined}
/>
)}
</div>
);
}
@@ -289,6 +340,7 @@ export function MessageList({
onSaveAsPage,
publishUserId,
publishUsername,
sessionId,
compact = false,
}: {
messages: Message[];
@@ -297,19 +349,14 @@ export function MessageList({
onSaveAsPage?: (message: Message) => void;
publishUserId?: string;
publishUsername?: string;
sessionId?: string;
compact?: boolean;
}) {
const { avatarUrl } = useUserAvatar();
return (
<div className="message-list">
{messages.length === 0 && (
<div className={`empty-state${compact ? ' empty-state-compact' : ''}`}>
<TKMindAvatar />
<h2>TKMind</h2>
<p>{compact ? '继续和空间里的 Agent 对话' : '发送消息即可在本机执行 Agent 任务'}</p>
</div>
)}
{messages.length === 0 && <ChatWelcomePanel compact={compact} />}
{messages.map((message, index) => {
const prev = index > 0 ? messages[index - 1] : undefined;
const showTime = shouldShowTimestamp(message.created, prev?.created);
@@ -324,6 +371,7 @@ export function MessageList({
saveDisabled={streaming}
publishUserId={publishUserId}
publishUsername={publishUsername}
sessionId={sessionId}
compact={compact}
/>
</div>
+17
View File
@@ -54,11 +54,15 @@ export function MindSpaceFeedCard({
previewMode = false,
onClick,
actions,
checked,
onCheckChange,
}: {
page: MindSpacePage;
previewMode?: boolean;
onClick: () => void;
actions?: { label: string; onClick: (e: React.MouseEvent) => void }[];
checked?: boolean;
onCheckChange?: (checked: boolean) => void;
}) {
const typeLabel = feedTypeLabel(page);
const isHtml = page.contentFormat === 'html';
@@ -68,6 +72,19 @@ export function MindSpaceFeedCard({
<div className="mindspace-feed-card-wrap">
<button type="button" className="mindspace-feed-card" onClick={onClick}>
<div className="mindspace-feed-cover">
{onCheckChange !== undefined && (
<label
className="mindspace-feed-select"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={checked ?? false}
onChange={(e) => onCheckChange(e.target.checked)}
/>
<span>{checked ? '已选' : '选择'}</span>
</label>
)}
{isHtml ? (
<WorkCover
coverMode="thumbnail-first"
+92 -15
View File
@@ -476,6 +476,8 @@ export function MindSpaceView({
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const [bulkDeletingAssets, setBulkDeletingAssets] = useState(false);
const [selectedDraftPageIds, setSelectedDraftPageIds] = useState<string[]>([]);
const [bulkDeletingDraftPages, setBulkDeletingDraftPages] = useState(false);
const [newPageOpen, setNewPageOpen] = useState(false);
const [newPageTitle, setNewPageTitle] = useState('');
const [newPageContent, setNewPageContent] = useState('');
@@ -1335,6 +1337,55 @@ export function MindSpaceView({
}
};
const selectedDraftPageSet = useMemo(() => new Set(selectedDraftPageIds), [selectedDraftPageIds]);
const allDraftPagesSelected =
pages.length > 0 && pages.every((p) => selectedDraftPageSet.has(p.id));
const toggleDraftPageSelected = (pageId: string) => {
setSelectedDraftPageIds((ids) =>
ids.includes(pageId) ? ids.filter((id) => id !== pageId) : [...ids, pageId],
);
};
const toggleAllDraftPagesSelected = () => {
if (allDraftPagesSelected) {
setSelectedDraftPageIds([]);
} else {
setSelectedDraftPageIds(pages.map((p) => p.id));
}
};
const removeDraftPages = async () => {
if (selectedDraftPageIds.length === 0) return;
if (previewMode) {
setError('预览模式下无法删除页面');
return;
}
setBulkDeletingDraftPages(true);
setError(null);
const deletingIds = [...selectedDraftPageIds];
const failedIds: string[] = [];
const failedNames: string[] = [];
try {
for (const pageId of deletingIds) {
try {
await deleteMindSpacePage(pageId);
if (selectedPageId === pageId) closePage();
} catch {
failedIds.push(pageId);
failedNames.push(pages.find((p) => p.id === pageId)?.title ?? pageId);
}
}
setSelectedDraftPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
await load();
if (failedNames.length > 0) {
setError(`有 ${failedNames.length} 个页面删除失败:${failedNames.slice(0, 3).join('、')}`);
}
} finally {
setBulkDeletingDraftPages(false);
}
};
const previewAsset = useMemo(
() => assets.find((asset) => asset.id === previewAssetId) ?? null,
[assets, previewAssetId],
@@ -1362,11 +1413,6 @@ export function MindSpaceView({
发布到广场
</button>
)}
{canGenerateWithAgent(asset) && (
<button type="button" onClick={() => openAgentGenerator(asset)}>
AI 生成页面
</button>
)}
{asset.publicUrl && (
<a
className="mindspace-asset-share"
@@ -2004,16 +2050,47 @@ export function MindSpaceView({
{selectedCategory.code === 'draft' ? (
pages.length > 0 ? (
<div className="mindspace-feed-grid">
{pages.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
previewMode={previewMode}
onClick={() => showPage(page.id)}
/>
))}
</div>
<>
<div className="mindspace-asset-bulkbar">
<button
type="button"
onClick={toggleAllDraftPagesSelected}
disabled={bulkDeletingDraftPages}
>
{allDraftPagesSelected ? '取消全选' : '全选'}
</button>
<span>已选 {selectedDraftPageIds.length} 项</span>
{selectedDraftPageIds.length > 0 && (
<button
type="button"
onClick={() => setSelectedDraftPageIds([])}
disabled={bulkDeletingDraftPages}
>
清空
</button>
)}
<button
type="button"
className="mindspace-asset-bulk-delete"
onClick={() => void removeDraftPages()}
disabled={selectedDraftPageIds.length === 0 || bulkDeletingDraftPages}
>
{bulkDeletingDraftPages ? '删除中…' : '删除选中'}
</button>
</div>
<div className="mindspace-feed-grid">
{pages.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
previewMode={previewMode}
onClick={() => showPage(page.id)}
checked={selectedDraftPageSet.has(page.id)}
onCheckChange={() => toggleDraftPageSelected(page.id)}
/>
))}
</div>
</>
) : (
<div className="mindspace-empty">
<h2>还没有页面草稿</h2>
+64 -24
View File
@@ -68,6 +68,7 @@ export function PageSaveDialog({
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveNotice, setSaveNotice] = useState<string | null>(null);
const [duplicateResolved, setDuplicateResolved] = useState<'replace' | 'new' | null>(null);
const [privateAcknowledged, setPrivateAcknowledged] = useState(false);
const [thumbnailVersion, setThumbnailVersion] = useState(0);
const [previewScrollLock, setPreviewScrollLock] = useState(false);
@@ -151,7 +152,10 @@ export function PageSaveDialog({
const privateNeedsAck =
categoryCode === 'private' && privacyFindings.length > 0 && analysis?.privacyScan.allowed;
const save = async () => {
const existingPage = analysis?.existingPage ?? null;
const showDuplicatePrompt = existingPage !== null && duplicateResolved === null && !saveNotice;
const save = async (replacePageId?: string) => {
if (!title.trim()) return;
setSaving(true);
setSaveError(null);
@@ -168,10 +172,11 @@ export function PageSaveDialog({
categoryCode === 'private' && privateNeedsAck && privateAcknowledged
? privacyFindings.map((finding) => finding.id)
: undefined,
replacePageId,
});
if (result.kind === 'page') {
setSaveNotice(`已保存到${CATEGORY_LABELS[categoryCode]}`);
onSaved({ kind: 'page', categoryCode: 'draft', pageId: result.page.id });
setSaveNotice(replacePageId ? '已替换原有页面' : `已保存到${CATEGORY_LABELS[categoryCode]}`);
onSaved({ kind: 'page', categoryCode, pageId: result.page.id });
return;
}
setSaveNotice(`已保存到${CATEGORY_LABELS[result.categoryCode]}`);
@@ -202,7 +207,29 @@ export function PageSaveDialog({
{analysisLoading && <div className="page-save-state">正在识别页面内容…</div>}
{analysisError && <div className="page-save-error">{analysisError}</div>}
{!analysisLoading && analysis && (
{!analysisLoading && showDuplicatePrompt && (
<div className="page-save-duplicate">
<p>此消息已保存为「{existingPage!.title}」,请选择操作:</p>
<div className="page-save-duplicate-actions">
<button
type="button"
className="page-save-duplicate-btn"
onClick={() => setDuplicateResolved('replace')}
>
替换旧版本
</button>
<button
type="button"
className="page-save-duplicate-btn page-save-duplicate-btn-new"
onClick={() => setDuplicateResolved('new')}
>
另存为新页面
</button>
</div>
</div>
)}
{!analysisLoading && analysis && !showDuplicatePrompt && (
<div className="page-save-layout">
<div className="page-save-form">
{isStaticHtml && analysis && (
@@ -225,6 +252,7 @@ export function PageSaveDialog({
onChange={(event) => {
const nextIndex = Number(event.target.value);
setSelectedLinkIndex(nextIndex);
setDuplicateResolved(null);
void loadAnalysis(nextIndex);
}}
>
@@ -362,26 +390,38 @@ export function PageSaveDialog({
{saveError && <div className="page-save-error">{saveError}</div>}
</div>
<div className="page-save-actions">
<button type="button" onClick={onClose} disabled={saving}>
取消
</button>
<button
type="button"
className="page-save-primary"
onClick={() => void save()}
disabled={
saving ||
analysisLoading ||
!title.trim() ||
Boolean(analysisError) ||
privateBlocked ||
(privateNeedsAck && !privateAcknowledged)
}
>
{saving ? '正在保存…' : categoryCode === 'draft' ? '保存并进入编辑' : '保存到空间'}
</button>
</div>
{!showDuplicatePrompt && (
<div className="page-save-actions">
<button type="button" onClick={onClose} disabled={saving}>
取消
</button>
<button
type="button"
className="page-save-primary"
onClick={() =>
void save(
duplicateResolved === 'replace' && existingPage ? existingPage.id : undefined,
)
}
disabled={
saving ||
analysisLoading ||
!title.trim() ||
Boolean(analysisError) ||
privateBlocked ||
(privateNeedsAck && !privateAcknowledged)
}
>
{saving
? '正在保存…'
: duplicateResolved === 'replace'
? '替换并进入编辑'
: categoryCode === 'draft'
? '保存并进入编辑'
: '保存到空间'}
</button>
</div>
)}
</section>
</div>,
document.body,
+2 -2
View File
@@ -2,11 +2,11 @@ import tkmindAvatar from '../assets/tkmind-avatar.png';
type TKMindAvatarProps = {
className?: string;
size?: 'sm' | 'md';
size?: 'sm' | 'md' | 'lg';
};
export function TKMindAvatar({ className = '', size = 'sm' }: TKMindAvatarProps) {
const sizeClass = size === 'md' ? 'tkmind-avatar-md' : 'tkmind-avatar-sm';
const sizeClass = size === 'lg' ? 'tkmind-avatar-lg' : size === 'md' ? 'tkmind-avatar-md' : 'tkmind-avatar-sm';
return (
<div
+73 -53
View File
@@ -52,6 +52,7 @@ import {
import { buildAbsoluteAssetImageUrl } from '../utils/mindspaceCards';
import {
buildUserMessage,
mergeConversationSnapshot,
normalizeConversationMessages,
getDisplayText,
getToolConfirmation,
@@ -60,7 +61,12 @@ import {
isRelayServerErrorMessage,
pushMessage,
} from '../utils/message';
import { prependUnique, shouldShowNewChatTitle, sortAndTrim, touchSession } from '../utils/sessions';
import {
mergeSessionLists,
prependUnique,
shouldShowNewChatTitle,
touchSession,
} from '../utils/sessions';
const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用';
@@ -338,7 +344,7 @@ export function useTKMindChat(
setSessionsLoading(true);
try {
const items = await listSessions();
setSessions(sortAndTrim(items));
setSessions((prev) => mergeSessionLists(prev, items));
} catch (err) {
if (err instanceof ApiError && err.status === 0) {
setError('网络不可用,无法刷新历史列表');
@@ -385,6 +391,7 @@ export function useTKMindChat(
const syncSessionMessages = useCallback(async (sessionId: string) => {
try {
const detail = await loadSessionDetail(sessionId);
setSessions((prev) => prependUnique(prev, detail.session));
if (sessionRef.current?.id !== sessionId) return;
messagesRef.current = detail.messages;
setMessages(detail.messages);
@@ -467,12 +474,14 @@ export function useTKMindChat(
}
return;
}
case 'UpdateConversation':
messagesRef.current = normalizeConversationMessages(
case 'UpdateConversation': {
const snapshot = normalizeConversationMessages(
event.conversation.filter((m) => m.metadata?.userVisible),
);
messagesRef.current = mergeConversationSnapshot(messagesRef.current, snapshot);
setMessages(messagesRef.current);
return;
}
case 'Error':
setError(event.error);
setChatState('error');
@@ -519,11 +528,22 @@ export function useTKMindChat(
sessionId,
(event) => {
const rid = activeRequestId.current;
if (rid) processEvent(event, rid, sessionId);
else if (event.type === 'ActiveRequests' && event.request_ids.length > 0) {
activeRequestId.current = event.request_ids[0];
setChatState('streaming');
if (event.type === 'ActiveRequests') {
if (!rid && event.request_ids.length > 0) {
// SSE reconnected while agent was running — adopt the active request.
activeRequestId.current = event.request_ids[0];
setChatState('streaming');
} else if (rid && !event.request_ids.includes(rid)) {
// Our request is no longer active — agent finished while SSE was paused.
// Sync the final state from the server.
activeRequestId.current = null;
setChatState('idle');
setPendingTool(null);
void syncSessionMessages(sessionId);
}
return;
}
if (rid) processEvent(event, rid, sessionId);
},
(err) => {
if (err instanceof ApiError && err.status === 401) return;
@@ -531,6 +551,8 @@ export function useTKMindChat(
notifyInsufficientBalance();
return;
}
// SSE 断连时如果 agent 还在跑,不打断 chatState,等重连后事件恢复
if (activeRequestId.current) return;
setError(err.message);
},
{
@@ -643,7 +665,6 @@ export function useTKMindChat(
setError(null);
const previousSessionId = readStoredSessionId(userRef.current?.id);
let sessionId: string | null = null;
let staleSession = false;
if (previousSessionId) {
@@ -667,20 +688,12 @@ export function useTKMindChat(
}
}
const freshSession = await startSession();
sessionId = freshSession.id;
writeStoredSessionId(userRef.current?.id, sessionId);
if (staleSession) {
setNotice('上次会话已失效,已为你新建对话');
}
if (cancelled) return;
await connectSessionRef.current(sessionId, {
skipResume: true,
seedSession: freshSession,
});
if (cancelled) return;
void loadProjectMemory(sessionId, false);
setChatState('idle');
void refreshSessions();
} catch (err) {
if (!cancelled) {
@@ -745,7 +758,7 @@ export function useTKMindChat(
imageUrls?: string[],
) => {
const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
if (!session || (!text.trim() && normalizedImageUrls.length === 0)) return;
if (!text.trim() && normalizedImageUrls.length === 0) return;
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
const trimmed = text.trim();
@@ -764,15 +777,41 @@ export function useTKMindChat(
activeRequestId.current = requestId;
messagesRef.current = [...messagesRef.current, userMessage];
setMessages(messagesRef.current);
setSessions((prev) => touchSession(prev, session.id, 1));
setChatState('streaming');
setError(null);
setPendingTool(null);
let activeSessionId = session?.id ?? null;
if (!activeSessionId) {
try {
const created = await startSession();
activeSessionId = created.id;
writeStoredSessionId(userRef.current?.id, created.id);
setSession(created);
setSessions((prev) => prependUnique(prev, created));
subscribeToSession(created.id);
void ensureProvider(created.id);
void loadProjectMemory(created.id, false);
void refreshSessions();
} catch (err) {
if (err instanceof ApiError && err.status === 402) {
notifyInsufficientBalance();
} else {
setError(err instanceof Error ? err.message : String(err));
setChatState('error');
}
activeRequestId.current = null;
return;
}
} else {
setSessions((prev) => touchSession(prev, activeSessionId!, 1));
}
try {
await sendReply(session.id, requestId, userMessage);
await sendReply(activeSessionId, requestId, userMessage);
} catch (err) {
setSessions((prev) => touchSession(prev, session.id, -1));
if (session) setSessions((prev) => touchSession(prev, activeSessionId!, -1));
if (err instanceof ApiError && err.status === 402) {
notifyInsufficientBalance();
} else {
@@ -782,7 +821,7 @@ export function useTKMindChat(
activeRequestId.current = null;
}
},
[notifyInsufficientBalance, session, chatState, grantedSkills],
[notifyInsufficientBalance, session, chatState, grantedSkills, subscribeToSession, ensureProvider, loadProjectMemory, refreshSessions],
);
const stop = useCallback(async () => {
@@ -808,37 +847,18 @@ export function useTKMindChat(
const newSession = useCallback(() => {
if (chatState === 'streaming') return;
resetSessionView();
connectTokenRef.current += 1;
unsubscribeRef.current?.();
unsubscribeRef.current = null;
clearStoredSessionId(userRef.current?.id);
activeRequestId.current = null;
messagesRef.current = [];
setMessages([]);
setSession(null);
const token = connectTokenRef.current;
void (async () => {
try {
const created = await startSession();
if (token !== connectTokenRef.current) return;
writeStoredSessionId(userRef.current?.id, created.id);
setSession(created);
setSessions((prev) => prependUnique(prev, created));
void loadProjectMemory(created.id, false);
void refreshSessions();
await ensureProvider(created.id);
if (token !== connectTokenRef.current) return;
await connectSession(created.id, { showLoading: false });
} catch (err) {
if (token !== connectTokenRef.current) return;
if (err instanceof ApiError && err.status === 402) {
notifyInsufficientBalance();
return;
}
setError(err instanceof Error ? err.message : String(err));
setChatState('error');
}
})();
}, [chatState, connectSession, ensureProvider, loadProjectMemory, notifyInsufficientBalance, refreshSessions, resetSessionView]);
setError(null);
setPendingTool(null);
setChatState('idle');
}, [chatState]);
const refreshProjectMemory = useCallback(async () => {
if (!session || chatState === 'streaming') return;
+880 -30
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -515,6 +515,7 @@ export type ChatSaveAnalysis = {
filename: string | null;
hasHtmlContent: boolean;
privacyScan: MindSpacePrivacyScan;
existingPage: { id: string; title: string; categoryCode: string } | null;
};
export type ChatSaveResult =
+20
View File
@@ -113,6 +113,26 @@ export function normalizeConversationMessages(messages: Message[]): Message[] {
);
}
/**
* Merge an authoritative server conversation snapshot into the currently
* displayed messages without ever erasing what the user can already see.
*
* `UpdateConversation` snapshots arrive on every SSE (re)connect — which on
* mobile happens constantly via the page visibility cycle. A mid-turn snapshot
* can be shorter than the live view (e.g. assistant messages not yet flagged
* userVisible), so a blind replace would blank an active conversation even
* though the backend session is alive and persisted. We therefore treat the
* snapshot as authoritative for content/order but keep any locally-displayed
* messages the snapshot hasn't caught up to yet (optimistic / in-flight tail).
* The authoritative full reload on `Finish` corrects any drift afterwards.
*/
export function mergeConversationSnapshot(current: Message[], incoming: Message[]): Message[] {
if (incoming.length === 0) return current;
const incomingIds = new Set(incoming.map((message) => message.id).filter(Boolean));
const localOnly = current.filter((message) => message.id && !incomingIds.has(message.id));
return localOnly.length ? [...incoming, ...localOnly] : incoming;
}
export function getDisplayText(message: Message): string {
if ('displayText' in message.metadata) {
return message.metadata.displayText ?? '';
+46 -5
View File
@@ -3,9 +3,18 @@ import type { Session } from '../types';
export const DEFAULT_CHAT_TITLE = 'New Chat';
function isGeneratedSessionName(name: string): boolean {
return /^20\d{6}(?:[_-]\d+)?$/.test(name.trim());
}
function isDefaultSessionName(name: string): boolean {
const trimmed = name.trim();
return !trimmed || trimmed === DEFAULT_CHAT_TITLE || trimmed === '新对话' || isGeneratedSessionName(trimmed);
}
export function shouldShowNewChatTitle(session: Session): boolean {
if (session.recipe) return false;
return !session.user_set_name && session.message_count === 0;
return !session.user_set_name && session.message_count === 0 && isDefaultSessionName(session.name);
}
export function sortAndTrim(sessions: Session[]): Session[] {
@@ -18,9 +27,39 @@ export function sortAndTrim(sessions: Session[]): Session[] {
.slice(0, appConfig.sessionMaxCount);
}
export function hasMeaningfulSessionTitle(session: Session): boolean {
if (session.user_set_name) return Boolean(session.name.trim());
if (session.recipe?.title?.trim()) return true;
return !isDefaultSessionName(session.name);
}
export function mergeSessionRecord(existing: Session | undefined, incoming: Session): Session {
if (!existing) return incoming;
const keepExistingTitle =
hasMeaningfulSessionTitle(existing) && !hasMeaningfulSessionTitle(incoming);
if (!keepExistingTitle) return { ...existing, ...incoming, id: incoming.id };
return {
...existing,
...incoming,
id: incoming.id,
name: existing.name,
user_set_name: existing.user_set_name,
recipe: existing.recipe ?? incoming.recipe,
};
}
export function mergeSessionLists(prev: Session[], incoming: Session[]): Session[] {
const previousById = new Map(prev.map((item) => [item.id, item]));
return sortAndTrim(incoming.map((item) => mergeSessionRecord(previousById.get(item.id), item)));
}
export function prependUnique(prev: Session[], session: Session): Session[] {
if (prev.some((s) => s.id === session.id)) return sortAndTrim(prev);
return sortAndTrim([session, ...prev.filter((s) => s.id !== session.id)]);
return sortAndTrim([
mergeSessionRecord(prev.find((s) => s.id === session.id), session),
...prev.filter((s) => s.id !== session.id),
]);
}
export function touchSession(sessions: Session[], sessionId: string, messageDelta = 0): Session[] {
@@ -102,9 +141,11 @@ export function groupSessionsByDate(sessions: Session[]): SessionDateGroup[] {
return groups;
}
const DEFAULT_SESSION_NAMES = new Set([DEFAULT_CHAT_TITLE, '新对话']);
export function getSessionListLabel(session: Session): string {
const displayName = getSessionDisplayName(session);
if (displayName !== DEFAULT_CHAT_TITLE && displayName !== '新对话') {
const displayName = getSessionDisplayName(session).trim();
if (displayName && !DEFAULT_SESSION_NAMES.has(displayName) && !isGeneratedSessionName(displayName)) {
return displayName;
}
if (session.message_count > 0) {
+55 -5
View File
@@ -266,7 +266,7 @@ export async function buildVisionPayload({
};
}
export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth, llmProviderService, localFetchAsset, subscriptionService }) {
export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth, llmProviderService, localFetchAsset, subscriptionService, sessionSnapshotService }) {
const targets = apiTargets?.length ? apiTargets : apiTarget ? [apiTarget] : [];
const primaryTarget = targets[0] ?? apiTarget ?? '';
let rrIdx = 0;
@@ -296,6 +296,45 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
}
}
function isGeneratedSessionName(name) {
const normalized = typeof name === 'string' ? name.trim() : '';
return /^20\d{6}(?:[_-]\d+)?$/.test(normalized);
}
// A session "name" that carries no real topic: empty, or Goose's default
// placeholder before its describe step has run.
function hasDefaultSessionName(session) {
if (session?.user_set_name) return false;
const name = typeof session?.name === 'string' ? session.name.trim() : '';
return name === '' || name === 'New Chat' || name === '新对话' || isGeneratedSessionName(name);
}
// For sessions still carrying a default name, fill in a title derived from the
// first user message (kept in the snapshot cache) so the history list shows a
// meaningful label instead of "会话 <id>". Best-effort: never blocks the list.
async function enrichSessionHistory(sessions) {
if (!sessionSnapshotService?.isEnabled?.()) return;
const needsSummary = sessions.filter(
(session) => hasDefaultSessionName(session) || Number(session?.message_count ?? 0) === 0,
);
if (needsSummary.length === 0) return;
try {
const summaries = await sessionSnapshotService.getHistorySummaries(needsSummary.map((s) => s.id));
for (const session of needsSummary) {
const summary = summaries.get(session.id);
if (!summary) continue;
if (summary.title && hasDefaultSessionName(session)) {
session.name = summary.title;
}
if (Number(session?.message_count ?? 0) === 0 && Number(summary.messageCount ?? 0) > 0) {
session.message_count = Number(summary.messageCount);
}
}
} catch {
// Non-fatal: fall back to the client-side label.
}
}
async function pickTarget() {
if (targets.length <= 1) return primaryTarget;
for (let i = 0; i < targets.length; i += 1) {
@@ -632,7 +671,10 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
if (healthyTargets < targets.length) {
res.setHeader('X-TKMind-Degraded', '1');
}
res.json({ sessions: [...sessionsById.values()] });
const sessions = [...sessionsById.values()];
await enrichSessionHistory(sessions);
res.json({ sessions });
} catch (err) {
res.status(500).json({
message: sanitizeUserFacingProxyMessage(
@@ -725,6 +767,13 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
});
const source = Readable.fromWeb(upstream.body);
// 每 20s 发一个注释行保持连接,防止 nginx/代理因静默超时切断 SSE
const keepalive = setInterval(() => {
if (!res.writableEnded) res.write(': keepalive\n\n');
}, 20000);
const stopKeepalive = () => clearInterval(keepalive);
billingTransform.on('data', (chunk) => {
res.write(chunk);
if (pendingBalance != null) {
@@ -732,9 +781,10 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
pendingBalance = null;
}
});
billingTransform.on('end', () => res.end());
billingTransform.on('error', () => res.end());
source.on('error', () => res.end());
billingTransform.on('end', () => { stopKeepalive(); res.end(); });
billingTransform.on('error', () => { stopKeepalive(); res.end(); });
source.on('error', () => { stopKeepalive(); res.end(); });
req.on('close', stopKeepalive);
source.pipe(billingTransform);
} catch (err) {
res.status(502).json({
+7 -1
View File
@@ -1662,9 +1662,15 @@ export function createUserAuth(pool, options = {}) {
try {
const layout = await publishLayoutFor(user, { migrateLegacy: false });
sandboxMcp = {
serverPath: resolveSandboxMcpServerPath(),
// When goosed runs in a container its filesystem is split from the portal's,
// so the host paths the portal would otherwise send (node binary, MCP script)
// are not resolvable. These env overrides let the portal send container-canonical
// paths instead. Unset (e.g. local dev, co-located native goosed) => fall back to
// the portal's own paths, so behavior is unchanged.
serverPath: process.env.GOOSED_MCP_SERVER_PATH ?? resolveSandboxMcpServerPath(),
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);
+1 -1
View File
@@ -57,7 +57,7 @@ test('publish dir and public url use stable user id', () => {
const skillPath = path.join(layout.publishDir, '.agents', 'skills', 'static-page-publish', 'SKILL.md');
assert.ok(fs.existsSync(skillPath));
const skillText = fs.readFileSync(skillPath, 'utf8');
assert.match(skillText, new RegExp(`g2\\.tkmind\\.cn/${PUBLISH_ROOT_DIR}/${USER_ID}/${PUBLIC_ZONE_DIR}/`));
assert.match(skillText, new RegExp(`m\\.tkmind\\.cn/${PUBLISH_ROOT_DIR}/${USER_ID}/${PUBLIC_ZONE_DIR}/`));
assert.match(skillText, /public\/report\.html/);
assert.match(skillText, /\[.*\]\(.*\)/);
assert.match(skillText, /mindspace-cover/);
+11 -1
View File
@@ -182,6 +182,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,13 +210,22 @@ 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,
+64
View File
@@ -367,6 +367,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',