From 4bb4858e84b81cd2dde0e90dfbe79603d3944936 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 27 Jun 2026 18:13:44 +0800 Subject: [PATCH 01/37] feat: package portal updates for 103 release --- .gitignore | 1 + PRODUCTION_RELEASE_RULES.md | 11 +- capabilities.mjs | 5 +- db.mjs | 21 +- docs/release-deploy.md | 16 + llm-providers.test.mjs | 13 +- mindspace-assets.test.mjs | 2 +- mindspace-pages.mjs | 61 ++ mindspace-sandbox-mcp.mjs | 3 + package-lock.json | 56 +- package.json | 2 + schema.sql | 3 +- scripts/build-portal-runtime.mjs | 3 +- scripts/release-portal-runtime-prod.sh | 32 + scripts/run-memind-portal-prod.sh | 1 + server.mjs | 88 ++- session-snapshot.mjs | 190 ++++- src/api/client.ts | 21 + src/components/ChatPanel.tsx | 73 +- src/components/ChatSharePreviewModal.tsx | 111 +++ src/components/ChatView.tsx | 7 +- src/components/ChatWelcomePanel.tsx | 110 +++ src/components/HistorySidebar.tsx | 56 +- src/components/MessageList.tsx | 100 ++- src/components/MindSpaceFeedCard.tsx | 17 + src/components/MindSpaceView.tsx | 107 ++- src/components/PageSaveDialog.tsx | 88 ++- src/components/TKMindAvatar.tsx | 4 +- src/hooks/useTKMindChat.ts | 126 ++-- src/index.css | 910 ++++++++++++++++++++++- src/types.ts | 1 + src/utils/message.ts | 20 + src/utils/sessions.ts | 51 +- tkmind-proxy.mjs | 60 +- user-auth.mjs | 8 +- user-publish.test.mjs | 2 +- wechat-mp.mjs | 12 +- wechat-mp.test.mjs | 64 ++ 38 files changed, 2224 insertions(+), 232 deletions(-) create mode 100644 src/components/ChatSharePreviewModal.tsx create mode 100644 src/components/ChatWelcomePanel.tsx diff --git a/.gitignore b/.gitignore index 04b6af0..d5ddfbb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ ops/node_modules/ dist/ ops/dist/ +.runtime/portal/ .env .env.local diff --git a/PRODUCTION_RELEASE_RULES.md b/PRODUCTION_RELEASE_RULES.md index 167030c..d56bf9b 100644 --- a/PRODUCTION_RELEASE_RULES.md +++ b/PRODUCTION_RELEASE_RULES.md @@ -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、跳过发布包的理由。 diff --git a/capabilities.mjs b/capabilities.mjs index fee7b5d..3de40bf 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -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) { diff --git a/db.mjs b/db.mjs index 27cebe2..a371908 100644 --- a/db.mjs +++ b/db.mjs @@ -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) { diff --git a/docs/release-deploy.md b/docs/release-deploy.md index d2a4ea5..89ede21 100644 --- a/docs/release-deploy.md +++ b/docs/release-deploy.md @@ -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` diff --git a/llm-providers.test.mjs b/llm-providers.test.mjs index b3c9671..bc46e3a 100644 --- a/llm-providers.test.mjs +++ b/llm-providers.test.mjs @@ -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}`); }, }; diff --git a/mindspace-assets.test.mjs b/mindspace-assets.test.mjs index 4fdb658..a63efc3 100644 --- a/mindspace-assets.test.mjs +++ b/mindspace-assets.test.mjs @@ -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')), diff --git a/mindspace-pages.mjs b/mindspace-pages.mjs index 92fca08..76d30f0 100644 --- a/mindspace-pages.mjs +++ b/mindspace-pages.mjs @@ -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} 个版本`, diff --git a/mindspace-sandbox-mcp.mjs b/mindspace-sandbox-mcp.mjs index f8b2095..583fd4a 100644 --- a/mindspace-sandbox-mcp.mjs +++ b/mindspace-sandbox-mcp.mjs @@ -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; } diff --git a/package-lock.json b/package-lock.json index 56a7fce..4d7e980 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3a963fe..87e15a2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/schema.sql b/schema.sql index b1ca566..bd1976d 100644 --- a/schema.sql +++ b/schema.sql @@ -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 '', diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs index 5642c1a..655cd20 100755 --- a/scripts/build-portal-runtime.mjs +++ b/scripts/build-portal-runtime.mjs @@ -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:', diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index fcf4bc2..991c5c7 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -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 diff --git a/scripts/run-memind-portal-prod.sh b/scripts/run-memind-portal-prod.sh index 146bd92..1f14012 100755 --- a/scripts/run-memind-portal-prod.sh +++ b/scripts/run-memind-portal-prod.sh @@ -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}" diff --git a/server.mjs b/server.mjs index c619976..7e5e2c0 100644 --- a/server.mjs +++ b/server.mjs @@ -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); diff --git a/session-snapshot.mjs b/session-snapshot.mjs index a5c33b7..0a1b933 100644 --- a/session-snapshot.mjs +++ b/session-snapshot.mjs @@ -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; 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 }; } diff --git a/src/api/client.ts b/src/api/client.ts index 00e30e4..a15cc7b 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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 { let res: Response; try { @@ -754,6 +773,7 @@ export async function saveChatMessageAsPage(input: { categoryCode?: MindSpaceSaveCategory; selectedLinkIndex?: number; acknowledgedFindingIds?: string[]; + replacePageId?: string; }): Promise { 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, }), }, ); diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 25fa6c6..c707182 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -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(null); const [voiceStopSignal, setVoiceStopSignal] = useState(0); + const [randomPrompt] = useState( + () => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)], + ); const imageInputRef = useRef(null); const bottomRef = useRef(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 ( <> -
+
setPageSource(message)} publishUserId={user?.id} publishUsername={user?.username} + sessionId={session?.id} compact={compact} /> {!compact && ( @@ -369,7 +406,7 @@ export function ChatPanel({ /> )} -