Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.

Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-15 15:04:43 -07:00
commit 2e14873f2d
272 changed files with 64133 additions and 0 deletions
+652
View File
@@ -0,0 +1,652 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
ApiError,
applyLocalLlmFallback,
bootstrapProjectMemory,
cancelRequest,
confirmTool,
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 SESSION_KEY = 'tkmind-h5-session-id';
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 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) {
openRecharge(true);
setChatState('idle');
return;
}
setError(err.message);
},
{
pauseWhenHidden: true,
onBalance: (balanceCents) => {
const currentUser = userRef.current;
const updateUser = onUserUpdateRef.current;
if (currentUser && updateUser) {
updateUser({ ...currentUser, balanceCents });
}
},
},
);
},
[openRecharge, processEvent],
);
const connectSession = useCallback(
async (sessionId: string) => {
const token = ++connectTokenRef.current;
unsubscribeRef.current?.();
unsubscribeRef.current = null;
setChatState('loading');
setError(null);
setPendingTool(null);
activeRequestId.current = null;
const resumed = await resumeSession(sessionId);
if (token !== connectTokenRef.current) return;
const { session: detail, messages: history } = await loadSessionDetail(sessionId);
if (token !== connectTokenRef.current) return;
localStorage.setItem(SESSION_KEY, 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 ?? localStorage.getItem(SESSION_KEY);
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 = localStorage.getItem(SESSION_KEY);
let staleSession = false;
if (sessionId) {
try {
await getSession(sessionId);
} catch (err) {
if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) {
localStorage.removeItem(SESSION_KEY);
sessionId = null;
staleSession = true;
} else {
throw err;
}
}
}
if (!sessionId) {
const created = await startSession();
sessionId = created.id;
localStorage.setItem(SESSION_KEY, sessionId);
await loadProjectMemory(sessionId, false);
if (staleSession) {
setNotice('上次会话已失效,已为你新建对话');
}
}
if (cancelled) return;
await ensureProvider(sessionId);
if (cancelled) return;
await connectSessionRef.current(sessionId);
if (cancelled) return;
void refreshSessions();
} catch (err) {
if (!cancelled) {
if (err instanceof ApiError && err.status === 402) {
openRecharge(true);
setChatState('idle');
return;
}
setError(err instanceof Error ? err.message : String(err));
setChatState('error');
}
}
};
void boot();
return () => {
cancelled = true;
connectTokenRef.current += 1;
unsubscribeRef.current?.();
};
// Intentionally mount-only: reconnecting on every callback identity change caused refresh loops.
}, [ensureProvider, loadProjectMemory, openRecharge, refreshSessions]);
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(
async (sessionId: string) => {
if (session?.id === sessionId || chatState === 'streaming') return;
try {
await connectSession(sessionId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setChatState('error');
}
},
[session, chatState, connectSession],
);
const submit = useCallback(
async (text: string, options?: { mindspaceContext?: MindSpaceChatContext }) => {
if (!session || !text.trim()) return;
if (chatState === 'streaming' || chatState === 'loading') 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) {
openRecharge(true);
setChatState('idle');
} else {
setError(err instanceof Error ? err.message : String(err));
setChatState('error');
}
activeRequestId.current = null;
}
},
[openRecharge, 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(async () => {
if (chatState === 'streaming') return;
try {
setChatState('loading');
const created = await startSession();
await loadProjectMemory(created.id, false);
await ensureProvider(created.id);
await connectSession(created.id);
void refreshSessions();
} catch (err) {
if (err instanceof ApiError && err.status === 402) {
openRecharge(true);
setChatState('idle');
return;
}
setError(err instanceof Error ? err.message : String(err));
setChatState('error');
}
}, [chatState, connectSession, ensureProvider, loadProjectMemory, openRecharge, refreshSessions]);
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') return;
await rememberRecentContext({ silent: false });
}, [rememberRecentContext]);
const onSidebarOpen = useCallback(() => {
void refreshSessions();
}, [refreshSessions]);
return {
session,
sessions,
sessionsLoading,
messages,
chatState,
error,
notice,
pendingTool,
memoryLoading,
submit,
stop,
approveTool,
newSession,
rememberCurrentContext,
refreshProjectMemory,
switchSession,
refreshSessions,
retryConnect,
dismissNotice,
onSidebarOpen,
workingDir: session?.working_dir ?? user?.workspaceRoot ?? '',
balanceCents: user?.balanceCents,
totalCreditCents: user?.totalCreditCents,
rechargePrompt,
rechargeForced,
openRecharge,
dismissRecharge,
completeRecharge,
};
}