Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a69e2766ed | |||
| d81e798b28 | |||
| db225b784e | |||
| aea3c1e83e | |||
| 5242cbf08b | |||
| 881f70f4bf |
+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 () => {
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ export function scrubUserMessageImageAttachments(message) {
|
|||||||
let contentChanged = false;
|
let contentChanged = false;
|
||||||
const content = Array.isArray(message.content)
|
const content = Array.isArray(message.content)
|
||||||
? message.content.map((item) => {
|
? message.content.map((item) => {
|
||||||
|
if (item?.type === 'image_url') {
|
||||||
|
contentChanged = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (item?.type !== 'text' || typeof item.text !== 'string') return item;
|
if (item?.type !== 'text' || typeof item.text !== 'string') return item;
|
||||||
const nextText = stripAgentImageText(item.text);
|
const nextText = stripAgentImageText(item.text);
|
||||||
if (nextText === item.text) return item;
|
if (nextText === item.text) return item;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ test('extractCurrentTurnImageUrls prefers metadata and dedupes asset aliases', (
|
|||||||
imageUrls: ['/api/mindspace/v1/assets/asset-1/download?inline=1'],
|
imageUrls: ['/api/mindspace/v1/assets/asset-1/download?inline=1'],
|
||||||
},
|
},
|
||||||
content: [
|
content: [
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: '/api/mindspace/v1/assets/old-asset/download?inline=1' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text:
|
text:
|
||||||
@@ -64,6 +68,7 @@ test('scrubUserMessageImageAttachments archives urls for ui and strips agent tex
|
|||||||
]);
|
]);
|
||||||
assert.deepEqual(scrubbed.message.metadata.archivedPreviewImageUrls, ['blob:preview-old']);
|
assert.deepEqual(scrubbed.message.metadata.archivedPreviewImageUrls, ['blob:preview-old']);
|
||||||
assert.equal(scrubbed.message.metadata.displayText, '上一轮图片');
|
assert.equal(scrubbed.message.metadata.displayText, '上一轮图片');
|
||||||
|
assert.equal(scrubbed.message.content.some((item) => item.type === 'image_url'), false);
|
||||||
assert.equal(scrubbed.message.content[0].text, '上一轮图片');
|
assert.equal(scrubbed.message.content[0].text, '上一轮图片');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -876,11 +876,12 @@ async function bootstrapUserAuth() {
|
|||||||
const target = await tkmindProxy.resolveTarget(sessionId);
|
const target = await tkmindProxy.resolveTarget(sessionId);
|
||||||
return tkmindProxy.apiFetchTo(target, pathname, init);
|
return tkmindProxy.apiFetchTo(target, pathname, init);
|
||||||
},
|
},
|
||||||
submitSessionReply: ({ userId, sessionId, requestId, userMessage }) =>
|
submitSessionReply: ({ userId, sessionId, requestId, userMessage, options }) =>
|
||||||
tkmindProxy.submitSessionReplyForUser(userId, sessionId, requestId, userMessage),
|
tkmindProxy.submitSessionReplyForUser(userId, sessionId, requestId, userMessage, options),
|
||||||
scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null,
|
scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null,
|
||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
llmProviderService,
|
llmProviderService,
|
||||||
|
chatIntentRouter,
|
||||||
onPageGenerated: async ({ userId, sessionId, artifacts = [] }) => {
|
onPageGenerated: async ({ userId, sessionId, artifacts = [] }) => {
|
||||||
for (const artifact of artifacts) {
|
for (const artifact of artifacts) {
|
||||||
void sendMindSpaceAnalyticsEvent({
|
void sendMindSpaceAnalyticsEvent({
|
||||||
|
|||||||
+22
-6
@@ -1591,7 +1591,7 @@ export function createTkmindProxy({
|
|||||||
|
|
||||||
async function syncHistoricalImageTurnIsolation(sessionId, activeMessageId) {
|
async function syncHistoricalImageTurnIsolation(sessionId, activeMessageId) {
|
||||||
const activeId = String(activeMessageId ?? '').trim();
|
const activeId = String(activeMessageId ?? '').trim();
|
||||||
if (!sessionId || !activeId) return;
|
if (!sessionId || !activeId) return { changed: false, updated: false };
|
||||||
try {
|
try {
|
||||||
const target = await resolveTarget(sessionId);
|
const target = await resolveTarget(sessionId);
|
||||||
const upstream = await apiFetch(
|
const upstream = await apiFetch(
|
||||||
@@ -1599,13 +1599,15 @@ export function createTkmindProxy({
|
|||||||
apiSecret,
|
apiSecret,
|
||||||
`/sessions/${encodeURIComponent(sessionId)}`,
|
`/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
);
|
);
|
||||||
if (!upstream.ok) return;
|
if (!upstream.ok) {
|
||||||
|
return { changed: false, updated: false, status: upstream.status };
|
||||||
|
}
|
||||||
const session = await upstream.json().catch(() => null);
|
const session = await upstream.json().catch(() => null);
|
||||||
const { conversation, changed } = scrubConversationHistoricalImageAttachments(
|
const { conversation, changed } = scrubConversationHistoricalImageAttachments(
|
||||||
session?.conversation ?? [],
|
session?.conversation ?? [],
|
||||||
activeId,
|
activeId,
|
||||||
);
|
);
|
||||||
if (!changed) return;
|
if (!changed) return { changed: false, updated: false };
|
||||||
const update = await apiFetch(
|
const update = await apiFetch(
|
||||||
target,
|
target,
|
||||||
apiSecret,
|
apiSecret,
|
||||||
@@ -1619,12 +1621,15 @@ export function createTkmindProxy({
|
|||||||
console.warn(
|
console.warn(
|
||||||
`Historical image scrub skipped for session ${sessionId}: upstream ${update.status}`,
|
`Historical image scrub skipped for session ${sessionId}: upstream ${update.status}`,
|
||||||
);
|
);
|
||||||
|
return { changed: true, updated: false, status: update.status };
|
||||||
}
|
}
|
||||||
|
return { changed: true, updated: true, status: update.status };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'Historical image scrub skipped:',
|
'Historical image scrub skipped:',
|
||||||
err instanceof Error ? err.message : err,
|
err instanceof Error ? err.message : err,
|
||||||
);
|
);
|
||||||
|
return { changed: false, updated: false, error: err };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1633,7 +1638,11 @@ export function createTkmindProxy({
|
|||||||
sessionId,
|
sessionId,
|
||||||
requestId,
|
requestId,
|
||||||
userMessage,
|
userMessage,
|
||||||
{ toolMode = 'chat', forceDeepReasoning = false } = {},
|
{
|
||||||
|
toolMode = 'chat',
|
||||||
|
forceDeepReasoning = false,
|
||||||
|
requireHistoricalImageIsolation = false,
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
if (!userId || !sessionId) throw new Error('缺少会话信息');
|
if (!userId || !sessionId) throw new Error('缺少会话信息');
|
||||||
const owns = await sessionStore.validateOwnership(userId, sessionId);
|
const owns = await sessionStore.validateOwnership(userId, sessionId);
|
||||||
@@ -1655,8 +1664,15 @@ export function createTkmindProxy({
|
|||||||
|
|
||||||
const user = await userAuth.getUserById(userId);
|
const user = await userAuth.getUserById(userId);
|
||||||
if (!user) throw new Error('用户不存在');
|
if (!user) throw new Error('用户不存在');
|
||||||
if (messageHasImages(userMessage)) {
|
if (requireHistoricalImageIsolation || messageHasImages(userMessage)) {
|
||||||
await syncHistoricalImageTurnIsolation(sessionId, userMessage?.id);
|
const imageIsolation = await syncHistoricalImageTurnIsolation(sessionId, userMessage?.id);
|
||||||
|
if (requireHistoricalImageIsolation && imageIsolation.changed && !imageIsolation.updated) {
|
||||||
|
const error = new Error(
|
||||||
|
`historical_image_session_update_unsupported:${imageIsolation.status ?? 'unknown'}`,
|
||||||
|
);
|
||||||
|
error.code = 'HISTORICAL_IMAGE_SESSION_UPDATE_UNSUPPORTED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let finalUserMessage = userMessage;
|
let finalUserMessage = userMessage;
|
||||||
if (llmProviderService && messageHasImages(userMessage) && await llmProviderService.hasVisionKey()) {
|
if (llmProviderService && messageHasImages(userMessage) && await llmProviderService.hasVisionKey()) {
|
||||||
|
|||||||
+59
-1
@@ -13,7 +13,7 @@ import {
|
|||||||
} from './tkmind-proxy.mjs';
|
} from './tkmind-proxy.mjs';
|
||||||
import { createMemoryV2 } from './memory-v2.mjs';
|
import { createMemoryV2 } from './memory-v2.mjs';
|
||||||
|
|
||||||
async function withFakeGoosedSession(handler) {
|
async function withFakeGoosedSession(handler, { conversation = [] } = {}) {
|
||||||
const workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memind-memory-v2-'));
|
const workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memind-memory-v2-'));
|
||||||
const harnessEntries = [];
|
const harnessEntries = [];
|
||||||
const replyBodies = [];
|
const replyBodies = [];
|
||||||
@@ -42,9 +42,15 @@ async function withFakeGoosedSession(handler) {
|
|||||||
id: 'session-1',
|
id: 'session-1',
|
||||||
working_dir: workingDir,
|
working_dir: workingDir,
|
||||||
goose_mode: 'chat',
|
goose_mode: 'chat',
|
||||||
|
conversation,
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (req.method === 'PUT' && req.url === '/sessions/session-1') {
|
||||||
|
res.writeHead(405, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ message: 'method not allowed' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (req.method === 'GET' && req.url === '/sessions/session-1/extensions') {
|
if (req.method === 'GET' && req.url === '/sessions/session-1/extensions') {
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({
|
res.end(JSON.stringify({
|
||||||
@@ -898,6 +904,58 @@ test('submitSessionReplyForUser adds goose metadata visibility flags before repl
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('submitSessionReplyForUser fails closed when historical image scrub is unsupported', async () => {
|
||||||
|
await withFakeGoosedSession(async ({ apiTarget, workingDir, replyBodies }) => {
|
||||||
|
const proxy = createTkmindProxy({
|
||||||
|
apiTarget,
|
||||||
|
apiSecret: 'test-secret',
|
||||||
|
userAuth: {
|
||||||
|
...createMemoryTestUserAuth(workingDir),
|
||||||
|
async ownsSession() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async canUseChat() {
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
async getUserById() {
|
||||||
|
return { id: 'user-1' };
|
||||||
|
},
|
||||||
|
async resolveUserPolicies() {
|
||||||
|
return { unrestricted: true, policies: {} };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
proxy.submitSessionReplyForUser(
|
||||||
|
'user-1',
|
||||||
|
'session-1',
|
||||||
|
'request-after-image',
|
||||||
|
{
|
||||||
|
id: 'message-current',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: '继续分析' }],
|
||||||
|
},
|
||||||
|
{ requireHistoricalImageIsolation: true },
|
||||||
|
),
|
||||||
|
/historical_image_session_update_unsupported:405/,
|
||||||
|
);
|
||||||
|
assert.equal(replyBodies.length, 0);
|
||||||
|
}, {
|
||||||
|
conversation: [
|
||||||
|
{
|
||||||
|
id: 'message-old-image',
|
||||||
|
role: 'user',
|
||||||
|
metadata: { imageUrls: ['https://example.com/old.png'] },
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: '上一张图片' },
|
||||||
|
{ type: 'image_url', image_url: { url: 'https://example.com/old.png' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('submitSessionReplyForUser applies the shared Qwen vision preprocessing path', async () => {
|
test('submitSessionReplyForUser applies the shared Qwen vision preprocessing path', async () => {
|
||||||
await withFakeGoosedSession(async ({ apiTarget, workingDir, replyBodies }) => {
|
await withFakeGoosedSession(async ({ apiTarget, workingDir, replyBodies }) => {
|
||||||
const proxy = createTkmindProxy({
|
const proxy = createTkmindProxy({
|
||||||
|
|||||||
+14
-2
@@ -50,12 +50,22 @@ export function ensureUserZoneDirs(workspaceRoot) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 上传完成后镜像到 MindSpace/<user>/<category>/文件名 */
|
/** 上传完成后镜像到 MindSpace/<user>/<category>/文件名 */
|
||||||
export function mirrorAssetToZone({ workspaceRoot, categoryCode, filename, sourcePath }) {
|
export function mirrorAssetToZone({
|
||||||
|
workspaceRoot,
|
||||||
|
categoryCode,
|
||||||
|
filename,
|
||||||
|
sourcePath,
|
||||||
|
overwrite = true,
|
||||||
|
}) {
|
||||||
if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return null;
|
if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return null;
|
||||||
if (!sourcePath || !fs.existsSync(sourcePath)) return null;
|
if (!sourcePath || !fs.existsSync(sourcePath)) return null;
|
||||||
ensureUserZoneDirs(workspaceRoot);
|
ensureUserZoneDirs(workspaceRoot);
|
||||||
const dest = resolveZoneFilePath(workspaceRoot, categoryCode, filename);
|
const dest = resolveZoneFilePath(workspaceRoot, categoryCode, filename);
|
||||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
// Layout bootstrap is a recovery/backfill path. Re-copying every canonical
|
||||||
|
// asset on every chat request changes workspace mtimes, can overwrite a
|
||||||
|
// newer Agent edit, and makes historical HTML look newly generated.
|
||||||
|
if (!overwrite && fs.existsSync(dest)) return dest;
|
||||||
fs.copyFileSync(sourcePath, dest);
|
fs.copyFileSync(sourcePath, dest);
|
||||||
return dest;
|
return dest;
|
||||||
}
|
}
|
||||||
@@ -165,7 +175,8 @@ export async function syncUserZonesFromAssets(pool, storageRoot, userId, workspa
|
|||||||
FROM h5_assets a
|
FROM h5_assets a
|
||||||
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
|
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
|
||||||
JOIN h5_asset_versions v ON v.id = a.current_version_id
|
JOIN h5_asset_versions v ON v.id = a.current_version_id
|
||||||
WHERE a.user_id = ? AND a.status <> 'deleted' AND c.category_code IN (${placeholders})`,
|
WHERE a.user_id = ? AND a.status <> 'deleted' AND c.category_code IN (${placeholders})
|
||||||
|
ORDER BY a.updated_at DESC`,
|
||||||
[userId, ...UPLOAD_ZONE_CODES],
|
[userId, ...UPLOAD_ZONE_CODES],
|
||||||
);
|
);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -175,6 +186,7 @@ export async function syncUserZonesFromAssets(pool, storageRoot, userId, workspa
|
|||||||
categoryCode: row.category_code,
|
categoryCode: row.category_code,
|
||||||
filename: row.original_filename,
|
filename: row.original_filename,
|
||||||
sourcePath,
|
sourcePath,
|
||||||
|
overwrite: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { workspaceRoot, mirrored: rows.length };
|
return { workspaceRoot, mirrored: rows.length };
|
||||||
|
|||||||
@@ -63,6 +63,31 @@ test('syncUserZonesFromAssets backfills from canonical storage', async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('syncUserZonesFromAssets does not overwrite an existing workspace edit', async () => {
|
||||||
|
const h5Root = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-existing-'));
|
||||||
|
const storageRoot = path.join(h5Root, 'data', 'mindspace');
|
||||||
|
const userId = USER_ID;
|
||||||
|
const storageKey = path.posix.join('users', userId, 'assets', 'page-1', 'versions', 'v1');
|
||||||
|
const sourcePath = path.join(storageRoot, storageKey);
|
||||||
|
fs.mkdirSync(path.dirname(sourcePath), { recursive: true });
|
||||||
|
fs.writeFileSync(sourcePath, '<html>stored version</html>');
|
||||||
|
const workspace = resolveUserWorkspaceRoot(h5Root, { id: userId, username: 'john' });
|
||||||
|
const workspacePath = path.join(workspace, 'public', 'page.html');
|
||||||
|
fs.mkdirSync(path.dirname(workspacePath), { recursive: true });
|
||||||
|
fs.writeFileSync(workspacePath, '<html>new Agent edit</html>');
|
||||||
|
const before = fs.statSync(workspacePath).mtimeMs;
|
||||||
|
const pool = {
|
||||||
|
async query() {
|
||||||
|
return [[{ original_filename: 'page.html', category_code: 'public', storage_key: storageKey }]];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await syncUserZonesFromAssets(pool, storageRoot, userId, workspace);
|
||||||
|
|
||||||
|
assert.equal(fs.readFileSync(workspacePath, 'utf8'), '<html>new Agent edit</html>');
|
||||||
|
assert.equal(fs.statSync(workspacePath).mtimeMs, before);
|
||||||
|
});
|
||||||
|
|
||||||
test('user space publishing guidance points to sandbox file tools', () => {
|
test('user space publishing guidance points to sandbox file tools', () => {
|
||||||
const workspaceRoot = `/var/h5/MindSpace/${USER_ID}`;
|
const workspaceRoot = `/var/h5/MindSpace/${USER_ID}`;
|
||||||
const hints = renderUserSpaceHints({
|
const hints = renderUserSpaceHints({
|
||||||
|
|||||||
@@ -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() ?? '',
|
||||||
|
|||||||
+257
-102
@@ -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,101 +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 } = {},
|
{ 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;
|
||||||
|
|
||||||
const userMessage = createUserMessage(prompt, metadata);
|
try {
|
||||||
if (submitReply) {
|
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
|
||||||
await submitReply({ sessionId, requestId, userMessage });
|
method: 'GET',
|
||||||
} else {
|
headers: { Accept: 'text/event-stream' },
|
||||||
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
|
signal: abortController.signal,
|
||||||
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 hasScopedAssistantUpdate = false;
|
if (submitReply) {
|
||||||
|
await submitReply({ sessionId, requestId, userMessage, signal: abortController.signal });
|
||||||
while (true) {
|
} else {
|
||||||
const { value, done } = await reader.read();
|
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
|
||||||
if (done) break;
|
method: 'POST',
|
||||||
buffer += decoder.decode(value, { stream: true });
|
body: JSON.stringify({
|
||||||
const frames = buffer.split('\n\n');
|
request_id: requestId,
|
||||||
buffer = frames.pop() ?? '';
|
user_message: userMessage,
|
||||||
for (const frame of frames) {
|
}),
|
||||||
let data = '';
|
signal: abortController.signal,
|
||||||
for (const line of frame.split('\n')) {
|
});
|
||||||
if (line.startsWith('data:')) data += line.slice(5).trim();
|
if (!replyResponse.ok) {
|
||||||
|
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;
|
||||||
} else if (event.type === 'UpdateConversation') {
|
try {
|
||||||
// Ignore unscoped snapshots until this request has yielded an assistant update.
|
event = JSON.parse(data);
|
||||||
// Otherwise a stale session snapshot can overwrite the current reply with a
|
} catch {
|
||||||
// previous page/link from the same WeChat-dedicated session.
|
continue;
|
||||||
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,
|
|
||||||
};
|
|
||||||
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;
|
||||||
@@ -413,6 +450,10 @@ function looksLikeHtmlGenerationIntent(text) {
|
|||||||
return isPageGenerateText(text);
|
return isPageGenerateText(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replyRequestMessages(reply) {
|
||||||
|
return reply?.requestMessages ?? reply?.messages ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
function looksLikeDocxDownloadIntent(text) {
|
function looksLikeDocxDownloadIntent(text) {
|
||||||
const normalized = String(text ?? '').trim();
|
const normalized = String(text ?? '').trim();
|
||||||
if (!normalized) return false;
|
if (!normalized) return false;
|
||||||
@@ -446,8 +487,8 @@ function hasAnyToolRequest(messages = []) {
|
|||||||
function isSuspiciousBareCompletionReply(reply, intent) {
|
function isSuspiciousBareCompletionReply(reply, intent) {
|
||||||
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
|
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
|
||||||
if (!isBareCompletionText(reply?.text)) return false;
|
if (!isBareCompletionText(reply?.text)) return false;
|
||||||
if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false;
|
if (extractHtmlWriteTargets(replyRequestMessages(reply)).length > 0) return false;
|
||||||
return !hasAnyToolRequest(reply?.messages ?? []);
|
return !hasAnyToolRequest(replyRequestMessages(reply));
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikePublishSuccessClaim(text) {
|
function looksLikePublishSuccessClaim(text) {
|
||||||
@@ -482,15 +523,15 @@ async function isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists = d
|
|||||||
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
|
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
|
||||||
const text = String(reply?.text ?? '').trim();
|
const text = String(reply?.text ?? '').trim();
|
||||||
if (!looksLikePublishSuccessClaim(text)) return false;
|
if (!looksLikePublishSuccessClaim(text)) return false;
|
||||||
if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false;
|
if (extractHtmlWriteTargets(replyRequestMessages(reply)).length > 0) return false;
|
||||||
return !(await hasAnyValidPublishedHtmlLink(text, linkExists));
|
return !(await hasAnyValidPublishedHtmlLink(text, linkExists));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMissingRequiredPublishSkill(reply, intent) {
|
function isMissingRequiredPublishSkill(reply, intent) {
|
||||||
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
|
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
|
||||||
const wroteHtml = extractHtmlWriteTargets(reply?.messages ?? []).length > 0;
|
const wroteHtml = extractHtmlWriteTargets(replyRequestMessages(reply)).length > 0;
|
||||||
if (!wroteHtml) return false;
|
if (!wroteHtml) return false;
|
||||||
return !usedStaticPagePublishSkill(reply?.messages ?? []);
|
return !usedStaticPagePublishSkill(replyRequestMessages(reply));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) {
|
function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) {
|
||||||
@@ -525,7 +566,7 @@ function ensurePublicHtmlArtifact(htmlPath, workingDir) {
|
|||||||
|
|
||||||
function collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }) {
|
function collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }) {
|
||||||
const artifacts = [];
|
const artifacts = [];
|
||||||
const htmlTargets = extractHtmlWriteTargets(reply?.messages ?? []);
|
const htmlTargets = extractHtmlWriteTargets(replyRequestMessages(reply));
|
||||||
for (const target of htmlTargets) {
|
for (const target of htmlTargets) {
|
||||||
const artifact = ensurePublicHtmlArtifact(target, workingDir);
|
const artifact = ensurePublicHtmlArtifact(target, workingDir);
|
||||||
if (!artifact) continue;
|
if (!artifact) continue;
|
||||||
@@ -671,7 +712,16 @@ function rewritePublishedHtmlLinks(text, artifacts = []) {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl, artifacts: providedArtifacts = null }) {
|
async function maybeAttachPublishedHtmlLink(
|
||||||
|
reply,
|
||||||
|
{
|
||||||
|
workingDir,
|
||||||
|
publicBaseUrl,
|
||||||
|
artifacts: providedArtifacts = null,
|
||||||
|
allowAttachment = true,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!allowAttachment) return String(reply?.text ?? '').trim();
|
||||||
const artifacts = Array.isArray(providedArtifacts)
|
const artifacts = Array.isArray(providedArtifacts)
|
||||||
? providedArtifacts
|
? providedArtifacts
|
||||||
: collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl });
|
: collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl });
|
||||||
@@ -935,12 +985,15 @@ function resolveHtmlPublishArtifacts({
|
|||||||
userId = '',
|
userId = '',
|
||||||
sessionId = '',
|
sessionId = '',
|
||||||
onPageGenerated = null,
|
onPageGenerated = null,
|
||||||
|
allowRecentArtifacts = true,
|
||||||
}) {
|
}) {
|
||||||
|
const artifactMessages = reply?.requestMessages ?? reply?.messages ?? [];
|
||||||
|
const artifactReply = { ...reply, messages: artifactMessages };
|
||||||
materializeMissingPublicHtmlWrites({
|
materializeMissingPublicHtmlWrites({
|
||||||
messages: reply?.messages ?? [],
|
messages: artifactMessages,
|
||||||
publishDir: workingDir,
|
publishDir: workingDir,
|
||||||
});
|
});
|
||||||
const publishedArtifacts = collectPublishedHtmlArtifacts(reply, {
|
const publishedArtifacts = collectPublishedHtmlArtifacts(artifactReply, {
|
||||||
workingDir,
|
workingDir,
|
||||||
publicBaseUrl,
|
publicBaseUrl,
|
||||||
});
|
});
|
||||||
@@ -948,12 +1001,14 @@ function resolveHtmlPublishArtifacts({
|
|||||||
workingDir,
|
workingDir,
|
||||||
publicBaseUrl,
|
publicBaseUrl,
|
||||||
});
|
});
|
||||||
const recentArtifacts = collectRecentPublishedHtmlArtifacts(intent, {
|
const recentArtifacts = allowRecentArtifacts
|
||||||
workingDir,
|
? collectRecentPublishedHtmlArtifacts(intent, {
|
||||||
publicBaseUrl,
|
workingDir,
|
||||||
replyText: reply?.text,
|
publicBaseUrl,
|
||||||
sinceMs: requestStartedAt,
|
replyText: reply?.text,
|
||||||
});
|
sinceMs: requestStartedAt,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
const confirmedArtifacts = allExistingHtmlArtifacts({
|
const confirmedArtifacts = allExistingHtmlArtifacts({
|
||||||
publishedArtifacts,
|
publishedArtifacts,
|
||||||
expectedArtifacts,
|
expectedArtifacts,
|
||||||
@@ -965,7 +1020,11 @@ function resolveHtmlPublishArtifacts({
|
|||||||
recentArtifacts,
|
recentArtifacts,
|
||||||
replyText: reply?.text,
|
replyText: reply?.text,
|
||||||
});
|
});
|
||||||
if (typeof onPageGenerated === 'function' && confirmedArtifacts.length > 0) {
|
if (
|
||||||
|
typeof onPageGenerated === 'function'
|
||||||
|
&& confirmedArtifacts.length > 0
|
||||||
|
&& (allowRecentArtifacts || publishedArtifacts.length > 0)
|
||||||
|
) {
|
||||||
void onPageGenerated({ userId, sessionId, artifacts: confirmedArtifacts });
|
void onPageGenerated({ userId, sessionId, artifacts: confirmedArtifacts });
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -1016,7 +1075,7 @@ export function shouldRetryHtmlGenerationReply({
|
|||||||
if (isMissingRequiredPublishSkill(reply, intent) || isSuspiciousBareCompletionReply(reply, intent)) {
|
if (isMissingRequiredPublishSkill(reply, intent) || isSuspiciousBareCompletionReply(reply, intent)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!usedStaticPagePublishSkill(reply?.messages ?? [])) return false;
|
if (!usedStaticPagePublishSkill(replyRequestMessages(reply))) return false;
|
||||||
return !hasAnyUrl(reply?.text);
|
return !hasAnyUrl(reply?.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1039,11 +1098,22 @@ export function isRecoverableWechatAgentSessionError(message) {
|
|||||||
if (/wechat_page_fresh_thumbnail_required:/i.test(normalized)) return true;
|
if (/wechat_page_fresh_thumbnail_required:/i.test(normalized)) return true;
|
||||||
if (/403|404|not found|无权访问/i.test(normalized)) return true;
|
if (/403|404|not found|无权访问/i.test(normalized)) return true;
|
||||||
if (/tool_calls|tool_call_id|insufficient tool messages/i.test(normalized)) return true;
|
if (/tool_calls|tool_call_id|insufficient tool messages/i.test(normalized)) return true;
|
||||||
|
if (/historical_image_session_update_unsupported|unknown variant [`']?image_url/i.test(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (/session already has an active request|active request.*cancel/i.test(normalized)) return true;
|
if (/session already has an active request|active request.*cancel/i.test(normalized)) return true;
|
||||||
if (/wechat_agent_incomplete_reply/i.test(normalized)) return true;
|
if (/wechat_agent_incomplete_reply/i.test(normalized)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldDeliverWechatHtmlArtifacts(wechatIntent, intent) {
|
||||||
|
return (
|
||||||
|
wechatIntent?.kind === 'page.generate'
|
||||||
|
|| looksLikeHtmlGenerationIntent(intent?.agentText)
|
||||||
|
|| isPageDataIntent(intent?.agentText)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function collectWechatAgentReplyVisibleTexts(reply) {
|
function collectWechatAgentReplyVisibleTexts(reply) {
|
||||||
const texts = [];
|
const texts = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -1054,7 +1124,7 @@ function collectWechatAgentReplyVisibleTexts(reply) {
|
|||||||
texts.push(normalized);
|
texts.push(normalized);
|
||||||
};
|
};
|
||||||
append(reply?.text);
|
append(reply?.text);
|
||||||
for (const message of reply?.messages ?? []) {
|
for (const message of replyRequestMessages(reply)) {
|
||||||
if (message?.role !== 'assistant') continue;
|
if (message?.role !== 'assistant') continue;
|
||||||
append(messageVisibleText(message));
|
append(messageVisibleText(message));
|
||||||
}
|
}
|
||||||
@@ -1504,6 +1574,7 @@ export function createWechatMpService({
|
|||||||
scheduleService = null,
|
scheduleService = null,
|
||||||
wechatScheduleLlmConfigService = null,
|
wechatScheduleLlmConfigService = null,
|
||||||
llmProviderService = null,
|
llmProviderService = null,
|
||||||
|
chatIntentRouter = null,
|
||||||
onPageGenerated = null,
|
onPageGenerated = null,
|
||||||
applySessionLlmProvider = null,
|
applySessionLlmProvider = null,
|
||||||
refreshSessionSnapshot = null,
|
refreshSessionSnapshot = null,
|
||||||
@@ -1537,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,
|
||||||
@@ -1852,7 +1930,7 @@ export function createWechatMpService({
|
|||||||
notifyFailure = true,
|
notifyFailure = true,
|
||||||
}) => {
|
}) => {
|
||||||
if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return;
|
if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return;
|
||||||
const images = collectWechatGeneratedImages(reply?.messages ?? []);
|
const images = collectWechatGeneratedImages(replyRequestMessages(reply));
|
||||||
let verification = verifyFreshWechatPageThumbnails(artifacts, images);
|
let verification = verifyFreshWechatPageThumbnails(artifacts, images);
|
||||||
if (!verification.ok) {
|
if (!verification.ok) {
|
||||||
logger.warn?.(
|
logger.warn?.(
|
||||||
@@ -1863,7 +1941,7 @@ export function createWechatMpService({
|
|||||||
const repair = repairUnambiguousFreshWechatPageThumbnail({
|
const repair = repairUnambiguousFreshWechatPageThumbnail({
|
||||||
artifacts,
|
artifacts,
|
||||||
images,
|
images,
|
||||||
currentRunHtmlArtifacts: extractPublicHtmlWriteArtifacts(reply?.messages ?? [], {
|
currentRunHtmlArtifacts: extractPublicHtmlWriteArtifacts(replyRequestMessages(reply), {
|
||||||
publishDir,
|
publishDir,
|
||||||
}),
|
}),
|
||||||
verificationReason: verification.reason,
|
verificationReason: verification.reason,
|
||||||
@@ -2232,9 +2310,54 @@ export function createWechatMpService({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const prepareWechatAgentUserMessage = async ({ userId, sessionId, userMessage }) => {
|
||||||
|
if (
|
||||||
|
!chatIntentRouter?.resolveAgentMemoryContext
|
||||||
|
|| !chatIntentRouter?.applyAgentOrchestration
|
||||||
|
) {
|
||||||
|
return userMessage;
|
||||||
|
}
|
||||||
|
const displayText = String(
|
||||||
|
userMessage?.metadata?.displayText
|
||||||
|
?? messageVisibleText(userMessage),
|
||||||
|
).trim();
|
||||||
|
try {
|
||||||
|
const memoryContext = await chatIntentRouter.resolveAgentMemoryContext({
|
||||||
|
userId,
|
||||||
|
sessionId,
|
||||||
|
text: displayText,
|
||||||
|
forceDeepReasoning: false,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!memoryContext?.injectionEnabled
|
||||||
|
|| !Array.isArray(memoryContext.memories)
|
||||||
|
|| memoryContext.memories.length === 0
|
||||||
|
) {
|
||||||
|
return userMessage;
|
||||||
|
}
|
||||||
|
return chatIntentRouter.applyAgentOrchestration(
|
||||||
|
userMessage,
|
||||||
|
{
|
||||||
|
route: 'agent_orchestration',
|
||||||
|
reason: '微信服务号消息由 Agent 处理',
|
||||||
|
source: 'wechat_mp',
|
||||||
|
},
|
||||||
|
{ memoryContext },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn?.(
|
||||||
|
'WeChat MP agent memory resolve skipped:',
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
return userMessage;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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({
|
||||||
@@ -2246,6 +2369,7 @@ export function createWechatMpService({
|
|||||||
// policies. Reusing a conversational route here can make a new request
|
// policies. Reusing a conversational route here can make a new request
|
||||||
// inspect/retry unrelated historical pages from that session.
|
// inspect/retry unrelated historical pages from that session.
|
||||||
const isPageDataRequest = isPageDataIntent(resetCandidate);
|
const isPageDataRequest = isPageDataIntent(resetCandidate);
|
||||||
|
const htmlArtifactDeliveryExpected = shouldDeliverWechatHtmlArtifacts(wechatIntent, intent);
|
||||||
const forceNew = shouldForceNewWechatAgentSession(wechatIntent, resetCandidate);
|
const forceNew = shouldForceNewWechatAgentSession(wechatIntent, resetCandidate);
|
||||||
let route = await ensureWechatAgentSession({
|
let route = await ensureWechatAgentSession({
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
@@ -2294,6 +2418,11 @@ export function createWechatMpService({
|
|||||||
agentPrompt,
|
agentPrompt,
|
||||||
buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
|
buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
|
||||||
{
|
{
|
||||||
|
prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({
|
||||||
|
userId: user.userId,
|
||||||
|
sessionId,
|
||||||
|
userMessage,
|
||||||
|
}),
|
||||||
submitReply: submitSessionReply
|
submitReply: submitSessionReply
|
||||||
? ({ requestId: replyRequestId, userMessage }) =>
|
? ({ requestId: replyRequestId, userMessage }) =>
|
||||||
submitSessionReply({
|
submitSessionReply({
|
||||||
@@ -2301,11 +2430,13 @@ export function createWechatMpService({
|
|||||||
sessionId,
|
sessionId,
|
||||||
requestId: replyRequestId,
|
requestId: replyRequestId,
|
||||||
userMessage,
|
userMessage,
|
||||||
|
options: { requireHistoricalImageIsolation: true },
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
|
timeoutMs: agentReplyTimeoutMs,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const generatedImages = collectWechatGeneratedImages(reply?.messages ?? []);
|
const generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply));
|
||||||
if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) {
|
if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) {
|
||||||
const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试');
|
const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试');
|
||||||
error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED';
|
error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED';
|
||||||
@@ -2328,6 +2459,7 @@ export function createWechatMpService({
|
|||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
sessionId,
|
sessionId,
|
||||||
onPageGenerated,
|
onPageGenerated,
|
||||||
|
allowRecentArtifacts: htmlArtifactDeliveryExpected,
|
||||||
});
|
});
|
||||||
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, {
|
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, {
|
||||||
confirmedArtifacts,
|
confirmedArtifacts,
|
||||||
@@ -2344,7 +2476,10 @@ export function createWechatMpService({
|
|||||||
hasValidLinkInReply,
|
hasValidLinkInReply,
|
||||||
});
|
});
|
||||||
const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent);
|
const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent);
|
||||||
let publishArtifacts = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts });
|
let publishArtifacts =
|
||||||
|
htmlArtifactDeliveryExpected || publishedArtifacts.length > 0
|
||||||
|
? selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts })
|
||||||
|
: [];
|
||||||
|
|
||||||
if (wechatIntent.kind === 'page.generate') {
|
if (wechatIntent.kind === 'page.generate') {
|
||||||
const pageOutcome = resolvePageGenerateOutcome({
|
const pageOutcome = resolvePageGenerateOutcome({
|
||||||
@@ -2430,6 +2565,7 @@ export function createWechatMpService({
|
|||||||
workingDir,
|
workingDir,
|
||||||
publicBaseUrl: config.publicBaseUrl,
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
artifacts: publishArtifacts,
|
artifacts: publishArtifacts,
|
||||||
|
allowAttachment: publishArtifacts.length > 0,
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
wechatIntent.kind !== 'page.generate'
|
wechatIntent.kind !== 'page.generate'
|
||||||
@@ -2451,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
|
||||||
@@ -2469,7 +2610,9 @@ export function createWechatMpService({
|
|||||||
}
|
}
|
||||||
throw markWechatUserNotified(err instanceof Error ? err : new Error(message));
|
throw markWechatUserNotified(err instanceof Error ? err : new Error(message));
|
||||||
}
|
}
|
||||||
const mayBeStaleSession = sessionId && isRecoverableWechatAgentSessionError(message);
|
const mayBeStaleSession =
|
||||||
|
sessionId
|
||||||
|
&& (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message));
|
||||||
if (mayBeStaleSession) {
|
if (mayBeStaleSession) {
|
||||||
route = await ensureWechatAgentSession({
|
route = await ensureWechatAgentSession({
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
@@ -2496,6 +2639,11 @@ export function createWechatMpService({
|
|||||||
retryPrompt,
|
retryPrompt,
|
||||||
buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
|
buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
|
||||||
{
|
{
|
||||||
|
prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({
|
||||||
|
userId: user.userId,
|
||||||
|
sessionId,
|
||||||
|
userMessage,
|
||||||
|
}),
|
||||||
submitReply: submitSessionReply
|
submitReply: submitSessionReply
|
||||||
? ({ requestId: replyRequestId, userMessage }) =>
|
? ({ requestId: replyRequestId, userMessage }) =>
|
||||||
submitSessionReply({
|
submitSessionReply({
|
||||||
@@ -2503,11 +2651,13 @@ export function createWechatMpService({
|
|||||||
sessionId,
|
sessionId,
|
||||||
requestId: replyRequestId,
|
requestId: replyRequestId,
|
||||||
userMessage,
|
userMessage,
|
||||||
|
options: { requireHistoricalImageIsolation: true },
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
|
timeoutMs: agentReplyTimeoutMs,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const generatedImages = collectWechatGeneratedImages(reply?.messages ?? []);
|
const generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply));
|
||||||
if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) {
|
if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) {
|
||||||
const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试');
|
const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试');
|
||||||
error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED';
|
error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED';
|
||||||
@@ -2530,6 +2680,7 @@ export function createWechatMpService({
|
|||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
sessionId,
|
sessionId,
|
||||||
onPageGenerated,
|
onPageGenerated,
|
||||||
|
allowRecentArtifacts: htmlArtifactDeliveryExpected,
|
||||||
});
|
});
|
||||||
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, {
|
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, {
|
||||||
confirmedArtifacts,
|
confirmedArtifacts,
|
||||||
@@ -2546,7 +2697,10 @@ export function createWechatMpService({
|
|||||||
hasValidLinkInReply,
|
hasValidLinkInReply,
|
||||||
});
|
});
|
||||||
const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent);
|
const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent);
|
||||||
let publishArtifacts = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts });
|
let publishArtifacts =
|
||||||
|
htmlArtifactDeliveryExpected || publishedArtifacts.length > 0
|
||||||
|
? selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts })
|
||||||
|
: [];
|
||||||
|
|
||||||
if (wechatIntent.kind === 'page.generate') {
|
if (wechatIntent.kind === 'page.generate') {
|
||||||
const pageOutcome = resolvePageGenerateOutcome({
|
const pageOutcome = resolvePageGenerateOutcome({
|
||||||
@@ -2628,6 +2782,7 @@ export function createWechatMpService({
|
|||||||
workingDir,
|
workingDir,
|
||||||
publicBaseUrl: config.publicBaseUrl,
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
artifacts: publishArtifacts,
|
artifacts: publishArtifacts,
|
||||||
|
allowAttachment: publishArtifacts.length > 0,
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
wechatIntent.kind !== 'page.generate'
|
wechatIntent.kind !== 'page.generate'
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ 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';
|
||||||
|
|
||||||
function signatureFor(token, timestamp, nonce) {
|
function signatureFor(token, timestamp, nonce) {
|
||||||
return crypto
|
return crypto
|
||||||
@@ -68,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, '\\"');
|
||||||
}
|
}
|
||||||
@@ -82,6 +111,7 @@ function createBoundWechatService({
|
|||||||
scheduleService = null,
|
scheduleService = null,
|
||||||
applySessionLlmProvider = null,
|
applySessionLlmProvider = null,
|
||||||
submitSessionReply = null,
|
submitSessionReply = null,
|
||||||
|
chatIntentRouter = null,
|
||||||
}) {
|
}) {
|
||||||
return createWechatMpService({
|
return createWechatMpService({
|
||||||
config: {
|
config: {
|
||||||
@@ -133,6 +163,7 @@ function createBoundWechatService({
|
|||||||
startAgentSession,
|
startAgentSession,
|
||||||
sessionApiFetch,
|
sessionApiFetch,
|
||||||
submitSessionReply,
|
submitSessionReply,
|
||||||
|
chatIntentRouter,
|
||||||
scheduleService,
|
scheduleService,
|
||||||
applySessionLlmProvider,
|
applySessionLlmProvider,
|
||||||
wechatFetch,
|
wechatFetch,
|
||||||
@@ -157,6 +188,7 @@ test('Page Data requests always rotate away from an existing WeChat route', () =
|
|||||||
);
|
);
|
||||||
assert.equal(shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '继续聊德川家康'), false);
|
assert.equal(shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '继续聊德川家康'), false);
|
||||||
assert.equal(shouldForceNewWechatAgentSession({ kind: 'session.reset' }, '继续聊德川家康'), true);
|
assert.equal(shouldForceNewWechatAgentSession({ kind: 'session.reset' }, '继续聊德川家康'), true);
|
||||||
|
assert.equal(shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '换新会话'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildWechatAgentPrompt requires docx generation before html when Word download is requested', () => {
|
test('buildWechatAgentPrompt requires docx generation before html when Word download is requested', () => {
|
||||||
@@ -549,6 +581,107 @@ test('maybeAttachPublishedHtmlLink can attach a verified existing public html li
|
|||||||
|
|
||||||
assert.match(text, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/thailand-guide\.html/);
|
assert.match(text, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/thailand-guide\.html/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('maybeAttachPublishedHtmlLink leaves normal chat untouched when attachment is disabled', async () => {
|
||||||
|
const text = await maybeAttachPublishedHtmlLink(
|
||||||
|
{ text: '这是普通聊天回复。', messages: [] },
|
||||||
|
{
|
||||||
|
workingDir: '/tmp/user-1',
|
||||||
|
publicBaseUrl: 'https://m.tkmind.cn',
|
||||||
|
artifacts: [
|
||||||
|
{
|
||||||
|
localPath: '/tmp/user-1/public/old.html',
|
||||||
|
relativePath: 'public/old.html',
|
||||||
|
url: 'https://m.tkmind.cn/MindSpace/user-1/public/old.html',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
allowAttachment: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(text, '这是普通聊天回复。');
|
||||||
|
assert.doesNotMatch(text, /查看页面|old\.html/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wechat mp normal chat ignores historical html touched during the request', async (t) => {
|
||||||
|
const token = 'token';
|
||||||
|
const timestamp = '1710000000';
|
||||||
|
const nonce = 'nonce';
|
||||||
|
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-normal-html-');
|
||||||
|
const oldHtmlPath = path.join(workspaceRoot, 'public', 'old-page.html');
|
||||||
|
const sentPayloads = [];
|
||||||
|
t.after(() => fs.rmSync(workspaceRoot, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const service = createBoundWechatService({
|
||||||
|
token,
|
||||||
|
userAuth: {
|
||||||
|
async resolveWorkingDir() {
|
||||||
|
return workspaceRoot;
|
||||||
|
},
|
||||||
|
async getUserPublishLayout() {
|
||||||
|
return {
|
||||||
|
publishDir: workspaceRoot,
|
||||||
|
displayName: 'John',
|
||||||
|
username: 'john',
|
||||||
|
slug: 'john',
|
||||||
|
constraints: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sessionApiFetch: async (_sessionId, pathname) => {
|
||||||
|
if (pathname === '/sessions/session-1/events') {
|
||||||
|
return new Response(
|
||||||
|
[
|
||||||
|
'data: {"type":"Message","request_id":"req-normal-html","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"这是普通路线建议。"}]}}\n\n',
|
||||||
|
'data: {"type":"Finish","request_id":"req-normal-html","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
|
||||||
|
].join(''),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pathname === '/sessions/session-1/reply') {
|
||||||
|
fs.mkdirSync(path.dirname(oldHtmlPath), { recursive: true });
|
||||||
|
fs.writeFileSync(oldHtmlPath, '<!doctype html><title>Old</title>');
|
||||||
|
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||||
|
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected api path: ${pathname}`);
|
||||||
|
},
|
||||||
|
wechatFetch: async (url, init = {}) => {
|
||||||
|
if (String(url).includes('/cgi-bin/stable_token')) {
|
||||||
|
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (String(url).includes('/cgi-bin/message/custom/send')) {
|
||||||
|
sentPayloads.push(JSON.parse(init.body));
|
||||||
|
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected wechat url: ${url}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalRandomUuid = crypto.randomUUID;
|
||||||
|
crypto.randomUUID = () => 'req-normal-html';
|
||||||
|
try {
|
||||||
|
const result = await service.handleInboundMessage(
|
||||||
|
inboundXml({ content: '帮我推荐一条跑步路线' }),
|
||||||
|
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||||
|
);
|
||||||
|
await result.task;
|
||||||
|
} finally {
|
||||||
|
crypto.randomUUID = originalRandomUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(sentPayloads.length, 1);
|
||||||
|
assert.equal(sentPayloads[0].text.content, '这是普通路线建议。');
|
||||||
|
assert.doesNotMatch(sentPayloads[0].text.content, /查看页面|old-page\.html/);
|
||||||
|
});
|
||||||
test('wechat mp service splits long agent replies into multiple customer messages', async () => {
|
test('wechat mp service splits long agent replies into multiple customer messages', async () => {
|
||||||
const token = 'token';
|
const token = 'token';
|
||||||
const timestamp = '1710000000';
|
const timestamp = '1710000000';
|
||||||
@@ -2736,9 +2869,111 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history',
|
|||||||
isRecoverableWechatAgentSessionError('Session already has an active request. Cancel it first.'),
|
isRecoverableWechatAgentSessionError('Session already has an active request. Cancel it first.'),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
isRecoverableWechatAgentSessionError(
|
||||||
|
'Request failed: Bad request (400): messages[4]: unknown variant `image_url`, expected `text`',
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isRecoverableWechatAgentSessionError('historical_image_session_update_unsupported:405'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
assert.equal(isRecoverableWechatAgentSessionError('network timeout'), false);
|
assert.equal(isRecoverableWechatAgentSessionError('network timeout'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('wechat mp rotates and retries when historical image isolation is unsupported', async () => {
|
||||||
|
const token = 'token';
|
||||||
|
const timestamp = '1710000000';
|
||||||
|
const nonce = 'nonce';
|
||||||
|
const submittedSessions = [];
|
||||||
|
const sentPayloads = [];
|
||||||
|
let activeSessionId = 'session-1';
|
||||||
|
let routeCleared = false;
|
||||||
|
|
||||||
|
const service = createBoundWechatService({
|
||||||
|
token,
|
||||||
|
startAgentSession: async () => ({ id: 'session-2' }),
|
||||||
|
userAuth: {
|
||||||
|
async getWechatAgentRoute() {
|
||||||
|
return routeCleared ? null : { agentSessionId: activeSessionId, status: 'active' };
|
||||||
|
},
|
||||||
|
async clearWechatAgentRoute() {
|
||||||
|
routeCleared = true;
|
||||||
|
},
|
||||||
|
async upsertWechatAgentRoute({ agentSessionId }) {
|
||||||
|
activeSessionId = agentSessionId;
|
||||||
|
routeCleared = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
submitSessionReply: async ({ sessionId, options }) => {
|
||||||
|
submittedSessions.push(sessionId);
|
||||||
|
assert.equal(options?.requireHistoricalImageIsolation, true);
|
||||||
|
if (sessionId === 'session-1') {
|
||||||
|
throw new Error('historical_image_session_update_unsupported:405');
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
sessionApiFetch: async (sessionId, pathname) => {
|
||||||
|
if (pathname === `/sessions/${sessionId}/events`) {
|
||||||
|
if (sessionId === 'session-1') {
|
||||||
|
return new Response('', {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'text/event-stream' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(
|
||||||
|
[
|
||||||
|
'data: {"type":"Message","request_id":"req-image-retry","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"新会话已恢复,可以继续。"}]}}\n\n',
|
||||||
|
'data: {"type":"Finish","request_id":"req-image-retry","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
|
||||||
|
].join(''),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||||
|
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected api path: ${sessionId} ${pathname}`);
|
||||||
|
},
|
||||||
|
wechatFetch: async (url, init = {}) => {
|
||||||
|
if (String(url).includes('/cgi-bin/stable_token')) {
|
||||||
|
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (String(url).includes('/cgi-bin/message/custom/send')) {
|
||||||
|
sentPayloads.push(JSON.parse(init.body));
|
||||||
|
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected wechat url: ${url}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalRandomUuid = crypto.randomUUID;
|
||||||
|
crypto.randomUUID = (() => {
|
||||||
|
const ids = ['req-image-first', 'req-image-retry'];
|
||||||
|
return () => ids.shift() ?? 'req-image-retry';
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
const result = await service.handleInboundMessage(
|
||||||
|
inboundXml({ content: '继续分析' }),
|
||||||
|
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||||
|
);
|
||||||
|
await result.task;
|
||||||
|
} finally {
|
||||||
|
crypto.randomUUID = originalRandomUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(submittedSessions, ['session-1', 'session-2']);
|
||||||
|
assert.equal(activeSessionId, 'session-2');
|
||||||
|
assert.equal(sentPayloads.length, 1);
|
||||||
|
assert.equal(sentPayloads[0].text.content, '新会话已恢复,可以继续。');
|
||||||
|
});
|
||||||
|
|
||||||
test('findRecoverableWechatAgentErrorInReply scans all assistant messages', () => {
|
test('findRecoverableWechatAgentErrorInReply scans all assistant messages', () => {
|
||||||
const toolCallsError =
|
const toolCallsError =
|
||||||
"Request failed: Bad request (400): An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message).";
|
"Request failed: Bad request (400): An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message).";
|
||||||
@@ -3935,6 +4170,102 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () =>
|
|||||||
assert.match(submitCalls[0].userMessage.metadata.imageUrls[0], /\/public\/wechat-mp\//);
|
assert.match(submitCalls[0].userMessage.metadata.imageUrls[0], /\/public\/wechat-mp\//);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('wechat mp injects episodic recall context before submitting the Agent reply', async () => {
|
||||||
|
const token = 'token';
|
||||||
|
const timestamp = '1710000000';
|
||||||
|
const nonce = 'nonce';
|
||||||
|
const submitCalls = [];
|
||||||
|
const episodicCalls = [];
|
||||||
|
const chatIntentRouter = createChatIntentRouter({
|
||||||
|
env: {
|
||||||
|
MEMORY_AGENT_RESOLVE_ENABLED: '1',
|
||||||
|
MEMORY_AGENT_INJECTION_MODE: 'canary',
|
||||||
|
MEMORY_AGENT_CANARY_USER_IDS: 'user-canary',
|
||||||
|
MEMORY_AGENT_RESOLVE_TIMEOUT_MS: '200',
|
||||||
|
},
|
||||||
|
memoryV2: {
|
||||||
|
async resolve() {
|
||||||
|
return { memories: [] };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
episodicMemoryService: {
|
||||||
|
async resolve(input) {
|
||||||
|
episodicCalls.push(input);
|
||||||
|
return {
|
||||||
|
source: 'episodic-index',
|
||||||
|
memories: [{
|
||||||
|
id: 'episodic:tokugawa',
|
||||||
|
label: '历史会话',
|
||||||
|
text: '用户与助手之前讨论过德川家康。',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const service = createBoundWechatService({
|
||||||
|
token,
|
||||||
|
chatIntentRouter,
|
||||||
|
userAuth: {
|
||||||
|
async findWechatUserByOpenid() {
|
||||||
|
return { userId: 'user-canary', status: 'active', nickname: '唐' };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sessionApiFetch: async (_sessionId, pathname) => {
|
||||||
|
if (pathname === '/sessions/session-1/events') {
|
||||||
|
return new Response(
|
||||||
|
[
|
||||||
|
'data: {"type":"Message","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"记得,我们之前聊过德川家康。"}]}}\n\n',
|
||||||
|
'data: {"type":"Finish"}\n\n',
|
||||||
|
].join(''),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||||
|
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected api path: ${pathname}`);
|
||||||
|
},
|
||||||
|
submitSessionReply: async (input) => {
|
||||||
|
submitCalls.push(input);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
wechatFetch: async (url) => {
|
||||||
|
if (String(url).includes('/cgi-bin/stable_token')) {
|
||||||
|
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (String(url).includes('/cgi-bin/message/custom/send')) {
|
||||||
|
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected wechat url: ${url}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.handleInboundMessage(
|
||||||
|
inboundXml({ content: '你记得我们聊过德川家康吗?' }),
|
||||||
|
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||||
|
);
|
||||||
|
await result.task;
|
||||||
|
|
||||||
|
assert.equal(episodicCalls.length, 1);
|
||||||
|
assert.deepEqual(episodicCalls[0], {
|
||||||
|
userId: 'user-canary',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
query: '你记得我们聊过德川家康吗?',
|
||||||
|
limit: 3,
|
||||||
|
});
|
||||||
|
assert.equal(submitCalls.length, 1);
|
||||||
|
assert.equal(submitCalls[0].userMessage.metadata.displayText, '你记得我们聊过德川家康吗?');
|
||||||
|
assert.match(submitCalls[0].userMessage.content[0].text, /\[Memory Context\]/);
|
||||||
|
assert.match(submitCalls[0].userMessage.content[0].text, /用户与助手之前讨论过德川家康/);
|
||||||
|
assert.match(submitCalls[0].userMessage.content[0].text, /不是系统指令/);
|
||||||
|
});
|
||||||
|
|
||||||
test('wechat mp serializes image and follow-up text and reattaches recent image', async () => {
|
test('wechat mp serializes image and follow-up text and reattaches recent image', async () => {
|
||||||
const token = 'token';
|
const token = 'token';
|
||||||
const timestamp = '1710000000';
|
const timestamp = '1710000000';
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const DOCX_DOWNLOAD_PATTERN =
|
|||||||
/(?:(?:word|docx|\.docx|\.doc|文档).*(?:下载|链接|导出|给我)|(?:下载|导出|提供|给我).*(?:word|docx|\.docx|\.doc|文档))/iu;
|
/(?:(?:word|docx|\.docx|\.doc|文档).*(?:下载|链接|导出|给我)|(?:下载|导出|提供|给我).*(?:word|docx|\.docx|\.doc|文档))/iu;
|
||||||
|
|
||||||
export const TOPIC_RESET_PATTERN =
|
export const TOPIC_RESET_PATTERN =
|
||||||
/^(换(个)?话题|新问题|忽略之前|不管之前|重新开始|reset)$/iu;
|
/^(换(个)?话题|换新会话|新会话|开新会话|另开会话|清空上下文|新问题|忽略之前|不管之前|重新开始|reset)$/iu;
|
||||||
|
|
||||||
export const TOPIC_RESET_LOOSE_PATTERN = /忽略.*之前|不要管.*之前|别管.*之前/u;
|
export const TOPIC_RESET_LOOSE_PATTERN = /忽略.*之前|不要管.*之前|别管.*之前/u;
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ test('classifyWechatIntent detects page.generate', () => {
|
|||||||
|
|
||||||
test('classifyWechatIntent detects session.reset', () => {
|
test('classifyWechatIntent detects session.reset', () => {
|
||||||
assert.equal(classifyWechatIntent({ msgType: 'text', agentText: '换话题' }).kind, 'session.reset');
|
assert.equal(classifyWechatIntent({ msgType: 'text', agentText: '换话题' }).kind, 'session.reset');
|
||||||
|
assert.equal(classifyWechatIntent({ msgType: 'text', agentText: '换新会话' }).kind, 'session.reset');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('selectSendableHtmlArtifacts never returns stub html', () => {
|
test('selectSendableHtmlArtifacts never returns stub html', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user