feat: split h5 direct chat from goosed tasks
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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&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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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
@@ -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, /不得改写图片里人物的年龄、性别、人数或主体关系/);
|
||||
|
||||
@@ -2903,6 +2903,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
updateUser,
|
||||
purchaseSpaceQuota,
|
||||
recharge,
|
||||
getBillingState,
|
||||
billSessionUsage,
|
||||
listUsageRecords,
|
||||
listBillingLedger,
|
||||
|
||||
Reference in New Issue
Block a user