Fix image generate failed (#1080)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -164,7 +164,9 @@
|
||||
"collapseAction": "実行グラフを折りたたむ"
|
||||
},
|
||||
"imageGeneration": {
|
||||
"generating": "画像を生成しています。少々お待ちください…"
|
||||
"generating": "画像を生成しています。少々お待ちください…",
|
||||
"previewLoading": "画像プレビューを読み込んでいます…",
|
||||
"previewUnavailable": "画像プレビューを一時的に表示できません"
|
||||
},
|
||||
"composer": {
|
||||
"attachFiles": "ファイルを添付",
|
||||
|
||||
@@ -164,7 +164,9 @@
|
||||
"collapseAction": "Свернуть граф выполнения"
|
||||
},
|
||||
"imageGeneration": {
|
||||
"generating": "Изображение генерируется, подождите…"
|
||||
"generating": "Изображение генерируется, подождите…",
|
||||
"previewLoading": "Загрузка предпросмотра изображения…",
|
||||
"previewUnavailable": "Предпросмотр изображения временно недоступен"
|
||||
},
|
||||
"composer": {
|
||||
"attachFiles": "Прикрепить файлы",
|
||||
|
||||
@@ -164,7 +164,9 @@
|
||||
"collapseAction": "收起执行关系图"
|
||||
},
|
||||
"imageGeneration": {
|
||||
"generating": "图片生成中,请稍候…"
|
||||
"generating": "图片生成中,请稍候…",
|
||||
"previewLoading": "正在载入图片预览…",
|
||||
"previewUnavailable": "图片预览暂时不可用"
|
||||
},
|
||||
"composer": {
|
||||
"attachFiles": "添加文件",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user