From 068a8daea64aa8a93daa3317da91519324f9efaa Mon Sep 17 00:00:00 2001
From: ddCat <2695092045@qq.com>
Date: Tue, 8 Sep 2026 17:26:34 +0800
Subject: [PATCH] =?UTF-8?q?feat(*):=20=E5=88=9B=E4=BD=9C=E5=B7=A5=E4=BD=9C?=
=?UTF-8?q?=E5=8F=B0=20-=20=E5=AE=8C=E5=96=84=E5=AA=92=E4=BD=93=E4=B8=8A?=
=?UTF-8?q?=E4=BC=A0=E9=A2=84=E8=A7=88=E4=B8=8E=E8=BF=9E=E7=BA=BF=E4=BA=A4?=
=?UTF-8?q?=E4=BA=92=E5=B9=B6=E4=BF=AE=E5=A4=8D=E4=BB=BB=E5=8A=A1=E7=AB=A0?=
=?UTF-8?q?=E8=8A=82=E5=8F=8A=E6=97=A5=E5=BF=97=E7=AD=9B=E9=80=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/internal/handler/auth.go | 4 +-
backend/internal/repository/analytics.go | 20 +-
backend/internal/repository/analytics_test.go | 39 +
backend/internal/service/analytics.go | 21 +-
.../service/chapter_assets_prompt_test.go | 27 +
backend/internal/service/prompt_template.go | 29 +-
.../service/prompt_template_defaults.go | 34 +
canvas-agent/pnpm-lock.yaml | 2012 +++++++++++++++++
.../docs/backend/task-runtime-stability.mdx | 8 +
docs/content/docs/overview/features.mdx | 10 +-
docs/content/docs/progress/pending-test.mdx | 48 +
.../canvas/canvas-file-upload-content.tsx | 27 +
.../canvas/canvas-node-action-context.ts | 1 +
.../components/canvas/canvas-node-content.tsx | 11 +
web/src/components/canvas/canvas-node.tsx | 14 +-
web/src/components/canvas/canvas-toolbar.tsx | 8 +-
.../components/canvas/canvas-upload-modal.tsx | 19 +-
web/src/components/canvas/infinite-canvas.tsx | 1 +
.../lib/canvas/canvas-character-reference.ts | 5 +-
web/src/lib/canvas/canvas-connection-tilt.ts | 18 +
web/src/lib/canvas/canvas-file-upload.ts | 87 +
.../lib/canvas/canvas-generation-task-sync.ts | 5 +-
web/src/lib/canvas/canvas-image-source.ts | 30 +
.../lib/canvas/canvas-project-generation.ts | 3 +-
.../lib/canvas/canvas-resource-references.ts | 5 +-
.../lib/canvas/canvas-video-hover-preview.ts | 93 +
web/src/lib/canvas/chapter-asset-breakdown.ts | 27 +
.../definitions/node-hover-tools.tsx | 4 +-
web/src/pages/admin/logs/logs-page.tsx | 16 +-
.../pages/canvas/canvas-project-feedback.tsx | 3 +-
.../canvas/canvas-project-world-layers.tsx | 3 +
web/src/pages/canvas/project.tsx | 8 +-
.../use-canvas-connection-controller.ts | 9 +-
.../pages/canvas/use-canvas-media-tools.ts | 13 +-
web/src/pages/canvas/use-canvas-upload.ts | 270 +--
web/src/pages/plugins/index.tsx | 9 +-
web/src/pages/projects/detail/chapters.tsx | 42 +-
.../projects/detail/project-chapter-ai.ts | 21 +-
web/src/pages/tasks/index.tsx | 1 -
web/src/pages/tasks/task-grid-card.tsx | 3 +-
web/src/pages/tasks/task-list-row.tsx | 3 +-
web/src/pages/tasks/task-video-thumbnail.tsx | 41 +
web/src/services/api/auth.ts | 6 +-
web/src/services/api/resources.ts | 12 +-
web/src/services/file-storage.ts | 4 +-
web/src/services/image-storage.ts | 4 +-
web/src/styles/globals.css | 76 +
web/src/types/canvas.ts | 3 +
web/test/canvas-connection-tilt.test.ts | 47 +
web/test/canvas-image-source.test.ts | 58 +
web/test/canvas-image-upload.test.tsx | 140 ++
web/test/canvas-resource-references.test.ts | 4 +-
web/test/canvas-video-hover-preview.test.ts | 90 +
web/test/chapter-asset-breakdown.test.ts | 21 +
web/test/generation-task-local.test.ts | 9 +-
web/test/resource-upload-progress.test.ts | 64 +
56 files changed, 3316 insertions(+), 274 deletions(-)
create mode 100644 backend/internal/service/chapter_assets_prompt_test.go
create mode 100644 canvas-agent/pnpm-lock.yaml
create mode 100644 web/src/components/canvas/canvas-file-upload-content.tsx
create mode 100644 web/src/lib/canvas/canvas-connection-tilt.ts
create mode 100644 web/src/lib/canvas/canvas-file-upload.ts
create mode 100644 web/src/lib/canvas/canvas-image-source.ts
create mode 100644 web/src/lib/canvas/canvas-video-hover-preview.ts
create mode 100644 web/src/lib/canvas/chapter-asset-breakdown.ts
create mode 100644 web/src/pages/tasks/task-video-thumbnail.tsx
create mode 100644 web/test/canvas-connection-tilt.test.ts
create mode 100644 web/test/canvas-image-source.test.ts
create mode 100644 web/test/canvas-image-upload.test.tsx
create mode 100644 web/test/canvas-video-hover-preview.test.ts
create mode 100644 web/test/chapter-asset-breakdown.test.ts
create mode 100644 web/test/resource-upload-progress.test.ts
diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go
index 5557b2d79..390695f61 100644
--- a/backend/internal/handler/auth.go
+++ b/backend/internal/handler/auth.go
@@ -683,7 +683,7 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
- logs, err := svc.AdminAPICallLogs(user, service.APICallLogQuery{AnalyticsQuery: analyticsQuery(c), Keyword: c.Query("keyword"), Status: c.Query("status"), Page: page, Limit: limit})
+ logs, err := svc.AdminAPICallLogs(user, service.APICallLogQuery{AnalyticsQuery: analyticsQuery(c), RecordType: c.Query("recordType"), Keyword: c.Query("keyword"), Status: c.Query("status"), Page: page, Limit: limit})
if err != nil {
failService(c, err)
return
@@ -776,7 +776,7 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
if value := strings.TrimSpace(c.Query("ids")); value != "" {
selectedIDs = strings.Split(value, ",")
}
- data, err := svc.AdminAPICallLogsCSV(user, service.APICallLogQuery{AnalyticsQuery: analyticsQuery(c), Keyword: c.Query("keyword"), Status: c.Query("status"), IDs: selectedIDs})
+ data, err := svc.AdminAPICallLogsCSV(user, service.APICallLogQuery{AnalyticsQuery: analyticsQuery(c), RecordType: c.Query("recordType"), Keyword: c.Query("keyword"), Status: c.Query("status"), IDs: selectedIDs})
if err != nil {
failService(c, err)
return
diff --git a/backend/internal/repository/analytics.go b/backend/internal/repository/analytics.go
index ef091ffd7..f4e261c34 100644
--- a/backend/internal/repository/analytics.go
+++ b/backend/internal/repository/analytics.go
@@ -21,11 +21,12 @@ type AnalyticsFilter struct {
type APICallLogFilter struct {
AnalyticsFilter
- Keyword string
- Status string
- IDs []string
- Page int
- Limit int
+ RecordType string
+ Keyword string
+ Status string
+ IDs []string
+ Page int
+ Limit int
}
func (r *Repository) RecordUserActivity(userID string, event string, count int, now time.Time) error {
@@ -137,7 +138,14 @@ func (r *Repository) ExportAPICallLogs(filter APICallLogFilter, limit int) ([]mo
}
func (r *Repository) filteredAPICallLogQuery(filter APICallLogFilter) *gorm.DB {
- query := visibleAPICallLogQuery(r.apiCallLogQuery(filter.AnalyticsFilter))
+ query := r.apiCallLogQuery(filter.AnalyticsFilter)
+ switch filter.RecordType {
+ case "download":
+ query = query.Where("api_call_logs.request_kind = ?", "download")
+ case "all":
+ default:
+ query = visibleAPICallLogQuery(query).Where("COALESCE(api_call_logs.request_kind, '') <> ?", "download")
+ }
if value := strings.TrimSpace(filter.Keyword); value != "" {
pattern := "%" + strings.ToLower(value) + "%"
query = query.
diff --git a/backend/internal/repository/analytics_test.go b/backend/internal/repository/analytics_test.go
index e585b1a63..bf8ac0c9b 100644
--- a/backend/internal/repository/analytics_test.go
+++ b/backend/internal/repository/analytics_test.go
@@ -18,6 +18,45 @@ type sqlCaptureLogger struct {
statements []string
}
+func TestAPICallLogRecordTypeFiltersListAndExport(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open("file:api-log-record-types?mode=memory&cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.AutoMigrate(&model.ApiCallLog{}); err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now()
+ for _, item := range []model.ApiCallLog{
+ {ID: "request", Capability: "video", RequestKind: "create", CreatedAt: now},
+ {ID: "poll", Capability: "video", RequestKind: "poll", CreatedAt: now},
+ {ID: "video-download", Capability: "video", RequestKind: "download", CreatedAt: now},
+ {ID: "image-download", Capability: "image", RequestKind: "download", CreatedAt: now},
+ } {
+ if err := db.Create(&item).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ repo := New(db)
+ for _, tc := range []struct {
+ kind string
+ count int
+ }{{"", 1}, {"request", 1}, {"download", 2}, {"all", 4}} {
+ filter := APICallLogFilter{AnalyticsFilter: AnalyticsFilter{From: now.Add(-time.Hour), To: now.Add(time.Hour)}, RecordType: tc.kind}
+ logs, total, err := repo.QueryAPICallLogs(filter)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(logs) != tc.count || total != int64(tc.count) {
+ t.Fatalf("kind=%s: count=%d total=%d", tc.kind, len(logs), total)
+ }
+ exported, err := repo.ExportAPICallLogs(filter, 100)
+ if err != nil || len(exported) != tc.count {
+ t.Fatalf("kind=%s: export count=%d err=%v", tc.kind, len(exported), err)
+ }
+ }
+}
+
func (l *sqlCaptureLogger) LogMode(logger.LogLevel) logger.Interface { return l }
func (*sqlCaptureLogger) Info(context.Context, string, ...any) {}
func (*sqlCaptureLogger) Warn(context.Context, string, ...any) {}
diff --git a/backend/internal/service/analytics.go b/backend/internal/service/analytics.go
index 6f685997d..a61d26e52 100644
--- a/backend/internal/service/analytics.go
+++ b/backend/internal/service/analytics.go
@@ -103,11 +103,12 @@ type AnalyticsFailureRow struct {
type APICallLogQuery struct {
AnalyticsQuery
- Keyword string
- Status string
- IDs []string
- Page int
- Limit int
+ RecordType string
+ Keyword string
+ Status string
+ IDs []string
+ Page int
+ Limit int
}
type APICallLogPage struct {
@@ -189,8 +190,11 @@ func (s *Service) AdminAPICallLogs(actor *model.User, query APICallLogQuery) (*A
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
+ if query.RecordType != "" && query.RecordType != "request" && query.RecordType != "download" && query.RecordType != "all" {
+ return nil, BadAuthRequest("请求明细类型无效")
+ }
filter := normalizeAnalyticsFilter(query.AnalyticsQuery)
- logs, total, err := s.repo.QueryAPICallLogs(repository.APICallLogFilter{AnalyticsFilter: filter, Keyword: query.Keyword, Status: query.Status, Page: query.Page, Limit: query.Limit})
+ logs, total, err := s.repo.QueryAPICallLogs(repository.APICallLogFilter{AnalyticsFilter: filter, RecordType: query.RecordType, Keyword: query.Keyword, Status: query.Status, Page: query.Page, Limit: query.Limit})
if err != nil {
return nil, err
}
@@ -378,6 +382,9 @@ func (s *Service) AdminAPICallLogsCSV(actor *model.User, query APICallLogQuery)
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
+ if query.RecordType != "" && query.RecordType != "request" && query.RecordType != "download" && query.RecordType != "all" {
+ return nil, BadAuthRequest("请求明细类型无效")
+ }
filter := normalizeAnalyticsFilter(query.AnalyticsQuery)
ids := uniqueNonEmpty(query.IDs)
if len(query.IDs) > 0 && len(ids) == 0 {
@@ -386,7 +393,7 @@ func (s *Service) AdminAPICallLogsCSV(actor *model.User, query APICallLogQuery)
if len(ids) > 200 {
return nil, BadAuthRequest("单次最多导出 200 条已选请求明细")
}
- logs, err := s.repo.ExportAPICallLogs(repository.APICallLogFilter{AnalyticsFilter: filter, Keyword: query.Keyword, Status: query.Status, IDs: ids}, 10_000)
+ logs, err := s.repo.ExportAPICallLogs(repository.APICallLogFilter{AnalyticsFilter: filter, RecordType: query.RecordType, Keyword: query.Keyword, Status: query.Status, IDs: ids}, 10_000)
if err != nil {
return nil, err
}
diff --git a/backend/internal/service/chapter_assets_prompt_test.go b/backend/internal/service/chapter_assets_prompt_test.go
new file mode 100644
index 000000000..7cf04110c
--- /dev/null
+++ b/backend/internal/service/chapter_assets_prompt_test.go
@@ -0,0 +1,27 @@
+package service
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestChapterAssetsPromptContract(t *testing.T) {
+ if !json.Valid([]byte(chapterAssetsJSONSchema)) {
+ t.Fatal("invalid chapter assets schema")
+ }
+ for _, tc := range []struct {
+ text string
+ valid bool
+ }{
+ {`{"characters":[],"scenes":[{"name":"车站","description":"空旷站台","prompt":"夜间站台"}],"props":[{"name":"信封","description":"牛皮纸信封","prompt":"信封特写"}]}`, true},
+ {`{"characters":[],"scenes":[],"props":[]}`, true},
+ {`{"characters":[]}`, false},
+ {`{"characters":[],"scenes":[{"name":"车站"}],"props":[]}`, false},
+ {`{"characters":[{"name":"张三"}],"scenes":[],"props":[]}`, false},
+ } {
+ err := validatePromptTemplateResult(promptOperationChapterAssetsExtract, map[string]interface{}{"text": tc.text})
+ if (err == nil) != tc.valid {
+ t.Fatalf("valid=%v err=%v", tc.valid, err)
+ }
+ }
+}
diff --git a/backend/internal/service/prompt_template.go b/backend/internal/service/prompt_template.go
index f2e095764..7b243ad27 100644
--- a/backend/internal/service/prompt_template.go
+++ b/backend/internal/service/prompt_template.go
@@ -382,13 +382,40 @@ func validatePromptTemplateResult(operation string, result map[string]interface{
extractor := extractJSONText
// 角色卡契约要求顶层对象,模型正文里若先出现数组(例如先列一遍角色名)会让通用提取命中错误片段,
// 因此这里优先挑出带 characters 键的对象候选。
- if operation == promptOperationCharacterExtract {
+ if operation == promptOperationCharacterExtract || operation == promptOperationChapterAssetsExtract {
extractor = func(raw string) (string, error) { return extractPreferredJSONText(raw, "characters") }
}
jsonText, err := extractor(text)
if err != nil {
return fmt.Errorf("%s 返回内容不符合受保护 JSON 契约:%w", definition.Label, err)
}
+ if operation == promptOperationChapterAssetsExtract {
+ var assets struct {
+ Characters *[]map[string]interface{} `json:"characters"`
+ Scenes *[]map[string]interface{} `json:"scenes"`
+ Props *[]map[string]interface{} `json:"props"`
+ }
+ if err := json.Unmarshal([]byte(jsonText), &assets); err != nil {
+ return fmt.Errorf("章节资产提取 JSON 无法解析:%w", err)
+ }
+ if assets.Characters == nil || assets.Scenes == nil || assets.Props == nil {
+ return errors.New("章节资产提取结果必须包含 characters、scenes 和 props 数组")
+ }
+ for _, group := range [][]map[string]interface{}{*assets.Scenes, *assets.Props} {
+ for _, asset := range group {
+ for _, field := range []string{"name", "description", "prompt"} {
+ value, ok := asset[field].(string)
+ if !ok || strings.TrimSpace(value) == "" {
+ return fmt.Errorf("章节场景或道具缺少有效的 %s", field)
+ }
+ }
+ }
+ }
+ if len(*assets.Characters) > 0 {
+ return validatePromptTemplateResult(promptOperationCharacterExtract, map[string]interface{}{"text": jsonText})
+ }
+ return nil
+ }
if operation != promptOperationCharacterExtract {
return nil
}
diff --git a/backend/internal/service/prompt_template_defaults.go b/backend/internal/service/prompt_template_defaults.go
index 8684538cc..3fb956af7 100644
--- a/backend/internal/service/prompt_template_defaults.go
+++ b/backend/internal/service/prompt_template_defaults.go
@@ -11,6 +11,7 @@ const (
promptOperationStoryboardFirstFrame = "storyboard_first_frame"
promptOperationStoryboardVideo = "storyboard_video"
promptOperationCharacterExtract = "character_extract"
+ promptOperationChapterAssetsExtract = "chapter_assets_extract"
promptOperationCharacterTurnaround = "character_turnaround"
)
@@ -18,6 +19,12 @@ const legacyStoryboardVideoPromptPreamble = "生成单一连续镜头的视频
func defaultPromptDefinitions() []PromptOperationDefinition {
return []PromptOperationDefinition{
+ {
+ Operation: promptOperationChapterAssetsExtract, Label: "章节角色、场景与道具提取", Category: "角色", OutputType: "json", SchemaKey: "chapter-assets/v1",
+ Description: "从章节正文提取角色、实际发生剧情的场景和影响剧情的道具,分别进入资产待确认列表。",
+ Variables: []PromptTemplateVariable{{Label: "项目名称", Placeholder: "{{项目名称}}"}, {Label: "章节名称", Placeholder: "{{章节名称}}"}, {Label: "项目画风", Placeholder: "{{项目画风}}"}},
+ DefaultContent: `你是短剧资产导演。按章节事实提取角色、场景和道具。同一身份的别名合并,不编造正文未出现的资产;未明确的视觉信息写“正文未明确”。角色包含剧情定位、稳定外貌服装体态、性格、一致性提示、三视图提示及语言、声音年龄、音色。场景描述空间布局、环境和光照;道具描述材质、形状及剧情用途。无对应资产时返回空数组。`,
+ },
{
Operation: promptOperationStoryboardPlan, Label: "分镜规划", Category: "分镜", OutputType: "json", SchemaKey: "storyboard-plan/v3",
Description: "把剧情、项目画风和当前角色版本规划为可执行镜头。",
@@ -105,6 +112,13 @@ func protectedPromptContext(operation string, values map[string]string) string {
return storyboardRepairProtectedContext(values)
case promptOperationCharacterExtract:
return characterExtractProtectedContext(values)
+ case promptOperationChapterAssetsExtract:
+ return strings.Join([]string{
+ fmt.Sprintf("【任务】\n从短剧项目《%s》的章节“%s”提取角色、场景和道具。", values["项目名称"], values["章节名称"]),
+ "【项目画风】\n" + values["项目画风"],
+ "【章节正文】\n" + values["章节正文"],
+ "【受保护输出契约】\n" + promptOutputContract(operation),
+ }, "\n\n")
case promptOperationCharacterTurnaround:
return strings.Join([]string{"【角色名称】\n" + values["角色名称"], "【项目画风】\n" + values["项目画风"], "【角色设定】\n" + values["角色设定"]}, "\n\n")
default:
@@ -118,6 +132,8 @@ func promptOutputContract(operation string) string {
return "服务端固定 JSON Schema storyboard-plan/v3(不可由运营模板或用户定制覆盖):\n" + storyboardPlanJSONSchema
case promptOperationCharacterExtract:
return "服务端固定 JSON Schema character-breakdown/v1(不可由运营模板或用户定制覆盖):\n" + characterBreakdownJSONSchema
+ case promptOperationChapterAssetsExtract:
+ return "服务端固定 JSON Schema chapter-assets/v1(不可由运营模板或用户定制覆盖):\n" + chapterAssetsJSONSchema
default:
return "当前操作输出普通文本提示词,没有 JSON Schema。"
}
@@ -182,6 +198,24 @@ const storyboardPlanJSONSchema = `{
}
}`
+const chapterAssetsJSONSchema = `{
+ "type": "object", "additionalProperties": false,
+ "required": ["characters", "scenes", "props"],
+ "properties": {
+ "characters": {"$ref": "#/$defs/characterBreakdown/properties/characters"},
+ "scenes": {"type": "array", "items": {"$ref": "#/$defs/asset"}},
+ "props": {"type": "array", "items": {"$ref": "#/$defs/asset"}}
+ },
+ "$defs": {
+ "asset": {
+ "type": "object", "additionalProperties": false,
+ "required": ["name", "description", "prompt"],
+ "properties": {"name": {"type": "string"}, "description": {"type": "string"}, "prompt": {"type": "string"}}
+ },
+ "characterBreakdown": ` + characterBreakdownJSONSchema + `
+ }
+}`
+
const characterBreakdownJSONSchema = `{
"type": "object",
"additionalProperties": false,
diff --git a/canvas-agent/pnpm-lock.yaml b/canvas-agent/pnpm-lock.yaml
new file mode 100644
index 000000000..e2eb40f97
--- /dev/null
+++ b/canvas-agent/pnpm-lock.yaml
@@ -0,0 +1,2012 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@modelcontextprotocol/sdk':
+ specifier: ^1.30.0
+ version: 1.30.0(zod@3.25.76)
+ '@openai/codex':
+ specifier: ^0.149.1
+ version: 0.149.1
+ docx:
+ specifier: ^9.5.1
+ version: 9.7.1
+ express:
+ specifier: ^5.2.1
+ version: 5.2.1
+ json-canonicalize:
+ specifier: 2.0.0
+ version: 2.0.0
+ onnxruntime-node:
+ specifier: ^1.22.0
+ version: 1.29.0
+ playwright-core:
+ specifier: ^1.55.0
+ version: 1.62.1
+ sharp:
+ specifier: ^0.34.3
+ version: 0.34.5
+ zod:
+ specifier: ^3.25.76
+ version: 3.25.76
+ devDependencies:
+ '@types/express':
+ specifier: ^5.0.6
+ version: 5.0.6
+ '@types/node':
+ specifier: ^22.20.1
+ version: 22.20.1
+ tsx:
+ specifier: ^4.23.1
+ version: 4.23.12
+ typescript:
+ specifier: ^7.0.2
+ version: 7.0.2
+
+packages:
+
+ '@emnapi/runtime@1.11.3':
+ resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
+
+ '@esbuild/aix-ppc64@0.28.2':
+ resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.28.2':
+ resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.28.2':
+ resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.28.2':
+ resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.28.2':
+ resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.28.2':
+ resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.28.2':
+ resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.28.2':
+ resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.28.2':
+ resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.28.2':
+ resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.28.2':
+ resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.28.2':
+ resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.28.2':
+ resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.28.2':
+ resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.28.2':
+ resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.28.2':
+ resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.28.2':
+ resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.28.2':
+ resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.28.2':
+ resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.28.2':
+ resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.28.2':
+ resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.28.2':
+ resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.28.2':
+ resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.28.2':
+ resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@hono/node-server@2.1.1':
+ resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==}
+ engines: {node: '>=20'}
+ peerDependencies:
+ hono: ^4
+
+ '@img/colour@1.1.0':
+ resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
+ engines: {node: '>=18'}
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-darwin-x64@0.34.5':
+ resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linux-arm64@0.34.5':
+ resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linux-arm@0.34.5':
+ resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@img/sharp-linux-s390x@0.34.5':
+ resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-linux-x64@0.34.5':
+ resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-wasm32@0.34.5':
+ resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [wasm32]
+
+ '@img/sharp-win32-arm64@0.34.5':
+ resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@img/sharp-win32-ia32@0.34.5':
+ resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ia32]
+ os: [win32]
+
+ '@img/sharp-win32-x64@0.34.5':
+ resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@modelcontextprotocol/sdk@1.30.0':
+ resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@cfworker/json-schema': ^4.1.1
+ zod: ^3.25 || ^4.0
+ peerDependenciesMeta:
+ '@cfworker/json-schema':
+ optional: true
+
+ '@openai/codex@0.149.1':
+ resolution: {integrity: sha512-6q5pbcpFbJbqOpkubSDBwXmktQ55aD8eUzGzBF1zASob2DjwhBKDSNGtdZKalfrNJUdTDTPDMmzCXEXs5tMBYA==}
+ engines: {node: '>=16'}
+ hasBin: true
+
+ '@openai/codex@0.149.1-darwin-arm64':
+ resolution: {integrity: sha512-6X84kTCbnTgPIJ2EdcPsrvwS0Wxsqpa+bCswGmRf4BjhcQ5nPMnBC6yCAaCMj+vrbXQHj+L6sa9FaR4QkmA1qw==}
+ engines: {node: '>=16'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@openai/codex@0.149.1-darwin-x64':
+ resolution: {integrity: sha512-MfLBQLfcElJL9tvj6y45qVHHMGSXCPnQOixuD3/Zq0g1BW/eFizkrGLdn48cFpc+l8cK+gt5nYG5pQYwVs6g4A==}
+ engines: {node: '>=16'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@openai/codex@0.149.1-linux-arm64':
+ resolution: {integrity: sha512-OqxUfZ1TVvHd18zHPKK/8ZRlpk8Vy11mg5CMHaLxNWldTbwVImDKtSLWT+m8m4NM5Sz4PbjtZMrVT/RfpBW/mQ==}
+ engines: {node: '>=16'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@openai/codex@0.149.1-linux-x64':
+ resolution: {integrity: sha512-Of5fGYgr7tAMsyj6vhXb4/RM/UoA3Zq8BLegUBDC09UNy1XTLGYP/2XD+UX8z3qh0NDwxYdCjFIWdDNijKZggQ==}
+ engines: {node: '>=16'}
+ cpu: [x64]
+ os: [linux]
+
+ '@openai/codex@0.149.1-win32-arm64':
+ resolution: {integrity: sha512-5K0DmOKGK9Bos627p8sK8ATHjovPK0sDyT6h9Cb+4v+5CW5SGw1HLgjGxoLfJ8g3cg6mtg/pRCXXo2L/j71UVA==}
+ engines: {node: '>=16'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@openai/codex@0.149.1-win32-x64':
+ resolution: {integrity: sha512-G3QXGAg7nyyhqOeooAMUekBCeHd8a1QByhKcVAFyzNBaI06t6Ft7nsF+1SzFS0spuIdU4YyMi5YD26ukADBQUQ==}
+ engines: {node: '>=16'}
+ cpu: [x64]
+ os: [win32]
+
+ '@types/body-parser@1.19.6':
+ resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
+
+ '@types/connect@3.4.38':
+ resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
+
+ '@types/express-serve-static-core@5.1.3':
+ resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==}
+
+ '@types/express@5.0.6':
+ resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
+
+ '@types/http-errors@2.0.5':
+ resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
+
+ '@types/node@22.20.1':
+ resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
+
+ '@types/node@25.9.5':
+ resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==}
+
+ '@types/qs@6.15.1':
+ resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
+
+ '@types/range-parser@1.2.7':
+ resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
+
+ '@types/send@1.2.1':
+ resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
+
+ '@types/serve-static@2.2.0':
+ resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
+
+ '@typescript/typescript-aix-ppc64@7.0.2':
+ resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@typescript/typescript-darwin-arm64@7.0.2':
+ resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@typescript/typescript-darwin-x64@7.0.2':
+ resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@typescript/typescript-freebsd-arm64@7.0.2':
+ resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@typescript/typescript-freebsd-x64@7.0.2':
+ resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@typescript/typescript-linux-arm64@7.0.2':
+ resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@typescript/typescript-linux-arm@7.0.2':
+ resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm]
+ os: [linux]
+
+ '@typescript/typescript-linux-loong64@7.0.2':
+ resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@typescript/typescript-linux-mips64el@7.0.2':
+ resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@typescript/typescript-linux-ppc64@7.0.2':
+ resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@typescript/typescript-linux-riscv64@7.0.2':
+ resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@typescript/typescript-linux-s390x@7.0.2':
+ resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
+ engines: {node: '>=16.20.0'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@typescript/typescript-linux-x64@7.0.2':
+ resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [linux]
+
+ '@typescript/typescript-netbsd-arm64@7.0.2':
+ resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@typescript/typescript-netbsd-x64@7.0.2':
+ resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@typescript/typescript-openbsd-arm64@7.0.2':
+ resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@typescript/typescript-openbsd-x64@7.0.2':
+ resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@typescript/typescript-sunos-x64@7.0.2':
+ resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@typescript/typescript-win32-arm64@7.0.2':
+ resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@typescript/typescript-win32-x64@7.0.2':
+ resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [win32]
+
+ accepts@2.0.0:
+ resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
+ engines: {node: '>= 0.6'}
+
+ adm-zip@0.6.0:
+ resolution: {integrity: sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==}
+ engines: {node: '>=14.0'}
+
+ ajv-formats@3.0.1:
+ resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+
+ ajv@8.20.0:
+ resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
+
+ body-parser@2.3.0:
+ resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
+ engines: {node: '>=18'}
+
+ bytes@3.1.2:
+ resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
+ engines: {node: '>= 0.8'}
+
+ call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+
+ call-bound@1.0.4:
+ resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+ engines: {node: '>= 0.4'}
+
+ content-disposition@1.1.0:
+ resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
+ engines: {node: '>=18'}
+
+ content-type@1.0.5:
+ resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+ engines: {node: '>= 0.6'}
+
+ content-type@2.1.0:
+ resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
+ engines: {node: '>=18'}
+
+ cookie-signature@1.2.2:
+ resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
+ engines: {node: '>=6.6.0'}
+
+ cookie@0.7.2:
+ resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+ engines: {node: '>= 0.6'}
+
+ core-util-is@1.0.3:
+ resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
+
+ cors@2.8.6:
+ resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
+ engines: {node: '>= 0.10'}
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ define-data-property@1.1.4:
+ resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
+ engines: {node: '>= 0.4'}
+
+ define-properties@1.2.1:
+ resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
+ engines: {node: '>= 0.4'}
+
+ depd@2.0.0:
+ resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+ engines: {node: '>= 0.8'}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ docx@9.7.1:
+ resolution: {integrity: sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==}
+ engines: {node: '>=10'}
+
+ dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+
+ ee-first@1.1.1:
+ resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+
+ encodeurl@2.0.0:
+ resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
+ engines: {node: '>= 0.8'}
+
+ es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ es-object-atoms@1.1.2:
+ resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
+ engines: {node: '>= 0.4'}
+
+ esbuild@0.28.2:
+ resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ escape-html@1.0.3:
+ resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
+ etag@1.8.1:
+ resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
+ engines: {node: '>= 0.6'}
+
+ eventsource-parser@3.1.1:
+ resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==}
+ engines: {node: '>=18.0.0'}
+
+ eventsource@3.0.7:
+ resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
+ engines: {node: '>=18.0.0'}
+
+ express-rate-limit@8.6.2:
+ resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==}
+ engines: {node: '>= 16'}
+ peerDependencies:
+ express: '>= 4.11'
+
+ express@5.2.1:
+ resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
+ engines: {node: '>= 18'}
+
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+ fast-uri@3.1.6:
+ resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==}
+
+ finalhandler@2.1.1:
+ resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
+ engines: {node: '>= 18.0.0'}
+
+ forwarded@0.2.0:
+ resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
+ engines: {node: '>= 0.6'}
+
+ fresh@2.0.0:
+ resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
+ engines: {node: '>= 0.8'}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+
+ get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+
+ global-agent@4.1.3:
+ resolution: {integrity: sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==}
+ engines: {node: '>=10.0'}
+
+ globalthis@1.0.4:
+ resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
+ engines: {node: '>= 0.4'}
+
+ gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+
+ has-property-descriptors@1.0.2:
+ resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
+
+ has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+
+ hash.js@1.1.7:
+ resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
+
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+ engines: {node: '>= 0.4'}
+
+ hono@4.13.5:
+ resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==}
+ engines: {node: '>=16.9.0'}
+
+ http-errors@2.0.1:
+ resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
+ engines: {node: '>= 0.8'}
+
+ iconv-lite@0.7.3:
+ resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
+ engines: {node: '>=0.10.0'}
+
+ immediate@3.0.6:
+ resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+
+ inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+ ip-address@10.5.0:
+ resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==}
+ engines: {node: '>= 12'}
+
+ ipaddr.js@1.9.1:
+ resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
+ engines: {node: '>= 0.10'}
+
+ is-promise@4.0.0:
+ resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
+
+ isarray@1.0.0:
+ resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
+
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ jose@6.2.10:
+ resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==}
+
+ json-canonicalize@2.0.0:
+ resolution: {integrity: sha512-yyrnK/mEm6Na3ChbJUWueXdapueW0p380RUyTW87XGb1ww8l8hU0pRrGC3vSWHe9CxrbPHX2fGUOZpNiHR0IIg==}
+
+ json-schema-traverse@1.0.0:
+ resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+
+ json-schema-typed@8.0.2:
+ resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
+
+ jszip@3.10.1:
+ resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
+
+ lie@3.3.0:
+ resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+
+ matcher@4.0.0:
+ resolution: {integrity: sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==}
+ engines: {node: '>=10'}
+
+ math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+
+ media-typer@1.1.1:
+ resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==}
+ engines: {node: '>= 0.8'}
+
+ merge-descriptors@2.0.0:
+ resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
+ engines: {node: '>=18'}
+
+ mime-db@1.54.0:
+ resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
+ engines: {node: '>= 0.6'}
+
+ mime-types@3.0.2:
+ resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
+ engines: {node: '>=18'}
+
+ minimalistic-assert@1.0.1:
+ resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ nanoid@5.1.16:
+ resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==}
+ engines: {node: ^18 || >=20}
+ hasBin: true
+
+ negotiator@1.1.0:
+ resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==}
+ engines: {node: '>=18'}
+
+ object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+ engines: {node: '>=0.10.0'}
+
+ object-inspect@1.13.4:
+ resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+ engines: {node: '>= 0.4'}
+
+ object-keys@1.1.1:
+ resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
+ engines: {node: '>= 0.4'}
+
+ on-finished@2.4.1:
+ resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
+ engines: {node: '>= 0.8'}
+
+ once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
+ onnxruntime-common@1.29.0:
+ resolution: {integrity: sha512-/F63/e2VJoaVXGGNu6S5QH7jivBThGO95OzAVXXQ8hTta/b1QxI8udHa6cI3+3mAb5WWIIaMMwfZw01oivjJ1g==}
+
+ onnxruntime-node@1.29.0:
+ resolution: {integrity: sha512-WjiVVB72riILz8HbYvxvmjKyE/WmkYoSfKY++axo5jAR609HQg8MwiG/HhShpTcJfmmAdzxxmB+MMST3A+SiPA==}
+ os: [win32, darwin, linux]
+
+ pako@1.0.11:
+ resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+
+ parseurl@1.3.3:
+ resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+ engines: {node: '>= 0.8'}
+
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
+ path-to-regexp@8.4.2:
+ resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
+
+ pkce-challenge@5.0.1:
+ resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
+ engines: {node: '>=16.20.0'}
+
+ playwright-core@1.62.1:
+ resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ process-nextick-args@2.0.1:
+ resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
+
+ proxy-addr@2.0.7:
+ resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
+ engines: {node: '>= 0.10'}
+
+ qs@6.15.3:
+ resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
+ engines: {node: '>=0.6'}
+
+ range-parser@1.3.0:
+ resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
+ engines: {node: '>= 0.6'}
+
+ raw-body@3.0.2:
+ resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
+ engines: {node: '>= 0.10'}
+
+ readable-stream@2.3.8:
+ resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
+
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
+ router@2.2.0:
+ resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
+ engines: {node: '>= 18'}
+
+ safe-buffer@5.1.2:
+ resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
+
+ safer-buffer@2.1.2:
+ resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
+ sax@1.6.1:
+ resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
+ engines: {node: '>=11.0.0'}
+
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ send@1.2.1:
+ resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
+ engines: {node: '>= 18'}
+
+ serialize-error@8.1.0:
+ resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==}
+ engines: {node: '>=10'}
+
+ serve-static@2.2.1:
+ resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
+ engines: {node: '>= 18'}
+
+ setimmediate@1.0.5:
+ resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
+
+ setprototypeof@1.2.0:
+ resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+
+ sharp@0.34.5:
+ resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
+ side-channel-list@1.0.1:
+ resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-map@1.0.1:
+ resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+ engines: {node: '>= 0.4'}
+
+ side-channel-weakmap@1.0.2:
+ resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+ engines: {node: '>= 0.4'}
+
+ side-channel@1.1.1:
+ resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
+ engines: {node: '>= 0.4'}
+
+ statuses@2.0.2:
+ resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+ engines: {node: '>= 0.8'}
+
+ string_decoder@1.1.1:
+ resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
+
+ toidentifier@1.0.1:
+ resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+ engines: {node: '>=0.6'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ tsx@4.23.12:
+ resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==}
+ engines: {node: '>=18.0.0'}
+ hasBin: true
+
+ type-fest@0.20.2:
+ resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
+ engines: {node: '>=10'}
+
+ type-is@2.1.0:
+ resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
+ engines: {node: '>= 18'}
+
+ typescript@7.0.2:
+ resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
+ engines: {node: '>=16.20.0'}
+ hasBin: true
+
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
+ undici-types@7.24.6:
+ resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
+
+ unpipe@1.0.0:
+ resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
+ engines: {node: '>= 0.8'}
+
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+ vary@1.1.2:
+ resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+ engines: {node: '>= 0.8'}
+
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
+ wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
+ xml-js@1.6.11:
+ resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
+ hasBin: true
+
+ xml@1.0.1:
+ resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==}
+
+ zod-to-json-schema@3.25.2:
+ resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
+ peerDependencies:
+ zod: ^3.25.28 || ^4
+
+ zod@3.25.76:
+ resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
+
+snapshots:
+
+ '@emnapi/runtime@1.11.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@esbuild/aix-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm@0.28.2':
+ optional: true
+
+ '@esbuild/android-x64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-x64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/linux-loong64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-s390x@0.28.2':
+ optional: true
+
+ '@esbuild/linux-x64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/sunos-x64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/win32-x64@0.28.2':
+ optional: true
+
+ '@hono/node-server@2.1.1(hono@4.13.5)':
+ dependencies:
+ hono: 4.13.5
+
+ '@img/colour@1.1.0': {}
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-darwin-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-linux-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-arm@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-s390x@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-wasm32@0.34.5':
+ dependencies:
+ '@emnapi/runtime': 1.11.3
+ optional: true
+
+ '@img/sharp-win32-arm64@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-ia32@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-x64@0.34.5':
+ optional: true
+
+ '@modelcontextprotocol/sdk@1.30.0(zod@3.25.76)':
+ dependencies:
+ '@hono/node-server': 2.1.1(hono@4.13.5)
+ ajv: 8.20.0
+ ajv-formats: 3.0.1(ajv@8.20.0)
+ content-type: 1.0.5
+ cors: 2.8.6
+ cross-spawn: 7.0.6
+ eventsource: 3.0.7
+ eventsource-parser: 3.1.1
+ express: 5.2.1
+ express-rate-limit: 8.6.2(express@5.2.1)
+ hono: 4.13.5
+ jose: 6.2.10
+ json-schema-typed: 8.0.2
+ pkce-challenge: 5.0.1
+ raw-body: 3.0.2
+ zod: 3.25.76
+ zod-to-json-schema: 3.25.2(zod@3.25.76)
+ transitivePeerDependencies:
+ - supports-color
+
+ '@openai/codex@0.149.1':
+ optionalDependencies:
+ '@openai/codex-darwin-arm64': '@openai/codex@0.149.1-darwin-arm64'
+ '@openai/codex-darwin-x64': '@openai/codex@0.149.1-darwin-x64'
+ '@openai/codex-linux-arm64': '@openai/codex@0.149.1-linux-arm64'
+ '@openai/codex-linux-x64': '@openai/codex@0.149.1-linux-x64'
+ '@openai/codex-win32-arm64': '@openai/codex@0.149.1-win32-arm64'
+ '@openai/codex-win32-x64': '@openai/codex@0.149.1-win32-x64'
+
+ '@openai/codex@0.149.1-darwin-arm64':
+ optional: true
+
+ '@openai/codex@0.149.1-darwin-x64':
+ optional: true
+
+ '@openai/codex@0.149.1-linux-arm64':
+ optional: true
+
+ '@openai/codex@0.149.1-linux-x64':
+ optional: true
+
+ '@openai/codex@0.149.1-win32-arm64':
+ optional: true
+
+ '@openai/codex@0.149.1-win32-x64':
+ optional: true
+
+ '@types/body-parser@1.19.6':
+ dependencies:
+ '@types/connect': 3.4.38
+ '@types/node': 22.20.1
+
+ '@types/connect@3.4.38':
+ dependencies:
+ '@types/node': 22.20.1
+
+ '@types/express-serve-static-core@5.1.3':
+ dependencies:
+ '@types/node': 22.20.1
+ '@types/qs': 6.15.1
+ '@types/range-parser': 1.2.7
+ '@types/send': 1.2.1
+
+ '@types/express@5.0.6':
+ dependencies:
+ '@types/body-parser': 1.19.6
+ '@types/express-serve-static-core': 5.1.3
+ '@types/serve-static': 2.2.0
+
+ '@types/http-errors@2.0.5': {}
+
+ '@types/node@22.20.1':
+ dependencies:
+ undici-types: 6.21.0
+
+ '@types/node@25.9.5':
+ dependencies:
+ undici-types: 7.24.6
+
+ '@types/qs@6.15.1': {}
+
+ '@types/range-parser@1.2.7': {}
+
+ '@types/send@1.2.1':
+ dependencies:
+ '@types/node': 22.20.1
+
+ '@types/serve-static@2.2.0':
+ dependencies:
+ '@types/http-errors': 2.0.5
+ '@types/node': 22.20.1
+
+ '@typescript/typescript-aix-ppc64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-darwin-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-darwin-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-freebsd-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-freebsd-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-arm@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-loong64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-mips64el@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-ppc64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-riscv64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-s390x@7.0.2':
+ optional: true
+
+ '@typescript/typescript-linux-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-netbsd-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-netbsd-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-openbsd-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-openbsd-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-sunos-x64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-win32-arm64@7.0.2':
+ optional: true
+
+ '@typescript/typescript-win32-x64@7.0.2':
+ optional: true
+
+ accepts@2.0.0:
+ dependencies:
+ mime-types: 3.0.2
+ negotiator: 1.1.0
+
+ adm-zip@0.6.0: {}
+
+ ajv-formats@3.0.1(ajv@8.20.0):
+ optionalDependencies:
+ ajv: 8.20.0
+
+ ajv@8.20.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-uri: 3.1.6
+ json-schema-traverse: 1.0.0
+ require-from-string: 2.0.2
+
+ body-parser@2.3.0:
+ dependencies:
+ bytes: 3.1.2
+ content-type: 2.1.0
+ debug: 4.4.3
+ http-errors: 2.0.1
+ iconv-lite: 0.7.3
+ on-finished: 2.4.1
+ qs: 6.15.3
+ raw-body: 3.0.2
+ type-is: 2.1.0
+ transitivePeerDependencies:
+ - supports-color
+
+ bytes@3.1.2: {}
+
+ call-bind-apply-helpers@1.0.2:
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+
+ call-bound@1.0.4:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ get-intrinsic: 1.3.0
+
+ content-disposition@1.1.0: {}
+
+ content-type@1.0.5: {}
+
+ content-type@2.1.0: {}
+
+ cookie-signature@1.2.2: {}
+
+ cookie@0.7.2: {}
+
+ core-util-is@1.0.3: {}
+
+ cors@2.8.6:
+ dependencies:
+ object-assign: 4.1.1
+ vary: 1.1.2
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ define-data-property@1.1.4:
+ dependencies:
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ define-properties@1.2.1:
+ dependencies:
+ define-data-property: 1.1.4
+ has-property-descriptors: 1.0.2
+ object-keys: 1.1.1
+
+ depd@2.0.0: {}
+
+ detect-libc@2.1.2: {}
+
+ docx@9.7.1:
+ dependencies:
+ '@types/node': 25.9.5
+ hash.js: 1.1.7
+ jszip: 3.10.1
+ nanoid: 5.1.16
+ xml: 1.0.1
+ xml-js: 1.6.11
+
+ dunder-proto@1.0.1:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ ee-first@1.1.1: {}
+
+ encodeurl@2.0.0: {}
+
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
+ es-object-atoms@1.1.2:
+ dependencies:
+ es-errors: 1.3.0
+
+ esbuild@0.28.2:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.28.2
+ '@esbuild/android-arm': 0.28.2
+ '@esbuild/android-arm64': 0.28.2
+ '@esbuild/android-x64': 0.28.2
+ '@esbuild/darwin-arm64': 0.28.2
+ '@esbuild/darwin-x64': 0.28.2
+ '@esbuild/freebsd-arm64': 0.28.2
+ '@esbuild/freebsd-x64': 0.28.2
+ '@esbuild/linux-arm': 0.28.2
+ '@esbuild/linux-arm64': 0.28.2
+ '@esbuild/linux-ia32': 0.28.2
+ '@esbuild/linux-loong64': 0.28.2
+ '@esbuild/linux-mips64el': 0.28.2
+ '@esbuild/linux-ppc64': 0.28.2
+ '@esbuild/linux-riscv64': 0.28.2
+ '@esbuild/linux-s390x': 0.28.2
+ '@esbuild/linux-x64': 0.28.2
+ '@esbuild/netbsd-arm64': 0.28.2
+ '@esbuild/netbsd-x64': 0.28.2
+ '@esbuild/openbsd-arm64': 0.28.2
+ '@esbuild/openbsd-x64': 0.28.2
+ '@esbuild/openharmony-arm64': 0.28.2
+ '@esbuild/sunos-x64': 0.28.2
+ '@esbuild/win32-arm64': 0.28.2
+ '@esbuild/win32-ia32': 0.28.2
+ '@esbuild/win32-x64': 0.28.2
+
+ escape-html@1.0.3: {}
+
+ escape-string-regexp@4.0.0: {}
+
+ etag@1.8.1: {}
+
+ eventsource-parser@3.1.1: {}
+
+ eventsource@3.0.7:
+ dependencies:
+ eventsource-parser: 3.1.1
+
+ express-rate-limit@8.6.2(express@5.2.1):
+ dependencies:
+ debug: 4.4.3
+ express: 5.2.1
+ ip-address: 10.5.0
+ transitivePeerDependencies:
+ - supports-color
+
+ express@5.2.1:
+ dependencies:
+ accepts: 2.0.0
+ body-parser: 2.3.0
+ content-disposition: 1.1.0
+ content-type: 1.0.5
+ cookie: 0.7.2
+ cookie-signature: 1.2.2
+ debug: 4.4.3
+ depd: 2.0.0
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ finalhandler: 2.1.1
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ merge-descriptors: 2.0.0
+ mime-types: 3.0.2
+ on-finished: 2.4.1
+ once: 1.4.0
+ parseurl: 1.3.3
+ proxy-addr: 2.0.7
+ qs: 6.15.3
+ range-parser: 1.3.0
+ router: 2.2.0
+ send: 1.2.1
+ serve-static: 2.2.1
+ statuses: 2.0.2
+ type-is: 2.1.0
+ vary: 1.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ fast-deep-equal@3.1.3: {}
+
+ fast-uri@3.1.6: {}
+
+ finalhandler@2.1.1:
+ dependencies:
+ debug: 4.4.3
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ on-finished: 2.4.1
+ parseurl: 1.3.3
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ forwarded@0.2.0: {}
+
+ fresh@2.0.0: {}
+
+ fsevents@2.3.3:
+ optional: true
+
+ function-bind@1.1.2: {}
+
+ get-intrinsic@1.3.0:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.4
+ math-intrinsics: 1.1.0
+
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.2
+
+ global-agent@4.1.3:
+ dependencies:
+ globalthis: 1.0.4
+ matcher: 4.0.0
+ semver: 7.8.5
+ serialize-error: 8.1.0
+
+ globalthis@1.0.4:
+ dependencies:
+ define-properties: 1.2.1
+ gopd: 1.2.0
+
+ gopd@1.2.0: {}
+
+ has-property-descriptors@1.0.2:
+ dependencies:
+ es-define-property: 1.0.1
+
+ has-symbols@1.1.0: {}
+
+ hash.js@1.1.7:
+ dependencies:
+ inherits: 2.0.4
+ minimalistic-assert: 1.0.1
+
+ hasown@2.0.4:
+ dependencies:
+ function-bind: 1.1.2
+
+ hono@4.13.5: {}
+
+ http-errors@2.0.1:
+ dependencies:
+ depd: 2.0.0
+ inherits: 2.0.4
+ setprototypeof: 1.2.0
+ statuses: 2.0.2
+ toidentifier: 1.0.1
+
+ iconv-lite@0.7.3:
+ dependencies:
+ safer-buffer: 2.1.2
+
+ immediate@3.0.6: {}
+
+ inherits@2.0.4: {}
+
+ ip-address@10.5.0: {}
+
+ ipaddr.js@1.9.1: {}
+
+ is-promise@4.0.0: {}
+
+ isarray@1.0.0: {}
+
+ isexe@2.0.0: {}
+
+ jose@6.2.10: {}
+
+ json-canonicalize@2.0.0: {}
+
+ json-schema-traverse@1.0.0: {}
+
+ json-schema-typed@8.0.2: {}
+
+ jszip@3.10.1:
+ dependencies:
+ lie: 3.3.0
+ pako: 1.0.11
+ readable-stream: 2.3.8
+ setimmediate: 1.0.5
+
+ lie@3.3.0:
+ dependencies:
+ immediate: 3.0.6
+
+ matcher@4.0.0:
+ dependencies:
+ escape-string-regexp: 4.0.0
+
+ math-intrinsics@1.1.0: {}
+
+ media-typer@1.1.1: {}
+
+ merge-descriptors@2.0.0: {}
+
+ mime-db@1.54.0: {}
+
+ mime-types@3.0.2:
+ dependencies:
+ mime-db: 1.54.0
+
+ minimalistic-assert@1.0.1: {}
+
+ ms@2.1.3: {}
+
+ nanoid@5.1.16: {}
+
+ negotiator@1.1.0:
+ dependencies:
+ content-type: 2.1.0
+
+ object-assign@4.1.1: {}
+
+ object-inspect@1.13.4: {}
+
+ object-keys@1.1.1: {}
+
+ on-finished@2.4.1:
+ dependencies:
+ ee-first: 1.1.1
+
+ once@1.4.0:
+ dependencies:
+ wrappy: 1.0.2
+
+ onnxruntime-common@1.29.0: {}
+
+ onnxruntime-node@1.29.0:
+ dependencies:
+ adm-zip: 0.6.0
+ global-agent: 4.1.3
+ onnxruntime-common: 1.29.0
+
+ pako@1.0.11: {}
+
+ parseurl@1.3.3: {}
+
+ path-key@3.1.1: {}
+
+ path-to-regexp@8.4.2: {}
+
+ pkce-challenge@5.0.1: {}
+
+ playwright-core@1.62.1: {}
+
+ process-nextick-args@2.0.1: {}
+
+ proxy-addr@2.0.7:
+ dependencies:
+ forwarded: 0.2.0
+ ipaddr.js: 1.9.1
+
+ qs@6.15.3:
+ dependencies:
+ es-define-property: 1.0.1
+ side-channel: 1.1.1
+
+ range-parser@1.3.0: {}
+
+ raw-body@3.0.2:
+ dependencies:
+ bytes: 3.1.2
+ http-errors: 2.0.1
+ iconv-lite: 0.7.3
+ unpipe: 1.0.0
+
+ readable-stream@2.3.8:
+ dependencies:
+ core-util-is: 1.0.3
+ inherits: 2.0.4
+ isarray: 1.0.0
+ process-nextick-args: 2.0.1
+ safe-buffer: 5.1.2
+ string_decoder: 1.1.1
+ util-deprecate: 1.0.2
+
+ require-from-string@2.0.2: {}
+
+ router@2.2.0:
+ dependencies:
+ debug: 4.4.3
+ depd: 2.0.0
+ is-promise: 4.0.0
+ parseurl: 1.3.3
+ path-to-regexp: 8.4.2
+ transitivePeerDependencies:
+ - supports-color
+
+ safe-buffer@5.1.2: {}
+
+ safer-buffer@2.1.2: {}
+
+ sax@1.6.1: {}
+
+ semver@7.8.5: {}
+
+ send@1.2.1:
+ dependencies:
+ debug: 4.4.3
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ mime-types: 3.0.2
+ ms: 2.1.3
+ on-finished: 2.4.1
+ range-parser: 1.3.0
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ serialize-error@8.1.0:
+ dependencies:
+ type-fest: 0.20.2
+
+ serve-static@2.2.1:
+ dependencies:
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ parseurl: 1.3.3
+ send: 1.2.1
+ transitivePeerDependencies:
+ - supports-color
+
+ setimmediate@1.0.5: {}
+
+ setprototypeof@1.2.0: {}
+
+ sharp@0.34.5:
+ dependencies:
+ '@img/colour': 1.1.0
+ detect-libc: 2.1.2
+ semver: 7.8.5
+ optionalDependencies:
+ '@img/sharp-darwin-arm64': 0.34.5
+ '@img/sharp-darwin-x64': 0.34.5
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ '@img/sharp-linux-arm': 0.34.5
+ '@img/sharp-linux-arm64': 0.34.5
+ '@img/sharp-linux-ppc64': 0.34.5
+ '@img/sharp-linux-riscv64': 0.34.5
+ '@img/sharp-linux-s390x': 0.34.5
+ '@img/sharp-linux-x64': 0.34.5
+ '@img/sharp-linuxmusl-arm64': 0.34.5
+ '@img/sharp-linuxmusl-x64': 0.34.5
+ '@img/sharp-wasm32': 0.34.5
+ '@img/sharp-win32-arm64': 0.34.5
+ '@img/sharp-win32-ia32': 0.34.5
+ '@img/sharp-win32-x64': 0.34.5
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ side-channel-list@1.0.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-map@1.0.1:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+
+ side-channel-weakmap@1.0.2:
+ dependencies:
+ call-bound: 1.0.4
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-map: 1.0.1
+
+ side-channel@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-list: 1.0.1
+ side-channel-map: 1.0.1
+ side-channel-weakmap: 1.0.2
+
+ statuses@2.0.2: {}
+
+ string_decoder@1.1.1:
+ dependencies:
+ safe-buffer: 5.1.2
+
+ toidentifier@1.0.1: {}
+
+ tslib@2.8.1:
+ optional: true
+
+ tsx@4.23.12:
+ dependencies:
+ esbuild: 0.28.2
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ type-fest@0.20.2: {}
+
+ type-is@2.1.0:
+ dependencies:
+ content-type: 2.1.0
+ media-typer: 1.1.1
+ mime-types: 3.0.2
+
+ typescript@7.0.2:
+ optionalDependencies:
+ '@typescript/typescript-aix-ppc64': 7.0.2
+ '@typescript/typescript-darwin-arm64': 7.0.2
+ '@typescript/typescript-darwin-x64': 7.0.2
+ '@typescript/typescript-freebsd-arm64': 7.0.2
+ '@typescript/typescript-freebsd-x64': 7.0.2
+ '@typescript/typescript-linux-arm': 7.0.2
+ '@typescript/typescript-linux-arm64': 7.0.2
+ '@typescript/typescript-linux-loong64': 7.0.2
+ '@typescript/typescript-linux-mips64el': 7.0.2
+ '@typescript/typescript-linux-ppc64': 7.0.2
+ '@typescript/typescript-linux-riscv64': 7.0.2
+ '@typescript/typescript-linux-s390x': 7.0.2
+ '@typescript/typescript-linux-x64': 7.0.2
+ '@typescript/typescript-netbsd-arm64': 7.0.2
+ '@typescript/typescript-netbsd-x64': 7.0.2
+ '@typescript/typescript-openbsd-arm64': 7.0.2
+ '@typescript/typescript-openbsd-x64': 7.0.2
+ '@typescript/typescript-sunos-x64': 7.0.2
+ '@typescript/typescript-win32-arm64': 7.0.2
+ '@typescript/typescript-win32-x64': 7.0.2
+
+ undici-types@6.21.0: {}
+
+ undici-types@7.24.6: {}
+
+ unpipe@1.0.0: {}
+
+ util-deprecate@1.0.2: {}
+
+ vary@1.1.2: {}
+
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
+ wrappy@1.0.2: {}
+
+ xml-js@1.6.11:
+ dependencies:
+ sax: 1.6.1
+
+ xml@1.0.1: {}
+
+ zod-to-json-schema@3.25.2(zod@3.25.76):
+ dependencies:
+ zod: 3.25.76
+
+ zod@3.25.76: {}
diff --git a/docs/content/docs/backend/task-runtime-stability.mdx b/docs/content/docs/backend/task-runtime-stability.mdx
index 4c6eb3885..c8b666cf1 100644
--- a/docs/content/docs/backend/task-runtime-stability.mdx
+++ b/docs/content/docs/backend/task-runtime-stability.mdx
@@ -5,6 +5,14 @@ description: 热点回源保护、并发租约、故障边界和上线验收
# 任务缓存与队列稳定性
+## 详情读取和日志筛选边界
+
+任务中心打开详情只读取任务和日志,不在该路径调用画布结果同步。后台同步遇到短剧领域项目任务(`domainProjectId` 等于任务 `projectId`)或没有生成节点关联时跳过画布写回,避免把短剧项目 ID 当作画布 ID 查询。
+
+管理员请求明细列表及 CSV 导出支持 `recordType`:省略或 `request` 默认仅请求(继续隐藏视频轮询并排除下载);`download` 只包含 `request_kind=download`;`all` 包含请求、轮询与下载。非法值返回参数错误,管理员权限校验保持不变;该筛选不改变统计聚合接口。
+
+章节提取采用独立提示词操作 `chapter_assets_extract`,受保护输出契约为 `chapter-assets/v1`,要求 `characters`、`scenes`、`props` 三个数组。场景和道具要求非空的 `name`、`description`、`prompt`;角色继续执行原角色契约校验。写入候选资产时场景映射为 `environment`,继续使用现有服务的项目归属、去重与写入校验,不新增数据库字段。
+
## 范围与事实源
当前任务仍由数据库持久化、API 进程内 worker 领取和执行,Redis 负责跨实例并发协调。本轮是热点读取和租约的止血修复,不是独立任务系统迁移,也不能替代线上事故取证。
diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx
index 279f39432..3a8de1601 100644
--- a/docs/content/docs/overview/features.mdx
+++ b/docs/content/docs/overview/features.mdx
@@ -5,6 +5,14 @@ description: 当前项目已实现的核心工作台能力与使用入口
# 功能清单
+## 章节资产提取与任务浏览
+
+画布视频支持鼠标悬停 350ms 后静音预览前 3 秒,不循环;全局仅一个悬停解码器,离开或操作画布立即停止并恢复封面。手动播放优先,系统开启减弱动态效果时不自动预览。
+
+剧情章节支持一次提取角色、场景与道具,分别写入角色、环境和道具待确认资产。输出必须包含三类数组;无人章节可以只产生场景或道具,不把缺失字段当作提取成功。
+
+任务详情只读取任务与日志,不因打开详情写入画布。视频任务列表和网格优先展示已有封面;没有封面时,在进入视口后提取首帧缩略图。后台请求明细默认仅显示请求,可切换仅下载或全部明细,导出沿用当前筛选条件。
+
## 站点及外观
管理员可以在“系统配置 → 站点及外观”统一管理品牌识别、登录页、SEO、首页收尾与备案、皮肤主题。品牌识别包括站点名、英文品牌标识、浅色与深色 Logo 和 Logo 外框;登录页包括主标题、说明、品牌视频和可选封面。Logo 可以只上传任意一套并自动复用,也可以分别适配浅色与深色界面;关闭外框后 Logo 会在原导航占位内等比放大。
@@ -194,4 +202,4 @@ SEO 标题、描述、可选关键词会投影到浏览器 metadata、Open Graph
- `web/src/lib/plugins/plugin-permission-check.ts`:命令执行权限与失败关闭策略。
- `web/src/services/api/timeline-tasks.ts`:转写与渲染任务客户端。
- `backend/internal/service/task_timeline.go`、`task_render.go`、`timeline_render_plan.go`、`transcription_whisper.go`:后端转写、渲染与渲染计划任务。
-- `backend/internal/repository/project_workbench_read.go`:项目概览指标聚合(含成片完成度计数)。
\ No newline at end of file
+- `backend/internal/repository/project_workbench_read.go`:项目概览指标聚合(含成片完成度计数)。
diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx
index d8fd42a24..5e5ddcf1b 100644
--- a/docs/content/docs/progress/pending-test.mdx
+++ b/docs/content/docs/progress/pending-test.mdx
@@ -5,6 +5,54 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试
+## 连线目标节点 3D 倾斜反馈
+
+- 拖动连线进入现有合法目标吸附区时,目标节点按接近点倾斜,最大约 5°,以接近点作为支点;支持单条和批量连线,不扩大连线命中区域或改变节点坐标。移出、完成或取消后恢复,普通 hover、点击和选中不启用此效果。
+- 性能降级模式、拖动节点和系统减弱动态效果下不启用倾斜。修复逐帧传入鼠标坐标导致目标节点 memo 失效的问题:仅在进入目标时锁定接近点,同一目标内移动保持引用和倾斜不变,切换、退出或取消时更新;连线端点仍按原有 RAF 节奏更新。
+- [x] 方向、边界、缩放、600 次同目标移动引用稳定性及相邻连线/媒体专项共 40 项测试通过,前端构建通过。真实浏览器验证样式在普通/选中状态为 `none`,目标状态为 3D 变换,清除后恢复 `none` 和 `will-change: auto`。
+- [ ] 在用户出现卡顿的大画布复验连线延迟和渲染开销;独立浏览器渲染计数实验初始计数异常为零,未获得有效的渲染次数对比,不能据此认定性能验收通过。
+- [ ] 登录画布验证不同缩放下从各方向接近目标、完成、Escape 取消、窗口失焦,以及批量连线的合法/非法目标切换。
+
+## 画布视频悬停预览
+
+- 鼠标停留 350ms 后静音播放前 3 秒,播放完恢复静态封面;短视频结束即停止,不循环。触屏、按键拖拽以及系统减弱动态效果下不自动播放,点击播放仍使用完整播放器。
+- 同时仅一个悬停预览(包括待加载任务),快速划过不解析视频地址;离开、滚轮、按下鼠标、键盘操作、窗口失焦、页面隐藏、节点离开视口或画布变换均释放播放器。手动播放优先,悬停不触发整文件后台缓存。
+- [x] 悬停生命周期与相邻媒体专项测试共 31 项通过,前端完整构建通过;真实浏览器以 4.2 秒本地生成视频验证静音播放及约 3 秒后播放器移除。此验证不依赖登录,也不等同于完整画布验收。
+- [ ] 在真实大画布中验收大量视频节点下的快速划过、拖拽、缩放、慢网和跨域失败,记录目标设备的帧率与内存;本地专项测试不代表所有设备的性能保证。
+
+预览使用原视频,3 秒限制是播放时长而非下载字节上限;实际网络流量取决于浏览器缓冲、源视频码率和服务端 Range 支持。
+
+## 任务详情、章节资产与请求明细
+
+- [x] 前端专项回归覆盖图片自引用、任务同步边界、章节三类资产解析及相邻媒体逻辑;无角色章节允许保留场景和道具,缺失必填字段仍报错。
+- [x] 后端 repository、service、handler 测试通过,覆盖请求/下载筛选及导出、章节资产输出校验。
+- [x] 视频首帧组件使用浏览器内生成的视频验证:输出 400×225 缩略图,卸载后释放对象 URL;此项不等同于登录态任务页面验收。
+- [ ] 登录后打开画布任务和短剧任务详情,确认不再因详情读取触发画布同步而报 `record not found`。
+- [ ] 检查生成图片的提示词面板:不含自身引用,原有上游素材引用仍保留。
+- [ ] 使用真实文本模型提取章节,确认角色、场景、道具均进入待确认资产,重复提取与刷新恢复不重复入库。
+- [ ] 请求明细默认仅请求,切换仅下载/全部后,分页、检索、CSV 导出一致。
+- [ ] 视频任务列表和网格检查已有封面、无封面、跨域失败、滚动懒加载及明暗主题。
+- [ ] “参考 LibTV 的深度动作捕捉”尚未实现,需确认对应功能入口、截图或预期输入输出。
+
+本轮不涉及数据库迁移;前后端需一起更新,后端重启后才会提供新章节提取模板和日志筛选参数。
+
+## 文件上传占位、真实进度与磨砂流光
+
+- 图片、视频按本地读取的真实宽高比占位;音频、TXT / Markdown 文本使用各自节点尺寸。普通 multipart 与大文件分片上传回传真实字节进度,磨砂和流光随百分比逐渐变淡;传输 100% 后显示保存处理中,不能当作服务端保存成功。
+- 四类文件共用上传、拖入、替换及失败重试流程;完成后填入同一节点,上传弹窗提前关闭,批量仍按顺序处理。纯剪贴板文字直接插入,不伪造网络上传过程。文本按 UTF-8 读取,不支持 PDF / Word 文档解析。
+- 上传失败保留明确错误和重新选择入口;替换失败保留原资源;上传中删除节点不会在完成后重新插入;刷新后中断占位转为错误。减弱动态效果设置下禁用流光和淡出过渡;媒体本地降级明确提示尚未上传到服务器。
+- [x] 占位、进度浓度、四类文件、错误状态、相邻回归与普通/分片传输模拟测试通过,前端类型检查和完整构建通过;静态渲染与模拟传输不等同于真实网络及浏览器视觉验收。
+- [ ] 慢速网络下验证横图、竖图、视频、音频、TXT / Markdown、拖入和替换:比例正确,遮罩随实际进度变淡,完成前后位置稳定,右上角与节点状态同步。补验大于 50MB 的分片上传及保存失败。
+- [ ] 验证明暗主题、上传失败重试、刷新中断、上传中拖动或删除节点,以及系统减弱动态效果;未确认真实网络上传的浏览器交互。
+
+## 上传图片保护与画布浮层顺序(#438 / #436)
+
+- 上传/导入的纯图片素材不再显示生成框和生成设置入口;空白生成节点、已有生成结果和显式复制/版本节点仍保留配置入口。替换上传会清除旧生成落点状态。
+- 人物质感操作创建独立图片编辑节点,通过入边引用原图;底部添加与外观面板接入统一浮层管理,打开或交互时置顶。
+- [x] 图片素材判定、人物质感节点构造、入口接线、浮层接线及相邻布局/引用测试共 24 项通过;前端完整构建通过。浏览器访问现有服务进入登录页,尚未完成登录后的交互验证,也未调用真实生成接口。
+- [ ] 上传、拖入和替换图片,确认原图没有生成框;执行人物质感、局部重绘、裁剪、放大与表情编辑,确认原图保留、结果生成到新节点并有原图连线。
+- [ ] 先打开节点编辑框,再打开底部添加/外观菜单,确认后打开的面板在上层;再次点击节点编辑框能置顶,键盘操作、明暗主题和菜单关闭正常。
+
## Tiptap 依赖版本对齐
- Tiptap 核心、React、扩展及间接依赖统一锁定为 `3.28.0`,同步 Bun 与 pnpm 锁文件;显式声明 `pm` 和 `extensions`,避免 pnpm 保留旧 peer 解析。此项不修改编辑器业务代码和 UI 样式。
diff --git a/web/src/components/canvas/canvas-file-upload-content.tsx b/web/src/components/canvas/canvas-file-upload-content.tsx
new file mode 100644
index 000000000..95d11aebe
--- /dev/null
+++ b/web/src/components/canvas/canvas-file-upload-content.tsx
@@ -0,0 +1,27 @@
+import type { CSSProperties } from "react";
+import { AlertCircle, ImageUp, FileText, Film, Music2 } from "lucide-react";
+import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
+import { uploadVeilOpacity } from "@/lib/canvas/canvas-file-upload";
+import type { CanvasTheme } from "@/lib/canvas-theme";
+import { useCanvasNodeActions } from "./canvas-node-action-context";
+
+export function CanvasFileUploadContent({ node, theme, reduceMotion = false }: { node: CanvasNodeData; theme: CanvasTheme; reduceMotion?: boolean }) {
+ const { upload } = useCanvasNodeActions();
+ const failed = node.metadata?.fileUpload === "error";
+ const percent = node.metadata?.fileUploadProgress;
+ const label = node.type === CanvasNodeType.Image ? "图片" : node.type === CanvasNodeType.Video ? "视频" : node.type === CanvasNodeType.Audio ? "音频" : "文本";
+ const Icon = failed ? AlertCircle : node.type === CanvasNodeType.Video ? Film : node.type === CanvasNodeType.Audio ? Music2 : node.type === CanvasNodeType.Text ? FileText : ImageUp;
+ return (
+
+
+
+
+
+ {failed ? `${label}上传未完成` : `正在上传${label}`}
+ {failed ? node.metadata?.errorDetails || "请重新选择文件上传" : percent === 100 ? "文件已传输,正在保存与处理" : percent !== undefined ? `已上传 ${percent}%` : "正在准备文件"}
+ {!failed && }
+ {failed && upload ? : null}
+
+
+ );
+}
diff --git a/web/src/components/canvas/canvas-node-action-context.ts b/web/src/components/canvas/canvas-node-action-context.ts
index b57c82e8a..1942cea83 100644
--- a/web/src/components/canvas/canvas-node-action-context.ts
+++ b/web/src/components/canvas/canvas-node-action-context.ts
@@ -6,6 +6,7 @@ import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
// 但画布节点经 CanvasProjectWorldLayers 渲染、不便逐个透传 handler,
// 通过 Context 注入,避免改动 world-layers。无 Provider 时静默降级为 no-op。
export type CanvasNodeActionContextValue = {
+ upload?: (node: CanvasNodeData) => void;
download?: (node: CanvasNodeData) => void;
duplicate?: (node: CanvasNodeData) => void;
deleteNode?: (node: CanvasNodeData) => void;
diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx
index 5c837e53c..1e3935ba6 100644
--- a/web/src/components/canvas/canvas-node-content.tsx
+++ b/web/src/components/canvas/canvas-node-content.tsx
@@ -8,6 +8,7 @@ import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
+import { bindCanvasVideoHoverPreview } from "@/lib/canvas/canvas-video-hover-preview";
import { buildLibTVImagePreviewUrl, buildLibTVVideoSourceUrl } from "@/lib/canvas/libtv-import";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { CanvasTheme } from "@/lib/canvas-theme";
@@ -26,6 +27,7 @@ import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textare
import { CanvasAudioPlayer } from "./canvas-audio-player";
import { useCanvasNodeActions } from "./canvas-node-action-context";
import { CanvasSubtitleOverlay } from "./canvas-subtitle-overlay";
+import { CanvasFileUploadContent } from "./canvas-file-upload-content";
import { MarkdownNodeContent } from "./nodes/markdown-node";
import { ChartNodeContent } from "./nodes/chart-node";
import { CompareNodeContent } from "./nodes/compare-node";
@@ -62,6 +64,7 @@ export type CanvasNodeContentProps = {
};
export function CanvasNodeContent(props: CanvasNodeContentProps) {
+ if (props.node.metadata?.fileUpload) return ;
const hasCustomContent = props.node.type === CanvasNodeType.Config
|| props.node.type === CanvasNodeType.Script
|| Boolean(props.node.metadata?.directorSceneId)
@@ -496,6 +499,14 @@ function InactiveVideoPreview({ node, theme, onPlay }: Pick {
+ const element = previewRef.current;
+ if (!element) return;
+ const content = node.metadata?.content || "";
+ const fallback = node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(content) : content;
+ return bindCanvasVideoHoverPreview(element, () => resolveMediaUrl(node.metadata?.storageKey, fallback));
+ }, [node.metadata?.content, node.metadata?.storageKey, node.metadata?.importSource?.provider]);
+
useEffect(() => {
updateMetadataRef.current = updateMetadata;
}, [updateMetadata]);
diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx
index 037a9488f..2086d5775 100644
--- a/web/src/components/canvas/canvas-node.tsx
+++ b/web/src/components/canvas/canvas-node.tsx
@@ -5,6 +5,7 @@ import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, C
import { useCanvasNodeActions } from "./canvas-node-action-context";
import { canvasThemes } from "@/lib/canvas-theme";
+import { canvasConnectionTilt } from "@/lib/canvas/canvas-connection-tilt";
import { storyboardMinNodeHeight } from "@/lib/canvas/canvas-storyboard-layout";
import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -28,6 +29,7 @@ type CanvasNodeProps = {
isRelated: boolean;
isFocusRelated: boolean;
isConnectionTarget: boolean;
+ connectionApproach?: Position;
forceInputVisible?: boolean;
showImageInfo: boolean;
reduceMediaEffects?: boolean;
@@ -75,6 +77,7 @@ export const CanvasNode = React.memo(function CanvasNode({
isRelated,
isFocusRelated,
isConnectionTarget,
+ connectionApproach,
forceInputVisible = false,
showImageInfo,
reduceMediaEffects = false,
@@ -265,6 +268,9 @@ export const CanvasNode = React.memo(function CanvasNode({
if (next !== data.title) onTitleChange?.(data.id, next);
};
+ const connectionTilt = isConnectionTarget && !reduceMediaEffects && !dragOffset
+ ? canvasConnectionTilt(data, connectionApproach) : undefined;
+
return (
onMouseDown(event, data.id)}
onDoubleClick={(event) => {
if (isBatchRoot) {
@@ -488,6 +498,8 @@ function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProp
previous.isRelated === next.isRelated &&
previous.isFocusRelated === next.isFocusRelated &&
previous.isConnectionTarget === next.isConnectionTarget &&
+ previous.connectionApproach?.x === next.connectionApproach?.x &&
+ previous.connectionApproach?.y === next.connectionApproach?.y &&
previous.forceInputVisible === next.forceInputVisible &&
previous.showImageInfo === next.showImageInfo &&
previous.reduceMediaEffects === next.reduceMediaEffects &&
diff --git a/web/src/components/canvas/canvas-toolbar.tsx b/web/src/components/canvas/canvas-toolbar.tsx
index ba76508b4..6cd2fc569 100644
--- a/web/src/components/canvas/canvas-toolbar.tsx
+++ b/web/src/components/canvas/canvas-toolbar.tsx
@@ -6,6 +6,7 @@ import { Palette, Info } from "lucide-react";
import { FloatingDock } from "@/components/ui/aceternity/floating-dock";
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
import { CanvasAppearanceControls } from "@/components/canvas/canvas-appearance-controls";
+import { useCanvasOverlayLayer } from "@/components/canvas/canvas-overlay-layer";
import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu";
import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands";
import { ToolbarSettingsModal } from "@/components/canvas/toolbars/toolbar-settings-modal";
@@ -89,6 +90,7 @@ export function CanvasToolbar({
onOpenProjectCharacters: () => void;
}) {
const rootRef = useRef
(null);
+ const { bringToFront, zIndex } = useCanvasOverlayLayer("main-toolbar", "var(--z-toolbar)");
const dockRef = useRef(null);
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
@@ -98,6 +100,10 @@ export function CanvasToolbar({
const [panelX, setPanelX] = useState(0);
const [prefs, setPrefs] = useState(() => readToolbarPrefs("main"));
+ useEffect(() => {
+ if (addOpen || appearanceOpen) bringToFront();
+ }, [addOpen, appearanceOpen, bringToFront]);
+
// 设置面板关闭后重新读取偏好(用户可能调整了排序/显隐)
useEffect(() => {
if (!settingsOpen) setPrefs(readToolbarPrefs("main"));
@@ -186,7 +192,7 @@ export function CanvasToolbar({
const createCommands = useCanvasCreateCommands(ctx, runAddAction);
return (
-
+
{addOpen ? (
上传文件
-
批量导入图片、视频和音频到当前画布
+
批量导入图片、视频、音频和文本到当前画布
已选 {fileList.length} 项
@@ -67,7 +69,7 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
fileList={fileList}
beforeUpload={(file) => {
if (isCanvasUploadFile(file)) return false;
- message.warning(`“${file.name}”不是支持的图片、视频或音频文件`);
+ message.warning(`“${file.name}”不是支持的图片、视频、音频或 TXT / Markdown 文件`);
return Upload.LIST_IGNORE;
}}
onChange={({ fileList: nextFileList }) => setFileList(nextFileList)}
@@ -86,7 +88,8 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
图片
视频
- MP3 / WAV
+ 音频
+ TXT / Markdown
@@ -148,11 +151,11 @@ function CanvasUploadFilePreview({ file }: { file: UploadFile }) {
}
return (
- {source && isAudioFile(source) ? : }
+ {source && isTextUploadFile(source) ? : source && (source.type.startsWith("audio/") || isAudioFile(source)) ? : }
);
}
function isCanvasUploadFile(file: File) {
- return file.type.startsWith("image/") || file.type.startsWith("video/") || isAudioFile(file);
+ return Boolean(uploadNodeType(file));
}
diff --git a/web/src/components/canvas/infinite-canvas.tsx b/web/src/components/canvas/infinite-canvas.tsx
index 2639dae68..be7d307ee 100644
--- a/web/src/components/canvas/infinite-canvas.tsx
+++ b/web/src/components/canvas/infinite-canvas.tsx
@@ -397,6 +397,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
return (
, point?: Position) {
+ if (!point || node.width <= 0 || node.height <= 0) return undefined;
+ const x = Math.max(0, Math.min(1, (point.x - node.position.x) / node.width));
+ const y = Math.max(0, Math.min(1, (point.y - node.position.y) / node.height));
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined;
+ return { rotateX: (0.5 - y) * 10, rotateY: (x - 0.5) * 10, origin: `${x * 100}% ${y * 100}%` };
+}
diff --git a/web/src/lib/canvas/canvas-file-upload.ts b/web/src/lib/canvas/canvas-file-upload.ts
new file mode 100644
index 000000000..096b4b2aa
--- /dev/null
+++ b/web/src/lib/canvas/canvas-file-upload.ts
@@ -0,0 +1,87 @@
+import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
+import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
+import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
+
+export const CANVAS_UPLOAD_ACCEPT = "image/*,video/*,audio/*,.mp3,.wav,text/plain,text/markdown,.txt,.md,.markdown";
+export function isTextUploadFile(file: Pick
) {
+ return /\.(txt|md|markdown)$/i.test(file.name) || file.type === "text/plain" || file.type === "text/markdown";
+}
+export function uploadNodeType(file: Pick) {
+ if (file.type.startsWith("image/")) return CanvasNodeType.Image;
+ if (file.type.startsWith("video/")) return CanvasNodeType.Video;
+ if (file.type.startsWith("audio/") || /\.(mp3|wav)$/i.test(file.name)) return CanvasNodeType.Audio;
+ if (isTextUploadFile(file)) return CanvasNodeType.Text;
+ return null;
+}
+export function uploadPercent(loaded: number, total: number): number | undefined {
+ if (!Number.isFinite(loaded) || !Number.isFinite(total) || total <= 0) return undefined;
+ return Math.round(Math.min(1, Math.max(0, loaded / total)) * 100);
+}
+export function uploadVeilOpacity(percent = 0) {
+ return 1 - Math.min(100, Math.max(0, percent)) / 100 * 0.9;
+}
+
+export async function createFileUploadPlaceholder(id: string, file: File, position: Position) {
+ const type = uploadNodeType(file);
+ if (!type) throw new Error("请选择图片、视频、音频或 TXT / Markdown 文件");
+ if (type === CanvasNodeType.Image) return createImageUploadPlaceholder(id, file, position, await readUploadImageSize(file));
+ const size = type === CanvasNodeType.Video ? fitNodeSize(...await readUploadVideoSize(file)) : NODE_DEFAULT_SIZE[type];
+ return {
+ id, type, title: file.name, ...size,
+ position: { x: position.x - size.width / 2, y: position.y - size.height / 2 },
+ metadata: { fileUpload: "uploading", bytes: file.size, mimeType: file.type },
+ } satisfies CanvasNodeData;
+}
+
+async function readUploadVideoSize(file: File): Promise<[number, number]> {
+ const url = URL.createObjectURL(file);
+ const video = document.createElement("video");
+ try {
+ return await new Promise((resolve, reject) => {
+ const timer = window.setTimeout(() => reject(new Error("视频尺寸读取超时,请确认文件可播放")), 15000);
+ video.onloadedmetadata = () => {
+ window.clearTimeout(timer);
+ if (video.videoWidth && video.videoHeight) resolve([video.videoWidth, video.videoHeight]);
+ else reject(new Error("无法读取视频尺寸"));
+ };
+ video.onerror = () => { window.clearTimeout(timer); reject(new Error("无法读取视频,请确认格式受浏览器支持")); };
+ video.preload = "metadata";
+ video.src = url;
+ });
+ } finally {
+ video.onloadedmetadata = null;
+ video.onerror = null;
+ video.removeAttribute("src");
+ video.load();
+ URL.revokeObjectURL(url);
+ }
+}
+
+// 不使用 readImageMeta 的默认尺寸兜底:上传占位必须来自实际解码尺寸。
+export async function readUploadImageSize(file: File): Promise<{ width: number; height: number }> {
+ const url = URL.createObjectURL(file);
+ const image = new Image();
+ try {
+ image.src = url;
+ await image.decode();
+ if (!image.naturalWidth || !image.naturalHeight) throw new Error("无法读取图片尺寸,请重新选择图片");
+ return { width: image.naturalWidth, height: image.naturalHeight };
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+}
+
+export function createImageUploadPlaceholder(id: string, file: Pick, position: Position, naturalSize: { width: number; height: number }): CanvasNodeData {
+ const size = fitNodeSize(naturalSize.width, naturalSize.height);
+ return {
+ id, type: CanvasNodeType.Image, title: file.name,
+ position: { x: position.x - size.width / 2, y: position.y - size.height / 2 },
+ ...size,
+ metadata: { fileUpload: "uploading", naturalWidth: naturalSize.width, naturalHeight: naturalSize.height, bytes: file.size, mimeType: file.type },
+ };
+}
+
+export function interruptFileUpload(node: CanvasNodeData): CanvasNodeData {
+ if (node.metadata?.fileUpload !== "uploading") return node;
+ return { ...node, metadata: { ...node.metadata, fileUpload: "error", fileUploadProgress: undefined, errorDetails: "上传已中断,请重新选择文件上传" } };
+}
diff --git a/web/src/lib/canvas/canvas-generation-task-sync.ts b/web/src/lib/canvas/canvas-generation-task-sync.ts
index 31f45e94f..f2ef9f2d1 100644
--- a/web/src/lib/canvas/canvas-generation-task-sync.ts
+++ b/web/src/lib/canvas/canvas-generation-task-sync.ts
@@ -15,7 +15,7 @@ import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type Ca
export function generationTaskInput(task: GenerationTask) {
if (!task.inputJson) return null;
try {
- return JSON.parse(task.inputJson) as { mode?: CanvasGenerationMode; metadata?: { nodeId?: string; sourceNodeId?: string }; prompt?: string };
+ return JSON.parse(task.inputJson) as { mode?: CanvasGenerationMode; metadata?: { nodeId?: string; sourceNodeId?: string; domainProjectId?: string }; prompt?: string };
} catch {
return null;
}
@@ -258,6 +258,9 @@ function applySuccessfulVersionSelection(nodes: CanvasNodeData[], updatedNode: C
export async function syncGenerationTaskToCanvasStore(task: GenerationTask) {
if (task.status !== "succeeded" || !task.projectId) return false;
+ // 短剧任务使用业务项目 ID,不能拿它请求同名的画布项目。
+ const domainProjectId = task.clientContext?.domainProjectId || generationTaskInput(task)?.metadata?.domainProjectId;
+ if (domainProjectId === task.projectId || !generationTaskNodeId(task)) return false;
const { loadCanvasProjectForEditing } = await import("@/services/user-data-sync");
const project = await loadCanvasProjectForEditing(task.projectId);
if (!project) return false;
diff --git a/web/src/lib/canvas/canvas-image-source.ts b/web/src/lib/canvas/canvas-image-source.ts
new file mode 100644
index 000000000..101cae6c9
--- /dev/null
+++ b/web/src/lib/canvas/canvas-image-source.ts
@@ -0,0 +1,30 @@
+import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
+
+/** 已有媒体但没有生成配置的图片是输入素材,不提供原地生成入口。 */
+export function isCanvasImageSourceNode(node: CanvasNodeData | null | undefined): boolean {
+ if (node?.type === CanvasNodeType.Image && node.metadata?.fileUpload) return true;
+ return node?.type === CanvasNodeType.Image
+ && Boolean(node.metadata?.content)
+ && !node.metadata?.generationType
+ && !node.metadata?.taskId
+ && !node.metadata?.generationResultPlacement
+ && !node.metadata?.copiedFromNodeId
+ && !node.metadata?.versionOfNodeId;
+}
+
+export function createPortraitTextureNode(source: CanvasNodeData, id: string): CanvasNodeData {
+ return {
+ id,
+ type: CanvasNodeType.Image,
+ title: `${source.title || "图片"} · 人物质感`,
+ position: { x: source.position.x + source.width + 96, y: source.position.y },
+ width: source.width,
+ height: source.height,
+ metadata: {
+ prompt: "@图片1",
+ composerContent: "@图片1",
+ generationType: "edit",
+ portraitTexture: source.metadata?.portraitTexture,
+ },
+ };
+}
diff --git a/web/src/lib/canvas/canvas-project-generation.ts b/web/src/lib/canvas/canvas-project-generation.ts
index 55d6cfeca..3d61ca857 100644
--- a/web/src/lib/canvas/canvas-project-generation.ts
+++ b/web/src/lib/canvas/canvas-project-generation.ts
@@ -11,6 +11,7 @@ import { modelCapabilityConfigFor, workflowFieldCurrentValue, workflowFieldHasSt
import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, resolveVideoOperation, type ModelGenerationDefaults, type ModelRequirements } from "@/lib/model-selection";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
+import { interruptFileUpload } from "@/lib/canvas/canvas-file-upload";
import { isCanvasWorkflowProvider, resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, type CanvasImageGenerationType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasVideoEditOperation } from "@/types/canvas";
@@ -552,7 +553,7 @@ export function resetInterruptedGeneration(nodes: CanvasNodeData[]) {
const configHeight = NODE_DEFAULT_SIZE[CanvasNodeType.Config].height;
let changed = false;
const reset = nodes.map((node) => {
- const mediaNode = ensureMediaNodeMinimumSize(node);
+ const mediaNode = ensureMediaNodeMinimumSize(interruptFileUpload(node));
const resizedNode =
mediaNode.type === CanvasNodeType.Config && (mediaNode.width < configWidth || mediaNode.height < configHeight)
? { ...mediaNode, width: Math.max(mediaNode.width, configWidth), height: Math.max(mediaNode.height, configHeight) }
diff --git a/web/src/lib/canvas/canvas-resource-references.ts b/web/src/lib/canvas/canvas-resource-references.ts
index 512c5b11b..bd1e3aa11 100644
--- a/web/src/lib/canvas/canvas-resource-references.ts
+++ b/web/src/lib/canvas/canvas-resource-references.ts
@@ -216,7 +216,7 @@ export function buildCanvasNodeMentionReferenceMap(nodes: CanvasNodeData[], conn
const configTargetId = configTargetBySourceId.get(node.id);
const configInputs = configTargetId ? (resourceInputsByTargetId.get(configTargetId) || []).filter((input) => input.id !== node.id) : [];
const ownInputs = resourceInputsByTargetId.get(node.id) || [];
- const inputs = configInputs.length ? configInputs : ownInputs.length ? ownInputs : isResourceNode(node) ? [node] : [];
+ const inputs = configInputs.length ? configInputs : ownInputs.filter((input) => input.id !== node.id);
referencesByNodeId.set(node.id, labelResourceNodes(inputs, true));
}
return referencesByNodeId;
@@ -231,8 +231,7 @@ export function getMentionResourceNodes(nodeId: string, nodes: CanvasNodeData[],
if (configInputs.length) return configInputs;
const ownInputs = getContextResourceNodes(nodeId, nodes, connections);
if (ownInputs.length) return ownInputs;
- const node = nodes.find((item) => item.id === nodeId);
- return node && isResourceNode(node) ? [node] : [];
+ return [];
}
export function getGenerationResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
diff --git a/web/src/lib/canvas/canvas-video-hover-preview.ts b/web/src/lib/canvas/canvas-video-hover-preview.ts
new file mode 100644
index 000000000..880a89165
--- /dev/null
+++ b/web/src/lib/canvas/canvas-video-hover-preview.ts
@@ -0,0 +1,93 @@
+// Only the hovered node owns a decoder; pending hover work shares the same lease.
+let stopCurrentPreview: (() => void) | undefined;
+export const VIDEO_HOVER_DELAY_MS = 350;
+export const VIDEO_HOVER_PREVIEW_MS = 3000;
+
+export function bindCanvasVideoHoverPreview(element: HTMLElement, resolveSource: () => Promise) {
+ let stop: (() => void) | undefined;
+ const enter = (event: PointerEvent) => {
+ if (event.pointerType !== "mouse" || event.buttons || document.hidden
+ || window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ || element.closest('[data-canvas-viewport-interacting="true"]')) return;
+ stopCurrentPreview?.();
+ let disposed = false;
+ let video: HTMLVideoElement | undefined;
+ let playbackTimer: ReturnType | undefined;
+ const listeners = new AbortController();
+ const viewport = element.closest("[data-canvas-viewport]");
+ const visibility = new IntersectionObserver((entries) => {
+ if (entries.some((entry) => !entry.isIntersecting)) cancel();
+ });
+ const observer = new MutationObserver(() => {
+ if (viewport?.getAttribute("data-canvas-viewport-interacting") === "true") cancel();
+ });
+ const cancel = () => {
+ if (disposed) return;
+ disposed = true;
+ clearTimeout(delay);
+ clearTimeout(deadline);
+ clearTimeout(playbackTimer);
+ listeners.abort();
+ observer.disconnect();
+ visibility.disconnect();
+ if (video) {
+ video.pause();
+ video.removeAttribute("src");
+ video.load();
+ video.remove();
+ }
+ if (stopCurrentPreview === cancel) stopCurrentPreview = undefined;
+ if (stop === cancel) stop = undefined;
+ };
+ const delay = setTimeout(() => {
+ // Explicit playback wins over an incidental hover.
+ if (Array.from(document.querySelectorAll("video, audio")).some((media) => !(media as HTMLMediaElement).paused)) {
+ cancel();
+ return;
+ }
+ void resolveSource().then((src) => {
+ if (disposed) return;
+ if (!src || !element.isConnected || document.hidden) { cancel(); return; }
+ video = document.createElement("video");
+ video.muted = true;
+ video.playsInline = true;
+ video.preload = "metadata";
+ video.className = "pointer-events-none absolute inset-0 size-full rounded-[var(--node-radius)] bg-black object-contain";
+ video.setAttribute("aria-hidden", "true");
+ video.dataset.canvasHoverPreview = "true";
+ video.addEventListener("ended", cancel, { signal: listeners.signal });
+ video.addEventListener("error", cancel, { signal: listeners.signal });
+ video.addEventListener("timeupdate", () => {
+ if (video && video.currentTime >= VIDEO_HOVER_PREVIEW_MS / 1000) cancel();
+ }, { signal: listeners.signal });
+ video.addEventListener("playing", () => {
+ if (!playbackTimer) playbackTimer = setTimeout(cancel, VIDEO_HOVER_PREVIEW_MS);
+ }, { signal: listeners.signal });
+ video.src = src;
+ element.appendChild(video);
+ void video.play().catch(cancel);
+ }).catch(cancel);
+ }, VIDEO_HOVER_DELAY_MS);
+ // Bound stalled network/decoder sessions as well as actual playback.
+ const deadline = setTimeout(cancel, 8000);
+ stop = cancel;
+ stopCurrentPreview = cancel;
+ visibility.observe(element);
+ if (viewport) observer.observe(viewport, { attributes: true, attributeFilter: ["data-canvas-viewport-interacting"] });
+ for (const type of ["pointerdown", "wheel", "keydown", "blur"] as const) {
+ window.addEventListener(type, cancel, { capture: true, passive: true, signal: listeners.signal });
+ }
+ document.addEventListener("visibilitychange", cancel, { signal: listeners.signal });
+ document.addEventListener("play", (event) => { if (event.target !== video) cancel(); }, { capture: true, signal: listeners.signal });
+ };
+ const leave = () => stop?.();
+ element.addEventListener("pointerenter", enter);
+ element.addEventListener("pointerleave", leave);
+ element.addEventListener("pointercancel", leave);
+ return () => {
+ leave();
+ element.removeEventListener("pointerenter", enter);
+ element.removeEventListener("pointerleave", leave);
+ element.removeEventListener("pointercancel", leave);
+ };
+}
diff --git a/web/src/lib/canvas/chapter-asset-breakdown.ts b/web/src/lib/canvas/chapter-asset-breakdown.ts
new file mode 100644
index 000000000..f95292bc8
--- /dev/null
+++ b/web/src/lib/canvas/chapter-asset-breakdown.ts
@@ -0,0 +1,27 @@
+import { extractCharacterBreakdownJson, parseCharacterBreakdown, type CharacterBreakdown } from "./canvas-character-reference";
+
+export type ChapterAssetDefinition = { name: string; description: string; prompt: string };
+export type ChapterAssetBreakdown = { characters: CharacterBreakdown[]; scenes: ChapterAssetDefinition[]; props: ChapterAssetDefinition[] };
+
+export function parseChapterAssetBreakdown(raw: string): ChapterAssetBreakdown {
+ const text = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
+ const value: unknown = extractCharacterBreakdownJson(text, true);
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("章节资产结果必须是 JSON 对象");
+ const payload = value as Record;
+ if (!Array.isArray(payload.characters) || !Array.isArray(payload.scenes) || !Array.isArray(payload.props)) {
+ throw new Error("章节资产结果缺少角色、场景或道具数组,请重新提取");
+ }
+ const parseAssets = (items: unknown[], label: string): ChapterAssetDefinition[] => items.map((item) => {
+ if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`${label}资产格式无效`);
+ const asset = item as Record;
+ for (const key of ["name", "description", "prompt"]) {
+ if (typeof asset[key] !== "string" || !asset[key].trim()) throw new Error(`${label}资产缺少有效的 ${key}`);
+ }
+ return { name: (asset.name as string).trim(), description: (asset.description as string).trim(), prompt: (asset.prompt as string).trim() };
+ });
+ return {
+ characters: payload.characters.length ? parseCharacterBreakdown(JSON.stringify({ characters: payload.characters })) : [],
+ scenes: parseAssets(payload.scenes, "场景"),
+ props: parseAssets(payload.props, "道具"),
+ };
+}
diff --git a/web/src/lib/canvas/tool-registry/definitions/node-hover-tools.tsx b/web/src/lib/canvas/tool-registry/definitions/node-hover-tools.tsx
index 0b3f81fb2..3c29b3ceb 100644
--- a/web/src/lib/canvas/tool-registry/definitions/node-hover-tools.tsx
+++ b/web/src/lib/canvas/tool-registry/definitions/node-hover-tools.tsx
@@ -3,6 +3,7 @@ import { AudioLines, Captions, Clapperboard, Download, FolderPlus, Images, Image
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { registerToolbarTools, type ToolContext, type ToolDefinition } from "@/lib/canvas/tool-registry";
import { CanvasNodeType } from "@/types/canvas";
+import { isCanvasImageSourceNode } from "@/lib/canvas/canvas-image-source";
// 节点状态判定辅助函数——从 ToolContext 派生
function isImage(ctx: ToolContext) { return ctx.node?.type === CanvasNodeType.Image; }
@@ -15,10 +16,11 @@ function hasVideo(ctx: ToolContext) { return isVideo(ctx) && Boolean(ctx.nodeMet
function hasAudio(ctx: ToolContext) { return isAudio(ctx) && Boolean(ctx.nodeMetadata?.content); }
function isCharacterReference(ctx: ToolContext) { return isText(ctx) && ctx.nodeMetadata?.workflowKind === "character" && Boolean(ctx.nodeMetadata?.characterAssetId); }
function isEditableText(ctx: ToolContext) { return isText(ctx) && !isCharacterReference(ctx); }
-function canOpenDialog(ctx: ToolContext) { return isEditableText(ctx) || isImage(ctx) || isVideo(ctx); }
+function canOpenDialog(ctx: ToolContext) { return isEditableText(ctx) || (isImage(ctx) && !isCanvasImageSourceNode(ctx.node)) || isVideo(ctx); }
function simpleMode(ctx: ToolContext) { return ctx.workspaceMode === "simple"; }
function isImageBatchRoot(ctx: ToolContext) { return isImage(ctx) && Boolean(ctx.nodeMetadata?.isBatchRoot && ctx.nodeMetadata.batchChildIds?.length); }
function canRetry(ctx: ToolContext) {
+ if (ctx.nodeMetadata?.fileUpload) return false;
const requiresPromptChange = ctx.nodeMetadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(ctx.nodeMetadata?.errorDetails);
const batchHasFailures = isImageBatchRoot(ctx) && (ctx.nodeMetadata?.batchFailedCount || (ctx.nodeMetadata?.status === "error" ? 1 : 0)) > 0;
return (ctx.nodeMetadata?.status === "error" || (batchHasFailures && ctx.nodeMetadata?.status !== "loading")) && !requiresPromptChange;
diff --git a/web/src/pages/admin/logs/logs-page.tsx b/web/src/pages/admin/logs/logs-page.tsx
index 897a58723..ce32906e9 100644
--- a/web/src/pages/admin/logs/logs-page.tsx
+++ b/web/src/pages/admin/logs/logs-page.tsx
@@ -20,6 +20,7 @@ export default function LogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const keyword = searchParams.get("filter") || "";
const status = normalizeStatus(searchParams.get("status"));
+ const recordType = searchParams.get("recordType") === "download" ? "download" : searchParams.get("recordType") === "all" ? "all" : "request";
const page = positiveInt(searchParams.get("page"), 1);
const pageSize = normalizePageSize(searchParams.get("pageSize"));
const debouncedKeyword = useDebouncedValue(keyword);
@@ -30,7 +31,7 @@ export default function LogsPage() {
const [detailLogId, setDetailLogId] = useState(null);
const [mediaPreview, setMediaPreview] = useState<{ url: string; kind: "image" | "video"; title: string } | null>(null);
const requestSequence = useRef(0);
- const hasFilters = Boolean(keyword || status !== "all");
+ const hasFilters = Boolean(keyword || status !== "all" || recordType !== "request");
const updateUrl = (patch: Record, replace = false) => {
const next = new URLSearchParams(searchParams);
@@ -45,7 +46,7 @@ export default function LogsPage() {
useEffect(() => {
const sequence = ++requestSequence.current;
setLoading(true);
- void listAdminApiLogs({ keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, limit: pageSize })
+ void listAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, limit: pageSize })
.then((result) => {
if (sequence !== requestSequence.current) return;
setLogs(result.logs);
@@ -55,7 +56,7 @@ export default function LogsPage() {
})
.catch((error) => sequence === requestSequence.current && message.error(error instanceof Error ? error.message : "读取请求明细失败"))
.finally(() => sequence === requestSequence.current && setLoading(false));
- }, [debouncedKeyword, status, page, pageSize]);
+ }, [debouncedKeyword, status, recordType, page, pageSize]);
const columns: ColumnsType = [
{ title: "时间", width: 168, render: (_, log) => formatTime(log.startedAt || log.createdAt) },
@@ -129,7 +130,7 @@ export default function LogsPage() {
description="模型生成、状态查询与结果下载;仅计费调用扣除积分"
actions={
exportAdminApiLogs({ keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })}
+ exportFile={() => exportAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })}
fileName={() => `请求明细-${new Date().toISOString().slice(0, 10)}.csv`}
label="导出当前筛选"
successMessage="已按当前筛选导出请求明细"
@@ -155,6 +156,8 @@ export default function LogsPage() {
>
}
toolbarFilters={
+ <>
+
-
{progress}%
+
处理步骤 {status.step}/{status.total}
);
}
diff --git a/web/src/pages/canvas/canvas-project-world-layers.tsx b/web/src/pages/canvas/canvas-project-world-layers.tsx
index 9c47691c6..d023b11f4 100644
--- a/web/src/pages/canvas/canvas-project-world-layers.tsx
+++ b/web/src/pages/canvas/canvas-project-world-layers.tsx
@@ -4,6 +4,7 @@ import { Link2 } from "lucide-react";
import { ConnectionPath } from "@/components/canvas/canvas-connections";
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
import { CanvasNode } from "@/components/canvas/canvas-node";
+import type { CanvasConnectionApproach } from "@/lib/canvas/canvas-connection-tilt";
import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
import { sortCanvasNodesByStackOrder, type CanvasNodeStackOrder } from "@/lib/canvas/canvas-node-stack-order";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
@@ -24,6 +25,7 @@ type CanvasProjectWorldLayersProps = {
connectingParams: ConnectionHandle | null;
mouseWorld: Position;
connectionTargetNodeId: string | null;
+ connectionApproach: CanvasConnectionApproach;
nodeById: Map
;
visibleNodes: CanvasNodeData[];
nodeStackOrder: CanvasNodeStackOrder;
@@ -159,6 +161,7 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
isRelated={props.relatedNodeIds.has(node.id)}
isFocusRelated={props.activeNodeId === node.id}
isConnectionTarget={props.connectionTargetNodeId === node.id || props.batchConnectionPreview?.targetNodeId === node.id}
+ connectionApproach={props.connectionApproach?.nodeId === node.id ? props.connectionApproach.point : undefined}
forceInputVisible={Boolean(props.batchConnectionPreview)}
batchCount={props.batchChildCountById.get(node.id) || 0}
batchExpanded={Boolean(node.metadata?.imageBatchExpanded)}
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index 490b71585..8a17176ae 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -12,6 +12,7 @@ import { getActiveUserScope } from "@/lib/user-scope";
import { resourceFileUrl, resourceIdFromStorageKey, syncResourceToArkPrivateAsset } from "@/services/api/resources";
import { uploadImage } from "@/services/image-storage";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
+import { isCanvasImageSourceNode } from "@/lib/canvas/canvas-image-source";
import copyToClipboard from "copy-to-clipboard";
import { nanoid } from "nanoid";
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, writeCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
@@ -983,6 +984,7 @@ function InfiniteCanvasPage() {
closeConnectionCreateMenu,
connectionTargetAnchorRatio,
connectionTargetNodeId,
+ connectionApproach,
connectingParams,
createConnectedNode,
getConnectionCreateDisabledReason,
@@ -1460,6 +1462,7 @@ function InfiniteCanvasPage() {
updateNodeFromContent(nodeId, (node) => ({ ...node, metadata: { ...node.metadata, ...patch } }));
}, [updateNodeFromContent]);
const canvasNodeActions = useMemo(() => ({
+ upload: replaceCanvasNodeMedia,
download: downloadNodeImage,
duplicate: duplicateNodeFromContent,
deleteNode: deleteNodeFromContent,
@@ -1468,7 +1471,7 @@ function InfiniteCanvasPage() {
updateMediaNode: updateMediaNodeFromContent,
openPortraitClearance,
openArtCritique,
- }), [deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, openPortraitClearance, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent]);
+ }), [deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, openPortraitClearance, replaceCanvasNodeMedia, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent]);
const { agentSnapshot, agentUndoCount, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasAgentOperations({
projectId,
domainProjectId: currentProject?.projectId,
@@ -2295,6 +2298,7 @@ function InfiniteCanvasPage() {
) : null}
- {dialogNode && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox && !isCanvasNodeMoving ? (
+ {dialogNode && !isCanvasImageSourceNode(dialogNode) && !dialogNode.metadata?.fileUpload && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox && !isCanvasNodeMoving ? (
state.runtimeStatuses);
const [connectingParams, setConnectingParams] = useState(null);
const [connectionTargetNodeId, setConnectionTargetNodeId] = useState(null);
+ const [connectionApproach, setConnectionApproach] = useState(null);
const [connectionTargetAnchorRatio, setConnectionTargetAnchorRatio] = useState();
const [pendingConnectionCreate, setPendingConnectionCreate] = useState(null);
const [batchConnectionPreview, setBatchConnectionPreview] = useState(null);
@@ -108,6 +110,7 @@ export function useCanvasConnectionController({
const updateBatchConnectionPreview = useCallback((next: CanvasBatchConnectionPreview | null) => {
batchConnectionPreviewRef.current = next;
setBatchConnectionPreview(next);
+ setConnectionApproach((previous) => latchCanvasConnectionApproach(previous, next && next.status !== "invalid" ? next.targetNodeId : null, next?.mouseWorld || { x: 0, y: 0 }));
}, []);
const clearBatchConnection = useCallback(() => {
@@ -120,6 +123,7 @@ export function useCanvasConnectionController({
connectingParamsRef.current = next;
setConnectingParams(next);
if (!next) {
+ setConnectionApproach(null);
connectingPointerIdRef.current = null;
connectingPointerStartRef.current = null;
setConnectionTargetNodeId(null);
@@ -596,9 +600,11 @@ export function useCanvasConnectionController({
const current = connectingParamsRef.current;
if (!current || connectingPointerIdRef.current !== event.pointerId || pendingConnectionCreateRef.current) return;
const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, current);
+ const point = screenToCanvas(event.clientX, event.clientY);
+ setConnectionApproach((previous) => latchCanvasConnectionApproach(previous, dropTarget.nodeId, point));
setConnectionTargetNodeId(dropTarget.nodeId);
setConnectionTargetAnchorRatio(dropTarget.anchorRatio);
- setMouseWorld(screenToCanvas(event.clientX, event.clientY));
+ setMouseWorld(point);
};
const handlePointerMove = (event: PointerEvent) => {
// Pointer events can arrive faster than the canvas can paint. Keep
@@ -677,6 +683,7 @@ export function useCanvasConnectionController({
cancelPendingConnectionCreate,
closeConnectionCreateMenu,
connectionTargetNodeId,
+ connectionApproach,
connectionTargetAnchorRatio,
connectingParams,
createConnectedNode,
diff --git a/web/src/pages/canvas/use-canvas-media-tools.ts b/web/src/pages/canvas/use-canvas-media-tools.ts
index 506383879..da4e34b5e 100644
--- a/web/src/pages/canvas/use-canvas-media-tools.ts
+++ b/web/src/pages/canvas/use-canvas-media-tools.ts
@@ -26,6 +26,7 @@ import {
import { fitNodeSize, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
import { compositeEmotionImage, emotionGenerationSize, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "@/lib/canvas/canvas-emotion";
import { DEFAULT_PORTRAIT_TEXTURE_SETTINGS } from "@/lib/canvas/canvas-portrait-texture";
+import { createPortraitTextureNode } from "@/lib/canvas/canvas-image-source";
import { captureVideoFrames } from "@/lib/canvas/canvas-video-frame";
import { buildVideoFrameNodes } from "@/lib/canvas/canvas-video-frame-nodes";
import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video-merge";
@@ -182,14 +183,16 @@ export function useCanvasMediaTools({
return;
}
const portraitTextureSettings = { ...DEFAULT_PORTRAIT_TEXTURE_SETTINGS, ...node.metadata?.portraitTexture };
- const composerContent = node.metadata?.composerContent?.trim() || node.metadata?.prompt?.trim() || "@图片1";
+ const child = createPortraitTextureNode(node, nanoid());
+ child.metadata = { ...child.metadata, portraitTexture: portraitTextureSettings };
setHoveredNodeId(null);
setToolbarNodeId(null);
- setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, prompt: composerContent, composerContent, portraitTexture: portraitTextureSettings } } : item));
- setSelectedNodeIds(new Set([node.id]));
+ setNodes((current) => [...current, child]);
+ setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: child.id }]);
+ setSelectedNodeIds(new Set([child.id]));
setSelectedConnectionId(null);
- setDialogNodeId(node.id);
- }, [message, setDialogNodeId, setHoveredNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, setToolbarNodeId]);
+ setDialogNodeId(child.id);
+ }, [message, setConnections, setDialogNodeId, setHoveredNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, setToolbarNodeId]);
const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => {
if (!node.metadata?.content) return;
diff --git a/web/src/pages/canvas/use-canvas-upload.ts b/web/src/pages/canvas/use-canvas-upload.ts
index f87797a52..780a07c84 100644
--- a/web/src/pages/canvas/use-canvas-upload.ts
+++ b/web/src/pages/canvas/use-canvas-upload.ts
@@ -11,6 +11,8 @@ import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { isAudioFile } from "@/lib/canvas/canvas-project-generation";
import { fitNodeSize, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
+import { CANVAS_UPLOAD_ACCEPT, createFileUploadPlaceholder, uploadNodeType, uploadPercent } from "@/lib/canvas/canvas-file-upload";
+import { resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { uploadMediaFile } from "@/services/file-storage";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { getProjectUnit } from "@/services/api/projects";
@@ -141,36 +143,83 @@ export function useCanvasUpload({
return result.assetId;
}, [canvasId, domainProjectId, queryClient]);
- const createImageFileNode = useCallback(async (file: File, position: Position) => {
- const progress = startUploadStatus("上传图片", "读取图片文件", domainProjectId ? 4 : 3);
+ const activeUploadsRef = useRef(new Set());
+ const createFileNode = useCallback(async (file: File, position: Position, replaceId?: string) => {
+ const original = replaceId ? nodesRef.current.find((node) => node.id === replaceId) : undefined;
+ if (replaceId && (!original || activeUploadsRef.current.has(replaceId))) return null;
+ const id = replaceId || `upload-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
+ activeUploadsRef.current.add(id);
+ const progress = startUploadStatus(replaceId ? "替换文件" : "上传文件", "读取文件信息", domainProjectId ? 4 : 3);
try {
- progress.update("上传到服务器并同步资源", 2);
- const image = await uploadImage(file);
- progress.update("更新画布节点", 3);
- const size = fitNodeSize(image.width, image.height);
- const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
- const node: CanvasNodeData = {
- id,
- type: CanvasNodeType.Image,
- title: file.name,
- position: { x: position.x - size.width / 2, y: position.y - size.height / 2 },
- width: size.width,
- height: size.height,
- metadata: imageMetadata(image),
+ const placeholder = await createFileUploadPlaceholder(id, file, position);
+ setNodes((current) => replaceId ? current.map((item) => item.id === id ? {
+ ...item, width: placeholder.width, height: placeholder.height,
+ metadata: { ...item.metadata, fileUpload: "uploading", fileUploadProgress: undefined, status: undefined, size: undefined, errorDetails: undefined },
+ } : item) : [...current, placeholder]);
+ selectInsertedNode(id, "close");
+ let lastPercent: number | undefined;
+ const onProgress = (loaded: number, total: number) => {
+ const percent = uploadPercent(loaded, total);
+ if (percent === undefined || percent === lastPercent) return;
+ lastPercent = percent;
+ setNodes((current) => current.map((item) => item.id === id ? { ...item, metadata: { ...item.metadata, fileUploadProgress: percent } } : item));
+ progress.update(percent === 100 ? "文件已传输,正在保存与处理" : `已上传 ${percent}%`, 2);
};
- setNodes((current) => [...current, node]);
- selectInsertedNode(id, "open");
+ progress.update("上传文件并同步资源", 2);
+ let metadata: CanvasNodeData["metadata"];
+ if (placeholder.type === CanvasNodeType.Image) {
+ metadata = imageMetadata(await uploadImage(file, onProgress));
+ } else if (placeholder.type === CanvasNodeType.Text) {
+ const content = await file.text();
+ const resource = await uploadResourceFile(file, "file", { fileName: file.name }, onProgress);
+ metadata = { content, prompt: content, storageKey: resourceStorageKey(resource.id), mimeType: file.type || "text/plain", bytes: file.size, status: "success" };
+ } else {
+ const media = await uploadMediaFile(file, placeholder.type === CanvasNodeType.Video ? "video" : "audio", onProgress);
+ metadata = placeholder.type === CanvasNodeType.Video ? videoMetadata(media) : audioMetadata(media);
+ }
+ progress.update("更新画布节点", 3);
+ const currentNode = nodesRef.current.find((item) => item.id === id);
+ if (!currentNode) {
+ progress.done("上传完成,画布占位已移除");
+ return null;
+ }
+ const node: CanvasNodeData = {
+ ...currentNode, type: placeholder.type,
+ metadata: {
+ ...currentNode.metadata, ...metadata,
+ fileUpload: undefined, fileUploadProgress: undefined, errorDetails: undefined,
+ ...(replaceId ? {
+ assetId: undefined, taskId: undefined, freeResize: false,
+ isBatchRoot: undefined, batchRootId: undefined, batchChildIds: undefined,
+ batchFailedCount: undefined, batchUsesReferenceImages: undefined,
+ generationType: undefined, generationResultPlacement: undefined,
+ copiedFromNodeId: undefined, versionOfNodeId: undefined,
+ model: undefined, size: undefined, quality: undefined,
+ transparentBackground: undefined, count: undefined, references: undefined,
+ primaryImageId: undefined, imageBatchExpanded: undefined,
+ richText: undefined, composerContent: undefined,
+ } : {}),
+ },
+ };
+ setNodes((current) => current.map((item) => item.id === id ? { ...item, type: node.type, metadata: node.metadata } : item));
if (domainProjectId) progress.update("写入项目资产", 4);
const persisted = await persistMediaNode(node);
- progress.done(persisted ? "图片已添加到画布" : "图片已添加,项目资产待重试");
+ const localOnly = metadata.storageKey && !resourceIdFromStorageKey(metadata.storageKey);
+ progress.done(localOnly ? "已保存在本机,尚未上传到服务器" : persisted ? "文件已添加到画布" : "文件已添加,项目资产待重试");
+ if (localOnly) message.warning("文件已保存在本机,尚未上传到服务器");
return id;
} catch (error) {
- const details = error instanceof Error ? error.message : "图片上传失败";
+ const details = error instanceof Error ? error.message : "文件上传失败";
+ setNodes((current) => current.map((item) => item.id !== id ? item : original?.metadata?.content ? {
+ ...item, width: original.width, height: original.height, metadata: original.metadata,
+ } : { ...item, metadata: { ...item.metadata, fileUpload: "error", fileUploadProgress: undefined, errorDetails: details } }));
progress.fail(details);
message.error(details);
return null;
+ } finally {
+ activeUploadsRef.current.delete(id);
}
- }, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
+ }, [domainProjectId, message, nodesRef, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
const createImageAssetNode = useCallback(async (asset: ImageAsset, position?: Position) => {
try {
@@ -209,52 +258,6 @@ export function useCanvasUpload({
}
}, [getCanvasCenter, message, selectInsertedNode, setNodes]);
- const createVideoFileNode = useCallback(async (file: File, position: Position) => {
- const progress = startUploadStatus("上传视频", "读取视频文件", domainProjectId ? 4 : 3);
- try {
- progress.update("上传到服务器并同步资源", 2);
- const video = await uploadMediaFile(file, "video");
- progress.update("更新画布节点", 3);
- const size = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
- const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
- const node = { id, type: CanvasNodeType.Video, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: videoMetadata(video) } satisfies CanvasNodeData;
- setNodes((current) => [...current, node]);
- selectInsertedNode(id, "open");
- if (domainProjectId) progress.update("写入项目资产", 4);
- const persisted = await persistMediaNode(node);
- progress.done(persisted ? "视频已添加到画布" : "视频已添加,项目资产待重试");
- return id;
- } catch (error) {
- const details = error instanceof Error ? error.message : "视频上传失败";
- progress.fail(details);
- message.error(details);
- return null;
- }
- }, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
-
- const createAudioFileNode = useCallback(async (file: File, position: Position) => {
- const progress = startUploadStatus("上传音频", "读取音频文件", domainProjectId ? 4 : 3);
- try {
- progress.update("上传到服务器并同步资源", 2);
- const audio = await uploadMediaFile(file, "audio");
- progress.update("更新画布节点", 3);
- const size = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
- const id = `audio-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
- const node = { id, type: CanvasNodeType.Audio, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: audioMetadata(audio) } satisfies CanvasNodeData;
- setNodes((current) => [...current, node]);
- selectInsertedNode(id, "preserve");
- if (domainProjectId) progress.update("写入项目资产", 4);
- const persisted = await persistMediaNode(node);
- progress.done(persisted ? "音频已添加到画布" : "音频已添加,项目资产待重试");
- return id;
- } catch (error) {
- const details = error instanceof Error ? error.message : "音频上传失败";
- progress.fail(details);
- message.error(details);
- return null;
- }
- }, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
-
const createTextNodeFromClipboard = useCallback((text: string, position?: Position) => {
const trimmed = text.trim();
if (!trimmed) return false;
@@ -312,8 +315,8 @@ export function useCanvasUpload({
: target?.type === CanvasNodeType.Video
? "video/*"
: target?.type === CanvasNodeType.Audio
- ? "audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav"
- : "image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav";
+ ? "audio/*,.mp3,.wav"
+ : target?.type === CanvasNodeType.Text ? "text/plain,text/markdown,.txt,.md,.markdown" : CANVAS_UPLOAD_ACCEPT;
}
imageInputRef.current?.click();
}, [nodesRef]);
@@ -324,9 +327,9 @@ export function useCanvasUpload({
}, []);
const handleUploadFiles = useCallback(async (files: File[]) => {
- const supportedFiles = files.filter((file) => file.type.startsWith("image/") || file.type.startsWith("video/") || isAudioFile(file));
+ const supportedFiles = files.filter((file) => uploadNodeType(file));
if (!supportedFiles.length) {
- message.warning("请选择图片、视频、MP3 或 WAV 文件");
+ message.warning("请选择图片、视频、音频或 TXT / Markdown 文件");
return false;
}
const center = uploadTargetRef.current?.position || getCanvasCenter();
@@ -339,11 +342,7 @@ export function useCanvasUpload({
x: originX + (index % columns) * BATCH_UPLOAD_COLUMN_GAP,
y: center.y + Math.floor(index / columns) * BATCH_UPLOAD_ROW_GAP,
};
- const createdId = await (isAudioFile(file)
- ? createAudioFileNode(file, position)
- : file.type.startsWith("video/")
- ? createVideoFileNode(file, position)
- : createImageFileNode(file, position));
+ const createdId = await createFileNode(file, position);
if (createdId) createdIds.push(createdId);
}
if (!createdIds.length) return false;
@@ -354,7 +353,7 @@ export function useCanvasUpload({
if (failedCount) message.warning(`已添加 ${createdIds.length} 个文件,${failedCount} 个上传失败`);
else message.success(`已添加 ${createdIds.length} 个文件到画布`);
return true;
- }, [createAudioFileNode, createImageFileNode, createVideoFileNode, getCanvasCenter, message, setDialogNodeId, setSelectedConnectionId, setSelectedNodeIds]);
+ }, [createFileNode, getCanvasCenter, message, setDialogNodeId, setSelectedConnectionId, setSelectedNodeIds]);
// 时间线专用:把本地音视频文件上传为直连媒体(仅时间线作用域,不创建画布节点),返回媒体描述数组。
const uploadTimelineMedia = useCallback(async (files: File[]): Promise => {
@@ -441,89 +440,11 @@ export function useCanvasUpload({
const replaceNodeMedia = useCallback(async (nodeId: string, file: File) => {
const currentNode = nodesRef.current.find((node) => node.id === nodeId);
- if (!currentNode) return false;
- if (isAudioFile(file)) {
- const progress = startUploadStatus("替换音频", "读取音频文件");
- try {
- progress.update("上传到服务器并同步资源", 2);
- const audio = await uploadMediaFile(file, "audio");
- progress.update("更新画布节点", 3);
- const node = { ...currentNode, type: CanvasNodeType.Audio, title: file.name, metadata: { ...currentNode.metadata, ...audioMetadata(audio), assetId: undefined, taskId: undefined, errorDetails: undefined } } satisfies CanvasNodeData;
- setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
- selectInsertedNode(nodeId, "preserve");
- const persisted = await persistMediaNode(node);
- progress.done(persisted ? "音频已替换,可撤销恢复" : "音频已替换,项目资产待重试");
- return true;
- } catch (error) {
- const details = error instanceof Error ? error.message : "音频替换失败";
- progress.fail(details);
- message.error(details);
- return false;
- }
- }
- if (file.type.startsWith("video/")) {
- const progress = startUploadStatus("替换视频", "读取视频文件");
- try {
- progress.update("上传到服务器并同步资源", 2);
- const video = await uploadMediaFile(file, "video");
- progress.update("更新画布节点", 3);
- const node = { ...currentNode, type: CanvasNodeType.Video, title: file.name, metadata: { ...currentNode.metadata, ...videoMetadata(video), assetId: undefined, taskId: undefined, errorDetails: undefined } } satisfies CanvasNodeData;
- setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
- selectInsertedNode(nodeId, "open");
- const persisted = await persistMediaNode(node);
- progress.done(persisted ? "视频已替换,可撤销恢复" : "视频已替换,项目资产待重试");
- return true;
- } catch (error) {
- const details = error instanceof Error ? error.message : "视频替换失败";
- progress.fail(details);
- message.error(details);
- return false;
- }
- }
- const progress = startUploadStatus("替换图片", "读取图片文件");
- try {
- progress.update("上传到服务器并同步资源", 2);
- const image = await uploadImage(file);
- progress.update("更新画布节点", 3);
- const node = {
- ...currentNode,
- type: CanvasNodeType.Image,
- title: file.name,
- metadata: {
- ...currentNode.metadata,
- ...imageMetadata(image),
- assetId: undefined,
- taskId: undefined,
- errorDetails: undefined,
- freeResize: false,
- isBatchRoot: undefined,
- batchRootId: undefined,
- batchChildIds: undefined,
- batchFailedCount: undefined,
- batchUsesReferenceImages: undefined,
- generationType: undefined,
- model: undefined,
- size: undefined,
- quality: undefined,
- transparentBackground: undefined,
- count: undefined,
- references: undefined,
- primaryImageId: undefined,
- imageBatchExpanded: undefined,
- },
- } satisfies CanvasNodeData;
- setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
- selectInsertedNode(nodeId, "open");
- const persisted = await persistMediaNode(node);
- progress.done(persisted ? "图片已替换,可撤销恢复" : "图片已替换,项目资产待重试");
- return true;
- } catch (error) {
- const details = error instanceof Error ? error.message : "图片替换失败";
- progress.fail(details);
- message.error(details);
- return false;
- }
- }, [message, nodesRef, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
+ if (!currentNode || !uploadNodeType(file)) return false;
+ const typedNode = [CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio, CanvasNodeType.Text].includes(currentNode.type as CanvasNodeType);
+ if (typedNode && uploadNodeType(file) !== currentNode.type) return false;
+ return Boolean(await createFileNode(file, currentNode.position, nodeId));
+ }, [createFileNode, nodesRef]);
const pasteSystemClipboard = useCallback(async (position?: Position, clipboardEvent?: ClipboardEvent | null) => {
const isNodeMarker = (value: string) => {
@@ -536,7 +457,7 @@ export function useCanvasUpload({
if (await replaceNodeMedia(selected[0].id, file)) message.success("已用剪切板图片替换,可撤销恢复");
return true;
}
- const inserted = await createImageFileNode(file, position || getCanvasCenter());
+ const inserted = await createFileNode(file, position || getCanvasCenter());
if (inserted) message.success("已从剪切板添加图片");
return Boolean(inserted);
};
@@ -587,20 +508,17 @@ export function useCanvasUpload({
// ignore
}
return false;
- }, [createImageFileNode, createTextNodeFromClipboard, getCanvasCenter, message, nodesRef, replaceNodeMedia, selectedNodeIdsRef]);
+ }, [createFileNode, createTextNodeFromClipboard, getCanvasCenter, message, nodesRef, replaceNodeMedia, selectedNodeIdsRef]);
const handleImageInputChange = useCallback(async (event: ChangeEvent) => {
const file = event.target.files?.[0];
const target = uploadTargetRef.current;
try {
- if (!file || (!file.type.startsWith("image/") && !file.type.startsWith("video/") && !isAudioFile(file))) return;
+ if (!file || !uploadNodeType(file)) return;
if (target?.nodeId) {
const targetNode = nodesRef.current.find((node) => node.id === target.nodeId);
- const compatible = !targetNode
- || (targetNode.type === CanvasNodeType.Image && file.type.startsWith("image/"))
- || (targetNode.type === CanvasNodeType.Video && file.type.startsWith("video/"))
- || (targetNode.type === CanvasNodeType.Audio && isAudioFile(file))
- || (targetNode.type !== CanvasNodeType.Image && targetNode.type !== CanvasNodeType.Video && targetNode.type !== CanvasNodeType.Audio);
+ const compatible = targetNode && (targetNode.type === uploadNodeType(file)
+ || ![CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio, CanvasNodeType.Text].includes(targetNode.type as CanvasNodeType));
if (!compatible) {
message.warning("请选择与当前节点相同类型的媒体文件");
return;
@@ -609,12 +527,12 @@ export function useCanvasUpload({
return;
}
const position = target?.position || getCanvasCenter();
- await (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position));
+ await createFileNode(file, position);
} finally {
uploadTargetRef.current = null;
event.target.value = "";
}
- }, [createAudioFileNode, createImageFileNode, createVideoFileNode, getCanvasCenter, message, nodesRef, replaceNodeMedia]);
+ }, [createFileNode, getCanvasCenter, message, nodesRef, replaceNodeMedia]);
const handleDrop = useCallback((event: DragEvent) => {
event.preventDefault();
@@ -635,7 +553,7 @@ export function useCanvasUpload({
void createImageAssetNode(asset, screenToCanvas(event.clientX, event.clientY));
return;
}
- const files = Array.from(event.dataTransfer.files).filter((item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item));
+ const files = Array.from(event.dataTransfer.files).filter((item) => uploadNodeType(item));
if (!files.length) return;
if (files.length > 1) {
void handleUploadFiles(files);
@@ -644,9 +562,7 @@ export function useCanvasUpload({
const file = files[0];
const position = screenToCanvas(event.clientX, event.clientY);
const target = [...nodesRef.current].reverse().find((node) => {
- const compatible = (node.type === CanvasNodeType.Image && file.type.startsWith("image/"))
- || (node.type === CanvasNodeType.Video && file.type.startsWith("video/"))
- || (node.type === CanvasNodeType.Audio && isAudioFile(file));
+ const compatible = node.type === uploadNodeType(file);
return compatible && position.x >= node.position.x && position.x <= node.position.x + node.width && position.y >= node.position.y && position.y <= node.position.y + node.height;
});
if (target) {
@@ -655,8 +571,8 @@ export function useCanvasUpload({
});
return;
}
- void (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position));
- }, [createAudioFileNode, createImageAssetNode, createImageFileNode, createVideoFileNode, handleProjectChapterInsert, handleUploadFiles, message, nodesRef, replaceNodeMedia, screenToCanvas]);
+ void createFileNode(file, position);
+ }, [createFileNode, createImageAssetNode, handleProjectChapterInsert, handleUploadFiles, message, nodesRef, replaceNodeMedia, screenToCanvas]);
const handleFileDragEnter = useCallback((event: DragEvent) => {
if (!hasDraggedFiles(event)) return;
@@ -678,10 +594,10 @@ export function useCanvasUpload({
}, []);
const pasteAssistantImage = useCallback((file: File) => {
- void createImageFileNode(file, getCanvasCenter()).then((inserted) => {
+ void createFileNode(file, getCanvasCenter()).then((inserted) => {
if (inserted) message.success("已从剪切板添加图片");
});
- }, [createImageFileNode, getCanvasCenter, message]);
+ }, [createFileNode, getCanvasCenter, message]);
const openAssetsAtPosition = useCallback((position?: Position) => {
assetInsertPositionRef.current = position || null;
diff --git a/web/src/pages/plugins/index.tsx b/web/src/pages/plugins/index.tsx
index decc8939a..ce67f370b 100644
--- a/web/src/pages/plugins/index.tsx
+++ b/web/src/pages/plugins/index.tsx
@@ -157,10 +157,11 @@ export default function PluginsPage() {
}, [backendPluginById, categoryFilter, features.systemPluginsVisibleToUsers, installations, pluginStates, registeredPlugins, search, statusFilter, trustFilter, user?.role]);
const pluginSections = useMemo(
- () => [
- ...protocolSectionMeta.map((section) => ({ ...section, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, section.key)) })),
- { key: "other", label: "应用插件", description: "画布、素材与工作流扩展", icon: PlugZap, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, "other")) },
- ].filter((section) => categoryFilter === "all" || section.key === categoryFilter),
+ () =>
+ [
+ ...protocolSectionMeta.map((section) => ({ ...section, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, section.key)) })),
+ { key: "other", label: "应用插件", description: "画布、素材与工作流扩展", icon: PlugZap, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, "other")) },
+ ].filter((section) => categoryFilter === "all" || section.key === categoryFilter),
[categoryFilter, filteredPlugins],
);
diff --git a/web/src/pages/projects/detail/chapters.tsx b/web/src/pages/projects/detail/chapters.tsx
index 944e3860b..995185f45 100644
--- a/web/src/pages/projects/detail/chapters.tsx
+++ b/web/src/pages/projects/detail/chapters.tsx
@@ -54,7 +54,6 @@ import { SkillRuntimePicker, useSkillRuntimeCatalog } from "@/components/skills/
import { ModelPicker } from "@/components/model-picker";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
-import { normalizeCharacterName } from "@/lib/canvas/canvas-character-reference";
import { decodeNovelText, splitTextIntoChapters } from "@/lib/canvas/canvas-document";
import { navigateToSettings } from "@/lib/settings-navigation";
import {
@@ -73,7 +72,7 @@ import { listGenerationTasks, queryGenerationTask, type GenerationTask } from "@
import { formatCount, formatTime, statusLabel, type ProjectDetailViewProps } from "./shared";
import { chapterStoryboardAssets, chapterStoryboardCharacters, chapterStoryboardReplaceImpact, storyboardRowsToProjectShots } from "./chapter-storyboard-production";
-import { chapterCharactersFromGenerationTask, chapterStoryboardFromGenerationTask, chapterTaskIdentity, extractChapterCharacters, generateChapterStoryboard } from "./project-chapter-ai";
+import { chapterAssetsFromGenerationTask, chapterStoryboardFromGenerationTask, chapterTaskIdentity, extractChapterAssets, generateChapterStoryboard } from "./project-chapter-ai";
const CHAPTER_ROW_HEIGHT = 62;
const MAX_NOVEL_IMPORT_CHAPTERS = 2500;
@@ -349,14 +348,15 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
const markChapterOperationCompleted = (unitId: string, kind: ChapterOperationKind) => {
setCompletedChapterOperations((current) => ({ ...current, [chapterOperationKey(unitId, kind)]: true }));
};
- const storeExtractedCharacters = async (unitId: string, characters: Awaited>) => {
- const knownNames = new Set([
- ...detail.assetCandidates.filter((candidate) => candidate.category === "character").map((candidate) => normalizeCharacterName(candidate.name)),
- ...detail.assets.filter((asset) => asset.category === "character").map((asset) => normalizeCharacterName(asset.title)),
- ]);
- const fresh = characters.filter((character) => ![character.name, ...character.aliases].map(normalizeCharacterName).some((name) => knownNames.has(name)));
- const created = fresh.length
- ? await createProjectAssetCandidates(detail.project.id, fresh.map((character) => ({ unitId, name: character.name, category: "character", details: { ...character } })), "chapter_character_extract")
+ const storeExtractedAssets = async (unitId: string, assets: Awaited>) => {
+ // 去重由服务端按分类、名称及别名统一完成,避免使用页面分页快照漏判。
+ const candidates = [
+ ...assets.characters.map((asset) => ({ unitId, name: asset.name, category: "character" as const, details: { ...asset } })),
+ ...assets.scenes.map((asset) => ({ unitId, name: asset.name, category: "environment" as const, details: { ...asset } })),
+ ...assets.props.map((asset) => ({ unitId, name: asset.name, category: "prop" as const, details: { ...asset } })),
+ ];
+ const created = candidates.length
+ ? await createProjectAssetCandidates(detail.project.id, candidates, "chapter_character_extract")
: { candidates: [] };
markChapterOperationCompleted(unitId, "characters");
refreshProject();
@@ -380,16 +380,16 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
operationUnitId = input.chapterId;
setCharacterExtractOpen(false);
beginChapterOperation(operationUnitId, "characters");
- message.info("角色提取任务已开始,可继续编辑或切换章节");
- const characters = await extractChapterCharacters(input, { onTaskUpdate: (task) => updateChapterOperation(operationUnitId, "characters", task) });
- const freshCount = await storeExtractedCharacters(operationUnitId, characters);
+ message.info("角色、场景与道具提取任务已开始,可继续编辑或切换章节");
+ const assets = await extractChapterAssets(input, { onTaskUpdate: (task) => updateChapterOperation(operationUnitId, "characters", task) });
+ const freshCount = await storeExtractedAssets(operationUnitId, assets);
if (!freshCount) {
- message.info("本章角色已存在于待确认列表中");
+ message.info("没有新增资产:提取为空或资产已存在");
return;
}
- message.success(`已提取 ${freshCount} 个角色,请到项目资产确认`);
+ message.success(`已提取 ${freshCount} 个角色、场景或道具,请到项目资产确认`);
} catch (error) {
- message.error(error instanceof Error ? error.message : "角色提取失败");
+ message.error(error instanceof Error ? error.message : "章节资产提取失败");
} finally {
if (operationUnitId) finishChapterOperation(operationUnitId, "characters");
}
@@ -477,8 +477,8 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
recoveringTaskIdsRef.current.add(task.id);
void queryGenerationTask(task.id).then(async (completedTask) => {
if (kind === "characters") {
- await storeExtractedCharacters(taskChapterId, chapterCharactersFromGenerationTask(completedTask));
- message.success("已恢复刷新前完成的角色提取结果");
+ await storeExtractedAssets(taskChapterId, chapterAssetsFromGenerationTask(completedTask));
+ message.success("已恢复刷新前完成的章节资产提取结果");
} else {
await storeGeneratedStoryboard(taskChapterId, chapterStoryboardFromGenerationTask(completedTask).rows);
message.success("已恢复刷新前完成的章节分镜");
@@ -599,7 +599,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
{dirty ? "有未保存修改" : `保存于 ${formatTime(selectedUnit.updatedAt)}`}·{formatCount(wordCount)} 字·{chapterCanvasCount(selectedUnit.id)} 个画布
- } disabled={!selectedUnit || dirty || Boolean(characterOperation)} loading={Boolean(characterOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setCharacterExtractOpen(true); }} aria-label={characterOperation ? `提取角色,已运行 ${formatOperationElapsed(characterOperation.startedAt, operationNow)}` : "提取角色"}>{characterOperation ? `提取角色(已运行${formatOperationElapsed(characterOperation.startedAt, operationNow)})` : charactersGenerated ? "提取角色(已生成)" : "提取角色"}
+ } disabled={!selectedUnit || dirty || Boolean(characterOperation)} loading={Boolean(characterOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setCharacterExtractOpen(true); }} aria-label={characterOperation ? `提取角色、场景与道具,已运行 ${formatOperationElapsed(characterOperation.startedAt, operationNow)}` : "提取角色、场景与道具"}>{characterOperation ? `提取资产(已运行${formatOperationElapsed(characterOperation.startedAt, operationNow)})` : charactersGenerated ? "提取资产(已生成)" : "提取角色、场景与道具"}
} disabled={!selectedUnit || dirty || Boolean(storyboardOperation)} loading={Boolean(storyboardOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setSelectedSkillIds([]); setStoryboardOpen(true); }} aria-label={storyboardOperation ? `生成到分镜制作,已运行 ${formatOperationElapsed(storyboardOperation.startedAt, operationNow)}` : "生成到分镜制作"}>{storyboardOperation ? `生成到分镜制作(已运行${formatOperationElapsed(storyboardOperation.startedAt, operationNow)})` : storyboardGenerated ? "生成到分镜制作(已生成)" : "生成到分镜制作"}
: } disabled={!selectedUnit || !dirty || !draftTitle.trim() || saveMutation.isPending} loading={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{dirty ? "保存" : "已保存"}
@@ -613,7 +613,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
setCreateOpen(false)} loading={createMutation.isPending} onSubmit={(values) => createMutation.mutate(values)} />
setImportOpen(false)} onImport={(chapters) => importMutation.mutate(chapters)} />
- setCharacterExtractOpen(false)} onOk={() => void extractCharacters()} styles={{ body: { paddingTop: 12 } }}>
+ setCharacterExtractOpen(false)} onOk={() => void extractCharacters()} styles={{ body: { paddingTop: 12 } }}>
当前章节
@@ -622,7 +622,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
diff --git a/web/src/pages/projects/detail/project-chapter-ai.ts b/web/src/pages/projects/detail/project-chapter-ai.ts
index a16f5f44b..23d848e7a 100644
--- a/web/src/pages/projects/detail/project-chapter-ai.ts
+++ b/web/src/pages/projects/detail/project-chapter-ai.ts
@@ -1,6 +1,7 @@
import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
import { storyboardRowsFromTask } from "@/lib/canvas/canvas-project-domain";
-import { parseCharacterBreakdown, type CharacterBreakdown } from "@/lib/canvas/canvas-character-reference";
+import { parseChapterAssetBreakdown, type ChapterAssetBreakdown } from "@/lib/canvas/chapter-asset-breakdown";
+import { parseCharacterBreakdown } from "@/lib/canvas/canvas-character-reference";
import { backendProviderConfig, parseBackendGenerationResult } from "@/services/api/generation-task";
import { createGenerationTask, waitForGenerationTask, type GenerationTask } from "@/services/api/task-center";
import type { Skill } from "@/services/api/skills";
@@ -38,24 +39,28 @@ export function chapterTaskIdentity(task: GenerationTask): { chapterId: string;
return null;
}
-export function chapterCharactersFromGenerationTask(task: GenerationTask) {
+export function chapterAssetsFromGenerationTask(task: GenerationTask) {
const result = parseBackendGenerationResult(task);
- if (!result.text?.trim()) throw new Error("模型没有返回可用的角色提取结果");
- return parseCharacterBreakdown(result.text);
+ if (!result.text?.trim()) throw new Error("模型没有返回可用的章节资产提取结果");
+ // 历史角色任务的输出契约只有角色;仅按明确的模板操作恢复,不能给新任务缺字段兜底。
+ if (generationTaskMetadata(task.inputJson).promptTemplateOperation === "character_extract") {
+ return { characters: parseCharacterBreakdown(result.text), scenes: [], props: [] };
+ }
+ return parseChapterAssetBreakdown(result.text);
}
export function chapterStoryboardFromGenerationTask(task: GenerationTask) {
return storyboardRowsFromTask(task);
}
-export async function extractChapterCharacters(input: ChapterAnalysisInput, options?: ChapterTaskOptions): Promise {
+export async function extractChapterAssets(input: ChapterAnalysisInput, options?: ChapterTaskOptions): Promise {
const result = await runProjectTextTask(input, "chapter_character_breakdown", {
项目名称: input.projectName,
章节名称: input.chapterTitle,
项目画风: input.projectStyle || "项目尚未指定画风,保持视觉描述中性、可执行。",
章节正文: input.sourceText,
}, options);
- return parseCharacterBreakdown(result);
+ return parseChapterAssetBreakdown(result);
}
type ChapterStoryboardGenerationInput = {
@@ -118,9 +123,9 @@ async function runProjectTextTask(input: ChapterAnalysisInput, operation: string
projectId: input.projectId,
nodeId: `${operation}:${input.chapterId}`,
mode: "text",
- prompt: "使用当前启用的角色卡提取模板。",
+ prompt: "使用当前启用的章节角色、场景与道具提取模板。",
config: { ...input.config, model },
- metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation, promptTemplateOperation: "character_extract", promptTemplateVariables },
+ metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation, promptTemplateOperation: "chapter_assets_extract", promptTemplateVariables },
onTaskCreated: options?.onTaskUpdate,
});
if (!result.text?.trim()) throw new Error("模型没有返回可用结果");
diff --git a/web/src/pages/tasks/index.tsx b/web/src/pages/tasks/index.tsx
index e6830afeb..6d6a965b7 100644
--- a/web/src/pages/tasks/index.tsx
+++ b/web/src/pages/tasks/index.tsx
@@ -251,7 +251,6 @@ export default function TasksPage() {
const [detail, logs] = await Promise.all([queryGenerationTask(task.id), listTaskLogs(task.id)]);
setDetailTask(detail);
setTaskLogs(logs);
- if (await syncGenerationTaskToCanvasStore(detail)) message.success("已同步到画布");
} catch (error) {
message.error(error instanceof Error ? error.message : "任务详情加载失败");
} finally {
diff --git a/web/src/pages/tasks/task-grid-card.tsx b/web/src/pages/tasks/task-grid-card.tsx
index 3faee5e58..fcd2e439f 100644
--- a/web/src/pages/tasks/task-grid-card.tsx
+++ b/web/src/pages/tasks/task-grid-card.tsx
@@ -8,6 +8,7 @@ import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/g
import { statusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center";
import { isTaskFailed, statusDotClassName, TaskDate } from "./task-shared";
+import { TaskVideoThumbnail } from "./task-video-thumbnail";
export function TaskGridCard({ task, actingId, onOpen, onRetry }: { task: GenerationTask; actingId: string; onOpen: () => void; onRetry: () => void }) {
const isActive = task.status === "queued" || task.status === "running";
@@ -22,7 +23,7 @@ export function TaskGridCard({ task, actingId, onOpen, onRetry }: { task: Genera
{thumbnailUrl ? (
) : isVideo && task.previewUrl ? (
- 视频预览
+
) : (
)}
diff --git a/web/src/pages/tasks/task-list-row.tsx b/web/src/pages/tasks/task-list-row.tsx
index 6609df4e0..1f1c794eb 100644
--- a/web/src/pages/tasks/task-list-row.tsx
+++ b/web/src/pages/tasks/task-list-row.tsx
@@ -10,6 +10,7 @@ import { formatTaskKind, statusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center";
import type { AiConfig } from "@/stores/use-config-store";
import { formatModelName, getTaskCanvasContext, isTaskFailed, statusDotClassName, taskAttentionReason, TaskBilling, TaskDate } from "./task-shared";
+import { TaskVideoThumbnail } from "./task-video-thumbnail";
export function TaskListRow({
task,
@@ -122,7 +123,7 @@ function TaskPreviewThumbnail({ task, onOpen }: { task: GenerationTask; onOpen:
aria-label={previewUnavailable ? "预览不可用,素材可能已删除" : isVideo ? "放大预览生成视频" : "放大预览生成图片"}
title={previewUnavailable ? "预览不可用,素材可能已删除" : undefined}
>
- {thumbnailUrl ? setUnavailableUrl(thumbnailUrl)} /> : 视频}
+ {thumbnailUrl ? setUnavailableUrl(thumbnailUrl)} /> : isVideo ? : }
{!previewUnavailable ? (
{isVideo ? : }
diff --git a/web/src/pages/tasks/task-video-thumbnail.tsx b/web/src/pages/tasks/task-video-thumbnail.tsx
new file mode 100644
index 000000000..68fa19f9d
--- /dev/null
+++ b/web/src/pages/tasks/task-video-thumbnail.tsx
@@ -0,0 +1,41 @@
+import { useEffect, useRef, useState } from "react";
+import { Video } from "lucide-react";
+import { MediaPreview } from "@/components/media-preview";
+import { captureVideoPoster } from "@/lib/video-poster";
+
+/** 只解码进入视口的视频,复用串行解码队列;缩略图随组件释放,不写入用户资产。 */
+export function TaskVideoThumbnail({ src }: { src: string }) {
+ const container = useRef(null);
+ const [poster, setPoster] = useState<{ source: string; url: string }>();
+ useEffect(() => {
+ const controller = new AbortController();
+ let objectUrl = "";
+ let started = false;
+ const capture = () => {
+ if (started || controller.signal.aborted) return;
+ started = true;
+ void captureVideoPoster(src, { signal: controller.signal, maxWidth: 400 }).then((result) => {
+ if (controller.signal.aborted || !result.poster) return;
+ objectUrl = URL.createObjectURL(result.poster);
+ setPoster({ source: src, url: objectUrl });
+ }).catch((error) => {
+ if (!controller.signal.aborted) console.warn("任务视频首帧提取失败", error instanceof Error ? error.name : "UnknownError");
+ });
+ };
+ const observer = new IntersectionObserver((entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ observer.disconnect();
+ capture();
+ }
+ });
+ if (container.current) observer.observe(container.current);
+ return () => {
+ controller.abort();
+ observer.disconnect();
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
+ };
+ }, [src]);
+ return
+ {poster?.source === src ? : 视频预览}
+ ;
+}
diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts
index 90c1b08d8..1fe5dd0c4 100644
--- a/web/src/services/api/auth.ts
+++ b/web/src/services/api/auth.ts
@@ -567,7 +567,9 @@ export function updateAdminDrawingEngineSetting(input: Pick(api.patch("/admin/settings/drawing-engine", input));
}
-export function listAdminApiLogs(params: AdminListParams = {}) {
+export type AdminApiLogParams = AdminListParams & { recordType?: "request" | "download" | "all" };
+
+export function listAdminApiLogs(params: AdminApiLogParams = {}) {
return request<{ logs: ApiCallLog[]; total: number; page: number; limit: number }>(api.get("/admin/api-logs", { params }));
}
@@ -579,7 +581,7 @@ export function queryAdminApiLogTask(id: string) {
return request(api.post(`/admin/api-logs/${encodeURIComponent(id)}/query-task`));
}
-export async function exportAdminApiLogs(params: AdminListParams & { ids?: string[] } = {}) {
+export async function exportAdminApiLogs(params: AdminApiLogParams & { ids?: string[] } = {}) {
const response = await api.get("/admin/api-logs-export.csv", { params: { ...params, ids: params.ids?.join(",") }, responseType: "blob" });
return response.data;
}
diff --git a/web/src/services/api/resources.ts b/web/src/services/api/resources.ts
index f81e70be1..0e0a4b977 100644
--- a/web/src/services/api/resources.ts
+++ b/web/src/services/api/resources.ts
@@ -137,7 +137,12 @@ export async function uploadResourceFile(
if (meta?.height) formData.append("height", String(Math.round(meta.height)));
if (meta?.durationMs) formData.append("durationMs", String(Math.round(meta.durationMs)));
try {
- const data = await request<{ resource: RemoteResource }>(api.post("/resources", formData, uploadRequestConfig(meta?.idempotencyKey)));
+ const data = await request<{ resource: RemoteResource }>(api.post("/resources", formData, {
+ ...uploadRequestConfig(meta?.idempotencyKey),
+ onUploadProgress: onProgress ? ({ loaded, total }) => {
+ if (total && total > 0) onProgress(Math.min(file.size, file.size * loaded / total), file.size);
+ } : undefined,
+ }));
resourceCache.set(resourceCacheKey(data.resource.id), data.resource);
return data.resource;
} catch (error) {
@@ -170,7 +175,10 @@ async function runChunkedUpload(file: Blob, name: string, kind: "image" | "video
const data = new FormData();
data.append("chunk", blob);
// raw 二进制直传,与后端按裸 body 逐片落盘对齐(勿设手动 Content-Type,让 axios 处理)。
- await request<{ index: number }>(api.put(`/resources/uploads/${encodeURIComponent(session.uploadId)}/chunks/${index}`, blob, { headers: { "Content-Type": "application/octet-stream" } }));
+ await request<{ index: number }>(api.put(`/resources/uploads/${encodeURIComponent(session.uploadId)}/chunks/${index}`, blob, {
+ headers: { "Content-Type": "application/octet-stream" },
+ onUploadProgress: onProgress ? ({ loaded }) => onProgress(start + Math.min(loaded, blob.size), file.size) : undefined,
+ }));
onProgress?.(Math.min(end, file.size), file.size);
}
const complete = await request<{ resource: RemoteResource }>(api.post(`/resources/uploads/${encodeURIComponent(session.uploadId)}/complete`));
diff --git a/web/src/services/file-storage.ts b/web/src/services/file-storage.ts
index 0d34ff438..d82477d31 100644
--- a/web/src/services/file-storage.ts
+++ b/web/src/services/file-storage.ts
@@ -12,7 +12,7 @@ export type UploadedFile = { url: string; storageKey: string; bytes: number; mim
const store = localforage.createInstance({ name: "infinite-canvas", storeName: "media_files" });
const objectUrls = new Map();
-export async function uploadMediaFile(input: string | Blob, prefix = "file"): Promise {
+export async function uploadMediaFile(input: string | Blob, prefix = "file", onProgress?: (uploadedBytes: number, totalBytes: number) => void): Promise {
// 直传和失败后的本地同步必须复用同一上传身份,避免响应丢失后创建第二个对象。
const storageKey = `${prefix}:${getActiveUserScope()}:${nanoid()}`;
const blob = typeof input === "string" ? await (await fetch(input)).blob() : input;
@@ -31,7 +31,7 @@ export async function uploadMediaFile(input: string | Blob, prefix = "file"): Pr
const poster = captured?.poster ? await uploadImage(captured.poster).catch(() => undefined) : undefined;
try {
const kind = blob.type.startsWith("video/") ? "video" : blob.type.startsWith("audio/") ? "audio" : "file";
- const resource = await uploadResourceFile(blob, kind, { ...meta, fileName: input instanceof File ? input.name : undefined, idempotencyKey: storageKey });
+ const resource = await uploadResourceFile(blob, kind, { ...meta, fileName: input instanceof File ? input.name : undefined, idempotencyKey: storageKey }, onProgress);
await primeResourceBlobCache(resourceStorageKey(resource.id), blob).catch(() => "");
URL.revokeObjectURL(previewUrl);
return { url: resource.publicUrl || resourceFileUrl(resource.id), storageKey: resourceStorageKey(resource.id), bytes: resource.size || blob.size, mimeType: resource.mimeType || blob.type || "application/octet-stream", width: resource.width || meta.width, height: resource.height || meta.height, durationMs: resource.durationMs || meta.durationMs, hasAudio: meta.hasAudio, preview: poster };
diff --git a/web/src/services/image-storage.ts b/web/src/services/image-storage.ts
index 27c72e9ee..7566f33d6 100644
--- a/web/src/services/image-storage.ts
+++ b/web/src/services/image-storage.ts
@@ -18,7 +18,7 @@ export type UploadedImage = {
const store = localforage.createInstance({ name: "infinite-canvas", storeName: "image_files" });
const objectUrls = new Map();
-export async function uploadImage(input: string | Blob): Promise {
+export async function uploadImage(input: string | Blob, onProgress?: (uploadedBytes: number, totalBytes: number) => void): Promise {
// 同一个逻辑上传在直传失败后会退回 IndexedDB,并由云端数据同步再次提交。
// 提前生成本地 key,确保两条路径向后端发送相同的幂等标识。
const storageKey = `image:${getActiveUserScope()}:${nanoid()}`;
@@ -41,7 +41,7 @@ export async function uploadImage(input: string | Blob): Promise
const previewUrl = URL.createObjectURL(blob);
const meta = await readImageMeta(previewUrl);
try {
- const resource = await uploadResourceFile(blob, "image", { width: meta.width, height: meta.height, fileName: input instanceof File ? input.name : undefined, idempotencyKey: storageKey });
+ const resource = await uploadResourceFile(blob, "image", { width: meta.width, height: meta.height, fileName: input instanceof File ? input.name : undefined, idempotencyKey: storageKey }, onProgress);
await primeResourceBlobCache(resourceStorageKey(resource.id), blob).catch(() => "");
URL.revokeObjectURL(previewUrl);
return {
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index f0c2fc77b..1bb1346c0 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -8040,6 +8040,18 @@
.canvas-node-shell {
box-shadow: none;
+ transition: transform 160ms ease-out;
+ }
+
+ /* Only a valid connection approach tilts; selection and ordinary hover stay flat. */
+ .canvas-node-shell[data-connection-tilt="true"] {
+ transform: perspective(900px) rotateX(var(--connection-tilt-x)) rotateY(var(--connection-tilt-y));
+ will-change: transform;
+ }
+
+ .no-motion .canvas-node-shell[data-connection-tilt="true"] {
+ transform: none;
+ transition: none;
}
.canvas-node-inline-action {
@@ -16125,3 +16137,67 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
letter-spacing: .04em;
}
.creation-history-group-head h4::after { content: ""; flex: 1; height: 1px; background: var(--creation-history-line); transform: translateY(-2px); }
+
+/* 图片上传:浅磨砂与低饱和流光,和模型生成状态分离。 */
+.canvas-file-upload {
+ --upload-glow-blue: color-mix(in srgb, var(--status-loading) 22%, transparent);
+ --upload-glow-mint: color-mix(in srgb, var(--status-success) 14%, transparent);
+ --upload-glow-warm: color-mix(in srgb, var(--status-warning) 12%, transparent);
+ position: relative;
+ isolation: isolate;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ background: var(--upload-surface);
+ color: var(--upload-text);
+}
+.canvas-file-upload-glow {
+ transition: opacity 180ms ease-out;
+ position: absolute;
+ inset: -45%;
+ background: radial-gradient(ellipse at 28% 32%, var(--upload-glow-blue), transparent 48%), radial-gradient(ellipse at 75% 64%, var(--upload-glow-mint), transparent 46%), radial-gradient(ellipse at 55% 78%, var(--upload-glow-warm), transparent 42%);
+ animation: canvas-file-upload-drift 5s ease-in-out infinite alternate;
+ pointer-events: none;
+}
+.canvas-file-upload-frost {
+ transition: opacity 180ms ease-out;
+ position: absolute;
+ inset: 0;
+ background: color-mix(in srgb, var(--upload-surface) 24%, transparent);
+ backdrop-filter: blur(20px) saturate(0.8);
+ pointer-events: none;
+}
+.canvas-file-upload-frost::after {
+ content: "";
+ position: absolute;
+ inset: -60%;
+ background: linear-gradient(110deg, transparent 35%, color-mix(in srgb, var(--upload-text) 4%, transparent) 47%, color-mix(in srgb, var(--upload-surface) 40%, transparent) 51%, transparent 65%);
+ animation: canvas-file-upload-sheen 4s ease-in-out infinite;
+}
+.canvas-file-upload-copy {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ height: 100%;
+ padding: var(--space-5);
+ text-align: center;
+}
+.canvas-file-upload-copy > svg { color: var(--upload-muted); }
+.canvas-file-upload-label { font-size: var(--fs-caption); font-weight: 500; }
+.canvas-file-upload-detail { max-width: 100%; font-size: var(--fs-tiny); color: var(--upload-muted); overflow-wrap: anywhere; }
+.canvas-file-upload-retry { margin-top: var(--space-2); padding: var(--space-2) var(--space-3); border-radius: var(--r-md); background: color-mix(in srgb, var(--upload-text) 8%, transparent); font-size: var(--fs-caption); cursor: pointer; }
+.canvas-file-upload-retry:hover { background: color-mix(in srgb, var(--upload-text) 12%, transparent); }
+.canvas-file-upload-retry:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: 2px; }
+@keyframes canvas-file-upload-drift { from { transform: translate(-8%, -4%) rotate(-8deg); } to { transform: translate(8%, 4%) rotate(12deg); } }
+@keyframes canvas-file-upload-sheen { from { transform: translateX(-35%); } to { transform: translateX(35%); } }
+.canvas-file-upload[data-static] .canvas-file-upload-glow,
+.canvas-file-upload[data-static] .canvas-file-upload-frost::after { animation: none; }
+.canvas-file-upload[data-static] .canvas-file-upload-glow,
+.canvas-file-upload[data-static] .canvas-file-upload-frost { transition: none; }
+@media (prefers-reduced-motion: reduce) {
+ .canvas-file-upload-glow, .canvas-file-upload-frost::after { animation: none; }
+ .canvas-file-upload-glow, .canvas-file-upload-frost { transition: none; }
+}
diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts
index 03d37a3fc..c0115739b 100644
--- a/web/src/types/canvas.ts
+++ b/web/src/types/canvas.ts
@@ -210,6 +210,9 @@ export type CanvasNodeMetadata = {
promptTemplateOperation?: string;
promptTemplateVariables?: Record;
status?: CanvasNodeStatus;
+ /** 浏览器文件上传,与模型生成任务状态独立。 */
+ fileUpload?: "uploading" | "error";
+ fileUploadProgress?: number;
locked?: boolean;
errorDetails?: string;
generationErrorCode?: string;
diff --git a/web/test/canvas-connection-tilt.test.ts b/web/test/canvas-connection-tilt.test.ts
new file mode 100644
index 000000000..e6dc6a4a8
--- /dev/null
+++ b/web/test/canvas-connection-tilt.test.ts
@@ -0,0 +1,47 @@
+import { expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { canvasConnectionTilt, latchCanvasConnectionApproach } from "../src/lib/canvas/canvas-connection-tilt";
+
+const node = { position: { x: 100, y: 200 }, width: 400, height: 200 };
+test("connection approach determines tilt direction and pivot", () => {
+ expect(canvasConnectionTilt(node, { x: 100, y: 300 })).toEqual({ rotateX: 0, rotateY: -5, origin: "0% 50%" });
+ expect(canvasConnectionTilt(node, { x: 500, y: 300 })).toEqual({ rotateX: 0, rotateY: 5, origin: "100% 50%" });
+ expect(canvasConnectionTilt(node, { x: 300, y: 200 })?.rotateX).toBe(5);
+ expect(canvasConnectionTilt(node, { x: 300, y: 400 })?.rotateX).toBe(-5);
+});
+test("snap padding is clamped, absent or invalid approach stays flat", () => {
+ expect(canvasConnectionTilt(node, { x: -100, y: 700 })).toEqual({ rotateX: -5, rotateY: -5, origin: "0% 100%" });
+ expect(canvasConnectionTilt(node)).toBeUndefined();
+ expect(canvasConnectionTilt(node, { x: NaN, y: 0 })).toBeUndefined();
+ expect(canvasConnectionTilt({ ...node, width: 0 }, { x: 0, y: 0 })).toBeUndefined();
+});
+test("world-space approach is independent of viewport scaling", () => {
+ const point = { x: 150, y: 250 };
+ const result = canvasConnectionTilt(node, point);
+ for (const scale of [0.25, 1, 4]) {
+ const screen = { x: point.x * scale + 30, y: point.y * scale - 70 };
+ expect(canvasConnectionTilt(node, { x: (screen.x - 30) / scale, y: (screen.y + 70) / scale })).toEqual(result);
+ }
+});
+test("only connection targets receive approach data; memo tracks it and selection does not activate tilt", () => {
+ const source = readFileSync(new URL("../src/components/canvas/canvas-node.tsx", import.meta.url), "utf8");
+ const layers = readFileSync(new URL("../src/pages/canvas/canvas-project-world-layers.tsx", import.meta.url), "utf8");
+ expect(source).toContain("isConnectionTarget && !reduceMediaEffects && !dragOffset");
+ expect(source).toContain("previous.connectionApproach?.x === next.connectionApproach?.x");
+ expect(source).toContain('data-connection-tilt={connectionTilt ? "true" : undefined}');
+ expect(layers).toContain("props.connectionApproach?.nodeId === node.id ? props.connectionApproach.point : undefined");
+ expect(layers).not.toMatch(/connectionApproach=\{[^\n]*mouseWorld/);
+});
+
+test("600 pointer frames inside one target retain identical entry data", () => {
+ const entry = latchCanvasConnectionApproach(null, "target", { x: 10, y: 20 });
+ for (let frame = 0; frame < 600; frame++) {
+ expect(latchCanvasConnectionApproach(entry, "target", { x: frame, y: frame })).toBe(entry);
+ }
+ expect(entry?.point).toEqual({ x: 10, y: 20 });
+ const next = latchCanvasConnectionApproach(entry, "other", { x: 100, y: 200 });
+ expect(next).not.toBe(entry);
+ expect(next?.point).toEqual({ x: 100, y: 200 });
+ expect(latchCanvasConnectionApproach(next, null, { x: 0, y: 0 })).toBeNull();
+ expect(latchCanvasConnectionApproach(null, "target", { x: 30, y: 40 })?.point).toEqual({ x: 30, y: 40 });
+});
diff --git a/web/test/canvas-image-source.test.ts b/web/test/canvas-image-source.test.ts
new file mode 100644
index 000000000..b12a7b313
--- /dev/null
+++ b/web/test/canvas-image-source.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { createPortraitTextureNode, isCanvasImageSourceNode } from "../src/lib/canvas/canvas-image-source";
+import { canGenerateImageInPlace } from "../src/lib/canvas/canvas-generation-layout";
+import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas";
+
+const source: CanvasNodeData = {
+ id: "uploaded", type: CanvasNodeType.Image, title: "原图", position: { x: 10, y: 20 }, width: 300, height: 200,
+ metadata: { content: "original-image", storageKey: "original-key", status: "success" },
+};
+const read = (path: string) => readFileSync(resolve(import.meta.dir, "../src", path), "utf8");
+
+describe("上传图片是输入素材", () => {
+ test("上传与导入图片没有生成框,空节点与生成结果仍可配置", () => {
+ expect(isCanvasImageSourceNode(source)).toBe(true);
+ expect(isCanvasImageSourceNode({ ...source, metadata: {} })).toBe(false);
+ expect(isCanvasImageSourceNode({ ...source, metadata: { ...source.metadata, generationType: "edit" } })).toBe(false);
+ expect(isCanvasImageSourceNode({ ...source, metadata: { ...source.metadata, taskId: "task" } })).toBe(false);
+ expect(isCanvasImageSourceNode({ ...source, type: CanvasNodeType.Video })).toBe(false);
+ expect(isCanvasImageSourceNode(null)).toBe(false);
+ });
+ test("显式复制/版本生成入口保持可用", () => {
+ expect(isCanvasImageSourceNode({ ...source, metadata: { ...source.metadata, generationResultPlacement: "replace-node" } })).toBe(false);
+ expect(isCanvasImageSourceNode({ ...source, metadata: { ...source.metadata, copiedFromNodeId: "original" } })).toBe(false);
+ });
+ test("人物质感创建独立编辑节点,不复制原图输出和任务状态", () => {
+ const before = structuredClone(source);
+ const child = createPortraitTextureNode(source, "texture");
+ expect(source).toEqual(before);
+ expect(child.id).toBe("texture");
+ expect(child.position).toEqual({ x: 406, y: 20 });
+ expect(child.metadata?.composerContent).toBe("@图片1");
+ expect(child.metadata?.generationType).toBe("edit");
+ expect(child.metadata?.content).toBeUndefined();
+ expect(child.metadata?.storageKey).toBeUndefined();
+ expect(child.metadata?.taskId).toBeUndefined();
+ expect(isCanvasImageSourceNode(child)).toBe(false);
+ expect(canGenerateImageInPlace(child)).toBe(true);
+ expect(canGenerateImageInPlace(source)).toBe(false);
+ });
+ test("页面和工具入口共享素材判定,人物质感接入原图连线", () => {
+ expect(read("pages/canvas/project.tsx")).toContain("dialogNode && !isCanvasImageSourceNode(dialogNode)");
+ expect(read("lib/canvas/tool-registry/definitions/node-hover-tools.tsx")).toContain("!isCanvasImageSourceNode(ctx.node)");
+ const mediaTools = read("pages/canvas/use-canvas-media-tools.ts");
+ const portrait = mediaTools.slice(mediaTools.indexOf("const openPortraitTextureEditor"), mediaTools.indexOf("const cropImageNode"));
+ expect(portrait).toContain("fromNodeId: node.id, toNodeId: child.id");
+ expect(portrait).toContain("setDialogNodeId(child.id)");
+ expect(portrait).not.toContain("current.map");
+ });
+});
+
+test("底部菜单使用画布浮层管理并响应打开、鼠标和键盘交互", () => {
+ const toolbar = read("components/canvas/canvas-toolbar.tsx");
+ expect(toolbar).toContain('useCanvasOverlayLayer("main-toolbar", "var(--z-toolbar)")');
+ expect(toolbar).toContain("if (addOpen || appearanceOpen) bringToFront()");
+ expect(toolbar).toContain("style={{ zIndex }} onPointerDownCapture={bringToFront} onFocusCapture={bringToFront}");
+});
diff --git a/web/test/canvas-image-upload.test.tsx b/web/test/canvas-image-upload.test.tsx
new file mode 100644
index 000000000..652ffa35a
--- /dev/null
+++ b/web/test/canvas-image-upload.test.tsx
@@ -0,0 +1,140 @@
+import { describe, expect, test } from "bun:test";
+import { renderToStaticMarkup } from "react-dom/server";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { createImageUploadPlaceholder, createFileUploadPlaceholder, interruptFileUpload, readUploadImageSize, uploadNodeType, uploadPercent, uploadVeilOpacity } from "../src/lib/canvas/canvas-file-upload";
+import { CanvasNodeType } from "../src/types/canvas";
+import { isCanvasImageSourceNode } from "../src/lib/canvas/canvas-image-source";
+import { CanvasFileUploadContent } from "../src/components/canvas/canvas-file-upload-content";
+import { CanvasNodeActionContext } from "../src/components/canvas/canvas-node-action-context";
+import { canvasThemes } from "../src/lib/canvas-theme";
+
+const file = { name: "photo.png", size: 1234, type: "image/png" };
+const placeholder = () => createImageUploadPlaceholder("upload", file, { x: 100, y: 200 }, { width: 1920, height: 1080 });
+const read = (path: string) => readFileSync(resolve(import.meta.dir, "../src", path), "utf8");
+
+describe("图片上传占位", () => {
+ for (const [width, height] of [[1920, 1080], [900, 1600], [800, 800], [4000, 500]]) {
+ test(`按真实尺寸保持比例并围绕落点居中 ${width}x${height}`, () => {
+ const node = createImageUploadPlaceholder("upload", file, { x: 100, y: 200 }, { width, height });
+ expect(node.width / node.height).toBeCloseTo(width / height);
+ expect(node.position.x + node.width / 2).toBe(100);
+ expect(node.position.y + node.height / 2).toBe(200);
+ expect(node.metadata?.naturalWidth).toBe(width);
+ expect(node.metadata?.naturalHeight).toBe(height);
+ expect(node.metadata?.fileUpload).toBe("uploading");
+ expect(node.metadata?.content).toBeUndefined();
+ expect(node.metadata?.status).toBeUndefined();
+ expect(isCanvasImageSourceNode(node)).toBe(true);
+ });
+ }
+
+ test("重新打开画布将中断上传转为可重试错误,不修改其他节点", () => {
+ const node = placeholder();
+ const interrupted = interruptFileUpload(node);
+ expect(interrupted.metadata?.fileUpload).toBe("error");
+ expect(interrupted.metadata?.errorDetails).toContain("中断");
+ expect(interrupted.position).toEqual(node.position);
+ expect(node.metadata?.fileUpload).toBe("uploading");
+ expect(interruptFileUpload(interrupted)).toBe(interrupted);
+ const ready = { ...node, metadata: { content: "image" } };
+ expect(interruptFileUpload(ready)).toBe(ready);
+ });
+
+ test("图片解码成功和失败均释放临时 URL,不伪造尺寸", async () => {
+ const originalImage = globalThis.Image;
+ const create = URL.createObjectURL;
+ const revoke = URL.revokeObjectURL;
+ const revoked: string[] = [];
+ let fail = false;
+ try {
+ URL.createObjectURL = () => "blob:upload-test";
+ URL.revokeObjectURL = (url) => { revoked.push(url); };
+ globalThis.Image = class {
+ src = "";
+ naturalWidth = 1200;
+ naturalHeight = 800;
+ async decode() { if (fail) throw new Error("decode failed"); }
+ } as unknown as typeof Image;
+ const input = new File(["test"], "test.png", { type: "image/png" });
+ expect(await readUploadImageSize(input)).toEqual({ width: 1200, height: 800 });
+ fail = true;
+ await expect(readUploadImageSize(input)).rejects.toThrow("decode failed");
+ expect(revoked).toEqual(["blob:upload-test", "blob:upload-test"]);
+ } finally {
+ globalThis.Image = originalImage;
+ URL.createObjectURL = create;
+ URL.revokeObjectURL = revoke;
+ }
+ });
+
+ for (const mode of ["light", "dark"] as const) {
+ test(`${mode}主题包含可访问的上传状态、减弱动效和失败重试`, () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain("正在上传图片");
+ expect(html).toContain('aria-busy="true"');
+ expect(html).toContain('data-static="true"');
+ expect(html).toContain(canvasThemes[mode].node.fill);
+ const error = renderToStaticMarkup( {} }}>);
+ expect(error).toContain("重新选择文件");
+ expect(error).toContain('aria-busy="false"');
+ expect(error).toContain('data-static="true"');
+ });
+ }
+
+ test("上传先占位再传输,完成替换同一节点,弹窗不等待传输完成", () => {
+ const hook = read("pages/canvas/use-canvas-upload.ts");
+ const create = hook.slice(hook.indexOf("const createFileNode"), hook.indexOf("const createImageAssetNode"));
+ expect(create.indexOf("createFileUploadPlaceholder")).toBeLessThan(create.indexOf("await uploadImage(file,"));
+ expect(create).toContain("if (!currentNode)");
+ expect(create).toContain("fileUpload: undefined");
+ const replace = hook.slice(hook.indexOf('const replaceNodeMedia'), hook.indexOf("const pasteSystemClipboard"));
+ expect(replace).toContain("await createFileNode(file, currentNode.position, nodeId)");
+ expect(create).toContain("original?.metadata?.content");
+ expect(read("components/canvas/canvas-upload-modal.tsx")).toContain("onClose();\n await pendingUpload;");
+ expect(read("styles/globals.css")).toContain("prefers-reduced-motion: reduce");
+ });
+});
+
+test("真实进度与遮罩浓度反向关联,不生成未知进度", () => {
+ expect(uploadPercent(20, 100)).toBe(20);
+ expect(uploadPercent(150, 100)).toBe(100);
+ expect(uploadPercent(0, 0)).toBeUndefined();
+ expect(uploadPercent(NaN, 100)).toBeUndefined();
+ expect(uploadVeilOpacity(0)).toBe(1);
+ expect(uploadVeilOpacity(50)).toBeCloseTo(0.55);
+ expect(uploadVeilOpacity(100)).toBeCloseTo(0.1);
+});
+
+test("统一识别图片、视频、音频、纯文本文件,不误收 PDF 或 Word", () => {
+ for (const [name, type, expected] of [
+ ["image.png", "image/png", CanvasNodeType.Image], ["video.mp4", "video/mp4", CanvasNodeType.Video],
+ ["sound.ogg", "audio/ogg", CanvasNodeType.Audio], ["sound.wav", "", CanvasNodeType.Audio],
+ ["note.md", "", CanvasNodeType.Text], ["note.txt", "text/plain", CanvasNodeType.Text],
+ ["document.pdf", "application/pdf", null], ["document.docx", "", null],
+ ] as const) expect(uploadNodeType({ name, type })).toBe(expected);
+});
+
+test("音频与文本在传输前就有独立占位,可重试且不带模型生成状态", async () => {
+ for (const [name, type, expected] of [["voice.mp3", "audio/mpeg", CanvasNodeType.Audio], ["note.md", "text/markdown", CanvasNodeType.Text]] as const) {
+ const node = await createFileUploadPlaceholder("file", new File(["content"], name, { type }), { x: 0, y: 0 });
+ expect(node.type).toBe(expected);
+ expect(node.metadata?.fileUpload).toBe("uploading");
+ expect(node.metadata?.content).toBeUndefined();
+ expect(node.metadata?.status).toBeUndefined();
+ expect(interruptFileUpload(node).metadata?.fileUpload).toBe("error");
+ }
+});
+
+test("所有资源渲染真实百分比,100% 仍等待保存,磨砂渐淡但文案不淡出", () => {
+ for (const type of [CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio, CanvasNodeType.Text]) {
+ const node = { ...placeholder(), type, metadata: { fileUpload: "uploading" as const, fileUploadProgress: 50 } };
+ const html = renderToStaticMarkup();
+ expect(html).toContain("已上传 50%");
+ expect(html).toContain('opacity:0.55');
+ expect(html).toContain('aria-valuenow="50"');
+ const complete = renderToStaticMarkup();
+ expect(complete).toContain("文件已传输,正在保存与处理");
+ expect(complete).toContain('aria-busy="true"');
+ }
+});
diff --git a/web/test/canvas-resource-references.test.ts b/web/test/canvas-resource-references.test.ts
index b1c78ec78..45cf68cd6 100644
--- a/web/test/canvas-resource-references.test.ts
+++ b/web/test/canvas-resource-references.test.ts
@@ -190,7 +190,8 @@ describe("canvas resource mention slots", () => {
expect(references.get(target.id)?.map((reference) => reference.nodeId)).toEqual([audio.id]);
expect(references.get(config.id)?.map((reference) => reference.nodeId)).toEqual([target.id, audio.id]);
- expect(references.get(image.id)?.map((reference) => reference.nodeId)).toEqual([image.id]);
+ expect(references.get(image.id)?.map((reference) => reference.nodeId)).toEqual([]);
+ expect(buildNodeMentionReferences(image, nodes, connections)).toEqual([]);
});
test("素材库身份 token 保持稳定", () => {
@@ -259,4 +260,3 @@ describe("remove canvas resource mention tokens", () => {
expect(nextTarget.metadata?.composerContent).toBe("比较 和 @图片1");
});
});
-
diff --git a/web/test/canvas-video-hover-preview.test.ts b/web/test/canvas-video-hover-preview.test.ts
new file mode 100644
index 000000000..e1b8e3e3c
--- /dev/null
+++ b/web/test/canvas-video-hover-preview.test.ts
@@ -0,0 +1,90 @@
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { bindCanvasVideoHoverPreview, VIDEO_HOVER_DELAY_MS } from "../src/lib/canvas/canvas-video-hover-preview";
+
+const wait = (ms = VIDEO_HOVER_DELAY_MS + 30) => new Promise((resolve) => setTimeout(resolve, ms));
+const originals = new Map();
+let videos: FakeVideo[];
+let reduced = false;
+let interacting = false;
+let mutation: () => void;
+let visibility: (entries: { isIntersecting: boolean }[]) => void;
+const cleanups: (() => void)[] = [];
+class FakeVideo extends EventTarget {
+ paused = true; currentTime = 0; src = ""; muted = false; playsInline = false; preload = "";
+ className = ""; dataset: Record = {}; removed = false; loads = 0;
+ setAttribute() {}
+ removeAttribute() { this.src = ""; }
+ load() { this.loads++; }
+ remove() { this.removed = true; }
+ pause() { this.paused = true; }
+ async play() { this.paused = false; this.dispatchEvent(new Event("playing")); }
+}
+class FakeElement extends EventTarget {
+ isConnected = true;
+ closest(selector: string) { return selector.includes('="true"') ? (interacting ? this : null) : this; }
+ getAttribute() { return interacting ? "true" : null; }
+ appendChild(video: FakeVideo) { videos.push(video); }
+}
+function stub(key: string, value: unknown) {
+ originals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
+ Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
+}
+function setup(resolve: () => Promise = async () => "test.mp4") {
+ const element = new FakeElement();
+ cleanups.push(bindCanvasVideoHoverPreview(element as unknown as HTMLElement, resolve));
+ return element;
+}
+function enter(element: FakeElement, pointerType = "mouse", buttons = 0) {
+ element.dispatchEvent(Object.assign(new Event("pointerenter"), { pointerType, buttons }));
+}
+beforeEach(() => {
+ videos = []; reduced = false; interacting = false;
+ stub("window", Object.assign(new EventTarget(), { matchMedia: () => ({ matches: reduced }) }));
+ stub("document", Object.assign(new EventTarget(), { hidden: false, querySelectorAll: () => videos.filter((v) => !v.removed), createElement: () => new FakeVideo() }));
+ stub("MutationObserver", class { constructor(callback: () => void) { mutation = callback; } observe() {} disconnect() {} });
+ stub("IntersectionObserver", class { constructor(callback: typeof visibility) { visibility = callback; } observe() {} disconnect() {} });
+});
+afterEach(() => {
+ cleanups.splice(0).forEach((cleanup) => cleanup());
+ for (const [key, descriptor] of originals) {
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
+ else Reflect.deleteProperty(globalThis, key);
+ }
+ originals.clear();
+});
+
+test("quick pass, touch, dragging and reduced motion never resolve media", async () => {
+ let calls = 0;
+ const element = setup(async () => { calls++; return "test.mp4"; });
+ enter(element); element.dispatchEvent(new Event("pointerleave"));
+ enter(element, "touch"); enter(element, "mouse", 1);
+ reduced = true; enter(element);
+ await wait();
+ expect(calls).toBe(0);
+});
+test("hover is muted and capped at first three seconds; resources are released", async () => {
+ const element = setup(); enter(element); await wait();
+ const video = videos[0];
+ expect(video.muted).toBe(true); expect(video.playsInline).toBe(true);
+ video.currentTime = 3; video.dispatchEvent(new Event("timeupdate"));
+ expect(video.paused).toBe(true); expect(video.src).toBe(""); expect(video.loads).toBe(1); expect(video.removed).toBe(true);
+});
+test("new hover cancels previous decoder and ignores stale URL resolution", async () => {
+ let resolve!: (value: string) => void;
+ const slow = setup(() => new Promise((done) => { resolve = done; }));
+ enter(slow); await wait();
+ const next = setup(); enter(next); resolve("stale.mp4"); await wait();
+ expect(videos.length).toBe(1); expect(videos[0].src).toBe("test.mp4");
+ enter(slow); expect(videos[0].removed).toBe(true);
+});
+test("wheel, viewport interaction and leaving viewport cancel playback", async () => {
+ const element = setup(); enter(element); await wait();
+ window.dispatchEvent(new Event("wheel")); expect(videos[0].removed).toBe(true);
+ enter(element); await wait(); interacting = true; mutation(); expect(videos[1].removed).toBe(true);
+ interacting = false; enter(element); await wait(); visibility([{ isIntersecting: false }]); expect(videos[2].removed).toBe(true);
+});
+test("manual playback is never competing with hover", async () => {
+ const manual = new FakeVideo(); manual.paused = false; videos.push(manual);
+ let calls = 0; const element = setup(async () => { calls++; return "test.mp4"; });
+ enter(element); await wait(); expect(calls).toBe(0); expect(manual.paused).toBe(false);
+});
diff --git a/web/test/chapter-asset-breakdown.test.ts b/web/test/chapter-asset-breakdown.test.ts
new file mode 100644
index 000000000..32b408b3a
--- /dev/null
+++ b/web/test/chapter-asset-breakdown.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, test } from "bun:test";
+import { parseChapterAssetBreakdown } from "../src/lib/canvas/chapter-asset-breakdown";
+
+describe("章节三类资产解析", () => {
+ test("无角色章节仍可提取场景和道具", () => {
+ const result = parseChapterAssetBreakdown(JSON.stringify({ characters: [], scenes: [{ name: "车站", description: "空旷站台", prompt: "夜间站台" }], props: [{ name: "信封", description: "牛皮纸信封", prompt: "褐色信封特写" }] }));
+ expect(result.characters).toEqual([]);
+ expect(result.scenes[0].name).toBe("车站");
+ expect(result.props[0].name).toBe("信封");
+ });
+ test("缺失数组或缺失描述不能静默跳过", () => {
+ expect(() => parseChapterAssetBreakdown('{"characters":[]}')).toThrow("缺少");
+ expect(() => parseChapterAssetBreakdown('{"characters":[],"scenes":[{"name":"车站"}],"props":[]}')).toThrow("description");
+ });
+ test("支持围栏 JSON 和无资产章节", () => {
+ expect(parseChapterAssetBreakdown('```json\n{"characters":[],"scenes":[],"props":[]}\n```')).toEqual({ characters: [], scenes: [], props: [] });
+ });
+ test("忽略正文解释但仍校验完整资产对象", () => {
+ expect(parseChapterAssetBreakdown('提取结果如下:\n{"characters":[],"scenes":[],"props":[]}\n以上为全部结果。')).toEqual({ characters: [], scenes: [], props: [] });
+ });
+});
diff --git a/web/test/generation-task-local.test.ts b/web/test/generation-task-local.test.ts
index b3553de8b..994f3f26b 100644
--- a/web/test/generation-task-local.test.ts
+++ b/web/test/generation-task-local.test.ts
@@ -11,7 +11,14 @@ import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas";
import { onlineToolToOps } from "../src/components/canvas/canvas-assistant-panel";
import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel } from "../src/lib/generation-task-display";
import { generationErrorMessage } from "../src/lib/generation-error";
-import { generationTaskNodeId } from "../src/lib/canvas/canvas-generation-task-sync";
+import { generationTaskNodeId, syncGenerationTaskToCanvasStore } from "../src/lib/canvas/canvas-generation-task-sync";
+
+test("业务项目任务和无节点任务不会读取画布项目", async () => {
+ const task = { id: "chapter-task", type: "canvas_text", status: "succeeded", projectId: "domain-project", clientContext: { domainProjectId: "domain-project", nodeId: "chapter_character_breakdown:chapter" } } as GenerationTask;
+ expect(await syncGenerationTaskToCanvasStore(task)).toBe(false);
+ expect(await syncGenerationTaskToCanvasStore({ ...task, clientContext: undefined, inputJson: JSON.stringify({ metadata: { domainProjectId: "domain-project", nodeId: "chapter-node" } }) })).toBe(false);
+ expect(await syncGenerationTaskToCanvasStore({ ...task, clientContext: undefined })).toBe(false);
+});
test("canvas recovery reads node identity from task summaries without full task input", () => {
const task: GenerationTask = { id: "task-summary", type: "canvas_image", status: "running", prompt: "", attempts: 1, createdAt: "", updatedAt: "", clientContext: { nodeId: "node-summary" } };
diff --git a/web/test/resource-upload-progress.test.ts b/web/test/resource-upload-progress.test.ts
new file mode 100644
index 000000000..dea20161a
--- /dev/null
+++ b/web/test/resource-upload-progress.test.ts
@@ -0,0 +1,64 @@
+import { expect, test } from "bun:test";
+import type { AxiosProgressEvent } from "axios";
+import { apiClient } from "../src/services/api/request";
+import { uploadResourceFile } from "../src/services/api/resources";
+
+test("普通 multipart 将传输进度换算为文件字节,总量未知不伪造百分比", async () => {
+ const adapter = apiClient.defaults.adapter;
+ const progress: number[][] = [];
+ try {
+ apiClient.defaults.adapter = async (config) => {
+ config.onUploadProgress?.({ loaded: 100, total: undefined } as AxiosProgressEvent);
+ config.onUploadProgress?.({ loaded: 250, total: 1000 } as AxiosProgressEvent);
+ config.onUploadProgress?.({ loaded: 1000, total: 1000 } as AxiosProgressEvent);
+ return { config, status: 200, statusText: "OK", headers: {}, data: { code: 0, data: { resource: { id: "upload-test" } } } };
+ };
+ await uploadResourceFile(new Blob([new Uint8Array(400)]), "file", undefined, (loaded, total) => progress.push([loaded, total]));
+ expect(progress).toEqual([[100, 400], [400, 400]]);
+ } finally {
+ apiClient.defaults.adapter = adapter;
+ }
+});
+
+test("分片上传累加片内真实进度,传输 100% 后仍须等待合并响应", async () => {
+ const adapter = apiClient.defaults.adapter;
+ const size = 51 * 1024 * 1024;
+ const chunkSize = 32 * 1024 * 1024;
+ const progress: number[][] = [];
+ let merged = false;
+ try {
+ apiClient.defaults.adapter = async (config) => {
+ let data: unknown;
+ if (config.url === "/resources/uploads") data = { uploadId: "session", chunkSize, chunkCount: 2 };
+ else if (config.url?.endsWith("/complete")) {
+ expect(progress.at(-1)).toEqual([size, size]);
+ merged = true;
+ data = { resource: { id: "chunk-test" } };
+ } else {
+ const index = Number(config.url?.split("/").at(-1));
+ const bytes = index === 0 ? chunkSize : size - chunkSize;
+ config.onUploadProgress?.({ loaded: bytes / 2, total: bytes } as AxiosProgressEvent);
+ data = { index };
+ }
+ return { config, status: 200, statusText: "OK", headers: {}, data: { code: 0, data } };
+ };
+ await uploadResourceFile(new Blob([new Uint8Array(size)]), "video", undefined, (loaded, total) => progress.push([loaded, total]));
+ expect(progress).toEqual([[chunkSize / 2, size], [chunkSize, size], [chunkSize + (size - chunkSize) / 2, size], [size, size]]);
+ expect(merged).toBe(true);
+ } finally {
+ apiClient.defaults.adapter = adapter;
+ }
+});
+
+test("传输结束不吞掉服务端保存错误", async () => {
+ const adapter = apiClient.defaults.adapter;
+ try {
+ apiClient.defaults.adapter = async (config) => {
+ config.onUploadProgress?.({ loaded: 100, total: 100 } as AxiosProgressEvent);
+ return { config, status: 200, statusText: "OK", headers: {}, data: { code: 9, msg: "保存失败" } };
+ };
+ await expect(uploadResourceFile(new Blob(["text"]), "file", undefined, () => {})).rejects.toThrow("保存失败");
+ } finally {
+ apiClient.defaults.adapter = adapter;
+ }
+});