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:
John
2026-06-15 22:09:38 -07:00
parent 3cd322ccfe
commit 6ee6fd64dd
94 changed files with 7015 additions and 2136 deletions
+117
View File
@@ -0,0 +1,117 @@
import { useCallback, useRef, useState } from 'react';
export type PageDraftSnapshot = {
title: string;
summary: string;
content: string;
};
const MAX_HISTORY = 50;
const HISTORY_DEBOUNCE_MS = 600;
export function usePageDraftHistory(initial: PageDraftSnapshot) {
const [snapshot, setSnapshot] = useState(initial);
const stableRef = useRef(initial);
const pastRef = useRef<PageDraftSnapshot[]>([]);
const futureRef = useRef<PageDraftSnapshot[]>([]);
const debounceRef = useRef<number | null>(null);
const [historyRevision, setHistoryRevision] = useState(0);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const bump = () => {
setCanUndo(pastRef.current.length > 0);
setCanRedo(futureRef.current.length > 0);
setHistoryRevision((value) => value + 1);
};
const flushDebounced = useCallback(() => {
if (debounceRef.current != null) {
window.clearTimeout(debounceRef.current);
debounceRef.current = null;
}
}, []);
const recordStable = useCallback((next: PageDraftSnapshot) => {
if (JSON.stringify(stableRef.current) === JSON.stringify(next)) return;
pastRef.current = [...pastRef.current.slice(-(MAX_HISTORY - 1)), stableRef.current];
futureRef.current = [];
stableRef.current = next;
bump();
}, []);
const update = useCallback(
(next: PageDraftSnapshot) => {
setSnapshot(next);
bump();
flushDebounced();
debounceRef.current = window.setTimeout(() => {
debounceRef.current = null;
recordStable(next);
}, HISTORY_DEBOUNCE_MS);
},
[flushDebounced, recordStable],
);
const replace = useCallback(
(next: PageDraftSnapshot, options?: { recordHistory?: boolean }) => {
flushDebounced();
if (options?.recordHistory) {
pastRef.current = [...pastRef.current.slice(-(MAX_HISTORY - 1)), snapshot];
futureRef.current = [];
}
stableRef.current = next;
setSnapshot(next);
bump();
},
[flushDebounced, snapshot],
);
const undo = useCallback(() => {
flushDebounced();
const previous = pastRef.current[pastRef.current.length - 1];
if (!previous) return false;
pastRef.current = pastRef.current.slice(0, -1);
futureRef.current = [snapshot, ...futureRef.current];
stableRef.current = previous;
setSnapshot(previous);
bump();
return true;
}, [flushDebounced, snapshot]);
const redo = useCallback(() => {
flushDebounced();
const next = futureRef.current[0];
if (!next) return false;
futureRef.current = futureRef.current.slice(1);
pastRef.current = [...pastRef.current, snapshot];
stableRef.current = next;
setSnapshot(next);
bump();
return true;
}, [flushDebounced, snapshot]);
const reset = useCallback(
(next: PageDraftSnapshot) => {
flushDebounced();
pastRef.current = [];
futureRef.current = [];
stableRef.current = next;
setSnapshot(next);
bump();
},
[flushDebounced],
);
return {
snapshot,
update,
replace,
undo,
redo,
reset,
canUndo,
canRedo,
historyRevision,
};
}
+363
View File
@@ -0,0 +1,363 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
ApiError,
cancelRequest,
closeMindSpacePageEditSession,
confirmTool,
forkMindSpacePageEditSession,
loadSessionDetail,
resumeSession,
sendReply,
subscribeSessionEvents,
} from '../api/client';
import type { ChatState, Message, MindSpaceChatContext, PortalUser, Session, SessionEvent } from '../types';
import { buildContextPrefix } from '../utils/mindspaceChatContext';
import { buildUserAddressPrefix } from '../utils/userAddress';
import {
createUserMessage,
getDisplayText,
getToolConfirmation,
pushMessage,
} from '../utils/message';
import { extractMindSpacePagePatch, type MindSpacePagePatch } from '../utils/mindspacePagePatch';
function buildPageEditSummary(messages: Message[], pageTitle: string): string {
const lines = messages
.map((message) => {
const text = getDisplayText(message).trim();
if (!text) return null;
return `${message.role === 'user' ? '用户' : 'Agent'}: ${text}`;
})
.filter(Boolean)
.slice(-12);
if (lines.length === 0) return '';
return [`[全屏页面编辑摘要 · ${pageTitle}]`, '用户在预览模式下的子 Agent 对话:', ...lines]
.join('\n')
.slice(0, 6000);
}
export function usePageEditSubChat({
pageId,
pageTitle,
parentSessionId,
h5ApiBase,
user,
enabled,
onSessionChange,
onPagePatch,
onForkUnavailable,
}: {
pageId: string;
pageTitle: string;
parentSessionId?: string | null;
h5ApiBase?: string | null;
user?: PortalUser | null;
enabled: boolean;
onSessionChange?: (sessionId: string | null) => void;
onPagePatch?: (patch: MindSpacePagePatch) => void;
onForkUnavailable?: () => void;
}) {
const [session, setSession] = useState<Session | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [chatState, setChatState] = useState<ChatState>('idle');
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [pendingTool, setPendingTool] = useState<ReturnType<typeof getToolConfirmation>>(null);
const activeRequestId = useRef<string | null>(null);
const unsubscribeRef = useRef<(() => void) | null>(null);
const messagesRef = useRef<Message[]>([]);
const sessionRef = useRef<Session | null>(null);
const appliedPatchKeysRef = useRef(new Set<string>());
const closingRef = useRef(false);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => {
sessionRef.current = session;
}, [session]);
const dismissNotice = useCallback(() => setNotice(null), []);
const processEvent = useCallback((event: SessionEvent, requestId: 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': {
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((message) => message.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;
return;
default:
return;
}
}, []);
const connectSubSession = useCallback(
async (sessionId: string) => {
unsubscribeRef.current?.();
unsubscribeRef.current = null;
setChatState('loading');
setError(null);
setPendingTool(null);
activeRequestId.current = null;
await resumeSession(sessionId);
const { session: detail, messages: history } = await loadSessionDetail(sessionId);
const nextSession = { ...detail, id: sessionId };
sessionRef.current = nextSession;
setSession(nextSession);
messagesRef.current = history;
setMessages(history);
setChatState('idle');
unsubscribeRef.current = subscribeSessionEvents(
sessionId,
(event) => {
const rid = activeRequestId.current;
if (rid) processEvent(event, rid);
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 || err.status === 402)) return;
setError(err.message);
},
{ pauseWhenHidden: false },
);
},
[processEvent],
);
const start = useCallback(async () => {
if (!enabled || !parentSessionId || !pageId || closingRef.current) return;
setChatState('loading');
setError(null);
try {
const forked = await forkMindSpacePageEditSession(pageId, parentSessionId, h5ApiBase);
onSessionChange?.(forked.sessionId);
await connectSubSession(forked.sessionId);
} catch (err) {
if (
err instanceof ApiError &&
(err.status === 404 || err.code === 'not_found')
) {
onForkUnavailable?.();
setChatState('idle');
setError(null);
onSessionChange?.(null);
return;
}
setError(err instanceof Error ? err.message : '无法启动页面编辑 Agent');
setChatState('error');
onSessionChange?.(null);
}
}, [
connectSubSession,
enabled,
h5ApiBase,
onForkUnavailable,
onSessionChange,
pageId,
parentSessionId,
]);
const close = useCallback(
async (options?: { merge?: boolean }) => {
const currentSession = sessionRef.current;
if (!currentSession || closingRef.current) return;
closingRef.current = true;
unsubscribeRef.current?.();
unsubscribeRef.current = null;
try {
if (options?.merge !== false) {
const summary = buildPageEditSummary(messagesRef.current, pageTitle);
if (summary) {
await closeMindSpacePageEditSession(pageId, {
sessionId: currentSession.id,
parentSessionId,
summary,
});
} else {
await closeMindSpacePageEditSession(pageId, {
sessionId: currentSession.id,
parentSessionId,
summary: '',
});
}
}
} catch {
// best-effort merge on close
} finally {
onSessionChange?.(null);
sessionRef.current = null;
setSession(null);
messagesRef.current = [];
setMessages([]);
setChatState('idle');
setPendingTool(null);
activeRequestId.current = null;
appliedPatchKeysRef.current.clear();
closingRef.current = false;
}
},
[pageId, pageTitle, parentSessionId, onSessionChange],
);
const submit = useCallback(
async (text: string, context: MindSpaceChatContext) => {
const currentSession = sessionRef.current;
if (!currentSession || !text.trim()) return;
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
const trimmed = text.trim();
const mindspacePrefix = buildContextPrefix(context);
const userPrefix = buildUserAddressPrefix(user);
const agentPrefix = `${userPrefix}${mindspacePrefix}`;
const userMessage = createUserMessage(trimmed, {
agentText: `${agentPrefix}${trimmed}`,
displayText: trimmed,
});
const requestId = crypto.randomUUID();
activeRequestId.current = requestId;
messagesRef.current = [...messagesRef.current, userMessage];
setMessages(messagesRef.current);
setChatState('streaming');
setError(null);
setPendingTool(null);
try {
await sendReply(currentSession.id, requestId, userMessage);
} catch (err) {
if (err instanceof ApiError && err.status === 402) {
setNotice('余额不足,请充值后继续使用');
setChatState('idle');
} else {
setError(err instanceof Error ? err.message : String(err));
setChatState('error');
}
activeRequestId.current = null;
}
},
[chatState, user],
);
const stop = useCallback(async () => {
const currentSession = sessionRef.current;
if (!currentSession || !activeRequestId.current) return;
try {
await cancelRequest(currentSession.id, activeRequestId.current);
} finally {
activeRequestId.current = null;
setChatState('idle');
}
}, []);
const approveTool = useCallback(
async (allow: boolean) => {
const currentSession = sessionRef.current;
if (!currentSession || !pendingTool) return;
await confirmTool(currentSession.id, pendingTool.id, allow ? 'allow_once' : 'deny_once');
setPendingTool(null);
setChatState('streaming');
},
[pendingTool],
);
const retryConnect = useCallback(async () => {
const currentSession = sessionRef.current;
if (!currentSession) {
await start();
return;
}
try {
await connectSubSession(currentSession.id);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setChatState('error');
}
}, [connectSubSession, start]);
const startedRef = useRef(false);
useEffect(() => {
if (!enabled) {
startedRef.current = false;
return;
}
if (startedRef.current) return;
startedRef.current = true;
void start();
return () => {
startedRef.current = false;
void close({ merge: true });
};
}, [close, enabled, pageId, parentSessionId, start]);
useEffect(() => {
if (!onPagePatch) return;
if (chatState !== 'idle') return;
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message.role !== 'assistant') continue;
const patch = extractMindSpacePagePatch(getDisplayText(message));
if (!patch) continue;
const key = `${message.id ?? i}:${JSON.stringify(patch)}`;
if (appliedPatchKeysRef.current.has(key)) return;
appliedPatchKeysRef.current.add(key);
onPagePatch(patch);
return;
}
}, [chatState, messages, onPagePatch]);
return {
session,
messages,
chatState,
error,
notice,
pendingTool,
submit,
stop,
approveTool,
dismissNotice,
retryConnect,
openRecharge: () => setNotice('余额不足,请充值后继续使用'),
close,
};
}
export type PageEditSubChatBridge = ReturnType<typeof usePageEditSubChat>;
+183 -54
View File
@@ -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,
+45 -5
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { transcribeOneShot } from '../voice/asrTransport';
import { mapMicError } from '../voice/audioAnalyser';
import { shouldFallbackToServerAsr, shouldPreferServerAsr } from '../voice/capabilities';
import { MicCapture } from '../voice/micCapture';
import { waitForMicRelease } from '../voice/micSession';
import { createLiveSpeechRecognition, isSpeechRecognitionSupported } from '../voice/speechRecognition';
@@ -18,7 +19,9 @@ export function useVoiceSession({
const [phase, setPhase] = useState<VoiceSessionPhase>('idle');
const [text, setText] = useState('');
const [analyser, setAnalyser] = useState<AnalyserNode | null>(null);
const [liveRecognition, setLiveRecognition] = useState(isSpeechRecognitionSupported());
const [liveRecognition, setLiveRecognition] = useState(
isSpeechRecognitionSupported() && !shouldPreferServerAsr(),
);
const committedRef = useRef('');
const interimRef = useRef('');
@@ -65,6 +68,26 @@ export function useVoiceSession({
setPhase((current) => (current === 'transcribing' ? current : 'ready'));
}, []);
const startServerAsrCapture = useCallback(
async (sessionId: number) => {
recognitionRef.current?.abort();
recognitionRef.current = null;
const capture = new MicCapture();
captureRef.current = capture;
await capture.start();
if (sessionRef.current !== sessionId) {
await capture.cancel();
captureRef.current = null;
return false;
}
setAnalyser(capture.levelAnalyser);
setLiveRecognition(false);
setPhase('listening');
return true;
},
[],
);
const finishFallbackRecording = useCallback(async () => {
const duration = Date.now() - startedAtRef.current;
const capture = captureRef.current;
@@ -115,7 +138,7 @@ export function useVoiceSession({
if (sessionRef.current !== sessionId) return;
startedAtRef.current = Date.now();
const useLiveSpeech = isSpeechRecognitionSupported();
const useLiveSpeech = isSpeechRecognitionSupported() && !shouldPreferServerAsr();
if (useLiveSpeech) {
const recognition = createLiveSpeechRecognition({
@@ -128,9 +151,26 @@ export function useVoiceSession({
interimRef.current = '';
syncDisplayText();
},
onError: (message) => onError?.(message),
onError: (message) => {
if (shouldFallbackToServerAsr(message)) {
void startServerAsrCapture(sessionId)
.then((ok) => {
if (!ok) return;
onError?.('实时识别不可用,已切换为录音识别,说完后点「完成识别」');
})
.catch((err) => onError?.(mapMicError(err)));
return;
}
onError?.(message);
},
});
if (!recognition) throw new Error('当前浏览器不支持实时语音识别');
if (!recognition) {
if (MicCapture.isSupported()) {
const ok = await startServerAsrCapture(sessionId);
if (ok) return;
}
throw new Error('当前浏览器不支持实时语音识别');
}
recognitionRef.current = recognition;
recognition.start();
if (sessionRef.current !== sessionId) {
@@ -166,7 +206,7 @@ export function useVoiceSession({
return () => {
void cleanup();
};
}, [active, cleanup, onError, resetSession, syncDisplayText]);
}, [active, cleanup, onError, resetSession, startServerAsrCapture, syncDisplayText]);
const updateText = useCallback((value: string) => {
userEditedRef.current = true;