fix(canvas): 画布与素材同步 - 修复批量缩略图、远程资源回退与重命名同步
This commit is contained in:
@@ -488,7 +488,7 @@ export function AssetLibraryPickerModal({
|
||||
</nav>
|
||||
<div className="asset-picker-grid-wrap">
|
||||
<div className="asset-picker-grid">
|
||||
{remoteEnabled && remoteQuery.isError ? <div role="alert">素材读取失败<Button onClick={() => void remoteQuery.refetch()}>重试</Button></div> : loading || (useRemoteItems && remoteQuery.isFetching) ? (
|
||||
{remoteEnabled && remoteQuery.isError && !localItems.length ? <div className="asset-picker-empty" role="alert"><FolderOpen /><strong>素材读取失败</strong><span>服务端暂时不可用,重试不会影响本地已保存素材。</span><Button onClick={() => void remoteQuery.refetch()}>重试</Button></div> : loading || (useRemoteItems && remoteQuery.isFetching) ? (
|
||||
<div className="asset-picker-empty">
|
||||
<LoaderCircle className="animate-spin" />
|
||||
<strong>正在读取素材</strong>
|
||||
@@ -519,8 +519,8 @@ export function AssetLibraryPickerModal({
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
) : footerNote ? (
|
||||
<span className="asset-picker-footer-note">{footerNote}</span>
|
||||
) : footerNote || (remoteEnabled && remoteQuery.isError) ? (
|
||||
<span className="asset-picker-footer-note">{remoteEnabled && remoteQuery.isError ? "云端素材暂时不可用,当前显示本地缓存" : footerNote}</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Input } from "antd";
|
||||
import { Check, LibraryBig, LoaderCircle, Plus, Search, Sparkles, Users } from "lucide-react";
|
||||
import { Check, LoaderCircle, Plus, Search, Sparkles, Users } from "lucide-react";
|
||||
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
@@ -45,16 +45,20 @@ export function CanvasAgentSkillLibraryModal({
|
||||
onInstall,
|
||||
onLoadMore,
|
||||
}: CanvasAgentSkillLibraryModalProps) {
|
||||
const [tab, setTab] = useState<SkillLibraryTab>("enabled");
|
||||
const [tab, setTab] = useState<SkillLibraryTab>("market");
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const wasOpenRef = useRef(false);
|
||||
const selectedCount = selectedSkillIds.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !wasOpenRef.current) setTab(selectedCount > 0 ? "enabled" : "installed");
|
||||
if (open && !wasOpenRef.current) setTab("market");
|
||||
wasOpenRef.current = open;
|
||||
}, [open, selectedCount]);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.scrollTo({ top: 0 });
|
||||
}, [category, search, tab]);
|
||||
|
||||
const visibleSkills = useMemo(() => {
|
||||
const keyword = search.trim().toLocaleLowerCase("zh-CN");
|
||||
@@ -86,16 +90,21 @@ export function CanvasAgentSkillLibraryModal({
|
||||
const categoryItems = useMemo(() => {
|
||||
const unique = new Map<string, SkillCategory>();
|
||||
categories.forEach((item) => {
|
||||
if (item.value && item.label) unique.set(item.value, item);
|
||||
if (item.value && item.value !== "all" && item.label) unique.set(item.value, item);
|
||||
});
|
||||
return [{ value: "all", label: "全部" }, ...unique.values()];
|
||||
return [...unique.values()];
|
||||
}, [categories]);
|
||||
|
||||
const emptyText = tab === "enabled"
|
||||
? "本轮还没有启用 Skill,可在“已加入”中选择"
|
||||
? "本轮还没有启用技能,可在“我的技能”中选择"
|
||||
: tab === "installed"
|
||||
? "还没有匹配的已加入 Skill,可前往“发现 Skills”添加"
|
||||
: "没有匹配的公开 Skill,换个关键词或分类试试";
|
||||
? "还没有匹配的已加入技能,可前往“全部”添加"
|
||||
: "没有匹配的公开技能,换个关键词或分类试试";
|
||||
|
||||
const changeTab = (value: SkillLibraryTab) => {
|
||||
setTab(value);
|
||||
onCategoryChange("all");
|
||||
};
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
@@ -104,7 +113,16 @@ export function CanvasAgentSkillLibraryModal({
|
||||
title={null}
|
||||
footer={null}
|
||||
centered
|
||||
width="min(1180px, calc(100vw - 24px))"
|
||||
width="min(1180px, 66vw)"
|
||||
closable={false}
|
||||
modalRender={(modal) => (
|
||||
<div className="canvas-agent-skill-library-frame">
|
||||
<button type="button" className="canvas-agent-skill-library-close" aria-label="关闭技能商店" onClick={onClose}>
|
||||
<kbd>ESC</kbd><span>关闭</span>
|
||||
</button>
|
||||
{modal}
|
||||
</div>
|
||||
)}
|
||||
onCancel={onClose}
|
||||
flush
|
||||
>
|
||||
@@ -115,85 +133,78 @@ export function CanvasAgentSkillLibraryModal({
|
||||
data-canvas-wheel-scroll
|
||||
aria-label="Agent Skills 技能库"
|
||||
>
|
||||
<header className="canvas-agent-skill-library-header">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-lg font-semibold">
|
||||
<LibraryBig className="size-5" aria-hidden="true" />
|
||||
Skills 技能库
|
||||
</div>
|
||||
<p className="mt-1 text-xs" style={{ color: theme.node.muted }}>按分类浏览和搜索技能,本轮最多启用 8 个,发送消息时固定版本。</p>
|
||||
</div>
|
||||
<div className="canvas-agent-skill-library-count" style={{ color: theme.node.muted }}>
|
||||
已启用 <strong style={{ color: selectedCount >= 8 ? "var(--destructive)" : theme.accent.primary }}>{selectedCount}</strong>/8
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="canvas-agent-skill-library-toolbar">
|
||||
<div className="canvas-agent-skill-library-tabs" role="tablist" aria-label="Skills 视图">
|
||||
<SkillTab active={tab === "enabled"} label="已启用" count={selectedCount} onClick={() => setTab("enabled")} />
|
||||
<SkillTab active={tab === "installed"} label="已加入" count={installedSkills.length} onClick={() => setTab("installed")} />
|
||||
<SkillTab active={tab === "market"} label="发现 Skills" onClick={() => setTab("market")} />
|
||||
</div>
|
||||
<aside className="canvas-agent-skill-library-sidebar">
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<Search className="size-4" style={{ color: theme.node.muted }} aria-hidden="true" />}
|
||||
value={search}
|
||||
onChange={(event) => onSearch(event.target.value)}
|
||||
placeholder="搜索 Skill 名称、说明或作者"
|
||||
aria-label="搜索 Skills"
|
||||
placeholder="搜索技能"
|
||||
aria-label="搜索技能名称、说明或作者"
|
||||
className="canvas-agent-skill-library-search"
|
||||
/>
|
||||
</div>
|
||||
<nav className="canvas-agent-skill-library-categories thin-scrollbar" aria-label="技能视图与分类">
|
||||
<SkillTab active={tab === "market" && category === "all"} label="全部" onClick={() => changeTab("market")} />
|
||||
<SkillTab active={tab === "installed"} label="我的技能" count={installedSkills.length} onClick={() => changeTab("installed")} />
|
||||
<SkillTab active={tab === "enabled"} label="已启用" count={selectedCount} onClick={() => changeTab("enabled")} />
|
||||
{categoryItems.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className={`canvas-agent-skill-library-tab ${tab === "market" && category === item.value ? "is-active" : ""}`}
|
||||
aria-pressed={tab === "market" && category === item.value}
|
||||
onClick={() => { setTab("market"); onCategoryChange(item.value); }}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<nav className="canvas-agent-skill-library-categories" aria-label="Skill 分类">
|
||||
{categoryItems.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className={`canvas-agent-skill-category ${category === item.value ? "is-active" : ""}`}
|
||||
aria-pressed={category === item.value}
|
||||
onClick={() => onCategoryChange(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
<div className="canvas-agent-skill-library-content">
|
||||
<header className="canvas-agent-skill-library-header">
|
||||
<h2>技能商店</h2>
|
||||
<button type="button" className="canvas-agent-skill-library-count" onClick={() => changeTab("enabled")} aria-label={`查看本轮已启用的 ${selectedCount} 个技能,最多 8 个`}>
|
||||
已启用 <strong>{selectedCount}</strong><span> / 8</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div ref={listRef} className="canvas-agent-skill-library-list thin-scrollbar" role="tabpanel">
|
||||
{visibleSkills.map((skill) => (
|
||||
<SkillLibraryCard
|
||||
key={skill.skillId}
|
||||
skill={skill}
|
||||
theme={theme}
|
||||
categories={categories}
|
||||
selected={selectedSkillIds.includes(skill.skillId)}
|
||||
canSelect={selectedCount < 8 || selectedSkillIds.includes(skill.skillId)}
|
||||
onToggle={() => onToggle(skill.skillId)}
|
||||
onInstall={() => onInstall(skill)}
|
||||
/>
|
||||
))}
|
||||
{loading && visibleSkills.length === 0 ? (
|
||||
<div className="canvas-agent-skill-library-state" style={{ color: theme.node.muted }}>
|
||||
<LoaderCircle className="size-5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
|
||||
正在读取技能库…
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && visibleSkills.length === 0 ? (
|
||||
<div className="canvas-agent-skill-library-state" style={{ color: theme.node.muted }}>
|
||||
<Sparkles className="size-6" aria-hidden="true" />
|
||||
<span>{emptyText}</span>
|
||||
{tab !== "market" ? <Button size="small" onClick={() => setTab("market")}>浏览公开 Skills</Button> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{tab === "market" && hasMore ? <div ref={loadMoreRef} className="col-span-full h-px" aria-hidden="true" /> : null}
|
||||
<div ref={listRef} className="canvas-agent-skill-library-list thin-scrollbar" aria-label="技能列表" aria-busy={loading}>
|
||||
{visibleSkills.map((skill) => (
|
||||
<SkillLibraryCard
|
||||
key={skill.skillId}
|
||||
skill={skill}
|
||||
theme={theme}
|
||||
categories={categories}
|
||||
selected={selectedSkillIds.includes(skill.skillId)}
|
||||
canSelect={selectedCount < 8 || selectedSkillIds.includes(skill.skillId)}
|
||||
onToggle={() => onToggle(skill.skillId)}
|
||||
onInstall={() => onInstall(skill)}
|
||||
/>
|
||||
))}
|
||||
{loading && visibleSkills.length === 0 ? (
|
||||
<div className="canvas-agent-skill-library-state" style={{ color: theme.node.muted }}>
|
||||
<LoaderCircle className="size-5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
|
||||
正在读取技能库…
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && visibleSkills.length === 0 ? (
|
||||
<div className="canvas-agent-skill-library-state" style={{ color: theme.node.muted }}>
|
||||
<Sparkles className="size-6" aria-hidden="true" />
|
||||
<span>{emptyText}</span>
|
||||
{tab !== "market" ? <Button size="small" onClick={() => changeTab("market")}>浏览公开技能</Button> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{tab === "market" && hasMore ? <div ref={loadMoreRef} className="col-span-full h-px" aria-hidden="true" /> : null}
|
||||
</div>
|
||||
|
||||
<footer className="canvas-agent-skill-library-footer" style={{ color: theme.node.muted, borderColor: theme.node.stroke }}>
|
||||
<span>{tab === "enabled" ? `${selectedCount} 个技能将在本轮生效` : tab === "installed" ? `${visibleSkills.length} 个已加入技能` : `已加载 ${marketSkills.length} 个公开技能`}</span>
|
||||
{tab === "market" && hasMore ? (
|
||||
<Button size="small" disabled={loading} loading={loading} onClick={() => void onLoadMore()}>{loading ? "加载中" : "加载更多"}</Button>
|
||||
) : <span>{tab === "market" ? "已加载全部" : "最多启用 8 个"}</span>}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<footer className="canvas-agent-skill-library-footer" style={{ color: theme.node.muted, borderColor: theme.node.stroke }}>
|
||||
<span>{tab === "enabled" ? `${selectedCount} 个技能将在本轮生效` : tab === "installed" ? `${visibleSkills.length} 个已加入技能` : `已加载 ${marketSkills.length} 个公开技能`}</span>
|
||||
{tab === "market" && hasMore ? (
|
||||
<Button size="small" disabled={loading} loading={loading} onClick={() => void onLoadMore()}>{loading ? "加载中" : "加载更多"}</Button>
|
||||
) : <span>{tab === "market" ? "已加载全部" : "最多启用 8 个"}</span>}
|
||||
</footer>
|
||||
</section>
|
||||
</AppModal>
|
||||
);
|
||||
@@ -201,7 +212,7 @@ export function CanvasAgentSkillLibraryModal({
|
||||
|
||||
function SkillTab({ active, label, count, onClick }: { active: boolean; label: string; count?: number; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" role="tab" aria-selected={active} className={`canvas-agent-skill-library-tab ${active ? "is-active" : ""}`} onClick={onClick}>
|
||||
<button type="button" aria-pressed={active} className={`canvas-agent-skill-library-tab ${active ? "is-active" : ""}`} onClick={onClick}>
|
||||
{label}
|
||||
{typeof count === "number" ? <span>{count}</span> : null}
|
||||
</button>
|
||||
@@ -219,7 +230,9 @@ function SkillLibraryCard({ skill, theme, categories, selected, canSelect, onTog
|
||||
}) {
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [coverFailed, setCoverFailed] = useState(false);
|
||||
const coverUrl = skill.showcaseMedia?.find((item) => item.showcaseUrl)?.showcaseUrl;
|
||||
const cover = skill.showcaseMedia?.find((item) => item.type === "image" && item.showcaseUrl)
|
||||
|| skill.showcaseMedia?.find((item) => item.showcaseUrl);
|
||||
const coverUrl = cover?.showcaseUrl;
|
||||
const categoryLabel = categories.find((item) => item.value === skill.tag)?.label || "其他";
|
||||
const author = skill.effectiveUser?.name || "影策创作者";
|
||||
const addedCount = formatSkillCount(skill.addedCount || 0);
|
||||
@@ -237,48 +250,49 @@ function SkillLibraryCard({ skill, theme, categories, selected, canSelect, onTog
|
||||
};
|
||||
|
||||
return (
|
||||
<article className={`canvas-agent-skill-card ${selected ? "is-selected" : ""}`} style={{ borderColor: selected ? theme.accent.primary : theme.node.stroke }}>
|
||||
<div className="canvas-agent-skill-card-cover" style={{ background: theme.node.fill }}>
|
||||
<article className={`canvas-agent-skill-card ${selected ? "is-selected" : ""}`}>
|
||||
<div className="canvas-agent-skill-card-cover">
|
||||
{coverUrl && !coverFailed ? (
|
||||
<img src={coverUrl} alt="" loading="lazy" onError={() => setCoverFailed(true)} />
|
||||
cover?.type === "video"
|
||||
? <video src={coverUrl} muted playsInline preload="metadata" onError={() => setCoverFailed(true)} />
|
||||
: <img src={coverUrl} alt="" loading="lazy" onError={() => setCoverFailed(true)} />
|
||||
) : (
|
||||
<div className="canvas-agent-skill-card-placeholder" aria-hidden="true">
|
||||
<Sparkles className="size-7" />
|
||||
</div>
|
||||
)}
|
||||
<span className="canvas-agent-skill-card-category">{categoryLabel}</span>
|
||||
{selected ? <span className="canvas-agent-skill-card-selected" aria-label="本轮已启用"><Check className="size-4" aria-hidden="true" /></span> : null}
|
||||
</div>
|
||||
<div className="canvas-agent-skill-card-body">
|
||||
<div className="canvas-agent-skill-card-title-row">
|
||||
<h3 title={skill.skillName}>{skill.skillName}</h3>
|
||||
{skill.version ? <span className="canvas-agent-skill-card-version">v{skill.version}</span> : null}
|
||||
<div className="canvas-agent-skill-card-action">
|
||||
{skill.isAdded ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="default"
|
||||
className={selected ? "is-selected" : ""}
|
||||
disabled={!canSelect}
|
||||
aria-pressed={selected}
|
||||
title={!canSelect ? "本轮最多启用 8 个 Skills" : undefined}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{selected ? "已启用" : "使用"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="small" icon={<Plus className="size-3.5" />} loading={installing} disabled={installing || !canSelect} title={!canSelect ? "本轮最多启用 8 个 Skills" : undefined} onClick={() => void install()}>
|
||||
加入
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p title={skill.description || "暂无技能说明"}>{skill.description || "暂无技能说明"}</p>
|
||||
<div className="canvas-agent-skill-card-meta" style={{ color: theme.node.muted }}>
|
||||
<span className="min-w-0 truncate">{author}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
{skill.version ? <><span aria-hidden="true">·</span><span className="canvas-agent-skill-card-version">v{skill.version}</span></> : null}
|
||||
<span className="inline-flex shrink-0 items-center gap-1"><Users className="size-3.5" aria-hidden="true" />{addedCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="canvas-agent-skill-card-action">
|
||||
{skill.isAdded ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="default"
|
||||
className={selected ? "is-selected" : ""}
|
||||
icon={selected ? <Check className="size-3.5" /> : <Plus className="size-3.5" />}
|
||||
disabled={!canSelect}
|
||||
aria-pressed={selected}
|
||||
title={!canSelect ? "本轮最多启用 8 个 Skills" : undefined}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{selected ? "已启用" : "启用"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="small" icon={<Plus className="size-3.5" />} loading={installing} disabled={installing} onClick={() => void install()}>
|
||||
加入
|
||||
</Button>
|
||||
)}
|
||||
<p title={skill.description || "暂无技能说明"}>{skill.description || "暂无技能说明"}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -277,7 +277,7 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
|
||||
{readOnly ? "输入 @ 插入参考图" : "输入 @ 插入参考图 · ⌘/Ctrl + Enter 生成此行"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<div className="flex h-16 min-h-16 items-center justify-center">
|
||||
<ResultThumbnail
|
||||
output={output}
|
||||
status={status}
|
||||
@@ -366,7 +366,7 @@ function ReferenceThumbnail({ node, label, theme, readOnly, rowId, columnIndex,
|
||||
data-batch-reference-cell
|
||||
data-row-id={rowId}
|
||||
data-column-index={columnIndex}
|
||||
className="relative size-16 overflow-hidden rounded-lg border"
|
||||
className="relative box-border grid size-16 shrink-0 place-items-center overflow-hidden rounded-lg border"
|
||||
style={{ borderColor: filled ? theme.node.stroke : "transparent", opacity: isDraggingCell ? 0.55 : 1 }}
|
||||
disabled={readOnly}
|
||||
onPointerDown={onPointerDown}
|
||||
@@ -382,7 +382,7 @@ function ReferenceThumbnail({ node, label, theme, readOnly, rowId, columnIndex,
|
||||
if (file && !readOnly) onUploadFile(file);
|
||||
}}
|
||||
>
|
||||
{filled ? <CachedResourceImage eager src={node.metadata?.previewContent || node.metadata?.content} storageKey={node.metadata?.storageKey} alt={node.title || "参考图"} className="size-16 object-cover" fallback={fallback} /> : fallback}
|
||||
{filled ? <CachedResourceImage eager src={node.metadata?.previewContent || node.metadata?.content} storageKey={node.metadata?.storageKey} alt={node.title || "参考图"} className="block size-full max-h-full max-w-full object-cover" fallback={fallback} /> : fallback}
|
||||
<span className="absolute bottom-1 left-1 rounded px-1 py-0.5 text-[8px] font-medium text-white" style={{ background: "rgba(0,0,0,.58)" }}>{label}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
@@ -399,14 +399,14 @@ function ResultThumbnail({ output, status, theme, onFocus }: { output?: CanvasNo
|
||||
type="button"
|
||||
aria-label={title}
|
||||
disabled={!output}
|
||||
className="relative size-16 overflow-hidden rounded-lg border-2"
|
||||
className="relative box-border grid size-16 shrink-0 place-items-center overflow-hidden rounded-lg border-2"
|
||||
style={{ borderColor: tone, cursor: output ? "pointer" : "default" }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (output) onFocus();
|
||||
}}
|
||||
>
|
||||
{filled && output ? <CachedResourceImage eager src={output.metadata?.previewContent || output.metadata?.content} storageKey={output.metadata?.storageKey} alt="生成结果" className="size-16 object-cover" fallback={<EmptyThumbnail theme={theme} compact />} /> : <EmptyThumbnail theme={theme} compact />}
|
||||
{filled && output ? <CachedResourceImage eager src={output.metadata?.previewContent || output.metadata?.content} storageKey={output.metadata?.storageKey} alt="生成结果" className="block size-full max-h-full max-w-full object-cover" fallback={<EmptyThumbnail theme={theme} compact />} /> : <EmptyThumbnail theme={theme} compact />}
|
||||
{status.loading ? <span className="absolute inset-0 grid place-items-center bg-black/35"><LoaderCircle className="size-4 animate-spin" style={{ color: tone }} /></span> : null}
|
||||
<span className="absolute right-1 top-1 size-2 rounded-full" style={{ background: tone }} />
|
||||
</button>
|
||||
@@ -422,7 +422,7 @@ function statusColor(tone: RowStatusTone, fallback: string) {
|
||||
}
|
||||
|
||||
function EmptyThumbnail({ theme, compact = false }: { theme: CanvasTheme; compact?: boolean }): ReactNode {
|
||||
const sizeClass = compact ? "size-12" : "size-16";
|
||||
const sizeClass = "size-full";
|
||||
return (
|
||||
<div
|
||||
className={`grid shrink-0 place-items-center rounded-lg border border-dashed ${sizeClass}`}
|
||||
|
||||
@@ -118,7 +118,6 @@ export function AgentChatMessage({
|
||||
<ChevronDown className="agent-reasoning-chevron" aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="agent-reasoning-content" data-canvas-wheel-scroll>
|
||||
<span className="agent-reasoning-rail" aria-hidden="true" />
|
||||
<div className="agent-reasoning-text">{item.text || (item.streaming ? "正在整理思路…" : "暂无可展示的推理摘要")}</div>
|
||||
</div>
|
||||
</details>
|
||||
@@ -235,7 +234,7 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<AgentTimelineMarker theme={theme} tone="approval" icon={<CircleAlert className="size-3.5" />} />
|
||||
<div className="agent-pending-tool min-w-0 flex-1 rounded-r-lg border-l-2 py-1 pl-3 pr-1" style={{ borderColor: "#f97316", background: "rgba(249,115,22,.05)", color: theme.node.text }}>
|
||||
<div className="agent-pending-tool min-w-0 flex-1 rounded-lg border py-2 pl-3 pr-3" style={{ borderColor: "rgba(249,115,22,.22)", background: "rgba(249,115,22,.05)", color: theme.node.text }}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[13px] font-semibold leading-5">
|
||||
@@ -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 (
|
||||
<span className="relative flex w-6 shrink-0 self-stretch justify-center" aria-hidden="true">
|
||||
<span className="absolute bottom-[-20px] top-6 w-px opacity-35" style={{ background: theme.toolbar.border }} />
|
||||
<span className="relative grid size-6 place-items-center rounded-full" style={{ background: tone === "agent" ? theme.accent.primarySoft : theme.node.fill, color }}>
|
||||
{icon || <span className="size-3 opacity-90" style={{ background: color, WebkitMask: "url(/icons/openai.svg) center / contain no-repeat", mask: "url(/icons/openai.svg) center / contain no-repeat" }} />}
|
||||
</span>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const credits = quotedCredits ?? configuredCredits;
|
||||
const [routeQuote, setRouteQuote] = useState<LogicalModelQuote | null>(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 ? (
|
||||
<span className="canvas-node-composer-submit-cost">
|
||||
<CreditSymbol />
|
||||
<span>{formattedCredits}</span>
|
||||
<span>{routeQuote?.estimated ? "预计 " : ""}{formattedCredits}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span className="canvas-node-composer-submit-action" aria-hidden>
|
||||
|
||||
@@ -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(" "),
|
||||
|
||||
@@ -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<CanvasNodeData[], { first?: ProjectPreviewMedia; latest?: ProjectPreviewMedia }>();
|
||||
@@ -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";
|
||||
|
||||
@@ -350,7 +350,15 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
spellCheck={props.spellCheck}
|
||||
tabIndex={props.tabIndex}
|
||||
className={`${className || ""} relative z-10 cursor-text select-text whitespace-pre-wrap break-words`}
|
||||
style={{ ...mergedStyle, color: style?.color || theme.node.text }}
|
||||
style={{
|
||||
...mergedStyle,
|
||||
color: style?.color || theme.node.text,
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
maxHeight: "100%",
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
}}
|
||||
onInput={syncEditableValue}
|
||||
onCompositionStart={(event) => {
|
||||
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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<str
|
||||
if (requestedIds.has(node.id)) node.metadata?.batchChildIds?.forEach((childId) => 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<str
|
||||
const batchRoot = { ...cleaned, metadata: { ...cleaned.metadata, batchChildIds: childIds, primaryImageId } };
|
||||
return primaryNode ? applyBatchPrimaryImage(batchRoot, primaryNode) : batchRoot;
|
||||
});
|
||||
const nextNodes = cleanedNodes.map((node) => reconcileImageBatchRoot(node, cleanedNodes));
|
||||
return { removedIds, nodes: nextNodes };
|
||||
}
|
||||
|
||||
|
||||
@@ -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<boolean>((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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const generationCredits = quotedCredits ?? configuredCredits;
|
||||
const [routeQuote, setRouteQuote] = useState<LogicalModelQuote | null>(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) {
|
||||
</div>
|
||||
<footer className="workflow-editor-actions">
|
||||
<div className="workflow-generation-cost" aria-live="polite">
|
||||
{creditsEnabled && formattedGenerationCredits ? <><CreditSymbol /><span>本次预计 {formattedGenerationCredits} 积分</span></> : creditsEnabled && routedModel ? <span>本次费用将在提交时按实际规格计算</span> : null}
|
||||
{creditsEnabled && formattedGenerationCredits ? <><CreditSymbol /><span title={routeQuote ? modelQuoteDescription(routeQuote) : undefined}>本次预计 {formattedGenerationCredits} 积分</span></> : creditsEnabled && routedModel ? <span>本次费用将在提交时按实际规格计算</span> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2"><Button danger icon={<Trash2 className="size-4" />} loading={deleteShot.isPending} disabled={saveShot.isPending || selectedShotSubmitting || changeAssetBinding.isPending} onClick={requestDeleteShot}>删除镜头</Button><Button htmlType="submit" icon={<Save className="size-4" />} loading={saveShot.isPending} disabled={!editorDirty || deleteShot.isPending}>保存脚本</Button><Button type="primary" icon={<Play className="size-4" />} loading={selectedShotSubmitting || shotTask?.status === "queued" || shotTask?.status === "running"} disabled={deleteShot.isPending} onClick={() => void generateArtifact()}>{selectedShotSubmitting ? `${stageCopy.action}(正在提交)` : shotTask?.status === "queued" || shotTask?.status === "running" ? `${stageCopy.action}(已运行${shotTaskElapsed})` : shotTask?.status === "failed" ? `${stageCopy.action}(上次失败,可重试)` : shotTask?.status === "succeeded" && !newestArtifact ? `${stageCopy.action}(已完成,正在同步)` : newestArtifact ? `${stageCopy.action}(已生成)` : stageCopy.action}</Button></div>
|
||||
</footer>
|
||||
|
||||
@@ -184,7 +184,6 @@ export default function ProjectsPage() {
|
||||
const hasInitialError = query.isError && !query.data;
|
||||
return (
|
||||
<WorkspacePage className="library-page project-library-page" grid>
|
||||
<PageHeader title="短剧 Agent" description="你的故事、章节与镜头,都在这里。" meta={<span className="app-projects-header-meta">{totalProjectCount} 个项目</span>} />
|
||||
<details className="story-launcher-panel" aria-label="开始一部新短剧">
|
||||
<summary className="story-launcher-head">
|
||||
<div className="story-launcher-title">
|
||||
|
||||
@@ -96,6 +96,9 @@ export class ResourceUploadError extends Error {
|
||||
const resourceCache = new Map<string, RemoteResource>();
|
||||
const resourceRequests = new Map<string, Promise<RemoteResource>>();
|
||||
const missingResourceIds = new Set<string>();
|
||||
const ossUrlCache = new Map<string, { url: string; expiresAt: number }>();
|
||||
const ossUrlRequests = new Map<string, Promise<string>>();
|
||||
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<RemoteResource> {
|
||||
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 <img>/<video>
|
||||
// can still display it without CORS, so background cache fills must not proxy it.
|
||||
try {
|
||||
const ossUrl = await getResourceOSSUrl(storageKey);
|
||||
const response = await fetch(ossUrl, { credentials: "omit", mode: "cors" });
|
||||
if (response.ok) return response.blob();
|
||||
if (!options?.allowProxyFallback || response.status === 401 || response.status === 403 || response.status === 404) return null;
|
||||
} catch (error) {
|
||||
if (!options?.allowProxyFallback) throw error;
|
||||
}
|
||||
if (!options?.allowProxyFallback) return null;
|
||||
const response = await fetch(resourceProxyFileUrl(id), { credentials: "include" });
|
||||
return response.ok ? response.blob() : null;
|
||||
}
|
||||
|
||||
function extensionFromMime(mimeType: string, kind: string) {
|
||||
|
||||
@@ -122,11 +122,23 @@ export async function getCachedResourceBlob(storageKey: string) {
|
||||
if (sessionBlob) return sessionBlob;
|
||||
const pending = inFlight.get(target.key);
|
||||
if (pending) {
|
||||
await pending;
|
||||
return sessionBlobs.get(target.key) || blobStore.getItem<Blob>(target.key);
|
||||
await pending.catch(() => "");
|
||||
const downloaded = sessionBlobs.get(target.key) || await blobStore.getItem<Blob>(target.key);
|
||||
if (downloaded) return downloaded;
|
||||
return loadAndPersistProxyFallback(storageKey);
|
||||
}
|
||||
await cacheResourceObjectUrl(storageKey);
|
||||
return sessionBlobs.get(target.key) || blobStore.getItem<Blob>(target.key);
|
||||
await cacheResourceObjectUrl(storageKey).catch(() => "");
|
||||
const downloaded = sessionBlobs.get(target.key) || await blobStore.getItem<Blob>(target.key);
|
||||
if (downloaded) return downloaded;
|
||||
// Explicit byte consumers (exports/provider inputs) need a same-origin fallback
|
||||
// when the OSS bucket does not expose CORS. Display-only cache fills never do.
|
||||
return loadAndPersistProxyFallback(storageKey);
|
||||
}
|
||||
|
||||
async function loadAndPersistProxyFallback(storageKey: string) {
|
||||
const blob = await getResourceBlob(storageKey, { allowProxyFallback: true });
|
||||
if (blob) await primeResourceBlobCache(storageKey, blob).catch(() => "");
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function downloadAndCacheResource(storageKey: string, target: ResourceCacheMeta) {
|
||||
|
||||
Reference in New Issue
Block a user