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:
+226
-31
@@ -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()}
|
||||
>
|
||||
发送
|
||||
|
||||
Reference in New Issue
Block a user