diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index df6328e..355cf9f 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -164,7 +164,9 @@ "collapseAction": "Collapse execution graph" }, "imageGeneration": { - "generating": "Generating image, please wait…" + "generating": "Generating image, please wait…", + "previewLoading": "Loading image preview…", + "previewUnavailable": "Image preview is temporarily unavailable" }, "composer": { "attachFiles": "Attach files", diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index 9e49804..68dcb20 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -164,7 +164,9 @@ "collapseAction": "実行グラフを折りたたむ" }, "imageGeneration": { - "generating": "画像を生成しています。少々お待ちください…" + "generating": "画像を生成しています。少々お待ちください…", + "previewLoading": "画像プレビューを読み込んでいます…", + "previewUnavailable": "画像プレビューを一時的に表示できません" }, "composer": { "attachFiles": "ファイルを添付", diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json index 42bdfff..2056ad0 100644 --- a/src/i18n/locales/ru/chat.json +++ b/src/i18n/locales/ru/chat.json @@ -164,7 +164,9 @@ "collapseAction": "Свернуть граф выполнения" }, "imageGeneration": { - "generating": "Изображение генерируется, подождите…" + "generating": "Изображение генерируется, подождите…", + "previewLoading": "Загрузка предпросмотра изображения…", + "previewUnavailable": "Предпросмотр изображения временно недоступен" }, "composer": { "attachFiles": "Прикрепить файлы", diff --git a/src/i18n/locales/zh/chat.json b/src/i18n/locales/zh/chat.json index 4a77c2d..b0e1678 100644 --- a/src/i18n/locales/zh/chat.json +++ b/src/i18n/locales/zh/chat.json @@ -164,7 +164,9 @@ "collapseAction": "收起执行关系图" }, "imageGeneration": { - "generating": "图片生成中,请稍候…" + "generating": "图片生成中,请稍候…", + "previewLoading": "正在载入图片预览…", + "previewUnavailable": "图片预览暂时不可用" }, "composer": { "attachFiles": "添加文件", diff --git a/src/pages/Chat/ChatMessage.tsx b/src/pages/Chat/ChatMessage.tsx index 3276d5e..b8fb56a 100644 --- a/src/pages/Chat/ChatMessage.tsx +++ b/src/pages/Chat/ChatMessage.tsx @@ -7,6 +7,7 @@ */ import { useState, useCallback, useEffect, memo } from 'react'; import { Sparkles, Copy, Check, Wrench, FileText, Film, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; @@ -407,12 +408,7 @@ export const ChatMessage = memo(function ChatMessage({ onPreview={() => setLightboxImg({ src: file.preview!, fileName: file.fileName, filePath: file.filePath, mimeType: file.mimeType })} /> ) : ( -
- -
+ ); } // Non-image files → file card @@ -469,11 +465,7 @@ export const ChatMessage = memo(function ChatMessage({ ); } if (isImage && !file.preview) { - return ( -
- -
- ); + return ; } return ; })} @@ -758,6 +750,32 @@ function FileCard({ file, onOpen }: { file: AttachedFileMeta; onOpen?: (file: At ); } +function ImagePreviewPlaceholder({ file }: { file: AttachedFileMeta }) { + const { t } = useTranslation('chat'); + const unavailable = file.previewStatus === 'unavailable'; + const label = unavailable + ? t('imageGeneration.previewUnavailable') + : t('imageGeneration.previewLoading'); + + return ( +
+ {unavailable ? ( + + ) : ( + + )} + {label} +
+ ); +} + // ── Image Thumbnail (user bubble — square crop with zoom hint) ── function ImageThumbnail({ diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index 7471c7f..9a2c85f 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -325,6 +325,10 @@ export function Chat() { const hasRunningStreamToolStatus = streamingTools.some((tool) => tool.status === 'running'); const shouldRenderStreaming = sending && (hasStreamText || hasStreamTools || hasStreamImages || hasStreamToolStatus); const hasAnyStreamContent = hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus; + const hasHistoryCompletionBlockingStream = hasStreamText + || hasStreamImages + || hasRunningStreamToolStatus + || streamTools.length > 0; const isEmpty = messages.length === 0 && !sending; const showScrollToLatest = !isEmpty && !isAtBottom; @@ -417,10 +421,12 @@ export function Chat() { // History may already contain the final answer while lifecycle flags are // still armed (missing Gateway terminal phase, blocked chat.send RPC, etc.). // Treat the run as closed for graph/input UI when the transcript is done - // and nothing is actively streaming. Require prior tool activity so an early - // narration-only history snapshot does not collapse the graph mid-chain. + // and no user-visible reply/tool stream is active. Require prior tool activity + // so an early narration-only history snapshot does not collapse the graph + // mid-chain. Thinking-only stale stream content should not keep image + // generation runs open after history already contains the final media. const runCompletedInHistory = hasFinalReply - && !hasAnyStreamContent + && !hasHistoryCompletionBlockingStream && (hasToolActivity || !sending); const isLatestOpenRun = isLatestRunSegment && !runError @@ -635,7 +641,7 @@ export function Chat() { streamingReplyText, suppressThinking, }]; - }, [messages, subagentCompletionInfos, currentSessionKey, streamingMessage, streamingTools, pendingFinal, sending, hasAnyStreamContent, hasStreamText, hasStreamImages, streamText, streamTools.length, hasRunningStreamToolStatus, childTranscripts, currentAgentId, agents, sessionLabels, graphStepCache, runError, isRunTrigger]); + }, [messages, subagentCompletionInfos, currentSessionKey, streamingMessage, streamingTools, pendingFinal, sending, hasAnyStreamContent, hasStreamText, hasStreamImages, streamText, streamTools.length, hasRunningStreamToolStatus, hasHistoryCompletionBlockingStream, childTranscripts, currentAgentId, agents, sessionLabels, graphStepCache, runError, isRunTrigger]); const hasActiveExecutionGraph = userRunCards.some((card) => card.active); let latestRunSegmentCompletion = { hasFinalReply: false, hasToolActivity: false }; let pendingImageGeneration = false; @@ -656,7 +662,7 @@ export function Chat() { break; } const runSettledInHistory = latestRunSegmentCompletion.hasFinalReply - && !hasAnyStreamContent + && !hasHistoryCompletionBlockingStream && (latestRunSegmentCompletion.hasToolActivity || !sending); const inputRunActive = (sending || hasActiveExecutionGraph) && !runSettledInHistory; const replyTextOverrides = useMemo(() => { diff --git a/src/pages/Chat/task-visualization.ts b/src/pages/Chat/task-visualization.ts index 664e7ea..98b0f89 100644 --- a/src/pages/Chat/task-visualization.ts +++ b/src/pages/Chat/task-visualization.ts @@ -1,4 +1,5 @@ import { + extractImages, extractText, extractTextSegments, extractToolUse, @@ -46,6 +47,7 @@ export function findReplyMessageIndex(messages: RawMessage[], hasStreamingReply: const message = messages[idx]; if (!message || message.role !== 'assistant') continue; const replyText = extractText(message).trim(); + if (messageHasUserVisibleImage(message)) return idx; if (replyText.length === 0 || isInternalAssistantReplyText(replyText)) continue; if (isGeneratingStatusNarration(replyText)) continue; return idx; @@ -53,6 +55,13 @@ export function findReplyMessageIndex(messages: RawMessage[], hasStreamingReply: return -1; } +function messageHasUserVisibleImage(message: RawMessage): boolean { + if ((message._attachedFiles ?? []).some((file) => file.mimeType.startsWith('image/'))) { + return true; + } + return extractImages(message).length > 0; +} + /** * When true, assistant history in the run segment should be folded into the * execution graph because the live answer is (or will be) shown via streaming. @@ -159,6 +168,7 @@ export function segmentHasFinalReply(segmentMessages: RawMessage[]): boolean { return segmentMessages.some((message, index) => { if (index <= lastToolUseOffset) return false; if (message.role !== 'assistant') return false; + if (messageHasUserVisibleImage(message)) return true; const replyText = extractText(message).trim(); if (replyText.length === 0 || isInternalAssistantReplyText(replyText)) return false; if (isGeneratingStatusNarration(replyText)) return false; diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 0d0812a..8a39039 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -1327,16 +1327,15 @@ function enrichWithCachedImages(messages: RawMessage[]): RawMessage[] { }); } -/** - * Async: load missing previews from disk via IPC for messages that have - * _attachedFiles with null previews. Updates messages in-place and triggers re-render. - * Handles both [media attached: ...] patterns and raw filePath entries. - */ -async function loadMissingPreviews(messages: RawMessage[]): Promise { - // See helpers.ts loadMissingPreviews for the canonical comment block — - // this monolithic copy is kept in sync so legacy chat.ts callers also - // resolve Gateway-injected outgoing media URLs into local previews. - type PreviewRef = { filePath?: string; gatewayUrl?: string; mimeType: string }; +type PreviewRef = { filePath?: string; gatewayUrl?: string; mimeType: string }; + +const IMAGE_PREVIEW_RETRY_DELAYS_MS = [300, 900, 1800]; + +function waitForPreviewRetry(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function collectMissingPreviewRefs(messages: RawMessage[]): PreviewRef[] { const needPreview: PreviewRef[] = []; const seenKeys = new Set(); @@ -1348,7 +1347,7 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise { const key = file.filePath || file.gatewayUrl; if (!key || seenKeys.has(key)) continue; const needsLoad = file.mimeType.startsWith('image/') - ? !file.preview + ? !file.preview && file.previewStatus !== 'unavailable' : file.fileSize === 0; if (!needsLoad) continue; seenKeys.add(key); @@ -1367,7 +1366,9 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise { const file = msg._attachedFiles[i]; const ref = refs[i]; if (!file || !ref || seenKeys.has(ref.filePath)) continue; - const needsLoad = ref.mimeType.startsWith('image/') ? !file.preview : file.fileSize === 0; + const needsLoad = ref.mimeType.startsWith('image/') + ? !file.preview && file.previewStatus !== 'unavailable' + : file.fileSize === 0; if (needsLoad) { seenKeys.add(ref.filePath); needPreview.push({ filePath: ref.filePath, mimeType: ref.mimeType }); @@ -1376,61 +1377,115 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise { } } - if (needPreview.length === 0) { - return false; - } + return needPreview; +} - try { - const thumbnails = await hostApiFetch>( - '/api/files/thumbnails', - { - method: 'POST', - body: JSON.stringify({ paths: needPreview }), - }, - ); +function applyPreviewResults( + messages: RawMessage[], + thumbnails: Record, +): boolean { + let updated = false; + for (const msg of messages) { + if (!msg._attachedFiles) continue; - let updated = false; - for (const msg of messages) { - if (!msg._attachedFiles) continue; + // Update files that have filePath OR gatewayUrl + for (const file of msg._attachedFiles) { + const key = file.filePath || file.gatewayUrl; + if (!key) continue; + const thumb = thumbnails[key]; + if (thumb && (thumb.preview || thumb.fileSize)) { + if (thumb.preview) file.preview = thumb.preview; + if (thumb.fileSize) file.fileSize = thumb.fileSize; + delete file.previewStatus; + if (file.filePath) { + _imageCache.set(file.filePath, { ...file }); + } + updated = true; + } + } - // Update files that have filePath OR gatewayUrl - for (const file of msg._attachedFiles) { - const key = file.filePath || file.gatewayUrl; - if (!key) continue; - const thumb = thumbnails[key]; + // Legacy: update by index for [media attached: ...] refs + if (msg.role === 'user') { + const text = getMessageText(msg.content); + const refs = extractMediaRefs(text); + for (let i = 0; i < refs.length; i++) { + const file = msg._attachedFiles[i]; + const ref = refs[i]; + if (!file || !ref || file.filePath) continue; // skip if already handled via filePath + const thumb = thumbnails[ref.filePath]; if (thumb && (thumb.preview || thumb.fileSize)) { if (thumb.preview) file.preview = thumb.preview; if (thumb.fileSize) file.fileSize = thumb.fileSize; - if (file.filePath) { - _imageCache.set(file.filePath, { ...file }); - } + delete file.previewStatus; + _imageCache.set(ref.filePath, { ...file }); updated = true; } } - - // Legacy: update by index for [media attached: ...] refs - if (msg.role === 'user') { - const text = getMessageText(msg.content); - const refs = extractMediaRefs(text); - for (let i = 0; i < refs.length; i++) { - const file = msg._attachedFiles[i]; - const ref = refs[i]; - if (!file || !ref || file.filePath) continue; // skip if already handled via filePath - const thumb = thumbnails[ref.filePath]; - if (thumb && (thumb.preview || thumb.fileSize)) { - if (thumb.preview) file.preview = thumb.preview; - if (thumb.fileSize) file.fileSize = thumb.fileSize; - _imageCache.set(ref.filePath, { ...file }); - updated = true; - } - } - } } - if (updated) saveImageCache(_imageCache); - return updated; - } catch (err) { - console.warn('[loadMissingPreviews] Failed:', err); - return false; + } + + if (updated) saveImageCache(_imageCache); + return updated; +} + +function markMissingImagePreviewsUnavailable(messages: RawMessage[]): boolean { + let updated = false; + for (const msg of messages) { + if (!msg._attachedFiles) continue; + for (const file of msg._attachedFiles) { + if (!file.mimeType.startsWith('image/')) continue; + if (file.preview || file.previewStatus === 'unavailable') continue; + if (!file.filePath && !file.gatewayUrl) continue; + file.previewStatus = 'unavailable'; + updated = true; + } + } + return updated; +} + +/** + * Async: load missing previews from disk via IPC for messages that have + * _attachedFiles with null previews. Updates messages in-place and triggers re-render. + * Handles both [media attached: ...] patterns and raw filePath entries. + */ +async function loadMissingPreviews(messages: RawMessage[]): Promise { + // See helpers.ts loadMissingPreviews for the canonical comment block — + // this monolithic copy is kept in sync so legacy chat.ts callers also + // resolve Gateway-injected outgoing media URLs into local previews. + let updatedAny = false; + let attempt = 0; + + while (true) { + const needPreview = collectMissingPreviewRefs(messages); + if (needPreview.length === 0) return updatedAny; + if (attempt > 0) { + const delayMs = IMAGE_PREVIEW_RETRY_DELAYS_MS[attempt - 1]; + if (delayMs) await waitForPreviewRetry(delayMs); + } + + try { + const thumbnails = await hostApiFetch>( + '/api/files/thumbnails', + { + method: 'POST', + body: JSON.stringify({ paths: needPreview }), + }, + ); + if (applyPreviewResults(messages, thumbnails)) { + updatedAny = true; + } + } catch (err) { + console.warn('[loadMissingPreviews] Failed:', err); + return updatedAny; + } + + if (!collectMissingPreviewRefs(messages).some((ref) => ref.mimeType.startsWith('image/'))) { + return updatedAny; + } + if (attempt >= IMAGE_PREVIEW_RETRY_DELAYS_MS.length) { + return markMissingImagePreviewsUnavailable(messages) || updatedAny; + } + attempt += 1; } } diff --git a/src/stores/chat/helpers.ts b/src/stores/chat/helpers.ts index 296b4a3..f6d5fce 100644 --- a/src/stores/chat/helpers.ts +++ b/src/stores/chat/helpers.ts @@ -1096,20 +1096,22 @@ function enrichWithCachedImages(messages: RawMessage[]): RawMessage[] { }); } -/** - * Async: load missing previews from disk via IPC for messages that have - * _attachedFiles with null previews. Updates messages in-place and triggers re-render. - * Handles both [media attached: ...] patterns and raw filePath entries. - */ -async function loadMissingPreviews(messages: RawMessage[]): Promise { - // Collect all image refs that need previews. The IPC handler accepts: - // - { filePath, mimeType } — local on-disk files - // - { gatewayUrl, mimeType } — Gateway-injected outgoing media; the - // handler resolves the URL to a local file - // via `~/.openclaw/media/outgoing/records/`. - // We use `filePath || gatewayUrl` as the dedupe / lookup key on the way - // back; a file always carries at most one of the two. - type PreviewRef = { filePath?: string; gatewayUrl?: string; mimeType: string }; +type PreviewRef = { filePath?: string; gatewayUrl?: string; mimeType: string }; + +const IMAGE_PREVIEW_RETRY_DELAYS_MS = [300, 900, 1800]; + +function waitForPreviewRetry(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Collect all image refs that need previews. The IPC handler accepts: +// - { filePath, mimeType } — local on-disk files +// - { gatewayUrl, mimeType } — Gateway-injected outgoing media; the +// handler resolves the URL to a local file +// via `~/.openclaw/media/outgoing/records/`. +// We use `filePath || gatewayUrl` as the dedupe / lookup key on the way +// back; a file always carries at most one of the two. +function collectMissingPreviewRefs(messages: RawMessage[]): PreviewRef[] { const needPreview: PreviewRef[] = []; const seenKeys = new Set(); @@ -1122,7 +1124,7 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise { if (!key || seenKeys.has(key)) continue; // Images: need preview. Non-images: need file size (for FileCard display). const needsLoad = file.mimeType.startsWith('image/') - ? !file.preview + ? !file.preview && file.previewStatus !== 'unavailable' : file.fileSize === 0; if (!needsLoad) continue; seenKeys.add(key); @@ -1141,7 +1143,9 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise { const file = msg._attachedFiles[i]; const ref = refs[i]; if (!file || !ref || seenKeys.has(ref.filePath)) continue; - const needsLoad = ref.mimeType.startsWith('image/') ? !file.preview : file.fileSize === 0; + const needsLoad = ref.mimeType.startsWith('image/') + ? !file.preview && file.previewStatus !== 'unavailable' + : file.fileSize === 0; if (needsLoad) { seenKeys.add(ref.filePath); needPreview.push({ filePath: ref.filePath, mimeType: ref.mimeType }); @@ -1150,59 +1154,113 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise { } } - if (needPreview.length === 0) return false; + return needPreview; +} - try { - const thumbnails = await invokeIpc( - 'media:getThumbnails', - needPreview, - ) as Record; +function applyPreviewResults( + messages: RawMessage[], + thumbnails: Record, +): boolean { + let updated = false; - let updated = false; - for (const msg of messages) { - if (!msg._attachedFiles) continue; + for (const msg of messages) { + if (!msg._attachedFiles) continue; - // Update files that have filePath OR gatewayUrl - for (const file of msg._attachedFiles) { - const key = file.filePath || file.gatewayUrl; - if (!key) continue; - const thumb = thumbnails[key]; + // Update files that have filePath OR gatewayUrl + for (const file of msg._attachedFiles) { + const key = file.filePath || file.gatewayUrl; + if (!key) continue; + const thumb = thumbnails[key]; + if (thumb && (thumb.preview || thumb.fileSize)) { + if (thumb.preview) file.preview = thumb.preview; + if (thumb.fileSize) file.fileSize = thumb.fileSize; + delete file.previewStatus; + // Only persist local-path entries to the localStorage cache. + // Gateway outgoing URLs are tied to a specific session/attachment + // id and can be stale across runs, so caching is harmful. + if (file.filePath) { + _imageCache.set(file.filePath, { ...file }); + } + updated = true; + } + } + + // Legacy: update by index for [media attached: ...] refs + if (msg.role === 'user') { + const text = getMessageText(msg.content); + const refs = extractMediaRefs(text); + for (let i = 0; i < refs.length; i++) { + const file = msg._attachedFiles[i]; + const ref = refs[i]; + if (!file || !ref || file.filePath) continue; // skip if already handled via filePath + const thumb = thumbnails[ref.filePath]; if (thumb && (thumb.preview || thumb.fileSize)) { if (thumb.preview) file.preview = thumb.preview; if (thumb.fileSize) file.fileSize = thumb.fileSize; - // Only persist local-path entries to the localStorage cache. - // Gateway outgoing URLs are tied to a specific session/attachment - // id and can be stale across runs, so caching is harmful. - if (file.filePath) { - _imageCache.set(file.filePath, { ...file }); - } + delete file.previewStatus; + _imageCache.set(ref.filePath, { ...file }); updated = true; } } - - // Legacy: update by index for [media attached: ...] refs - if (msg.role === 'user') { - const text = getMessageText(msg.content); - const refs = extractMediaRefs(text); - for (let i = 0; i < refs.length; i++) { - const file = msg._attachedFiles[i]; - const ref = refs[i]; - if (!file || !ref || file.filePath) continue; // skip if already handled via filePath - const thumb = thumbnails[ref.filePath]; - if (thumb && (thumb.preview || thumb.fileSize)) { - if (thumb.preview) file.preview = thumb.preview; - if (thumb.fileSize) file.fileSize = thumb.fileSize; - _imageCache.set(ref.filePath, { ...file }); - updated = true; - } - } - } } - if (updated) saveImageCache(_imageCache); - return updated; - } catch (err) { - console.warn('[loadMissingPreviews] Failed:', err); - return false; + } + + if (updated) saveImageCache(_imageCache); + return updated; +} + +function markMissingImagePreviewsUnavailable(messages: RawMessage[]): boolean { + let updated = false; + for (const msg of messages) { + if (!msg._attachedFiles) continue; + for (const file of msg._attachedFiles) { + if (!file.mimeType.startsWith('image/')) continue; + if (file.preview || file.previewStatus === 'unavailable') continue; + if (!file.filePath && !file.gatewayUrl) continue; + file.previewStatus = 'unavailable'; + updated = true; + } + } + return updated; +} + +/** + * Async: load missing previews from disk via IPC for messages that have + * _attachedFiles with null previews. Updates messages in-place and triggers re-render. + * Handles both [media attached: ...] patterns and raw filePath entries. + */ +async function loadMissingPreviews(messages: RawMessage[]): Promise { + let updatedAny = false; + let attempt = 0; + + while (true) { + const needPreview = collectMissingPreviewRefs(messages); + if (needPreview.length === 0) return updatedAny; + if (attempt > 0) { + const delayMs = IMAGE_PREVIEW_RETRY_DELAYS_MS[attempt - 1]; + if (delayMs) await waitForPreviewRetry(delayMs); + } + + try { + const thumbnails = await invokeIpc( + 'media:getThumbnails', + needPreview, + ) as Record; + if (applyPreviewResults(messages, thumbnails)) { + updatedAny = true; + } + } catch (err) { + console.warn('[loadMissingPreviews] Failed:', err); + return updatedAny; + } + + if (!collectMissingPreviewRefs(messages).some((ref) => ref.mimeType.startsWith('image/'))) { + return updatedAny; + } + if (attempt >= IMAGE_PREVIEW_RETRY_DELAYS_MS.length) { + return markMissingImagePreviewsUnavailable(messages) || updatedAny; + } + attempt += 1; } } diff --git a/src/stores/chat/types.ts b/src/stores/chat/types.ts index 6bee019..52a145c 100644 --- a/src/stores/chat/types.ts +++ b/src/stores/chat/types.ts @@ -4,6 +4,7 @@ export interface AttachedFileMeta { mimeType: string; fileSize: number; preview: string | null; + previewStatus?: 'unavailable'; filePath?: string; source?: 'user-upload' | 'tool-result' | 'message-ref' | 'gateway-media'; /** diff --git a/tests/e2e/chat-run-state-events.spec.ts b/tests/e2e/chat-run-state-events.spec.ts index d45d763..ae5349d 100644 --- a/tests/e2e/chat-run-state-events.spec.ts +++ b/tests/e2e/chat-run-state-events.spec.ts @@ -97,4 +97,79 @@ test.describe('ClawX chat run state events', () => { await closeElectronApp(app); } }); + + test('shows clear image preview states for generated media while hydration retries', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + const gatewayUrl = '/api/chat/media/outgoing/agent%3Amain%3Aimage-preview/image-1/full'; + const history = [ + { + role: 'assistant', + id: 'generated-image', + timestamp: Date.now() / 1000, + content: [{ + type: 'image', + url: gatewayUrl, + mimeType: 'image/png', + alt: 'generated.png', + }], + }, + ]; + + try { + await installIpcMocks(app, { + gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true }, + gatewayRpc: { + [stableStringify(['sessions.list', { includeDerivedTitles: true, includeLastMessage: true }])]: { + success: true, + result: { + sessions: [{ key: MAIN_SESSION_KEY, displayName: 'main' }], + }, + }, + [stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200, maxChars: 500000 }])]: { + success: true, + result: { messages: history }, + }, + }, + hostApi: { + [stableStringify(['/api/gateway/status', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true }, + }, + }, + [stableStringify(['/api/agents', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { success: true, agents: [{ id: 'main', name: 'Main' }] }, + }, + }, + [stableStringify(['/api/files/thumbnails', 'POST'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { [gatewayUrl]: { preview: null, fileSize: 0 } }, + }, + }, + }, + }); + + const page = await getStableWindow(app); + try { + await page.reload(); + } catch (error) { + if (!String(error).includes('ERR_FILE_NOT_FOUND')) { + throw error; + } + } + + await expect(page.getByTestId('image-preview-unavailable')).toBeVisible({ timeout: 10_000 }); + } finally { + await closeElectronApp(app); + } + }); }); diff --git a/tests/unit/chat-helpers-enrichment.test.ts b/tests/unit/chat-helpers-enrichment.test.ts index 42767ee..9269831 100644 --- a/tests/unit/chat-helpers-enrichment.test.ts +++ b/tests/unit/chat-helpers-enrichment.test.ts @@ -3,7 +3,9 @@ import { enrichWithToolResultFiles, enrichWithToolCallAttachments, enrichWithCachedImages, + loadMissingPreviews, } from '@/stores/chat/helpers'; +import { invokeIpc } from '@/lib/api-client'; import type { RawMessage } from '@/stores/chat'; vi.mock('@/lib/api-client', () => ({ @@ -371,3 +373,77 @@ describe('enrichWithToolCallAttachments', () => { expect(enriched[0]?._attachedFiles?.[0]?.mimeType).toBe('image/png'); }); }); + +describe('loadMissingPreviews', () => { + it('retries image preview hydration when Gateway media records are not ready yet', async () => { + vi.useFakeTimers(); + try { + const gatewayUrl = '/api/chat/media/outgoing/agent%3Amain%3As-1/generated/full'; + const messages: RawMessage[] = [ + { + role: 'assistant', + content: [], + _attachedFiles: [{ + fileName: 'generated.png', + mimeType: 'image/png', + fileSize: 0, + preview: null, + gatewayUrl, + source: 'gateway-media', + }], + }, + ]; + + vi.mocked(invokeIpc) + .mockResolvedValueOnce({ [gatewayUrl]: { preview: null, fileSize: 0 } }) + .mockResolvedValueOnce({ [gatewayUrl]: { preview: 'data:image/png;base64,ok', fileSize: 42 } }); + + const result = loadMissingPreviews(messages); + expect(invokeIpc).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(300); + await expect(result).resolves.toBe(true); + + expect(invokeIpc).toHaveBeenCalledTimes(2); + expect(messages[0]?._attachedFiles?.[0]).toMatchObject({ + preview: 'data:image/png;base64,ok', + fileSize: 42, + }); + expect(messages[0]?._attachedFiles?.[0]?.previewStatus).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it('marks image previews unavailable after retry exhaustion', async () => { + vi.useFakeTimers(); + try { + const gatewayUrl = '/api/chat/media/outgoing/agent%3Amain%3As-1/missing/full'; + const messages: RawMessage[] = [ + { + role: 'assistant', + content: [], + _attachedFiles: [{ + fileName: 'missing.png', + mimeType: 'image/png', + fileSize: 0, + preview: null, + gatewayUrl, + source: 'gateway-media', + }], + }, + ]; + + vi.mocked(invokeIpc).mockResolvedValue({ [gatewayUrl]: { preview: null, fileSize: 0 } }); + + const result = loadMissingPreviews(messages); + await vi.advanceTimersByTimeAsync(300 + 900 + 1800); + await expect(result).resolves.toBe(true); + + expect(invokeIpc).toHaveBeenCalledTimes(4); + expect(messages[0]?._attachedFiles?.[0]?.previewStatus).toBe('unavailable'); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/tests/unit/chat-message.test.tsx b/tests/unit/chat-message.test.tsx index 15c0e01..0de35fa 100644 --- a/tests/unit/chat-message.test.tsx +++ b/tests/unit/chat-message.test.tsx @@ -77,6 +77,51 @@ describe('ChatMessage attachment dedupe', () => { expect(screen.getByAltText('desktop_screenshot.png')).toBeInTheDocument(); }); + it('shows an explicit loading state for image artifacts before preview hydration finishes', () => { + const message: RawMessage = { + role: 'assistant', + content: 'Image generated.', + _attachedFiles: [ + { + fileName: 'generated.png', + mimeType: 'image/png', + fileSize: 0, + preview: null, + gatewayUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/generated/full', + source: 'gateway-media', + }, + ], + }; + + render(); + + expect(screen.getByTestId('image-preview-loading')).toBeInTheDocument(); + expect(screen.queryByTestId('image-preview-unavailable')).not.toBeInTheDocument(); + }); + + it('shows an unavailable state after image preview hydration gives up', () => { + const message: RawMessage = { + role: 'assistant', + content: 'Image generated.', + _attachedFiles: [ + { + fileName: 'generated.png', + mimeType: 'image/png', + fileSize: 0, + preview: null, + previewStatus: 'unavailable', + gatewayUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/generated/full', + source: 'gateway-media', + }, + ], + }; + + render(); + + expect(screen.getByTestId('image-preview-unavailable')).toBeInTheDocument(); + expect(screen.queryByTestId('image-preview-loading')).not.toBeInTheDocument(); + }); + it('keeps message-ref image artifacts visible alongside reply text when process attachments are suppressed', () => { const message: RawMessage = { role: 'assistant', diff --git a/tests/unit/chat-page-execution-graph.test.tsx b/tests/unit/chat-page-execution-graph.test.tsx index fae51f2..195d1da 100644 --- a/tests/unit/chat-page-execution-graph.test.tsx +++ b/tests/unit/chat-page-execution-graph.test.tsx @@ -21,6 +21,19 @@ vi.mock('@/stores/agents', () => ({ useAgentsStore: (selector: (state: typeof agentsState) => unknown) => selector(agentsState), })); +vi.mock('@/stores/artifact-panel', () => { + const state = { + open: false, + widthPct: 45, + openChanges: vi.fn(), + openPreview: vi.fn(), + close: vi.fn(), + }; + return { + useArtifactPanel: (selector: (value: typeof state) => unknown) => selector(state), + }; +}); + vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), })); @@ -74,7 +87,17 @@ vi.mock('@/pages/Chat/ChatInput', () => ({ })); vi.mock('@/pages/Chat/ChatMessage', () => ({ - ChatMessage: ({ message, textOverride }: { message: { content?: unknown }; textOverride?: string }) => { + ChatMessage: ({ + message, + textOverride, + isStreaming, + suppressAssistantText, + }: { + message: { content?: unknown }; + textOverride?: string; + isStreaming?: boolean; + suppressAssistantText?: boolean; + }) => { const text = typeof textOverride === 'string' ? textOverride : typeof message?.content === 'string' @@ -86,7 +109,11 @@ vi.mock('@/pages/Chat/ChatMessage', () => ({ .map((block) => block.text) .join(' ') : ''; - return
{text}
; + return ( +
+ {suppressAssistantText ? '' : text} +
+ ); }, })); @@ -352,6 +379,66 @@ describe('Chat execution graph lifecycle', () => { expect(screen.getAllByText('404 Resource not found').length).toBeGreaterThan(0); }); + it('keeps history final reply folded while matching streamed text is still active', async () => { + const finalText = 'History final answer is already recorded.'; + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + messages: [ + { + role: 'user', + content: 'Summarize the run', + }, + { + role: 'assistant', + id: 'tool-turn', + content: [ + { type: 'text', text: 'Reading the source data.' }, + { type: 'tool_use', id: 'read-1', name: 'read_file', input: { path: '/tmp/source.txt' } }, + ], + }, + { + role: 'assistant', + id: 'final-turn', + content: [{ type: 'text', text: finalText }], + }, + ], + loading: false, + error: null, + runError: null, + sending: true, + activeRunId: 'run-history-stream-race', + streamingText: '', + streamingMessage: { + role: 'assistant', + content: [{ type: 'text', text: finalText }], + }, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: Date.now(), + pendingToolImages: [], + sessions: [{ key: 'agent:main:main' }], + currentSessionKey: 'agent:main:main', + currentAgentId: 'main', + sessionLabels: {}, + sessionLastActivity: {}, + thinkingLevel: null, + }); + + const { Chat } = await import('@/pages/Chat/index'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('chat-execution-graph')).toHaveAttribute('data-collapsed', 'false'); + }); + + expect(screen.getByTestId('mock-streaming-message')).toHaveTextContent(finalText); + expect( + screen.getAllByTestId('mock-chat-message') + .filter((element) => element.textContent === finalText), + ).toHaveLength(0); + }); + it('stops trailing thinking when history already contains the final reply but sending is stuck', async () => { const { useChatStore } = await import('@/stores/chat'); useChatStore.setState({ @@ -407,6 +494,95 @@ describe('Chat execution graph lifecycle', () => { expect(screen.queryByTestId('chat-activity-indicator')).not.toBeInTheDocument(); }); + it('stops trailing thinking when generated image media arrives but session wake is missed', async () => { + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + messages: [ + { + role: 'user', + content: '生成一个小麦', + }, + { + role: 'assistant', + id: 'image-tool-turn', + content: [ + { + type: 'toolCall', + id: 'image-1', + name: 'image_generate', + arguments: { prompt: 'golden wheat' }, + }, + ], + }, + { + role: 'assistant', + id: 'message-tool-turn', + content: [ + { + type: 'toolCall', + id: 'message-1', + name: 'message', + arguments: { + action: 'send', + attachments: [{ path: '/tmp/wheat.png' }], + }, + }, + ], + }, + { + role: 'assistant', + id: 'generated-image', + content: [{ + type: 'image', + url: '/api/chat/media/outgoing/agent%3Amain%3As-1/image-1/full', + mimeType: 'image/png', + alt: 'wheat.png', + }], + _attachedFiles: [{ + fileName: 'wheat.png', + mimeType: 'image/png', + fileSize: 42, + preview: 'data:image/png;base64,ok', + gatewayUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/image-1/full', + source: 'gateway-media', + }], + }, + ], + loading: false, + error: null, + runError: null, + sending: true, + activeRunId: 'run-stuck-image', + streamingText: '', + streamingMessage: { + role: 'assistant', + content: [{ type: 'thinking', thinking: '等待图片生成完成。' }], + }, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: Date.now(), + pendingToolImages: [], + sessions: [{ key: 'agent:main:main' }], + currentSessionKey: 'agent:main:main', + currentAgentId: 'main', + sessionLabels: {}, + sessionLastActivity: {}, + thinkingLevel: null, + }); + + const { Chat } = await import('@/pages/Chat/index'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('chat-execution-graph')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('chat-execution-step-thinking-trailing')).not.toBeInTheDocument(); + expect(screen.queryByTestId('chat-typing-indicator')).not.toBeInTheDocument(); + expect(screen.queryByTestId('chat-activity-indicator')).not.toBeInTheDocument(); + }); + it('keeps the run active when narration landed in history before tools finished', async () => { const { useChatStore } = await import('@/stores/chat'); useChatStore.setState({ diff --git a/tests/unit/task-visualization.test.ts b/tests/unit/task-visualization.test.ts index 3680cc5..53c722b 100644 --- a/tests/unit/task-visualization.test.ts +++ b/tests/unit/task-visualization.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { deriveTaskSteps, parseSubagentCompletionInfo } from '@/pages/Chat/task-visualization'; +import { + deriveTaskSteps, + findReplyMessageIndex, + parseSubagentCompletionInfo, + segmentHasFinalReply, +} from '@/pages/Chat/task-visualization'; import { stripProcessMessagePrefix } from '@/pages/Chat/message-utils'; import type { RawMessage, ToolStatus } from '@/stores/chat'; @@ -366,3 +371,51 @@ status: completed successfully`, }); }); }); + +describe('run completion detection', () => { + it('treats delivered image attachments as a final reply after image generation tools', () => { + const messages: RawMessage[] = [ + { + role: 'assistant', + content: [{ + type: 'toolCall', + id: 'tool-image', + name: 'image_generate', + arguments: { prompt: 'wheat' }, + }], + }, + { + role: 'assistant', + content: [{ + type: 'toolCall', + id: 'tool-message', + name: 'message', + arguments: { + action: 'send', + attachments: [{ path: '/tmp/wheat.png' }], + }, + }], + }, + { + role: 'assistant', + content: [{ + type: 'image', + url: '/api/chat/media/outgoing/agent%3Amain%3As-1/image-1/full', + mimeType: 'image/png', + alt: 'wheat.png', + }], + _attachedFiles: [{ + fileName: 'wheat.png', + mimeType: 'image/png', + fileSize: 42, + preview: 'data:image/png;base64,ok', + gatewayUrl: '/api/chat/media/outgoing/agent%3Amain%3As-1/image-1/full', + source: 'gateway-media', + }], + }, + ]; + + expect(segmentHasFinalReply(messages)).toBe(true); + expect(findReplyMessageIndex(messages, false)).toBe(2); + }); +});