import { useCallback, useEffect, useRef, useState } from 'react'; import { ApiError, applyLocalLlmFallback, bootstrapProjectMemory, cancelRequest, claimMindSpaceConversationUploads, confirmTool, createAgentRun, deleteChatSession, getAgentRun, getMindSpace, getMe, listNotifications, markNotificationRead, listSessions, loadSessionDetail, readConfig, rememberUserMemory, rememberProjectContext, uploadMindSpaceAsset, resumeSession, startSession, subscribeAgentRunEvents, subscribeNotificationEvents, subscribeSessionEvents, syncUserMemory, updateProvider, } from '../api/client'; import type { AgentRun } from '../api/client'; import { appConfig } from '../config'; import { BALANCE_REFRESH_EVENT, isRechargeNotification, requestBalanceRefresh } from '../utils/balanceRefresh'; import { clearStoredSessionId, readStoredSessionId, writeStoredSessionId, } from '../utils/sessionStorage'; import type { ImageGenerationMode } from '../utils/imageGeneration'; import type { CapabilityMap, ChatFileAttachment, ChatState, Message, MindSpaceChatContext, PortalUser, Session, SessionSummary, SessionEvent, ToolConfirmation, UserNotification, } from '../types'; import { buildContextPrefix } from '../utils/mindspaceChatContext'; import { buildUserAddressPrefix } from '../utils/userAddress'; import { AIDER_DEVELOPMENT_SKILL_NAME, buildAutoChatSkillPrefix, } from '../../chat-skills.mjs'; import { reconcileSessionEventRequestContext, resolvePostAgentRunChatState, shouldKeepStreamingAfterRunError, shouldPromoteSessionIdToStreaming, } from '../../chat-agent-run-gate.mjs'; import { mergeConversationSnapshot } from '../../chat-finish-sync.mjs'; import { CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, } from '../utils/imageUpload'; import { buildAbsoluteAssetDownloadUrl } from '../utils/mindspaceCards'; import { resolveAgentRunOptions, resolvePageDataDevTaskType } from '../utils/agentRunMode'; import { buildUserMessage, normalizeConversationMessages, getDisplayText, getToolConfirmation, isCreditsExhaustedNotification, isRelayServerErrorMessage, pushMessage, } from '../utils/message'; import { appendSessionLists, prependUnique, shouldShowNewChatTitle, toSessionSummary, touchSession, } from '../utils/sessions'; const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用'; const REPLY_RECOVERY_SYNC_DELAYS_MS = [1500, 5000, 12000]; const FINISH_SYNC_RETRY_DELAYS_MS = [500, 1500, 3000]; const ACTIVE_REQUEST_MISSING_GRACE_MS = 2500; function createAgentRunFailedError(message: string) { const error = new Error(message) as Error & { code: string }; error.code = 'AGENT_RUN_FAILED'; return error; } function errorCode(error: unknown) { if (error instanceof ApiError) return error.code; if (error && typeof error === 'object' && 'code' in error) { return String((error as { code?: unknown }).code ?? ''); } return ''; } const DIRECT_CHAT_SESSION_PREFIX = 'h5direct_'; export { INSUFFICIENT_BALANCE_NOTICE }; function isDirectChatSessionId(sessionId?: string | null) { return typeof sessionId === 'string' && sessionId.startsWith(DIRECT_CHAT_SESSION_PREFIX); } function getSessionEventRequestId(event: SessionEvent): string | undefined { const raw = event as SessionEvent & { chat_request_id?: string; request_id?: string }; return raw.chat_request_id ?? raw.request_id; } function sessionFinishedViaPortalDirectChat(messages: Message[], userMessage: Message) { const lastAssistant = [...messages].reverse().find((message) => message.role === 'assistant'); if (lastAssistant?.metadata?.source !== 'portal-direct-chat') return false; const userText = getDisplayText(userMessage).trim(); const userId = userMessage.id; return messages.some( (message) => message.role === 'user' && (message.id === userId || getDisplayText(message).trim() === userText), ); } async function waitForAgentRun(runId: string): Promise { return await waitForAgentRunWithDirectChatPreview(runId, {}); } const DIRECT_CHAT_SESSION_POLL_MS = 600; const AGENT_RUN_STATUS_POLL_MS = 1_500; const AGENT_RUN_WAIT_TIMEOUT_MS = 16 * 60 * 1000; async function waitForAgentRunWithDirectChatPreview( runId: string, handlers: { onSessionId?: (sessionId: string) => void; onMessages?: (messages: Message[]) => void; isCancelled?: () => boolean; }, ): Promise { return await new Promise((resolve, reject) => { let pollTimer: number | null = null; let runStatusPollTimer: number | null = null; let runStatusPollInFlight = false; let waitTimer: number | null = null; let pollingSessionId: string | null = null; let settled = false; let unsubscribe = () => {}; const settle = (action: () => void) => { if (settled) return; settled = true; stopPoll(); stopRunStatusPoll(); if (waitTimer != null) { window.clearTimeout(waitTimer); waitTimer = null; } unsubscribe(); action(); }; const stopPoll = () => { if (pollTimer != null) { window.clearInterval(pollTimer); pollTimer = null; } }; const stopRunStatusPoll = () => { if (runStatusPollTimer != null) { window.clearInterval(runStatusPollTimer); runStatusPollTimer = null; } }; const startPolling = (sessionId: string) => { if (pollingSessionId === sessionId && pollTimer != null) return; pollingSessionId = sessionId; stopPoll(); pollTimer = window.setInterval(() => { if (handlers.isCancelled?.()) return; void loadSessionDetail(sessionId) .then(({ messages }) => { const hasAssistant = messages.some( (message) => message.role === 'assistant' && message.metadata?.source === 'portal-direct-chat', ); if (hasAssistant) { handlers.onMessages?.(messages); stopPoll(); } }) .catch(() => {}); }, DIRECT_CHAT_SESSION_POLL_MS); }; const handleRunStatus = (run: AgentRun) => { if (run.sessionId) { handlers.onSessionId?.(run.sessionId); if (isDirectChatSessionId(run.sessionId)) { startPolling(run.sessionId); } } if (run.status === 'succeeded') { settle(() => resolve(run)); return; } if (run.status === 'failed') { settle(() => reject(createAgentRunFailedError(run.error || '后台任务失败,请稍后重试'))); } }; const pollRunStatus = () => { if (settled || runStatusPollInFlight || handlers.isCancelled?.()) return; runStatusPollInFlight = true; void getAgentRun(runId) .then(handleRunStatus) .catch(() => { // SSE remains primary; polling only closes terminal-state gaps. }) .finally(() => { runStatusPollInFlight = false; }); }; unsubscribe = subscribeAgentRunEvents( runId, handleRunStatus, (error) => { void getAgentRun(runId) .then(handleRunStatus) .catch(() => settle(() => reject(error))); }, ); runStatusPollTimer = window.setInterval(pollRunStatus, AGENT_RUN_STATUS_POLL_MS); pollRunStatus(); waitTimer = window.setTimeout(() => { void getAgentRun(runId) .then((run) => { if (run.status === 'succeeded') { settle(() => resolve(run)); return; } if (run.status === 'failed') { settle(() => reject(createAgentRunFailedError(run.error || '后台任务失败,请稍后重试'))); return; } settle(() => reject( new Error('后台任务耗时较长,已停止等待;页面若已生成可在「我的页面」查看'), ), ); }) .catch((error) => { settle(() => reject(error instanceof Error ? error : new Error(String(error))), ); }); }, AGENT_RUN_WAIT_TIMEOUT_MS); }); } function mergeMessagePages(older: Message[], current: Message[]): Message[] { if (older.length === 0) return current; if (current.length === 0) return older; const seen = new Set(); const merged: Message[] = []; for (const message of [...older, ...current]) { const key = message.id ?? `${message.role}:${message.created}:${merged.length}`; if (seen.has(key)) continue; seen.add(key); merged.push(message); } return merged; } const SESSION_LIST_RETRY_ATTEMPTS = 3; const SESSION_LIST_RETRY_DELAY_MS = 450; function isTransientSessionListError(err: unknown) { return err instanceof ApiError && (err.status === 0 || err.status >= 502); } function sessionListFailureMessage(err: unknown, action: 'refresh' | 'load-more') { const fallback = action === 'refresh' ? '历史列表暂时无法加载,请稍后重试' : '暂时无法加载更多历史,请稍后重试'; if (!(err instanceof ApiError)) return fallback; if (err.status === 0) { return action === 'refresh' ? '无法连接后端,历史列表未刷新(聊天仍可使用)' : '无法连接后端,暂时无法加载更多历史'; } return err.message?.trim() || fallback; } async function fetchSessionListWithRetry( options?: Parameters[0], ): Promise>> { let lastErr: unknown; for (let attempt = 0; attempt < SESSION_LIST_RETRY_ATTEMPTS; attempt += 1) { try { return await listSessions(options); } catch (err) { lastErr = err; if (!isTransientSessionListError(err) || attempt === SESSION_LIST_RETRY_ATTEMPTS - 1) break; await new Promise((resolve) => window.setTimeout(resolve, SESSION_LIST_RETRY_DELAY_MS * (attempt + 1))); } } throw lastErr; } function isTransientConnectError(err: unknown) { if (!(err instanceof ApiError)) return false; if (err.status === 0 || err.status >= 502) return true; return /超时|timeout/i.test(err.message); } function isMissingAgentSessionError(err: unknown) { return err instanceof ApiError && /session not found/i.test(err.message); } async function withTransientConnectRetry(fn: () => Promise, attempts = 3): Promise { let lastErr: unknown; for (let attempt = 0; attempt < attempts; attempt += 1) { try { return await fn(); } catch (err) { lastErr = err; if (!isTransientConnectError(err) || attempt === attempts - 1) break; await new Promise((resolve) => window.setTimeout(resolve, 600 * (attempt + 1))); } } throw lastErr; } export function useTKMindChat( user?: PortalUser | null, onUserUpdate?: (user: PortalUser) => void, capabilities?: CapabilityMap | null, grantedSkills?: string[], ) { const canUseProjectMemory = Boolean(capabilities?.context_memory); const canUseLongTermMemory = Boolean(capabilities?.memory_store); const [session, setSession] = useState(null); const [sessions, setSessions] = useState([]); const [sessionsLoading, setSessionsLoading] = useState(false); const [sessionsLoadingMore, setSessionsLoadingMore] = useState(false); const [sessionsHasMore, setSessionsHasMore] = useState(false); const [sessionSearchQuery, setSessionSearchQueryState] = useState(''); const [messages, setMessages] = useState([]); const [messageHistoryLoadingMore, setMessageHistoryLoadingMore] = useState(false); const [messageHistoryHasMore, setMessageHistoryHasMore] = useState(false); const [messageHistoryTotal, setMessageHistoryTotal] = useState(0); const [chatState, setChatState] = useState('loading'); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [pendingTool, setPendingTool] = useState(null); const [memoryLoading, setMemoryLoading] = useState(false); const [userMemoryLoading, setUserMemoryLoading] = useState(false); const [rechargePrompt, setRechargePrompt] = useState(false); const [rechargeForced, setRechargeForced] = useState(false); const [subscribePrompt, setSubscribePrompt] = useState(false); const [activeNotification, setActiveNotification] = useState(null); const seenNotificationIdsRef = useRef>(new Set()); const activeRequestId = useRef(null); const activeRequestMissingTimerRef = useRef | null>(null); const chatStateRef = useRef(chatState); const subscribedSessionIdRef = useRef(null); const unsubscribeRef = useRef<(() => void) | null>(null); const connectTokenRef = useRef(0); const unavailableAgentSessionIdsRef = useRef(new Set()); const messagesRef = useRef([]); const sessionRef = useRef(null); const sessionsRef = useRef([]); const sessionsOffsetRef = useRef(0); const sessionSearchQueryRef = useRef(''); const messageHistoryLoadedCountRef = useRef(0); const messageHistoryTotalRef = useRef(0); const messageHistoryHasMoreRef = useRef(false); const rememberedContextRef = useRef(null); const rememberInFlightRef = useRef(false); const fallbackRetriedRef = useRef(new Set()); const userRef = useRef(user); const onUserUpdateRef = useRef(onUserUpdate); const chatImageCategoryIdRef = useRef(null); const chatFileCategoryIdRef = useRef(null); useEffect(() => { chatStateRef.current = chatState; }, [chatState]); const clearActiveRequestMissingTimer = useCallback(() => { if (!activeRequestMissingTimerRef.current) return; window.clearTimeout(activeRequestMissingTimerRef.current); activeRequestMissingTimerRef.current = null; }, []); const dismissNotice = useCallback(() => { const currentNotification = activeNotification; setNotice(null); setActiveNotification(null); if (currentNotification) { void markNotificationRead(currentNotification.id).catch(() => {}); } }, [activeNotification]); const notifyInsufficientBalance = useCallback(() => { setNotice(INSUFFICIENT_BALANCE_NOTICE); setChatState('idle'); }, []); const openRecharge = useCallback((forced = false) => { setRechargeForced(forced); setRechargePrompt(true); }, []); const dismissRecharge = useCallback(() => { if (rechargeForced) return; setRechargePrompt(false); }, [rechargeForced]); const completeRecharge = useCallback((nextBalance?: number) => { setRechargeForced(false); setRechargePrompt(false); const currentUser = userRef.current; const updateUser = onUserUpdateRef.current; if (typeof nextBalance === 'number' && currentUser && updateUser) { const prevBalance = currentUser.balanceCents; const prevTotal = currentUser.totalCreditCents ?? prevBalance; const rechargeDelta = nextBalance - prevBalance; updateUser({ ...currentUser, balanceCents: nextBalance, totalCreditCents: Math.max(prevTotal + rechargeDelta, nextBalance), }); } }, []); const openSubscribe = useCallback(() => { setSubscribePrompt(true); }, []); const dismissSubscribe = useCallback(() => { setSubscribePrompt(false); }, []); const completeSubscribe = useCallback((nextBalanceCents: number, subscription: import('../types').ActiveSubscription) => { setSubscribePrompt(false); const currentUser = userRef.current; const updateUser = onUserUpdateRef.current; if (currentUser && updateUser) { updateUser({ ...currentUser, balanceCents: nextBalanceCents, subscription, planType: subscription.planType, }); } }, []); useEffect(() => { userRef.current = user; }, [user]); useEffect(() => { onUserUpdateRef.current = onUserUpdate; }, [onUserUpdate]); const refreshUserBalance = useCallback(() => { if (!onUserUpdateRef.current) return; void getMe() .then(({ user: fresh }) => onUserUpdateRef.current?.(fresh)) .catch(() => {}); }, []); useEffect(() => { const handleBalanceRefresh = () => refreshUserBalance(); window.addEventListener(BALANCE_REFRESH_EVENT, handleBalanceRefresh); return () => window.removeEventListener(BALANCE_REFRESH_EVENT, handleBalanceRefresh); }, [refreshUserBalance]); useEffect(() => { messagesRef.current = messages; }, [messages]); useEffect(() => { messageHistoryHasMoreRef.current = messageHistoryHasMore; }, [messageHistoryHasMore]); useEffect(() => { sessionRef.current = session; }, [session]); useEffect(() => { sessionsRef.current = sessions; }, [sessions]); useEffect(() => { sessionSearchQueryRef.current = sessionSearchQuery; }, [sessionSearchQuery]); useEffect(() => { if (!user?.id) return; let cancelled = false; const showNotification = (notification: UserNotification) => { if (cancelled) return; const hasSeen = seenNotificationIdsRef.current.has(notification.id); if (activeNotification?.id === notification.id && hasSeen) return; seenNotificationIdsRef.current.add(notification.id); setActiveNotification(notification); setNotice(`${notification.title}\n${notification.body}`.trim()); if (isRechargeNotification(notification)) { requestBalanceRefresh(); } }; const pullNotifications = async () => { try { const notifications = await listNotifications('unread', 10); if (cancelled) return; if (notifications.length === 0) { seenNotificationIdsRef.current.clear(); return; } const currentIds = new Set(notifications.map((item) => item.id)); seenNotificationIdsRef.current.forEach((id) => { if (!currentIds.has(id)) seenNotificationIdsRef.current.delete(id); }); showNotification(notifications[0]); } catch { // Ignore notification polling failures to avoid disrupting chat. } }; const handleForegroundSync = () => { if (document.visibilityState === 'visible') { void pullNotifications(); } }; void pullNotifications(); const unsubscribe = subscribeNotificationEvents({ onNotification: showNotification, onSync: () => void pullNotifications(), }); window.addEventListener('focus', handleForegroundSync); document.addEventListener('visibilitychange', handleForegroundSync); return () => { cancelled = true; unsubscribe(); window.removeEventListener('focus', handleForegroundSync); document.removeEventListener('visibilitychange', handleForegroundSync); }; }, [user?.id, activeNotification?.id]); const loadProjectMemory = useCallback(async (sessionId: string, force: boolean) => { if (isDirectChatSessionId(sessionId)) return null; if (!canUseProjectMemory) return null; setMemoryLoading(true); try { const result = await bootstrapProjectMemory(sessionId, appConfig.memoryQuery, force); if (force && !result.summary) { setNotice('未找到可用的项目记忆,当前会话仍可正常使用'); } return result; } catch (err) { setNotice(`项目记忆加载失败:${err instanceof Error ? err.message : String(err)}`); return null; } finally { setMemoryLoading(false); } }, [canUseProjectMemory]); const formatUserMemoryNotice = useCallback((result: { analyzed: number; memories: number; totalMemories: number; syncedToSession: boolean; }) => { const fragments = []; if (result.memories > 0) { fragments.push(`新增 ${result.memories} 条长期记忆`); } else if (result.analyzed > 0) { fragments.push('已分析最近对话,暂无新的长期记忆'); } else { fragments.push('没有新的用户对话可提炼'); } fragments.push(`当前累计 ${result.totalMemories} 条`); if (result.syncedToSession) { fragments.push('已同步到当前会话'); } return fragments.join(','); }, []); const rememberRecentContext = useCallback( async ({ silent = false, sessionId, sessionName, recentContext, }: { silent?: boolean; sessionId?: string; sessionName?: string; recentContext?: string; } = {}) => { const currentSession = sessionId ? { id: sessionId, name: sessionName ?? sessionRef.current?.name ?? sessionId, } : sessionRef.current; if (!currentSession) return false; if (isDirectChatSessionId(currentSession.id)) return false; if (rememberInFlightRef.current) return false; const contextText = recentContext ?? messagesRef.current .filter((message) => getDisplayText(message).trim()) .slice(-6) .map((message) => `${message.role === 'user' ? 'User' : 'Assistant'}: ${getDisplayText(message)}`) .join('\n\n') .slice(0, 6_000); if (!canUseProjectMemory) { return false; } if (!contextText) { if (!silent) setNotice('当前还没有可保存的对话内容'); return false; } const contextKey = `${currentSession.id}:${contextText}`; if (rememberedContextRef.current === contextKey) { return false; } rememberInFlightRef.current = true; if (!silent) { setMemoryLoading(true); } try { await rememberProjectContext( currentSession.id, contextText, `H5 会话 ${currentSession.name}`, ); rememberedContextRef.current = contextKey; await loadProjectMemory(currentSession.id, true); if (!silent) { setNotice('最近对话已写入长期记忆,并同步到当前会话'); } return true; } catch (err) { if (!silent) { setNotice(`保存项目记忆失败:${err instanceof Error ? err.message : String(err)}`); } return false; } finally { rememberInFlightRef.current = false; if (!silent) { setMemoryLoading(false); } } }, [canUseProjectMemory, loadProjectMemory], ); const refreshSessions = useCallback( async (options?: { preserveExisting?: boolean; query?: string }): Promise => { setSessionsLoading(true); try { const { items, page } = await fetchSessionListWithRetry({ limit: appConfig.sessionPageSize, offset: 0, query: options?.query ?? sessionSearchQueryRef.current, }); const merged = options?.preserveExisting ? appendSessionLists(sessionsRef.current, items) : items; const nextOffset = Number(page.offset ?? 0) + items.length; const total = Math.max(Number(page.total ?? merged.length), merged.length); sessionsOffsetRef.current = options?.preserveExisting ? Math.max(sessionsOffsetRef.current, nextOffset) : nextOffset; setSessionsHasMore(merged.length < total); if (options?.query != null) { sessionSearchQueryRef.current = options.query; setSessionSearchQueryState(options.query); } setSessions(merged); return items; } catch (err) { if (err instanceof ApiError || err instanceof Error) { setNotice(sessionListFailureMessage(err, 'refresh')); } return []; } finally { setSessionsLoading(false); } }, [], ); const loadMoreSessions = useCallback(async () => { if (sessionsLoading || sessionsLoadingMore || !sessionsHasMore) return; setSessionsLoadingMore(true); try { const { items, page } = await fetchSessionListWithRetry({ limit: appConfig.sessionPageSize, offset: sessionsOffsetRef.current, query: sessionSearchQueryRef.current, }); const merged = appendSessionLists(sessionsRef.current, items); const nextOffset = Number(page.offset ?? sessionsOffsetRef.current) + items.length; const total = Math.max(Number(page.total ?? merged.length), merged.length); sessionsOffsetRef.current = Math.max(sessionsOffsetRef.current, nextOffset); setSessionsHasMore(merged.length < total); setSessions(merged); } catch (err) { if (isTransientSessionListError(err) || err instanceof ApiError) { setNotice(sessionListFailureMessage(err, 'load-more')); } } finally { setSessionsLoadingMore(false); } }, [sessionsHasMore, sessionsLoading, sessionsLoadingMore]); const setSessionSearchQuery = useCallback((query: string) => { const nextQuery = query.trim(); sessionSearchQueryRef.current = nextQuery; setSessionSearchQueryState(nextQuery); sessionsOffsetRef.current = 0; setSessionsHasMore(false); void refreshSessions({ query: nextQuery }); }, [refreshSessions]); const resolveChatImageUploadCategoryId = useCallback(async () => { if (chatImageCategoryIdRef.current) return chatImageCategoryIdRef.current; const space = await getMindSpace(); const category = space.categories.find((item) => item.code === 'public') ?? space.categories.find((item) => item.code === 'oa') ?? space.categories[0]; if (!category) { throw new Error('当前空间暂无可用的图片上传分类'); } chatImageCategoryIdRef.current = category.id; return category.id; }, []); const uploadChatImage = useCallback(async ( file: File, onProgress?: (progress: number) => void, options: { messageId?: string | null } = {}, ): Promise => { if (!file.type.startsWith('image/')) { throw new Error('只支持图片文件'); } const categoryId = await resolveChatImageUploadCategoryId(); const asset = await uploadMindSpaceAsset(categoryId, file, { maxImageBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, onProgress, sessionId: sessionRef.current?.id ?? null, messageId: options.messageId ?? null, }); return buildAbsoluteAssetDownloadUrl({ id: asset.id, updatedAt: asset.updatedAt, }); }, [resolveChatImageUploadCategoryId]); const resolveChatFileUploadCategoryId = useCallback(async () => { if (chatFileCategoryIdRef.current) return chatFileCategoryIdRef.current; const space = await getMindSpace(); const category = space.categories.find((item) => item.code === 'oa') ?? space.categories[0]; if (!category) { throw new Error('当前空间暂无可用的附件上传分类'); } chatFileCategoryIdRef.current = category.id; return category.id; }, []); const uploadChatAttachment = useCallback(async ( file: File, onProgress?: (progress: number) => void, options: { messageId?: string | null } = {}, ) => { if (file.type.startsWith('image/')) { throw new Error('请使用图片上传入口发送图片'); } const categoryId = await resolveChatFileUploadCategoryId(); const asset = await uploadMindSpaceAsset(categoryId, file, { onProgress, sessionId: sessionRef.current?.id ?? null, messageId: options.messageId ?? null, }); return { assetId: asset.id, downloadUrl: buildAbsoluteAssetDownloadUrl({ id: asset.id, updatedAt: asset.updatedAt, }), filename: asset.displayName || asset.filename || file.name, mimeType: asset.mimeType || file.type || 'application/octet-stream', }; }, [resolveChatFileUploadCategoryId]); const syncSessionMessages = useCallback(async (sessionId: string) => { // REGRESSION GUARD: Finish may arrive before the backend persists assistant turns. // Never blind-replace with server snapshot — merge + retry (see chat-finish-sync.mjs). try { const localMessages = messagesRef.current; const currentLoadedCount = Math.max( messageHistoryLoadedCountRef.current, localMessages.length, appConfig.sessionMessagePageSize, ); const knownSession = sessionRef.current?.id === sessionId ? sessionRef.current : sessionsRef.current.find((item) => item.id === sessionId); const hints = { messageCount: Math.max(knownSession?.message_count ?? 0, localMessages.length), updatedAt: knownSession?.updated_at, }; const historyQuery = { before: 0, limit: currentLoadedCount }; const fetchDetail = () => loadSessionDetail(sessionId, hints, historyQuery); let detail = await fetchDetail(); let nextMessages = mergeConversationSnapshot(localMessages, detail.messages); for (const delay of FINISH_SYNC_RETRY_DELAYS_MS) { if (detail.messages.length >= localMessages.length) break; await new Promise((resolve) => window.setTimeout(resolve, delay)); if (sessionRef.current?.id !== sessionId) return; detail = await fetchDetail(); nextMessages = mergeConversationSnapshot(localMessages, detail.messages); } setSessions((prev) => prependUnique(prev, toSessionSummary(detail.session))); if (sessionRef.current?.id !== sessionId) return; messagesRef.current = nextMessages; messageHistoryLoadedCountRef.current = nextMessages.length; messageHistoryTotalRef.current = Math.max( Number(detail.page.total ?? nextMessages.length), nextMessages.length, ); setMessageHistoryHasMore(nextMessages.length < messageHistoryTotalRef.current); setMessageHistoryTotal(messageHistoryTotalRef.current); setMessages(nextMessages); setSession((current) => (current?.id === sessionId ? detail.session : current)); } catch (err) { console.warn('Session final sync failed:', err); setNotice('对话已完成,但最终内容同步失败;当前显示可能仍是临时流式结果,请稍后刷新。'); // Keep the optimistic streamed state if the follow-up sync fails. } }, []); const scheduleReplyRecoverySync = useCallback( (sessionId: string, submitToken: number) => { for (const delay of REPLY_RECOVERY_SYNC_DELAYS_MS) { window.setTimeout(() => { if (connectTokenRef.current !== submitToken) return; if (sessionRef.current?.id !== sessionId) return; void syncSessionMessages(sessionId); }, delay); } }, [syncSessionMessages], ); const processEvent = useCallback( (event: SessionEvent, requestId: string, sessionId: string) => { const eventRequestId = getSessionEventRequestId(event); if (eventRequestId && eventRequestId !== requestId) return; clearActiveRequestMissingTimer(); switch (event.type) { case 'Message': { const fallbackReason = isCreditsExhaustedNotification(event.message) ? 'credits' : isRelayServerErrorMessage(event.message) ? 'relay' : null; if (fallbackReason) { const retryKey = `${sessionId}:${requestId}:${fallbackReason}`; if (!fallbackRetriedRef.current.has(retryKey) && sessionRef.current) { fallbackRetriedRef.current.add(retryKey); void (async () => { try { const fallback = await applyLocalLlmFallback(sessionRef.current!.id); if (!fallback.ok) { throw new Error(fallback.message ?? '本地 LLM 不可用'); } setNotice( fallbackReason === 'credits' ? `DeepSeek 额度不足,已切换到本地 ${fallback.model ?? 'qwen2.5-coder:7b'} 并重试…` : `云端 Relay 返回 500(请求体过大),已切换到本地 ${fallback.model ?? 'qwen2.5-coder:7b'} 并重试…`, ); const lastUser = [...messagesRef.current] .reverse() .find((message) => message.role === 'user'); if (!lastUser) { setChatState('idle'); activeRequestId.current = null; return; } const retryRequestId = crypto.randomUUID(); activeRequestId.current = retryRequestId; setChatState('waiting'); setError(null); const retryRun = await createAgentRun(sessionRef.current!.id, retryRequestId, lastUser); const finishedRetryRun = retryRun.status === 'succeeded' ? retryRun : await waitForAgentRun(retryRun.id); if (!finishedRetryRun.sessionId) { throw new Error('后台任务已提交,但未返回会话'); } setChatState('streaming'); } catch (err) { setError(err instanceof Error ? err.message : String(err)); setChatState('error'); activeRequestId.current = null; } })(); } else { setError( fallbackReason === 'credits' ? 'DeepSeek 额度已耗尽,且本地 fallback 重试失败或未启用' : '云端 Relay 不可用,且本地 fallback 重试失败或未启用', ); setChatState('error'); activeRequestId.current = null; } return; } if (!event.message.metadata?.userVisible) return; setMessages((prev) => { const next = pushMessage(prev, event.message); messagesRef.current = next; messageHistoryLoadedCountRef.current = next.length; return next; }); const confirmation = getToolConfirmation(event.message); if (confirmation) { setPendingTool(confirmation); setChatState('waiting'); } else { setChatState('streaming'); } return; } case 'UpdateConversation': { let snapshot = normalizeConversationMessages( event.conversation.filter((m) => m.metadata?.userVisible), ); const loadedCount = Math.max(messageHistoryLoadedCountRef.current, messagesRef.current.length); if (messageHistoryHasMoreRef.current && loadedCount > 0 && snapshot.length > loadedCount) { snapshot = snapshot.slice(-loadedCount); } messagesRef.current = mergeConversationSnapshot(messagesRef.current, snapshot); messageHistoryLoadedCountRef.current = messagesRef.current.length; setMessages(messagesRef.current); return; } case 'Error': setError(event.error); setChatState('error'); activeRequestId.current = null; return; case 'Finish': setChatState('idle'); setPendingTool(null); activeRequestId.current = null; if (isDirectChatSessionId(sessionId)) { unsubscribeRef.current?.(); unsubscribeRef.current = null; subscribedSessionIdRef.current = null; } setSessions((prev) => touchSession(prev, sessionId, 0)); if (userRef.current && onUserUpdateRef.current) { void getMe() .then(({ user: fresh }) => onUserUpdateRef.current?.(fresh)) .catch(() => {}); } void (async () => { await syncSessionMessages(sessionId); const recentContext = messagesRef.current .filter((message) => getDisplayText(message).trim()) .slice(-6) .map((message) => `${message.role === 'user' ? 'User' : 'Assistant'}: ${getDisplayText(message)}`) .join('\n\n') .slice(0, 6_000); const finishedSession = sessionRef.current; void rememberRecentContext({ silent: true, sessionId, sessionName: finishedSession?.name, recentContext, }); })(); return; default: return; } }, [clearActiveRequestMissingTimer, rememberRecentContext, syncSessionMessages], ); const subscribeToSession = useCallback( (sessionId: string) => { if (subscribedSessionIdRef.current === sessionId && unsubscribeRef.current) { return; } subscribedSessionIdRef.current = sessionId; unsubscribeRef.current?.(); unsubscribeRef.current = subscribeSessionEvents( sessionId, (event) => { let rid = activeRequestId.current; if (event.type === 'ActiveRequests') { const activeRequestContext = reconcileSessionEventRequestContext({ activeRequestId: rid, chatState: chatStateRef.current, eventType: event.type, activeRequestIds: event.request_ids, }); if ( activeRequestContext.activeRequestId && activeRequestContext.activeRequestId !== rid ) { clearActiveRequestMissingTimer(); activeRequestId.current = activeRequestContext.activeRequestId; rid = activeRequestContext.activeRequestId; } else if (rid && event.request_ids.includes(rid)) { clearActiveRequestMissingTimer(); } else if (activeRequestContext.allowMissingGrace) { // The backend can briefly report no active request between tool phases. // Confirm the absence before turning the UI idle, otherwise MindSpace // refreshes the page while tools are still mutating it. if (!activeRequestMissingTimerRef.current) { activeRequestMissingTimerRef.current = window.setTimeout(() => { activeRequestMissingTimerRef.current = null; if (activeRequestId.current !== rid) return; activeRequestId.current = null; setChatState('idle'); setPendingTool(null); void syncSessionMessages(sessionId); }, ACTIVE_REQUEST_MISSING_GRACE_MS); } } if (activeRequestContext.promoteStreaming) { setChatState('streaming'); } return; } const eventRequestId = getSessionEventRequestId(event); const eventRequestContext = reconcileSessionEventRequestContext({ activeRequestId: rid, chatState: chatStateRef.current, eventType: event.type, eventRequestId, }); if ( eventRequestContext.activeRequestId && eventRequestContext.activeRequestId !== rid ) { clearActiveRequestMissingTimer(); activeRequestId.current = eventRequestContext.activeRequestId; rid = eventRequestContext.activeRequestId; if (eventRequestContext.promoteStreaming) { setChatState('streaming'); } } if ( rid || (isDirectChatSessionId(sessionId) && (event.type === 'UpdateConversation' || event.type === 'Finish')) ) { processEvent(event, rid ?? '', sessionId); } }, (err) => { if (err instanceof ApiError && err.status === 401) return; if (err instanceof ApiError && err.status === 402) { notifyInsufficientBalance(); return; } // SSE 断连时如果 agent 还在跑,不打断 chatState,等重连后事件恢复 if (activeRequestId.current) return; setError(err.message); }, { pauseWhenHidden: true, onBalance: (update) => { const currentUser = userRef.current; const updateUser = onUserUpdateRef.current; if (currentUser && updateUser) { updateUser({ ...currentUser, balanceCents: update.balanceCents, ...(typeof update.tokensUsed === 'number' ? { tokensUsed: update.tokensUsed } : {}), }); } }, }, ); }, [clearActiveRequestMissingTimer, notifyInsufficientBalance, processEvent], ); const resetSessionView = useCallback(() => { connectTokenRef.current += 1; unsubscribeRef.current?.(); unsubscribeRef.current = null; subscribedSessionIdRef.current = null; clearActiveRequestMissingTimer(); setError(null); setPendingTool(null); activeRequestId.current = null; messagesRef.current = []; messageHistoryLoadedCountRef.current = 0; messageHistoryTotalRef.current = 0; messageHistoryHasMoreRef.current = false; setMessageHistoryHasMore(false); setMessageHistoryTotal(0); setMessageHistoryLoadingMore(false); setMessages([]); setChatState('connecting'); }, [clearActiveRequestMissingTimer]); const connectSession = useCallback( async ( sessionId: string, options?: { showLoading?: boolean; skipResume?: boolean; seedSession?: Session; skipReconcile?: boolean; }, ) => { const showLoading = options?.showLoading !== false; const token = ++connectTokenRef.current; unsubscribeRef.current?.(); unsubscribeRef.current = null; subscribedSessionIdRef.current = null; clearActiveRequestMissingTimer(); if (showLoading) { setChatState('loading'); } setError(null); setPendingTool(null); activeRequestId.current = null; let completed = false; try { const knownSession = sessionsRef.current.find((s) => s.id === sessionId); const hints = knownSession ? { messageCount: knownSession.message_count, updatedAt: knownSession.updated_at } : undefined; const detailPromise = withTransientConnectRetry(() => loadSessionDetail(sessionId, hints, { before: 0, limit: appConfig.sessionMessagePageSize, }), ); const resumedPromise = options?.skipResume || isDirectChatSessionId(sessionId) || unavailableAgentSessionIdsRef.current.has(sessionId) ? Promise.resolve(options.seedSession ?? null) : withTransientConnectRetry(() => resumeSession(sessionId, { skipReconcile: options?.skipReconcile ?? false, }), ); const { session: detail, messages: history, page } = await detailPromise; if (token !== connectTokenRef.current) return; writeStoredSessionId(userRef.current?.id, sessionId); setSession((current) => current?.id === sessionId ? { ...detail, ...current, id: sessionId } : { ...detail, id: sessionId }, ); messagesRef.current = history; messageHistoryLoadedCountRef.current = history.length; messageHistoryTotalRef.current = Math.max(Number(page.total ?? history.length), history.length); messageHistoryHasMoreRef.current = history.length < messageHistoryTotalRef.current; setMessageHistoryHasMore(messageHistoryHasMoreRef.current); setMessageHistoryTotal(messageHistoryTotalRef.current); setMessages(history); let resumed: Session | null = null; try { resumed = await resumedPromise; } catch (err) { if (!isMissingAgentSessionError(err)) throw err; // Persisted conversation is still readable even when its transient // Agent runtime has been reclaimed. Do not leave a completed chat // in the connecting state or retry resume on future selections. unavailableAgentSessionIdsRef.current.add(sessionId); } if (token !== connectTokenRef.current) return; setSession({ ...detail, ...(resumed ?? {}), id: sessionId }); setChatState('idle'); completed = true; setSessions((prev) => prependUnique(prev, toSessionSummary({ ...detail, ...(resumed ?? {}), id: sessionId })), ); subscribeToSession(sessionId); } finally { if (!completed && token === connectTokenRef.current) { setChatState((current) => current === 'connecting' || current === 'loading' ? 'idle' : current, ); } } }, [clearActiveRequestMissingTimer, subscribeToSession], ); const ensureProvider = useCallback(async (sessionId: string) => { if (isDirectChatSessionId(sessionId)) return; const provider = appConfig.provider || (await readConfig('TKMIND_PROVIDER')); const model = appConfig.model || (await readConfig('TKMIND_MODEL')); if (provider && model) { try { await updateProvider(sessionId, provider, model); } catch { // resumeSession may have already configured the provider } } }, []); const loadOlderMessages = useCallback(async () => { const sessionId = sessionRef.current?.id; if (!sessionId || messageHistoryLoadingMore || !messageHistoryHasMoreRef.current) return; const knownSession = sessionsRef.current.find((item) => item.id === sessionId); const hints = knownSession ? { messageCount: knownSession.message_count, updatedAt: knownSession.updated_at } : undefined; setMessageHistoryLoadingMore(true); try { const { messages: olderMessages, page } = await loadSessionDetail( sessionId, hints, { before: messageHistoryLoadedCountRef.current, limit: appConfig.sessionMessagePageSize, }, ); if (sessionRef.current?.id !== sessionId) return; const merged = mergeMessagePages(olderMessages, messagesRef.current); messagesRef.current = merged; messageHistoryLoadedCountRef.current = merged.length; messageHistoryTotalRef.current = Math.max(Number(page.total ?? merged.length), merged.length); messageHistoryHasMoreRef.current = merged.length < messageHistoryTotalRef.current; setMessageHistoryHasMore(messageHistoryHasMoreRef.current); setMessageHistoryTotal(messageHistoryTotalRef.current); setMessages(merged); } catch (err) { if (err instanceof ApiError && err.status === 0) { setError('网络不可用,无法继续加载更早消息'); } } finally { setMessageHistoryLoadingMore(false); } }, [messageHistoryLoadingMore]); const retryConnect = useCallback(async () => { const sessionId = session?.id ?? readStoredSessionId(userRef.current?.id); if (!sessionId) return; setError(null); try { await connectSession(sessionId); } catch (err) { setError(err instanceof Error ? err.message : String(err)); setChatState('error'); } }, [session, connectSession]); const connectSessionRef = useRef(connectSession); connectSessionRef.current = connectSession; useEffect(() => { let cancelled = false; const boot = async () => { try { setChatState('loading'); setError(null); const summaries = await refreshSessions(); const previousSessionId = readStoredSessionId(userRef.current?.id); let staleSession = false; let restorableSessionId: string | null = null; if (previousSessionId) { const previousSession = summaries.find((item) => item.id === previousSessionId) ?? null; if (previousSession && shouldShowNewChatTitle(previousSession)) { try { await deleteChatSession(previousSessionId); } catch { // Best-effort cleanup: a failed delete should not block the next fresh chat. } clearStoredSessionId(userRef.current?.id); setSessions((prev) => prev.filter((item) => item.id !== previousSessionId)); } else { restorableSessionId = previousSessionId; } } if (cancelled) return; if (restorableSessionId) { try { await connectSessionRef.current(restorableSessionId, { showLoading: false, skipReconcile: true, }); } catch (err) { if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) { clearStoredSessionId(userRef.current?.id); staleSession = true; setSession(null); messageHistoryLoadedCountRef.current = 0; messageHistoryTotalRef.current = 0; messageHistoryHasMoreRef.current = false; setMessageHistoryHasMore(false); setMessageHistoryTotal(0); setMessages([]); setChatState('idle'); } else if (isTransientConnectError(err)) { setSession(null); messageHistoryLoadedCountRef.current = 0; messageHistoryTotalRef.current = 0; messageHistoryHasMoreRef.current = false; setMessageHistoryHasMore(false); setMessageHistoryTotal(0); setMessages([]); setChatState('idle'); setNotice('上次会话恢复超时,请点左上角 ☰ 选择历史对话,或稍后刷新重试'); } else { throw err; } } if (cancelled) return; } else { setChatState('idle'); } if (staleSession) { setNotice('上次会话已失效,已为你新建对话'); } } catch (err) { if (!cancelled) { if (err instanceof ApiError && err.status === 402) { notifyInsufficientBalance(); return; } setError(err instanceof Error ? err.message : String(err)); setChatState('error'); } } }; void boot(); return () => { cancelled = true; connectTokenRef.current += 1; unsubscribeRef.current?.(); clearActiveRequestMissingTimer(); }; // Re-run when the signed-in user changes so session storage stays per-user. }, [clearActiveRequestMissingTimer, ensureProvider, loadProjectMemory, notifyInsufficientBalance, refreshSessions, user?.id]); useEffect(() => { const handleVisibility = () => { if (!document.hidden && session?.id) { void refreshSessions({ preserveExisting: true }); } }; document.addEventListener('visibilitychange', handleVisibility); return () => document.removeEventListener('visibilitychange', handleVisibility); }, [session?.id, refreshSessions]); const switchSession = useCallback( (sessionId: string) => { if (session?.id === sessionId || chatState === 'streaming' || chatState === 'waiting') return; resetSessionView(); const token = connectTokenRef.current; const target = sessions.find((item) => item.id === sessionId); if (target) { setSession({ ...target, working_dir: userRef.current?.workspaceRoot ?? '', conversation: null, }); writeStoredSessionId(userRef.current?.id, sessionId); } void (async () => { try { await connectSession(sessionId, { showLoading: false }); } catch (err) { if (token !== connectTokenRef.current) return; setError(err instanceof Error ? err.message : String(err)); setChatState('error'); } })(); }, [session, chatState, connectSession, resetSessionView, sessions], ); const submit = useCallback( async ( text: string, options?: { mindspaceContext?: MindSpaceChatContext; messageId?: string; forceDeepReasoning?: boolean; pgRequired?: boolean; imageGenerationMode?: ImageGenerationMode; selectedChatSkill?: string; fileAttachments?: ChatFileAttachment[]; }, 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; // 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 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; if (activeSessionId) { setSessions((prev) => touchSession(prev, activeSessionId!, 1)); } try { const pageDataDevTaskType = resolvePageDataDevTaskType(trimmed); const requiresAider = options?.selectedChatSkill === AIDER_DEVELOPMENT_SKILL_NAME; const runOptions = resolveAgentRunOptions(trimmed, { taskType: pageDataDevTaskType ?? 'h5_chat_code_task', forceCode: requiresAider, requiredExecutor: requiresAider ? 'aider' : undefined, userId: userRef.current?.id ?? null, requestId, mindspaceContext: options?.mindspaceContext ?? null, }); const createdRun = await createAgentRun( activeSessionId, requestId, userMessage, { ...runOptions, ...(options?.forceDeepReasoning ? { forceDeepReasoning: true } : {}), ...(options?.mindspaceContext?.selectedAssets?.length ? { selectedAssetIds: options.mindspaceContext.selectedAssets .map((asset) => asset.id) .filter(Boolean), } : {}), }, ); const runSessionId = createdRun.sessionId ?? activeSessionId; if (runSessionId && !isDirectChatSessionId(runSessionId)) { activeSessionId = runSessionId; subscribeToSession(runSessionId); } const finishedRun = createdRun.status === 'succeeded' ? createdRun : await waitForAgentRunWithDirectChatPreview(createdRun.id, { isCancelled: () => submitToken !== connectTokenRef.current, onSessionId: (sessionId) => { if (submitToken !== connectTokenRef.current) return; if (activeSessionId !== sessionId) { activeSessionId = sessionId; const nextSession: Session = { id: sessionId, name: 'New Chat', message_count: messagesRef.current.length, working_dir: '', }; writeStoredSessionId(userRef.current?.id, sessionId); setSession(nextSession); setSessions((prev) => prependUnique(prev, nextSession)); } if (!isDirectChatSessionId(sessionId)) { subscribeToSession(sessionId); if (shouldPromoteSessionIdToStreaming(chatStateRef.current)) { setChatState('streaming'); } } }, onMessages: (snapshotMessages) => { if (submitToken !== connectTokenRef.current) return; messagesRef.current = mergeConversationSnapshot( messagesRef.current, snapshotMessages, ); messageHistoryLoadedCountRef.current = messagesRef.current.length; setMessages(messagesRef.current); }, }); if (submitToken !== connectTokenRef.current) return; activeSessionId = finishedRun.sessionId; 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 nextSession: Session = { id: activeSessionId, name: 'New Chat', message_count: messagesRef.current.length, working_dir: '', }; writeStoredSessionId(userRef.current?.id, activeSessionId); setSession(nextSession); setSessions((prev) => prependUnique(prev, nextSession)); void loadSessionDetail(activeSessionId) .then(({ session: loaded }) => { setSession(loaded); setSessions((prev) => prependUnique(prev, loaded)); }) .catch(() => {}); void ensureProvider(activeSessionId); void loadProjectMemory(activeSessionId, false); void refreshSessions(); } if (isDirectChatSessionId(activeSessionId)) { try { const detail = await loadSessionDetail(activeSessionId); if (submitToken === connectTokenRef.current) { messagesRef.current = mergeConversationSnapshot(messagesRef.current, detail.messages); messageHistoryLoadedCountRef.current = messagesRef.current.length; setMessages(messagesRef.current); setSession(detail.session); } } catch { // Messages may already be present from the direct-chat preview poll. } clearActiveRequestMissingTimer(); activeRequestId.current = null; setChatState('idle'); } else { let finishedViaPortalDirectChat = false; try { const detail = await loadSessionDetail(activeSessionId); if (submitToken === connectTokenRef.current) { messagesRef.current = mergeConversationSnapshot( messagesRef.current, detail.messages, ); messageHistoryLoadedCountRef.current = messagesRef.current.length; setMessages(messagesRef.current); setSession(detail.session); finishedViaPortalDirectChat = sessionFinishedViaPortalDirectChat( detail.messages, userMessage, ); } } catch { // Fall through to agent streaming when the snapshot is not ready yet. } subscribeToSession(activeSessionId); 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') { clearActiveRequestMissingTimer(); activeRequestId.current = null; setChatState('idle'); } else { setChatState('streaming'); scheduleReplyRecoverySync(activeSessionId, submitToken); } } } catch (err) { if ( activeSessionId && err instanceof ApiError && shouldKeepStreamingAfterRunError(err.status, err.message, err.code) ) { subscribeToSession(activeSessionId); setChatState('streaming'); setError(null); scheduleReplyRecoverySync(activeSessionId, submitToken); return; } if ( activeSessionId && shouldKeepStreamingAfterRunError( undefined, err instanceof Error ? err.message : String(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); setChatState('streaming'); setError(null); scheduleReplyRecoverySync(activeSessionId, submitToken); return; } if (session && activeSessionId) setSessions((prev) => touchSession(prev, activeSessionId!, -1)); if (err instanceof ApiError && err.status === 402) { notifyInsufficientBalance(); } else { setError(err instanceof Error ? err.message : String(err)); setChatState('error'); } clearActiveRequestMissingTimer(); activeRequestId.current = null; } }, [ notifyInsufficientBalance, session, grantedSkills, clearActiveRequestMissingTimer, subscribeToSession, scheduleReplyRecoverySync, ensureProvider, loadProjectMemory, refreshSessions, syncSessionMessages, ], ); const stop = useCallback(async () => { if (!session || !activeRequestId.current) return; try { await cancelRequest(session.id, activeRequestId.current); } finally { clearActiveRequestMissingTimer(); activeRequestId.current = null; setChatState('idle'); } }, [clearActiveRequestMissingTimer, session]); const approveTool = useCallback( async (allow: boolean) => { if (!session || !pendingTool) return; await confirmTool(session.id, pendingTool.id, allow ? 'allow_once' : 'deny_once'); setPendingTool(null); setChatState('streaming'); }, [session, pendingTool], ); const newSession = useCallback(async () => { const token = ++connectTokenRef.current; const previousSession = sessionRef.current; const previousSessionId = previousSession?.id ?? null; const pendingRequestId = activeRequestId.current; if (previousSessionId && pendingRequestId) { try { await cancelRequest(previousSessionId, pendingRequestId); } catch { // Best-effort: the user is explicitly abandoning the in-flight turn. } } unsubscribeRef.current?.(); unsubscribeRef.current = null; subscribedSessionIdRef.current = null; clearActiveRequestMissingTimer(); clearStoredSessionId(userRef.current?.id); activeRequestId.current = null; messagesRef.current = []; messageHistoryLoadedCountRef.current = 0; messageHistoryTotalRef.current = 0; messageHistoryHasMoreRef.current = false; setMessageHistoryHasMore(false); setMessageHistoryTotal(0); setMessageHistoryLoadingMore(false); setMessages([]); setSession(null); setError(null); setPendingTool(null); setChatState('connecting'); let completed = false; try { if ( previousSessionId && previousSession && shouldShowNewChatTitle(toSessionSummary(previousSession)) ) { try { await deleteChatSession(previousSessionId); if (token === connectTokenRef.current) { setSessions((prev) => prev.filter((item) => item.id !== previousSessionId)); } } catch { // Keep the abandoned empty session in history if cleanup fails. } } const started = await startSession(); if (token !== connectTokenRef.current) return; const nextSession: Session = { ...started, name: started.name || 'New Chat', working_dir: userRef.current?.workspaceRoot ?? started.working_dir ?? '', conversation: null, }; writeStoredSessionId(userRef.current?.id, nextSession.id); setSession(nextSession); setSessions((prev) => prependUnique(prev, toSessionSummary(nextSession))); await ensureProvider(nextSession.id); if (token !== connectTokenRef.current) return; subscribeToSession(nextSession.id); setChatState('idle'); completed = true; void refreshSessions(); } catch (err) { if (token !== connectTokenRef.current) return; setSession(null); setChatState('idle'); setError(err instanceof Error ? err.message : String(err)); } finally { if (!completed && token === connectTokenRef.current) { setChatState((current) => (current === 'connecting' ? 'idle' : current)); } } }, [ clearActiveRequestMissingTimer, ensureProvider, refreshSessions, subscribeToSession, ]); const refreshProjectMemory = useCallback(async () => { if (!session || chatState === 'streaming' || chatState === 'waiting') return; if (isDirectChatSessionId(session.id)) return; await loadProjectMemory(session.id, true); }, [chatState, loadProjectMemory, session]); const rememberCurrentUserMemory = useCallback(async () => { if (!session || !canUseLongTermMemory) return; if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return; setUserMemoryLoading(true); try { const result = await rememberUserMemory(session.id); setNotice(formatUserMemoryNotice(result)); } catch (err) { setNotice(`保存长期记忆失败:${err instanceof Error ? err.message : String(err)}`); } finally { setUserMemoryLoading(false); } }, [canUseLongTermMemory, chatState, formatUserMemoryNotice, session]); const refreshUserMemory = useCallback(async () => { if (!session || !canUseLongTermMemory) return; if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return; setUserMemoryLoading(true); try { const result = await syncUserMemory(session.id); setNotice( result.syncedToSession ? `长期记忆已同步(累计 ${result.totalMemories} 条)` : '长期记忆已刷新,但当前会话尚未同步', ); } catch (err) { setNotice(`刷新长期记忆失败:${err instanceof Error ? err.message : String(err)}`); } finally { setUserMemoryLoading(false); } }, [canUseLongTermMemory, chatState, session]); const rememberCurrentContext = useCallback(async () => { if ( chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting' || chatState === 'waiting' ) return; await rememberRecentContext({ silent: false }); }, [chatState, rememberRecentContext]); const onSidebarOpen = useCallback(() => { if (sessionSearchQueryRef.current) { void refreshSessions({ query: sessionSearchQueryRef.current }); return; } if (sessionsRef.current.length === 0) { void refreshSessions(); return; } void refreshSessions({ preserveExisting: true }); }, [refreshSessions]); const deleteSession = useCallback( (sessionId: string) => { if (chatState === 'streaming' || chatState === 'waiting') return; void (async () => { try { await deleteChatSession(sessionId); const wasActive = sessionRef.current?.id === sessionId; const nextSessions = sessions.filter((item) => item.id !== sessionId); setSessions(nextSessions); if (!wasActive) return; const fallbackId = nextSessions[0]?.id; if (fallbackId) { void switchSession(fallbackId); return; } clearStoredSessionId(userRef.current?.id); resetSessionView(); setSession(null); void newSession(); } catch (err) { setNotice(`删除会话失败:${err instanceof Error ? err.message : String(err)}`); } })(); }, [chatState, newSession, resetSessionView, sessions, switchSession], ); return { session, sessions, sessionsLoading, sessionsLoadingMore, sessionsHasMore, sessionSearchQuery, messages, messageHistoryLoadingMore, messageHistoryHasMore, messageHistoryTotal, chatState, error, notice, pendingTool, memoryLoading, userMemoryLoading, canUseProjectMemory, canUseLongTermMemory, capabilities, grantedSkills, submit, stop, approveTool, newSession, rememberCurrentContext, refreshProjectMemory, rememberCurrentUserMemory, refreshUserMemory, switchSession, deleteSession, refreshSessions, loadMoreSessions, setSessionSearchQuery, loadOlderMessages, retryConnect, dismissNotice, onSidebarOpen, uploadChatImage, uploadChatAttachment, workingDir: session?.working_dir ?? user?.workspaceRoot ?? '', balanceCents: user?.balanceCents, totalCreditCents: user?.totalCreditCents, tokensUsed: user?.tokensUsed ?? 0, subscription: user?.subscription, rechargePrompt, rechargeForced, openRecharge, dismissRecharge, completeRecharge, subscribePrompt, openSubscribe, dismissSubscribe, completeSubscribe, }; }