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:
john
2026-06-20 15:08:10 +08:00
parent 2e5afe3bfd
commit 70492d9eba
24 changed files with 648 additions and 129 deletions
+95 -2
View File
@@ -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 && (
+16
View File
@@ -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;
+4 -2
View File
@@ -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();
};