Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 558c4ef2ee | |||
| 53b0d2c62f |
@@ -72,6 +72,49 @@ export function shouldScheduleMissingActiveRequestGrace({
|
|||||||
return allowMissingGrace && !agentRunPending;
|
return allowMissingGrace && !agentRunPending;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const QUEUED_CHAT_SUBMIT_NOTICE =
|
||||||
|
'已收到你的消息,将在当前任务完成后自动继续执行。';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | undefined | null} chatState
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isChatSubmitBusy(chatState) {
|
||||||
|
return (
|
||||||
|
chatState === 'streaming' ||
|
||||||
|
chatState === 'loading' ||
|
||||||
|
chatState === 'connecting' ||
|
||||||
|
chatState === 'waiting'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number | undefined | null} queueLength
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function buildQueuedChatSubmitNotice(queueLength = 1) {
|
||||||
|
const length = Math.max(1, Number(queueLength) || 1);
|
||||||
|
if (length <= 1) return QUEUED_CHAT_SUBMIT_NOTICE;
|
||||||
|
return `已收到你的消息,当前还有 ${length} 条待执行,将在任务完成后按顺序继续。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ chatState?: string; agentRunPending?: boolean; pendingTool?: boolean; queueLength?: number }} input
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function canFlushQueuedChatSubmit({
|
||||||
|
chatState = 'idle',
|
||||||
|
agentRunPending = false,
|
||||||
|
pendingTool = false,
|
||||||
|
queueLength = 0,
|
||||||
|
} = {}) {
|
||||||
|
if (!queueLength || queueLength <= 0) return false;
|
||||||
|
if (isChatSubmitBusy(chatState)) return false;
|
||||||
|
if (agentRunPending) return false;
|
||||||
|
if (pendingTool) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Only transport uncertainty may continue a run after submit fails. A
|
* Only transport uncertainty may continue a run after submit fails. A
|
||||||
* deterministic gateway error already has a terminal outcome and must return
|
* deterministic gateway error already has a terminal outcome and must return
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
|
buildQueuedChatSubmitNotice,
|
||||||
|
canFlushQueuedChatSubmit,
|
||||||
|
isChatSubmitBusy,
|
||||||
reconcileSessionEventRequestContext,
|
reconcileSessionEventRequestContext,
|
||||||
resolvePostAgentRunChatState,
|
resolvePostAgentRunChatState,
|
||||||
shouldIgnoreZeroActivityFinish,
|
shouldIgnoreZeroActivityFinish,
|
||||||
@@ -145,6 +148,52 @@ test('missing ActiveRequests cannot unlock while the Portal agent-run is pending
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('isChatSubmitBusy covers active composer states', () => {
|
||||||
|
assert.equal(isChatSubmitBusy('idle'), false);
|
||||||
|
assert.equal(isChatSubmitBusy('error'), false);
|
||||||
|
assert.equal(isChatSubmitBusy('waiting'), true);
|
||||||
|
assert.equal(isChatSubmitBusy('streaming'), true);
|
||||||
|
assert.equal(isChatSubmitBusy('connecting'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildQueuedChatSubmitNotice reflects queue depth', () => {
|
||||||
|
assert.match(buildQueuedChatSubmitNotice(1), /当前任务完成后/);
|
||||||
|
assert.match(buildQueuedChatSubmitNotice(2), /2 条待执行/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canFlushQueuedChatSubmit waits for idle composer without pending tool or run', () => {
|
||||||
|
assert.equal(
|
||||||
|
canFlushQueuedChatSubmit({
|
||||||
|
chatState: 'idle',
|
||||||
|
queueLength: 1,
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
canFlushQueuedChatSubmit({
|
||||||
|
chatState: 'waiting',
|
||||||
|
queueLength: 1,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
canFlushQueuedChatSubmit({
|
||||||
|
chatState: 'idle',
|
||||||
|
agentRunPending: true,
|
||||||
|
queueLength: 1,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
canFlushQueuedChatSubmit({
|
||||||
|
chatState: 'idle',
|
||||||
|
pendingTool: true,
|
||||||
|
queueLength: 1,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('reconcileSessionEventRequestContext adopts Goose request id while agent-run gate waits', () => {
|
test('reconcileSessionEventRequestContext adopts Goose request id while agent-run gate waits', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
reconcileSessionEventRequestContext({
|
reconcileSessionEventRequestContext({
|
||||||
|
|||||||
@@ -46,3 +46,34 @@ export async function markPageDeliveryContractReady({ pool, userId, relativePath
|
|||||||
);
|
);
|
||||||
return Number(result?.affectedRows ?? 0) > 0;
|
return Number(result?.affectedRows ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function releaseMaterializedPageDeliveryContracts({
|
||||||
|
pool,
|
||||||
|
userId,
|
||||||
|
relativePaths = [],
|
||||||
|
allowPgRequired = false,
|
||||||
|
} = {}) {
|
||||||
|
if (!pool || !userId) return [];
|
||||||
|
const released = [];
|
||||||
|
for (const rawPath of relativePaths) {
|
||||||
|
const workspaceRelativePath = normalizeDeliveryRelativePath(rawPath);
|
||||||
|
if (!workspaceRelativePath) continue;
|
||||||
|
const contract = await getPageDeliveryContract({
|
||||||
|
pool,
|
||||||
|
userId,
|
||||||
|
relativePath: workspaceRelativePath,
|
||||||
|
});
|
||||||
|
if (!contract || contract.status === 'ready') continue;
|
||||||
|
if (contract.data_mode === 'pg_required' && !allowPgRequired) continue;
|
||||||
|
if (
|
||||||
|
await markPageDeliveryContractReady({
|
||||||
|
pool,
|
||||||
|
userId,
|
||||||
|
relativePath: workspaceRelativePath,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
released.push(workspaceRelativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return released;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
markPageDeliveryContractReady,
|
markPageDeliveryContractReady,
|
||||||
normalizeDeliveryRelativePath,
|
normalizeDeliveryRelativePath,
|
||||||
preparePageDeliveryContract,
|
preparePageDeliveryContract,
|
||||||
|
releaseMaterializedPageDeliveryContracts,
|
||||||
} from './mindspace-delivery-contract.mjs';
|
} from './mindspace-delivery-contract.mjs';
|
||||||
|
|
||||||
test('normalizes only safe public HTML delivery paths', () => {
|
test('normalizes only safe public HTML delivery paths', () => {
|
||||||
@@ -40,3 +41,43 @@ test('contract lifecycle writes preparing then ready against the same route key'
|
|||||||
assert.equal(await markPageDeliveryContractReady({ pool, userId: 'user-1', relativePath: 'public/form.html' }), true);
|
assert.equal(await markPageDeliveryContractReady({ pool, userId: 'user-1', relativePath: 'public/form.html' }), true);
|
||||||
assert.ok(calls.some((call) => call.sql.includes("status = 'ready'")));
|
assert.ok(calls.some((call) => call.sql.includes("status = 'ready'")));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('releaseMaterializedPageDeliveryContracts skips pg_required until allowed', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const pool = {
|
||||||
|
async query(sql, params) {
|
||||||
|
calls.push({ sql, params });
|
||||||
|
if (sql.includes('SELECT id, data_mode')) {
|
||||||
|
const path = params?.[1];
|
||||||
|
if (path === 'public/form.html') {
|
||||||
|
return [[{ id: 'c1', data_mode: 'pg_required', status: 'preparing' }]];
|
||||||
|
}
|
||||||
|
if (path === 'public/report.html') {
|
||||||
|
return [[{ id: 'c2', data_mode: 'static', status: 'preparing' }]];
|
||||||
|
}
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
if (sql.includes("status = 'ready'")) return [{ affectedRows: 1 }];
|
||||||
|
return [{ affectedRows: 0 }];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
await releaseMaterializedPageDeliveryContracts({
|
||||||
|
pool,
|
||||||
|
userId: 'user-1',
|
||||||
|
relativePaths: ['public/form.html', 'public/report.html'],
|
||||||
|
allowPgRequired: false,
|
||||||
|
}),
|
||||||
|
['public/report.html'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
await releaseMaterializedPageDeliveryContracts({
|
||||||
|
pool,
|
||||||
|
userId: 'user-1',
|
||||||
|
relativePaths: ['public/form.html'],
|
||||||
|
allowPgRequired: true,
|
||||||
|
}),
|
||||||
|
['public/form.html'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
+16
-16
@@ -1,7 +1,7 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { buildChatSkillPrompt, SCHEDULED_TASK_AUTOMATION_SKILL_NAME } from './chat-skills.mjs';
|
import { buildChatSkillPrompt, SCHEDULED_TASK_AUTOMATION_SKILL_NAME } from './chat-skills.mjs';
|
||||||
import { markPageDeliveryContractReady } from './mindspace-delivery-contract.mjs';
|
import { releaseMaterializedPageDeliveryContracts } from './mindspace-delivery-contract.mjs';
|
||||||
import {
|
import {
|
||||||
collectOwnPublicHtmlRelativePaths,
|
collectOwnPublicHtmlRelativePaths,
|
||||||
materializeMissingPublicHtmlWrites,
|
materializeMissingPublicHtmlWrites,
|
||||||
@@ -126,29 +126,29 @@ export async function finalizeScheduledTaskPageDelivery({
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT workspace_relative_path
|
`SELECT workspace_relative_path
|
||||||
FROM h5_page_delivery_contracts
|
FROM h5_page_delivery_contracts
|
||||||
WHERE user_id = ? AND request_id = ? AND status = 'preparing'`,
|
WHERE user_id = ? AND status = 'preparing'`,
|
||||||
[userId, sessionId],
|
[userId],
|
||||||
);
|
);
|
||||||
for (const row of rows ?? []) {
|
for (const row of rows ?? []) {
|
||||||
if (row?.workspace_relative_path) relativePaths.add(row.workspace_relative_path);
|
if (row?.workspace_relative_path) relativePaths.add(row.workspace_relative_path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const readyPaths = [];
|
const readyPaths = await releaseMaterializedPageDeliveryContracts({
|
||||||
for (const relativePath of relativePaths) {
|
pool,
|
||||||
const ready = await markPageDeliveryContractReady({
|
userId,
|
||||||
pool,
|
relativePaths: [...relativePaths],
|
||||||
|
allowPgRequired: true,
|
||||||
|
}).catch((error) => {
|
||||||
|
logger.warn?.('[ScheduledTask] release delivery contracts failed:', error);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
for (const relativePath of readyPaths) {
|
||||||
|
logger.info?.('[ScheduledTask] delivery contract ready', {
|
||||||
userId,
|
userId,
|
||||||
|
sessionId,
|
||||||
relativePath,
|
relativePath,
|
||||||
}).catch(() => false);
|
});
|
||||||
if (ready) readyPaths.push(relativePath);
|
|
||||||
else {
|
|
||||||
logger.warn?.('[ScheduledTask] delivery contract not ready', {
|
|
||||||
userId,
|
|
||||||
sessionId,
|
|
||||||
relativePath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return readyPaths;
|
return readyPaths;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
markPageDeliveryContractReady,
|
markPageDeliveryContractReady,
|
||||||
preparePageDeliveryContract,
|
preparePageDeliveryContract,
|
||||||
|
releaseMaterializedPageDeliveryContracts,
|
||||||
} from '../mindspace-delivery-contract.mjs';
|
} from '../mindspace-delivery-contract.mjs';
|
||||||
import { maybeRepairH5HtmlAfterFinish } from '../mindspace-h5-html-finish-guard.mjs';
|
import { maybeRepairH5HtmlAfterFinish } from '../mindspace-h5-html-finish-guard.mjs';
|
||||||
import { maybeRepairPageDataAfterFinish } from '../mindspace-page-data-finish-guard.mjs';
|
import { maybeRepairPageDataAfterFinish } from '../mindspace-page-data-finish-guard.mjs';
|
||||||
@@ -79,6 +80,8 @@ export function attachPortalSessionRoutes(
|
|||||||
maybeRepairPageDataAfterFinish,
|
maybeRepairPageDataAfterFinish,
|
||||||
markPageDeliveryContractReadyFn =
|
markPageDeliveryContractReadyFn =
|
||||||
markPageDeliveryContractReady,
|
markPageDeliveryContractReady,
|
||||||
|
releaseMaterializedPageDeliveryContractsFn =
|
||||||
|
releaseMaterializedPageDeliveryContracts,
|
||||||
finishDeliveryRetryDelaysMs = [250, 1_000],
|
finishDeliveryRetryDelaysMs = [250, 1_000],
|
||||||
finishDeliveryRetryWaitFn = (delayMs) =>
|
finishDeliveryRetryWaitFn = (delayMs) =>
|
||||||
new Promise((resolve) =>
|
new Promise((resolve) =>
|
||||||
@@ -477,6 +480,8 @@ export function attachPortalSessionRoutes(
|
|||||||
// workspace from DB-backed assets only.
|
// workspace from DB-backed assets only.
|
||||||
const finalizeAfterFinishOnce = async (sid, uid) => {
|
const finalizeAfterFinishOnce = async (sid, uid) => {
|
||||||
beginSessionPageDelivery(sid);
|
beginSessionPageDelivery(sid);
|
||||||
|
let releaseCandidatePaths = [];
|
||||||
|
let allowPgRequiredRelease = false;
|
||||||
try {
|
try {
|
||||||
const apiFetchFn = async (pathname, init) => {
|
const apiFetchFn = async (pathname, init) => {
|
||||||
const target = await tkmindProxy.resolveTarget(sid);
|
const target = await tkmindProxy.resolveTarget(sid);
|
||||||
@@ -616,6 +621,8 @@ export function attachPortalSessionRoutes(
|
|||||||
...deliveryContractWrites.keys(),
|
...deliveryContractWrites.keys(),
|
||||||
]),
|
]),
|
||||||
].sort();
|
].sort();
|
||||||
|
releaseCandidatePaths = publicHtmlRelativePaths;
|
||||||
|
allowPgRequiredRelease = htmlReady && pageDataReady;
|
||||||
const pgRequired = [
|
const pgRequired = [
|
||||||
...(Array.isArray(messages) ? messages : []),
|
...(Array.isArray(messages) ? messages : []),
|
||||||
].some(
|
].some(
|
||||||
@@ -660,11 +667,6 @@ export function attachPortalSessionRoutes(
|
|||||||
pgRequired,
|
pgRequired,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await markPageDeliveryContractReadyFn({
|
|
||||||
pool: authPool,
|
|
||||||
userId: uid,
|
|
||||||
relativePath,
|
|
||||||
}).catch(() => false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const memoryV2 = getMemoryV2();
|
const memoryV2 = getMemoryV2();
|
||||||
@@ -685,6 +687,20 @@ export function attachPortalSessionRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (releaseCandidatePaths.length > 0) {
|
||||||
|
await releaseMaterializedPageDeliveryContractsFn({
|
||||||
|
pool: authPool,
|
||||||
|
userId: uid,
|
||||||
|
relativePaths: releaseCandidatePaths,
|
||||||
|
allowPgRequired: allowPgRequiredRelease,
|
||||||
|
}).catch((error) => {
|
||||||
|
logger.warn(
|
||||||
|
`[MindSpace] delivery contract release failed for session ${sid}: ${
|
||||||
|
error instanceof Error ? error.message : error
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
endSessionPageDelivery(sid);
|
endSessionPageDelivery(sid);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ function createDependencies(overrides = {}) {
|
|||||||
return { sessionId, hooks };
|
return { sessionId, hooks };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return {
|
const setup = {
|
||||||
calls,
|
calls,
|
||||||
proxy,
|
proxy,
|
||||||
dependencies: {
|
dependencies: {
|
||||||
@@ -203,6 +203,30 @@ function createDependencies(overrides = {}) {
|
|||||||
...overrides,
|
...overrides,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
if (
|
||||||
|
!Object.prototype.hasOwnProperty.call(
|
||||||
|
overrides,
|
||||||
|
'releaseMaterializedPageDeliveryContractsFn',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setup.dependencies.releaseMaterializedPageDeliveryContractsFn =
|
||||||
|
async (input) => {
|
||||||
|
const released = [];
|
||||||
|
for (const relativePath of input.relativePaths ?? []) {
|
||||||
|
if (
|
||||||
|
await setup.dependencies.markPageDeliveryContractReadyFn({
|
||||||
|
pool: input.pool,
|
||||||
|
userId: input.userId,
|
||||||
|
relativePath,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
released.push(relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return released;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return setup;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('session module preserves route inventory and order', () => {
|
test('session module preserves route inventory and order', () => {
|
||||||
@@ -709,8 +733,8 @@ test('Finish hook preserves refresh, sync, delivery readiness, memory, and lock
|
|||||||
'prepare-page-data',
|
'prepare-page-data',
|
||||||
'repair-page-data',
|
'repair-page-data',
|
||||||
'prepare-contract',
|
'prepare-contract',
|
||||||
'ready',
|
|
||||||
'memory',
|
'memory',
|
||||||
|
'ready',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -957,5 +981,73 @@ test('Finish retries when a delivery guard is initially not ready', async () =>
|
|||||||
assert.equal(pageDataChecks, 2);
|
assert.equal(pageDataChecks, 2);
|
||||||
assert.deepEqual(setup.calls.begin, ['session-1', 'session-1']);
|
assert.deepEqual(setup.calls.begin, ['session-1', 'session-1']);
|
||||||
assert.deepEqual(setup.calls.end, ['session-1', 'session-1']);
|
assert.deepEqual(setup.calls.end, ['session-1', 'session-1']);
|
||||||
assert.deepEqual(readyPaths, ['public/survey.html']);
|
assert.deepEqual(readyPaths, ['public/survey.html', 'public/survey.html']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Finish finally releases static HTML contracts when delivery guards fail', async () => {
|
||||||
|
let hooks = null;
|
||||||
|
const releasedPaths = [];
|
||||||
|
const setup = createDependencies({
|
||||||
|
finishDeliveryRetryDelaysMs: [],
|
||||||
|
getAuthPool: () => ({ id: 'pool' }),
|
||||||
|
getTkmindProxy: () => ({
|
||||||
|
async resolveTarget(sessionId) {
|
||||||
|
return `target:${sessionId}`;
|
||||||
|
},
|
||||||
|
async apiFetchTo() {
|
||||||
|
return createUpstream({
|
||||||
|
body: {
|
||||||
|
id: 'session-1',
|
||||||
|
conversation: [
|
||||||
|
{
|
||||||
|
id: 'user-1',
|
||||||
|
role: 'user',
|
||||||
|
content: 'update page',
|
||||||
|
metadata: { userVisible: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
proxySessionEvents(_req, _res, _sessionId, receivedHooks) {
|
||||||
|
hooks = receivedHooks;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getMindSpacePublicFinish: () => ({
|
||||||
|
async syncAfterFinish() {
|
||||||
|
return {
|
||||||
|
publicHtmlRelativePaths: ['public/daily-news-0813.html'],
|
||||||
|
docxSync: { missing: [] },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async preparePageDataAfterFinish() {
|
||||||
|
return {
|
||||||
|
autoBind: { bound: [], skipped: [], errors: [] },
|
||||||
|
evaluation: { structuralPageData: false, relevantFiles: [] },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async maybeRepairH5HtmlAfterFinishFn() {
|
||||||
|
return { skipped: 'limit' };
|
||||||
|
},
|
||||||
|
async releaseMaterializedPageDeliveryContractsFn(input) {
|
||||||
|
releasedPaths.push(...(input.relativePaths ?? []));
|
||||||
|
return input.relativePaths ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const api = createRouterRecorder();
|
||||||
|
attachPortalSessionRoutes(api, setup.dependencies);
|
||||||
|
await api.routes.get('GET /sessions/:sessionId/events')(
|
||||||
|
createRequest(),
|
||||||
|
createResponseRecorder(),
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => hooks.onAfterFinish('session-1', 'user-1'),
|
||||||
|
/page delivery guards are not ready/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(releasedPaths, ['public/daily-news-0813.html']);
|
||||||
|
assert.deepEqual(setup.calls.end, ['session-1']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -492,6 +492,7 @@ export function ChatPanel({
|
|||||||
chatState === 'connecting' ||
|
chatState === 'connecting' ||
|
||||||
chatState === 'waiting';
|
chatState === 'waiting';
|
||||||
const offlineBlocked = !online;
|
const offlineBlocked = !online;
|
||||||
|
const inputBlocked = !!pendingTool || chatState === 'error' || offlineBlocked;
|
||||||
const publishSkillName = user?.publishSkillName ?? 'static-page-publish';
|
const publishSkillName = user?.publishSkillName ?? 'static-page-publish';
|
||||||
const hasPublishSkill = grantedSkills?.includes(publishSkillName) ?? false;
|
const hasPublishSkill = grantedSkills?.includes(publishSkillName) ?? false;
|
||||||
const hasPageDataCollectSkill = grantedSkills?.includes('page-data-collect') ?? false;
|
const hasPageDataCollectSkill = grantedSkills?.includes('page-data-collect') ?? false;
|
||||||
@@ -525,9 +526,9 @@ export function ChatPanel({
|
|||||||
? '上传中…'
|
? '上传中…'
|
||||||
: chatState === 'connecting'
|
: chatState === 'connecting'
|
||||||
? '连接中…'
|
? '连接中…'
|
||||||
: chatState === 'waiting'
|
: busy && canSubmit
|
||||||
? '提交中…'
|
? '排队发送'
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const applyTemplatePrefill = useCallback((prompt: string, skillId: string, label: string) => {
|
const applyTemplatePrefill = useCallback((prompt: string, skillId: string, label: string) => {
|
||||||
pendingSkillRef.current = skillId;
|
pendingSkillRef.current = skillId;
|
||||||
@@ -577,7 +578,7 @@ export function ChatPanel({
|
|||||||
setVoiceNotice('已识别,可编辑后发送');
|
setVoiceNotice('已识别,可编辑后发送');
|
||||||
};
|
};
|
||||||
|
|
||||||
const voiceDisabled = busy || !!pendingTool || chatState === 'error' || offlineBlocked;
|
const voiceDisabled = inputBlocked || uploadingImage || uploadingFile;
|
||||||
const canSubmit = input.trim() || pendingImages.length > 0 || pendingFiles.length > 0;
|
const canSubmit = input.trim() || pendingImages.length > 0 || pendingFiles.length > 0;
|
||||||
const uploadDisabled = !canUpload || busy || uploadingImage || uploadingFile || offlineBlocked || !!pendingTool;
|
const uploadDisabled = !canUpload || busy || uploadingImage || uploadingFile || offlineBlocked || !!pendingTool;
|
||||||
|
|
||||||
@@ -719,7 +720,7 @@ export function ChatPanel({
|
|||||||
skillIdOverride ?? pendingSkillRef.current ?? templateSelection?.skillId ?? undefined;
|
skillIdOverride ?? pendingSkillRef.current ?? templateSelection?.skillId ?? undefined;
|
||||||
pendingSkillRef.current = null;
|
pendingSkillRef.current = null;
|
||||||
setActiveTemplatePrefill(null);
|
setActiveTemplatePrefill(null);
|
||||||
if ((!trimmed && pendingImages.length === 0 && pendingFiles.length === 0) || voiceDisabled) return;
|
if ((!trimmed && pendingImages.length === 0 && pendingFiles.length === 0) || inputBlocked) return;
|
||||||
if (pendingImages.length > 0 && !onUploadImage) {
|
if (pendingImages.length > 0 && !onUploadImage) {
|
||||||
setImageError('当前会话暂不支持图片发送');
|
setImageError('当前会话暂不支持图片发送');
|
||||||
return;
|
return;
|
||||||
@@ -1462,16 +1463,15 @@ export function ChatPanel({
|
|||||||
<button type="button" className="danger-btn chat-input-action" onClick={() => void onStop()}>
|
<button type="button" className="danger-btn chat-input-action" onClick={() => void onStop()}>
|
||||||
停止
|
停止
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`send-btn chat-input-action${showHomeWelcome ? ' chat-input-action-home' : ''}`}
|
className={`send-btn chat-input-action${showHomeWelcome ? ' chat-input-action-home' : ''}`}
|
||||||
disabled={!canSubmit || voiceDisabled || uploadingImage || uploadingFile}
|
disabled={!canSubmit || inputBlocked || uploadingImage || uploadingFile}
|
||||||
onClick={() => void handleSubmit()}
|
onClick={() => void handleSubmit()}
|
||||||
>
|
>
|
||||||
{sendButtonLabel ?? '发送'}
|
{sendButtonLabel ?? '发送'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{compact && onClose && (
|
{compact && onClose && (
|
||||||
<button type="button" className="space-chat-panel-dismiss" onClick={onClose}>
|
<button type="button" className="space-chat-panel-dismiss" onClick={onClose}>
|
||||||
|
|||||||
@@ -23,8 +23,24 @@ function formatDateTime(timestamp: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACHIEVEMENTS_DATE_TIMEZONE = 'Asia/Shanghai';
|
||||||
|
|
||||||
|
function localCreatedDateKey(timestamp: number) {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: ACHIEVEMENTS_DATE_TIMEZONE,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).formatToParts(new Date(timestamp));
|
||||||
|
const year = parts.find((part) => part.type === 'year')?.value ?? '0000';
|
||||||
|
const month = parts.find((part) => part.type === 'month')?.value ?? '01';
|
||||||
|
const day = parts.find((part) => part.type === 'day')?.value ?? '01';
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateHeading(timestamp: number) {
|
function formatDateHeading(timestamp: number) {
|
||||||
return new Date(timestamp).toLocaleDateString('zh-CN', {
|
return new Date(timestamp).toLocaleDateString('zh-CN', {
|
||||||
|
timeZone: ACHIEVEMENTS_DATE_TIMEZONE,
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -45,7 +61,7 @@ function formatClickMetric(page: MindSpacePage) {
|
|||||||
function groupPagesByCreatedDate(pages: MindSpacePage[]) {
|
function groupPagesByCreatedDate(pages: MindSpacePage[]) {
|
||||||
const groups = new Map<string, MindSpacePage[]>();
|
const groups = new Map<string, MindSpacePage[]>();
|
||||||
for (const page of pages) {
|
for (const page of pages) {
|
||||||
const key = new Date(page.createdAt).toISOString().slice(0, 10);
|
const key = localCreatedDateKey(page.createdAt);
|
||||||
const bucket = groups.get(key);
|
const bucket = groups.get(key);
|
||||||
if (bucket) bucket.push(page);
|
if (bucket) bucket.push(page);
|
||||||
else groups.set(key, [page]);
|
else groups.set(key, [page]);
|
||||||
@@ -54,7 +70,7 @@ function groupPagesByCreatedDate(pages: MindSpacePage[]) {
|
|||||||
.sort(([left], [right]) => right.localeCompare(left))
|
.sort(([left], [right]) => right.localeCompare(left))
|
||||||
.map(([dateKey, items]) => ({
|
.map(([dateKey, items]) => ({
|
||||||
dateKey,
|
dateKey,
|
||||||
heading: formatDateHeading(items[0]?.createdAt ?? Date.parse(`${dateKey}T00:00:00`)),
|
heading: formatDateHeading(items[0]?.createdAt ?? Date.parse(`${dateKey}T12:00:00+08:00`)),
|
||||||
items,
|
items,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
+173
-81
@@ -58,6 +58,9 @@ import {
|
|||||||
buildAutoChatSkillPrefix,
|
buildAutoChatSkillPrefix,
|
||||||
} from '../../chat-skills.mjs';
|
} from '../../chat-skills.mjs';
|
||||||
import {
|
import {
|
||||||
|
buildQueuedChatSubmitNotice,
|
||||||
|
canFlushQueuedChatSubmit,
|
||||||
|
isChatSubmitBusy,
|
||||||
reconcileSessionEventRequestContext,
|
reconcileSessionEventRequestContext,
|
||||||
resolvePostAgentRunChatState,
|
resolvePostAgentRunChatState,
|
||||||
shouldIgnoreZeroActivityFinish,
|
shouldIgnoreZeroActivityFinish,
|
||||||
@@ -89,6 +92,24 @@ import {
|
|||||||
touchSession,
|
touchSession,
|
||||||
} from '../utils/sessions';
|
} from '../utils/sessions';
|
||||||
|
|
||||||
|
type ChatSubmitOptions = {
|
||||||
|
mindspaceContext?: MindSpaceChatContext;
|
||||||
|
messageId?: string;
|
||||||
|
forceDeepReasoning?: boolean;
|
||||||
|
pgRequired?: boolean;
|
||||||
|
imageGenerationMode?: ImageGenerationMode;
|
||||||
|
selectedChatSkill?: string;
|
||||||
|
fileAttachments?: ChatFileAttachment[];
|
||||||
|
goalRunId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingChatSubmitEntry = {
|
||||||
|
userMessage: Message;
|
||||||
|
options?: ChatSubmitOptions;
|
||||||
|
normalizedImageUrls: string[];
|
||||||
|
normalizedFileAttachments: ChatFileAttachment[];
|
||||||
|
};
|
||||||
|
|
||||||
const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用';
|
const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用';
|
||||||
const REPLY_RECOVERY_SYNC_DELAYS_MS = [1500, 5000, 12000];
|
const REPLY_RECOVERY_SYNC_DELAYS_MS = [1500, 5000, 12000];
|
||||||
const FINISH_SYNC_RETRY_DELAYS_MS = [500, 1500, 3000];
|
const FINISH_SYNC_RETRY_DELAYS_MS = [500, 1500, 3000];
|
||||||
@@ -400,11 +421,18 @@ export function useTKMindChat(
|
|||||||
const onUserUpdateRef = useRef(onUserUpdate);
|
const onUserUpdateRef = useRef(onUserUpdate);
|
||||||
const chatImageCategoryIdRef = useRef<string | null>(null);
|
const chatImageCategoryIdRef = useRef<string | null>(null);
|
||||||
const chatFileCategoryIdRef = useRef<string | null>(null);
|
const chatFileCategoryIdRef = useRef<string | null>(null);
|
||||||
|
const pendingSubmitQueueRef = useRef<PendingChatSubmitEntry[]>([]);
|
||||||
|
const flushingPendingSubmitRef = useRef(false);
|
||||||
|
const pendingToolRef = useRef<ToolConfirmation | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chatStateRef.current = chatState;
|
chatStateRef.current = chatState;
|
||||||
}, [chatState]);
|
}, [chatState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pendingToolRef.current = pendingTool;
|
||||||
|
}, [pendingTool]);
|
||||||
|
|
||||||
const clearActiveRequestMissingTimer = useCallback(() => {
|
const clearActiveRequestMissingTimer = useCallback(() => {
|
||||||
if (!activeRequestMissingTimerRef.current) return;
|
if (!activeRequestMissingTimerRef.current) return;
|
||||||
window.clearTimeout(activeRequestMissingTimerRef.current);
|
window.clearTimeout(activeRequestMissingTimerRef.current);
|
||||||
@@ -1175,6 +1203,8 @@ export function useTKMindChat(
|
|||||||
unsubscribeRef.current = null;
|
unsubscribeRef.current = null;
|
||||||
subscribedSessionIdRef.current = null;
|
subscribedSessionIdRef.current = null;
|
||||||
clearActiveRequestMissingTimer();
|
clearActiveRequestMissingTimer();
|
||||||
|
pendingSubmitQueueRef.current = [];
|
||||||
|
flushingPendingSubmitRef.current = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
setPendingTool(null);
|
setPendingTool(null);
|
||||||
agentRunPendingRef.current = false;
|
agentRunPendingRef.current = false;
|
||||||
@@ -1478,84 +1508,23 @@ export function useTKMindChat(
|
|||||||
[session, chatState, connectSession, resetSessionView, sessions],
|
[session, chatState, connectSession, resetSessionView, sessions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const submit = useCallback(
|
const executeAgentSubmit = useCallback(
|
||||||
async (
|
async (
|
||||||
text: string,
|
userMessage: Message,
|
||||||
options?: {
|
options: ChatSubmitOptions | undefined,
|
||||||
mindspaceContext?: MindSpaceChatContext;
|
normalizedImageUrls: string[],
|
||||||
messageId?: string;
|
normalizedFileAttachments: ChatFileAttachment[],
|
||||||
forceDeepReasoning?: boolean;
|
|
||||||
pgRequired?: boolean;
|
|
||||||
imageGenerationMode?: ImageGenerationMode;
|
|
||||||
selectedChatSkill?: string;
|
|
||||||
fileAttachments?: ChatFileAttachment[];
|
|
||||||
goalRunId?: string;
|
|
||||||
},
|
|
||||||
imageUrls?: string[],
|
|
||||||
previewImageUrls?: string[],
|
|
||||||
) => {
|
) => {
|
||||||
const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
|
const trimmed = getDisplayText(userMessage).trim();
|
||||||
const normalizedPreviewImageUrls = (previewImageUrls ?? []).filter(
|
|
||||||
(value) => typeof value === 'string' && value.trim(),
|
|
||||||
);
|
|
||||||
const normalizedFileAttachments = (options?.fileAttachments ?? []).filter(
|
|
||||||
(item) => item?.downloadUrl?.trim() && item?.filename?.trim(),
|
|
||||||
);
|
|
||||||
if (!text.trim() && normalizedImageUrls.length === 0 && normalizedFileAttachments.length === 0) return;
|
|
||||||
// Use the ref here (not the React state) so that rapid back-to-back calls in the
|
|
||||||
// same render cycle are blocked even before the state update has been re-rendered.
|
|
||||||
if (
|
|
||||||
chatStateRef.current === 'streaming' ||
|
|
||||||
chatStateRef.current === 'loading' ||
|
|
||||||
chatStateRef.current === 'connecting' ||
|
|
||||||
chatStateRef.current === 'waiting'
|
|
||||||
) return;
|
|
||||||
|
|
||||||
const trimmed = text.trim();
|
|
||||||
const mindspacePrefix = options?.mindspaceContext
|
|
||||||
? buildContextPrefix(options.mindspaceContext)
|
|
||||||
: '';
|
|
||||||
const userPrefix = buildUserAddressPrefix(userRef.current);
|
|
||||||
const skillPrefix = buildAutoChatSkillPrefix(trimmed, grantedSkills ?? []);
|
|
||||||
const pgContractPrefix = options?.pgRequired
|
|
||||||
? '[交付约束:用户已明确要求使用专属 PostgreSQL 数据空间。若生成页面,必须按 page-data-collect 完成建表、dataset、policy 和 workspace page 绑定;禁止 localStorage、SQLite、静态 JSON 或内存持久化。所有验证通过前不得回复已发布或给出页面链接。]\n'
|
|
||||||
: '';
|
|
||||||
const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}${pgContractPrefix}`;
|
|
||||||
const priorMessageCount = messagesRef.current.length;
|
|
||||||
const userMessage = buildUserMessage(trimmed, {
|
|
||||||
id: options?.messageId,
|
|
||||||
agentText: `${agentPrefix}${trimmed}`,
|
|
||||||
displayText: trimmed,
|
|
||||||
imageUrls: normalizedImageUrls,
|
|
||||||
previewImageUrls: normalizedPreviewImageUrls,
|
|
||||||
fileAttachments: normalizedFileAttachments,
|
|
||||||
});
|
|
||||||
userMessage.metadata = {
|
|
||||||
...userMessage.metadata,
|
|
||||||
memindRun: {
|
|
||||||
...(userMessage.metadata?.memindRun && typeof userMessage.metadata.memindRun === 'object'
|
|
||||||
? userMessage.metadata.memindRun
|
|
||||||
: {}),
|
|
||||||
sessionMessageCount: priorMessageCount,
|
|
||||||
...(options?.pgRequired ? { pgRequired: true } : {}),
|
|
||||||
imageGenerationMode: options?.imageGenerationMode ?? 'auto',
|
|
||||||
...(options?.selectedChatSkill ? { selectedChatSkill: options.selectedChatSkill } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const requestId = crypto.randomUUID();
|
const requestId = crypto.randomUUID();
|
||||||
const submitToken = connectTokenRef.current;
|
const submitToken = connectTokenRef.current;
|
||||||
activeRequestId.current = requestId;
|
activeRequestId.current = requestId;
|
||||||
messagesRef.current = [...messagesRef.current, userMessage];
|
|
||||||
messageHistoryLoadedCountRef.current = messagesRef.current.length;
|
|
||||||
setMessages(messagesRef.current);
|
|
||||||
setChatState('waiting');
|
setChatState('waiting');
|
||||||
// Immediately reflect in the ref so any synchronous re-entry is blocked before
|
|
||||||
// the next React render cycle runs the useEffect that normally syncs this ref.
|
|
||||||
chatStateRef.current = 'waiting';
|
chatStateRef.current = 'waiting';
|
||||||
setError(null);
|
setError(null);
|
||||||
setPendingTool(null);
|
setPendingTool(null);
|
||||||
|
|
||||||
let activeSessionId = session?.id ?? null;
|
let activeSessionId = sessionRef.current?.id ?? null;
|
||||||
|
|
||||||
if (activeSessionId) {
|
if (activeSessionId) {
|
||||||
setSessions((prev) => touchSession(prev, activeSessionId!, 1));
|
setSessions((prev) => touchSession(prev, activeSessionId!, 1));
|
||||||
@@ -1634,14 +1603,15 @@ export function useTKMindChat(
|
|||||||
});
|
});
|
||||||
if (submitToken !== connectTokenRef.current) return;
|
if (submitToken !== connectTokenRef.current) return;
|
||||||
agentRunPendingRef.current = false;
|
agentRunPendingRef.current = false;
|
||||||
activeSessionId = finishedRun.sessionId;
|
activeSessionId = finishedRun.sessionId ?? activeSessionId;
|
||||||
if (!activeSessionId) {
|
if (!activeSessionId) {
|
||||||
throw new Error('后台任务已提交,但未返回会话');
|
throw new Error('后台任务已提交,但未返回会话');
|
||||||
}
|
}
|
||||||
if (normalizedImageUrls.length > 0 || normalizedFileAttachments.length > 0) {
|
if (normalizedImageUrls.length > 0 || normalizedFileAttachments.length > 0) {
|
||||||
void claimMindSpaceConversationUploads(activeSessionId, userMessage.id).catch(() => {});
|
void claimMindSpaceConversationUploads(activeSessionId, userMessage.id).catch(() => {});
|
||||||
}
|
}
|
||||||
if (!session?.id || session.id !== activeSessionId) {
|
const currentSessionId = sessionRef.current?.id ?? null;
|
||||||
|
if (!currentSessionId || currentSessionId !== activeSessionId) {
|
||||||
const nextSession: Session = {
|
const nextSession: Session = {
|
||||||
id: activeSessionId,
|
id: activeSessionId,
|
||||||
name: 'New Chat',
|
name: 'New Chat',
|
||||||
@@ -1700,10 +1670,6 @@ export function useTKMindChat(
|
|||||||
const nextChatState = resolvePostAgentRunChatState({
|
const nextChatState = resolvePostAgentRunChatState({
|
||||||
chatState: chatStateRef.current,
|
chatState: chatStateRef.current,
|
||||||
finishedViaPortalDirectChat,
|
finishedViaPortalDirectChat,
|
||||||
// The agent-run result is authoritative even when the immediate
|
|
||||||
// session snapshot has not yet carried portal-direct metadata.
|
|
||||||
// Without this, a completed Page Data task can re-enter streaming
|
|
||||||
// and leave the Stop button attached to no active request.
|
|
||||||
agentRunSucceeded: finishedRun.status === 'succeeded',
|
agentRunSucceeded: finishedRun.status === 'succeeded',
|
||||||
});
|
});
|
||||||
if (nextChatState === 'idle') {
|
if (nextChatState === 'idle') {
|
||||||
@@ -1735,9 +1701,6 @@ export function useTKMindChat(
|
|||||||
errorCode(err),
|
errorCode(err),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
// Goose may report its session concurrency guard as a failed run
|
|
||||||
// message instead of an HTTP 409. Reattach to the session stream
|
|
||||||
// and reconcile the snapshot; do not strand the composer in error.
|
|
||||||
subscribeToSession(activeSessionId);
|
subscribeToSession(activeSessionId);
|
||||||
setChatState('streaming');
|
setChatState('streaming');
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -1745,7 +1708,9 @@ export function useTKMindChat(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
agentRunPendingRef.current = false;
|
agentRunPendingRef.current = false;
|
||||||
if (session && activeSessionId) setSessions((prev) => touchSession(prev, activeSessionId!, -1));
|
if (sessionRef.current && activeSessionId) {
|
||||||
|
setSessions((prev) => touchSession(prev, activeSessionId!, -1));
|
||||||
|
}
|
||||||
if (err instanceof ApiError && err.status === 402) {
|
if (err instanceof ApiError && err.status === 402) {
|
||||||
notifyInsufficientBalance();
|
notifyInsufficientBalance();
|
||||||
} else {
|
} else {
|
||||||
@@ -1758,18 +1723,143 @@ export function useTKMindChat(
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
notifyInsufficientBalance,
|
notifyInsufficientBalance,
|
||||||
session,
|
|
||||||
grantedSkills,
|
|
||||||
clearActiveRequestMissingTimer,
|
clearActiveRequestMissingTimer,
|
||||||
subscribeToSession,
|
subscribeToSession,
|
||||||
scheduleReplyRecoverySync,
|
scheduleReplyRecoverySync,
|
||||||
ensureProvider,
|
ensureProvider,
|
||||||
loadProjectMemory,
|
loadProjectMemory,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
syncSessionMessages,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const flushPendingSubmitQueue = useCallback(async () => {
|
||||||
|
if (flushingPendingSubmitRef.current) return;
|
||||||
|
if (
|
||||||
|
!canFlushQueuedChatSubmit({
|
||||||
|
chatState: chatStateRef.current,
|
||||||
|
agentRunPending: agentRunPendingRef.current,
|
||||||
|
pendingTool: Boolean(pendingToolRef.current),
|
||||||
|
queueLength: pendingSubmitQueueRef.current.length,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = pendingSubmitQueueRef.current.shift();
|
||||||
|
if (!next) return;
|
||||||
|
|
||||||
|
flushingPendingSubmitRef.current = true;
|
||||||
|
try {
|
||||||
|
await executeAgentSubmit(
|
||||||
|
next.userMessage,
|
||||||
|
next.options,
|
||||||
|
next.normalizedImageUrls,
|
||||||
|
next.normalizedFileAttachments,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
flushingPendingSubmitRef.current = false;
|
||||||
|
if (
|
||||||
|
canFlushQueuedChatSubmit({
|
||||||
|
chatState: chatStateRef.current,
|
||||||
|
agentRunPending: agentRunPendingRef.current,
|
||||||
|
pendingTool: Boolean(pendingToolRef.current),
|
||||||
|
queueLength: pendingSubmitQueueRef.current.length,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
void flushPendingSubmitQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [executeAgentSubmit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!canFlushQueuedChatSubmit({
|
||||||
|
chatState,
|
||||||
|
agentRunPending: agentRunPendingRef.current,
|
||||||
|
pendingTool: Boolean(pendingTool),
|
||||||
|
queueLength: pendingSubmitQueueRef.current.length,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void flushPendingSubmitQueue();
|
||||||
|
}, [chatState, pendingTool, flushPendingSubmitQueue]);
|
||||||
|
|
||||||
|
const submit = useCallback(
|
||||||
|
async (
|
||||||
|
text: string,
|
||||||
|
options?: ChatSubmitOptions,
|
||||||
|
imageUrls?: string[],
|
||||||
|
previewImageUrls?: string[],
|
||||||
|
) => {
|
||||||
|
const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
|
||||||
|
const normalizedPreviewImageUrls = (previewImageUrls ?? []).filter(
|
||||||
|
(value) => typeof value === 'string' && value.trim(),
|
||||||
|
);
|
||||||
|
const normalizedFileAttachments = (options?.fileAttachments ?? []).filter(
|
||||||
|
(item) => item?.downloadUrl?.trim() && item?.filename?.trim(),
|
||||||
|
);
|
||||||
|
if (!text.trim() && normalizedImageUrls.length === 0 && normalizedFileAttachments.length === 0) return;
|
||||||
|
|
||||||
|
const trimmed = text.trim();
|
||||||
|
const mindspacePrefix = options?.mindspaceContext
|
||||||
|
? buildContextPrefix(options.mindspaceContext)
|
||||||
|
: '';
|
||||||
|
const userPrefix = buildUserAddressPrefix(userRef.current);
|
||||||
|
const skillPrefix = buildAutoChatSkillPrefix(trimmed, grantedSkills ?? []);
|
||||||
|
const pgContractPrefix = options?.pgRequired
|
||||||
|
? '[交付约束:用户已明确要求使用专属 PostgreSQL 数据空间。若生成页面,必须按 page-data-collect 完成建表、dataset、policy 和 workspace page 绑定;禁止 localStorage、SQLite、静态 JSON 或内存持久化。所有验证通过前不得回复已发布或给出页面链接。]\n'
|
||||||
|
: '';
|
||||||
|
const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}${pgContractPrefix}`;
|
||||||
|
const priorMessageCount = messagesRef.current.length;
|
||||||
|
const userMessage = buildUserMessage(trimmed, {
|
||||||
|
id: options?.messageId,
|
||||||
|
agentText: `${agentPrefix}${trimmed}`,
|
||||||
|
displayText: trimmed,
|
||||||
|
imageUrls: normalizedImageUrls,
|
||||||
|
previewImageUrls: normalizedPreviewImageUrls,
|
||||||
|
fileAttachments: normalizedFileAttachments,
|
||||||
|
});
|
||||||
|
userMessage.metadata = {
|
||||||
|
...userMessage.metadata,
|
||||||
|
memindRun: {
|
||||||
|
...(userMessage.metadata?.memindRun && typeof userMessage.metadata.memindRun === 'object'
|
||||||
|
? userMessage.metadata.memindRun
|
||||||
|
: {}),
|
||||||
|
sessionMessageCount: priorMessageCount,
|
||||||
|
...(options?.pgRequired ? { pgRequired: true } : {}),
|
||||||
|
imageGenerationMode: options?.imageGenerationMode ?? 'auto',
|
||||||
|
...(options?.selectedChatSkill ? { selectedChatSkill: options.selectedChatSkill } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isChatSubmitBusy(chatStateRef.current)) {
|
||||||
|
pendingSubmitQueueRef.current.push({
|
||||||
|
userMessage,
|
||||||
|
options,
|
||||||
|
normalizedImageUrls,
|
||||||
|
normalizedFileAttachments,
|
||||||
|
});
|
||||||
|
messagesRef.current = [...messagesRef.current, userMessage];
|
||||||
|
messageHistoryLoadedCountRef.current = messagesRef.current.length;
|
||||||
|
setMessages(messagesRef.current);
|
||||||
|
setNotice(buildQueuedChatSubmitNotice(pendingSubmitQueueRef.current.length));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesRef.current = [...messagesRef.current, userMessage];
|
||||||
|
messageHistoryLoadedCountRef.current = messagesRef.current.length;
|
||||||
|
setMessages(messagesRef.current);
|
||||||
|
await executeAgentSubmit(
|
||||||
|
userMessage,
|
||||||
|
options,
|
||||||
|
normalizedImageUrls,
|
||||||
|
normalizedFileAttachments,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[executeAgentSubmit, grantedSkills],
|
||||||
|
);
|
||||||
|
|
||||||
const stop = useCallback(async () => {
|
const stop = useCallback(async () => {
|
||||||
if (!session || !activeRequestId.current) return;
|
if (!session || !activeRequestId.current) return;
|
||||||
try {
|
try {
|
||||||
@@ -1894,6 +1984,8 @@ export function useTKMindChat(
|
|||||||
clearStoredSessionId(userRef.current?.id);
|
clearStoredSessionId(userRef.current?.id);
|
||||||
agentRunPendingRef.current = false;
|
agentRunPendingRef.current = false;
|
||||||
activeRequestId.current = null;
|
activeRequestId.current = null;
|
||||||
|
pendingSubmitQueueRef.current = [];
|
||||||
|
flushingPendingSubmitRef.current = false;
|
||||||
messagesRef.current = [];
|
messagesRef.current = [];
|
||||||
messageHistoryLoadedCountRef.current = 0;
|
messageHistoryLoadedCountRef.current = 0;
|
||||||
messageHistoryTotalRef.current = 0;
|
messageHistoryTotalRef.current = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user