Add MindSpace page live edit, chat skills, and H5 deploy tooling.
Introduce page edit sessions with draft preview and patch API, chat skill picker, user memory profile, h5ApiBase resolution, voice WAV transport, and scripts for 105/g2 deployment and Plaza local dev. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+183
-54
@@ -5,6 +5,7 @@ import {
|
||||
bootstrapProjectMemory,
|
||||
cancelRequest,
|
||||
confirmTool,
|
||||
deleteChatSession,
|
||||
getMe,
|
||||
getSession,
|
||||
listSessions,
|
||||
@@ -41,7 +42,39 @@ import {
|
||||
} from '../utils/message';
|
||||
import { prependUnique, sortAndTrim, touchSession } from '../utils/sessions';
|
||||
|
||||
const SESSION_KEY = 'tkmind-h5-session-id';
|
||||
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,
|
||||
@@ -74,6 +107,11 @@ export function useTKMindChat(
|
||||
|
||||
const dismissNotice = useCallback(() => setNotice(null), []);
|
||||
|
||||
const notifyInsufficientBalance = useCallback(() => {
|
||||
setNotice(INSUFFICIENT_BALANCE_NOTICE);
|
||||
setChatState('idle');
|
||||
}, []);
|
||||
|
||||
const openRecharge = useCallback((forced = false) => {
|
||||
setRechargeForced(forced);
|
||||
setRechargePrompt(true);
|
||||
@@ -359,46 +397,71 @@ export function useTKMindChat(
|
||||
(err) => {
|
||||
if (err instanceof ApiError && err.status === 401) return;
|
||||
if (err instanceof ApiError && err.status === 402) {
|
||||
openRecharge(true);
|
||||
setChatState('idle');
|
||||
notifyInsufficientBalance();
|
||||
return;
|
||||
}
|
||||
setError(err.message);
|
||||
},
|
||||
{
|
||||
pauseWhenHidden: true,
|
||||
onBalance: (balanceCents) => {
|
||||
onBalance: (update) => {
|
||||
const currentUser = userRef.current;
|
||||
const updateUser = onUserUpdateRef.current;
|
||||
if (currentUser && updateUser) {
|
||||
updateUser({ ...currentUser, balanceCents });
|
||||
updateUser({
|
||||
...currentUser,
|
||||
balanceCents: update.balanceCents,
|
||||
...(typeof update.tokensUsed === 'number'
|
||||
? { tokensUsed: update.tokensUsed }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[openRecharge, processEvent],
|
||||
[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) => {
|
||||
async (
|
||||
sessionId: string,
|
||||
options?: { showLoading?: boolean; skipResume?: boolean; seedSession?: Session },
|
||||
) => {
|
||||
const showLoading = options?.showLoading !== false;
|
||||
const token = ++connectTokenRef.current;
|
||||
unsubscribeRef.current?.();
|
||||
unsubscribeRef.current = null;
|
||||
|
||||
setChatState('loading');
|
||||
if (showLoading) {
|
||||
setChatState('loading');
|
||||
}
|
||||
setError(null);
|
||||
setPendingTool(null);
|
||||
activeRequestId.current = null;
|
||||
|
||||
const resumed = await resumeSession(sessionId);
|
||||
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;
|
||||
|
||||
localStorage.setItem(SESSION_KEY, sessionId);
|
||||
setSession({ ...detail, ...resumed, id: sessionId });
|
||||
writeStoredSessionId(userRef.current?.id, sessionId);
|
||||
setSession({ ...detail, ...(resumed ?? {}), id: sessionId });
|
||||
messagesRef.current = history;
|
||||
setMessages(history);
|
||||
setChatState('idle');
|
||||
@@ -422,7 +485,7 @@ export function useTKMindChat(
|
||||
}, []);
|
||||
|
||||
const retryConnect = useCallback(async () => {
|
||||
const sessionId = session?.id ?? localStorage.getItem(SESSION_KEY);
|
||||
const sessionId = session?.id ?? readStoredSessionId(userRef.current?.id);
|
||||
if (!sessionId) return;
|
||||
setError(null);
|
||||
try {
|
||||
@@ -444,15 +507,16 @@ export function useTKMindChat(
|
||||
setChatState('loading');
|
||||
setError(null);
|
||||
|
||||
let sessionId = localStorage.getItem(SESSION_KEY);
|
||||
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)) {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
clearStoredSessionId(userRef.current?.id);
|
||||
sessionId = null;
|
||||
staleSession = true;
|
||||
} else {
|
||||
@@ -462,26 +526,32 @@ export function useTKMindChat(
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
const created = await startSession();
|
||||
sessionId = created.id;
|
||||
localStorage.setItem(SESSION_KEY, sessionId);
|
||||
await loadProjectMemory(sessionId, false);
|
||||
freshSession = await startSession();
|
||||
sessionId = freshSession.id;
|
||||
writeStoredSessionId(userRef.current?.id, sessionId);
|
||||
if (staleSession) {
|
||||
setNotice('上次会话已失效,已为你新建对话');
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
await ensureProvider(sessionId);
|
||||
if (!freshSession) {
|
||||
await ensureProvider(sessionId);
|
||||
}
|
||||
if (cancelled) return;
|
||||
await connectSessionRef.current(sessionId);
|
||||
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) {
|
||||
openRecharge(true);
|
||||
setChatState('idle');
|
||||
notifyInsufficientBalance();
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -496,8 +566,8 @@ export function useTKMindChat(
|
||||
connectTokenRef.current += 1;
|
||||
unsubscribeRef.current?.();
|
||||
};
|
||||
// Intentionally mount-only: reconnecting on every callback identity change caused refresh loops.
|
||||
}, [ensureProvider, loadProjectMemory, openRecharge, refreshSessions]);
|
||||
// Re-run when the signed-in user changes so session storage stays per-user.
|
||||
}, [ensureProvider, loadProjectMemory, notifyInsufficientBalance, refreshSessions, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibility = () => {
|
||||
@@ -510,22 +580,34 @@ export function useTKMindChat(
|
||||
}, [session?.id, refreshSessions]);
|
||||
|
||||
const switchSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
(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');
|
||||
|
||||
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],
|
||||
[session, chatState, connectSession, resetSessionView, sessions],
|
||||
);
|
||||
|
||||
const submit = useCallback(
|
||||
async (text: string, options?: { mindspaceContext?: MindSpaceChatContext }) => {
|
||||
if (!session || !text.trim()) return;
|
||||
if (chatState === 'streaming' || chatState === 'loading') return;
|
||||
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
|
||||
|
||||
const trimmed = text.trim();
|
||||
const mindspacePrefix = options?.mindspaceContext
|
||||
@@ -553,8 +635,7 @@ export function useTKMindChat(
|
||||
} catch (err) {
|
||||
setSessions((prev) => touchSession(prev, session.id, -1));
|
||||
if (err instanceof ApiError && err.status === 402) {
|
||||
openRecharge(true);
|
||||
setChatState('idle');
|
||||
notifyInsufficientBalance();
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setChatState('error');
|
||||
@@ -562,7 +643,7 @@ export function useTKMindChat(
|
||||
activeRequestId.current = null;
|
||||
}
|
||||
},
|
||||
[openRecharge, session, chatState],
|
||||
[notifyInsufficientBalance, session, chatState],
|
||||
);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
@@ -585,25 +666,40 @@ export function useTKMindChat(
|
||||
[session, pendingTool],
|
||||
);
|
||||
|
||||
const newSession = useCallback(async () => {
|
||||
const newSession = useCallback(() => {
|
||||
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;
|
||||
|
||||
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');
|
||||
}
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setChatState('error');
|
||||
}
|
||||
}, [chatState, connectSession, ensureProvider, loadProjectMemory, openRecharge, refreshSessions]);
|
||||
})();
|
||||
}, [chatState, connectSession, ensureProvider, loadProjectMemory, notifyInsufficientBalance, refreshSessions, resetSessionView]);
|
||||
|
||||
const refreshProjectMemory = useCallback(async () => {
|
||||
if (!session || chatState === 'streaming') return;
|
||||
@@ -611,14 +707,45 @@ export function useTKMindChat(
|
||||
}, [chatState, loadProjectMemory, session]);
|
||||
|
||||
const rememberCurrentContext = useCallback(async () => {
|
||||
if (chatState === 'streaming' || chatState === 'loading') return;
|
||||
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
|
||||
await rememberRecentContext({ silent: false });
|
||||
}, [rememberRecentContext]);
|
||||
}, [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,
|
||||
@@ -636,6 +763,7 @@ export function useTKMindChat(
|
||||
rememberCurrentContext,
|
||||
refreshProjectMemory,
|
||||
switchSession,
|
||||
deleteSession,
|
||||
refreshSessions,
|
||||
retryConnect,
|
||||
dismissNotice,
|
||||
@@ -643,6 +771,7 @@ export function useTKMindChat(
|
||||
workingDir: session?.working_dir ?? user?.workspaceRoot ?? '',
|
||||
balanceCents: user?.balanceCents,
|
||||
totalCreditCents: user?.totalCreditCents,
|
||||
tokensUsed: user?.tokensUsed ?? 0,
|
||||
rechargePrompt,
|
||||
rechargeForced,
|
||||
openRecharge,
|
||||
|
||||
Reference in New Issue
Block a user