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));