From fc5b50c936401c53e8e4de5ee82da237486ce6d6 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 27 Jun 2026 21:31:02 +0800 Subject: [PATCH 1/5] fix: make goosed mcp paths container safe --- capabilities.mjs | 17 ++++++-- capabilities.test.mjs | 28 +++++++++++++ llm-providers.mjs | 33 ++++++++++----- llm-providers.test.mjs | 72 +++++++++++++++++++++++++++++--- scripts/build-portal-runtime.mjs | 14 ++++--- server.mjs | 20 ++++++--- user-auth.mjs | 3 +- 7 files changed, 157 insertions(+), 30 deletions(-) diff --git a/capabilities.mjs b/capabilities.mjs index fee7b5d..b5c86e2 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -3,10 +3,18 @@ import { fileURLToPath } from 'node:url'; import { resolveAgentGooseMode } from './policies.mjs'; /** Spawned as a separate Node process by goosed; must sit beside bundled portal runtime. */ -export function resolveSandboxMcpServerPath() { +export function resolveSandboxMcpServerPath(overridePath) { + const normalized = String(overridePath ?? '').trim(); + if (normalized) return normalized; return path.join(path.dirname(fileURLToPath(import.meta.url)), 'mindspace-sandbox-mcp.mjs'); } +export function resolveSandboxMcpNodeExecPath(overridePath) { + const normalized = String(overridePath ?? '').trim(); + if (normalized) return normalized; + return process.execPath; +} + export const CAPABILITY_CATALOG = [ { key: 'shell', @@ -307,7 +315,7 @@ export function buildAgentExtensionPolicy( '工作区沙箱文件系统与用户私有数据空间。用户私有数据空间是当前用户唯一的 SQLite 数据库,适合问卷、表单、清单、调研数据和分析中间表;不要用于账号、计费、权限、审计、公开平台数据或跨用户数据。', display_name: 'sandbox-fs', bundled: false, - cmd: sandboxMcp.nodeExecPath ?? process.execPath, + cmd: resolveSandboxMcpNodeExecPath(sandboxMcp.nodeExecPath), // sandboxRoot passed as argv[2] so it works even if goosed doesn't forward envs args: [sandboxMcp.serverPath, sandboxMcp.sandboxRoot], // envs (goosed field name) as belt-and-suspenders backup @@ -350,7 +358,10 @@ export function buildAgentExtensionPolicy( extensions.push(makeExtension('platform', 'summon', summonTools)); } } - if (capabilities.code_sandbox) { + const enableCodeExecutionExtension = /^(1|true|yes)$/i.test( + process.env.TKMIND_ENABLE_CODE_EXECUTION_EXTENSION ?? '', + ); + if (capabilities.code_sandbox && enableCodeExecutionExtension) { extensions.push(makeExtension('platform', 'code_execution', [])); } if (capabilities.chat_recall) { diff --git a/capabilities.test.mjs b/capabilities.test.mjs index 34ec989..e05fef6 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -8,6 +8,7 @@ import { clampUserCapabilities, DEFAULT_USER_CAPABILITIES, normalizeCapabilityPatch, + resolveSandboxMcpNodeExecPath, resolveSandboxMcpServerPath, sandboxDeveloperTools, sandboxMcpTools, @@ -20,6 +21,18 @@ test('resolveSandboxMcpServerPath resolves to an existing MCP entry file', () => assert.ok(fs.existsSync(serverPath)); }); +test('resolveSandboxMcpServerPath honors container-path override without host fs checks', () => { + assert.equal( + resolveSandboxMcpServerPath('/opt/portal/mindspace-sandbox-mcp.mjs'), + '/opt/portal/mindspace-sandbox-mcp.mjs', + ); +}); + +test('resolveSandboxMcpNodeExecPath honors container-path override without host fs checks', () => { + assert.equal(resolveSandboxMcpNodeExecPath('/usr/local/bin/node'), '/usr/local/bin/node'); + assert.equal(resolveSandboxMcpNodeExecPath(''), process.execPath); +}); + test('default user policy blocks dangerous capabilities', () => { assert.equal(DEFAULT_USER_CAPABILITIES.shell, false); assert.equal(DEFAULT_USER_CAPABILITIES.filesystem, false); @@ -222,6 +235,7 @@ test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of const sandboxExt = policy.extensionOverrides.find((ext) => ext.name === 'sandbox-fs'); assert.ok(sandboxExt, 'sandbox-fs extension should be present'); assert.equal(sandboxExt.type, 'stdio'); + assert.equal(sandboxExt.cmd, process.execPath); assert.equal(sandboxExt.envs.SANDBOX_ROOT, '/opt/h5/MindSpace/abc123'); assert.equal(sandboxExt.args[1], '/opt/h5/MindSpace/abc123'); // also passed as argv[2] assert.ok(sandboxExt.available_tools.includes('write_file')); @@ -237,3 +251,17 @@ test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of assert.ok(!allTools.includes('write'), 'built-in write should not be exposed'); assert.ok(!allTools.includes('shell'), 'shell should not be exposed'); }); + +test('sandboxMcp honors container node executable override', () => { + const caps = { ...DEFAULT_USER_CAPABILITIES, static_publish: true }; + const policy = buildAgentExtensionPolicy(caps, { + sandboxMcp: { + serverPath: '/opt/h5/mindspace-sandbox-mcp.mjs', + sandboxRoot: '/opt/h5/MindSpace/abc123', + nodeExecPath: '/usr/local/bin/node', + }, + }); + + const sandboxExt = policy.extensionOverrides.find((ext) => ext.name === 'sandbox-fs'); + assert.equal(sandboxExt?.cmd, '/usr/local/bin/node'); +}); diff --git a/llm-providers.mjs b/llm-providers.mjs index 719928c..6461a37 100644 --- a/llm-providers.mjs +++ b/llm-providers.mjs @@ -861,9 +861,12 @@ function validateCustomPayload(payload) { export function createLlmProviderService( pool, - { apiTarget, apiSecret, encryptionKey, apiFetchImpl = undiciFetch } = {}, + { apiTarget, apiTargets = [], apiSecret, encryptionKey, apiFetchImpl = undiciFetch } = {}, ) { const launchStates = new Map(); + const syncTargets = [ + ...new Set([...(Array.isArray(apiTargets) ? apiTargets : []), apiTarget].filter(Boolean)), + ]; function catalogItem(providerId) { return catalogById[providerId] ?? null; @@ -1742,19 +1745,29 @@ export function createLlmProviderService( }, async syncSelectedToGoosed() { - let resolved = await resolveExecutorProvider('goose'); - if (!resolved.ok) { - resolved = await resolveSelectedProvider(); - } - if (!resolved.ok) { - return { ok: false, synced: false, message: resolved.message }; + const targets = syncTargets.length ? syncTargets : [apiTarget].filter(Boolean); + let lastResolved = null; + for (const target of targets) { + const targetFetch = (url, init) => { + const pathname = `${url.pathname}${url.search}`; + return goosedApiFetch(target, apiSecret, pathname, init, apiFetchImpl); + }; + let resolved = await resolveExecutorProvider('goose', 'default', targetFetch); + if (!resolved.ok) { + resolved = await resolveSelectedProvider(targetFetch); + } + if (!resolved.ok) { + return { ok: false, synced: false, target, message: resolved.message }; + } + lastResolved = resolved; } return { ok: true, synced: true, - source: resolved.source, - providerId: resolved.providerId, - model: resolved.model, + targets, + source: lastResolved.source, + providerId: lastResolved.providerId, + model: lastResolved.model, }; }, diff --git a/llm-providers.test.mjs b/llm-providers.test.mjs index b3c9671..e840977 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}`); }, }; @@ -597,6 +598,65 @@ test('syncProfileToGoosed writes provider, model and secret keys for builtin', a ); }); +test('syncSelectedToGoosed writes selected provider to every configured target', async () => { + const encrypted = encryptSecret('sk-test', 'unit-test-secret'); + const row = { + id: 'key-deepseek', + provider_id: 'custom_deepseek', + provider_kind: 'builtin', + name: 'DeepSeek', + api_url: null, + base_path: null, + models_json: JSON.stringify(['deepseek-chat']), + goosed_provider_id: null, + engine: 'openai', + relay_provider: null, + api_key_ciphertext: encrypted.ciphertext, + api_key_iv: encrypted.iv, + api_key_tag: encrypted.tag, + default_model: 'deepseek-chat', + status: 'active', + is_selected: 1, + created_at: 1, + updated_at: 1, + }; + const pool = { + async query(sql) { + if (sql.includes('SELECT * FROM h5_llm_executor_bindings')) return [[]]; + if (sql.includes('is_selected = 1')) return [[row]]; + throw new Error(`Unexpected SQL: ${sql}`); + }, + }; + const calls = []; + const mockFetch = async (url, init) => { + calls.push({ url: String(url), body: JSON.parse(init.body) }); + if (String(url).includes('/chat/completions')) { + return { + ok: true, + json: async () => ({ choices: [{ message: { content: 'ok' } }] }), + text: async () => JSON.stringify({ choices: [{ message: { content: 'ok' } }] }), + }; + } + return { ok: true, text: async () => '' }; + }; + const service = createLlmProviderService(pool, { + apiTarget: 'https://127.0.0.1:18006', + apiTargets: ['https://127.0.0.1:18006', 'https://127.0.0.1:18007'], + apiSecret: 'secret', + encryptionKey: 'unit-test-secret', + apiFetchImpl: mockFetch, + }); + const result = await service.syncSelectedToGoosed(); + assert.equal(result.ok, true); + assert.deepEqual(result.targets, ['https://127.0.0.1:18006', 'https://127.0.0.1:18007']); + assert.deepEqual( + calls + .filter((call) => call.url.includes('/config/upsert') && call.body.key === 'GOOSE_PROVIDER') + .map((call) => new URL(call.url).port), + ['18006', '18007'], + ); +}); + test('syncProfileToGoosed tolerates missing /config/upsert endpoint', async () => { const mockFetch = async (_url, init) => { if (String(_url).includes('/config/custom-providers')) { diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs index 5642c1a..15ad61d 100755 --- a/scripts/build-portal-runtime.mjs +++ b/scripts/build-portal-runtime.mjs @@ -138,14 +138,15 @@ async function copyNodeModules() { if (!(await exists(nodeModulesDir))) { throw new Error(`缺少 node_modules 目录: ${nodeModulesDir}`); } + const resolvedNodeModulesDir = await fs.realpath(nodeModulesDir); console.log('==> 拷贝生产运行依赖 node_modules'); - await copyDir(nodeModulesDir, path.join(runtimeRoot, 'node_modules')); - await rewriteNodeModulesSymlinks(path.join(runtimeRoot, 'node_modules')); + await copyDir(resolvedNodeModulesDir, path.join(runtimeRoot, 'node_modules')); + await rewriteNodeModulesSymlinks(path.join(runtimeRoot, 'node_modules'), resolvedNodeModulesDir); } -async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir) { +async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir, sourceNodeModulesDir) { console.log('==> 重写 node_modules 顶层符号链接为 runtime 内相对路径'); - const localNodeModulesRoot = `${nodeModulesDir}${path.sep}`; + const localNodeModulesRoot = `${sourceNodeModulesDir}${path.sep}`; async function visit(dir) { const entries = await fs.readdir(dir); @@ -199,8 +200,11 @@ async function writeMetadata() { 'export NODE_ENV=production', 'export H5_PORT="${H5_PORT:-8081}"', 'export H5_PUBLIC_BASE_URL="${H5_PUBLIC_BASE_URL:-https://m.tkmind.cn}"', + 'export TKMIND_API_TARGETS="${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}"', 'export TKMIND_API_TARGET="${TKMIND_API_TARGET:-https://127.0.0.1:18006}"', 'export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1:-https://127.0.0.1:18007}"', + 'export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-/usr/local/bin/node}"', + 'export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-/opt/portal/mindspace-sandbox-mcp.mjs}"', '', 'NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"', 'if [[ ! -x "${NODE_BIN}" ]]; then', @@ -232,7 +236,7 @@ async function writeMetadata() { 'Key runtime differences must stay in .env, not in the artifact:', ' DATABASE_URL / MYSQL_*', ' H5_PUBLIC_BASE_URL', - ' TKMIND_API_TARGET / TKMIND_API_TARGET_1', + ' TKMIND_API_TARGETS / TKMIND_API_TARGET / TKMIND_API_TARGET_1', ' H5_USERS_ROOT / MINDSPACE_STORAGE_ROOT / MEMIND_SHARED_PUBLISH_ROOT', '', 'Deployment and operations transport:', diff --git a/server.mjs b/server.mjs index c619976..9193611 100644 --- a/server.mjs +++ b/server.mjs @@ -117,12 +117,21 @@ function loadEnvFile(filePath) { loadEnvFile(path.join(__dirname, '../../.env.local')); loadEnvFile(path.join(__dirname, '.env')); +function parseApiTargets() { + const csvTargets = (process.env.TKMIND_API_TARGETS ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + const legacyTargets = [ + process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006', + process.env.TKMIND_API_TARGET_1, + ].filter(Boolean); + return [...new Set([...csvTargets, ...legacyTargets])]; +} + const PORT = Number(process.env.H5_PORT ?? 8081); -const API_TARGET = process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006'; -const API_TARGETS = [ - API_TARGET, - ...(process.env.TKMIND_API_TARGET_1 ? [process.env.TKMIND_API_TARGET_1] : []), -]; +const API_TARGETS = parseApiTargets(); +const API_TARGET = API_TARGETS[0] ?? 'https://127.0.0.1:18006'; const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret'; const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET; const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD; @@ -486,6 +495,7 @@ async function bootstrapUserAuth() { await userAuth.ensureAdminUser(); llmProviderService = createLlmProviderService(pool, { apiTarget: API_TARGET, + apiTargets: API_TARGETS, apiSecret: API_SECRET, }); wordFilterService = createWordFilterService(pool); diff --git a/user-auth.mjs b/user-auth.mjs index 36115b3..b46d3d0 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -1662,9 +1662,10 @@ export function createUserAuth(pool, options = {}) { try { const layout = await publishLayoutFor(user, { migrateLegacy: false }); sandboxMcp = { - serverPath: resolveSandboxMcpServerPath(), + serverPath: resolveSandboxMcpServerPath(process.env.GOOSED_MCP_SERVER_PATH), sandboxRoot: layout.publishDir, userId: user.id, + nodeExecPath: process.env.GOOSED_MCP_NODE_PATH, }; } catch (err) { console.warn('[getAgentSessionPolicy] sandbox MCP setup failed, falling back:', err?.message); From 0420c42fe20a8f2b408348941fbed8eceda6f615 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 27 Jun 2026 21:40:58 +0800 Subject: [PATCH 2/5] fix: make schema bootstrap mysql safe --- db.mjs | 85 +++++++++++++++++++++++++++++++++++++++++++++++++---- db.test.mjs | 22 ++++++++++++++ schema.sql | 4 +-- 3 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 db.test.mjs diff --git a/db.mjs b/db.mjs index 27cebe2..93ed4f8 100644 --- a/db.mjs +++ b/db.mjs @@ -46,6 +46,84 @@ export function createDbPool() { }); } +export function splitSqlStatements(sql) { + const statements = []; + let current = ''; + let quote = null; + let lineComment = false; + let blockComment = false; + + for (let i = 0; i < sql.length; i += 1) { + const char = sql[i]; + const next = sql[i + 1]; + + if (lineComment) { + if (char === '\n') { + lineComment = false; + current += '\n'; + } + continue; + } + + if (blockComment) { + if (char === '*' && next === '/') { + blockComment = false; + i += 1; + } + continue; + } + + if (quote) { + current += char; + if (char === '\\' && (quote === '\'' || quote === '"') && next) { + current += next; + i += 1; + continue; + } + if (char === quote) { + if (next === quote && quote !== '`') { + current += next; + i += 1; + } else { + quote = null; + } + } + continue; + } + + if (char === '-' && next === '-') { + lineComment = true; + i += 1; + continue; + } + + if (char === '/' && next === '*') { + blockComment = true; + i += 1; + continue; + } + + if (char === '\'' || char === '"' || char === '`') { + quote = char; + current += char; + continue; + } + + if (char === ';') { + const trimmed = current.trim(); + if (trimmed) statements.push(trimmed); + current = ''; + continue; + } + + current += char; + } + + const trimmed = current.trim(); + if (trimmed) statements.push(trimmed); + return statements; +} + async function columnExists(pool, table, column) { const [rows] = await pool.query( `SELECT 1 FROM information_schema.COLUMNS @@ -589,11 +667,8 @@ export async function migrateSchema(pool) { export async function initSchema(pool) { const schemaPath = path.join(__dirname, 'schema.sql'); const sql = fs.readFileSync(schemaPath, 'utf8'); - for (const statement of sql.split(';')) { - const trimmed = statement.trim(); - if (trimmed) { - await pool.query(trimmed); - } + for (const statement of splitSqlStatements(sql)) { + await pool.query(statement); } await migrateSchema(pool); await ensureDefaultSpaces(pool, { diff --git a/db.test.mjs b/db.test.mjs new file mode 100644 index 0000000..93d9f16 --- /dev/null +++ b/db.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { splitSqlStatements } from './db.mjs'; + +test('splitSqlStatements ignores semicolons in comments and strings', () => { + const statements = splitSqlStatements(` + -- comment with a semicolon; should not split + CREATE TABLE one ( + label VARCHAR(64) DEFAULT 'semi;colon' + ); + /* block; comment */ + CREATE TABLE two ( + id BIGINT PRIMARY KEY, + name VARCHAR(64) DEFAULT "two;name" + ); + `); + + assert.equal(statements.length, 2); + assert.match(statements[0], /^CREATE TABLE one/); + assert.match(statements[1], /^CREATE TABLE two/); +}); diff --git a/schema.sql b/schema.sql index b1ca566..a78fbb7 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 (expires_at, user_confirmed_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, @@ -1013,7 +1013,7 @@ CREATE TABLE IF NOT EXISTS h5_blocked_words ( -- Shared experience store (etat C): all goosed Worker instances read/write the -- same learned experience so capability is not siloed per instance. Built on --- MySQL first with a keyword + recency retrieval; `embedding` is reserved for a +-- MySQL first with a keyword + recency retrieval. `embedding` is reserved for a -- later move to PostgreSQL + pgvector without changing the calling contract. CREATE TABLE IF NOT EXISTS h5_experience ( id CHAR(36) PRIMARY KEY, From c924b2bb77bda351ac2e29c502addc183f5c2ac1 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 27 Jun 2026 21:48:27 +0800 Subject: [PATCH 3/5] docs: add release must-read checklist --- PRODUCTION_RELEASE_RULES.md | 2 + docs/release-deploy.md | 1 + docs/发包必看.md | 275 ++++++++++++++++++++++++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 docs/发包必看.md diff --git a/PRODUCTION_RELEASE_RULES.md b/PRODUCTION_RELEASE_RULES.md index 167030c..4467292 100644 --- a/PRODUCTION_RELEASE_RULES.md +++ b/PRODUCTION_RELEASE_RULES.md @@ -1,5 +1,7 @@ # 生产发布规则 +> 每次生产发包前先读:[docs/发包必看.md](docs/发包必看.md)。那里记录了 2026-06-27 Portal/goosed 事故后的强制检查项。 + 1. `103` 是正式生产主机,`105` 是云侧入口/历史链路;**本机一律不允许直接 `rsync` 到 `103` 或 `105`**,也不允许在线改源码后继续运行。 2. **禁止 SSH 登录 `105` 直接修改业务代码**(含服务号菜单脚本 `scripts/wechat-mp-menu.mjs`)。105 上文件是部署产物;变更必须:本地 `test-memind` 修改 → Git commit → 正式发布 → 必要时在目标环境执行 API 同步。详见 [docs/105-server-operations.md](docs/105-server-operations.md)。 2. **Portal 生产必须是无源码 runtime 模式**:构建只发生在本机 Mac,产物是 `.runtime/portal/`;`103` 只接收 runtime artifact、继承持久目录、启动服务,**禁止**在 `103` 上 `npm install`、`npm run build` 或保留可运行源码树。 diff --git a/docs/release-deploy.md b/docs/release-deploy.md index d2a4ea5..1016c7d 100644 --- a/docs/release-deploy.md +++ b/docs/release-deploy.md @@ -2,6 +2,7 @@ > 2026-06-26 起,103 / Studio 正式禁止 `rsync` 发布。 > Portal 生产必须是**无源码 runtime 模式**,唯一合法入口是 `bash scripts/release-portal-runtime-prod.sh`。 +> 每次发包前先读:[发包必看](发包必看.md)。 ## 当前规则 diff --git a/docs/发包必看.md b/docs/发包必看.md new file mode 100644 index 0000000..19d851a --- /dev/null +++ b/docs/发包必看.md @@ -0,0 +1,275 @@ +# 发包必看 + +> 适用范围:`test-memind` Portal runtime 发布、`goosed-prod` 镜像更新、103/105 生产链路排障。 +> 这份文档是发布前强制阅读的事故经验清单。后续发包如果绕过这里,容易把 2026-06-27 的问题重新带回生产。 + +## 0. 先确认边界 + +- 生产 Portal 主机是 `103 / Studio`:`john@58.38.22.103`,默认 SSH 命令不要再写旧端口 `2222`。 +- `105` 是云侧入口/历史链路,不是 Portal 源码真相;不要 SSH 到 105 直接改业务代码。 +- Portal 生产目录:`/Users/john/Project/Memind`。 +- goosed 生产目录:`/Users/john/Project/goosed-prod`。 +- Portal 生产必须是无源码 runtime 模式,只能用 `scripts/release-portal-runtime-prod.sh` 发包。 +- 禁止直接 `rsync` 到 103/105,禁止在 103 上 `npm install`、`npm run build` 后继续跑。 + +## 1. 发包前硬性检查 + +1. 当前代码必须已经包含以下修复,或位于它们之后: + - `fc5b50c fix: make goosed mcp paths container safe` + - `0420c42 fix: make schema bootstrap mysql safe` +2. release worktree 必须干净: + +```bash +git status --short +``` + +3. 不允许把 `.runtime/portal/`、`node_modules`、本地构建缓存提交进 Git。 +4. 如果 release worktree 需要临时复用主 worktree 的依赖,只能临时建 symlink,发布/测试后必须删除: + +```bash +ln -s /Users/john/PycharmProjects/test/test-memind/node_modules /Users/john/PycharmProjects/test/test-memind-release-goosed-mcp/node_modules +# 测试或发布完成后: +rm -f /Users/john/PycharmProjects/test/test-memind-release-goosed-mcp/node_modules +``` + +5. 发包前至少跑: + +```bash +node --test db.test.mjs capabilities.test.mjs llm-providers.test.mjs wechat-mp.test.mjs +``` + +## 2. Portal runtime 发布唯一流程 + +只使用: + +```bash +bash scripts/release-portal-runtime-prod.sh --skip-tests --yes +``` + +这个脚本必须完成这些动作: + +- 本机构建 `.runtime/portal`。 +- 上传 artifact 到 103。 +- 103 备份当前 `/Users/john/Project/Memind` 全目录。 +- 103 单独备份持久目录:`.env`、`MindSpace/`、`data/`、`users/`、`.tailscale/`、`public/plaza-covers/`、`logs/`。 +- 原子切换 live 目录。 +- 更新 LaunchAgent 指向 `/Users/john/Project/Memind/scripts/run-memind-portal-prod.sh`。 +- 健康检查 `http://127.0.0.1:8081/api/status` 返回 200。 + +发布成功后,记录脚本输出里的: + +- `release_id` +- `archived_source` +- `full_backup` +- `persist_backup` +- `git_head` + +## 3. Portal 发布后必须验收 + +在本机执行: + +```bash +curl -k -i https://m.tkmind.cn/auth/login \ + -H 'content-type: application/json' \ + --data '{"username":"john","password":"wrong-password"}' | head -40 +``` + +正确结果应该是 `401`,消息类似 `用户名或密码错误`。 +如果返回 `503` 且消息是 `未配置用户数据库或访问密码`,说明用户系统 bootstrap 失败,不是简单健康检查能发现的问题。 + +在 103 执行: + +```bash +ssh john@58.38.22.103 ' + printf "portal="; curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8081/api/status + for p in 18006 18007 18008 18009; do + printf "goosed_${p}=" + curl -k -s -o /dev/null -w "%{http_code}\n" https://127.0.0.1:${p}/status + done + pid=$(pgrep -f "node .*server.mjs" | head -1) + echo "portal_pid=${pid}" + ps eww -p "$pid" | tr " " "\n" | awk -F= "/^(DATABASE_URL|H5_PORT|TKMIND_API_TARGETS|GOOSED_MCP_NODE_PATH|GOOSED_MCP_SERVER_PATH)=/ {print \$1\"=SET\"}" +' +``` + +必须看到: + +- `portal=200` +- `goosed_18006=200` +- `goosed_18007=200` +- `goosed_18008=200` +- `goosed_18009=200` +- `DATABASE_URL=SET` +- `TKMIND_API_TARGETS=SET` +- `GOOSED_MCP_NODE_PATH=SET` +- `GOOSED_MCP_SERVER_PATH=SET` + +日志检查: + +```bash +ssh john@58.38.22.103 ' + tail -200 ~/Library/Logs/memind-portal.log | + egrep "User auth bootstrap failed|ER_PARSE_ERROR|Cannot find package|Failed to add extension|No such file|Unknown extension|Provider not set|not configured" || true +' +``` + +新启动之后不能继续出现上述错误。 + +## 4. 2026-06-27 事故复盘形成的禁止项 + +### 4.1 不要只看 `/api/status` + +`/api/status` 返回 200 只能说明 Express 进程活着,不能说明登录、数据库、Agent 会话策略都正常。每次 Portal 发包必须额外测 `/auth/login`。 + +### 4.2 schema 必须兼容 MySQL + +禁止在 `schema.sql` 里写 PostgreSQL partial index: + +```sql +KEY idx_xxx (...) WHERE ... +``` + +MySQL 不支持这种写法。生产曾因此触发: + +```text +User auth bootstrap failed +ER_PARSE_ERROR +未配置用户数据库或访问密码 +``` + +`db.mjs` 现在使用 `splitSqlStatements()`,会跳过 SQL 注释里的分号;不要改回 `sql.split(';')`。 + +### 4.3 不要把本地 `node_modules` symlink 打进包 + +release worktree 如果有 `node_modules -> /Users/john/.../node_modules` symlink,构建脚本必须 realpath 后复制真实依赖。发布后必须确认线上: + +```bash +ssh john@58.38.22.103 'cd /Users/john/Project/Memind && test -d node_modules/http-proxy-middleware && echo ok' +``` + +否则可能出现: + +```text +Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'http-proxy-middleware' +``` + +### 4.4 Portal 下发给 goosed 的 MCP 路径必须是容器内路径 + +推荐并默认使用: + +```bash +GOOSED_MCP_NODE_PATH=/usr/local/bin/node +GOOSED_MCP_SERVER_PATH=/opt/portal/mindspace-sandbox-mcp.mjs +``` + +不要让新会话继续依赖宿主机路径: + +```text +/opt/homebrew/Cellar/node@24/24.16.0/bin/node +/Users/john/Project/Memind/mindspace-sandbox-mcp.mjs +``` + +否则会触发: + +```text +Failed to add extension: IO error: No such file or directory (os error 2) +``` + +### 4.5 `code_execution` 默认不能下发 + +当前 goosed 不认识 `code_execution` 扩展。除非确认 goosed 已支持,否则不要设置: + +```bash +TKMIND_ENABLE_CODE_EXECUTION_EXTENSION=1 +``` + +### 4.6 Provider 必须同步到全部 goosed target + +Portal 现在使用: + +```bash +TKMIND_API_TARGETS=https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009 +``` + +发布或重建 goosed 容器后,要确认 18006-18009 都有 provider 配置。否则 Agent 会出现: + +```text +Provider 'custom_deepseek' is not configured +``` + +注意:DeepSeek API key / provider secret 是运行配置,不应该烘焙进镜像。 + +## 5. goosed-prod 镜像更新必看 + +当前已验证镜像: + +```text +tkmind/goosed:prod-20260627-2125-mcp +``` + +这个镜像必须保证以下路径在容器内存在: + +```text +/usr/local/bin/node +/opt/homebrew/bin/node +/opt/homebrew/opt/node@24/bin/node +/opt/homebrew/Cellar/node@24/24.16.0/bin/node +/opt/portal/mindspace-sandbox-mcp.mjs +``` + +重建镜像后必须验: + +```bash +ssh john@58.38.22.103 ' + cd /Users/john/Project/goosed-prod + set -a + source .env + source .paths.env + export PORTAL_RUNTIME_DIR="$portal_runtime_dir" MINDSPACE_ROOT="$mindspace_root" + set +a + /opt/homebrew/bin/docker compose -f docker-compose.prod.yml ps + for c in goosed-prod-1 goosed-prod-2 goosed-prod-3 goosed-prod-4; do + echo "--- $c" + /opt/homebrew/bin/docker exec "$c" sh -lc " + ls -l /usr/local/bin/node \ + /opt/homebrew/bin/node \ + /opt/homebrew/opt/node@24/bin/node \ + /opt/homebrew/Cellar/node@24/24.16.0/bin/node \ + /opt/portal/mindspace-sandbox-mcp.mjs + " + done +' +``` + +重建容器后必须重新确认: + +- 四个容器都是 `healthy`。 +- 18006-18009 `/status` 都是 200。 +- provider 已同步到四个 target。 +- sandbox-fs smoke 能成功 `add extension` 并完成一次 `/reply`。 + +## 6. 出问题时先走这条判断链 + +1. `m.tkmind.cn/api/status` 是否 200? +2. `/auth/login` 是否进入 400/401,而不是 503? +3. 103 Portal 进程是否拿到了 `DATABASE_URL`? +4. `~/Library/Logs/memind-portal.log` 是否有 `User auth bootstrap failed`? +5. 18006-18009 是否全 200? +6. goosed 容器内 `/usr/local/bin/node` 和 `/opt/portal/mindspace-sandbox-mcp.mjs` 是否存在? +7. provider 是否同步到四个 target? +8. 如果是微信服务号或专属 Agent 报错,必须测真实业务路径,不要只看健康接口。 + +## 7. 发布完成后给用户的最小回报格式 + +发布完成后至少说明: + +- release id +- git head +- full backup +- persisted backup +- Portal 健康结果 +- `/auth/login` 结果 +- 18006-18009 健康结果 +- 是否发现并处理了日志里的错误关键词 + +不要只说“已发布成功”。 From 8264e71f9e7581c2bc4e69352d46346345c63d55 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 27 Jun 2026 21:57:21 +0800 Subject: [PATCH 4/5] feat: release chat and mindspace UI updates --- db.mjs | 7 + mindspace-pages.mjs | 61 ++ package-lock.json | 56 +- package.json | 2 + schema.sql | 1 + server.mjs | 70 +- 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 | 109 ++- 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 +- 24 files changed, 2042 insertions(+), 209 deletions(-) create mode 100644 src/components/ChatSharePreviewModal.tsx create mode 100644 src/components/ChatWelcomePanel.tsx diff --git a/db.mjs b/db.mjs index 93ed4f8..47cc91c 100644 --- a/db.mjs +++ b/db.mjs @@ -203,6 +203,13 @@ 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`, + ); + } + await pool.query(`UPDATE h5_users SET slug = username WHERE slug IS NULL OR slug = ''`); if (!(await indexExists(pool, 'h5_users', 'uq_h5_users_slug'))) { await pool.query(`ALTER TABLE h5_users ADD UNIQUE KEY uq_h5_users_slug (slug)`); 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/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 a78fbb7..b30407f 100644 --- a/schema.sql +++ b/schema.sql @@ -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/server.mjs b/server.mjs index 9193611..2181f1e 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'; @@ -520,6 +521,7 @@ async function bootstrapUserAuth() { userAuth, llmProviderService, subscriptionService, + sessionSnapshotService, localFetchAsset: mindSpaceAssets ? async (userId, assetId) => { const { asset, path: assetPath } = await mindSpaceAssets.readAsset(userId, assetId); @@ -2506,6 +2508,13 @@ api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => { thumbnailReady = false; } } + const sessionId = String(req.body?.session_id ?? req.body?.sessionId ?? '').trim(); + const messageId = String(req.body?.message_id ?? req.body?.messageId ?? '').trim(); + const existingPage = await mindSpacePages.findPageBySourceMessage( + req.currentUser.id, + sessionId, + messageId, + ).catch(() => null); return sendData(res, req, { contentMode: analysis.contentMode, links: analysis.links, @@ -2524,12 +2533,51 @@ 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); + 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 未启用' }); @@ -2635,11 +2683,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({ /> )} -