feat(*): 画布助手 - 新增 Live2D 配置导入与模型渲染支持

This commit is contained in:
ddCat
2026-09-21 14:11:29 +08:00
parent 4f9e79f67f
commit 4e0b0a9256
40 changed files with 1668 additions and 59 deletions

View File

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

View File

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

View File

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

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

View File

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