diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index ccce195..11f48b0 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -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, }; } diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index e8f71ef..1d5864b 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -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 = []; diff --git a/conversation-memory.mjs b/conversation-memory.mjs index 6410b35..3dd5cb8 100644 --- a/conversation-memory.mjs +++ b/conversation-memory.mjs @@ -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); diff --git a/conversation-memory.test.mjs b/conversation-memory.test.mjs index 927a0e2..23b23d4 100644 --- a/conversation-memory.test.mjs +++ b/conversation-memory.test.mjs @@ -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'; diff --git a/direct-chat-service.mjs b/direct-chat-service.mjs new file mode 100644 index 0000000..7a2c679 --- /dev/null +++ b/direct-chat-service.mjs @@ -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, + }; +} diff --git a/direct-chat-service.test.mjs b/direct-chat-service.test.mjs new file mode 100644 index 0000000..fb9fa41 --- /dev/null +++ b/direct-chat-service.test.mjs @@ -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); +}); diff --git a/llm-providers.mjs b/llm-providers.mjs index 317d196..e0d984f 100644 --- a/llm-providers.mjs +++ b/llm-providers.mjs @@ -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', diff --git a/llm-providers.test.mjs b/llm-providers.test.mjs index ace745d..a12de9d 100644 --- a/llm-providers.test.mjs +++ b/llm-providers.test.mjs @@ -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 = { diff --git a/mindspace-public-asset-token.test.mjs b/mindspace-public-asset-token.test.mjs index 578bfe8..e828e63 100644 --- a/mindspace-public-asset-token.test.mjs +++ b/mindspace-public-asset-token.test.mjs @@ -44,7 +44,7 @@ test('normalizePublicAssetReferences rewrites absolute asset urls to same-origin const html = [ '', '', - '', + '', ].join(''); const normalized = normalizePublicAssetReferences(html); diff --git a/mindspace-public-delivery.test.mjs b/mindspace-public-delivery.test.mjs index 41a745d..2b7a57e 100644 --- a/mindspace-public-delivery.test.mjs +++ b/mindspace-public-delivery.test.mjs @@ -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: `demo`, 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, diff --git a/mindspace-public-page-context.test.mjs b/mindspace-public-page-context.test.mjs index 12c6cd8..6cd39c8 100644 --- a/mindspace-public-page-context.test.mjs +++ b/mindspace-public-page-context.test.mjs @@ -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', ); }); diff --git a/package.json b/package.json index cd5e102..63a3b5f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/dev-core.mjs b/scripts/dev-core.mjs index e710cd4..97a32ec 100644 --- a/scripts/dev-core.mjs +++ b/scripts/dev-core.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}`); diff --git a/server.mjs b/server.mjs index da2642a..ae5a54e 100644 --- a/server.mjs +++ b/server.mjs @@ -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 }); diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index 8bcf742..ef8e906 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -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() diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index 07caf80..2ca432a 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -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) { diff --git a/tkmind-proxy.test.mjs b/tkmind-proxy.test.mjs index f0e6e16..26732af 100644 --- a/tkmind-proxy.test.mjs +++ b/tkmind-proxy.test.mjs @@ -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, 'MIT'); + 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, /不得改写图片里人物的年龄、性别、人数或主体关系/); diff --git a/user-auth.mjs b/user-auth.mjs index 5bb7ee7..423047b 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -2903,6 +2903,7 @@ export function createUserAuth(pool, options = {}) { updateUser, purchaseSpaceQuota, recharge, + getBillingState, billSessionUsage, listUsageRecords, listBillingLedger,