Compare commits

...

8 Commits

Author SHA1 Message Date
john 8755928405 fix(mindspace): expose findPageBySourceMessage on remote pageService adapter.
Quick Plaza publish resolves existing chat pages by session/message id; production remote adapter rejected the RPC because the binding contract omitted this method.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 19:58:42 +08:00
john 3318c04490 feat(chat): add one-click Plaza publish for HTML page messages.
Speed up the quick-plaza path with session snapshot reads, publish timeouts, and a modal fix so parent re-renders no longer abort in-flight requests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 19:58:39 +08:00
john 53eb0014ba fix(mindspace): map publication RPC errors to HTTP status codes
Return 404/401/403 from MindSpace RPC for known publication errors and
preserve error codes through the Portal remote adapter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 19:44:58 +08:00
john 85061dfa9f fix: stream Goosed page replies into chat without waiting for agent run
Subscribe session SSE as soon as the Goosed session is known, keep request
context during waiting, and poll session snapshots so page results appear
in the conversation while generation is still running.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 12:25:52 +08:00
john 1165ebf684 fix: normalize Goosed reply payload and restart agent-run worker on release
Add id/created defaults for user_message, ensure metadata in agent-run
gateway before submit, and kickstart the agent-run worker after portal deploy
so queued page-generation runs use the latest runtime bundle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 12:04:31 +08:00
john cb7a446d3e fix: ensure userVisible metadata on Goosed session replies
Goosed rejects /sessions/:id/reply when user_message.metadata lacks
userVisible. Normalize metadata before submit and during agent orchestration
so page-generation runs no longer fail before streaming starts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 11:58:17 +08:00
john 5f8ef9ddb0 fix: make MindSpace docx repair script runtime-safe
Avoid importing mindspace-public-finish-sync in production runtime so release
link repair can copy oa docx files or strip broken anchors without extra deps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 11:29:17 +08:00
john 00a00a1f69 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 <cursoragent@cursor.com>
2026-07-05 11:26:34 +08:00
29 changed files with 1374 additions and 218 deletions
+2 -1
View File
@@ -7,6 +7,7 @@ import {
persistSessionTranscriptFromSnapshot,
persistSessionTranscriptMessages,
} from './conversation-transcript-persist.mjs';
import { ensureGooseUserMessageMetadata } from './goose-message.mjs';
const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000];
const TERMINAL_STATUSES = new Set(['succeeded', 'failed']);
@@ -614,7 +615,7 @@ export function createAgentRunGateway({
row.user_id,
sessionId,
row.request_id,
userMessage,
ensureGooseUserMessageMetadata(userMessage),
{
toolMode: runOptions.toolMode,
forceDeepReasoning: runOptions.forceDeepReasoning,
+2
View File
@@ -406,6 +406,8 @@ export function applyAgentOrchestrationToUserMessage(userMessage, classification
displayText: displayText || undefined,
chatIntentRoute: CHAT_INTENT_ROUTE.AGENT,
chatIntentSource: classification?.source ?? null,
userVisible: userMessage?.metadata?.userVisible ?? true,
agentVisible: userMessage?.metadata?.agentVisible ?? true,
};
return {
...userMessage,
+6
View File
@@ -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();
+10
View File
@@ -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机器人研究报告页面:';
+59
View File
@@ -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,
};
}
+24
View File
@@ -0,0 +1,24 @@
import crypto from 'node:crypto';
/**
* Goosed requires user_message.metadata.userVisible/agentVisible and created on /reply.
*/
export function ensureGooseUserMessageMetadata(userMessage) {
if (!userMessage || typeof userMessage !== 'object' || Array.isArray(userMessage)) {
return userMessage;
}
const metadata =
userMessage.metadata && typeof userMessage.metadata === 'object' && !Array.isArray(userMessage.metadata)
? { ...userMessage.metadata }
: {};
if (metadata.userVisible == null) metadata.userVisible = true;
if (metadata.agentVisible == null) metadata.agentVisible = true;
const created = Number(userMessage.created ?? 0) || Math.floor(Date.now() / 1000);
return {
...userMessage,
id: userMessage.id ?? crypto.randomUUID(),
role: userMessage.role ?? 'user',
created,
metadata,
};
}
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ensureGooseUserMessageMetadata } from './goose-message.mjs';
test('ensureGooseUserMessageMetadata adds required visibility flags', () => {
const normalized = ensureGooseUserMessageMetadata({
role: 'user',
content: [{ type: 'text', text: '帮我生成页面' }],
});
assert.equal(normalized.metadata.userVisible, true);
assert.equal(normalized.metadata.agentVisible, true);
assert.equal(typeof normalized.created, 'number');
assert.ok(normalized.id);
});
test('ensureGooseUserMessageMetadata preserves existing metadata fields', () => {
const normalized = ensureGooseUserMessageMetadata({
role: 'user',
content: [{ type: 'text', text: '编排文本' }],
metadata: {
displayText: '用户原文',
chatIntentRoute: 'agent_orchestration',
},
});
assert.equal(normalized.metadata.displayText, '用户原文');
assert.equal(normalized.metadata.chatIntentRoute, 'agent_orchestration');
assert.equal(normalized.metadata.userVisible, true);
assert.equal(normalized.metadata.agentVisible, true);
});
+206
View File
@@ -0,0 +1,206 @@
export function slugFromPageTitle(title, pageId) {
const ascii = String(title ?? '')
.normalize('NFKC')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, pageId ? 54 : 64);
const base = ascii || 'page';
if (pageId) {
return `${base}-${String(pageId).replace(/-/g, '').slice(0, 8)}`.slice(0, 64);
}
return base;
}
async function resolveExistingPage({
userId,
sessionId,
messageId,
analysis,
mindSpacePages,
}) {
const byMessage = await mindSpacePages
.findPageBySourceMessage(userId, sessionId, messageId)
.catch(() => null);
if (byMessage) return byMessage;
if (analysis.contentMode === 'static_html' && analysis.relativePath) {
const byPath = await mindSpacePages
.findPageByRelativePath(userId, analysis.relativePath)
.catch(() => null);
if (byPath) return byPath;
}
return null;
}
async function buildPageInput({ bundle, body = {} }) {
const { source, analysis, resolvedHtml } = bundle;
let pageInput = {
title: body.title,
summary: body.summary,
templateId: body.template_id ?? 'editorial',
pageType: body.page_type,
categoryCode: 'draft',
};
if (analysis.contentMode === 'static_html') {
if (!resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
pageInput = {
...pageInput,
title: body.title || resolvedHtml.suggestedTitle,
summary: body.summary || resolvedHtml.suggestedSummary,
content: resolvedHtml.content,
contentFormat: 'html',
pageType: 'html',
};
} else {
pageInput = {
...pageInput,
title: body.title || 'AI 创作',
content: source.content,
contentFormat: 'markdown',
};
}
return pageInput;
}
export async function ensureChatPageForPlaza({
userId,
bundle,
mindSpacePages,
ensureWorkspaceHtmlThumbnail,
publishDir,
body = {},
skipThumbnail = false,
}) {
const { source, analysis, resolvedHtml } = bundle;
const sessionId = body.session_id ?? body.sessionId;
const messageId = body.message_id ?? body.messageId;
const snapshot = {
session_name: source.session.name,
message_created: source.message.created,
role: source.message.role,
content_mode: analysis.contentMode,
public_url: analysis.previewUrl,
relative_path: analysis.relativePath ?? null,
};
const pageInput = await buildPageInput({ bundle, body });
const existingPage = await resolveExistingPage({
userId,
sessionId,
messageId,
analysis,
mindSpacePages,
});
if (
!skipThumbnail &&
analysis.contentMode === 'static_html' &&
resolvedHtml?.content &&
analysis.relativePath &&
ensureWorkspaceHtmlThumbnail &&
publishDir
) {
await ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, {
title: pageInput.title,
subtitle: pageInput.summary,
}).catch(() => {});
}
if (existingPage) {
return mindSpacePages.updatePage(userId, existingPage.id, {
...pageInput,
expectedVersion: existingPage.versionNo,
});
}
return mindSpacePages.createFromChat(userId, pageInput, {
sessionId,
messageId,
snapshot,
});
}
export async function ensurePagePublicationForPlaza({ userId, page, mindSpacePublications }) {
const existing = await mindSpacePublications.getCurrent(userId, page.id);
if (existing?.id) return existing;
const preferredSlug = slugFromPageTitle(page.title, page.id);
return mindSpacePublications.publish(userId, page.id, {
pageVersionId: page.currentVersionId,
accessMode: 'public',
urlSlug: preferredSlug,
autoAcknowledgeFindings: true,
});
}
export async function ensurePlazaPostForPublication({
userId,
publication,
plazaPosts,
categorySlug = 'other',
}) {
const post = await plazaPosts.publishPostForPublication(
userId,
{
publication_id: publication.id,
category_slug: categorySlug,
cover_url: '',
allow_comment: true,
},
{ forcePublished: true, deferPostPublishedHooks: true },
);
return { post };
}
export async function quickPlazaFromChat({
user,
h5Root,
bundle,
body,
mindSpacePages,
mindSpacePublications,
plazaPosts,
publishDir,
}) {
if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' });
}
const page = await ensureChatPageForPlaza({
userId: user.id,
bundle,
mindSpacePages,
publishDir,
body,
skipThumbnail: true,
});
const publication = await ensurePagePublicationForPlaza({
userId: user.id,
page,
mindSpacePublications,
});
const { post } = await ensurePlazaPostForPublication({
userId: user.id,
publication,
plazaPosts,
});
return {
pageId: page.id,
publicationId: publication.id,
publicUrl: publication.publicUrl ?? publication.public_url ?? null,
post: {
id: post.id,
status: post.status,
},
};
}
+16
View File
@@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { slugFromPageTitle } from './mindspace-chat-plaza.mjs';
import { MINDSPACE_SERVER_ADAPTER_BINDINGS } from './mindspace-server-adapter-contract.mjs';
test('adapter contract exposes findPageBySourceMessage for quick plaza', () => {
assert.ok(MINDSPACE_SERVER_ADAPTER_BINDINGS.pageService.includes('findPageBySourceMessage'));
});
test('slugFromPageTitle falls back to page when title has no ascii', () => {
assert.equal(slugFromPageTitle('春江花月夜', '1c99b83b-0454-474f-a5d2-129d34506a32'), 'page-1c99b83b');
});
test('slugFromPageTitle keeps ascii slug and page suffix', () => {
assert.equal(slugFromPageTitle('Travel Guide 2026', 'abcd-1234-5678-9012'), 'travel-guide-2026-abcd1234');
});
+3 -1
View File
@@ -581,7 +581,9 @@ export function createPublicationService(pool, options = {}) {
findings: result.findings,
});
}
const acknowledged = new Set(input.acknowledgedFindingIds ?? []);
const acknowledged = input.autoAcknowledgeFindings
? new Set(result.findings.map((finding) => finding.id))
: new Set(input.acknowledgedFindingIds ?? []);
const missingAcknowledgements = result.findings.filter(
(finding) => !finding.blocking && !acknowledged.has(finding.id),
);
+23 -8
View File
@@ -27,17 +27,32 @@ function buildRemoteOperationUrl(endpoint, bindingKey, method, operationBasePath
return `${base}${normalizedBasePath}/${encodeURIComponent(bindingKey)}/${encodeURIComponent(method)}`;
}
async function parseRemoteOperationResponse(response, bindingKey, method) {
if (!response.ok) {
const bodyText = await response.text().catch(() => '');
throw new Error(
function throwRemoteOperationError(response, bindingKey, method, bodyText, payload) {
const error = new Error(
payload?.message ??
`MindSpace remote adapter ${bindingKey}.${method}() failed with ${response.status}${bodyText ? `: ${bodyText}` : ''}`,
);
);
if (payload?.code) error.code = payload.code;
if (payload?.details !== undefined) error.details = payload.details;
throw error;
}
async function parseRemoteOperationResponse(response, bindingKey, method) {
const bodyText = await response.text().catch(() => '');
let payload = null;
if (bodyText) {
try {
payload = JSON.parse(bodyText);
} catch {
payload = null;
}
}
if (!response.ok) {
throwRemoteOperationError(response, bindingKey, method, bodyText, payload);
}
if (response.status === 204) return null;
const text = await response.text();
if (!text) return null;
return JSON.parse(text);
if (!bodyText) return null;
return payload ?? JSON.parse(bodyText);
}
async function invokeRemoteOperation({
+23
View File
@@ -133,3 +133,26 @@ test('createMindSpaceRemoteServerAdapter surfaces remote conversation package er
/conversationPackageRegistry\.readManifestForSession\(\) failed with 502: upstream manifest missing/,
);
});
test('createMindSpaceRemoteServerAdapter preserves publication error codes from rpc payload', async () => {
const adapter = createMindSpaceRemoteServerAdapter({
endpoint: 'https://mindspace.example.com/',
authToken: 'secret-token',
fetchFn: async () => ({
ok: false,
status: 404,
async text() {
return JSON.stringify({
message: '公开页面不存在',
code: 'publication_not_found',
});
},
}),
logger: { log() {}, warn() {}, error() {} },
});
await assert.rejects(
() => adapter.publicationService.resolvePublic('john', 'missing-page', null, null, {}),
(error) => error instanceof Error && error.code === 'publication_not_found',
);
});
+1
View File
@@ -30,6 +30,7 @@ export const MINDSPACE_SERVER_ADAPTER_BINDINGS = Object.freeze({
'deletePage',
'findPageByRelativePath',
'findPageBySourceAsset',
'findPageBySourceMessage',
'getDeletePreview',
'getPage',
'listPages',
+35 -4
View File
@@ -53,6 +53,36 @@ function json(res, statusCode, payload) {
res.end(body);
}
function serializeRpcError(error) {
return {
message: error instanceof Error ? error.message : String(error),
code: error?.code ?? 'internal_error',
...(error?.details !== undefined ? { details: error.details } : {}),
};
}
function resolveRpcErrorStatus(error) {
switch (error?.code) {
case 'publication_not_found':
case 'publication_owner_not_found':
case 'page_not_found':
case 'category_not_found':
return 404;
case 'publication_login_required':
return 401;
case 'publication_password_required':
return 403;
case 'invalid_input':
case 'invalid_publish_input':
case 'invalid_state_transition':
case 'slug_conflict':
case 'security_ack_required':
return 400;
default:
return 500;
}
}
export async function createMindSpaceRpcRequestHandler({
adapter,
env = process.env,
@@ -113,10 +143,11 @@ export async function createMindSpaceRpcRequestHandler({
const result = await service[method](...args);
return json(res, 200, result);
} catch (error) {
logger.error?.('[MindSpace RPC Error]', error);
return json(res, 500, {
message: error instanceof Error ? error.message : String(error),
});
const statusCode = resolveRpcErrorStatus(error);
if (statusCode >= 500) {
logger.error?.('[MindSpace RPC Error]', error);
}
return json(res, statusCode, serializeRpcError(error));
}
};
}
@@ -196,3 +196,52 @@ test('rpc invocation revives JSON-serialized buffers before dispatch', async ()
assert.deepEqual(response.body, { isBuffer: true, size: payload.length });
assert.equal(Buffer.isBuffer(adapter.calls.writeUploadContent[0][2]), true);
});
test('rpc maps publication_not_found to 404 with error code', async () => {
const adapter = createStubAdapter();
adapter.publicationService.resolvePublic = async () => {
const error = new Error('公开页面不存在');
error.code = 'publication_not_found';
throw error;
};
const handler = await createMindSpaceRpcRequestHandler({
adapter,
env: {
MINDSPACE_MEMIND_ROOT: '..',
},
});
const response = await runRequest(handler, {
method: 'POST',
path: '/mindspace/v1/adapter/publicationService/resolvePublic',
body: JSON.stringify({ args: ['john', 'missing-page', null, null, {}] }),
});
assert.equal(response.statusCode, 404);
assert.equal(response.body.code, 'publication_not_found');
assert.match(response.body.message, /公开页面不存在/);
});
test('rpc maps publication_login_required to 401 with error code', async () => {
const adapter = createStubAdapter();
adapter.publicationService.resolvePublic = async () => {
const error = new Error('登录后才能访问此页面');
error.code = 'publication_login_required';
throw error;
};
const handler = await createMindSpaceRpcRequestHandler({
adapter,
env: {
MINDSPACE_MEMIND_ROOT: '..',
},
});
const response = await runRequest(handler, {
method: 'POST',
path: '/mindspace/v1/adapter/publicationService/resolvePublic',
body: JSON.stringify({ args: ['john', 'login-page', null, null, {}] }),
});
assert.equal(response.statusCode, 401);
assert.equal(response.body.code, 'publication_login_required');
});
+1
View File
@@ -123,6 +123,7 @@ export function createPlazaSeoService(
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: list.join('\n'),
signal: AbortSignal.timeout(5_000),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
+12 -35
View File
@@ -231,6 +231,10 @@ 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'),
);
}
async function writeMetadata() {
@@ -260,41 +264,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'),
+11 -1
View File
@@ -539,6 +539,12 @@ if ! launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1; th
nohup "${APP_DIR}/scripts/run-memind-portal-prod.sh" >> "${HOME}/Library/Logs/memind-portal.log" 2>&1 &
fi
AGENT_RUN_WORKER_LABEL="${MEMIND_AGENT_RUN_WORKER_LABEL:-cn.tkmind.memind-agent-run-worker}"
if launchctl print "${LAUNCHD_GUI}/${AGENT_RUN_WORKER_LABEL}" >/dev/null 2>&1; then
say "重启 agent run worker"
launchctl kickstart -k "${LAUNCHD_GUI}/${AGENT_RUN_WORKER_LABEL}" >/dev/null 2>&1 || true
fi
say "健康检查"
for _ in $(seq 1 60); do
portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)"
@@ -555,11 +561,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
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env node
/**
* Repair missing public/*.docx download targets before release link checks.
* Runtime-safe: no imports from the full MindSpace finish-sync graph.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const DOCX_HREF_RE = /<a\b[^>]*\bhref=(["'])([^"']+\.docx)\1[^>]*>[\s\S]*?<\/a>/gi;
const DOCX_HREF_CAPTURE_RE = /<a\b[^>]*\bhref=(["'])([^"']+\.docx)\1/gi;
function listDocxHrefBasenames(html) {
const basenames = new Set();
let match = DOCX_HREF_CAPTURE_RE.exec(html);
while (match) {
const base = path.basename(String(match[2] ?? '')).trim();
if (base) basenames.add(base);
match = DOCX_HREF_CAPTURE_RE.exec(html);
}
DOCX_HREF_CAPTURE_RE.lastIndex = 0;
return [...basenames];
}
function listMissingDocxTargets(html, publicDir) {
return listDocxHrefBasenames(html).filter((basename) => {
const docxPath = path.join(publicDir, basename);
return !fs.existsSync(docxPath);
});
}
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 };
}
function tryCopyDocxFromOa(userRoot, publicDir, basename) {
const destination = path.join(publicDir, basename);
if (fs.existsSync(destination)) return false;
const source = path.join(userRoot, 'oa', basename);
if (!fs.existsSync(source) || !fs.statSync(source).isFile()) return false;
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.copyFileSync(source, destination);
return true;
}
export function repairMindspacePublicDownloads({ publishDir } = {}) {
const root = path.resolve(String(publishDir ?? ''));
if (!root || !fs.existsSync(root)) {
return { synced: [], stripped: [] };
}
const synced = [];
const stripped = [];
for (const userDir of fs.readdirSync(root, { withFileTypes: true })) {
if (!userDir.isDirectory()) continue;
const userRoot = path.join(root, userDir.name);
const publicDir = path.join(userRoot, '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');
for (const basename of listDocxHrefBasenames(html)) {
if (tryCopyDocxFromOa(userRoot, publicDir, basename)) {
synced.push(path.join(userDir.name, 'public', basename));
}
}
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, 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();
}
+27
View File
@@ -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"
+95 -11
View File
@@ -122,6 +122,7 @@ import {
normalizePublicHtmlRelativePath,
syncPublicHtmlAfterFinish,
} from './mindspace-public-finish-sync.mjs';
import { quickPlazaFromChat } from './mindspace-chat-plaza.mjs';
import { extractCoverSignals, generateHtmlThumbnail } from './mindspace-thumbnails.mjs';
import {
extractSharePreviewMeta,
@@ -2908,7 +2909,8 @@ function messageText(message) {
.trim();
}
async function resolveOwnedAssistantMessage(userId, sessionId, messageId) {
async function resolveOwnedAssistantMessage(user, sessionId, messageId) {
const userId = user?.id;
if (!sessionId || !messageId) {
throw Object.assign(new Error('缺少来源会话或消息'), {
code: 'invalid_page_input',
@@ -2917,13 +2919,52 @@ async function resolveOwnedAssistantMessage(userId, sessionId, messageId) {
if (!(await userAuth.ownsSession(userId, sessionId))) {
throw Object.assign(new Error('来源会话不存在'), { code: 'source_message_not_found' });
}
const upstream = await tkmindProxy.apiFetch(`/sessions/${encodeURIComponent(sessionId)}`, {
method: 'GET',
});
if (!upstream.ok) {
throw Object.assign(new Error('无法读取来源会话'), { code: 'source_message_not_found' });
let session = null;
if (sessionSnapshotService?.isEnabled()) {
const snapshot = await sessionSnapshotService.get(sessionId).catch(() => null);
if (snapshot?.messages?.length) {
session = {
...snapshot.session,
conversation: sanitizeSessionConversationPublicHtmlLinks(snapshot.messages, user),
};
if (authPool) {
session = await repairSessionConversationFromDb(authPool, session, sessionId, userId);
}
}
}
const session = await upstream.json();
if (!session) {
const target = await tkmindProxy.resolveTarget(sessionId);
let upstream;
try {
upstream = await tkmindProxy.apiFetchTo(
target,
`/sessions/${encodeURIComponent(sessionId)}`,
{ method: 'GET', signal: AbortSignal.timeout(15_000) },
);
} catch (error) {
throw Object.assign(
new Error(
error?.name === 'TimeoutError' || error?.name === 'AbortError'
? '读取来源会话超时,请稍后重试'
: '无法读取来源会话',
),
{ code: 'source_message_not_found' },
);
}
if (!upstream.ok) {
throw Object.assign(new Error('无法读取来源会话'), { code: 'source_message_not_found' });
}
session = await upstream.json();
if (Array.isArray(session.conversation)) {
session.conversation = sanitizeSessionConversationPublicHtmlLinks(session.conversation, user);
}
if (authPool) {
session = await repairSessionConversationFromDb(authPool, session, sessionId, userId);
}
}
const message = (session.conversation ?? []).find((item) => item.id === messageId);
if (!message) {
throw Object.assign(new Error('来源消息不存在'), { code: 'source_message_not_found' });
@@ -2963,7 +3004,7 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) {
const previewTitle = String(input.previewTitle ?? input.preview_title ?? '').trim();
const previewSummary = String(input.previewSummary ?? input.preview_summary ?? '').trim();
const source = await resolveOwnedAssistantMessage(user.id, sessionId, messageId);
const source = await resolveOwnedAssistantMessage(user, sessionId, messageId);
let { analysis, resolvedHtml } = await resolveChatSaveAnalysis({
content: source.content,
userId: user.id,
@@ -3294,6 +3335,40 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => {
}
});
api.post('/mindspace/v1/pages/quick-plaza-from-chat', async (req, res) => {
if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 或 MindSpace 未启用');
}
const startedAt = Date.now();
try {
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body);
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
const result = await quickPlazaFromChat({
user: req.currentUser,
h5Root: __dirname,
bundle,
body: req.body ?? {},
mindSpacePages,
mindSpacePublications,
plazaPosts,
publishDir,
});
console.info('[quick-plaza] ok', {
ms: Date.now() - startedAt,
pageId: result.pageId,
publicationId: result.publicationId,
postId: result.post?.id,
});
return sendData(res, req, result, 201);
} catch (error) {
console.error('[quick-plaza] error:', { ms: Date.now() - startedAt, error });
if (error?.code && mapPlazaError(error) !== 500) {
return plazaRouteError(res, req, error);
}
return mindSpaceError(res, req, error);
}
});
async function handleChatSaveDocx(req, res) {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
@@ -3348,7 +3423,7 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
throw Object.assign(new Error('无效的保存目标'), { code: 'invalid_category_code' });
}
const source = await resolveOwnedAssistantMessage(
req.currentUser.id,
req.currentUser,
req.body?.session_id,
req.body?.message_id,
);
@@ -5723,15 +5798,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;
});
});
+30
View File
@@ -64,6 +64,7 @@ import type { AgentRunCreateOptions, AgentRunValidation } from '../utils/agentRu
const API = '/api';
const DEFAULT_API_TIMEOUT_MS = 20_000;
const AGENT_CONNECT_TIMEOUT_MS = 60_000;
const QUICK_PLAZA_TIMEOUT_MS = 120_000;
const AGENT_RUNS_PATH = '/agent/runs';
export type AgentRun = {
@@ -1086,6 +1087,35 @@ export async function quickShareFromChat(input: {
return result.data;
}
export async function quickPlazaFromChat(input: {
sessionId: string;
messageId: string;
selectedLinkIndex?: number;
}, signal?: AbortSignal): Promise<{
pageId: string;
publicationId: string;
publicUrl: string | null;
post: PlazaPostBrief;
}> {
const result = await apiFetch<{
data: {
pageId: string;
publicationId: string;
publicUrl: string | null;
post: PlazaPostBrief;
};
}>('/mindspace/v1/pages/quick-plaza-from-chat', {
method: 'POST',
body: JSON.stringify({
session_id: input.sessionId,
message_id: input.messageId,
selected_link_index: input.selectedLinkIndex ?? 0,
}),
signal,
}, { timeoutMs: QUICK_PLAZA_TIMEOUT_MS });
return result.data;
}
export async function downloadChatMessageDocx(input: {
sessionId: string;
messageId: string;
+19
View File
@@ -16,6 +16,7 @@ import { AvatarPicker } from './AvatarPicker';
import { ChatSkillPicker } from './ChatSkillPicker';
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
import { ChatSharePreviewModal } from './ChatSharePreviewModal';
import { ChatPlazaPublishModal } from './ChatPlazaPublishModal';
import { MessageList } from './MessageList';
import { PageSaveDialog } from './PageSaveDialog';
import { VoiceInputButton } from './VoiceInputButton';
@@ -166,6 +167,7 @@ export function ChatPanel({
const [voiceRecording, setVoiceRecording] = useState(false);
const [pageSource, setPageSource] = useState<Message | null>(null);
const [sharePreviewSource, setSharePreviewSource] = useState<Message | null>(null);
const [plazaPublishSource, setPlazaPublishSource] = useState<Message | null>(null);
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
const [forceDeepReasoning, setForceDeepReasoning] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
@@ -629,6 +631,14 @@ export function ChatPanel({
setPageSource(message);
};
const openPlazaPublish = (message: Message) => {
setPlazaPublishSource(message);
};
const closePlazaPublish = useCallback(() => {
setPlazaPublishSource(null);
}, []);
const downloadLongImage = (_message: Message, publicUrl: string) => {
triggerUrlDownload(appendLongImageDownloadParam(publicUrl));
};
@@ -665,6 +675,7 @@ export function ChatPanel({
streaming={chatState === 'streaming'}
onAvatarClick={compact ? undefined : openAvatarPicker}
onSaveAsPage={openSaveActions}
onShareToPlaza={openPlazaPublish}
onDownloadLongImage={downloadLongImage}
onDownloadDocx={(message) => void downloadDocx(message)}
publishUserId={user?.id}
@@ -721,6 +732,14 @@ export function ChatPanel({
/>
)}
{plazaPublishSource?.id && session?.id && (
<ChatPlazaPublishModal
sessionId={session.id}
messageId={plazaPublishSource.id}
onClose={closePlazaPublish}
/>
)}
<footer className={compact ? 'space-chat-panel-footer' : `footer${showHomeWelcome ? ' footer-home' : ''}`}>
{connectStatusText && (
<div className="chat-connect-status" role="status" aria-live="polite">
+155
View File
@@ -0,0 +1,155 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ApiError, quickPlazaFromChat } from '../api/client';
import { resolvePlazaPostUrl } from '../utils/publicUrl';
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
type PlazaPublishPhase =
| { kind: 'loading' }
| { kind: 'success'; plazaUrl: string }
| { kind: 'already'; plazaUrl: string; message: string }
| { kind: 'error'; message: string };
function resolveAlreadyPublishedMessage(err: ApiError) {
const postId = String(err.details?.post_id ?? err.details?.postId ?? '').trim();
const plazaUrl = postId ? resolvePlazaPostUrl(postId) : '';
return {
message: '该内容已发布到 Plaza,请先在广场删除后再试',
plazaUrl,
};
}
export function ChatPlazaPublishModal({
sessionId,
messageId,
onClose,
}: {
sessionId: string;
messageId: string;
onClose: () => void;
}) {
const [phase, setPhase] = useState<PlazaPublishPhase>({ kind: 'loading' });
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
if (closeTimer.current) window.clearTimeout(closeTimer.current);
};
}, []);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || phase.kind === 'loading') return;
onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, phase.kind]);
useEffect(() => {
const controller = new AbortController();
void quickPlazaFromChat({ sessionId, messageId }, controller.signal)
.then((result) => {
if (controller.signal.aborted) return;
const plazaUrl = resolvePlazaPostUrl(result.post.id);
setPhase({ kind: 'success', plazaUrl });
closeTimer.current = window.setTimeout(() => onCloseRef.current(), 2200);
})
.catch((err) => {
if (controller.signal.aborted) return;
if (err instanceof ApiError && err.code === 'ALREADY_PUBLISHED') {
const resolved = resolveAlreadyPublishedMessage(err);
setPhase({
kind: 'already',
message: resolved.message,
plazaUrl: resolved.plazaUrl,
});
return;
}
setPhase({
kind: 'error',
message: err instanceof Error ? err.message : '发布到 Plaza 失败,请重试',
});
});
return () => controller.abort();
}, [sessionId, messageId]);
const canClose = phase.kind !== 'loading';
return createPortal(
<div
className="chat-plaza-publish-backdrop"
role="presentation"
onClick={canClose ? onClose : undefined}
>
<div
className="chat-plaza-publish-panel"
role="dialog"
aria-modal="true"
aria-labelledby="chat-plaza-publish-title"
aria-busy={phase.kind === 'loading'}
onClick={(event) => event.stopPropagation()}
>
{phase.kind === 'loading' && (
<div className="chat-plaza-publish-body">
<ChatLoadingSpinner className="chat-plaza-publish-spinner" />
<h3 id="chat-plaza-publish-title"></h3>
<p> Plaza 广</p>
</div>
)}
{phase.kind === 'success' && (
<div className="chat-plaza-publish-body chat-plaza-publish-body-success">
<div className="chat-plaza-publish-icon chat-plaza-publish-icon-success" aria-hidden="true">
</div>
<h3 id="chat-plaza-publish-title"></h3>
<p> Plaza 广</p>
<a href={phase.plazaUrl} target="_blank" rel="noreferrer" className="chat-plaza-publish-link">
Plaza
</a>
</div>
)}
{phase.kind === 'already' && (
<div className="chat-plaza-publish-body chat-plaza-publish-body-warning">
<div className="chat-plaza-publish-icon chat-plaza-publish-icon-warning" aria-hidden="true">
!
</div>
<h3 id="chat-plaza-publish-title"></h3>
<p>{phase.message}</p>
{phase.plazaUrl ? (
<a href={phase.plazaUrl} target="_blank" rel="noreferrer" className="chat-plaza-publish-link">
</a>
) : null}
<button type="button" className="chat-plaza-publish-close-btn" onClick={onClose}>
</button>
</div>
)}
{phase.kind === 'error' && (
<div className="chat-plaza-publish-body chat-plaza-publish-body-error">
<div className="chat-plaza-publish-icon chat-plaza-publish-icon-error" aria-hidden="true">
×
</div>
<h3 id="chat-plaza-publish-title"></h3>
<p>{phase.message}</p>
<button type="button" className="chat-plaza-publish-close-btn" onClick={onClose}>
</button>
</div>
)}
</div>
</div>,
document.body,
);
}
+74 -78
View File
@@ -83,6 +83,22 @@ function DocumentIcon() {
);
}
function PlazaIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" className="icon-plaza">
<rect x="4" y="4" width="7" height="7" rx="1.6" stroke="currentColor" strokeWidth="1.7" />
<rect x="13" y="4" width="7" height="7" rx="1.6" stroke="currentColor" strokeWidth="1.7" />
<rect x="4" y="13" width="7" height="7" rx="1.6" stroke="currentColor" strokeWidth="1.7" />
<path
d="M13 16.5h7M16.5 13v7"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
);
}
function LinkIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -240,6 +256,7 @@ function MessageRow({
avatarUrl,
onAvatarClick,
onSaveAsPage,
onShareToPlaza,
onDownloadLongImage,
onDownloadDocx,
saveDisabled,
@@ -253,6 +270,7 @@ function MessageRow({
avatarUrl: string | null;
onAvatarClick?: () => void;
onSaveAsPage?: (message: Message) => void;
onShareToPlaza?: (message: Message) => void;
onDownloadLongImage?: (message: Message, publicUrl: string) => void;
onDownloadDocx?: (message: Message) => void;
saveDisabled?: boolean;
@@ -286,8 +304,59 @@ function MessageRow({
const hasAssistantActions =
!isUser && Boolean(message.id && onSaveAsPage && copyText);
const hasPageDownloadActions = hasAssistantActions && saveActions.kind === 'page' && Boolean(saveActions.previewUrl);
const hasPlazaAction = hasPageDownloadActions && Boolean(onShareToPlaza);
const showActionsToggle = compact && Boolean(copyText);
const renderPageActions = () => (
<div className="msg-page-actions">
<button
type="button"
className="msg-save-page"
disabled={saveDisabled}
onClick={() => onSaveAsPage!(message)}
aria-label={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
title={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
>
<PageIcon />
</button>
{saveActions.previewUrl && <PublicLinkCopyButton url={saveActions.previewUrl} />}
{hasPageDownloadActions && (
<>
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadLongImage?.(message, saveActions.previewUrl!)}
aria-label="下载图片"
title="下载图片"
>
<ImageIcon />
</button>
{hasPlazaAction && (
<button
type="button"
className="msg-save-page icon-plaza"
disabled={saveDisabled}
onClick={() => onShareToPlaza!(message)}
aria-label="发布到 Plaza"
title="发布到 Plaza"
>
<PlazaIcon />
</button>
)}
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadDocx?.(message)}
aria-label="保存文档"
title="保存文档"
>
<DocumentIcon />
</button>
</>
)}
</div>
);
return (
<div className={`msg-row ${isUser ? 'msg-row-user' : 'msg-row-assistant'}`}>
{!isUser && <TKMindAvatar />}
@@ -344,89 +413,13 @@ function MessageRow({
{copyText && !compact && (
<div className="msg-actions">
<CopyButton text={copyText} />
{hasAssistantActions && (
<div className="msg-page-actions">
<button
type="button"
className="msg-save-page"
disabled={saveDisabled}
onClick={() => onSaveAsPage!(message)}
aria-label={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
title={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
>
<PageIcon />
</button>
{saveActions.previewUrl && (
<PublicLinkCopyButton url={saveActions.previewUrl} />
)}
{hasPageDownloadActions && (
<>
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadLongImage?.(message, saveActions.previewUrl!)}
aria-label="下载图片"
title="下载图片"
>
<ImageIcon />
</button>
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadDocx?.(message)}
aria-label="保存文档"
title="保存文档"
>
<DocumentIcon />
</button>
</>
)}
</div>
)}
{hasAssistantActions && renderPageActions()}
</div>
)}
{copyText && compact && actionsOpen && (
<div className="msg-actions msg-actions-compact">
<CopyButton text={copyText} />
{hasAssistantActions && (
<div className="msg-page-actions">
<button
type="button"
className="msg-save-page"
disabled={saveDisabled}
onClick={() => onSaveAsPage!(message)}
aria-label={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
title={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
>
<PageIcon />
</button>
{saveActions.previewUrl && (
<PublicLinkCopyButton url={saveActions.previewUrl} />
)}
{hasPageDownloadActions && (
<>
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadLongImage?.(message, saveActions.previewUrl!)}
aria-label="下载图片"
title="下载图片"
>
<ImageIcon />
</button>
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadDocx?.(message)}
aria-label="保存文档"
title="保存文档"
>
<DocumentIcon />
</button>
</>
)}
</div>
)}
{hasAssistantActions && renderPageActions()}
</div>
)}
</div>
@@ -448,6 +441,7 @@ export function MessageList({
streaming,
onAvatarClick,
onSaveAsPage,
onShareToPlaza,
onDownloadLongImage,
onDownloadDocx,
publishUserId,
@@ -458,6 +452,7 @@ export function MessageList({
streaming: boolean;
onAvatarClick?: () => void;
onSaveAsPage?: (message: Message) => void;
onShareToPlaza?: (message: Message) => void;
onDownloadLongImage?: (message: Message, publicUrl: string) => void;
onDownloadDocx?: (message: Message) => void;
publishUserId?: string;
@@ -496,6 +491,7 @@ export function MessageList({
avatarUrl={avatarUrl}
onAvatarClick={onAvatarClick}
onSaveAsPage={onSaveAsPage}
onShareToPlaza={onShareToPlaza}
onDownloadLongImage={onDownloadLongImage}
onDownloadDocx={onDownloadDocx}
saveDisabled={streaming}
+144 -73
View File
@@ -82,6 +82,11 @@ function isDirectChatSessionId(sessionId?: string | null) {
return typeof sessionId === 'string' && sessionId.startsWith(DIRECT_CHAT_SESSION_PREFIX);
}
function getSessionEventRequestId(event: SessionEvent): string | undefined {
const raw = event as SessionEvent & { chat_request_id?: string; request_id?: string };
return raw.chat_request_id ?? raw.request_id;
}
function sessionFinishedViaPortalDirectChat(messages: Message[], userMessage: Message) {
const lastAssistant = [...messages].reverse().find((message) => message.role === 'assistant');
if (lastAssistant?.metadata?.source !== 'portal-direct-chat') return false;
@@ -278,6 +283,8 @@ export function useTKMindChat(
const activeRequestId = useRef<string | null>(null);
const activeRequestMissingTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
const chatStateRef = useRef<ChatState>(chatState);
const subscribedSessionIdRef = useRef<string | null>(null);
const unsubscribeRef = useRef<(() => void) | null>(null);
const connectTokenRef = useRef(0);
const messagesRef = useRef<Message[]>([]);
@@ -295,6 +302,10 @@ export function useTKMindChat(
const onUserUpdateRef = useRef(onUserUpdate);
const chatImageCategoryIdRef = useRef<string | null>(null);
useEffect(() => {
chatStateRef.current = chatState;
}, [chatState]);
const clearActiveRequestMissingTimer = useCallback(() => {
if (!activeRequestMissingTimerRef.current) return;
window.clearTimeout(activeRequestMissingTimerRef.current);
@@ -718,10 +729,22 @@ export function useTKMindChat(
}
}, []);
const scheduleReplyRecoverySync = useCallback(
(sessionId: string, submitToken: number) => {
for (const delay of REPLY_RECOVERY_SYNC_DELAYS_MS) {
window.setTimeout(() => {
if (connectTokenRef.current !== submitToken) return;
if (sessionRef.current?.id !== sessionId) return;
void syncSessionMessages(sessionId);
}, delay);
}
},
[syncSessionMessages],
);
const processEvent = useCallback(
(event: SessionEvent, requestId: string, sessionId: string) => {
const raw = event as SessionEvent & { chat_request_id?: string; request_id?: string };
const eventRequestId = raw.chat_request_id ?? raw.request_id;
const eventRequestId = getSessionEventRequestId(event);
if (eventRequestId && eventRequestId !== requestId) return;
clearActiveRequestMissingTimer();
@@ -824,6 +847,7 @@ export function useTKMindChat(
if (isDirectChatSessionId(sessionId)) {
unsubscribeRef.current?.();
unsubscribeRef.current = null;
subscribedSessionIdRef.current = null;
}
setSessions((prev) => touchSession(prev, sessionId, 0));
if (userRef.current && onUserUpdateRef.current) {
@@ -857,11 +881,15 @@ export function useTKMindChat(
const subscribeToSession = useCallback(
(sessionId: string) => {
if (subscribedSessionIdRef.current === sessionId && unsubscribeRef.current) {
return;
}
subscribedSessionIdRef.current = sessionId;
unsubscribeRef.current?.();
unsubscribeRef.current = subscribeSessionEvents(
sessionId,
(event) => {
const rid = activeRequestId.current;
let rid = activeRequestId.current;
if (event.type === 'ActiveRequests') {
if (!rid && event.request_ids.length > 0) {
// SSE reconnected while agent was running — adopt the active request.
@@ -871,6 +899,11 @@ export function useTKMindChat(
} else if (rid && event.request_ids.includes(rid)) {
clearActiveRequestMissingTimer();
} else if (rid && !event.request_ids.includes(rid)) {
// While waiting for the agent run gate, keep request context alive so
// Goose streaming events are not dropped before the UI subscribes.
if (chatStateRef.current === 'waiting') {
return;
}
// The backend can briefly report no active request between tool phases.
// Confirm the absence before turning the UI idle, otherwise MindSpace
// refreshes the page while tools are still mutating it.
@@ -887,6 +920,17 @@ export function useTKMindChat(
}
return;
}
const eventRequestId = getSessionEventRequestId(event);
if (
!rid &&
eventRequestId &&
(chatStateRef.current === 'waiting' || chatStateRef.current === 'streaming') &&
(event.type === 'Message' || event.type === 'Finish')
) {
activeRequestId.current = eventRequestId;
rid = eventRequestId;
clearActiveRequestMissingTimer();
}
if (
rid ||
(isDirectChatSessionId(sessionId) &&
@@ -930,6 +974,7 @@ export function useTKMindChat(
connectTokenRef.current += 1;
unsubscribeRef.current?.();
unsubscribeRef.current = null;
subscribedSessionIdRef.current = null;
clearActiveRequestMissingTimer();
setError(null);
setPendingTool(null);
@@ -959,6 +1004,7 @@ export function useTKMindChat(
const token = ++connectTokenRef.current;
unsubscribeRef.current?.();
unsubscribeRef.current = null;
subscribedSessionIdRef.current = null;
clearActiveRequestMissingTimer();
if (showLoading) {
@@ -968,45 +1014,57 @@ export function useTKMindChat(
setPendingTool(null);
activeRequestId.current = null;
const knownSession = sessionsRef.current.find((s) => s.id === sessionId);
const hints = knownSession
? { messageCount: knownSession.message_count, updatedAt: knownSession.updated_at }
: undefined;
const detailPromise = withTransientConnectRetry(() =>
loadSessionDetail(sessionId, hints, {
before: 0,
limit: appConfig.sessionMessagePageSize,
}),
);
const resumedPromise =
options?.skipResume || isDirectChatSessionId(sessionId)
? Promise.resolve(options.seedSession ?? null)
: withTransientConnectRetry(() =>
resumeSession(sessionId, {
skipReconcile: options?.skipReconcile ?? false,
}),
);
const { session: detail, messages: history, page } = await detailPromise;
if (token !== connectTokenRef.current) return;
let completed = false;
try {
const knownSession = sessionsRef.current.find((s) => s.id === sessionId);
const hints = knownSession
? { messageCount: knownSession.message_count, updatedAt: knownSession.updated_at }
: undefined;
const detailPromise = withTransientConnectRetry(() =>
loadSessionDetail(sessionId, hints, {
before: 0,
limit: appConfig.sessionMessagePageSize,
}),
);
const resumedPromise =
options?.skipResume || isDirectChatSessionId(sessionId)
? Promise.resolve(options.seedSession ?? null)
: withTransientConnectRetry(() =>
resumeSession(sessionId, {
skipReconcile: options?.skipReconcile ?? false,
}),
);
const { session: detail, messages: history, page } = await detailPromise;
if (token !== connectTokenRef.current) return;
writeStoredSessionId(userRef.current?.id, sessionId);
setSession((current) => (current?.id === sessionId ? { ...detail, ...current, id: sessionId } : { ...detail, id: sessionId }));
messagesRef.current = history;
messageHistoryLoadedCountRef.current = history.length;
messageHistoryTotalRef.current = Math.max(Number(page.total ?? history.length), history.length);
messageHistoryHasMoreRef.current = history.length < messageHistoryTotalRef.current;
setMessageHistoryHasMore(messageHistoryHasMoreRef.current);
setMessageHistoryTotal(messageHistoryTotalRef.current);
setMessages(history);
const resumed = await resumedPromise;
if (token !== connectTokenRef.current) return;
setSession({ ...detail, ...(resumed ?? {}), id: sessionId });
setChatState('idle');
setSessions((prev) =>
prependUnique(prev, toSessionSummary({ ...detail, ...(resumed ?? {}), id: sessionId })),
);
writeStoredSessionId(userRef.current?.id, sessionId);
setSession((current) =>
current?.id === sessionId ? { ...detail, ...current, id: sessionId } : { ...detail, id: sessionId },
);
messagesRef.current = history;
messageHistoryLoadedCountRef.current = history.length;
messageHistoryTotalRef.current = Math.max(Number(page.total ?? history.length), history.length);
messageHistoryHasMoreRef.current = history.length < messageHistoryTotalRef.current;
setMessageHistoryHasMore(messageHistoryHasMoreRef.current);
setMessageHistoryTotal(messageHistoryTotalRef.current);
setMessages(history);
const resumed = await resumedPromise;
if (token !== connectTokenRef.current) return;
setSession({ ...detail, ...(resumed ?? {}), id: sessionId });
setChatState('idle');
completed = true;
setSessions((prev) =>
prependUnique(prev, toSessionSummary({ ...detail, ...(resumed ?? {}), id: sessionId })),
);
subscribeToSession(sessionId);
subscribeToSession(sessionId);
} finally {
if (!completed && token === connectTokenRef.current) {
setChatState((current) =>
current === 'connecting' || current === 'loading' ? 'idle' : current,
);
}
}
},
[clearActiveRequestMissingTimer, subscribeToSession],
);
@@ -1282,6 +1340,11 @@ export function useTKMindChat(
...(options?.forceDeepReasoning ? { forceDeepReasoning: true } : {}),
},
);
const runSessionId = createdRun.sessionId ?? activeSessionId;
if (runSessionId && !isDirectChatSessionId(runSessionId)) {
activeSessionId = runSessionId;
subscribeToSession(runSessionId);
}
const finishedRun =
createdRun.status === 'succeeded'
? createdRun
@@ -1289,17 +1352,22 @@ export function useTKMindChat(
isCancelled: () => submitToken !== connectTokenRef.current,
onSessionId: (sessionId) => {
if (submitToken !== connectTokenRef.current) return;
if (activeSessionId === sessionId) return;
activeSessionId = sessionId;
const nextSession: Session = {
id: sessionId,
name: 'New Chat',
message_count: messagesRef.current.length,
working_dir: '',
};
writeStoredSessionId(userRef.current?.id, sessionId);
setSession(nextSession);
setSessions((prev) => prependUnique(prev, nextSession));
if (activeSessionId !== sessionId) {
activeSessionId = sessionId;
const nextSession: Session = {
id: sessionId,
name: 'New Chat',
message_count: messagesRef.current.length,
working_dir: '',
};
writeStoredSessionId(userRef.current?.id, sessionId);
setSession(nextSession);
setSessions((prev) => prependUnique(prev, nextSession));
}
if (!isDirectChatSessionId(sessionId)) {
subscribeToSession(sessionId);
setChatState('streaming');
}
},
onMessages: (snapshotMessages) => {
if (submitToken !== connectTokenRef.current) return;
@@ -1381,6 +1449,7 @@ export function useTKMindChat(
setChatState('idle');
} else {
setChatState('streaming');
scheduleReplyRecoverySync(activeSessionId, submitToken);
}
}
} catch (err) {
@@ -1388,13 +1457,7 @@ export function useTKMindChat(
subscribeToSession(activeSessionId);
setChatState('streaming');
setError(null);
for (const delay of REPLY_RECOVERY_SYNC_DELAYS_MS) {
window.setTimeout(() => {
if (sessionRef.current?.id === activeSessionId) {
void syncSessionMessages(activeSessionId);
}
}, delay);
}
scheduleReplyRecoverySync(activeSessionId, submitToken);
return;
}
if (session && activeSessionId) setSessions((prev) => touchSession(prev, activeSessionId!, -1));
@@ -1415,6 +1478,7 @@ export function useTKMindChat(
grantedSkills,
clearActiveRequestMissingTimer,
subscribeToSession,
scheduleReplyRecoverySync,
ensureProvider,
loadProjectMemory,
refreshSessions,
@@ -1459,6 +1523,7 @@ export function useTKMindChat(
unsubscribeRef.current?.();
unsubscribeRef.current = null;
subscribedSessionIdRef.current = null;
clearActiveRequestMissingTimer();
clearStoredSessionId(userRef.current?.id);
activeRequestId.current = null;
@@ -1475,22 +1540,23 @@ export function useTKMindChat(
setPendingTool(null);
setChatState('connecting');
if (
previousSessionId &&
previousSession &&
shouldShowNewChatTitle(toSessionSummary(previousSession))
) {
try {
await deleteChatSession(previousSessionId);
if (token === connectTokenRef.current) {
setSessions((prev) => prev.filter((item) => item.id !== previousSessionId));
}
} catch {
// Keep the abandoned empty session in history if cleanup fails.
}
}
let completed = false;
try {
if (
previousSessionId &&
previousSession &&
shouldShowNewChatTitle(toSessionSummary(previousSession))
) {
try {
await deleteChatSession(previousSessionId);
if (token === connectTokenRef.current) {
setSessions((prev) => prev.filter((item) => item.id !== previousSessionId));
}
} catch {
// Keep the abandoned empty session in history if cleanup fails.
}
}
const started = await startSession();
if (token !== connectTokenRef.current) return;
@@ -1507,12 +1573,17 @@ export function useTKMindChat(
if (token !== connectTokenRef.current) return;
subscribeToSession(nextSession.id);
setChatState('idle');
completed = true;
void refreshSessions();
} catch (err) {
if (token !== connectTokenRef.current) return;
setSession(null);
setChatState('idle');
setError(err instanceof Error ? err.message : String(err));
} finally {
if (!completed && token === connectTokenRef.current) {
setChatState((current) => (current === 'connecting' ? 'idle' : current));
}
}
}, [
clearActiveRequestMissingTimer,
+113
View File
@@ -1377,6 +1377,12 @@ body,
gap: 6px;
}
.msg-save-page.icon-plaza:hover,
.msg-save-page.icon-plaza.is-done {
color: #2f6f57;
border-color: rgba(47, 111, 87, 0.55);
}
.msg-save-page,
.msg-public-share-link {
display: inline-flex;
@@ -1442,6 +1448,107 @@ body,
backdrop-filter: blur(10px);
}
.chat-plaza-publish-backdrop {
position: fixed;
z-index: 1250;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
background: rgba(7, 12, 10, 0.72);
backdrop-filter: blur(10px);
}
.chat-plaza-publish-panel {
width: min(360px, 100%);
padding: 28px 24px;
border: 1px solid rgba(238, 176, 78, 0.28);
border-radius: 22px;
color: #18211d;
background:
radial-gradient(circle at 100% 0, rgba(47, 111, 87, 0.14), transparent 16rem),
#f8f3e8;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.38);
}
.chat-plaza-publish-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
text-align: center;
}
.chat-plaza-publish-body h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.chat-plaza-publish-body p {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: rgba(24, 33, 29, 0.72);
}
.chat-plaza-publish-spinner {
width: 28px;
height: 28px;
margin-bottom: 4px;
}
.chat-plaza-publish-icon {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 999px;
font-size: 22px;
font-weight: 700;
line-height: 1;
}
.chat-plaza-publish-icon-success {
color: #2f6f57;
background: rgba(47, 111, 87, 0.14);
}
.chat-plaza-publish-icon-warning {
color: #b7791f;
background: rgba(238, 176, 78, 0.18);
}
.chat-plaza-publish-icon-error {
color: #b42318;
background: rgba(220, 38, 38, 0.12);
}
.chat-plaza-publish-link {
margin-top: 4px;
color: #2f6f57;
font-size: 14px;
text-decoration: underline;
text-underline-offset: 2px;
}
.chat-plaza-publish-close-btn {
margin-top: 8px;
padding: 8px 18px;
border: 1px solid rgba(24, 33, 29, 0.14);
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
color: #18211d;
cursor: pointer;
font: inherit;
font-size: 14px;
}
.chat-plaza-publish-close-btn:hover {
border-color: rgba(47, 111, 87, 0.35);
color: #2f6f57;
}
.page-save-panel {
display: flex;
flex-direction: column;
@@ -9880,6 +9987,12 @@ body,
color: var(--ms-soft);
}
.space-chat-panel .msg-save-page.icon-plaza:hover,
.space-chat-panel .msg-save-page.icon-plaza.is-done {
color: var(--ms-green-deep);
border-color: rgba(47, 111, 87, 0.45);
}
.space-chat-panel .chat-skill-menu {
border-color: rgba(24, 33, 29, 0.12);
background: rgba(255, 252, 244, 0.98);
+58 -6
View File
@@ -16,6 +16,7 @@ import { buildCurrentTimeAgentPrefix, buildTaskRoutingAgentText } from './user-m
import { reconcileAgentSession } from './session-reconcile.mjs';
import { createImgproxySigner } from './imgproxy-signer.mjs';
import { isDirectChatSessionId } from './direct-chat-service.mjs';
import { ensureGooseUserMessageMetadata } from './goose-message.mjs';
import {
memoryLimitForIntervention,
resolveMemoryInterventionMode,
@@ -901,16 +902,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 "会话 <id>". 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 +944,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.
@@ -1347,6 +1376,10 @@ export function createTkmindProxy({
await userAuth.getAgentSessionPolicy(userId),
);
}
body = {
...body,
user_message: ensureGooseUserMessageMetadata(body.user_message),
};
const target = await resolveTarget(sessionId);
const upstream = await apiFetch(
@@ -1627,8 +1660,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));
+41
View File
@@ -16,6 +16,7 @@ import { createMemoryV2 } from './memory-v2.mjs';
async function withFakeGoosedSession(handler) {
const workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memind-memory-v2-'));
const harnessEntries = [];
const replyBodies = [];
let server;
try {
@@ -52,6 +53,7 @@ async function withFakeGoosedSession(handler) {
return;
}
if (req.method === 'POST' && req.url === '/sessions/session-1/reply') {
replyBodies.push(body);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
return;
@@ -77,6 +79,7 @@ async function withFakeGoosedSession(handler) {
apiTarget: `http://127.0.0.1:${port}`,
workingDir,
harnessEntries,
replyBodies,
});
} finally {
if (server?.listening) {
@@ -538,6 +541,44 @@ test('getRuntimeStatus preserves Memory V2 status contract for release gates', a
});
});
test('submitSessionReplyForUser adds goose metadata visibility flags before reply', async () => {
await withFakeGoosedSession(async ({ apiTarget, workingDir, replyBodies }) => {
const proxy = createTkmindProxy({
apiTarget,
apiSecret: 'test-secret',
userAuth: {
...createMemoryTestUserAuth(workingDir),
async ownsSession() {
return true;
},
async canUseChat() {
return { ok: true };
},
async getUserById() {
return { id: 'user-1' };
},
async resolveUserPolicies() {
return { unrestricted: true, policies: {} };
},
},
});
await proxy.submitSessionReplyForUser(
'user-1',
'session-1',
'request-goose-meta',
{
role: 'user',
content: [{ type: 'text', text: '帮我生成一个分享页面' }],
},
);
assert.equal(replyBodies.length, 1);
assert.equal(replyBodies[0]?.user_message?.metadata?.userVisible, true);
assert.equal(replyBodies[0]?.user_message?.metadata?.agentVisible, true);
});
});
test('submitSessionReplyForUser passes current prompt to Memory V2 resolve before existing reply path', async () => {
let resolveInput = null;
await withFakeGoosedSession(async ({ apiTarget, workingDir }) => {