feat: add custom realtime mode icons
This commit is contained in:
parent
7164304451
commit
3775485bdf
@ -113,6 +113,7 @@ import {
|
||||
Loader2,
|
||||
Check,
|
||||
Paintbrush,
|
||||
Wand2,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@ -284,6 +285,13 @@ function LocalAiSectionToggleButton({
|
||||
);
|
||||
}
|
||||
|
||||
function maskAdminSecret(value: string) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) return "未填写";
|
||||
if (normalized.length <= 10) return "••••••";
|
||||
return `${normalized.slice(0, 6)}••••${normalized.slice(-4)}`;
|
||||
}
|
||||
|
||||
function reorderEnhanceProPresets(
|
||||
presets: EnhanceProPreset[],
|
||||
fromIndex: number,
|
||||
@ -581,6 +589,13 @@ type StorageConfig = {
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
type RealtimeCanvasModeIconConfig = {
|
||||
realtimeIconUrl: string;
|
||||
enhanceIconUrl: string;
|
||||
};
|
||||
|
||||
type RealtimeCanvasModeIconKey = keyof RealtimeCanvasModeIconConfig;
|
||||
|
||||
type SiteSettings = {
|
||||
siteName: string;
|
||||
siteLogo: string | null;
|
||||
@ -621,6 +636,7 @@ type SiteSettings = {
|
||||
};
|
||||
agentWorkflowUiConfig: AgentWorkflowUiConfig;
|
||||
realtimeCanvasEnabled: boolean;
|
||||
realtimeCanvasModeIconConfig: RealtimeCanvasModeIconConfig;
|
||||
aiDetailGenerationEnabled: boolean;
|
||||
canvasDefaultModelId: string | null;
|
||||
canvasAutoModeDefaultModelId: string | null;
|
||||
@ -695,6 +711,25 @@ const defaultContactConfig: ContactConfig = {
|
||||
wechatQrUrl: "",
|
||||
};
|
||||
|
||||
const defaultRealtimeCanvasModeIconConfig: RealtimeCanvasModeIconConfig = {
|
||||
realtimeIconUrl: "",
|
||||
enhanceIconUrl: "",
|
||||
};
|
||||
|
||||
function normalizeRealtimeCanvasModeIconConfig(
|
||||
value: unknown
|
||||
): RealtimeCanvasModeIconConfig {
|
||||
const raw =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
realtimeIconUrl: String(raw.realtimeIconUrl || "").trim(),
|
||||
enhanceIconUrl: String(raw.enhanceIconUrl || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const defaultStorageFailoverConfig: StorageFailoverConfig = {
|
||||
enabled: false,
|
||||
backupStorageConfigId: "",
|
||||
@ -1163,6 +1198,7 @@ export function AdminSettingsClient() {
|
||||
},
|
||||
agentWorkflowUiConfig: defaultAgentWorkflowUiConfig,
|
||||
realtimeCanvasEnabled: true,
|
||||
realtimeCanvasModeIconConfig: defaultRealtimeCanvasModeIconConfig,
|
||||
aiDetailGenerationEnabled: false,
|
||||
canvasDefaultModelId: null,
|
||||
canvasAutoModeDefaultModelId: null,
|
||||
@ -1304,6 +1340,9 @@ export function AdminSettingsClient() {
|
||||
),
|
||||
realtimeCanvasEnabled:
|
||||
(siteSettingsData as any).realtimeCanvasEnabled ?? true,
|
||||
realtimeCanvasModeIconConfig: normalizeRealtimeCanvasModeIconConfig(
|
||||
(siteSettingsData as any).realtimeCanvasModeIconConfig
|
||||
),
|
||||
aiDetailGenerationEnabled:
|
||||
siteSettingsData.aiDetailGenerationEnabled ?? false,
|
||||
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
|
||||
@ -1748,6 +1787,26 @@ export function AdminSettingsClient() {
|
||||
}).apiKeys.length,
|
||||
[enhanceProApiKeysDraft]
|
||||
);
|
||||
const savedEnhanceProKeyRows = useMemo(
|
||||
() =>
|
||||
enhanceProConfig.apiKeys.map((apiKey, index) => ({
|
||||
id: `saved-enhance-pro-key-${index}-${apiKey.slice(-6)}`,
|
||||
name: `Krea Key ${index + 1}`,
|
||||
maskedKey: maskAdminSecret(apiKey),
|
||||
})),
|
||||
[enhanceProConfig.apiKeys]
|
||||
);
|
||||
const draftEnhanceProKeyRows = useMemo(
|
||||
() =>
|
||||
normalizeEnhanceProKreaConfig({
|
||||
apiKeys: enhanceProApiKeysDraft,
|
||||
}).apiKeys.map((apiKey, index) => ({
|
||||
id: `draft-enhance-pro-key-${index}-${apiKey.slice(-6)}`,
|
||||
name: `待保存 Key ${index + 1}`,
|
||||
maskedKey: maskAdminSecret(apiKey),
|
||||
})),
|
||||
[enhanceProApiKeysDraft]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (enhanceProConfig.presets.length === 0) {
|
||||
@ -2282,6 +2341,8 @@ export function AdminSettingsClient() {
|
||||
const [canvasSkillTagUploadingId, setCanvasSkillTagUploadingId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [realtimeModeIconUploading, setRealtimeModeIconUploading] =
|
||||
useState<RealtimeCanvasModeIconKey | null>(null);
|
||||
const { data: canvasSkillTagsData, mutate: mutateCanvasSkillTags } = useSWR<{
|
||||
enabled?: boolean;
|
||||
title?: string;
|
||||
@ -2418,6 +2479,50 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRealtimeModeIconUpload = async (
|
||||
field: RealtimeCanvasModeIconKey,
|
||||
file: File
|
||||
) => {
|
||||
const isSvg =
|
||||
file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg");
|
||||
if (!isSvg) {
|
||||
toast.error("请上传 SVG 图标文件");
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 512 * 1024) {
|
||||
toast.error("SVG 图标不能超过 512KB");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setRealtimeModeIconUploading(field);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("purpose", "admin-svg-icon");
|
||||
|
||||
const response = await fetchUploadWithRetry(formData);
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result?.url) {
|
||||
throw new Error(result?.error || "上传失败");
|
||||
}
|
||||
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
realtimeCanvasModeIconConfig: {
|
||||
...prev.realtimeCanvasModeIconConfig,
|
||||
[field]: result.url,
|
||||
},
|
||||
}));
|
||||
toast.success("模式图标上传成功,请保存配置后生效");
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "模式图标上传失败");
|
||||
} finally {
|
||||
setRealtimeModeIconUploading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCanvasSkillTag = () => {
|
||||
setCanvasSkillTags((prev) => [...prev, createEmptyCanvasSkillTag()]);
|
||||
};
|
||||
@ -9812,6 +9917,151 @@ export function AdminSettingsClient() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{siteSettings.realtimeCanvasEnabled && (
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
实时画布模式图标
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
上传 SVG 后会替换前端“实时画布模式”和“图像增强模式”的按钮图标;前端会按选中状态自动切换黑白色。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
{
|
||||
field: "realtimeIconUrl",
|
||||
title: "实时画布模式",
|
||||
description: "左侧模式切换按钮的画笔图标",
|
||||
uploadId: "realtime-mode-icon-upload",
|
||||
fallback: Paintbrush,
|
||||
},
|
||||
{
|
||||
field: "enhanceIconUrl",
|
||||
title: "图像增强模式",
|
||||
description: "右侧模式切换按钮的魔法棒图标",
|
||||
uploadId: "enhance-mode-icon-upload",
|
||||
fallback: Wand2,
|
||||
},
|
||||
] as const
|
||||
).map((item) => {
|
||||
const iconUrl =
|
||||
siteSettings.realtimeCanvasModeIconConfig[
|
||||
item.field
|
||||
];
|
||||
const FallbackIcon = item.fallback;
|
||||
const isUploading =
|
||||
realtimeModeIconUploading === item.field;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.field}
|
||||
className="rounded-xl border bg-muted/10 p-3 space-y-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={isUploading}
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById(item.uploadId)
|
||||
?.click()
|
||||
}
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
上传中
|
||||
</>
|
||||
) : (
|
||||
"上传 SVG"
|
||||
)}
|
||||
</Button>
|
||||
{iconUrl ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-muted-foreground"
|
||||
onClick={() =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
realtimeCanvasModeIconConfig: {
|
||||
...prev.realtimeCanvasModeIconConfig,
|
||||
[item.field]: "",
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<input
|
||||
id={item.uploadId}
|
||||
type="file"
|
||||
accept=".svg,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
await handleRealtimeModeIconUpload(
|
||||
item.field,
|
||||
file
|
||||
);
|
||||
}
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex h-10 items-center gap-2 rounded-[18px] bg-[#111111] px-3 text-xs font-medium text-white">
|
||||
{iconUrl ? (
|
||||
<img
|
||||
alt={`${item.title}选中预览`}
|
||||
className="h-4 w-4 object-contain brightness-0 invert"
|
||||
src={iconUrl}
|
||||
/>
|
||||
) : (
|
||||
<FallbackIcon className="h-4 w-4 text-white" />
|
||||
)}
|
||||
选中态
|
||||
</div>
|
||||
<div className="inline-flex h-10 items-center gap-2 rounded-[18px] border bg-white px-3 text-xs font-medium text-[#666666]">
|
||||
{iconUrl ? (
|
||||
<img
|
||||
alt={`${item.title}未选中预览`}
|
||||
className="h-4 w-4 object-contain brightness-0 opacity-70"
|
||||
src={iconUrl}
|
||||
/>
|
||||
) : (
|
||||
<FallbackIcon className="h-4 w-4 text-[#666666]" />
|
||||
)}
|
||||
未选中态
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="videoCanvasEnabled">
|
||||
@ -9833,6 +10083,24 @@ export function AdminSettingsClient() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
视频画布功能配置
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
独立维护复刻画布里的功能预设提示词、默认模型、单功能模型和扣点配置。
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/settings/video-canvas">
|
||||
前往视频画布功能配置
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{siteSettings.videoCanvasEnabled && (
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
@ -15680,23 +15948,18 @@ export function AdminSettingsClient() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="enhanceProApiKeys">API Key 池</Label>
|
||||
<Textarea
|
||||
id="enhanceProApiKeys"
|
||||
value={enhanceProApiKeysDraft}
|
||||
onChange={(e) => setEnhanceProApiKeysDraft(e.target.value)}
|
||||
placeholder="为安全起见不回显已保存 Key;每行一个新 Krea Bearer Token,保存后会替换当前 Key 池。留空保存则保持当前池不变。"
|
||||
rows={4}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-xs text-muted-foreground md:flex-row md:items-center md:justify-between">
|
||||
<span>
|
||||
已保存 {enhanceProConfig.apiKeys.length} 个 Key
|
||||
{enhanceProApiKeysDraft.trim()
|
||||
? `,本次保存会替换为 ${enhanceProApiKeysDraftCount} 个 Key`
|
||||
: ";留空不会覆盖已保存池"}
|
||||
。生成任务会轮询使用,异步查询会绑定回创建任务时使用的 Key。
|
||||
</span>
|
||||
<div className="space-y-4 rounded-xl border p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Krea Key 池</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
当前共 {enhanceProConfig.apiKeys.length} 个 Key
|
||||
{enhanceProApiKeysDraft.trim()
|
||||
? `,本次保存会替换为 ${enhanceProApiKeysDraftCount} 个 Key`
|
||||
: ",留空保存不会覆盖已保存池"}
|
||||
。增强 Pro 任务会轮询使用可用 Key。
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
@ -15724,6 +15987,93 @@ export function AdminSettingsClient() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="enhanceProApiKeys">批量导入 Key</Label>
|
||||
<Textarea
|
||||
id="enhanceProApiKeys"
|
||||
value={enhanceProApiKeysDraft}
|
||||
onChange={(e) => setEnhanceProApiKeysDraft(e.target.value)}
|
||||
placeholder="为安全起见不回显已保存 Key;每行一个新 Krea Bearer Token,保存后会替换当前 Key 池。留空保存则保持当前池不变。"
|
||||
rows={4}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
异步查询会绑定回创建任务时使用的 Key,避免轮询切换 Key 后查不到任务。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{draftEnhanceProKeyRows.length ? (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50/70 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium text-emerald-900">
|
||||
待保存 Key 预览
|
||||
</div>
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-700">
|
||||
{draftEnhanceProKeyRows.length} 个
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{draftEnhanceProKeyRows.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col gap-2 rounded-lg border bg-background p-3 lg:flex-row lg:items-center lg:justify-between"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-600">
|
||||
待保存
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
Key: {item.maskedKey}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
API: {enhanceProConfig.baseUrl || ENHANCE_PRO_KREA_DEFAULT_BASE_URL}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{savedEnhanceProKeyRows.length ? (
|
||||
savedEnhanceProKeyRows.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col gap-4 rounded-lg border p-4 lg:flex-row lg:items-center lg:justify-between"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs",
|
||||
enhanceProConfig.enabled
|
||||
? "bg-emerald-500/10 text-emerald-600"
|
||||
: "bg-zinc-200 text-zinc-600"
|
||||
)}
|
||||
>
|
||||
{enhanceProConfig.enabled ? "已启用" : "已禁用"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
Key: {item.maskedKey}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
API: {enhanceProConfig.baseUrl || ENHANCE_PRO_KREA_DEFAULT_BASE_URL}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
当前还没有导入任何 Krea Key。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
|
||||
@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { upsertMediaAsset } from "@/lib/db/queries";
|
||||
import { isSiteAdminType } from "@/lib/middleware/admin";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
/**
|
||||
@ -90,9 +91,15 @@ const FileSchema = z.object({
|
||||
message: "File size should be less than 50MB",
|
||||
})
|
||||
// Update the file type based on the kind of files you want to accept
|
||||
.refine((file) => ["image/jpeg", "image/png", "image/webp"].includes(file.type), {
|
||||
message: "File type should be JPEG, PNG or WebP",
|
||||
}),
|
||||
.refine(
|
||||
(file) =>
|
||||
["image/jpeg", "image/png", "image/webp", "image/svg+xml"].includes(
|
||||
file.type
|
||||
),
|
||||
{
|
||||
message: "File type should be JPEG, PNG, WebP or SVG",
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@ -121,6 +128,18 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
|
||||
}
|
||||
|
||||
const uploadPurpose = String(formData.get("purpose") || "");
|
||||
const isSvgUpload = file.type === "image/svg+xml";
|
||||
if (
|
||||
isSvgUpload &&
|
||||
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "SVG uploads are only allowed for administrator icon settings" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[Upload API] File info:", {
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
@ -153,9 +172,9 @@ export async function POST(request: Request) {
|
||||
// WebP格式不优化(用于预览图,保持原样)
|
||||
if (file.type === 'image/jpeg' || file.type === 'image/jpg' || file.type === 'image/png') {
|
||||
optimizedBuffer = await optimizeImage(optimizedBuffer, file.type);
|
||||
} else if (file.type === 'image/webp') {
|
||||
// WebP格式不优化(用于预览图),直接使用
|
||||
console.log(`[Upload] WebP格式(预览图),跳过优化,直接上传`);
|
||||
} else if (file.type === 'image/webp' || file.type === 'image/svg+xml') {
|
||||
// WebP/SVG 不优化;SVG 是矢量文件,交给前端按状态反色渲染。
|
||||
console.log(`[Upload] ${file.type}格式,跳过优化,直接上传`);
|
||||
}
|
||||
|
||||
// 将Buffer转换为ArrayBuffer用于上传
|
||||
|
||||
@ -107,6 +107,7 @@ const MEMBERSHIP_CONFIG_KEY = "membership_config";
|
||||
const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
|
||||
const STORAGE_FAILOVER_CONFIG_KEY = "storage_failover_config";
|
||||
const REALTIME_CANVAS_CONFIG_KEY = "realtime_canvas_config";
|
||||
const REALTIME_CANVAS_MODE_ICON_CONFIG_KEY = "realtime_canvas_mode_icon_config";
|
||||
const LAYER_SPLIT_CONFIG_KEY = "layer_split_config";
|
||||
const AGENT_CATEGORY_TREE_CONFIG_KEY = "agent_category_tree_config";
|
||||
const CANVAS_AGENT_MODE_ENABLED_KEY = "canvas_agent_mode_enabled";
|
||||
@ -134,6 +135,31 @@ const AGENT_WORKFLOW_SETTINGS_FIELD_KEY_MAP = {
|
||||
AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY,
|
||||
} as const;
|
||||
type AgentWorkflowSettingsField = keyof typeof AGENT_WORKFLOW_SETTINGS_FIELD_KEY_MAP;
|
||||
type RealtimeCanvasModeIconConfig = {
|
||||
realtimeIconUrl: string;
|
||||
enhanceIconUrl: string;
|
||||
};
|
||||
|
||||
const defaultRealtimeCanvasModeIconConfig: RealtimeCanvasModeIconConfig = {
|
||||
realtimeIconUrl: "",
|
||||
enhanceIconUrl: "",
|
||||
};
|
||||
|
||||
function normalizeRealtimeCanvasModeIconConfig(
|
||||
value: unknown
|
||||
): RealtimeCanvasModeIconConfig {
|
||||
const raw =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
const normalizeUrl = (input: unknown) =>
|
||||
String(input || "").trim().slice(0, 2000);
|
||||
|
||||
return {
|
||||
realtimeIconUrl: normalizeUrl(raw.realtimeIconUrl),
|
||||
enhanceIconUrl: normalizeUrl(raw.enhanceIconUrl),
|
||||
};
|
||||
}
|
||||
|
||||
function stableStringifySettingsValue(value: unknown): string {
|
||||
if (value === null || typeof value !== "object") {
|
||||
@ -406,6 +432,7 @@ export async function GET() {
|
||||
pointsPackagesSetting,
|
||||
dulupayConfigSetting,
|
||||
realtimeCanvasConfigSetting,
|
||||
realtimeCanvasModeIconConfigSetting,
|
||||
layerSplitConfigSetting,
|
||||
canvasAgentModeEnabledSetting,
|
||||
videoCanvasEnabledSetting,
|
||||
@ -510,6 +537,12 @@ export async function GET() {
|
||||
.where(eq(systemSettings.key, REALTIME_CANVAS_CONFIG_KEY))
|
||||
.limit(1)
|
||||
.then(rows => rows[0]),
|
||||
db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.where(eq(systemSettings.key, REALTIME_CANVAS_MODE_ICON_CONFIG_KEY))
|
||||
.limit(1)
|
||||
.then(rows => rows[0]),
|
||||
db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
@ -642,6 +675,9 @@ export async function GET() {
|
||||
: normalizeDulupayForPublic(dulupayConfigSetting?.value);
|
||||
const realtimeCanvasServiceEnabled =
|
||||
(realtimeCanvasConfigSetting?.value as { enabled?: boolean } | undefined)?.enabled !== false;
|
||||
const realtimeCanvasModeIconConfig = normalizeRealtimeCanvasModeIconConfig(
|
||||
realtimeCanvasModeIconConfigSetting?.value
|
||||
);
|
||||
const layerSplitEnabled = normalizeLayerSplitEnabled(
|
||||
layerSplitConfigSetting?.value
|
||||
);
|
||||
@ -749,6 +785,7 @@ export async function GET() {
|
||||
agentPsdTemplateCategoryTagIndex:
|
||||
defaultAgentPsdTemplateCategoryTagIndex,
|
||||
realtimeCanvasEnabled: true,
|
||||
realtimeCanvasModeIconConfig: defaultRealtimeCanvasModeIconConfig,
|
||||
realtimeCanvasServiceEnabled: true,
|
||||
layerSplitEnabled,
|
||||
storageFailoverConfig,
|
||||
@ -809,6 +846,7 @@ export async function GET() {
|
||||
pointsPackages,
|
||||
dulupayConfig,
|
||||
realtimeCanvasServiceEnabled,
|
||||
realtimeCanvasModeIconConfig,
|
||||
layerSplitEnabled,
|
||||
canvasAgentModeEnabled,
|
||||
videoCanvasEnabled,
|
||||
@ -1149,6 +1187,12 @@ const updateSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
realtimeCanvasEnabled: z.boolean().optional(),
|
||||
realtimeCanvasModeIconConfig: z
|
||||
.object({
|
||||
realtimeIconUrl: z.string().trim().max(2000).optional(),
|
||||
enhanceIconUrl: z.string().trim().max(2000).optional(),
|
||||
})
|
||||
.optional(),
|
||||
aiDetailGenerationEnabled: z.boolean().optional(),
|
||||
canvasDefaultModelId: z.string().nullable().optional(),
|
||||
canvasAutoModeDefaultModelId: z.string().nullable().optional(),
|
||||
@ -1275,6 +1319,7 @@ export async function PUT(request: NextRequest) {
|
||||
dulupayConfig,
|
||||
storageFailoverConfig,
|
||||
ossImageMigrationConfig,
|
||||
realtimeCanvasModeIconConfig,
|
||||
canvasAgentModeEnabled,
|
||||
videoCanvasEnabled,
|
||||
videoCanvasSidebarEnabled,
|
||||
@ -1382,6 +1427,25 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
};
|
||||
|
||||
const persistSystemSetting = async (key: string, value: unknown) => {
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({
|
||||
key,
|
||||
value,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: systemSettings.key,
|
||||
set: {
|
||||
value,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: session.user.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const persistHomeAnimationConfig = async () => {
|
||||
if (homeAnimationConfig === undefined) {
|
||||
return;
|
||||
@ -1918,6 +1982,13 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
if (realtimeCanvasModeIconConfig !== undefined) {
|
||||
await persistSystemSetting(
|
||||
REALTIME_CANVAS_MODE_ICON_CONFIG_KEY,
|
||||
normalizeRealtimeCanvasModeIconConfig(realtimeCanvasModeIconConfig)
|
||||
);
|
||||
}
|
||||
|
||||
await persistHomeAnimationConfig();
|
||||
|
||||
return NextResponse.json({
|
||||
@ -2018,6 +2089,10 @@ export async function PUT(request: NextRequest) {
|
||||
ossImageMigrationConfig !== undefined
|
||||
? normalizeOssImageMigrationConfig(ossImageMigrationConfig)
|
||||
: defaultOssImageMigrationConfig,
|
||||
realtimeCanvasModeIconConfig:
|
||||
realtimeCanvasModeIconConfig !== undefined
|
||||
? normalizeRealtimeCanvasModeIconConfig(realtimeCanvasModeIconConfig)
|
||||
: defaultRealtimeCanvasModeIconConfig,
|
||||
canvasAgentModeEnabled:
|
||||
canvasAgentModeEnabled !== undefined
|
||||
? normalizeCanvasAgentModeEnabled(canvasAgentModeEnabled)
|
||||
@ -2514,6 +2589,13 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
if (realtimeCanvasModeIconConfig !== undefined) {
|
||||
await persistSystemSetting(
|
||||
REALTIME_CANVAS_MODE_ICON_CONFIG_KEY,
|
||||
normalizeRealtimeCanvasModeIconConfig(realtimeCanvasModeIconConfig)
|
||||
);
|
||||
}
|
||||
|
||||
if (videoCanvasEnabled !== undefined) {
|
||||
const normalized = normalizeVideoCanvasEnabled(videoCanvasEnabled);
|
||||
await db
|
||||
@ -2632,6 +2714,10 @@ export async function PUT(request: NextRequest) {
|
||||
ossImageMigrationConfig !== undefined
|
||||
? normalizeOssImageMigrationConfig(ossImageMigrationConfig)
|
||||
: defaultOssImageMigrationConfig,
|
||||
realtimeCanvasModeIconConfig:
|
||||
realtimeCanvasModeIconConfig !== undefined
|
||||
? normalizeRealtimeCanvasModeIconConfig(realtimeCanvasModeIconConfig)
|
||||
: defaultRealtimeCanvasModeIconConfig,
|
||||
canvasAgentModeEnabled:
|
||||
canvasAgentModeEnabled !== undefined
|
||||
? normalizeCanvasAgentModeEnabled(canvasAgentModeEnabled)
|
||||
|
||||
@ -82,6 +82,10 @@ type RealtimeConfig = {
|
||||
|
||||
type RealtimeStatus = "idle" | "connecting" | "live" | "error";
|
||||
type RealtimeWorkspaceMode = "realtime" | "enhance-pro";
|
||||
type RealtimeCanvasModeIconConfig = {
|
||||
realtimeIconUrl: string;
|
||||
enhanceIconUrl: string;
|
||||
};
|
||||
type RealtimeBrushMode =
|
||||
| "select"
|
||||
| "brush"
|
||||
@ -849,6 +853,11 @@ export function RealtimeCanvasDialog({
|
||||
const [siteName, setSiteName] = useState("AI Chatbot");
|
||||
const [siteLogo, setSiteLogo] = useState<string | null>(null);
|
||||
const [isLayerSplitEnabled, setIsLayerSplitEnabled] = useState(false);
|
||||
const [modeIconConfig, setModeIconConfig] =
|
||||
useState<RealtimeCanvasModeIconConfig>({
|
||||
realtimeIconUrl: "",
|
||||
enhanceIconUrl: "",
|
||||
});
|
||||
|
||||
const statusBadge = useMemo(() => {
|
||||
if (status === "live")
|
||||
@ -911,6 +920,21 @@ export function RealtimeCanvasDialog({
|
||||
setSiteLogo(data.siteLogoDark.trim());
|
||||
}
|
||||
setIsLayerSplitEnabled(data.layerSplitEnabled === true);
|
||||
const iconConfig =
|
||||
data.realtimeCanvasModeIconConfig &&
|
||||
typeof data.realtimeCanvasModeIconConfig === "object"
|
||||
? data.realtimeCanvasModeIconConfig
|
||||
: {};
|
||||
setModeIconConfig({
|
||||
realtimeIconUrl:
|
||||
typeof iconConfig.realtimeIconUrl === "string"
|
||||
? iconConfig.realtimeIconUrl.trim()
|
||||
: "",
|
||||
enhanceIconUrl:
|
||||
typeof iconConfig.enhanceIconUrl === "string"
|
||||
? iconConfig.enhanceIconUrl.trim()
|
||||
: "",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[RealtimeCanvas] Failed to load site settings:", error);
|
||||
@ -919,7 +943,7 @@ export function RealtimeCanvasDialog({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [open]);
|
||||
|
||||
const pushMetric = useCallback((event: any) => {
|
||||
metricsQueueRef.current.push(event);
|
||||
@ -3160,7 +3184,7 @@ export function RealtimeCanvasDialog({
|
||||
<div className="inline-flex h-12 min-w-0 items-center gap-1 rounded-[22px] border border-[#ebebeb] bg-[#fafafa] p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)]">
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex h-full items-center gap-2 rounded-[18px] px-4 text-[14px] font-medium tracking-[-0.02em] transition",
|
||||
"group inline-flex h-full items-center gap-2 rounded-[18px] px-4 text-[14px] font-medium tracking-[-0.02em] transition",
|
||||
workspaceMode === "realtime"
|
||||
? softActionButtonClass
|
||||
: "border border-transparent bg-transparent text-[#666666] hover:bg-white hover:text-[#111111]"
|
||||
@ -3168,12 +3192,26 @@ export function RealtimeCanvasDialog({
|
||||
onClick={() => handleWorkspaceModeChange("realtime")}
|
||||
type="button"
|
||||
>
|
||||
<Paintbrush className="h-4 w-4" />
|
||||
{modeIconConfig.realtimeIconUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-4 w-4 object-contain transition",
|
||||
workspaceMode === "realtime"
|
||||
? "brightness-0 invert"
|
||||
: "brightness-0 opacity-70 group-hover:opacity-100"
|
||||
)}
|
||||
src={modeIconConfig.realtimeIconUrl}
|
||||
/>
|
||||
) : (
|
||||
<Paintbrush className="h-4 w-4" />
|
||||
)}
|
||||
实时画布模式
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex h-full items-center gap-2 rounded-[18px] px-4 text-[14px] font-medium tracking-[-0.02em] transition",
|
||||
"group inline-flex h-full items-center gap-2 rounded-[18px] px-4 text-[14px] font-medium tracking-[-0.02em] transition",
|
||||
workspaceMode === "enhance-pro"
|
||||
? softActionButtonClass
|
||||
: "border border-transparent bg-transparent text-[#666666] hover:bg-white hover:text-[#111111]"
|
||||
@ -3181,7 +3219,21 @@ export function RealtimeCanvasDialog({
|
||||
onClick={() => handleWorkspaceModeChange("enhance-pro")}
|
||||
type="button"
|
||||
>
|
||||
<Wand2 className="h-4 w-4" />
|
||||
{modeIconConfig.enhanceIconUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-4 w-4 object-contain transition",
|
||||
workspaceMode === "enhance-pro"
|
||||
? "brightness-0 invert"
|
||||
: "brightness-0 opacity-70 group-hover:opacity-100"
|
||||
)}
|
||||
src={modeIconConfig.enhanceIconUrl}
|
||||
/>
|
||||
) : (
|
||||
<Wand2 className="h-4 w-4" />
|
||||
)}
|
||||
图像增强模式
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user