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: `