Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a69e2766ed | |||
| d81e798b28 |
+33
-17
@@ -488,7 +488,7 @@ export function createAgentRunGateway({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markRun(runId, status, fields = {}) {
|
async function markRun(runId, status, fields = {}, { expectedStatus = null } = {}) {
|
||||||
const updates = ['status = ?', 'updated_at = ?'];
|
const updates = ['status = ?', 'updated_at = ?'];
|
||||||
const values = [status, nowMs()];
|
const values = [status, nowMs()];
|
||||||
for (const [key, value] of Object.entries(fields)) {
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
@@ -496,12 +496,18 @@ export function createAgentRunGateway({
|
|||||||
values.push(value);
|
values.push(value);
|
||||||
}
|
}
|
||||||
values.push(runId);
|
values.push(runId);
|
||||||
await pool.query(
|
const where = expectedStatus
|
||||||
`UPDATE h5_agent_runs SET ${updates.join(', ')} WHERE id = ?`,
|
? 'WHERE id = ? AND status = ?'
|
||||||
|
: 'WHERE id = ?';
|
||||||
|
if (expectedStatus) values.push(expectedStatus);
|
||||||
|
const [result] = await pool.query(
|
||||||
|
`UPDATE h5_agent_runs SET ${updates.join(', ')} ${where}`,
|
||||||
values,
|
values,
|
||||||
);
|
);
|
||||||
|
if (Number(result?.affectedRows ?? 0) === 0) return false;
|
||||||
await appendEvent(runId, status, fields);
|
await appendEvent(runId, status, fields);
|
||||||
await appendRunSnapshot(runId);
|
await appendRunSnapshot(runId);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRunHeartbeat(runId, { attempt }) {
|
function startRunHeartbeat(runId, { attempt }) {
|
||||||
@@ -940,11 +946,12 @@ export function createAgentRunGateway({
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await markRun(runId, 'succeeded', {
|
const marked = await markRun(runId, 'succeeded', {
|
||||||
agent_session_id: sessionId,
|
agent_session_id: sessionId,
|
||||||
completed_at: nowMs(),
|
completed_at: nowMs(),
|
||||||
error_message: null,
|
error_message: null,
|
||||||
});
|
}, { expectedStatus: 'running' });
|
||||||
|
if (!marked) return false;
|
||||||
if (typeof observePersonalMemoryOnSuccess === 'function') {
|
if (typeof observePersonalMemoryOnSuccess === 'function') {
|
||||||
await observePersonalMemoryOnSuccess({
|
await observePersonalMemoryOnSuccess({
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
@@ -999,7 +1006,7 @@ export function createAgentRunGateway({
|
|||||||
await markRun(runId, retryable ? 'retryable' : 'failed', {
|
await markRun(runId, retryable ? 'retryable' : 'failed', {
|
||||||
error_message: message,
|
error_message: message,
|
||||||
completed_at: retryable ? null : nowMs(),
|
completed_at: retryable ? null : nowMs(),
|
||||||
});
|
}, { expectedStatus: 'running' });
|
||||||
if (retryable && autoDispatch) {
|
if (retryable && autoDispatch) {
|
||||||
setTimeout(() => dispatchRun(runId), retryDelaysMs[nextAttempt - 1]);
|
setTimeout(() => dispatchRun(runId), retryDelaysMs[nextAttempt - 1]);
|
||||||
}
|
}
|
||||||
@@ -1129,6 +1136,7 @@ export function createAgentRunGateway({
|
|||||||
);
|
);
|
||||||
const startedCutoff = nowMs() - normalizedStaleMs;
|
const startedCutoff = nowMs() - normalizedStaleMs;
|
||||||
const sessionFinishedCutoff = nowMs() - normalizedSessionFinishedGraceMs;
|
const sessionFinishedCutoff = nowMs() - normalizedSessionFinishedGraceMs;
|
||||||
|
const heartbeatCutoff = nowMs() - normalizedStaleMs;
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
r.id,
|
r.id,
|
||||||
@@ -1155,6 +1163,7 @@ export function createAgentRunGateway({
|
|||||||
) sf ON sf.run_id = r.id
|
) sf ON sf.run_id = r.id
|
||||||
WHERE r.status = 'running'
|
WHERE r.status = 'running'
|
||||||
AND r.started_at IS NOT NULL
|
AND r.started_at IS NOT NULL
|
||||||
|
AND (h.latest_heartbeat_at IS NULL OR h.latest_heartbeat_at <= ?)
|
||||||
AND (
|
AND (
|
||||||
r.started_at <= ?
|
r.started_at <= ?
|
||||||
OR (
|
OR (
|
||||||
@@ -1164,7 +1173,7 @@ export function createAgentRunGateway({
|
|||||||
)
|
)
|
||||||
ORDER BY COALESCE(sf.session_finished_at, r.started_at) ASC
|
ORDER BY COALESCE(sf.session_finished_at, r.started_at) ASC
|
||||||
LIMIT ?`,
|
LIMIT ?`,
|
||||||
[startedCutoff, sessionFinishedCutoff, normalizedLimit],
|
[heartbeatCutoff, startedCutoff, sessionFinishedCutoff, normalizedLimit],
|
||||||
);
|
);
|
||||||
const recovered = [];
|
const recovered = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -1198,11 +1207,12 @@ export function createAgentRunGateway({
|
|||||||
requireRecoverableError: false,
|
requireRecoverableError: false,
|
||||||
});
|
});
|
||||||
if (deliverableRecovered) {
|
if (deliverableRecovered) {
|
||||||
await markRun(row.id, 'succeeded', {
|
const marked = await markRun(row.id, 'succeeded', {
|
||||||
agent_session_id: row.agent_session_id ?? null,
|
agent_session_id: row.agent_session_id ?? null,
|
||||||
completed_at: nowMs(),
|
completed_at: nowMs(),
|
||||||
error_message: null,
|
error_message: null,
|
||||||
});
|
}, { expectedStatus: 'running' });
|
||||||
|
if (!marked) continue;
|
||||||
item.status = 'succeeded';
|
item.status = 'succeeded';
|
||||||
item.recoveredAs = 'deliverables';
|
item.recoveredAs = 'deliverables';
|
||||||
recovered.push(item);
|
recovered.push(item);
|
||||||
@@ -1223,15 +1233,21 @@ export function createAgentRunGateway({
|
|||||||
AND started_at IS NOT NULL
|
AND started_at IS NOT NULL
|
||||||
AND (
|
AND (
|
||||||
started_at <= ?
|
started_at <= ?
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM h5_agent_run_events sf
|
FROM h5_agent_run_events sf
|
||||||
WHERE sf.run_id = h5_agent_runs.id
|
WHERE sf.run_id = h5_agent_runs.id
|
||||||
AND sf.event_type = 'session_finished'
|
AND sf.event_type = 'session_finished'
|
||||||
AND sf.created_at <= ?
|
AND sf.created_at <= ?
|
||||||
)
|
)
|
||||||
)`,
|
)
|
||||||
[message, completedAt, completedAt, row.id, startedCutoff, sessionFinishedCutoff],
|
AND (
|
||||||
|
SELECT COALESCE(MAX(hb.created_at), h5_agent_runs.started_at)
|
||||||
|
FROM h5_agent_run_events hb
|
||||||
|
WHERE hb.run_id = h5_agent_runs.id
|
||||||
|
AND hb.event_type = 'worker_heartbeat'
|
||||||
|
) <= ?`,
|
||||||
|
[message, completedAt, completedAt, row.id, startedCutoff, sessionFinishedCutoff, heartbeatCutoff],
|
||||||
);
|
);
|
||||||
if (Number(update?.affectedRows ?? 0) === 0) continue;
|
if (Number(update?.affectedRows ?? 0) === 0) continue;
|
||||||
await appendEvent(row.id, 'stale_recovered', {
|
await appendEvent(row.id, 'stale_recovered', {
|
||||||
|
|||||||
+20
-13
@@ -43,12 +43,16 @@ function createFakePool({ sessionDeliverables = {}, workspaceDeliverables = {} }
|
|||||||
return timestamps.length ? Math.max(...timestamps) : null;
|
return timestamps.length ? Math.max(...timestamps) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isStaleRunningRow = (row, startedCutoff, sessionFinishedCutoff) => {
|
const isStaleRunningRow = (row, heartbeatCutoff, startedCutoff, sessionFinishedCutoff) => {
|
||||||
if (row.status !== 'running' || row.started_at == null) return false;
|
if (row.status !== 'running' || row.started_at == null) return false;
|
||||||
const finishedAt = sessionFinishedAt(row.id);
|
const finishedAt = sessionFinishedAt(row.id);
|
||||||
|
const heartbeatAt = latestHeartbeatAt(row.id);
|
||||||
return (
|
return (
|
||||||
Number(row.started_at) <= Number(startedCutoff)
|
(heartbeatAt == null || Number(heartbeatAt) <= Number(heartbeatCutoff))
|
||||||
|| (finishedAt != null && Number(finishedAt) <= Number(sessionFinishedCutoff))
|
&& (
|
||||||
|
Number(row.started_at) <= Number(startedCutoff)
|
||||||
|
|| (finishedAt != null && Number(finishedAt) <= Number(sessionFinishedCutoff))
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,9 +116,9 @@ function createFakePool({ sessionDeliverables = {}, workspaceDeliverables = {} }
|
|||||||
}]];
|
}]];
|
||||||
}
|
}
|
||||||
if (sql.includes('SELECT') && sql.includes('session_finished_at') && sql.includes('r.started_at <= ?')) {
|
if (sql.includes('SELECT') && sql.includes('session_finished_at') && sql.includes('r.started_at <= ?')) {
|
||||||
const [startedCutoff, sessionFinishedCutoff, limit = 1] = params;
|
const [heartbeatCutoff, startedCutoff, sessionFinishedCutoff, limit = 1] = params;
|
||||||
return [[...runs.values()]
|
return [[...runs.values()]
|
||||||
.filter((row) => isStaleRunningRow(row, startedCutoff, sessionFinishedCutoff))
|
.filter((row) => isStaleRunningRow(row, heartbeatCutoff, startedCutoff, sessionFinishedCutoff))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aKey = Number(sessionFinishedAt(a.id) ?? a.started_at ?? 0);
|
const aKey = Number(sessionFinishedAt(a.id) ?? a.started_at ?? 0);
|
||||||
const bKey = Number(sessionFinishedAt(b.id) ?? b.started_at ?? 0);
|
const bKey = Number(sessionFinishedAt(b.id) ?? b.started_at ?? 0);
|
||||||
@@ -237,9 +241,9 @@ function createFakePool({ sessionDeliverables = {}, workspaceDeliverables = {} }
|
|||||||
return [{ affectedRows: 1 }];
|
return [{ affectedRows: 1 }];
|
||||||
}
|
}
|
||||||
if (sql.includes("WHERE id = ?") && sql.includes("status = 'running'") && sql.includes('session_finished')) {
|
if (sql.includes("WHERE id = ?") && sql.includes("status = 'running'") && sql.includes('session_finished')) {
|
||||||
const [errorMessage, updatedAt, completedAt, id, startedCutoff, sessionFinishedCutoff] = params;
|
const [errorMessage, updatedAt, completedAt, id, startedCutoff, sessionFinishedCutoff, heartbeatCutoff] = params;
|
||||||
const row = runs.get(id);
|
const row = runs.get(id);
|
||||||
if (!row || !isStaleRunningRow(row, startedCutoff, sessionFinishedCutoff)) {
|
if (!row || !isStaleRunningRow(row, heartbeatCutoff, startedCutoff, sessionFinishedCutoff)) {
|
||||||
return [{ affectedRows: 0 }];
|
return [{ affectedRows: 0 }];
|
||||||
}
|
}
|
||||||
Object.assign(row, {
|
Object.assign(row, {
|
||||||
@@ -283,7 +287,9 @@ function createFakePool({ sessionDeliverables = {}, workspaceDeliverables = {} }
|
|||||||
return [rows];
|
return [rows];
|
||||||
}
|
}
|
||||||
if (sql.includes('UPDATE h5_agent_runs SET')) {
|
if (sql.includes('UPDATE h5_agent_runs SET')) {
|
||||||
const id = params.at(-1);
|
const setSql = sql.split(' WHERE ')[0];
|
||||||
|
const columns = [...setSql.matchAll(/([a-z_]+) = \?/g)].map((match) => match[1]);
|
||||||
|
const id = params[columns.length];
|
||||||
const row = runs.get(id);
|
const row = runs.get(id);
|
||||||
if (!row) return [{ affectedRows: 0 }];
|
if (!row) return [{ affectedRows: 0 }];
|
||||||
if (sql.includes("status = 'running'") && sql.includes('started_at = COALESCE(started_at, ?)')) {
|
if (sql.includes("status = 'running'") && sql.includes('started_at = COALESCE(started_at, ?)')) {
|
||||||
@@ -297,7 +303,8 @@ function createFakePool({ sessionDeliverables = {}, workspaceDeliverables = {} }
|
|||||||
});
|
});
|
||||||
return [{ affectedRows: 1 }];
|
return [{ affectedRows: 1 }];
|
||||||
}
|
}
|
||||||
const columns = [...sql.matchAll(/([a-z_]+) = \?/g)].map((match) => match[1]);
|
const expectedStatus = sql.includes('AND status = ?') ? params[columns.length + 1] : null;
|
||||||
|
if (expectedStatus && row.status !== expectedStatus) return [{ affectedRows: 0 }];
|
||||||
for (let i = 0; i < columns.length; i += 1) {
|
for (let i = 0; i < columns.length; i += 1) {
|
||||||
row[columns[i]] = params[i];
|
row[columns[i]] = params[i];
|
||||||
}
|
}
|
||||||
@@ -1817,7 +1824,7 @@ test('stale running recovery marks old running rows failed with an event', async
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stale running recovery still considers old runs even with fresh heartbeat', async () => {
|
test('stale running recovery ignores old runs with a fresh heartbeat', async () => {
|
||||||
const pool = createFakePool();
|
const pool = createFakePool();
|
||||||
const gateway = createAgentRunGateway({
|
const gateway = createAgentRunGateway({
|
||||||
pool,
|
pool,
|
||||||
@@ -1847,9 +1854,9 @@ test('stale running recovery still considers old runs even with fresh heartbeat'
|
|||||||
|
|
||||||
const result = await gateway.recoverStaleRunningRuns({ staleMs: 1000, dryRun: false });
|
const result = await gateway.recoverStaleRunningRuns({ staleMs: 1000, dryRun: false });
|
||||||
|
|
||||||
assert.equal(result.considered, 1);
|
assert.equal(result.considered, 0);
|
||||||
assert.equal(result.recovered, 1);
|
assert.equal(result.recovered, 0);
|
||||||
assert.equal(pool.runs.get(run.id).status, 'failed');
|
assert.equal(pool.runs.get(run.id).status, 'running');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stale running recovery succeeds when workspace pages exist after sync', async () => {
|
test('stale running recovery succeeds when workspace pages exist after sync', async () => {
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ export function loadWechatMpConfig(env = process.env) {
|
|||||||
acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0',
|
acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0',
|
||||||
acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '0',
|
acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '0',
|
||||||
mediaAnalysisGrayUsers: parseCsvList(env.H5_WECHAT_MP_MEDIA_GRAY_USERS),
|
mediaAnalysisGrayUsers: parseCsvList(env.H5_WECHAT_MP_MEDIA_GRAY_USERS),
|
||||||
|
reliabilityGrayUsers: parseCsvList(env.H5_WECHAT_MP_RELIABILITY_GRAY_USERS),
|
||||||
|
agentReplyTimeoutMs: Math.max(
|
||||||
|
0,
|
||||||
|
Number(env.H5_WECHAT_MP_AGENT_REPLY_TIMEOUT_MS ?? 15 * 60 * 1000),
|
||||||
|
),
|
||||||
requireFreshPageThumbnail: env.H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS !== '0',
|
requireFreshPageThumbnail: env.H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS !== '0',
|
||||||
repairFreshPageThumbnail: env.H5_WECHAT_MP_FRESH_THUMBNAIL_REPAIR === '1',
|
repairFreshPageThumbnail: env.H5_WECHAT_MP_FRESH_THUMBNAIL_REPAIR === '1',
|
||||||
encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '',
|
encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '',
|
||||||
|
|||||||
+130
-86
@@ -69,6 +69,7 @@ const DEFAULT_WECHAT_JSAPI_TICKET_URL = 'https://api.weixin.qq.com/cgi-bin/ticke
|
|||||||
const DEFAULT_ASR_TARGET = process.env.H5_ASR_TARGET ?? 'https://asr.tkmind.cn';
|
const DEFAULT_ASR_TARGET = process.env.H5_ASR_TARGET ?? 'https://asr.tkmind.cn';
|
||||||
const WECHAT_RECENT_MEDIA_TTL_MS = 15 * 60 * 1000;
|
const WECHAT_RECENT_MEDIA_TTL_MS = 15 * 60 * 1000;
|
||||||
const WECHAT_RECENT_IMAGE_MAX_COUNT = 10;
|
const WECHAT_RECENT_IMAGE_MAX_COUNT = 10;
|
||||||
|
const DEFAULT_WECHAT_AGENT_REPLY_TIMEOUT_MS = 15 * 60 * 1000;
|
||||||
export { loadWechatMpConfig };
|
export { loadWechatMpConfig };
|
||||||
const PUBLIC_HTML_LINK_PATTERN =
|
const PUBLIC_HTML_LINK_PATTERN =
|
||||||
/https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi;
|
/https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi;
|
||||||
@@ -229,110 +230,137 @@ function pushMessage(messages, incoming) {
|
|||||||
return [...messages, incoming];
|
return [...messages, incoming];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeSessionReply(
|
export async function executeSessionReply(
|
||||||
apiFetch,
|
apiFetch,
|
||||||
sessionId,
|
sessionId,
|
||||||
requestId,
|
requestId,
|
||||||
prompt,
|
prompt,
|
||||||
metadata = {},
|
metadata = {},
|
||||||
{ submitReply = null, prepareUserMessage = null } = {},
|
{ submitReply = null, prepareUserMessage = null, timeoutMs = 0 } = {},
|
||||||
) {
|
) {
|
||||||
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
|
const normalizedTimeoutMs = Math.max(0, Number(timeoutMs) || 0);
|
||||||
method: 'GET',
|
const abortController = new AbortController();
|
||||||
headers: { Accept: 'text/event-stream' },
|
let timedOut = false;
|
||||||
});
|
let reader = null;
|
||||||
if (!eventsResponse.ok || !eventsResponse.body) {
|
const timeout = normalizedTimeoutMs > 0
|
||||||
const text = await eventsResponse.text().catch(() => '');
|
? setTimeout(() => {
|
||||||
throw new Error(text || '无法建立公众号消息事件流');
|
timedOut = true;
|
||||||
}
|
abortController.abort();
|
||||||
|
void reader?.cancel?.().catch?.(() => {});
|
||||||
|
}, normalizedTimeoutMs)
|
||||||
|
: null;
|
||||||
|
|
||||||
let userMessage = createUserMessage(prompt, metadata);
|
try {
|
||||||
if (prepareUserMessage) {
|
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
|
||||||
userMessage = (await prepareUserMessage(userMessage)) ?? userMessage;
|
method: 'GET',
|
||||||
}
|
headers: { Accept: 'text/event-stream' },
|
||||||
if (submitReply) {
|
signal: abortController.signal,
|
||||||
await submitReply({ sessionId, requestId, userMessage });
|
|
||||||
} else {
|
|
||||||
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
request_id: requestId,
|
|
||||||
user_message: userMessage,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!replyResponse.ok) {
|
if (!eventsResponse.ok || !eventsResponse.body) {
|
||||||
const text = await replyResponse.text().catch(() => '');
|
const text = await eventsResponse.text().catch(() => '');
|
||||||
throw new Error(text || 'Agent reply 请求失败');
|
throw new Error(text || '无法建立公众号消息事件流');
|
||||||
}
|
}
|
||||||
replyResponse.body?.cancel().catch?.(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = eventsResponse.body.getReader();
|
let userMessage = createUserMessage(prompt, metadata);
|
||||||
const decoder = new TextDecoder();
|
if (prepareUserMessage) {
|
||||||
let buffer = '';
|
userMessage = (await prepareUserMessage(userMessage)) ?? userMessage;
|
||||||
let messages = [];
|
}
|
||||||
let requestMessages = [];
|
if (submitReply) {
|
||||||
let hasScopedAssistantUpdate = false;
|
await submitReply({ sessionId, requestId, userMessage, signal: abortController.signal });
|
||||||
|
} else {
|
||||||
while (true) {
|
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
|
||||||
const { value, done } = await reader.read();
|
method: 'POST',
|
||||||
if (done) break;
|
body: JSON.stringify({
|
||||||
buffer += decoder.decode(value, { stream: true });
|
request_id: requestId,
|
||||||
const frames = buffer.split('\n\n');
|
user_message: userMessage,
|
||||||
buffer = frames.pop() ?? '';
|
}),
|
||||||
for (const frame of frames) {
|
signal: abortController.signal,
|
||||||
let data = '';
|
});
|
||||||
for (const line of frame.split('\n')) {
|
if (!replyResponse.ok) {
|
||||||
if (line.startsWith('data:')) data += line.slice(5).trim();
|
const text = await replyResponse.text().catch(() => '');
|
||||||
|
throw new Error(text || 'Agent reply 请求失败');
|
||||||
}
|
}
|
||||||
if (!data) continue;
|
replyResponse.body?.cancel().catch?.(() => {});
|
||||||
let event;
|
}
|
||||||
try {
|
|
||||||
event = JSON.parse(data);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const routingId = event.chat_request_id ?? event.request_id;
|
|
||||||
if (routingId && routingId !== requestId) continue;
|
|
||||||
|
|
||||||
if (event.type === 'Message' && event.message?.metadata?.userVisible) {
|
reader = eventsResponse.body.getReader();
|
||||||
const hasActionRequired = event.message.content?.some((item) => item.type === 'actionRequired');
|
const decoder = new TextDecoder();
|
||||||
if (hasActionRequired) {
|
let buffer = '';
|
||||||
throw new Error('当前回复需要人工确认,公众号通道暂不支持');
|
let messages = [];
|
||||||
|
let requestMessages = [];
|
||||||
|
let hasScopedAssistantUpdate = false;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const frames = buffer.split('\n\n');
|
||||||
|
buffer = frames.pop() ?? '';
|
||||||
|
for (const frame of frames) {
|
||||||
|
let data = '';
|
||||||
|
for (const line of frame.split('\n')) {
|
||||||
|
if (line.startsWith('data:')) data += line.slice(5).trim();
|
||||||
}
|
}
|
||||||
if (event.message.role === 'assistant') hasScopedAssistantUpdate = true;
|
if (!data) continue;
|
||||||
messages = pushMessage(messages, event.message);
|
let event;
|
||||||
requestMessages = pushMessage(requestMessages, event.message);
|
try {
|
||||||
} else if (event.type === 'UpdateConversation') {
|
event = JSON.parse(data);
|
||||||
// Ignore unscoped snapshots until this request has yielded an assistant update.
|
} catch {
|
||||||
// Otherwise a stale session snapshot can overwrite the current reply with a
|
continue;
|
||||||
// previous page/link from the same WeChat-dedicated session.
|
|
||||||
if (hasScopedAssistantUpdate) {
|
|
||||||
messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible);
|
|
||||||
}
|
}
|
||||||
} else if (event.type === 'Error') {
|
const routingId = event.chat_request_id ?? event.request_id;
|
||||||
throw new Error(event.error || '任务执行失败');
|
if (routingId && routingId !== requestId) continue;
|
||||||
} else if (event.type === 'Finish') {
|
|
||||||
const assistant = [...messages].reverse().find((item) => item.role === 'assistant');
|
if (event.type === 'Message' && event.message?.metadata?.userVisible) {
|
||||||
if (!hasScopedAssistantUpdate || !assistant) {
|
const hasActionRequired = event.message.content?.some((item) => item.type === 'actionRequired');
|
||||||
throw new Error('本轮未收到可发送的新回复,请稍后重试');
|
if (hasActionRequired) {
|
||||||
|
throw new Error('当前回复需要人工确认,公众号通道暂不支持');
|
||||||
|
}
|
||||||
|
if (event.message.role === 'assistant') hasScopedAssistantUpdate = true;
|
||||||
|
messages = pushMessage(messages, event.message);
|
||||||
|
requestMessages = pushMessage(requestMessages, event.message);
|
||||||
|
} else if (event.type === 'UpdateConversation') {
|
||||||
|
// Ignore unscoped snapshots until this request has yielded an assistant update.
|
||||||
|
// Otherwise a stale session snapshot can overwrite the current reply with a
|
||||||
|
// previous page/link from the same WeChat-dedicated session.
|
||||||
|
if (hasScopedAssistantUpdate) {
|
||||||
|
messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible);
|
||||||
|
}
|
||||||
|
} else if (event.type === 'Error') {
|
||||||
|
throw new Error(event.error || '任务执行失败');
|
||||||
|
} else if (event.type === 'Finish') {
|
||||||
|
const assistant = [...messages].reverse().find((item) => item.role === 'assistant');
|
||||||
|
if (!hasScopedAssistantUpdate || !assistant) {
|
||||||
|
throw new Error('本轮未收到可发送的新回复,请稍后重试');
|
||||||
|
}
|
||||||
|
const reply = {
|
||||||
|
text: messageVisibleText(assistant),
|
||||||
|
tokenState: event.token_state ?? null,
|
||||||
|
messages,
|
||||||
|
// Keep request-scoped stream messages separate from a later full
|
||||||
|
// UpdateConversation snapshot. Artifact delivery must never inspect
|
||||||
|
// historical tool calls from the whole dedicated session.
|
||||||
|
requestMessages,
|
||||||
|
};
|
||||||
|
assertWechatAgentReplyIsSendable(reply);
|
||||||
|
return reply;
|
||||||
}
|
}
|
||||||
const reply = {
|
|
||||||
text: messageVisibleText(assistant),
|
|
||||||
tokenState: event.token_state ?? null,
|
|
||||||
messages,
|
|
||||||
// Keep request-scoped stream messages separate from a later full
|
|
||||||
// UpdateConversation snapshot. Artifact delivery must never inspect
|
|
||||||
// historical tool calls from the whole dedicated session.
|
|
||||||
requestMessages,
|
|
||||||
};
|
|
||||||
assertWechatAgentReplyIsSendable(reply);
|
|
||||||
return reply;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('公众号消息事件流提前结束');
|
throw new Error('公众号消息事件流提前结束');
|
||||||
|
} catch (err) {
|
||||||
|
if (timedOut) {
|
||||||
|
const timeoutError = new Error(`公众号消息处理超时(${normalizedTimeoutMs}ms),请稍后重试`);
|
||||||
|
timeoutError.code = 'WECHAT_AGENT_REPLY_TIMEOUT';
|
||||||
|
timeoutError.retryable = true;
|
||||||
|
throw timeoutError;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
await reader?.cancel?.().catch?.(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const WECHAT_CUSTOMER_TEXT_MAX_BYTES = 2048;
|
const WECHAT_CUSTOMER_TEXT_MAX_BYTES = 2048;
|
||||||
@@ -1580,6 +1608,13 @@ export function createWechatMpService({
|
|||||||
mediaAnalysisGrayUsers: Array.isArray(config.mediaAnalysisGrayUsers)
|
mediaAnalysisGrayUsers: Array.isArray(config.mediaAnalysisGrayUsers)
|
||||||
? config.mediaAnalysisGrayUsers
|
? config.mediaAnalysisGrayUsers
|
||||||
: [],
|
: [],
|
||||||
|
reliabilityGrayUsers: Array.isArray(config.reliabilityGrayUsers)
|
||||||
|
? config.reliabilityGrayUsers
|
||||||
|
: [],
|
||||||
|
agentReplyTimeoutMs: Math.max(
|
||||||
|
0,
|
||||||
|
Number(config.agentReplyTimeoutMs ?? DEFAULT_WECHAT_AGENT_REPLY_TIMEOUT_MS),
|
||||||
|
),
|
||||||
requireFreshPageThumbnail,
|
requireFreshPageThumbnail,
|
||||||
repairFreshPageThumbnail,
|
repairFreshPageThumbnail,
|
||||||
asrTarget: config.asrTarget || DEFAULT_ASR_TARGET,
|
asrTarget: config.asrTarget || DEFAULT_ASR_TARGET,
|
||||||
@@ -2321,6 +2356,8 @@ export function createWechatMpService({
|
|||||||
const runIntentMessage = async ({ inbound, intent, user }) => {
|
const runIntentMessage = async ({ inbound, intent, user }) => {
|
||||||
const wechatIntent = classifyWechatIntent(intent);
|
const wechatIntent = classifyWechatIntent(intent);
|
||||||
const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers);
|
const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers);
|
||||||
|
const reliabilityEnabled = isWechatMediaGrayUser(user, config.reliabilityGrayUsers);
|
||||||
|
const agentReplyTimeoutMs = reliabilityEnabled ? config.agentReplyTimeoutMs : 0;
|
||||||
const resetCandidate =
|
const resetCandidate =
|
||||||
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
|
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
|
||||||
const imagePolicy = resolveWechatImageGenerationPolicy({
|
const imagePolicy = resolveWechatImageGenerationPolicy({
|
||||||
@@ -2396,6 +2433,7 @@ export function createWechatMpService({
|
|||||||
options: { requireHistoricalImageIsolation: true },
|
options: { requireHistoricalImageIsolation: true },
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
|
timeoutMs: agentReplyTimeoutMs,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply));
|
const generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply));
|
||||||
@@ -2549,6 +2587,11 @@ export function createWechatMpService({
|
|||||||
return { sessionId };
|
return { sessionId };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (err?.code === 'WECHAT_AGENT_REPLY_TIMEOUT') {
|
||||||
|
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
|
||||||
|
logger.warn?.('WeChat MP timed-out session route clear failed:', clearErr);
|
||||||
|
});
|
||||||
|
}
|
||||||
// A Page Data request must fail closed. Retrying a poisoned completion in
|
// A Page Data request must fail closed. Retrying a poisoned completion in
|
||||||
// another session while the finish guard is also active can turn one
|
// another session while the finish guard is also active can turn one
|
||||||
// request into repairs against historical pages. Drop only this user's
|
// request into repairs against historical pages. Drop only this user's
|
||||||
@@ -2611,6 +2654,7 @@ export function createWechatMpService({
|
|||||||
options: { requireHistoricalImageIsolation: true },
|
options: { requireHistoricalImageIsolation: true },
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
|
timeoutMs: agentReplyTimeoutMs,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply));
|
const generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply));
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
verifyWechatMpSignature,
|
verifyWechatMpSignature,
|
||||||
verifyWechatMpUrlChallenge,
|
verifyWechatMpUrlChallenge,
|
||||||
decryptWechatMpPayload,
|
decryptWechatMpPayload,
|
||||||
|
executeSessionReply,
|
||||||
WECHAT_CUSTOMER_TEXT_MAX_BYTES,
|
WECHAT_CUSTOMER_TEXT_MAX_BYTES,
|
||||||
} from './wechat-mp.mjs';
|
} from './wechat-mp.mjs';
|
||||||
import { createChatIntentRouter } from './chat-intent-router.mjs';
|
import { createChatIntentRouter } from './chat-intent-router.mjs';
|
||||||
@@ -69,6 +70,33 @@ function previewReadyPageHtml({ title = 'Page', subtitle = '测试页面', cover
|
|||||||
return `<!doctype html><html><head><meta name="description" content="${subtitle}"><meta name="mindspace-cover" content='{"tag":"页面","accent":"#3366cc","accent2":"#112233","subtitle":"${subtitle}"${coverField}}'><title>${title}</title></head><body><main>${'x'.repeat(600)}</main><p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p></body></html>`;
|
return `<!doctype html><html><head><meta name="description" content="${subtitle}"><meta name="mindspace-cover" content='{"tag":"页面","accent":"#3366cc","accent2":"#112233","subtitle":"${subtitle}"${coverField}}'><title>${title}</title></head><body><main>${'x'.repeat(600)}</main><p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p></body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('executeSessionReply converts a hanging event stream into a bounded timeout', async () => {
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new TextEncoder().encode(': connected\n\n'));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
executeSessionReply(
|
||||||
|
async (_pathname, init) => {
|
||||||
|
assert.equal(init.method, 'GET');
|
||||||
|
return { ok: true, body };
|
||||||
|
},
|
||||||
|
'session-timeout',
|
||||||
|
'request-timeout',
|
||||||
|
'hello',
|
||||||
|
{},
|
||||||
|
{ timeoutMs: 20, submitReply: async () => {} },
|
||||||
|
),
|
||||||
|
(error) => error?.code === 'WECHAT_AGENT_REPLY_TIMEOUT',
|
||||||
|
);
|
||||||
|
assert.equal(cancelled, true);
|
||||||
|
});
|
||||||
|
|
||||||
function jsonEscapedPreviewReadyPageHtml(options = {}) {
|
function jsonEscapedPreviewReadyPageHtml(options = {}) {
|
||||||
return previewReadyPageHtml(options).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
return previewReadyPageHtml(options).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user