Files
memind/src/hooks/useTKMindChat.ts
T
Your Name b0f5d6a51c Extract memind_adm admin server, add local dev tooling, and remove image-generation.
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>
2026-06-17 16:39:39 -07:00

782 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react';
import {
ApiError,
applyLocalLlmFallback,
bootstrapProjectMemory,
cancelRequest,
confirmTool,
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,
};
}