Files
memind/tkmind-proxy.mjs
T
john 25f8223253 refactor: Business logic and dependencies updates
- 核心服务代码更新 (db, server, auth, proxy)
- Agent 相关模块更新 (mindspace, experience)
- 前端组件和 hooks 更新
- 数据库 schema 更新
- 依赖版本更新
2026-06-27 08:25:02 +08:00

873 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Readable } from 'node:stream';
import { Agent, fetch as undiciFetch } from 'undici';
import { appendBalanceEvent, createSseBillingTransform } from './sse-billing.mjs';
import { developerToolsFromPolicy } from './capabilities.mjs';
import { evaluateProxyRequest, isNativeH5ApiPath } from './policies.mjs';
import { buildSandboxSessionConstraints } from './user-publish.mjs';
import { buildTaskRoutingAgentText } from './user-memory-profile.mjs';
import { reconcileAgentSession } from './session-reconcile.mjs';
import { createImgproxySigner } from './imgproxy-signer.mjs';
const insecureDispatcher = new Agent({
connect: { rejectUnauthorized: false },
});
function isHttpsTarget(target) {
return target.startsWith('https://');
}
function sanitizeUserFacingProxyMessage(message, fallback = '后端连接失败,请稍后重试') {
const normalized = String(message ?? '').trim();
if (!normalized) return fallback;
if (!/goose|goosed/i.test(normalized)) return normalized;
if (/超时|timeout/i.test(normalized)) {
return '后端连接超时,请确认后端服务正常后重试';
}
if (/不可用|连接失败|failed to fetch|networkerror|fetch failed|upstream|econn|enotfound/i.test(normalized)) {
return fallback;
}
return normalized
.replace(/\bgoosed\b/gi, '后端服务')
.replace(/\bgoose\b/gi, '后端');
}
async function apiFetch(target, apiSecret, pathname, init = {}) {
const url = new URL(pathname, target);
const headers = {
...(init.headers ?? {}),
'X-Secret-Key': apiSecret,
};
if (init.body && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
return undiciFetch(url, {
...init,
headers,
dispatcher: isHttpsTarget(target) ? insecureDispatcher : undefined,
});
}
async function readJsonBody(req) {
if (req.method === 'GET' || req.method === 'HEAD') return null;
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
if (chunks.length === 0) return null;
const raw = Buffer.concat(chunks).toString('utf8');
if (!raw.trim()) return null;
return JSON.parse(raw);
}
function sendProxyResponse(res, upstream) {
res.status(upstream.status);
upstream.headers.forEach((value, key) => {
if (key === 'transfer-encoding') return;
res.setHeader(key, value);
});
if (!upstream.body) {
res.end();
return;
}
Readable.fromWeb(upstream.body).pipe(res);
}
function extractSessionId(req, body) {
const fromParams = req.params?.sessionId ?? req.params?.id;
if (fromParams) return fromParams;
if (body?.session_id) return body.session_id;
if (body?.sessionId) return body.sessionId;
return null;
}
function firstUserText(message) {
return (
message?.content?.find?.((item) => item?.type === 'text' && typeof item.text === 'string')?.text ??
''
);
}
const IMAGE_URL_LINE_RE = /^\[图片\d+]:\s*(\S.+)$/;
export function extractImageUrlsFromMessage(userMessage) {
const urls = [];
const imageUrls = userMessage?.metadata?.imageUrls;
if (Array.isArray(imageUrls)) {
urls.push(...imageUrls);
}
const content = userMessage?.content;
if (Array.isArray(content)) {
for (const item of content) {
if (item?.type === 'image_url' && item.image_url?.url) {
urls.push(item.image_url.url);
continue;
}
if (item?.type !== 'text' || typeof item.text !== 'string') continue;
for (const line of item.text.split('\n')) {
const match = line.trim().match(IMAGE_URL_LINE_RE);
if (match?.[1]) urls.push(match[1].trim());
}
}
}
return [...new Set(urls.filter((url) => typeof url === 'string' && url.trim()))];
}
function messageHasImages(userMessage) {
return extractImageUrlsFromMessage(userMessage).length > 0;
}
function injectTaskRoutingHint(body, sessionPolicy) {
const originalText = firstUserText(body?.user_message);
if (!originalText?.trim()) return body;
const agentText = buildTaskRoutingAgentText(originalText, sessionPolicy);
if (!agentText || agentText === originalText) return body;
const userMessage = body.user_message ?? {};
const content = Array.isArray(userMessage.content) ? [...userMessage.content] : [];
const firstTextIndex = content.findIndex(
(item) => item?.type === 'text' && typeof item.text === 'string',
);
if (firstTextIndex >= 0) {
content[firstTextIndex] = { ...content[firstTextIndex], text: agentText };
} else {
content.unshift({ type: 'text', text: agentText });
}
return {
...body,
user_message: {
...userMessage,
content,
metadata: {
...(userMessage.metadata ?? {}),
displayText:
userMessage.metadata?.displayText && String(userMessage.metadata.displayText).trim()
? userMessage.metadata.displayText
: originalText,
},
},
};
}
export async function buildVisionPayload({
userMessage,
userId,
publishLayout,
localFetchAsset,
llmProviderService,
imgproxySigner = null,
}) {
if (!localFetchAsset || !llmProviderService || !userId) return null;
const rawImageUrls = extractImageUrlsFromMessage(userMessage);
if (rawImageUrls.length === 0) return null;
const imageItems = [];
for (const rawUrl of rawImageUrls) {
try {
const match = rawUrl.match(/\/mindspace\/v1\/assets\/([^/?#]+)(?:\/download)?/);
if (!match) continue;
const assetId = decodeURIComponent(match[1]);
let buffer;
let mimeType = 'image/jpeg';
if (imgproxySigner) {
try {
const visionUrl = imgproxySigner.buildUrl(
process.env.IMGPROXY_BASE_URL || 'https://img.tkmind.cn',
`users/${userId}/assets/${assetId}`,
'vision'
);
const visionResponse = await undiciFetch(visionUrl);
if (visionResponse.ok) {
buffer = Buffer.from(await visionResponse.arrayBuffer());
mimeType = visionResponse.headers.get('content-type') || 'image/jpeg';
} else {
throw new Error(`Vision fetch failed: ${visionResponse.status}`);
}
} catch (visionErr) {
console.warn(`Vision fetch fallback for asset ${assetId}:`, visionErr instanceof Error ? visionErr.message : visionErr);
const asset = await localFetchAsset(userId, assetId);
buffer = asset.buffer;
mimeType = asset.mimeType;
}
} else {
const asset = await localFetchAsset(userId, assetId);
buffer = asset.buffer;
mimeType = asset.mimeType;
}
let relativePath = rawUrl;
try {
const parsed = new URL(rawUrl);
relativePath = parsed.pathname + parsed.search;
} catch { /* keep rawUrl */ }
imageItems.push({ mimeType, data: buffer.toString('base64'), relativePath, rawUrl });
} catch (err) {
console.warn('Vision image fetch skipped:', err instanceof Error ? err.message : err);
}
}
if (imageItems.length === 0) return null;
const visionDescription = await llmProviderService
.analyzeImagesWithVision(imageItems, '请详细描述图片的视觉内容:主体、颜色、风格、构图,不要生成代码或页面方案')
.catch(() => null);
const pathList = imageItems
.map((item, i) => `图片${i + 1}: <img src="${item.relativePath}" alt="图片${i + 1}">`)
.join(' ');
const publicUrlPrefix = publishLayout?.publicUrl
? `${String(publishLayout.publicUrl).replace(/\/$/, '')}/public/`
: null;
const injectedNote =
'\n\n【TKMind 图片分析结果 — 仅供执行参考,不要向用户复述此段内容】\n' +
(visionDescription
? `Qwen VL 图片描述:\n${visionDescription}\n\n`
: '') +
`图片 HTML 嵌入路径(直接写入 <img> 标签,浏览器有 cookie 可直接加载,无需 fetch):\n${pathList}\n` +
'执行要求:必须先调用 load_skill → static-page-publish(每次生成页面都要调用,不可省略),' +
'再用 write_file 写入 public/页面.html' +
(publicUrlPrefix
? `完成后向用户给出 Markdown 可点击链接,格式:[页面标题](${publicUrlPrefix}<文件名>.html)`
: '完成后按技能说明里的链接格式给用户一个 Markdown 可点击链接;') +
'不要向用户展示 HTML 代码块。';
let updatedContent = Array.isArray(userMessage?.content) ? [...userMessage.content] : [];
for (const item of imageItems) {
updatedContent = updatedContent.map((c) => {
if (c?.type !== 'text' || typeof c.text !== 'string') return c;
const updated = c.text.replaceAll(item.rawUrl, item.relativePath);
return updated === c.text ? c : { ...c, text: updated };
});
}
const lastTextIdx = updatedContent.reduceRight(
(found, item, i) => (found === -1 && item?.type === 'text' ? i : found),
-1,
);
if (lastTextIdx >= 0) {
updatedContent[lastTextIdx] = {
...updatedContent[lastTextIdx],
text: updatedContent[lastTextIdx].text + injectedNote,
};
} else {
updatedContent.push({ type: 'text', text: injectedNote });
}
return {
userMessage: { ...userMessage, content: updatedContent },
billableImageCount: visionDescription ? 1 : 0,
};
}
export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth, llmProviderService, localFetchAsset, subscriptionService }) {
const targets = apiTargets?.length ? apiTargets : apiTarget ? [apiTarget] : [];
const primaryTarget = targets[0] ?? apiTarget ?? '';
let rrIdx = 0;
let imgproxySigner = null;
if (process.env.IMGPROXY_BASE_URL && process.env.IMGPROXY_SIGNING_KEY && process.env.IMGPROXY_SIGNING_SALT) {
try {
imgproxySigner = createImgproxySigner(
process.env.IMGPROXY_SIGNING_KEY,
process.env.IMGPROXY_SIGNING_SALT,
);
console.log('[TKMindProxy] imgproxy signer initialized');
} catch (err) {
console.warn('[TKMindProxy] imgproxy signer init failed:', err instanceof Error ? err.message : err);
}
}
async function targetHealthy(target) {
try {
const upstream = await apiFetch(target, apiSecret, '/status', {
method: 'GET',
signal: AbortSignal.timeout(1500),
});
return upstream.ok;
} catch {
return false;
}
}
async function pickTarget() {
if (targets.length <= 1) return primaryTarget;
for (let i = 0; i < targets.length; i += 1) {
const target = targets[rrIdx];
rrIdx = (rrIdx + 1) % targets.length;
if (await targetHealthy(target)) return target;
}
return primaryTarget;
}
async function resolveTarget(sessionId) {
if (targets.length <= 1 || !sessionId) return primaryTarget;
try {
const { target, node } = await userAuth.getSessionTarget(sessionId);
// Prefer the pinned URL: stable across reordering/resizing the target list.
// Only honor it if that upstream is still configured; otherwise fall back to
// the legacy integer index, then to primary.
if (target && targets.includes(target)) return target;
return targets[node] ?? primaryTarget;
} catch {
return primaryTarget;
}
}
async function applySessionLlmProvider(sessionId) {
if (!llmProviderService || !sessionId) return null;
try {
const target = await resolveTarget(sessionId);
return await llmProviderService.applyBestProviderForSession(
sessionId,
(url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init),
);
} catch (err) {
console.warn(
'LLM provider apply skipped:',
err instanceof Error ? err.message : err,
);
return null;
}
}
async function applyLocalFallbackForSession(sessionId) {
if (!llmProviderService || !sessionId) return null;
const target = await resolveTarget(sessionId);
return llmProviderService.applyLocalFallbackForSession(
sessionId,
(url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init),
);
}
// Tracks which sessions are currently using the vision provider so we only
// issue a switch-back call when the session was actually on vision.
const visionActiveSessions = new Set();
async function applyVisionProviderForSession(sessionId) {
if (!llmProviderService || !sessionId) return null;
const target = await resolveTarget(sessionId);
return llmProviderService.applyVisionProviderForSession(
sessionId,
(url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init),
);
}
// Two-step vision approach:
// Step 1 — Call Qwen VL directly (not through Goose) to analyze the image.
// Step 2 — Inject Qwen's text description + server-relative image paths into the
// user_message that goes to DeepSeek via Goose. DeepSeek retains full
// tool-calling capability (write_file, etc.) and creates the page properly.
async function buildVisionBody(userMessage, userId, publishLayout) {
return buildVisionPayload({
userMessage,
userId,
publishLayout,
localFetchAsset,
llmProviderService,
imgproxySigner,
});
}
async function reconcileSessionPolicyForUser(userId, sessionId) {
if (!userId || !sessionId) return;
const target = await resolveTarget(sessionId);
const workingDir = await userAuth.resolveWorkingDir(userId);
const sessionPolicy = await userAuth.getAgentSessionPolicy(userId);
const publishLayout = await userAuth.getUserPublishLayout(userId);
await reconcileAgentSession(
(pathname, init) => apiFetch(target, apiSecret, pathname, init),
sessionId,
{
workingDir,
sessionPolicy,
sandboxConstraints: publishLayout?.constraints ?? null,
tolerateInvalidWorkingDir: true,
userContext: publishLayout
? {
userId,
displayName: publishLayout.displayName,
username: publishLayout.username,
slug: publishLayout.slug,
}
: null,
},
);
}
const requireUser = async (req, res, next) => {
try {
const session = req.userSession;
if (!session) {
res.status(401).json({ message: '未登录' });
return;
}
const me = await userAuth.getMe(req.userToken);
if (!me) {
res.status(401).json({ message: '登录已过期' });
return;
}
req.currentUser = me;
next();
} catch (err) {
res.status(500).json({ message: err instanceof Error ? err.message : '认证失败' });
}
};
const ensureChatAllowed = async (req, res, next) => {
const gate = await userAuth.canUseChat(req.currentUser.id);
if (!gate.ok) {
res.status(402).json({
message: gate.message,
code: gate.code,
balanceCents: gate.balanceCents,
minRechargeCents: gate.minRechargeCents,
suggestedTiers: gate.suggestedTiers,
});
return;
}
next();
};
const handlers = {
'POST /agent/start': [
requireUser,
ensureChatAllowed,
async (req, res) => {
try {
const workingDir = await userAuth.resolveWorkingDir(req.currentUser.id);
const sessionPolicy = await userAuth.getAgentSessionPolicy(req.currentUser.id);
const startTarget = await pickTarget();
const upstream = await apiFetch(startTarget, apiSecret, '/agent/start', {
method: 'POST',
body: JSON.stringify({
working_dir: workingDir,
enable_context_memory: sessionPolicy.enableContextMemory,
...(sessionPolicy.extensionOverrides
? { extension_overrides: sessionPolicy.extensionOverrides }
: {}),
...(req.body?.recipe ? { recipe: req.body.recipe } : {}),
}),
});
const text = await upstream.text();
if (!upstream.ok) {
res.status(upstream.status).send(text);
return;
}
const session = JSON.parse(text);
if (session?.id) {
await userAuth.registerAgentSession(
req.currentUser.id,
session.id,
startTarget,
);
if (sessionPolicy.gooseMode) {
const modeRes = await apiFetch(startTarget, apiSecret, '/agent/update_session', {
method: 'POST',
body: JSON.stringify({
session_id: session.id,
goose_mode: sessionPolicy.gooseMode,
}),
});
if (!modeRes.ok) {
const modeText = await modeRes.text().catch(() => '');
res.status(modeRes.status).send(modeText || '设置会话模式失败');
return;
}
}
const publishLayout = await userAuth.getUserPublishLayout(req.currentUser.id);
const api = (pathname, init) => apiFetch(startTarget, apiSecret, pathname, init);
try {
await reconcileAgentSession(api, session.id, {
workingDir,
sessionPolicy,
sandboxConstraints: publishLayout?.constraints ?? null,
userContext: publishLayout
? {
userId: req.currentUser.id,
displayName: publishLayout.displayName,
username: publishLayout.username,
slug: publishLayout.slug,
}
: null,
});
} catch (reconcileErr) {
res.status(500).json({
message:
reconcileErr instanceof Error
? `会话策略同步失败:${reconcileErr.message}`
: '会话策略同步失败',
});
return;
}
await applySessionLlmProvider(session.id);
}
res.status(upstream.status).json(session);
} catch (err) {
res.status(500).json({ message: err instanceof Error ? err.message : '启动会话失败' });
}
},
],
'POST /agent/resume': [
requireUser,
ensureChatAllowed,
async (req, res) => {
try {
const sessionId = req.body?.session_id;
if (!sessionId) {
res.status(400).json({ message: '缺少 session_id' });
return;
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
res.status(403).json({ message: '无权访问该会话' });
return;
}
const resumeTarget = await resolveTarget(sessionId);
const upstream = await apiFetch(resumeTarget, apiSecret, '/agent/resume', {
method: 'POST',
body: JSON.stringify(req.body ?? {}),
});
const text = await upstream.text();
if (!upstream.ok) {
res.status(upstream.status).send(text);
return;
}
const payload = JSON.parse(text);
const skipReconcile = req.body?.skip_reconcile === true;
if (!skipReconcile) {
const workingDir = await userAuth.resolveWorkingDir(req.currentUser.id);
const sessionPolicy = await userAuth.getAgentSessionPolicy(req.currentUser.id);
const publishLayout = await userAuth.getUserPublishLayout(req.currentUser.id);
try {
await reconcileAgentSession(
(pathname, init) => apiFetch(resumeTarget, apiSecret, pathname, init),
sessionId,
{
workingDir,
sessionPolicy,
sandboxConstraints: publishLayout?.constraints ?? null,
tolerateInvalidWorkingDir: true,
userContext: publishLayout
? {
userId: req.currentUser.id,
displayName: publishLayout.displayName,
username: publishLayout.username,
slug: publishLayout.slug,
}
: null,
},
);
} catch (reconcileErr) {
res.status(500).json({
message:
reconcileErr instanceof Error
? `会话恢复后策略同步失败:${reconcileErr.message}`
: '会话恢复后策略同步失败',
});
return;
}
}
await applySessionLlmProvider(sessionId);
res.status(upstream.status).json(payload);
} catch (err) {
res.status(500).json({
message: sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : '恢复会话失败',
'恢复会话失败',
),
});
}
},
],
'GET /sessions': [
requireUser,
async (req, res) => {
try {
const owned = await userAuth.listOwnedSessionIds(req.currentUser.id);
const sessionsById = new Map();
let healthyTargets = 0;
let lastFailure = null;
for (const target of targets) {
try {
const upstream = await apiFetch(target, apiSecret, '/sessions', {
method: 'GET',
signal: AbortSignal.timeout(3000),
});
const text = await upstream.text();
if (!upstream.ok) {
lastFailure = text || `upstream ${upstream.status}`;
continue;
}
healthyTargets += 1;
const payload = JSON.parse(text);
for (const item of payload.sessions ?? []) {
if (owned.has(item.id)) sessionsById.set(item.id, item);
}
} catch (err) {
lastFailure = sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : '读取会话失败',
'读取会话失败',
);
}
}
if (healthyTargets === 0) {
res.status(502).json({ message: lastFailure ?? '后端连接失败,请稍后重试' });
return;
}
if (healthyTargets < targets.length) {
res.setHeader('X-TKMind-Degraded', '1');
}
res.json({ sessions: [...sessionsById.values()] });
} catch (err) {
res.status(500).json({
message: sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : '读取会话失败',
'读取会话失败',
),
});
}
},
],
};
const sessionScoped = (build) => [
requireUser,
async (req, res, next) => {
try {
const body = req.body ?? (await readJsonBody(req));
req.body = body;
const sessionId = extractSessionId(req, body);
if (!sessionId) {
res.status(400).json({ message: '缺少 session_id' });
return;
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
res.status(403).json({ message: '无权访问该会话' });
return;
}
req.agentSessionId = sessionId;
await build(req, res, next);
} catch (err) {
res.status(500).json({
message: sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : '请求失败',
'请求失败',
),
});
}
},
];
const proxySessionEvents = async (req, res, sessionId, { onAfterFinish } = {}) => {
try {
const pathname = `/sessions/${sessionId}/events`;
const sessionTarget = await resolveTarget(sessionId);
const upstream = await apiFetch(sessionTarget, apiSecret, pathname, {
method: 'GET',
headers: {
Accept: 'text/event-stream',
'Last-Event-ID': req.get('last-event-id') ?? '',
},
});
if (!upstream.ok || !upstream.body) {
const text = await upstream.text().catch(() => '');
res.status(upstream.status).send(text);
return;
}
res.status(upstream.status);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
let pendingBalance = null;
const billingTransform = createSseBillingTransform({
onFinish: async (event) => {
const result = await userAuth.billSessionUsage(
req.currentUser.id,
sessionId,
event.token_state,
null,
);
if (result.ok && result.costCents > 0 && result.balanceCents != null) {
pendingBalance = {
balanceCents: result.balanceCents,
tokensUsed: result.tokensUsed ?? undefined,
lastUsage: {
inputTokens: result.deltaInputTokens ?? 0,
outputTokens: result.deltaOutputTokens ?? 0,
costCents: result.costCents,
},
};
}
// Fire-and-forget snapshot refresh after conversation finishes.
if (typeof onAfterFinish === 'function') {
void Promise.resolve().then(() => onAfterFinish(sessionId, req.currentUser.id)).catch(() => {});
}
},
});
const source = Readable.fromWeb(upstream.body);
billingTransform.on('data', (chunk) => {
res.write(chunk);
if (pendingBalance != null) {
res.write(appendBalanceEvent(pendingBalance));
pendingBalance = null;
}
});
billingTransform.on('end', () => res.end());
billingTransform.on('error', () => res.end());
source.on('error', () => res.end());
source.pipe(billingTransform);
} catch (err) {
res.status(502).json({
message: sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : 'SSE 代理失败',
'SSE 代理失败',
),
});
}
};
const proxyFallback = async (req, res) => {
try {
const pathname = req.originalUrl.replace(/^\/api/, '') || '/';
if (isNativeH5ApiPath(pathname)) {
res.status(404).json({
message: 'H5 本地接口未找到,请确认服务端已更新并重启',
code: 'not_found',
});
return;
}
const policyState = await userAuth.resolveUserPolicies(req.currentUser);
const capabilityState = await userAuth.resolveUserCapabilities(req.currentUser);
const gate = evaluateProxyRequest(req.method, pathname, policyState.policies, {
unrestricted: policyState.unrestricted,
});
if (!gate.allowed) {
res.status(403).json({ message: gate.reason ?? '该 API 已被策略禁止' });
return;
}
if (
/^\/agent\/harness_(bootstrap|remember)$/.test(pathname) &&
!capabilityState.unrestricted &&
!capabilityState.capabilities.context_memory
) {
res.status(403).json({ message: '当前账户未开通项目记忆,无法访问该 API' });
return;
}
const isReplyPath = pathname.match(/^\/sessions\/[^/]+\/reply$/) && req.method === 'POST';
const sessionMatch = pathname.match(/^\/sessions\/([^/]+)/);
let baseBody = req.body;
if (isReplyPath && !policyState.unrestricted) {
baseBody = injectTaskRoutingHint(
req.body,
await userAuth.getAgentSessionPolicy(req.currentUser.id),
);
}
// Vision pre-processing: call Qwen VL directly for image analysis, then inject
// the description + image paths into the message text for DeepSeek/Goose.
// We do NOT switch the Goose session provider — DeepSeek retains full tool-calling
// capability so it can write files and return real links.
if (isReplyPath && llmProviderService) {
const sessionId = sessionMatch?.[1];
if (sessionId) {
const hasImages = messageHasImages(req.body?.user_message);
if (hasImages && await llmProviderService.hasVisionKey()) {
const publishLayout = await userAuth
.getUserPublishLayout(req.currentUser?.id)
.catch(() => null);
const visionResult = await buildVisionBody(
baseBody?.user_message ?? req.body?.user_message,
req.currentUser?.id,
publishLayout,
).catch(() => null);
if (visionResult?.userMessage) {
baseBody = { ...(baseBody ?? req.body), user_message: visionResult.userMessage };
}
if (visionResult?.billableImageCount > 0 && subscriptionService) {
await subscriptionService.consumeImageQuota(
req.currentUser.id,
visionResult.billableImageCount,
).catch((err) => {
console.warn(
'Subscription image quota consume skipped:',
err instanceof Error ? err.message : err,
);
});
}
}
}
}
const body =
req.method === 'GET' || req.method === 'HEAD'
? undefined
: baseBody
? JSON.stringify(baseBody)
: undefined;
const fallbackTarget =
req.goosedTarget ??
(sessionMatch ? await resolveTarget(sessionMatch[1]) : primaryTarget);
const upstream = await apiFetch(fallbackTarget, apiSecret, pathname, {
method: req.method,
body,
headers: {
Accept: req.get('accept') ?? '*/*',
'Last-Event-ID': req.get('last-event-id') ?? '',
},
});
sendProxyResponse(res, upstream);
} catch (err) {
res.status(502).json({
message: sanitizeUserFacingProxyMessage(
err instanceof Error ? err.message : '代理失败',
'代理失败',
),
});
}
};
return {
requireUser,
ensureChatAllowed,
applySessionLlmProvider,
applyLocalFallbackForSession,
applyVisionProviderForSession,
reconcileSessionPolicyForUser,
handlers,
sessionScoped,
proxyFallback,
proxySessionEvents,
resolveTarget,
apiFetch: async (pathname, init) => apiFetch(await pickTarget(), apiSecret, pathname, init),
apiFetchTo: (target, pathname, init) => apiFetch(target, apiSecret, pathname, init),
};
}
export function matchHandler(handlers, method, path) {
const key = `${method.toUpperCase()} ${path}`;
return handlers[key] ?? null;
}