merge: 0629001 into 0629002
合并反馈、语音 ASR、MindSpace 修复等 0629001 发布改动,并与 Agent Runs 网关改动完成冲突解决。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+381
-37
@@ -1,9 +1,16 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Readable, Transform } 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 {
|
||||
buildPublicUrl,
|
||||
buildSandboxSessionConstraints,
|
||||
PUBLISH_ROOT_DIR,
|
||||
resolvePublicBaseUrl,
|
||||
} from './user-publish.mjs';
|
||||
import { buildTaskRoutingAgentText } from './user-memory-profile.mjs';
|
||||
import { reconcileAgentSession } from './session-reconcile.mjs';
|
||||
import { createImgproxySigner } from './imgproxy-signer.mjs';
|
||||
@@ -31,6 +38,30 @@ function sanitizeUserFacingProxyMessage(message, fallback = '后端连接失败
|
||||
.replace(/\bgoose\b/gi, '后端');
|
||||
}
|
||||
|
||||
const PUBLIC_IMAGE_STORAGE_KEY_PATTERN = /^users\/([^/]+)\/images\/(\d{4}-\d{2}-\d{2}\/[^?#"'<>@\s]+)$/i;
|
||||
|
||||
function extractPublicImageStorageKey(rawUrl) {
|
||||
const value = String(rawUrl ?? '').trim();
|
||||
if (!value) return null;
|
||||
const directMatch = value.match(/(^|\/)(users\/[^?#"'<>@\s]+\/images\/\d{4}-\d{2}-\d{2}\/[^?#"'<>@\s]+)/i);
|
||||
if (directMatch?.[2]) return directMatch[2];
|
||||
const plainIndex = value.indexOf('plain/local:///');
|
||||
if (plainIndex >= 0) {
|
||||
const tail = value.slice(plainIndex + 'plain/local:///'.length);
|
||||
const storageKey = tail.replace(/@[a-z0-9]+(?:[?#].*)?$/i, '').replace(/[?#].*$/, '');
|
||||
return storageKey || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPublicStandardImageUrl(rawUrl, userId) {
|
||||
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]}`);
|
||||
}
|
||||
|
||||
async function apiFetch(target, apiSecret, pathname, init = {}) {
|
||||
const url = new URL(pathname, target);
|
||||
const headers = {
|
||||
@@ -89,6 +120,192 @@ function firstUserText(message) {
|
||||
}
|
||||
|
||||
const IMAGE_URL_LINE_RE = /^\[图片\d+]:\s*(\S.+)$/;
|
||||
const PUBLIC_HTML_LINK_PATTERN =
|
||||
/https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi;
|
||||
const PUBLIC_HTML_MARKDOWN_LINK_PATTERN =
|
||||
/\[([^\]\n]*)\]\((https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html))\)/gi;
|
||||
const USER_IDENTITY_BLOCK_PATTERN = /^\[用户身份\][\s\S]*?(?:\n{2,}|$)/;
|
||||
const IMAGE_URL_LINES_PATTERN = /\n*\[图片\d+]: [^\n]+/g;
|
||||
const TKMIND_VISION_NOTE_PATTERN = /\n*【TKMind 图片分析结果[\s\S]*$/;
|
||||
|
||||
function decodePathSegment(segment) {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeUrlPath(relativePath) {
|
||||
return String(relativePath ?? '')
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((part) => encodeURIComponent(part))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
function normalizeStaticHtmlRelativePath(relativePath) {
|
||||
const parts = String(relativePath ?? '')
|
||||
.replace(/^\/+/, '')
|
||||
.split('/')
|
||||
.filter((part) => part && part !== '.' && part !== '..');
|
||||
if (parts.length === 0) return '';
|
||||
if (parts[0].toLowerCase() === 'public') return ['public', ...parts.slice(1)].join('/');
|
||||
if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`;
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function canonicalizeStaticPageUrl(publicUrl, originalRelativePath, canonicalRelativePath) {
|
||||
const originalClean = String(originalRelativePath ?? '').replace(/^\/+/, '');
|
||||
if (!canonicalRelativePath || canonicalRelativePath === originalClean) return publicUrl;
|
||||
const suffix = encodeUrlPath(originalClean).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return String(publicUrl).replace(new RegExp(`${suffix}$`), encodeUrlPath(canonicalRelativePath));
|
||||
}
|
||||
|
||||
function buildMissingPublicHtmlNotice(filename) {
|
||||
return `(页面生成未完成,已阻止显示失效链接:${filename || '页面'}。)`;
|
||||
}
|
||||
|
||||
function publicHtmlExistsForUser(owner, relativePath, currentUser) {
|
||||
const normalizedOwner = String(owner ?? '').trim().toLowerCase();
|
||||
const normalizedUserId = String(currentUser?.id ?? '').trim().toLowerCase();
|
||||
const normalizedUsername = String(currentUser?.username ?? '').trim().toLowerCase();
|
||||
if (!normalizedOwner) return false;
|
||||
if (normalizedOwner !== normalizedUserId && normalizedOwner !== normalizedUsername) return true;
|
||||
const normalizedRelativePath = normalizeStaticHtmlRelativePath(relativePath);
|
||||
if (!normalizedRelativePath || !normalizedRelativePath.toLowerCase().endsWith('.html')) return false;
|
||||
const root = path.resolve(process.cwd(), PUBLISH_ROOT_DIR, normalizedOwner);
|
||||
const target = path.resolve(root, normalizedRelativePath);
|
||||
if (target !== root && !target.startsWith(`${root}${path.sep}`)) return false;
|
||||
return fs.existsSync(target) && fs.statSync(target).isFile();
|
||||
}
|
||||
|
||||
function sanitizeOwnPublicHtmlUrl(publicUrl, owner, rawRelativePath, currentUser) {
|
||||
const relativePath = decodePathSegment(rawRelativePath);
|
||||
const canonicalRelativePath = normalizeStaticHtmlRelativePath(relativePath);
|
||||
if (!publicHtmlExistsForUser(owner, canonicalRelativePath, currentUser)) {
|
||||
return {
|
||||
ok: false,
|
||||
notice: buildMissingPublicHtmlNotice(path.posix.basename(canonicalRelativePath || relativePath)),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
url: canonicalizeStaticPageUrl(publicUrl, relativePath, canonicalRelativePath),
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizePublicHtmlLinksInText(text, currentUser) {
|
||||
let next = String(text ?? '').replace(
|
||||
PUBLIC_HTML_MARKDOWN_LINK_PATTERN,
|
||||
(match, label, url, owner, rawRelativePath) => {
|
||||
const result = sanitizeOwnPublicHtmlUrl(url, owner, rawRelativePath, currentUser);
|
||||
if (!result.ok) return result.notice;
|
||||
return label ? `[${label}](${result.url})` : result.url;
|
||||
},
|
||||
);
|
||||
return next.replace(PUBLIC_HTML_LINK_PATTERN, (match, owner, rawRelativePath) => {
|
||||
const result = sanitizeOwnPublicHtmlUrl(match, owner, rawRelativePath, currentUser);
|
||||
return result.ok ? result.url : result.notice;
|
||||
});
|
||||
}
|
||||
|
||||
export function sanitizeUserVisibleMessageText(text, currentUser) {
|
||||
return sanitizePublicHtmlLinksInText(
|
||||
String(text ?? '')
|
||||
.replace(USER_IDENTITY_BLOCK_PATTERN, '')
|
||||
.replace(TKMIND_VISION_NOTE_PATTERN, '')
|
||||
.replace(IMAGE_URL_LINES_PATTERN, '')
|
||||
.trim(),
|
||||
currentUser,
|
||||
);
|
||||
}
|
||||
|
||||
export function sanitizeSessionMessagePublicHtmlLinks(message, currentUser) {
|
||||
if (!message || !Array.isArray(message.content)) return message;
|
||||
let changed = false;
|
||||
const imageUrls = extractImageUrlsFromMessage(message);
|
||||
const content = message.content.map((item) => {
|
||||
if (item?.type !== 'text' || typeof item.text !== 'string') return item;
|
||||
const nextText = sanitizeUserVisibleMessageText(item.text, currentUser);
|
||||
if (nextText === item.text) return item;
|
||||
changed = true;
|
||||
return { ...item, text: nextText };
|
||||
});
|
||||
const currentDisplayText =
|
||||
typeof message.metadata?.displayText === 'string' ? message.metadata.displayText : null;
|
||||
const nextDisplayText =
|
||||
currentDisplayText == null ? null : sanitizeUserVisibleMessageText(currentDisplayText, currentUser);
|
||||
const metadataChanged =
|
||||
(currentDisplayText != null && nextDisplayText !== currentDisplayText) ||
|
||||
(!Array.isArray(message.metadata?.imageUrls) && imageUrls.length > 0);
|
||||
if (!changed && !metadataChanged) return message;
|
||||
return {
|
||||
...message,
|
||||
content,
|
||||
metadata: {
|
||||
...(message.metadata ?? {}),
|
||||
...(nextDisplayText != null ? { displayText: nextDisplayText } : {}),
|
||||
...(!Array.isArray(message.metadata?.imageUrls) && imageUrls.length > 0
|
||||
? { imageUrls }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeSessionConversationPublicHtmlLinks(conversation, currentUser) {
|
||||
if (!Array.isArray(conversation)) return conversation;
|
||||
return conversation.map((message) => sanitizeSessionMessagePublicHtmlLinks(message, currentUser));
|
||||
}
|
||||
|
||||
function createSessionEventSanitizer(currentUser) {
|
||||
let buffer = '';
|
||||
const flushChunk = (controller, chunk) => {
|
||||
if (!chunk) return;
|
||||
const block = String(chunk);
|
||||
if (!block.includes('data: ')) {
|
||||
controller.push(block);
|
||||
return;
|
||||
}
|
||||
const lines = block.split('\n');
|
||||
const sanitizedLines = lines.map((line) => {
|
||||
if (!line.startsWith('data: ')) return line;
|
||||
const raw = line.slice(6);
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(raw);
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
if (event?.type === 'Message' && event.message) {
|
||||
event.message = sanitizeSessionMessagePublicHtmlLinks(event.message, currentUser);
|
||||
} else if (event?.type === 'UpdateConversation' && Array.isArray(event.conversation)) {
|
||||
event.conversation = sanitizeSessionConversationPublicHtmlLinks(event.conversation, currentUser);
|
||||
}
|
||||
return `data: ${JSON.stringify(event)}`;
|
||||
});
|
||||
controller.push(sanitizedLines.join('\n'));
|
||||
};
|
||||
|
||||
return new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
buffer += chunk.toString('utf8');
|
||||
let boundary = buffer.indexOf('\n\n');
|
||||
while (boundary >= 0) {
|
||||
const block = buffer.slice(0, boundary + 2);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
flushChunk(this, block);
|
||||
boundary = buffer.indexOf('\n\n');
|
||||
}
|
||||
callback();
|
||||
},
|
||||
flush(callback) {
|
||||
flushChunk(this, buffer);
|
||||
buffer = '';
|
||||
callback();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function extractImageUrlsFromMessage(userMessage) {
|
||||
const urls = [];
|
||||
@@ -161,41 +378,49 @@ export async function buildVisionPayload({
|
||||
llmProviderService,
|
||||
imgproxySigner = null,
|
||||
}) {
|
||||
if (!localFetchAsset || !llmProviderService || !userId) return null;
|
||||
void imgproxySigner;
|
||||
if (!llmProviderService || !userId) return null;
|
||||
const rawImageUrls = extractImageUrlsFromMessage(userMessage);
|
||||
if (rawImageUrls.length === 0) return null;
|
||||
const originalDisplayText =
|
||||
typeof userMessage?.metadata?.displayText === 'string' &&
|
||||
userMessage.metadata.displayText.trim()
|
||||
? userMessage.metadata.displayText
|
||||
: Array.isArray(userMessage?.content)
|
||||
? userMessage.content
|
||||
.filter((item) => item?.type === 'text' && typeof item.text === 'string')
|
||||
.map((item) => item.text)
|
||||
.join('\n')
|
||||
.trim()
|
||||
: '';
|
||||
|
||||
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;
|
||||
const fetchableUrl = (() => {
|
||||
if (/^https?:\/\//i.test(rawUrl)) return rawUrl;
|
||||
if (rawUrl.startsWith('/')) {
|
||||
const baseUrl = process.env.H5_PUBLIC_BASE_URL || 'http://127.0.0.1:8081';
|
||||
return new URL(rawUrl, baseUrl).toString();
|
||||
}
|
||||
} else {
|
||||
return rawUrl;
|
||||
})();
|
||||
try {
|
||||
const response = await undiciFetch(fetchableUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vision fetch failed: ${response.status}`);
|
||||
}
|
||||
buffer = Buffer.from(await response.arrayBuffer());
|
||||
mimeType = response.headers.get('content-type') || 'image/jpeg';
|
||||
} catch (fetchErr) {
|
||||
if (!match || !localFetchAsset) {
|
||||
throw fetchErr;
|
||||
}
|
||||
const assetId = decodeURIComponent(match[1]);
|
||||
console.warn(`Vision fetch fallback for asset ${assetId}:`, fetchErr instanceof Error ? fetchErr.message : fetchErr);
|
||||
const asset = await localFetchAsset(userId, assetId);
|
||||
buffer = asset.buffer;
|
||||
mimeType = asset.mimeType;
|
||||
@@ -206,7 +431,14 @@ export async function buildVisionPayload({
|
||||
const parsed = new URL(rawUrl);
|
||||
relativePath = parsed.pathname + parsed.search;
|
||||
} catch { /* keep rawUrl */ }
|
||||
imageItems.push({ mimeType, data: buffer.toString('base64'), relativePath, rawUrl });
|
||||
const publicStandardUrl = buildPublicStandardImageUrl(rawUrl, userId);
|
||||
imageItems.push({
|
||||
mimeType,
|
||||
data: buffer.toString('base64'),
|
||||
relativePath,
|
||||
rawUrl,
|
||||
embedUrl: publicStandardUrl ?? relativePath,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Vision image fetch skipped:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
@@ -218,7 +450,7 @@ export async function buildVisionPayload({
|
||||
.catch(() => null);
|
||||
|
||||
const pathList = imageItems
|
||||
.map((item, i) => `图片${i + 1}: <img src="${item.relativePath}" alt="图片${i + 1}">`)
|
||||
.map((item, i) => `图片${i + 1}: <img src="${item.embedUrl}" alt="图片${i + 1}">`)
|
||||
.join(' ');
|
||||
|
||||
const publicUrlPrefix = publishLayout?.publicUrl
|
||||
@@ -230,9 +462,10 @@ export async function buildVisionPayload({
|
||||
(visionDescription
|
||||
? `Qwen VL 图片描述:\n${visionDescription}\n\n`
|
||||
: '') +
|
||||
`图片 HTML 嵌入路径(直接写入 <img> 标签,浏览器有 cookie 可直接加载,无需 fetch):\n${pathList}\n` +
|
||||
'写作约束:不得改写图片里人物的年龄、性别、人数或主体关系;如果用户明确要求儿童语气或童趣风格,也只能调整表达方式,不能把图片主体改写成儿童场景。\n' +
|
||||
`图片 HTML 嵌入路径(直接写入 <img> 标签;以下都是无需 cookie 的公开压缩标准图片,禁止使用 local://、/users/ 私有路径、原图地址或需要登录态的下载链接):\n${pathList}\n` +
|
||||
'执行要求:必须先调用 load_skill → static-page-publish(每次生成页面都要调用,不可省略),' +
|
||||
'再用 write_file 写入 public/页面.html;' +
|
||||
'再用 write_file 写入 public/页面.html(禁止用 shell/cat/heredoc 写 HTML,容器内文件不会出现在公网链接);' +
|
||||
(publicUrlPrefix
|
||||
? `完成后向用户给出 Markdown 可点击链接,格式:[页面标题](${publicUrlPrefix}<文件名>.html);`
|
||||
: '完成后按技能说明里的链接格式给用户一个 Markdown 可点击链接;') +
|
||||
@@ -242,7 +475,7 @@ export async function buildVisionPayload({
|
||||
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);
|
||||
const updated = c.text.replaceAll(item.rawUrl, item.embedUrl);
|
||||
return updated === c.text ? c : { ...c, text: updated };
|
||||
});
|
||||
}
|
||||
@@ -261,7 +494,14 @@ export async function buildVisionPayload({
|
||||
}
|
||||
|
||||
return {
|
||||
userMessage: { ...userMessage, content: updatedContent },
|
||||
userMessage: {
|
||||
...userMessage,
|
||||
content: updatedContent,
|
||||
metadata: {
|
||||
...(userMessage.metadata ?? {}),
|
||||
...(originalDisplayText ? { displayText: originalDisplayText } : {}),
|
||||
},
|
||||
},
|
||||
billableImageCount: visionDescription ? 1 : 0,
|
||||
};
|
||||
}
|
||||
@@ -345,6 +585,73 @@ export function createTkmindProxy({
|
||||
}
|
||||
}
|
||||
|
||||
function projectSessionSummary(session) {
|
||||
return {
|
||||
id: session?.id,
|
||||
name: typeof session?.name === 'string' ? session.name : '',
|
||||
message_count: Number(session?.message_count ?? 0),
|
||||
created_at: session?.created_at ?? undefined,
|
||||
updated_at: session?.updated_at ?? undefined,
|
||||
user_set_name: Boolean(session?.user_set_name),
|
||||
recipe: session?.recipe ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function sortSessionsByRecent(left, right) {
|
||||
const leftTime = Date.parse(left?.updated_at ?? left?.created_at ?? '') || 0;
|
||||
const rightTime = Date.parse(right?.updated_at ?? right?.created_at ?? '') || 0;
|
||||
return rightTime - leftTime;
|
||||
}
|
||||
|
||||
function matchesSessionQuery(summary, rawQuery) {
|
||||
const query = String(rawQuery ?? '').trim().toLowerCase();
|
||||
if (!query) return true;
|
||||
const haystacks = [
|
||||
summary?.name,
|
||||
summary?.recipe?.title,
|
||||
summary?.id,
|
||||
]
|
||||
.filter((value) => typeof value === 'string' && value.trim())
|
||||
.map((value) => value.toLowerCase());
|
||||
return haystacks.some((value) => value.includes(query));
|
||||
}
|
||||
|
||||
function paginateSessionConversation(conversation, beforeValue, limitValue) {
|
||||
const visibleConversation = Array.isArray(conversation)
|
||||
? conversation.filter((message) => message?.metadata?.userVisible)
|
||||
: [];
|
||||
const total = visibleConversation.length;
|
||||
const before = Math.max(Number(beforeValue ?? 0) || 0, 0);
|
||||
const requestedLimit = Number(limitValue ?? 0) || 0;
|
||||
if (requestedLimit <= 0) {
|
||||
return {
|
||||
conversation: visibleConversation,
|
||||
page: {
|
||||
total,
|
||||
before: 0,
|
||||
limit: total,
|
||||
returned: total,
|
||||
has_more_before: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(requestedLimit, 1), 200);
|
||||
const end = Math.max(total - before, 0);
|
||||
const start = Math.max(end - limit, 0);
|
||||
const paged = visibleConversation.slice(start, end);
|
||||
return {
|
||||
conversation: paged,
|
||||
page: {
|
||||
total,
|
||||
before,
|
||||
limit,
|
||||
returned: paged.length,
|
||||
has_more_before: start > 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function pickTarget() {
|
||||
if (targets.length <= 1) return primaryTarget;
|
||||
for (let i = 0; i < targets.length; i += 1) {
|
||||
@@ -793,6 +1100,10 @@ export function createTkmindProxy({
|
||||
requireUser,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const offset = Math.max(Number(req.query?.offset ?? 0) || 0, 0);
|
||||
const requestedLimit = Number(req.query?.limit ?? 20) || 20;
|
||||
const limit = Math.min(Math.max(requestedLimit, 1), 100);
|
||||
const query = String(req.query?.query ?? '').trim();
|
||||
const owned = await userAuth.listOwnedSessionIds(req.currentUser.id);
|
||||
const sessionsById = new Map();
|
||||
let healthyTargets = 0;
|
||||
@@ -830,9 +1141,20 @@ export function createTkmindProxy({
|
||||
res.setHeader('X-TKMind-Degraded', '1');
|
||||
}
|
||||
|
||||
const sessions = [...sessionsById.values()];
|
||||
const sessions = [...sessionsById.values()].sort(sortSessionsByRecent);
|
||||
await enrichSessionHistory(sessions);
|
||||
res.json({ sessions });
|
||||
const summaries = sessions.map(projectSessionSummary).filter((item) => matchesSessionQuery(item, query));
|
||||
const paged = summaries.slice(offset, offset + limit);
|
||||
res.json({
|
||||
sessions: paged,
|
||||
page: {
|
||||
total: summaries.length,
|
||||
offset,
|
||||
limit,
|
||||
has_more: offset + paged.length < summaries.length,
|
||||
query,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
message: sanitizeUserFacingProxyMessage(
|
||||
@@ -900,11 +1222,12 @@ export function createTkmindProxy({
|
||||
let pendingBalance = null;
|
||||
const billingTransform = createSseBillingTransform({
|
||||
onFinish: async (event) => {
|
||||
const billingRequestId = event.request_id ?? event.chat_request_id ?? null;
|
||||
const result = await userAuth.billSessionUsage(
|
||||
req.currentUser.id,
|
||||
sessionId,
|
||||
event.token_state,
|
||||
null,
|
||||
billingRequestId,
|
||||
);
|
||||
if (result.ok && result.costCents > 0 && result.balanceCents != null) {
|
||||
pendingBalance = {
|
||||
@@ -925,6 +1248,7 @@ export function createTkmindProxy({
|
||||
});
|
||||
|
||||
const source = Readable.fromWeb(upstream.body);
|
||||
const linkSanitizer = createSessionEventSanitizer(req.currentUser);
|
||||
|
||||
// 每 20s 发一个注释行保持连接,防止 nginx/代理因静默超时切断 SSE
|
||||
const keepalive = setInterval(() => {
|
||||
@@ -941,9 +1265,10 @@ export function createTkmindProxy({
|
||||
});
|
||||
billingTransform.on('end', () => { stopKeepalive(); res.end(); });
|
||||
billingTransform.on('error', () => { stopKeepalive(); res.end(); });
|
||||
linkSanitizer.on('error', () => { stopKeepalive(); res.end(); });
|
||||
source.on('error', () => { stopKeepalive(); res.end(); });
|
||||
req.on('close', stopKeepalive);
|
||||
source.pipe(billingTransform);
|
||||
source.pipe(linkSanitizer).pipe(billingTransform);
|
||||
} catch (err) {
|
||||
res.status(502).json({
|
||||
message: sanitizeUserFacingProxyMessage(
|
||||
@@ -1063,6 +1388,25 @@ export function createTkmindProxy({
|
||||
},
|
||||
});
|
||||
|
||||
if (req.method === 'GET' && /^\/sessions\/[^/]+$/.test(pathname) && upstream.ok) {
|
||||
const payload = await upstream.json().catch(() => null);
|
||||
if (payload && Array.isArray(payload.conversation)) {
|
||||
const sanitizedConversation = sanitizeSessionConversationPublicHtmlLinks(
|
||||
payload.conversation,
|
||||
req.currentUser,
|
||||
);
|
||||
const { conversation, page } = paginateSessionConversation(
|
||||
sanitizedConversation,
|
||||
req.query?.history_before,
|
||||
req.query?.history_limit,
|
||||
);
|
||||
payload.conversation = conversation;
|
||||
payload.conversation_page = page;
|
||||
}
|
||||
res.status(upstream.status).json(payload ?? {});
|
||||
return;
|
||||
}
|
||||
|
||||
sendProxyResponse(res, upstream);
|
||||
} catch (err) {
|
||||
res.status(502).json({
|
||||
|
||||
Reference in New Issue
Block a user