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
+226 -31
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar';
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
@@ -10,6 +10,14 @@ import { VoiceInputButton } from './VoiceInputButton';
import type { CapabilityMap, ChatState, Message, PortalUser, Session, ToolConfirmation } from '../types';
import type { MindSpaceSaveCategory } from '../types';
const MAX_PENDING_IMAGES = 6;
type PendingChatImage = {
id: string;
url: string;
local: boolean;
};
export function ChatPanel({
variant,
user,
@@ -20,6 +28,7 @@ export function ChatPanel({
capabilities,
grantedSkills,
onSubmit,
onUploadImage,
onStop,
onApproveTool,
onPageSaved,
@@ -33,7 +42,8 @@ export function ChatPanel({
session: Session | null;
capabilities?: CapabilityMap;
grantedSkills?: string[];
onSubmit: (text: string) => void | Promise<void>;
onSubmit: (text: string, imageUrls?: string[]) => void | Promise<void>;
onUploadImage?: (file: File) => Promise<string>;
onStop: () => void | Promise<void>;
onApproveTool: (allow: boolean) => void | Promise<void>;
onPageSaved?: (result: {
@@ -45,9 +55,28 @@ export function ChatPanel({
}) {
const online = useNetworkStatus();
const [input, setInput] = useState('');
const [voiceRecording, setVoiceRecording] = useState(false);
const [pageSource, setPageSource] = useState<Message | null>(null);
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
const [uploadingImage, setUploadingImage] = useState(false);
const [imageError, setImageError] = useState<string | null>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef(input);
const voiceBaseRef = useRef('');
const pendingImagesRef = useRef(pendingImages);
inputRef.current = input;
pendingImagesRef.current = pendingImages;
useEffect(() => {
return () => {
pendingImagesRef.current.forEach((item) => {
if (item.local) URL.revokeObjectURL(item.url);
});
};
}, []);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -61,13 +90,6 @@ export function ChatPanel({
const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, { grantedSkills, canPublish });
const compact = variant === 'compact';
const handleSubmit = async () => {
const text = input;
if (!text.trim()) return;
setInput('');
await onSubmit(text);
};
const placeholder = offlineBlocked
? '网络断开,恢复连接后可继续输入'
: chatState === 'connecting'
@@ -76,12 +98,133 @@ export function ChatPanel({
? '在这里继续和 Agent 对话…'
: '输入任务,例如:列出当前目录文件';
const handleVoiceSend = async (text: string) => {
const mergeVoiceText = (spoken: string) => {
const base = voiceBaseRef.current.trimEnd();
const chunk = spoken.trim();
if (!chunk) return base;
return base ? `${base} ${chunk}` : chunk;
};
const handleVoiceStart = () => {
voiceBaseRef.current = inputRef.current;
setVoiceNotice(null);
await onSubmit(text);
};
const handleVoiceLiveTranscript = (text: string) => {
setInput(mergeVoiceText(text));
};
const handleVoiceTranscript = (text: string) => {
setInput(mergeVoiceText(text));
setVoiceNotice('已识别,可编辑后发送');
};
const handleVoiceComplete = () => {
setVoiceNotice('已识别,可编辑后发送');
};
const voiceDisabled = busy || !!pendingTool || chatState === 'error' || offlineBlocked;
const canSubmit = input.trim() || pendingImages.length > 0;
const imageAttachmentDisabled = !onUploadImage || busy || uploadingImage || offlineBlocked || !!pendingTool;
const revokePendingImage = (item: PendingChatImage) => {
if (item.local) URL.revokeObjectURL(item.url);
};
const handleSubmit = async () => {
const trimmed = input.trim();
if ((!trimmed && pendingImages.length === 0) || voiceDisabled) return;
if (uploadingImage || pendingImages.some((item) => item.local)) {
setImageError('图片仍在上传中,请稍候再发送');
return;
}
const imagesToSend = pendingImages.map((item) => item.url);
const sentImages = [...pendingImages];
setInput('');
setImageError(null);
try {
await onSubmit(trimmed, imagesToSend);
sentImages.forEach(revokePendingImage);
setPendingImages([]);
} catch (err) {
setInput(trimmed);
setPendingImages(sentImages);
setImageError(err instanceof Error ? err.message : '发送失败,请重试');
}
};
const handleImageInputChange = async (event: ChangeEvent<HTMLInputElement>) => {
if (!onUploadImage) return;
const selected = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
if (selected.length === 0) return;
const imageFiles = selected.filter((file) => file.type.startsWith('image/'));
if (imageFiles.length === 0) {
setImageError('请上传图片文件');
return;
}
const remaining = MAX_PENDING_IMAGES - pendingImages.length;
if (remaining <= 0) {
setImageError(`最多支持一次性附加 ${MAX_PENDING_IMAGES} 张图片`);
return;
}
const toUpload = imageFiles.slice(0, remaining);
if (imageFiles.length > remaining) {
setImageError(`最多支持 ${MAX_PENDING_IMAGES} 张图片,本次仅上传前 ${remaining} 张`);
} else {
setImageError(null);
}
const placeholders = toUpload.map((file, index) => ({
id: `${Date.now()}-${index}-${file.name}`,
url: URL.createObjectURL(file),
local: true,
}));
setPendingImages((prev) => [...prev, ...placeholders]);
setUploadingImage(true);
try {
const uploaded = await Promise.all(toUpload.map((file) => onUploadImage(file)));
const placeholderIds = placeholders.map((item) => item.id);
setPendingImages((prev) => {
const next = prev.map((item) => {
const index = placeholderIds.indexOf(item.id);
if (index < 0 || !uploaded[index]) return item;
revokePendingImage(item);
return { ...item, url: uploaded[index], local: false };
});
const seen = new Set<string>();
return next.filter((item) => {
if (seen.has(item.url)) return false;
seen.add(item.url);
return true;
});
});
} catch (err) {
setPendingImages((prev) => {
const removeIds = new Set(placeholders.map((item) => item.id));
prev.forEach((item) => {
if (removeIds.has(item.id)) revokePendingImage(item);
});
return prev.filter((item) => !removeIds.has(item.id));
});
setImageError(err instanceof Error ? err.message : '图片上传失败');
} finally {
setUploadingImage(false);
}
};
const handleRemoveImage = (idToRemove: string) => {
setPendingImages((prev) => {
const target = prev.find((item) => item.id === idToRemove);
if (target) revokePendingImage(target);
return prev.filter((item) => item.id !== idToRemove);
});
};
return (
<>
@@ -138,26 +281,78 @@ export function ChatPanel({
{voiceNotice}
</div>
)}
{(pendingImages.length > 0 || imageError) && (
<div className="chat-image-attachments">
{imageError && <div className="chat-image-upload-error">{imageError}</div>}
{pendingImages.length > 0 && (
<div className="chat-image-attachment-grid">
{pendingImages.map((item, index) => (
<div key={item.id} className="chat-image-attachment-item">
<a href={item.url} target="_blank" rel="noreferrer">
<img src={item.url} alt={`上传图片 ${index + 1}`} className="chat-image-attachment-thumb" />
</a>
<button
type="button"
className="chat-image-attachment-remove"
aria-label="移除图片"
onClick={() => handleRemoveImage(item.id)}
>
×
</button>
</div>
))}
</div>
)}
</div>
)}
<div className="chat-input-row">
<VoiceInputButton
disabled={voiceDisabled}
onSend={(text) => void handleVoiceSend(text)}
onError={setVoiceNotice}
/>
<textarea
className="input"
rows={1}
placeholder={placeholder}
value={input}
disabled={voiceDisabled}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSubmit();
}
}}
/>
<div className="chat-input-shell">
{onUploadImage && (
<>
<button
type="button"
className="chat-image-upload-trigger"
title="上传图片"
disabled={imageAttachmentDisabled}
onClick={() => imageInputRef.current?.click()}
>
🖼️
</button>
<input
ref={imageInputRef}
type="file"
accept="image/*"
multiple
hidden
onChange={handleImageInputChange}
/>
</>
)}
<textarea
className={`input chat-input-field${onUploadImage ? ' chat-input-field-with-image-upload' : ''}`}
rows={1}
placeholder={placeholder}
value={input}
disabled={voiceDisabled}
readOnly={voiceRecording}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSubmit();
}
}}
/>
<VoiceInputButton
disabled={voiceDisabled}
onVoiceStart={handleVoiceStart}
onLiveTranscript={handleVoiceLiveTranscript}
onTranscript={handleVoiceTranscript}
onVoiceComplete={handleVoiceComplete}
onRecordingChange={setVoiceRecording}
onError={setVoiceNotice}
/>
</div>
{chatSkills.length > 0 && (
<ChatSkillPicker
skills={chatSkills}
@@ -174,7 +369,7 @@ export function ChatPanel({
<button
type="button"
className="send-btn chat-input-action"
disabled={!input.trim() || voiceDisabled}
disabled={!canSubmit || voiceDisabled || uploadingImage}
onClick={() => void handleSubmit()}
>
发送