Compare commits
6 Commits
7e05a8d47b
...
e9cfadfb90
| Author | SHA1 | Date | |
|---|---|---|---|
| e9cfadfb90 | |||
| c73e51e400 | |||
| 4e98a81aae | |||
| 92343ef1f6 | |||
| d13a4c9a48 | |||
| 7679854b5d |
Vendored
+1
-1
@@ -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.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ClientRoot } from "@/app/root";
|
||||
|
||||
export default function CatchAllPage() {
|
||||
return <ClientRoot />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ClientRoot } from "@/app/root";
|
||||
|
||||
export default function ProjectPage() {
|
||||
return <ClientRoot />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ClientRoot } from "@/app/root";
|
||||
|
||||
export default function ProjectPage() {
|
||||
return <ClientRoot />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ClientRoot } from "@/app/root";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
return <ClientRoot />;
|
||||
}
|
||||
@@ -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 = "/";
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -103,6 +103,9 @@
|
||||
"promo": "限时优惠,立享5折 · Image-2 低至 $0.004/张,专注图片生成与编辑",
|
||||
"upgradeNow": "立即升级",
|
||||
"backHome": "返回首页",
|
||||
"notFoundTitle": "404:页面未找到!",
|
||||
"notFoundDescription": "我们到处都找过了,但找不到您要找的页面。",
|
||||
"notFoundIllustration": "404 页面未找到",
|
||||
"save": "保存",
|
||||
"share": "分享",
|
||||
"shareLinkCopied": "分享链接已复制",
|
||||
|
||||
@@ -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
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user