Files
memind/src/hooks/useTKMindChat.ts
T

1934 lines
70 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,
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;
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<AgentRun> {
return await waitForAgentRunWithDirectChatPreview(runId, {});
}
const DIRECT_CHAT_SESSION_POLL_MS = 600;
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<AgentRun> {
return await new Promise<AgentRun>((resolve, reject) => {
let pollTimer: number | null = null;
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();
if (waitTimer != null) {
window.clearTimeout(waitTimer);
waitTimer = null;
}
unsubscribe();
action();
};
const stopPoll = () => {
if (pollTimer != null) {
window.clearInterval(pollTimer);
pollTimer = 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);
};
unsubscribe = subscribeAgentRunEvents(
runId,
(run) => {
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(new Error(run.error || '后台任务失败,请稍后重试')));
}
},
(error) => {
settle(() => reject(error));
},
);
waitTimer = window.setTimeout(() => {
void getAgentRun(runId)
.then((run) => {
if (run.status === 'succeeded') {
settle(() => resolve(run));
return;
}
if (run.status === 'failed') {
settle(() => reject(new Error(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<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;
}
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);
}
function isMissingAgentSessionError(err: unknown) {
return err instanceof ApiError && /session not found/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 chatStateRef = useRef<ChatState>(chatState);
const subscribedSessionIdRef = useRef<string | null>(null);
const unsubscribeRef = useRef<(() => void) | null>(null);
const connectTokenRef = useRef(0);
const unavailableAgentSessionIdsRef = useRef(new Set<string>());
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 chatFileCategoryIdRef = useRef<string | null>(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<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,
options: { messageId?: string | null } = {},
): 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,
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),
err instanceof ApiError ? err.code : '',
)
) {
// 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,
};
}