feat: improve mindspace asset handling and local runtime paths
This commit is contained in:
@@ -48,7 +48,8 @@ const CHAT_PLACEHOLDER_PROMPTS = [
|
||||
type PendingChatImage = {
|
||||
id: string;
|
||||
url: string;
|
||||
local: boolean;
|
||||
previewUrl: string;
|
||||
uploading: boolean;
|
||||
};
|
||||
|
||||
function FileIcon() {
|
||||
@@ -87,7 +88,7 @@ export function ChatPanel({
|
||||
session: Session | null;
|
||||
capabilities?: CapabilityMap;
|
||||
grantedSkills?: string[];
|
||||
onSubmit: (text: string, imageUrls?: string[]) => void | Promise<void>;
|
||||
onSubmit: (text: string, imageUrls?: string[], previewImageUrls?: string[]) => void | Promise<void>;
|
||||
onUploadImage?: (file: File) => Promise<string>;
|
||||
onStop: () => void | Promise<void>;
|
||||
onApproveTool: (allow: boolean) => void | Promise<void>;
|
||||
@@ -124,7 +125,7 @@ export function ChatPanel({
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pendingImagesRef.current.forEach((item) => {
|
||||
if (item.local) URL.revokeObjectURL(item.url);
|
||||
if (item.previewUrl.startsWith('blob:')) URL.revokeObjectURL(item.previewUrl);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
@@ -183,17 +184,18 @@ export function ChatPanel({
|
||||
const imageAttachmentDisabled = !onUploadImage || busy || uploadingImage || offlineBlocked || !!pendingTool;
|
||||
|
||||
const revokePendingImage = (item: PendingChatImage) => {
|
||||
if (item.local) URL.revokeObjectURL(item.url);
|
||||
if (item.previewUrl.startsWith('blob:')) URL.revokeObjectURL(item.previewUrl);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = input.trim();
|
||||
if ((!trimmed && pendingImages.length === 0) || voiceDisabled) return;
|
||||
if (uploadingImage || pendingImages.some((item) => item.local)) {
|
||||
if (uploadingImage || pendingImages.some((item) => item.uploading)) {
|
||||
setImageError('图片仍在上传中,请稍候再发送');
|
||||
return;
|
||||
}
|
||||
const imagesToSend = pendingImages.map((item) => item.url);
|
||||
const previewImagesToSend = pendingImages.map((item) => item.previewUrl);
|
||||
const sentImages = [...pendingImages];
|
||||
|
||||
suppressVoiceUpdateRef.current = true;
|
||||
@@ -205,7 +207,7 @@ export function ChatPanel({
|
||||
setVoiceNotice(null);
|
||||
setImageError(null);
|
||||
try {
|
||||
await onSubmit(trimmed, imagesToSend);
|
||||
await onSubmit(trimmed, imagesToSend, previewImagesToSend);
|
||||
sentImages.forEach(revokePendingImage);
|
||||
setPendingImages([]);
|
||||
} catch (err) {
|
||||
@@ -245,8 +247,9 @@ export function ChatPanel({
|
||||
|
||||
const placeholders = toUpload.map((file, index) => ({
|
||||
id: `${Date.now()}-${index}-${file.name}`,
|
||||
url: URL.createObjectURL(file),
|
||||
local: true,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
url: '',
|
||||
uploading: true,
|
||||
}));
|
||||
setPendingImages((prev) => [...prev, ...placeholders]);
|
||||
|
||||
@@ -258,8 +261,7 @@ export function ChatPanel({
|
||||
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 };
|
||||
return { ...item, url: uploaded[index], uploading: false };
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
return next.filter((item) => {
|
||||
@@ -310,8 +312,9 @@ export function ChatPanel({
|
||||
|
||||
const placeholders = toUpload.map((file, index) => ({
|
||||
id: `${Date.now()}-${index}-${file.name || 'pasted-image'}`,
|
||||
url: URL.createObjectURL(file),
|
||||
local: true,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
url: '',
|
||||
uploading: true,
|
||||
}));
|
||||
setPendingImages((prev) => [...prev, ...placeholders]);
|
||||
|
||||
@@ -323,8 +326,7 @@ export function ChatPanel({
|
||||
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 };
|
||||
return { ...item, url: uploaded[index], uploading: false };
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
return next.filter((item) => {
|
||||
@@ -419,8 +421,8 @@ export function ChatPanel({
|
||||
<div className="chat-image-attachment-grid">
|
||||
{pendingImages.map((item, index) => (
|
||||
<div key={item.id} className="chat-image-attachment-item">
|
||||
<a href={item.url} target="_blank" rel="noreferrer">
|
||||
<img src={item.url} alt={`上传图片 ${index + 1}`} className="chat-image-attachment-thumb" />
|
||||
<a href={item.url || item.previewUrl} target="_blank" rel="noreferrer">
|
||||
<img src={item.previewUrl} alt={`上传图片 ${index + 1}`} className="chat-image-attachment-thumb" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -297,7 +297,9 @@ export function ChatView({
|
||||
session={session}
|
||||
capabilities={capabilities}
|
||||
grantedSkills={grantedSkills}
|
||||
onSubmit={(text, imageUrls) => void submit(text, undefined, imageUrls)}
|
||||
onSubmit={(text, imageUrls, previewImageUrls) =>
|
||||
void submit(text, undefined, imageUrls, previewImageUrls)
|
||||
}
|
||||
onUploadImage={uploadChatImage}
|
||||
onStop={stop}
|
||||
onApproveTool={approveTool}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useUserAvatar } from '../hooks/useUserAvatar';
|
||||
import type { Message } from '../types';
|
||||
import { getDisplayText, getImageUrls, getThinking } from '../utils/message';
|
||||
import { getDisplayText, getImageUrls, getRenderableImageUrls, getThinking } from '../utils/message';
|
||||
import { getMessageSaveActions } from '../utils/messageSave';
|
||||
import { renderMarkdown } from '../utils/markdown';
|
||||
import { filterText } from '../utils/wordFilter';
|
||||
@@ -189,6 +189,7 @@ function MessageRow({
|
||||
const thinking = getThinking(message);
|
||||
const isUser = message.role === 'user';
|
||||
const imageUrls = getImageUrls(message);
|
||||
const renderableImageUrls = getRenderableImageUrls(message);
|
||||
const copyText = [thinking ? `【思考】\n${thinking}` : '', text].filter(Boolean).join('\n\n');
|
||||
|
||||
if (!text && !thinking && message.role === 'assistant') {
|
||||
@@ -221,16 +222,16 @@ function MessageRow({
|
||||
)}
|
||||
{imageUrls.length > 0 && (
|
||||
<div className="msg-image-gallery">
|
||||
{imageUrls.map((imageUrl, index) => (
|
||||
{renderableImageUrls.map((image, index) => (
|
||||
<a
|
||||
key={`${imageUrl}-${index}`}
|
||||
href={imageUrl}
|
||||
key={`${image.href}-${index}`}
|
||||
href={image.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="msg-image-link"
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
src={image.src}
|
||||
alt={`图片 ${index + 1}`}
|
||||
className="msg-image-thumb"
|
||||
loading="lazy"
|
||||
|
||||
@@ -182,7 +182,12 @@ export function PageSaveDialog({
|
||||
setSaveNotice(`已保存到${CATEGORY_LABELS[result.categoryCode]}`);
|
||||
onSaved({ kind: 'asset', categoryCode: result.categoryCode });
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : '保存失败');
|
||||
const message = err instanceof Error ? err.message : '保存失败';
|
||||
if (message.includes('用户不存在') || message.includes('未授权') || message.includes('登录已过期')) {
|
||||
setSaveError('当前账号未登录或已失效,请重新登录后再保存');
|
||||
} else {
|
||||
setSaveError(message);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export function PageSavePreviewPanel({
|
||||
title="页面预览"
|
||||
src={pagePreviewUrl}
|
||||
className="page-save-mini-page-frame"
|
||||
sandbox="allow-scripts"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
scrolling="yes"
|
||||
onError={() => setPreviewFailed(true)}
|
||||
/>
|
||||
|
||||
@@ -123,10 +123,10 @@ export function SpaceChatPanel({
|
||||
chatState={chatState}
|
||||
pendingTool={pendingTool}
|
||||
session={session}
|
||||
onSubmit={(text, imageUrls) =>
|
||||
onSubmit={(text, imageUrls, previewImageUrls) =>
|
||||
chatBridge
|
||||
? void submit(text, context, imageUrls)
|
||||
: void submit(text, { mindspaceContext: context }, imageUrls)
|
||||
? void submit(text, context, imageUrls, previewImageUrls)
|
||||
: void submit(text, { mindspaceContext: context }, imageUrls, previewImageUrls)
|
||||
}
|
||||
onUploadImage={uploadChatImage}
|
||||
onStop={stop}
|
||||
|
||||
@@ -276,9 +276,17 @@ export function usePageEditSubChat({
|
||||
);
|
||||
|
||||
const submit = useCallback(
|
||||
async (text: string, context: MindSpaceChatContext, imageUrls?: string[]) => {
|
||||
async (
|
||||
text: string,
|
||||
context: MindSpaceChatContext,
|
||||
imageUrls?: string[],
|
||||
previewImageUrls?: string[],
|
||||
) => {
|
||||
const currentSession = sessionRef.current;
|
||||
const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
|
||||
const normalizedPreviewImageUrls = (previewImageUrls ?? []).filter(
|
||||
(value) => typeof value === 'string' && value.trim(),
|
||||
);
|
||||
if (!currentSession || (!text.trim() && normalizedImageUrls.length === 0)) return;
|
||||
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
|
||||
|
||||
@@ -290,6 +298,7 @@ export function usePageEditSubChat({
|
||||
agentText: `${agentPrefix}${trimmed}`,
|
||||
displayText: trimmed,
|
||||
imageUrls: normalizedImageUrls,
|
||||
previewImageUrls: normalizedPreviewImageUrls,
|
||||
});
|
||||
const requestId = crypto.randomUUID();
|
||||
activeRequestId.current = requestId;
|
||||
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
normalizeConversationMessages,
|
||||
getDisplayText,
|
||||
getToolConfirmation,
|
||||
getVisibleText,
|
||||
isCreditsExhaustedNotification,
|
||||
isRelayServerErrorMessage,
|
||||
pushMessage,
|
||||
@@ -69,9 +68,15 @@ import {
|
||||
} from '../utils/sessions';
|
||||
|
||||
const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用';
|
||||
const REPLY_RECOVERY_SYNC_DELAYS_MS = [1500, 5000, 12000];
|
||||
|
||||
export { INSUFFICIENT_BALANCE_NOTICE };
|
||||
|
||||
function isAmbiguousReplySubmitError(err: unknown) {
|
||||
if (!(err instanceof ApiError)) return true;
|
||||
return err.status === 0 || err.status === 409 || err.status >= 500;
|
||||
}
|
||||
|
||||
export function useTKMindChat(
|
||||
user?: PortalUser | null,
|
||||
onUserUpdate?: (user: PortalUser) => void,
|
||||
@@ -666,6 +671,7 @@ export function useTKMindChat(
|
||||
|
||||
const previousSessionId = readStoredSessionId(userRef.current?.id);
|
||||
let staleSession = false;
|
||||
let restorableSessionId: string | null = null;
|
||||
|
||||
if (previousSessionId) {
|
||||
try {
|
||||
@@ -676,8 +682,10 @@ export function useTKMindChat(
|
||||
} catch {
|
||||
// Best-effort cleanup: a failed delete should not block the next fresh chat.
|
||||
}
|
||||
clearStoredSessionId(userRef.current?.id);
|
||||
} else {
|
||||
restorableSessionId = previousSessionId;
|
||||
}
|
||||
clearStoredSessionId(userRef.current?.id);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) {
|
||||
clearStoredSessionId(userRef.current?.id);
|
||||
@@ -693,7 +701,12 @@ export function useTKMindChat(
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
setChatState('idle');
|
||||
if (restorableSessionId) {
|
||||
await connectSessionRef.current(restorableSessionId, { showLoading: false });
|
||||
if (cancelled) return;
|
||||
} else {
|
||||
setChatState('idle');
|
||||
}
|
||||
void refreshSessions();
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
@@ -756,8 +769,12 @@ export function useTKMindChat(
|
||||
text: string,
|
||||
options?: { mindspaceContext?: MindSpaceChatContext },
|
||||
imageUrls?: string[],
|
||||
previewImageUrls?: string[],
|
||||
) => {
|
||||
const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
|
||||
const normalizedPreviewImageUrls = (previewImageUrls ?? []).filter(
|
||||
(value) => typeof value === 'string' && value.trim(),
|
||||
);
|
||||
if (!text.trim() && normalizedImageUrls.length === 0) return;
|
||||
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
|
||||
|
||||
@@ -772,6 +789,7 @@ export function useTKMindChat(
|
||||
agentText: `${agentPrefix}${trimmed}`,
|
||||
displayText: trimmed,
|
||||
imageUrls: normalizedImageUrls,
|
||||
previewImageUrls: normalizedPreviewImageUrls,
|
||||
});
|
||||
const requestId = crypto.randomUUID();
|
||||
activeRequestId.current = requestId;
|
||||
@@ -811,6 +829,19 @@ export function useTKMindChat(
|
||||
try {
|
||||
await sendReply(activeSessionId, requestId, userMessage);
|
||||
} catch (err) {
|
||||
if (isAmbiguousReplySubmitError(err)) {
|
||||
subscribeToSession(activeSessionId);
|
||||
setChatState('streaming');
|
||||
setError(null);
|
||||
for (const delay of REPLY_RECOVERY_SYNC_DELAYS_MS) {
|
||||
window.setTimeout(() => {
|
||||
if (sessionRef.current?.id === activeSessionId) {
|
||||
void syncSessionMessages(activeSessionId);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (session) setSessions((prev) => touchSession(prev, activeSessionId!, -1));
|
||||
if (err instanceof ApiError && err.status === 402) {
|
||||
notifyInsufficientBalance();
|
||||
@@ -821,7 +852,17 @@ export function useTKMindChat(
|
||||
activeRequestId.current = null;
|
||||
}
|
||||
},
|
||||
[notifyInsufficientBalance, session, chatState, grantedSkills, subscribeToSession, ensureProvider, loadProjectMemory, refreshSessions],
|
||||
[
|
||||
notifyInsufficientBalance,
|
||||
session,
|
||||
chatState,
|
||||
grantedSkills,
|
||||
subscribeToSession,
|
||||
ensureProvider,
|
||||
loadProjectMemory,
|
||||
refreshSessions,
|
||||
syncSessionMessages,
|
||||
],
|
||||
);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ export type Message = {
|
||||
agentVisible: boolean;
|
||||
displayText?: string;
|
||||
imageUrls?: string[];
|
||||
previewImageUrls?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+41
-5
@@ -4,10 +4,31 @@ import { stripUserAddressPrefix } from './userAddress';
|
||||
|
||||
const IMAGE_URL_LINE_RE = /^\[图片\d+]: (.+)$/;
|
||||
|
||||
function normalizeImageUrl(url: string): string {
|
||||
const value = String(url ?? '').trim();
|
||||
if (!value) return '';
|
||||
if (!/\/api\/mindspace\/v1\/assets\/[^/]+\/download\b/.test(value)) return value;
|
||||
|
||||
try {
|
||||
const parsed = value.startsWith('http://') || value.startsWith('https://')
|
||||
? new URL(value)
|
||||
: new URL(value, window.location.origin);
|
||||
if (parsed.searchParams.get('inline') === '1' && !parsed.searchParams.has('viewer')) {
|
||||
parsed.searchParams.set('viewer', '0');
|
||||
}
|
||||
return parsed.origin === window.location.origin
|
||||
? `${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
: parsed.toString();
|
||||
} catch {
|
||||
const separator = value.includes('?') ? '&' : '?';
|
||||
return /(?:\?|&)viewer=/.test(value) ? value : `${value}${separator}viewer=0`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatImageUrlsForAgentText(urls: string[]): string {
|
||||
return urls
|
||||
.filter((url) => typeof url === 'string' && url.trim())
|
||||
.map((url, index) => `[图片${index + 1}]: ${url.trim()}`)
|
||||
.map((url, index) => `[图片${index + 1}]: ${normalizeImageUrl(url)}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
@@ -85,10 +106,13 @@ export function normalizeUserMessageForApi(message: Message): Message {
|
||||
|
||||
export function buildUserMessage(
|
||||
text: string,
|
||||
options?: { agentText?: string; displayText?: string; imageUrls?: string[] },
|
||||
options?: { agentText?: string; displayText?: string; imageUrls?: string[]; previewImageUrls?: string[] },
|
||||
): Message {
|
||||
const displayText = options?.displayText ?? text;
|
||||
const imageUrls = (options?.imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
|
||||
const previewImageUrls = (options?.previewImageUrls ?? []).filter(
|
||||
(value) => typeof value === 'string' && value.trim(),
|
||||
);
|
||||
const baseAgentText = options?.agentText ?? text;
|
||||
const imageText = formatImageUrlsForAgentText(imageUrls);
|
||||
const agentText = [baseAgentText.trim(), imageText].filter(Boolean).join('\n\n');
|
||||
@@ -103,6 +127,7 @@ export function buildUserMessage(
|
||||
agentVisible: true,
|
||||
displayText,
|
||||
...(imageUrls.length ? { imageUrls } : {}),
|
||||
...(previewImageUrls.length ? { previewImageUrls } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -169,16 +194,27 @@ export function getImageUrls(message: Message): string[] {
|
||||
const fromMetadata = message.metadata.imageUrls?.filter(
|
||||
(url) => typeof url === 'string' && url.trim(),
|
||||
);
|
||||
if (fromMetadata?.length) return fromMetadata;
|
||||
if (fromMetadata?.length) return fromMetadata.map(normalizeImageUrl);
|
||||
|
||||
const fromContent = readLegacyImageUrls(message.content);
|
||||
if (fromContent.length) return fromContent;
|
||||
if (fromContent.length) return fromContent.map(normalizeImageUrl);
|
||||
|
||||
const text = message.content
|
||||
.filter((item): item is Extract<MessageContent, { type: 'text' }> => item.type === 'text')
|
||||
.map((item) => item.text)
|
||||
.join('\n');
|
||||
return parseImageUrlsFromText(text);
|
||||
return parseImageUrlsFromText(text).map(normalizeImageUrl);
|
||||
}
|
||||
|
||||
export function getRenderableImageUrls(message: Message): Array<{ src: string; href: string }> {
|
||||
const imageUrls = getImageUrls(message);
|
||||
const previewImageUrls = message.metadata.previewImageUrls?.filter(
|
||||
(url) => typeof url === 'string' && url.trim(),
|
||||
) ?? [];
|
||||
return imageUrls.map((href, index) => ({
|
||||
href,
|
||||
src: previewImageUrls[index] || href,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getSystemNotificationText(message: Message): string | null {
|
||||
|
||||
@@ -58,7 +58,7 @@ export function isImageAsset(asset: MindSpaceAsset) {
|
||||
|
||||
export function buildAssetImageUrl(asset: Pick<MindSpaceAsset, 'id' | 'updatedAt' | 'publicUrl'>) {
|
||||
if (asset.publicUrl) return asset.publicUrl;
|
||||
return `/api/mindspace/v1/assets/${asset.id}/download?inline=1&v=${asset.updatedAt}`;
|
||||
return `/api/mindspace/v1/assets/${asset.id}/download?inline=1&viewer=0&v=${asset.updatedAt}`;
|
||||
}
|
||||
|
||||
export function buildAbsoluteAssetImageUrl(
|
||||
|
||||
Reference in New Issue
Block a user