From c5b81f696ca5af4022b49d23ffde85ec7ea2f69a Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Mon, 21 Sep 2026 14:11:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(*):=20=E7=94=BB=E5=B8=83=E5=8A=A9=E6=89=8B?= =?UTF-8?q?=20-=20=E6=96=B0=E5=A2=9E=20Live2D=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E4=B8=8E=E6=A8=A1=E5=9E=8B=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + backend/internal/app/admin_storage.go | 2 +- backend/internal/app/appearance.go | 19 +- backend/internal/app/appearance_canvas.go | 64 ++++ backend/internal/app/appearance_live2d.go | 294 ++++++++++++++++++ .../internal/app/appearance_live2d_test.go | 252 +++++++++++++++ backend/internal/app/resource.go | 5 +- backend/internal/handler/appearance.go | 1 + backend/internal/handler/appearance_live2d.go | 72 +++++ .../handler/appearance_routes_test.go | 15 +- backend/internal/handler/openapi.yaml | 62 ++++ backend/internal/service/aliases_consts.go | 1 + .../content/docs/backend/backend-database.mdx | 6 + .../docs/backend/canvas-agent-appearance.mdx | 47 +++ docs/content/docs/backend/code-map.mdx | 7 + docs/content/docs/backend/http-api.mdx | 12 + docs/content/docs/backend/meta.json | 1 + docs/content/docs/overview/features.mdx | 4 + docs/content/docs/progress/pending-test.mdx | 10 + docs/index.md | 2 + web/bun.lock | 152 +++++++++ web/package.json | 2 + .../canvas/canvas-agent-welcome.tsx | 9 +- .../canvas/canvas-cloud-agent-panel.tsx | 37 ++- .../components/canvas/canvas-cloud-agent.css | 10 + web/src/components/canvas/live2d-avatar.tsx | 196 ++++++++++++ .../canvas/use-agent-launcher-position.ts | 18 +- web/src/lib/canvas/agent-appearance.ts | 29 ++ web/src/lib/canvas/agent-launcher-position.ts | 18 +- web/src/lib/canvas/live2d-core-adapter.ts | 21 ++ .../components/storage-resources-panel.tsx | 5 +- .../settings/appearance-settings-page.tsx | 36 ++- .../components/canvas-appearance-editor.tsx | 170 ++++++++++ web/src/services/api/appearance.ts | 15 + web/src/services/live2d-loader.ts | 29 ++ web/src/stores/use-appearance-store.ts | 7 +- web/test/agent-launcher-position.test.mjs | 7 + web/test/agent-panel-presentation.test.tsx | 11 +- web/test/live2d-core-adapter.test.ts | 28 ++ web/test/live2d-loader.test.ts | 47 +++ 40 files changed, 1668 insertions(+), 59 deletions(-) create mode 100644 backend/internal/app/appearance_canvas.go create mode 100644 backend/internal/app/appearance_live2d.go create mode 100644 backend/internal/app/appearance_live2d_test.go create mode 100644 backend/internal/handler/appearance_live2d.go create mode 100644 docs/content/docs/backend/canvas-agent-appearance.mdx create mode 100644 web/src/components/canvas/live2d-avatar.tsx create mode 100644 web/src/lib/canvas/agent-appearance.ts create mode 100644 web/src/lib/canvas/live2d-core-adapter.ts create mode 100644 web/src/pages/admin/settings/components/canvas-appearance-editor.tsx create mode 100644 web/src/services/live2d-loader.ts create mode 100644 web/test/live2d-core-adapter.test.ts create mode 100644 web/test/live2d-loader.test.ts diff --git a/.gitignore b/.gitignore index c7798020c..bb4bc2f8d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ docs/content/docs/plugins/* docs/content/docs/backend/* !docs/content/docs/backend/task-runtime-stability.mdx !docs/content/docs/backend/agent-prompt-policy.mdx +!docs/content/docs/backend/canvas-agent-appearance.mdx !docs/content/docs/backend/http-api.mdx !docs/content/docs/backend/code-map.mdx !docs/content/docs/backend/newapi-channel-2.mdx @@ -72,3 +73,6 @@ camera_language_guide.md backend/cmd/server/server # One-off local probe scripts and their compiled output. backend/tmp_* +# Deployment-owned proprietary Live2D Core; never redistribute in this repository. +/web/public/live2d/live2dcubismcore.min.js +/web/public/live2d/live2dcubismcore.min.js.map diff --git a/backend/internal/app/admin_storage.go b/backend/internal/app/admin_storage.go index ade2f9030..54d13c7e9 100644 --- a/backend/internal/app/admin_storage.go +++ b/backend/internal/app/admin_storage.go @@ -138,7 +138,7 @@ func normalizeAdminResourceQuery(query AdminResourceQuery) (repository.AdminReso Limit: limit, Offset: (page - 1) * limit, } - if filter.Kind != "" && !oneOf(filter.Kind, "image", "video", "audio", "file") { + if filter.Kind != "" && !oneOf(filter.Kind, "image", "video", "audio", "file", "live2d") { return repository.AdminResourceFilter{}, 0, 0, BadAuthRequest("资源类型筛选无效") } if filter.Status != "" && !oneOf(filter.Status, string(model.ResourceStatusPending), string(model.ResourceStatusReady), string(model.ResourceStatusFailed), string(model.ResourceStatusDeleted)) { diff --git a/backend/internal/app/appearance.go b/backend/internal/app/appearance.go index 6cc40b730..25a54397e 100644 --- a/backend/internal/app/appearance.go +++ b/backend/internal/app/appearance.go @@ -31,7 +31,7 @@ const ( ) const ( - appearanceSchemaVersion = 8 + appearanceSchemaVersion = 9 appearanceLogoMaxBytes int64 = 5 << 20 appearancePosterMaxBytes int64 = 10 << 20 appearanceVideoMaxBytes int64 = 256 << 20 @@ -48,6 +48,7 @@ const ( ) type AppearanceSetting struct { + Canvas CanvasAppearance `json:"canvas"` SchemaVersion int `json:"schemaVersion"` BrandName string `json:"brandName"` BrandSlug string `json:"brandSlug"` @@ -70,6 +71,7 @@ type AppearanceSetting struct { } type PublicAppearanceSetting struct { + Canvas CanvasAppearance `json:"canvas"` SchemaVersion int `json:"schemaVersion"` BrandName string `json:"brandName"` BrandSlug string `json:"brandSlug"` @@ -109,6 +111,7 @@ type AdminAppearanceSetting struct { func defaultAppearanceSetting() AppearanceSetting { return AppearanceSetting{ + Canvas: defaultCanvasAppearance(), SchemaVersion: appearanceSchemaVersion, BrandName: defaultAppearanceBrandName, BrandSlug: defaultAppearanceBrandSlug, @@ -169,6 +172,11 @@ func (s *Service) UpdateAppearance(actor *model.User, value AppearanceSetting) ( return nil, err } value.SchemaVersion = appearanceSchemaVersion + canvas, canvasErr := normalizeCanvasAppearance(value.Canvas) + if canvasErr != nil { + return nil, canvasErr + } + value.Canvas = canvas value.BrandName = strings.TrimSpace(value.BrandName) value.BrandSlug = strings.ToLower(strings.TrimSpace(value.BrandSlug)) value.AuthHeroTitle = normalizeAppearanceCopy(value.AuthHeroTitle) @@ -197,6 +205,13 @@ func (s *Service) UpdateAppearance(actor *model.User, value AppearanceSetting) ( if err != nil { return nil, err } + if value.Canvas.Live2DResourceID != "" { + entry, err := s.validateLive2DResource(actor, value.Canvas.Live2DResourceID, before.Canvas.Live2DResourceID) + if err != nil { + return nil, err + } + value.Canvas.Live2DEntry = entry + } for _, candidate := range []struct { slot string resourceID string @@ -321,6 +336,7 @@ func (s *Service) appearanceResourceReferences(resourceIDs []string) map[string] {resourceID: value.DarkLogoResourceID, title: "深色模式品牌 Logo"}, {resourceID: value.AuthVideoResourceID, title: "登录页品牌视频"}, {resourceID: value.AuthVideoPosterResourceID, title: "登录页视频封面"}, + {resourceID: value.Canvas.Live2DResourceID, title: "画布 Agent Live2D 形象"}, } wanted := make(map[string]struct{}, len(resourceIDs)) for _, resourceID := range resourceIDs { @@ -615,6 +631,7 @@ func publicAppearanceSetting(setting *model.SystemSetting, value AppearanceSetti revision = strconv.FormatInt(setting.UpdatedAt.UTC().UnixNano(), 36) } result := &PublicAppearanceSetting{ + Canvas: value.Canvas, SchemaVersion: appearanceSchemaVersion, BrandName: value.BrandName, BrandSlug: value.BrandSlug, diff --git a/backend/internal/app/appearance_canvas.go b/backend/internal/app/appearance_canvas.go new file mode 100644 index 000000000..4d9d08170 --- /dev/null +++ b/backend/internal/app/appearance_canvas.go @@ -0,0 +1,64 @@ +package app + +import ( + "strings" + "unicode" +) + +// CanvasAppearance is presentation-only; no free-form system prompts or tool permissions. +type CanvasAppearance struct { + AgentName string `json:"agentName"` + LauncherLabel string `json:"launcherLabel"` + PanelTitle string `json:"panelTitle"` + WelcomeTitle string `json:"welcomeTitle"` + WelcomeDescription string `json:"welcomeDescription"` + InputPlaceholder string `json:"inputPlaceholder"` + AvatarType string `json:"avatarType"` + Live2DResourceID string `json:"live2dResourceId"` + Live2DEntry string `json:"live2dEntry"` + AvatarHeight int `json:"avatarHeight"` +} + +func defaultCanvasAppearance() CanvasAppearance { + return CanvasAppearance{AgentName: "影策", LauncherLabel: "Agent", PanelTitle: "画布助手", WelcomeTitle: "在这里,和{agentName}让灵感,慢慢成形", WelcomeDescription: "从一个想法开始,和{agentName}一起创作。", InputPlaceholder: "输入操作指导;用 @ 引用画布节点,用 / 或 、 引用 Skills", AvatarType: "orb", AvatarHeight: 220} +} + +func normalizeCanvasAppearance(value CanvasAppearance) (CanvasAppearance, error) { + // Older API clients have no canvas object; keep their default presentation valid. + if value == (CanvasAppearance{}) { + return defaultCanvasAppearance(), nil + } + for _, field := range []struct { + value *string + label string + max int + required bool + }{ + {&value.AgentName, "助手名称", 24, true}, {&value.LauncherLabel, "入口文字", 24, false}, + {&value.PanelTitle, "面板标题", 40, true}, {&value.WelcomeTitle, "欢迎标题", 100, true}, + {&value.WelcomeDescription, "欢迎说明", 200, false}, {&value.InputPlaceholder, "输入提示", 160, true}, + } { + *field.value = strings.TrimSpace(*field.value) + if err := validateAppearanceCopy(*field.value, field.label, field.max, field.required); err != nil { + return value, err + } + } + for _, ch := range value.AgentName { + if unicode.IsControl(ch) { + return value, BadAuthRequest("助手名称不能包含控制字符") + } + } + if value.AvatarType != "orb" && value.AvatarType != "live2d" { + return value, BadAuthRequest("请选择有效的 Agent 形象类型") + } + if value.AvatarHeight < 120 || value.AvatarHeight > 360 { + return value, BadAuthRequest("形象高度须在 120–360 之间") + } + if value.AvatarType == "live2d" && value.Live2DResourceID == "" { + return value, BadAuthRequest("请先导入 Live2D 模型") + } + if value.Live2DResourceID == "" { + value.Live2DEntry = "" + } + return value, nil +} diff --git a/backend/internal/app/appearance_live2d.go b/backend/internal/app/appearance_live2d.go new file mode 100644 index 000000000..0b11a8fb2 --- /dev/null +++ b/backend/internal/app/appearance_live2d.go @@ -0,0 +1,294 @@ +package app + +import ( + "archive/zip" + "bytes" + "encoding/json" + "image/png" + "io" + "mime/multipart" + "path" + "strings" + "unicode" + + "infinite-canvas/backend/internal/model" +) + +const Live2DMaxBytes int64 = 128 << 20 +const live2DMaxExpanded uint64 = 384 << 20 +const live2DMaxFileBytes int64 = 128 << 20 + +type Live2DImport struct { + ResourceID string `json:"resourceId"` + Entry string `json:"entry"` +} +type live2DNamedFile struct { + Name string `json:"Name"` + File string `json:"File"` +} +type live2DMotion struct { + File string `json:"File"` + Sound string `json:"Sound"` + FadeInTime float64 `json:"FadeInTime"` + FadeOutTime float64 `json:"FadeOutTime"` +} +type live2DReferences struct { + Moc string `json:"Moc"` + Textures []string `json:"Textures"` + Physics string `json:"Physics"` + Pose string `json:"Pose"` + DisplayInfo string `json:"DisplayInfo"` + UserData string `json:"UserData"` + Expressions []live2DNamedFile `json:"Expressions"` + Motions map[string][]live2DMotion `json:"Motions"` +} + +func live2DFileName(name string) bool { + return name != "" && len(name) <= 240 && path.Clean(name) == name && !strings.HasPrefix(name, "/") && name != ".." && !strings.HasPrefix(name, "../") && !strings.ContainsAny(name, "\\:%?#") && strings.IndexFunc(name, unicode.IsControl) == -1 +} + +func readLive2DFile(file *zip.File, max int64) ([]byte, error) { + if file.UncompressedSize64 > uint64(max) { + return nil, BadAuthRequest("Live2D 文件超过大小限制") + } + r, err := file.Open() + if err != nil { + return nil, err + } + defer r.Close() + data, err := io.ReadAll(io.LimitReader(r, max+1)) + if err != nil { + return nil, BadAuthRequest("Live2D 文件损坏") + } + if int64(len(data)) > max { + return nil, BadAuthRequest("Live2D 文件超过大小限制") + } + return data, nil +} + +// Never extract uploaded paths to disk. Only validated data files are served from the archive. +func validateLive2DArchive(reader io.ReaderAt, size int64) (string, error) { + if size <= 0 || size > Live2DMaxBytes { + return "", BadAuthRequest("Live2D ZIP 须为非空文件且不超过 128 MiB") + } + z, err := zip.NewReader(reader, size) + if err != nil { + return "", BadAuthRequest("无法读取 Live2D ZIP") + } + if len(z.File) > 256 { + return "", BadAuthRequest("Live2D ZIP 最多 256 个条目") + } + files := map[string]*zip.File{} + folded := map[string]bool{} + entry := "" + var total uint64 + var texturePixels int64 + for _, f := range z.File { + name := strings.TrimSuffix(f.Name, "/") + if !live2DFileName(name) { + return "", BadAuthRequest("模型包包含非法路径") + } + if folded[strings.ToLower(name)] { + return "", BadAuthRequest("模型包包含重复路径") + } + folded[strings.ToLower(name)] = true + if f.FileInfo().IsDir() { + continue + } + if !f.Mode().IsRegular() { + return "", BadAuthRequest("模型包仅允许普通文件") + } + if f.UncompressedSize64 > uint64(live2DMaxFileBytes) || total > live2DMaxExpanded-f.UncompressedSize64 { + return "", BadAuthRequest("模型包解压内容超过限制") + } + total += f.UncompressedSize64 + ext := strings.ToLower(path.Ext(name)) + if ext != ".json" && ext != ".moc3" && ext != ".png" { + return "", BadAuthRequest("仅支持 JSON、moc3 和 PNG;请移除脚本、音频及其他文件") + } + data, err := readLive2DFile(f, live2DMaxFileBytes) + if err != nil { + return "", err + } + if ext == ".json" && (len(data) > 2<<20 || !json.Valid(data)) { + return "", BadAuthRequest("模型 JSON 无效或超过 2 MiB") + } + if ext == ".moc3" && (len(data) < 4 || string(data[:4]) != "MOC3") { + return "", BadAuthRequest("模型 moc3 文件无效") + } + if ext == ".png" { + cfg, err := png.DecodeConfig(bytes.NewReader(data)) + if err != nil || cfg.Width > 4096 || cfg.Height > 4096 { + return "", BadAuthRequest("纹理须为有效 PNG,且不超过 4096×4096") + } + texturePixels += int64(cfg.Width) * int64(cfg.Height) + if texturePixels > 32*1024*1024 { + return "", BadAuthRequest("模型纹理总像素超过 32 Mi,需压缩纹理") + } + } + files[name] = f + if strings.HasSuffix(name, ".model3.json") { + if entry != "" { + return "", BadAuthRequest("一个 ZIP 只能包含一个 model3.json") + } + entry = name + } + } + if entry == "" { + return "", BadAuthRequest("模型包缺少 model3.json 入口") + } + data, err := readLive2DFile(files[entry], 2<<20) + if err != nil { + return "", err + } + var manifest struct { + Version int `json:"Version"` + FileReferences json.RawMessage `json:"FileReferences"` + } + if json.Unmarshal(data, &manifest) != nil || manifest.Version != 3 { + return "", BadAuthRequest("仅支持 model3.json Version 3") + } + var refs live2DReferences + decoder := json.NewDecoder(bytes.NewReader(manifest.FileReferences)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&refs); err != nil { + return "", BadAuthRequest("模型资源引用格式不受支持") + } + if refs.Moc == "" || len(refs.Textures) == 0 || len(refs.Textures) > 8 { + return "", BadAuthRequest("模型须包含 moc3 和 1–8 张纹理") + } + check := func(name, suffix string, required bool) error { + if name == "" && !required { + return nil + } + if !live2DFileName(name) || !strings.HasSuffix(strings.ToLower(name), suffix) || files[path.Join(path.Dir(entry), name)] == nil { + return BadAuthRequest("模型资源缺失、路径非法或类型不符:" + name) + } + return nil + } + if err := check(refs.Moc, ".moc3", true); err != nil { + return "", err + } + for _, name := range refs.Textures { + if err := check(name, ".png", true); err != nil { + return "", err + } + } + for _, name := range []string{refs.Physics, refs.Pose, refs.DisplayInfo, refs.UserData} { + if err := check(name, ".json", false); err != nil { + return "", err + } + } + for _, expression := range refs.Expressions { + if err := check(expression.File, ".exp3.json", true); err != nil { + return "", err + } + } + for _, motions := range refs.Motions { + for _, motion := range motions { + if motion.Sound != "" { + return "", BadAuthRequest("首期不支持模型音频,请移除 Sound 引用") + } + if err := check(motion.File, ".motion3.json", true); err != nil { + return "", err + } + } + } + return entry, nil +} + +func (s *Service) UploadLive2D(actor *model.User, header *multipart.FileHeader) (*Live2DImport, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + if header == nil { + return nil, BadAuthRequest("请选择 Live2D ZIP") + } + f, err := header.Open() + if err != nil { + return nil, err + } + defer f.Close() + entry, err := validateLive2DArchive(f, header.Size) + if err != nil { + return nil, err + } + header.Header.Set("Content-Type", "application/zip") + resource, err := s.uploadLocalResource(actor.ID, header, "live2d", 0, 0, 0) + if err != nil { + return nil, err + } + return &Live2DImport{ResourceID: resource.ID, Entry: entry}, nil +} + +func (s *Service) validateLive2DResource(actor *model.User, id, currentID string) (string, error) { + resource, err := s.repo.Resource(id) + if err != nil || resource.Kind != "live2d" || resource.Provider != "local" || resource.Status != model.ResourceStatusReady { + return "", BadAuthRequest("Live2D 资源不存在或未就绪") + } + if id != currentID && resource.UserID != actor.ID { + return "", Forbidden("只能启用自己上传的模型") + } + _, body, err := s.OpenResource(resource.UserID, id) + if err != nil { + return "", err + } + defer body.Close() + reader, ok := body.(io.ReaderAt) + if !ok { + return "", BadAuthRequest("模型须存储在本地资源目录") + } + return validateLive2DArchive(reader, resource.Size) +} + +func (s *Service) Live2DAsset(actor *model.User, id, name string) ([]byte, string, error) { + if !live2DFileName(name) { + return nil, "", BadAuthRequest("模型资源路径无效") + } + _, appearance, err := s.readAppearance() + if err != nil { + return nil, "", err + } + resource, err := s.repo.Resource(id) + if err != nil || resource.Kind != "live2d" || resource.Provider != "local" || resource.Status != model.ResourceStatusReady { + return nil, "", BadAuthRequest("模型资源不可用") + } + if appearance.Canvas.AvatarType != "live2d" || appearance.Canvas.Live2DResourceID != id { + if err := s.RequireAdmin(actor); err != nil { + return nil, "", err + } + if resource.UserID != actor.ID && appearance.Canvas.Live2DResourceID != id { + return nil, "", Forbidden("不能预览其他管理员的未启用模型") + } + } + _, body, err := s.OpenResource(resource.UserID, id) + if err != nil { + return nil, "", err + } + defer body.Close() + reader, ok := body.(io.ReaderAt) + if !ok { + return nil, "", BadAuthRequest("模型资源不可读取") + } + z, err := zip.NewReader(reader, resource.Size) + if err != nil { + return nil, "", err + } + for _, f := range z.File { + if f.Name == name { + mime := "application/octet-stream" + switch strings.ToLower(path.Ext(name)) { + case ".json": + mime = "application/json" + case ".png": + mime = "image/png" + case ".moc3": + default: + return nil, "", BadAuthRequest("不支持的模型资源类型") + } + data, err := readLive2DFile(f, live2DMaxFileBytes) + return data, mime, err + } + } + return nil, "", BadAuthRequest("模型文件不存在") +} diff --git a/backend/internal/app/appearance_live2d_test.go b/backend/internal/app/appearance_live2d_test.go new file mode 100644 index 000000000..dce83201b --- /dev/null +++ b/backend/internal/app/appearance_live2d_test.go @@ -0,0 +1,252 @@ +package app + +import ( + "archive/zip" + "bytes" + "image" + "image/png" + "os" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func live2DTestFiles(t *testing.T) map[string][]byte { + t.Helper() + var texture bytes.Buffer + if err := png.Encode(&texture, image.NewNRGBA(image.Rect(0, 0, 2, 2))); err != nil { + t.Fatal(err) + } + return map[string][]byte{ + "avatar/avatar.model3.json": []byte(`{"Version":3,"FileReferences":{"Moc":"avatar.moc3","Textures":["texture.png"]}}`), + "avatar/avatar.moc3": []byte("MOC3-test-validation-only"), + "avatar/texture.png": texture.Bytes(), + } +} + +func live2DTestZIP(t *testing.T, files map[string][]byte) []byte { + t.Helper() + var data bytes.Buffer + w := zip.NewWriter(&data) + for name, content := range files { + f, err := w.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := f.Write(content); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return data.Bytes() +} + +func TestLive2DArchiveAccepts50MiB(t *testing.T) { + files := live2DTestFiles(t) + // Structural fixture only, not a playable model. Store preserves the ZIP size. + moc := make([]byte, 50<<20) + copy(moc, "MOC3") + files["avatar/avatar.moc3"] = moc + var data bytes.Buffer + w := zip.NewWriter(&data) + for name, content := range files { + f, err := w.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Store}) + if err != nil { + t.Fatal(err) + } + if _, err := f.Write(content); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if data.Len() <= 50<<20 { + t.Fatal("fixture must exercise a ZIP larger than 50 MiB") + } + if _, err := validateLive2DArchive(bytes.NewReader(data.Bytes()), int64(data.Len())); err != nil { + t.Fatal(err) + } +} + +func TestLive2DArchiveValidation(t *testing.T) { + tests := []struct { + name string + mutate func(map[string][]byte) + valid bool + }{ + {"valid nested model", func(map[string][]byte) {}, true}, + {"traversal", func(f map[string][]byte) { f["../escape.json"] = []byte(`{}`) }, false}, + {"script", func(f map[string][]byte) { f["run.js"] = []byte(`alert(1)`) }, false}, + {"case collision", func(f map[string][]byte) { f["avatar/TEXTURE.png"] = f["avatar/texture.png"] }, false}, + {"missing moc", func(f map[string][]byte) { delete(f, "avatar/avatar.moc3") }, false}, + {"fake moc", func(f map[string][]byte) { f["avatar/avatar.moc3"] = []byte("fake") }, false}, + {"invalid png", func(f map[string][]byte) { f["avatar/texture.png"] = []byte("not png") }, false}, + {"external texture", func(f map[string][]byte) { + f["avatar/avatar.model3.json"] = []byte(`{"Version":3,"FileReferences":{"Moc":"avatar.moc3","Textures":["https://example.com/texture.png"]}}`) + }, false}, + {"audio reference", func(f map[string][]byte) { + f["avatar/avatar.model3.json"] = []byte(`{"Version":3,"FileReferences":{"Moc":"avatar.moc3","Textures":["texture.png"],"Motions":{"Idle":[{"File":"idle.motion3.json","Sound":"voice.wav"}]}}}`) + }, false}, + {"multiple entries", func(f map[string][]byte) { f["other.model3.json"] = f["avatar/avatar.model3.json"] }, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + files := live2DTestFiles(t) + tt.mutate(files) + data := live2DTestZIP(t, files) + entry, err := validateLive2DArchive(bytes.NewReader(data), int64(len(data))) + if (err == nil) != tt.valid { + t.Fatalf("entry=%q err=%v", entry, err) + } + if tt.valid && entry != "avatar/avatar.model3.json" { + t.Fatal(entry) + } + }) + } + if _, err := validateLive2DArchive(bytes.NewReader(nil), Live2DMaxBytes+1); err == nil { + t.Fatal("oversized ZIP accepted") + } + for _, name := range []string{"", "../a", "a/../b", "/a", "a\\b", "a%2fb", "https://host/a", "a?b"} { + if live2DFileName(name) { + t.Fatalf("unsafe filename accepted: %q", name) + } + } +} + +func TestLive2DArchiveRejectsSymlinksAndEntryFlood(t *testing.T) { + var data bytes.Buffer + w := zip.NewWriter(&data) + header := &zip.FileHeader{Name: "link.json", Method: zip.Store} + header.SetMode(os.ModeSymlink | 0o777) + f, err := w.CreateHeader(header) + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte("outside")); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if _, err := validateLive2DArchive(bytes.NewReader(data.Bytes()), int64(data.Len())); err == nil { + t.Fatal("symlink accepted") + } + data.Reset() + w = zip.NewWriter(&data) + for i := 0; i < 257; i++ { + if _, err := w.Create("duplicate.json"); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if _, err := validateLive2DArchive(bytes.NewReader(data.Bytes()), int64(data.Len())); err == nil { + t.Fatal("entry flood accepted") + } +} + +func TestLive2DAppearanceImportPublishAndDetach(t *testing.T) { + svc, _, _, admin := newAppearanceTestService(t) + if _, err := svc.UploadLive2D(nil, nil); err == nil { + t.Fatal("anonymous upload accepted") + } + data := live2DTestZIP(t, live2DTestFiles(t)) + ordinary, err := svc.UploadResource(admin.ID, multipartFileHeader(t, "ordinary.zip", "application/zip", data), "live2d", 0, 0, 0) + if err != nil { + t.Fatal(err) + } + if ordinary.Kind == "live2d" { + t.Fatal("ordinary upload claimed the reserved model kind") + } + imported, err := svc.UploadLive2D(admin, multipartFileHeader(t, "avatar.zip", "application/zip", data)) + if err != nil { + t.Fatal(err) + } + resource, err := svc.repo.Resource(imported.ResourceID) + if err != nil || resource.Kind != "live2d" || resource.Provider != "local" { + t.Fatalf("resource=%+v err=%v", resource, err) + } + if _, _, err := svc.Live2DAsset(nil, imported.ResourceID, imported.Entry); err == nil { + t.Fatal("draft is publicly accessible") + } + if body, mime, err := svc.Live2DAsset(admin, imported.ResourceID, imported.Entry); err != nil || mime != "application/json" || len(body) == 0 { + t.Fatalf("preview failed: %s %v", mime, err) + } + other := &model.User{ID: "other-admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive} + if _, _, err := svc.Live2DAsset(other, imported.ResourceID, imported.Entry); err == nil { + t.Fatal("other admin previewed draft") + } + value := defaultAppearanceSetting() + value.Canvas.AgentName = "小鱼" + value.Canvas.AvatarType = "live2d" + value.Canvas.Live2DResourceID = imported.ResourceID + value.Canvas.Live2DEntry = "client-cannot-choose.json" + if _, err := svc.UpdateAppearance(other, value); err == nil { + t.Fatal("other admin enabled draft") + } + saved, err := svc.UpdateAppearance(admin, value) + if err != nil { + t.Fatal(err) + } + if saved.Public.Canvas.AgentName != "小鱼" || saved.Public.BrandName != "影策" || saved.Public.Canvas.Live2DEntry != imported.Entry { + t.Fatalf("bad public setting: %+v", saved.Public) + } + if _, _, err := svc.Live2DAsset(nil, imported.ResourceID, "avatar/texture.png"); err != nil { + t.Fatal(err) + } + if _, _, err := svc.Live2DAsset(nil, imported.ResourceID, "../avatar/texture.png"); err == nil { + t.Fatal("traversal accepted") + } + refs := svc.appearanceResourceReferences([]string{imported.ResourceID}) + if len(refs[imported.ResourceID]) != 1 { + t.Fatalf("missing deletion protection: %+v", refs) + } + if _, err := svc.AdminResourcePage(admin, AdminResourceQuery{Kind: "live2d", Page: 1, Limit: 20}); err != nil { + t.Fatal(err) + } + blocked, err := svc.DeleteAdminResources(admin, AdminResourceDeleteRequest{ResourceIDs: []string{imported.ResourceID}}) + if err != nil || len(blocked.Blocked) != 1 || len(blocked.Deleted) != 0 { + t.Fatalf("referenced model deletion: %+v %v", blocked, err) + } + value.Canvas.AvatarType = "orb" + if _, err := svc.UpdateAppearance(admin, value); err != nil { + t.Fatal(err) + } + if _, _, err := svc.Live2DAsset(nil, imported.ResourceID, imported.Entry); err == nil { + t.Fatal("disabled model still public") + } + if len(svc.appearanceResourceReferences([]string{imported.ResourceID})[imported.ResourceID]) != 1 { + t.Fatal("orb selection lost resource reference") + } + value.Canvas.Live2DResourceID = "" + if _, err := svc.UpdateAppearance(admin, value); err != nil { + t.Fatal(err) + } + if len(svc.appearanceResourceReferences([]string{imported.ResourceID})[imported.ResourceID]) != 0 { + t.Fatal("detached model still referenced") + } + deleted, err := svc.DeleteAdminResources(admin, AdminResourceDeleteRequest{ResourceIDs: []string{imported.ResourceID}}) + if err != nil || len(deleted.Deleted) != 1 { + t.Fatalf("detached model deletion: %+v %v", deleted, err) + } +} + +func TestCanvasAppearanceValidation(t *testing.T) { + for _, mutate := range []func(*CanvasAppearance){ + func(v *CanvasAppearance) { v.AgentName = "\n" }, + func(v *CanvasAppearance) { v.AgentName = "小\x00鱼" }, + func(v *CanvasAppearance) { v.AvatarHeight = 1000 }, + func(v *CanvasAppearance) { v.AvatarType = "invalid" }, + func(v *CanvasAppearance) { v.AvatarType = "live2d" }, + } { + value := defaultCanvasAppearance() + mutate(&value) + if _, err := normalizeCanvasAppearance(value); err == nil { + t.Fatalf("invalid value accepted: %+v", value) + } + } +} diff --git a/backend/internal/app/resource.go b/backend/internal/app/resource.go index 4f157644e..5f5430e2c 100644 --- a/backend/internal/app/resource.go +++ b/backend/internal/app/resource.go @@ -500,7 +500,10 @@ func (s *Service) storeResource(userID string, kind string, fileName string, mim return nil, false, resourceUploadInProgress() } now := time.Now() - kind = normalizeResourceKind(kind, mimeType) + // Live2D is reserved for the administrator's validated local import path. + if !(forceLocal && kind == "live2d") { + kind = normalizeResourceKind(kind, mimeType) + } var setting ossSettingValue var storageSettingID string var useOSS bool diff --git a/backend/internal/handler/appearance.go b/backend/internal/handler/appearance.go index e2a218c3d..d301f008c 100644 --- a/backend/internal/handler/appearance.go +++ b/backend/internal/handler/appearance.go @@ -11,6 +11,7 @@ import ( ) func RegisterAppearanceRoutes(r *gin.RouterGroup, svc *service.Service) { + registerLive2DRoutes(r, svc) r.GET("/public/appearance", func(c *gin.Context) { setting, err := svc.Appearance() if err != nil { diff --git a/backend/internal/handler/appearance_live2d.go b/backend/internal/handler/appearance_live2d.go new file mode 100644 index 000000000..e828661a0 --- /dev/null +++ b/backend/internal/handler/appearance_live2d.go @@ -0,0 +1,72 @@ +package handler + +import ( + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/service" +) + +func registerLive2DRoutes(r *gin.RouterGroup, svc *service.Service) { + r.POST("/admin/settings/appearance/live2d", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.RequireAdmin(actor); err != nil { + failService(c, err) + return + } + policy, available := loadRuntimePolicy(c, svc) + if !available || !enforceRateLimit(c, "admin-live2d-upload:"+actor.ID, policy.Request.ResourceUploadPerMinute, time.Minute) { + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.Live2DMaxBytes+(1<<20)) + file, err := c.FormFile("file") + if c.Request.MultipartForm != nil { + defer c.Request.MultipartForm.RemoveAll() + } + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + result, err := svc.UploadLive2D(actor, file) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"model": result}) + }) + serve := func(admin bool) gin.HandlerFunc { + return func(c *gin.Context) { + var actor *model.User + if admin { + var err error + actor, err = currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.RequireAdmin(actor); err != nil { + failService(c, err) + return + } + } + data, mime, err := svc.Live2DAsset(actor, c.Param("id"), strings.TrimPrefix(c.Param("file"), "/")) + if err != nil { + failService(c, err) + return + } + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Cache-Control", "private, no-cache") + c.Header("Content-Security-Policy", "default-src 'none'") + c.Data(http.StatusOK, mime, data) + } + } + r.GET("/public/appearance/live2d/:id/*file", serve(false)) + r.GET("/admin/settings/appearance/live2d/:id/*file", serve(true)) +} diff --git a/backend/internal/handler/appearance_routes_test.go b/backend/internal/handler/appearance_routes_test.go index fccfebff6..cc1f608d5 100644 --- a/backend/internal/handler/appearance_routes_test.go +++ b/backend/internal/handler/appearance_routes_test.go @@ -13,12 +13,15 @@ func TestAppearanceRoutesAreRegistered(t *testing.T) { router := gin.New() RegisterAppearanceRoutes(router.Group("/api"), &service.Service{}) wanted := map[string]bool{ - "GET /api/public/appearance": false, - "GET /api/public/appearance/assets/:slot": false, - "GET /api/admin/settings/appearance": false, - "PATCH /api/admin/settings/appearance": false, - "DELETE /api/admin/settings/appearance": false, - "POST /api/admin/settings/appearance/assets/:slot": false, + "POST /api/admin/settings/appearance/live2d": false, + "GET /api/admin/settings/appearance/live2d/:id/*file": false, + "GET /api/public/appearance/live2d/:id/*file": false, + "GET /api/public/appearance": false, + "GET /api/public/appearance/assets/:slot": false, + "GET /api/admin/settings/appearance": false, + "PATCH /api/admin/settings/appearance": false, + "DELETE /api/admin/settings/appearance": false, + "POST /api/admin/settings/appearance/assets/:slot": false, } for _, route := range router.Routes() { key := route.Method + " " + route.Path diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index 6368f3a0d..211e6c62a 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -26,6 +26,68 @@ tags: - name: agent - name: admin paths: + /admin/settings/appearance/live2d: + post: + tags: [admin] + summary: 导入管理员 Live2D 模型草稿(不自动启用) + description: ZIP 最大 128 MiB,最多 256 条目;解压累计 384 MiB,单文件最大 128 MiB。只接受一个 Version 3 model3.json 及 ZIP 内 MOC3、PNG、JSON 引用。启用通过 PATCH /admin/settings/appearance 的 canvas 配置完成。 + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: {type: string, format: binary} + responses: + "200": + description: 成功信封 data.model 包含 resourceId 和服务端确认的 entry + content: + application/json: + schema: + type: object + properties: + code: {type: integer, enum: [0]} + msg: {type: string} + data: + type: object + properties: + model: + type: object + required: [resourceId, entry] + properties: + resourceId: {type: string} + entry: {type: string} + "400": {$ref: "#/components/responses/Error"} + "401": {$ref: "#/components/responses/Error"} + "403": {$ref: "#/components/responses/Error"} + "429": {$ref: "#/components/responses/Error"} + /admin/settings/appearance/live2d/{id}/{file}: + get: + tags: [admin] + summary: 读取模型草稿文件(管理员所有者或当前配置引用) + parameters: + - {in: path, name: id, required: true, schema: {type: string}} + - {in: path, name: file, required: true, description: ZIP 内相对路径,可包含多级目录, schema: {type: string}} + responses: + "200": {description: JSON、PNG 或 MOC3 文件,非 JSON 信封} + "400": {$ref: "#/components/responses/Error"} + "401": {$ref: "#/components/responses/Error"} + "403": {$ref: "#/components/responses/Error"} + /public/appearance/live2d/{id}/{file}: + get: + tags: [meta] + security: [] + summary: 读取当前启用的 Live2D 模型文件 + parameters: + - {in: path, name: id, required: true, schema: {type: string}} + - {in: path, name: file, required: true, description: ZIP 内相对路径,可包含多级目录, schema: {type: string}} + responses: + "200": {description: JSON、PNG 或 MOC3 文件,非 JSON 信封;停用后拒绝公开读取} + "400": {$ref: "#/components/responses/Error"} + "401": {$ref: "#/components/responses/Error"} + "403": {$ref: "#/components/responses/Error"} /tools: get: tags: [canvas] diff --git a/backend/internal/service/aliases_consts.go b/backend/internal/service/aliases_consts.go index 51d610eb9..a0cc88463 100644 --- a/backend/internal/service/aliases_consts.go +++ b/backend/internal/service/aliases_consts.go @@ -3,6 +3,7 @@ package service import "infinite-canvas/backend/internal/app" const ( + Live2DMaxBytes = app.Live2DMaxBytes AnnouncementImageMaxBytes = app.AnnouncementImageMaxBytes AppearanceAssetDarkLogo = app.AppearanceAssetDarkLogo AppearanceAssetLogo = app.AppearanceAssetLogo diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 712688a87..354ef5184 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -5,6 +5,12 @@ description: 影策后端核心持久化表与状态边界 # 后端数据库 +## 画布 Agent 外观配置 + +`system_settings` 的 `appearance` JSON 合同版本 9 新增 `canvas`:助手名称、入口标签、面板标题、欢迎文案、输入提示、`avatarType`(`orb|live2d`)、`avatarHeight`(120–360)、`live2dResourceId` 和服务端确认的 `live2dEntry`。独立于站点品牌;缺失时读取内置画布默认值。不新增数据库结构迁移,不改变全局 schema 版本。 + +模型包复用 `resources` 表,`kind=live2d`、`provider=local`,ZIP 不解压到磁盘。该 kind 仅由验证后的管理员导入路径保留,普通上传不能声明它。外观配置中的模型 ID 参与删除引用检查;清除引用不删除资源。默认球仍可保留模型引用,便于切换。 + 后端通过 GORM 管理 SQLite 与 PostgreSQL 表结构。结构变化由 `migrate-schema` 按版本顺序执行并写入 `schema_migrations`;受管生产部署在 Backend 启动前运行独立迁移服务,Backend 只校验当前 Schema 是否与二进制兼容。业务写路径必须通过 service 和 repository 的状态约束,不能直接依赖前端状态。 ## 结构迁移与版本 diff --git a/docs/content/docs/backend/canvas-agent-appearance.mdx b/docs/content/docs/backend/canvas-agent-appearance.mdx new file mode 100644 index 000000000..ab85614c2 --- /dev/null +++ b/docs/content/docs/backend/canvas-agent-appearance.mdx @@ -0,0 +1,47 @@ +--- +title: 画布 Agent 外观与 Live2D +description: 助手名称、文案、模型导入和运行库部署 +--- + +# 画布 Agent 外观与 Live2D + +管理员在「站点及外观 → 画布配置」设置助手名称、入口文字、面板标题、欢迎标题、欢迎说明和输入提示。助手名称独立于站点品牌,例如站点为“影策”、助手为“小鱼”。文案中的 `{agentName}` 会替换为助手名称,并作为普通文本渲染。 + +这些字段只改变展示,不改历史对话、模型提示词、AI 自我介绍、技能或工具权限。运行中插话提示保留原有操作说明。入口文字和欢迎说明允许留空。 + +## 模型来源与授权 + +导入的是已经制作、绑定并导出的 Cubism 运行时模型包,不是立绘图片、PSD 或编辑工程。可以使用自己制作或委托制作且获得授权的模型;项目不提供一键把图片生成 Live2D 的功能。 + +使用前分别确认模型素材和 Cubism Core 的使用、部署与分发授权。启用后浏览器需要取得模型资源,无法承诺防下载;不应上传需要保密的素材。 + +## 部署运行库 + +前端使用固定版本 `pixi.js@6.5.10` 和 `pixi-live2d-display@0.4.0`,按需加载 Cubism 3/4 渲染适配器。Cubism Core 不包含在仓库中,不从第三方 CDN 加载,也不接受模型 ZIP 中的脚本。 + +1. 部署者从 Live2D 官方获取具有相应授权、与模型版本兼容的 Cubism SDK for Web。 +2. 将 Core 文件 `live2dcubismcore.min.js` 放到前端 `web/public/live2d/` 后重新构建;静态镜像部署也可将文件只读挂载到 `/usr/share/nginx/html/live2d/live2dcubismcore.min.js`。该文件已被 Git 忽略,不提交到本仓库。 +3. 确认同源 `/live2d/live2dcubismcore.min.js` 返回 JavaScript 而非 SPA 页面。非根路径部署跟随前端构建 BASE_URL。 +4. 后台导入并预览,成功后选择 Live2D,点击顶部「保存修改」。导入本身不启用;失败不更改当前形象。 + +没有 Core、模型不兼容、网络超时或 WebGL 上下文丢失时,入口回退默认动态球,仍可点击打开 Agent。后台显示错误并提供重试。未提供授权 Core 和真实模型时,不能仅凭构建成功认定播放验收通过。 + +接入层支持旧版 `drawables.renderOrders` 和新版 Core 的 `Model.getRenderOrders()` 绘制顺序接口,不修改 Core 文件。当前 Cubism 4 渲染适配器不支持新版离屏渲染对象;此类模型会明确报错,而不是忽略对象后显示不完整形象。 + +## ZIP 要求 + +- 压缩包不超过 128 MiB,解压累计不超过 384 MiB,最多 256 个条目;单文件不超过 128 MiB,JSON 不超过 2 MiB。 +- 只能有一个 `*.model3.json`,`Version` 为 3,所有引用必须位于 ZIP 内且使用规范相对路径。 +- 包含 `.moc3` 和 1–8 张 PNG 纹理;单张不超过 4096 × 4096,包内纹理总像素不超过 32 Mi 像素。 +- 可带动作、表情、物理、姿势等 JSON;不支持 Cubism 2、音频引用、脚本和编辑工程。请移除 README、缩略 JPEG、系统隐藏文件等无关文件后打包。 +- 不允许外部 URL、路径穿越、软链接或大小写冲突的路径。后端验证结构和边界;MOC3 与 Core 的实际兼容性由预览确认。 + +ZIP 作为本地 `live2d` 资源保存,不解压到磁盘。草稿只有上传者可预览;当前配置引用的模型允许其他管理员预览。只有已启用模型的指定数据文件允许公开读取。启用时服务端重新检查并决定入口,不信任客户端提供的入口路径。 + +## 行为与清理 + +- 高度可设 120–360px,画布入口按视口缩小并限制拖拽边界,保留点击打开、鼠标拖动和方向键移动。 +- 渲染上限为 30fps;后台标签页及不可见预览暂停更新,减少动态效果偏好下只渲染静态形象。首期不含口型、语音联动或点击表情交互。 +- 选择默认球不会删除模型引用;清除引用并保存后,可到资源管理清理。放弃未保存导入的包也可在那里清理。 +- 引用中的包受资源删除保护。恢复默认外观也会恢复画布配置,但不删除文件。 +- 配置存于 `system_settings` 的 `appearance` JSON(合同版本 9),不新增表或数据库迁移。一起更新前后端;降级前备份设置及资源,不用旧后台覆盖新配置。 diff --git a/docs/content/docs/backend/code-map.mdx b/docs/content/docs/backend/code-map.mdx index 19f15b0b3..bb5e7a519 100644 --- a/docs/content/docs/backend/code-map.mdx +++ b/docs/content/docs/backend/code-map.mdx @@ -5,6 +5,13 @@ description: 从产品功能快速定位主要代码入口 # 代码功能地图 +## 画布 Agent 配置与 Live2D + +- 后台入口:`web/src/pages/admin/settings/appearance-settings-page.tsx` 与 `components/canvas-appearance-editor.tsx`。 +- 公共配置及文案:`web/src/stores/use-appearance-store.ts`、`web/src/lib/canvas/agent-appearance.ts`。 +- 画布形象:`web/src/components/canvas/live2d-avatar.tsx`、`canvas-cloud-agent-panel.tsx`;受限资源加载:`web/src/services/live2d-loader.ts`。 +- 服务端:`backend/internal/app/appearance_canvas.go` 校验配置,`appearance_live2d.go` 验证 ZIP、导入及资源访问;`backend/internal/handler/appearance_live2d.go` 为 HTTP 边界。已有外观设置和资源引用链路负责持久化及删除保护。 + 这是一张入口地图,不罗列所有文件。先找到功能所属层,再沿调用关系阅读。 ## 前端页面 diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index f00f57b97..3a74a8ce9 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -13,6 +13,18 @@ description: 信封、错误码与 OpenAPI ## 站点皮肤主按钮 +### 画布配置与 Live2D(外观合同版本 9) + +`GET /public/appearance` 和管理员 `GET /admin/settings/appearance` 返回 `canvas`;管理员通过 `PATCH /admin/settings/appearance` 随外观配置统一保存。字段为 `agentName`(1–24 字)、`launcherLabel`(0–24 字)、`panelTitle`(1–40 字)、`welcomeTitle`(1–100 字)、`welcomeDescription`(0–200 字)、`inputPlaceholder`(1–160 字)、`avatarType`(`orb|live2d`)、`avatarHeight`(120–360 整数)、`live2dResourceId`、`live2dEntry`。入口由服务端核验覆盖;Live2D 无有效资源时拒绝启用。配置不修改 Agent 系统提示词。 + +- `POST /admin/settings/appearance/live2d`:管理员 multipart `file` 上传,受上传频率和配额限制。成功信封的 `data.model` 为 `{ resourceId, entry }`,上传不会发布。ZIP 限 128 MiB、256 条目、解压合计 384 MiB、单文件 128 MiB;只接收 model3/MOC3/PNG/JSON,拒绝路径穿越、脚本和外部资源。 +- `GET /admin/settings/appearance/live2d/:id/*file`:管理员预览,校验资源归属;其他管理员仅能查看当前外观已引用模型。 +- `GET /public/appearance/live2d/:id/*file`:仅提供当前启用模型的数据文件。停用或更换后旧模型拒绝公开访问。成功为文件内容,失败为错误信封;响应带 `nosniff` 和私有、需重新验证的缓存策略。 + +管理员资源列表增加 `kind=live2d` 筛选;引用中的模型禁止删除,清除外观引用后按既有资源删除流程清理。 + +部署要求与更详细的文件限制见 [画布 Agent 外观与 Live2D](/docs/backend/canvas-agent-appearance)。 + `GET /public/appearance` 返回当前 `activeSkin`;管理员通过 `GET/PUT /admin/settings/appearance` 读取、保存完整主题库。外观合同版本 8 新增 `tokens.buttons.light/dark`,字段为 `mode`(`solid|gradient`)、整数 `angle`(0–360)、HEX 色值 `start/end`、`hoverStart/hoverEnd`、`activeStart/activeEnd`、`foreground`。纯色模式使用既有主操作颜色,渐变仅用于主按钮。非法模式、角度、色值或修改锁定经典主题均拒绝保存;整块缺失的旧配置按主题身份补齐。此变更不增加数据表迁移。 ## 模型规格报价 diff --git a/docs/content/docs/backend/meta.json b/docs/content/docs/backend/meta.json index 8ad5a2da0..0ef1f564d 100644 --- a/docs/content/docs/backend/meta.json +++ b/docs/content/docs/backend/meta.json @@ -4,6 +4,7 @@ "defaultOpen": true, "pages": [ "local-development", + "canvas-agent-appearance", "http-api", "code-map", "backend-database", diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index ce52ddb88..fdc993f06 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -5,6 +5,10 @@ description: 当前项目已实现的核心工作台能力与使用入口 # 功能清单 +## 画布 Agent 名称、文案与形象 + +「站点及外观 → 画布配置」提供独立助手名称、入口文字、面板标题和欢迎/输入文案。支持导入授权的 Live2D Cubism 3/4 运行时 ZIP,预览后保存启用、调整高度、切回默认动态球。加载失败保留可点击的默认入口,上传和资源访问具有管理员校验和引用保护。Cubism Core 由部署者提供,真实播放待验收;不含图片自动建模或 AI 自我介绍同步。详见 [画布 Agent 外观与 Live2D](/docs/backend/canvas-agent-appearance)。 + ## 素材库分类与画布媒体链路 素材库支持用户手动新增、重命名和删除分类,也可以按分类或“未分类”筛选素材,并批量移动素材。删除分类不会删除素材,分类内素材会回到“未分类”。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 873e823fa..f4c5c98a6 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,16 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 画布 Agent 配置与 Live2D + +- [ ] 使用约 50 MiB 的真实模型 ZIP 验收上传和预览;ZIP 上限 128 MiB,解压累计 384 MiB、单文件 128 MiB,超限须拒绝。 +- [ ] 站点仍叫“影策”,助手改为“小鱼”;保存、刷新、撤销和恢复默认后入口、面板、欢迎及输入文案正确,历史对话不改写。 +- [ ] 使用授权 Core 和真实 Cubism 3/4 模型完成预览、保存、画布播放验收。没有 Core、模型不兼容、网络超时或 WebGL 丢失时显示错误/默认球,不阻止打开对话。 +- [ ] 导入不自动启用;上传期间不可保存/刷新/恢复默认;离开未保存配置不影响线上形象;其他管理员不能预览或启用未发布草稿。 +- [ ] 未启用包不能匿名访问;启用后模型资源正常读取,切回默认球停止公开服务;引用中包无法删除,清除引用并保存后可清理。 +- [ ] 验收大模型、多次切换/打开/关闭、后台标签页、隐藏后台标签、减少动态效果、跨域 API Cookie、明暗主题、窄屏拖动和键盘移动;观察 GPU/内存释放。 +- 已用用户提供的 Shizuku 运行包和 Cubism SDK for Web 5-r.5 Core,在现有开发服务的真实 `Live2DAvatar` 组件中复现并修复绘制顺序接口异常,浏览器首帧渲染就绪。模型请求由测试拦截提供本地文件,不代表后台上传、权限、保存和画布全链路已验收;首期不含口型、语音联动、点击表情或 AI 自我介绍同步。 + ## 系统渠道模型展示标签 - [ ] 在系统渠道的模型编辑中添加多个标签(如“限时特价”“官方1折”),选择不同颜色,保存并重新打开后文字、颜色及顺序保持一致;删除全部标签后前台不再显示。 diff --git a/docs/index.md b/docs/index.md index 1d864f695..1d4355cde 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,8 @@ ## 设计沉淀 +- [画布 Agent 外观与 Live2D](content/docs/backend/canvas-agent-appearance.mdx):独立助手名称、文案模板、模型包边界、Core 部署和验收要求。 + - [云端 Agent 架构优化方案](design/cloud-agent-architecture-optimization.md):跨端生成合同、引用绑定、审批依赖、报价预算、运行存储与工作上下文的现状审查、分阶段方案和验收门槛(提案,未实施)。 - [影策品牌首页](design/story-creation-homepage.md):六幕电影卷轴、公开入口、故事素材、工作台预览与响应式降级。 diff --git a/web/bun.lock b/web/bun.lock index 28e67e515..c9a6db5b9 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -50,6 +50,8 @@ "mammoth": "^1.12.0", "motion": "^12.42.2", "nanoid": "^6.0.0", + "pixi-live2d-display": "0.4.0", + "pixi.js": "6.5.10", "react": "19.2.7", "react-aria-components": "^1.21.1", "react-dom": "19.2.7", @@ -532,6 +534,78 @@ "@pierre/theming": ["@pierre/theming@0.0.2", "https://registry.npmmirror.com/@pierre/theming/-/theming-0.0.2.tgz", { "peerDependencies": { "@pierre/theme": "^1.1.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw=="], + "@pixi/accessibility": ["@pixi/accessibility@6.5.10", "", { "peerDependencies": { "@pixi/core": "6.5.10", "@pixi/display": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-URrI1H+1kjjHSyhY1QXcUZ8S3omdVTrXg5y0gndtpOhIelErBTC9NWjJfw6s0Rlmv5+x5VAitQTgw9mRiatDgw=="], + + "@pixi/app": ["@pixi/app@6.5.10", "", { "peerDependencies": { "@pixi/core": "6.5.10", "@pixi/display": "6.5.10", "@pixi/math": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-VsNHLajZ5Dbc/Zrj7iWmIl3eu6Fec+afjW/NXXezD8Sp3nTDF0bv5F+GDgN/zSc2gqIvPHyundImT7hQGBDghg=="], + + "@pixi/compressed-textures": ["@pixi/compressed-textures@6.5.10", "", { "peerDependencies": { "@pixi/constants": "6.5.10", "@pixi/core": "6.5.10", "@pixi/loaders": "6.5.10", "@pixi/settings": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-41NT5mkfam47DrkB8xMp3HUZDt7139JMB6rVNOmb3u2vm+2mdy9tzi5s9nN7bG9xgXlchxcFzytTURk+jwXVJA=="], + + "@pixi/constants": ["@pixi/constants@6.5.10", "", {}, "sha512-PUF2Y9YISRu5eVrVVHhHCWpc/KmxQTg3UH8rIUs8UI9dCK41/wsPd3pEahzf7H47v7x1HCohVZcFO3XQc1bUDw=="], + + "@pixi/core": ["@pixi/core@6.5.10", "", { "dependencies": { "@types/offscreencanvas": "^2019.6.4" }, "peerDependencies": { "@pixi/constants": "6.5.10", "@pixi/extensions": "6.5.10", "@pixi/math": "6.5.10", "@pixi/runner": "6.5.10", "@pixi/settings": "6.5.10", "@pixi/ticker": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-Gdzp5ENypyglvsh5Gv3teUZnZnmizo4xOsL+QqmWALdFlJXJwLJMVhKVThV/q/095XR6i4Ou54oshn+m4EkuFw=="], + + "@pixi/display": ["@pixi/display@6.5.10", "", { "peerDependencies": { "@pixi/constants": "6.5.10", "@pixi/math": "6.5.10", "@pixi/settings": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-NxFdDDxlbH5fQkzGHraLGoTMucW9pVgXqQm13TSmkA3NWIi/SItHL4qT2SI8nmclT9Vid1VDEBCJFAbdeuQw1Q=="], + + "@pixi/extensions": ["@pixi/extensions@6.5.10", "", {}, "sha512-EIUGza+E+sCy3dupuIjvRK/WyVyfSzHb5XsxRaxNrPwvG1iIUIqNqZ3owLYCo4h17fJWrj/yXVufNNtUKQccWQ=="], + + "@pixi/extract": ["@pixi/extract@6.5.10", "", { "peerDependencies": { "@pixi/constants": "6.5.10", "@pixi/core": "6.5.10", "@pixi/math": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-hXFIc4EGs14GFfXAjT1+6mzopzCMWeXeai38/Yod3vuBXkkp8+ksen6kE09vTnB9l1IpcIaCM+XZEokuqoGX2A=="], + + "@pixi/filter-alpha": ["@pixi/filter-alpha@6.5.10", "", { "peerDependencies": { "@pixi/core": "6.5.10" } }, "sha512-GWHLJvY0QOIDRjVx0hdUff6nl/PePQg84i8XXPmANrvA+gJ/eSRTQRmQcdgInQfawENADB/oRqpcCct6IAcKpQ=="], + + "@pixi/filter-blur": ["@pixi/filter-blur@6.5.10", "", { "peerDependencies": { "@pixi/constants": "6.5.10", "@pixi/core": "6.5.10", "@pixi/settings": "6.5.10" } }, "sha512-LJsRocVOdM9hTzZKjP+jmkfoL1nrJi5XpR0ItgRN8fflOC7A7Ln4iPe7nukbbq3H7QhZSunbygMubbO6xhThZw=="], + + "@pixi/filter-color-matrix": ["@pixi/filter-color-matrix@6.5.10", "", { "peerDependencies": { "@pixi/core": "6.5.10" } }, "sha512-C2S44/EoWTrhqedLWOZTq9GZV5loEq1+MhyK9AUzEubWGMHhou1Juhn2mRZ7R6flKPCRQNKrXpStUwCAouud3Q=="], + + "@pixi/filter-displacement": ["@pixi/filter-displacement@6.5.10", "", { "peerDependencies": { "@pixi/core": "6.5.10", "@pixi/math": "6.5.10" } }, "sha512-fbblMYyPX/hO3Tpoaa4tOBYxqp4TxjNrz6xyt15tKSVxWQElk+Tx98GJ+aaBoiHOKt8ezzHplStWoHG++JIv/w=="], + + "@pixi/filter-fxaa": ["@pixi/filter-fxaa@6.5.10", "", { "peerDependencies": { "@pixi/core": "6.5.10" } }, "sha512-wbHL9UtY3g7jTyvO8JaZks6DqV8AO5c96Hfu0zfndWBPs79Ul6/sq3LD2eE+yq5vK5T2R9Sr4s54ls1JT3Sppg=="], + + "@pixi/filter-noise": ["@pixi/filter-noise@6.5.10", "", { "peerDependencies": { "@pixi/core": "6.5.10" } }, "sha512-CX+/06NVaw3HsjipZVb7aemkca0TC8I6qfKI4lx2ugxS/6G6zkY5zqd8+nVSXW4DpUXB6eT0emwfRv6N00NvuA=="], + + "@pixi/graphics": ["@pixi/graphics@6.5.10", "", { "peerDependencies": { "@pixi/constants": "6.5.10", "@pixi/core": "6.5.10", "@pixi/display": "6.5.10", "@pixi/math": "6.5.10", "@pixi/sprite": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-KPHGJ910fi8bRQQ+VcTIgrK+bKIm8yAQaZKPqMtm14HzHPGcES6HkgeNY1sd7m8J4aS9btm5wOSyFu0p5IzTpA=="], + + "@pixi/interaction": ["@pixi/interaction@6.5.10", "", { "peerDependencies": { "@pixi/core": "6.5.10", "@pixi/display": "6.5.10", "@pixi/math": "6.5.10", "@pixi/ticker": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-v809pJmXA2B9dV/vdrDMUqJT+fBB/ARZli2YRmI2dPbEbkaYr8FNmxCAJnwT8o+ymTx044Ie820hn9tVrtMtfA=="], + + "@pixi/loaders": ["@pixi/loaders@6.5.10", "", { "peerDependencies": { "@pixi/constants": "6.5.10", "@pixi/core": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-AuK7mXBmyVsDFL9DDFPB8sqP8fwQ2NOktvu98bQuJl0/p/UeK/0OAQnF3wcf3FeBv5YGXfNHL21c2DCisjKfTg=="], + + "@pixi/math": ["@pixi/math@6.5.10", "", {}, "sha512-fxeu7ykVbMGxGV2S3qRTupHToeo1hdWBm8ihyURn3BMqJZe2SkZEECPd5RyvIuuNUtjRnmhkZRnF3Jsz2S+L0g=="], + + "@pixi/mesh": ["@pixi/mesh@6.5.10", "", { "peerDependencies": { "@pixi/constants": "6.5.10", "@pixi/core": "6.5.10", "@pixi/display": "6.5.10", "@pixi/math": "6.5.10", "@pixi/settings": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-tUNPsdp5/t/yRsCmfxIcufIfbQVzgAlMNgQ1igWOkSxzhB7vlEbZ8ZLLW5tQcNyM/r7Nhjz+RoB+RD+/BCtvlA=="], + + "@pixi/mesh-extras": ["@pixi/mesh-extras@6.5.10", "", { "peerDependencies": { "@pixi/constants": "6.5.10", "@pixi/core": "6.5.10", "@pixi/math": "6.5.10", "@pixi/mesh": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-UCG7OOPPFeikrX09haCibCMR0jPQ4UJ+4HiYiAv/3dahq5eEzBx+yAwVtxcVCjonkTf/lu5SzmHdzpsbHLx5aw=="], + + "@pixi/mixin-cache-as-bitmap": ["@pixi/mixin-cache-as-bitmap@6.5.10", "", { "peerDependencies": { "@pixi/constants": "6.5.10", "@pixi/core": "6.5.10", "@pixi/display": "6.5.10", "@pixi/math": "6.5.10", "@pixi/settings": "6.5.10", "@pixi/sprite": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-HV4qPZt8R7uuPZf1XE5S0e3jbN4+/EqgAIkueIyK3Em+0IO1rCmIbzzYxFPxkElMUu5VvN1r4hXK846z9ITnhw=="], + + "@pixi/mixin-get-child-by-name": ["@pixi/mixin-get-child-by-name@6.5.10", "", { "peerDependencies": { "@pixi/display": "6.5.10" } }, "sha512-YYd9wjnI/4aKY0H5Ij413UppVZn3YE1No2CZrNevV6WbhylsJucowY3hJihtl9mxkpwtaUIyWMjmphkbOinbzA=="], + + "@pixi/mixin-get-global-position": ["@pixi/mixin-get-global-position@6.5.10", "", { "peerDependencies": { "@pixi/display": "6.5.10", "@pixi/math": "6.5.10" } }, "sha512-A83gTZP9CdQAyrAvOZl1P707Q0QvIC0V8UnBAMd4GxuhMOXJtXVPCdmfPVXUrfoywgnH+/Bgimq5xhsXTf8Hzg=="], + + "@pixi/particle-container": ["@pixi/particle-container@6.5.10", "", { "peerDependencies": { "@pixi/constants": "6.5.10", "@pixi/core": "6.5.10", "@pixi/display": "6.5.10", "@pixi/math": "6.5.10", "@pixi/sprite": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-CCNAdYGzKoOc3FtK2kyWCNjygdHppeOEqqK189yhg3yRSsvby+HMms/cM6bLK/4Vf6mFoAy1na3w/oXpqTR2Ag=="], + + "@pixi/polyfill": ["@pixi/polyfill@6.5.10", "", { "dependencies": { "object-assign": "^4.1.1", "promise-polyfill": "^8.2.0" } }, "sha512-KDTWyr285VvPM8GGTVIZAhmxGrOlTznUGK/9kWS3GtrogwLWn41S/86Yej1gYvotVyUomCcOok33Jzahb+vX1w=="], + + "@pixi/prepare": ["@pixi/prepare@6.5.10", "", { "peerDependencies": { "@pixi/core": "6.5.10", "@pixi/display": "6.5.10", "@pixi/graphics": "6.5.10", "@pixi/settings": "6.5.10", "@pixi/text": "6.5.10", "@pixi/ticker": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-PHMApz/GPg7IX/7+2S98criN2+Mp+fgiKpojV9cnl0SlW2zMxfAHBBi8zik9rHBgjx8X6d6bR0MG1rPtb6vSxQ=="], + + "@pixi/runner": ["@pixi/runner@6.5.10", "", {}, "sha512-4HiHp6diCmigJT/DSbnqQP62OfWKmZB7zPWMdV1AEdr4YT1QxzXAW1wHg7dkoEfyTHqZKl0tm/zcqKq/iH7tMA=="], + + "@pixi/settings": ["@pixi/settings@6.5.10", "", { "peerDependencies": { "@pixi/constants": "6.5.10" } }, "sha512-ypAS5L7pQ2Qb88yQK72bXtc7sD8OrtLWNXdZ/gnw5kwSWCFaOSoqhKqJCXrR5DQtN98+RQefwbEAmMvqobhFyw=="], + + "@pixi/sprite": ["@pixi/sprite@6.5.10", "", { "peerDependencies": { "@pixi/constants": "6.5.10", "@pixi/core": "6.5.10", "@pixi/display": "6.5.10", "@pixi/math": "6.5.10", "@pixi/settings": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-UiK+8LgM9XQ/SBDKjRgZ8WggdOSlFRXqiWjEZVmNkiyU8HvXeFzWPRhpc8RR1zDwAUhZWKtMhF8X/ba9m+z2lg=="], + + "@pixi/sprite-animated": ["@pixi/sprite-animated@6.5.10", "", { "peerDependencies": { "@pixi/core": "6.5.10", "@pixi/sprite": "6.5.10", "@pixi/ticker": "6.5.10" } }, "sha512-x1kayucAqpVbNk+j+diC/7sQGQsAl6NCH1J2/EEaiQjlV3GOx1MXS9Tft1N1Y1y7otbg1XsnBd60/Yzcp05pxA=="], + + "@pixi/sprite-tiling": ["@pixi/sprite-tiling@6.5.10", "", { "peerDependencies": { "@pixi/constants": "6.5.10", "@pixi/core": "6.5.10", "@pixi/display": "6.5.10", "@pixi/math": "6.5.10", "@pixi/sprite": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-lDFcPuwExrdJhli+WmjPivChjeCG6NiRl36iQ8n2zVi/MYVv9qfKCA6IdU7HBWk1AZdsg6KUTpwfmVLUI+qz3w=="], + + "@pixi/spritesheet": ["@pixi/spritesheet@6.5.10", "", { "peerDependencies": { "@pixi/core": "6.5.10", "@pixi/loaders": "6.5.10", "@pixi/math": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-7uOZ1cYyYtPb0ZEgXV1SZ8ujtluZNY0TL5z3+Qc8cgGGZK/MaWG7N6Wf+uR4BR2x8FLNwcyN5IjbQDKCpblrmg=="], + + "@pixi/text": ["@pixi/text@6.5.10", "", { "peerDependencies": { "@pixi/core": "6.5.10", "@pixi/math": "6.5.10", "@pixi/settings": "6.5.10", "@pixi/sprite": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-ikwkonLJ+6QmEVW8Ji9fS5CjrKNbU4mHzYuwRQas/VJQuSWgd0myCcaw6ZbF1oSfQe70HgbNOR0sH8Q3Com0qg=="], + + "@pixi/text-bitmap": ["@pixi/text-bitmap@6.5.10", "", { "peerDependencies": { "@pixi/constants": "6.5.10", "@pixi/core": "6.5.10", "@pixi/display": "6.5.10", "@pixi/loaders": "6.5.10", "@pixi/math": "6.5.10", "@pixi/mesh": "6.5.10", "@pixi/settings": "6.5.10", "@pixi/text": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-g/iFIMGp6Pfi0BvX6Ykp48Z6JXVgKOrc7UCIR9CM21wYcCiQGqtdFwstV236xk6/D8NToUtSOcifhtQ28dVTdQ=="], + + "@pixi/ticker": ["@pixi/ticker@6.5.10", "", { "peerDependencies": { "@pixi/extensions": "6.5.10", "@pixi/settings": "6.5.10" } }, "sha512-UqX1XYtzqFSirmTOy8QAK4Ccg4KkIZztrBdRPKwFSOEiKAJoGDCSBmyQBo/9aYQKGObbNnrJ7Hxv3/ucg3/1GA=="], + + "@pixi/utils": ["@pixi/utils@6.5.10", "", { "dependencies": { "@types/earcut": "^2.1.0", "earcut": "^2.2.4", "eventemitter3": "^3.1.0", "url": "^0.11.0" }, "peerDependencies": { "@pixi/constants": "6.5.10", "@pixi/settings": "6.5.10" } }, "sha512-4f4qDMmAz9IoSAe08G2LAxUcEtG9jSdudfsMQT2MG+OpfToirboE6cNoO0KnLCvLzDVE/mfisiQ9uJbVA9Ssdw=="], + "@primer/octicons": ["@primer/octicons@19.33.0", "https://registry.npmmirror.com/@primer/octicons/-/octicons-19.33.0.tgz", { "dependencies": { "object-assign": "^4.1.1" } }, "sha512-kYrlutFnNVWDgckjQwOv9kXiFIPjTD/N+DIx6qzMBAk82nGTE85UKSvFvbt/RUhviNEoCjFoEr4fn5EAbkkUzQ=="], "@radix-ui/number": ["@radix-ui/number@1.1.3", "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.3.tgz", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="], @@ -1008,6 +1082,8 @@ "@types/draco3d": ["@types/draco3d@1.4.10", "https://registry.npmmirror.com/@types/draco3d/-/draco3d-1.4.10.tgz", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="], + "@types/earcut": ["@types/earcut@2.1.4", "", {}, "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ=="], + "@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -1144,12 +1220,18 @@ "aria-hidden": ["aria-hidden@1.2.6", "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "array-union": ["array-union@1.0.2", "", { "dependencies": { "array-uniq": "^1.0.1" } }, "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng=="], + + "array-uniq": ["array-uniq@1.0.3", "", {}, "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q=="], + "assign-symbols": ["assign-symbols@1.0.0", "https://registry.npmmirror.com/assign-symbols/-/assign-symbols-1.0.0.tgz", {}, "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw=="], "ast-types": ["ast-types@0.16.1", "https://registry.npmmirror.com/ast-types/-/ast-types-0.16.1.tgz", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "astring": ["astring@1.9.0", "https://registry.npmmirror.com/astring/-/astring-1.9.0.tgz", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], + "async": ["async@2.6.4", "", { "dependencies": { "lodash": "^4.17.14" } }, "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA=="], + "asynckit": ["asynckit@0.4.0", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "attr-accept": ["attr-accept@2.2.5", "https://registry.npmmirror.com/attr-accept/-/attr-accept-2.2.5.tgz", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="], @@ -1252,6 +1334,8 @@ "commander": ["commander@14.0.3", "https://registry.npmmirror.com/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], + "compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], @@ -1414,6 +1498,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "earcut": ["earcut@2.2.4", "", {}, "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ=="], + "eciesjs": ["eciesjs@0.4.18", "https://registry.npmmirror.com/eciesjs/-/eciesjs-0.4.18.tgz", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ=="], "ee-first": ["ee-first@1.1.1", "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -1422,6 +1508,8 @@ "elkjs": ["elkjs@0.11.1", "https://registry.npmmirror.com/elkjs/-/elkjs-0.11.1.tgz", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="], + "email-addresses": ["email-addresses@3.1.0", "", {}, "sha512-k0/r7GrWVL32kZlGwfPNgB2Y/mMXVTq/decgLczm/j34whdaspNrZO8CnXPf1laaHxI6ptUlsnAxN+UAPw+fzg=="], + "emoji-mart": ["emoji-mart@5.6.0", "https://registry.npmmirror.com/emoji-mart/-/emoji-mart-5.6.0.tgz", {}, "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow=="], "emoji-regex": ["emoji-regex@10.6.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -1536,12 +1624,18 @@ "file-selector": ["file-selector@0.5.0", "https://registry.npmmirror.com/file-selector/-/file-selector-0.5.0.tgz", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA=="], + "filename-reserved-regex": ["filename-reserved-regex@2.0.0", "", {}, "sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ=="], + + "filenamify": ["filenamify@4.3.0", "", { "dependencies": { "filename-reserved-regex": "^2.0.0", "strip-outer": "^1.0.1", "trim-repeated": "^1.0.0" } }, "sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg=="], + "fill-range": ["fill-range@7.1.1", "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "filter-obj": ["filter-obj@5.1.0", "https://registry.npmmirror.com/filter-obj/-/filter-obj-5.1.0.tgz", {}, "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng=="], "finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "find-cache-dir": ["find-cache-dir@3.3.2", "", { "dependencies": { "commondir": "^1.0.1", "make-dir": "^3.0.2", "pkg-dir": "^4.1.0" } }, "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig=="], + "find-root": ["find-root@1.1.0", "https://registry.npmmirror.com/find-root/-/find-root-1.1.0.tgz", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -1568,6 +1662,8 @@ "fs-extra": ["fs-extra@11.3.5", "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.5.tgz", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + "fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -1592,12 +1688,18 @@ "get-value": ["get-value@2.0.6", "https://registry.npmmirror.com/get-value/-/get-value-2.0.6.tgz", {}, "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA=="], + "gh-pages": ["gh-pages@4.0.0", "", { "dependencies": { "async": "^2.6.1", "commander": "^2.18.0", "email-addresses": "^3.0.1", "filenamify": "^4.3.0", "find-cache-dir": "^3.3.1", "fs-extra": "^8.1.0", "globby": "^6.1.0" }, "bin": { "gh-pages": "bin/gh-pages.js", "gh-pages-clean": "bin/gh-pages-clean.js" } }, "sha512-p8S0T3aGJc68MtwOcZusul5qPSNZCalap3NWbhRUZYu1YOdp+EjZ+4kPmRM8h3NNRdqw00yuevRjlkuSzCn7iQ=="], + "giscus": ["giscus@1.6.0", "https://registry.npmmirror.com/giscus/-/giscus-1.6.0.tgz", { "dependencies": { "lit": "^3.2.1" } }, "sha512-Zrsi8r4t1LVW950keaWcsURuZUQwUaMKjvJgTCY125vkW6OiEBkatE7ScJDbpqKHdZwb///7FVC21SE3iFK3PQ=="], + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "globby": ["globby@6.1.0", "", { "dependencies": { "array-union": "^1.0.1", "glob": "^7.0.3", "object-assign": "^4.0.1", "pify": "^2.0.0", "pinkie-promise": "^2.0.0" } }, "sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw=="], + "glsl-noise": ["glsl-noise@0.0.0", "https://registry.npmmirror.com/glsl-noise/-/glsl-noise-0.0.0.tgz", {}, "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w=="], "glur": ["glur@1.1.2", "https://registry.npmmirror.com/glur/-/glur-1.1.2.tgz", {}, "sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA=="], @@ -1688,6 +1790,8 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + "inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inline-style-parser": ["inline-style-parser@0.2.7", "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -1888,6 +1992,8 @@ "magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="], + "mammoth": ["mammoth@1.12.0", "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w=="], "markdown-extensions": ["markdown-extensions@2.0.0", "https://registry.npmmirror.com/markdown-extensions/-/markdown-extensions-2.0.0.tgz", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], @@ -2104,6 +2210,8 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "package-manager-detector": ["package-manager-detector@1.8.0", "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.8.0.tgz", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="], "pako": ["pako@2.0.3", "https://registry.npmmirror.com/pako/-/pako-2.0.3.tgz", {}, "sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw=="], @@ -2144,8 +2252,20 @@ "picomatch": ["picomatch@4.0.5", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + + "pinkie": ["pinkie@2.0.4", "", {}, "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg=="], + + "pinkie-promise": ["pinkie-promise@2.0.1", "", { "dependencies": { "pinkie": "^2.0.0" } }, "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw=="], + + "pixi-live2d-display": ["pixi-live2d-display@0.4.0", "", { "dependencies": { "gh-pages": "^4.0.0" }, "peerDependencies": { "@pixi/core": "^6", "@pixi/display": "^6", "@pixi/loaders": "^6", "@pixi/math": "^6", "@pixi/sprite": "^6", "@pixi/utils": "^6" } }, "sha512-xeYC6y4Y0Bxe9ksWNlGFZC1rII/MPrzPQK7t1c3ubA8RhkOISIqHJl38fNumXqhGEs+yItmgDOkFT+9dsyGDjA=="], + + "pixi.js": ["pixi.js@6.5.10", "", { "dependencies": { "@pixi/accessibility": "6.5.10", "@pixi/app": "6.5.10", "@pixi/compressed-textures": "6.5.10", "@pixi/constants": "6.5.10", "@pixi/core": "6.5.10", "@pixi/display": "6.5.10", "@pixi/extensions": "6.5.10", "@pixi/extract": "6.5.10", "@pixi/filter-alpha": "6.5.10", "@pixi/filter-blur": "6.5.10", "@pixi/filter-color-matrix": "6.5.10", "@pixi/filter-displacement": "6.5.10", "@pixi/filter-fxaa": "6.5.10", "@pixi/filter-noise": "6.5.10", "@pixi/graphics": "6.5.10", "@pixi/interaction": "6.5.10", "@pixi/loaders": "6.5.10", "@pixi/math": "6.5.10", "@pixi/mesh": "6.5.10", "@pixi/mesh-extras": "6.5.10", "@pixi/mixin-cache-as-bitmap": "6.5.10", "@pixi/mixin-get-child-by-name": "6.5.10", "@pixi/mixin-get-global-position": "6.5.10", "@pixi/particle-container": "6.5.10", "@pixi/polyfill": "6.5.10", "@pixi/prepare": "6.5.10", "@pixi/runner": "6.5.10", "@pixi/settings": "6.5.10", "@pixi/sprite": "6.5.10", "@pixi/sprite-animated": "6.5.10", "@pixi/sprite-tiling": "6.5.10", "@pixi/spritesheet": "6.5.10", "@pixi/text": "6.5.10", "@pixi/text-bitmap": "6.5.10", "@pixi/ticker": "6.5.10", "@pixi/utils": "6.5.10" } }, "sha512-Z2mjeoISml2iuVwT1e/BQwERYM2yKoiR08ZdGrg8y5JjeuVptfTrve4DbPMRN/kEDodesgQZGV/pFv0fE9Q2SA=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "https://registry.npmmirror.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="], + "png-chunk-text": ["png-chunk-text@1.0.0", "https://registry.npmmirror.com/png-chunk-text/-/png-chunk-text-1.0.0.tgz", {}, "sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw=="], "png-chunks-encode": ["png-chunks-encode@1.0.0", "https://registry.npmmirror.com/png-chunks-encode/-/png-chunks-encode-1.0.0.tgz", { "dependencies": { "crc-32": "^0.3.0", "sliced": "^1.0.1" } }, "sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA=="], @@ -2176,6 +2296,8 @@ "process-nextick-args": ["process-nextick-args@2.0.1", "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "promise-polyfill": ["promise-polyfill@8.3.0", "", {}, "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg=="], + "promise-worker-transferable": ["promise-worker-transferable@1.0.4", "https://registry.npmmirror.com/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", { "dependencies": { "is-promise": "^2.1.0", "lie": "^3.0.2" } }, "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw=="], "prompts": ["prompts@2.4.2", "https://registry.npmmirror.com/prompts/-/prompts-2.4.2.tgz", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], @@ -2484,6 +2606,8 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strip-outer": ["strip-outer@1.0.1", "", { "dependencies": { "escape-string-regexp": "^1.0.2" } }, "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg=="], + "style-mod": ["style-mod@4.1.3", "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], "style-to-js": ["style-to-js@1.1.21", "https://registry.npmmirror.com/style-to-js/-/style-to-js-1.1.21.tgz", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], @@ -2532,6 +2656,8 @@ "trim-lines": ["trim-lines@3.0.1", "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "trim-repeated": ["trim-repeated@1.0.0", "", { "dependencies": { "escape-string-regexp": "^1.0.2" } }, "sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg=="], + "troika-three-text": ["troika-three-text@0.52.4", "https://registry.npmmirror.com/troika-three-text/-/troika-three-text-0.52.4.tgz", { "dependencies": { "bidi-js": "^1.0.2", "troika-three-utils": "^0.52.4", "troika-worker-utils": "^0.52.0", "webgl-sdf-generator": "1.1.1" }, "peerDependencies": { "three": ">=0.125.0" } }, "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg=="], "troika-three-utils": ["troika-three-utils@0.52.4", "https://registry.npmmirror.com/troika-three-utils/-/troika-three-utils-0.52.4.tgz", { "peerDependencies": { "three": ">=0.125.0" } }, "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A=="], @@ -2594,6 +2720,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "url": ["url@0.11.4", "", { "dependencies": { "punycode": "^1.4.1", "qs": "^6.12.3" } }, "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg=="], + "url-join": ["url-join@5.0.0", "https://registry.npmmirror.com/url-join/-/url-join-5.0.0.tgz", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="], "use-callback-ref": ["use-callback-ref@1.3.3", "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -2772,6 +2900,8 @@ "@pierre/diffs/shiki": ["shiki@4.4.3", "https://registry.npmmirror.com/shiki/-/shiki-4.4.3.tgz", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="], + "@pixi/utils/eventemitter3": ["eventemitter3@3.1.2", "", {}, "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q=="], + "@radix-ui/react-accordion/@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.7.tgz", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], "@radix-ui/react-accordion/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.4", "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.4.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pWJo6lQAfR6uy1n7ii7PaCc9dLPwTXDYbQpORZU5B548Aqvl2pP1SM1vJGKyxIFqZMHRopRO4CQYX2iXAIB5jA=="], @@ -3372,6 +3502,10 @@ "get-intrinsic/hasown": ["hasown@2.0.3", "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + "gh-pages/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "gh-pages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "hast-util-from-parse5/@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "hast-util-raw/@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -3430,6 +3564,8 @@ "parse5/entities": ["entities@6.0.1", "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "points-on-path/points-on-curve": ["points-on-curve@0.2.0", "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], "postcss/nanoid": ["nanoid@3.3.16", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], @@ -3500,12 +3636,16 @@ "stats-gl/three": ["three@0.170.0", "https://registry.npmmirror.com/three/-/three-0.170.0.tgz", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="], + "strip-outer/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + "three-stdlib/fflate": ["fflate@0.6.10", "https://registry.npmmirror.com/fflate/-/fflate-0.6.10.tgz", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="], "tinyglobby/picomatch": ["picomatch@4.0.4", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "tldraw/idb": ["idb@7.1.1", "https://registry.npmmirror.com/idb/-/idb-7.1.1.tgz", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="], + "trim-repeated/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + "tunnel-rat/zustand": ["zustand@4.5.7", "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "type-is/content-type": ["content-type@2.0.0", "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], @@ -3530,6 +3670,8 @@ "unist-util-visit-parents/@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "url/punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="], + "vfile/@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "vfile-location/@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -3816,12 +3958,18 @@ "express/mime-types/mime-db": ["mime-db@1.54.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "gh-pages/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + + "gh-pages/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "leva/@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.9", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.9.tgz", { "dependencies": { "@radix-ui/react-slot": "1.3.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aX5c84AYUD0EIodbQfpujB5XI0CbEaQ5U9du/wntNQK451i5iCJlE2JN6IN1LgQ3SnoBbnW/Jv1Z4rUowZV+hg=="], "mermaid/roughjs/points-on-curve": ["points-on-curve@0.2.0", "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], "parse-json/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "radix-ui/@radix-ui/react-popover/@radix-ui/react-id": ["@radix-ui/react-id@1.1.3", "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.3.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-f/Wxm0ctyMymUJK0fqTSQlm85rbzdAkoNbPXJQ5+6caowVO8Yx+NWGjGz/oGhs/D+WIbbQpOrU0hU2Li2/42xQ=="], "radix-ui/@radix-ui/react-popper/@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.3", "https://registry.npmmirror.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.3.tgz", { "dependencies": { "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-W0GSYZFKEfi6raMiMEfJSngvVbFDIyxtW5JuVg5NoQBY59l0dVQVGLbhog/tOdwq0qtZ+TXuX1ikSNan4/IZXA=="], @@ -3910,6 +4058,8 @@ "leva/@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.2", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.3.2.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-nLBh0hpzgQA16ioKAWTSQvGjRj7QcI3pTpngtYZ12b4RP9N8eGSj7ziceHyHe04zrd0bAcLyMijO0ws0DLmDWw=="], + "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], @@ -3917,5 +4067,7 @@ "leva/@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.7.tgz", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], "leva/@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.4", "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.4.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pWJo6lQAfR6uy1n7ii7PaCc9dLPwTXDYbQpORZU5B548Aqvl2pP1SM1vJGKyxIFqZMHRopRO4CQYX2iXAIB5jA=="], + + "pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], } } diff --git a/web/package.json b/web/package.json index e8932fdb9..992ac4ec4 100644 --- a/web/package.json +++ b/web/package.json @@ -62,6 +62,8 @@ "mammoth": "^1.12.0", "motion": "^12.42.2", "nanoid": "^6.0.0", + "pixi-live2d-display": "0.4.0", + "pixi.js": "6.5.10", "react": "19.2.7", "react-aria-components": "^1.21.1", "react-dom": "19.2.7", diff --git a/web/src/components/canvas/canvas-agent-welcome.tsx b/web/src/components/canvas/canvas-agent-welcome.tsx index 5eecd3fc4..be0750fb3 100644 --- a/web/src/components/canvas/canvas-agent-welcome.tsx +++ b/web/src/components/canvas/canvas-agent-welcome.tsx @@ -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 (
) : null} - - -
@@ -754,6 +767,7 @@ export default function AppearanceSettingsPage() { ), }, + { key: "canvas", label: "画布配置", children: }, ]} /> diff --git a/web/src/pages/admin/settings/components/canvas-appearance-editor.tsx b/web/src/pages/admin/settings/components/canvas-appearance-editor.tsx new file mode 100644 index 000000000..a6f2d3815 --- /dev/null +++ b/web/src/pages/admin/settings/components/canvas-appearance-editor.tsx @@ -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(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) => 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 ( +
+
+

Agent 身份与文案

+

独立于站点品牌。文案可使用 {"{agentName}"} 引用助手名称;这里只配置展示文字,不修改工具权限。

+
+ {( + [ + ["agentName", "助手名称", 24], + ["launcherLabel", "悬浮入口文字(可留空)", 24], + ["panelTitle", "对话面板标题", 40], + ["welcomeTitle", "欢迎标题", 100], + ["welcomeDescription", "欢迎说明", 200], + ["inputPlaceholder", "输入框提示", 160], + ] as const + ).map(([key, label, max]) => ( + + change({ [key]: event.target.value })} /> + + ))} +
+
+
+

Agent 形象

+ change({ avatarType: event.target.value })} + options={[ + { label: "默认动态球", value: "orb" }, + { label: "Live2D", value: "live2d", disabled: !url || readyURL !== url }, + ]} + /> +

上传有授权的 Cubism 3/4 运行时 ZIP:一个 model3.json、moc3、PNG 纹理及可选动作/表情 JSON。首期不支持音频、旧版模型和编辑工程。模型会发送到浏览器,无法保证防下载。

+
+ +

下载前请确认素材授权;官方原始包需按上述要求整理为运行时 ZIP,不能直接整包导入。

+
+ { + void upload(event.target.files?.[0]); + event.currentTarget.value = ""; + }} + /> +
+ + {url ? ( + + ) : null} +
+ +
+ {url ? ( + } + onReady={() => { + setReadyURL(url); + setError(""); + }} + onError={(reason) => { + setReadyURL(""); + setError(reason); + }} + /> + ) : ( + + )} + {agentCopy(value.welcomeTitle, value.agentName)} +

{agentCopy(value.welcomeDescription, value.agentName)}

+ {url ? {readyURL === url ? "预览已就绪,可选择 Live2D 并保存启用" : "等待模型预览就绪"} : null} +
+ {error ? ( + { + setError(""); + setAttempt((n) => n + 1); + }} + > + 重试 + + } + /> + ) : null} +

部署者需自行确认 Live2D 授权并安装 Cubism Core 至 /live2d/live2dcubismcore.min.js。未安装时名称、文案和默认动态球仍可使用。未启用的导入包可在资源管理中清理。

+
+
+ ); +} diff --git a/web/src/services/api/appearance.ts b/web/src/services/api/appearance.ts index 5b544477c..449a78424 100644 --- a/web/src/services/api/appearance.ts +++ b/web/src/services/api/appearance.ts @@ -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("/")}`; +} diff --git a/web/src/services/live2d-loader.ts b/web/src/services/live2d-loader.ts new file mode 100644 index 000000000..a67b71d39 --- /dev/null +++ b/web/src/services/live2d-loader.ts @@ -0,0 +1,29 @@ +import type { Live2DLoader as Loader } from "pixi-live2d-display/cubism4"; +import { apiBaseURL } from "@/services/api/request"; + +const installed = new WeakSet(); + +// 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); + } + }); +} diff --git a/web/src/stores/use-appearance-store.ts b/web/src/stores/use-appearance-store.ts index 399cad5e9..42959f94e 100644 --- a/web/src/stores/use-appearance-store.ts +++ b/web/src/stores/use-appearance-store.ts @@ -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 | nu return { ...DEFAULT_PUBLIC_APPEARANCE, ...value, - schemaVersion: 8, + schemaVersion: 9, + canvas: { ...DEFAULT_CANVAS_APPEARANCE, ...value?.canvas }, brandName: resolvedBrandName, brandSlug, authHeroTitle, diff --git a/web/test/agent-launcher-position.test.mjs b/web/test/agent-launcher-position.test.mjs index 3accc9497..7401dd33c 100644 --- a/web/test/agent-launcher-position.test.mjs +++ b/web/test/agent-launcher-position.test.mjs @@ -5,6 +5,13 @@ import { clampAgentLauncherPosition, moveAgentLauncher, restoreAgentLauncherPosi const viewport = { width: 1280, height: 800 }; const start = { right: 20, bottom: 20 }; +test("Live2D bounds use actual rectangular dimensions when restoring and dragging", () => { + const size = { width: 180, height: 240 }; + const small = { width: 320, height: 400 }; + expect(restoreAgentLauncherPosition('{"right":999,"bottom":999}', small, size)).toEqual({ right: 120, bottom: 140 }); + expect(moveAgentLauncher({ start, x: 0, y: 0, dragged: true }, -999, -999, small, size).position).toEqual({ right: 120, bottom: 140 }); +}); + test("launcher restores a valid position and defaults safely for corrupt preferences", () => { expect(restoreAgentLauncherPosition('{"right":240,"bottom":180}', viewport)).toEqual({ right: 240, bottom: 180 }); for (const raw of [null, "invalid", "null", "{}", '{"right":"20","bottom":20}', '{"right":1e999,"bottom":20}']) { diff --git a/web/test/agent-panel-presentation.test.tsx b/web/test/agent-panel-presentation.test.tsx index 061585025..59aad6070 100644 --- a/web/test/agent-panel-presentation.test.tsx +++ b/web/test/agent-panel-presentation.test.tsx @@ -4,11 +4,12 @@ import { AgentWelcome } from "@/components/canvas/canvas-agent-welcome"; import { AgentChatComposer } from "@/components/canvas/canvas-cloud-agent-chat-ui"; import { agentPermissionVisual } from "@/components/canvas/canvas-cloud-agent-settings"; import { canvasThemes } from "@/lib/canvas-theme"; +import { DEFAULT_CANVAS_APPEARANCE, agentCopy } from "@/lib/canvas/agent-appearance"; const noop = () => {}; -test("welcome uses the configured brand and escapes it as text", () => { - const html = renderToStaticMarkup(); +test("welcome uses the independent assistant name and escapes it as text", () => { + const html = renderToStaticMarkup(" }} nodeCount={2} onChooseSkill={noop} onDraftPrompt={noop} />); expect(html).toContain("在这里,和我的<工作室>让灵感,慢慢成形"); expect(html).toContain("2 个节点"); expect(html.match(/type="button"/g)).toHaveLength(3); @@ -16,12 +17,16 @@ test("welcome uses the configured brand and escapes it as text", () => { }); test("empty canvas disables only the canvas-analysis shortcut", () => { - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup(); expect(html.match(/disabled=""/g)).toHaveLength(1); expect(html).toContain("添加节点后,一起梳理创作思路"); expect(html).not.toContain("生成前由你确认"); }); +test("assistant template replacement treats names literally", () => { + expect(agentCopy("{agentName} / {agentName}", "$&小鱼")).toBe("$&小鱼 / $&小鱼"); +}); + test("permission modes have distinct icons, not just distinct colors", () => { const icons = (["read_only", "request_approval", "auto"] as const).map((mode) => { const Icon = agentPermissionVisual(mode).icon; diff --git a/web/test/live2d-core-adapter.test.ts b/web/test/live2d-core-adapter.test.ts new file mode 100644 index 000000000..23b84077b --- /dev/null +++ b/web/test/live2d-core-adapter.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { adaptLive2DRenderOrders } from "../src/lib/canvas/live2d-core-adapter"; + +describe("Live2D Core render orders", () => { + test("preserves the legacy Core interface", () => { + const orders = new Int32Array([0]); + const original = () => orders; + const model = { getModel: () => ({ drawables: { count: 1, renderOrders: orders } }), getDrawableRenderOrders: original }; + adaptLive2DRenderOrders(model); + expect(model.getDrawableRenderOrders).toBe(original); + }); + test("reads current orders from the new Core on every frame", () => { + let orders = new Int32Array([1, 0]); + const model = { getModel: () => ({ drawables: { count: 2 }, offscreens: { count: 0 }, getRenderOrders: () => orders }), getDrawableRenderOrders: () => new Int32Array() }; + adaptLive2DRenderOrders(model); + expect(model.getDrawableRenderOrders()).toBe(orders); + orders = new Int32Array([0, 1]); + expect(model.getDrawableRenderOrders()).toBe(orders); + }); + test("rejects unsupported offscreens", () => { + const model = { getModel: () => ({ drawables: { count: 1 }, offscreens: { count: 1 } }), getDrawableRenderOrders: () => new Int32Array() }; + expect(() => adaptLive2DRenderOrders(model)).toThrow("离屏渲染"); + }); + test("rejects missing or inconsistent orders", () => { + expect(() => adaptLive2DRenderOrders({ getModel: () => ({ drawables: { count: 1 } }), getDrawableRenderOrders: () => new Int32Array() })).toThrow("缺少"); + expect(() => adaptLive2DRenderOrders({ getModel: () => ({ drawables: { count: 1 }, getRenderOrders: () => new Int32Array() }), getDrawableRenderOrders: () => new Int32Array() })).toThrow("不匹配"); + }); +}); diff --git a/web/test/live2d-loader.test.ts b/web/test/live2d-loader.test.ts new file mode 100644 index 000000000..6aece29b2 --- /dev/null +++ b/web/test/live2d-loader.test.ts @@ -0,0 +1,47 @@ +import { afterEach, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { installLive2DLoader } from "../src/services/live2d-loader"; +import type { Live2DLoader, Live2DLoaderContext } from "pixi-live2d-display/cubism4"; + +const originalFetch = globalThis.fetch; +const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); +afterEach(() => { + globalThis.fetch = originalFetch; + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow); + else Reflect.deleteProperty(globalThis, "window"); +}); + +test("Live2D loader restricts credentialed URLs, propagates failures and aborts on destroy", async () => { + Object.defineProperty(globalThis, "window", { configurable: true, value: { location: { href: "https://canvas.example/admin" } } }); + const loader = { middlewares: [] } as unknown as typeof Live2DLoader; + installLive2DLoader(loader); + installLive2DLoader(loader); + expect(loader.middlewares).toHaveLength(1); + const run = (context: Live2DLoaderContext) => loader.middlewares[0](context, async () => {}); + const url = "/api/admin/settings/appearance/live2d/id/model.model3.json"; + let requests = 0; + globalThis.fetch = (async (_url: unknown, options: RequestInit) => { + requests++; + expect(options.credentials).toBe("include"); + return new Response('{"Version":3}', { status: 200 }); + }) as typeof fetch; + const context = { url, type: "json" } as Live2DLoaderContext; + await run(context); + expect(context.result).toEqual({ Version: 3 }); + for (const unsafe of ["https://evil.example" + url, "/api/admin/users", "//evil.example/model.json"]) { + await expect(run({ url: unsafe, type: "json" })).rejects.toThrow("本站模型接口"); + } + expect(requests).toBe(1); + globalThis.fetch = (async () => new Response("denied", { status: 403 })) as typeof fetch; + await expect(run({ url, type: "json" })).rejects.toThrow("403"); + + const target = new EventEmitter(); + globalThis.fetch = ((_url: unknown, options: RequestInit) => + new Promise((_resolve, reject) => { + options.signal?.addEventListener("abort", () => reject(new Error("aborted"))); + })) as typeof fetch; + const pending = run({ url, type: "json", target: target as unknown as Live2DLoaderContext["target"] }); + target.emit("destroy"); + await expect(pending).rejects.toThrow("aborted"); + expect(target.listenerCount("destroy")).toBe(0); +});