feat: chat uploads, vision turn isolation, and MindSpace agent improvements

Add chat file/image upload UX, attachment proxying, vision thumbnails, and per-turn image scoping so agents only use the current upload. Extend MindSpace asset context, billing token state, OA/scenario verify scripts, and related runtime config.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-11 00:23:01 +08:00
parent e3063ea806
commit 32fb2cdeaf
51 changed files with 4588 additions and 186 deletions
+73 -3
View File
@@ -29,6 +29,7 @@ import {
} from '../api/client';
import type { AgentRun } from '../api/client';
import { appConfig } from '../config';
import { BALANCE_REFRESH_EVENT, isRechargeNotification, requestBalanceRefresh } from '../utils/balanceRefresh';
import {
clearStoredSessionId,
readStoredSessionId,
@@ -36,6 +37,7 @@ import {
} from '../utils/sessionStorage';
import type {
CapabilityMap,
ChatFileAttachment,
ChatState,
Message,
MindSpaceChatContext,
@@ -340,6 +342,7 @@ export function useTKMindChat(
const userRef = useRef(user);
const onUserUpdateRef = useRef(onUserUpdate);
const chatImageCategoryIdRef = useRef<string | null>(null);
const chatFileCategoryIdRef = useRef<string | null>(null);
useEffect(() => {
chatStateRef.current = chatState;
@@ -422,6 +425,19 @@ export function useTKMindChat(
onUserUpdateRef.current = onUserUpdate;
}, [onUserUpdate]);
const refreshUserBalance = useCallback(() => {
if (!onUserUpdateRef.current) return;
void getMe()
.then(({ user: fresh }) => onUserUpdateRef.current?.(fresh))
.catch(() => {});
}, []);
useEffect(() => {
const handleBalanceRefresh = () => refreshUserBalance();
window.addEventListener(BALANCE_REFRESH_EVENT, handleBalanceRefresh);
return () => window.removeEventListener(BALANCE_REFRESH_EVENT, handleBalanceRefresh);
}, [refreshUserBalance]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
@@ -453,6 +469,9 @@ export function useTKMindChat(
seenNotificationIdsRef.current.add(notification.id);
setActiveNotification(notification);
setNotice(`${notification.title}\n${notification.body}`.trim());
if (isRechargeNotification(notification)) {
requestBalanceRefresh();
}
};
const pullNotifications = async () => {
@@ -715,6 +734,46 @@ export function useTKMindChat(
});
}, [resolveChatImageUploadCategoryId]);
const resolveChatFileUploadCategoryId = useCallback(async () => {
if (chatFileCategoryIdRef.current) return chatFileCategoryIdRef.current;
const space = await getMindSpace();
const category =
space.categories.find((item) => item.code === 'oa') ??
space.categories[0];
if (!category) {
throw new Error('当前空间暂无可用的附件上传分类');
}
chatFileCategoryIdRef.current = category.id;
return category.id;
}, []);
const uploadChatAttachment = useCallback(async (
file: File,
onProgress?: (progress: number) => void,
options: { messageId?: string | null } = {},
) => {
if (file.type.startsWith('image/')) {
throw new Error('请使用图片上传入口发送图片');
}
const categoryId = await resolveChatFileUploadCategoryId();
const asset = await uploadMindSpaceAsset(categoryId, file, {
onProgress,
sessionId: sessionRef.current?.id ?? null,
messageId: options.messageId ?? null,
});
return {
assetId: asset.id,
downloadUrl: buildAbsoluteAssetDownloadUrl({
id: asset.id,
updatedAt: asset.updatedAt,
}),
filename: asset.displayName || asset.filename || file.name,
mimeType: asset.mimeType || file.type || 'application/octet-stream',
};
}, [resolveChatFileUploadCategoryId]);
const syncSessionMessages = useCallback(async (sessionId: string) => {
// REGRESSION GUARD: Finish may arrive before the backend persists assistant turns.
// Never blind-replace with server snapshot — merge + retry (see chat-finish-sync.mjs).
@@ -1306,7 +1365,13 @@ export function useTKMindChat(
const submit = useCallback(
async (
text: string,
options?: { mindspaceContext?: MindSpaceChatContext; messageId?: string; forceDeepReasoning?: boolean; selectedChatSkill?: string },
options?: {
mindspaceContext?: MindSpaceChatContext;
messageId?: string;
forceDeepReasoning?: boolean;
selectedChatSkill?: string;
fileAttachments?: ChatFileAttachment[];
},
imageUrls?: string[],
previewImageUrls?: string[],
) => {
@@ -1314,7 +1379,10 @@ export function useTKMindChat(
const normalizedPreviewImageUrls = (previewImageUrls ?? []).filter(
(value) => typeof value === 'string' && value.trim(),
);
if (!text.trim() && normalizedImageUrls.length === 0) return;
const normalizedFileAttachments = (options?.fileAttachments ?? []).filter(
(item) => item?.downloadUrl?.trim() && item?.filename?.trim(),
);
if (!text.trim() && normalizedImageUrls.length === 0 && normalizedFileAttachments.length === 0) return;
// Use the ref here (not the React state) so that rapid back-to-back calls in the
// same render cycle are blocked even before the state update has been re-rendered.
if (
@@ -1338,6 +1406,7 @@ export function useTKMindChat(
displayText: trimmed,
imageUrls: normalizedImageUrls,
previewImageUrls: normalizedPreviewImageUrls,
fileAttachments: normalizedFileAttachments,
});
userMessage.metadata = {
...userMessage.metadata,
@@ -1437,7 +1506,7 @@ export function useTKMindChat(
if (!activeSessionId) {
throw new Error('后台任务已提交,但未返回会话');
}
if (normalizedImageUrls.length > 0) {
if (normalizedImageUrls.length > 0 || normalizedFileAttachments.length > 0) {
void claimMindSpaceConversationUploads(activeSessionId, userMessage.id).catch(() => {});
}
if (!session?.id || session.id !== activeSessionId) {
@@ -1778,6 +1847,7 @@ export function useTKMindChat(
dismissNotice,
onSidebarOpen,
uploadChatImage,
uploadChatAttachment,
workingDir: session?.working_dir ?? user?.workspaceRoot ?? '',
balanceCents: user?.balanceCents,
totalCreditCents: user?.totalCreditCents,