import { useCallback, useEffect, useRef, useState } from 'react'; import { ApiError, applyLocalLlmFallback, bootstrapProjectMemory, cancelRequest, confirmTool, deleteChatSession, getMe, getSession, listSessions, loadSessionDetail, readConfig, rememberProjectContext, resumeSession, sendReply, startSession, subscribeSessionEvents, updateProvider, } from '../api/client'; import { appConfig } from '../config'; import type { CapabilityMap, ChatState, Message, MindSpaceChatContext, PortalUser, Session, SessionEvent, ToolConfirmation, } from '../types'; import { buildContextPrefix } from '../utils/mindspaceChatContext'; import { buildUserAddressPrefix } from '../utils/userAddress'; import { createUserMessage, getDisplayText, getToolConfirmation, getVisibleText, isCreditsExhaustedNotification, isRelayServerErrorMessage, pushMessage, } from '../utils/message'; import { prependUnique, sortAndTrim, touchSession } from '../utils/sessions'; const LEGACY_SESSION_KEY = 'tkmind-h5-session-id'; function resolveSessionStorageKey(userId?: string | null): string { return userId ? `${LEGACY_SESSION_KEY}:${userId}` : LEGACY_SESSION_KEY; } function readStoredSessionId(userId?: string | null): string | null { const scopedKey = resolveSessionStorageKey(userId); const scopedValue = localStorage.getItem(scopedKey); if (scopedValue) return scopedValue; const legacyValue = localStorage.getItem(LEGACY_SESSION_KEY); if (legacyValue && userId) { localStorage.setItem(scopedKey, legacyValue); localStorage.removeItem(LEGACY_SESSION_KEY); return legacyValue; } return legacyValue; } function writeStoredSessionId(userId: string | null | undefined, sessionId: string) { localStorage.setItem(resolveSessionStorageKey(userId), sessionId); } function clearStoredSessionId(userId?: string | null) { localStorage.removeItem(resolveSessionStorageKey(userId)); if (!userId) { localStorage.removeItem(LEGACY_SESSION_KEY); } } const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用'; export { INSUFFICIENT_BALANCE_NOTICE }; export function useTKMindChat( user?: PortalUser | null, onUserUpdate?: (user: PortalUser) => void, capabilities?: CapabilityMap | null, ) { const canUseProjectMemory = Boolean(capabilities?.context_memory); const [session, setSession] = useState(null); const [sessions, setSessions] = useState([]); const [sessionsLoading, setSessionsLoading] = useState(false); const [messages, setMessages] = useState([]); 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 [rechargePrompt, setRechargePrompt] = useState(false); const [rechargeForced, setRechargeForced] = useState(false); const activeRequestId = useRef(null); const unsubscribeRef = useRef<(() => void) | null>(null); const connectTokenRef = useRef(0); const messagesRef = useRef([]); const sessionRef = useRef(null); const rememberedContextRef = useRef(null); const rememberInFlightRef = useRef(false); const fallbackRetriedRef = useRef(new Set()); const userRef = useRef(user); const onUserUpdateRef = useRef(onUserUpdate); const dismissNotice = useCallback(() => setNotice(null), []); 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), }); } }, []); useEffect(() => { userRef.current = user; }, [user]); useEffect(() => { onUserUpdateRef.current = onUserUpdate; }, [onUserUpdate]); useEffect(() => { messagesRef.current = messages; }, [messages]); useEffect(() => { sessionRef.current = session; }, [session]); const loadProjectMemory = useCallback(async (sessionId: string, force: boolean) => { if (!canUseProjectMemory) return null; setMemoryLoading(true); try { const result = await bootstrapProjectMemory(sessionId, appConfig.memoryQuery, force); if (force) { setNotice( result.summary ? `项目记忆已刷新(${result.source})` : '未找到可用的项目记忆,当前会话仍可正常使用', ); } return result; } catch (err) { setNotice(`项目记忆加载失败:${err instanceof Error ? err.message : String(err)}`); return null; } finally { setMemoryLoading(false); } }, [canUseProjectMemory]); 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 (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 () => { setSessionsLoading(true); try { const items = await listSessions(); setSessions(sortAndTrim(items)); } catch (err) { if (err instanceof ApiError && err.status === 0) { setError('网络不可用,无法刷新历史列表'); } } finally { setSessionsLoading(false); } }, []); const processEvent = useCallback( (event: SessionEvent, requestId: string, sessionId: string) => { const raw = event as SessionEvent & { chat_request_id?: string; request_id?: string }; const eventRequestId = raw.chat_request_id ?? raw.request_id; if (eventRequestId && eventRequestId !== requestId) return; 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('streaming'); setError(null); await sendReply(sessionRef.current!.id, retryRequestId, lastUser); } 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; return next; }); const confirmation = getToolConfirmation(event.message); if (confirmation) { setPendingTool(confirmation); setChatState('waiting'); } else { setChatState('streaming'); } return; } case 'UpdateConversation': messagesRef.current = event.conversation.filter((m) => m.metadata?.userVisible); setMessages(messagesRef.current); return; case 'Error': setError(event.error); setChatState('error'); activeRequestId.current = null; return; case 'Finish': setChatState('idle'); setPendingTool(null); activeRequestId.current = null; setSessions((prev) => touchSession(prev, sessionId, 0)); if (userRef.current && onUserUpdateRef.current) { void getMe() .then(({ user: fresh }) => onUserUpdateRef.current?.(fresh)) .catch(() => {}); } 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; window.setTimeout(() => { void rememberRecentContext({ silent: true, sessionId, sessionName: finishedSession?.name, recentContext, }); }, 0); return; default: return; } }, [rememberRecentContext], ); const subscribeToSession = useCallback( (sessionId: string) => { unsubscribeRef.current?.(); unsubscribeRef.current = subscribeSessionEvents( sessionId, (event) => { const rid = activeRequestId.current; if (rid) processEvent(event, rid, sessionId); else if (event.type === 'ActiveRequests' && event.request_ids.length > 0) { activeRequestId.current = event.request_ids[0]; setChatState('streaming'); } }, (err) => { if (err instanceof ApiError && err.status === 401) return; if (err instanceof ApiError && err.status === 402) { notifyInsufficientBalance(); 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 } : {}), }); } }, }, ); }, [notifyInsufficientBalance, processEvent], ); const resetSessionView = useCallback(() => { connectTokenRef.current += 1; unsubscribeRef.current?.(); unsubscribeRef.current = null; setError(null); setPendingTool(null); activeRequestId.current = null; messagesRef.current = []; setMessages([]); setChatState('connecting'); }, []); const connectSession = useCallback( async ( sessionId: string, options?: { showLoading?: boolean; skipResume?: boolean; seedSession?: Session }, ) => { const showLoading = options?.showLoading !== false; const token = ++connectTokenRef.current; unsubscribeRef.current?.(); unsubscribeRef.current = null; if (showLoading) { setChatState('loading'); } setError(null); setPendingTool(null); activeRequestId.current = null; const resumed = options?.skipResume ? (options.seedSession ?? null) : await resumeSession(sessionId); if (token !== connectTokenRef.current) return; const { session: detail, messages: history } = await loadSessionDetail(sessionId); if (token !== connectTokenRef.current) return; writeStoredSessionId(userRef.current?.id, sessionId); setSession({ ...detail, ...(resumed ?? {}), id: sessionId }); messagesRef.current = history; setMessages(history); setChatState('idle'); setSessions((prev) => prependUnique(prev, { ...detail, ...resumed, id: sessionId })); subscribeToSession(sessionId); }, [subscribeToSession], ); const ensureProvider = useCallback(async (sessionId: string) => { 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 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); let sessionId = readStoredSessionId(userRef.current?.id); let staleSession = false; let freshSession: Session | null = null; if (sessionId) { try { await getSession(sessionId); } catch (err) { if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) { clearStoredSessionId(userRef.current?.id); sessionId = null; staleSession = true; } else { throw err; } } } if (!sessionId) { freshSession = await startSession(); sessionId = freshSession.id; writeStoredSessionId(userRef.current?.id, sessionId); if (staleSession) { setNotice('上次会话已失效,已为你新建对话'); } } if (cancelled) return; if (!freshSession) { await ensureProvider(sessionId); } if (cancelled) return; await connectSessionRef.current(sessionId, { skipResume: Boolean(freshSession), seedSession: freshSession ?? undefined, }); if (cancelled) return; if (freshSession) { void loadProjectMemory(sessionId, false); } void refreshSessions(); } 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?.(); }; // Re-run when the signed-in user changes so session storage stays per-user. }, [ensureProvider, loadProjectMemory, notifyInsufficientBalance, refreshSessions, user?.id]); useEffect(() => { const handleVisibility = () => { if (!document.hidden && session?.id) { void refreshSessions(); } }; document.addEventListener('visibilitychange', handleVisibility); return () => document.removeEventListener('visibilitychange', handleVisibility); }, [session?.id, refreshSessions]); const switchSession = useCallback( (sessionId: string) => { if (session?.id === sessionId || chatState === 'streaming') return; resetSessionView(); const token = connectTokenRef.current; const target = sessions.find((item) => item.id === sessionId); if (target) { setSession(target); 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 }) => { if (!session || !text.trim()) return; if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return; const trimmed = text.trim(); const mindspacePrefix = options?.mindspaceContext ? buildContextPrefix(options.mindspaceContext) : ''; const userPrefix = buildUserAddressPrefix(userRef.current); const agentPrefix = `${userPrefix}${mindspacePrefix}`; const userMessage = agentPrefix ? createUserMessage(trimmed, { agentText: `${agentPrefix}${trimmed}`, displayText: trimmed, }) : createUserMessage(trimmed); const requestId = crypto.randomUUID(); activeRequestId.current = requestId; messagesRef.current = [...messagesRef.current, userMessage]; setMessages(messagesRef.current); setSessions((prev) => touchSession(prev, session.id, 1)); setChatState('streaming'); setError(null); setPendingTool(null); try { await sendReply(session.id, requestId, userMessage); } catch (err) { setSessions((prev) => touchSession(prev, session.id, -1)); if (err instanceof ApiError && err.status === 402) { notifyInsufficientBalance(); } else { setError(err instanceof Error ? err.message : String(err)); setChatState('error'); } activeRequestId.current = null; } }, [notifyInsufficientBalance, session, chatState], ); const stop = useCallback(async () => { if (!session || !activeRequestId.current) return; try { await cancelRequest(session.id, activeRequestId.current); } finally { activeRequestId.current = null; setChatState('idle'); } }, [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(() => { if (chatState === 'streaming') return; resetSessionView(); setSession(null); const token = connectTokenRef.current; void (async () => { try { const created = await startSession(); if (token !== connectTokenRef.current) return; writeStoredSessionId(userRef.current?.id, created.id); setSession(created); setSessions((prev) => prependUnique(prev, created)); void loadProjectMemory(created.id, false); void refreshSessions(); await ensureProvider(created.id); if (token !== connectTokenRef.current) return; await connectSession(created.id, { showLoading: false }); } catch (err) { if (token !== connectTokenRef.current) return; if (err instanceof ApiError && err.status === 402) { notifyInsufficientBalance(); return; } setError(err instanceof Error ? err.message : String(err)); setChatState('error'); } })(); }, [chatState, connectSession, ensureProvider, loadProjectMemory, notifyInsufficientBalance, refreshSessions, resetSessionView]); const refreshProjectMemory = useCallback(async () => { if (!session || chatState === 'streaming') return; await loadProjectMemory(session.id, true); }, [chatState, loadProjectMemory, session]); const rememberCurrentContext = useCallback(async () => { if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return; await rememberRecentContext({ silent: false }); }, [chatState, rememberRecentContext]); const onSidebarOpen = useCallback(() => { void refreshSessions(); }, [refreshSessions]); const deleteSession = useCallback( (sessionId: string) => { if (chatState === 'streaming') 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, messages, chatState, error, notice, pendingTool, memoryLoading, submit, stop, approveTool, newSession, rememberCurrentContext, refreshProjectMemory, switchSession, deleteSession, refreshSessions, retryConnect, dismissNotice, onSidebarOpen, workingDir: session?.working_dir ?? user?.workspaceRoot ?? '', balanceCents: user?.balanceCents, totalCreditCents: user?.totalCreditCents, tokensUsed: user?.tokensUsed ?? 0, rechargePrompt, rechargeForced, openRecharge, dismissRecharge, completeRecharge, }; }