Fix image generate failed (#1080)

This commit is contained in:
paisley
2026-05-29 12:10:04 +08:00
committed by GitHub
parent 5b0c1c9909
commit 97dca873a1
15 changed files with 720 additions and 139 deletions

View File

@@ -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",

View File

@@ -164,7 +164,9 @@
"collapseAction": "実行グラフを折りたたむ"
},
"imageGeneration": {
"generating": "画像を生成しています。少々お待ちください…"
"generating": "画像を生成しています。少々お待ちください…",
"previewLoading": "画像プレビューを読み込んでいます…",
"previewUnavailable": "画像プレビューを一時的に表示できません"
},
"composer": {
"attachFiles": "ファイルを添付",

View File

@@ -164,7 +164,9 @@
"collapseAction": "Свернуть граф выполнения"
},
"imageGeneration": {
"generating": "Изображение генерируется, подождите…"
"generating": "Изображение генерируется, подождите…",
"previewLoading": "Загрузка предпросмотра изображения…",
"previewUnavailable": "Предпросмотр изображения временно недоступен"
},
"composer": {
"attachFiles": "Прикрепить файлы",

View File

@@ -164,7 +164,9 @@
"collapseAction": "收起执行关系图"
},
"imageGeneration": {
"generating": "图片生成中,请稍候…"
"generating": "图片生成中,请稍候…",
"previewLoading": "正在载入图片预览…",
"previewUnavailable": "图片预览暂时不可用"
},
"composer": {
"attachFiles": "添加文件",

View File

@@ -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 })}
/>
) : (
<div
key={`local-${i}`}
className="w-36 h-36 rounded-xl border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 flex items-center justify-center text-muted-foreground"
>
<File className="h-8 w-8" />
</div>
<ImagePreviewPlaceholder key={`local-${i}`} file={file} />
);
}
// Non-image files → file card
@@ -469,11 +465,7 @@ export const ChatMessage = memo(function ChatMessage({
);
}
if (isImage && !file.preview) {
return (
<div key={`local-${i}`} className="w-36 h-36 rounded-xl border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 flex items-center justify-center text-muted-foreground">
<File className="h-8 w-8" />
</div>
);
return <ImagePreviewPlaceholder key={`local-${i}`} file={file} />;
}
return <FileCard key={`local-${i}`} file={file} onOpen={onOpenFile} />;
})}
@@ -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 (
<div
className={cn(
'flex h-36 w-36 flex-col items-center justify-center gap-2 rounded-xl border border-black/10 bg-black/5 px-3 text-center text-muted-foreground dark:border-white/10 dark:bg-white/5',
unavailable && 'border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-400',
)}
data-testid={unavailable ? 'image-preview-unavailable' : 'image-preview-loading'}
title={file.fileName}
>
{unavailable ? (
<AlertCircle className="h-5 w-5 shrink-0" />
) : (
<Loader2 className="h-5 w-5 shrink-0 animate-spin text-primary" />
)}
<span className="text-xs leading-4">{label}</span>
</div>
);
}
// ── Image Thumbnail (user bubble — square crop with zoom hint) ──
function ImageThumbnail({

View File

@@ -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(() => {

View File

@@ -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;

View File

@@ -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<boolean> {
// 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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function collectMissingPreviewRefs(messages: RawMessage[]): PreviewRef[] {
const needPreview: PreviewRef[] = [];
const seenKeys = new Set<string>();
@@ -1348,7 +1347,7 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise<boolean> {
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<boolean> {
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<boolean> {
}
}
if (needPreview.length === 0) {
return false;
}
return needPreview;
}
try {
const thumbnails = await hostApiFetch<Record<string, { preview: string | null; fileSize: number }>>(
'/api/files/thumbnails',
{
method: 'POST',
body: JSON.stringify({ paths: needPreview }),
},
);
function applyPreviewResults(
messages: RawMessage[],
thumbnails: Record<string, { preview: string | null; fileSize: number }>,
): 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<boolean> {
// 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<Record<string, { preview: string | null; fileSize: number }>>(
'/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;
}
}

View File

@@ -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<boolean> {
// 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<void> {
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<string>();
@@ -1122,7 +1124,7 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise<boolean> {
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<boolean> {
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<boolean> {
}
}
if (needPreview.length === 0) return false;
return needPreview;
}
try {
const thumbnails = await invokeIpc(
'media:getThumbnails',
needPreview,
) as Record<string, { preview: string | null; fileSize: number }>;
function applyPreviewResults(
messages: RawMessage[],
thumbnails: Record<string, { preview: string | null; fileSize: number }>,
): 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<boolean> {
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<string, { preview: string | null; fileSize: number }>;
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;
}
}

View File

@@ -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';
/**

View File

@@ -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);
}
});
});

View File

@@ -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();
}
});
});

View File

@@ -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(<ChatMessage message={message} />);
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(<ChatMessage message={message} />);
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',

View File

@@ -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 <div>{text}</div>;
return (
<div data-testid={isStreaming ? 'mock-streaming-message' : 'mock-chat-message'}>
{suppressAssistantText ? '' : text}
</div>
);
},
}));
@@ -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(<Chat />);
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(<Chat />);
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({

View File

@@ -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);
});
});