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

Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
DAO-QIN
2026-10-04 10:43:31 +08:00
committed by ddcat666
parent 002a45f0c3
commit 853c7d2ce7
107 changed files with 9261 additions and 408 deletions

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 ? "返回管理后台" : "返回创作台";

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
}
>

View File

@@ -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">

View File

@@ -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>
);

View File

@@ -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}
</>
);
}

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" }}>

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View File

@@ -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");

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">