From 00a00a1f6987170647d51dd2404e8e3a70691dbc Mon Sep 17 00:00:00 2001 From: john Date: Sun, 5 Jul 2026 11:26:34 +0800 Subject: [PATCH] fix: portal stability, session list DB fallback, and UX polish - Free stale Memind listeners on 8081 before startup and exit cleanly on EADDRINUSE - Backfill owned sessions missing from Goose via h5_conversation_messages summaries - Strip Memind task orchestration prefixes from user-facing chat text - Repair missing public docx links before release MindSpace link checks Co-authored-by: Cursor --- conversation-display.mjs | 6 ++ conversation-display.test.mjs | 10 ++ conversation-memory.mjs | 59 ++++++++++++ scripts/build-portal-runtime.mjs | 51 ++++------- scripts/release-portal-runtime-prod.sh | 6 +- scripts/repair-mindspace-public-downloads.mjs | 91 +++++++++++++++++++ scripts/run-memind-portal-prod.sh | 27 ++++++ server.mjs | 13 ++- tkmind-proxy.mjs | 59 ++++++++++-- 9 files changed, 278 insertions(+), 44 deletions(-) create mode 100755 scripts/repair-mindspace-public-downloads.mjs diff --git a/conversation-display.mjs b/conversation-display.mjs index 06a27b1..ab99efe 100644 --- a/conversation-display.mjs +++ b/conversation-display.mjs @@ -6,6 +6,7 @@ import { stripKnownChatSkillPrompt } from './chat-skills.mjs'; */ export const TASK_ROUTING_HINT_RE = /^【TKMind 路由提示】[\s\S]*?\n\n/; +export const MEMIND_TASK_ORCHESTRATION_RE = /^【Memind 任务编排】[\s\S]*?(?:用户任务:|用户任务:)\s*/u; export const MINDSPACE_CONTEXT_RE = /^\[MindSpace 上下文\][\s\S]*?\n\n/; const USER_IDENTITY_BLOCK_RE = /^\[用户身份\][\s\S]*?\n\n/; @@ -17,6 +18,10 @@ export function stripTaskRoutingHint(text) { return String(text ?? '').replace(TASK_ROUTING_HINT_RE, '').trimStart(); } +export function stripMemindTaskOrchestrationPrefix(text) { + return String(text ?? '').replace(MEMIND_TASK_ORCHESTRATION_RE, '').trimStart(); +} + export function stripMindSpaceContextPrefix(text) { let next = String(text ?? ''); while (MINDSPACE_CONTEXT_RE.test(next)) { @@ -60,6 +65,7 @@ export function deriveUserFacingText(text) { let next = String(text ?? ''); next = stripUserIdentityPrefix(next); next = stripTaskRoutingHint(next); + next = stripMemindTaskOrchestrationPrefix(next); next = stripMindSpaceContextPrefix(next); next = stripKnownChatSkillPrompt(next); return next.trim(); diff --git a/conversation-display.test.mjs b/conversation-display.test.mjs index e8b46a5..c721a45 100644 --- a/conversation-display.test.mjs +++ b/conversation-display.test.mjs @@ -25,6 +25,16 @@ test('deriveUserFacingText removes routing hint and skill preface from agent pay assert.equal(deriveUserFacingText(agentPayload), userText); }); +test('deriveUserFacingText removes Memind task orchestration prefix', () => { + const userText = '帮我生成深度搜索报告'; + const agentPayload = [ + '【Memind 任务编排】以下为用户任务,请使用工具与技能实际执行并产出结果,不要只做文字描述。', + '路由判定:用户要求深度搜索并形成报告。', + `用户任务:${userText}`, + ].join('\n'); + assert.equal(deriveUserFacingText(agentPayload), userText); +}); + test('deriveAssistantFacingText hides skill/process narration without a deliverable link', () => { const internal = '好的,John!注意到技能更新了几个细节要求,比如页脚要用`data-mindspace-page-tag="platform-brand"`和`tkmind.cn`。我来为你重新生成全面增强版的AI机器人研究报告页面:'; diff --git a/conversation-memory.mjs b/conversation-memory.mjs index bc16a90..8e3307a 100644 --- a/conversation-memory.mjs +++ b/conversation-memory.mjs @@ -6,6 +6,7 @@ import { normalizeApiUrl, resolveChatCompletionsUrl, } from './llm-providers.mjs'; +import { deriveUserFacingText } from './conversation-display.mjs'; const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false }, @@ -419,11 +420,69 @@ export function createConversationMemoryService(pool, options = {}) { })); } + async function loadSessionListSummaries(userId, sessionIds = []) { + if (!isEnabled() || !pool || !userId || !Array.isArray(sessionIds) || sessionIds.length === 0) { + return new Map(); + } + const ids = [...new Set(sessionIds.filter(Boolean))]; + if (!ids.length) return new Map(); + const placeholders = ids.map(() => '?').join(', '); + const [rows] = await pool.query( + `SELECT agent_session_id, role, text, raw_json, sequence_no, created_at, updated_at + FROM h5_conversation_messages + WHERE user_id = ? AND agent_session_id IN (${placeholders}) + ORDER BY sequence_no ASC, created_at ASC`, + [userId, ...ids], + ); + const buckets = new Map(); + for (const row of rows) { + const sessionId = String(row.agent_session_id ?? '').trim(); + if (!sessionId) continue; + if (!buckets.has(sessionId)) { + buckets.set(sessionId, { messages: [], maxUpdatedAt: 0 }); + } + const bucket = buckets.get(sessionId); + bucket.messages.push(row); + bucket.maxUpdatedAt = Math.max( + bucket.maxUpdatedAt, + Number(row.updated_at ?? row.created_at ?? 0), + ); + } + const summaries = new Map(); + for (const sessionId of ids) { + const bucket = buckets.get(sessionId); + if (!bucket || bucket.messages.length === 0) continue; + const firstUser = bucket.messages.find((row) => String(row.role ?? '') === 'user'); + let title = ''; + if (firstUser) { + let text = String(firstUser.text ?? '').trim(); + if (firstUser.raw_json) { + try { + const parsed = JSON.parse(firstUser.raw_json); + text = extractConversationMessageText(parsed) || text; + } catch { + // keep text column fallback + } + } + title = deriveUserFacingText(text).slice(0, 80); + } + summaries.set(sessionId, { + title: title || sessionId, + messageCount: bucket.messages.length, + updatedAt: bucket.maxUpdatedAt + ? new Date(bucket.maxUpdatedAt).toISOString() + : undefined, + }); + } + return summaries; + } + return { isEnabled, saveConversationMessages, analyzeUser, saveAndAnalyze, listMemories, + loadSessionListSummaries, }; } diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs index 9ec2680..f0ccda5 100755 --- a/scripts/build-portal-runtime.mjs +++ b/scripts/build-portal-runtime.mjs @@ -231,6 +231,14 @@ async function copyMindspacePublicLinkTools() { path.join(root, 'scripts', 'check-mindspace-public-links.mjs'), path.join(runtimeRoot, 'scripts', 'check-mindspace-public-links.mjs'), ); + await fs.copyFile( + path.join(root, 'scripts', 'repair-mindspace-public-downloads.mjs'), + path.join(runtimeRoot, 'scripts', 'repair-mindspace-public-downloads.mjs'), + ); + await fs.copyFile( + path.join(root, 'mindspace-public-finish-sync.mjs'), + path.join(runtimeRoot, 'mindspace-public-finish-sync.mjs'), + ); } async function writeMetadata() { @@ -260,41 +268,14 @@ async function writeMetadata() { throw new Error(`缺少隧道脚本: ${tunnelScript}`); } - await writeFile( - path.join(runtimeScriptsDir, 'run-memind-portal-prod.sh'), - [ - '#!/usr/bin/env bash', - 'set -euo pipefail', - '', - 'ROOT="$(cd "$(dirname "$0")/.." && pwd)"', - 'cd "$ROOT"', - '', - 'if [[ -f "${ROOT}/.env" ]]; then', - ' set -a', - ' # shellcheck disable=SC1091', - ' source "${ROOT}/.env"', - ' set +a', - 'fi', - '', - '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}"', - '', - 'NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"', - 'if [[ ! -x "${NODE_BIN}" ]]; then', - ' NODE_BIN="$(command -v node)"', - 'fi', - '', - 'export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-${NODE_BIN}}"', - 'export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-${ROOT}/mindspace-sandbox-mcp.mjs}"', - '', - 'exec "${NODE_BIN}" "${ROOT}/server.mjs"', - '', - ].join('\n'), - ); + const prodStartScript = path.join(root, 'scripts', 'run-memind-portal-prod.sh'); + if (await exists(prodStartScript)) { + await fs.copyFile(prodStartScript, path.join(runtimeScriptsDir, 'run-memind-portal-prod.sh')); + await fs.chmod(path.join(runtimeScriptsDir, 'run-memind-portal-prod.sh'), 0o755); + } else { + throw new Error(`缺少 Portal 启动脚本: ${prodStartScript}`); + } + await fs.copyFile( path.join(root, 'scripts', 'load-env.mjs'), path.join(runtimeRoot, 'scripts', 'load-env.mjs'), diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index 35b7359..795ac83 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -555,11 +555,15 @@ if [[ "${portal_code}" != "200" ]]; then fi if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" != "1" && -d "${APP_DIR}/MindSpace" && -f "${APP_DIR}/scripts/check-mindspace-public-links.mjs" ]]; then - say "检查 MindSpace 公开页相对链接" + say "修复 MindSpace 公开页缺失 docx 下载" node_bin="/opt/homebrew/opt/node@24/bin/node" if [[ ! -x "${node_bin}" ]]; then node_bin="$(command -v node)" fi + if [[ -f "${APP_DIR}/scripts/repair-mindspace-public-downloads.mjs" ]]; then + "${node_bin}" "${APP_DIR}/scripts/repair-mindspace-public-downloads.mjs" --root "${APP_DIR}/MindSpace" || true + fi + say "检查 MindSpace 公开页相对链接" if ! "${node_bin}" "${APP_DIR}/scripts/check-mindspace-public-links.mjs" --root "${APP_DIR}/MindSpace" --downloads-only; then echo "MindSpace public link check failed: broken relative download/asset links under public/*.html" >&2 echo "Set ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1 only if you accept shipping with known broken links." >&2 diff --git a/scripts/repair-mindspace-public-downloads.mjs b/scripts/repair-mindspace-public-downloads.mjs new file mode 100755 index 0000000..575d0c1 --- /dev/null +++ b/scripts/repair-mindspace-public-downloads.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +/** + * Repair missing public/*.docx download targets before release link checks. + * 1) sync from oa/ when a safe source exists + * 2) remove broken docx anchors from HTML when no source is available + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { syncPublicDocxDownloads } from '../mindspace-public-finish-sync.mjs'; + +const DOCX_HREF_RE = /]*\bhref=(["'])([^"']+\.docx)\1[^>]*>[\s\S]*?<\/a>/gi; +const DOCX_HREF_CAPTURE_RE = /]*\bhref=(["'])([^"']+\.docx)\1/gi; + +function listMissingDocxTargets(html, publicDir) { + const missing = new Set(); + let match = DOCX_HREF_CAPTURE_RE.exec(html); + while (match) { + const docxPath = path.resolve(publicDir, path.basename(String(match[2] ?? ''))); + if (!fs.existsSync(docxPath)) { + missing.add(path.basename(docxPath).toLowerCase()); + } + match = DOCX_HREF_CAPTURE_RE.exec(html); + } + DOCX_HREF_CAPTURE_RE.lastIndex = 0; + return [...missing]; +} + +function stripBrokenDocxAnchors(html, missingBasenames) { + const missing = new Set(missingBasenames.map((item) => String(item ?? '').toLowerCase())); + if (!missing.size) return { html, changed: false }; + let changed = false; + const next = html.replace(DOCX_HREF_RE, (match, _quote, hrefValue) => { + const base = path.basename(String(hrefValue ?? '')).toLowerCase(); + if (!missing.has(base)) return match; + changed = true; + return ''; + }); + return { html: next, changed }; +} + +export function repairMindspacePublicDownloads({ publishDir } = {}) { + const root = path.resolve(String(publishDir ?? '')); + if (!root || !fs.existsSync(root)) { + return { synced: [], missing: [], stripped: [] }; + } + + const syncResult = syncPublicDocxDownloads({ publishDir: root, minCompleteSize: 1024 }); + const stripped = []; + + for (const userDir of fs.readdirSync(root, { withFileTypes: true })) { + if (!userDir.isDirectory()) continue; + const publicDir = path.join(root, userDir.name, 'public'); + if (!fs.existsSync(publicDir)) continue; + for (const entry of fs.readdirSync(publicDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.html')) continue; + const htmlPath = path.join(publicDir, entry.name); + const html = fs.readFileSync(htmlPath, 'utf8'); + const missingBasenames = listMissingDocxTargets(html, publicDir); + const { html: nextHtml, changed } = stripBrokenDocxAnchors(html, missingBasenames); + if (!changed) continue; + fs.writeFileSync(htmlPath, nextHtml, 'utf8'); + stripped.push(path.relative(root, htmlPath)); + } + } + + return { + synced: syncResult.synced, + missing: syncResult.missing, + stripped, + }; +} + +function main() { + const args = process.argv.slice(2); + let publishDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'MindSpace'); + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--root' && args[i + 1]) { + publishDir = args[i + 1]; + i += 1; + } + } + const result = repairMindspacePublicDownloads({ publishDir }); + console.log(JSON.stringify(result, null, 2)); +} + +const isMain = process.argv[1] + && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +if (isMain) { + main(); +} diff --git a/scripts/run-memind-portal-prod.sh b/scripts/run-memind-portal-prod.sh index 449c9db..ef14260 100755 --- a/scripts/run-memind-portal-prod.sh +++ b/scripts/run-memind-portal-prod.sh @@ -26,4 +26,31 @@ fi export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-${NODE_BIN}}" export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-${ROOT}/mindspace-sandbox-mcp.mjs}" +free_port_if_stale_memind_listener() { + local port="${H5_PORT:-8081}" + local pids + pids="$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)" + [[ -z "${pids}" ]] && return 0 + + while read -r pid; do + [[ -z "${pid}" ]] && continue + local args + args="$(ps -p "${pid}" -o args= 2>/dev/null || true)" + if [[ "${args}" == *"${ROOT}/server.mjs"* ]]; then + echo "[portal-start] stopping stale Memind listener pid=${pid}" >&2 + kill "${pid}" 2>/dev/null || true + sleep 1 + if kill -0 "${pid}" 2>/dev/null; then + kill -9 "${pid}" 2>/dev/null || true + sleep 1 + fi + continue + fi + echo "[portal-start] port ${port} held by foreign pid=${pid}; refusing to start" >&2 + exit 1 + done <<< "${pids}" +} + +free_port_if_stale_memind_listener + exec "${NODE_BIN}" "${ROOT}/server.mjs" diff --git a/server.mjs b/server.mjs index aaa565d..d8a6d6d 100644 --- a/server.mjs +++ b/server.mjs @@ -5723,15 +5723,24 @@ app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); }); -userAuthReady.then((enabled) => { + userAuthReady.then((enabled) => { if (isDatabaseConfigured() && !enabled && process.env.NODE_ENV === 'production') { console.error('Refusing to start portal without user auth while database is configured'); process.exit(1); } - app.listen(PORT, HOST, () => { + const server = app.listen(PORT, HOST, () => { console.log(`TKMind H5 @ http://${HOST}:${PORT}`); console.log(`Proxy -> ${API_TARGETS.join(', ')}`); console.log(`Auth -> ${enabled ? 'multi-user (MySQL)' : legacyAuth ? 'legacy password' : 'disabled'}`); console.log(`Wiki @ http://${HOST}:${PORT}/${PUBLISH_ROOT_DIR}/wiki`); }); + server.on('error', (err) => { + if (err?.code === 'EADDRINUSE') { + console.error( + `Port ${PORT} already in use (${HOST}:${PORT}); exiting so LaunchAgent can retry after ThrottleInterval`, + ); + process.exit(1); + } + throw err; + }); }); diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index def7f13..26198d8 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -901,16 +901,41 @@ export function createTkmindProxy({ // For sessions still carrying a default name, fill in a title derived from the // first user message (kept in the snapshot cache) so the history list shows a // meaningful label instead of "会话 ". Best-effort: never blocks the list. - async function enrichSessionHistory(sessions) { - if (!sessionSnapshotService?.isEnabled?.()) return; + async function enrichSessionHistory(sessions, userId) { const needsSummary = sessions.filter( (session) => hasDefaultSessionName(session) || Number(session?.message_count ?? 0) === 0, ); if (needsSummary.length === 0) return; + + if (sessionSnapshotService?.isEnabled?.()) { + try { + const summaries = await sessionSnapshotService.getHistorySummaries(needsSummary.map((s) => s.id)); + for (const session of needsSummary) { + const summary = summaries.get(session.id); + if (!summary) continue; + if (summary.title && hasDefaultSessionName(session)) { + session.name = summary.title; + } + if (Number(session?.message_count ?? 0) === 0 && Number(summary.messageCount ?? 0) > 0) { + session.message_count = Number(summary.messageCount); + } + } + } catch { + // Non-fatal: fall back to DB summaries below. + } + } + + const stillNeedsSummary = needsSummary.filter( + (session) => hasDefaultSessionName(session) || Number(session?.message_count ?? 0) === 0, + ); + if (stillNeedsSummary.length === 0 || !conversationMemoryService?.loadSessionListSummaries) return; try { - const summaries = await sessionSnapshotService.getHistorySummaries(needsSummary.map((s) => s.id)); - for (const session of needsSummary) { - const summary = summaries.get(session.id); + const dbSummaries = await conversationMemoryService.loadSessionListSummaries( + userId, + stillNeedsSummary.map((session) => session.id), + ); + for (const session of stillNeedsSummary) { + const summary = dbSummaries.get(session.id); if (!summary) continue; if (summary.title && hasDefaultSessionName(session)) { session.name = summary.title; @@ -918,6 +943,9 @@ export function createTkmindProxy({ if (Number(session?.message_count ?? 0) === 0 && Number(summary.messageCount ?? 0) > 0) { session.message_count = Number(summary.messageCount); } + if (!session.updated_at && summary.updatedAt) { + session.updated_at = summary.updatedAt; + } } } catch { // Non-fatal: fall back to the client-side label. @@ -1627,8 +1655,27 @@ export function createTkmindProxy({ } } + const stillMissingFromGoose = [...owned].filter((sessionId) => !sessionsById.has(sessionId)); + if (stillMissingFromGoose.length > 0 && conversationMemoryService?.loadSessionListSummaries) { + const dbSummaries = await conversationMemoryService + .loadSessionListSummaries(req.currentUser.id, stillMissingFromGoose) + .catch(() => new Map()); + for (const sessionId of stillMissingFromGoose) { + const summary = dbSummaries.get(sessionId); + if (!summary) continue; + sessionsById.set(sessionId, { + id: sessionId, + name: summary.title || sessionId, + message_count: Number(summary.messageCount ?? 0), + updated_at: summary.updatedAt, + user_set_name: false, + recipe: null, + }); + } + } + const sessions = [...sessionsById.values()].sort(sortSessionsByRecent); - await enrichSessionHistory(sessions); + await enrichSessionHistory(sessions, req.currentUser.id); if (typeof userAuth.getSessionOrigins === 'function' && sessions.length > 0) { try { const origins = await userAuth.getSessionOrigins(sessions.map((s) => s.id));