1934 lines
70 KiB
TypeScript
1934 lines
70 KiB
TypeScript
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,
|
||
};
|
||
}
|