feat(h5): web 联网能力、实时查询路由与 session Finish 对齐
- 新增 web 能力并挂载 platform/web(web_search/fetch_url) - 实时查询强制 web skill,router fallback 与 await session Finish - Session Broker 覆盖率/指标、stream replay 与相关单测/E2E 脚本 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+218
-49
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { Readable, Transform, Writable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
@@ -16,13 +17,23 @@ import {
|
||||
import { buildCurrentTimeAgentPrefix, buildTaskRoutingAgentText } from './user-memory-profile.mjs';
|
||||
import { reconcileAgentSession } from './session-reconcile.mjs';
|
||||
import { createSessionAccess, isSessionBrokerEnabled } from './session-broker.mjs';
|
||||
import { createSessionBrokerMetrics, isSessionBrokerMetricsEnabled } from './session-broker-metrics.mjs';
|
||||
import { createImgproxySigner } from './imgproxy-signer.mjs';
|
||||
import { isDirectChatSessionId } from './direct-chat-service.mjs';
|
||||
import { ensureGooseUserMessageMetadata } from './goose-message.mjs';
|
||||
import { consumeSessionEventsUntilFinish } from './session-reply-wait.mjs';
|
||||
import {
|
||||
memoryLimitForIntervention,
|
||||
resolveMemoryInterventionMode,
|
||||
} from './memory-intervention.mjs';
|
||||
import {
|
||||
formatSessionStreamSseChunk,
|
||||
isSessionStreamReplayEnabled,
|
||||
parseSessionSseBlock,
|
||||
parseSessionStreamLastEventId,
|
||||
shouldPersistSessionStreamEvent,
|
||||
shouldSkipUpstreamAfterSessionReplay,
|
||||
} from './session-stream.mjs';
|
||||
|
||||
const insecureDispatcher = new Agent({
|
||||
connect: { rejectUnauthorized: false },
|
||||
@@ -566,41 +577,57 @@ export function sanitizeSessionConversationPublicHtmlLinks(conversation, current
|
||||
return conversation.map((message) => sanitizeSessionMessagePublicHtmlLinks(message, currentUser));
|
||||
}
|
||||
|
||||
function createSessionEventSanitizer(currentUser, { onEvent } = {}) {
|
||||
function createSessionEventSanitizer(currentUser, { onEvent, onPersistFrame, normalizeReplayIds = false } = {}) {
|
||||
let buffer = '';
|
||||
const flushChunk = (controller, chunk) => {
|
||||
if (!chunk) return;
|
||||
const block = String(chunk);
|
||||
if (!block.includes('data: ')) {
|
||||
const flushChunk = (controller, block) => {
|
||||
if (!block) return;
|
||||
const parsed = parseSessionSseBlock(block);
|
||||
if (!parsed.data) {
|
||||
controller.push(block);
|
||||
return;
|
||||
}
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(parsed.data);
|
||||
} catch {
|
||||
controller.push(block);
|
||||
return;
|
||||
}
|
||||
event = finalizeSessionStreamEvent(event);
|
||||
if (typeof onEvent === 'function') {
|
||||
try {
|
||||
onEvent(event);
|
||||
} catch {
|
||||
// Ignore side-effect failures and keep SSE flowing to the client.
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
if (normalizeReplayIds) {
|
||||
const frameId = parsed.id || crypto.randomUUID();
|
||||
if (typeof onPersistFrame === 'function' && shouldPersistSessionStreamEvent(event)) {
|
||||
try {
|
||||
onPersistFrame({ id: frameId, upstreamEventId: parsed.id ?? null, payload: event });
|
||||
} catch {
|
||||
// Ignore persistence failures and keep SSE flowing to the client.
|
||||
}
|
||||
}
|
||||
controller.push(formatSessionStreamSseChunk({
|
||||
id: frameId,
|
||||
event: parsed.eventName,
|
||||
data: event,
|
||||
}));
|
||||
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;
|
||||
}
|
||||
event = finalizeSessionStreamEvent(event);
|
||||
if (typeof onEvent === 'function') {
|
||||
try {
|
||||
onEvent(event);
|
||||
} catch {
|
||||
// Ignore side-effect failures and keep SSE flowing to the client.
|
||||
}
|
||||
}
|
||||
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'));
|
||||
controller.push(`${sanitizedLines.join('\n')}\n`);
|
||||
};
|
||||
|
||||
return new Transform({
|
||||
@@ -844,6 +871,7 @@ export function createTkmindProxy({
|
||||
apiSecret,
|
||||
userAuth,
|
||||
sessionAccess = null,
|
||||
sessionStreamStore = null,
|
||||
llmProviderService,
|
||||
localFetchAsset,
|
||||
subscriptionService,
|
||||
@@ -853,6 +881,9 @@ export function createTkmindProxy({
|
||||
}) {
|
||||
const sessionStore =
|
||||
sessionAccess ?? createSessionAccess({ userAuth, enabled: isSessionBrokerEnabled() });
|
||||
const brokerMetrics = isSessionBrokerMetricsEnabled() && sessionStore.enabled
|
||||
? createSessionBrokerMetrics({ logger: console })
|
||||
: null;
|
||||
const targets = apiTargets?.length ? apiTargets : apiTarget ? [apiTarget] : [];
|
||||
const primaryTarget = targets[0] ?? apiTarget ?? '';
|
||||
let rrIdx = 0;
|
||||
@@ -1215,6 +1246,16 @@ export function createTkmindProxy({
|
||||
// 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;
|
||||
if (brokerMetrics) {
|
||||
if (target && !targets.includes(target)) {
|
||||
brokerMetrics.resolveTargetMiss({
|
||||
sessionId,
|
||||
target,
|
||||
node,
|
||||
reason: 'stale_pinned_target',
|
||||
});
|
||||
}
|
||||
}
|
||||
return targets[node] ?? primaryTarget;
|
||||
} catch {
|
||||
return primaryTarget;
|
||||
@@ -1319,7 +1360,7 @@ export function createTkmindProxy({
|
||||
);
|
||||
}
|
||||
|
||||
async function submitSessionReplyForUser(
|
||||
async function prepareSessionReplyBody(
|
||||
userId,
|
||||
sessionId,
|
||||
requestId,
|
||||
@@ -1377,8 +1418,24 @@ export function createTkmindProxy({
|
||||
...body,
|
||||
user_message: ensureGooseUserMessageMetadata(body.user_message),
|
||||
};
|
||||
|
||||
const target = await resolveTarget(sessionId);
|
||||
return { body, target };
|
||||
}
|
||||
|
||||
async function submitSessionReplyForUser(
|
||||
userId,
|
||||
sessionId,
|
||||
requestId,
|
||||
userMessage,
|
||||
options = {},
|
||||
) {
|
||||
const { body, target } = await prepareSessionReplyBody(
|
||||
userId,
|
||||
sessionId,
|
||||
requestId,
|
||||
userMessage,
|
||||
options,
|
||||
);
|
||||
const upstream = await apiFetch(
|
||||
target,
|
||||
apiSecret,
|
||||
@@ -1395,6 +1452,56 @@ export function createTkmindProxy({
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function submitSessionReplyAndAwaitFinishForUser(
|
||||
userId,
|
||||
sessionId,
|
||||
requestId,
|
||||
userMessage,
|
||||
{ toolMode = 'chat', forceDeepReasoning = false, timeoutMs = 15 * 60 * 1000 } = {},
|
||||
) {
|
||||
const { body, target } = await prepareSessionReplyBody(
|
||||
userId,
|
||||
sessionId,
|
||||
requestId,
|
||||
userMessage,
|
||||
{ toolMode, forceDeepReasoning },
|
||||
);
|
||||
const eventsResponse = await apiFetch(
|
||||
target,
|
||||
apiSecret,
|
||||
`/sessions/${encodeURIComponent(sessionId)}/events`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
},
|
||||
);
|
||||
if (!eventsResponse.ok || !eventsResponse.body) {
|
||||
const text = await eventsResponse.text().catch(() => '');
|
||||
throw new Error(text || `无法建立 session 事件流 (${eventsResponse.status})`);
|
||||
}
|
||||
|
||||
const finishPromise = consumeSessionEventsUntilFinish(eventsResponse.body, {
|
||||
requestId,
|
||||
timeoutMs,
|
||||
});
|
||||
const replyResponse = await apiFetch(
|
||||
target,
|
||||
apiSecret,
|
||||
`/sessions/${encodeURIComponent(sessionId)}/reply`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!replyResponse.ok) {
|
||||
const text = await replyResponse.text().catch(() => '');
|
||||
throw new Error(text || `发送失败 (${replyResponse.status})`);
|
||||
}
|
||||
replyResponse.body?.cancel?.().catch?.(() => {});
|
||||
const finish = await finishPromise;
|
||||
return { ok: true, ...finish };
|
||||
}
|
||||
|
||||
const requireUser = async (req, res, next) => {
|
||||
try {
|
||||
const session = req.userSession;
|
||||
@@ -1745,12 +1852,66 @@ export function createTkmindProxy({
|
||||
const upstreamAbort = new AbortController();
|
||||
const streamRequestedAt = Date.now();
|
||||
let clientClosed = false;
|
||||
const replayEnabled = isSessionStreamReplayEnabled() && sessionStreamStore;
|
||||
const initialLastEventId = parseSessionStreamLastEventId(req.get('last-event-id'));
|
||||
const abortUpstream = () => {
|
||||
clientClosed = true;
|
||||
upstreamAbort.abort();
|
||||
};
|
||||
req.once('close', abortUpstream);
|
||||
|
||||
const writeReplayToClient = async (events) => {
|
||||
for (const event of events) {
|
||||
if (clientClosed || res.writableEnded) return;
|
||||
await writeClientChunk(formatSessionStreamSseChunk({
|
||||
id: event.id,
|
||||
data: event.payload,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let writeClientChunk = async () => {};
|
||||
let pendingBalance = null;
|
||||
const waitForDrain = () => new Promise((resolve) => res.once('drain', resolve));
|
||||
writeClientChunk = async (chunk) => {
|
||||
if (res.writableEnded || clientClosed) return;
|
||||
let needsDrain = !res.write(chunk);
|
||||
if (pendingBalance != null && !res.writableEnded && !clientClosed) {
|
||||
needsDrain = !res.write(appendBalanceEvent(pendingBalance)) || needsDrain;
|
||||
pendingBalance = null;
|
||||
}
|
||||
if (needsDrain && !res.writableEnded && !clientClosed) {
|
||||
await waitForDrain();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (replayEnabled && initialLastEventId) {
|
||||
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?.();
|
||||
|
||||
let batch = await sessionStreamStore.listEventsForUser(req.currentUser.id, sessionId, {
|
||||
afterEventId: initialLastEventId,
|
||||
});
|
||||
if (!batch) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
if (batch.cursorMiss) {
|
||||
batch = await sessionStreamStore.listEventsForUser(req.currentUser.id, sessionId);
|
||||
}
|
||||
await writeReplayToClient(batch?.events ?? []);
|
||||
if (shouldSkipUpstreamAfterSessionReplay(batch?.events ?? [], { cursorMiss: batch?.cursorMiss })) {
|
||||
req.off('close', abortUpstream);
|
||||
if (!res.writableEnded) res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const pathname = `/sessions/${sessionId}/events`;
|
||||
const sessionTarget = await resolveTarget(sessionId);
|
||||
const upstream = await apiFetch(sessionTarget, apiSecret, pathname, {
|
||||
@@ -1765,18 +1926,23 @@ export function createTkmindProxy({
|
||||
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
const text = await upstream.text().catch(() => '');
|
||||
res.status(upstream.status).send(text);
|
||||
if (!res.headersSent) {
|
||||
res.status(upstream.status).send(text);
|
||||
} else if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(upstream.status);
|
||||
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?.();
|
||||
if (!res.headersSent) {
|
||||
res.status(upstream.status);
|
||||
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?.();
|
||||
}
|
||||
|
||||
let pendingBalance = null;
|
||||
const billingTransform = createSseBillingTransform({
|
||||
onFinish: async (event) => {
|
||||
const billingRequestId = event.request_id ?? event.chat_request_id ?? null;
|
||||
@@ -1815,19 +1981,21 @@ export function createTkmindProxy({
|
||||
callback(null, chunk);
|
||||
},
|
||||
});
|
||||
const linkSanitizer = createSessionEventSanitizer(req.currentUser, { onEvent });
|
||||
const waitForDrain = () => new Promise((resolve) => res.once('drain', resolve));
|
||||
const writeClientChunk = async (chunk) => {
|
||||
if (res.writableEnded || clientClosed) return;
|
||||
let needsDrain = !res.write(chunk);
|
||||
if (pendingBalance != null && !res.writableEnded && !clientClosed) {
|
||||
needsDrain = !res.write(appendBalanceEvent(pendingBalance)) || needsDrain;
|
||||
pendingBalance = null;
|
||||
}
|
||||
if (needsDrain && !res.writableEnded && !clientClosed) {
|
||||
await waitForDrain();
|
||||
}
|
||||
};
|
||||
const linkSanitizer = createSessionEventSanitizer(req.currentUser, {
|
||||
onEvent,
|
||||
normalizeReplayIds: replayEnabled,
|
||||
onPersistFrame: replayEnabled
|
||||
? ({ id, upstreamEventId, payload }) => {
|
||||
void sessionStreamStore.appendEvent({
|
||||
userId: req.currentUser.id,
|
||||
sessionId,
|
||||
id,
|
||||
upstreamEventId,
|
||||
payload,
|
||||
}).catch(() => {});
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const clientSink = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
writeClientChunk(chunk).then(() => callback(), callback);
|
||||
@@ -2032,6 +2200,7 @@ export function createTkmindProxy({
|
||||
startSessionForUser,
|
||||
getRuntimeStatus,
|
||||
submitSessionReplyForUser,
|
||||
submitSessionReplyAndAwaitFinishForUser,
|
||||
apiFetch: async (pathname, init) => apiFetch(await pickTarget(), apiSecret, pathname, init),
|
||||
apiFetchTo: (target, pathname, init) => apiFetch(target, apiSecret, pathname, init),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user