feat(*): 创作配置 - 支持 Midjourney、多图保存与自定义公告
Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
121
web/src/components/layout/update-announcement-content.css
Normal file
121
web/src/components/layout/update-announcement-content.css
Normal 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;
|
||||
}
|
||||
}
|
||||
51
web/src/components/layout/update-announcement-content.tsx
Normal file
51
web/src/components/layout/update-announcement-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user