Compare commits

...

6 Commits

Author SHA1 Message Date
root e9cfadfb90 refactor(ui): extract NotFoundPage into its own i18n-aware module
Move the inline 404 component and its styles out of App.tsx into a
dedicated NotFoundPage module, and localize its copy via i18n keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:16:30 +08:00
root c73e51e400 feat(i18n): add not-found page copy keys
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:16:20 +08:00
root 4e98a81aae feat(ui): path-based app-router routes with 404 and project auth preview
Resolve routes from pathname (and legacy hash), add a notFound route with
a dedicated 404 page, and show a blurred workspace preview for
unauthenticated project visits. Wire up Next.js app-router entry files and
listen for popstate so history navigation updates the route.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:07:13 +08:00
root 92343ef1f6 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>
2026-07-08 12:07:05 +08:00
root d13a4c9a48 fix(agent): let stopped threads start a new task and scope thread state to the latest task
Replace the stored cancel func with a handle so a stale deferred cleanup
can't delete a newer task's cancellation, and clear a thread's cancelled
flag when it submits fresh work instead of refusing to restart. Thread
progress/finished state now derives from the latest user task in the
thread rather than the whole history. Adds coverage for continuing a
stopped thread and for sanitized generation-failure messages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:06:50 +08:00
root 7679854b5d feat(image): stream text-to-image generation with SSE and fallback
Send stream=true for text-to-image requests and consume the SSE
response, falling back to a non-streamed request when the stream errors
in a way that warrants it. Raises the scanner line limit to handle large
base64 image payloads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:06:39 +08:00
24 changed files with 1559 additions and 136 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { ClientRoot } from "@/app/root";
export default function CatchAllPage() {
return <ClientRoot />;
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { ClientRoot } from "@/app/root";
export default function ProjectPage() {
return <ClientRoot />;
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { ClientRoot } from "@/app/root";
export default function ProjectPage() {
return <ClientRoot />;
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { ClientRoot } from "@/app/root";
export default function ProjectsPage() {
return <ClientRoot />;
}
+23 -4
View File
@@ -1,25 +1,44 @@
export type AppRoute =
| { name: "home" }
| { name: "projects" }
| { name: "project"; id: string };
| { name: "project"; id: string }
| { name: "notFound" };
export function readRoute(): AppRoute {
if (typeof window === "undefined") {
return { name: "home" };
}
const hash = window.location.hash.replace(/^#/, "");
if (hash === "/projects") {
if (hash) {
return routeFromPath(hash) ?? { name: "notFound" };
}
return routeFromPath(window.location.pathname) ?? { name: "notFound" };
}
function routeFromPath(path: string): AppRoute | null {
const [pathOnly] = path.split(/[?#]/);
const normalized = pathOnly.replace(/\/+$/, "") || "/";
if (normalized === "/projects") {
return { name: "projects" };
}
const match = hash.match(/^\/project\/([^/]+)$/);
const match = normalized.match(/^\/projects?\/([^/]+)$/);
if (match) {
return { name: "project", id: decodeURIComponent(match[1]) };
}
return { name: "home" };
if (normalized === "/") {
return { name: "home" };
}
return null;
}
export function openHome() {
if (typeof window === "undefined") return;
if (window.location.pathname !== "/") {
window.history.pushState(null, "", "/");
window.dispatchEvent(new PopStateEvent("popstate"));
return;
}
window.location.hash = "/";
}
+3
View File
@@ -103,6 +103,9 @@
"promo": "Limited offer, 50% off · Image-2 from $0.004/image for image generation and editing",
"upgradeNow": "Upgrade now",
"backHome": "Back home",
"notFoundTitle": "404: Page not found!",
"notFoundDescription": "We looked everywhere, but could not find the page you are looking for.",
"notFoundIllustration": "404 page not found",
"save": "Save",
"share": "Share",
"shareLinkCopied": "Share link copied",
+3
View File
@@ -103,6 +103,9 @@
"promo": "限时优惠,立享5折 · Image-2 低至 $0.004/张,专注图片生成与编辑",
"upgradeNow": "立即升级",
"backHome": "返回首页",
"notFoundTitle": "404:页面未找到!",
"notFoundDescription": "我们到处都找过了,但找不到您要找的页面。",
"notFoundIllustration": "404 页面未找到",
"save": "保存",
"share": "分享",
"shareLinkCopied": "分享链接已复制",
+5 -7
View File
@@ -168,19 +168,17 @@ export const designGateway = {
agentSocket(projectId: string, payload: AgentChatPayload, handlers: ProjectEventHandlers) {
let closed = false;
let closeEvents: (() => void) | null = payload.threadId ? designGateway.projectEvents(projectId, handlers, { threadId: payload.threadId }) : null;
let closeEvents: (() => void) | null = null;
void designGateway
.agentChat(projectId, payload)
.then((response) => {
if (closed) return;
handlers.onProject?.(response.project);
if (!closeEvents) {
closeEvents = designGateway.projectEvents(response.project.id, handlers, {
threadId: response.threadId || payload.threadId,
eventsUrl: response.eventsUrl
});
}
closeEvents = designGateway.projectEvents(response.project.id, handlers, {
threadId: response.threadId || payload.threadId,
eventsUrl: response.eventsUrl
});
})
.catch((error: Error) => {
if (!closed) {
+63 -2
View File
@@ -3,8 +3,10 @@
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { readRoute, type AppRoute } from "@/application/route";
import { BrandMark } from "@/ui/components/BrandMark";
import { CanvasWorkspace } from "@/ui/pages/CanvasWorkspace";
import { HomePage } from "@/ui/pages/HomePage";
import { NotFoundPage } from "@/ui/pages/NotFoundPage";
import { ProjectsRoutePage } from "@/ui/pages/ProjectsRoutePage";
import { useAuth } from "@/ui/auth/AuthProvider";
@@ -16,7 +18,11 @@ export function App() {
setRoute(readRoute());
const handleRoute = () => setRoute(readRoute());
window.addEventListener("hashchange", handleRoute);
return () => window.removeEventListener("hashchange", handleRoute);
window.addEventListener("popstate", handleRoute);
return () => {
window.removeEventListener("hashchange", handleRoute);
window.removeEventListener("popstate", handleRoute);
};
}, []);
useEffect(() => {
@@ -33,8 +39,63 @@ export function App() {
);
}
if (route.name === "project" && !isAuthenticated) return <HomePage />;
if (route.name === "project" && !isAuthenticated) return <ProjectAuthPreview />;
if (route.name === "project") return <CanvasWorkspace projectId={route.id} />;
if (route.name === "projects") return <ProjectsRoutePage />;
if (route.name === "notFound") return <NotFoundPage />;
return <HomePage />;
}
function ProjectAuthPreview() {
return (
<div className="project-auth-shell" aria-hidden="true">
<div className="project-auth-blur">
<header className="project-auth-topbar">
<div className="project-auth-title">
<BrandMark className="project-auth-mark" />
<span />
</div>
<div className="project-auth-top-actions">
<i />
<i />
<b />
</div>
</header>
<main className="project-auth-main">
<section className="project-auth-stage">
<div className="project-auth-grid" />
<div className="project-auth-frame primary" />
<div className="project-auth-frame secondary" />
<div className="project-auth-frame tertiary" />
<div className="project-auth-toolbar">
<i />
<i />
<i />
<i />
</div>
</section>
<aside className="project-auth-panel">
<div className="project-auth-panel-head">
<span />
<i />
<i />
</div>
<div className="project-auth-thread">
<b />
<span />
<span />
<span />
</div>
<div className="project-auth-composer">
<span />
<div>
<i />
<i />
</div>
</div>
</aside>
</main>
</div>
</div>
);
}
@@ -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} />}
+118
View File
@@ -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) {
@@ -0,0 +1,83 @@
.not-found-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: #101827;
background: #fff;
}
.not-found-content {
width: min(520px, 100%);
display: grid;
justify-items: center;
text-align: center;
}
.not-found-mark {
width: min(280px, 72vw);
height: auto;
margin-bottom: 57px;
}
.not-found-content h1 {
margin: 0;
color: #111827;
font-size: 32px;
line-height: 1.25;
font-weight: 800;
letter-spacing: 0;
}
.not-found-content p {
margin: 16px 0 32px;
color: #6b7280;
font-size: 14px;
line-height: 1.5;
font-weight: 500;
}
.not-found-content button {
min-width: 128px;
height: 42px;
padding: 0 26px;
border-radius: 12px;
color: #fff;
background: #030303;
font-size: 16px;
font-weight: 700;
line-height: 1;
transition: transform 0.16s ease, background 0.16s ease;
}
.not-found-content button:hover {
background: #171717;
transform: translateY(-1px);
}
@media (max-width: 760px) {
.not-found-shell {
padding: 44px 22px;
}
.not-found-mark {
margin-bottom: 44px;
}
.not-found-content h1 {
font-size: 28px;
}
.not-found-content p {
margin: 14px 0 28px;
font-size: 14px;
}
.not-found-content button {
min-width: 120px;
height: 40px;
border-radius: 12px;
font-size: 15px;
}
}
@@ -0,0 +1,123 @@
"use client";
import { openHome } from "@/application/route";
import { useI18n } from "@/i18n/i18n";
export function NotFoundPage() {
const { t } = useI18n();
return (
<main className="not-found-shell">
<section className="not-found-content">
<NotFoundMark label={t("notFoundIllustration")} />
<h1>{t("notFoundTitle")}</h1>
<p>{t("notFoundDescription")}</p>
<button type="button" onClick={openHome}>
{t("backHome")}
</button>
</section>
</main>
);
}
function NotFoundMark({ label }: { label: string }) {
return (
<svg className="not-found-mark" xmlns="http://www.w3.org/2000/svg" width="279" height="128" fill="none" viewBox="0 0 279 128" aria-label={label}>
<g clipPath="url(#missing_svg_a)">
<g filter="url(#missing_svg_b)">
<path stroke="#FFAE62" strokeLinecap="round" strokeOpacity="0.5" strokeWidth="20.671" d="M53 12v90" />
</g>
<g filter="url(#missing_svg_c)">
<path stroke="#FFAE62" strokeLinecap="round" strokeOpacity="0.5" strokeWidth="20.671" d="M53.5 11.5 14.843 60.29c-5.193 6.555-.525 16.21 7.838 16.21H74" />
</g>
<g filter="url(#missing_svg_d)">
<path stroke="#FFAE62" strokeLinecap="round" strokeOpacity="0.5" strokeWidth="20.671" d="M231 12v90" />
</g>
<g filter="url(#missing_svg_e)">
<path stroke="#FFAE62" strokeLinecap="round" strokeOpacity="0.5" strokeWidth="20.671" d="m231.5 11.5-38.657 48.79c-5.193 6.555-.525 16.21 7.838 16.21H252" />
</g>
<g filter="url(#missing_svg_f)">
<circle cx="231.089" cy="101.089" r="6.291" stroke="#fff" strokeWidth="3.595" />
</g>
<circle cx="231.089" cy="101.089" r="7.19" stroke="#fff" strokeWidth="1.798" />
<g filter="url(#missing_svg_g)">
<path fill="#2F3640" d="m231.748 101.191 16.029 1.702c.874.093 1.188 1.204.492 1.741l-5.784 4.455a.97.97 0 0 0-.344.511l-1.954 7.034c-.235.847-1.383.974-1.798.199l-7.604-14.214a.974.974 0 0 1 .963-1.428" />
<path stroke="#fff" strokeLinecap="round" strokeLinejoin="round" strokeWidth="0.723" d="m231.748 101.191 16.029 1.702c.874.093 1.188 1.204.492 1.741l-5.784 4.455a.97.97 0 0 0-.344.511l-1.954 7.034c-.235.847-1.383.974-1.798.199l-7.604-14.214a.974.974 0 0 1 .963-1.428" />
</g>
<g filter="url(#missing_svg_h)">
<ellipse cx="131.5" cy="57" stroke="#FFAE62" strokeLinecap="round" strokeOpacity="0.5" strokeWidth="20.671" rx="29.5" ry="40" />
</g>
</g>
<defs>
<filter id="missing_svg_b" width="20.672" height="110.672" x="42.664" y="1.664" colorInterpolationFilters="sRGB" filterUnits="userSpaceOnUse">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
<feOffset />
<feGaussianBlur stdDeviation="4.494" />
<feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic" />
<feColorMatrix values="0 0 0 0 0.623529 0 0 0 0 0.458824 0 0 0 0 0.309804 0 0 0 0.2 0" />
<feBlend in2="shape" result="innerShadow" />
</filter>
<filter id="missing_svg_c" width="82.027" height="85.672" x="2.309" y="1.164" colorInterpolationFilters="sRGB" filterUnits="userSpaceOnUse">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
<feOffset />
<feGaussianBlur stdDeviation="4.494" />
<feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic" />
<feColorMatrix values="0 0 0 0 0.623529 0 0 0 0 0.458824 0 0 0 0 0.309804 0 0 0 0.2 0" />
<feBlend in2="shape" result="innerShadow" />
</filter>
<filter id="missing_svg_d" width="20.672" height="110.672" x="220.664" y="1.664" colorInterpolationFilters="sRGB" filterUnits="userSpaceOnUse">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
<feOffset />
<feGaussianBlur stdDeviation="4.494" />
<feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic" />
<feColorMatrix values="0 0 0 0 0.623529 0 0 0 0 0.458824 0 0 0 0 0.309804 0 0 0 0.2 0" />
<feBlend in2="shape" result="innerShadow" />
</filter>
<filter id="missing_svg_e" width="82.027" height="85.672" x="180.309" y="1.164" colorInterpolationFilters="sRGB" filterUnits="userSpaceOnUse">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
<feOffset />
<feGaussianBlur stdDeviation="4.494" />
<feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic" />
<feColorMatrix values="0 0 0 0 0.623529 0 0 0 0 0.458824 0 0 0 0 0.309804 0 0 0 0.2 0" />
<feBlend in2="shape" result="innerShadow" />
</filter>
<filter id="missing_svg_f" width="26.601" height="26.603" x="217.787" y="87.787" colorInterpolationFilters="sRGB" filterUnits="userSpaceOnUse">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur result="foregroundBlur" stdDeviation="2.606" />
</filter>
<filter id="missing_svg_g" width="30.279" height="28.458" x="224.519" y="100.822" colorInterpolationFilters="sRGB" filterUnits="userSpaceOnUse">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
<feOffset dy="5.786" />
<feGaussianBlur stdDeviation="2.893" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
<feBlend in2="BackgroundImageFix" result="dropShadow" />
<feBlend in="SourceGraphic" in2="dropShadow" result="shape" />
</filter>
<filter id="missing_svg_h" width="79.672" height="100.672" x="91.664" y="6.664" colorInterpolationFilters="sRGB" filterUnits="userSpaceOnUse">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
<feOffset />
<feGaussianBlur stdDeviation="4.494" />
<feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic" />
<feColorMatrix values="0 0 0 0 0.623529 0 0 0 0 0.458824 0 0 0 0 0.309804 0 0 0 0.2 0" />
<feBlend in2="shape" result="innerShadow" />
</filter>
<clipPath id="missing_svg_a">
<path fill="#fff" d="M0 0h279v128H0z" />
</clipPath>
</defs>
</svg>
);
}
+271
View File
@@ -1,6 +1,7 @@
@import "./components/ProjectThumb/index.css";
@import "./components/ProjectCard/index.css";
@import "./pages/ProjectsPage/index.css";
@import "./pages/NotFoundPage/index.css";
@import "./components/DeleteProjectDialog/index.css";
@import "./components/AccountManagementDialog/index.css";
@import "./components/WorkspaceTitle/index.css";
@@ -889,6 +890,276 @@ button:disabled {
font-weight: 800;
}
.project-auth-shell {
width: 100vw;
height: 100vh;
overflow: hidden;
background: #f7f7f4;
}
.project-auth-blur {
width: 100%;
height: 100%;
overflow: hidden;
filter: blur(18px);
opacity: 0.66;
transform: scale(1.04);
transform-origin: center;
pointer-events: none;
user-select: none;
}
.project-auth-topbar {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
border-bottom: 1px solid #eceef1;
background: rgba(255, 255, 255, 0.86);
}
.project-auth-title,
.project-auth-top-actions,
.project-auth-panel-head,
.project-auth-toolbar,
.project-auth-composer div {
display: flex;
align-items: center;
}
.project-auth-title {
gap: 10px;
}
.project-auth-mark {
width: 18px;
height: 26px;
color: #151515;
}
.project-auth-title span,
.project-auth-thread b,
.project-auth-thread span,
.project-auth-composer span {
display: block;
border-radius: 999px;
background: #e1e3e6;
}
.project-auth-title span {
width: 168px;
height: 14px;
}
.project-auth-top-actions {
gap: 8px;
}
.project-auth-top-actions i,
.project-auth-panel-head i,
.project-auth-toolbar i,
.project-auth-composer i {
display: block;
border-radius: 999px;
background: #e3e5e8;
}
.project-auth-top-actions i {
width: 28px;
height: 28px;
}
.project-auth-top-actions b {
width: 24px;
height: 24px;
display: block;
border-radius: 999px;
background: #151515;
}
.project-auth-main {
height: calc(100vh - 48px);
display: grid;
grid-template-columns: minmax(0, 1fr) 400px;
}
.project-auth-stage {
position: relative;
min-width: 0;
overflow: hidden;
background: #f7f7f4;
}
.project-auth-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(38, 45, 57, 0.055) 1px, transparent 1px),
linear-gradient(90deg, rgba(38, 45, 57, 0.055) 1px, transparent 1px);
background-size: 38px 38px;
}
.project-auth-frame {
position: absolute;
border: 1px solid rgba(189, 193, 201, 0.9);
border-radius: 8px;
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 18px 58px rgba(17, 24, 39, 0.08);
}
.project-auth-frame.primary {
left: 18%;
top: 20%;
width: 260px;
height: 180px;
}
.project-auth-frame.secondary {
left: 48%;
top: 34%;
width: 220px;
height: 150px;
}
.project-auth-frame.tertiary {
left: 31%;
top: 58%;
width: 180px;
height: 118px;
}
.project-auth-toolbar {
position: absolute;
left: 50%;
bottom: 28px;
gap: 10px;
padding: 9px 12px;
border: 1px solid #e5e7eb;
border-radius: 16px;
background: rgba(255, 255, 255, 0.9);
transform: translateX(-50%);
box-shadow: 0 18px 48px rgba(17, 24, 39, 0.1);
}
.project-auth-toolbar i {
width: 26px;
height: 26px;
}
.project-auth-panel {
min-width: 0;
display: flex;
flex-direction: column;
border-left: 1px solid #eceef1;
background: rgba(255, 255, 255, 0.88);
}
.project-auth-panel-head {
height: 56px;
gap: 10px;
padding: 0 18px;
}
.project-auth-panel-head span {
width: 184px;
height: 16px;
display: block;
margin-right: auto;
border-radius: 999px;
background: #dfe2e6;
}
.project-auth-panel-head i {
width: 24px;
height: 24px;
}
.project-auth-thread {
display: flex;
flex-direction: column;
gap: 14px;
padding: 28px 22px;
}
.project-auth-thread b {
width: 62%;
height: 18px;
}
.project-auth-thread span {
height: 12px;
}
.project-auth-thread span:nth-child(2) {
width: 86%;
}
.project-auth-thread span:nth-child(3) {
width: 72%;
}
.project-auth-thread span:nth-child(4) {
width: 48%;
}
.project-auth-composer {
margin: auto 18px 20px;
padding: 18px;
border: 1px solid #e3e5e9;
border-radius: 18px;
background: #fff;
box-shadow: 0 20px 60px rgba(17, 24, 39, 0.08);
}
.project-auth-composer span {
width: 72%;
height: 14px;
margin-bottom: 42px;
}
.project-auth-composer div {
justify-content: space-between;
}
.project-auth-composer i {
width: 28px;
height: 28px;
}
@media (max-width: 760px) {
.project-auth-main {
grid-template-columns: minmax(0, 1fr);
}
.project-auth-panel {
display: none;
}
.project-auth-title span {
width: 112px;
}
.project-auth-frame.primary {
left: 14%;
top: 23%;
width: 190px;
height: 138px;
}
.project-auth-frame.secondary {
left: 42%;
top: 48%;
width: 150px;
height: 108px;
}
.project-auth-frame.tertiary {
display: none;
}
}
.workspace-shell {
width: 100vw;
height: 100vh;
+46 -12
View File
@@ -72,6 +72,10 @@ type DesignService struct {
now func() time.Time
}
type agentTaskCancelHandle struct {
cancel context.CancelFunc
}
type GenerateResult struct {
Project design.Project
Message design.Message
@@ -537,14 +541,7 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
if err != nil {
return design.AgentThread{}, err
}
if threadIsCancelled(project.Messages, threadID) || s.isAgentTaskCancelled(project.ID, threadID) {
return design.AgentThread{
ThreadID: threadID,
Status: "cancelled",
ThreadIDType: threadIDType,
Project: project,
}, nil
}
s.clearAgentTaskCancellation(project.ID, threadID)
now := s.now()
actionID := newID()
@@ -1700,7 +1697,7 @@ func (s *DesignService) isUserCancelledGeneration(ctx context.Context, projectID
if getErr != nil {
return false
}
return threadIsCancelled(project.Messages, threadID)
return threadHasCancelledMessage(project.Messages, threadID)
}
func detachedUserContext(ctx context.Context) context.Context {
@@ -1737,13 +1734,16 @@ func (s *DesignService) withAgentTaskCancellation(ctx context.Context, projectID
return ctx, func() {}
}
taskCtx, cancel := context.WithCancel(ctx)
s.agentTaskCancels.Store(key, context.CancelFunc(cancel))
handle := &agentTaskCancelHandle{cancel: cancel}
s.agentTaskCancels.Store(key, handle)
if s.isAgentTaskCancelled(projectID, threadID) {
cancel()
}
return taskCtx, func() {
cancel()
s.agentTaskCancels.Delete(key)
if current, ok := s.agentTaskCancels.Load(key); ok && current == handle {
s.agentTaskCancels.Delete(key)
}
}
}
@@ -1754,7 +1754,9 @@ func (s *DesignService) cancelAgentTask(projectID string, threadID string) {
}
s.cancelledAgentTasks.Store(key, s.now())
if cancel, ok := s.agentTaskCancels.Load(key); ok {
if cancelFunc, ok := cancel.(context.CancelFunc); ok {
if handle, ok := cancel.(*agentTaskCancelHandle); ok && handle != nil {
handle.cancel()
} else if cancelFunc, ok := cancel.(context.CancelFunc); ok {
cancelFunc()
}
}
@@ -1763,6 +1765,14 @@ func (s *DesignService) cancelAgentTask(projectID string, threadID string) {
})
}
func (s *DesignService) clearAgentTaskCancellation(projectID string, threadID string) {
key := agentTaskKey(projectID, threadID)
if key == "" {
return
}
s.cancelledAgentTasks.Delete(key)
}
func (s *DesignService) isAgentTaskCancelled(projectID string, threadID string) bool {
key := agentTaskKey(projectID, threadID)
if key == "" {
@@ -3122,6 +3132,30 @@ func (s *DesignService) applyIncrementalGeneratedNode(ctx context.Context, proje
}
func threadIsCancelled(messages []design.Message, threadID string) bool {
threadID = strings.TrimSpace(threadID)
if threadID == "" {
return false
}
for index := len(messages) - 1; index >= 0; index-- {
message := messages[index]
if strings.TrimSpace(message.ThreadID) != threadID {
continue
}
if strings.EqualFold(strings.TrimSpace(message.Status), "cancelled") {
return true
}
if strings.EqualFold(strings.TrimSpace(message.Role), "user") {
return false
}
switch strings.ToLower(strings.TrimSpace(message.Status)) {
case "running", "streaming", "submitted", "success", "failed":
return false
}
}
return false
}
func threadHasCancelledMessage(messages []design.Message, threadID string) bool {
threadID = strings.TrimSpace(threadID)
if threadID == "" {
return false
@@ -64,6 +64,42 @@ func TestMarkGenerationFailedRemovesCanvasPlaceholders(t *testing.T) {
}
}
func TestMarkGenerationFailedDoesNotExposeInternalEndpoint(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
service := NewDesignService(repo, nil, nil, nil, nil)
project := design.Project{
ID: "project-safe-error",
Title: "Safe failure",
Brief: "prompt",
Status: design.StatusExploring,
UpdatedAt: time.Now(),
Nodes: []design.Node{
{ID: "placeholder", Type: design.NodeTypeImage, Title: "生成中", Content: "prompt", Status: "generating"},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
failure := errors.New(`gpt image 2 generation failed: Post "http://154.21.87.3:8080/v1/images/generations": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`)
if err := service.markGenerationFailed(ctx, project.ID, failure); err != nil {
t.Fatal(err)
}
saved, err := repo.Get(ctx, project.ID)
if err != nil {
t.Fatal(err)
}
content := saved.Messages[len(saved.Messages)-1].Content
if strings.Contains(content, "154.21.87.3") || strings.Contains(content, "/v1/images/generations") || strings.Contains(content, "Client.Timeout") {
t.Fatalf("expected sanitized user-facing failure, got %q", content)
}
if !strings.Contains(content, "图片生成耗时较久") {
t.Fatalf("expected friendly timeout message, got %q", content)
}
}
func TestMarkGenerationFailedKeepsRealImageNodeActionTarget(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
@@ -895,7 +931,7 @@ func TestCreateProjectGenerationPartialFailurePreservesSuccessfulImagesAndCloses
if message.Name == "generate_media" && message.Status == "success" {
foundArtifact = true
}
if message.Role == "error" && message.Title == "图片生成部分失败" && strings.Contains(message.Content, "image 2") {
if message.Role == "error" && message.Title == "图片生成部分失败" && strings.Contains(message.Content, "已保留成功结果") {
foundError = true
}
}
@@ -1154,8 +1190,8 @@ func TestCreateProjectGenerationFailureUsesJobUserContext(t *testing.T) {
t.Fatalf("expected failed project, got %s", saved.Status)
}
last := saved.Messages[len(saved.Messages)-1]
if last.Role != "error" || last.Content != "upstream image response timed out" {
t.Fatalf("expected original failure message for job user, got %#v", last)
if last.Role != "error" || strings.Contains(last.Content, "upstream") || !strings.Contains(last.Content, "画布内容已保留") {
t.Fatalf("expected safe failure message for job user, got %#v", last)
}
}
@@ -1486,9 +1522,10 @@ func TestStopAgentTaskClearsAgentPlaceholdersWithoutStoppingImageGenerator(t *te
}
}
func TestAgentChatDoesNotRestartAlreadyCancelledThread(t *testing.T) {
func TestAgentChatContinuesStoppedThreadWithNewTask(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
queue := &fakeJobQueue{}
service := NewDesignService(repo, nil, nil, nil, nil, fakeCreativeChatAgent{
decision: design.AgentDecision{Intent: "image"},
taskPlan: design.AgentTaskPlan{
@@ -1498,6 +1535,7 @@ func TestAgentChatDoesNotRestartAlreadyCancelledThread(t *testing.T) {
ImageTasks: []design.AgentImageTask{{Title: "视觉方案", Brief: "视觉方案"}},
},
})
service.SetJobQueue(queue)
project := design.Project{
ID: "project-cancelled-thread",
Title: "Cancelled",
@@ -1511,6 +1549,7 @@ func TestAgentChatDoesNotRestartAlreadyCancelledThread(t *testing.T) {
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
service.cancelAgentTask(project.ID, "thread-cancelled")
thread, err := service.AgentChat(ctx, project.ID, design.AgentChatRequest{
ThreadID: "thread-cancelled",
@@ -1526,14 +1565,20 @@ func TestAgentChatDoesNotRestartAlreadyCancelledThread(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if thread.Status != "cancelled" {
t.Fatalf("expected cancelled thread response, got %s", thread.Status)
if thread.Status != "running" {
t.Fatalf("expected stopped thread to accept a new running task, got %s", thread.Status)
}
saved, err := repo.Get(ctx, project.ID)
if err != nil {
t.Fatal(err)
}
if len(saved.Nodes) != 0 || len(saved.Messages) != 1 {
t.Fatalf("expected cancelled thread not to create new work, got nodes=%#v messages=%#v", saved.Nodes, saved.Messages)
if len(saved.Nodes) != 1 || saved.Nodes[0].Status != "generating" {
t.Fatalf("expected new image task placeholder, got %#v", saved.Nodes)
}
if len(saved.Messages) <= 1 || saved.Messages[len(saved.Messages)-1].Status != "success" {
t.Fatalf("expected new thread messages after cancelled task, got %#v", saved.Messages)
}
if len(queue.jobs) != 1 || queue.jobs[0].Kind != design.JobFollowupGeneration {
t.Fatalf("expected new followup generation job, got %#v", queue.jobs)
}
}
@@ -1,6 +1,7 @@
package piagent
import (
"bufio"
"bytes"
"context"
"encoding/base64"
@@ -28,6 +29,7 @@ const (
defaultImageGenerationTimeout = 10 * time.Minute
defaultImageGenerationResponseHeaderTimeout = 4 * time.Minute
maxImageGenerationTimeout = 10 * time.Minute
imageGenerationStreamMaxLineBytes = 64 << 20
)
type ImageGenerator interface {
@@ -132,70 +134,95 @@ func (c *ImageClient) Generate(ctx context.Context, prompt string, opts ImageReq
images := normalizeImageInputs(opts.Images)
idempotencyKey := strings.TrimSpace(opts.IdempotencyKey)
body, contentType, err := c.imageRequestBody(ctx, model, prompt, size, count, images)
if err != nil {
return GeneratedImage{}, err
streamPlans := []bool{false}
if len(images) == 0 {
streamPlans = []bool{true, false}
}
var lastErr error
startedAt := time.Now()
promptRunes := utf8.RuneCountInString(prompt)
for attempt := 0; attempt < 3; attempt++ {
image, err := c.generateOnce(ctx, body, contentType, idempotencyKey, len(images) > 0)
if err == nil {
logx.Infof(
"gpt image generation succeeded model=%s size=%s count=%d prompt_runes=%d attempt=%d elapsed=%s timeout=%s images=%d idempotency_key=%s",
for _, stream := range streamPlans {
body, contentType, err := c.imageRequestBody(ctx, model, prompt, size, count, images, stream)
if err != nil {
return GeneratedImage{}, err
}
for attempt := 0; attempt < 3; attempt++ {
image, err := c.generateOnce(ctx, body, contentType, idempotencyKey, len(images) > 0, stream, count)
if err == nil {
logx.Infof(
"gpt image generation succeeded model=%s size=%s count=%d prompt_runes=%d attempt=%d stream=%t elapsed=%s timeout=%s images=%d idempotency_key=%s",
model,
size,
count,
promptRunes,
attempt+1,
stream,
time.Since(startedAt).Round(time.Millisecond),
c.client.Timeout,
len(image.URLs),
idempotencyKey,
)
return image, nil
}
if ctx.Err() != nil {
return GeneratedImage{}, ctx.Err()
}
lastErr = err
if stream && isImageGenerationStreamFallbackError(err) {
logx.Errorf(
"gpt image generation stream fallback model=%s size=%s count=%d prompt_runes=%d attempt=%d elapsed=%s idempotency_key=%s err=%v",
model,
size,
count,
promptRunes,
attempt+1,
time.Since(startedAt).Round(time.Millisecond),
idempotencyKey,
err,
)
break
}
timeoutRetryable := idempotencyKey != "" && isImageGenerationTimeoutError(err)
retryable := isRetryableImageGenerationError(err) || timeoutRetryable
terminal := isImageGenerationTerminalError(err) && !timeoutRetryable
logx.Errorf(
"gpt image generation failed model=%s size=%s count=%d prompt_runes=%d attempt=%d stream=%t elapsed=%s timeout=%s retryable=%t terminal=%t idempotency_key=%s err=%v",
model,
size,
count,
promptRunes,
attempt+1,
stream,
time.Since(startedAt).Round(time.Millisecond),
c.client.Timeout,
len(image.URLs),
retryable,
terminal,
idempotencyKey,
err,
)
return image, nil
if !retryable || attempt == 2 {
break
}
select {
case <-ctx.Done():
return GeneratedImage{}, ctx.Err()
case <-time.After(time.Duration(attempt+1) * 1500 * time.Millisecond):
}
}
if ctx.Err() != nil {
return GeneratedImage{}, ctx.Err()
}
lastErr = err
timeoutRetryable := idempotencyKey != "" && isImageGenerationTimeoutError(err)
retryable := isRetryableImageGenerationError(err) || timeoutRetryable
terminal := isImageGenerationTerminalError(err) && !timeoutRetryable
logx.Errorf(
"gpt image generation failed model=%s size=%s count=%d prompt_runes=%d attempt=%d elapsed=%s timeout=%s retryable=%t terminal=%t idempotency_key=%s err=%v",
model,
size,
count,
promptRunes,
attempt+1,
time.Since(startedAt).Round(time.Millisecond),
c.client.Timeout,
retryable,
terminal,
idempotencyKey,
err,
)
if !retryable || attempt == 2 {
if !(stream && shouldFallbackImageGenerationStream(lastErr, idempotencyKey)) {
break
}
select {
case <-ctx.Done():
return GeneratedImage{}, ctx.Err()
case <-time.After(time.Duration(attempt+1) * 1500 * time.Millisecond):
}
}
return GeneratedImage{}, lastErr
}
func (c *ImageClient) imageRequestBody(ctx context.Context, model string, prompt string, size string, count int, images []string) ([]byte, string, error) {
func (c *ImageClient) imageRequestBody(ctx context.Context, model string, prompt string, size string, count int, images []string, stream bool) ([]byte, string, error) {
if len(images) > 0 {
if c.inputImageTransport == "url" {
return jsonImageEditBody(model, prompt, size, count, images)
return jsonImageEditBody(model, prompt, size, count, images, stream)
}
return c.multipartImageEditBody(ctx, model, prompt, size, count, images)
return c.multipartImageEditBody(ctx, model, prompt, size, count, images, stream)
}
requestPayload := map[string]any{
@@ -208,11 +235,14 @@ func (c *ImageClient) imageRequestBody(ctx context.Context, model string, prompt
if size != "" {
requestPayload["size"] = size
}
if stream {
requestPayload["stream"] = true
}
body, err := json.Marshal(requestPayload)
return body, "application/json", err
}
func jsonImageEditBody(model string, prompt string, size string, count int, images []string) ([]byte, string, error) {
func jsonImageEditBody(model string, prompt string, size string, count int, images []string, stream bool) ([]byte, string, error) {
requestPayload := map[string]any{
"model": model,
"prompt": prompt,
@@ -224,11 +254,14 @@ func jsonImageEditBody(model string, prompt string, size string, count int, imag
if size != "" {
requestPayload["size"] = size
}
if stream {
requestPayload["stream"] = true
}
body, err := json.Marshal(requestPayload)
return body, "application/json", err
}
func (c *ImageClient) multipartImageEditBody(ctx context.Context, model string, prompt string, size string, count int, images []string) ([]byte, string, error) {
func (c *ImageClient) multipartImageEditBody(ctx context.Context, model string, prompt string, size string, count int, images []string, stream bool) ([]byte, string, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
fields := map[string]string{
@@ -241,6 +274,9 @@ func (c *ImageClient) multipartImageEditBody(ctx context.Context, model string,
if count > 1 {
fields["n"] = strconv.Itoa(count)
}
if stream {
fields["stream"] = "true"
}
for key, value := range fields {
if err := writer.WriteField(key, value); err != nil {
return nil, "", err
@@ -413,7 +449,7 @@ func imageExtension(contentType string, fallback string) string {
}
}
func (c *ImageClient) generateOnce(ctx context.Context, body []byte, contentType string, idempotencyKey string, edit bool) (GeneratedImage, error) {
func (c *ImageClient) generateOnce(ctx context.Context, body []byte, contentType string, idempotencyKey string, edit bool, stream bool, expectedCount int) (GeneratedImage, error) {
endpoint := "/v1/images/generations"
if edit {
endpoint = "/v1/images/edits"
@@ -425,6 +461,9 @@ func (c *ImageClient) generateOnce(ctx context.Context, body []byte, contentType
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", contentType)
req.Header.Set("Connection", "close")
if stream {
req.Header.Set("Accept", "text/event-stream")
}
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
@@ -437,16 +476,21 @@ func (c *ImageClient) generateOnce(ctx context.Context, body []byte, contentType
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var detail struct {
Error any `json:"error"`
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if readErr != nil {
return GeneratedImage{}, readErr
}
_ = json.NewDecoder(resp.Body).Decode(&detail)
if detail.Error != nil {
return GeneratedImage{}, fmt.Errorf("image generation failed: %s: %v", resp.Status, detail.Error)
detail := imageErrorBody(respBody)
if detail != "" {
return GeneratedImage{}, fmt.Errorf("image generation failed: %s: %s", resp.Status, detail)
}
return GeneratedImage{}, fmt.Errorf("image generation failed: %s", resp.Status)
}
if stream {
return decodeImageGenerationStreamOrJSON(resp.Body, expectedCount)
}
var responsePayload any
if err := json.NewDecoder(resp.Body).Decode(&responsePayload); err != nil {
return GeneratedImage{}, err
@@ -459,6 +503,180 @@ func (c *ImageClient) generateOnce(ctx context.Context, body []byte, contentType
return GeneratedImage{URL: urls[0], URLs: urls}, nil
}
type imageStreamEvent struct {
name string
dataLines []string
hasData bool
}
func decodeImageGenerationStreamOrJSON(body io.Reader, expectedCount int) (GeneratedImage, error) {
if expectedCount <= 0 {
expectedCount = 1
}
scanner := bufio.NewScanner(body)
scanner.Buffer(make([]byte, 0, 64*1024), imageGenerationStreamMaxLineBytes)
var raw strings.Builder
event := imageStreamEvent{name: "message"}
sawEvents := false
urls := make([]string, 0, expectedCount)
seen := make(map[string]struct{})
appendURLs := func(values []string) {
for _, value := range values {
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
urls = append(urls, value)
}
}
flush := func() (bool, error) {
if !event.hasData {
event = imageStreamEvent{name: "message"}
return false, nil
}
sawEvents = true
name := event.name
dataText := strings.TrimSpace(strings.Join(event.dataLines, "\n"))
event = imageStreamEvent{name: "message"}
if dataText == "" {
return false, nil
}
if dataText == "[DONE]" {
return true, nil
}
if strings.Contains(strings.ToLower(name), "partial_image") {
return false, nil
}
var payload any
if err := json.Unmarshal([]byte(dataText), &payload); err != nil {
return false, nil
}
if isPartialImagePayload(payload) {
return false, nil
}
if message := imageStreamFailureMessage(payload); message != "" {
return true, fmt.Errorf("image generation stream failed: %s", message)
}
appendURLs(collectGeneratedImageURLs(payload))
return len(urls) >= expectedCount, nil
}
for scanner.Scan() {
line := strings.TrimSuffix(scanner.Text(), "\r")
trimmed := strings.TrimSpace(line)
switch {
case line == "":
done, err := flush()
if err != nil {
return GeneratedImage{}, err
}
if done && len(urls) > 0 {
return GeneratedImage{URL: urls[0], URLs: urls}, nil
}
case strings.HasPrefix(line, ":"):
continue
case strings.HasPrefix(line, "event:"):
event.name = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
event.hasData = true
event.dataLines = append(event.dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
default:
if !sawEvents && trimmed != "" {
raw.WriteString(line)
raw.WriteByte('\n')
}
}
}
if err := scanner.Err(); err != nil {
return GeneratedImage{}, err
}
done, err := flush()
if err != nil {
return GeneratedImage{}, err
}
if len(urls) > 0 {
return GeneratedImage{URL: urls[0], URLs: urls}, nil
}
if sawEvents || done {
return GeneratedImage{}, fmt.Errorf("image generation stream returned no complete image")
}
var responsePayload any
if err := json.Unmarshal([]byte(raw.String()), &responsePayload); err != nil {
return GeneratedImage{}, err
}
urls = collectGeneratedImageURLs(responsePayload)
if len(urls) == 0 {
return GeneratedImage{}, fmt.Errorf("image generation returned empty image")
}
return GeneratedImage{URL: urls[0], URLs: urls}, nil
}
func imageErrorBody(body []byte) string {
body = bytes.TrimSpace(body)
if len(body) == 0 {
return ""
}
var detail struct {
Error any `json:"error"`
}
if err := json.Unmarshal(body, &detail); err == nil && detail.Error != nil {
return fmt.Sprint(detail.Error)
}
return trimErrorBody(body)
}
func isPartialImagePayload(payload any) bool {
item, ok := payload.(map[string]any)
if !ok {
return false
}
if value, ok := item["type"].(string); ok && strings.Contains(strings.ToLower(value), "partial_image") {
return true
}
return false
}
func imageStreamFailureMessage(payload any) string {
item, ok := payload.(map[string]any)
if !ok {
return ""
}
if message := imageErrorMessage(item["error"]); message != "" {
return message
}
typeValue, _ := item["type"].(string)
statusValue, _ := item["status"].(string)
if strings.Contains(strings.ToLower(typeValue), "failed") || strings.EqualFold(statusValue, "failed") {
if message, ok := item["message"].(string); ok && strings.TrimSpace(message) != "" {
return strings.TrimSpace(message)
}
return "upstream reported failed status"
}
return ""
}
func imageErrorMessage(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(typed)
case map[string]any:
for _, key := range []string{"message", "detail", "type", "code"} {
if message, ok := typed[key].(string); ok && strings.TrimSpace(message) != "" {
return strings.TrimSpace(message)
}
}
return fmt.Sprint(typed)
default:
return fmt.Sprint(typed)
}
}
func collectGeneratedImageURLs(payload any) []string {
urls := make([]string, 0)
seen := make(map[string]struct{})
@@ -516,13 +734,36 @@ func isGeneratedImageURLKey(key string) bool {
func isGeneratedImageBase64Key(key string) bool {
switch key {
case "b64_json", "b64json", "base64", "image_base64", "imagebase64":
case "b64_json", "b64json", "base64", "image_base64", "imagebase64", "result":
return true
default:
return false
}
}
func isImageGenerationStreamFallbackError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "unknown parameter") && strings.Contains(message, "stream") ||
strings.Contains(message, "unsupported") && strings.Contains(message, "stream") ||
strings.Contains(message, "not support") && strings.Contains(message, "stream") ||
strings.Contains(message, "stream returned no complete image") ||
strings.Contains(message, "incomplete stream") ||
strings.Contains(message, "text/event-stream")
}
func shouldFallbackImageGenerationStream(err error, idempotencyKey string) bool {
if isImageGenerationStreamFallbackError(err) {
return true
}
if strings.TrimSpace(idempotencyKey) == "" {
return false
}
return isRetryableImageGenerationError(err) || isImageGenerationTimeoutError(err)
}
func isRetryableImageGenerationError(err error) bool {
if err == nil {
return false
@@ -97,6 +97,9 @@ func TestImageClientSendsTransportDefaultSizeWhenUnset(t *testing.T) {
if requestBody["size"] != "1024x1024" {
t.Fatalf("expected transport default size, got %#v", requestBody["size"])
}
if requestBody["stream"] != true {
t.Fatalf("expected image generations to use stream transport, got %#v", requestBody["stream"])
}
if _, ok := requestBody["n"]; ok {
t.Fatalf("expected single image request to omit n for compatible transports, got %#v", requestBody["n"])
}
@@ -306,6 +309,112 @@ func TestImageClientParsesAlternateImageResponseShapes(t *testing.T) {
}
}
func TestImageClientParsesStreamingImageResponse(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
t.Fatal(err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: image_generation.completed\n"))
_, _ = w.Write([]byte(`data: {"type":"image_generation.completed","result":"Z2VuZXJhdGVkIGJ5dGVz"}` + "\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer server.Close()
client := NewImageClient(ImageClientOptions{BaseURL: server.URL, APIKey: "test-key"})
image, err := client.Generate(context.Background(), "create an image", ImageRequestOptions{Model: "gpt-image-2"})
if err != nil {
t.Fatal(err)
}
if requestBody["stream"] != true {
t.Fatalf("expected stream request body, got %#v", requestBody)
}
if image.URL != "data:image/png;base64,Z2VuZXJhdGVkIGJ5dGVz" {
t.Fatalf("expected streamed base64 image, got %#v", image)
}
}
func TestImageClientFallsBackWhenStreamIsUnsupported(t *testing.T) {
calls := 0
streamValues := make([]any, 0, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
var requestBody map[string]any
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
t.Fatal(err)
}
streamValues = append(streamValues, requestBody["stream"])
if calls == 1 {
http.Error(w, `{"error":{"message":"unknown parameter: stream"}}`, http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"url":"https://example.com/image.png"}]}`))
}))
defer server.Close()
client := NewImageClient(ImageClientOptions{BaseURL: server.URL, APIKey: "test-key"})
image, err := client.Generate(context.Background(), "create an image", ImageRequestOptions{Model: "gpt-image-2"})
if err != nil {
t.Fatal(err)
}
if image.URL != "https://example.com/image.png" {
t.Fatalf("expected fallback image, got %#v", image)
}
if calls != 2 {
t.Fatalf("expected stream then json fallback, got %d calls", calls)
}
if len(streamValues) != 2 || streamValues[0] != true || streamValues[1] != nil {
t.Fatalf("expected first request stream=true and fallback without stream, got %#v", streamValues)
}
}
func TestImageClientFallsBackAfterIdempotentStreamFailures(t *testing.T) {
calls := 0
streamValues := make([]any, 0, 4)
keys := make([]string, 0, 4)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
keys = append(keys, r.Header.Get("Idempotency-Key"))
var requestBody map[string]any
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
t.Fatal(err)
}
streamValues = append(streamValues, requestBody["stream"])
if calls <= 3 {
http.Error(w, `{"error":{"message":"temporary stream failure"}}`, http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"url":"https://example.com/recovered.png"}]}`))
}))
defer server.Close()
client := NewImageClient(ImageClientOptions{BaseURL: server.URL, APIKey: "test-key"})
image, err := client.Generate(context.Background(), "create an image", ImageRequestOptions{
Model: "gpt-image-2",
IdempotencyKey: "same-generation",
})
if err != nil {
t.Fatal(err)
}
if image.URL != "https://example.com/recovered.png" {
t.Fatalf("expected fallback image, got %#v", image)
}
if calls != 4 {
t.Fatalf("expected three stream attempts then json fallback, got %d calls", calls)
}
if len(streamValues) != 4 || streamValues[0] != true || streamValues[1] != true || streamValues[2] != true || streamValues[3] != nil {
t.Fatalf("expected stream retries then json fallback, got %#v", streamValues)
}
for _, key := range keys {
if key != "same-generation" {
t.Fatalf("expected idempotency key to be preserved, got %#v", keys)
}
}
}
func TestImageClientRetriesTransientFailures(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+40 -3
View File
@@ -198,7 +198,12 @@ func projectThreadState(messages []design.Message, threadID string) (started boo
if threadID == "" {
return false, false, false
}
for _, message := range messages {
startIndex := latestThreadTaskStartIndex(messages, threadID)
if startIndex < 0 {
return false, false, false
}
for index := startIndex; index < len(messages); index++ {
message := messages[index]
if strings.TrimSpace(message.ThreadID) != threadID {
continue
}
@@ -211,7 +216,7 @@ func projectThreadState(messages []design.Message, threadID string) (started boo
failed = true
}
}
if hasUnfinishedThreadWork(messages, threadID) {
if hasUnfinishedThreadWorkFrom(messages, threadID, startIndex) {
finished = false
}
return started, finished, failed
@@ -222,8 +227,16 @@ func hasUnfinishedThreadWork(messages []design.Message, threadID string) bool {
if threadID == "" {
return false
}
return hasUnfinishedThreadWorkFrom(messages, threadID, latestThreadTaskStartIndex(messages, threadID))
}
func hasUnfinishedThreadWorkFrom(messages []design.Message, threadID string, startIndex int) bool {
if startIndex < 0 {
return false
}
running := make(map[string]bool)
for _, message := range messages {
for index := startIndex; index < len(messages); index++ {
message := messages[index]
if strings.TrimSpace(message.ThreadID) != threadID {
continue
}
@@ -242,6 +255,30 @@ func hasUnfinishedThreadWork(messages []design.Message, threadID string) bool {
return len(running) > 0
}
func latestThreadTaskStartIndex(messages []design.Message, threadID string) int {
threadID = strings.TrimSpace(threadID)
if threadID == "" {
return -1
}
firstIndex := -1
latestUserIndex := -1
for index, message := range messages {
if strings.TrimSpace(message.ThreadID) != threadID {
continue
}
if firstIndex < 0 {
firstIndex = index
}
if strings.EqualFold(strings.TrimSpace(message.Role), "user") {
latestUserIndex = index
}
}
if latestUserIndex >= 0 {
return latestUserIndex
}
return firstIndex
}
func threadWorkKey(message design.Message) string {
role := strings.ToLower(strings.TrimSpace(message.Role))
if role == "" {
@@ -100,6 +100,18 @@ func TestProjectThreadStateFinishesAfterGenerationToolSucceeds(t *testing.T) {
}
}
func TestProjectThreadStateKeepsNewTaskOpenAfterStoppedTask(t *testing.T) {
started, finished, failed := projectThreadState([]design.Message{
{ID: "old-user", Role: "user", Type: "user", Content: "生成三张图", ThreadID: "thread-agent", CreatedAt: time.Now().Add(-3 * time.Second)},
{ID: "old-stop", Role: "assistant", Type: "assistant", Content: "已停止", ThreadID: "thread-agent", Status: "cancelled", CreatedAt: time.Now().Add(-2 * time.Second)},
{ID: "new-user", Role: "user", Type: "user", Content: "继续生成一张", ThreadID: "thread-agent", CreatedAt: time.Now().Add(-time.Second)},
{ID: "planner", Role: "assistant", Type: "assistant", Name: "agent_planner", ThreadID: "thread-agent", Status: "success", CreatedAt: time.Now()},
}, "thread-agent")
if !started || finished || failed {
t.Fatalf("expected new task to keep stopped thread open, got started=%v finished=%v failed=%v", started, finished, failed)
}
}
func TestProjectEventFingerprintIgnoresMessageOnlyChanges(t *testing.T) {
now := time.Now()
project := design.Project{