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:
+6
-1
@@ -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/*"
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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
@@ -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[] {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user