Files
memind/src/hooks/usePageEditSubChat.ts
T
john 229805a070 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>
2026-06-19 23:06:43 +08:00

389 lines
12 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import {
ApiError,
cancelRequest,
closeMindSpacePageEditSession,
confirmTool,
forkMindSpacePageEditSession,
getMindSpace,
loadSessionDetail,
resumeSession,
uploadMindSpaceAsset,
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 {
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,
} from '../utils/message';
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,
onForkUnavailable,
}: {
pageId: string;
pageTitle: string;
parentSessionId?: string | null;
h5ApiBase?: string | null;
user?: PortalUser | null;
enabled: boolean;
onSessionChange?: (sessionId: string | null) => 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 closingRef = useRef(false);
const chatImageCategoryIdRef = useRef<string | null>(null);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => {
sessionRef.current = session;
}, [session]);
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;
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 = normalizeConversationMessages(
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;
closingRef.current = false;
}
},
[pageId, pageTitle, parentSessionId, onSessionChange],
);
const submit = useCallback(
async (text: string, context: MindSpaceChatContext, imageUrls?: string[]) => {
const currentSession = sessionRef.current;
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 = buildUserMessage(trimmed, {
agentText: `${agentPrefix}${trimmed}`,
displayText: trimmed,
imageUrls: normalizedImageUrls,
});
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]);
return {
session,
messages,
chatState,
error,
notice,
pendingTool,
submit,
stop,
approveTool,
uploadChatImage,
dismissNotice,
retryConnect,
openRecharge: () => setNotice('余额不足,请充值后继续使用'),
close,
};
}
export type PageEditSubChatBridge = ReturnType<typeof usePageEditSubChat>;