feat(*): 创作配置 - 支持 Midjourney、多图保存与自定义公告

Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
DAO-QINandddcat666 authored and ddcat666 committed 2026-10-04 10:43:31 +08:00
1 parent 50f0c30fa2
commit d0b27d2871
107 files changed
+9261 -408

No files matched your search

+3 -4
View File
@@ -21,8 +21,7 @@ export function RequireFeature({ feature, children }: { feature: FeatureKey; chi
const navigate = useNavigate();
const user = useUserStore((state) => state.user);
const features = useUserStore((state) => state.features);
const adminBypass = feature === "pluginCenterEnabled" && user?.role === "admin";
const [checking, setChecking] = useState(() => !adminBypass && !useUserStore.getState().features[feature]);
const [checking, setChecking] = useState(() => !useUserStore.getState().features[feature]);
const [error, setError] = useState("");
useEffect(() => {
@@ -42,9 +41,9 @@ export function RequireFeature({ feature, children }: { feature: FeatureKey; chi
if (checking) return <WorkspacePage><WorkspaceLoadingState label="正在确认功能状态" detail={featureNames[feature]} rows={3} /></WorkspacePage>;
if (error) return <WorkspacePage><WorkspaceErrorState title="无法确认功能状态" description={error} actionLabel="返回创作台" onRetry={() => navigate("/", { replace: true })} /></WorkspacePage>;
if (!adminBypass && !features[feature]) {
if (!features[feature]) {
// 管理员页面返回到管理后台首页,用户页面返回到创作台
const isAdminFeature = feature === "frontendModelsEnabled" || (feature === "pluginCenterEnabled" && user?.role === "admin");
const isAdminFeature = feature === "frontendModelsEnabled";
const backPath = isAdminFeature ? "/admin" : "/";
const backLabel = isAdminFeature ? "返回管理后台" : "返回创作台";
@@ -344,192 +344,192 @@ export function AgentChatComposer({
<div className="agent-composer-wrap min-w-0 shrink-0" onWheelCapture={(event) => event.stopPropagation()}>
{/* 有底部附加内容时,外壳托住输入卡片与底部条,形成一张整卡。 */}
<div className="agent-composer-shell relative" data-has-footer={footer ? "" : undefined}>
{/* 整卡模式下发送光环包住整张卡,避免画在输入区与连接器之间形成一条线。 */}
{footer && sending && !reducedMotion ? <WorkingGlow active color={theme.accent.primary} radius={22} /> : null}
<div
className="agent-composer-surface group/composer relative transition-[background-color,box-shadow] duration-200"
style={{
color: theme.accent.primary,
}}
>
{!footer && sending && !reducedMotion ? <WorkingGlow active color={theme.accent.primary} radius={22} /> : null}
{attachments.length ? (
<div className="thin-scrollbar mb-2 flex gap-2 overflow-x-auto pb-1">
{attachments.map((item, index) => (
<div key={item.id} className="group relative w-20 shrink-0">
<button
type="button"
className="relative block size-20 overflow-hidden rounded-lg"
title="点击放大预览"
aria-label={`预览 ${item.name || `图片${index + 1}`}`}
onClick={() => setPreviewAttachment(item)}
onDoubleClick={() => setPreviewAttachment(item)}
>
<img src={item.url} alt={item.name} className="size-full object-cover" />
</button>
<div className="mt-1 flex min-w-0 items-center justify-between gap-1">
<button type="button" className="flex min-w-0 items-center gap-0.5 truncate text-[var(--fs-tiny)] opacity-80 hover:opacity-100" title={`插入 @图片${index + 1}`} onClick={() => insertAttachmentMention(item)}>
<AtSign className="size-2.5 shrink-0" />
<span className="truncate">图片{index + 1}</span>
{/* 整卡模式下发送光环包住整张卡,避免画在输入区与连接器之间形成一条线。 */}
{footer && sending && !reducedMotion ? <WorkingGlow active color={theme.accent.primary} radius={22} /> : null}
<div
className="agent-composer-surface group/composer relative transition-[background-color,box-shadow] duration-200"
style={{
color: theme.accent.primary,
}}
>
{!footer && sending && !reducedMotion ? <WorkingGlow active color={theme.accent.primary} radius={22} /> : null}
{attachments.length ? (
<div className="thin-scrollbar mb-2 flex gap-2 overflow-x-auto pb-1">
{attachments.map((item, index) => (
<div key={item.id} className="group relative w-20 shrink-0">
<button
type="button"
className="relative block size-20 overflow-hidden rounded-lg"
title="点击放大预览"
aria-label={`预览 ${item.name || `图片${index + 1}`}`}
onClick={() => setPreviewAttachment(item)}
onDoubleClick={() => setPreviewAttachment(item)}
>
<img src={item.url} alt={item.name} className="size-full object-cover" />
</button>
{onRemoveAttachment ? (
<button
type="button"
className="grid size-4 shrink-0 place-items-center rounded-full opacity-70 hover:opacity-100"
style={{ background: theme.toolbar.panel, color: theme.node.text }}
onClick={() => onRemoveAttachment(item.id)}
aria-label="移除图片"
>
<X className="size-3" />
<div className="mt-1 flex min-w-0 items-center justify-between gap-1">
<button type="button" className="flex min-w-0 items-center gap-0.5 truncate text-[var(--fs-tiny)] opacity-80 hover:opacity-100" title={`插入 @图片${index + 1}`} onClick={() => insertAttachmentMention(item)}>
<AtSign className="size-2.5 shrink-0" />
<span className="truncate">图片{index + 1}</span>
</button>
) : null}
{onRemoveAttachment ? (
<button
type="button"
className="grid size-4 shrink-0 place-items-center rounded-full opacity-70 hover:opacity-100"
style={{ background: theme.toolbar.panel, color: theme.node.text }}
onClick={() => onRemoveAttachment(item.id)}
aria-label="移除图片"
>
<X className="size-3" />
</button>
) : null}
</div>
</div>
</div>
))}
</div>
) : null}
<div className="relative" onKeyDownCapture={handleSlashKeyCapture} onPasteCapture={handlePasteCapture}>
<button
type="button"
role="separator"
aria-orientation="horizontal"
aria-label="调整提示词面板高度"
aria-valuemin={MIN_AGENT_PROMPT_HEIGHT}
aria-valuemax={MAX_AGENT_PROMPT_HEIGHT}
aria-valuenow={promptHeight}
className="agent-composer-resize-handle"
style={{ color: theme.node.muted }}
onPointerDown={startPromptResize}
onPointerMove={resizePrompt}
onPointerUp={finishPromptResize}
onPointerCancel={finishPromptResize}
onKeyDown={resizePromptByKeyboard}
>
<span />
</button>
<div className="agent-composer-prompt-scroll" style={{ height: promptHeight }}>
<CanvasResourceMentionTextarea
value={prompt}
references={composerReferences}
includeAssetLibrary={includeAssetLibrary}
sendOnEnter={canSubmit ? "both" : false}
disabled={disabled}
onChange={handlePromptChange}
onSubmit={() => {
if (canSubmit) onSubmit();
}}
onContentSizeChange={handlePromptContentSizeChange}
className="w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45"
containerClassName="min-h-[60px] h-full"
style={{ color: theme.node.text }}
placeholder={placeholder}
aria-label="Agent 输入"
/>
</div>
{slash && slashCandidates.length ? (
<div
data-agent-slash-menu
className="absolute bottom-full left-0 z-[var(--z-toolbar)] mb-2 w-full max-w-xs overflow-hidden rounded-2xl p-1.5 shadow-2xl"
style={{ background: theme.toolbar.panel, boxShadow: `0 18px 44px ${theme.spatial.shadow}` }}
onMouseDown={(event) => event.preventDefault()}
>
{slashCandidates.map((skill, index) => (
<button
key={skill.skillId}
type="button"
className="flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs"
style={{ background: index === activeSlashIndex ? theme.toolbar.itemHover : "transparent", color: theme.node.text }}
onMouseEnter={() => setSlashIndex(index)}
onClick={() => applySlashSkill(skill)}
>
<Sparkles className="size-3.5 shrink-0 opacity-70" />
<span className="min-w-0 truncate font-medium">{skill.skillName}</span>
{skill.description ? <span className="min-w-0 flex-1 truncate opacity-50">{skill.description}</span> : null}
</button>
))}
</div>
) : null}
</div>
<div className="agent-composer-toolbar mt-2">
<div className="agent-composer-controls flex min-w-0 items-center gap-1">
{onAddFiles ? (
<>
<input
ref={fileInputRef}
hidden
type="file"
accept="image/*"
multiple
onChange={(event) => {
void onAddFiles(event.target.files);
event.target.value = "";
}}
/>
<Tooltip title="上传图片">
<Button
type="text"
shape="circle"
className="!h-8 !w-8 !min-w-8 !transition-transform hover:!scale-105 active:!scale-95"
disabled={sending}
style={{ color: theme.node.muted }}
icon={<ImagePlus className="size-4" />}
onClick={() => fileInputRef.current?.click()}
/>
</Tooltip>
</>
<div className="relative" onKeyDownCapture={handleSlashKeyCapture} onPasteCapture={handlePasteCapture}>
<button
type="button"
role="separator"
aria-orientation="horizontal"
aria-label="调整提示词面板高度"
aria-valuemin={MIN_AGENT_PROMPT_HEIGHT}
aria-valuemax={MAX_AGENT_PROMPT_HEIGHT}
aria-valuenow={promptHeight}
className="agent-composer-resize-handle"
style={{ color: theme.node.muted }}
onPointerDown={startPromptResize}
onPointerMove={resizePrompt}
onPointerUp={finishPromptResize}
onPointerCancel={finishPromptResize}
onKeyDown={resizePromptByKeyboard}
>
<span />
</button>
<div className="agent-composer-prompt-scroll" style={{ height: promptHeight }}>
<CanvasResourceMentionTextarea
value={prompt}
references={composerReferences}
includeAssetLibrary={includeAssetLibrary}
sendOnEnter={canSubmit ? "both" : false}
disabled={disabled}
onChange={handlePromptChange}
onSubmit={() => {
if (canSubmit) onSubmit();
}}
onContentSizeChange={handlePromptContentSizeChange}
className="w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45"
containerClassName="min-h-[60px] h-full"
style={{ color: theme.node.text }}
placeholder={placeholder}
aria-label="Agent 输入"
/>
</div>
{slash && slashCandidates.length ? (
<div
data-agent-slash-menu
className="absolute bottom-full left-0 z-[var(--z-toolbar)] mb-2 w-full max-w-xs overflow-hidden rounded-2xl p-1.5 shadow-2xl"
style={{ background: theme.toolbar.panel, boxShadow: `0 18px 44px ${theme.spatial.shadow}` }}
onMouseDown={(event) => event.preventDefault()}
>
{slashCandidates.map((skill, index) => (
<button
key={skill.skillId}
type="button"
className="flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs"
style={{ background: index === activeSlashIndex ? theme.toolbar.itemHover : "transparent", color: theme.node.text }}
onMouseEnter={() => setSlashIndex(index)}
onClick={() => applySlashSkill(skill)}
>
<Sparkles className="size-3.5 shrink-0 opacity-70" />
<span className="min-w-0 truncate font-medium">{skill.skillName}</span>
{skill.description ? <span className="min-w-0 flex-1 truncate opacity-50">{skill.description}</span> : null}
</button>
))}
</div>
) : null}
{left}
</div>
<div className="agent-composer-submit flex items-center gap-2">
{submitAccessory}
{canStop ? (
<div className="agent-composer-toolbar mt-2">
<div className="agent-composer-controls flex min-w-0 items-center gap-1">
{onAddFiles ? (
<>
<input
ref={fileInputRef}
hidden
type="file"
accept="image/*"
multiple
onChange={(event) => {
void onAddFiles(event.target.files);
event.target.value = "";
}}
/>
<Tooltip title="上传图片">
<Button
type="text"
shape="circle"
className="!h-8 !w-8 !min-w-8 !transition-transform hover:!scale-105 active:!scale-95"
disabled={sending}
style={{ color: theme.node.muted }}
icon={<ImagePlus className="size-4" />}
onClick={() => fileInputRef.current?.click()}
/>
</Tooltip>
</>
) : null}
{left}
</div>
<div className="agent-composer-submit flex items-center gap-2">
{submitAccessory}
{canStop ? (
<motion.button
type="button"
disabled={stopping}
aria-label="停止本轮"
title="停止当前 Agent 运行"
onClick={() => void onStop?.()}
whileHover={!reducedMotion && !stopping ? { scale: 1.06, y: -1 } : undefined}
whileTap={!reducedMotion && !stopping ? { scale: 0.9, y: 1 } : undefined}
animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1 }}
transition={{ type: "spring", stiffness: 420, damping: 24 }}
className="grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed"
style={{ background: theme.accent.danger, color: theme.accent.onPrimary }}
>
{stopping ? <LoaderCircle className="size-4 animate-spin" /> : <Square className="size-3.5" fill="currentColor" />}
</motion.button>
) : null}
<motion.button
type="button"
disabled={stopping}
aria-label="停止本轮"
title="停止当前 Agent 运行"
onClick={() => void onStop?.()}
whileHover={!reducedMotion && !stopping ? { scale: 1.06, y: -1 } : undefined}
whileTap={!reducedMotion && !stopping ? { scale: 0.9, y: 1 } : undefined}
animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1 }}
transition={{ type: "spring", stiffness: 420, damping: 24 }}
className="grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed"
style={{ background: theme.accent.danger, color: theme.accent.onPrimary }}
disabled={!canSubmit}
aria-label={sending ? "发送中" : canStop ? "插话" : "发送"}
title={canStop ? "插话:Agent 下一次开口时看到它" : "点击发送;Enter 或 ⌘/Ctrl+Enter 发送"}
onClick={() => onSubmit()}
whileHover={canSubmit && !reducedMotion ? { scale: 1.06, y: -1 } : undefined}
whileTap={canSubmit && !reducedMotion ? { scale: 0.9, y: 1 } : undefined}
animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1, rotate: 0 }}
transition={sending && !reducedMotion ? { duration: 0.42, ease: "easeOut" } : { type: "spring", stiffness: 420, damping: 24 }}
data-icon-only
className="agent-composer-send grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed"
style={{
background: canSubmit || sending ? theme.accent.primary : theme.spatial.surface,
color: canSubmit || sending ? theme.accent.onPrimary : theme.node.muted,
}}
>
{stopping ? <LoaderCircle className="size-4 animate-spin" /> : <Square className="size-3.5" fill="currentColor" />}
<motion.span
key={stopping ? "stopping" : sending ? "sending" : "ready"}
initial={reducedMotion ? false : { opacity: 0, scale: 0.65, rotate: sending ? -25 : 25 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.18, ease: "easeOut" }}
className="grid place-items-center"
>
{sending ? <LoaderCircle className="size-3.5 animate-spin" /> : <ArrowUp className="size-3.5" />}
</motion.span>
</motion.button>
) : null}
<motion.button
type="button"
disabled={!canSubmit}
aria-label={sending ? "发送中" : canStop ? "插话" : "发送"}
title={canStop ? "插话:Agent 下一次开口时看到它" : "点击发送;Enter 或 ⌘/Ctrl+Enter 发送"}
onClick={() => onSubmit()}
whileHover={canSubmit && !reducedMotion ? { scale: 1.06, y: -1 } : undefined}
whileTap={canSubmit && !reducedMotion ? { scale: 0.9, y: 1 } : undefined}
animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1, rotate: 0 }}
transition={sending && !reducedMotion ? { duration: 0.42, ease: "easeOut" } : { type: "spring", stiffness: 420, damping: 24 }}
data-icon-only
className="agent-composer-send grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed"
style={{
background: canSubmit || sending ? theme.accent.primary : theme.spatial.surface,
color: canSubmit || sending ? theme.accent.onPrimary : theme.node.muted,
}}
>
<motion.span
key={stopping ? "stopping" : sending ? "sending" : "ready"}
initial={reducedMotion ? false : { opacity: 0, scale: 0.65, rotate: sending ? -25 : 25 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.18, ease: "easeOut" }}
className="grid place-items-center"
>
{sending ? <LoaderCircle className="size-3.5 animate-spin" /> : <ArrowUp className="size-3.5" />}
</motion.span>
</motion.button>
</div>
</div>
</div>
</div>
{footer}
{footer}
</div>
{previewAttachment ? <AgentImagePreview attachment={previewAttachment} onClose={() => setPreviewAttachment(null)} /> : null}
</div>
@@ -18,8 +18,16 @@ type AgentConnector = {
function NotionIcon() {
return (
<svg width="20" height="20" viewBox="0 0 100 100" fill="none">
<path d="M6.017 4.313l55.333 -4.087c6.797 -0.583 8.543 -0.19 12.817 2.917l17.663 12.443c2.913 2.14 3.883 2.723 3.883 5.053v68.243c0 4.277 -1.553 6.807 -6.99 7.193L24.467 99.967c-4.08 0.193 -6.023 -0.39 -8.16 -3.113L3.3 79.94c-2.333 -3.113 -3.3 -5.443 -3.3 -8.167V11.113c0 -3.497 1.553 -6.413 6.017 -6.8z" fill="#fff"/>
<path fillRule="evenodd" clipRule="evenodd" d="M61.35 0.227l-55.333 4.087C1.553 4.7 0 7.617 0 11.113v60.66c0 2.724 0.967 5.053 3.3 8.167l13.007 16.913c2.137 2.723 4.08 3.307 8.16 3.113l64.257 -3.89c5.433 -0.387 6.99 -2.917 6.99 -7.193V20.64c0 -2.21 -0.873 -2.847 -3.443 -4.733L74.167 3.143c-4.273 -3.107 -6.02 -3.5 -12.817 -2.917zM25.92 19.523c-5.247 0.353 -6.437 0.433 -9.417 -1.99L8.927 11.507c-0.77 -0.78 -0.383 -1.753 1.557 -1.947l53.193 -3.887c4.467 -0.39 6.793 1.167 8.54 2.527l9.123 6.61c0.39 0.197 1.36 1.36 0.193 1.36l-54.933 3.307 -0.68 0.047zM19.803 88.3V30.367c0 -2.53 0.777 -3.697 3.103 -3.893L86 22.78c2.14 -0.193 3.107 1.167 3.107 3.693v57.547c0 2.53 -0.39 4.67 -3.883 4.863l-60.377 3.5c-3.493 0.193 -5.043 -0.97 -5.043 -4.083zm59.6 -54.827c0.387 1.75 0 3.5 -1.75 3.7l-2.91 0.577v42.773c-2.527 1.36 -4.853 2.137 -6.797 2.137 -3.107 0 -3.883 -0.973 -6.21 -3.887l-19.03 -29.94v28.967l6.02 1.363s0 3.5 -4.857 3.5l-13.39 0.777c-0.39 -0.78 0 -2.723 1.357 -3.11l3.497 -0.97v-38.3L30.48 40.667c-0.39 -1.75 0.58 -4.277 3.3 -4.473l14.367 -0.967 19.8 30.327v-26.83l-5.047 -0.58c-0.39 -2.143 1.163 -3.7 3.103 -3.89l13.4 -0.78z" fill="#000"/>
<path
d="M6.017 4.313l55.333 -4.087c6.797 -0.583 8.543 -0.19 12.817 2.917l17.663 12.443c2.913 2.14 3.883 2.723 3.883 5.053v68.243c0 4.277 -1.553 6.807 -6.99 7.193L24.467 99.967c-4.08 0.193 -6.023 -0.39 -8.16 -3.113L3.3 79.94c-2.333 -3.113 -3.3 -5.443 -3.3 -8.167V11.113c0 -3.497 1.553 -6.413 6.017 -6.8z"
fill="#fff"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M61.35 0.227l-55.333 4.087C1.553 4.7 0 7.617 0 11.113v60.66c0 2.724 0.967 5.053 3.3 8.167l13.007 16.913c2.137 2.723 4.08 3.307 8.16 3.113l64.257 -3.89c5.433 -0.387 6.99 -2.917 6.99 -7.193V20.64c0 -2.21 -0.873 -2.847 -3.443 -4.733L74.167 3.143c-4.273 -3.107 -6.02 -3.5 -12.817 -2.917zM25.92 19.523c-5.247 0.353 -6.437 0.433 -9.417 -1.99L8.927 11.507c-0.77 -0.78 -0.383 -1.753 1.557 -1.947l53.193 -3.887c4.467 -0.39 6.793 1.167 8.54 2.527l9.123 6.61c0.39 0.197 1.36 1.36 0.193 1.36l-54.933 3.307 -0.68 0.047zM19.803 88.3V30.367c0 -2.53 0.777 -3.697 3.103 -3.893L86 22.78c2.14 -0.193 3.107 1.167 3.107 3.693v57.547c0 2.53 -0.39 4.67 -3.883 4.863l-60.377 3.5c-3.493 0.193 -5.043 -0.97 -5.043 -4.083zm59.6 -54.827c0.387 1.75 0 3.5 -1.75 3.7l-2.91 0.577v42.773c-2.527 1.36 -4.853 2.137 -6.797 2.137 -3.107 0 -3.883 -0.973 -6.21 -3.887l-19.03 -29.94v28.967l6.02 1.363s0 3.5 -4.857 3.5l-13.39 0.777c-0.39 -0.78 0 -2.723 1.357 -3.11l3.497 -0.97v-38.3L30.48 40.667c-0.39 -1.75 0.58 -4.277 3.3 -4.473l14.367 -0.967 19.8 30.327v-26.83l-5.047 -0.58c-0.39 -2.143 1.163 -3.7 3.103 -3.89l13.4 -0.78z"
fill="#000"
/>
</svg>
);
}
@@ -27,11 +35,11 @@ function NotionIcon() {
function FeishuIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#3370FF"/>
<path d="M7 7h10v3H7V7z" fill="#fff"/>
<path d="M7 11h4v6H7v-6z" fill="#fff" opacity="0.9"/>
<path d="M12 11h5v2.5h-5V11z" fill="#fff" opacity="0.75"/>
<path d="M12 14.5h5V17h-5v-2.5z" fill="#fff" opacity="0.75"/>
<rect width="24" height="24" rx="6" fill="#3370FF" />
<path d="M7 7h10v3H7V7z" fill="#fff" />
<path d="M7 11h4v6H7v-6z" fill="#fff" opacity="0.9" />
<path d="M12 11h5v2.5h-5V11z" fill="#fff" opacity="0.75" />
<path d="M12 14.5h5V17h-5v-2.5z" fill="#fff" opacity="0.75" />
</svg>
);
}
@@ -39,9 +47,9 @@ function FeishuIcon() {
function BasecampIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#1DB954"/>
<path d="M12 6c-3.5 0-6 2.5-6 6s2.5 6 6 6 6-2.5 6-6-2.5-6-6-6zm0 10c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4z" fill="#fff"/>
<circle cx="12" cy="12" r="2" fill="#fff"/>
<circle cx="12" cy="12" r="11" fill="#1DB954" />
<path d="M12 6c-3.5 0-6 2.5-6 6s2.5 6 6 6 6-2.5 6-6-2.5-6-6-6zm0 10c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4z" fill="#fff" />
<circle cx="12" cy="12" r="2" fill="#fff" />
</svg>
);
}
@@ -49,9 +57,9 @@ function BasecampIcon() {
function ObsidianIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#7C3AED"/>
<path d="M12 4l7 4v8l-7 4-7-4V8l7-4z" stroke="#fff" strokeWidth="1.5" fill="none"/>
<path d="M12 4v8m0 8V12m-7-4l7 4m7-4l-7 4" stroke="#fff" strokeWidth="1.2" opacity="0.6"/>
<rect width="24" height="24" rx="6" fill="#7C3AED" />
<path d="M12 4l7 4v8l-7 4-7-4V8l7-4z" stroke="#fff" strokeWidth="1.5" fill="none" />
<path d="M12 4v8m0 8V12m-7-4l7 4m7-4l-7 4" stroke="#fff" strokeWidth="1.2" opacity="0.6" />
</svg>
);
}
@@ -59,8 +67,13 @@ function ObsidianIcon() {
function GitHubIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="11" fill="#24292e"/>
<path fillRule="evenodd" clipRule="evenodd" d="M12 3C7.03 3 3 7.03 3 12c0 3.98 2.58 7.35 6.15 8.54.45.08.62-.19.62-.43v-1.7c-2.52.55-3.05-1.08-3.05-1.08-.41-1.04-1-1.32-1-1.32-.82-.56.06-.55.06-.55.91.06 1.39.93 1.39.93.81 1.38 2.12.98 2.63.75.08-.58.31-.98.57-1.2-2.01-.23-4.13-1-4.13-4.47 0-.99.35-1.8.93-2.43-.09-.23-.4-1.14.09-2.38 0 0 .76-.24 2.48.92a8.6 8.6 0 012.26-.3c.77 0 1.54.1 2.26.3 1.72-1.16 2.48-.92 2.48-.92.49 1.24.18 2.15.09 2.38.58.63.93 1.44.93 2.43 0 3.48-2.13 4.24-4.15 4.46.33.28.62.83.62 1.67v2.47c0 .24.17.52.63.43A9.003 9.003 0 0021 12c0-4.97-4.03-9-9-9z" fill="#fff"/>
<circle cx="12" cy="12" r="11" fill="#24292e" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 3C7.03 3 3 7.03 3 12c0 3.98 2.58 7.35 6.15 8.54.45.08.62-.19.62-.43v-1.7c-2.52.55-3.05-1.08-3.05-1.08-.41-1.04-1-1.32-1-1.32-.82-.56.06-.55.06-.55.91.06 1.39.93 1.39.93.81 1.38 2.12.98 2.63.75.08-.58.31-.98.57-1.2-2.01-.23-4.13-1-4.13-4.47 0-.99.35-1.8.93-2.43-.09-.23-.4-1.14.09-2.38 0 0 .76-.24 2.48.92a8.6 8.6 0 012.26-.3c.77 0 1.54.1 2.26.3 1.72-1.16 2.48-.92 2.48-.92.49 1.24.18 2.15.09 2.38.58.63.93 1.44.93 2.43 0 3.48-2.13 4.24-4.15 4.46.33.28.62.83.62 1.67v2.47c0 .24.17.52.63.43A9.003 9.003 0 0021 12c0-4.97-4.03-9-9-9z"
fill="#fff"
/>
</svg>
);
}
@@ -68,12 +81,12 @@ function GitHubIcon() {
function TencentDocsIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#1E6FFF"/>
<path d="M8 6h5.5l3 3v8.5a1 1 0 01-1 1H8a1 1 0 01-1-1V7a1 1 0 011-1z" fill="#fff"/>
<path d="M13.5 6v2.2c0 .4.3.8.8.8H17" fill="#BFD4FF"/>
<rect x="9.5" y="12" width="5.5" height="1.2" rx=".6" fill="#1E6FFF"/>
<rect x="9.5" y="14.5" width="5.5" height="1.2" rx=".6" fill="#1E6FFF" opacity=".7"/>
<rect x="9.5" y="17" width="3.5" height="1.2" rx=".6" fill="#1E6FFF" opacity=".5"/>
<rect width="24" height="24" rx="6" fill="#1E6FFF" />
<path d="M8 6h5.5l3 3v8.5a1 1 0 01-1 1H8a1 1 0 01-1-1V7a1 1 0 011-1z" fill="#fff" />
<path d="M13.5 6v2.2c0 .4.3.8.8.8H17" fill="#BFD4FF" />
<rect x="9.5" y="12" width="5.5" height="1.2" rx=".6" fill="#1E6FFF" />
<rect x="9.5" y="14.5" width="5.5" height="1.2" rx=".6" fill="#1E6FFF" opacity=".7" />
<rect x="9.5" y="17" width="3.5" height="1.2" rx=".6" fill="#1E6FFF" opacity=".5" />
</svg>
);
}
@@ -81,10 +94,10 @@ function TencentDocsIcon() {
function SlackIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M8.5 3.5a2 2 0 11-4 0 2 2 0 014 0zm0 7a2 2 0 01-2 2h-2a2 2 0 010-4h2a2 2 0 012 2z" fill="#E01E5A"/>
<path d="M15.5 10.5a2 2 0 100-4 2 2 0 000 4zm7 0a2 2 0 01-2 2h-2a2 2 0 110-4h2a2 2 0 012 2z" fill="#36C5F0"/>
<path d="M15.5 20.5a2 2 0 100-4 2 2 0 000 4zm0-7a2 2 0 012-2v-2a2 2 0 10-4 0v2a2 2 0 012 2z" fill="#2EB67D"/>
<path d="M8.5 13.5a2 2 0 10-4 0 2 2 0 004 0zm7 0a2 2 0 01-2 2v2a2 2 0 104 0v-2a2 2 0 01-2-2z" fill="#ECB22E"/>
<path d="M8.5 3.5a2 2 0 11-4 0 2 2 0 014 0zm0 7a2 2 0 01-2 2h-2a2 2 0 010-4h2a2 2 0 012 2z" fill="#E01E5A" />
<path d="M15.5 10.5a2 2 0 100-4 2 2 0 000 4zm7 0a2 2 0 01-2 2h-2a2 2 0 110-4h2a2 2 0 012 2z" fill="#36C5F0" />
<path d="M15.5 20.5a2 2 0 100-4 2 2 0 000 4zm0-7a2 2 0 012-2v-2a2 2 0 10-4 0v2a2 2 0 012 2z" fill="#2EB67D" />
<path d="M8.5 13.5a2 2 0 10-4 0 2 2 0 004 0zm7 0a2 2 0 01-2 2v2a2 2 0 104 0v-2a2 2 0 01-2-2z" fill="#ECB22E" />
</svg>
);
}
@@ -111,12 +124,7 @@ export function AgentConnectorsBar({ onClose }: { onClose: () => void }) {
<div className="agent-connectors-list">
{AGENT_CONNECTORS.map((connector) => (
<Tooltip key={connector.id} title={`${connector.name} · 研发中`} placement="top">
<button
type="button"
className="agent-connector-item"
aria-label={`${connector.name}(研发中)`}
onClick={() => message.info({ content: `${connector.name}连接器研发中,敬请期待`, key: `agent-connector-${connector.id}` })}
>
<button type="button" className="agent-connector-item" aria-label={`${connector.name}(研发中)`} onClick={() => message.info({ content: `${connector.name}连接器研发中,敬请期待`, key: `agent-connector-${connector.id}` })}>
{connector.icon}
</button>
</Tooltip>
@@ -239,7 +239,13 @@ export function AgentContextRing({ view }: { view: AgentContextUsageView }) {
</span>
{view.compactAtTokens ? <span>压缩线 {formatContextCount(view.compactAtTokens)}</span> : null}
</div>
{view.compactionError ? <p className="agent-context-note" role="status">{view.compactionError}</p> : view.lastCompaction ? <p className="agent-context-note">本轮已完成一次上下文压缩,下一次读数会刷新。</p> : null}
{view.compactionError ? (
<p className="agent-context-note" role="status">
{view.compactionError}
</p>
) : view.lastCompaction ? (
<p className="agent-context-note">本轮已完成一次上下文压缩,下一次读数会刷新。</p>
) : null}
</div>
}
>
@@ -58,7 +58,7 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
<div className="image-size-preset-heading-label">
<strong id={`${id}-${tier}-title`}>{tier.toUpperCase()}</strong>
<span>{items.length ? `${items.length} 个比例` : "未启用"}</span>
<Switch size="sm" checked={items.length === ratios.length} disabled={disabled} aria-label={`启用 ${tier.toUpperCase()} 规格`} onChange={(enabled) => toggleTier(tier, ratios, enabled)} />
<Switch size="sm" checked={items.length > 0} disabled={disabled} aria-label={`启用 ${tier.toUpperCase()} 规格`} onChange={(enabled) => toggleTier(tier, ratios, enabled)} />
</div>
</header>
<div className="image-size-preset-body">
@@ -4,10 +4,15 @@ import ReactMarkdown from "react-markdown";
import { AppModal } from "@/components/ui/product/app-modal/app-modal";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { useAppearanceStore } from "@/stores/use-appearance-store";
import { updateAnnouncementVersion } from "@/lib/update-announcement";
import { UpdateAnnouncementContent } from "./update-announcement-content";
export function AppChangelogDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const reducedMotion = useReducedMotion();
const version = `v${__APP_VERSION__.replace(/^v/, "")}`;
const updates = useAppearanceStore((state) => state.appearance.updates);
const custom = Boolean(updates?.enabled);
const version = updateAnnouncementVersion(updates, __APP_VERSION__);
return (
<AppModal
@@ -18,8 +23,8 @@ export function AppChangelogDialog({ open, onClose }: { open: boolean; onClose:
<ScrollText className="size-4" />
</span>
<div className="app-changelog-heading-copy">
<div className="app-changelog-heading-title">更新日志</div>
<div className="app-changelog-heading-description">按版本查看产品能力、交互与稳定性变化</div>
<div className="app-changelog-heading-title">{custom ? "更新公告" : "更新日志"}</div>
<div className="app-changelog-heading-description">{custom ? "按版本查看本站发布的更新与功能介绍" : "按版本查看产品能力、交互与稳定性变化"}</div>
</div>
<span className="app-changelog-current-version">当前版本 {version}</span>
</div>
@@ -36,29 +41,33 @@ export function AppChangelogDialog({ open, onClose }: { open: boolean; onClose:
)}
>
<div className="app-changelog-scroll thin-scrollbar">
<ReactMarkdown
components={{
h1: () => null,
h2: ({ children }) => {
const label = String(children);
const latest = label === "Unreleased";
{custom && updates ? (
<UpdateAnnouncementContent value={updates} />
) : (
<ReactMarkdown
components={{
h1: () => null,
h2: ({ children }) => {
const label = String(children);
const latest = label === "Unreleased";
return (
<h3 className={`app-changelog-section-heading${latest ? " is-latest" : ""}`}>
<span className="app-changelog-section-marker" aria-hidden="true" />
<span>{latest ? "开发中" : label}</span>
{latest ? <span className="app-changelog-latest-badge">最新</span> : null}
</h3>
);
},
ul: ({ children }) => <ul className="app-changelog-list">{children}</ul>,
li: ({ children }) => <li>{children}</li>,
p: ({ children }) => <p className="app-changelog-paragraph">{children}</p>,
code: ({ children }) => <code className="app-changelog-code">{children}</code>,
}}
>
{__APP_CHANGELOG__}
</ReactMarkdown>
return (
<h3 className={`app-changelog-section-heading${latest ? " is-latest" : ""}`}>
<span className="app-changelog-section-marker" aria-hidden="true" />
<span>{latest ? "开发中" : label}</span>
{latest ? <span className="app-changelog-latest-badge">最新</span> : null}
</h3>
);
},
ul: ({ children }) => <ul className="app-changelog-list">{children}</ul>,
li: ({ children }) => <li>{children}</li>,
p: ({ children }) => <p className="app-changelog-paragraph">{children}</p>,
code: ({ children }) => <code className="app-changelog-code">{children}</code>,
}}
>
{__APP_CHANGELOG__}
</ReactMarkdown>
)}
</div>
</AppModal>
);
@@ -1,5 +1,8 @@
import { ScrollText } from "lucide-react";
import { lazy, Suspense, useState, type CSSProperties, type ReactNode } from "react";
import { useAppearanceStore } from "@/stores/use-appearance-store";
import { updateAnnouncementVersion } from "@/lib/update-announcement";
import { refreshPublicAppearance } from "@/services/appearance-bootstrap";
const AppChangelogDialog = lazy(() => import("@/components/layout/app-changelog-dialog").then((module) => ({ default: module.AppChangelogDialog })));
@@ -16,17 +19,37 @@ type AppChangelogButtonProps = {
label?: ReactNode;
};
export function AppChangelogButton({ className, style, showVersion = false, showLabel = false, labelClassName, versionClassName, icon, label = "更新日志" }: AppChangelogButtonProps) {
export function AppChangelogButton({ className, style, showVersion = false, showLabel = false, labelClassName, versionClassName, icon, label }: AppChangelogButtonProps) {
const [open, setOpen] = useState(false);
const updates = useAppearanceStore((state) => state.appearance.updates);
const title = updates?.enabled ? "更新公告" : "更新日志";
return (
<>
<button type="button" className={className} style={style} onClick={() => setOpen(true)} aria-label="查看更新日志" title="更新日志">
<button
type="button"
className={className}
style={style}
onClick={() => {
void refreshPublicAppearance();
setOpen(true);
}}
aria-label={`查看${title}`}
title={title}
>
{icon ?? <ScrollText className="size-4 shrink-0" />}
{showLabel ? <span className={`whitespace-nowrap ${labelClassName || ""}`}>{label}</span> : null}
{showVersion ? <span className={versionClassName}>v{APP_VERSION.replace(/^v/, "")}</span> : null}
{showLabel ? <span className={`whitespace-nowrap ${labelClassName || ""}`}>{label ?? title}</span> : null}
{showVersion ? (
<span className={`${versionClassName || ""} min-w-0 truncate`} title={updateAnnouncementVersion(updates, APP_VERSION)}>
{updateAnnouncementVersion(updates, APP_VERSION)}
</span>
) : null}
</button>
{open ? <Suspense fallback={null}><AppChangelogDialog open onClose={() => setOpen(false)} /></Suspense> : null}
{open ? (
<Suspense fallback={null}>
<AppChangelogDialog open onClose={() => setOpen(false)} />
</Suspense>
) : null}
</>
);
}
+20 -2
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { lazy, Suspense, useLayoutEffect } from "react";
import { lazy, Suspense, useEffect, useLayoutEffect } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { App, ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN";
@@ -13,13 +13,18 @@ import { isIsolatedDirectorRepro } from "@/lib/dev-repro";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { applyAppearanceMetadata, useAppearanceStore } from "@/stores/use-appearance-store";
import { useUserStore } from "@/stores/use-user-store";
import { refreshPublicAppearance } from "@/services/appearance-bootstrap";
const ClientRootInit = lazy(() => import("@/components/layout/client-root-init").then((module) => ({ default: module.ClientRootInit })));
function ClientRootBoundary({ children }: { children: ReactNode }) {
const authenticated = useUserStore((state) => Boolean(state.user));
if (!authenticated) return children;
return <Suspense fallback={<FullScreenLoader label="正在准备创作环境" detail="连接本地能力与模型配置" />}><ClientRootInit>{children}</ClientRootInit></Suspense>;
return (
<Suspense fallback={<FullScreenLoader label="正在准备创作环境" detail="连接本地能力与模型配置" />}>
<ClientRootInit>{children}</ClientRootInit>
</Suspense>
);
}
export function AppProviders({ children }: { children: ReactNode }) {
@@ -39,6 +44,19 @@ export function AppProviders({ children }: { children: ReactNode }) {
// 与启动入口共用精确路径边界;生产构建中 import.meta.env.DEV 为 false,始终不启用隔离。
const isolateDevRepro = typeof window !== "undefined" && isIsolatedDirectorRepro(import.meta.env.DEV, window.location.pathname);
useEffect(() => {
if (isolateDevRepro) return;
const refresh = () => {
if (document.visibilityState === "visible") void refreshPublicAppearance();
};
window.addEventListener("focus", refresh);
document.addEventListener("visibilitychange", refresh);
return () => {
window.removeEventListener("focus", refresh);
document.removeEventListener("visibilitychange", refresh);
};
}, [isolateDevRepro]);
return (
<ConfigProvider locale={zhCN} theme={getAntThemeConfig(dark, appearance.activeSkin)} wave={{ disabled: true }}>
<App message={{ duration: 3, maxCount: 3 }} notification={{ duration: 4.5, maxCount: 3, placement: "topRight" }}>
@@ -0,0 +1,121 @@
.update-announcement-content {
display: grid;
gap: 28px;
min-width: 0;
color: inherit;
}
.update-announcement-release {
min-width: 0;
}
.update-announcement-release + .update-announcement-release {
padding-top: 24px;
border-top: 1px solid currentColor;
border-color: color-mix(in srgb, currentColor 15%, transparent);
}
.update-announcement-release-heading {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.update-announcement-release-heading h3 {
margin: 0;
font-size: 18px;
font-weight: 650;
overflow-wrap: anywhere;
}
.update-announcement-release-heading time {
margin-left: auto;
font-size: 12px;
opacity: 0.6;
}
.update-announcement-badge {
padding: 2px 8px;
border: 1px solid currentColor;
border-color: color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
font-size: 11px;
}
.update-announcement-release-title {
margin: 0 0 16px;
font-size: 15px;
font-weight: 600;
overflow-wrap: anywhere;
}
.update-announcement-block-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
align-items: start;
}
.update-announcement-block {
min-width: 0;
overflow-wrap: anywhere;
}
.update-announcement-block.is-full {
grid-column: 1 / -1;
}
.update-announcement-block figure {
margin: 0;
}
.update-announcement-block img,
.update-announcement-block video {
display: block;
width: 100%;
height: auto;
max-height: 420px;
object-fit: contain;
border-radius: 10px;
}
.update-announcement-block figcaption {
padding-top: 8px;
font-size: 12px;
line-height: 1.6;
opacity: 0.65;
}
.update-announcement-copy {
font-size: 14px;
line-height: 1.85;
}
.update-announcement-copy > :first-child {
margin-top: 0;
}
.update-announcement-copy > :last-child {
margin-bottom: 0;
}
.update-announcement-copy p {
margin: 0 0 10px;
white-space: pre-wrap;
}
.update-announcement-copy ul,
.update-announcement-copy ol {
margin: 0 0 10px;
padding-left: 22px;
}
.update-announcement-copy pre {
overflow: auto;
padding: 12px;
background: color-mix(in srgb, currentColor 6%, transparent);
border-radius: 8px;
}
.update-announcement-copy a {
text-decoration: underline;
text-underline-offset: 3px;
}
.update-announcement-media-empty,
.update-announcement-empty {
padding: 24px;
text-align: center;
opacity: 0.6;
background: color-mix(in srgb, currentColor 5%, transparent);
border-radius: 10px;
}
@media (max-width: 600px) {
.update-announcement-block-grid {
grid-template-columns: minmax(0, 1fr);
}
.update-announcement-release-heading time {
margin-left: 0;
}
}
@@ -0,0 +1,51 @@
import ReactMarkdown from "react-markdown";
import type { UpdateAnnouncement, UpdateBlock } from "@/lib/update-announcement";
import "./update-announcement-content.css";
export function UpdateAnnouncementContent({ value, mediaURL }: { value: UpdateAnnouncement; mediaURL?: (block: UpdateBlock) => string }) {
return (
<div className="update-announcement-content">
{value.releases.length ? (
value.releases.map((release) => (
<section className="update-announcement-release" key={release.id} aria-label={`版本 ${release.version}`}>
<header className="update-announcement-release-heading">
<h3>{release.version || "待填写版本"}</h3>
{release.version === value.currentVersion ? <span className="update-announcement-badge">当前版本</span> : null}
{release.date ? <time dateTime={release.date}>{release.date}</time> : null}
</header>
{release.title ? <h4 className="update-announcement-release-title">{release.title}</h4> : null}
<div className="update-announcement-block-grid">
{release.blocks.map((block) => {
const url = mediaURL?.(block) || block.mediaUrl || "";
return (
<div className={`update-announcement-block is-${block.layout}`} key={block.id}>
{block.type === "text" ? (
<div className="update-announcement-copy">
<ReactMarkdown components={{ img: () => null }}>{block.text || "待填写文字内容"}</ReactMarkdown>
</div>
) : (
<figure>
{url ? (
block.type === "image" ? (
<img src={url} alt={block.caption || "更新公告配图"} loading="lazy" />
) : (
<video src={url} controls playsInline preload="metadata" aria-label={block.caption || "更新公告视频"} />
)
) : (
<div className="update-announcement-media-empty">待上传{block.type === "image" ? "图片" : "视频"}</div>
)}
{block.caption ? <figcaption>{block.caption}</figcaption> : null}
</figure>
)}
</div>
);
})}
</div>
</section>
))
) : (
<p className="update-announcement-empty">添加版本与内容后,可在此预览公告。</p>
)}
</div>
);
}
@@ -40,7 +40,7 @@ function toolItem(slug: NavigationToolSlug, to: string): WorkspaceNavItem {
return { id: slug, title: tool?.label ?? slug, icon: tool?.icon, to };
}
function buildNav(features: FeatureAvailability, isAdmin: boolean): { groups: WorkspaceNavGroup[]; footer: WorkspaceNavItem[] } {
function buildNav(features: FeatureAvailability): { groups: WorkspaceNavGroup[]; footer: WorkspaceNavItem[] } {
const groups: WorkspaceNavGroup[] = [
{
items: [
@@ -51,7 +51,7 @@ function buildNav(features: FeatureAvailability, isAdmin: boolean): { groups: Wo
},
{
heading: "资源与工具",
items: [{ ...toolItem("assets", "/assets"), title: "资产" }, { ...toolItem("skills", "/skills"), title: "技能" }, ...(features.pluginCenterEnabled || isAdmin ? [{ ...toolItem("plugins", "/plugins"), title: "插件" }] : [])],
items: [{ ...toolItem("assets", "/assets"), title: "资产" }, { ...toolItem("skills", "/skills"), title: "技能" }, ...(features.pluginCenterEnabled ? [{ ...toolItem("plugins", "/plugins"), title: "插件" }] : [])],
},
...(features.taskCenterEnabled ? [{ items: [{ ...toolItem("tasks", "/tasks"), title: "创作历史", icon: HistoryIcon }] }] : []),
];
@@ -290,7 +290,7 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp
const features = useUserStore((state) => state.features);
const user = useUserStore((state) => state.user);
const { handleLogout } = useWorkspaceLogout();
const { groups, footer } = useMemo(() => buildNav(features, user?.role === "admin"), [features, user?.role]);
const { groups, footer } = useMemo(() => buildNav(features), [features]);
const slug = pathname.split("/").filter(Boolean)[0] || "home";
const section = searchParams.get("section");
+34 -7
View File
@@ -6,7 +6,8 @@ import { SegmentedControl } from "@/components/ui/base/segmented-control";
import type { ReactNode } from "react";
import { defaultImageCapabilityConfig, defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ImageCapabilityConfig, type ModelCapabilityConfig, type TextCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import type { ModelProtocol } from "@/lib/model-protocols";
import type { ModelProtocol, ModelProtocolWorkflow } from "@/lib/model-protocols";
import { cleanVideoScreenSpecValues, resolveWorkflowVideoScreenSpec, updateWorkflowVideoScreenSpec } from "@/lib/video-screen-specs";
import { VIDEO_RESOLUTION_CAPABILITY_OPTIONS } from "@/lib/video-generation-options";
import { Select } from "@/components/ui/base/select";
@@ -33,23 +34,28 @@ type Props = {
protocol?: ModelProtocol;
capability?: "text" | "image" | "video";
model?: string;
workflows?: ModelProtocolWorkflow[];
disabled?: boolean;
section?: "all" | "protocol" | "references";
};
export function ModelCapabilityEditor({ value, onChange, protocol, capability = "video", model = "", disabled = false, section = "all" }: Props) {
export function ModelCapabilityEditor({ value, onChange, protocol, capability = "video", model = "", workflows = [], disabled = false, section = "all" }: Props) {
if (capability === "text") {
return <TextCapabilityEditor value={value} onChange={onChange} protocol={protocol} disabled={disabled} section={section} />;
}
if (capability === "image") {
return <ImageCapabilityEditor value={value} onChange={onChange} protocol={protocol} model={model} disabled={disabled} section={section} />;
}
const profile = normalizeModelCapabilityConfig(value || defaultModelCapabilityConfig(protocol)).video!;
const workflow = workflows.find((item) => item.id === model.trim().replace(/^models\//, "") && item.providerId === protocol);
const storedProfile = normalizeModelCapabilityConfig(value || defaultModelCapabilityConfig(protocol, model)).video!;
const autoDL = protocol === "autodl-comfyui";
const profile = autoDL ? resolveWorkflowVideoScreenSpec(storedProfile, workflow) : storedProfile;
const update = (patch: Partial<VideoCapabilityConfig>) => onChange?.({ version: 1, video: { ...profile, ...patch } });
const updateReferences = (patch: Partial<VideoCapabilityConfig["references"]>) => update({ references: { ...profile.references, ...patch } });
const updateDuration = (patch: Partial<VideoCapabilityConfig["duration"]>) => update({ duration: { ...profile.duration, ...patch } });
const durationValues = (profile.duration.values || []).join(",");
const resolutionOptions = Array.from(new Set([...VIDEO_RESOLUTION_CAPABILITY_OPTIONS, ...profile.resolutions]));
const autoDLSpecFields = <AutoDLScreenSpecFields profile={profile} workflow={workflow} disabled={disabled} onChange={(video) => onChange?.({ version: 1, video })} />;
if (section === "references") {
return (
@@ -150,7 +156,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
)}
</ProtocolParameterCard>
<ProtocolParameterCard step="03" title="画面规格" description="控制比例、分辨率及默认输出">
<div className="admin-capability-spec-grid">
{autoDL ? autoDLSpecFields : <div className="admin-capability-spec-grid">
<Field label="支持比例">
<Select
mode="multiple"
@@ -185,7 +191,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
onChange={(defaultResolution) => update({ defaultResolution })}
/>
</Field>
</div>
</div>}
</ProtocolParameterCard>
</div>
</div>
@@ -262,7 +268,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
)}
</CapabilityBlock>
<CapabilityBlock title="画面规格">
<div className="grid gap-3 sm:grid-cols-2">
{autoDL ? autoDLSpecFields : <div className="grid gap-3 sm:grid-cols-2">
<Field label="画面比例">
<Select
mode="multiple"
@@ -297,7 +303,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
onChange={(defaultResolution) => update({ defaultResolution })}
/>
</Field>
</div>
</div>}
</CapabilityBlock>
{section === "all" ? (
<CapabilityBlock title="视频引用">
@@ -661,6 +667,27 @@ function ProtocolParameterCard({ step, title, description, children, className =
);
}
function AutoDLScreenSpecFields({ profile, workflow, disabled, onChange }: { profile: VideoCapabilityConfig; workflow?: ModelProtocolWorkflow; disabled: boolean; onChange: (profile: VideoCapabilityConfig) => void }) {
if (!profile.fixedScreenSpec?.resolutions.length) {
return <p className="text-[var(--fs-label)] text-foreground/60">当前插件未声明此工作流的画面规格,请核对上游模型 ID。</p>;
}
const options = profile.fixedScreenSpec.resolutions;
return (
<div className="admin-capability-spec-grid">
<Field label="输出分辨率">
<Select ariaLabel="输出分辨率" mode="multiple" className="admin-capability-tags w-full" disabled={disabled} value={profile.resolutions} placeholder="选择分辨率" options={options.map((item) => ({ label: item, value: item }))} onChange={(values: string[]) => {
const resolutions = cleanVideoScreenSpecValues(values);
const defaultResolution = resolutions.includes(profile.defaultResolution) ? profile.defaultResolution : resolutions[0] || "";
onChange(updateWorkflowVideoScreenSpec(profile, workflow, { resolutions, defaultResolution }));
}} />
</Field>
<Field label="默认分辨率">
<Select ariaLabel="默认分辨率" className="w-full" disabled={disabled} value={profile.defaultResolution || undefined} options={profile.resolutions.map((item) => ({ label: item, value: item }))} onChange={(defaultResolution: string) => onChange(updateWorkflowVideoScreenSpec(profile, workflow, { defaultResolution }))} />
</Field>
</div>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block min-w-0">
@@ -1,7 +1,7 @@
import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
/** 异步阶段只准备结果;提交时按节点身份合并,绝不能用旧文档覆盖当前文档。 */
export function commitCanvasGenerationResult(current: CanvasNodeData[], before: CanvasNodeData, result: CanvasNodeData, taskId: string): CanvasNodeData[] {
export function commitCanvasGenerationResult(current: CanvasNodeData[], before: CanvasNodeData, result: CanvasNodeData, taskId: string, additionalNodes: CanvasNodeData[] = []): CanvasNodeData[] {
const live = current.find((node) => node.id === before.id);
if (!live) throw new Error("生成结果对应节点已删除,结果仍保留在任务中心");
if (result.id !== before.id || (live.metadata?.taskId && live.metadata.taskId !== taskId) || (before.metadata?.taskId && !live.metadata?.taskId)) {
@@ -24,9 +24,12 @@ export function commitCanvasGenerationResult(current: CanvasNodeData[], before:
metadata,
};
const versionRootId = merged.metadata?.versionOfNodeId;
return current.map((node) => {
const committed = current.map((node) => {
if (node.id === merged.id) return versionRootId ? { ...merged, metadata: { ...merged.metadata, versionPrimary: true } } : merged;
if (!versionRootId || (node.metadata?.versionOfNodeId || node.id) !== versionRootId) return node;
return { ...node, metadata: { ...node.metadata, versionPrimary: false } };
});
// 同一任务的图片子节点使用稳定 ID;重放只补缺失项,不覆盖用户已移动、改名的图片。
const existingIds = new Set(current.map((node) => node.id));
return [...committed, ...additionalNodes.filter((node) => !existingIds.has(node.id)).map((node) => ({ ...node, position: { x: node.position.x + merged.position.x - result.position.x, y: node.position.y + merged.position.y - result.position.y } }))];
}
@@ -1,5 +1,6 @@
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result";
import { imageGenerationChildPosition } from "@/lib/canvas/canvas-generation-layout";
import { fitNodeSize, nodeSizeFromRatio, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
import { compositeEmotionImage } from "@/lib/canvas/canvas-emotion";
import { storeGeneratedAudio } from "@/services/api/audio";
@@ -122,13 +123,13 @@ export function applyGeneratedMediaResultMetadata(node: CanvasNodeData, media: C
}, fallbackModel);
}
export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[] = [node]): Promise<CanvasNodeData> {
export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[] = [node], outputIndex = 0): Promise<CanvasNodeData> {
const mode = generationTaskMode(task, node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : node.type === CanvasNodeType.Audio ? "audio" : "image");
const prompt = node.metadata?.prompt || task.prompt;
const result = parseBackendGenerationResult(task);
if (mode === "image") {
const image = result.images?.[0];
const image = result.images?.[outputIndex];
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
let resultDataUrl = image.dataUrl;
const emotionEdit = node.metadata?.emotionEdit;
@@ -159,7 +160,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
width: imageSize.width,
height: imageSize.height,
position: { x: node.position.x + node.width / 2 - imageSize.width / 2, y: node.position.y + node.height / 2 - imageSize.height / 2 },
metadata: applyGeneratedMediaResultMetadata(node, imageMetadata(normalizedImage), { prompt, ...completedTaskMetadata(task) }, task.model),
metadata: applyGeneratedMediaResultMetadata(node, imageMetadata(normalizedImage), { prompt, ...completedTaskMetadata(task), generationOutputCount: 1 }, task.model),
};
}
@@ -233,34 +234,40 @@ async function cacheGeneratedRemoteVideo(result: GeneratedVideoResult & { storag
export async function applyGenerationTaskResultToNodes(nodes: CanvasNodeData[], task: GenerationTask, targetNodeId?: string) {
const node = findGenerationTaskNode(nodes, task, targetNodeId);
if (!node) return { nodes, updated: false, nodeId: "", node: null };
const updatedNode = await buildGenerationTaskNodeResult(node, task, nodes);
const { node: updatedNode, additionalNodes } = await buildGenerationTaskNodeResults(node, task, nodes);
return {
nodes: applySuccessfulVersionSelection(nodes, updatedNode),
nodes: commitCanvasGenerationResult(nodes, node, updatedNode, task.id, additionalNodes),
updated: true,
nodeId: node.id,
node: updatedNode,
additionalNodes,
};
}
export async function applyMaterializedGenerationTaskResultToNodes(nodes: CanvasNodeData[], task: GenerationTask, output: GenerationTaskOutput, effectKey: string, targetNodeId?: string) {
const node = findGenerationTaskNode(nodes, task, targetNodeId);
if (!node) return { nodes, updated: false, nodeId: "", node: null };
if (generationEffectApplied(node.metadata || {}, effectKey)) {
return { nodes, updated: true, nodeId: node.id, node };
if (generationEffectApplied(node.metadata || {}, effectKey) && generationTaskOutputsApplied(node, task)) {
return { nodes, updated: true, nodeId: node.id, node, additionalNodes: [] };
}
const asset = useAssetStore.getState().assets.find((candidate) => candidate.id === output.materializedAssetId);
if (!asset) throw new Error("生成任务输出素材不存在");
const result = parseBackendGenerationResult(task);
if (asset.kind === "image") {
const images = [...(result.images || [])];
images[output.outputIndex] = {
dataUrl: asset.data.dataUrl || asset.coverUrl,
storageKey: asset.data.storageKey,
width: asset.data.width,
height: asset.data.height,
bytes: asset.data.bytes,
mimeType: asset.data.mimeType,
};
// materialize 已保存整个任务;画布按真实输出序号接收每张图及其素材绑定。
for (const item of task.outputs?.length ? task.outputs : [output]) {
const imageAsset = useAssetStore.getState().assets.find((candidate) => candidate.id === item.materializedAssetId);
if (!imageAsset || imageAsset.kind !== "image") throw new Error("生成任务图片输出素材不存在");
images[item.outputIndex] = {
dataUrl: imageAsset.data.dataUrl || imageAsset.coverUrl,
storageKey: imageAsset.data.storageKey,
width: imageAsset.data.width,
height: imageAsset.data.height,
bytes: imageAsset.data.bytes,
mimeType: imageAsset.data.mimeType,
};
}
result.images = images;
} else if (asset.kind === "video") {
result.video = { dataUrl: asset.data.url, ...asset.data };
@@ -269,28 +276,75 @@ export async function applyMaterializedGenerationTaskResultToNodes(nodes: Canvas
} else {
throw new Error("生成任务输出素材类型不支持画布节点");
}
const updatedNode = await buildGenerationTaskNodeResult(node, { ...task, resultJson: JSON.stringify(result) }, nodes);
const durableNode = {
...updatedNode,
metadata: applyGenerationConsumerEffect({ ...updatedNode.metadata, assetId: asset.id }, effectKey, (metadata) => metadata).value,
};
const { node: updatedNode, additionalNodes } = await buildGenerationTaskNodeResults(node, { ...task, resultJson: JSON.stringify(result) }, nodes);
const stamp = (item: CanvasNodeData) => ({ ...item, metadata: applyGenerationConsumerEffect(item.metadata || {}, effectKey, (metadata) => metadata).value });
const durableNode = stamp({ ...updatedNode, metadata: { ...updatedNode.metadata, assetId: updatedNode.metadata?.assetId || asset.id } });
const durableChildren = additionalNodes.map(stamp);
return {
nodes: applySuccessfulVersionSelection(nodes, durableNode),
nodes: commitCanvasGenerationResult(nodes, node, durableNode, task.id, durableChildren),
updated: true,
nodeId: node.id,
node: durableNode,
additionalNodes: durableChildren,
};
}
function applySuccessfulVersionSelection(nodes: CanvasNodeData[], updatedNode: CanvasNodeData) {
const versionRootId = updatedNode.metadata?.versionOfNodeId;
return nodes.map((item) => {
if (item.id === updatedNode.id) {
return versionRootId ? { ...updatedNode, metadata: { ...updatedNode.metadata, versionPrimary: true } } : updatedNode;
}
if (!versionRootId || (item.metadata?.versionOfNodeId || item.id) !== versionRootId) return item;
return { ...item, metadata: { ...item.metadata, versionPrimary: false } };
});
export function generationTaskOutputsApplied(node: CanvasNodeData, task: GenerationTask) {
if (node.metadata?.taskId !== task.id || node.metadata.status !== "success" || !node.metadata.content) return false;
if (generationTaskMode(task) !== "image") return true;
const count = parseBackendGenerationResult(task).images?.length || 1;
return node.metadata.generationOutputCount === count || (count > 1 && (node.metadata.batchChildIds?.length || 0) >= count);
}
export function shouldRecoverCanvasImageOutputs(node: CanvasNodeData) {
return node.type === CanvasNodeType.Image && node.metadata?.status === "success" && Boolean(node.metadata.taskId)
&& node.metadata.generationOutputCount === undefined && !node.metadata.isBatchRoot && !node.metadata.batchRootId
&& /(?:midjourney|(?:^|::)mj-)/i.test(node.metadata.producedModel || node.metadata.model || "");
}
async function buildGenerationTaskNodeResults(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[]) {
const resultNode = await buildGenerationTaskNodeResult(node, task, nodes);
const images = generationTaskMode(task) === "image" ? parseBackendGenerationResult(task).images || [] : [];
if (images.length <= 1) return { node: resultNode, additionalNodes: [] as CanvasNodeData[] };
const children: CanvasNodeData[] = [];
for (let index = 0; index < images.length; index += 1) {
const id = `${node.id}:task:${task.id}:image:${index}`;
const existing = nodes.find((item) => item.id === id);
if (existing) { children.push(existing); continue; }
const child = await buildGenerationTaskNodeResult({ ...node, id, title: `${node.title} · ${index + 1}`, parentId: undefined }, task, nodes, index);
children.push({
...child,
position: imageGenerationChildPosition(resultNode.position, resultNode.width, child, index),
metadata: {
...child.metadata,
assetId: task.outputs?.find((output) => output.outputIndex === index)?.materializedAssetId,
isBatchRoot: undefined, batchChildIds: undefined, primaryImageId: undefined, imageBatchExpanded: undefined,
batchRootId: node.id, versionOfNodeId: undefined, versionLabel: undefined, versionPrimary: undefined,
agentGenerationContinuation: undefined,
},
});
}
const primary = children.find((child) => child.id === node.metadata?.primaryImageId) || children[0];
return {
node: {
...resultNode,
metadata: {
...resultNode.metadata,
...imageMetadata({
url: primary.metadata!.content!, storageKey: primary.metadata!.storageKey!,
width: primary.metadata!.naturalWidth!, height: primary.metadata!.naturalHeight!,
bytes: primary.metadata!.bytes || 0, mimeType: primary.metadata!.mimeType || "image/png",
}),
assetId: primary.metadata?.assetId,
isBatchRoot: true,
batchChildIds: children.map((child) => child.id),
primaryImageId: primary.id,
imageBatchExpanded: node.metadata?.imageBatchExpanded ?? false,
generationOutputCount: images.length,
},
},
additionalNodes: children,
};
}
export async function syncGenerationTaskToCanvasStore(task: GenerationTask) {
@@ -303,11 +357,11 @@ export async function syncGenerationTaskToCanvasStore(task: GenerationTask) {
if (!project) return false;
const node = findGenerationTaskNode(project.nodes, task);
if (!node) return false;
if (node.metadata?.taskId === task.id && node.metadata.status === "success" && node.metadata.content) return false;
const updatedNode = await buildGenerationTaskNodeResult(node, task, project.nodes);
if (generationTaskOutputsApplied(node, task)) return false;
const { node: updatedNode, additionalNodes } = await buildGenerationTaskNodeResults(node, task, project.nodes);
const latest = useCanvasStore.getState().projects.find((item) => item.id === project.id);
if (!latest?.nodes.some((item) => item.id === node.id)) return false;
useCanvasStore.getState().updateProject(project.id, { nodes: commitCanvasGenerationResult(latest.nodes, node, updatedNode, task.id) });
useCanvasStore.getState().updateProject(project.id, { nodes: commitCanvasGenerationResult(latest.nodes, node, updatedNode, task.id, additionalNodes) });
return true;
}
@@ -159,7 +159,7 @@ export function reconcileImageBatchRoot(root: CanvasNodeData, nodes: CanvasNodeD
const loading = children.some((node) => node.metadata?.status === "loading");
const failed = children.find((node) => node.metadata?.status === "error");
// 批次父节点只是子任务的投影,不能保留上一轮单图任务的身份/终态。
const metadata: CanvasNodeMetadata = resetGenerationTaskMetadata(root.metadata, root.metadata?.status);
const metadata: CanvasNodeMetadata = root.metadata.generationOutputCount ? { ...root.metadata } : resetGenerationTaskMetadata(root.metadata, root.metadata?.status);
metadata.batchFailedCount = children.filter((node) => node.metadata?.status === "error").length;
if (primary) {
@@ -11,6 +11,7 @@ function snapshotMetadata(metadata: CanvasNodeMetadata | undefined, rootId: stri
delete snapshot.taskStage;
delete snapshot.taskCreatedAt;
delete snapshot.taskUpdatedAt;
delete snapshot.generationOutputCount;
delete snapshot.errorDetails;
delete snapshot.generationErrorCode;
delete snapshot.failedPromptFingerprint;
@@ -478,6 +478,7 @@ export function applyBatchPrimaryImage(root: CanvasNodeData, primary: CanvasNode
primaryImageId: primary.id,
content: primary.metadata?.content,
storageKey: primary.metadata?.storageKey,
assetId: primary.metadata?.assetId,
status: primary.metadata?.status,
naturalWidth: primary.metadata?.naturalWidth,
naturalHeight: primary.metadata?.naturalHeight,
+1
View File
@@ -34,5 +34,6 @@ export function resetGenerationTaskMetadata(metadata: CanvasNodeMetadata | undef
delete next.taskReceiptRecorded;
delete next.taskCreatedAt;
delete next.taskUpdatedAt;
delete next.generationOutputCount;
return next;
}
+38 -2
View File
@@ -1,6 +1,8 @@
import type { ModelProtocol, ModelProtocolWorkflow } from "@/lib/model-protocols";
import type { ImageResolutionOption, ImageResolutionTier } from "@/lib/image-resolution-tiers";
import { imagePresetForRatio } from "./image-size-presets";
import { type WorkflowVideoFieldLike, workflowImageCapabilityConfig, workflowVideoCapabilityConfig } from "./model-capabilities-workflow";
import { resolveWorkflowVideoScreenSpec } from "./video-screen-specs";
export { workflowFieldChoiceValues, workflowFieldConfigurationError, workflowFieldCurrentValue, workflowFieldHasStoredValue, workflowFieldKey, workflowFieldNumberBounds, workflowFieldPresetOptions, workflowFieldRandomKey, workflowFieldRole, workflowFieldSafeToOverride, workflowFieldSource, workflowFieldSubmissionValue, workflowFieldValueError, workflowImageCapabilityConfig, workflowOutputSizeValue, workflowParameterFields, workflowVideoCapabilityConfig, workflowVideoDefaultSize, workflowVideoFieldsFromJson, type WorkflowFieldNumberBounds, type WorkflowVideoFieldLike } from "./model-capabilities-workflow";
@@ -91,12 +93,15 @@ export type VideoCapabilityConfig = {
defaultRatio: string;
resolutions: string[];
defaultResolution: string;
fixedScreenSpec?: VideoScreenSpecConfig;
generateAudio: { supported: boolean; default: boolean };
watermark: { supported: boolean; default: boolean };
operations: string[];
defaultOperation: string;
};
export type VideoScreenSpecConfig = Pick<VideoCapabilityConfig, "ratios" | "defaultRatio" | "resolutions" | "defaultResolution">;
// 旧版本的“允许自定义”可能只保存了 `*`,前台需要用这组标准值恢复可选项。
export const STANDARD_IMAGE_SIZE_VALUES = [
"1:1",
@@ -148,7 +153,7 @@ export function normalizeModelCapabilityConfig(config: ModelCapabilityConfig): M
}
: undefined,
video: config.video
? {
? resolveWorkflowVideoScreenSpec({
...config.video,
ratios: normalizeCapabilityStrings(config.video.ratios),
defaultRatio: normalizeCapabilityString(config.video.defaultRatio),
@@ -156,7 +161,7 @@ export function normalizeModelCapabilityConfig(config: ModelCapabilityConfig): M
defaultResolution: normalizeCapabilityString(config.video.defaultResolution),
operations: normalizeCapabilityStrings(config.video.operations),
defaultOperation: normalizeCapabilityString(config.video.defaultOperation),
}
})
: undefined,
};
}
@@ -215,6 +220,37 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
outputFormat: { supported: true },
maxOutputs: 15,
};
if (protocol === "kacang-midjourney-special" || protocol === "kacang-midjourney-v7" || protocol === "kacang-midjourney") {
const stable = protocol === "kacang-midjourney";
const extendedRatios = stable || protocol === "kacang-midjourney-v7";
const ratios = ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "21:9", ...(extendedRatios ? ["9:21"] : [])];
// 卡藏未声明分辨率档。1K 预设只用于宿主比例选择,插件不发送 resolution。
image.references = { ...image.references, maxImages: stable ? 5 : 1, maskSupported: false };
image.size = { parameter: "aspect_ratio", values: [...(extendedRatios ? ["auto"] : []), ...ratios], default: stable ? "9:16" : "16:9", allowCustom: false, presets: ratios.map((ratio) => ({ ...imagePresetForRatio("1k", ratio), ratio })) };
// 稳定版的数值 quality 通过插件命名空间设置,避免与宿主分辨率档混用。
image.quality = { supported: false, values: [], default: "auto" };
image.transparentBackground = { supported: false, default: false };
image.responseFormat = { supported: false };
image.outputFormat = { supported: false };
image.maxOutputs = 1;
return image;
}
if (protocol === "cangyuan-midjourney-v7" || protocol === "cangyuan-midjourney-v82") {
const v7 = protocol === "cangyuan-midjourney-v7";
const tier = !v7 && model.trim().toLowerCase().replace(/^models\//, "") === "midjourney-2k" ? "2k" : "1k";
const ratios = v7
? ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "21:9"]
: ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "1:2", "6:11", "5:6", "2:1", "11:6", "6:5"];
image.references = { ...image.references, promptMaxChars: v7 ? 4000 : 32000, maxImages: v7 ? 5 : 1, maskSupported: !v7 };
image.size = { parameter: "aspect_ratio", values: ["auto", ...ratios], default: "auto", allowCustom: false, presets: ratios.map((ratio) => imagePresetForRatio(tier, ratio)) };
image.quality = { supported: false, values: [], default: "auto" };
image.transparentBackground = { supported: false, default: false };
image.responseFormat = { supported: false };
image.outputFormat = { supported: false };
// n=1 是一次提交;供应商返回的四张图片仍全部消费。
image.maxOutputs = 1;
return image;
}
if (protocol === "grok-image") {
image.references.maxImages = 1;
image.references.maskSupported = false;
+3 -2
View File
@@ -1,7 +1,8 @@
export type ModelProtocol = string;
export type ProtocolCapability = "text" | "image" | "video" | "audio";
export type ModelProtocolWorkflow = { id: string; label: string; providerId: string; capability: ProtocolCapability; parameters: Array<{ name: string; type: string; required?: boolean; description?: string; values?: string[]; mapping?: string }>; defaults?: Record<string, string | number | boolean> };
export type ModelProtocolDefinition = { value: ModelProtocol; label: string; vendor?: string; capability: ProtocolCapability; create: string; contentType: string; poll?: string; media: string; enabled?: boolean; baseUrl?: string; workflows?: ModelProtocolWorkflow[] };
export type ModelProtocolParameter = { name: string; type: string; required?: boolean; description?: string; values?: string[]; mapping?: string };
export type ModelProtocolWorkflow = { id: string; label: string; providerId: string; capability: ProtocolCapability; parameters: ModelProtocolParameter[]; defaults?: Record<string, string | number | boolean> };
export type ModelProtocolDefinition = { value: ModelProtocol; label: string; vendor?: string; capability: ProtocolCapability; create: string; contentType: string; poll?: string; media: string; enabled?: boolean; baseUrl?: string; parameters?: ModelProtocolParameter[]; workflows?: ModelProtocolWorkflow[] };
export function protocolGroups(protocols: ModelProtocolDefinition[]) {
return (["text", "image", "video", "audio"] as ProtocolCapability[]).map((capability) => ({ label: { text: "文本", image: "图片", video: "视频", audio: "音频" }[capability], options: protocols.filter((item) => item.capability === capability && item.enabled !== false).map((item) => ({ label: `${item.label} · ${item.create.replace(/^POST /, "")}`, value: item.value })) }));
+49
View File
@@ -0,0 +1,49 @@
export type UpdateBlock = {
id: string;
type: "text" | "image" | "video";
text: string;
resourceId: string;
caption: string;
layout: "full" | "half";
mediaUrl?: string;
};
export type UpdateRelease = { id: string; version: string; title: string; date: string; blocks: UpdateBlock[] };
export type UpdateAnnouncement = { enabled: boolean; currentVersion: string; releases: UpdateRelease[] };
export const DEFAULT_UPDATE_ANNOUNCEMENT: UpdateAnnouncement = { enabled: false, currentVersion: "", releases: [] };
export function normalizeUpdateAnnouncement(value?: UpdateAnnouncement | null): UpdateAnnouncement {
return { ...DEFAULT_UPDATE_ANNOUNCEMENT, ...value, releases: Array.isArray(value?.releases) ? value.releases : [] };
}
export function updateAnnouncementVersion(value: UpdateAnnouncement | undefined, builtInVersion: string) {
return value?.enabled ? value.currentVersion : `v${builtInVersion.replace(/^v/, "")}`;
}
export function moveUpdateItem<T>(items: T[], index: number, direction: -1 | 1): T[] {
const destination = index + direction;
if (index < 0 || index >= items.length || destination < 0 || destination >= items.length) return items;
const result = [...items];
[result[index], result[destination]] = [result[destination], result[index]];
return result;
}
export function validateUpdateAnnouncement(value: UpdateAnnouncement): string | undefined {
if (value.enabled && (!value.currentVersion.trim() || !value.releases.length)) return "开启自定义公告前请填写当前版本并添加公告内容";
if (value.enabled && !value.releases.some((release) => release.version.trim() === value.currentVersion.trim())) return "当前版本须对应已添加的公告版本";
if (value.releases.length > 30) return "最多添加 30 个公告版本";
const versions = new Set<string>();
let totalBytes = 0;
for (const release of value.releases) {
const version = release.version.trim();
if (!version || Array.from(version).length > 40) return "每个公告版本号须为 1 至 40 个字符";
if (versions.has(version)) return "公告版本号不能重复";
versions.add(version);
if (!release.blocks.length || release.blocks.length > 50) return "每个版本须添加 1 至 50 个内容块";
for (const block of release.blocks) {
if (block.type === "text" && !block.text.trim()) return "请填写文字内容,或删除空白内容块";
if (block.type !== "text" && !block.resourceId) return "请上传图片或视频,或删除空白内容块";
totalBytes += new TextEncoder().encode(block.text.trim() + block.caption.trim()).length;
}
}
if (totalBytes > 200000) return "公告内容总大小不能超过 200KB";
}
+61
View File
@@ -0,0 +1,61 @@
import type { VideoCapabilityConfig, VideoScreenSpecConfig } from "./model-capabilities";
import type { ModelProtocolWorkflow } from "./model-protocols";
export function cleanVideoScreenSpecValues(values: readonly string[]) {
const seen = new Set<string>();
return values
.map((value) => value.trim())
.filter((value) => {
const key = value.toLowerCase();
if (!value || seen.has(key)) return false;
seen.add(key);
return true;
});
}
export function workflowVideoScreenSpec(workflow: ModelProtocolWorkflow): VideoScreenSpecConfig {
const resolution = workflow.parameters.find((field) => field.mapping === "resolution" || field.name === "resolution");
return {
ratios: [],
defaultRatio: "",
resolutions: cleanVideoScreenSpecValues(resolution?.values || []),
defaultResolution: String(resolution ? workflow.defaults?.[resolution.name] || "" : "").trim(),
};
}
export function resolveWorkflowVideoScreenSpec(profile: VideoCapabilityConfig, workflow?: ModelProtocolWorkflow): VideoCapabilityConfig {
const fixed = workflow ? workflowVideoScreenSpec(workflow) : profile.fixedScreenSpec;
if (!fixed) return profile;
const previous = profile.fixedScreenSpec;
const workflowChanged = workflow && previous && (previous.defaultResolution !== fixed.defaultResolution || previous.resolutions.length !== fixed.resolutions.length || previous.resolutions.some((value, index) => value !== fixed.resolutions[index]));
const initializing = workflow && !profile.fixedScreenSpec;
const available = new Map(fixed.resolutions.map((value) => [value.toLowerCase(), value]));
const configured = cleanVideoScreenSpecValues(profile.resolutions).map((value) => available.get(value.toLowerCase()) || value);
const initialValues = configured.filter((value) => available.has(value.toLowerCase()));
const source = workflowChanged || (initializing && !initialValues.length) ? fixed : profile;
const resolutions = source === fixed ? fixed.resolutions : initializing ? initialValues : configured;
const defaultResolution = source.defaultResolution.trim();
const selected: VideoScreenSpecConfig = {
ratios: [],
defaultRatio: "",
resolutions,
defaultResolution: resolutions.find((value) => value.toLowerCase() === defaultResolution.toLowerCase()) || (initializing ? resolutions[0] || "" : defaultResolution),
};
return {
...profile,
...selected,
fixedScreenSpec: fixed,
};
}
export function updateWorkflowVideoScreenSpec(profile: VideoCapabilityConfig, workflow: ModelProtocolWorkflow | undefined, patch: Partial<VideoScreenSpecConfig>): VideoCapabilityConfig {
const resolved = resolveWorkflowVideoScreenSpec(profile, workflow);
if (!resolved.fixedScreenSpec) return profile;
return resolveWorkflowVideoScreenSpec(
{
...resolved,
...patch,
},
workflow,
);
}
@@ -158,7 +158,7 @@ function AdminShellLayout() {
<main id="admin-root" data-admin-root data-admin-density={density} className="admin-shell flex h-full min-h-0 overflow-hidden">
<aside className={cn("admin-sidebar hidden shrink-0 flex-col overflow-hidden lg:flex", collapsed && "is-collapsed")}>
<div className="admin-sidebar-identity shrink-0">
<AdminTooltip title={collapsed ? "查看更新日志" : undefined} placement="right">
<AdminTooltip title={collapsed ? (appearance.updates?.enabled ? "查看更新公告" : "查看更新日志") : undefined} placement="right">
<AppChangelogButton
className={cn("admin-sidebar-brand-button", collapsed && "is-collapsed")}
icon={<BrandLogoFrame className="admin-sidebar-brand-mark grid shrink-0 place-items-center bg-foreground text-background" logoClassName="size-5 object-contain" alt="" fallback={<InfinityIcon className="size-4" />} />}
@@ -1,5 +1,6 @@
import type { ModelCapabilityChoice } from "@/components/model-protocol-picker";
import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { imagePresetForRatio, imageSizePresets } from "@/lib/image-size-presets";
import { modelProtocolSupportsTokenBilling, type ModelProtocolDefinition } from "@/lib/model-protocols";
import type { ChannelModel } from "@/services/api/wallet";
import type { ModelTag } from "@/lib/model-tags";
@@ -67,6 +68,25 @@ export function changeChannelModelCapability(values: ChannelModelFormValues, pro
};
}
export function updateChannelModelUpstreamCapabilities(values: ChannelModelFormValues): ChannelModelFormValues {
if (values.capability !== "image" || values.protocol !== "cangyuan-midjourney-v82") return values;
const defaults = defaultModelCapabilityConfig(values.protocol, values.providerModelKey?.trim() || values.modelKey.trim());
const image = values.capabilityConfig?.image;
if (!image) return { ...values, capabilityConfig: defaults };
if (image.size.parameter !== "aspect_ratio") return values;
const tier = defaults.image!.size.presets![0].tier;
const presets = imageSizePresets(image);
if (presets.length && presets.every((preset) => preset.tier === tier)) return values;
const ratios = Array.from(new Set(presets.map((preset) => preset.ratio)));
return {
...values,
capabilityConfig: {
...values.capabilityConfig!,
image: { ...image, size: { ...image.size, presets: ratios.length ? ratios.map((ratio) => imagePresetForRatio(tier, ratio)) : defaults.image!.size.presets } },
},
};
}
export function validateChannelModelProtocol(capability: string, protocol: string | undefined, protocols: ModelProtocolDefinition[]) {
if (!protocols.some((p) => p.value === protocol && p.capability === capability && p.enabled !== false)) {
throw new Error("请选择当前能力下已启用的请求协议");
@@ -14,7 +14,7 @@ import type { ModelChannel } from "@/stores/use-config-store";
import { defaultPriceTier, normalizeUpstreamModelKey, priceTierPayloadFromForm } from "./channel-model-price-tier-form";
import { PriceTierFields } from "./channel-model-price-tier-fields";
import { ChannelModelTagsEditor } from "./channel-model-tags-editor";
import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, validateChannelModelPrices, validateChannelModelProtocol, type ChannelModelFormValues as FormValues, type EditorSection } from "./channel-model-editor-form";
import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, updateChannelModelUpstreamCapabilities, validateChannelModelPrices, validateChannelModelProtocol, type ChannelModelFormValues as FormValues, type EditorSection } from "./channel-model-editor-form";
export function ChannelModelEditor({
channel,
@@ -77,6 +77,13 @@ export function ChannelModelEditor({
} else if (changed.protocol) {
form.setFieldValue("capabilityConfig", modelCapability === "audio" ? undefined : defaultModelCapabilityConfig(changed.protocol, providerModelKey.trim() || modelKey.trim()));
setConfigurationChanged(true);
} else if (changed.providerModelKey !== undefined || changed.modelKey !== undefined) {
const values = form.getFieldsValue(true);
const next = updateChannelModelUpstreamCapabilities(values);
if (next !== values) {
form.setFieldValue("capabilityConfig", next.capabilityConfig);
setConfigurationChanged(true);
}
}
};
@@ -333,6 +340,7 @@ export function ChannelModelEditor({
model={providerModelKey || modelKey}
protocol={form.getFieldValue("protocol")}
section="references"
workflows={protocols.find((item) => item.value === modelProtocol)?.workflows}
value={capabilityConfig}
onChange={(next) => {
dirtyRef.current = true;
@@ -351,6 +359,7 @@ export function ChannelModelEditor({
model={providerModelKey || modelKey}
protocol={form.getFieldValue("protocol")}
section="protocol"
workflows={protocols.find((item) => item.value === modelProtocol)?.workflows}
value={capabilityConfig}
onChange={(next) => {
dirtyRef.current = true;
@@ -38,7 +38,7 @@ export function PriceTierFields({
// 静默改变实际发往供应商的模型,必须显式提示并允许一键恢复“跟随模型默认”。
const staleTierUpstream = matchMode === "default" && tierUpstream && modelUpstream && tierUpstream !== modelUpstream ? tierUpstream : "";
const video = capabilityConfig?.video;
const resolutionOptions = video?.resolutions || [];
const resolutionOptions = Array.from(new Set([...(video?.resolutions || []), ...(protocol === "autodl-comfyui" ? video?.fixedScreenSpec?.resolutions || [] : [])]));
const tokenEnabled = modelProtocolSupportsTokenBilling(capability, protocol);
const isVideo = capability === "video";
const isAudio = capability === "audio";
@@ -8,6 +8,8 @@ import { Switch } from "@/pages/admin/ui/controls";
import { WelcomeSetting } from "@/pages/admin/settings/components/welcome-setting";
import { SkinThemeEditor } from "@/pages/admin/settings/components/skin-theme-editor";
import { CanvasAppearanceEditor } from "./components/canvas-appearance-editor";
import { UpdateAnnouncementEditor } from "./components/update-announcement-editor";
import { DEFAULT_UPDATE_ANNOUNCEMENT, normalizeUpdateAnnouncement, validateUpdateAnnouncement, type UpdateAnnouncement } from "@/lib/update-announcement";
import { App, Button, Tabs, Form, Input } from "antd";
import { useCallback, useEffect, useRef, useState } from "react";
import { useBlocker } from "react-router";
@@ -28,6 +30,7 @@ export default function AppearanceSettingsPage() {
const [setting, setSetting] = useState<AdminAppearance | null>(null);
const [brandName, setBrandName] = useState("");
const [canvas, setCanvas] = useState<CanvasAppearance>(DEFAULT_CANVAS_APPEARANCE);
const [updates, setUpdates] = useState<UpdateAnnouncement>(DEFAULT_UPDATE_ANNOUNCEMENT);
const [canvasUploading, setCanvasUploading] = useState(false);
const [brandSlug, setBrandSlug] = useState("");
const [authHeroTitle, setAuthHeroTitle] = useState("");
@@ -59,7 +62,10 @@ export default function AppearanceSettingsPage() {
const dirty =
Boolean(setting) &&
(JSON.stringify(canvas) !== JSON.stringify(setting?.canvas || DEFAULT_CANVAS_APPEARANCE) || canvasUploading || brandName.trim() !== setting?.brandName ||
(JSON.stringify(updates) !== JSON.stringify(normalizeUpdateAnnouncement(setting?.updates)) ||
JSON.stringify(canvas) !== JSON.stringify(setting?.canvas || DEFAULT_CANVAS_APPEARANCE) ||
canvasUploading ||
brandName.trim() !== setting?.brandName ||
brandSlug.trim().toLocaleLowerCase() !== setting?.brandSlug ||
normalizeDraftCopy(authHeroTitle) !== setting?.authHeroTitle ||
normalizeDraftCopy(authHeroDescription) !== setting?.authHeroDescription ||
@@ -83,6 +89,7 @@ export default function AppearanceSettingsPage() {
setSetting({ ...value, skinThemes: themes, skinId: selectedID });
setBrandName(value.brandName);
setCanvas(value.canvas || DEFAULT_CANVAS_APPEARANCE);
setUpdates(normalizeUpdateAnnouncement(value.updates));
setBrandSlug(value.brandSlug);
setAuthHeroTitle(value.authHeroTitle);
setAuthHeroDescription(value.authHeroDescription);
@@ -135,7 +142,7 @@ export default function AppearanceSettingsPage() {
if (blocker.state !== "blocked") return;
modal.confirm({
title: "放弃站点及外观调整?",
content: "当前品牌、画布 Agent、SEO、备案、皮肤或媒体配置尚未保存,离开后草稿会丢失。线上站点不会改变。",
content: "当前品牌、画布 Agent、更新公告、SEO、备案、皮肤或媒体配置尚未保存,离开后草稿会丢失。线上站点不会改变。",
okText: "放弃并离开",
cancelText: "继续编辑",
okButtonProps: { danger: true },
@@ -192,6 +199,7 @@ export default function AppearanceSettingsPage() {
const discardDraft = () => {
if (!setting || saving || restoring || canvasUploading) return;
setCanvas(setting.canvas || DEFAULT_CANVAS_APPEARANCE);
setUpdates(normalizeUpdateAnnouncement(setting.updates));
setBrandName(setting.brandName);
setBrandSlug(setting.brandSlug);
setAuthHeroTitle(setting.authHeroTitle);
@@ -222,7 +230,7 @@ export default function AppearanceSettingsPage() {
}
modal.confirm({
title: "放弃调整并重新读取?",
content: "重新读取会丢弃当前品牌、画布 Agent、SEO、备案、皮肤和待上传文件。",
content: "重新读取会丢弃当前品牌、画布 Agent、更新公告、SEO、备案、皮肤和待上传文件。",
okText: "放弃并刷新",
cancelText: "继续编辑",
okButtonProps: { danger: true },
@@ -234,7 +242,7 @@ export default function AppearanceSettingsPage() {
if (!setting?.configured || saving || refreshing || restoring || canvasUploading) return;
modal.confirm({
title: "恢复影策默认品牌标识?",
content: "品牌名称、英文标识、Logo、画布 Agent 名称/文案/形象、登录页文案、视频、封面、SEO、备案和皮肤主题会立即恢复为项目内置值。已上传文件仍保留在存储资源中,不会被删除。",
content: "品牌名称、英文标识、Logo、画布 Agent 名称/文案/形象、更新公告、登录页文案、视频、封面、SEO、备案和皮肤主题会立即恢复为项目内置值;自定义公告会清空并关闭,展示原有更新日志。已上传文件仍保留在存储资源中,不会被删除。",
okText: "恢复默认",
cancelText: "取消",
okButtonProps: { danger: true },
@@ -261,6 +269,12 @@ export default function AppearanceSettingsPage() {
const save = async () => {
if (!setting || saving || restoring || canvasUploading) return;
const updatesError = validateUpdateAnnouncement(updates);
if (updatesError) {
setActiveTab("updates");
message.error(updatesError);
return;
}
if (![canvas.agentName, canvas.panelTitle, canvas.welcomeTitle, canvas.inputPlaceholder].every((text) => text.trim())) {
setActiveTab("canvas");
message.error("请填写助手名称、面板标题、欢迎标题和输入框提示");
@@ -337,6 +351,7 @@ export default function AppearanceSettingsPage() {
}
const updated = await updateAdminAppearance({
canvas,
updates,
brandName: nextBrandName,
brandSlug: nextBrandSlug,
authHeroTitle: nextAuthHeroTitle,
@@ -445,7 +460,7 @@ export default function AppearanceSettingsPage() {
<strong>{dirty ? "站点配置有调整待保存" : "站点及外观已与服务端同步"}</strong>
<AdminStatusBadge label={dirty ? "尚未生效" : "服务端当前值"} tone={dirty ? "warning" : "neutral"} />
</div>
<p>{dirty ? "切换分类保留草稿;保存修改会一次应用品牌、登录页、SEO、备案和皮肤调整。" : "按分类管理站点配置;欢迎页开关独立即时保存,其余修改统一保存。"}</p>
<p>{dirty ? "切换分类保留草稿;保存修改会一次应用品牌、登录页、更新公告、SEO、备案和皮肤调整。" : "按分类管理站点配置;欢迎页开关独立即时保存,其余修改统一保存。"}</p>
</div>
</div>
<div className="admin-appearance-command-actions">
@@ -650,12 +665,7 @@ export default function AppearanceSettingsPage() {
key: "welcome",
label: "欢迎页",
children: (
<SettingsSectionCard
className="admin-appearance-section"
icon={<Globe2 className="size-4" aria-hidden="true" />}
title="欢迎页"
description="控制访客是否可以访问欢迎页,开关修改后立即保存。"
>
<SettingsSectionCard className="admin-appearance-section" icon={<Globe2 className="size-4" aria-hidden="true" />} title="欢迎页" description="控制访客是否可以访问欢迎页,开关修改后立即保存。">
<div className="admin-appearance-section-form">
<WelcomeSetting />
</div>
@@ -761,7 +771,8 @@ export default function AppearanceSettingsPage() {
</SettingsSectionCard>
),
},
{ key: "canvas", label: "画布配置", children: <CanvasAppearanceEditor value={canvas} onChange={setCanvas} disabled={saving || refreshing || restoring} onUploading={setCanvasUploading} /> },
{ key: "canvas", label: "画布配置", children: <CanvasAppearanceEditor value={canvas} onChange={setCanvas} disabled={saving || refreshing || restoring || canvasUploading} onUploading={setCanvasUploading} /> },
{ key: "updates", label: "更新公告", children: <UpdateAnnouncementEditor value={updates} onChange={setUpdates} disabled={saving || refreshing || restoring || canvasUploading} onUploading={setCanvasUploading} /> },
]}
/>
</div>
@@ -42,7 +42,7 @@ export function CanvasAppearanceEditor({ value, onChange, disabled, onUploading
<div className="grid gap-6 lg:grid-cols-2">
<div>
<h3 className="mb-3 font-semibold">Agent 身份与文案</h3>
<p className="mb-4 text-sm text-foreground/60">独立于站点品牌。文案可使用 {"{agentName}"} 引用助手名称;这里只配置展示文字,不修改工具权限。</p>
<p className="mb-4 text-sm text-foreground/60">独立于站点品牌。助手名称同步用于界面文案和对话中的自我介绍;文案可使用 {"{agentName}"} 引用名称,不修改工具权限。</p>
<Form layout="vertical" disabled={disabled || uploading}>
{(
[
@@ -0,0 +1,109 @@
[data-admin-root] .admin-update-announcement-editor {
display: grid;
gap: 24px;
min-width: 0;
}
[data-admin-root] .admin-update-announcement-switch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
[data-admin-root] .admin-update-announcement-switch h3 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
}
[data-admin-root] .admin-update-announcement-switch p {
margin: 0;
color: var(--admin-text-muted);
font-size: 13px;
line-height: 1.7;
}
[data-admin-root] .admin-update-announcement-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 24px;
align-items: start;
}
[data-admin-root] .admin-update-announcement-fields {
display: grid;
gap: 16px;
min-width: 0;
}
[data-admin-root] .admin-update-announcement-release {
display: grid;
gap: 16px;
padding: 20px;
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius-lg);
}
[data-admin-root] .admin-update-announcement-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
}
[data-admin-root] .admin-update-announcement-toolbar > div,
[data-admin-root] .admin-update-announcement-add-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
[data-admin-root] .admin-update-announcement-meta {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
}
[data-admin-root] .admin-update-announcement-block {
display: grid;
gap: 14px;
padding: 16px;
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius-lg);
background: var(--admin-surface-2);
}
[data-admin-root] .admin-update-announcement-block .ant-form-item:last-child {
margin-bottom: 0;
}
[data-admin-root] .admin-update-announcement-preview {
position: sticky;
top: 0;
padding: 24px;
max-height: 72vh;
overflow: auto;
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius-lg);
background: var(--admin-surface);
color: var(--admin-text);
}
[data-admin-root] .admin-update-announcement-preview-heading {
display: grid;
gap: 6px;
padding-bottom: 18px;
margin-bottom: 22px;
border-bottom: 1px solid var(--admin-border);
overflow-wrap: anywhere;
}
[data-admin-root] .admin-update-announcement-preview-heading span {
color: var(--admin-text-muted);
font-size: 12px;
}
@media (max-width: 1100px) {
[data-admin-root] .admin-update-announcement-layout {
grid-template-columns: minmax(0, 1fr);
}
[data-admin-root] .admin-update-announcement-preview {
position: static;
}
}
@media (max-width: 600px) {
[data-admin-root] .admin-update-announcement-meta {
grid-template-columns: minmax(0, 1fr);
}
[data-admin-root] .admin-update-announcement-release,
[data-admin-root] .admin-update-announcement-preview {
padding: 16px;
}
}
@@ -0,0 +1,223 @@
import { useRef, useState } from "react";
import { App, Button, Form, Input, Select } from "antd";
import { ArrowDown, ArrowUp, ImagePlus, Plus, Trash2, Upload, Video, Type } from "lucide-react";
import { Switch } from "@/pages/admin/ui/controls";
import { UpdateAnnouncementContent } from "@/components/layout/update-announcement-content";
import { moveUpdateItem, type UpdateAnnouncement, type UpdateBlock, type UpdateRelease } from "@/lib/update-announcement";
import { updateAnnouncementPreviewURL, uploadAppearanceAsset } from "@/services/api/appearance";
import "./update-announcement-editor.css";
export function UpdateAnnouncementEditor({ value, onChange, disabled, onUploading }: { value: UpdateAnnouncement; onChange: (value: UpdateAnnouncement) => void; disabled: boolean; onUploading: (value: boolean) => void }) {
const { message } = App.useApp();
const [uploading, setUploading] = useState("");
const fileInput = useRef<HTMLInputElement>(null);
const target = useRef<{ releaseId: string; blockId: string; type: "image" | "video" } | null>(null);
const locked = disabled || Boolean(uploading);
const change = (patch: Partial<UpdateAnnouncement>) => onChange({ ...value, ...patch });
const changeRelease = (id: string, patch: Partial<UpdateRelease>) => {
const before = value.releases.find((release) => release.id === id);
change({ releases: value.releases.map((release) => (release.id === id ? { ...release, ...patch } : release)), ...(patch.version !== undefined && before?.version === value.currentVersion ? { currentVersion: patch.version } : {}) });
};
const changeBlock = (release: UpdateRelease, id: string, patch: Partial<UpdateBlock>) => changeRelease(release.id, { blocks: release.blocks.map((block) => (block.id === id ? { ...block, ...patch } : block)) });
const addBlock = (release: UpdateRelease, type: UpdateBlock["type"]) => changeRelease(release.id, { blocks: [...release.blocks, { id: crypto.randomUUID(), type, text: "", resourceId: "", caption: "", layout: "full" }] });
async function upload(file?: File) {
const selected = target.current;
if (!file || !selected) return;
const video = selected.type === "video";
const allowed = video ? ["video/mp4", "video/webm"] : ["image/png", "image/jpeg", "image/webp"];
const maxMB = video ? 256 : 10;
if (!allowed.includes(file.type) || file.size <= 0 || file.size > maxMB * 1024 * 1024) {
message.error(`请选择${video ? "MP4 / WebM 视频" : "PNG / JPEG / WebP 图片"},最大 ${maxMB}MB`);
return;
}
setUploading(selected.blockId);
onUploading(true);
try {
const resource = await uploadAppearanceAsset(video ? "update-video" : "update-image", file);
const release = value.releases.find((item) => item.id === selected.releaseId);
if (release) changeBlock(release, selected.blockId, { resourceId: resource.id });
message.success("媒体已上传,保存修改后随公告生效");
} catch (error) {
message.error(error instanceof Error ? error.message : "公告媒体上传失败");
} finally {
setUploading("");
onUploading(false);
target.current = null;
}
}
return (
<div className="admin-update-announcement-editor">
<div className="admin-update-announcement-switch">
<div>
<h3>自定义更新公告</h3>
<p>关闭时展示原有更新日志;开启时展示下方自定义版本与内容。修改后点击页面顶部“保存修改”生效。</p>
</div>
<Switch checked={value.enabled} disabled={locked} aria-label="启用自定义更新公告" onChange={(enabled) => change({ enabled })} />
</div>
<input
ref={fileInput}
type="file"
className="hidden"
onChange={(event) => {
void upload(event.target.files?.[0]);
event.currentTarget.value = "";
}}
/>
<div className="admin-update-announcement-layout">
<div className="admin-update-announcement-fields">
<Form layout="vertical" disabled={locked}>
<Form.Item label="当前展示版本" htmlFor="update-current-version">
<Select
id="update-current-version"
aria-label="当前展示版本"
placeholder="添加公告版本后选择"
value={value.currentVersion || undefined}
options={value.releases.filter((release) => release.version.trim()).map((release) => ({ value: release.version, label: release.version }))}
onChange={(currentVersion) => change({ currentVersion })}
/>
</Form.Item>
</Form>
{value.releases.map((release, index) => (
<section className="admin-update-announcement-release" key={release.id}>
<div className="admin-update-announcement-toolbar">
<strong>公告版本 {index + 1}</strong>
<div>
<Button aria-label={`上移公告版本 ${index + 1}`} icon={<ArrowUp className="size-4" />} disabled={locked || index === 0} onClick={() => change({ releases: moveUpdateItem(value.releases, index, -1) })} />
<Button
aria-label={`下移公告版本 ${index + 1}`}
icon={<ArrowDown className="size-4" />}
disabled={locked || index === value.releases.length - 1}
onClick={() => change({ releases: moveUpdateItem(value.releases, index, 1) })}
/>
<Button
aria-label={`删除公告版本 ${index + 1}`}
icon={<Trash2 className="size-4" />}
disabled={locked}
onClick={() => {
const releases = value.releases.filter((item) => item.id !== release.id);
change({ releases, currentVersion: release.version === value.currentVersion ? releases[0]?.version || "" : value.currentVersion });
}}
/>
</div>
</div>
<Form layout="vertical" disabled={locked}>
<div className="admin-update-announcement-meta">
<Form.Item label="版本号" htmlFor={`update-version-${release.id}`}>
<Input id={`update-version-${release.id}`} value={release.version} maxLength={40} placeholder="例如 v2.0.0" onChange={(event) => changeRelease(release.id, { version: event.target.value })} />
</Form.Item>
<Form.Item label="发布日期(可选)" htmlFor={`update-date-${release.id}`}>
<Input id={`update-date-${release.id}`} type="date" value={release.date} onChange={(event) => changeRelease(release.id, { date: event.target.value })} />
</Form.Item>
</div>
<Form.Item label="标题(可选)" htmlFor={`update-title-${release.id}`}>
<Input id={`update-title-${release.id}`} value={release.title} maxLength={100} placeholder="本次更新的主题" onChange={(event) => changeRelease(release.id, { title: event.target.value })} />
</Form.Item>
</Form>
{release.blocks.map((block, blockIndex) => (
<div className="admin-update-announcement-block" key={block.id}>
<div className="admin-update-announcement-toolbar">
<strong>
{block.type === "text" ? "文字" : block.type === "image" ? "图片" : "视频"} {blockIndex + 1}
</strong>
<div>
<Button
aria-label={`上移内容块 ${blockIndex + 1}(版本 ${index + 1})`}
icon={<ArrowUp className="size-4" />}
disabled={locked || blockIndex === 0}
onClick={() => changeRelease(release.id, { blocks: moveUpdateItem(release.blocks, blockIndex, -1) })}
/>
<Button
aria-label={`下移内容块 ${blockIndex + 1}(版本 ${index + 1})`}
icon={<ArrowDown className="size-4" />}
disabled={locked || blockIndex === release.blocks.length - 1}
onClick={() => changeRelease(release.id, { blocks: moveUpdateItem(release.blocks, blockIndex, 1) })}
/>
<Button
aria-label={`删除内容块 ${blockIndex + 1}(版本 ${index + 1})`}
icon={<Trash2 className="size-4" />}
disabled={locked}
onClick={() => changeRelease(release.id, { blocks: release.blocks.filter((item) => item.id !== block.id) })}
/>
</div>
</div>
<Form layout="vertical" disabled={locked}>
<Form.Item label="布局" htmlFor={`update-layout-${block.id}`}>
<Select
id={`update-layout-${block.id}`}
value={block.layout}
options={[
{ value: "full", label: "整行" },
{ value: "half", label: "半行(相邻半行内容并排)" },
]}
onChange={(layout) => changeBlock(release, block.id, { layout })}
/>
</Form.Item>
{block.type === "text" ? (
<Form.Item label="正文" htmlFor={`update-text-${block.id}`} extra="支持换行、列表和 Markdown 加粗。">
<Input.TextArea
id={`update-text-${block.id}`}
value={block.text}
maxLength={10000}
autoSize={{ minRows: 4, maxRows: 14 }}
onChange={(event) => changeBlock(release, block.id, { text: event.target.value })}
/>
</Form.Item>
) : (
<>
<Button
disabled={locked}
loading={uploading === block.id}
icon={<Upload className="size-4" />}
onClick={() => {
target.current = { releaseId: release.id, blockId: block.id, type: block.type as "image" | "video" };
if (fileInput.current) {
fileInput.current.accept = block.type === "image" ? "image/png,image/jpeg,image/webp" : "video/mp4,video/webm";
fileInput.current.click();
}
}}
>
{block.resourceId ? "更换" : "上传"}
{block.type === "image" ? "图片(最大 10MB)" : "视频(最大 256MB)"}
</Button>
<Form.Item label="媒体说明(可选)" htmlFor={`update-caption-${block.id}`}>
<Input id={`update-caption-${block.id}`} value={block.caption} maxLength={200} onChange={(event) => changeBlock(release, block.id, { caption: event.target.value })} />
</Form.Item>
</>
)}
</Form>
</div>
))}
<div className="admin-update-announcement-add-actions">
{(
[
["text", "添加文字", Type],
["image", "添加图片", ImagePlus],
["video", "添加视频", Video],
] as const
).map(([type, label, Icon]) => (
<Button key={type} disabled={locked || release.blocks.length >= 50} icon={<Icon className="size-4" />} onClick={() => addBlock(release, type)}>
{label}
</Button>
))}
</div>
</section>
))}
<Button
disabled={locked || value.releases.length >= 30}
icon={<Plus className="size-4" />}
onClick={() => change({ releases: [...value.releases, { id: crypto.randomUUID(), version: "", title: "", date: "", blocks: [{ id: crypto.randomUUID(), type: "text", text: "", resourceId: "", caption: "", layout: "full" }] }] })}
>
添加公告版本
</Button>
</div>
<aside className="admin-update-announcement-preview" aria-label="更新公告实时预览">
<div className="admin-update-announcement-preview-heading">
<strong>更新公告 · {value.currentVersion || "待填写版本"}</strong>
<span>实时预览 · 保存后生效</span>
</div>
<UpdateAnnouncementContent value={value} mediaURL={(block) => (block.resourceId ? updateAnnouncementPreviewURL(block.resourceId) : "")} />
</aside>
</div>
</div>
);
}
@@ -1025,6 +1025,23 @@
font-weight: 650;
}
:root:not(.dark) :is(.admin-drawer, .admin-secondary-drawer) .ant-drawer-content,
:root:not(.dark) :is(.admin-modal-root, .admin-channel-modal-root) .ant-modal-container {
border: 0 !important;
box-shadow: 0 16px 40px rgb(15 23 42 / 0.1) !important;
}
:root:not(.dark) :is(.admin-drawer, .admin-secondary-drawer) :is(.ant-drawer-header, .ant-drawer-footer),
:root:not(.dark) :is(.admin-modal-root, .admin-channel-modal-root) :is(.ant-modal-header, .ant-modal-footer) {
border-color: transparent !important;
background: var(--admin-surface) !important;
}
:root:not(.dark) :is(.admin-drawer, .admin-secondary-drawer) .ant-drawer-body,
:root:not(.dark) :is(.admin-modal-root, .admin-channel-modal-root) .ant-modal-body {
background: var(--admin-surface) !important;
}
@media (prefers-reduced-motion: reduce) {
[data-admin-root] *,
body.admin-overlays * {
+14 -10
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateA
import { useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { applyGenerationTaskResultToNodes, generationTaskCanReloadResource, generationTaskNodeId } from "@/lib/canvas/canvas-generation-task-sync";
import { applyGenerationTaskResultToNodes, generationTaskCanReloadResource, generationTaskNodeId, generationTaskOutputsApplied, shouldRecoverCanvasImageOutputs } from "@/lib/canvas/canvas-generation-task-sync";
import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result";
import { reconcileImageBatchRoot } from "@/lib/canvas/canvas-image-batch-retry";
import { markCanvasTaskRecoveryUnconfirmed } from "@/lib/canvas/canvas-task-state";
@@ -13,8 +13,10 @@ import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { cinematicStoryboardColumns, storyboardRowsFromTask } from "@/lib/canvas/canvas-project-domain";
import { generationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract";
import { generationFailureMetadata } from "@/lib/generation-error";
import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
import { attachNodeEffectKey } from "@/services/generation-task-materializer";
import { consumeCanvasGenerationContinuation } from "./use-canvas-operation-history";
type CanvasGenerationRequest = {
@@ -172,7 +174,7 @@ export async function recoverCanvasGenerationTaskNode(input: {
} catch (error) {
if (!isCurrentProject() || (error instanceof Error && error.name === "AbortError")) return;
if (isCanvasGenerationDurableAckError(error)) return;
const failure = generationFailureMetadata(error, input.node.metadata?.composerContent || input.node.metadata?.prompt || "");
const failure = generationFailureMetadata(error, nodeGenerationPrompt(input.node));
input.setNodes((current) =>
current.map((item) =>
item.id === input.node.id
@@ -233,7 +235,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
status: (node.metadata?.taskStatus as GenerationTask["status"]) || "running",
stage: node.metadata?.taskStage,
progress: node.metadata?.taskProgress,
prompt: node.metadata?.prompt || "",
prompt: nodeGenerationPrompt(node),
attempts: 1,
createdAt: node.metadata?.taskCreatedAt || new Date().toISOString(),
updatedAt: node.metadata?.taskUpdatedAt || new Date().toISOString(),
@@ -258,7 +260,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
if (node.id !== targetNodeId) return node;
const failed = task.status === "failed" || task.status === "cancelled";
const hasCompletedContent = task.status === "succeeded" && Boolean(node.metadata?.content || node.metadata?.storageKey);
const failure = failed ? generationFailureMetadata(task.error || (task.status === "cancelled" ? "任务已取消" : "任务失败"), node.metadata?.composerContent || node.metadata?.prompt || task.prompt || "") : undefined;
const failure = failed ? generationFailureMetadata(task.error || (task.status === "cancelled" ? "任务已取消" : "任务失败"), nodeGenerationPrompt(node) || task.prompt || "") : undefined;
return {
...node,
metadata: {
@@ -293,14 +295,14 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
const applied = await applyGenerationTaskResultToNodes(nodesRef.current, task, nodeId);
if (signal.aborted) throw new DOMException("The operation was aborted", "AbortError");
if (!applied.updated || !applied.node) throw new Error("画布中找不到对应任务节点");
setNodes((current) => commitCanvasGenerationResult(current, before, applied.node!, task.id));
setNodes((current) => commitCanvasGenerationResult(current, before, applied.node!, task.id, applied.additionalNodes));
};
if (!task.outputs?.length && task.type === "canvas_text") {
await applyStoredTaskResult();
return;
}
try {
await consumeGenerationTaskNode(
const materialized = await consumeGenerationTaskNode(
task,
nodeId,
0,
@@ -319,10 +321,12 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
{ signal: consumerControllerRef.current.signal },
);
const currentNode = nodesRef.current.find((node) => node.id === nodeId);
if (task.status === "succeeded" && (!currentNode?.metadata?.content || currentNode.metadata.status !== NODE_STATUS_SUCCESS)) {
if (task.status === "succeeded" && (!currentNode || !generationTaskOutputsApplied(currentNode, materialized))) {
// attach effect 可能已经完成,但旧画布快照仍停留在 loading。
// 最终以节点是否真实拿到媒体结果为准,不能只信幂等记录。
await applyStoredTaskResult();
const output = materialized.outputs?.find((item) => item.outputIndex === 0);
if (!output) throw new Error("生成任务没有可恢复的输出");
await applyCanvasGenerationTaskNodeEffect({ projectId, nodeId, task: materialized, output, effectKey: attachNodeEffectKey(task.id, nodeId, 0), signal: consumerControllerRef.current.signal, nodesRef, setNodes });
}
} catch (error) {
// 成功任务的副作用确认失败时,直接用已持久化结果回写节点,避免永久停留在生成中。
@@ -379,7 +383,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
const pendingAgentContinuation = node.metadata?.agentGenerationContinuation?.status === "pending";
const aggregateBatchRoot = node.metadata?.isBatchRoot && node.metadata.batchChildIds?.length;
if (aggregateBatchRoot && !pendingAgentContinuation) return false;
return pendingAgentContinuation || node.metadata?.status === NODE_STATUS_LOADING || node.metadata?.errorDetails === "页面刷新后生成已中断,请重新生成。" || Boolean(node.metadata?.taskId && node.metadata.status !== NODE_STATUS_SUCCESS);
return shouldRecoverCanvasImageOutputs(node) || pendingAgentContinuation || node.metadata?.status === NODE_STATUS_LOADING || node.metadata?.errorDetails === "页面刷新后生成已中断,请重新生成。" || Boolean(node.metadata?.taskId && node.metadata.status !== NODE_STATUS_SUCCESS);
});
const needsDiscovery = recoveryNodes.some((node) => !node.metadata?.taskId && !node.metadata?.agentGenerationContinuation?.taskId);
let projectTasks: GenerationTask[] = [];
@@ -430,7 +434,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
});
} catch (error) {
if (!isCurrentProject() || (error instanceof Error && error.name === "AbortError")) return;
const failure = generationFailureMetadata(error, node.metadata?.composerContent || node.metadata?.prompt || "");
const failure = generationFailureMetadata(error, nodeGenerationPrompt(node));
setNodes((current) =>
isCurrentProject()
? current.map((item) =>
+10 -11
View File
@@ -706,27 +706,26 @@ export default function CreatePage() {
}));
if (requestLifecycle.signal.aborted) throw new DOMException("Aborted", "AbortError");
const boundTaskIdList = Array.from(boundTaskIds);
const generatedImages = settled.flatMap((entry, batchIndex) => {
const generatedTasks = settled.flatMap((entry, batchIndex) => {
if (entry.status !== "fulfilled") return [];
return (entry.value.images || []).map((image, resultIndex) => ({
image,
return [{
result: entry.value,
taskId: boundTaskIdsByBatchIndex.get(batchIndex) || boundTaskIdList[batchIndex],
batchIndex,
resultIndex,
}));
}];
});
const taskFailures = settled.filter((entry): entry is PromiseRejectedResult => entry.status === "rejected");
const storedImages = await Promise.allSettled(generatedImages.map(async ({ image, taskId, batchIndex }) => {
const storedImages = await Promise.allSettled(generatedTasks.map(async ({ result, taskId, batchIndex }) => {
if (!taskId) throw new Error("生成任务缺少稳定任务标识");
const task = completedCreationGenerationTask(runtime, { taskId, task: boundTasks.get(taskId), mode: "image", prompt: expandedPrompt, result: { mode: "image", images: [image] }, conversationId: activeConversation.id, messageId: assistantMessage.id, batchIndex, batchCount: taskCount });
const task = completedCreationGenerationTask(runtime, { taskId, task: boundTasks.get(taskId), mode: "image", prompt: expandedPrompt, result, conversationId: activeConversation.id, messageId: assistantMessage.id, batchIndex, batchCount: taskCount });
const materialized = await runtime.consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, resultStorageKeys, effectKey }) => {
await updateOriginAssistant((item) => runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "图片已生成", ...(resultUrls.length ? { resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) } : {}), ...(resultStorageKeys.length ? { resultStorageKeys: Array.from(new Set([...(current.resultStorageKeys || []), ...resultStorageKeys])) } : {}) })).value);
}, { signal: requestLifecycle.signal });
const url = runtime.generationTaskMaterializedUrls(materialized)[0];
if (!url) throw new Error("图片结果资源不可用");
return url;
const urls = runtime.generationTaskMaterializedUrls(materialized);
if (!urls.length) throw new Error("图片结果资源不可用");
return urls;
}));
const resultUrls = storedImages.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
const resultUrls = storedImages.flatMap((entry) => entry.status === "fulfilled" ? entry.value : []);
const resourceFailures = storedImages.filter((entry) => entry.status === "rejected");
const failedCount = taskFailures.length + resourceFailures.length;
if (!resultUrls.length) {
@@ -181,6 +181,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
model={activeModel}
value={activeModelCost?.capabilityConfig || defaultModelCapabilityConfig(activeProtocol, activeModel)}
protocol={activeProtocol}
workflows={modelProtocolDefinition(activeProtocol, availableProtocols)?.workflows}
onChange={(capabilityConfig) => updateCost(activeModel, { capabilityConfig })}
/>
) : <p className="text-xs text-foreground/50">当前模型类型无需额外配置引用与参数。</p>}
+11 -2
View File
@@ -2,8 +2,10 @@ import { http } from "@/services/api/request";
import type { SkinDefinition } from "@/lib/skin-themes";
import type { CanvasAppearance } from "@/lib/canvas/agent-appearance";
import { apiBaseURL } from "@/services/api/request";
import type { UpdateAnnouncement } from "@/lib/update-announcement";
export type PublicAppearance = {
updates?: UpdateAnnouncement;
canvas?: CanvasAppearance;
schemaVersion: number;
brandName: string;
@@ -34,6 +36,7 @@ export type PublicAppearance = {
};
export type AdminAppearance = {
updates?: UpdateAnnouncement;
canvas?: CanvasAppearance;
schemaVersion: number;
brandName: string;
@@ -86,6 +89,7 @@ export async function updateAdminAppearance(
AdminAppearance,
| "brandName"
| "canvas"
| "updates"
| "brandSlug"
| "authHeroTitle"
| "authHeroDescription"
@@ -114,15 +118,20 @@ export async function resetAdminAppearance() {
return result.setting;
}
export async function uploadAppearanceAsset(slot: AppearanceAssetSlot, file: File) {
export async function uploadAppearanceAsset(slot: AppearanceAssetSlot | "update-image" | "update-video", file: File) {
const body = new FormData();
body.append("file", file);
const result = await http.post<{ resource: AppearanceResource }>(`/admin/settings/appearance/assets/${slot}`, body);
return result.resource;
}
export function updateAnnouncementPreviewURL(resourceId: string) {
return `${apiBaseURL.replace(/\/$/, "")}/admin/settings/appearance/updates/${encodeURIComponent(resourceId)}`;
}
export async function uploadLive2D(file: File) {
const body = new FormData(); body.append("file", file);
const body = new FormData();
body.append("file", file);
const result = await http.post<{ model: { resourceId: string; entry: string } }>("/admin/settings/appearance/live2d", body);
return result.model;
}
+9
View File
@@ -14,6 +14,14 @@ type PluginProviderCatalogItem = {
enabled: boolean;
unavailableReason?: string;
baseUrl?: string;
parameters?: Array<{
name: string;
type: string;
required?: boolean;
description?: string;
values?: string[];
mapping?: string;
}>;
workflows?: Array<{
id: string;
label: string;
@@ -41,6 +49,7 @@ function toProviderDefinition(item: PluginProviderCatalogItem): ModelProtocolDef
media: `${item.vendor} · ${item.version}`,
enabled: item.enabled && !item.unavailableReason,
baseUrl: item.baseUrl,
parameters: item.parameters || [],
workflows: item.workflows || [],
};
}
+24 -1
View File
@@ -1,7 +1,30 @@
import { getPublicAppearance, type PublicAppearance } from "@/services/api/appearance";
import { commitPublicAppearance, DEFAULT_PUBLIC_APPEARANCE } from "@/stores/use-appearance-store";
import { commitPublicAppearance, DEFAULT_PUBLIC_APPEARANCE, useAppearanceStore } from "@/stores/use-appearance-store";
const APPEARANCE_BOOTSTRAP_TIMEOUT_MS = 4_000;
let refreshing: Promise<PublicAppearance | undefined> | undefined;
// A failed refresh must keep the last saved identity rather than restore defaults.
export function refreshPublicAppearance(fetchAppearance: (signal: AbortSignal) => Promise<PublicAppearance> = getPublicAppearance) {
if (refreshing) return refreshing;
refreshing = (async () => {
const previous = useAppearanceStore.getState().appearance;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), APPEARANCE_BOOTSTRAP_TIMEOUT_MS);
try {
const appearance = await fetchAppearance(controller.signal);
if (useAppearanceStore.getState().appearance !== previous) return undefined;
return commitPublicAppearance(appearance);
} catch {
return undefined;
} finally {
clearTimeout(timer);
}
})().finally(() => {
refreshing = undefined;
});
return refreshing;
}
export async function resolvePublicAppearance(fetchAppearance: (signal: AbortSignal) => Promise<PublicAppearance> = getPublicAppearance) {
const controller = new AbortController();
@@ -123,7 +123,7 @@ export async function applyCanvasGenerationTaskNodeEffect(input: {
if (!applied.updated || !applied.node) throw new Error("画布中找不到对应任务节点");
throwIfAborted(input.signal);
const previousNodes = input.nodesRef.current;
const nodes = commitCanvasGenerationResult(previousNodes, before, applied.node, input.task.id);
const nodes = commitCanvasGenerationResult(previousNodes, before, applied.node, input.task.id, applied.additionalNodes);
await persistCanvasGenerationEffect({
projectId: input.projectId,
effectKey: input.effectKey,
@@ -133,7 +133,7 @@ export async function applyCanvasGenerationTaskNodeEffect(input: {
});
throwIfAborted(input.signal);
// 持久化已完成;最后一次 await 之后仍按节点合并,保留期间发生的编辑。
input.setNodes((current) => commitCanvasGenerationResult(current, before, applied.node!, input.task.id));
input.setNodes((current) => commitCanvasGenerationResult(current, before, applied.node!, input.task.id, applied.additionalNodes));
}
export async function persistCanvasOperationContinuationEffect(input: {
+5 -2
View File
@@ -1,12 +1,14 @@
import { create } from "zustand";
import { DEFAULT_CANVAS_APPEARANCE } from "@/lib/canvas/agent-appearance";
import { DEFAULT_UPDATE_ANNOUNCEMENT, normalizeUpdateAnnouncement } from "@/lib/update-announcement";
import type { PublicAppearance } from "@/services/api/appearance";
import { applySkinTheme, DEFAULT_CLASSIC_SKIN, normalizeSkinDefinition } from "@/lib/skin-themes";
export const DEFAULT_PUBLIC_APPEARANCE: PublicAppearance = {
updates: DEFAULT_UPDATE_ANNOUNCEMENT,
canvas: DEFAULT_CANVAS_APPEARANCE,
schemaVersion: 9,
schemaVersion: 10,
brandName: "影策",
brandSlug: "open-ai-canvas",
authHeroTitle: "让一个故事,\n从文字走向银幕。",
@@ -62,7 +64,8 @@ export function normalizePublicAppearance(value?: Partial<PublicAppearance> | nu
return {
...DEFAULT_PUBLIC_APPEARANCE,
...value,
schemaVersion: 9,
schemaVersion: 10,
updates: normalizeUpdateAnnouncement(value?.updates),
canvas: { ...DEFAULT_CANVAS_APPEARANCE, ...value?.canvas },
brandName: resolvedBrandName,
brandSlug,
+8 -3
View File
@@ -9751,9 +9751,14 @@
}
:root:not(.dark) .admin-model-editor-section {
border: 1px solid rgba(17, 17, 17, 0.15);
background: #ffffff;
box-shadow: 0 1px 3px rgb(17 17 17 / 0.055);
border: 0;
background: transparent;
box-shadow: none;
}
:root:not(.dark) .admin-model-editor-section + .admin-model-editor-section {
border-top: 0;
padding-top: 8px;
}
:root:not(.dark) .admin-model-editor-drawer :is(.admin-capability-group, .admin-capability-boolean-field, .admin-capability-parameter-field),
+3 -2
View File
@@ -18786,12 +18786,13 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
box-shadow: none !important;
}
.ant-select.app-unified-select .ant-select-arrow {
.ant-select.app-unified-select .ant-select-suffix {
margin-inline-end: 10px !important;
color: color-mix(in srgb, var(--foreground) 48%, transparent) !important;
transition: color var(--motion-state, 180ms) ease, transform var(--motion-state, 180ms) ease !important;
}
.ant-select.app-unified-select.ant-select-open .ant-select-arrow {
.ant-select.app-unified-select.ant-select-open .ant-select-suffix {
color: color-mix(in srgb, var(--foreground) 72%, transparent) !important;
}
+1
View File
@@ -390,6 +390,7 @@ export type CanvasNodeMetadata = {
taskCreatedAt?: string;
taskUpdatedAt?: string;
generationEffectKeys?: string[];
generationOutputCount?: number;
agentGenerationContinuation?: {
id: string;
taskId: string;