From d42e392ed75916cbd2f5d931af29a0035afa883c Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Thu, 27 Aug 2026 22:12:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(*):=20=E5=88=9B=E4=BD=9C=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8F=B0=20-=20=E4=BF=AE=E5=A4=8D=20Agent=E3=80=81=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E5=BC=95=E7=94=A8=E3=80=81=E9=99=84=E4=BB=B6=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E4=B8=8E=20Agnes=20=E8=A7=86=E9=A2=91=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/protocol/builtin.go | 217 +++++++++++++++++- backend/internal/protocol/builtin_test.go | 61 ++++- backend/internal/protocol/docs/agnes-video.md | 55 ++--- backend/internal/protocol/types.go | 1 + backend/internal/service/provider.go | 25 +- backend/internal/service/provider_test.go | 11 + docs/content/docs/progress/pending-test.mdx | 45 ++++ .../canvas/canvas-agent-chat-ui.tsx | 11 +- .../canvas/canvas-assistant-panel.tsx | 16 +- .../canvas/canvas-config-composer.tsx | 42 ++-- .../components/canvas/canvas-context-menu.tsx | 1 + .../canvas/canvas-local-agent-panel.tsx | 143 +++++++++--- .../components/canvas/canvas-node-content.tsx | 5 +- .../canvas/canvas-node-generation.ts | 91 +++++--- .../canvas/canvas-node-prompt-panel.tsx | 30 +-- .../canvas-resource-mention-textarea.tsx | 62 ++++- ...use-resolved-canvas-resource-references.ts | 79 +++++++ web/src/lib/canvas/canvas-project-domain.ts | 5 +- .../lib/canvas/canvas-resource-references.ts | 24 +- .../canvas/canvas-storyboard-materializer.ts | 10 +- web/src/pages/canvas/project.tsx | 80 +++++-- .../pages/canvas/use-canvas-media-tools.ts | 4 +- web/src/pages/canvas/use-canvas-storyboard.ts | 6 +- web/src/pages/create/creation-references.ts | 44 +++- web/src/pages/create/index.tsx | 112 +++++++-- web/src/styles/globals.css | 51 ++-- .../canvas-node-generation-mentions.test.ts | 71 ++++++ .../canvas-resource-mention-editor.test.ts | 27 +++ web/test/canvas-resource-references.test.ts | 58 ++++- web/test/canvas-storyboard-assets.test.ts | 4 +- web/test/create-library-button.test.ts | 7 +- web/test/create-sidebar-theme.test.ts | 1 + web/test/creation-references.test.ts | 58 ++++- 33 files changed, 1218 insertions(+), 239 deletions(-) create mode 100644 web/src/components/canvas/use-resolved-canvas-resource-references.ts create mode 100644 web/test/canvas-node-generation-mentions.test.ts diff --git a/backend/internal/protocol/builtin.go b/backend/internal/protocol/builtin.go index 367f54a45..74d138f15 100644 --- a/backend/internal/protocol/builtin.go +++ b/backend/internal/protocol/builtin.go @@ -487,11 +487,218 @@ func asyncAudioAdapter() Adapter { } func agnesAdapter() Adapter { - info := metadata("agnes-video", "Agnes", "Agnes", CapabilityVideo, "", "", "") - info.Enabled = false - info.Installable = false - info.UnavailableReason = "未提供可验证的官方请求、鉴权、轮询和响应协议资料" - return UnavailableAdapter{Info: info} + info := metadata("agnes-video", "Agnes 视频", "Agnes AI", CapabilityVideo, "POST /v1/videos", "GET /agnesapi?video_id={id}&model_name={model}", "application/json") + info.Description = "Agnes AI 官方异步视频协议,支持 Agnes Video V2.0、2.5 与 2.5 Flash。" + info.RequiresPublicMediaURLs = true + info.Parameters = []Parameter{ + {Name: "model", Type: "string", Required: true, Mapping: "model", Description: "官方模型 ID:agnes-video-v2.0、agnes-video-2.5 或 agnes-video-2.5-flash。"}, + {Name: "prompt", Type: "string", Required: true, Mapping: "prompt", Description: "视频生成提示词。"}, + {Name: "images", Type: "media[]", Mapping: "first_frame/last_frame/images/image/extra_body.image", Description: "2.5 系列按 mode 映射为关键帧或参考图;V2.0 映射为 image 或关键帧数组。"}, + {Name: "videos", Type: "media[]", Mapping: "videos", Description: "仅 Agnes Video 2.5 reference 模式支持,Flash 与 V2.0 不支持。"}, + {Name: "audios", Type: "media[]", Mapping: "audios", Description: "Agnes Video 2.5 系列 reference 模式支持。"}, + {Name: "duration", Type: "integer", Mapping: "seconds/num_frames", Description: "2.5 系列映射为字符串 seconds;V2.0 按帧率换算 num_frames。"}, + {Name: "aspectRatio", Type: "string", Mapping: "aspect_ratio", Description: "2.5 系列画幅比例。"}, + {Name: "resolution", Type: "string", Mapping: "size", Description: "2.5 支持 720P、960P、2K;Flash 固定 720P。"}, + } + return builtinAdapter{ + info: info, + create: buildAgnesCreate, + parseCreate: parseAgnesCreate, + poll: func(c PollContext) (RequestSpec, error) { + model := strings.TrimSpace(c.Model) + if model == "" { + model = strings.TrimSpace(c.Request.Model) + } + path := "/agnesapi?video_id=" + url.QueryEscape(c.TaskID) + if model != "" { + path += "&model_name=" + url.QueryEscape(model) + } + return RequestSpec{Method: http.MethodGet, Path: path, OriginPath: true}, nil + }, + parsePoll: parseAgnesPoll, + } +} + +func buildAgnesCreate(r GenerationRequest) (RequestSpec, error) { + model := strings.TrimSpace(r.Model) + switch model { + case "agnes-video-v2.0": + body, err := buildAgnesV20Body(r) + if err != nil { + return RequestSpec{}, err + } + return jsonSpec(http.MethodPost, "/v1/videos", body), nil + case "agnes-video-2.5", "agnes-video-2.5-flash": + body, err := buildAgnesV25Body(r) + if err != nil { + return RequestSpec{}, err + } + return jsonSpec(http.MethodPost, "/v1/videos", body), nil + default: + return RequestSpec{}, fmt.Errorf("Agnes 不支持模型 %q", model) + } +} + +func buildAgnesV25Body(r GenerationRequest) (map[string]any, error) { + mode := firstString(r.Extra, "mode") + if mode == "" { + switch { + case len(r.Videos) > 0 || len(r.Audios) > 0: + mode = "reference" + case len(r.Images) > 0: + mode = "keyframe" + default: + mode = "text" + } + } + if mode != "text" && mode != "keyframe" && mode != "reference" { + return nil, fmt.Errorf("Agnes 2.5 mode 必须是 text、keyframe 或 reference") + } + seconds := defaultInt(r.Duration, 5) + if seconds < 4 || seconds > 12 { + return nil, fmt.Errorf("Agnes 2.5 seconds 必须在 4 到 12 秒之间") + } + size := strings.ToUpper(defaultValue(r.Resolution, firstString(r.Extra, "size"))) + if size == "" { + size = "720P" + } + if r.Model == "agnes-video-2.5-flash" { + if size != "720P" { + return nil, fmt.Errorf("Agnes Video 2.5 Flash 仅支持 720P") + } + if len(r.Images) > 5 { + return nil, fmt.Errorf("Agnes Video 2.5 Flash 最多支持 5 张参考图") + } + if len(r.Videos) > 0 { + return nil, fmt.Errorf("Agnes Video 2.5 Flash 不支持参考视频") + } + } else if size != "720P" && size != "960P" && size != "2K" { + return nil, fmt.Errorf("Agnes Video 2.5 size 必须是 720P、960P 或 2K") + } + body := map[string]any{ + "model": r.Model, + "prompt": r.Prompt, + "mode": mode, + "seconds": strconv.Itoa(seconds), + "size": size, + "aspect_ratio": defaultValue(r.AspectRatio, firstString(r.Extra, "aspect_ratio")), + "n": 1, + } + if seed, ok := r.Extra["seed"]; ok { + body["seed"] = seed + } + switch mode { + case "text": + if len(r.Images)+len(r.Videos)+len(r.Audios) > 0 { + return nil, fmt.Errorf("Agnes 2.5 text 模式不接受参考媒体") + } + case "keyframe": + if len(r.Images) == 0 || len(r.Images) > 2 || len(r.Videos)+len(r.Audios) > 0 { + return nil, fmt.Errorf("Agnes 2.5 keyframe 模式需要 1 到 2 张图片,且不接受音视频参考") + } + body["first_frame"] = mediaValue(r.Images[0]) + if len(r.Images) > 1 { + body["last_frame"] = mediaValue(r.Images[1]) + } + case "reference": + if len(r.Images)+len(r.Videos)+len(r.Audios) == 0 { + return nil, fmt.Errorf("Agnes 2.5 reference 模式至少需要一种参考媒体") + } + if len(r.Images) > 0 { + body["images"] = mediaValues(r.Images) + } + if len(r.Audios) > 0 { + body["audios"] = mediaValues(r.Audios) + } + if len(r.Videos) > 0 { + videos := make([]any, 0, len(r.Videos)) + for _, video := range r.Videos { + videos = append(videos, map[string]any{"url": mediaValue(video)}) + } + body["videos"] = videos + } + } + return compactMap(body), nil +} + +func buildAgnesV20Body(r GenerationRequest) (map[string]any, error) { + if len(r.Videos)+len(r.Audios) > 0 { + return nil, fmt.Errorf("Agnes Video V2.0 不支持参考视频或音频") + } + body := map[string]any{"model": r.Model, "prompt": r.Prompt} + mergeExtra(body, r.Extra, "mode", "width", "height", "num_frames", "frame_rate", "num_inference_steps", "seed", "negative_prompt") + if r.Duration > 0 { + frameRate := 24 + if value, ok := r.Extra["frame_rate"].(float64); ok && value >= 1 && value <= 60 { + frameRate = int(value) + } + if value, ok := r.Extra["frame_rate"].(int); ok && value >= 1 && value <= 60 { + frameRate = value + } + if _, supplied := r.Extra["frame_rate"]; !supplied { + body["frame_rate"] = frameRate + } + if _, supplied := r.Extra["num_frames"]; !supplied { + target := r.Duration * frameRate + frames := ((target-1+4)/8)*8 + 1 + if frames > 441 { + frames = 441 + } + body["num_frames"] = frames + } + } + if len(r.Images) == 1 { + body["image"] = mediaValue(r.Images[0]) + } else if len(r.Images) > 1 { + extraBody := map[string]any{} + if configured := object(r.Extra["extra_body"]); configured != nil { + for key, value := range configured { + extraBody[key] = value + } + } + extraBody["image"] = mediaValues(r.Images) + extraBody["mode"] = "keyframes" + body["extra_body"] = extraBody + } else if configured := object(r.Extra["extra_body"]); configured != nil { + body["extra_body"] = configured + } + return compactMap(body), nil +} + +func parseAgnesCreate(payload map[string]any) (CreateResult, error) { + id := firstString(payload, "video_id", "task_id", "id") + if id == "" { + return CreateResult{}, fmt.Errorf("Agnes 创建响应没有 video_id") + } + status := normalizeStatus(firstString(payload, "status")) + if status == "" { + status = StatusPending + } + return CreateResult{TaskID: id, Status: status}, nil +} + +func parseAgnesPoll(c PollContext, payload map[string]any) (PollResult, error) { + id := defaultValue(firstString(payload, "video_id", "task_id", "id"), c.TaskID) + status := normalizeStatus(firstString(payload, "status")) + if status == "" { + status = StatusProcessing + } + message := firstString(payload, "message", "detail") + if failure := object(payload["error"]); failure != nil { + message = defaultValue(firstString(failure, "message", "detail", "code"), message) + } + if status == StatusFailed { + return PollResult{TaskID: id, Status: status, Message: defaultValue(message, "Agnes 视频生成失败")}, nil + } + if status != StatusSucceeded { + return PollResult{TaskID: id, Status: status, Message: message}, nil + } + metadata := object(payload["metadata"]) + videoURL := firstString(metadata, "url") + if videoURL == "" { + return PollResult{}, fmt.Errorf("Agnes 完成响应没有 metadata.url") + } + return PollResult{TaskID: id, Status: status, Result: &Result{Videos: []MediaReference{{URL: videoURL, Kind: string(CapabilityVideo)}}}}, nil } func videoAdapter(info Metadata, create func(GenerationRequest) (RequestSpec, error)) Adapter { diff --git a/backend/internal/protocol/builtin_test.go b/backend/internal/protocol/builtin_test.go index 436425dc8..2ed50d1d9 100644 --- a/backend/internal/protocol/builtin_test.go +++ b/backend/internal/protocol/builtin_test.go @@ -24,18 +24,25 @@ func TestBuiltinCatalogContainsRequestedProtocols(t *testing.T) { t.Fatal("legacy OpenAI video alias was not registered") } available := registry.List(SurfaceUserCustomChannel, CapabilityVideo, false) + var agnesAvailable bool for _, item := range available { if item.ID == "agnes-video" { - t.Fatal("unavailable Agnes adapter was selectable") + agnesAvailable = true + if !item.Enabled || !item.Installable || item.UnavailableReason != "" { + t.Fatalf("Agnes metadata must be enabled: %#v", item) + } } } + if !agnesAvailable { + t.Fatal("Agnes adapter was not selectable") + } all := registry.List(SurfaceAdminSystemChannel, CapabilityVideo, true) var found bool for _, item := range all { if item.ID == "agnes-video" { found = true - if item.UnavailableReason == "" || item.Enabled { - t.Fatalf("Agnes metadata must explain that it is disabled: %#v", item) + if !item.Enabled || item.UnavailableReason != "" { + t.Fatalf("Agnes metadata must stay enabled: %#v", item) } } } @@ -126,6 +133,7 @@ func TestImageAndVideoAdaptersMapProviderShapes(t *testing.T) { {"gemini-veo", "/v1beta/models/veo-test:predictLongRunning", "/v1beta/operations/video-1", GenerationRequest{Model: "veo-test", Prompt: "a clip"}}, {"novita-video", "/v3/video/create", "/v3/async/task-result?task_id=video-1", GenerationRequest{Model: "novita", Prompt: "a clip"}}, {"minimax-video", "/v2/video_generation", "/v2/query/video_generation/video-1", GenerationRequest{Model: "MiniMax-H3", Prompt: "a clip"}}, + {"agnes-video", "/v1/videos", "/agnesapi?video_id=video-1&model_name=agnes-video-2.5", GenerationRequest{Model: "agnes-video-2.5", Prompt: "a clip", Duration: 5}}, } for _, tc := range cases { t.Run(tc.id, func(t *testing.T) { @@ -206,14 +214,49 @@ func TestAsyncVideoResponseNormalizesStatusAndResult(t *testing.T) { } } -func TestAgnesIsExplicitlyUnavailable(t *testing.T) { +func TestAgnesMapsOfficialCreateAndPollContracts(t *testing.T) { adapter, _ := Builtins().Get("agnes-video") - _, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{Model: "agnes", Prompt: "test"}}) - if err == nil { - t.Fatal("Agnes unexpectedly accepted a request") + create, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{ + Model: "agnes-video-2.5", Prompt: "use the first frame", Duration: 5, Resolution: "960P", AspectRatio: "16:9", + Images: []MediaReference{{URL: "https://cdn.example/first.png"}}, + }}) + if err != nil { + t.Fatal(err) } - if _, ok := err.(UnavailableError); !ok { - t.Fatalf("error = %T %v", err, err) + body := create.Body.(map[string]any) + if create.Path != "/v1/videos" || body["mode"] != "keyframe" || body["seconds"] != "5" || body["size"] != "960P" || body["first_frame"] != "https://cdn.example/first.png" { + t.Fatalf("create = %#v", create) + } + poll, err := adapter.BuildPoll(context.Background(), PollContext{Model: "agnes-video-2.5", TaskID: "video/1"}) + if err != nil { + t.Fatal(err) + } + if !poll.OriginPath || poll.Path != "/agnesapi?video_id=video%2F1&model_name=agnes-video-2.5" { + t.Fatalf("poll = %#v", poll) + } + created, err := adapter.ParseCreate(context.Background(), []byte(`{"id":"task-1","video_id":"video-1","status":"queued"}`)) + if err != nil || created.TaskID != "video-1" || created.Status != StatusPending { + t.Fatalf("created = %#v, err = %v", created, err) + } + completed, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: "video-1"}, []byte(`{"video_id":"video-1","status":"completed","metadata":{"url":"https://cdn.example/result.mp4"}}`)) + if err != nil || completed.Status != StatusSucceeded || completed.Result == nil || completed.Result.Videos[0].URL != "https://cdn.example/result.mp4" { + t.Fatalf("completed = %#v, err = %v", completed, err) + } + failed, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: "video-1"}, []byte(`{"video_id":"video-1","status":"failed","error":{"message":"quota exceeded"}}`)) + if err != nil || failed.Status != StatusFailed || failed.Message != "quota exceeded" { + t.Fatalf("failed = %#v, err = %v", failed, err) + } +} + +func TestAgnesFlashRejectsUnsupportedOfficialInputs(t *testing.T) { + adapter, _ := Builtins().Get("agnes-video") + _, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{Model: "agnes-video-2.5-flash", Prompt: "test", Duration: 5, Resolution: "2K"}}) + if err == nil || !strings.Contains(err.Error(), "720P") { + t.Fatalf("resolution error = %v", err) + } + _, err = adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{Model: "agnes-video-2.5-flash", Prompt: "test", Duration: 5, Videos: []MediaReference{{URL: "https://cdn.example/reference.mp4"}}}}) + if err == nil || !strings.Contains(err.Error(), "不支持参考视频") { + t.Fatalf("video error = %v", err) } } diff --git a/backend/internal/protocol/docs/agnes-video.md b/backend/internal/protocol/docs/agnes-video.md index 9e5843a38..f8ce57071 100644 --- a/backend/internal/protocol/docs/agnes-video.md +++ b/backend/internal/protocol/docs/agnes-video.md @@ -1,46 +1,49 @@ -# Agnes 视频协议(当前不可用) +# Agnes AI 视频协议 -Agnes 作为内置占位插件保留,是为了让历史模型配置能显示明确的不可用原因,而不是静默消失。当前仓库没有可验证的官方接口、鉴权、模型、轮询和响应资料,因此插件被强制禁用,也不可安装或选择。 +Agnes AI 官方视频接口使用 Bearer API Key 和异步任务模型。影策实现覆盖 `agnes-video-v2.0`、`agnes-video-2.5`、`agnes-video-2.5-flash`,创建任务后保存响应中的 `video_id`,再查询任务状态。官方国际站 Base URL 为 `https://apihub.agnes-ai.com/v1`;密钥只配置在后端渠道,不进入浏览器 URL、日志或任务正文。 -## 接口与当前状态 +## 接口与鉴权 {{OPERATIONS}} -创建、轮询、取消路径均为空。调用 `BuildCreate` 会返回 `UnavailableError`:`未提供可验证的官方请求、鉴权、轮询和响应协议资料`。页面展示该条目不代表可以发送任务。 +创建请求发送到 `/v1/videos`。轮询接口比较特殊:它位于同一主机的根路径 `/agnesapi`,而不是 Base URL 的 `/v1` 下。影策通过“同源根路径”合同拼接请求,因此配置 `https://apihub.agnes-ai.com/v1` 时会得到 `https://apihub.agnes-ai.com/agnesapi?...`,不会错误拼成 `/v1/agnesapi`,也不会把主机硬编码进协议适配器。 -## 模型与能力状态 +请求头使用 `Authorization: Bearer ` 和 `Content-Type: application/json`。创建响应优先读取 `video_id`,兼容读取 `task_id` 和 `id`;轮询始终携带 `video_id`,并在模型名可用时携带 `model_name`。 -当前没有任何经过验证的模型 ID、能力范围或计费规则。任何 Agnes 模型名都不可提交;只有取得官方模型目录并完成真实账户验证后,才能写入渠道模型配置。 +## 模型与模式 -## 参数与字段状态 +- `agnes-video-2.5`:支持 `text`、`keyframe`、`reference`。时长为字符串 `"4"` 到 `"12"`,默认 5 秒;分辨率支持 `720P`、`960P`、`2K`。 +- `agnes-video-2.5-flash`:模式与 2.5 相同,但固定 `720P`,参考图最多 5 张,不支持参考视频。 +- `agnes-video-v2.0`:支持文生视频、单图生视频和多关键帧;时长由 `num_frames / frame_rate` 控制,`num_frames` 必须符合 `8n + 1` 且不超过 441。 -当前 metadata 没有参数映射,创建请求也不存在。`model`、`prompt`、参考素材、时长、比例、分辨率、音频和水印均不得推测或透传。 +2.5 系列未显式传 `mode` 时,纯文本使用 `text`;存在音频或视频时使用 `reference`;只有一至两张图片时使用 `keyframe`。如果业务需要把图片当风格或主体参考,应在扩展参数中明确传 `mode: "reference"`。模式与媒体组合不合法时,影策在发出上游请求前直接拒绝。 -## 启用前必须补齐的事实 +## 参数映射 -| 项目 | 必须取得的证据 | 验收方式 | -| --- | --- | --- | -| 服务入口 | 官方 Base URL、版本、区域 | 官方文档链接和最小健康探测 | -| 鉴权 | Header、Token/签名算法、权限范围 | 不在 URL/日志泄露密钥的集成测试 | -| 模型 | 精确模型 ID、能力和停用策略 | 真实账户模型发现或官方表 | -| 创建 | Method、Path、Content-Type、完整字段 | 请求 fixture 与真实 2xx 响应 | -| 轮询 | 任务 ID、状态枚举、失败结构 | queued -> running -> completed/failed fixture | -| 结果 | 视频 URL、文件接口、有效期 | 下载并存入影策资源的冒烟测试 | -| 取消与账务 | 是否支持取消、失败退款语义 | 官方合同与错误场景验证 | +{{PARAMETERS}} -## 禁止的临时处理 +### Agnes Video 2.5 / 2.5 Flash -- 不得根据插件名猜测 `/v1/videos`、模型名或 Bearer 鉴权。 -- 不得复用其他视频供应商的字段并把 200 响应当兼容。 -- 不得用空任务 ID、默认成功状态或假 URL 让页面“看起来能用”。 -- 不得仅在前端隐藏;后端 registry 和写路径必须继续拒绝不可用协议。 +公共请求字段包括 `model`、`prompt`、`mode`、`seconds`、`size`、`aspect_ratio`、`seed`、`n`。影策固定 `n: 1`。影策字段 `images` 在 `keyframe` 模式把前两张图片依次映射为 `first_frame`、`last_frame`;在 `reference` 模式映射为官方图片数组。影策字段 `videos`、`audios` 分别映射为官方同名数组。参考视频当前发送 `{ "url": "..." }`,不臆造 `start_seconds` 或 `require_audio`。 -## 未来实现结构 +### Agnes Video V2.0 -取得官方资料后,应新增独立 adapter、专属 create/poll/result fixture、错误码映射和一份替换本文的完整文档。若协议实际是已有供应商的别名,应迁移历史配置到明确的 canonical ID,而不是永久保留第二套执行逻辑。 +单张 `images` 输入映射为 `image`;多张图片映射为 `extra_body.image` 并设置 `extra_body.mode: "keyframes"`。影策字段 `duration` 会按 `frame_rate`(缺省 24)换算到最近的 `8n + 1` 帧并限制在 441 帧内。高级字段 `width`、`height`、`num_frames`、`frame_rate`、`num_inference_steps`、`seed`、`negative_prompt` 可由模型协议扩展参数传入。V2.0 不使用 `aspectRatio` 或 `resolution` 直接猜测尺寸。 + +## 响应、状态与错误 + +创建成功响应中的 `video_id` 是推荐轮询标识。状态映射为:`queued` -> 等待,`in_progress` -> 处理中,`completed` -> 成功,`failed` -> 失败。成功结果只读取官方 `metadata.url`;如果响应宣称完成却没有该字段,影策会返回真实协议错误,不生成假 URL。失败信息优先读取 `error.message`,其次读取顶层 `message` 或 `detail`。 + +参考媒体必须是 Agnes 服务可公开访问且在任务完成前持续有效的 URL。适配器声明了公共媒体 URL 要求,影策运行时会按现有资源流程准备上游可访问地址;本机、私网、Cookie 保护或很快过期的链接不能作为 Agnes 输入。 ## 官方资料 -当前未找到可验证且与本插件对应的官方开发者资料。为避免误导,本页不引用搜索聚合、论坛转述或其他同名产品文档。 +- 官网: +- 文档首页: +- Agnes Video 2.5: +- Agnes Video 2.5 Flash: +- Agnes Video V2.0: + +官方当前给出的创建接口是 `POST https://apihub.agnes-ai.com/v1/videos`,推荐查询接口是 `GET https://apihub.agnes-ai.com/agnesapi?video_id=&model_name=`。价格和促销会变化,不在协议适配器中固化。 {{CONTRACT}} diff --git a/backend/internal/protocol/types.go b/backend/internal/protocol/types.go index e7f6ebe2f..a8b8a6bc7 100644 --- a/backend/internal/protocol/types.go +++ b/backend/internal/protocol/types.go @@ -78,6 +78,7 @@ type PollContext struct { type RequestSpec struct { Method string `json:"method"` Path string `json:"path"` + OriginPath bool `json:"originPath,omitempty"` ContentType string `json:"contentType"` Headers map[string]string `json:"headers,omitempty"` Body any `json:"body,omitempty"` diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 98d4c1e54..6c7f578ac 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -2062,7 +2062,11 @@ func executeProtocolRequest(ctx context.Context, config providerConfig, spec pro } body = bytes.NewReader(data) } - req, err := http.NewRequestWithContext(ctx, method, apiURL(config.BaseURL, spec.Path), body) + requestURL, err := protocolRequestURL(config.BaseURL, spec) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, method, requestURL, body) if err != nil { return nil, err } @@ -2078,6 +2082,25 @@ func executeProtocolRequest(ctx context.Context, config providerConfig, spec pro return data, err } +func protocolRequestURL(baseURL string, spec protocol.RequestSpec) (string, error) { + if !spec.OriginPath { + return apiURL(baseURL, spec.Path), nil + } + base, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil || base.Scheme == "" || base.Host == "" { + return "", fmt.Errorf("协议根路径请求的 Base URL 无效") + } + requestPath, err := url.Parse(spec.Path) + if err != nil || !strings.HasPrefix(requestPath.Path, "/") { + return "", fmt.Errorf("协议根路径请求必须使用绝对路径") + } + base.Path = requestPath.Path + base.RawPath = requestPath.RawPath + base.RawQuery = requestPath.RawQuery + base.Fragment = "" + return base.String(), nil +} + func finishProtocolResult(ctx context.Context, config providerConfig, mode string, result *protocol.Result) (map[string]interface{}, error) { if result == nil { return nil, errors.New("声明式协议已完成但没有返回结果") diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 0d508579b..fc63e7ac4 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -17,6 +17,7 @@ import ( "time" "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/protocol" ) const testReferenceImageDataURL = "data:image/png;base64,aGVsbG8=" @@ -76,6 +77,16 @@ func TestChannelAPIURLForProtocolUsesGeminiDefault(t *testing.T) { } } +func TestProtocolRequestURLCanResolveSameOriginRootPath(t *testing.T) { + got, err := protocolRequestURL("https://apihub.agnes-ai.com/v1", protocol.RequestSpec{Path: "/agnesapi?video_id=video-1&model_name=agnes-video-2.5", OriginPath: true}) + if err != nil { + t.Fatal(err) + } + if got != "https://apihub.agnes-ai.com/agnesapi?video_id=video-1&model_name=agnes-video-2.5" { + t.Fatalf("root path URL = %q", got) + } +} + func TestSystemChannelIDFromBaseURLSupportsShortAndLegacyProxyPaths(t *testing.T) { for _, test := range []struct{ base, want string }{ {base: "/api/channel-1", want: "channel-1"}, diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 5eacb8af0..0584b511f 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,51 @@ description: 影策已实现功能的手工验证清单与未确认边界 # 待测试 +## 提示词位置型资源引用与缩略图 + +提示词中的画布连接使用 `@图片N`、`@视频N`、`@音频N`、`@文本N` 等位置型槽位,不再把节点 ID 写入提示词;创作页附件对用户仍显示同类编号,但内部使用稳定附件身份,避免删除中间项后引用错位。以下场景仍需要在浏览器中由用户确认: + +- [x] 29 项提示词引用专项测试、TypeScript 检查与生产构建通过。 +- [x] 旧 `@[node:节点ID]` 仅做读取迁移;新写入、配置组装、故事板物化、Agent 建图和快捷生成均只生成位置型引用。 +- [x] 图片、视频、音频、文本和角色分别独立计数;生成时按当前连线顺序解析槽位,删除连线不再删除提示词。 +- [x] 只有 `storageKey` 的图片/视频引用会异步解析预览地址;生成中按钮使用节点强调色与可见旋转动画。 +- [ ] 在画布中连接图片 A 并写入 `@图片1`,删除 A 的连线后连接图片 B,确认提示词不变、缩略图切换到 B、生成请求使用 B。 +- [ ] 混合连接两张图片、一段音频和一段文本,确认标签依次为 `@图片1`、`@音频1`、`@图片2`、`@文本1`,调整不同类型的连线不会串号。 +- [ ] 打开包含旧 `@[node:...]` 的画布节点,确认面板自动迁移成可见位置标签,保存和后续编辑不再出现节点 ID。 +- [ ] 使用只有 `storageKey`、没有 `content` 的本地图片和服务端资源图片,确认顶部参考栏、行内引用和放大预览均能显示。 +- [ ] 点击生成,确认按钮保持节点强调色并显示连续旋转动画,不再出现纯红色空圆。 +- [ ] 在 `/create` 添加至少三张参考图,并在提示词中重复引用 `@图片2`;拖动参考内容轨道中的另一张图片覆盖行内缩略图,确认提示词文本保持不变且所有 `@图片2` 缩略图同步刷新。 +- [ ] 从本机拖入图片覆盖行内缩略图,确认上传期间不能发送,上传成功后原编号保持,上传失败时旧图和旧提示词不变。 +- [ ] 分别测试图片模式、视频模式和文本模式,确认只有图片引用可成为替换目标,视频、音频、文件和技能引用不会误触发。 +- [ ] 替换一个已经位于参考轨道中的图片,确认附件不会重复,其他引用仍指向正确素材。 +- [ ] 确认浅色/深色主题、窄窗口和同一图片多次出现时,拖入高亮与“替换”遮罩清晰且不遮挡相邻文字。 + +用户确认上述场景后,再将对应条目移动到功能文档的已验证范围。 + +## 工作区、Agent、画布复制、模型配置与创作附件修复 + +- [x] 前端 TypeScript 检查与生产构建通过;提示词引用、创作附件和侧栏主题专项测试通过。 +- [x] 空间工作区与普通页面的左侧菜单统一使用 `--workspace-navigation`,不再随路由切换表面色。 +- [x] 本机 Agent 输入框改为 Enter 换行、Cmd/Ctrl+Enter 发送;本轮模型失败会保留原请求快照并提供手动重试,不重复插入用户消息。 +- [x] 文本节点展示区支持框选复制,复制文本在 Clipboard API 不可用时使用兼容回退;图片节点右键增加“复制图片”,非 PNG 图片会转为 PNG 后写入系统剪贴板。 +- [x] 后台模型配置的章节标题与内容、价格档匹配条件与计费状态均改为上下结构。 +- [x] 创作页附件删除按钮隔离 Reorder 拖拽事件;附件引用使用稳定 token,删除第三张不会让第四、第五张引用串位。 +- [ ] 在普通列表页、创作页、空间工作区之间切换,确认左侧菜单明暗色调始终一致,折叠与移动端展开状态无闪色。 +- [ ] 在本机 Agent 输入多行文本,确认 Enter 只换行、Cmd/Ctrl+Enter 和发送按钮正常提交;模拟模型中途失败后点击“重试本轮”,确认沿用原会话、文本、图片和技能且不重复用户气泡。 +- [ ] 在画布文本节点中拖选部分文字并复制;分别右键 PNG、JPEG、服务端 `storageKey` 图片执行“复制图片”,确认可粘贴到系统应用。 +- [ ] 在后台打开文本、图片、视频模型配置,确认所有章节为纵向阅读顺序,宽屏和窄屏都没有左右主从栏或价格档竖分隔线。 +- [ ] 在创作页上传 5 张参考图,展开后依次删除第 3、5、1 张,确认每张都能就地删除,轨道不触发拖拽,提示词中的其余引用仍指向原附件。 + +## Agnes AI 官方视频协议 + +- [x] Agnes 协议已启用,覆盖 `agnes-video-v2.0`、`agnes-video-2.5`、`agnes-video-2.5-flash` 的官方创建字段、状态、失败信息和 `metadata.url` 结果解析。 +- [x] 轮询使用同一 Base URL 主机的根路径 `/agnesapi`,专项测试确认 `https://apihub.agnes-ai.com/v1` 不会被拼成错误的 `/v1/agnesapi`。 +- [x] Flash 的 720P、最多 5 张图片、禁止参考视频规则已在请求前强校验;协议与 provider service 专项测试通过。 +- [ ] 使用真实 Agnes API Key 分别完成 V2.0 文生视频、单图生视频、2.5 关键帧、2.5 reference、2.5 Flash 的创建与轮询冒烟。 +- [ ] 验证真实失败任务能显示官方 `error.message`,429/5xx 保持现有上游错误语义,生成成功后视频能进入影策资源保存流程。 + +用户确认上述场景后,再将对应条目移动到功能文档的已验证范围。 + ## 插件分层管理 - [ ] 管理员在 `/admin/plugins` 停用官方应用插件后,普通用户的有效状态和新请求同时被阻止;重新开放后保留原个人启用选择。 diff --git a/web/src/components/canvas/canvas-agent-chat-ui.tsx b/web/src/components/canvas/canvas-agent-chat-ui.tsx index cc975a7f5..d6e0b4947 100644 --- a/web/src/components/canvas/canvas-agent-chat-ui.tsx +++ b/web/src/components/canvas/canvas-agent-chat-ui.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react"; import { Button, Tooltip } from "antd"; import { motion, useReducedMotion } from "motion/react"; -import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, Sparkles, UserRound, Wrench, X, XCircle } from "lucide-react"; +import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, RotateCcw, Sparkles, UserRound, Wrench, X, XCircle } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import type { CanvasAgentOperationImpact } from "@/lib/canvas/canvas-agent-ops"; @@ -48,7 +48,7 @@ export function extractCanvasAgentQuickActions(text: string): CanvasAgentQuickAc const WORKING_TEXT = "正在推演..."; -export function AgentChatMessage({ item, theme, user, isStreaming = false, onRejectTool, onApproveTool, onQuickAction }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; isStreaming?: boolean; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void; onQuickAction?: (prompt: string) => void }) { +export function AgentChatMessage({ item, theme, user, isStreaming = false, retrying = false, onRejectTool, onApproveTool, onQuickAction, onRetry }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; isStreaming?: boolean; retrying?: boolean; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void; onQuickAction?: (prompt: string) => void; onRetry?: () => void }) { const isUser = item.role === "user"; const isSystem = item.role === "system"; const color = item.role === "error" ? "#dc2626" : item.role === "tool" ? "#2563eb" : theme.node.text; @@ -77,6 +77,11 @@ export function AgentChatMessage({ item, theme, user, isStreaming = false, onRej {!isUser ? : null}
{item.role === "assistant" ? {item.text} :
{item.text}
} + {item.role === "error" && onRetry ? ( + + ) : null} {quickActions.length && onQuickAction ? (
{quickActions.map((action) => ( @@ -341,7 +346,7 @@ export function AgentChatComposer({ value={prompt} references={references} includeAssetLibrary={includeAssetLibrary} - sendOnEnter + sendOnEnter={false} disabled={disabled} onChange={handlePromptChange} onSubmit={onSubmit} diff --git a/web/src/components/canvas/canvas-assistant-panel.tsx b/web/src/components/canvas/canvas-assistant-panel.tsx index 1424ec17c..f56ac0fea 100644 --- a/web/src/components/canvas/canvas-assistant-panel.tsx +++ b/web/src/components/canvas/canvas-assistant-panel.tsx @@ -21,6 +21,7 @@ import { imageReferenceLabel } from "@/lib/image-reference-prompt"; import { navigateToSettings } from "@/lib/settings-navigation"; import { cinematicAgentSessionOpsJson, createCinematicAgentSession, isAgentSessionPollingAbort, resumeCinematicAgentSession } from "@/lib/canvas/canvas-agent-session"; import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary"; +import { buildOrderedCanvasResourceReferences, canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references"; import { AgentChatComposer, AgentChatMessage, AgentWorkingMessage, type CanvasAgentChatMessage, type CanvasAgentMode } from "./canvas-agent-chat-ui"; import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button"; import { ModelLogo } from "@/components/model-logo"; @@ -1560,7 +1561,20 @@ function generationFlowOps(input: Record, snapshot: CanvasAgent const textId = `text-${nanoid()}`; const targetId = `${mode}-${nanoid()}`; const referenceNodeIds = Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds.filter((id): id is string => typeof id === "string") : []; - const tokens = [`@[node:${textId}]`, ...referenceNodeIds.map((id) => `@[node:${id}]`)]; + const promptNode: CanvasNodeData = { + id: textId, + type: CanvasNodeType.Text, + title: stringOptional(input.title) || "提示词", + position: { x, y }, + width: NODE_DEFAULT_SIZE[CanvasNodeType.Text].width, + height: NODE_DEFAULT_SIZE[CanvasNodeType.Text].height, + metadata: { content: prompt }, + }; + const referenceNodes = referenceNodeIds.flatMap((id) => { + const node = snapshot.nodes.find((candidate) => candidate.id === id); + return node ? [node] : []; + }); + const tokens = buildOrderedCanvasResourceReferences([promptNode, ...referenceNodes]).map(canvasResourceMentionToken); return [ textNodeOp({ id: textId, text: prompt, title: stringOptional(input.title) || "提示词" }, x, y), generationTargetNodeOp(targetId, { ...input, prompt: tokens.join("\n") }, x + NODE_DEFAULT_SIZE[CanvasNodeType.Text].width + 80, y, config), diff --git a/web/src/components/canvas/canvas-config-composer.tsx b/web/src/components/canvas/canvas-config-composer.tsx index b3cd4cb77..c2126b9fa 100644 --- a/web/src/components/canvas/canvas-config-composer.tsx +++ b/web/src/components/canvas/canvas-config-composer.tsx @@ -7,7 +7,7 @@ import { canvasThemes } from "@/lib/canvas-theme"; import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow"; import { useThemeStore } from "@/stores/use-theme-store"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; -import type { NodeGenerationInput } from "./canvas-node-generation"; +import { generationInputMentionLabel, normalizeGenerationNodeMentionTokens, type NodeGenerationInput } from "./canvas-node-generation"; import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools"; import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker"; import type { CanvasGenerationMode, CanvasNodeMetadata, CanvasWorkspaceMode } from "@/types/canvas"; @@ -42,7 +42,7 @@ type ComposerCandidate = reference: CanvasResourceReference; }; -export const CONFIG_REFERENCE_PATTERN = /@\[node:([^\]]+)\]/g; +export const CONFIG_REFERENCE_PATTERN = /@\[node:([^\]]+)\]|@(图片|视频|音频|文本|角色|绘图)(\d+)/g; export function CanvasConfigComposer({ value, inputs, skillReferences = [], generationMode, metadata, onChange, onMetadataChange, onClose, workspaceMode = "professional" }: CanvasConfigComposerProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; @@ -54,7 +54,8 @@ export function CanvasConfigComposer({ value, inputs, skillReferences = [], gene const [presetOpen, setPresetOpen] = useState(false); const simpleMode = workspaceMode === "simple"; const workflowVideoReferenceMode = generationMode === "video" && isCanvasWorkflowProvider(metadata); - const tokens = useMemo(() => parseComposerTokens(value), [value]); + const normalizedValue = useMemo(() => normalizeGenerationNodeMentionTokens(value, inputs), [inputs, value]); + const tokens = useMemo(() => parseComposerTokens(normalizedValue, inputs), [inputs, normalizedValue]); const referenceById = useMemo(() => new Map(inputs.map((input) => [input.nodeId, input])), [inputs]); const videoFrameOptions = useMemo( () => @@ -72,6 +73,10 @@ export function CanvasConfigComposer({ value, inputs, skillReferences = [], gene }, [inputs, mention, skillReferences]); useEffect(() => { + if (normalizedValue !== value) { + onChange(normalizedValue); + return; + } if (document.activeElement === editorRef.current) return; const editor = editorRef.current; if (!editor) return; @@ -84,7 +89,7 @@ export function CanvasConfigComposer({ value, inputs, skillReferences = [], gene const input = referenceById.get(token.nodeId); if (input) editor.append(createReferenceChip(input, inputs, theme, setImagePreview)); }); - }, [inputs, referenceById, theme, tokens]); + }, [inputs, normalizedValue, onChange, referenceById, theme, tokens, value]); const syncFromEditor = () => { const editor = editorRef.current; @@ -344,6 +349,7 @@ function createReferenceChip(input: NodeGenerationInput, inputs: NodeGenerationI const wrapper = document.createElement("span"); wrapper.contentEditable = "false"; wrapper.dataset.referenceNodeId = input.nodeId; + wrapper.dataset.referenceToken = `@${generationInputMentionLabel(input, inputs)}`; wrapper.className = "mx-px inline-flex h-7 max-w-40 items-center justify-center overflow-hidden rounded-md border px-1 text-xs leading-none align-middle"; Object.assign(wrapper.style, chipStyle(theme)); if (input.type === "image" && input.image && input.sourceKind !== "drawing") { @@ -377,8 +383,8 @@ function serializeNodes(nodes: NodeListOf) { nodes.forEach((node) => { if (node.nodeType === Node.TEXT_NODE) result += node.textContent || ""; if (!(node instanceof HTMLElement)) return; - const nodeId = node.dataset.referenceNodeId; - if (nodeId) result += `@[node:${nodeId}]`; + const referenceToken = node.dataset.referenceToken; + if (referenceToken) result += referenceToken; else if (node.tagName === "BR") result += "\n"; else result += serializeNodes(node.childNodes); }); @@ -454,27 +460,31 @@ function placeCaretAtEnd(element: HTMLElement) { selection?.addRange(range); } -function parseComposerTokens(value: string): Token[] { +function parseComposerTokens(value: string, inputs: NodeGenerationInput[]): Token[] { const tokens: Token[] = []; + const inputByLabel = new Map(inputs.map((input) => [generationInputMentionLabel(input, inputs), input])); + const inputByNodeId = new Map(inputs.map((input) => [input.nodeId, input])); let lastIndex = 0; for (const match of value.matchAll(CONFIG_REFERENCE_PATTERN)) { if (match.index === undefined) continue; + const input = match[1] ? inputByNodeId.get(match[1]) : inputByLabel.get(`${match[2]}${match[3]}`); + const end = match.index + match[0].length; + if (!input || (!match[1] && !hasMentionBoundary(value, end))) continue; if (match.index > lastIndex) tokens.push({ type: "text", value: value.slice(lastIndex, match.index) }); - tokens.push({ type: "reference", nodeId: match[1] }); - lastIndex = match.index + match[0].length; + tokens.push({ type: "reference", nodeId: input.nodeId }); + lastIndex = end; } if (lastIndex < value.length) tokens.push({ type: "text", value: value.slice(lastIndex) }); return tokens; } function resourceLabel(input: NodeGenerationInput, inputs: NodeGenerationInput[]) { - const sameTypeInputs = inputs.filter((item) => item.type === input.type && item.sourceKind === input.sourceKind); - const index = Math.max(0, sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId)); - if (input.sourceKind === "drawing") return `绘图${index + 1}`; - if (input.type === "image") return `图片${index + 1}`; - if (input.type === "video") return `视频${index + 1}`; - if (input.type === "audio") return `音频${index + 1}`; - return `文本${index + 1}`; + return generationInputMentionLabel(input, inputs); +} + +function hasMentionBoundary(value: string, index: number) { + const char = value[index]; + return !char || /\s|[,.!?;:,。!?;:、)\]}】)]/.test(char); } function chipStyle(theme: (typeof canvasThemes)[keyof typeof canvasThemes]): CSSProperties { diff --git a/web/src/components/canvas/canvas-context-menu.tsx b/web/src/components/canvas/canvas-context-menu.tsx index ceadf21e6..62cb34c29 100644 --- a/web/src/components/canvas/canvas-context-menu.tsx +++ b/web/src/components/canvas/canvas-context-menu.tsx @@ -207,6 +207,7 @@ export function CanvasNodeContextMenu({ } label="复制节点" shortcut="⌘C" onClick={() => runAction(onCopyNode)} /> + {isImage ? } label="复制图片" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null} } label={isImage ? "复制图片地址" : "复制视频地址"} disabled={!canCopyMediaUrl} onClick={() => runAction(onCopyMediaUrl)} /> } label="创建参数变体" shortcut="⌘D" onClick={() => runAction(onDuplicate)} /> } label="删除节点" danger onClick={() => runAction(onDelete)} /> diff --git a/web/src/components/canvas/canvas-local-agent-panel.tsx b/web/src/components/canvas/canvas-local-agent-panel.tsx index 383f6f99c..0ef63f8b5 100644 --- a/web/src/components/canvas/canvas-local-agent-panel.tsx +++ b/web/src/components/canvas/canvas-local-agent-panel.tsx @@ -56,6 +56,14 @@ type AgentLogContext = { connected: boolean; enabled: boolean; activity: string; type AgentWorkspace = { canvasId: string; workspacePath: string; activeThreadId?: string }; type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] }; type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] }; +type AgentTurnPayload = { + text: string; + prompt: string; + canvasId: string; + threadId?: string; + attachments: Array>; + skills: Array<{ skillId: string; name: string; description: string; instruction: string }>; +}; export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snapshot, @@ -106,6 +114,8 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ clearEventLogs, } = useCanvasAgentStore(); const [resizing, setResizing] = useState(false); + const [retryTurn, setRetryTurn] = useState(null); + const [retryMessageId, setRetryMessageId] = useState(null); const listRef = useRef(null); // 供 Agent 输入框「@」插入的画布节点引用候选(active 标记为可用,供「@」菜单列出),与「/」弹出的已加入技能候选 const composerReferences = useMemo(() => buildCanvasResourceReferences(snapshot.nodes, snapshot.connections).map((item) => ({ ...item, active: true })), [snapshot]); @@ -139,6 +149,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ const connectionControllerRef = useRef(null); const activeToolRequestIdsRef = useRef(new Set()); const recoveredToolResultIdsRef = useRef(new Set()); + const activeTurnRef = useRef(null); const syncState = useCallback( (clientId: string, nextSnapshot: CanvasAgentSnapshot) => { const stateHash = hashCanvasAgentSnapshot(nextSnapshot); @@ -202,6 +213,9 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ runtimeRevisionRef.current = 0; runtimeStateHashRef.current = ""; runtimeCanonicalStateHashRef.current = ""; + activeTurnRef.current = null; + setRetryTurn(null); + setRetryMessageId(null); }, [snapshot.projectId]); useEffect(() => { if (!connected) return; @@ -269,8 +283,12 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ } if (event.type === "agent_error") { const errorMessage = parseEventJson<{ message?: unknown }>(event.data)?.message; - setAgentState({ activity: "出错", waiting: false }); - addMessage({ role: "error", title: "错误", text: normalizeText(errorMessage) }); + setAgentState({ activity: "出错", waiting: false, sending: false }); + const messageId = addMessage({ role: "error", title: "错误", text: normalizeText(errorMessage) || "本地 Agent 执行失败,请重试本轮。" }); + if (activeTurnRef.current) { + setRetryTurn(activeTurnRef.current); + setRetryMessageId(messageId || null); + } addEventLog("错误", errorMessage, errorMessage); return; } @@ -322,6 +340,54 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ return () => clearTimeout(timer); }, [connected, snapshot, syncState]); + const submitTurn = async (payload: AgentTurnPayload, appendUserMessage: boolean) => { + const state = useCanvasAgentStore.getState(); + if (!state.connected || state.sending || state.waiting) return; + activeTurnRef.current = payload; + if (appendUserMessage) { + setRetryTurn(null); + setRetryMessageId(null); + } + setAgentState({ activity: appendUserMessage ? "发送中" : "重试中", sending: true, waiting: true }); + if (appendUserMessage) { + const files = state.attachments; + addMessage({ role: "user", text: payload.text || "发送了图片", attachments: files }); + addEventLog("用户发送", { text: payload.text, attachments: files.map(({ name, type, size }) => ({ name, type, size })) }); + files.forEach((item) => { + URL.revokeObjectURL(item.url); + attachmentUrlsRef.current.delete(item.url); + }); + setAgentState({ prompt: "", attachments: [] }); + } else { + addEventLog("重试本轮", { threadId: payload.threadId, attachments: payload.attachments.map(({ name, type }) => ({ name, type })) }); + } + try { + const data = await fetchAgentJson<{ threadId?: string }>("/agent/codex/turn", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + prompt: payload.prompt, + canvasId: payload.canvasId, + threadId: payload.threadId, + attachments: payload.attachments, + skills: payload.skills, + }), + }); + const acceptedPayload = data.threadId ? { ...payload, threadId: data.threadId } : payload; + activeTurnRef.current = acceptedPayload; + if (data.threadId) setAgentState({ activeThreadId: data.threadId }); + addEventLog(appendUserMessage ? "本地 Agent 已接收" : "本地 Agent 已接收重试", { accepted: true }); + } catch (error) { + setRetryTurn(activeTurnRef.current); + setAgentState({ activity: "发送失败", waiting: false }); + const messageId = addMessage({ role: "error", title: appendUserMessage ? "发送失败" : "重试失败", text: error instanceof Error ? error.message : "发送失败" }); + setRetryMessageId(messageId || null); + addEventLog(appendUserMessage ? "发送失败" : "重试失败", error); + } finally { + setAgentState({ sending: false }); + } + }; + const sendPrompt = async (overrideText?: string) => { const text = (overrideText ?? prompt).trim(); const files = attachments; @@ -332,35 +398,14 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ addMessage({ role: "error", title: "图片过大", text: "图片附件超过 30MB,请删减后再发送。" }); return; } - setAgentState({ activity: "发送中", sending: true, waiting: true }); - addMessage({ role: "user", text: text || "发送了图片", attachments: files }); - addEventLog("用户发送", { text, attachments: files.map(({ name, type, size }) => ({ name, type, size })) }); - try { - const data = await fetchAgentJson<{ threadId?: string }>("/agent/codex/turn", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - prompt: requestPrompt, - canvasId: snapshotRef.current.projectId, - threadId: useCanvasAgentStore.getState().activeThreadId || undefined, - attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })), - skills: mentionedSkills.map((skill) => ({ skillId: skill.skill_id, name: skill.skill_name, description: skill.description, instruction: skill.instruction || skill.description })), - }), - }); - if (data.threadId) setAgentState({ activeThreadId: data.threadId }); - addEventLog("本地 Agent 已接收", { accepted: true }); - files.forEach((item) => { - URL.revokeObjectURL(item.url); - attachmentUrlsRef.current.delete(item.url); - }); - setAgentState({ prompt: "", attachments: [] }); - } catch (error) { - setAgentState({ activity: "发送失败", waiting: false }); - addMessage({ role: "error", title: "发送失败", text: error instanceof Error ? error.message : "发送失败" }); - addEventLog("发送失败", error); - } finally { - setAgentState({ sending: false }); - } + await submitTurn({ + text, + prompt: requestPrompt, + canvasId: snapshotRef.current.projectId, + threadId: useCanvasAgentStore.getState().activeThreadId || undefined, + attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })), + skills: mentionedSkills.map((skill) => ({ skillId: skill.skill_id, name: skill.skill_name, description: skill.description, instruction: skill.instruction || skill.description })), + }, true); }; const addAttachments = async (files: FileList | File[] | null) => { @@ -549,6 +594,9 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ setAgentState({ loadingThreads: true }); try { const data = await fetchAgentJson("/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) }); + activeTurnRef.current = null; + setRetryTurn(null); + setRetryMessageId(null); setAgentState({ activeThreadId: data.thread?.id || data.workspace?.activeThreadId || "", messages: [], activeTab: "chat", activity: "新对话" }); await loadThreads(); } catch (error) { @@ -565,6 +613,9 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ setAgentState({ loadingThreads: true }); try { const data = await fetchAgentJson(`/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) }); + activeTurnRef.current = null; + setRetryTurn(null); + setRetryMessageId(null); setAgentState({ activeThreadId: data.thread?.id || threadId, messages: normalizeHistoryMessages(data.messages || []), activeTab: "chat", activity: "已恢复会话" }); await loadThreads(); } catch (error) { @@ -582,6 +633,11 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ try { await fetchAgentJson(`/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) }); const current = useCanvasAgentStore.getState(); + if (current.activeThreadId === threadId) { + activeTurnRef.current = null; + setRetryTurn(null); + setRetryMessageId(null); + } setAgentState({ threads: current.threads.filter((thread) => thread.id !== threadId), activeThreadId: current.activeThreadId === threadId ? "" : current.activeThreadId, @@ -630,24 +686,25 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ const addMessage = (item: Omit) => { const text = normalizeText(item.text); - if (!text && !item.attachments?.length) return; + if (!text && !item.attachments?.length) return undefined; const next = { ...item, id: `${Date.now()}-${Math.random()}`, text }; const currentMessages = useCanvasAgentStore.getState().messages; if (next.streamId) { const index = currentMessages.findIndex((message) => message.streamId === next.streamId); if (index >= 0) { setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, ...next, id: message.id, text: next.text || message.text } : message)) }); - return; + return currentMessages[index].id; } } const last = currentMessages.at(-1); if (last?.role === "assistant" && next.role === "assistant" && last.title === next.title) { const merged = mergeAgentText(last.text, next.text); - if (merged === last.text) return; + if (merged === last.text) return last.id; setAgentState({ messages: [...useCanvasAgentStore.getState().messages.slice(0, -1), { ...last, text: merged, meta: next.meta || last.meta }] }); - return; + return last.id; } pushMessage(next); + return next.id; }; const addEventLog = (title: string, text: unknown, raw?: unknown) => { @@ -660,11 +717,20 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ const nextActivity = activityText(event); if (nextActivity) setAgentState({ activity: nextActivity }); if (event.type === "turn.started") setAgentState({ waiting: true }); - if (event.type === "turn.completed" || event.type === "turn.failed" || event.type === "error") setAgentState({ waiting: false, sending: false }); + if (event.type === "turn.completed") { + activeTurnRef.current = null; + setRetryTurn(null); + setRetryMessageId(null); + setAgentState({ waiting: false, sending: false }); + } else if (event.type === "turn.failed" || event.type === "error") { + if (activeTurnRef.current) setRetryTurn(activeTurnRef.current); + setAgentState({ waiting: false, sending: false }); + } const item = formatAgentEvent(event); if (item) { if (item.role === "error") setAgentState({ waiting: false, sending: false }); - addMessage(item); + const messageId = addMessage(item); + if ((event.type === "turn.failed" || event.type === "error") && activeTurnRef.current && item.role === "error") setRetryMessageId(messageId || null); } }; @@ -716,7 +782,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ /> ) : null} {messages.map((item) => ( - void sendPrompt(text)} /> + void submitTurn(retryTurn, false) : undefined} onQuickAction={(text) => void sendPrompt(text)} /> ))} {pendingTool ? ( | null { const item = event.item; + if (event.type === "turn.failed" || event.type === "error") return { role: "error", title: "模型调用失败", text: normalizeText(event.error?.message || event.message) || "模型调用失败,请重试本轮。", detail: event }; if (event.type === "item.completed" && item?.type === "error") return { role: "error", title: "错误", text: normalizeText(item.message), detail: item }; if ((event.type === "item.updated" || event.type === "item.completed") && item?.type === "agent_message") return { role: "assistant", title: "Codex", text: stringText(item.text), meta: usageText(event), streamId: item.id }; if (event.type === "item.completed" && isMcpToolItem(item) && isReadTool(String(item?.tool || ""))) return { role: "tool", title: `${toolName(String(item?.tool || ""))}完成`, text: item?.error?.message || toolSummary(item), detail: toolDetail(item) }; diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx index 02523ea9b..467595227 100644 --- a/web/src/components/canvas/canvas-node-content.tsx +++ b/web/src/components/canvas/canvas-node-content.tsx @@ -317,13 +317,14 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere /> ) : richTextHTML ? (
event.stopPropagation()} onWheel={(event) => event.stopPropagation()} dangerouslySetInnerHTML={{ __html: richTextHTML }} /> ) : ( -
event.stopPropagation()}> +
event.stopPropagation()} onWheel={(event) => event.stopPropagation()}> {node.metadata?.content || 双击编辑文字}
)} diff --git a/web/src/components/canvas/canvas-node-generation.ts b/web/src/components/canvas/canvas-node-generation.ts index b82add1c4..166e0bdcb 100644 --- a/web/src/components/canvas/canvas-node-generation.ts +++ b/web/src/components/canvas/canvas-node-generation.ts @@ -71,7 +71,7 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData : []; const inputs = [...connectedInputs, ...portraitTextureInput, ...buildAssetGenerationInputs(assets)]; const storyboardInputs = getConnectedStoryboardRows(nodeId, nodes, connections); - const hasExplicitResourceMention = /@\[(?:node|asset):[^\]]+\]/.test(normalizeLegacyNodeMentions(prompt, inputs)); + const hasExplicitResourceMention = hasResolvableGenerationMention(prompt, inputs); const isWorkflowSource = sourceNode?.type === CanvasNodeType.Config && isCanvasWorkflowProvider(sourceNode.metadata); if ((Boolean(sourceNode?.metadata?.composerContent?.trim()) && (sourceNode?.type === CanvasNodeType.Config || isWorkflowSource)) || hasExplicitResourceMention) { const autoIncludeWorkflowMedia = isWorkflowSource; @@ -139,8 +139,9 @@ function buildComposerGenerationContext( autoIncludeWorkflowMedia = false, workflowMediaInputs: NodeGenerationInput[] = [], ): NodeGenerationContext { - const normalizedPrompt = normalizeLegacyNodeMentions(prompt, inputs); - const inputByToken = new Map(inputs.map((input) => [generationInputToken(input), input])); + const normalizedPrompt = normalizeGenerationNodeMentionTokens(prompt, inputs); + const slotInputByToken = new Map(generationSlotEntries(inputs).map(({ token, input }) => [token, input])); + const assetInputById = new Map(inputs.filter((input) => input.nodeId.startsWith("asset:")).map((input) => [input.nodeId.slice("asset:".length), input])); const nodeInputById = new Map(inputs.filter((input) => !input.nodeId.startsWith("asset:")).map((input) => [input.nodeId, input])); const selectedInputs: NodeGenerationInput[] = []; const labelByNodeId = new Map(); @@ -160,12 +161,12 @@ function buildComposerGenerationContext( }); } - for (const match of normalizedPrompt.matchAll(/@\[(node|asset):([^\]]+)\]/g)) { + for (const match of normalizedPrompt.matchAll(GENERATION_MENTION_PATTERN)) { if (match.index === undefined) continue; - hasToken = true; nextPrompt += normalizedPrompt.slice(lastIndex, match.index); - const input = inputByToken.get(`${match[1]}:${match[2]}`); + const input = resolveGenerationMention(normalizedPrompt, match, slotInputByToken, nodeInputById, assetInputById); if (input) { + hasToken = true; let label = labelByNodeId.get(input.nodeId); if (!label) { const labelKind = input.sourceKind === "drawing" ? "drawing" : input.type; @@ -243,32 +244,56 @@ function buildComposerGenerationContext( }; } -// 旧画布保存的是 @角色1 等显示标签;生成时升级为稳定节点 Token,避免标题或排序变化后引用错位。 -function normalizeLegacyNodeMentions(prompt: string, inputs: NodeGenerationInput[]) { - const counts = { image: 0, drawing: 0, video: 0, audio: 0, text: 0, character: 0 }; - const labels = inputs.filter((input) => !input.nodeId.startsWith("asset:")).map((input) => { - const kind = input.sourceKind === "drawing" ? "drawing" : input.type; - return { label: generationLabel(kind, counts[kind]++), nodeId: input.nodeId }; - }).sort((a, b) => b.label.length - a.label.length); - let next = prompt; - labels.forEach(({ label, nodeId }) => { - const token = `@${label}`; - let cursor = 0; - let result = ""; - while (cursor < next.length) { - const found = next.indexOf(token, cursor); - if (found < 0) { - result += next.slice(cursor); - break; - } - const end = found + token.length; - result += next.slice(cursor, found); - result += hasMentionBoundary(next, end) ? `@[node:${nodeId}]` : token; - cursor = end; - } - next = result; +const GENERATION_MENTION_PATTERN = /@\[(node|asset):([^\]]+)\]|@(图片|视频|音频|文本|角色|绘图)(\d+)/g; + +export function generationInputMentionLabel(input: NodeGenerationInput, inputs: NodeGenerationInput[]) { + const entry = generationSlotEntries(inputs).find((item) => item.input.nodeId === input.nodeId); + return entry?.label || generationLabel(input.sourceKind === "drawing" ? "drawing" : input.type, 0); +} + +export function normalizeGenerationNodeMentionTokens(prompt: string, inputs: NodeGenerationInput[]) { + const labelByNodeId = new Map(generationSlotEntries(inputs).map(({ input, label }) => [input.nodeId, label])); + return prompt.replace(/@\[node:([^\]]+)\]/g, (token, nodeId: string) => { + const label = labelByNodeId.get(nodeId); + return label ? `@${label}` : token; }); - return next; +} + +function hasResolvableGenerationMention(prompt: string, inputs: NodeGenerationInput[]) { + const normalizedPrompt = normalizeGenerationNodeMentionTokens(prompt, inputs); + const slotInputByToken = new Map(generationSlotEntries(inputs).map(({ token, input }) => [token, input])); + const nodeInputById = new Map(inputs.filter((input) => !input.nodeId.startsWith("asset:")).map((input) => [input.nodeId, input])); + const assetInputById = new Map(inputs.filter((input) => input.nodeId.startsWith("asset:")).map((input) => [input.nodeId.slice("asset:".length), input])); + for (const match of normalizedPrompt.matchAll(GENERATION_MENTION_PATTERN)) { + if (resolveGenerationMention(normalizedPrompt, match, slotInputByToken, nodeInputById, assetInputById)) return true; + } + return false; +} + +function generationSlotEntries(inputs: NodeGenerationInput[]) { + const counts = { image: 0, drawing: 0, video: 0, audio: 0, text: 0, character: 0 }; + return inputs.flatMap((input) => { + if (input.nodeId.startsWith("asset:")) return []; + const kind = input.sourceKind === "drawing" ? "drawing" : input.type; + const label = generationLabel(kind, counts[kind]++); + return [{ input, label, token: `@${label}` }]; + }); +} + +function resolveGenerationMention( + prompt: string, + match: RegExpMatchArray, + slotInputByToken: Map, + nodeInputById: Map, + assetInputById: Map, +) { + if (match[3]) { + const end = (match.index || 0) + match[0].length; + return hasMentionBoundary(prompt, end) ? slotInputByToken.get(match[0]) : undefined; + } + if (match[1] === "node") return nodeInputById.get(match[2]); + if (match[1] === "asset") return assetInputById.get(match[2]); + return undefined; } function hasMentionBoundary(value: string, index: number) { @@ -307,10 +332,6 @@ function buildAssetGenerationInputs(assets: Asset[]): NodeGenerationInput[] { }); } -function generationInputToken(input: NodeGenerationInput) { - return input.nodeId.startsWith("asset:") ? input.nodeId : `node:${input.nodeId}`; -} - function getConnectedStoryboardRows(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] { const targetNodeIds = new Set([nodeId]); connections.forEach((connection) => { diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 3c70ee31b..09599737d 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react"; +import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react"; import { Button, Image as AntImage, InputNumber, Modal, Tooltip } from "antd"; import { ModelPicker } from "@/components/model-picker"; @@ -21,10 +21,11 @@ import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-pic import { CanvasPortraitTexturePopover } from "./canvas-portrait-texture-popover"; import { CanvasPromptOptimizerDrawer } from "./canvas-prompt-optimizer-drawer"; import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode } from "@/types/canvas"; -import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; +import { canvasResourceMentionToken, normalizeCanvasNodeMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer"; import { createPluginHostContext } from "@/services/plugin-host"; import { usePluginStore } from "@/stores/use-plugin-store"; +import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references"; export type CanvasNodeGenerationMode = CanvasGenerationMode; @@ -75,7 +76,9 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi const [manualExpandedPromptHeight, setManualExpandedPromptHeight] = useState(null); const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴) const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false); - const activeReferences = mentionReferences.filter((item) => item.active && item.kind !== "skill"); + const resolvedMentionReferences = useResolvedCanvasResourceReferences(mentionReferences); + const normalizedSavedPrompt = useMemo(() => normalizeCanvasNodeMentionTokens(savedPrompt, mentionReferences), [mentionReferences, savedPrompt]); + const activeReferences = resolvedMentionReferences.filter((item) => item.active && item.kind !== "skill"); const requirements: ModelRequirements = { capability: mode, input: { @@ -116,7 +119,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi seconds: mode === "video" ? config.videoSeconds : 1, }); const activeReferenceCount = activeReferences.length; - const videoFrameOptions = mentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl })); + const videoFrameOptions = resolvedMentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl })); const hasVideoPromptTools = mode === "video" && !simpleMode && videoFrameOptions.length > 0; const monochromeAccent = theme.node.activeStroke; const composerTokens = { @@ -143,19 +146,20 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture); useEffect(() => { - setPrompt(node.metadata?.composerContent ?? node.metadata?.prompt ?? ""); - }, [node.id, node.metadata?.composerContent, node.metadata?.prompt]); + setPrompt(normalizedSavedPrompt); + if (normalizedSavedPrompt !== savedPrompt) onPromptChange(node.id, normalizedSavedPrompt); + }, [node.id, normalizedSavedPrompt, onPromptChange, savedPrompt]); useEffect(() => { setExpandedPromptOpen(false); setExpandedPresetOpen(false); - setPromptContentHeight(estimatePromptContentHeight(savedPrompt, false)); - setExpandedPromptContentHeight(estimatePromptContentHeight(savedPrompt, true)); + setPromptContentHeight(estimatePromptContentHeight(normalizedSavedPrompt, false)); + setExpandedPromptContentHeight(estimatePromptContentHeight(normalizedSavedPrompt, true)); setManualPromptHeight(null); setManualExpandedPromptHeight(null); }, [node.id]); - const skillReferences = useMemo(() => mentionReferences.filter((item) => item.kind === "skill"), [mentionReferences]); + const skillReferences = useMemo(() => resolvedMentionReferences.filter((item) => item.kind === "skill"), [resolvedMentionReferences]); const updatePrompt = (value: string) => { setPrompt(value); @@ -276,7 +280,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi style={ { color: isSubmitDisabled ? theme.node.faint : theme.node.text, - "--canvas-composer-submit-action": isSubmitDisabled ? theme.toolbar.itemHover : isRunning ? theme.accent.danger : monochromeAccent, + "--canvas-composer-submit-action": isSubmitDisabled ? theme.toolbar.itemHover : monochromeAccent, "--canvas-composer-submit-action-fg": isSubmitDisabled ? theme.node.faint : theme.canvas.background, } as CSSProperties } @@ -291,7 +295,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi ) : null} - {isRunning ? ); @@ -368,10 +372,10 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi return ( <>
- onRemoveReference?.(node.id, reference)} /> + onRemoveReference?.(node.id, reference)} /> , "onChange" | "val sendOnEnter?: boolean; onContentSizeChange?: (height: number) => void; includeAssetLibrary?: boolean; + activeDropReferenceId?: string | null; + onReferenceFilesDrop?: (reference: CanvasResourceReference, files: File[]) => void; }; export const CanvasResourceMentionTextarea = forwardRef(function CanvasResourceMentionTextarea( - { value, references, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, mentionMenuWidth = 320, sendOnEnter = true, onContentSizeChange, includeAssetLibrary = false, ...props }, + { value, references, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, mentionMenuWidth = 320, sendOnEnter = true, onContentSizeChange, includeAssetLibrary = false, activeDropReferenceId, onReferenceFilesDrop, ...props }, forwardedRef, ) { const rawTheme = useThemeStore((state) => state.theme); @@ -59,8 +62,11 @@ export const CanvasResourceMentionTextarea = forwardRef(null); const [activeIndex, setActiveIndex] = useState(-1); - const assetReferences = useMemo(() => includeAssetLibrary ? buildAssetMentionReferences(assets) : [], [assets, includeAssetLibrary]); - const activeCanvasReferences = useMemo(() => references.filter((item) => item.active), [references]); + const [nativeDropReferenceId, setNativeDropReferenceId] = useState(null); + const canvasReferences = useResolvedCanvasResourceReferences(references); + const rawAssetReferences = useMemo(() => includeAssetLibrary ? buildAssetMentionReferences(assets) : [], [assets, includeAssetLibrary]); + const assetReferences = useResolvedCanvasResourceReferences(rawAssetReferences); + const activeCanvasReferences = useMemo(() => canvasReferences.filter((item) => item.active), [canvasReferences]); const availableReferences = useMemo(() => [...activeCanvasReferences, ...assetReferences], [activeCanvasReferences, assetReferences]); const candidates = useMemo(() => { if (!mention) return []; @@ -106,6 +112,15 @@ export const CanvasResourceMentionTextarea = forwardRef { + const editor = editorRef.current; + if (!editor) return; + const dropReferenceId = nativeDropReferenceId || activeDropReferenceId || ""; + editor.querySelectorAll("[data-mention-reference-id]").forEach((chip) => { + chip.classList.toggle("is-replace-target", Boolean(dropReferenceId) && chip.dataset.mentionReferenceId === dropReferenceId); + }); + }, [activeDropReferenceId, nativeDropReferenceId, useRichEditor, value]); + useLayoutEffect(() => { const element = useRichEditor ? editorRef.current : textareaRef.current; const container = containerRef.current; @@ -199,6 +214,14 @@ export const CanvasResourceMentionTextarea = forwardRef { + const element = target instanceof Element ? target.closest("[data-mention-reference-id]") : null; + const referenceId = element?.dataset.mentionReferenceId; + return referenceId ? activeReferences.find((reference) => reference.id === referenceId) : undefined; + }; + + const imageFilesFromTransfer = (event: DragEvent) => Array.from(event.dataTransfer.files).filter((file) => file.type.startsWith("image/")); + const mergedStyle = { ...(style || {}), caretColor: style?.color || theme.node.text, @@ -254,6 +277,35 @@ export const CanvasResourceMentionTextarea = forwardRef) => { + const reference = referenceForDropTarget(event.target); + const hasImageFile = Array.from(event.dataTransfer.items).some((item) => item.kind === "file" && (!item.type || item.type.startsWith("image/"))) + || Array.from(event.dataTransfer.files).some((file) => file.type.startsWith("image/")); + if (!onReferenceFilesDrop || reference?.kind !== "image" || !hasImageFile) { + setNativeDropReferenceId(null); + props.onDragOver?.(event as unknown as React.DragEvent); + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + setNativeDropReferenceId(reference.id); + props.onDragOver?.(event as unknown as React.DragEvent); + }} + onDragLeave={(event: DragEvent) => { + const bounds = event.currentTarget.getBoundingClientRect(); + if (event.clientX <= bounds.left || event.clientX >= bounds.right || event.clientY <= bounds.top || event.clientY >= bounds.bottom) setNativeDropReferenceId(null); + props.onDragLeave?.(event as unknown as React.DragEvent); + }} + onDrop={(event: DragEvent) => { + const reference = referenceForDropTarget(event.target); + const files = imageFilesFromTransfer(event); + setNativeDropReferenceId(null); + if (onReferenceFilesDrop && reference?.kind === "image" && files.length) { + event.preventDefault(); + onReferenceFilesDrop(reference, files); + } + props.onDrop?.(event as unknown as React.DragEvent); + }} onKeyDown={(event: KeyboardEvent) => { if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current)) return; if (mention && candidates.length) { @@ -409,6 +461,7 @@ function createInlineMentionChip(reference: CanvasResourceReference, token: stri const chip = document.createElement("span"); chip.contentEditable = "false"; chip.dataset.mentionToken = token; + chip.dataset.mentionReferenceId = reference.id; chip.className = "canvas-resource-inline-mention"; const at = document.createElement("span"); @@ -641,6 +694,7 @@ function splitMentionText(value: string, references: CanvasResourceReference[]) const serializedToken = canvasResourceMentionToken(reference); referenceByToken.set(serializedToken, { reference, serializedToken }); referenceByToken.set(`@${reference.label}`, { reference, serializedToken }); + if (reference.nodeId && !reference.assetId) referenceByToken.set(`@[node:${reference.nodeId}]`, { reference, serializedToken }); }); const tokens = [...referenceByToken.keys()].sort((a, b) => b.length - a.length); const parts: MentionTextPart[] = []; diff --git a/web/src/components/canvas/use-resolved-canvas-resource-references.ts b/web/src/components/canvas/use-resolved-canvas-resource-references.ts new file mode 100644 index 000000000..bb7b54474 --- /dev/null +++ b/web/src/components/canvas/use-resolved-canvas-resource-references.ts @@ -0,0 +1,79 @@ +import { useEffect, useMemo, useState } from "react"; + +import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; +import { resolveMediaUrl } from "@/services/file-storage"; +import { resolveImageUrl } from "@/services/image-storage"; + +type ResolvedPreview = { + identity: string; + url: string; +}; + +const previewPromiseCache = new Map>(); + +export function useResolvedCanvasResourceReferences(references: CanvasResourceReference[]) { + const requests = useMemo( + () => references.flatMap((reference) => { + const identity = previewIdentity(reference); + return identity ? [{ reference, identity }] : []; + }), + [references], + ); + const [resolvedById, setResolvedById] = useState>({}); + + useEffect(() => { + if (!requests.length) return; + let cancelled = false; + void Promise.all( + requests.map(async ({ reference, identity }) => ({ + id: reference.id, + identity, + url: await resolveReferencePreview(reference, identity), + })), + ).then((resolved) => { + if (cancelled) return; + setResolvedById((current) => { + let changed = false; + const next = { ...current }; + resolved.forEach(({ id, identity, url }) => { + if (!url || (current[id]?.identity === identity && current[id]?.url === url)) return; + next[id] = { identity, url }; + changed = true; + }); + return changed ? next : current; + }); + }); + return () => { + cancelled = true; + }; + }, [requests]); + + return useMemo( + () => references.map((reference) => { + const identity = previewIdentity(reference); + const resolved = identity ? resolvedById[reference.id] : undefined; + return resolved?.identity === identity && resolved.url !== reference.previewUrl ? { ...reference, previewUrl: resolved.url } : reference; + }), + [references, resolvedById], + ); +} + +function previewIdentity(reference: CanvasResourceReference) { + if (!reference.storageKey || !["image", "video", "character"].includes(reference.kind)) return ""; + return `${reference.kind}:${reference.storageKey}`; +} + +function resolveReferencePreview(reference: CanvasResourceReference, identity: string) { + const cached = previewPromiseCache.get(identity); + if (cached) return cached; + const pending = (reference.kind === "video" + ? resolveMediaUrl(reference.storageKey, reference.previewUrl || "") + : resolveImageUrl(reference.storageKey, reference.previewUrl || "", { cacheMiss: true })) + .catch(() => reference.previewUrl || "") + .then((url) => { + if (!url) previewPromiseCache.delete(identity); + return url; + }); + previewPromiseCache.set(identity, pending); + return pending; +} diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts index a7ffcc404..17ba8e65d 100644 --- a/web/src/lib/canvas/canvas-project-domain.ts +++ b/web/src/lib/canvas/canvas-project-domain.ts @@ -236,7 +236,7 @@ export function attachNodeToStoryboardRow(nodes: CanvasNodeData[], connection: P if (!scriptNodeId || !linkedNode || !scriptNode) return nodes; const row = rowId ? scriptNode.metadata?.storyboard?.rows.find((item) => item.id === rowId) : undefined; const videoPrompt = row ? (row.videoMotionPrompt || row.plotDescription).trim() : ""; - const videoComposerContent = row ? storyboardComposerContent(videoPrompt, storyboardRowReferenceNodeIds(scriptNode, row, nodes, [], false)) : ""; + const videoComposerContent = row ? storyboardComposerContent(videoPrompt, storyboardRowReferenceNodeIds(scriptNode, row, nodes, [], false), nodes) : ""; return nodes.map((node) => { if (row && node.id === linkedNode.id && scriptNodeId === connection.fromNodeId && node.type === CanvasNodeType.Video) { @@ -272,7 +272,8 @@ export function expandStoryboardTextMentions(prompt: string, references: CanvasR let expanded = prompt; references.filter((reference) => reference.active && reference.kind === "text" && reference.text?.trim()).forEach((reference) => { const replacement = `【项目设定:${reference.title}】\n${reference.text!.trim()}`; - for (const token of [canvasResourceMentionToken(reference), `@${reference.label}`]) { + for (const token of [canvasResourceMentionToken(reference), `@${reference.label}`, reference.nodeId ? `@[node:${reference.nodeId}]` : ""]) { + if (!token) continue; if (expanded.includes(token)) expanded = expanded.split(token).join(replacement); } }); diff --git a/web/src/lib/canvas/canvas-resource-references.ts b/web/src/lib/canvas/canvas-resource-references.ts index feef4425b..4d0d3dcec 100644 --- a/web/src/lib/canvas/canvas-resource-references.ts +++ b/web/src/lib/canvas/canvas-resource-references.ts @@ -21,23 +21,21 @@ export type CanvasResourceReference = { skill?: Skill; assetId?: string; category?: AssetCategory; + mentionToken?: string; }; export function canvasResourceMentionToken(reference: CanvasResourceReference) { + if (reference.mentionToken) return reference.mentionToken; if (reference.kind === "skill" && reference.skill?.skill_id) return `@[skill:${reference.skill.skill_id}]`; if (reference.assetId) return `@[asset:${reference.assetId}]`; - return `@[node:${reference.nodeId}]`; + return `@${reference.label}`; } -export function removeCanvasResourceMention(prompt: string, reference: CanvasResourceReference) { - const token = canvasResourceMentionToken(reference); - if (!token || !prompt.includes(token)) return prompt; - return prompt - .split(token) - .join("") - .replace(/[ \t]{2,}/g, " ") - .replace(/\n{3,}/g, "\n\n") - .trim(); +export function normalizeCanvasNodeMentionTokens(prompt: string, references: CanvasResourceReference[]) { + return references.reduce((value, reference) => { + if (!reference.nodeId || reference.assetId || reference.kind === "skill") return value; + return value.split(`@[node:${reference.nodeId}]`).join(`@${reference.label}`); + }, prompt); } export function buildAssetMentionReferences(assets: Asset[]): CanvasResourceReference[] { @@ -73,6 +71,10 @@ export function buildNodeMentionReferences(node: CanvasNodeData, nodes: CanvasNo return labelResourceNodes(getMentionResourceNodes(node.id, nodes, connections), true); } +export function buildOrderedCanvasResourceReferences(nodes: CanvasNodeData[], active = true) { + return labelResourceNodes(nodes.filter(isResourceNode), active); +} + export function getMentionResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) { const configInputs = getConnectedConfigResourceNodes(nodeId, nodes, connections); if (configInputs.length) return configInputs; @@ -138,7 +140,7 @@ function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) { kind, label, title: node.title || label, - previewUrl: node.metadata?.workflowKind === "character" ? node.metadata.characterCoverUrl : node.type === CanvasNodeType.Drawing ? node.metadata?.drawingPreviewUrl : node.metadata?.content, + previewUrl: node.metadata?.workflowKind === "character" ? node.metadata.characterCoverUrl : node.type === CanvasNodeType.Drawing ? node.metadata?.drawingPreviewUrl : node.metadata?.previewContent || node.metadata?.content, storageKey: node.metadata?.storageKey, text: node.metadata?.workflowKind === "character" ? node.metadata.characterPrompt : node.type === CanvasNodeType.Text ? node.metadata?.content || node.metadata?.prompt : node.type === CanvasNodeType.Skill ? skillResourceText(node) : undefined, active, diff --git a/web/src/lib/canvas/canvas-storyboard-materializer.ts b/web/src/lib/canvas/canvas-storyboard-materializer.ts index 2008d503e..83a42bfd7 100644 --- a/web/src/lib/canvas/canvas-storyboard-materializer.ts +++ b/web/src/lib/canvas/canvas-storyboard-materializer.ts @@ -1,6 +1,7 @@ import { nanoid } from "nanoid"; import { storyboardAssetRoleForNode } from "@/lib/canvas/canvas-storyboard-assets"; +import { buildOrderedCanvasResourceReferences, canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type StoryboardAssetBinding, type StoryboardRow } from "@/types/canvas"; export function storyboardRowReferenceNodeIds( @@ -28,8 +29,13 @@ export function storyboardRowReferenceNodeIds( return Array.from(referenceIds).filter((nodeId) => nodes.some((node) => node.id === nodeId)); } -export function storyboardComposerContent(prompt: string, referenceNodeIds: string[]) { - const mentions = Array.from(new Set(referenceNodeIds)).map((nodeId) => `@[node:${nodeId}]`); +export function storyboardComposerContent(prompt: string, referenceNodeIds: string[], nodes: CanvasNodeData[]) { + const referenceIds = Array.from(new Set(referenceNodeIds)); + const referenceNodes = referenceIds.flatMap((nodeId) => { + const node = nodes.find((candidate) => candidate.id === nodeId); + return node ? [node] : []; + }); + const mentions = buildOrderedCanvasResourceReferences(referenceNodes).map(canvasResourceMentionToken); return mentions.length ? [`参考资产:${mentions.join(" ")}`, prompt.trim()].filter(Boolean).join("\n") : prompt.trim(); } diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 3b348bc7f..68a50be14 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -65,7 +65,7 @@ import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-co import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel"; import { useFocusMode } from "@/hooks/use-focus-mode"; import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store"; -import { getContextResourceNodes, removeCanvasResourceMention, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; +import { getContextResourceNodes, normalizeCanvasNodeMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays"; import { CanvasOverlayLayerContainer, CanvasOverlayLayerProvider } from "@/components/canvas/canvas-overlay-layer"; import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer"; @@ -147,6 +147,31 @@ const CanvasDrawingEditorModal = lazy(() => import("@/components/canvas/canvas-d const NODE_STATUS_SUCCESS = "success" as const; const EMPTY_RESOURCE_REFERENCES: CanvasResourceReference[] = []; +async function copyImageToSystemClipboard(source: string) { + if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) throw new Error("当前浏览器不支持复制图片"); + const response = await fetch(source); + if (!response.ok) throw new Error(`图片读取失败(HTTP ${response.status})`); + const sourceBlob = await response.blob(); + const blob = sourceBlob.type === "image/png" ? sourceBlob : await convertClipboardImageToPNG(sourceBlob); + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); +} + +async function convertClipboardImageToPNG(blob: Blob) { + if (typeof createImageBitmap !== "function") throw new Error("当前浏览器无法转换这张图片的格式"); + const bitmap = await createImageBitmap(blob); + try { + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext("2d"); + if (!context) throw new Error("当前浏览器无法处理这张图片"); + context.drawImage(bitmap, 0, 0); + return await new Promise((resolve, reject) => canvas.toBlob((value) => value ? resolve(value) : reject(new Error("图片格式转换失败")), "image/png")); + } finally { + bitmap.close(); + } +} + function visibleGenerationBatch(node: CanvasNodeData) { const batches = node.metadata?.generationBatches || []; for (let index = batches.length - 1; index >= 0; index -= 1) { @@ -1081,15 +1106,11 @@ function InfiniteCanvasPage() { .filter((connection) => connection.fromNodeId === referenceNodeId && (connection.toNodeId === targetNodeId || connection.toNodeId === configNodeId)) .map((connection) => connection.id), ); - const targetNode = nodesRef.current.find((node) => node.id === targetNodeId); - const currentPrompt = targetNode?.metadata?.composerContent ?? targetNode?.metadata?.prompt ?? ""; - const nextPrompt = removeCanvasResourceMention(currentPrompt, reference); - if (nextPrompt !== currentPrompt) handleNodePromptChange(targetNodeId, nextPrompt); if (!removedConnectionIds.size) return; connectionsRef.current = connectionsRef.current.filter((connection) => !removedConnectionIds.has(connection.id)); setConnections(connectionsRef.current); setSelectedConnectionId((current) => current && removedConnectionIds.has(current) ? null : current); - }, [connectionsRef, handleNodePromptChange, nodesRef, setConnections, setSelectedConnectionId]); + }, [connectionsRef, nodesRef, setConnections, setSelectedConnectionId]); const handleProjectFolderInsert = useCallback((folderId: string) => { const folder = linkedProjectQuery.data?.assetFolders.find((item) => item.id === folderId); @@ -1211,6 +1232,26 @@ function InfiniteCanvasPage() { scriptEditorNodeId, dialogNodeId, }); + useEffect(() => { + setNodes((current) => { + let changed = false; + const next = current.map((node) => { + const references = mentionReferencesByNodeId.get(node.id); + const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt; + if (!references?.length || !savedPrompt?.includes("@[node:")) return node; + const normalizedPrompt = normalizeCanvasNodeMentionTokens(savedPrompt, references); + if (normalizedPrompt === savedPrompt) return node; + changed = true; + return { + ...node, + metadata: node.metadata?.composerContent !== undefined + ? { ...node.metadata, composerContent: normalizedPrompt } + : { ...node.metadata, prompt: normalizedPrompt }, + }; + }); + return changed ? next : current; + }); + }, [mentionReferencesByNodeId, setNodes]); const dialogNode = dialogNodeId ? nodeById.get(dialogNodeId) || null : null; const subtitleNode = subtitleNodeId ? nodeById.get(subtitleNodeId) || null : null; const timelineNode = timelineNodeId ? nodeById.get(timelineNodeId) || null : null; @@ -1449,29 +1490,26 @@ function InfiniteCanvasPage() { const copyNodeContentToClipboard = useCallback( async (node: CanvasNodeData | null) => { releaseCopiedNodesPastePriority(); - const content = node?.metadata?.content; - if (!node || !content) { + const content = node?.metadata?.content?.trim(); + const resourceId = resourceIdFromStorageKey(node?.metadata?.storageKey); + const copySource = content || (node?.type === CanvasNodeType.Image && resourceId ? resourceFileUrl(resourceId) : ""); + if (!node || !copySource) { message.warning("没有可复制的内容"); return; } try { - if (node.type === CanvasNodeType.Image && typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) { - const response = await fetch(content); - const blob = await response.blob(); - await navigator.clipboard.write([new ClipboardItem({ [blob.type || "image/png"]: blob })]); + if (node.type === CanvasNodeType.Image) { + await copyImageToSystemClipboard(copySource); message.success("图片已复制"); return; } - if (!navigator.clipboard?.writeText) { - message.warning("当前浏览器不支持写入剪贴板"); - return; - } - await navigator.clipboard.writeText(content); + if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(copySource); + else if (!copyToClipboard(copySource)) throw new Error("当前浏览器不支持写入剪贴板"); message.success(node.type === CanvasNodeType.Text ? "文本已复制" : "内容链接已复制"); - } catch { - message.error("复制失败,请检查浏览器剪贴板权限"); + } catch (error) { + message.error(error instanceof Error ? error.message : "复制失败,请检查浏览器剪贴板权限"); } }, [message, releaseCopiedNodesPastePriority], @@ -1670,8 +1708,8 @@ function InfiniteCanvasPage() { y: sourceNode.position.y + sourceNode.height / 2, }, { - prompt: `@[node:${sourceNode.id}]`, - composerContent: `@[node:${sourceNode.id}]`, + prompt: "@文本1", + composerContent: "@文本1", model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, quality: effectiveConfig.quality, diff --git a/web/src/pages/canvas/use-canvas-media-tools.ts b/web/src/pages/canvas/use-canvas-media-tools.ts index f399527d9..1ceedc365 100644 --- a/web/src/pages/canvas/use-canvas-media-tools.ts +++ b/web/src/pages/canvas/use-canvas-media-tools.ts @@ -145,7 +145,7 @@ export function useCanvasMediaTools({ generationMode: "text", model: effectiveConfig.textModel || effectiveConfig.model || defaultConfig.textModel, count: 1, - composerContent: `参考图片:@[node:${node.id}]\n任务说明:@[node:${textNode.id}]`, + composerContent: "参考图片:@图片1\n任务说明:@文本1", }), title: "反推提示词结果", }; @@ -163,7 +163,7 @@ export function useCanvasMediaTools({ return; } const portraitTextureSettings = { ...DEFAULT_PORTRAIT_TEXTURE_SETTINGS, ...node.metadata?.portraitTexture }; - const composerContent = node.metadata?.composerContent?.trim() || node.metadata?.prompt?.trim() || `@[node:${node.id}]`; + const composerContent = node.metadata?.composerContent?.trim() || node.metadata?.prompt?.trim() || "@图片1"; setHoveredNodeId(null); setToolbarNodeId(null); setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, prompt: composerContent, composerContent, portraitTexture: portraitTextureSettings } } : item)); diff --git a/web/src/pages/canvas/use-canvas-storyboard.ts b/web/src/pages/canvas/use-canvas-storyboard.ts index 2fb5b7537..e96a33f93 100644 --- a/web/src/pages/canvas/use-canvas-storyboard.ts +++ b/web/src/pages/canvas/use-canvas-storyboard.ts @@ -200,7 +200,7 @@ export function useCanvasStoryboard({ const existing = row.imageNodeId ? nextNodes.find((node) => node.id === row.imageNodeId && node.type === CanvasNodeType.Image) : undefined; const existingMetadata = existing?.metadata?.content ? existing.metadata : resetGenerationTaskMetadata(existing?.metadata); const referenceIds = storyboardRowReferenceNodeIds(scriptNode, row, nextNodes, nextConnections, false, existing?.id); - const composerContent = storyboardComposerContent(prompt, referenceIds); + const composerContent = storyboardComposerContent(prompt, referenceIds, nextNodes); const imageNode = existing ? { ...existing, metadata: { ...existingMetadata, prompt, composerContent, ...storyboardPromptTemplateMetadata(row, "image"), workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 分镜图`, shotIndex: row.shotNumber } } : createCanvasNode(CanvasNodeType.Image, { x: startX + imageSpec.width / 2, y: scriptNode.position.y + index * (imageSpec.height + 36) + imageSpec.height / 2 }, { prompt, composerContent, ...storyboardPromptTemplateMetadata(row, "image"), workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 分镜图`, shotIndex: row.shotNumber, status: NODE_STATUS_IDLE }); @@ -285,7 +285,7 @@ export function useCanvasStoryboard({ const existingIndex = row.videoNodeId ? nextNodes.findIndex((node) => node.id === row.videoNodeId && node.type === CanvasNodeType.Video) : -1; const existing = existingIndex >= 0 ? nextNodes[existingIndex] : undefined; const referenceIds = storyboardRowReferenceNodeIds(scriptNode, row, nextNodes, nextConnections, false, existing?.id); - const composerContent = storyboardComposerContent(prompt, referenceIds); + const composerContent = storyboardComposerContent(prompt, referenceIds, nextNodes); if (existingIndex >= 0) { const existing = nextNodes[existingIndex]; const existingMetadata = existing.metadata?.content ? existing.metadata : resetGenerationTaskMetadata(existing.metadata); @@ -447,7 +447,7 @@ export function useCanvasStoryboard({ const prompt = (row.videoMotionPrompt || row.plotDescription).trim(); const existing = row.videoNodeId ? nextNodes.find((node) => node.id === row.videoNodeId && node.type === CanvasNodeType.Video) : undefined; const referenceIds = storyboardRowReferenceNodeIds(currentScriptNode, row, nextNodes, nextConnections, true, existing?.id); - const composerContent = storyboardComposerContent(prompt, referenceIds); + const composerContent = storyboardComposerContent(prompt, referenceIds, nextNodes); const existingMetadata = existing?.metadata?.content ? existing.metadata : resetGenerationTaskMetadata(existing?.metadata); const videoNode = existing ? { ...existing, metadata: { ...existingMetadata, prompt, composerContent, model: videoModel, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video" as const, videoEditOperation: "image_to_video" as const, videoStartFrameNodeId: row.imageNodeId, seconds: String(row.durationSeconds) } } diff --git a/web/src/pages/create/creation-references.ts b/web/src/pages/create/creation-references.ts index a7e8aa506..d5c50c609 100644 --- a/web/src/pages/create/creation-references.ts +++ b/web/src/pages/create/creation-references.ts @@ -8,7 +8,11 @@ export type CreationReference = CanvasResourceReference & { }; export function buildCreationMentionReferences(skills: Skill[], attachments: CreationAttachment[] = [], snapshots: CreationReference[] = []) { - const attachmentReferences = attachments.map(attachmentReference); + const counts = { image: 0, video: 0, audio: 0, file: 0 }; + const attachmentReferences = attachments.map((attachment) => { + const kind = creationAttachmentKind(attachment); + return attachmentReference(attachment, counts[kind]++); + }); const skillReferences = buildSkillMentionReferences(skills) as CreationReference[]; const current = [...attachmentReferences, ...skillReferences]; const currentIDs = new Set(current.map((reference) => reference.id)); @@ -17,7 +21,7 @@ export function buildCreationMentionReferences(skills: Skill[], attachments: Cre } export function selectedCreationReferences(prompt: string, references: CreationReference[]) { - return references.filter((reference) => prompt.includes(canvasResourceMentionToken(reference))); + return references.filter((reference) => prompt.includes(canvasResourceMentionToken(reference)) || prompt.includes(`@${reference.label}`)); } export function reconcileCreationAttachmentLimit(attachments: CreationAttachment[], references: CreationReference[], maxReferences: number) { @@ -31,7 +35,35 @@ export function reconcileCreationAttachmentLimit(attachments: CreationAttachment } export function removeCreationReferenceTokens(value: string, references: CreationReference[]) { - return references.reduce((current, reference) => current.split(canvasResourceMentionToken(reference)).join(""), value); + return references.reduce((current, reference) => current.split(canvasResourceMentionToken(reference)).join("").split(`@${reference.label}`).join(""), value); +} + +export function replaceCreationAttachmentReference(prompt: string, attachments: CreationAttachment[], targetAttachmentId: string, replacement: CreationAttachment) { + const targetIndex = attachments.findIndex((attachment) => attachment.id === targetAttachmentId); + if (targetIndex < 0) throw new Error("要替换的参考内容不存在"); + if (replacement.id === targetAttachmentId) return { prompt, attachments }; + + const currentReferences = buildCreationMentionReferences([], attachments); + const targetReference = currentReferences.find((reference) => reference.attachmentId === targetAttachmentId); + if (!targetReference) throw new Error("要替换的提示词引用不存在"); + + const remainingAttachments = attachments.filter((attachment) => attachment.id !== targetAttachmentId && attachment.id !== replacement.id); + const insertionIndex = Math.min(targetIndex, remainingAttachments.length); + const nextAttachments = [ + ...remainingAttachments.slice(0, insertionIndex), + replacement, + ...remainingAttachments.slice(insertionIndex), + ]; + const replacementReference = buildCreationMentionReferences([], nextAttachments).find((reference) => reference.attachmentId === replacement.id); + if (!replacementReference) throw new Error("替换后的提示词引用无效"); + + const targetToken = canvasResourceMentionToken(targetReference); + const replacementToken = canvasResourceMentionToken(replacementReference); + const normalizedPrompt = replaceVisibleReferenceLabel(prompt, targetReference.label, replacementToken); + return { + prompt: normalizedPrompt.split(targetToken).join(replacementToken), + attachments: nextAttachments, + }; } export function displayCreationPrompt(prompt: string, references: CreationReference[]) { @@ -81,5 +113,11 @@ function attachmentReference(attachment: CreationAttachment, index: number): Cre storageKey: attachment.storageKey, active: true, attachmentId: attachment.id, + mentionToken: `@[attachment:${attachment.id}]`, }; } + +function replaceVisibleReferenceLabel(value: string, label: string, replacementToken: string) { + const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return value.replace(new RegExp(`@${escapedLabel}(?=$|\\s|[,.!?;:,。!?;:、)\\]}】)])`, "gu"), replacementToken); +} diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 870b1f3f6..eeabff155 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -43,7 +43,7 @@ import type { PromptOptimizerProvider } from "@/lib/plugins/plugin-types"; import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer"; import { createPluginHostContext } from "@/services/plugin-host"; import { usePluginStore } from "@/stores/use-plugin-store"; -import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, selectedCreationReferences, type CreationReference } from "./creation-references"; +import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference } from "./creation-references"; import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets"; type CreationMode = "text" | "image" | "video"; @@ -151,6 +151,8 @@ export default function CreatePage() { const [mode, setMode] = useState("video"); const [prompt, setPrompt] = useState(""); const [attachments, setAttachments] = useState([]); + const promptRef = useRef(prompt); + const attachmentsRef = useRef(attachments); const [draftReferences, setDraftReferences] = useState([]); const [addedSkills, setAddedSkills] = useState([]); const [ratio, setRatio] = useState("16:9"); @@ -159,6 +161,7 @@ export default function CreatePage() { const [videoQuality, setVideoQuality] = useState(config.vquality || "720"); const [count, setCount] = useState(String(Math.max(1, Math.min(4, Number(config.count) || 1)))); const [busy, setBusy] = useState(false); + const [referenceReplacementBusy, setReferenceReplacementBusy] = useState(false); const [viewMode, setViewMode] = useState("chat"); const [selectedShotIndex, setSelectedShotIndex] = useState(-1); const [composingNextShot, setComposingNextShot] = useState(false); @@ -174,6 +177,8 @@ export default function CreatePage() { const retryPreparingRef = useRef(new Set()); const pendingRetryRef = useRef<{ context: CreationRetryContext; lockKey: string } | null>(null); const [retrySequence, setRetrySequence] = useState(0); + promptRef.current = prompt; + attachmentsRef.current = attachments; const activeConversation = useMemo(() => conversations.find((item) => item.id === activeId) || conversations[0], [activeId, conversations]); const historyConversations = useMemo( @@ -472,9 +477,53 @@ export default function CreatePage() { }; const reorderAttachments = useCallback((next: CreationAttachment[]) => { + attachmentsRef.current = next; setAttachments(next); }, []); + const replaceAttachmentReference = useCallback((targetAttachmentId: string, replacement: CreationAttachment) => { + const currentAttachments = attachmentsRef.current; + const target = currentAttachments.find((attachment) => attachment.id === targetAttachmentId); + if (!target) throw new Error("要替换的参考图不存在"); + if (creationAttachmentKind(target) !== "image" || creationAttachmentKind(replacement) !== "image") throw new Error("目前只支持替换提示词中的图片引用"); + if (target.id === replacement.id) return false; + + const result = replaceCreationAttachmentReference(promptRef.current, currentAttachments, targetAttachmentId, replacement); + promptRef.current = result.prompt; + attachmentsRef.current = result.attachments; + setPrompt(result.prompt); + setAttachments(result.attachments); + return true; + }, []); + + const replaceReferenceFromTrack = useCallback((targetAttachmentId: string, replacement: CreationAttachment) => { + try { + if (replaceAttachmentReference(targetAttachmentId, replacement)) toast.success("参考图已替换,槽位不变,提示词无需修改"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "参考图替换失败"); + } + }, [replaceAttachmentReference, toast]); + + const replaceReferenceFromFiles = useCallback(async (targetAttachmentId: string, files: File[]) => { + if (busy || referenceReplacementBusy) return; + const file = files.find((item) => item.type.startsWith("image/")); + if (!file) { + toast.warning("请拖入图片文件进行替换"); + return; + } + setReferenceReplacementBusy(true); + try { + const { asset, attachment } = await uploadCreationAsset(file); + if (creationAttachmentKind(attachment) !== "image") throw new Error("上传结果不是可用图片"); + if (asset) addAsset(asset); + if (replaceAttachmentReference(targetAttachmentId, attachment)) toast.success("参考图已替换,槽位不变,提示词无需修改"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "参考图上传或替换失败"); + } finally { + setReferenceReplacementBusy(false); + } + }, [addAsset, busy, referenceReplacementBusy, replaceAttachmentReference, toast]); + const submit = async (retryContext?: CreationRetryContext, retryLockKey?: string) => { const releaseRetryLock = () => { if (retryLockKey) retryPreparingRef.current.delete(retryLockKey); @@ -830,12 +879,15 @@ export default function CreatePage() { prompt, setPrompt, busy, + referenceReplacementBusy, attachments, referenceImageSize, maxReferences, references: mentionReferences, onRemoveAttachment: removeAttachment, onReorderAttachments: reorderAttachments, + onReplaceAttachment: replaceReferenceFromTrack, + onReplaceReferenceFiles: replaceReferenceFromFiles, onOpenLibrary: () => setLibraryOpen(true), fileInputRef, onFileChange: handleFileChange, @@ -1084,7 +1136,7 @@ function CreationAttachmentThumbnail({ item, onPreview, onRemove }: { {kind === "video" ?
; } @@ -1094,12 +1146,15 @@ type ComposerProps = { prompt: string; setPrompt: (value: string) => void; busy: boolean; + referenceReplacementBusy: boolean; attachments: CreationAttachment[]; referenceImageSize?: { width: number; height: number }; maxReferences: number; references: CreationReference[]; onRemoveAttachment: (id: string) => void; onReorderAttachments: (attachments: CreationAttachment[]) => void; + onReplaceAttachment: (targetAttachmentId: string, replacement: CreationAttachment) => void; + onReplaceReferenceFiles: (targetAttachmentId: string, files: File[]) => void; onOpenLibrary: () => void; fileInputRef: RefObject; onFileChange: (event: ChangeEvent) => void; @@ -1130,11 +1185,13 @@ function CreationComposer(props: ComposerProps) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false); + const [dropTargetReferenceId, setDropTargetReferenceId] = useState(null); const attachmentTrackRef = useRef(null); const cardDragRef = useRef<{ startX: number; startY: number; moved: boolean } | null>(null); const suppressAttachmentClickRef = useRef(false); const [trackState, setTrackState] = useState({ canScrollLeft: false, canScrollRight: false, isExpanded: false, isDragging: false }); - const canSubmit = Boolean(props.prompt.trim()) && !props.busy; + const interactionBusy = props.busy || props.referenceReplacementBusy; + const canSubmit = Boolean(props.prompt.trim()) && !interactionBusy; const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const priceChannel = resolveModelChannel(props.config, props.model); const canOptimizePrompt = Boolean(props.promptOptimizerProvider) && (props.mode === "image" || props.mode === "video"); @@ -1148,7 +1205,7 @@ function CreationComposer(props: ComposerProps) { }); const showCost = creditsEnabled && credits !== null; const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 }); - const actionLabel = props.busy ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送"; + const actionLabel = props.referenceReplacementBusy ? "正在替换参考图" : props.busy ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送"; const placeholder = props.mode === "text" ? "描述你的故事、角色或想继续讨论的创意" : props.mode === "image" @@ -1229,11 +1286,20 @@ function CreationComposer(props: ComposerProps) { track.scrollBy({ left: direction * Math.max(track.clientWidth * 0.72, 120), behavior: "smooth" }); window.setTimeout(updateTrackScrollState, 180); }; + const imageReferenceAtPoint = (x: number, y: number) => { + for (const element of document.elementsFromPoint(x, y)) { + const chip = element.closest("[data-mention-reference-id]"); + const referenceId = chip?.dataset.mentionReferenceId; + const reference = referenceId ? props.references.find((item) => item.id === referenceId) : undefined; + if (reference?.kind === "image" && reference.attachmentId) return reference; + } + return undefined; + }; const composer =
- + { const target = props.references.find((item) => item.id === reference.id); if (target?.attachmentId) props.onReplaceReferenceFiles(target.attachmentId, files); }} /> {props.attachments.length || canAddMoreReferences ?
{trackState.canScrollLeft ? : null} @@ -1253,18 +1319,28 @@ function CreationComposer(props: ComposerProps) { key={item.id} value={item} layout="position" - drag={!props.busy} + drag={!interactionBusy} className="creation-reference-stack-card" onPointerDown={beginCardDrag} onPointerMove={moveCardDrag} onPointerUp={endCardDrag} onPointerCancel={endCardDrag} - onDragStart={() => setTrackState((current) => ({ ...current, isDragging: true, isExpanded: true }))} - onDragEnd={() => setTrackState((current) => ({ ...current, isDragging: false, isExpanded: true }))} + onDragStart={() => { setDropTargetReferenceId(null); setTrackState((current) => ({ ...current, isDragging: true, isExpanded: true })); }} + onDrag={(_, info) => { + if (creationAttachmentKind(item) !== "image") return; + const target = imageReferenceAtPoint(info.point.x, info.point.y); + setDropTargetReferenceId(target?.attachmentId !== item.id ? target?.id || null : null); + }} + onDragEnd={(_, info) => { + const target = creationAttachmentKind(item) === "image" ? imageReferenceAtPoint(info.point.x, info.point.y) : undefined; + setDropTargetReferenceId(null); + setTrackState((current) => ({ ...current, isDragging: false, isExpanded: true })); + if (target?.attachmentId && target.attachmentId !== item.id) props.onReplaceAttachment(target.attachmentId, item); + }} > )} - {canAddMoreReferences ?
  • : null} + {canAddMoreReferences ?
  • : null} {trackState.canScrollRight ? : null}
    @@ -1274,7 +1350,7 @@ function CreationComposer(props: ComposerProps) {
    props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)} /> @@ -1291,8 +1367,8 @@ function CreationComposer(props: ComposerProps) { 优化 : null} - - + + {props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? : null} {props.mode === "video" ? : null} @@ -1300,18 +1376,18 @@ function CreationComposer(props: ComposerProps) {
    setPreviewUrl("")} /> diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 4907a564a..9eb8c6998 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -945,7 +945,7 @@ --workspace-border-strong: rgba(17, 17, 17, 0.2); --workspace-grid-line: rgba(17, 17, 17, 0.035); --workspace-nav: var(--workspace-navigation); - --workspace-sidebar: color-mix(in srgb, var(--workspace-surface) 55%, var(--workspace-navigation)); + --workspace-sidebar: var(--workspace-navigation); --workspace-accent: #171717; --workspace-accent-soft: rgba(17, 17, 17, 0.065); background-color: var(--workspace-canvas); @@ -966,7 +966,7 @@ --workspace-border-strong: rgba(255, 255, 255, 0.18); --workspace-grid-line: rgba(255, 255, 255, 0.026); --workspace-nav: var(--workspace-navigation); - --workspace-sidebar: color-mix(in srgb, var(--workspace-surface) 55%, var(--workspace-navigation)); + --workspace-sidebar: var(--workspace-navigation); --workspace-accent: #f5f5f5; --workspace-accent-soft: rgba(255, 255, 255, 0.08); } @@ -8849,6 +8849,7 @@ } .canvas-resource-inline-mention { + position: relative; display: inline-flex; min-width: 0; max-width: 100%; @@ -8869,6 +8870,26 @@ transform: translateY(var(--canvas-mention-chip-offset-y)); } + .canvas-resource-inline-mention.is-replace-target { + outline: var(--stroke-2) solid var(--control-focus-ring); + outline-offset: var(--space-half); + background: color-mix(in srgb, var(--control-focus-ring) 18%, var(--canvas-composer-control-surface)); + } + + .canvas-resource-inline-mention.is-replace-target::after { + content: "替换"; + position: absolute; + inset: 0; + z-index: 1; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--color-neutral-950) 76%, transparent); + color: var(--color-neutral-0); + font-size: var(--fs-micro); + font-weight: 600; + letter-spacing: 0.04em; + } + .canvas-resource-inline-at, .canvas-resource-inline-label { flex: 0 0 auto; @@ -11457,8 +11478,8 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background: .admin-model-editor-section { display: grid; - grid-template-columns: minmax(140px, 176px) minmax(0, 1fr); - gap: 24px; + grid-template-columns: minmax(0, 1fr); + gap: 16px; padding: 20px 22px; } @@ -11472,6 +11493,11 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background: min-width: 0; } +.admin-model-editor-section-heading { + padding-bottom: 12px; + border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); +} + .admin-model-editor-section-heading h2 { color: var(--foreground); font-size: 15px; @@ -11714,7 +11740,7 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background: .admin-price-tier-card-body { display: grid; - grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr); + grid-template-columns: minmax(0, 1fr); } .admin-price-tier-block { @@ -11722,7 +11748,7 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background: } .admin-price-tier-block + .admin-price-tier-block { - border-left: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); + border-top: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); } .admin-price-tier-block-title { @@ -11991,19 +12017,6 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background: grid-template-columns: minmax(0, 1fr); } - .admin-model-editor-section { - gap: 16px; - } - - .admin-model-editor-section-heading { - padding-bottom: 12px; - border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); - } - - .admin-price-tier-block + .admin-price-tier-block { - border-top: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); - border-left: 0; - } } /* 管理后台可读性基线:收敛历史页面中过低的文字透明度,并统一表单层级。 */ diff --git a/web/test/canvas-node-generation-mentions.test.ts b/web/test/canvas-node-generation-mentions.test.ts new file mode 100644 index 000000000..d829dde94 --- /dev/null +++ b/web/test/canvas-node-generation-mentions.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; + +import { buildNodeGenerationContext } from "../src/components/canvas/canvas-node-generation"; +import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas"; + +function node(id: string, type: CanvasNodeType, content: string): CanvasNodeData { + return { + id, + type, + title: id, + position: { x: 0, y: 0 }, + width: 100, + height: 100, + metadata: { content }, + }; +} + +function targetNode(): CanvasNodeData { + return { + id: "target", + type: CanvasNodeType.Video, + title: "target", + position: { x: 0, y: 0 }, + width: 100, + height: 100, + metadata: { composerContent: "让 @图片1 进入画面" }, + }; +} + +function connection(fromNodeId: string): CanvasConnection { + return { id: `connection-${fromNodeId}`, fromNodeId, toNodeId: "target" }; +} + +describe("canvas node generation position mentions", () => { + test("同一个 @图片1 在换线后自动指向新的第一张图片", () => { + const target = targetNode(); + const imageA = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a"); + const imageB = node("image-b", CanvasNodeType.Image, "data:image/png;base64,b"); + + const before = buildNodeGenerationContext(target.id, [imageA, target], [connection(imageA.id)], "让 @图片1 进入画面", []); + const after = buildNodeGenerationContext(target.id, [imageB, target], [connection(imageB.id)], "让 @图片1 进入画面", []); + + expect(before.referenceImages.map((image) => image.id)).toEqual(["image-a"]); + expect(after.referenceImages.map((image) => image.id)).toEqual(["image-b"]); + expect(before.prompt).toBe("让 图片1 进入画面"); + expect(after.prompt).toBe("让 图片1 进入画面"); + }); + + test("按类型位置选择资源,提示词出现顺序不会改变槽位含义", () => { + const target = targetNode(); + const imageA = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a"); + const audioA = node("audio-a", CanvasNodeType.Audio, "data:audio/mpeg;base64,a"); + const imageB = node("image-b", CanvasNodeType.Image, "data:image/png;base64,b"); + const connections = [connection(imageA.id), connection(audioA.id), connection(imageB.id)]; + const context = buildNodeGenerationContext(target.id, [imageA, audioA, imageB, target], connections, "让 @图片2 配合 @音频1", []); + + expect(context.referenceImages.map((image) => image.id)).toEqual(["image-b"]); + expect(context.referenceAudios.map((audio) => audio.id)).toEqual(["audio-a"]); + expect(context.prompt).toBe("让 图片1 配合 音频1"); + }); + + test("旧节点 token 只做读取迁移,不再进入生成提示词", () => { + const target = targetNode(); + const image = node("image-a", CanvasNodeType.Image, "data:image/png;base64,a"); + const context = buildNodeGenerationContext(target.id, [image, target], [connection(image.id)], "让 @[node:image-a] 进入画面", []); + + expect(context.referenceImages.map((item) => item.id)).toEqual(["image-a"]); + expect(context.prompt).toBe("让 图片1 进入画面"); + expect(context.prompt).not.toContain("@[node:"); + }); +}); diff --git a/web/test/canvas-resource-mention-editor.test.ts b/web/test/canvas-resource-mention-editor.test.ts index 4ac21bc7c..07e32671a 100644 --- a/web/test/canvas-resource-mention-editor.test.ts +++ b/web/test/canvas-resource-mention-editor.test.ts @@ -26,4 +26,31 @@ describe("canvas resource mention editor", () => { expect(previewRule).toContain("flex: 0 0 var(--canvas-mention-chip-preview-size)"); expect(previewRule).toContain("object-fit: cover"); }); + + test("exposes inline image references as replacement drop targets", () => { + const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx"); + const css = source("../src/styles/globals.css"); + + expect(component).toContain("activeDropReferenceId?: string | null"); + expect(component).toContain("onReferenceFilesDrop?:"); + expect(component).toContain("chip.dataset.mentionReferenceId = reference.id"); + expect(component).toContain('chip.classList.toggle("is-replace-target"'); + expect(component).toContain("onReferenceFilesDrop(reference, files)"); + expect(css).toContain(".canvas-resource-inline-mention.is-replace-target"); + expect(css).toContain('content: "替换"'); + }); + + test("resolves storage-backed previews and renders a visible loading spinner", () => { + const editor = source("../src/components/canvas/canvas-resource-mention-textarea.tsx"); + const panel = source("../src/components/canvas/canvas-node-prompt-panel.tsx"); + const configComposer = source("../src/components/canvas/canvas-config-composer.tsx"); + const project = source("../src/pages/canvas/project.tsx"); + + expect(editor).toContain("useResolvedCanvasResourceReferences"); + expect(panel).toContain(" { expect(collectUpstreamVideoNodes("a", nodes, connections).length).toBe(2); }); }); + +describe("canvas resource mention slots", () => { + test("画布节点引用只保存类型位置,不保存节点 ID", () => { + const target = videoNode("target"); + const image = imageNode("image-a"); + const [reference] = buildNodeMentionReferences(target, [image, target], [connection(image.id, target.id)]); + + expect(reference.label).toBe("图片1"); + expect(canvasResourceMentionToken(reference)).toBe("@图片1"); + expect(canvasResourceMentionToken(reference)).not.toContain(image.id); + }); + + test("图片、音频和文本分别按各自类型顺序编号", () => { + const target = videoNode("target"); + const nodes = [imageNode("image-a"), audioNode("audio-a"), imageNode("image-b"), textNode("text-a"), target]; + const connections = nodes.slice(0, -1).map((node) => connection(node.id, target.id)); + + expect(buildNodeMentionReferences(target, nodes, connections).map((reference) => reference.label)).toEqual(["图片1", "音频1", "图片2", "文本1"]); + }); + + test("素材库身份 token 保持稳定", () => { + expect(canvasResourceMentionToken({ + id: "asset:asset-a", + nodeId: "", + assetId: "asset-a", + kind: "image", + label: "场景图", + title: "场景图", + active: false, + })).toBe("@[asset:asset-a]"); + }); +}); diff --git a/web/test/canvas-storyboard-assets.test.ts b/web/test/canvas-storyboard-assets.test.ts index a55c0562c..3ba5a747b 100644 --- a/web/test/canvas-storyboard-assets.test.ts +++ b/web/test/canvas-storyboard-assets.test.ts @@ -67,10 +67,10 @@ describe("storyboard target materializer", () => { const nodes = [script, node("project-style", CanvasNodeType.Image, { content: "style" }), node("prop", CanvasNodeType.Image, { content: "prop" }), node("character", CanvasNodeType.Image, { workflowKind: "character", characterAssetId: "character-asset" }), node("manual", CanvasNodeType.Video, { content: "video" }), node("direct-manual", CanvasNodeType.Audio, { content: "audio" }), node("first-frame", CanvasNodeType.Image, { content: "frame", workflowKind: "shot" }), node("target", CanvasNodeType.Video)]; const connections: CanvasConnection[] = [{ id: "manual-row-input", fromNodeId: "manual", toNodeId: "script", toHandleId: "row:row-1" }]; - it("combines stable bindings and produces standard node mention tokens", () => { + it("combines stable bindings and produces position mention tokens", () => { const references = storyboardRowReferenceNodeIds(script, row, nodes, connections, true); expect(references).toEqual(["project-style", "prop", "character", "manual", "first-frame"]); - expect(storyboardComposerContent("快速跟拍", references)).toBe("参考资产:@[node:project-style] @[node:prop] @[node:character] @[node:manual] @[node:first-frame]\n快速跟拍"); + expect(storyboardComposerContent("快速跟拍", references, nodes)).toBe("参考资产:@图片1 @图片2 @角色1 @视频1 @图片3\n快速跟拍"); const withDirectManualInput = storyboardRowReferenceNodeIds(script, row, nodes, [...connections, { id: "direct", fromNodeId: "direct-manual", toNodeId: "target" }], false, "target"); expect(withDirectManualInput).toContain("direct-manual"); diff --git a/web/test/create-library-button.test.ts b/web/test/create-library-button.test.ts index 35b61a1ce..735afe47d 100644 --- a/web/test/create-library-button.test.ts +++ b/web/test/create-library-button.test.ts @@ -23,7 +23,7 @@ describe("creation library button", () => { expect(attachmentIndex).toBeGreaterThan(modePickerIndex); expect(libraryIndex).toBeGreaterThan(attachmentIndex); expect(dockSource).toContain("onClick={props.onOpenLibrary}"); - expect(dockSource).toContain("disabled={props.busy || !referencesSupported}"); + expect(dockSource).toContain("disabled={interactionBusy || !referencesSupported}"); }); test("uploads from the library without adding a reference before confirmation", () => { @@ -63,6 +63,7 @@ describe("creation library button", () => { expect(source).toContain('axis="x"'); expect(source).toContain("values={props.attachments}"); expect(source).toContain("onReorder={props.onReorderAttachments}"); + expect(source).toContain('className="creation-reference-card-remove" onPointerDown={(event) => event.stopPropagation()}'); expect(source).toContain(" { expect(source).toContain("creation-reference-stack-card"); expect(source).toContain("creation-reference-add-button"); expect(source).toContain("creation-reference-track-button"); + expect(source).toContain("imageReferenceAtPoint"); + expect(source).toContain("setDropTargetReferenceId"); + expect(source).toContain("props.onReplaceAttachment(target.attachmentId, item)"); + expect(source).toContain("onReferenceFilesDrop="); expect(source).toContain("CanvasPromptOptimizerDrawer"); expect(source).toContain("promptOptimizerOpen"); expect(source).toContain("provider={props.promptOptimizerProvider}"); diff --git a/web/test/create-sidebar-theme.test.ts b/web/test/create-sidebar-theme.test.ts index 27b068d5f..4b7b2408c 100644 --- a/web/test/create-sidebar-theme.test.ts +++ b/web/test/create-sidebar-theme.test.ts @@ -9,5 +9,6 @@ describe("create page sidebar theme", () => { expect(styles).not.toContain(".app-workspace-shell.is-creation-workspace .app-workspace-sidebar"); expect(styles).toContain(".app-workspace-shell.is-creation-workspace .app-workspace-stage"); expect(styles).toContain("background: color-mix(in srgb, var(--workspace-sidebar) 96%, var(--foreground) 4%);"); + expect(styles.match(/--workspace-sidebar: var\(--workspace-navigation\);/g)?.length).toBeGreaterThanOrEqual(4); }); }); diff --git a/web/test/creation-references.test.ts b/web/test/creation-references.test.ts index a5ae1ab08..a5ad7d25d 100644 --- a/web/test/creation-references.test.ts +++ b/web/test/creation-references.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { canvasResourceMentionToken } from "../src/lib/canvas/canvas-resource-references"; import { creationAttachmentKind, creationFileAccepted, creationMediaAspectRatio, creationUploadAccept, type CreationAttachment } from "../src/pages/create/creation-assets"; -import { buildCreationMentionReferences, reconcileCreationAttachmentLimit, removeCreationReferenceTokens } from "../src/pages/create/creation-references"; +import { buildCreationMentionReferences, displayCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences } from "../src/pages/create/creation-references"; function imageAttachment(id: string): CreationAttachment { return { @@ -53,6 +53,13 @@ describe("creation references", () => { expect(reference.label).toBe("文件1"); }); + test("不同附件类型分别按自己的引入顺序编号", () => { + const audio: CreationAttachment = { id: "audio", name: "audio.mp3", type: "audio/mpeg", url: "blob:audio", storageKey: "audio:key", previewUrl: "" }; + const references = buildCreationMentionReferences([], [imageAttachment("first"), audio, imageAttachment("second")]); + + expect(references.map((reference) => reference.label)).toEqual(["图片1", "音频1", "图片2"]); + }); + test("媒体占位按本次选择的画幅展示并为异常值提供模式回退", () => { expect(creationMediaAspectRatio("16:9", "video")).toBe("16 / 9"); expect(creationMediaAspectRatio("1:1", "image")).toBe("1 / 1"); @@ -60,4 +67,53 @@ describe("creation references", () => { expect(creationMediaAspectRatio("auto", "video")).toBe("16 / 9"); expect(creationMediaAspectRatio("auto", "image")).toBe("1 / 1"); }); + + test("替换图片时保留目标位置且提示词无需修改", () => { + const attachments = [imageAttachment("first"), imageAttachment("second"), imageAttachment("third")]; + const references = buildCreationMentionReferences([], attachments); + const oldToken = canvasResourceMentionToken(references[1]); + const replacement = imageAttachment("replacement"); + + const result = replaceCreationAttachmentReference(`让 ${oldToken} 靠近 ${oldToken}`, attachments, "second", replacement); + const nextReferences = buildCreationMentionReferences([], result.attachments); + const replacementReference = nextReferences.find((reference) => reference.attachmentId === replacement.id)!; + const replacementToken = canvasResourceMentionToken(replacementReference); + + expect(result.attachments.map((attachment) => attachment.id)).toEqual(["first", "replacement", "third"]); + expect(replacementReference.label).toBe("图片2"); + expect(result.prompt).toBe(`让 ${replacementToken} 靠近 ${replacementToken}`); + expect(result.prompt).not.toContain(oldToken); + expect(displayCreationPrompt(result.prompt, nextReferences)).toBe("让 @图片2 靠近 @图片2"); + }); + + test("删除中间附件后只移除该附件引用,后续附件仍按稳定身份保留", () => { + const attachments = ["first", "second", "third", "fourth", "fifth"].map(imageAttachment); + const references = buildCreationMentionReferences([], attachments); + const third = references[2]; + const fifth = references[4]; + const prompt = `删除前比较 ${canvasResourceMentionToken(third)} 和 ${canvasResourceMentionToken(fifth)}`; + const remaining = attachments.filter((attachment) => attachment.id !== "third"); + const nextPrompt = removeCreationReferenceTokens(prompt, [third]); + const nextReferences = buildCreationMentionReferences([], remaining); + + expect(nextPrompt).not.toContain(canvasResourceMentionToken(third)); + expect(nextPrompt).toContain(canvasResourceMentionToken(fifth)); + expect(selectedCreationReferences(nextPrompt, nextReferences).map((reference) => reference.attachmentId)).toEqual(["fifth"]); + expect(displayCreationPrompt(nextPrompt, nextReferences)).toBe("删除前比较 和 @图片4"); + }); + + test("替换会归一化可见标签并去除已存在的重复附件", () => { + const attachments = [imageAttachment("source"), imageAttachment("target"), imageAttachment("third")]; + const result = replaceCreationAttachmentReference("让 @图片2 参考 @图片2。", attachments, "target", attachments[0]); + const references = buildCreationMentionReferences([], result.attachments); + const sourceReference = references.find((reference) => reference.attachmentId === "source")!; + + expect(result.attachments.map((attachment) => attachment.id)).toEqual(["third", "source"]); + expect(sourceReference.label).toBe("图片2"); + expect(result.prompt).toBe(`让 ${canvasResourceMentionToken(sourceReference)} 参考 ${canvasResourceMentionToken(sourceReference)}。`); + }); + + test("目标附件不存在时拒绝替换", () => { + expect(() => replaceCreationAttachmentReference("", [imageAttachment("first")], "missing", imageAttachment("next"))).toThrow("要替换的参考内容不存在"); + }); });