229805a070
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>
389 lines
12 KiB
TypeScript
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>;
|