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
+6 -1
View File
@@ -88,7 +88,12 @@ function AuthenticatedApp({
);
return (
<ChatProvider user={user} capabilities={capabilities} onUserUpdate={onUserUpdate}>
<ChatProvider
user={user}
capabilities={capabilities}
grantedSkills={grantedSkills}
onUserUpdate={onUserUpdate}
>
<Routes>
<Route
path="/space/*"
+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();
};
+3 -1
View File
@@ -10,15 +10,17 @@ const ChatContext = createContext<ChatContextValue | null>(null);
export function ChatProvider({
user,
capabilities,
grantedSkills,
onUserUpdate,
children,
}: {
user?: PortalUser | null;
capabilities?: CapabilityMap | null;
grantedSkills?: string[];
onUserUpdate?: (user: PortalUser) => void;
children: ReactNode;
}) {
const chat = useTKMindChat(user, onUserUpdate, capabilities);
const chat = useTKMindChat(user, onUserUpdate, capabilities, grantedSkills);
return (
<ChatContext.Provider value={chat}>
+5 -2
View File
@@ -33,6 +33,7 @@ import type {
} from '../types';
import { buildContextPrefix } from '../utils/mindspaceChatContext';
import { buildUserAddressPrefix } from '../utils/userAddress';
import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs';
import {
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES,
@@ -90,6 +91,7 @@ export function useTKMindChat(
user?: PortalUser | null,
onUserUpdate?: (user: PortalUser) => void,
capabilities?: CapabilityMap | null,
grantedSkills?: string[],
) {
const canUseProjectMemory = Boolean(capabilities?.context_memory);
const [session, setSession] = useState<Session | null>(null);
@@ -665,7 +667,8 @@ export function useTKMindChat(
? buildContextPrefix(options.mindspaceContext)
: '';
const userPrefix = buildUserAddressPrefix(userRef.current);
const agentPrefix = `${userPrefix}${mindspacePrefix}`;
const skillPrefix = buildAutoChatSkillPrefix(trimmed, grantedSkills ?? []);
const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}`;
const userMessage = buildUserMessage(trimmed, {
agentText: `${agentPrefix}${trimmed}`,
displayText: trimmed,
@@ -693,7 +696,7 @@ export function useTKMindChat(
activeRequestId.current = null;
}
},
[notifyInsufficientBalance, session, chatState],
[notifyInsufficientBalance, session, chatState, grantedSkills],
);
const stop = useCallback(async () => {
+45 -32
View File
@@ -1566,7 +1566,7 @@ body,
.chat-image-attachments {
display: flex;
flex-direction: column;
gap: 6px;
gap: 4px;
}
.chat-image-upload-error {
@@ -1576,17 +1576,19 @@ body,
.chat-image-attachment-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-height: 168px;
flex-wrap: nowrap;
gap: 6px;
padding-bottom: 2px;
max-width: 100%;
overflow-y: auto;
overflow-x: auto;
}
.chat-image-attachment-item {
position: relative;
flex: 0 0 auto;
width: 72px;
height: 72px;
width: 58px;
height: 58px;
}
.chat-image-attachment-item a {
@@ -1596,9 +1598,9 @@ body,
}
.chat-image-attachment-thumb {
width: 72px;
height: 72px;
border-radius: 10px;
width: 58px;
height: 58px;
border-radius: 8px;
object-fit: cover;
border: 1px solid rgba(24, 33, 29, 0.1);
background: rgba(0, 0, 0, 0.04);
@@ -1606,34 +1608,39 @@ body,
.chat-image-attachment-remove {
position: absolute;
right: 6px;
top: 6px;
width: 22px;
height: 22px;
right: 4px;
top: 4px;
width: 18px;
height: 18px;
border: 0;
border-radius: 50%;
color: var(--color-text-primary);
background: rgba(0, 0, 0, 0.58);
font-size: 14px;
font-size: 12px;
cursor: pointer;
}
.chat-image-upload-trigger {
position: absolute;
left: 8px;
left: 10px;
top: 7px;
width: 28px;
height: 28px;
border: 1px solid rgba(24, 33, 29, 0.14);
border-radius: 8px;
background: rgba(24, 33, 29, 0.06);
color: #52605a;
width: 26px;
height: 26px;
border: 1px solid rgba(24, 33, 29, 0.18);
border-radius: 7px;
background: rgba(24, 33, 29, 0.08);
color: #5d6963;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.chat-image-upload-trigger:hover:not(:disabled) {
background: rgba(24, 33, 29, 0.12);
color: #4b5651;
}
.chat-image-upload-trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -1652,7 +1659,7 @@ body,
.chat-input-row textarea.input.chat-input-field {
width: 100%;
padding: 8px 50px 8px 12px;
padding: 8px 46px 8px 42px;
font-size: 14px;
line-height: 1.35;
resize: none;
@@ -1788,21 +1795,24 @@ body,
.voice-btn {
position: absolute;
right: 6px;
right: 7px;
bottom: 6px;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
color: #888;
border: 1px solid rgba(24, 33, 29, 0.18);
border-radius: 7px;
background: rgba(24, 33, 29, 0.08);
color: #5d6963;
cursor: pointer;
transition: color 0.15s ease;
transition:
background-color 120ms ease,
color 120ms ease,
border-color 120ms ease;
}
.voice-btn:disabled {
@@ -1811,12 +1821,15 @@ body,
}
.voice-btn:hover:not(:disabled) {
color: #666;
background: rgba(24, 33, 29, 0.12);
color: #4b5651;
border-color: rgba(24, 33, 29, 0.28);
}
.voice-btn-recording {
color: #07c160;
background: transparent;
background: rgba(7, 193, 96, 0.12);
border-color: rgba(7, 193, 96, 0.28);
box-shadow: none;
animation: none;
}
+10 -7
View File
@@ -1,5 +1,6 @@
import type { Message, MessageContent } from '../types';
import { mergeMessageContent } from '../../message-stream.mjs';
import { stripUserAddressPrefix } from './userAddress';
const IMAGE_URL_LINE_RE = /^\[图片\d+]: (.+)$/;
@@ -76,7 +77,7 @@ export function normalizeUserMessageForApi(message: Message): Message {
content: agentText ? [{ type: 'text', text: agentText }] : [],
metadata: {
...message.metadata,
...(displayText ? { displayText } : {}),
displayText,
...(imageUrls.length ? { imageUrls } : {}),
},
};
@@ -100,7 +101,7 @@ export function buildUserMessage(
metadata: {
userVisible: true,
agentVisible: true,
...(displayText ? { displayText } : {}),
displayText,
...(imageUrls.length ? { imageUrls } : {}),
},
};
@@ -113,11 +114,13 @@ export function normalizeConversationMessages(messages: Message[]): Message[] {
}
export function getDisplayText(message: Message): string {
return (
message.metadata.displayText ??
getSystemNotificationText(message) ??
getVisibleText(message)
);
if ('displayText' in message.metadata) {
return message.metadata.displayText ?? '';
}
const systemText = getSystemNotificationText(message);
if (systemText) return systemText;
const visible = getVisibleText(message);
return message.role === 'user' ? stripUserAddressPrefix(visible) : visible;
}
export function pushMessage(messages: Message[], incoming: Message): Message[] {
+9
View File
@@ -21,3 +21,12 @@ export function buildUserAddressPrefix(
`;
}
/** Strip hidden user-identity prefix when UI falls back to agent-side content. */
export function stripUserAddressPrefix(text: string): string {
if (!text.startsWith('[用户身份]\n')) return text;
const separator = '\n\n';
const end = text.indexOf(separator);
if (end === -1) return text;
return text.slice(end + separator.length).trim();
}