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:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user