feat(ui): surface friendly agent and project failure messages
Add friendlyAgentErrors to map raw agent/image errors to user-facing copy, and use it for timeline error bubbles and project failure banners. Redirect home with a toast when a project returns 403/404 instead of showing a generic error, and render the final agent response inline under skill progress. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -205,17 +205,114 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.agent-step-marker {
|
||||
.agent-thinking-card {
|
||||
animation: agent-thinking-enter 220ms ease-out both;
|
||||
}
|
||||
|
||||
.agent-thinking-title {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
color: #747982;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.agent-thinking-icon {
|
||||
position: relative;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
color: #6c7078;
|
||||
flex: 0 0 16px;
|
||||
color: #747982;
|
||||
}
|
||||
|
||||
.agent-thinking-card.current .agent-step-marker {
|
||||
color: #2f80ff;
|
||||
.agent-brain-icon,
|
||||
.agent-chevron-icon {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition:
|
||||
opacity 300ms ease-in-out,
|
||||
transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
.agent-brain-icon svg,
|
||||
.agent-chevron-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.agent-chevron-icon {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg) scale(0.5);
|
||||
}
|
||||
|
||||
.agent-thinking-title.is-thinking.is-expanded .agent-brain-icon {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
.agent-thinking-title.is-thinking.is-expanded .agent-chevron-icon {
|
||||
opacity: 1;
|
||||
transform: rotate(90deg) scale(1);
|
||||
}
|
||||
|
||||
.agent-thinking-title.is-complete .agent-brain-icon {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.agent-thinking-title.is-complete .agent-chevron-icon {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg) scale(0.5);
|
||||
}
|
||||
|
||||
.agent-thinking-label {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-thinking-title.is-thinking .agent-thinking-label {
|
||||
background-image: linear-gradient(90deg, #747982 0%, #747982 30%, #ffffff 50%, #747982 70%, #747982 100%);
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: agent-thinking-shimmer 1.45s linear infinite;
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
|
||||
.agent-thinking-card.has-content .agent-tool-content {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 10px;
|
||||
animation: agent-thinking-content-enter 240ms ease-out both;
|
||||
}
|
||||
|
||||
.agent-final-response {
|
||||
animation: agent-thinking-content-enter 240ms ease-out both;
|
||||
}
|
||||
|
||||
.agent-final-content {
|
||||
gap: 0;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.agent-final-content .message-bubble.assistant,
|
||||
.agent-final-content .moteva-response-card {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.agent-step-copy {
|
||||
@@ -231,13 +328,34 @@
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: agent-spin 0.9s linear infinite;
|
||||
@keyframes agent-thinking-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-spin {
|
||||
@keyframes agent-thinking-content-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-thinking-shimmer {
|
||||
from {
|
||||
background-position: 120% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -80% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,11 +598,47 @@
|
||||
|
||||
.message-bubble.error {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ffd7d3;
|
||||
border-radius: 8px;
|
||||
color: #9f1f17;
|
||||
background: #fff4f2;
|
||||
border: 1px solid #ece4d7;
|
||||
border-radius: 10px;
|
||||
color: #4a3522;
|
||||
background: #fffaf3;
|
||||
}
|
||||
|
||||
.message-error-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
color: #a15c1b;
|
||||
background: #fff0d7;
|
||||
}
|
||||
|
||||
.message-error-copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.message-bubble.error strong {
|
||||
display: block;
|
||||
color: #4a3522;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.message-bubble.error p,
|
||||
.message-bubble.error .markdown-content {
|
||||
color: #6a5746;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.message-reference-tag {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import { Check, Copy, Download, Eye, Globe2, Heart, Loader2 } from "lucide-react";
|
||||
import { Fragment, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
|
||||
import { Check, CircleAlert, Copy, Download, Eye, Globe2, Heart } from "lucide-react";
|
||||
import type { AgentMessage, CanvasNode } from "@/domain/design";
|
||||
import { useI18n } from "@/i18n/i18n";
|
||||
import { MarkdownContent } from "@/ui/components/MarkdownContent";
|
||||
import { friendlyAgentErrorMessage } from "@/ui/lib/friendlyAgentErrors";
|
||||
import { canvasImageUrl, thumbnailImageUrl } from "@/ui/lib/imageDelivery";
|
||||
import { parsePromptImageReferences, serializePromptImageReferences, type PromptImageReference } from "@/ui/lib/promptImageReferences";
|
||||
import { isImageUrl } from "@/ui/pages/CanvasWorkspace/canvas/canvasNodeOps";
|
||||
@@ -62,7 +63,25 @@ export function CanvasAgentTimeline({ messages, nodes, active }: { messages: Age
|
||||
const finalMessages = turn.messages.filter(isAssistantFinalMessage);
|
||||
const errorMessages = turn.messages.filter((message) => message.role === "error");
|
||||
const artifacts = turn.messages.flatMap(parseImageArtifacts);
|
||||
const showSkillProgress = (progressMessages.length > 0 || hasResearchProgress) && errorMessages.length === 0;
|
||||
const showSkillProgress = (turnActive || progressMessages.length > 0 || hasResearchProgress) && errorMessages.length === 0;
|
||||
const responseItems: ReactNode[] = finalMessages.map((message) => {
|
||||
const imageResponse = artifacts.length > 0 || isImageGenerationMessage(message) || isToolArtifactMessage(message);
|
||||
const artifactBatch = imageResponse && artifacts.length > 0 ? artifacts : [];
|
||||
const previewBatch =
|
||||
imageResponse && artifactBatch.length === 0
|
||||
? completedNodes.slice(generatedNodeIndex, generatedNodeIndex + 1)
|
||||
: completedNodes.slice(generatedNodeIndex, generatedNodeIndex + artifactBatch.length);
|
||||
if (imageResponse) generatedNodeIndex += Math.max(1, artifactBatch.length);
|
||||
|
||||
return imageResponse ? (
|
||||
<MotevaGeneratedResponse key={message.id} message={message} nodes={previewBatch} artifacts={artifactBatch} />
|
||||
) : (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
);
|
||||
});
|
||||
if (finalMessages.length === 0 && artifacts.length > 0) {
|
||||
responseItems.push(<MotevaGeneratedResponse key={`${turn.id}-artifacts`} artifacts={artifacts} />);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="agent-chat-session" key={turn.id} data-testid={`chat-session-${turn.id}`}>
|
||||
@@ -73,22 +92,14 @@ export function CanvasAgentTimeline({ messages, nodes, active }: { messages: Age
|
||||
)}
|
||||
<div className="agent-chat-messages" data-testid="agent-chat-messages">
|
||||
{showSkillProgress && <SkillProgress messages={turnMessages} active={turnActive} />}
|
||||
{finalMessages.map((message) => {
|
||||
const imageResponse = artifacts.length > 0 || isImageGenerationMessage(message) || isToolArtifactMessage(message);
|
||||
const artifactBatch = imageResponse && artifacts.length > 0 ? artifacts : [];
|
||||
const previewBatch =
|
||||
imageResponse && artifactBatch.length === 0
|
||||
? completedNodes.slice(generatedNodeIndex, generatedNodeIndex + 1)
|
||||
: completedNodes.slice(generatedNodeIndex, generatedNodeIndex + artifactBatch.length);
|
||||
if (imageResponse) generatedNodeIndex += Math.max(1, artifactBatch.length);
|
||||
|
||||
return imageResponse ? (
|
||||
<MotevaGeneratedResponse key={message.id} message={message} nodes={previewBatch} artifacts={artifactBatch} />
|
||||
) : (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
);
|
||||
})}
|
||||
{finalMessages.length === 0 && artifacts.length > 0 && <MotevaGeneratedResponse key={`${turn.id}-artifacts`} artifacts={artifacts} />}
|
||||
{showSkillProgress && responseItems.length > 0 ? (
|
||||
<div className="agent-tool-secondary agent-final-response">
|
||||
<div className="agent-tool-line" aria-hidden="true" />
|
||||
<div className="agent-tool-content agent-final-content">{responseItems}</div>
|
||||
</div>
|
||||
) : (
|
||||
responseItems
|
||||
)}
|
||||
{errorMessages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
@@ -221,13 +232,16 @@ function MotevaDateDivider({ value }: { value: string }) {
|
||||
}
|
||||
|
||||
function SkillProgress({ messages, active }: { messages: AgentMessage[]; active: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const { locale, t } = useI18n();
|
||||
const research = latestResearchPayload(messages);
|
||||
const progressSteps = compactAgentProgressSteps(
|
||||
messages.filter((message) => isProgressMessage(message) && !isResearchMessage(message) && !isToolArtifactMessage(message) && !isHeartbeatProgressMessage(message) && !isMutedProgressMessage(message))
|
||||
);
|
||||
const currentStep = preferredThinkingMessage(messages, progressSteps, active);
|
||||
const hasResearchResults = (research?.results.length ?? 0) > 0;
|
||||
const thinkingContent = currentStep ? cleanMessageContent(currentStep.content) : "";
|
||||
const showThinking = Boolean(currentStep || active);
|
||||
const thinkingLabel = active ? localizedThinkingLabel(locale, "running") : localizedThinkingLabel(locale, "done");
|
||||
|
||||
const researchTitle = research?.status === "running" ? t("searchingWeb") : hasResearchResults ? t("searchComplete") : research?.error ? t("searchFailed") : t("searchComplete");
|
||||
|
||||
@@ -258,19 +272,14 @@ function SkillProgress({ messages, active }: { messages: AgentMessage[]; active:
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{currentStep && (
|
||||
<section className={`agent-tool-group agent-thinking-card ${active ? "current" : ""}`}>
|
||||
<div className="agent-tool-title">
|
||||
<span className="agent-step-marker" aria-hidden="true">
|
||||
{active ? <Loader2 className="spin" size={14} /> : <Check size={13} />}
|
||||
</span>
|
||||
<span>{currentStep.title}</span>
|
||||
</div>
|
||||
{cleanMessageContent(currentStep.content) && (
|
||||
{showThinking && (
|
||||
<section className={`agent-tool-group agent-thinking-card ${active ? "current" : "complete"} ${thinkingContent ? "has-content" : ""}`}>
|
||||
<AgentThinkingTitle label={thinkingLabel} active={active} expanded={Boolean(thinkingContent)} />
|
||||
{thinkingContent && (
|
||||
<div className="agent-tool-secondary">
|
||||
<div className="agent-tool-line" aria-hidden="true" />
|
||||
<div className="agent-tool-content agent-step-copy">
|
||||
<p>{cleanMessageContent(currentStep.content)}</p>
|
||||
<p>{thinkingContent}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -280,6 +289,51 @@ function SkillProgress({ messages, active }: { messages: AgentMessage[]; active:
|
||||
);
|
||||
}
|
||||
|
||||
function AgentThinkingTitle({ label, active, expanded }: { label: string; active: boolean; expanded: boolean }) {
|
||||
return (
|
||||
<div className={`agent-thinking-title ${active ? "is-thinking" : "is-complete"} ${expanded ? "is-expanded" : ""}`} data-testid="agent-chat-session-title">
|
||||
<span className="agent-thinking-icon" aria-hidden="true">
|
||||
<span className="agent-brain-icon">
|
||||
<BrainGlyph />
|
||||
</span>
|
||||
<span className="agent-chevron-icon">
|
||||
<ChevronGlyph />
|
||||
</span>
|
||||
</span>
|
||||
<span className="agent-thinking-label">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrainGlyph() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" focusable="false">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillOpacity="0.9"
|
||||
d="M20.5 9.43a2.85 2.85 0 0 0-2.538-2.83l-.52-.056-.127-.507a3.363 3.363 0 0 0-4.565-2.274V7a2.5 2.5 0 0 0 2.5 2.5.75.75 0 0 1 0 1.5c-.946 0-1.815-.33-2.5-.879v9.923a3.85 3.85 0 0 0 1.82.456 3.88 3.88 0 0 0 3.726-2.807l.1-.35.336-.138a2.85 2.85 0 0 0 .913-4.669L19.1 12l.546-.536A2.84 2.84 0 0 0 20.5 9.43m-17 0c0 .797.326 1.516.854 2.034l.547.536-.547.536a2.849 2.849 0 0 0 .913 4.669l.337.138.1.35A3.88 3.88 0 0 0 9.431 20.5a3.85 3.85 0 0 0 1.819-.456V17a2.5 2.5 0 0 0-2.5-2.5.75.75 0 0 1 0-1.5c.946 0 1.815.329 2.5.878V3.763a3.363 3.363 0 0 0-4.565 2.274l-.127.507-.52.056A2.85 2.85 0 0 0 3.5 9.43m18.5 0c0 .962-.314 1.85-.843 2.57a4.345 4.345 0 0 1-1.53 6.44A5.375 5.375 0 0 1 12 21.344a5.376 5.376 0 0 1-7.629-2.903A4.345 4.345 0 0 1 2.841 12a4.348 4.348 0 0 1 2.536-6.808A4.86 4.86 0 0 1 12 2.458a4.861 4.861 0 0 1 6.621 2.734A4.35 4.35 0 0 1 22 9.431"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronGlyph() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" focusable="false">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillOpacity="0.9"
|
||||
d="M9.53 20.53a.75.75 0 1 1-1.06-1.06l6.586-6.586a1.25 1.25 0 0 0 0-1.768L8.47 4.53a.75.75 0 1 1 1.06-1.06l6.586 6.586a2.75 2.75 0 0 1 0 3.888z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function localizedThinkingLabel(locale: string, state: "running" | "done") {
|
||||
if (locale === "en-US") return state === "running" ? "Thinking..." : "Thinking complete";
|
||||
return state === "running" ? "思考中..." : "思考完成";
|
||||
}
|
||||
|
||||
function preferredThinkingMessage(messages: AgentMessage[], progressSteps: AgentMessage[], active: boolean) {
|
||||
if (active) {
|
||||
const runtimeMessage = [...progressSteps].reverse().find((message) => cleanMessageContent(message.content) || message.title.trim());
|
||||
@@ -397,7 +451,10 @@ function MessageBubble({ message }: { message: AgentMessage }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyButtonVisible, setCopyButtonVisible] = useState(false);
|
||||
const hideCopyButtonTimerRef = useRef<number | null>(null);
|
||||
const content = cleanMessageContent(message.content);
|
||||
const rawContent = cleanMessageContent(message.content);
|
||||
const friendlyError = message.role === "error" ? friendlyAgentErrorMessage({ ...message, content: rawContent }) : null;
|
||||
const content = friendlyError?.content || rawContent;
|
||||
const title = friendlyError?.title || message.title;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -440,9 +497,23 @@ function MessageBubble({ message }: { message: AgentMessage }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (message.role === "error") {
|
||||
return (
|
||||
<article className="message-bubble error" role="status">
|
||||
<span className="message-error-icon" aria-hidden="true">
|
||||
<CircleAlert size={16} />
|
||||
</span>
|
||||
<div className="message-error-copy">
|
||||
<strong>{title}</strong>
|
||||
<MarkdownContent content={content} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={`message-bubble ${message.role} copyable ${copyButtonVisible ? "show-copy" : ""}`} onMouseEnter={showCopyButton} onMouseLeave={scheduleHideCopyButton}>
|
||||
{message.title && <strong>{message.title}</strong>}
|
||||
{title && <strong>{title}</strong>}
|
||||
<MarkdownContent content={content} streaming={message.status === "streaming"} />
|
||||
<button className="message-copy-button" type="button" aria-label="Copy message" onClick={() => void copyMessage()}>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { AgentMessage } from "@/domain/design";
|
||||
|
||||
type ErrorScope = "image" | "partial-image" | "image-action" | "text-extraction" | "mockup-model" | "layer-merge";
|
||||
|
||||
export type FriendlyAgentError = {
|
||||
title: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export function friendlyAgentErrorMessage(message: Pick<AgentMessage, "role" | "title" | "content" | "name" | "toolHint" | "status">): FriendlyAgentError {
|
||||
const rawTitle = cleanErrorText(message.title);
|
||||
const rawContent = cleanErrorText(message.content);
|
||||
const combined = `${rawTitle}\n${rawContent}`;
|
||||
const scope = inferErrorScope(message, combined);
|
||||
const title = friendlyErrorTitle(scope);
|
||||
|
||||
if (message.role === "error" && rawContent && !isInternalFailureText(combined)) {
|
||||
return { title, content: rawContent };
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
content: fallbackErrorContent(scope, combined)
|
||||
};
|
||||
}
|
||||
|
||||
export function friendlyProjectFailureMessage(messages: AgentMessage[]) {
|
||||
const failure = [...messages].reverse().find((message) => message.role === "error" || message.status === "failed");
|
||||
if (!failure) return "";
|
||||
const friendly = friendlyAgentErrorMessage(failure);
|
||||
return friendly.content || friendly.title;
|
||||
}
|
||||
|
||||
function inferErrorScope(message: Pick<AgentMessage, "title" | "content" | "name" | "toolHint">, text: string): ErrorScope {
|
||||
const normalized = text.toLowerCase();
|
||||
const toolName = `${message.name || ""} ${message.toolHint || ""}`.toLowerCase();
|
||||
if (text.includes("部分")) return "partial-image";
|
||||
if (text.includes("文字") || toolName.includes("text_extraction")) return "text-extraction";
|
||||
if (text.includes("Mockup") || text.includes("mockup") || toolName.includes("mockup_model")) return "mockup-model";
|
||||
if (text.includes("合并图层") || normalized.includes("merge")) return "layer-merge";
|
||||
if (text.includes("图片处理") || toolName.includes("canvas_action")) return "image-action";
|
||||
return "image";
|
||||
}
|
||||
|
||||
function friendlyErrorTitle(scope: ErrorScope) {
|
||||
switch (scope) {
|
||||
case "partial-image":
|
||||
return "图片生成没有全部完成";
|
||||
case "image-action":
|
||||
return "图片处理没有完成";
|
||||
case "text-extraction":
|
||||
return "文字提取没有完成";
|
||||
case "mockup-model":
|
||||
return "Mockup 模型没有完成";
|
||||
case "layer-merge":
|
||||
return "图层合并没有完成";
|
||||
default:
|
||||
return "图片生成没有完成";
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackErrorContent(scope: ErrorScope, text: string) {
|
||||
const timeout = isTimeoutLikeFailure(text);
|
||||
const temporary = isTemporaryFailure(text);
|
||||
|
||||
switch (scope) {
|
||||
case "partial-image":
|
||||
if (timeout) return "部分图片已经生成完成,剩余图片耗时较久没有返回。已保留成功结果,可以只重试失败的部分。";
|
||||
return "部分图片已经生成完成,剩余图片没有成功生成。已保留成功结果,可以只重试失败的部分。";
|
||||
case "image-action":
|
||||
if (timeout) return "图片处理耗时较久,这次没有成功完成。原图已保留,可以稍后重试。";
|
||||
if (temporary) return "图片处理服务暂时繁忙。原图已保留,可以稍后重试。";
|
||||
return "图片处理没有成功完成。原图已保留,可以重新发起处理。";
|
||||
case "text-extraction":
|
||||
if (timeout) return "文字提取耗时较久,这次没有成功完成。图片状态已恢复,可以稍后重试。";
|
||||
return "文字提取没有成功完成。图片状态已恢复,可以稍后重试。";
|
||||
case "mockup-model":
|
||||
if (timeout) return "Mockup 模型生成耗时较久,这次没有成功完成。原图已保留,可以稍后重试。";
|
||||
return "Mockup 模型没有成功生成。原图已保留,可以稍后重试。";
|
||||
case "layer-merge":
|
||||
if (timeout) return "图层合并耗时较久,这次没有成功完成。原图层已保留,可以稍后重试。";
|
||||
return "图层合并没有成功完成。原图层已保留,可以稍后重试。";
|
||||
default:
|
||||
if (timeout) return "图片生成耗时较久,这次没有成功完成。画布内容已保留,可以直接重试,或先降低图片数量/尺寸后再生成。";
|
||||
if (temporary) return "图片生成服务暂时繁忙。画布内容已保留,可以稍后重试。";
|
||||
return "图片生成没有成功完成。画布内容已保留,可以重新发起生成。";
|
||||
}
|
||||
}
|
||||
|
||||
function isInternalFailureText(text: string) {
|
||||
const normalized = text.toLowerCase();
|
||||
return (
|
||||
/https?:\/\//i.test(text) ||
|
||||
/\b\d{1,3}(?:\.\d{1,3}){3}\b/.test(text) ||
|
||||
normalized.includes("/v1/images/") ||
|
||||
normalized.includes("client.timeout") ||
|
||||
normalized.includes("context deadline") ||
|
||||
normalized.includes("awaiting headers") ||
|
||||
normalized.includes("upstream") ||
|
||||
normalized.includes("failed:") ||
|
||||
normalized.includes(" post ") ||
|
||||
normalized.includes(" get ")
|
||||
);
|
||||
}
|
||||
|
||||
function isTimeoutLikeFailure(text: string) {
|
||||
const normalized = text.toLowerCase();
|
||||
return normalized.includes("deadline") || normalized.includes("timeout") || normalized.includes("timed out") || normalized.includes("awaiting headers") || normalized.includes("lease expired") || text.includes("超时") || text.includes("耗时");
|
||||
}
|
||||
|
||||
function isTemporaryFailure(text: string) {
|
||||
const normalized = text.toLowerCase();
|
||||
return normalized.includes("overload") || normalized.includes("too many requests") || normalized.includes("rate limit") || normalized.includes("temporar") || normalized.includes("service unavailable") || normalized.includes("bad gateway") || normalized.includes("gateway") || text.includes("繁忙");
|
||||
}
|
||||
|
||||
function cleanErrorText(value: string) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, type Dispatch, type RefObject, type Set
|
||||
import type { AgentChatPayload, AgentMessage, Project } from "@/domain/design";
|
||||
import { designGateway } from "@/infrastructure/designGateway";
|
||||
import { useUserMessage } from "@/ui/hooks/useUserMessage";
|
||||
import { friendlyProjectFailureMessage } from "@/ui/lib/friendlyAgentErrors";
|
||||
import { generatorTaskPollIntervalMs } from "@/ui/pages/CanvasWorkspace/canvas/generatorTaskPolling";
|
||||
|
||||
export type GenerationChannel = "agent" | "image-generator" | "canvas-action";
|
||||
@@ -404,6 +405,5 @@ function isActiveCanvasWorkNode(node: Project["nodes"][number]) {
|
||||
}
|
||||
|
||||
function projectFailureMessage(project: Project) {
|
||||
const failure = [...project.messages].reverse().find((message) => message.role === "error" || message.status === "failed");
|
||||
return failure?.content || failure?.title || "";
|
||||
return friendlyProjectFailureMessage(project.messages);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||
import type { AgentContent, AgentMessage, AgentThreadSummary, CanvasNode, CanvasViewport, ExtractedTextLayer, ExtractNodeTextResponse, MockupModel, MockupModelMap, MockupPlacement, MockupSurface, NodeActionRequest, Project } from "@/domain/design";
|
||||
import { useI18n, type Locale } from "@/i18n/i18n";
|
||||
import { designGateway } from "@/infrastructure/designGateway";
|
||||
import { httpStatusOf } from "@/infrastructure/userMessages";
|
||||
import { useAuth } from "@/ui/auth/AuthProvider";
|
||||
import { AccountManagementDialog } from "@/ui/components/AccountManagementDialog";
|
||||
import { ArtboardPreview } from "@/ui/components/ArtboardPreview";
|
||||
@@ -29,6 +30,7 @@ import { TextStyleToolbar, type TextStylePatch } from "@/ui/components/TextStyle
|
||||
import { loadMotevaFontCatalog } from "@/ui/components/TextStyleToolbar/fontCatalog";
|
||||
import { WorkspaceTitle } from "@/ui/components/WorkspaceTitle";
|
||||
import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, serializeImageAdjustments } from "@/ui/lib/imageAdjustments";
|
||||
import { friendlyProjectFailureMessage } from "@/ui/lib/friendlyAgentErrors";
|
||||
import { canvasImageUrl } from "@/ui/lib/imageDelivery";
|
||||
import { visiblePromptText } from "@/ui/lib/promptImageReferences";
|
||||
import { isImageGeneratorThreadMessage, visibleAgentMessages } from "./canvas/agentMessageFilters";
|
||||
@@ -257,6 +259,11 @@ function shouldBypassCanvasWheel(target: EventTarget | null) {
|
||||
return target instanceof Element && Boolean(target.closest(canvasWheelGuardSelector));
|
||||
}
|
||||
|
||||
function isProjectAccessDenied(err: unknown) {
|
||||
const status = httpStatusOf(err);
|
||||
return status === 403 || status === 404;
|
||||
}
|
||||
|
||||
function expandTargetGeometryForNode(node: CanvasNode, options: ExpandOptions) {
|
||||
const ratio = expandRatioValue(options.ratio, node.width / Math.max(node.height, 1));
|
||||
const sourceRatio = node.width / Math.max(node.height, 1);
|
||||
@@ -795,7 +802,14 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => setError(toUserMessageText(err, "generateError")));
|
||||
.catch((err: unknown) => {
|
||||
if (isProjectAccessDenied(err)) {
|
||||
showToast("你没有访问该项目权限", "error");
|
||||
openHome();
|
||||
return;
|
||||
}
|
||||
setError(toUserMessageText(err, "generateError"));
|
||||
});
|
||||
}, [applyProject, projectId, startProjectEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -5330,8 +5344,7 @@ function projectHasWebSearchEnabled(project: Project) {
|
||||
}
|
||||
|
||||
function projectFailureMessage(project: Project) {
|
||||
const failure = [...project.messages].reverse().find((message) => message.role === "error" || message.status === "failed");
|
||||
return failure?.content || failure?.title || "";
|
||||
return friendlyProjectFailureMessage(project.messages);
|
||||
}
|
||||
|
||||
function runningCanvasActionThreadIds(project: Project) {
|
||||
|
||||
Reference in New Issue
Block a user