Improve WeChat MP replies and ship MindSpace/H5 production updates.

Add WeChat service account routing with sync acks, connectivity tests, and context isolation; document deploy runbooks; and bundle related MindSpace, voice, Plaza, and server gateway changes for production rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-19 23:06:43 +08:00
parent b0f5d6a51c
commit 229805a070
241 changed files with 13190 additions and 902 deletions
+51 -5
View File
@@ -5,8 +5,10 @@ import {
closeMindSpacePageEditSession,
confirmTool,
forkMindSpacePageEditSession,
getMindSpace,
loadSessionDetail,
resumeSession,
uploadMindSpaceAsset,
sendReply,
subscribeSessionEvents,
} from '../api/client';
@@ -14,7 +16,15 @@ import type { ChatState, Message, MindSpaceChatContext, PortalUser, Session, Ses
import { buildContextPrefix } from '../utils/mindspaceChatContext';
import { buildUserAddressPrefix } from '../utils/userAddress';
import {
createUserMessage,
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES,
CHAT_IMAGE_MAX_SIDE,
compressImageForUpload,
} from '../utils/imageUpload';
import { buildAbsoluteAssetImageUrl } from '../utils/mindspaceCards';
import {
buildUserMessage,
normalizeConversationMessages,
getDisplayText,
getToolConfirmation,
pushMessage,
@@ -67,6 +77,7 @@ export function usePageEditSubChat({
const messagesRef = useRef<Message[]>([]);
const sessionRef = useRef<Session | null>(null);
const closingRef = useRef(false);
const chatImageCategoryIdRef = useRef<string | null>(null);
useEffect(() => {
messagesRef.current = messages;
@@ -78,6 +89,36 @@ export function usePageEditSubChat({
const dismissNotice = useCallback(() => setNotice(null), []);
const resolveChatImageUploadCategoryId = useCallback(async () => {
if (chatImageCategoryIdRef.current) return chatImageCategoryIdRef.current;
const space = await getMindSpace();
const category =
space.categories.find((item) => item.code === 'private') ??
space.categories.find((item) => item.code === 'oa') ??
space.categories.find((item) => item.code === 'public') ??
space.categories[0];
if (!category) {
throw new Error('当前空间暂无可用的图片上传分类');
}
chatImageCategoryIdRef.current = category.id;
return category.id;
}, []);
const uploadChatImage = useCallback(async (file: File): Promise<string> => {
if (!file.type.startsWith('image/')) {
throw new Error('只支持图片文件');
}
const compressed = await compressImageForUpload(file, {
maxInputBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
maxOutputBytes: CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES,
maxDimension: CHAT_IMAGE_MAX_SIDE,
});
const categoryId = await resolveChatImageUploadCategoryId();
const asset = await uploadMindSpaceAsset(categoryId, compressed);
return buildAbsoluteAssetImageUrl({ id: asset.id, updatedAt: asset.updatedAt });
}, [resolveChatImageUploadCategoryId]);
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;
@@ -101,7 +142,9 @@ export function usePageEditSubChat({
return;
}
case 'UpdateConversation':
messagesRef.current = event.conversation.filter((message) => message.metadata?.userVisible);
messagesRef.current = normalizeConversationMessages(
event.conversation.filter((message) => message.metadata?.userVisible),
);
setMessages(messagesRef.current);
return;
case 'Error':
@@ -233,18 +276,20 @@ export function usePageEditSubChat({
);
const submit = useCallback(
async (text: string, context: MindSpaceChatContext) => {
async (text: string, context: MindSpaceChatContext, imageUrls?: string[]) => {
const currentSession = sessionRef.current;
if (!currentSession || !text.trim()) return;
const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
if (!currentSession || (!text.trim() && normalizedImageUrls.length === 0)) 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, {
const userMessage = buildUserMessage(trimmed, {
agentText: `${agentPrefix}${trimmed}`,
displayText: trimmed,
imageUrls: normalizedImageUrls,
});
const requestId = crypto.randomUUID();
activeRequestId.current = requestId;
@@ -332,6 +377,7 @@ export function usePageEditSubChat({
submit,
stop,
approveTool,
uploadChatImage,
dismissNotice,
retryConnect,
openRecharge: () => setNotice('余额不足,请充值后继续使用'),
+83 -32
View File
@@ -6,12 +6,14 @@ import {
cancelRequest,
confirmTool,
deleteChatSession,
getMindSpace,
getMe,
getSession,
listSessions,
loadSessionDetail,
readConfig,
rememberProjectContext,
uploadMindSpaceAsset,
resumeSession,
sendReply,
startSession,
@@ -32,7 +34,15 @@ import type {
import { buildContextPrefix } from '../utils/mindspaceChatContext';
import { buildUserAddressPrefix } from '../utils/userAddress';
import {
createUserMessage,
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES,
CHAT_IMAGE_MAX_SIDE,
compressImageForUpload,
} from '../utils/imageUpload';
import { buildAbsoluteAssetImageUrl } from '../utils/mindspaceCards';
import {
buildUserMessage,
normalizeConversationMessages,
getDisplayText,
getToolConfirmation,
getVisibleText,
@@ -40,7 +50,7 @@ import {
isRelayServerErrorMessage,
pushMessage,
} from '../utils/message';
import { prependUnique, sortAndTrim, touchSession } from '../utils/sessions';
import { prependUnique, shouldShowNewChatTitle, sortAndTrim, touchSession } from '../utils/sessions';
const LEGACY_SESSION_KEY = 'tkmind-h5-session-id';
@@ -104,6 +114,7 @@ export function useTKMindChat(
const fallbackRetriedRef = useRef(new Set<string>());
const userRef = useRef(user);
const onUserUpdateRef = useRef(onUserUpdate);
const chatImageCategoryIdRef = useRef<string | null>(null);
const dismissNotice = useCallback(() => setNotice(null), []);
@@ -266,6 +277,40 @@ export function useTKMindChat(
}
}, []);
const resolveChatImageUploadCategoryId = useCallback(async () => {
if (chatImageCategoryIdRef.current) return chatImageCategoryIdRef.current;
const space = await getMindSpace();
const category =
space.categories.find((item) => item.code === 'private') ??
space.categories.find((item) => item.code === 'oa') ??
space.categories.find((item) => item.code === 'public') ??
space.categories[0];
if (!category) {
throw new Error('当前空间暂无可用的图片上传分类');
}
chatImageCategoryIdRef.current = category.id;
return category.id;
}, []);
const uploadChatImage = useCallback(async (file: File): Promise<string> => {
if (!file.type.startsWith('image/')) {
throw new Error('只支持图片文件');
}
const compressed = await compressImageForUpload(file, {
maxInputBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
maxOutputBytes: CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES,
maxDimension: CHAT_IMAGE_MAX_SIDE,
});
const categoryId = await resolveChatImageUploadCategoryId();
const asset = await uploadMindSpaceAsset(categoryId, compressed);
return buildAbsoluteAssetImageUrl({
id: asset.id,
updatedAt: asset.updatedAt,
});
}, [resolveChatImageUploadCategoryId]);
const processEvent = useCallback(
(event: SessionEvent, requestId: string, sessionId: string) => {
const raw = event as SessionEvent & { chat_request_id?: string; request_id?: string };
@@ -340,7 +385,9 @@ export function useTKMindChat(
return;
}
case 'UpdateConversation':
messagesRef.current = event.conversation.filter((m) => m.metadata?.userVisible);
messagesRef.current = normalizeConversationMessages(
event.conversation.filter((m) => m.metadata?.userVisible),
);
setMessages(messagesRef.current);
return;
case 'Error':
@@ -507,17 +554,24 @@ export function useTKMindChat(
setChatState('loading');
setError(null);
let sessionId = readStoredSessionId(userRef.current?.id);
const previousSessionId = readStoredSessionId(userRef.current?.id);
let sessionId: string | null = null;
let staleSession = false;
let freshSession: Session | null = null;
if (sessionId) {
if (previousSessionId) {
try {
await getSession(sessionId);
const previousSession = await getSession(previousSessionId);
if (shouldShowNewChatTitle(previousSession)) {
try {
await deleteChatSession(previousSessionId);
} catch {
// Best-effort cleanup: a failed delete should not block the next fresh chat.
}
}
clearStoredSessionId(userRef.current?.id);
} catch (err) {
if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) {
clearStoredSessionId(userRef.current?.id);
sessionId = null;
staleSession = true;
} else {
throw err;
@@ -525,28 +579,20 @@ export function useTKMindChat(
}
}
if (!sessionId) {
freshSession = await startSession();
sessionId = freshSession.id;
writeStoredSessionId(userRef.current?.id, sessionId);
if (staleSession) {
setNotice('上次会话已失效,已为你新建对话');
}
const freshSession = await startSession();
sessionId = freshSession.id;
writeStoredSessionId(userRef.current?.id, sessionId);
if (staleSession) {
setNotice('上次会话已失效,已为你新建对话');
}
if (cancelled) return;
if (!freshSession) {
await ensureProvider(sessionId);
}
if (cancelled) return;
await connectSessionRef.current(sessionId, {
skipResume: Boolean(freshSession),
seedSession: freshSession ?? undefined,
skipResume: true,
seedSession: freshSession,
});
if (cancelled) return;
if (freshSession) {
void loadProjectMemory(sessionId, false);
}
void loadProjectMemory(sessionId, false);
void refreshSessions();
} catch (err) {
if (!cancelled) {
@@ -605,8 +651,13 @@ export function useTKMindChat(
);
const submit = useCallback(
async (text: string, options?: { mindspaceContext?: MindSpaceChatContext }) => {
if (!session || !text.trim()) return;
async (
text: string,
options?: { mindspaceContext?: MindSpaceChatContext },
imageUrls?: string[],
) => {
const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
if (!session || (!text.trim() && normalizedImageUrls.length === 0)) return;
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
const trimmed = text.trim();
@@ -615,12 +666,11 @@ export function useTKMindChat(
: '';
const userPrefix = buildUserAddressPrefix(userRef.current);
const agentPrefix = `${userPrefix}${mindspacePrefix}`;
const userMessage = agentPrefix
? createUserMessage(trimmed, {
agentText: `${agentPrefix}${trimmed}`,
displayText: trimmed,
})
: createUserMessage(trimmed);
const userMessage = buildUserMessage(trimmed, {
agentText: `${agentPrefix}${trimmed}`,
displayText: trimmed,
imageUrls: normalizedImageUrls,
});
const requestId = crypto.randomUUID();
activeRequestId.current = requestId;
messagesRef.current = [...messagesRef.current, userMessage];
@@ -768,6 +818,7 @@ export function useTKMindChat(
retryConnect,
dismissNotice,
onSidebarOpen,
uploadChatImage,
workingDir: session?.working_dir ?? user?.workspaceRoot ?? '',
balanceCents: user?.balanceCents,
totalCreditCents: user?.totalCreditCents,
+20 -3
View File
@@ -30,6 +30,7 @@ export function useVoiceSession({
const recognitionRef = useRef<ReturnType<typeof createLiveSpeechRecognition> | null>(null);
const startedAtRef = useRef(0);
const sessionRef = useRef(0);
const stopTimerRef = useRef<number | null>(null);
const syncDisplayText = useCallback(() => {
if (userEditedRef.current) return;
@@ -46,6 +47,10 @@ export function useVoiceSession({
}, []);
const cleanup = useCallback(async () => {
if (stopTimerRef.current != null) {
window.clearTimeout(stopTimerRef.current);
stopTimerRef.current = null;
}
recognitionRef.current?.abort();
recognitionRef.current = null;
const capture = captureRef.current;
@@ -57,15 +62,27 @@ export function useVoiceSession({
}, []);
const stopListening = useCallback(() => {
recognitionRef.current?.stop();
recognitionRef.current = null;
const recognition = recognitionRef.current;
if (recognition) {
recognition.stop();
recognitionRef.current = null;
if (stopTimerRef.current != null) {
window.clearTimeout(stopTimerRef.current);
}
stopTimerRef.current = window.setTimeout(() => {
stopTimerRef.current = null;
setPhase((current) => (current === 'transcribing' ? current : 'ready'));
}, 220);
}
const capture = captureRef.current;
captureRef.current = null;
if (capture) {
void capture.cancel();
}
setAnalyser(null);
setPhase((current) => (current === 'transcribing' ? current : 'ready'));
if (!recognition) {
setPhase((current) => (current === 'transcribing' ? current : 'ready'));
}
}, []);
const startServerAsrCapture = useCallback(