Add attachment text extraction, auto web news skill, and chat/voice UI updates.
Simplify asset upload temp paths, refresh deploy docs for Aliyun DNS topology, and ship MindSpace content-scan and auth improvements. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import { ChangeEvent, useEffect, useRef, useState, type ClipboardEvent } from 'react';
|
||||
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
||||
import { openAvatarPicker } from '../utils/userAvatar';
|
||||
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
|
||||
@@ -18,6 +18,18 @@ type PendingChatImage = {
|
||||
local: boolean;
|
||||
};
|
||||
|
||||
function FileIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7zm0 0v5h5"
|
||||
/>
|
||||
<path fill="currentColor" d="M9 13h6v1.5H9zm0 3h6v1.5H9zm0-6h3v1.5H9z" opacity=".72" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatPanel({
|
||||
variant,
|
||||
user,
|
||||
@@ -61,11 +73,13 @@ export function ChatPanel({
|
||||
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef(input);
|
||||
const voiceBaseRef = useRef('');
|
||||
const pendingImagesRef = useRef(pendingImages);
|
||||
const suppressVoiceUpdateRef = useRef(false);
|
||||
|
||||
inputRef.current = input;
|
||||
pendingImagesRef.current = pendingImages;
|
||||
@@ -106,15 +120,18 @@ export function ChatPanel({
|
||||
};
|
||||
|
||||
const handleVoiceStart = () => {
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
voiceBaseRef.current = inputRef.current;
|
||||
setVoiceNotice(null);
|
||||
};
|
||||
|
||||
const handleVoiceLiveTranscript = (text: string) => {
|
||||
if (suppressVoiceUpdateRef.current) return;
|
||||
setInput(mergeVoiceText(text));
|
||||
};
|
||||
|
||||
const handleVoiceTranscript = (text: string) => {
|
||||
if (suppressVoiceUpdateRef.current) return;
|
||||
setInput(mergeVoiceText(text));
|
||||
setVoiceNotice('已识别,可编辑后发送');
|
||||
};
|
||||
@@ -141,17 +158,26 @@ export function ChatPanel({
|
||||
const imagesToSend = pendingImages.map((item) => item.url);
|
||||
const sentImages = [...pendingImages];
|
||||
|
||||
suppressVoiceUpdateRef.current = true;
|
||||
if (voiceRecording) {
|
||||
setVoiceStopSignal((value) => value + 1);
|
||||
}
|
||||
setInput('');
|
||||
voiceBaseRef.current = '';
|
||||
setVoiceNotice(null);
|
||||
setImageError(null);
|
||||
try {
|
||||
await onSubmit(trimmed, imagesToSend);
|
||||
sentImages.forEach(revokePendingImage);
|
||||
setPendingImages([]);
|
||||
} catch (err) {
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
setInput(trimmed);
|
||||
setPendingImages(sentImages);
|
||||
setImageError(err instanceof Error ? err.message : '发送失败,请重试');
|
||||
return;
|
||||
}
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
};
|
||||
|
||||
const handleImageInputChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -218,6 +244,71 @@ export function ChatPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!onUploadImage || imageAttachmentDisabled) return;
|
||||
const items = Array.from(event.clipboardData.items ?? []);
|
||||
const imageItems = items.filter((item) => item.kind === 'file' && item.type.startsWith('image/'));
|
||||
if (imageItems.length === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const files = imageItems
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => Boolean(file));
|
||||
if (files.length === 0) return;
|
||||
|
||||
const remaining = MAX_PENDING_IMAGES - pendingImages.length;
|
||||
if (remaining <= 0) {
|
||||
setImageError(`最多支持一次性附加 ${MAX_PENDING_IMAGES} 张图片`);
|
||||
return;
|
||||
}
|
||||
|
||||
const toUpload = files.slice(0, remaining);
|
||||
if (files.length > remaining) {
|
||||
setImageError(`最多支持 ${MAX_PENDING_IMAGES} 张图片,本次仅粘贴前 ${remaining} 张`);
|
||||
} else {
|
||||
setImageError(null);
|
||||
}
|
||||
|
||||
const placeholders = toUpload.map((file, index) => ({
|
||||
id: `${Date.now()}-${index}-${file.name || 'pasted-image'}`,
|
||||
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);
|
||||
@@ -316,7 +407,7 @@ export function ChatPanel({
|
||||
disabled={imageAttachmentDisabled}
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
>
|
||||
🖼️
|
||||
<FileIcon />
|
||||
</button>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
@@ -336,6 +427,7 @@ export function ChatPanel({
|
||||
disabled={voiceDisabled}
|
||||
readOnly={voiceRecording}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -351,6 +443,7 @@ export function ChatPanel({
|
||||
onVoiceComplete={handleVoiceComplete}
|
||||
onRecordingChange={setVoiceRecording}
|
||||
onError={setVoiceNotice}
|
||||
stopSignal={voiceStopSignal}
|
||||
/>
|
||||
</div>
|
||||
{chatSkills.length > 0 && (
|
||||
|
||||
@@ -41,6 +41,7 @@ export function VoiceInputButton({
|
||||
onVoiceComplete,
|
||||
onRecordingChange,
|
||||
onError,
|
||||
stopSignal = 0,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onTranscript?: (text: string) => void;
|
||||
@@ -49,6 +50,7 @@ export function VoiceInputButton({
|
||||
onVoiceComplete?: () => void;
|
||||
onRecordingChange?: (recording: boolean) => void;
|
||||
onError?: (message: string) => void;
|
||||
stopSignal?: number;
|
||||
}) {
|
||||
const [available, setAvailable] = useState(true);
|
||||
const [browserActive, setBrowserActive] = useState(false);
|
||||
@@ -62,6 +64,7 @@ export function VoiceInputButton({
|
||||
const startingRef = useRef(false);
|
||||
const browserCommittedRef = useRef(false);
|
||||
const prevRecordingRef = useRef(false);
|
||||
const lastStopSignalRef = useRef(stopSignal);
|
||||
|
||||
useEffect(() => {
|
||||
setAvailable(isVoiceInputAvailable());
|
||||
@@ -131,6 +134,19 @@ export function VoiceInputButton({
|
||||
resetSession();
|
||||
}, [browserActive, resetSession, wechatMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stopSignal === lastStopSignalRef.current) return;
|
||||
lastStopSignalRef.current = stopSignal;
|
||||
setBrowserActive(false);
|
||||
if (wechatMode) {
|
||||
void stopWechatVoiceRecord().catch(() => {
|
||||
// best-effort stop when the parent explicitly ends the current draft
|
||||
});
|
||||
setWechatPhase('idle');
|
||||
pendingWechatStopRef.current = false;
|
||||
}
|
||||
}, [stopSignal, wechatMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wechatMode) return;
|
||||
let cancelled = false;
|
||||
|
||||
@@ -17,7 +17,7 @@ export function VoiceInputDialog({
|
||||
onSend: (text: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
}) {
|
||||
const { phase, text, analyser, liveRecognition, updateText, stopListening, finishFallbackRecording } =
|
||||
const { phase, text, analyser, liveRecognition, stopListening, finishFallbackRecording, resetSession } =
|
||||
useVoiceSession({
|
||||
active: open && !disabled,
|
||||
onError,
|
||||
@@ -25,8 +25,9 @@ export function VoiceInputDialog({
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
stopListening();
|
||||
resetSession();
|
||||
onClose();
|
||||
}, [onClose, stopListening]);
|
||||
}, [onClose, resetSession, stopListening]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
@@ -53,6 +54,7 @@ export function VoiceInputDialog({
|
||||
const value = text.trim();
|
||||
if (!value || disabled) return;
|
||||
stopListening();
|
||||
resetSession();
|
||||
onSend(value);
|
||||
onClose();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user