Files
memind/src/hooks/useTKMindChat.ts
T
2026-07-02 08:42:44 +08:00

1437 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react';
import {
ApiError,
applyLocalLlmFallback,
bootstrapProjectMemory,
cancelRequest,
confirmTool,
createAgentRun,
deleteChatSession,
getMindSpace,
getMe,
listNotifications,
markNotificationRead,
listSessions,
loadSessionDetail,
readConfig,
rememberUserMemory,
rememberProjectContext,
uploadMindSpaceAsset,
resumeSession,
subscribeAgentRunEvents,
subscribeNotificationEvents,
subscribeSessionEvents,
syncUserMemory,
updateProvider,
} from '../api/client';
import { appConfig } from '../config';
import {
clearStoredSessionId,
readStoredSessionId,
writeStoredSessionId,
} from '../utils/sessionStorage';
import type {
CapabilityMap,
ChatState,
Message,
MindSpaceChatContext,
PortalUser,
Session,
SessionSummary,
SessionEvent,
ToolConfirmation,
UserNotification,
} from '../types';
import { buildContextPrefix } from '../utils/mindspaceChatContext';
import { buildUserAddressPrefix } from '../utils/userAddress';
import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs';
import { mergeConversationSnapshot } from '../../chat-finish-sync.mjs';
import {
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
} from '../utils/imageUpload';
import { buildAbsoluteAssetImageUrl } from '../utils/mindspaceCards';
import { resolveAgentRunOptions } 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;
export { INSUFFICIENT_BALANCE_NOTICE };
async function waitForAgentRun(runId: string) {
return await new Promise((resolve, reject) => {
const unsubscribe = subscribeAgentRunEvents(
runId,
(run) => {
if (run.status === 'succeeded') {
unsubscribe();
resolve(run);
return;
}
if (run.status === 'failed') {
unsubscribe();
reject(new Error(run.error || '后台任务失败,请稍后重试'));
}
},
(error) => {
unsubscribe();
reject(error);
},
);
});
}
function mergeMessagePages(older: Message[], current: Message[]): Message[] {
if (older.length === 0) return current;
if (current.length === 0) return older;
const seen = new Set<string>();
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;
}
function isAmbiguousReplySubmitError(err: unknown) {
if (!(err instanceof ApiError)) return true;
return err.status === 0 || err.status === 409 || err.status >= 500;
}
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<typeof listSessions>[0],
): Promise<Awaited<ReturnType<typeof listSessions>>> {
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);
}
async function withTransientConnectRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
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<Session | null>(null);
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [sessionsLoadingMore, setSessionsLoadingMore] = useState(false);
const [sessionsHasMore, setSessionsHasMore] = useState(false);
const [sessionSearchQuery, setSessionSearchQueryState] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const [messageHistoryLoadingMore, setMessageHistoryLoadingMore] = useState(false);
const [messageHistoryHasMore, setMessageHistoryHasMore] = useState(false);
const [messageHistoryTotal, setMessageHistoryTotal] = useState(0);
const [chatState, setChatState] = useState<ChatState>('loading');
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [pendingTool, setPendingTool] = useState<ToolConfirmation | null>(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<UserNotification | null>(null);
const seenNotificationIdsRef = useRef<Set<string>>(new Set());
const activeRequestId = useRef<string | null>(null);
const activeRequestMissingTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
const unsubscribeRef = useRef<(() => void) | null>(null);
const connectTokenRef = useRef(0);
const messagesRef = useRef<Message[]>([]);
const sessionRef = useRef<Session | null>(null);
const sessionsRef = useRef<SessionSummary[]>([]);
const sessionsOffsetRef = useRef(0);
const sessionSearchQueryRef = useRef('');
const messageHistoryLoadedCountRef = useRef(0);
const messageHistoryTotalRef = useRef(0);
const messageHistoryHasMoreRef = useRef(false);
const rememberedContextRef = useRef<string | null>(null);
const rememberInFlightRef = useRef(false);
const fallbackRetriedRef = useRef(new Set<string>());
const userRef = useRef(user);
const onUserUpdateRef = useRef(onUserUpdate);
const chatImageCategoryIdRef = useRef<string | null>(null);
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]);
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());
};
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 (!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 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 (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<SessionSummary[]> => {
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,
): Promise<string> => {
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,
});
return buildAbsoluteAssetImageUrl({
id: asset.id,
updatedAt: asset.updatedAt,
publicUrl: asset.publicUrl,
});
}, [resolveChatImageUploadCategoryId]);
const syncSessionMessages = useCallback(async (sessionId: string) => {
// REGRESSION GUARD: Finish may arrive before Goose 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 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;
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;
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) => {
unsubscribeRef.current?.();
unsubscribeRef.current = subscribeSessionEvents(
sessionId,
(event) => {
const rid = activeRequestId.current;
if (event.type === 'ActiveRequests') {
if (!rid && event.request_ids.length > 0) {
// SSE reconnected while agent was running — adopt the active request.
clearActiveRequestMissingTimer();
activeRequestId.current = event.request_ids[0];
setChatState('streaming');
} else if (rid && event.request_ids.includes(rid)) {
clearActiveRequestMissingTimer();
} else if (rid && !event.request_ids.includes(rid)) {
// Goose 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);
}
}
return;
}
if (rid) 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;
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;
clearActiveRequestMissingTimer();
if (showLoading) {
setChatState('loading');
}
setError(null);
setPendingTool(null);
activeRequestId.current = null;
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
? 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);
const resumed = await resumedPromise;
if (token !== connectTokenRef.current) return;
setSession({ ...detail, ...(resumed ?? {}), id: sessionId });
setChatState('idle');
setSessions((prev) =>
prependUnique(prev, toSessionSummary({ ...detail, ...(resumed ?? {}), id: sessionId })),
);
subscribeToSession(sessionId);
},
[clearActiveRequestMissingTimer, 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 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)) {
clearStoredSessionId(userRef.current?.id);
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 },
imageUrls?: string[],
previewImageUrls?: string[],
) => {
const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
const normalizedPreviewImageUrls = (previewImageUrls ?? []).filter(
(value) => typeof value === 'string' && value.trim(),
);
if (!text.trim() && normalizedImageUrls.length === 0) return;
if (
chatState === 'streaming' ||
chatState === 'loading' ||
chatState === 'connecting' ||
chatState === 'waiting'
) return;
const trimmed = text.trim();
const mindspacePrefix = options?.mindspaceContext
? buildContextPrefix(options.mindspaceContext)
: '';
const userPrefix = buildUserAddressPrefix(userRef.current);
const skillPrefix = buildAutoChatSkillPrefix(trimmed, grantedSkills ?? []);
const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}`;
const userMessage = buildUserMessage(trimmed, {
agentText: `${agentPrefix}${trimmed}`,
displayText: trimmed,
imageUrls: normalizedImageUrls,
previewImageUrls: normalizedPreviewImageUrls,
});
const requestId = crypto.randomUUID();
activeRequestId.current = requestId;
messagesRef.current = [...messagesRef.current, userMessage];
messageHistoryLoadedCountRef.current = messagesRef.current.length;
setMessages(messagesRef.current);
setChatState('waiting');
setError(null);
setPendingTool(null);
let activeSessionId = session?.id ?? null;
if (activeSessionId) {
setSessions((prev) => touchSession(prev, activeSessionId!, 1));
}
try {
const createdRun = await createAgentRun(
activeSessionId,
requestId,
userMessage,
resolveAgentRunOptions(trimmed, {
taskType: 'h5_chat_code_task',
userId: userRef.current?.id ?? null,
}),
);
const finishedRun =
createdRun.status === 'succeeded' ? createdRun : await waitForAgentRun(createdRun.id);
activeSessionId = finishedRun.sessionId;
if (!activeSessionId) {
throw new Error('后台任务已提交,但未返回会话');
}
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();
}
subscribeToSession(activeSessionId);
setChatState('streaming');
} catch (err) {
if (activeSessionId && isAmbiguousReplySubmitError(err)) {
subscribeToSession(activeSessionId);
setChatState('streaming');
setError(null);
for (const delay of REPLY_RECOVERY_SYNC_DELAYS_MS) {
window.setTimeout(() => {
if (sessionRef.current?.id === activeSessionId) {
void syncSessionMessages(activeSessionId);
}
}, delay);
}
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,
chatState,
grantedSkills,
clearActiveRequestMissingTimer,
subscribeToSession,
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(() => {
if (chatState === 'streaming' || chatState === 'waiting') return;
connectTokenRef.current += 1;
unsubscribeRef.current?.();
unsubscribeRef.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('idle');
}, [chatState, clearActiveRequestMissingTimer]);
const refreshProjectMemory = useCallback(async () => {
if (!session || chatState === 'streaming' || chatState === 'waiting') 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,
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,
};
}