@@ -835,7 +834,6 @@ function AgentTimelineMarker({ theme, tone, icon }: { theme: (typeof canvasTheme
const color = tone === "error" ? "#ef4444" : tone === "approval" ? "#f97316" : tone === "tool" ? "#4f7cff" : tone === "agent" ? theme.accent.primary : theme.node.muted;
return (
-
{icon || }
diff --git a/web/src/components/canvas/canvas-cloud-agent.css b/web/src/components/canvas/canvas-cloud-agent.css
index 38bbe76c8..645f48331 100644
--- a/web/src/components/canvas/canvas-cloud-agent.css
+++ b/web/src/components/canvas/canvas-cloud-agent.css
@@ -96,19 +96,18 @@
}
.agent-tool-row {
- /* 行为用图标和文案区分,颜色只留给真实的成功/失败状态。 */
+ /* 日志是事件流,不是连续卡片;只用间距和状态图标建立层次。 */
--agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent);
- min-height: 42px;
- border: 1px solid color-mix(in srgb, var(--foreground) 11%, transparent);
- border-radius: 14px;
- background: color-mix(in srgb, var(--foreground) 2.5%, var(--background));
- padding: 9px 10px;
- transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease;
+ min-height: 36px;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ padding: 5px 2px;
+ transition: background-color 160ms ease, transform 160ms ease;
}
.agent-tool-row:hover {
- border-color: color-mix(in srgb, var(--foreground) 20%, transparent);
- background: color-mix(in srgb, var(--foreground) 4%, var(--background));
+ background: color-mix(in srgb, var(--foreground) 3%, transparent);
}
/* 没有节点明细的“读取信息 / 操作画布”只保留时间线语义,不再撑成一张空卡片。 */
@@ -151,11 +150,11 @@
flex: 0 0 auto;
align-items: center;
gap: 5px;
- border: 1px solid color-mix(in srgb, var(--foreground) 16%, transparent);
- border-radius: 999px;
- background: color-mix(in srgb, var(--foreground) 5%, transparent);
- padding: 2px 7px 2px 6px;
- color: color-mix(in srgb, var(--foreground) 76%, transparent);
+ border: 0;
+ border-radius: 5px;
+ background: color-mix(in srgb, var(--agent-tool-accent) 9%, transparent);
+ padding: 2px 5px;
+ color: color-mix(in srgb, var(--foreground) 68%, transparent);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.01em;
@@ -235,10 +234,18 @@
.agent-composer-prompt-scroll {
min-height: 60px;
max-height: min(240px, 34vh);
+ min-width: 0;
overflow-y: auto;
+ overflow-x: hidden;
scrollbar-gutter: stable;
}
+.agent-composer-prompt-scroll > [data-canvas-no-zoom] {
+ min-height: 0;
+ height: 100%;
+ max-height: 100%;
+}
+
.agent-composer-resize-handle {
display: grid;
width: 100%;
@@ -398,17 +405,15 @@
.agent-reasoning-card {
min-width: 0;
overflow: hidden;
- border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
- border-radius: 14px;
- background: color-mix(in srgb, var(--foreground) 2.5%, var(--background));
- box-shadow: 0 5px 18px color-mix(in srgb, var(--foreground) 3%, transparent);
- transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease;
+ border: 0;
+ border-radius: 10px;
+ background: transparent;
+ box-shadow: none;
+ transition: background-color 160ms ease;
}
.agent-reasoning-card[open] {
- border-color: color-mix(in srgb, var(--agent-reasoning-accent) 28%, transparent);
background: color-mix(in srgb, var(--agent-reasoning-accent) 3.5%, var(--background));
- box-shadow: 0 8px 24px color-mix(in srgb, var(--agent-reasoning-accent) 7%, transparent);
}
.agent-reasoning-summary {
@@ -418,7 +423,7 @@
gap: 9px;
cursor: pointer;
list-style: none;
- padding: 9px 10px;
+ padding: 6px 4px;
color: color-mix(in srgb, var(--foreground) 72%, transparent);
user-select: none;
}
@@ -437,9 +442,9 @@
height: 25px;
flex: 0 0 25px;
place-items: center;
- border: 1px solid color-mix(in srgb, var(--agent-reasoning-accent) 24%, transparent);
- border-radius: 8px;
- background: color-mix(in srgb, var(--agent-reasoning-accent) 11%, transparent);
+ border: 0;
+ border-radius: 50%;
+ background: color-mix(in srgb, var(--agent-reasoning-accent) 10%, transparent);
color: var(--agent-reasoning-accent);
}
@@ -477,10 +482,10 @@
flex: 0 0 auto;
align-items: center;
gap: 5px;
- border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
- border-radius: 999px;
- background: color-mix(in srgb, var(--foreground) 4%, transparent);
- padding: 2px 7px;
+ border: 0;
+ border-radius: 5px;
+ background: transparent;
+ padding: 2px 4px;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
font-size: 10px;
font-weight: 600;
@@ -488,7 +493,6 @@
}
.agent-reasoning-status.is-live {
- border-color: color-mix(in srgb, var(--agent-reasoning-accent) 22%, transparent);
background: color-mix(in srgb, var(--agent-reasoning-accent) 9%, transparent);
color: var(--agent-reasoning-accent);
}
@@ -524,21 +528,13 @@
position: relative;
display: flex;
max-height: min(232px, 30vh);
- gap: 10px;
+ gap: 0;
overflow: auto;
- border-top: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
- padding: 10px 12px 12px 14px;
+ border-top: 0;
+ padding: 4px 10px 10px 38px;
scrollbar-gutter: stable;
}
-.agent-reasoning-rail {
- width: 2px;
- flex: 0 0 2px;
- align-self: stretch;
- border-radius: 999px;
- background: linear-gradient(180deg, color-mix(in srgb, var(--agent-reasoning-accent) 72%, transparent), color-mix(in srgb, var(--agent-reasoning-accent) 12%, transparent));
-}
-
.agent-reasoning-text {
min-width: 0;
color: color-mix(in srgb, var(--foreground) 58%, transparent);
@@ -873,170 +869,194 @@
gap: 8px;
}
+.canvas-agent-skill-library-modal .ant-modal {
+ padding-bottom: 0;
+ margin-top: var(--space-10);
+}
+
.canvas-agent-skill-library-modal .ant-modal-container {
- overflow: hidden;
- border: 1px solid var(--border);
- border-radius: var(--r-2xl);
- padding: 0;
- background: transparent;
- box-shadow: 0 12px 36px rgba(0, 0, 0, 0.18), var(--elevation-hairline) !important;
+ border: 0;
+ border-radius: var(--r-5xl);
+ background: var(--background);
+ box-shadow: var(--elevation-overlay);
}
-.canvas-agent-skill-library-modal .ant-modal-body {
- padding: 0;
+.canvas-agent-skill-library-frame {
+ position: relative;
}
-.canvas-agent-skill-library-modal .ant-modal-close {
- top: 16px;
- right: 16px;
- width: 34px;
- height: 34px;
+.canvas-agent-skill-library-close {
+ position: absolute;
+ top: calc(-1 * var(--space-10));
+ inset-inline-start: var(--space-2);
+ inset-inline-end: auto;
+ width: auto;
+ height: var(--space-8);
border-radius: var(--r-md);
+ padding: 0 var(--space-2);
color: var(--foreground);
- background: color-mix(in srgb, var(--foreground) 7%, transparent);
+ background: transparent;
+ overflow: visible;
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--fs-body);
}
-.canvas-agent-skill-library-modal .ant-modal-close:hover {
- background: color-mix(in srgb, var(--foreground) 12%, transparent);
+.canvas-agent-skill-library-close kbd {
+ border-radius: var(--r-xs);
+ background: var(--primary);
+ padding: var(--space-half) var(--space-2);
+ color: var(--primary-foreground);
+ font-size: var(--fs-caption);
}
.canvas-agent-skill-library-shell {
- position: relative;
display: grid;
- height: min(780px, calc(100dvh - 36px));
- grid-template-rows: auto auto auto minmax(0, 1fr) auto;
+ height: min(700px, 66dvh);
+ max-height: calc(100dvh - 88px);
+ grid-template-columns: 176px minmax(0, 1fr);
overflow: hidden;
border-radius: inherit;
- background:
- radial-gradient(circle at 0 0, color-mix(in srgb, var(--workspace-accent) 7%, transparent), transparent 34%),
- var(--cn-spatial-elevated, var(--background));
+}
+
+.canvas-agent-skill-library-sidebar {
+ display: flex;
+ min-height: 0;
+ min-width: 0;
+ flex-direction: column;
+ gap: var(--space-5);
+ margin: var(--space-6) 0;
+ border-right: 1px solid var(--border);
+ padding: 0 var(--space-3);
+}
+
+.canvas-agent-skill-library-search.ant-input-affix-wrapper {
+ flex: 0 0 auto;
+ height: var(--space-12);
+ border: 0;
+ border-radius: var(--r-full);
+ background: var(--library-surface);
+ padding-inline: var(--space-4);
+ font-size: var(--fs-body);
+}
+
+.canvas-agent-skill-library-search .ant-input-prefix {
+ margin-inline-end: var(--space-2);
+}
+
+.canvas-agent-skill-library-categories {
+ display: flex;
+ min-height: 0;
+ flex-direction: column;
+ gap: var(--space-1);
+ overflow-y: auto;
+ padding: var(--space-1);
+ overscroll-behavior: contain;
+}
+
+.canvas-agent-skill-library-tab {
+ display: flex;
+ min-height: var(--space-12);
+ flex: 0 0 auto;
+ align-items: center;
+ gap: var(--space-3);
+ border: 0;
+ border-radius: var(--r-lg);
+ padding: var(--space-3);
+ color: var(--muted-foreground);
+ font-size: var(--fs-body-lg);
+ text-align: left;
+ overflow-wrap: anywhere;
+ transition: background-color var(--motion-dur-fast) ease, color var(--motion-dur-fast) ease;
+}
+
+.canvas-agent-skill-library-tab::before {
+ width: var(--space-1);
+ height: var(--space-1);
+ flex: 0 0 auto;
+ border-radius: var(--r-full);
+ background: currentColor;
+ content: "";
+ opacity: 0.4;
+}
+
+.canvas-agent-skill-library-tab span {
+ margin-left: auto;
+ color: var(--muted-foreground);
+ font-size: var(--fs-caption);
+ font-variant-numeric: tabular-nums;
+}
+
+.canvas-agent-skill-library-tab:hover {
+ background: var(--library-surface-hover);
+ color: var(--foreground);
+}
+
+.canvas-agent-skill-library-tab.is-active {
+ color: var(--foreground);
+ font-weight: 650;
+}
+
+.canvas-agent-skill-library-tab.is-active::before {
+ opacity: 1;
+}
+
+.canvas-agent-skill-library-content {
+ container: agent-skill-content / inline-size;
+ display: flex;
+ min-width: 0;
+ min-height: 0;
+ flex-direction: column;
}
.canvas-agent-skill-library-header {
display: flex;
min-width: 0;
+ flex: 0 0 auto;
align-items: center;
justify-content: space-between;
- gap: 20px;
- padding: 18px 64px 14px 20px;
+ gap: var(--space-4);
+ padding: var(--space-5) var(--space-6);
}
-.canvas-agent-skill-library-header p {
- margin-bottom: 0;
+.canvas-agent-skill-library-header h2 {
+ margin: 0;
+ font-size: var(--fs-title);
+ font-weight: 650;
}
.canvas-agent-skill-library-count {
flex: 0 0 auto;
- border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
- border-radius: var(--r-lg);
- background: color-mix(in srgb, var(--foreground) 4%, transparent);
- padding: 7px 10px;
- font-size: var(--fs-label);
+ border: 0;
+ border-radius: var(--r-full);
+ background: var(--library-surface);
+ padding: var(--space-2-half) var(--space-4);
+ color: var(--foreground);
+ font-size: var(--fs-caption);
font-variant-numeric: tabular-nums;
}
-.canvas-agent-skill-library-toolbar {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(280px, 430px);
- align-items: center;
- gap: 14px;
- padding: 0 20px 12px;
+.canvas-agent-skill-library-count:hover {
+ background: var(--library-surface-hover);
}
-.canvas-agent-skill-library-tabs {
- display: flex;
- min-width: 0;
- align-items: center;
- gap: 4px;
-}
-
-.canvas-agent-skill-library-tab {
- display: inline-flex;
- min-height: 38px;
- flex: 0 0 auto;
- align-items: center;
- gap: 7px;
- border-radius: var(--r-md);
- padding: 0 12px;
- color: color-mix(in srgb, var(--foreground) 56%, transparent);
- font-size: var(--fs-caption);
- font-weight: 560;
- transition: background-color var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out);
-}
-
-.canvas-agent-skill-library-tab span {
- min-width: 20px;
- border-radius: 999px;
- background: color-mix(in srgb, currentColor 9%, transparent);
- padding: 1px 6px;
- font-size: var(--fs-tiny);
- line-height: 18px;
- text-align: center;
-}
-
-.canvas-agent-skill-library-tab:hover {
- background: color-mix(in srgb, var(--foreground) 4%, transparent);
- color: color-mix(in srgb, var(--foreground) 76%, transparent);
-}
-
-.canvas-agent-skill-library-tab.is-active {
- background: color-mix(in srgb, var(--foreground) 5%, transparent);
- color: color-mix(in srgb, var(--foreground) 78%, transparent);
-}
-
-.canvas-agent-skill-library-search {
- height: 40px;
- border-radius: var(--r-lg);
-}
-
-.canvas-agent-skill-library-categories {
- display: flex;
- min-width: 0;
- gap: 8px;
- overflow-x: auto;
- border-top: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
- border-bottom: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
- padding: 10px 20px;
- overscroll-behavior-x: contain;
- scrollbar-width: none;
-}
-
-.canvas-agent-skill-library-categories::-webkit-scrollbar {
- display: none;
-}
-
-.canvas-agent-skill-category {
- min-height: 34px;
- flex: 0 0 auto;
- border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
- border-radius: var(--r-md);
- padding: 0 13px;
- color: color-mix(in srgb, var(--foreground) 58%, transparent);
- font-size: var(--fs-caption);
- transition: border-color var(--motion-dur-fast) var(--motion-ease-out), background-color var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out);
-}
-
-.canvas-agent-skill-category:hover {
- border-color: color-mix(in srgb, var(--foreground) 20%, transparent);
- color: var(--foreground);
-}
-
-.canvas-agent-skill-category.is-active {
- border-color: color-mix(in srgb, var(--workspace-accent) 34%, var(--border));
- background: color-mix(in srgb, var(--workspace-accent) 11%, transparent);
- color: var(--workspace-accent);
+.canvas-agent-skill-library-count span {
+ color: var(--muted-foreground);
}
.canvas-agent-skill-library-list {
display: grid;
min-height: 0;
- grid-template-columns: repeat(2, minmax(0, 1fr));
+ flex: 1;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: max-content;
align-content: start;
- gap: 12px;
+ gap: var(--space-6) var(--space-4);
overflow-y: auto;
- padding: 16px 20px;
+ padding: var(--space-1) var(--space-6) var(--space-6);
overscroll-behavior: contain;
+ scrollbar-gutter: stable;
}
.canvas-agent-skill-library-state {
@@ -1045,54 +1065,52 @@
grid-column: 1 / -1;
place-items: center;
align-content: center;
- gap: 10px;
- padding: 28px;
- font-size: var(--fs-caption);
+ gap: var(--space-3);
+ padding: var(--space-6);
+ font-size: var(--fs-body);
text-align: center;
}
.canvas-agent-skill-card {
- display: grid;
min-width: 0;
- min-height: 136px;
- grid-template-columns: 164px minmax(0, 1fr) auto;
- gap: 14px;
- overflow: hidden;
- border: 1px solid var(--border);
- border-radius: var(--r-xl);
- background: color-mix(in srgb, var(--foreground) 3%, transparent);
- padding: 11px;
- transition: border-color var(--motion-dur-fast) var(--motion-ease-out), background-color var(--motion-dur-fast) var(--motion-ease-out), transform var(--motion-dur-fast) var(--motion-ease-out);
+ border: 0;
+ border-radius: var(--r-2xl);
+ transition: transform var(--motion-state) var(--motion-ease-out), background-color var(--motion-state) ease;
}
-.canvas-agent-skill-card:hover {
- border-color: color-mix(in srgb, var(--foreground) 22%, transparent) !important;
- background: color-mix(in srgb, var(--foreground) 5%, transparent);
- transform: translateY(-1px);
+.canvas-agent-skill-card:hover,
+.canvas-agent-skill-card:focus-within {
+ transform: translateY(var(--library-hover-offset));
+ background: var(--library-surface-hover);
}
-.canvas-agent-skill-card.is-selected {
- background: color-mix(in srgb, var(--workspace-accent) 7%, transparent);
- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--workspace-accent) 24%, transparent);
+.canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img,
+.canvas-agent-skill-card:focus-within .canvas-agent-skill-card-cover img {
+ transform: scale(1.025);
}
.canvas-agent-skill-card-cover {
position: relative;
min-width: 0;
overflow: hidden;
- border-radius: var(--r-lg);
- aspect-ratio: 16 / 10;
+ border-radius: var(--r-2xl);
+ aspect-ratio: 16 / 9;
+ background: var(--library-cover-surface);
+ transition: outline-color var(--motion-dur-fast) ease;
}
-.canvas-agent-skill-card-cover img {
+.canvas-agent-skill-card.is-selected .canvas-agent-skill-card-cover {
+ outline: 1px solid var(--library-selected-stroke);
+ outline-offset: 2px;
+}
+
+.canvas-agent-skill-card-cover img,
+.canvas-agent-skill-card-cover video {
+ display: block;
width: 100%;
height: 100%;
object-fit: cover;
- transition: transform var(--motion-dur-base) var(--motion-ease-out);
-}
-
-.canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img {
- transform: scale(1.025);
+ transition: transform var(--motion-state) var(--motion-ease-out);
}
.canvas-agent-skill-card-placeholder {
@@ -1100,258 +1118,242 @@
width: 100%;
height: 100%;
place-items: center;
- color: color-mix(in srgb, var(--workspace-accent) 64%, var(--foreground));
- background:
- radial-gradient(circle at 30% 20%, color-mix(in srgb, var(--workspace-accent) 28%, transparent), transparent 46%),
- linear-gradient(145deg, color-mix(in srgb, var(--foreground) 8%, transparent), color-mix(in srgb, var(--foreground) 2%, transparent));
+ color: var(--muted-foreground);
+ background: var(--library-cover-surface);
}
.canvas-agent-skill-card-category {
position: absolute;
- top: 7px;
- right: 7px;
- max-width: calc(100% - 14px);
+ bottom: var(--space-3);
+ left: var(--space-3);
+ max-width: calc(100% - var(--space-6));
overflow: hidden;
- border: 1px solid color-mix(in srgb, white 18%, transparent);
- border-radius: var(--r-md);
- background: color-mix(in srgb, black 62%, transparent);
- padding: 2px 7px;
- color: white;
+ border-radius: var(--r-sm);
+ background: color-mix(in srgb, var(--color-neutral-950) 75%, transparent);
+ padding: var(--space-1) var(--space-2);
+ color: var(--color-neutral-0);
font-size: var(--fs-tiny);
- line-height: 19px;
text-overflow: ellipsis;
white-space: nowrap;
backdrop-filter: blur(8px);
}
+.canvas-agent-skill-card-selected {
+ position: absolute;
+ top: var(--space-3);
+ right: var(--space-3);
+ display: grid;
+ width: var(--space-8);
+ height: var(--space-8);
+ place-items: center;
+ border-radius: var(--r-full);
+ color: var(--control-selected-fg);
+ background: var(--control-selected-bg);
+}
+
.canvas-agent-skill-card-body {
display: flex;
min-width: 0;
flex-direction: column;
- padding: 4px 0 2px;
+ gap: var(--space-2);
+ padding-top: var(--space-3);
}
.canvas-agent-skill-card-title-row {
display: flex;
min-width: 0;
- align-items: baseline;
- gap: 8px;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
}
.canvas-agent-skill-card-title-row h3 {
min-width: 0;
overflow: hidden;
margin: 0;
- color: var(--foreground);
- font-size: 15px;
+ color: inherit;
+ font-size: var(--fs-heading);
font-weight: 650;
- line-height: 22px;
+ line-height: 1.5;
text-overflow: ellipsis;
white-space: nowrap;
}
-.canvas-agent-skill-card-version {
- flex: 0 0 auto;
- color: color-mix(in srgb, var(--foreground) 42%, transparent);
- font-size: var(--fs-tiny);
-}
-
.canvas-agent-skill-card-body > p {
display: -webkit-box;
min-width: 0;
overflow: hidden;
- margin: 7px 0 10px;
- color: color-mix(in srgb, var(--foreground) 54%, transparent);
- font-size: var(--fs-label);
- line-height: 19px;
+ margin: 0;
+ color: var(--muted-foreground);
+ font-size: var(--fs-body);
+ line-height: 1.65;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
+ overflow-wrap: anywhere;
}
.canvas-agent-skill-card-meta {
display: flex;
min-width: 0;
align-items: center;
- gap: 6px;
- margin-top: auto;
- font-size: var(--fs-label);
+ gap: var(--space-2);
+ font-size: var(--fs-caption);
+}
+
+.canvas-agent-skill-card-version {
+ flex: 0 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.canvas-agent-skill-card-action {
- display: flex;
- align-items: flex-start;
- padding-top: 3px;
+ flex: 0 0 auto;
}
.canvas-agent-skill-card-action .ant-btn {
- min-width: 76px;
- border-radius: var(--r-md);
+ min-width: var(--space-16);
+ height: var(--space-8);
+ border: 0;
+ border-radius: var(--r-full);
+ background: var(--primary);
+ padding-inline: var(--space-3);
+ color: var(--primary-foreground);
+ box-shadow: none;
+ font-size: var(--fs-body);
}
-.canvas-agent-skill-card-action .ant-btn.is-selected {
- border-color: color-mix(in srgb, var(--workspace-accent) 32%, var(--border));
- background: color-mix(in srgb, var(--workspace-accent) 13%, transparent);
- color: var(--workspace-accent);
- box-shadow: none;
+.canvas-agent-skill-card-action .ant-btn:not(:disabled):hover {
+ background: color-mix(in srgb, var(--primary) 85%, var(--background));
+ color: var(--primary-foreground);
+}
+
+.canvas-agent-skill-card-action .ant-btn.is-selected,
+.canvas-agent-skill-card-action .ant-btn.is-selected:not(:disabled):hover {
+ background: var(--control-selected-bg);
+ color: var(--control-selected-fg);
+}
+
+.canvas-agent-skill-card-action .ant-btn:disabled {
+ background: var(--library-surface);
+ color: var(--muted-foreground);
+ opacity: 0.6;
}
.canvas-agent-skill-library-footer {
display: flex;
- min-height: 48px;
+ min-height: var(--space-12);
+ flex: 0 0 auto;
align-items: center;
justify-content: space-between;
- gap: 16px;
- border-top: 1px solid;
- padding: 8px 20px;
- font-size: var(--fs-label);
+ gap: var(--space-4);
+ padding: var(--space-2) var(--space-6);
+ font-size: var(--fs-caption);
}
.canvas-agent-skill-library-tab:focus-visible,
-.canvas-agent-skill-category:focus-visible {
+.canvas-agent-skill-library-close:focus-visible,
+.canvas-agent-skill-library-count:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
-@media (max-width: 920px) {
+@container agent-skill-content (max-width: 660px) {
.canvas-agent-skill-library-list {
- grid-template-columns: minmax(0, 1fr);
+ grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
-@media (max-width: 680px) {
+@media (max-width: 900px) {
+ .canvas-agent-skill-library-modal .ant-modal {
+ width: calc(100vw - 32px) !important;
+ }
+
.canvas-agent-skill-library-shell {
- height: calc(100dvh - 24px);
+ height: min(700px, calc(100dvh - 88px));
}
- .canvas-agent-skill-library-header {
- align-items: flex-start;
- gap: 10px;
- padding: 16px 56px 12px 14px;
+ .canvas-agent-skill-library-header,
+ .canvas-agent-skill-library-list,
+ .canvas-agent-skill-library-footer {
+ padding-inline: var(--space-5);
}
- .canvas-agent-skill-library-header p {
- display: none;
+ .canvas-agent-skill-library-sidebar {
+ padding-inline: var(--space-3);
+ }
+}
+
+@media (max-width: 639px) {
+ .canvas-agent-skill-library-modal .ant-modal-container {
+ border-radius: var(--r-4xl);
}
- .canvas-agent-skill-library-count {
- padding: 5px 8px;
- }
-
- .canvas-agent-skill-library-toolbar {
+ .canvas-agent-skill-library-shell {
grid-template-columns: minmax(0, 1fr);
- gap: 8px;
- padding: 0 14px 10px;
+ grid-template-rows: auto minmax(0, 1fr);
}
- .canvas-agent-skill-library-tabs {
- overflow-x: auto;
- scrollbar-width: none;
+ .canvas-agent-skill-library-sidebar {
+ gap: var(--space-2);
+ margin: 0;
+ border-right: 0;
+ padding: var(--space-4) var(--space-4) 0;
}
- .canvas-agent-skill-library-tabs::-webkit-scrollbar {
- display: none;
+ .canvas-agent-skill-library-search.ant-input-affix-wrapper {
+ height: var(--space-10);
}
.canvas-agent-skill-library-categories {
- padding: 8px 14px;
- }
-
- .canvas-agent-skill-library-list {
- gap: 10px;
- padding: 12px 14px;
- }
-
- .canvas-agent-skill-library-footer {
- min-height: 44px;
- padding: 6px 14px;
- }
-}
-
-@media (max-width: 560px) {
- .canvas-agent-skill-library-header {
- flex-wrap: wrap;
- }
-
- .canvas-agent-skill-library-count {
- position: absolute;
- top: 17px;
- right: 56px;
+ flex-direction: row;
+ gap: var(--space-1);
+ overflow-x: auto;
+ overscroll-behavior-x: contain;
}
.canvas-agent-skill-library-tab {
- min-height: 34px;
- padding: 0 9px;
- font-size: var(--fs-label);
- }
-
- .canvas-agent-skill-card {
- min-height: 122px;
- grid-template-columns: 104px minmax(0, 1fr);
- grid-template-rows: minmax(0, 1fr) auto;
- gap: 8px 10px;
- padding: 9px;
- }
-
- .canvas-agent-skill-card-cover {
- grid-row: 1 / 3;
- align-self: stretch;
- aspect-ratio: auto;
- }
-
- .canvas-agent-skill-card-body {
- padding: 1px 0 0;
- }
-
- .canvas-agent-skill-card-body > p {
- margin: 4px 0 6px;
- font-size: var(--fs-tiny);
- line-height: 17px;
- }
-
- .canvas-agent-skill-card-meta {
- font-size: var(--fs-tiny);
- }
-
- .canvas-agent-skill-card-action {
- justify-content: flex-end;
- padding: 0;
- }
-
- .canvas-agent-skill-card-action .ant-btn {
- min-width: 72px;
- }
-
- .canvas-agent-skill-library-footer > span:last-child {
- display: none;
- }
-}
-
-@media (max-height: 560px) {
- .canvas-agent-skill-library-shell {
- height: calc(100dvh - 16px);
+ min-height: var(--space-10);
+ gap: var(--space-2);
+ padding: var(--space-2);
+ font-size: var(--fs-body);
+ white-space: nowrap;
}
.canvas-agent-skill-library-header {
- padding-top: 12px;
- padding-bottom: 8px;
+ padding: var(--space-4);
}
- .canvas-agent-skill-library-categories {
- padding-top: 7px;
- padding-bottom: 7px;
+ .canvas-agent-skill-library-header h2 {
+ font-size: var(--fs-heading-lg);
+ }
+
+ .canvas-agent-skill-library-list {
+ grid-template-columns: minmax(0, 1fr);
+ gap: var(--space-6);
+ padding-inline: var(--space-4);
+ }
+
+ .canvas-agent-skill-library-footer {
+ gap: var(--space-2);
+ padding-inline: var(--space-4);
+ font-size: var(--fs-label);
}
}
@media (prefers-reduced-motion: reduce) {
.canvas-agent-skill-card,
+ .canvas-agent-skill-card-cover,
.canvas-agent-skill-card-cover img,
- .canvas-agent-skill-category,
+ .canvas-agent-skill-card-cover video,
.canvas-agent-skill-library-tab {
transition: none;
}
.canvas-agent-skill-card:hover,
- .canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img {
+ .canvas-agent-skill-card:focus-within,
+ .canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img,
+ .canvas-agent-skill-card:focus-within .canvas-agent-skill-card-cover img {
transform: none;
}
}
diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx
index 03a01ff0d..228786735 100644
--- a/web/src/components/canvas/canvas-node-prompt-panel.tsx
+++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx
@@ -9,7 +9,7 @@ import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-genera
import { clampPromptEditorModalSize, PROMPT_EDITOR_VIEWPORT_MARGIN } from "@/lib/canvas/canvas-prompt-editor-size";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { canvasThemes } from "@/lib/canvas-theme";
-import { modelQuoteRequest } from "@/lib/model-pricing";
+import { modelQuoteDescription, modelQuoteRequest } from "@/lib/model-pricing";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection";
import { navigateToSettings } from "@/lib/settings-navigation";
@@ -30,7 +30,7 @@ import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins
import { createPluginHostContext } from "@/services/plugin-host";
import { usePluginStore } from "@/stores/use-plugin-store";
import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references";
-import { quoteLogicalModel } from "@/services/api/logical-models";
+import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models";
export type CanvasNodeGenerationMode = CanvasGenerationMode;
@@ -146,8 +146,8 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
});
const quoteRequest = modelQuoteRequest(config, config.model, mode, resolvedRequirements);
const quoteRequestKey = JSON.stringify(quoteRequest || null);
- const [quotedCredits, setQuotedCredits] = useState(null);
- const credits = quotedCredits ?? configuredCredits;
+ const [routeQuote, setRouteQuote] = useState(null);
+ const credits = routeQuote ? routeQuote.amountMicrocredits / 1_000_000 : configuredCredits;
const activeReferenceCount = activeReferences.length;
const videoFrameOptions = resolvedMentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl }));
const hasVideoPromptTools = mode === "video" && !simpleMode && videoFrameOptions.length > 0;
@@ -210,15 +210,15 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
useEffect(() => {
if (!creditsEnabled || !quoteRequest) {
- setQuotedCredits(null);
+ setRouteQuote(null);
return;
}
const controller = new AbortController();
- setQuotedCredits(null);
- quoteLogicalModel(quoteRequest.logicalModelID, quoteRequest.intent, controller.signal)
- .then(({ quote }) => setQuotedCredits(quote.amountMicrocredits / 1_000_000))
+ setRouteQuote(null);
+ quoteModel(quoteRequest, controller.signal)
+ .then(({ quote }) => setRouteQuote(quote))
.catch(() => {
- if (!controller.signal.aborted) setQuotedCredits(null);
+ if (!controller.signal.aborted) setRouteQuote(null);
});
return () => controller.abort();
// quoteRequestKey captures the full normalized request without retriggering on object identity.
@@ -352,12 +352,12 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
}
onClick={() => (expanded ? submitExpandedPrompt() : submit())}
aria-label={actionLabel}
- title={actionLabel}
+ title={routeQuote ? modelQuoteDescription(routeQuote) : actionLabel}
>
{showCost ? (
- {formattedCredits}
+ {routeQuote?.estimated ? "预计 " : ""}{formattedCredits}
) : null}
diff --git a/web/src/components/canvas/canvas-project-asset-modal.tsx b/web/src/components/canvas/canvas-project-asset-modal.tsx
index 77551138e..c168a7caf 100644
--- a/web/src/components/canvas/canvas-project-asset-modal.tsx
+++ b/web/src/components/canvas/canvas-project-asset-modal.tsx
@@ -66,7 +66,7 @@ export function CanvasProjectAssetModal({
mediaKind,
asset: media,
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : project?.mediaType === "image" && remoteResourceId ? resourceFileUrl(remoteResourceId) : undefined,
- imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
+ imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : remoteResourceId ? `resource:${remoteResourceId}` : undefined,
imageFit: character ? "contain" : "cover",
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText,
searchText: [media?.tags?.join(" ") || "", project?.previewText || ""].join(" "),
diff --git a/web/src/components/canvas/canvas-project-card.tsx b/web/src/components/canvas/canvas-project-card.tsx
index 7ac0188a7..fb9c6e0bb 100644
--- a/web/src/components/canvas/canvas-project-card.tsx
+++ b/web/src/components/canvas/canvas-project-card.tsx
@@ -1,7 +1,7 @@
import { Check, Clapperboard, CloudUpload, Download, FileText, Frame, Image as ImageIcon, MoreHorizontal, Music2, Pencil, Plus, Settings2, Sparkles, Trash2, Video, X } from "lucide-react";
import type { ReactNode } from "react";
import { useNavigate, useSearchParams } from "react-router";
-import { Dropdown, Input } from "antd";
+import { App, Dropdown, Input } from "antd";
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
@@ -13,6 +13,7 @@ import { CachedResourceImage } from "@/components/cached-resource-image";
import { MediaPlaceholder } from "@/components/ui/product/media-placeholder";
import { cn } from "@/lib/utils";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
+import { hasRemoteUserDataSyncSession, loadCanvasProjectForEditing, saveRemoteUserDataNow } from "@/services/user-data-sync";
type ProjectPreviewMedia = { node: CanvasNodeData; url: string; storageKey?: string };
const projectPreviewMediaCache = new WeakMap();
@@ -30,6 +31,7 @@ export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; on
}
export function CanvasProjectCard({ project, projectName, variant = "library", readOnly = false, footer }: { project: CanvasProject; projectName?: string; variant?: "library" | "recent"; readOnly?: boolean; footer?: ReactNode }) {
+ const { message } = App.useApp();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const renameProject = useCanvasStore((state) => state.renameProject);
@@ -44,9 +46,19 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r
const editing = editingId === project.id;
const selected = selectedIds.includes(project.id);
const open = () => navigate(`/canvas/${project.id}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`);
- const saveTitle = () => {
- renameProject(project.id, editingTitle);
+ const saveTitle = async () => {
stopEditing();
+ if (!hasRemoteUserDataSyncSession()) {
+ renameProject(project.id, editingTitle);
+ return;
+ }
+ try {
+ await loadCanvasProjectForEditing(project.id);
+ renameProject(project.id, editingTitle);
+ await saveRemoteUserDataNow(project.id);
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "重命名失败");
+ }
};
const compact = variant === "recent";
diff --git a/web/src/components/canvas/canvas-resource-mention-textarea.tsx b/web/src/components/canvas/canvas-resource-mention-textarea.tsx
index 735bb3597..857701848 100644
--- a/web/src/components/canvas/canvas-resource-mention-textarea.tsx
+++ b/web/src/components/canvas/canvas-resource-mention-textarea.tsx
@@ -350,7 +350,15 @@ export const CanvasResourceMentionTextarea = forwardRef {
composingRef.current = true;
@@ -489,7 +497,7 @@ if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.curr
}}
value={value}
className={`${className || ""} relative z-10`}
- style={mergedStyle}
+ style={{ ...mergedStyle, height: "100%", minHeight: 0, maxHeight: "100%", overflowY: "auto", overflowX: "hidden" }}
onChange={(event) => {
const next = event.target.value;
onChange(next);
diff --git a/web/src/lib/canvas/canvas-image-batch-retry.ts b/web/src/lib/canvas/canvas-image-batch-retry.ts
index e0fc87f2b..a24d68e3d 100644
--- a/web/src/lib/canvas/canvas-image-batch-retry.ts
+++ b/web/src/lib/canvas/canvas-image-batch-retry.ts
@@ -135,7 +135,20 @@ export function reconcileImageBatchRoot(root: CanvasNodeData, nodes: CanvasNodeD
const children = (root.metadata.batchChildIds || [])
.map((id) => nodeById.get(id))
.filter((node): node is CanvasNodeData => Boolean(node && node.type === CanvasNodeType.Image && node.metadata?.batchRootId === root.id));
- if (!children.length) return root;
+ if (!children.length) {
+ const metadata: CanvasNodeMetadata = { ...root.metadata };
+ delete metadata.batchChildIds;
+ delete metadata.batchFailedCount;
+ delete metadata.primaryImageId;
+ delete metadata.isBatchRoot;
+ delete metadata.imageBatchExpanded;
+ delete metadata.errorDetails;
+ delete metadata.generationErrorCode;
+ delete metadata.resourceReloadAvailable;
+ delete metadata.failedPromptFingerprint;
+ if (!metadata.content) metadata.status = "idle";
+ return { ...root, metadata };
+ }
const primary = children.find((node) => node.id === root.metadata?.primaryImageId && node.metadata?.content) || children.find((node) => node.metadata?.content);
const loading = children.some((node) => node.metadata?.status === "loading");
diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts
index 382a8a0cf..090dcf6f9 100644
--- a/web/src/lib/canvas/canvas-project-domain.ts
+++ b/web/src/lib/canvas/canvas-project-domain.ts
@@ -10,6 +10,7 @@ import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { canvasNodeMentionToken, canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { batchReferenceHandleY } from "@/lib/canvas/canvas-batch-table";
+import { reconcileImageBatchRoot } from "@/lib/canvas/canvas-image-batch-retry";
import { scopedLocalStorage } from "@/lib/user-scope";
import type { GenerationTask } from "@/services/api/task-center";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ConnectionHandle, type Position, type StoryboardColumn, type StoryboardRow } from "@/types/canvas";
@@ -430,7 +431,7 @@ export function removeCanvasNodes(nodes: CanvasNodeData[], requestedIds: Set removedIds.add(childId));
});
const remainingNodes = nodes.filter((node) => !removedIds.has(node.id));
- const nextNodes = remainingNodes.map((node) => {
+ const cleanedNodes = remainingNodes.map((node) => {
const detached = node.parentId && removedIds.has(node.parentId) ? { ...node, parentId: undefined } : node;
const storyboard = detached.metadata?.storyboard;
const cleaned = storyboard
@@ -458,6 +459,7 @@ export function removeCanvasNodes(nodes: CanvasNodeData[], requestedIds: Set reconcileImageBatchRoot(node, cleanedNodes));
return { removedIds, nodes: nextNodes };
}
diff --git a/web/src/pages/canvas/use-canvas-batch-table.ts b/web/src/pages/canvas/use-canvas-batch-table.ts
index 8c9090641..46de87981 100644
--- a/web/src/pages/canvas/use-canvas-batch-table.ts
+++ b/web/src/pages/canvas/use-canvas-batch-table.ts
@@ -123,6 +123,8 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
navigateToSettings({ continueCreation: true });
return;
}
+ const generationConfig = buildGenerationConfig(effectiveConfig, undefined, "image");
+ const imageResolution = /^(1k|2k|4k)$/i.test(generationConfig.quality) ? generationConfig.quality.toUpperCase() : "由尺寸决定";
const activeNodeIds = new Set((sourceNode.metadata?.generationBatches || []).filter((batch) => batch.mode === "batch_image").flatMap((batch) => batch.items.filter((item) => ["waiting", "submitting", "queued", "running"].includes(item.status)).map((item) => item.nodeId)));
const requested = requestedRowIds?.length ? new Set(requestedRowIds) : null;
const rows = table.rows.filter((row) => {
@@ -139,7 +141,15 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
if (!rows.length) return message.info("没有可提交的未完成任务,请检查参考图和提示词");
const confirmed = await new Promise((resolve) => modal.confirm({
title: `确认提交 ${rows.length} 个批量图片任务`,
- content: `模型:${modelDisplayName(effectiveConfig, imageModel)};并发上限:${table.concurrency}。这些任务可能消耗积分或产生外部模型费用。`,
+ content: [
+ `模型:${modelDisplayName(effectiveConfig, generationConfig.model) || generationConfig.model}`,
+ `出图数量:${rows.length} 张(每行 1 张)`,
+ `尺寸:${generationConfig.size || "默认"}`,
+ `质量:${generationConfig.quality || "默认"}`,
+ `分辨率:${imageResolution}`,
+ `并发:${table.concurrency}`,
+ "这些任务可能消耗积分或产生外部模型费用。",
+ ].join("\n"),
okText: "确认生成",
cancelText: "取消",
centered: true,
@@ -160,7 +170,11 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon
...(existingIndex >= 0 ? resetGenerationTaskMetadata(nextNodes[existingIndex].metadata) : {}),
prompt,
composerContent: prompt,
- model: buildGenerationConfig(effectiveConfig, undefined, "image").model,
+ model: generationConfig.model,
+ size: generationConfig.size,
+ quality: generationConfig.quality,
+ transparentBackground: generationConfig.transparentBackground,
+ count: 1,
generationMode: "image" as const,
generationType: "edit" as const,
workflowKind: "final" as const,
diff --git a/web/src/pages/canvas/use-canvas-project-lifecycle.ts b/web/src/pages/canvas/use-canvas-project-lifecycle.ts
index 1aea91979..b08016c74 100644
--- a/web/src/pages/canvas/use-canvas-project-lifecycle.ts
+++ b/web/src/pages/canvas/use-canvas-project-lifecycle.ts
@@ -309,7 +309,11 @@ export function useCanvasProjectLifecycle({
const renameCurrentProject = useCallback((title: string) => {
renameProject(projectId, title);
- }, [projectId, renameProject]);
+ // 标题是画布列表和分享入口的元数据,重命名后立即提交,避免只停留在浏览器缓存。
+ void saveRemoteUserDataNow(projectId).catch((error) => {
+ message.warning(error instanceof Error ? `名称已更新到本地,云端同步将在后台重试:${error.message}` : "名称已更新到本地,云端同步将在后台重试");
+ });
+ }, [message, projectId, renameProject]);
const persistLocalEdits = useCallback(async () => {
const snapshot = { nodes: nodesRef.current, connections: connectionsRef.current, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo };
diff --git a/web/src/pages/projects/detail/workflow-production-workbench.tsx b/web/src/pages/projects/detail/workflow-production-workbench.tsx
index 5d567b31a..8501983ce 100644
--- a/web/src/pages/projects/detail/workflow-production-workbench.tsx
+++ b/web/src/pages/projects/detail/workflow-production-workbench.tsx
@@ -15,12 +15,12 @@ import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { ModelPicker } from "@/components/model-picker";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationOptions } from "@/lib/model-capabilities";
-import { modelQuoteRequest } from "@/lib/model-pricing";
+import { modelQuoteDescription, modelQuoteRequest } from "@/lib/model-pricing";
import { customShotTitle, formatShotOrdinal, normalizeDefaultShotTitle } from "@/lib/shot-label";
import { modelCompatibilityError, resolveCompatibleModel, resolveModelVideoBooleanOptions, type ModelRequirements } from "@/lib/model-selection";
import { formatVideoResolutionLabel } from "@/lib/video-generation-options";
import { submitBackendGenerationTask } from "@/services/api/generation-task";
-import { quoteLogicalModel } from "@/services/api/logical-models";
+import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models";
import { type GenerationTask } from "@/services/api/task-center";
import {
createUnitWorkflow,
@@ -182,8 +182,8 @@ export default function WorkflowProductionWorkbench(props: Props) {
});
const quoteRequest = useMemo(() => modelQuoteRequest(generationConfig, routedModel, generationCapability, modelRequirements), [generationCapability, generationConfig, modelRequirements, routedModel]);
const quoteRequestKey = JSON.stringify(quoteRequest || null);
- const [quotedCredits, setQuotedCredits] = useState(null);
- const generationCredits = quotedCredits ?? configuredCredits;
+ const [routeQuote, setRouteQuote] = useState(null);
+ const generationCredits = routeQuote ? routeQuote.amountMicrocredits / 1_000_000 : configuredCredits;
const formattedGenerationCredits = generationCredits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
const modelSummary = routedModel ? modelDisplayName(effectiveConfig, routedModel) : "未选择模型";
const durationSummary = `${Number(watchedDuration || Math.max(0.5, (selectedShot?.durationMs || 3000) / 1000))}s`;
@@ -212,15 +212,15 @@ export default function WorkflowProductionWorkbench(props: Props) {
useEffect(() => {
if (!creditsEnabled || !quoteRequest) {
- setQuotedCredits(null);
+ setRouteQuote(null);
return;
}
const controller = new AbortController();
- setQuotedCredits(null);
- quoteLogicalModel(quoteRequest.logicalModelID, quoteRequest.intent, controller.signal)
- .then(({ quote }) => setQuotedCredits(quote.amountMicrocredits / 1_000_000))
+ setRouteQuote(null);
+ quoteModel(quoteRequest, controller.signal)
+ .then(({ quote }) => setRouteQuote(quote))
.catch(() => {
- if (!controller.signal.aborted) setQuotedCredits(null);
+ if (!controller.signal.aborted) setRouteQuote(null);
});
return () => controller.abort();
// quoteRequestKey captures the normalized request without retriggering on object identity.
@@ -545,7 +545,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
diff --git a/web/src/pages/projects/index.tsx b/web/src/pages/projects/index.tsx
index f08bf9801..3516f967f 100644
--- a/web/src/pages/projects/index.tsx
+++ b/web/src/pages/projects/index.tsx
@@ -184,7 +184,6 @@ export default function ProjectsPage() {
const hasInitialError = query.isError && !query.data;
return (
- {totalProjectCount} 个项目} />
diff --git a/web/src/services/api/resources.ts b/web/src/services/api/resources.ts
index 70d7922f0..492961418 100644
--- a/web/src/services/api/resources.ts
+++ b/web/src/services/api/resources.ts
@@ -96,6 +96,9 @@ export class ResourceUploadError extends Error {
const resourceCache = new Map
();
const resourceRequests = new Map>();
const missingResourceIds = new Set();
+const ossUrlCache = new Map();
+const ossUrlRequests = new Map>();
+const OSS_URL_CACHE_TTL_MS = 4 * 60 * 1000;
export function resourceStorageKey(id: string) {
return `resource:${id}`;
@@ -271,14 +274,25 @@ export function refreshResource(id: string): Promise {
export async function getResourceOSSUrl(storageKey?: string) {
const id = resourceIdFromStorageKey(storageKey);
if (!id) throw new Error("当前媒体尚未上传到后端资源存储");
- try {
- const data = await http.get<{ url: string }>(`/resources/${encodeURIComponent(id)}/oss-url`);
- if (!data.url) throw new Error("后端未返回对象存储地址");
- return data.url;
- } catch (error) {
- if (error instanceof ApiError) throw new Error(error.message || "获取对象存储地址失败");
- throw error;
- }
+ const cached = ossUrlCache.get(resourceCacheKey(id));
+ if (cached && cached.expiresAt > Date.now()) return cached.url;
+ const pending = ossUrlRequests.get(resourceCacheKey(id));
+ if (pending) return pending;
+ const request = (async () => {
+ try {
+ const data = await http.get<{ url: string }>(`/resources/${encodeURIComponent(id)}/oss-url`);
+ if (!data.url) throw new Error("后端未返回对象存储地址");
+ ossUrlCache.set(resourceCacheKey(id), { url: data.url, expiresAt: Date.now() + OSS_URL_CACHE_TTL_MS });
+ return data.url;
+ } catch (error) {
+ if (error instanceof ApiError) throw new Error(error.message || "获取对象存储地址失败");
+ throw error;
+ } finally {
+ ossUrlRequests.delete(resourceCacheKey(id));
+ }
+ })();
+ ossUrlRequests.set(resourceCacheKey(id), request);
+ return request;
}
function resourceCacheKey(id: string) {
@@ -287,7 +301,7 @@ function resourceCacheKey(id: string) {
export function resourceFileUrl(id: string) {
const base = String(apiBaseURL).replace(/\/+$/, "");
- return `${base}/resources/${encodeURIComponent(id)}/file`;
+ return `${base}/resources/${encodeURIComponent(id)}/file?direct=1`;
}
function resourceProxyFileUrl(id: string) {
@@ -306,16 +320,25 @@ export function resolveResourceUrl(storageKey?: string, fallback = "") {
// 副本就绪前由后端回退原件,调用方再按需降级。
export function playbackVariantUrl(id: string) {
const base = String(apiBaseURL).replace(/\/+$/, "");
- return `${base}/resources/${encodeURIComponent(id)}/file?variant=playback`;
+ return `${base}/resources/${encodeURIComponent(id)}/file?variant=playback&direct=1`;
}
-export async function getResourceBlob(storageKey: string) {
+export async function getResourceBlob(storageKey: string, options?: { allowProxyFallback?: boolean }) {
const id = resourceIdFromStorageKey(storageKey);
if (!id) return null;
- const url = resourceProxyFileUrl(id);
- const response = await fetch(url, { credentials: isResourceUrl(url) ? "include" : "same-origin" });
- if (!response.ok) return null;
- return response.blob();
+ // A direct OSS response needs CORS to be readable as a Blob. Native
/