feat: split h5 direct chat from goosed tasks

This commit is contained in:
john
2026-07-04 14:01:00 +08:00
parent eb8d760dd9
commit 29d9a87d4f
18 changed files with 874 additions and 32 deletions
+32
View File
@@ -1,6 +1,7 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { isDirectChatSessionId } from './direct-chat-service.mjs';
const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000];
const TERMINAL_STATUSES = new Set(['succeeded', 'failed']);
@@ -209,6 +210,7 @@ export function createAgentRunGateway({
userAuth,
tkmindProxy,
toolGateway = null,
directChatService = null,
retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS,
autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true),
maxConcurrentRuns = positiveInteger(
@@ -384,6 +386,35 @@ export function createAgentRunGateway({
const userMessage = safeJsonParse(row.user_message_json, {});
const runOptions = getRunOptionsFromMessage(userMessage);
const toolGatewayStatus = toolGateway?.getStatus ? toolGateway.getStatus() : null;
if (directChatService?.canHandle?.({
sessionId: row.agent_session_id ?? null,
toolMode: runOptions.toolMode,
userMessage,
})) {
try {
const result = await directChatService.run({
userId: row.user_id,
sessionId: row.agent_session_id ?? null,
requestId: row.request_id,
userMessage,
});
await appendEvent(runId, 'direct_chat_completed', {
sessionId: result.sessionId,
providerId: result.providerId ?? null,
model: result.model ?? null,
billed: Boolean(result.billing?.ok),
});
return { sessionId: result.sessionId };
} catch (err) {
await appendEvent(runId, 'direct_chat_failed', {
code: err?.code ?? null,
message: err instanceof Error ? err.message : String(err),
});
if (isDirectChatSessionId(row.agent_session_id)) {
throw err;
}
}
}
if (runOptions.toolMode === 'code' && toolGatewayStatus?.enabled) {
const workingDir = userAuth?.resolveWorkingDir
? await userAuth.resolveWorkingDir(row.user_id)
@@ -592,6 +623,7 @@ export function createAgentRunGateway({
} : null,
terminalStatuses: [...TERMINAL_STATUSES],
toolGateway: toolGateway?.getStatus ? toolGateway.getStatus() : null,
directChat: directChatService?.getStatus ? directChatService.getStatus() : null,
};
}
+46
View File
@@ -255,6 +255,52 @@ test('agent run starts a session and marks submitted reply as succeeded', async
assert.equal(pool.runs.get(run.id).attempts, 1);
});
test('agent run uses direct chat service for eligible chat messages', async () => {
const pool = createFakePool();
const directRuns = [];
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {
async startSessionForUser() {
assert.fail('goose session should not start for direct chat');
},
async submitSessionReplyForUser() {
assert.fail('goose reply should not be submitted for direct chat');
},
},
directChatService: {
canHandle({ toolMode, userMessage }) {
return toolMode === 'chat' && userMessage?.content?.[0]?.text === 'hi';
},
async run(input) {
directRuns.push(input);
return {
sessionId: 'h5direct_session-1',
providerId: 'custom_deepseek',
model: 'deepseek-chat',
billing: { ok: true },
};
},
getStatus() {
return { enabled: true };
},
},
retryDelaysMs: [],
});
const run = await gateway.createRun('user-1', {
requestId: 'req-direct',
userMessage: { role: 'user', content: [{ type: 'text', text: 'hi' }] },
});
await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded');
assert.equal(pool.runs.get(run.id).agent_session_id, 'h5direct_session-1');
assert.equal(directRuns.length, 1);
assert.equal(directRuns[0].requestId, 'req-direct');
assert.ok(pool.events.some((event) => event.eventType === 'direct_chat_completed'));
});
test('agent run with code tool mode starts and submits with code policy', async () => {
const pool = createFakePool();
const submitted = [];
+17 -1
View File
@@ -154,6 +154,22 @@ export function createConversationMemoryService(pool, options = {}) {
const now = options.now ?? (() => Date.now());
const fetchImpl = options.fetch ?? undiciFetch;
const encryptionKey = options.encryptionKey;
const llmWarningIntervalMs = Math.max(
0,
Number(options.llmWarningIntervalMs ?? process.env.USER_CONVERSATION_MEMORY_LLM_WARN_INTERVAL_MS ?? 60000) || 0,
);
let lastLlmExtractionWarningAt = 0;
function warnLlmExtractionFailed(err) {
const ts = now();
if (
lastLlmExtractionWarningAt > 0 &&
llmWarningIntervalMs > 0 &&
ts - lastLlmExtractionWarningAt < llmWarningIntervalMs
) return;
lastLlmExtractionWarningAt = ts;
console.warn('[conversation-memory] LLM extraction failed:', err instanceof Error ? err.message : err);
}
function isEnabled() {
return process.env.USER_CONVERSATION_MEMORY_ENABLED !== '0';
@@ -318,7 +334,7 @@ export function createConversationMemoryService(pool, options = {}) {
memories = await extractWithLlm(messages);
extractionSucceeded = Array.isArray(memories);
} catch (err) {
console.warn('[conversation-memory] LLM extraction failed:', err instanceof Error ? err.message : err);
warnLlmExtractionFailed(err);
}
}
if (!memories) memories = fallbackMemoriesFromMessages(messages);
+57 -2
View File
@@ -1,8 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createConversationMemoryService, extractConversationMessageText } from './conversation-memory.mjs';
import { encryptSecret } from './llm-providers.mjs';
function createPool() {
function createPool({ provider = null } = {}) {
const state = {
messages: [],
memories: [],
@@ -101,7 +102,7 @@ function createPool() {
.map((item) => ({ ...item, status: 'active' })),
];
}
if (sql.includes('FROM h5_llm_provider_keys')) return [[]];
if (sql.includes('FROM h5_llm_provider_keys')) return [[provider].filter(Boolean)];
throw new Error(`Unexpected SQL: ${sql}`);
},
};
@@ -179,6 +180,60 @@ test('saveAndAnalyze leaves messages unanalyzed when extraction fails and no mem
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
});
test('saveAndAnalyze throttles repeated llm extraction warnings', async () => {
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1';
const warnings = [];
const originalWarn = console.warn;
console.warn = (...args) => warnings.push(args);
let currentNow = 4000;
try {
const secret = encryptSecret('test-key', 'memory-test-key');
const pool = createPool({
provider: {
api_url: 'https://llm.example.com/v1',
default_model: 'memory-model',
api_key_ciphertext: secret.ciphertext,
api_key_iv: secret.iv,
api_key_tag: secret.tag,
},
});
const service = createConversationMemoryService(pool, {
now: () => currentNow,
encryptionKey: 'memory-test-key',
llmWarningIntervalMs: 60000,
fetch: async () => {
throw new Error('upstream unavailable');
},
});
await service.saveAndAnalyze('session-warn-1', 'user-warn', [
{
id: 'warn-1',
role: 'user',
content: [{ type: 'text', text: '今天下雨了。' }],
metadata: { userVisible: true },
},
]);
currentNow += 1000;
await service.saveAndAnalyze('session-warn-2', 'user-warn', [
{
id: 'warn-2',
role: 'user',
content: [{ type: 'text', text: '明天可能也下雨。' }],
metadata: { userVisible: true },
},
]);
} finally {
console.warn = originalWarn;
if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
}
assert.equal(warnings.length, 1);
assert.match(String(warnings[0][0]), /conversation-memory/);
});
test('saveAndAnalyze still marks messages analyzed when llm extraction is disabled and fallback stores nothing', async () => {
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '0';
+253
View File
@@ -0,0 +1,253 @@
import crypto from 'node:crypto';
export const DIRECT_CHAT_SESSION_PREFIX = 'h5direct_';
function envFlag(value, fallback = false) {
const raw = String(value ?? '').trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
export function isDirectChatSessionId(sessionId) {
return String(sessionId ?? '').startsWith(DIRECT_CHAT_SESSION_PREFIX);
}
export function sendDirectChatSessionEvents(req, res, snapshot) {
let closed = false;
let keepalive = null;
const close = () => {
closed = true;
if (keepalive) clearInterval(keepalive);
};
const writeEvent = (id, payload) => {
if (closed || res.writableEnded) return;
res.write(`id: ${id}\n`);
res.write(`data: ${JSON.stringify(payload)}\n\n`);
};
res.status(200);
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
req.once?.('close', close);
keepalive = setInterval(() => {
if (!closed && !res.writableEnded) res.write(': keepalive\n\n');
}, 20000);
writeEvent(1, {
type: 'UpdateConversation',
conversation: snapshot.messages ?? [],
});
writeEvent(2, {
type: 'Finish',
token_state: null,
});
close();
res.end();
}
function createDirectSessionId() {
return `${DIRECT_CHAT_SESSION_PREFIX}${crypto.randomUUID()}`;
}
function messageText(message) {
const displayText = message?.metadata?.displayText;
if (typeof displayText === 'string' && displayText.trim()) return displayText.trim();
const content = message?.content;
if (typeof content === 'string') return content.trim();
if (!Array.isArray(content)) return String(message?.text ?? message?.value ?? '').trim();
return content
.map((item) => {
if (typeof item === 'string') return item;
if (item?.type === 'text') return item.text ?? '';
return '';
})
.join('\n')
.trim();
}
function assistantFacingText(message) {
const content = message?.content;
if (!Array.isArray(content)) return messageText(message);
return content
.map((item) => {
if (typeof item === 'string') return item;
if (item?.type === 'text') return item.text ?? '';
return '';
})
.join('\n')
.trim();
}
function isTextOnlyUserMessage(message) {
const content = message?.content;
if (!Array.isArray(content)) return Boolean(messageText(message));
if (content.length === 0) return false;
return content.every((item) => {
if (typeof item === 'string') return true;
return item?.type === 'text';
});
}
function renderMemoryLines(memories) {
const items = Array.isArray(memories) ? memories : [];
if (items.length === 0) return '';
return [
'以下是当前用户的长期记忆,只用于改善回答,不要主动暴露记忆来源:',
...items.slice(0, 30).map((item) => {
const label = item?.label ? `[${item.label}] ` : '';
const text = String(item?.text ?? item?.memory_text ?? item?.memoryText ?? '').trim();
return text ? `- ${label}${text}` : '';
}).filter(Boolean),
].join('\n');
}
function buildModelMessages({ previousMessages, userMessage, memories }) {
const system = [
'你是 TKMind H5 聊天助手。',
'优先直接回答用户问题;不要调用工具;涉及需要执行代码、改文件、生成页面或操作外部系统的任务时,简要说明已交由后台任务处理或请用户确认具体任务。',
renderMemoryLines(memories),
].filter(Boolean).join('\n\n');
const history = Array.isArray(previousMessages) ? previousMessages.slice(-12) : [];
return [
{ role: 'system', content: system },
...history
.map((message) => ({
role: message?.role === 'assistant' ? 'assistant' : 'user',
content: messageText(message),
}))
.filter((message) => message.content),
{ role: 'user', content: assistantFacingText(userMessage) || messageText(userMessage) },
];
}
function buildAssistantMessage(reply, { requestId, now }) {
return {
id: `direct-assistant-${requestId || crypto.randomUUID()}`,
role: 'assistant',
created: now,
content: [{ type: 'text', text: reply }],
metadata: {
userVisible: true,
source: 'portal-direct-chat',
},
};
}
function normalizeUsageForBilling(usage, previousState) {
if (!usage) return null;
const input = Math.max(0, Number(usage.inputTokens ?? 0) || 0);
const output = Math.max(0, Number(usage.outputTokens ?? 0) || 0);
if (input <= 0 && output <= 0) return null;
return {
accumulatedInputTokens: Number(previousState?.lastInputTokens ?? 0) + input,
accumulatedOutputTokens: Number(previousState?.lastOutputTokens ?? 0) + output,
};
}
export function createDirectChatService({
userAuth,
llmProviderService,
sessionSnapshotService,
memoryV2 = null,
conversationMemoryService = null,
enabled = envFlag(process.env.MEMIND_DIRECT_CHAT_ENABLED, false),
} = {}) {
function getStatus() {
return {
enabled,
sessionPrefix: DIRECT_CHAT_SESSION_PREFIX,
};
}
function canHandle({ sessionId = null, toolMode = 'chat', userMessage } = {}) {
if (!enabled) return false;
if (toolMode !== 'chat') return false;
if (sessionId && !isDirectChatSessionId(sessionId)) return false;
if (!isTextOnlyUserMessage(userMessage)) return false;
return Boolean(userAuth && llmProviderService && sessionSnapshotService);
}
async function resolveMemories(userId, sessionId, query) {
if (!userId) return [];
if (memoryV2?.resolve) {
const result = await memoryV2.resolve({ userId, sessionId, query, limit: 40 }).catch(() => null);
return Array.isArray(result?.memories) ? result.memories : [];
}
if (conversationMemoryService?.listMemories) {
return conversationMemoryService.listMemories(userId, { limit: 40 }).catch(() => []);
}
return [];
}
async function run({ userId, sessionId = null, requestId, userMessage } = {}) {
if (!canHandle({ sessionId, toolMode: 'chat', userMessage })) {
const err = new Error('Direct chat is not available for this run');
err.code = 'DIRECT_CHAT_UNAVAILABLE';
err.retryable = false;
throw err;
}
const activeSessionId = sessionId || createDirectSessionId();
const snapshot = await sessionSnapshotService.get(activeSessionId).catch(() => null);
const previousMessages = Array.isArray(snapshot?.messages) ? snapshot.messages : [];
const memories = await resolveMemories(userId, activeSessionId, messageText(userMessage));
const completion = await llmProviderService.createChatCompletion({
messages: buildModelMessages({ previousMessages, userMessage, memories }),
});
if (!completion?.ok) {
const err = new Error(completion?.message ?? 'Portal 直连聊天失败');
err.code = 'DIRECT_CHAT_COMPLETION_FAILED';
throw err;
}
const now = new Date().toISOString();
const assistantMessage = buildAssistantMessage(completion.reply, { requestId, now });
const messages = [...previousMessages, userMessage, assistantMessage];
const session = {
id: activeSessionId,
name: snapshot?.session?.name ?? 'New Chat',
working_dir: snapshot?.session?.working_dir ?? '',
message_count: messages.length,
created_at: snapshot?.session?.created_at ?? now,
updated_at: now,
user_set_name: snapshot?.session?.user_set_name ?? false,
recipe: snapshot?.session?.recipe ?? null,
conversation: messages,
};
if (!sessionId) {
await userAuth.registerAgentSession(userId, activeSessionId, 'h5-direct');
}
await sessionSnapshotService.save(activeSessionId, userId, session, messages);
let billing = null;
const tokenState = normalizeUsageForBilling(
completion.usage,
userAuth.getBillingState ? await userAuth.getBillingState(activeSessionId).catch(() => null) : null,
);
if (tokenState && userAuth.billSessionUsage) {
billing = await userAuth.billSessionUsage(userId, activeSessionId, tokenState, requestId).catch((err) => ({
ok: false,
message: err instanceof Error ? err.message : String(err),
}));
}
return {
ok: true,
sessionId: activeSessionId,
messages,
session,
assistantMessage,
billing,
model: completion.model,
providerId: completion.providerId,
};
}
return {
getStatus,
canHandle,
run,
};
}
+186
View File
@@ -0,0 +1,186 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createDirectChatService,
isDirectChatSessionId,
sendDirectChatSessionEvents,
} from './direct-chat-service.mjs';
test('direct chat is disabled by default', () => {
const service = createDirectChatService({
userAuth: {},
llmProviderService: {},
sessionSnapshotService: {},
});
assert.equal(service.getStatus().enabled, false);
assert.equal(service.canHandle({
userMessage: { role: 'user', content: [{ type: 'text', text: 'hi' }] },
}), false);
});
test('direct chat creates a direct session, saves snapshot, and bills returned usage', async () => {
const registered = [];
const saved = [];
const billed = [];
const llmMessages = [];
const service = createDirectChatService({
enabled: true,
userAuth: {
async registerAgentSession(userId, sessionId, target) {
registered.push({ userId, sessionId, target });
},
async getBillingState() {
return { lastInputTokens: 10, lastOutputTokens: 5 };
},
async billSessionUsage(userId, sessionId, tokenState, requestId) {
billed.push({ userId, sessionId, tokenState, requestId });
return { ok: true, costCents: 1 };
},
},
llmProviderService: {
async createChatCompletion({ messages }) {
llmMessages.push(messages);
return {
ok: true,
providerId: 'custom_deepseek',
model: 'deepseek-chat',
reply: '你好,已收到。',
usage: { inputTokens: 3, outputTokens: 4 },
};
},
},
sessionSnapshotService: {
async get() {
return null;
},
async save(sessionId, userId, session, messages) {
saved.push({ sessionId, userId, session, messages });
},
},
memoryV2: {
async resolve() {
return { memories: [{ label: 'preference', text: '喜欢简洁回答' }] };
},
},
});
const result = await service.run({
userId: 'user-1',
requestId: 'req-1',
userMessage: {
id: 'msg-user-1',
role: 'user',
content: [{ type: 'text', text: '打个招呼' }],
metadata: { userVisible: true },
},
});
assert.equal(result.ok, true);
assert.equal(isDirectChatSessionId(result.sessionId), true);
assert.deepEqual(registered, [
{ userId: 'user-1', sessionId: result.sessionId, target: 'h5-direct' },
]);
assert.equal(saved.length, 1);
assert.equal(saved[0].messages.length, 2);
assert.equal(saved[0].messages[1].content[0].text, '你好,已收到。');
assert.match(llmMessages[0][0].content, /喜欢简洁回答/);
assert.deepEqual(billed, [{
userId: 'user-1',
sessionId: result.sessionId,
tokenState: {
accumulatedInputTokens: 13,
accumulatedOutputTokens: 9,
},
requestId: 'req-1',
}]);
});
test('direct chat run can be replayed through finite session events', async () => {
const snapshots = new Map();
const service = createDirectChatService({
enabled: true,
userAuth: {
async registerAgentSession() {},
async getBillingState() {
return null;
},
async billSessionUsage() {
return null;
},
},
llmProviderService: {
async createChatCompletion() {
return {
ok: true,
reply: '这是直连回复。',
usage: null,
};
},
},
sessionSnapshotService: {
async get(sessionId) {
return snapshots.get(sessionId) ?? null;
},
async save(sessionId, userId, session, messages) {
snapshots.set(sessionId, { session, messages, userId });
},
},
});
const result = await service.run({
userId: 'user-1',
requestId: 'req-e2e',
userMessage: {
id: 'user-e2e',
role: 'user',
content: [{ type: 'text', text: '讲个程序员笑话' }],
metadata: { userVisible: true },
},
});
const snapshot = snapshots.get(result.sessionId);
const res = {
statusCode: 200,
headers: {},
chunks: [],
ended: false,
writableEnded: false,
status(code) {
this.statusCode = code;
return this;
},
setHeader(key, value) {
this.headers[key] = value;
},
write(chunk) {
this.chunks.push(chunk);
},
end() {
this.ended = true;
this.writableEnded = true;
},
};
sendDirectChatSessionEvents({ once() {} }, res, snapshot);
const stream = res.chunks.join('');
assert.equal(res.ended, true);
assert.equal(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
assert.match(stream, /"type":"UpdateConversation"/);
assert.match(stream, /"type":"Finish"/);
assert.match(stream, /这是直连回复。/);
});
test('direct chat rejects non-text messages', () => {
const service = createDirectChatService({
enabled: true,
userAuth: {},
llmProviderService: {},
sessionSnapshotService: {},
});
assert.equal(service.canHandle({
userMessage: {
role: 'user',
content: [{ type: 'image_url', image_url: { url: 'x' } }],
},
}), false);
});
+101
View File
@@ -342,6 +342,51 @@ function profileFromRow(row, decryptRow) {
};
}
function openAiCompatibleApiUrlForProfile(profile) {
const apiUrl = profile.apiUrl || BUILTIN_PROVIDER_TEST_URLS[profile.providerId] || '';
return resolveChatCompletionsUrl(apiUrl);
}
function extractChatCompletionText(payload) {
const content = payload?.choices?.[0]?.message?.content;
if (Array.isArray(content)) {
return content
.map((item) => {
if (typeof item === 'string') return item;
if (item?.type === 'text') return item.text ?? '';
return '';
})
.join('')
.trim();
}
if (content != null) return String(content);
const fallback = payload?.message?.content ?? payload?.output_text ?? payload?.output;
return fallback == null ? '' : String(fallback);
}
function normalizeChatUsage(usage) {
if (!usage || typeof usage !== 'object') return null;
const inputTokens = Number(
usage.prompt_tokens ??
usage.input_tokens ??
usage.promptTokens ??
usage.inputTokens ??
0,
);
const outputTokens = Number(
usage.completion_tokens ??
usage.output_tokens ??
usage.completionTokens ??
usage.outputTokens ??
0,
);
return {
inputTokens: Number.isFinite(inputTokens) ? inputTokens : 0,
outputTokens: Number.isFinite(outputTokens) ? outputTokens : 0,
raw: usage,
};
}
function normalizeExecutor(raw) {
const executor = String(raw ?? '').trim().toLowerCase();
return executorById[executor] ? executor : null;
@@ -1386,6 +1431,62 @@ export function createLlmProviderService(
return buildExecutorLaunchPlan(runtime, options);
},
async createChatCompletion({ messages, model = null, temperature = 0.7 } = {}, fetchImpl = apiFetchImpl) {
const row = await getSelectedRow();
if (!row) return { ok: false, message: '未配置可用的聊天模型' };
const profile = profileFromRow(row, decryptRow);
const apiUrl = openAiCompatibleApiUrlForProfile(profile);
if (!apiUrl) {
return {
ok: false,
message: `${row.name ?? profile.providerId} 暂不支持 Portal 直连聊天`,
};
}
const selectedModel = String(model || row.default_model || profile.defaultModel || '').trim();
if (!selectedModel) return { ok: false, message: '未配置聊天模型名称' };
const upstream = await fetchImpl(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${profile.apiKey}`,
},
body: JSON.stringify({
model: selectedModel,
messages: Array.isArray(messages) ? messages : [],
stream: false,
temperature,
...(profile.relayProvider ? { provider: profile.relayProvider } : {}),
}),
dispatcher: apiUrl.startsWith('https://') ? insecureDispatcher : undefined,
});
const text = await upstream.text().catch(() => '');
if (!upstream.ok) {
return {
ok: false,
status: upstream.status,
message: text.slice(0, 500) || `聊天模型请求失败 (${upstream.status})`,
};
}
let payload;
try {
payload = JSON.parse(text);
} catch {
return { ok: false, message: '聊天模型响应不是 JSON' };
}
const reply = extractChatCompletionText(payload).trim();
if (!reply) return { ok: false, message: '聊天模型未返回文本内容' };
return {
ok: true,
providerKeyId: row.id,
providerId: profile.providerId,
providerKind: profile.providerKind,
model: selectedModel,
reply,
usage: normalizeChatUsage(payload?.usage),
raw: payload,
};
},
async listExecutorLaunchPlans(options = {}) {
const runtimes = await this.listExecutorRuntimeConfigs({
purpose: options.purpose ?? 'default',
+62
View File
@@ -331,6 +331,68 @@ test('testKey checks builtin DeepSeek through OpenAI compatible endpoint', async
assert.equal(calls[0].body.model, 'deepseek-chat');
});
test('createChatCompletion uses selected OpenAI-compatible provider', async () => {
const encrypted = encryptSecret('sk-direct-test', 'unit-test-secret');
const row = {
id: 'key-direct',
provider_id: CUSTOM_PROVIDER_ID,
provider_kind: 'custom',
api_url: 'http://127.0.0.1:19999/v1',
base_path: null,
models_json: JSON.stringify(['direct-model']),
goosed_provider_id: null,
engine: 'openai',
relay_provider: 'deepseek',
name: 'Direct Provider',
api_key_ciphertext: encrypted.ciphertext,
api_key_iv: encrypted.iv,
api_key_tag: encrypted.tag,
default_model: 'direct-model',
status: 'active',
is_selected: 1,
created_at: 1,
updated_at: 1,
};
const pool = {
async query(sql, params = []) {
if (sql.includes('WHERE is_selected = 1')) return [[row]];
throw new Error(`Unexpected SQL: ${sql} ${JSON.stringify(params)}`);
},
};
const calls = [];
const mockFetch = async (url, init) => {
calls.push({ url: String(url), init, body: JSON.parse(init.body) });
return {
ok: true,
text: async () => JSON.stringify({
choices: [{ message: { content: 'direct reply' } }],
usage: { prompt_tokens: 11, completion_tokens: 7 },
}),
};
};
const service = createLlmProviderService(pool, {
encryptionKey: 'unit-test-secret',
apiFetchImpl: mockFetch,
});
const result = await service.createChatCompletion({
messages: [{ role: 'user', content: 'hi' }],
});
assert.equal(result.ok, true);
assert.equal(result.reply, 'direct reply');
assert.equal(result.model, 'direct-model');
assert.deepEqual(result.usage, {
inputTokens: 11,
outputTokens: 7,
raw: { prompt_tokens: 11, completion_tokens: 7 },
});
assert.equal(calls[0].url, 'http://127.0.0.1:19999/v1/chat/completions');
assert.equal(calls[0].init.headers.Authorization, 'Bearer sk-direct-test');
assert.equal(calls[0].body.provider, 'deepseek');
assert.equal(calls[0].body.stream, false);
});
test('getExecutorRuntimeConfig resolves aider env from binding', async () => {
const encrypted = encryptSecret('sk-deepseek-test', 'unit-test-secret');
const keyRow = {
+1 -1
View File
@@ -44,7 +44,7 @@ test('normalizePublicAssetReferences rewrites absolute asset urls to same-origin
const html = [
'<img src="https://m.tkmind.cn/api/mindspace/v1/assets/asset-1/download?inline=1">',
'<img src="http://127.0.0.1:5173/api/mindspace/v1/assets/asset-2/download?inline=1&amp;v=3">',
'<meta property="og:image" content="https://mm.tkmind.cn/api/mindspace/v1/assets/asset-3/download?inline=1">',
'<meta property="og:image" content="https://m.tkmind.cn/api/mindspace/v1/assets/asset-3/download?inline=1">',
].join('');
const normalized = normalizePublicAssetReferences(html);
+6 -6
View File
@@ -51,7 +51,7 @@ test('handleMindSpaceLongImageDownload renders and downloads when requested', as
const handled = await handleMindSpaceLongImageDownload({
query: { download: 'long-image' },
filePath: '/tmp/report.html',
pageUrl: 'https://mm.tkmind.cn/MindSpace/u/public/report.html',
pageUrl: 'https://m.tkmind.cn/MindSpace/u/public/report.html',
res,
isLongImageDownloadRequest: () => true,
longImagePathForHtml: () => '/tmp/report.long.png',
@@ -62,7 +62,7 @@ test('handleMindSpaceLongImageDownload renders and downloads when requested', as
assert.equal(handled, true);
assert.deepEqual(calls[0], ['render', {
url: 'https://mm.tkmind.cn/MindSpace/u/public/report.html',
url: 'https://m.tkmind.cn/MindSpace/u/public/report.html',
outputPath: '/tmp/report.long.png',
}]);
assert.deepEqual(calls[1], ['set', 'Cache-Control', 'no-store']);
@@ -101,10 +101,10 @@ test('decorateMindSpacePublishedHtml returns decorated html and csp', () => {
html: `<html><body><script>${pageScript}</script>demo</body></html>`,
embed: false,
context: {
origin: 'https://mm.tkmind.cn',
pageUrl: 'https://mm.tkmind.cn/MindSpace/u/public/report.html',
pageDirUrl: 'https://mm.tkmind.cn/MindSpace/u/public/',
fallbackImageUrl: 'https://mm.tkmind.cn/MindSpace/u/public/report.thumbnail.png',
origin: 'https://m.tkmind.cn',
pageUrl: 'https://m.tkmind.cn/MindSpace/u/public/report.html',
pageDirUrl: 'https://m.tkmind.cn/MindSpace/u/public/',
fallbackImageUrl: 'https://m.tkmind.cn/MindSpace/u/public/report.thumbnail.png',
},
userAgent: 'MicroMessenger',
preparePublicationHtmlForEmbed: (value) => value,
+10 -10
View File
@@ -4,8 +4,8 @@ import { buildPublishedHtmlViewContext, resolvePublicRequestOrigin } from './min
test('resolvePublicRequestOrigin prefers https for public hosts and forwarded proto for local hosts', () => {
assert.equal(
resolvePublicRequestOrigin({ hostHeader: 'mm.tkmind.cn', forwardedProto: 'http', protocol: 'http' }),
'https://mm.tkmind.cn',
resolvePublicRequestOrigin({ hostHeader: 'm.tkmind.cn', forwardedProto: 'http', protocol: 'http' }),
'https://m.tkmind.cn',
);
assert.equal(
resolvePublicRequestOrigin({ hostHeader: '127.0.0.1:8081', forwardedProto: 'http', protocol: 'https' }),
@@ -15,25 +15,25 @@ test('resolvePublicRequestOrigin prefers https for public hosts and forwarded pr
test('buildPublishedHtmlViewContext builds file and directory page urls', () => {
const direct = buildPublishedHtmlViewContext({
origin: 'https://mm.tkmind.cn',
origin: 'https://m.tkmind.cn',
requestPath: '/MindSpace/user-1/public/report.html?x=1',
filePath: '/tmp/MindSpace/user-1/public/report.html',
});
assert.equal(direct.pageUrl, 'https://mm.tkmind.cn/MindSpace/user-1/public/report.html');
assert.equal(direct.pageDirUrl, 'https://mm.tkmind.cn/MindSpace/user-1/public/');
assert.equal(direct.pageUrl, 'https://m.tkmind.cn/MindSpace/user-1/public/report.html');
assert.equal(direct.pageDirUrl, 'https://m.tkmind.cn/MindSpace/user-1/public/');
const implicit = buildPublishedHtmlViewContext({
origin: 'https://mm.tkmind.cn',
origin: 'https://m.tkmind.cn',
requestPath: '/MindSpace/user-1/public/report',
filePath: '/tmp/MindSpace/user-1/public/index.html',
});
assert.equal(implicit.pageUrl, 'https://mm.tkmind.cn/MindSpace/user-1/public/report/');
assert.equal(implicit.pageDirUrl, 'https://mm.tkmind.cn/MindSpace/user-1/public/report/');
assert.equal(implicit.pageUrl, 'https://m.tkmind.cn/MindSpace/user-1/public/report/');
assert.equal(implicit.pageDirUrl, 'https://m.tkmind.cn/MindSpace/user-1/public/report/');
});
test('buildPublishedHtmlViewContext adds thumbnail png fallback when svg sibling exists', () => {
const context = buildPublishedHtmlViewContext({
origin: 'https://mm.tkmind.cn',
origin: 'https://m.tkmind.cn',
requestPath: '/MindSpace/user-1/public/report.html',
filePath: '/tmp/MindSpace/user-1/public/report.html',
fileExists: (value) => value.endsWith('.thumbnail.svg'),
@@ -41,6 +41,6 @@ test('buildPublishedHtmlViewContext adds thumbnail png fallback when svg sibling
});
assert.equal(
context.fallbackImageUrl,
'https://mm.tkmind.cn/MindSpace/user-1/public/report.thumbnail.png',
'https://m.tkmind.cn/MindSpace/user-1/public/report.thumbnail.png',
);
});
+1 -1
View File
@@ -51,7 +51,7 @@
"smoke:memory-v2-qdrant": "node scripts/smoke-memory-v2-qdrant.mjs",
"smoke:memory-v2-external": "node scripts/smoke-memory-v2-external.mjs",
"mock:memory-v2-services": "node scripts/mock-memory-v2-services.mjs",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
"test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs",
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
"verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs",
+9 -1
View File
@@ -13,6 +13,7 @@ const opsPort = Number(process.env.OPS_PORT ?? 3002);
const adminPort = Number(process.env.ADMIN_PORT ?? 8082);
const portalUrl = `http://127.0.0.1:${portalPort}`;
const adminUrl = `http://127.0.0.1:${adminPort}`;
const localMindSpacePublicBase = `http://127.0.0.1:${vitePort}`;
const plazaPublicBase = (
process.env.PLAZA_PUBLIC_BASE ?? `http://127.0.0.1:${plazaPort}`
).replace(/\/$/, '');
@@ -88,9 +89,15 @@ async function waitFor(url, check, label, retries = 60) {
throw new Error(`${label} 未在 ${url} 启动`);
}
const useConfiguredPublicBase = ['1', 'true', 'yes', 'on'].includes(
String(process.env.MEMIND_DEV_USE_PROD_PUBLIC_BASE ?? '').trim().toLowerCase(),
);
const mindSpacePublicBase = (
process.env.H5_PUBLIC_BASE_URL ?? `http://127.0.0.1:${vitePort}`
useConfiguredPublicBase && process.env.H5_PUBLIC_BASE_URL
? process.env.H5_PUBLIC_BASE_URL
: localMindSpacePublicBase
).replace(/\/$/, '');
process.env.H5_PUBLIC_BASE_URL = mindSpacePublicBase;
const viteEnv = {
VITE_PLAZA_BASE: plazaPublicBase,
@@ -151,6 +158,7 @@ try {
console.log('');
console.log('本地服务:');
console.log(` MindSpace UI http://127.0.0.1:${vitePort}/?preview=mindspace`);
console.log(` MindSpace URL ${mindSpacePublicBase}`);
console.log(` Ops 审核后台 http://127.0.0.1:${opsPort}/ops/`);
console.log(` API / Portal ${portalUrl}`);
console.log(` memind_adm ${adminUrl}`);
+25 -3
View File
@@ -164,6 +164,7 @@ import { createScheduleService } from './schedule-service.mjs';
import { createFeedbackService } from './user-feedback.mjs';
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
import { createDirectChatService, isDirectChatSessionId, sendDirectChatSessionEvents } from './direct-chat-service.mjs';
import { createSessionSnapshotService } from './session-snapshot.mjs';
import { createConversationMemoryService } from './conversation-memory.mjs';
import { createManagedMemoryV2Runtime } from './memory-v2-runtime.mjs';
@@ -285,6 +286,7 @@ let userAuth = null;
let tkmindProxy = null;
let agentRunGateway = null;
let toolGateway = null;
let directChatService = null;
let sessionSnapshotService = null;
let conversationMemoryService = null;
let memoryV2 = null;
@@ -530,6 +532,13 @@ async function bootstrapUserAuth() {
sessionSnapshotService = createSessionSnapshotService(pool, {
conversationMemoryService,
});
directChatService = createDirectChatService({
userAuth,
llmProviderService,
sessionSnapshotService,
memoryV2,
conversationMemoryService,
});
tkmindProxy = createTkmindProxy({
apiTarget: API_TARGET,
apiTargets: API_TARGETS,
@@ -554,6 +563,7 @@ async function bootstrapUserAuth() {
userAuth,
tkmindProxy,
toolGateway,
directChatService,
autoDispatch: ['1', 'true', 'yes', 'on'].includes(
String(process.env.MEMIND_AGENT_RUN_AUTODISPATCH ?? '1').trim().toLowerCase(),
),
@@ -4450,7 +4460,7 @@ api.get('/sessions/:sessionId', async (req, res, next) => {
const canUseSnapshotCache = hintMc != null && hintUa != null;
const mcMatch = snapshot.meta.synced_msg_count === hintMc;
const uaMatch = snapshot.meta.source_updated_at === hintUa;
if (canUseSnapshotCache && mcMatch && uaMatch) {
if (isDirectChatSessionId(sessionId) || (canUseSnapshotCache && mcMatch && uaMatch)) {
const sanitizedMessages = sanitizeSessionConversationPublicHtmlLinks(
snapshot.messages,
req.currentUser,
@@ -4507,12 +4517,17 @@ api.get('/sessions/:sessionId', async (req, res, next) => {
api.delete('/sessions/:sessionId', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
const sessionId = req.params.sessionId;
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
const sessionId = req.params.sessionId;
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
try {
if (isDirectChatSessionId(sessionId)) {
await userAuth.unregisterAgentSession(req.currentUser.id, sessionId);
void sessionSnapshotService?.remove(sessionId).catch(() => {});
return res.status(204).end();
}
const deleteTarget = await tkmindProxy.resolveTarget(sessionId);
const upstream = await tkmindProxy.apiFetchTo(deleteTarget, `/sessions/${encodeURIComponent(sessionId)}`, {
method: 'DELETE',
@@ -4538,6 +4553,13 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
if (isDirectChatSessionId(sessionId)) {
const snapshot = await sessionSnapshotService?.get(sessionId).catch(() => null);
if (!snapshot) {
return res.status(404).json({ message: '会话不存在' });
}
return sendDirectChatSessionEvents(req, res, snapshot);
}
const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: req.currentUser.id });
const syncPublicHtmlDuringStream = (event) => {
materializePublicHtmlWritesFromSessionEvent(event, { publishDir });
+4
View File
@@ -747,6 +747,10 @@ export function useTKMindChat(
setChatState('idle');
setPendingTool(null);
activeRequestId.current = null;
if (sessionId.startsWith('h5direct_')) {
unsubscribeRef.current?.();
unsubscribeRef.current = null;
}
setSessions((prev) => touchSession(prev, sessionId, 0));
if (userRef.current && onUserUpdateRef.current) {
void getMe()
+32 -5
View File
@@ -15,6 +15,7 @@ import {
import { buildCurrentTimeAgentPrefix, buildTaskRoutingAgentText } from './user-memory-profile.mjs';
import { reconcileAgentSession } from './session-reconcile.mjs';
import { createImgproxySigner } from './imgproxy-signer.mjs';
import { isDirectChatSessionId } from './direct-chat-service.mjs';
const insecureDispatcher = new Agent({
connect: { rejectUnauthorized: false },
@@ -55,12 +56,22 @@ function extractPublicImageStorageKey(rawUrl) {
return null;
}
function buildPublicStandardImageUrl(rawUrl, userId) {
function resolvePublicBaseUrlFromLayout(publishLayout) {
const publicUrl = String(publishLayout?.publicUrl ?? '').trim();
if (!publicUrl) return resolvePublicBaseUrl();
try {
return new URL(publicUrl).origin;
} catch {
return resolvePublicBaseUrl();
}
}
function buildPublicStandardImageUrl(rawUrl, userId, publishLayout = null) {
const storageKey = extractPublicImageStorageKey(rawUrl);
if (!storageKey) return null;
const match = storageKey.match(PUBLIC_IMAGE_STORAGE_KEY_PATTERN);
if (!match || match[1] !== String(userId ?? '')) return null;
return buildPublicUrl(resolvePublicBaseUrl(), userId, `public/images/${match[2]}`);
return buildPublicUrl(resolvePublicBaseUrlFromLayout(publishLayout), userId, `public/images/${match[2]}`);
}
async function apiFetch(target, apiSecret, pathname, init = {}) {
@@ -491,7 +502,7 @@ function sanitizeOwnPublicHtmlUrl(publicUrl, owner, rawRelativePath, currentUser
}
return {
ok: true,
url: canonicalizeStaticPageUrl(publicUrl, relativePath, canonicalRelativePath),
url: buildPublicUrl(resolvePublicBaseUrl(), owner, canonicalRelativePath),
};
}
@@ -698,6 +709,7 @@ export async function buildVisionPayload({
localFetchAsset,
llmProviderService,
imgproxySigner = null,
fetchImpl = undiciFetch,
}) {
void imgproxySigner;
if (!llmProviderService || !userId) return null;
@@ -730,7 +742,7 @@ export async function buildVisionPayload({
return rawUrl;
})();
try {
const response = await undiciFetch(fetchableUrl);
const response = await fetchImpl(fetchableUrl);
if (!response.ok) {
throw new Error(`Vision fetch failed: ${response.status}`);
}
@@ -752,7 +764,7 @@ export async function buildVisionPayload({
const parsed = new URL(rawUrl);
relativePath = parsed.pathname + parsed.search;
} catch { /* keep rawUrl */ }
const publicStandardUrl = buildPublicStandardImageUrl(rawUrl, userId);
const publicStandardUrl = buildPublicStandardImageUrl(rawUrl, userId, publishLayout);
imageItems.push({
mimeType,
data: buffer.toString('base64'),
@@ -1583,6 +1595,21 @@ export function createTkmindProxy({
res.setHeader('X-TKMind-Degraded', '1');
}
const directSessionIds = [...owned].filter(isDirectChatSessionId);
if (directSessionIds.length > 0 && sessionSnapshotService?.isEnabled?.()) {
for (const sessionId of directSessionIds) {
if (sessionsById.has(sessionId)) continue;
const snapshot = await sessionSnapshotService.get(sessionId).catch(() => null);
if (!snapshot?.session) continue;
sessionsById.set(sessionId, {
...snapshot.session,
message_count: snapshot.meta?.synced_msg_count ?? snapshot.messages?.length ?? 0,
updated_at: snapshot.meta?.source_updated_at ?? snapshot.session.updated_at,
conversation: undefined,
});
}
}
const sessions = [...sessionsById.values()].sort(sortSessionsByRecent);
await enrichSessionHistory(sessions);
if (typeof userAuth.getSessionOrigins === 'function' && sessions.length > 0) {
+31 -2
View File
@@ -189,6 +189,30 @@ test('sanitizePublicHtmlLinksInText keeps existing own public html links', () =>
}
});
test('sanitizePublicHtmlLinksInText canonicalizes wrong MindSpace public hosts', () => {
const owner = `test-user-${Date.now()}-canonical-host`;
const previousBase = process.env.H5_PUBLIC_BASE_URL;
process.env.H5_PUBLIC_BASE_URL = 'http://127.0.0.1:5173';
const htmlPath = path.join(process.cwd(), 'MindSpace', owner, 'public', 'MIT-guide.html');
try {
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
fs.writeFileSync(htmlPath, '<!doctype html><title>MIT</title>');
const text =
`[下载页面](https://mindspace.tkmind.com/MindSpace/${owner}/public/MIT-guide.html)`;
const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john' });
assert.doesNotMatch(next, /mindspace\.tkmind\.com/);
assert.match(
next,
new RegExp(`http://127\\.0\\.0\\.1:5173/MindSpace/${owner}/public/MIT-guide\\.html`),
);
assert.doesNotMatch(next, /页面生成未完成/);
} finally {
if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL;
else process.env.H5_PUBLIC_BASE_URL = previousBase;
fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true });
}
});
test('sanitizeSessionConversationPublicHtmlLinks updates text items inside conversation messages', () => {
const owner = `test-user-${Date.now()}-conversation`;
const publicRoot = path.join(process.cwd(), 'MindSpace', owner, 'public');
@@ -261,7 +285,7 @@ test('sanitizeSessionConversationPublicHtmlLinks removes internal user prompt no
test('buildVisionPayload injects public standard image urls for page generation', async () => {
const payload = await buildVisionPayload({
userId: 'user-1',
publishLayout: { publicUrl: 'https://mm.tkmind.cn/MindSpace/user-1/' },
publishLayout: { publicUrl: 'https://m.tkmind.cn/MindSpace/user-1/' },
userMessage: {
content: [
{
@@ -273,10 +297,15 @@ test('buildVisionPayload injects public standard image urls for page generation'
llmProviderService: {
analyzeImagesWithVision: async () => '图片里是一位成年人。',
},
fetchImpl: async () => ({
ok: true,
arrayBuffer: async () => Buffer.from('fake-image'),
headers: new Map([['content-type', 'image/jpeg']]),
}),
});
const text = payload?.userMessage?.content?.[0]?.text ?? '';
assert.match(text, /https:\/\/mm\.tkmind\.cn\/MindSpace\/user-1\/public\/images\/2026-06-29\/hero\.jpg/);
assert.match(text, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/images\/2026-06-29\/hero\.jpg/);
assert.doesNotMatch(text, /plain\/local:\/\//);
assert.match(text, /无需 cookie 的公开压缩标准图片/);
assert.match(text, /不得改写图片里人物的年龄、性别、人数或主体关系/);
+1
View File
@@ -2903,6 +2903,7 @@ export function createUserAuth(pool, options = {}) {
updateUser,
purchaseSpaceQuota,
recharge,
getBillingState,
billSessionUsage,
listUsageRecords,
listBillingLedger,