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
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
export function useNetworkStatus() {
const [online, setOnline] = useState(
typeof navigator !== 'undefined' ? navigator.onLine : true,
);
useEffect(() => {
const handleOnline = () => setOnline(true);
const handleOffline = () => setOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return online;
}
+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,
};
}
+38
View File
@@ -0,0 +1,38 @@
import { useCallback, useEffect, useState } from 'react';
import {
AVATAR_CHANGED_EVENT,
clearUserAvatar,
loadUserAvatar,
notifyAvatarChanged,
processAvatarFile,
saveUserAvatar,
} from '../utils/userAvatar';
export function useUserAvatar() {
const [avatarUrl, setAvatarUrl] = useState<string | null>(() => loadUserAvatar());
useEffect(() => {
const sync = () => setAvatarUrl(loadUserAvatar());
window.addEventListener(AVATAR_CHANGED_EVENT, sync);
window.addEventListener('storage', sync);
return () => {
window.removeEventListener(AVATAR_CHANGED_EVENT, sync);
window.removeEventListener('storage', sync);
};
}, []);
const updateAvatar = useCallback(async (file: File) => {
const dataUrl = await processAvatarFile(file);
saveUserAvatar(dataUrl);
setAvatarUrl(dataUrl);
notifyAvatarChanged();
}, []);
const resetAvatar = useCallback(() => {
clearUserAvatar();
setAvatarUrl(null);
notifyAvatarChanged();
}, []);
return { avatarUrl, updateAvatar, resetAvatar };
}
+188
View File
@@ -0,0 +1,188 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { transcribeOneShot } from '../voice/asrTransport';
import { mapMicError } from '../voice/audioAnalyser';
import { MicCapture } from '../voice/micCapture';
import { waitForMicRelease } from '../voice/micSession';
import { createLiveSpeechRecognition, isSpeechRecognitionSupported } from '../voice/speechRecognition';
import type { VoiceSessionPhase } from '../voice/types';
const MIN_RECORD_MS = 500;
export function useVoiceSession({
active,
onError,
}: {
active: boolean;
onError?: (message: string) => void;
}) {
const [phase, setPhase] = useState<VoiceSessionPhase>('idle');
const [text, setText] = useState('');
const [analyser, setAnalyser] = useState<AnalyserNode | null>(null);
const [liveRecognition, setLiveRecognition] = useState(isSpeechRecognitionSupported());
const committedRef = useRef('');
const interimRef = useRef('');
const userEditedRef = useRef(false);
const captureRef = useRef<MicCapture | null>(null);
const recognitionRef = useRef<ReturnType<typeof createLiveSpeechRecognition> | null>(null);
const startedAtRef = useRef(0);
const sessionRef = useRef(0);
const syncDisplayText = useCallback(() => {
if (userEditedRef.current) return;
setText(`${committedRef.current}${interimRef.current}`);
}, []);
const resetSession = useCallback(() => {
committedRef.current = '';
interimRef.current = '';
userEditedRef.current = false;
setText('');
setAnalyser(null);
setPhase('idle');
}, []);
const cleanup = useCallback(async () => {
recognitionRef.current?.abort();
recognitionRef.current = null;
const capture = captureRef.current;
captureRef.current = null;
if (capture) {
await capture.cancel();
}
setAnalyser(null);
}, []);
const stopListening = useCallback(() => {
recognitionRef.current?.stop();
recognitionRef.current = null;
const capture = captureRef.current;
captureRef.current = null;
if (capture) {
void capture.cancel();
}
setAnalyser(null);
setPhase((current) => (current === 'transcribing' ? current : 'ready'));
}, []);
const finishFallbackRecording = useCallback(async () => {
const duration = Date.now() - startedAtRef.current;
const capture = captureRef.current;
captureRef.current = null;
setAnalyser(null);
if (duration < MIN_RECORD_MS) {
if (capture) await capture.cancel();
setPhase('ready');
onError?.('说话时间太短');
return;
}
setPhase('transcribing');
try {
if (!capture) throw new Error('未录制到音频');
const blob = await capture.stop();
if (!blob.size) throw new Error('未录制到音频');
const transcript = await transcribeOneShot(blob);
if (!transcript) {
onError?.('未识别到语音,请重试');
} else {
committedRef.current = transcript;
userEditedRef.current = false;
setText(transcript);
}
} catch (err) {
onError?.(err instanceof Error ? err.message : '识别失败');
} finally {
setPhase('ready');
}
}, [onError]);
useEffect(() => {
if (!active) {
void cleanup().then(resetSession);
return undefined;
}
const sessionId = sessionRef.current + 1;
sessionRef.current = sessionId;
resetSession();
setPhase('requesting');
const start = async () => {
try {
await waitForMicRelease();
if (sessionRef.current !== sessionId) return;
startedAtRef.current = Date.now();
const useLiveSpeech = isSpeechRecognitionSupported();
if (useLiveSpeech) {
const recognition = createLiveSpeechRecognition({
onInterim: (chunk) => {
interimRef.current = chunk;
syncDisplayText();
},
onFinal: (chunk) => {
committedRef.current += chunk;
interimRef.current = '';
syncDisplayText();
},
onError: (message) => onError?.(message),
});
if (!recognition) throw new Error('当前浏览器不支持实时语音识别');
recognitionRef.current = recognition;
recognition.start();
if (sessionRef.current !== sessionId) {
recognition.abort();
return;
}
setLiveRecognition(true);
setAnalyser(null);
setPhase('listening');
return;
}
const capture = new MicCapture();
captureRef.current = capture;
await capture.start();
if (sessionRef.current !== sessionId) {
await capture.cancel();
return;
}
setAnalyser(capture.levelAnalyser);
setLiveRecognition(false);
setPhase('listening');
} catch (err) {
if (sessionRef.current !== sessionId) return;
await cleanup();
setPhase('error');
onError?.(mapMicError(err));
}
};
void start();
return () => {
void cleanup();
};
}, [active, cleanup, onError, resetSession, syncDisplayText]);
const updateText = useCallback((value: string) => {
userEditedRef.current = true;
committedRef.current = value;
interimRef.current = '';
setText(value);
}, []);
return {
phase,
text,
analyser,
liveRecognition,
updateText,
stopListening,
finishFallbackRecording,
resetSession,
};
}