b0f5d6a51c
Split platform admin and ops APIs into standalone admin-server.mjs with network guards; simplify billing to RMB token pricing, refactor user auth, and add rsync deploy plus local-test scripts and docs. Co-authored-by: Cursor <cursoragent@cursor.com>
782 lines
25 KiB
TypeScript
782 lines
25 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
import {
|
||
ApiError,
|
||
applyLocalLlmFallback,
|
||
bootstrapProjectMemory,
|
||
cancelRequest,
|
||
confirmTool,
|
||
deleteChatSession,
|
||
getMe,
|
||
getSession,
|
||
listSessions,
|
||
loadSessionDetail,
|
||
readConfig,
|
||
rememberProjectContext,
|
||
resumeSession,
|
||
sendReply,
|
||
startSession,
|
||
subscribeSessionEvents,
|
||
updateProvider,
|
||
} from '../api/client';
|
||
import { appConfig } from '../config';
|
||
import type {
|
||
CapabilityMap,
|
||
ChatState,
|
||
Message,
|
||
MindSpaceChatContext,
|
||
PortalUser,
|
||
Session,
|
||
SessionEvent,
|
||
ToolConfirmation,
|
||
} from '../types';
|
||
import { buildContextPrefix } from '../utils/mindspaceChatContext';
|
||
import { buildUserAddressPrefix } from '../utils/userAddress';
|
||
import {
|
||
createUserMessage,
|
||
getDisplayText,
|
||
getToolConfirmation,
|
||
getVisibleText,
|
||
isCreditsExhaustedNotification,
|
||
isRelayServerErrorMessage,
|
||
pushMessage,
|
||
} from '../utils/message';
|
||
import { prependUnique, sortAndTrim, touchSession } from '../utils/sessions';
|
||
|
||
const LEGACY_SESSION_KEY = 'tkmind-h5-session-id';
|
||
|
||
function resolveSessionStorageKey(userId?: string | null): string {
|
||
return userId ? `${LEGACY_SESSION_KEY}:${userId}` : LEGACY_SESSION_KEY;
|
||
}
|
||
|
||
function readStoredSessionId(userId?: string | null): string | null {
|
||
const scopedKey = resolveSessionStorageKey(userId);
|
||
const scopedValue = localStorage.getItem(scopedKey);
|
||
if (scopedValue) return scopedValue;
|
||
const legacyValue = localStorage.getItem(LEGACY_SESSION_KEY);
|
||
if (legacyValue && userId) {
|
||
localStorage.setItem(scopedKey, legacyValue);
|
||
localStorage.removeItem(LEGACY_SESSION_KEY);
|
||
return legacyValue;
|
||
}
|
||
return legacyValue;
|
||
}
|
||
|
||
function writeStoredSessionId(userId: string | null | undefined, sessionId: string) {
|
||
localStorage.setItem(resolveSessionStorageKey(userId), sessionId);
|
||
}
|
||
|
||
function clearStoredSessionId(userId?: string | null) {
|
||
localStorage.removeItem(resolveSessionStorageKey(userId));
|
||
if (!userId) {
|
||
localStorage.removeItem(LEGACY_SESSION_KEY);
|
||
}
|
||
}
|
||
|
||
const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用';
|
||
|
||
export { INSUFFICIENT_BALANCE_NOTICE };
|
||
|
||
export function useTKMindChat(
|
||
user?: PortalUser | null,
|
||
onUserUpdate?: (user: PortalUser) => void,
|
||
capabilities?: CapabilityMap | null,
|
||
) {
|
||
const canUseProjectMemory = Boolean(capabilities?.context_memory);
|
||
const [session, setSession] = useState<Session | null>(null);
|
||
const [sessions, setSessions] = useState<Session[]>([]);
|
||
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||
const [messages, setMessages] = useState<Message[]>([]);
|
||
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 [rechargePrompt, setRechargePrompt] = useState(false);
|
||
const [rechargeForced, setRechargeForced] = useState(false);
|
||
|
||
const activeRequestId = useRef<string | null>(null);
|
||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||
const connectTokenRef = useRef(0);
|
||
const messagesRef = useRef<Message[]>([]);
|
||
const sessionRef = useRef<Session | null>(null);
|
||
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 dismissNotice = useCallback(() => setNotice(null), []);
|
||
|
||
const notifyInsufficientBalance = useCallback(() => {
|
||
setNotice(INSUFFICIENT_BALANCE_NOTICE);
|
||
setChatState('idle');
|
||
}, []);
|
||
|
||
const openRecharge = useCallback((forced = false) => {
|
||
setRechargeForced(forced);
|
||
setRechargePrompt(true);
|
||
}, []);
|
||
|
||
const dismissRecharge = useCallback(() => {
|
||
if (rechargeForced) return;
|
||
setRechargePrompt(false);
|
||
}, [rechargeForced]);
|
||
|
||
const completeRecharge = useCallback((nextBalance?: number) => {
|
||
setRechargeForced(false);
|
||
setRechargePrompt(false);
|
||
const currentUser = userRef.current;
|
||
const updateUser = onUserUpdateRef.current;
|
||
if (typeof nextBalance === 'number' && currentUser && updateUser) {
|
||
const prevBalance = currentUser.balanceCents;
|
||
const prevTotal = currentUser.totalCreditCents ?? prevBalance;
|
||
const rechargeDelta = nextBalance - prevBalance;
|
||
updateUser({
|
||
...currentUser,
|
||
balanceCents: nextBalance,
|
||
totalCreditCents: Math.max(prevTotal + rechargeDelta, nextBalance),
|
||
});
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
userRef.current = user;
|
||
}, [user]);
|
||
|
||
useEffect(() => {
|
||
onUserUpdateRef.current = onUserUpdate;
|
||
}, [onUserUpdate]);
|
||
|
||
useEffect(() => {
|
||
messagesRef.current = messages;
|
||
}, [messages]);
|
||
|
||
useEffect(() => {
|
||
sessionRef.current = session;
|
||
}, [session]);
|
||
|
||
const loadProjectMemory = useCallback(async (sessionId: string, force: boolean) => {
|
||
if (!canUseProjectMemory) return null;
|
||
setMemoryLoading(true);
|
||
try {
|
||
const result = await bootstrapProjectMemory(sessionId, appConfig.memoryQuery, force);
|
||
if (force) {
|
||
setNotice(
|
||
result.summary
|
||
? `项目记忆已刷新(${result.source})`
|
||
: '未找到可用的项目记忆,当前会话仍可正常使用',
|
||
);
|
||
}
|
||
return result;
|
||
} catch (err) {
|
||
setNotice(`项目记忆加载失败:${err instanceof Error ? err.message : String(err)}`);
|
||
return null;
|
||
} finally {
|
||
setMemoryLoading(false);
|
||
}
|
||
}, [canUseProjectMemory]);
|
||
|
||
const rememberRecentContext = useCallback(
|
||
async ({
|
||
silent = false,
|
||
sessionId,
|
||
sessionName,
|
||
recentContext,
|
||
}: {
|
||
silent?: boolean;
|
||
sessionId?: string;
|
||
sessionName?: string;
|
||
recentContext?: string;
|
||
} = {}) => {
|
||
const currentSession = sessionId
|
||
? {
|
||
id: sessionId,
|
||
name: sessionName ?? sessionRef.current?.name ?? sessionId,
|
||
}
|
||
: sessionRef.current;
|
||
if (!currentSession) return false;
|
||
if (rememberInFlightRef.current) return false;
|
||
|
||
const contextText =
|
||
recentContext ??
|
||
messagesRef.current
|
||
.filter((message) => getDisplayText(message).trim())
|
||
.slice(-6)
|
||
.map((message) => `${message.role === 'user' ? 'User' : 'Assistant'}: ${getDisplayText(message)}`)
|
||
.join('\n\n')
|
||
.slice(0, 6_000);
|
||
|
||
if (!canUseProjectMemory) {
|
||
return false;
|
||
}
|
||
|
||
if (!contextText) {
|
||
if (!silent) setNotice('当前还没有可保存的对话内容');
|
||
return false;
|
||
}
|
||
|
||
const contextKey = `${currentSession.id}:${contextText}`;
|
||
if (rememberedContextRef.current === contextKey) {
|
||
return false;
|
||
}
|
||
|
||
rememberInFlightRef.current = true;
|
||
if (!silent) {
|
||
setMemoryLoading(true);
|
||
}
|
||
|
||
try {
|
||
await rememberProjectContext(
|
||
currentSession.id,
|
||
contextText,
|
||
`H5 会话 ${currentSession.name}`,
|
||
);
|
||
rememberedContextRef.current = contextKey;
|
||
await loadProjectMemory(currentSession.id, true);
|
||
if (!silent) {
|
||
setNotice('最近对话已写入长期记忆,并同步到当前会话');
|
||
}
|
||
return true;
|
||
} catch (err) {
|
||
if (!silent) {
|
||
setNotice(`保存项目记忆失败:${err instanceof Error ? err.message : String(err)}`);
|
||
}
|
||
return false;
|
||
} finally {
|
||
rememberInFlightRef.current = false;
|
||
if (!silent) {
|
||
setMemoryLoading(false);
|
||
}
|
||
}
|
||
},
|
||
[canUseProjectMemory, loadProjectMemory],
|
||
);
|
||
|
||
const refreshSessions = useCallback(async () => {
|
||
setSessionsLoading(true);
|
||
try {
|
||
const items = await listSessions();
|
||
setSessions(sortAndTrim(items));
|
||
} catch (err) {
|
||
if (err instanceof ApiError && err.status === 0) {
|
||
setError('网络不可用,无法刷新历史列表');
|
||
}
|
||
} finally {
|
||
setSessionsLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const processEvent = useCallback(
|
||
(event: SessionEvent, requestId: string, sessionId: string) => {
|
||
const raw = event as SessionEvent & { chat_request_id?: string; request_id?: string };
|
||
const eventRequestId = raw.chat_request_id ?? raw.request_id;
|
||
if (eventRequestId && eventRequestId !== requestId) return;
|
||
|
||
switch (event.type) {
|
||
case 'Message': {
|
||
const fallbackReason = isCreditsExhaustedNotification(event.message)
|
||
? 'credits'
|
||
: isRelayServerErrorMessage(event.message)
|
||
? 'relay'
|
||
: null;
|
||
if (fallbackReason) {
|
||
const retryKey = `${sessionId}:${requestId}:${fallbackReason}`;
|
||
if (!fallbackRetriedRef.current.has(retryKey) && sessionRef.current) {
|
||
fallbackRetriedRef.current.add(retryKey);
|
||
void (async () => {
|
||
try {
|
||
const fallback = await applyLocalLlmFallback(sessionRef.current!.id);
|
||
if (!fallback.ok) {
|
||
throw new Error(fallback.message ?? '本地 LLM 不可用');
|
||
}
|
||
setNotice(
|
||
fallbackReason === 'credits'
|
||
? `DeepSeek 额度不足,已切换到本地 ${fallback.model ?? 'qwen2.5-coder:7b'} 并重试…`
|
||
: `云端 Relay 返回 500(请求体过大),已切换到本地 ${fallback.model ?? 'qwen2.5-coder:7b'} 并重试…`,
|
||
);
|
||
const lastUser = [...messagesRef.current]
|
||
.reverse()
|
||
.find((message) => message.role === 'user');
|
||
if (!lastUser) {
|
||
setChatState('idle');
|
||
activeRequestId.current = null;
|
||
return;
|
||
}
|
||
const retryRequestId = crypto.randomUUID();
|
||
activeRequestId.current = retryRequestId;
|
||
setChatState('streaming');
|
||
setError(null);
|
||
await sendReply(sessionRef.current!.id, retryRequestId, lastUser);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
setChatState('error');
|
||
activeRequestId.current = null;
|
||
}
|
||
})();
|
||
} else {
|
||
setError(
|
||
fallbackReason === 'credits'
|
||
? 'DeepSeek 额度已耗尽,且本地 fallback 重试失败或未启用'
|
||
: '云端 Relay 不可用,且本地 fallback 重试失败或未启用',
|
||
);
|
||
setChatState('error');
|
||
activeRequestId.current = null;
|
||
}
|
||
return;
|
||
}
|
||
if (!event.message.metadata?.userVisible) return;
|
||
setMessages((prev) => {
|
||
const next = pushMessage(prev, event.message);
|
||
messagesRef.current = next;
|
||
return next;
|
||
});
|
||
const confirmation = getToolConfirmation(event.message);
|
||
if (confirmation) {
|
||
setPendingTool(confirmation);
|
||
setChatState('waiting');
|
||
} else {
|
||
setChatState('streaming');
|
||
}
|
||
return;
|
||
}
|
||
case 'UpdateConversation':
|
||
messagesRef.current = event.conversation.filter((m) => m.metadata?.userVisible);
|
||
setMessages(messagesRef.current);
|
||
return;
|
||
case 'Error':
|
||
setError(event.error);
|
||
setChatState('error');
|
||
activeRequestId.current = null;
|
||
return;
|
||
case 'Finish':
|
||
setChatState('idle');
|
||
setPendingTool(null);
|
||
activeRequestId.current = null;
|
||
setSessions((prev) => touchSession(prev, sessionId, 0));
|
||
if (userRef.current && onUserUpdateRef.current) {
|
||
void getMe()
|
||
.then(({ user: fresh }) => onUserUpdateRef.current?.(fresh))
|
||
.catch(() => {});
|
||
}
|
||
const recentContext = messagesRef.current
|
||
.filter((message) => getDisplayText(message).trim())
|
||
.slice(-6)
|
||
.map((message) => `${message.role === 'user' ? 'User' : 'Assistant'}: ${getDisplayText(message)}`)
|
||
.join('\n\n')
|
||
.slice(0, 6_000);
|
||
const finishedSession = sessionRef.current;
|
||
window.setTimeout(() => {
|
||
void rememberRecentContext({
|
||
silent: true,
|
||
sessionId,
|
||
sessionName: finishedSession?.name,
|
||
recentContext,
|
||
});
|
||
}, 0);
|
||
return;
|
||
default:
|
||
return;
|
||
}
|
||
},
|
||
[rememberRecentContext],
|
||
);
|
||
|
||
const subscribeToSession = useCallback(
|
||
(sessionId: string) => {
|
||
unsubscribeRef.current?.();
|
||
unsubscribeRef.current = subscribeSessionEvents(
|
||
sessionId,
|
||
(event) => {
|
||
const rid = activeRequestId.current;
|
||
if (rid) processEvent(event, rid, sessionId);
|
||
else if (event.type === 'ActiveRequests' && event.request_ids.length > 0) {
|
||
activeRequestId.current = event.request_ids[0];
|
||
setChatState('streaming');
|
||
}
|
||
},
|
||
(err) => {
|
||
if (err instanceof ApiError && err.status === 401) return;
|
||
if (err instanceof ApiError && err.status === 402) {
|
||
notifyInsufficientBalance();
|
||
return;
|
||
}
|
||
setError(err.message);
|
||
},
|
||
{
|
||
pauseWhenHidden: true,
|
||
onBalance: (update) => {
|
||
const currentUser = userRef.current;
|
||
const updateUser = onUserUpdateRef.current;
|
||
if (currentUser && updateUser) {
|
||
updateUser({
|
||
...currentUser,
|
||
balanceCents: update.balanceCents,
|
||
...(typeof update.tokensUsed === 'number'
|
||
? { tokensUsed: update.tokensUsed }
|
||
: {}),
|
||
});
|
||
}
|
||
},
|
||
},
|
||
);
|
||
},
|
||
[notifyInsufficientBalance, processEvent],
|
||
);
|
||
|
||
const resetSessionView = useCallback(() => {
|
||
connectTokenRef.current += 1;
|
||
unsubscribeRef.current?.();
|
||
unsubscribeRef.current = null;
|
||
setError(null);
|
||
setPendingTool(null);
|
||
activeRequestId.current = null;
|
||
messagesRef.current = [];
|
||
setMessages([]);
|
||
setChatState('connecting');
|
||
}, []);
|
||
|
||
const connectSession = useCallback(
|
||
async (
|
||
sessionId: string,
|
||
options?: { showLoading?: boolean; skipResume?: boolean; seedSession?: Session },
|
||
) => {
|
||
const showLoading = options?.showLoading !== false;
|
||
const token = ++connectTokenRef.current;
|
||
unsubscribeRef.current?.();
|
||
unsubscribeRef.current = null;
|
||
|
||
if (showLoading) {
|
||
setChatState('loading');
|
||
}
|
||
setError(null);
|
||
setPendingTool(null);
|
||
activeRequestId.current = null;
|
||
|
||
const resumed = options?.skipResume
|
||
? (options.seedSession ?? null)
|
||
: await resumeSession(sessionId);
|
||
if (token !== connectTokenRef.current) return;
|
||
|
||
const { session: detail, messages: history } = await loadSessionDetail(sessionId);
|
||
if (token !== connectTokenRef.current) return;
|
||
|
||
writeStoredSessionId(userRef.current?.id, sessionId);
|
||
setSession({ ...detail, ...(resumed ?? {}), id: sessionId });
|
||
messagesRef.current = history;
|
||
setMessages(history);
|
||
setChatState('idle');
|
||
setSessions((prev) => prependUnique(prev, { ...detail, ...resumed, id: sessionId }));
|
||
|
||
subscribeToSession(sessionId);
|
||
},
|
||
[subscribeToSession],
|
||
);
|
||
|
||
const ensureProvider = useCallback(async (sessionId: string) => {
|
||
const provider = appConfig.provider || (await readConfig('TKMIND_PROVIDER'));
|
||
const model = appConfig.model || (await readConfig('TKMIND_MODEL'));
|
||
if (provider && model) {
|
||
try {
|
||
await updateProvider(sessionId, provider, model);
|
||
} catch {
|
||
// resumeSession may have already configured the provider
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
const retryConnect = useCallback(async () => {
|
||
const sessionId = session?.id ?? readStoredSessionId(userRef.current?.id);
|
||
if (!sessionId) return;
|
||
setError(null);
|
||
try {
|
||
await connectSession(sessionId);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
setChatState('error');
|
||
}
|
||
}, [session, connectSession]);
|
||
|
||
const connectSessionRef = useRef(connectSession);
|
||
connectSessionRef.current = connectSession;
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
const boot = async () => {
|
||
try {
|
||
setChatState('loading');
|
||
setError(null);
|
||
|
||
let sessionId = readStoredSessionId(userRef.current?.id);
|
||
let staleSession = false;
|
||
let freshSession: Session | null = null;
|
||
|
||
if (sessionId) {
|
||
try {
|
||
await getSession(sessionId);
|
||
} catch (err) {
|
||
if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) {
|
||
clearStoredSessionId(userRef.current?.id);
|
||
sessionId = null;
|
||
staleSession = true;
|
||
} else {
|
||
throw err;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!sessionId) {
|
||
freshSession = await startSession();
|
||
sessionId = freshSession.id;
|
||
writeStoredSessionId(userRef.current?.id, sessionId);
|
||
if (staleSession) {
|
||
setNotice('上次会话已失效,已为你新建对话');
|
||
}
|
||
}
|
||
|
||
if (cancelled) return;
|
||
if (!freshSession) {
|
||
await ensureProvider(sessionId);
|
||
}
|
||
if (cancelled) return;
|
||
await connectSessionRef.current(sessionId, {
|
||
skipResume: Boolean(freshSession),
|
||
seedSession: freshSession ?? undefined,
|
||
});
|
||
if (cancelled) return;
|
||
if (freshSession) {
|
||
void loadProjectMemory(sessionId, false);
|
||
}
|
||
void refreshSessions();
|
||
} catch (err) {
|
||
if (!cancelled) {
|
||
if (err instanceof ApiError && err.status === 402) {
|
||
notifyInsufficientBalance();
|
||
return;
|
||
}
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
setChatState('error');
|
||
}
|
||
}
|
||
};
|
||
|
||
void boot();
|
||
return () => {
|
||
cancelled = true;
|
||
connectTokenRef.current += 1;
|
||
unsubscribeRef.current?.();
|
||
};
|
||
// Re-run when the signed-in user changes so session storage stays per-user.
|
||
}, [ensureProvider, loadProjectMemory, notifyInsufficientBalance, refreshSessions, user?.id]);
|
||
|
||
useEffect(() => {
|
||
const handleVisibility = () => {
|
||
if (!document.hidden && session?.id) {
|
||
void refreshSessions();
|
||
}
|
||
};
|
||
document.addEventListener('visibilitychange', handleVisibility);
|
||
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
||
}, [session?.id, refreshSessions]);
|
||
|
||
const switchSession = useCallback(
|
||
(sessionId: string) => {
|
||
if (session?.id === sessionId || chatState === 'streaming') return;
|
||
|
||
resetSessionView();
|
||
const token = connectTokenRef.current;
|
||
const target = sessions.find((item) => item.id === sessionId);
|
||
if (target) {
|
||
setSession(target);
|
||
writeStoredSessionId(userRef.current?.id, sessionId);
|
||
}
|
||
|
||
void (async () => {
|
||
try {
|
||
await connectSession(sessionId, { showLoading: false });
|
||
} catch (err) {
|
||
if (token !== connectTokenRef.current) return;
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
setChatState('error');
|
||
}
|
||
})();
|
||
},
|
||
[session, chatState, connectSession, resetSessionView, sessions],
|
||
);
|
||
|
||
const submit = useCallback(
|
||
async (text: string, options?: { mindspaceContext?: MindSpaceChatContext }) => {
|
||
if (!session || !text.trim()) return;
|
||
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
|
||
|
||
const trimmed = text.trim();
|
||
const mindspacePrefix = options?.mindspaceContext
|
||
? buildContextPrefix(options.mindspaceContext)
|
||
: '';
|
||
const userPrefix = buildUserAddressPrefix(userRef.current);
|
||
const agentPrefix = `${userPrefix}${mindspacePrefix}`;
|
||
const userMessage = agentPrefix
|
||
? createUserMessage(trimmed, {
|
||
agentText: `${agentPrefix}${trimmed}`,
|
||
displayText: trimmed,
|
||
})
|
||
: createUserMessage(trimmed);
|
||
const requestId = crypto.randomUUID();
|
||
activeRequestId.current = requestId;
|
||
messagesRef.current = [...messagesRef.current, userMessage];
|
||
setMessages(messagesRef.current);
|
||
setSessions((prev) => touchSession(prev, session.id, 1));
|
||
setChatState('streaming');
|
||
setError(null);
|
||
setPendingTool(null);
|
||
|
||
try {
|
||
await sendReply(session.id, requestId, userMessage);
|
||
} catch (err) {
|
||
setSessions((prev) => touchSession(prev, session.id, -1));
|
||
if (err instanceof ApiError && err.status === 402) {
|
||
notifyInsufficientBalance();
|
||
} else {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
setChatState('error');
|
||
}
|
||
activeRequestId.current = null;
|
||
}
|
||
},
|
||
[notifyInsufficientBalance, session, chatState],
|
||
);
|
||
|
||
const stop = useCallback(async () => {
|
||
if (!session || !activeRequestId.current) return;
|
||
try {
|
||
await cancelRequest(session.id, activeRequestId.current);
|
||
} finally {
|
||
activeRequestId.current = null;
|
||
setChatState('idle');
|
||
}
|
||
}, [session]);
|
||
|
||
const approveTool = useCallback(
|
||
async (allow: boolean) => {
|
||
if (!session || !pendingTool) return;
|
||
await confirmTool(session.id, pendingTool.id, allow ? 'allow_once' : 'deny_once');
|
||
setPendingTool(null);
|
||
setChatState('streaming');
|
||
},
|
||
[session, pendingTool],
|
||
);
|
||
|
||
const newSession = useCallback(() => {
|
||
if (chatState === 'streaming') return;
|
||
|
||
resetSessionView();
|
||
setSession(null);
|
||
const token = connectTokenRef.current;
|
||
|
||
void (async () => {
|
||
try {
|
||
const created = await startSession();
|
||
if (token !== connectTokenRef.current) return;
|
||
|
||
writeStoredSessionId(userRef.current?.id, created.id);
|
||
setSession(created);
|
||
setSessions((prev) => prependUnique(prev, created));
|
||
|
||
void loadProjectMemory(created.id, false);
|
||
void refreshSessions();
|
||
|
||
await ensureProvider(created.id);
|
||
if (token !== connectTokenRef.current) return;
|
||
|
||
await connectSession(created.id, { showLoading: false });
|
||
} catch (err) {
|
||
if (token !== connectTokenRef.current) return;
|
||
if (err instanceof ApiError && err.status === 402) {
|
||
notifyInsufficientBalance();
|
||
return;
|
||
}
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
setChatState('error');
|
||
}
|
||
})();
|
||
}, [chatState, connectSession, ensureProvider, loadProjectMemory, notifyInsufficientBalance, refreshSessions, resetSessionView]);
|
||
|
||
const refreshProjectMemory = useCallback(async () => {
|
||
if (!session || chatState === 'streaming') return;
|
||
await loadProjectMemory(session.id, true);
|
||
}, [chatState, loadProjectMemory, session]);
|
||
|
||
const rememberCurrentContext = useCallback(async () => {
|
||
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
|
||
await rememberRecentContext({ silent: false });
|
||
}, [chatState, rememberRecentContext]);
|
||
|
||
const onSidebarOpen = useCallback(() => {
|
||
void refreshSessions();
|
||
}, [refreshSessions]);
|
||
|
||
const deleteSession = useCallback(
|
||
(sessionId: string) => {
|
||
if (chatState === 'streaming') return;
|
||
|
||
void (async () => {
|
||
try {
|
||
await deleteChatSession(sessionId);
|
||
const wasActive = sessionRef.current?.id === sessionId;
|
||
const nextSessions = sessions.filter((item) => item.id !== sessionId);
|
||
setSessions(nextSessions);
|
||
|
||
if (!wasActive) return;
|
||
|
||
const fallbackId = nextSessions[0]?.id;
|
||
if (fallbackId) {
|
||
void switchSession(fallbackId);
|
||
return;
|
||
}
|
||
|
||
clearStoredSessionId(userRef.current?.id);
|
||
resetSessionView();
|
||
setSession(null);
|
||
void newSession();
|
||
} catch (err) {
|
||
setNotice(`删除会话失败:${err instanceof Error ? err.message : String(err)}`);
|
||
}
|
||
})();
|
||
},
|
||
[chatState, newSession, resetSessionView, sessions, switchSession],
|
||
);
|
||
|
||
return {
|
||
session,
|
||
sessions,
|
||
sessionsLoading,
|
||
messages,
|
||
chatState,
|
||
error,
|
||
notice,
|
||
pendingTool,
|
||
memoryLoading,
|
||
submit,
|
||
stop,
|
||
approveTool,
|
||
newSession,
|
||
rememberCurrentContext,
|
||
refreshProjectMemory,
|
||
switchSession,
|
||
deleteSession,
|
||
refreshSessions,
|
||
retryConnect,
|
||
dismissNotice,
|
||
onSidebarOpen,
|
||
workingDir: session?.working_dir ?? user?.workspaceRoot ?? '',
|
||
balanceCents: user?.balanceCents,
|
||
totalCreditCents: user?.totalCreditCents,
|
||
tokensUsed: user?.tokensUsed ?? 0,
|
||
rechargePrompt,
|
||
rechargeForced,
|
||
openRecharge,
|
||
dismissRecharge,
|
||
completeRecharge,
|
||
};
|
||
}
|