Compare commits

...

2 Commits

Author SHA1 Message Date
john 558c4ef2ee fix(h5-chat): queue main-session submits while agent is busy
Memind CI / Test, build, and release guards (push) Successful in 4m1s
Prevent silent drops when users send follow-up instructions during waiting or streaming by showing the message immediately, notifying them it is queued, and auto-flushing after the composer returns to idle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 08:51:30 +08:00
john 53b0d2c62f fix(mindspace): release edited public pages and fix achievements dates
Memind CI / Test, build, and release guards (push) Successful in 3m37s
Finish now releases static HTML delivery contracts in a finally block so
re-edited pages are not stuck at HTTP 409, and M成果 groups pages by
Asia/Shanghai calendar dates to avoid duplicate day headings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 06:41:40 +08:00
10 changed files with 502 additions and 122 deletions
+43
View File
@@ -72,6 +72,49 @@ export function shouldScheduleMissingActiveRequestGrace({
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
* deterministic gateway error already has a terminal outcome and must return
+49
View File
@@ -1,6 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildQueuedChatSubmitNotice,
canFlushQueuedChatSubmit,
isChatSubmitBusy,
reconcileSessionEventRequestContext,
resolvePostAgentRunChatState,
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', () => {
assert.deepEqual(
reconcileSessionEventRequestContext({
+31
View File
@@ -46,3 +46,34 @@ export async function markPageDeliveryContractReady({ pool, userId, relativePath
);
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;
}
+41
View File
@@ -5,6 +5,7 @@ import {
markPageDeliveryContractReady,
normalizeDeliveryRelativePath,
preparePageDeliveryContract,
releaseMaterializedPageDeliveryContracts,
} from './mindspace-delivery-contract.mjs';
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.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
View File
@@ -1,7 +1,7 @@
import crypto from 'node:crypto';
import path from 'node:path';
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 {
collectOwnPublicHtmlRelativePaths,
materializeMissingPublicHtmlWrites,
@@ -126,29 +126,29 @@ export async function finalizeScheduledTaskPageDelivery({
const [rows] = await pool.query(
`SELECT workspace_relative_path
FROM h5_page_delivery_contracts
WHERE user_id = ? AND request_id = ? AND status = 'preparing'`,
[userId, sessionId],
WHERE user_id = ? AND status = 'preparing'`,
[userId],
);
for (const row of rows ?? []) {
if (row?.workspace_relative_path) relativePaths.add(row.workspace_relative_path);
}
}
const readyPaths = [];
for (const relativePath of relativePaths) {
const ready = await markPageDeliveryContractReady({
pool,
const readyPaths = await releaseMaterializedPageDeliveryContracts({
pool,
userId,
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,
sessionId,
relativePath,
}).catch(() => false);
if (ready) readyPaths.push(relativePath);
else {
logger.warn?.('[ScheduledTask] delivery contract not ready', {
userId,
sessionId,
relativePath,
});
}
});
}
return readyPaths;
}
+21 -5
View File
@@ -13,6 +13,7 @@ import {
import {
markPageDeliveryContractReady,
preparePageDeliveryContract,
releaseMaterializedPageDeliveryContracts,
} from '../mindspace-delivery-contract.mjs';
import { maybeRepairH5HtmlAfterFinish } from '../mindspace-h5-html-finish-guard.mjs';
import { maybeRepairPageDataAfterFinish } from '../mindspace-page-data-finish-guard.mjs';
@@ -79,6 +80,8 @@ export function attachPortalSessionRoutes(
maybeRepairPageDataAfterFinish,
markPageDeliveryContractReadyFn =
markPageDeliveryContractReady,
releaseMaterializedPageDeliveryContractsFn =
releaseMaterializedPageDeliveryContracts,
finishDeliveryRetryDelaysMs = [250, 1_000],
finishDeliveryRetryWaitFn = (delayMs) =>
new Promise((resolve) =>
@@ -477,6 +480,8 @@ export function attachPortalSessionRoutes(
// workspace from DB-backed assets only.
const finalizeAfterFinishOnce = async (sid, uid) => {
beginSessionPageDelivery(sid);
let releaseCandidatePaths = [];
let allowPgRequiredRelease = false;
try {
const apiFetchFn = async (pathname, init) => {
const target = await tkmindProxy.resolveTarget(sid);
@@ -616,6 +621,8 @@ export function attachPortalSessionRoutes(
...deliveryContractWrites.keys(),
]),
].sort();
releaseCandidatePaths = publicHtmlRelativePaths;
allowPgRequiredRelease = htmlReady && pageDataReady;
const pgRequired = [
...(Array.isArray(messages) ? messages : []),
].some(
@@ -660,11 +667,6 @@ export function attachPortalSessionRoutes(
pgRequired,
});
}
await markPageDeliveryContractReadyFn({
pool: authPool,
userId: uid,
relativePath,
}).catch(() => false);
}
}
const memoryV2 = getMemoryV2();
@@ -685,6 +687,20 @@ export function attachPortalSessionRoutes(
});
}
} 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);
}
};
+95 -3
View File
@@ -112,7 +112,7 @@ function createDependencies(overrides = {}) {
return { sessionId, hooks };
},
};
return {
const setup = {
calls,
proxy,
dependencies: {
@@ -203,6 +203,30 @@ function createDependencies(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', () => {
@@ -709,8 +733,8 @@ test('Finish hook preserves refresh, sync, delivery readiness, memory, and lock
'prepare-page-data',
'repair-page-data',
'prepare-contract',
'ready',
'memory',
'ready',
],
);
assert.equal(
@@ -957,5 +981,73 @@ test('Finish retries when a delivery guard is initially not ready', async () =>
assert.equal(pageDataChecks, 2);
assert.deepEqual(setup.calls.begin, ['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']);
});
+15 -15
View File
@@ -492,6 +492,7 @@ export function ChatPanel({
chatState === 'connecting' ||
chatState === 'waiting';
const offlineBlocked = !online;
const inputBlocked = !!pendingTool || chatState === 'error' || offlineBlocked;
const publishSkillName = user?.publishSkillName ?? 'static-page-publish';
const hasPublishSkill = grantedSkills?.includes(publishSkillName) ?? false;
const hasPageDataCollectSkill = grantedSkills?.includes('page-data-collect') ?? false;
@@ -525,9 +526,9 @@ export function ChatPanel({
? '上传中…'
: chatState === 'connecting'
? '连接中…'
: chatState === 'waiting'
? '提交中…'
: null;
: busy && canSubmit
? '排队发送'
: null;
const applyTemplatePrefill = useCallback((prompt: string, skillId: string, label: string) => {
pendingSkillRef.current = skillId;
@@ -577,7 +578,7 @@ export function ChatPanel({
setVoiceNotice('已识别,可编辑后发送');
};
const voiceDisabled = busy || !!pendingTool || chatState === 'error' || offlineBlocked;
const voiceDisabled = inputBlocked || uploadingImage || uploadingFile;
const canSubmit = input.trim() || pendingImages.length > 0 || pendingFiles.length > 0;
const uploadDisabled = !canUpload || busy || uploadingImage || uploadingFile || offlineBlocked || !!pendingTool;
@@ -719,7 +720,7 @@ export function ChatPanel({
skillIdOverride ?? pendingSkillRef.current ?? templateSelection?.skillId ?? undefined;
pendingSkillRef.current = 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) {
setImageError('当前会话暂不支持图片发送');
return;
@@ -1462,16 +1463,15 @@ export function ChatPanel({
<button type="button" className="danger-btn chat-input-action" onClick={() => void onStop()}>
</button>
) : (
<button
type="button"
className={`send-btn chat-input-action${showHomeWelcome ? ' chat-input-action-home' : ''}`}
disabled={!canSubmit || voiceDisabled || uploadingImage || uploadingFile}
onClick={() => void handleSubmit()}
>
{sendButtonLabel ?? '发送'}
</button>
)}
) : null}
<button
type="button"
className={`send-btn chat-input-action${showHomeWelcome ? ' chat-input-action-home' : ''}`}
disabled={!canSubmit || inputBlocked || uploadingImage || uploadingFile}
onClick={() => void handleSubmit()}
>
{sendButtonLabel ?? '发送'}
</button>
</div>
{compact && onClose && (
<button type="button" className="space-chat-panel-dismiss" onClick={onClose}>
+18 -2
View File
@@ -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) {
return new Date(timestamp).toLocaleDateString('zh-CN', {
timeZone: ACHIEVEMENTS_DATE_TIMEZONE,
year: 'numeric',
month: 'long',
day: 'numeric',
@@ -45,7 +61,7 @@ function formatClickMetric(page: MindSpacePage) {
function groupPagesByCreatedDate(pages: MindSpacePage[]) {
const groups = new Map<string, MindSpacePage[]>();
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);
if (bucket) bucket.push(page);
else groups.set(key, [page]);
@@ -54,7 +70,7 @@ function groupPagesByCreatedDate(pages: MindSpacePage[]) {
.sort(([left], [right]) => right.localeCompare(left))
.map(([dateKey, items]) => ({
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,
}));
}
+173 -81
View File
@@ -58,6 +58,9 @@ import {
buildAutoChatSkillPrefix,
} from '../../chat-skills.mjs';
import {
buildQueuedChatSubmitNotice,
canFlushQueuedChatSubmit,
isChatSubmitBusy,
reconcileSessionEventRequestContext,
resolvePostAgentRunChatState,
shouldIgnoreZeroActivityFinish,
@@ -89,6 +92,24 @@ import {
touchSession,
} 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 REPLY_RECOVERY_SYNC_DELAYS_MS = [1500, 5000, 12000];
const FINISH_SYNC_RETRY_DELAYS_MS = [500, 1500, 3000];
@@ -400,11 +421,18 @@ export function useTKMindChat(
const onUserUpdateRef = useRef(onUserUpdate);
const chatImageCategoryIdRef = 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(() => {
chatStateRef.current = chatState;
}, [chatState]);
useEffect(() => {
pendingToolRef.current = pendingTool;
}, [pendingTool]);
const clearActiveRequestMissingTimer = useCallback(() => {
if (!activeRequestMissingTimerRef.current) return;
window.clearTimeout(activeRequestMissingTimerRef.current);
@@ -1175,6 +1203,8 @@ export function useTKMindChat(
unsubscribeRef.current = null;
subscribedSessionIdRef.current = null;
clearActiveRequestMissingTimer();
pendingSubmitQueueRef.current = [];
flushingPendingSubmitRef.current = false;
setError(null);
setPendingTool(null);
agentRunPendingRef.current = false;
@@ -1478,84 +1508,23 @@ export function useTKMindChat(
[session, chatState, connectSession, resetSessionView, sessions],
);
const submit = useCallback(
const executeAgentSubmit = useCallback(
async (
text: string,
options?: {
mindspaceContext?: MindSpaceChatContext;
messageId?: string;
forceDeepReasoning?: boolean;
pgRequired?: boolean;
imageGenerationMode?: ImageGenerationMode;
selectedChatSkill?: string;
fileAttachments?: ChatFileAttachment[];
goalRunId?: string;
},
imageUrls?: string[],
previewImageUrls?: string[],
userMessage: Message,
options: ChatSubmitOptions | undefined,
normalizedImageUrls: string[],
normalizedFileAttachments: ChatFileAttachment[],
) => {
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;
// 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 trimmed = getDisplayText(userMessage).trim();
const requestId = crypto.randomUUID();
const submitToken = connectTokenRef.current;
activeRequestId.current = requestId;
messagesRef.current = [...messagesRef.current, userMessage];
messageHistoryLoadedCountRef.current = messagesRef.current.length;
setMessages(messagesRef.current);
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';
setError(null);
setPendingTool(null);
let activeSessionId = session?.id ?? null;
let activeSessionId = sessionRef.current?.id ?? null;
if (activeSessionId) {
setSessions((prev) => touchSession(prev, activeSessionId!, 1));
@@ -1634,14 +1603,15 @@ export function useTKMindChat(
});
if (submitToken !== connectTokenRef.current) return;
agentRunPendingRef.current = false;
activeSessionId = finishedRun.sessionId;
activeSessionId = finishedRun.sessionId ?? activeSessionId;
if (!activeSessionId) {
throw new Error('后台任务已提交,但未返回会话');
}
if (normalizedImageUrls.length > 0 || normalizedFileAttachments.length > 0) {
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 = {
id: activeSessionId,
name: 'New Chat',
@@ -1700,10 +1670,6 @@ export function useTKMindChat(
const nextChatState = resolvePostAgentRunChatState({
chatState: chatStateRef.current,
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',
});
if (nextChatState === 'idle') {
@@ -1735,9 +1701,6 @@ export function useTKMindChat(
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);
setChatState('streaming');
setError(null);
@@ -1745,7 +1708,9 @@ export function useTKMindChat(
return;
}
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) {
notifyInsufficientBalance();
} else {
@@ -1758,18 +1723,143 @@ export function useTKMindChat(
},
[
notifyInsufficientBalance,
session,
grantedSkills,
clearActiveRequestMissingTimer,
subscribeToSession,
scheduleReplyRecoverySync,
ensureProvider,
loadProjectMemory,
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 () => {
if (!session || !activeRequestId.current) return;
try {
@@ -1894,6 +1984,8 @@ export function useTKMindChat(
clearStoredSessionId(userRef.current?.id);
agentRunPendingRef.current = false;
activeRequestId.current = null;
pendingSubmitQueueRef.current = [];
flushingPendingSubmitRef.current = false;
messagesRef.current = [];
messageHistoryLoadedCountRef.current = 0;
messageHistoryTotalRef.current = 0;