feat(*): 画布助手 - 新增 Live2D 配置导入与模型渲染支持
This commit is contained in:
@@ -1,19 +1,20 @@
|
||||
import { ArrowUpRight, Clapperboard, Layers3, Sparkles } from "lucide-react";
|
||||
import { agentCopy, type CanvasAppearance } from "@/lib/canvas/agent-appearance";
|
||||
|
||||
type AgentWelcomeProps = {
|
||||
brandName: string;
|
||||
appearance: CanvasAppearance;
|
||||
nodeCount: number;
|
||||
onChooseSkill: () => void;
|
||||
onDraftPrompt: (prompt: string) => void;
|
||||
};
|
||||
|
||||
export function AgentWelcome({ brandName, nodeCount, onChooseSkill, onDraftPrompt }: AgentWelcomeProps) {
|
||||
export function AgentWelcome({ appearance, nodeCount, onChooseSkill, onDraftPrompt }: AgentWelcomeProps) {
|
||||
return (
|
||||
<section className="agent-welcome" aria-label="开始 Agent 创作">
|
||||
<div className="agent-welcome-intro">
|
||||
<span className="agent-welcome-orb" aria-hidden="true" />
|
||||
<h2>在这里,和{brandName}让灵感,慢慢成形</h2>
|
||||
<p>从一个想法开始,和 Agent 一起创作。</p>
|
||||
<h2>{agentCopy(appearance.welcomeTitle, appearance.agentName)}</h2>
|
||||
<p>{agentCopy(appearance.welcomeDescription, appearance.agentName)}</p>
|
||||
</div>
|
||||
<div className="agent-welcome-actions">
|
||||
<button type="button" onClick={onChooseSkill}>
|
||||
|
||||
@@ -35,6 +35,9 @@ import { CanvasCloudAgentSettings, agentPermissionLabel, agentPermissionMenuItem
|
||||
import { useAgentPanelLayout } from "./use-agent-panel-layout";
|
||||
import { useAgentLauncherPosition } from "./use-agent-launcher-position";
|
||||
import { AgentWelcome } from "./canvas-agent-welcome";
|
||||
import { DEFAULT_CANVAS_APPEARANCE, agentCopy } from "@/lib/canvas/agent-appearance";
|
||||
import { live2DModelURL } from "@/services/api/appearance";
|
||||
import { Live2DAvatar } from "./live2d-avatar";
|
||||
import "./canvas-cloud-agent.css";
|
||||
|
||||
type CloudAgentPanelProps = { canvasId: string; domainProjectId?: string; nodeCount: number; references: CanvasResourceReference[]; open: boolean; prefillPrompt?: string; onOpen: () => void; onCollapse: () => void; onFocusNode?: (nodeId: string) => void };
|
||||
@@ -42,6 +45,7 @@ type ApprovalState = { approvalId: string; detail: Record<string, unknown>; reas
|
||||
type AgentPanelView = "chat" | "history" | "settings";
|
||||
|
||||
export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, references, open, prefillPrompt, onOpen, onCollapse, onFocusNode }: CloudAgentPanelProps) {
|
||||
const appearance = useAppearanceStore((state) => state.appearance.canvas) || DEFAULT_CANVAS_APPEARANCE;
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
const config = useEffectiveConfig();
|
||||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
||||
@@ -741,7 +745,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
|
||||
disabled={Boolean(run && connectionStatus !== "connected") || !historyHydrated || !pendingHydrated}
|
||||
sending={busy}
|
||||
running={running}
|
||||
placeholder={running ? "运行中可直接插话,会在它下一步生效" : "输入操作指导;用 @ 引用画布节点,用 / 或 、 引用 Skills"}
|
||||
placeholder={running ? "运行中可直接插话,会在它下一步生效" : agentCopy(appearance.inputPlaceholder, appearance.agentName)}
|
||||
theme={theme}
|
||||
onPromptChange={setPrompt}
|
||||
onSubmit={() => void submit()}
|
||||
@@ -799,22 +803,32 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
|
||||
}
|
||||
|
||||
function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, onOpen }: { theme: CanvasTheme; statusColor: string; approvalPending: boolean; reducedMotion: boolean; onOpen: () => void }) {
|
||||
const { position, dragging, handlers } = useAgentLauncherPosition(onOpen);
|
||||
const appearance = useAppearanceStore((state) => state.appearance.canvas) || DEFAULT_CANVAS_APPEARANCE;
|
||||
const live = appearance.avatarType === "live2d" && Boolean(appearance.live2dResourceId && appearance.live2dEntry);
|
||||
const [viewport, setViewport] = useState(() => ({ width: window.innerWidth, height: window.innerHeight }));
|
||||
useEffect(() => {
|
||||
const resize = () => setViewport({ width: window.innerWidth, height: window.innerHeight });
|
||||
window.addEventListener("resize", resize);
|
||||
return () => window.removeEventListener("resize", resize);
|
||||
}, []);
|
||||
const height = live ? Math.max(40, Math.min(appearance.avatarHeight, viewport.height - 60, (viewport.width - 40) / 0.75)) : 76;
|
||||
const width = live ? Math.round(height * 0.75) : 76;
|
||||
const { position, dragging, handlers } = useAgentLauncherPosition(onOpen, width, height);
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
aria-label="打开云端 Agent"
|
||||
aria-label={`打开${appearance.agentName}`}
|
||||
title={`${approvalPending ? "Agent 等待你的审批" : "打开 Agent 助手"} · 拖动可调整位置,聚焦后可用方向键移动`}
|
||||
className={cn("canvas-agent-launcher fixed z-[var(--z-modal-overlay)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/35", dragging && "is-dragging")}
|
||||
style={{ ...position, color: theme.node.text, "--canvas-agent-launcher-shadow": theme.spatial.shadow } as CSSProperties}
|
||||
className={cn("canvas-agent-launcher fixed z-[var(--z-modal-overlay)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/35", dragging && "is-dragging", live && "canvas-agent-launcher-live2d")}
|
||||
style={{ ...position, width, height, color: theme.node.text, "--canvas-agent-launcher-shadow": theme.spatial.shadow } as CSSProperties}
|
||||
data-canvas-no-zoom
|
||||
{...handlers}
|
||||
whileHover={reducedMotion || dragging ? undefined : { scale: 1.035 }}
|
||||
whileTap={reducedMotion || dragging ? undefined : { scale: 0.96 }}
|
||||
transition={{ duration: reducedMotion ? 0 : 0.18 }}
|
||||
>
|
||||
<FluidOrb size={62} color="#7164f6" />
|
||||
<span className="canvas-agent-launcher-label">Agent</span>
|
||||
{live ? <Live2DAvatar url={live2DModelURL(appearance.live2dResourceId, appearance.live2dEntry)} width={width} height={height} reducedMotion={reducedMotion} fallback={<FluidOrb size={62} color="#7164f6" />} /> : <FluidOrb size={62} color="#7164f6" />}
|
||||
{appearance.launcherLabel ? <span className="canvas-agent-launcher-label">{appearance.launcherLabel}</span> : null}
|
||||
<span className={cn("canvas-agent-launcher-status", approvalPending && "is-pending")} style={{ "--canvas-agent-status-color": statusColor } as CSSProperties} />
|
||||
{approvalPending ? <span className="canvas-agent-launcher-badge">待审批</span> : null}
|
||||
</motion.button>
|
||||
@@ -822,17 +836,18 @@ function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, onO
|
||||
}
|
||||
|
||||
function AgentHeader({ theme, hasMessages, statusLabel, statusColor, nodeCount, onNew, onHistory, onSettings, onCollapse, onExport, exporting }: { theme: CanvasTheme; hasMessages: boolean; statusLabel: string; statusColor: string; nodeCount: number; onNew: () => void; onHistory: () => void; onSettings: () => void; onCollapse: () => void; onExport: () => void; exporting: boolean }) {
|
||||
const appearance = useAppearanceStore((state) => state.appearance.canvas) || DEFAULT_CANVAS_APPEARANCE;
|
||||
return (
|
||||
<header data-agent-drag-handle className="agent-panel-header flex shrink-0 items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="agent-panel-title">{hasMessages ? "画布助手" : "新对话"}</span>
|
||||
<span className="agent-panel-title">{agentCopy(appearance.panelTitle, appearance.agentName)}{hasMessages ? "" : " · 新对话"}</span>
|
||||
<span className="agent-panel-status flex items-center gap-1" style={{ color: statusColor }}>
|
||||
<CircleDot className="size-3" />
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="agent-panel-context">Agent · 当前画布 {nodeCount} 个节点</div>
|
||||
<div className="agent-panel-context">{appearance.agentName} · 当前画布 {nodeCount} 个节点</div>
|
||||
</div>
|
||||
<div className="agent-header-actions flex items-center gap-0.5" style={{ color: theme.node.muted }}>
|
||||
<Button type="text" shape="circle" icon={<Download className="size-4" />} loading={exporting} onClick={onExport} aria-label="导出 Agent 调试记录" title="导出对话、工具参数、审批和错误(分享前请检查隐私)" />
|
||||
@@ -918,7 +933,7 @@ function AgentConversation({
|
||||
onApprove: (settings?: AgentMediaSettings) => void;
|
||||
onReject: () => void;
|
||||
}) {
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
const appearance = useAppearanceStore((state) => state.appearance.canvas) || DEFAULT_CANVAS_APPEARANCE;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const followRef = useRef(true);
|
||||
@@ -949,7 +964,7 @@ function AgentConversation({
|
||||
const element = event.currentTarget;
|
||||
followRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 48;
|
||||
}}>
|
||||
{!messages.length ? <AgentWelcome brandName={brandName} nodeCount={nodeCount} onChooseSkill={onChooseSkill} onDraftPrompt={onDraftPrompt} /> : null}
|
||||
{!messages.length ? <AgentWelcome appearance={appearance} nodeCount={nodeCount} onChooseSkill={onChooseSkill} onDraftPrompt={onDraftPrompt} /> : null}
|
||||
<div ref={contentRef} className="agent-conversation-messages">
|
||||
{messages.map((item) => (
|
||||
<AgentChatMessage key={item.id} item={item} theme={theme} references={references} onFocusNode={onFocusNode} isStreaming={busy && !approval && item.streaming === true && item === messages.at(-1)} />
|
||||
|
||||
@@ -561,6 +561,16 @@
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.canvas-agent-launcher.canvas-agent-launcher-live2d {
|
||||
padding: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.canvas-agent-launcher-live2d::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.canvas-agent-launcher-status {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
|
||||
196
web/src/components/canvas/live2d-avatar.tsx
Normal file
196
web/src/components/canvas/live2d-avatar.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { installLive2DLoader } from "@/services/live2d-loader";
|
||||
import { adaptLive2DRenderOrders } from "@/lib/canvas/live2d-core-adapter";
|
||||
|
||||
let corePromise: Promise<void> | undefined;
|
||||
|
||||
function loadCore() {
|
||||
if ("Live2DCubismCore" in window) return Promise.resolve();
|
||||
if (corePromise) return corePromise;
|
||||
corePromise = new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
// Deployment-owned runtime only. Uploaded models may never provide executable code.
|
||||
script.src = `${import.meta.env.BASE_URL}live2d/live2dcubismcore.min.js`;
|
||||
const timer = window.setTimeout(() => fail(), 15000);
|
||||
const fail = () => {
|
||||
clearTimeout(timer);
|
||||
script.remove();
|
||||
reject(new Error("Live2D 运行库未安装或加载失败,请部署 Cubism Core 后重试"));
|
||||
};
|
||||
script.onload = () => {
|
||||
if (!("Live2DCubismCore" in window)) {
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
script.onerror = fail;
|
||||
document.head.append(script);
|
||||
}).catch((error) => {
|
||||
corePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
return corePromise;
|
||||
}
|
||||
|
||||
export function Live2DAvatar({
|
||||
url,
|
||||
width,
|
||||
height,
|
||||
reducedMotion = false,
|
||||
fallback,
|
||||
onReady,
|
||||
onError,
|
||||
}: {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
reducedMotion?: boolean;
|
||||
fallback?: ReactNode;
|
||||
onReady?: () => void;
|
||||
onError?: (message: string) => void;
|
||||
}) {
|
||||
const host = useRef<HTMLSpanElement>(null);
|
||||
const callbacks = useRef({ onReady, onError });
|
||||
callbacks.current = { onReady, onError };
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let deadline: ReturnType<typeof setTimeout> | undefined;
|
||||
let cancelLoading: (() => void) | undefined;
|
||||
setFailed(false);
|
||||
setReady(false);
|
||||
const init = async () => {
|
||||
await loadCore();
|
||||
const [PIXI, { Live2DModel, Live2DFactory, Live2DLoader, MotionPreloadStrategy, Cubism4InternalModel }] = await Promise.all([import("pixi.js"), import("pixi-live2d-display/cubism4")]);
|
||||
if (disposed) return;
|
||||
installLive2DLoader(Live2DLoader);
|
||||
const app = new PIXI.Application({ width, height, backgroundAlpha: 0, resolution: Math.min(window.devicePixelRatio || 1, 1.5), autoDensity: true, autoStart: false, antialias: true });
|
||||
const canvas = app.view as HTMLCanvasElement;
|
||||
canvas.style.width = "100%";
|
||||
canvas.style.height = "100%";
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
host.current?.append(canvas);
|
||||
let model: InstanceType<typeof Live2DModel> | undefined;
|
||||
let visible = true;
|
||||
let lost = false;
|
||||
let destroyed = false;
|
||||
let elapsed = performance.now();
|
||||
const failRendering = (message: string) => {
|
||||
lost = true;
|
||||
clearTimeout(deadline);
|
||||
cancelLoading?.();
|
||||
cleanup?.();
|
||||
setFailed(true);
|
||||
callbacks.current.onError?.(message);
|
||||
};
|
||||
const draw = () => {
|
||||
if (document.hidden || disposed || lost || !visible || !model) return;
|
||||
try {
|
||||
const now = performance.now();
|
||||
model.update(Math.min(now - elapsed, 100));
|
||||
elapsed = now;
|
||||
app.render();
|
||||
} catch {
|
||||
failRendering("Live2D 渲染失败,已回退默认形象");
|
||||
}
|
||||
};
|
||||
const sync = () => {
|
||||
clearInterval(timer);
|
||||
if (!document.hidden && visible && !lost && !reducedMotion && model) {
|
||||
elapsed = performance.now();
|
||||
timer = setInterval(draw, 1000 / 30);
|
||||
}
|
||||
};
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
visible = entry.isIntersecting;
|
||||
sync();
|
||||
});
|
||||
observer.observe(canvas);
|
||||
const contextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
failRendering("Live2D 渲染上下文丢失,已回退默认形象");
|
||||
};
|
||||
canvas.addEventListener("webglcontextlost", contextLost);
|
||||
document.addEventListener("visibilitychange", sync);
|
||||
cleanup = () => {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
clearInterval(timer);
|
||||
observer.disconnect();
|
||||
document.removeEventListener("visibilitychange", sync);
|
||||
canvas.removeEventListener("webglcontextlost", contextLost);
|
||||
app.destroy(true, { children: true, texture: true, baseTexture: true });
|
||||
};
|
||||
const loaded = new Live2DModel({ autoUpdate: false, autoInteract: false });
|
||||
const releaseLoaded = () => {
|
||||
// Cubism's destroy() assumes internalModel exists, even after failed setup.
|
||||
if (loaded.internalModel) loaded.destroy({ children: true, texture: true, baseTexture: true });
|
||||
else {
|
||||
loaded.emit("destroy");
|
||||
loaded.textures.forEach((texture) => texture.destroy(true));
|
||||
}
|
||||
};
|
||||
cancelLoading = () => loaded.emit("destroy");
|
||||
deadline = setTimeout(() => {
|
||||
disposed = true;
|
||||
cancelLoading?.();
|
||||
cleanup?.();
|
||||
setFailed(true);
|
||||
callbacks.current.onError?.("Live2D 加载超时,请检查模型和网络后重试");
|
||||
}, 30000);
|
||||
try {
|
||||
await Live2DFactory.setupLive2DModel(loaded, url, { autoUpdate: false, autoInteract: false, crossOrigin: "use-credentials", motionPreload: MotionPreloadStrategy.IDLE });
|
||||
} catch (error) {
|
||||
releaseLoaded();
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
cancelLoading = undefined;
|
||||
}
|
||||
if (disposed || lost) {
|
||||
releaseLoaded();
|
||||
cleanup?.();
|
||||
return;
|
||||
}
|
||||
model = loaded;
|
||||
app.stage.addChild(loaded);
|
||||
if (!(loaded.internalModel instanceof Cubism4InternalModel)) throw new Error("不支持的 Live2D 模型运行时");
|
||||
adaptLive2DRenderOrders(loaded.internalModel.coreModel);
|
||||
if (!(loaded.width > 0 && loaded.height > 0)) throw new Error("Live2D 模型尺寸无效");
|
||||
const scale = Math.min(width / loaded.width, height / loaded.height);
|
||||
loaded.scale.set(scale);
|
||||
loaded.anchor.set(0.5, 1);
|
||||
loaded.position.set(width / 2, height);
|
||||
loaded.update(0);
|
||||
app.render();
|
||||
sync();
|
||||
setReady(true);
|
||||
callbacks.current.onReady?.();
|
||||
};
|
||||
void init().catch((error) => {
|
||||
cleanup?.();
|
||||
cleanup = undefined;
|
||||
if (!disposed) {
|
||||
setFailed(true);
|
||||
callbacks.current.onError?.(error instanceof Error ? error.message : "Live2D 加载失败");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearTimeout(deadline);
|
||||
cancelLoading?.();
|
||||
cleanup?.();
|
||||
};
|
||||
}, [url, width, height, reducedMotion]);
|
||||
return (
|
||||
<span style={{ display: "grid", placeItems: "center", width, height, pointerEvents: "none" }}>
|
||||
<span ref={host} style={{ gridArea: "1 / 1", visibility: failed || !ready ? "hidden" : "visible", width, height }} />
|
||||
{failed || !ready ? <span style={{ gridArea: "1 / 1" }}>{fallback}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,14 @@ import { AGENT_LAUNCHER_POSITION_KEY, clampAgentLauncherPosition, moveAgentLaunc
|
||||
|
||||
const viewport = () => ({ width: window.innerWidth, height: window.innerHeight });
|
||||
|
||||
export function useAgentLauncherPosition(onOpen: () => void) {
|
||||
export function useAgentLauncherPosition(onOpen: () => void, width = 76, height = 76) {
|
||||
const size = { width, height };
|
||||
const [position, setPosition] = useState(() => {
|
||||
try {
|
||||
return restoreAgentLauncherPosition(localStorage.getItem(AGENT_LAUNCHER_POSITION_KEY), viewport());
|
||||
return restoreAgentLauncherPosition(localStorage.getItem(AGENT_LAUNCHER_POSITION_KEY), viewport(), size);
|
||||
} catch (error) {
|
||||
console.warn("Agent 入口位置无法读取", error);
|
||||
return restoreAgentLauncherPosition(null, viewport());
|
||||
return restoreAgentLauncherPosition(null, viewport(), size);
|
||||
}
|
||||
});
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -25,10 +26,11 @@ export function useAgentLauncherPosition(onOpen: () => void) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const resize = () => setPosition((current) => clampAgentLauncherPosition(current, viewport()));
|
||||
const resize = () => setPosition((current) => clampAgentLauncherPosition(current, viewport(), size));
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
return () => window.removeEventListener("resize", resize);
|
||||
}, []);
|
||||
}, [width, height]);
|
||||
|
||||
const onPointerDown = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
@@ -41,7 +43,7 @@ export function useAgentLauncherPosition(onOpen: () => void) {
|
||||
event.stopPropagation();
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
const next = moveAgentLauncher(gesture, event.clientX, event.clientY, viewport());
|
||||
const next = moveAgentLauncher(gesture, event.clientX, event.clientY, viewport(), size);
|
||||
gesture.dragged = next.dragged;
|
||||
if (!next.dragged) return;
|
||||
event.preventDefault();
|
||||
@@ -54,7 +56,7 @@ export function useAgentLauncherPosition(onOpen: () => void) {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
gestureRef.current = null;
|
||||
const next = cancelled ? { dragged: gesture.dragged, position: clampAgentLauncherPosition(position, viewport()) } : moveAgentLauncher(gesture, event.clientX, event.clientY, viewport());
|
||||
const next = cancelled ? { dragged: gesture.dragged, position: clampAgentLauncherPosition(position, viewport(), size) } : moveAgentLauncher(gesture, event.clientX, event.clientY, viewport(), size);
|
||||
suppressClick.current = cancelled || next.dragged;
|
||||
setDragging(false);
|
||||
setPosition(next.position);
|
||||
@@ -77,7 +79,7 @@ export function useAgentLauncherPosition(onOpen: () => void) {
|
||||
const offset = offsets[event.key];
|
||||
if (!offset) return;
|
||||
event.preventDefault();
|
||||
const next = clampAgentLauncherPosition({ right: position.right + offset[0], bottom: position.bottom + offset[1] }, viewport());
|
||||
const next = clampAgentLauncherPosition({ right: position.right + offset[0], bottom: position.bottom + offset[1] }, viewport(), size);
|
||||
setPosition(next);
|
||||
save(next);
|
||||
};
|
||||
|
||||
29
web/src/lib/canvas/agent-appearance.ts
Normal file
29
web/src/lib/canvas/agent-appearance.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type CanvasAppearance = {
|
||||
agentName: string;
|
||||
launcherLabel: string;
|
||||
panelTitle: string;
|
||||
welcomeTitle: string;
|
||||
welcomeDescription: string;
|
||||
inputPlaceholder: string;
|
||||
avatarType: "orb" | "live2d";
|
||||
live2dResourceId: string;
|
||||
live2dEntry: string;
|
||||
avatarHeight: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_CANVAS_APPEARANCE: CanvasAppearance = {
|
||||
agentName: "影策",
|
||||
launcherLabel: "Agent",
|
||||
panelTitle: "画布助手",
|
||||
welcomeTitle: "在这里,和{agentName}让灵感,慢慢成形",
|
||||
welcomeDescription: "从一个想法开始,和{agentName}一起创作。",
|
||||
inputPlaceholder: "输入操作指导;用 @ 引用画布节点,用 / 或 、 引用 Skills",
|
||||
avatarType: "orb",
|
||||
live2dResourceId: "",
|
||||
live2dEntry: "",
|
||||
avatarHeight: 220,
|
||||
};
|
||||
|
||||
export function agentCopy(template: string, name: string) {
|
||||
return template.replaceAll("{agentName}", () => name);
|
||||
}
|
||||
@@ -6,33 +6,33 @@ export const AGENT_LAUNCHER_POSITION_KEY = "canvas:agent-launcher-position:v1";
|
||||
export const AGENT_LAUNCHER_SIZE = 76;
|
||||
const MARGIN = 20; // Includes the label below the orb.
|
||||
|
||||
export function clampAgentLauncherPosition(position: AgentLauncherPosition, viewport: AgentLauncherViewport): AgentLauncherPosition {
|
||||
const clamp = (value: number, extent: number) => {
|
||||
const available = Math.max(0, extent - AGENT_LAUNCHER_SIZE);
|
||||
export function clampAgentLauncherPosition(position: AgentLauncherPosition, viewport: AgentLauncherViewport, size = { width: AGENT_LAUNCHER_SIZE, height: AGENT_LAUNCHER_SIZE }): AgentLauncherPosition {
|
||||
const clamp = (value: number, extent: number, dimension: number) => {
|
||||
const available = Math.max(0, extent - dimension);
|
||||
const margin = Math.min(MARGIN, available / 2);
|
||||
return Math.min(Math.max(value, margin), available - margin);
|
||||
};
|
||||
return { right: clamp(position.right, viewport.width), bottom: clamp(position.bottom, viewport.height) };
|
||||
return { right: clamp(position.right, viewport.width, size.width), bottom: clamp(position.bottom, viewport.height, size.height) };
|
||||
}
|
||||
|
||||
export function restoreAgentLauncherPosition(raw: string | null, viewport: AgentLauncherViewport): AgentLauncherPosition {
|
||||
export function restoreAgentLauncherPosition(raw: string | null, viewport: AgentLauncherViewport, size?: AgentLauncherViewport): AgentLauncherPosition {
|
||||
if (raw) {
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
if (value && Number.isFinite(value.right) && Number.isFinite(value.bottom)) return clampAgentLauncherPosition(value, viewport);
|
||||
if (value && Number.isFinite(value.right) && Number.isFinite(value.bottom)) return clampAgentLauncherPosition(value, viewport, size);
|
||||
} catch {
|
||||
// Damaged UI preferences must not hide the launcher.
|
||||
}
|
||||
}
|
||||
return clampAgentLauncherPosition({ right: MARGIN, bottom: MARGIN }, viewport);
|
||||
return clampAgentLauncherPosition({ right: MARGIN, bottom: MARGIN }, viewport, size);
|
||||
}
|
||||
|
||||
export function moveAgentLauncher(gesture: AgentLauncherGesture, x: number, y: number, viewport: AgentLauncherViewport) {
|
||||
export function moveAgentLauncher(gesture: AgentLauncherGesture, x: number, y: number, viewport: AgentLauncherViewport, size?: AgentLauncherViewport) {
|
||||
const dx = x - gesture.x;
|
||||
const dy = y - gesture.y;
|
||||
const dragged = gesture.dragged || Math.hypot(dx, dy) >= 6;
|
||||
return {
|
||||
dragged,
|
||||
position: clampAgentLauncherPosition(dragged ? { right: gesture.start.right - dx, bottom: gesture.start.bottom - dy } : gesture.start, viewport),
|
||||
position: clampAgentLauncherPosition(dragged ? { right: gesture.start.right - dx, bottom: gesture.start.bottom - dy } : gesture.start, viewport, size),
|
||||
};
|
||||
}
|
||||
|
||||
21
web/src/lib/canvas/live2d-core-adapter.ts
Normal file
21
web/src/lib/canvas/live2d-core-adapter.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
type CoreModel = {
|
||||
drawables: { count: number; renderOrders?: Int32Array };
|
||||
offscreens?: { count: number };
|
||||
getRenderOrders?: () => Int32Array;
|
||||
};
|
||||
|
||||
// Cubism 5.3 moved render orders to Model and added offscreen render objects.
|
||||
// Adapt only drawable-only models: the Cubism 4 renderer cannot draw offscreens.
|
||||
export function adaptLive2DRenderOrders(model: { getModel: () => CoreModel; getDrawableRenderOrders: () => Int32Array }) {
|
||||
const core = model.getModel();
|
||||
if (core.offscreens?.count) throw new Error("此模型使用新版离屏渲染,当前 Live2D 渲染器暂不支持");
|
||||
if (core.drawables.renderOrders) return;
|
||||
if (!core.getRenderOrders) throw new Error("Live2D Core 缺少绘制顺序接口,请检查运行库版本");
|
||||
const getOrders = () => {
|
||||
const orders = core.getRenderOrders!();
|
||||
if (orders.length !== core.drawables.count) throw new Error("Live2D 模型绘制顺序数量不匹配");
|
||||
return orders;
|
||||
};
|
||||
getOrders();
|
||||
model.getDrawableRenderOrders = getOrders;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export default function StorageResourcesPanel() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const keyword = searchParams.get("filter") || "";
|
||||
const kind = normalizeOption(searchParams.get("kind"), ["image", "video", "audio", "file"]);
|
||||
const kind = normalizeOption(searchParams.get("kind"), ["image", "video", "audio", "file", "live2d"]);
|
||||
const status = normalizeOption(searchParams.get("status"), ["pending", "ready", "failed", "deleted"]);
|
||||
const provider = normalizeOption(searchParams.get("provider"), ["local", "aliyun", "tencent", "qiniu", "s3"]);
|
||||
const userId = searchParams.get("userId") || "";
|
||||
@@ -317,6 +317,7 @@ const kindOptions = [
|
||||
{ label: "视频", value: "video" },
|
||||
{ label: "音频", value: "audio" },
|
||||
{ label: "文件", value: "file" },
|
||||
{ label: "Live2D 模型", value: "live2d" },
|
||||
];
|
||||
const statusOptions = [
|
||||
{ label: "全部状态", value: "all" },
|
||||
@@ -349,7 +350,7 @@ function fileName(objectKey: string) {
|
||||
return objectKey.split("/").filter(Boolean).at(-1) || "";
|
||||
}
|
||||
function kindLabel(kind: string) {
|
||||
return ({ image: "图片", video: "视频", audio: "音频", file: "文件" } as Record<string, string>)[kind] || kind || "未知";
|
||||
return ({ image: "图片", video: "视频", audio: "音频", file: "文件", live2d: "Live2D" } as Record<string, string>)[kind] || kind || "未知";
|
||||
}
|
||||
function statusLabel(status: string) {
|
||||
return ({ pending: "待处理", ready: "已就绪", failed: "失败", deleted: "已删除" } as Record<string, string>)[status] || status || "未知";
|
||||
|
||||
@@ -10,6 +10,8 @@ import { cn } from "@/lib/utils";
|
||||
import { cloneSkinDefinition, DEFAULT_CLASSIC_SKIN, duplicateSkinDefinition, isSkinButtonFill, normalizeSkinDefinition, type SkinDefinition } from "@/lib/skin-themes";
|
||||
import { SkinThemeEditor } from "@/pages/admin/settings/components/skin-theme-editor";
|
||||
import { WelcomeSetting } from "@/pages/admin/settings/components/welcome-setting";
|
||||
import { CanvasAppearanceEditor } from "./components/canvas-appearance-editor";
|
||||
import { DEFAULT_CANVAS_APPEARANCE, type CanvasAppearance } from "@/lib/canvas/agent-appearance";
|
||||
import { deleteAdminResources } from "@/services/api/admin-storage";
|
||||
import { getAdminAppearance, resetAdminAppearance, updateAdminAppearance, uploadAppearanceAsset, type AdminAppearance, type AppearanceAssetSlot } from "@/services/api/appearance";
|
||||
import { commitPublicAppearance, DEFAULT_PUBLIC_APPEARANCE } from "@/stores/use-appearance-store";
|
||||
@@ -31,6 +33,8 @@ export default function AppearanceSettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState("brand");
|
||||
const [setting, setSetting] = useState<AdminAppearance | null>(null);
|
||||
const [brandName, setBrandName] = useState("");
|
||||
const [canvas, setCanvas] = useState<CanvasAppearance>(DEFAULT_CANVAS_APPEARANCE);
|
||||
const [canvasUploading, setCanvasUploading] = useState(false);
|
||||
const [brandSlug, setBrandSlug] = useState("");
|
||||
const [authHeroTitle, setAuthHeroTitle] = useState("");
|
||||
const [authHeroDescription, setAuthHeroDescription] = useState("");
|
||||
@@ -61,7 +65,7 @@ export default function AppearanceSettingsPage() {
|
||||
|
||||
const dirty =
|
||||
Boolean(setting) &&
|
||||
(brandName.trim() !== setting?.brandName ||
|
||||
(JSON.stringify(canvas) !== JSON.stringify(setting?.canvas || DEFAULT_CANVAS_APPEARANCE) || canvasUploading || brandName.trim() !== setting?.brandName ||
|
||||
brandSlug.trim().toLocaleLowerCase() !== setting?.brandSlug ||
|
||||
normalizeDraftCopy(authHeroTitle) !== setting?.authHeroTitle ||
|
||||
normalizeDraftCopy(authHeroDescription) !== setting?.authHeroDescription ||
|
||||
@@ -84,6 +88,7 @@ export default function AppearanceSettingsPage() {
|
||||
const selectedID = themes.some((theme) => theme.id === value.skinId) ? value.skinId : "classic";
|
||||
setSetting({ ...value, skinThemes: themes, skinId: selectedID });
|
||||
setBrandName(value.brandName);
|
||||
setCanvas(value.canvas || DEFAULT_CANVAS_APPEARANCE);
|
||||
setBrandSlug(value.brandSlug);
|
||||
setAuthHeroTitle(value.authHeroTitle);
|
||||
setAuthHeroDescription(value.authHeroDescription);
|
||||
@@ -136,7 +141,7 @@ export default function AppearanceSettingsPage() {
|
||||
if (blocker.state !== "blocked") return;
|
||||
modal.confirm({
|
||||
title: "放弃站点及外观调整?",
|
||||
content: "当前品牌、SEO、备案、皮肤或媒体配置尚未保存,离开后草稿会丢失。线上站点不会改变。",
|
||||
content: "当前品牌、画布 Agent、SEO、备案、皮肤或媒体配置尚未保存,离开后草稿会丢失。线上站点不会改变。",
|
||||
okText: "放弃并离开",
|
||||
cancelText: "继续编辑",
|
||||
okButtonProps: { danger: true },
|
||||
@@ -191,7 +196,8 @@ export default function AppearanceSettingsPage() {
|
||||
};
|
||||
|
||||
const discardDraft = () => {
|
||||
if (!setting || saving || restoring) return;
|
||||
if (!setting || saving || restoring || canvasUploading) return;
|
||||
setCanvas(setting.canvas || DEFAULT_CANVAS_APPEARANCE);
|
||||
setBrandName(setting.brandName);
|
||||
setBrandSlug(setting.brandSlug);
|
||||
setAuthHeroTitle(setting.authHeroTitle);
|
||||
@@ -215,13 +221,14 @@ export default function AppearanceSettingsPage() {
|
||||
};
|
||||
|
||||
const requestRefresh = () => {
|
||||
if (canvasUploading) return;
|
||||
if (!dirty) {
|
||||
void load(false);
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: "放弃调整并重新读取?",
|
||||
content: "重新读取会丢弃当前品牌、SEO、备案、皮肤和待上传文件。",
|
||||
content: "重新读取会丢弃当前品牌、画布 Agent、SEO、备案、皮肤和待上传文件。",
|
||||
okText: "放弃并刷新",
|
||||
cancelText: "继续编辑",
|
||||
okButtonProps: { danger: true },
|
||||
@@ -230,10 +237,10 @@ export default function AppearanceSettingsPage() {
|
||||
};
|
||||
|
||||
const restoreBuiltInAppearance = () => {
|
||||
if (!setting?.configured || saving || refreshing || restoring) return;
|
||||
if (!setting?.configured || saving || refreshing || restoring || canvasUploading) return;
|
||||
modal.confirm({
|
||||
title: "恢复影策默认品牌标识?",
|
||||
content: "品牌名称、英文标识、Logo、登录页文案、视频、封面、SEO、备案和皮肤主题会立即恢复为项目内置值。已上传文件仍保留在存储资源中,不会被删除。",
|
||||
content: "品牌名称、英文标识、Logo、画布 Agent 名称/文案/形象、登录页文案、视频、封面、SEO、备案和皮肤主题会立即恢复为项目内置值。已上传文件仍保留在存储资源中,不会被删除。",
|
||||
okText: "恢复默认",
|
||||
cancelText: "取消",
|
||||
okButtonProps: { danger: true },
|
||||
@@ -259,7 +266,12 @@ export default function AppearanceSettingsPage() {
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!setting || saving || restoring) return;
|
||||
if (!setting || saving || restoring || canvasUploading) return;
|
||||
if (![canvas.agentName, canvas.panelTitle, canvas.welcomeTitle, canvas.inputPlaceholder].every((text) => text.trim())) {
|
||||
setActiveTab("canvas");
|
||||
message.error("请填写助手名称、面板标题、欢迎标题和输入框提示");
|
||||
return;
|
||||
}
|
||||
const nextBrandName = brandName.trim();
|
||||
const nextBrandSlug = brandSlug.trim().toLocaleLowerCase();
|
||||
const nextAuthHeroTitle = normalizeDraftCopy(authHeroTitle);
|
||||
@@ -330,6 +342,7 @@ export default function AppearanceSettingsPage() {
|
||||
ids[slot] = resource.id;
|
||||
}
|
||||
const updated = await updateAdminAppearance({
|
||||
canvas,
|
||||
brandName: nextBrandName,
|
||||
brandSlug: nextBrandSlug,
|
||||
authHeroTitle: nextAuthHeroTitle,
|
||||
@@ -443,17 +456,17 @@ export default function AppearanceSettingsPage() {
|
||||
</div>
|
||||
<div className="admin-appearance-command-actions">
|
||||
{dirty ? (
|
||||
<Button icon={<Undo2 className="size-4" />} disabled={saving || restoring} onClick={discardDraft}>
|
||||
<Button icon={<Undo2 className="size-4" />} disabled={saving || restoring || canvasUploading} onClick={discardDraft}>
|
||||
撤销调整
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<RotateCcw className="size-4" />} loading={restoring} disabled={!setting.configured || saving || refreshing} onClick={restoreBuiltInAppearance}>
|
||||
<Button icon={<RotateCcw className="size-4" />} loading={restoring} disabled={!setting.configured || saving || refreshing || canvasUploading} onClick={restoreBuiltInAppearance}>
|
||||
恢复影策默认
|
||||
</Button>
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} disabled={saving || restoring} onClick={requestRefresh}>
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} disabled={saving || restoring || canvasUploading} onClick={requestRefresh}>
|
||||
刷新状态
|
||||
</Button>
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || refreshing || restoring} onClick={() => void save()}>
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || refreshing || restoring || canvasUploading} onClick={() => void save()}>
|
||||
保存修改
|
||||
</Button>
|
||||
</div>
|
||||
@@ -754,6 +767,7 @@ export default function AppearanceSettingsPage() {
|
||||
</SettingsSectionCard>
|
||||
),
|
||||
},
|
||||
{ key: "canvas", label: "画布配置", children: <CanvasAppearanceEditor value={canvas} onChange={setCanvas} disabled={saving || refreshing || restoring} onUploading={setCanvasUploading} /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Alert, App, Button, Form, Input, InputNumber, Radio } from "antd";
|
||||
import { ExternalLink, Upload } from "lucide-react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { Live2DAvatar } from "@/components/canvas/live2d-avatar";
|
||||
import { FluidOrb } from "@/components/ui/fluid-orb";
|
||||
import { agentCopy, type CanvasAppearance } from "@/lib/canvas/agent-appearance";
|
||||
import { live2DModelURL, uploadLive2D } from "@/services/api/appearance";
|
||||
|
||||
export function CanvasAppearanceEditor({ value, onChange, disabled, onUploading }: { value: CanvasAppearance; onChange: (value: CanvasAppearance) => void; disabled: boolean; onUploading: (value: boolean) => void }) {
|
||||
const { message } = App.useApp();
|
||||
const reducedMotion = useReducedMotion() ?? false;
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [readyURL, setReadyURL] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const url = value.live2dResourceId ? live2DModelURL(value.live2dResourceId, value.live2dEntry, true) : "";
|
||||
const change = (patch: Partial<CanvasAppearance>) => onChange({ ...value, ...patch });
|
||||
async function upload(file?: File) {
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith(".zip") || file.size > 128 * 1024 * 1024) {
|
||||
message.error("请选择不超过 128 MiB 的 Live2D ZIP");
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
onUploading(true);
|
||||
setError("");
|
||||
try {
|
||||
const model = await uploadLive2D(file);
|
||||
setReadyURL("");
|
||||
change({ live2dResourceId: model.resourceId, live2dEntry: model.entry, avatarType: "orb" });
|
||||
message.success("导入成功,请预览后选择 Live2D,并点击页面顶部保存");
|
||||
} catch (cause) {
|
||||
message.error(cause instanceof Error ? cause.message : "模型导入失败");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
onUploading(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">Agent 身份与文案</h3>
|
||||
<p className="mb-4 text-sm text-foreground/60">独立于站点品牌。文案可使用 {"{agentName}"} 引用助手名称;这里只配置展示文字,不修改工具权限。</p>
|
||||
<Form layout="vertical" disabled={disabled || uploading}>
|
||||
{(
|
||||
[
|
||||
["agentName", "助手名称", 24],
|
||||
["launcherLabel", "悬浮入口文字(可留空)", 24],
|
||||
["panelTitle", "对话面板标题", 40],
|
||||
["welcomeTitle", "欢迎标题", 100],
|
||||
["welcomeDescription", "欢迎说明", 200],
|
||||
["inputPlaceholder", "输入框提示", 160],
|
||||
] as const
|
||||
).map(([key, label, max]) => (
|
||||
<Form.Item key={key} label={label}>
|
||||
<Input value={value[key]} maxLength={max} showCount onChange={(event) => change({ [key]: event.target.value })} />
|
||||
</Form.Item>
|
||||
))}
|
||||
</Form>
|
||||
</div>
|
||||
<div className="grid content-start gap-4">
|
||||
<h3 className="font-semibold">Agent 形象</h3>
|
||||
<Radio.Group
|
||||
value={value.avatarType}
|
||||
disabled={disabled || uploading}
|
||||
onChange={(event) => change({ avatarType: event.target.value })}
|
||||
options={[
|
||||
{ label: "默认动态球", value: "orb" },
|
||||
{ label: "Live2D", value: "live2d", disabled: !url || readyURL !== url },
|
||||
]}
|
||||
/>
|
||||
<p className="text-sm text-foreground/60">上传有授权的 Cubism 3/4 运行时 ZIP:一个 model3.json、moc3、PNG 纹理及可选动作/表情 JSON。首期不支持音频、旧版模型和编辑工程。模型会发送到浏览器,无法保证防下载。</p>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span className="text-foreground/60">参考资料</span>
|
||||
<a href="https://www.live2d.com/en/learn/sample/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 underline underline-offset-4">
|
||||
官方示例模型库
|
||||
<ExternalLink className="size-3.5" aria-hidden="true" />
|
||||
<span className="sr-only">(在新标签页打开)</span>
|
||||
</a>
|
||||
<a href="https://docs.live2d.com/en/cubism-sdk-manual/cubism-sdk-for-web/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 underline underline-offset-4">
|
||||
Cubism Web SDK 文档
|
||||
<ExternalLink className="size-3.5" aria-hidden="true" />
|
||||
<span className="sr-only">(在新标签页打开)</span>
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-xs text-foreground/60">下载前请确认素材授权;官方原始包需按上述要求整理为运行时 ZIP,不能直接整包导入。</p>
|
||||
</div>
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void upload(event.target.files?.[0]);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button icon={<Upload className="size-4" />} loading={uploading} disabled={disabled} onClick={() => input.current?.click()}>
|
||||
导入 Live2D 模型(最大 128 MiB)
|
||||
</Button>
|
||||
{url ? (
|
||||
<Button
|
||||
disabled={disabled || uploading}
|
||||
onClick={() => {
|
||||
change({ avatarType: "orb", live2dResourceId: "", live2dEntry: "" });
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
移除模型引用
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
形象高度
|
||||
<InputNumber aria-label="形象高度" min={120} max={360} value={value.avatarHeight} disabled={disabled || uploading} onChange={(height) => height !== null && change({ avatarHeight: height })} />
|
||||
px
|
||||
</label>
|
||||
<div className="grid justify-items-center gap-3 rounded-xl border border-border bg-background p-5" aria-label="Agent 形象预览">
|
||||
{url ? (
|
||||
<Live2DAvatar
|
||||
key={`${url}-${attempt}`}
|
||||
url={url}
|
||||
width={Math.round(value.avatarHeight * 0.75)}
|
||||
height={value.avatarHeight}
|
||||
reducedMotion={reducedMotion}
|
||||
fallback={<FluidOrb size={62} color="#7164f6" />}
|
||||
onReady={() => {
|
||||
setReadyURL(url);
|
||||
setError("");
|
||||
}}
|
||||
onError={(reason) => {
|
||||
setReadyURL("");
|
||||
setError(reason);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FluidOrb size={62} color="#7164f6" />
|
||||
)}
|
||||
<strong>{agentCopy(value.welcomeTitle, value.agentName)}</strong>
|
||||
<p className="text-sm text-foreground/60">{agentCopy(value.welcomeDescription, value.agentName)}</p>
|
||||
{url ? <small>{readyURL === url ? "预览已就绪,可选择 Live2D 并保存启用" : "等待模型预览就绪"}</small> : null}
|
||||
</div>
|
||||
{error ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
title="Live2D 暂不可用"
|
||||
description={error}
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setError("");
|
||||
setAttempt((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<p className="text-xs text-foreground/60">部署者需自行确认 Live2D 授权并安装 Cubism Core 至 /live2d/live2dcubismcore.min.js。未安装时名称、文案和默认动态球仍可使用。未启用的导入包可在资源管理中清理。</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { http } from "@/services/api/request";
|
||||
import type { SkinDefinition } from "@/lib/skin-themes";
|
||||
import type { CanvasAppearance } from "@/lib/canvas/agent-appearance";
|
||||
import { apiBaseURL } from "@/services/api/request";
|
||||
|
||||
export type PublicAppearance = {
|
||||
canvas?: CanvasAppearance;
|
||||
schemaVersion: number;
|
||||
brandName: string;
|
||||
brandSlug: string;
|
||||
@@ -31,6 +34,7 @@ export type PublicAppearance = {
|
||||
};
|
||||
|
||||
export type AdminAppearance = {
|
||||
canvas?: CanvasAppearance;
|
||||
schemaVersion: number;
|
||||
brandName: string;
|
||||
brandSlug: string;
|
||||
@@ -81,6 +85,7 @@ export async function updateAdminAppearance(
|
||||
input: Pick<
|
||||
AdminAppearance,
|
||||
| "brandName"
|
||||
| "canvas"
|
||||
| "brandSlug"
|
||||
| "authHeroTitle"
|
||||
| "authHeroDescription"
|
||||
@@ -115,3 +120,13 @@ export async function uploadAppearanceAsset(slot: AppearanceAssetSlot, file: Fil
|
||||
const result = await http.post<{ resource: AppearanceResource }>(`/admin/settings/appearance/assets/${slot}`, body);
|
||||
return result.resource;
|
||||
}
|
||||
|
||||
export async function uploadLive2D(file: File) {
|
||||
const body = new FormData(); body.append("file", file);
|
||||
const result = await http.post<{ model: { resourceId: string; entry: string } }>("/admin/settings/appearance/live2d", body);
|
||||
return result.model;
|
||||
}
|
||||
|
||||
export function live2DModelURL(resourceId: string, entry: string, preview = false) {
|
||||
return `${apiBaseURL.replace(/\/$/, "")}/${preview ? "admin/settings" : "public"}/appearance/live2d/${encodeURIComponent(resourceId)}/${entry.split("/").map(encodeURIComponent).join("/")}`;
|
||||
}
|
||||
|
||||
29
web/src/services/live2d-loader.ts
Normal file
29
web/src/services/live2d-loader.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Live2DLoader as Loader } from "pixi-live2d-display/cubism4";
|
||||
import { apiBaseURL } from "@/services/api/request";
|
||||
|
||||
const installed = new WeakSet<typeof Loader>();
|
||||
|
||||
// The SDK's default XHR loader omits cross-origin cookies and has no deadline.
|
||||
// Restrict credentialed requests to our own model API, never model-provided hosts.
|
||||
export function installLive2DLoader(loader: typeof Loader) {
|
||||
if (installed.has(loader)) return;
|
||||
installed.add(loader);
|
||||
loader.middlewares.unshift(async (context) => {
|
||||
const base = new URL(`${apiBaseURL.replace(/\/$/, "")}/`, window.location.href);
|
||||
const url = new URL(context.settings ? context.settings.resolveURL(context.url) : context.url, window.location.href);
|
||||
const allowed = ["public", "admin/settings"].some((prefix) => url.pathname.startsWith(`${base.pathname}${prefix}/appearance/live2d/`));
|
||||
if (url.origin !== base.origin || !allowed) throw new Error("Live2D 资源必须来自本站模型接口");
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
const deadline = setTimeout(abort, 20000);
|
||||
context.target?.once("destroy", abort);
|
||||
try {
|
||||
const response = await fetch(url, { credentials: "include", signal: controller.signal });
|
||||
if (!response.ok) throw new Error(`Live2D 资源加载失败(${response.status})`);
|
||||
context.result = context.type === "json" ? await response.json() : await response.arrayBuffer();
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
context.target?.off("destroy", abort);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { create } from "zustand";
|
||||
import { DEFAULT_CANVAS_APPEARANCE } from "@/lib/canvas/agent-appearance";
|
||||
|
||||
import type { PublicAppearance } from "@/services/api/appearance";
|
||||
import { applySkinTheme, DEFAULT_CLASSIC_SKIN, normalizeSkinDefinition } from "@/lib/skin-themes";
|
||||
|
||||
export const DEFAULT_PUBLIC_APPEARANCE: PublicAppearance = {
|
||||
schemaVersion: 8,
|
||||
canvas: DEFAULT_CANVAS_APPEARANCE,
|
||||
schemaVersion: 9,
|
||||
brandName: "影策",
|
||||
brandSlug: "open-ai-canvas",
|
||||
authHeroTitle: "让一个故事,\n从文字走向银幕。",
|
||||
@@ -60,7 +62,8 @@ export function normalizePublicAppearance(value?: Partial<PublicAppearance> | nu
|
||||
return {
|
||||
...DEFAULT_PUBLIC_APPEARANCE,
|
||||
...value,
|
||||
schemaVersion: 8,
|
||||
schemaVersion: 9,
|
||||
canvas: { ...DEFAULT_CANVAS_APPEARANCE, ...value?.canvas },
|
||||
brandName: resolvedBrandName,
|
||||
brandSlug,
|
||||
authHeroTitle,
|
||||
|
||||
Reference in New Issue
Block a user