merge: 0629001 into 0629002

合并反馈、语音 ASR、MindSpace 修复等 0629001 发布改动,并与 Agent Runs 网关改动完成冲突解决。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-29 22:51:03 +08:00
101 changed files with 20868 additions and 2592 deletions
+381 -37
View File
@@ -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({