diff --git a/AGENTS.md b/AGENTS.md index 841bb52..ea97014 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,10 @@ # 模型发现输出约定 -- 模型查询详情应提供可直接用于生成命令的参数值;数字枚举按对应 IDL 显式转换,不按数组位置或展示文案推断。 +- 模型查询详情应提供可直接用于生成命令的参数值;图片 `ratio` 只接受服务端数字枚举,查询的 `options/default` 同样使用数字,并用 `option_labels` 展示比例含义;视频 `ratio` 保持其接口要求的比例字符串。不提供数字和比例字符串两套图片输入,映射不按数组位置或展示文案推断。 - 覆盖 IDL 已定义且生成入口可消费的枚举;未知枚举直接跳过,不猜测或回退到其他值。已定义但没有 CLI 请求表达方式的枚举须明确识别为不可用。 -- 内部配置指纹不向用户输出;原始配置缓存和用户展示结构分离,保留可选字段缺失与零值的区别。 +- 内部配置指纹不向用户输出;原始配置缓存和用户展示结构分离,保留可选字段缺失与零值的区别。参数通用元数据(`label`、`description`、`required_field`、`active_when_any`)统一保留,只在确有契约差异时按模型类型分支。 - 模型级 `is_default` 不向 CLI 用户输出;服务端默认标记不等于用户授权代选模型。参数级默认值继续展示,原始配置缓存保持完整。 +- API 模型查询保留上游 `name` 展示名称和 `key` 模型枚举,白名单只控制准入,不用枚举覆盖名称。CLI 图片模型列表、搜索、详情、帮助和示例只向宿主展示 `name`,详情不透传含枚举的计费等内部配置;查询和生成使用完整名称,仅提交时从原始配置解析 key。不维护静态名称映射,不按简称猜模型;名称缺失、与 key 相同、重复或未找到时明确失败。视频保持 key 契约。 - 模型发现验收必须分别核对用户确认的预期模型集合与真实接口返回值转换;CLI 与接口同时少返回模型不能判为完整通过。请求携带 PPE 头也不能替代实际路由和生效配置证据。 - `Seedance_2.0_mini` 和 `Seedance_2.0_mini_lite` 允许省略生成分辨率,由服务端默认 `720p`;CLI 不主动补值,不因模型查询缺少分辨率维度而将该参数标为必填或伪造配置。 diff --git a/README.md b/README.md index 3456694..29abc38 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ node skills/xyq-marketing-skill/scripts/marketing.js query --thread-id THREAD_ID | 登录授权 | `status` / `login` / `logout` | [授权](skills/xyq-nest-skill/commands/auth.md) | | 个人 Canvas 画布与节点编辑 | `canvas` | [画布](skills/xyq-nest-skill/commands/canvas.md) | | 生图、参考图编辑 | `generate-image` | [图片](skills/xyq-nest-skill/commands/generate-image.md) | -| 查看可用视频模型、参数配置 | `model list` / `model describe` | [模型发现](skills/xyq-nest-skill/commands/model.md) | +| 查看可用图片/视频模型、参数配置 | `model list` / `model describe` | [模型发现](skills/xyq-nest-skill/commands/model.md) | | 生视频、首尾帧 | `generate-video` | [视频](skills/xyq-nest-skill/commands/generate-video.md) | | 视频超分 | `video-super-resolution` | [超分](skills/xyq-nest-skill/commands/video-super-resolution.md) | | 擦字幕 | `erase-video-subtitle` | [擦字幕](skills/xyq-nest-skill/commands/erase-video-subtitle.md) | @@ -205,20 +205,22 @@ pippit-tool-cli canvas command run xyq.generation.update_prompt \ ## 生图 CLI -`generate-image` 会上传本地参考图片,然后向综合 Nest Agent 提交生图请求: +`generate-image` 使用图片模型的展示名称;先查询当前列表并选择模型,再上传本地参考图片并提交生图请求。以下假设列表包含该名称: ```bash pippit-tool-cli generate-image \ --prompt "生成一张小猫海报" \ --image "~/images/cat.png" \ - --model "seedream_4.5" \ + --model "智能图片V2.5 Fast" \ --ratio 6 \ --generate-image-count 2 ``` -命令输出 `thread_id`、`run_id` 和 `web_thread_link`。提交 HTTP 请求时,`agent_name` 固定为 `pippit_nest_agent`,参考图会使用上传接口返回的 `pippit_asset_id` 写入顶层 `asset_ids`,生图模型写入 `general_agent_settings.image_model`,比例写入 `general_agent_settings.ratio`,生图数量写入 `general_agent_settings.generate_image_count`。`--model` 为必填参数,CLI 只做非空校验,具体模型值是否可用由服务端决定。 +命令输出 `thread_id`、`run_id` 和 `web_thread_link`。提交 HTTP 请求时,`agent_name` 固定为 `pippit_nest_agent`,参考图会使用上传接口返回的 `pippit_asset_id` 写入顶层 `asset_ids`,生图模型写入 `general_agent_settings.image_model`,比例写入 `general_agent_settings.ratio`,生图数量写入 `general_agent_settings.generate_image_count`。`--model` 为必填参数,填写 `model list --type image` 返回的完整名称,例如 `"智能图片V2.5 Fast"`。CLI 在上传素材前查询或复用有效缓存,解析名称后仅在提交请求中填写服务端模型标识;名称缺失、重复或未找到时停止,不猜测模型。 -`--ratio` 可选,填写服务端 `Ratio` 枚举值。CLI 只做整数格式解析,不检查枚举值是否在下表范围内;具体值是否可用由服务端决定。常用枚举值含义如下: +图片 `model list/search/describe` 返回给宿主的是 API 下发的展示名称 `name`,例如 `美学模型 8.2`。生成时填写 `--model "美学模型 8.2"`,CLI 仅在提交请求中使用该条目对应的 `key`。名称、可用模型和参数均由接口动态提供,不维护静态名称映射。 + +`--ratio` 可选,只接受服务端 `Ratio` 数字枚举,例如 `--ratio 3` 表示 `9:16`。通过 `model describe "模型名称" --type image` 查看当前可用的数字 `options/default`,`option_labels` 说明每个数字对应的比例。CLI 校验整数格式,模型是否支持该枚举由服务端决定。常用枚举值含义如下: | ratio 参数 | IDL 枚举 | 含义 | | ---: | --- | --- | @@ -232,18 +234,27 @@ pippit-tool-cli generate-image \ `--generate-image-count` 可选,填写生图数量,对应 IDL 字段 `GeneralSettingsPart.GenerateImageCount` / JSON 字段 `generate_image_count`。CLI 只校验不能为负数;具体数量范围由服务端决定。 -图片支持 `.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.webp`、`.svg`。CLI 会在提交前校验 prompt、model 必填、ratio 整数格式、generate-image-count 非负和文件后缀。 +`--resolution` 写入 `general_agent_settings.resolution`(转大写),新增 `--effort` 写入 `general_agent_settings.image_effort`(转小写)。分辨率和推理强度选项都来自该模型的动态配置;没有 `effort` 维度的模型不展示推理强度选择。只传用户指定的参数,未指定时省略,不自动补查询默认值。 + +图片支持 `.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.webp`、`.svg`。CLI 会在提交前校验 prompt、model 必填、ratio 格式、generate-image-count 非负和文件后缀;不新增模型或参数组合白名单。 -## 视频模型发现 +## 图片与视频模型发现 ```bash +# 图片模型及可选参数 +pippit-tool-cli model list --type image +pippit-tool-cli model search "智能图片" --type image +pippit-tool-cli model describe "智能图片V2.5 Fast" --type image +pippit-tool-cli model list --type image --refresh + +# 省略 --type 保持查询视频 pippit-tool-cli model list pippit-tool-cli model search MiniMax pippit-tool-cli model describe MiniMax-H3 pippit-tool-cli model list --refresh ``` -使用当前登录凭证查询服务端;成功结果按账号和环境隔离缓存 5 分钟,`--refresh` 强制刷新。失败时提示重试,不回退静态列表或过期缓存。`describe` 将比例枚举转换为可直接传给 `--ratio` 的字符串,并整理分辨率、时长和素材限制;不展示内部 `config_key`,未知比例枚举跳过。生成仍由服务端校验。详见 [模型发现](skills/xyq-nest-skill/commands/model.md)。 +使用当前个人登录凭证查询服务端 Skill 模型接口;图片 scene 为 `web_image_agent`,视频为 `web_turbo_video_generator`,不传 TeamID。成功结果按账号、环境和场景隔离缓存 5 分钟,`--refresh` 强制刷新。失败时提示重试,不回退静态列表或过期缓存。`describe` 提供可直接传给生成命令的参数:图片比例保留数字枚举并附比例说明,视频比例转换为字符串,同时整理分辨率、图片推理强度、视频时长和素材限制;不展示内部 `config_key`,未知比例枚举跳过。图片列表和详情仅用 `name` 标识模型,不展示底层模型枚举;搜索、详情查询和 `generate-image --model` 均使用名称,包含空格时加引号。图片详情保留原始 `parameter_config`,包括条件、必选标记、选项说明和参数组合约束。生成仍由服务端校验;服务端尚未开放图片场景或提交参数时会返回错误,不回退静态模型清单。详见 [模型发现](skills/xyq-nest-skill/commands/model.md)。 ## 生视频 CLI diff --git a/cmd/generate_image/generate_image.go b/cmd/generate_image/generate_image.go index 5739627..2ff9793 100644 --- a/cmd/generate_image/generate_image.go +++ b/cmd/generate_image/generate_image.go @@ -34,6 +34,7 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command "model": strings.TrimSpace(opts.Model), "ratio": strings.TrimSpace(opts.Ratio), "resolution": strings.ToUpper(strings.TrimSpace(opts.Resolution)), + "effort": strings.ToLower(strings.TrimSpace(opts.Effort)), "generate_image_count": optionalIntString(opts.GenerateImageCount), }) return err @@ -47,9 +48,10 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command flags.StringVar(&opts.Source, "source", "", "optional host agent/platform identifier for statistics only; filled silently by the host agent (e.g. doubao_office, workbuddy, codex)") flags.StringVar(&opts.Prompt, "prompt", "", "image generation prompt") flags.StringArrayVar(&opts.ImagePaths, "image", nil, "local reference image path; repeat for multiple images") - flags.StringVar(&opts.Model, "model", "", "image model; supported: seedream_5.0_pro, seedream_5.0, seedream_4.3, nova2, seedream_4.5, seedream_4.1, seedream_4") + flags.StringVar(&opts.Model, "model", "", "exact image model name from 'model list --type image' (quote names containing spaces)") flags.StringVar(&opts.Ratio, "ratio", "", "image ratio; "+internalgen.SupportedRatioUsage()) - flags.StringVar(&opts.Resolution, "resolution", "", "image resolution; only seedream_5.0_pro supports these options: 1K, 2K, 4K") + flags.StringVar(&opts.Resolution, "resolution", "", "image resolution; use 'model describe \"MODEL_NAME\" --type image' for available values") + flags.StringVar(&opts.Effort, "effort", "", "image reasoning effort; only use values exposed by 'model describe \"MODEL_NAME\" --type image'") flags.IntVar(&generateImageCount, "generate-image-count", 0, "generated image count") return cmd } diff --git a/cmd/generate_image_test.go b/cmd/generate_image_test.go index aaae79a..c5cd7a2 100644 --- a/cmd/generate_image_test.go +++ b/cmd/generate_image_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -10,18 +11,29 @@ import ( "strings" "testing" + "github.com/Pippit-dev/pippit-cli/internal/config" "github.com/bytedance/sonic" ) func TestGenerateImage(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("LocalAppData", t.TempDir()) var uploaded bool + var modelQueried bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer test-token" { t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization")) } switch r.URL.Path { + case "/api/biz/v1/skill/get_available_model_list": + modelQueried = true + _, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-key","config":{"models":[{"key":"seedream_5.0_pro","name":"Seedream 5.0 Pro","kind":"image"}]}}}`)) case "/api/biz/v1/skill/upload_file": + if !modelQueried { + t.Fatal("image name must be resolved before uploading") + } if r.Method != http.MethodPost { t.Fatalf("upload method = %s, want POST", r.Method) } @@ -100,7 +112,7 @@ func TestGenerateImage(t *testing.T) { "generate-image", "--prompt", "生成小猫海报", "--image", image, - "--model", "seedream_5.0_pro", + "--model", "Seedream 5.0 Pro", "--ratio", "6", "--resolution", "4K", "--generate-image-count", "2", @@ -137,3 +149,140 @@ func TestGenerateImageRequiresModel(t *testing.T) { t.Fatalf("error = %q, want model validation", err) } } + +func TestGenerateImageDynamicParameters(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("LocalAppData", t.TempDir()) + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.URL.Path == "/api/biz/v1/skill/get_available_model_list" { + _, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-key","config":{"models":[{"key":"future-image-model","name":"未来图片模型","kind":"image"}]}}}`)) + return + } + if r.URL.Path != "/api/biz/v1/skill/submit_run" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + return + } + var body struct { + AgentName string `json:"agent_name"` + Settings struct { + Model string `json:"image_model"` + Ratio int `json:"ratio"` + Resolution string `json:"resolution"` + Effort string `json:"image_effort"` + } `json:"general_agent_settings"` + } + if err := sonic.Unmarshal(raw, &body); err != nil { + t.Error(err) + return + } + if body.AgentName != "pippit_nest_agent" || body.Settings.Model != "future-image-model" || body.Settings.Ratio != 13 || body.Settings.Resolution != "8K" || body.Settings.Effort != "future-effort" { + t.Errorf("incorrect request mapping: %s", raw) + } + _, _ = w.Write([]byte(`{"ret":"0","data":{"run":{"thread_id":"image-thread","run_id":"image-run"}}}`)) + })) + defer server.Close() + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs([]string{"generate-image", "--prompt", "image", "--model", "未来图片模型", "--ratio", "13", "--resolution", "8k", "--effort", " FUTURE-EFFORT "}) + if err := root.Execute(); err != nil || requests != 2 { + t.Fatalf("Execute: err=%v requests=%d", err, requests) + } +} + +func TestImageModelNameFromDiscoveryToSubmission(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("LocalAppData", t.TempDir()) + queries, submits := 0, 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case config.GetAvailableModelListPath: + queries++ + _, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-key","config":{"models":[{"key":"ali_midjourney_8_2","name":"美学模型 8.2","benefit_map_by_combination":{"ali_midjourney_8_2":{"resource_id":"ali_midjourney_8_2"}},"kind":"image"}]}}}`)) + case config.SubmitRunPath: + submits++ + var body struct { + Settings map[string]json.RawMessage `json:"general_agent_settings"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if string(body.Settings["image_model"]) != `"ali_midjourney_8_2"` || len(body.Settings) != 1 { + t.Errorf("wire model not resolved or unrequested defaults filled: %v", body.Settings) + } + _, _ = w.Write([]byte(`{"ret":"1","errmsg":"ali_midjourney_8_2 暂时不可用","log_id":"image-log"}`)) + default: + t.Errorf("unexpected request: %s", r.URL.Path) + } + })) + defer server.Close() + execute := func(args ...string) (string, string, error) { + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs(args) + err := root.Execute() + return stdout.String(), stderr.String(), err + } + list, _, err := execute("model", "list", "--type", "image") + if err != nil { + t.Fatal(err) + } + var output struct{ Models []struct{ Name string } } + if err := json.Unmarshal([]byte(list), &output); err != nil || len(output.Models) != 1 { + t.Fatalf("list=%s err=%v", list, err) + } + name := output.Models[0].Name + if name != "美学模型 8.2" { + t.Fatalf("unexpected display name: %q", name) + } + detail, _, err := execute("model", "describe", name, "--type", "image") + if err != nil || !strings.Contains(detail, name) || strings.Contains(list+detail, "ali_midjourney_8_2") { + t.Fatalf("user-facing identity changed: list=%s detail=%s err=%v", list, detail, err) + } + stdout, stderr, err := execute("generate-image", "--prompt", "一张猫咪图片", "--model", name) + if err == nil || !strings.Contains(err.Error(), name) || !strings.Contains(err.Error(), "image-log") || strings.Contains(stdout+stderr+err.Error(), "ali_midjourney_8_2") { + t.Fatalf("server rejection must keep name and log ID: stdout=%s stderr=%s err=%v", stdout, stderr, err) + } + if queries != 1 || submits != 1 { + t.Fatalf("expected shared cache and no submit retry: queries=%d submits=%d", queries, submits) + } +} + +func TestImageNameFailureStopsBeforeUploadAndSubmit(t *testing.T) { + for _, tc := range []struct{ name, selection, models string }{ + {"unknown name", "不存在", `[{"key":"private-image","name":"图片模型","kind":"image"}]`}, + {"wire key", "private-image", `[{"key":"private-image","name":"图片模型","kind":"image"}]`}, + {"missing name", "图片模型", `[{"key":"private-image","kind":"image"}]`}, + {"duplicate name", "图片模型", `[{"key":"private-image","name":"图片模型","kind":"image"},{"key":"private-image-2","name":"图片模型","kind":"image"}]`}, + {"empty list", "图片模型", `[]`}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("LocalAppData", t.TempDir()) + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.URL.Path != config.GetAvailableModelListPath { + t.Errorf("must not upload or submit: %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-key","config":{"models":` + tc.models + `}}}`)) + })) + defer server.Close() + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs([]string{"generate-image", "--prompt", "image", "--model", tc.selection, "--image", "/missing.png"}) + err := root.Execute() + if err == nil || requests != 1 || strings.Contains(stdout.String()+stderr.String()+err.Error(), "private-image") { + t.Fatalf("invalid name accepted or exposed: requests=%d stdout=%s stderr=%s err=%v", requests, stdout.String(), stderr.String(), err) + } + }) + } +} diff --git a/cmd/model.go b/cmd/model.go index afde89f..af312f1 100644 --- a/cmd/model.go +++ b/cmd/model.go @@ -16,10 +16,7 @@ func newModelCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Com var modelType string service := models.NewService(runner) query := func(cmd *cobra.Command, args []string, describe bool) error { - if modelType != "video" { - return fmt.Errorf("当前仅支持 --type video") - } - result, err := service.Get(cmd.Context(), refresh) + result, err := service.Get(cmd.Context(), modelType, refresh) if err != nil { return err } @@ -43,9 +40,9 @@ func newModelCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Com return common.WriteJSON(stdout, output) } cmd := &cobra.Command{ - Use: "model [key]", - Short: "Discover available video models and their server configuration", - Long: "Query available video models using your current credentials. Successful queries are cached locally for 5 minutes. Use --refresh to bypass the cache; retry if a query fails.", + Use: "model [name-or-key]", + Short: "Discover available image and video models and their server configuration", + Long: "Query available image and video models using your current credentials. Select images by their full display name (in quotes), videos by key. Successful queries are cached locally for 5 minutes. Use --refresh to bypass the cache; retry if a query fails.", Args: cobra.MaximumNArgs(1), RunE: withErrorLog("model", nil, func(cmd *cobra.Command, args []string) error { return query(cmd, args, len(args) > 0) @@ -54,17 +51,17 @@ func newModelCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Com cmd.SetOut(stdout) cmd.SetErr(stderr) cmd.PersistentFlags().BoolVar(&refresh, "refresh", false, "refresh the 5-minute local model cache") - cmd.PersistentFlags().StringVarP(&modelType, "type", "t", "video", "model type (currently video only)") + cmd.PersistentFlags().StringVarP(&modelType, "type", "t", "video", "model type: video or image (default video)") cmd.AddCommand(&cobra.Command{ Use: "list [query]", Aliases: []string{"search"}, - Short: "List video models, optionally filtered by key or name", + Short: "List models; search images by name, videos by key or name", Args: cobra.MaximumNArgs(1), RunE: withErrorLog("model list", nil, func(cmd *cobra.Command, args []string) error { return query(cmd, args, false) }), }) cmd.AddCommand(&cobra.Command{ - Use: "describe ", Short: "Show model parameters with CLI-ready ratios, defaults, and limits", + Use: "describe ", Short: "Show model parameters; use the full image name or video key", Args: cobra.ExactArgs(1), RunE: withErrorLog("model describe", nil, func(cmd *cobra.Command, args []string) error { return query(cmd, args, true) diff --git a/cmd/model_test.go b/cmd/model_test.go index f81ed3c..cc8f6d1 100644 --- a/cmd/model_test.go +++ b/cmd/model_test.go @@ -67,7 +67,7 @@ func TestModelCommands(t *testing.T) { } func TestModelHelpDoesNotRequireLoginOrRequest(t *testing.T) { - for _, args := range [][]string{{"model", "--help"}, {"generate-video", "--help"}} { + for _, args := range [][]string{{"model", "--help"}, {"generate-video", "--help"}, {"generate-image", "--help"}} { var stdout, stderr bytes.Buffer root := NewRootCommand(&stdout, &stderr) root.SetArgs(args) @@ -79,3 +79,61 @@ func TestModelHelpDoesNotRequireLoginOrRequest(t *testing.T) { } } } + +func TestImageModelCommands(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("LocalAppData", t.TempDir()) + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["scene"] != "web_image_agent" || body["source_model_key"] != "" || r.URL.Path != config.GetAvailableModelListPath { + t.Errorf("unexpected request: body=%v path=%s err=%v", body, r.URL.Path, err) + } + _, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-config","config":{"models":[{"key":"image-model","report_name":"image-model","name":"图片测试模型","kind":"image","is_default":true,"supported_ratio_list":[0,2,6],"parameter_config":{"dimensions":[{"key":"resolution","default_value":"2K","option_list":[{"value":"2K"}]},{"key":"effort","default_value":"low","option_list":[{"value":"low"},{"value":"high"}]}]}}]}}}`)) + })) + defer server.Close() + for _, args := range [][]string{ + {"model", "list", "--type", "image"}, + {"model", "search", "图片", "-t", "image"}, + {"model", "describe", "图片测试模型", "--type", "image"}, + {"model", "图片测试模型", "-t", "image"}, + } { + var stdout, stderr bytes.Buffer + cfg := config.Load() + cfg.BaseURL, cfg.AccessKey = server.URL, "image-model-test-key" + root := newRootCommand(&stdout, &stderr, newRootRunner(cfg)) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("%v: %v", args, err) + } + if !strings.Contains(stdout.String(), `"scene": "web_image_agent"`) && !strings.Contains(stdout.String(), `"scene":"web_image_agent"`) { + t.Fatalf("missing image scene: %s", stdout.String()) + } + if strings.Contains(stdout.String(), `"is_default"`) || strings.Contains(stdout.String(), `"config_key"`) || strings.Contains(stdout.String(), "image-model") { + t.Fatalf("internal fields exposed: %s", stdout.String()) + } + if args[1] == "describe" || args[1] == "图片测试模型" { + var output struct { + Model struct { + Effort struct{ Options []string } + } + } + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil || len(output.Model.Effort.Options) != 2 { + t.Fatalf("missing effort choices: %s err=%v", stdout.String(), err) + } + } + } + if requests != 1 { + t.Fatalf("commands did not share image cache: %d requests", requests) + } + var stdout, stderr bytes.Buffer + cfg := config.Load() + cfg.BaseURL, cfg.AccessKey = server.URL, "image-model-test-key" + root := newRootCommand(&stdout, &stderr, newRootRunner(cfg)) + root.SetArgs([]string{"model", "list", "--type", "audio"}) + if err := root.Execute(); err == nil || requests != 1 { + t.Fatalf("invalid type must fail before request: err=%v requests=%d", err, requests) + } +} diff --git a/cmd/short_drama_test.go b/cmd/short_drama_test.go index 6668687..6978b11 100644 --- a/cmd/short_drama_test.go +++ b/cmd/short_drama_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/Pippit-dev/pippit-cli/internal/auth" "github.com/Pippit-dev/pippit-cli/internal/common" "github.com/Pippit-dev/pippit-cli/internal/config" "github.com/Pippit-dev/pippit-cli/internal/version" @@ -963,6 +964,7 @@ func newTestRootCommandWithAccessKey(t *testing.T, stdout, stderr io.Writer, bas cfg.AccessKey = accessKey client := common.NewHTTPClient(cfg.BaseURL, cfg.HTTPTimeout, common.NewAccessKeyAuthorizer(cfg.AccessKey)) runner := common.NewRunner(cfg, client) + runner.Auth = auth.NewManager(cfg) return newRootCommand(stdout, stderr, runner) } diff --git a/cmd/source_test.go b/cmd/source_test.go index 58a73a3..79eb0fc 100644 --- a/cmd/source_test.go +++ b/cmd/source_test.go @@ -16,6 +16,9 @@ import ( ) func TestSkillSubmitSourcePreservesCreativeRequest(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("LocalAppData", t.TempDir()) for _, name := range []string{"submit-run", "generate-image", "generate-video", "video-super-resolution", "erase-video-subtitle", "short-drama"} { t.Run(name, func(t *testing.T) { video := filepath.Join(t.TempDir(), "input.mp4") @@ -24,7 +27,7 @@ func TestSkillSubmitSourcePreservesCreativeRequest(t *testing.T) { } args := map[string][]string{ "submit-run": {"submit-run", "--message", " 保留原文\nworkbuddy 只是内容 ", "--thread-id", "skill_existing", "--asset-ids", "asset_1"}, - "generate-image": {"generate-image", "--prompt", "cat", "--model", "seedream_5.0_pro", "--ratio", "6", "--resolution", "4K"}, + "generate-image": {"generate-image", "--prompt", "cat", "--model", "Seedream 5.0 Pro", "--ratio", "6", "--resolution", "4K"}, "generate-video": {"generate-video", "--prompt", "cat", "--model", "Seedance_2.0_mini", "--duration", "4", "--ratio", "16:9"}, "video-super-resolution": {"video-super-resolution", "--video", video, "--output-resolution", "1080p"}, "erase-video-subtitle": {"erase-video-subtitle", "--video", video}, @@ -37,6 +40,8 @@ func TestSkillSubmitSourcePreservesCreativeRequest(t *testing.T) { uploadFailure := false server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { + case config.GetAvailableModelListPath: + _, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-key","config":{"models":[{"key":"seedream_5.0_pro","name":"Seedream 5.0 Pro","kind":"image"}]}}}`)) case config.SubmitRunPath: submitCount++ if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer test-token" { diff --git a/internal/common/ratio.go b/internal/common/ratio.go new file mode 100644 index 0000000..e83f406 --- /dev/null +++ b/internal/common/ratio.go @@ -0,0 +1,12 @@ +package common + +// RatioValue mirrors capcut_business_common.Ratio. Custom (1) has no CLI +// width/height parameters, so it and unknown enums have no display value. +func RatioValue(value int64) string { + return ratioValues[value] +} + +var ratioValues = map[int64]string{ + 0: "adaptive", 2: "16:9", 3: "9:16", 4: "4:3", 5: "3:4", 6: "1:1", + 7: "2:1", 8: "2.35:1", 9: "1.85:1", 10: "1.125:2.436", 11: "3:2", 12: "2:3", 13: "21:9", +} diff --git a/internal/generate_image/generate_image.go b/internal/generate_image/generate_image.go index 63255b6..e81afbf 100644 --- a/internal/generate_image/generate_image.go +++ b/internal/generate_image/generate_image.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/Pippit-dev/pippit-cli/internal/common" + "github.com/Pippit-dev/pippit-cli/internal/models" ) const ( @@ -19,7 +20,7 @@ var ( allowedImageExtensions = common.StringSet(allowedImageExtensionList) ) -const ratioUsage = "enum values: 0=原始比例/自动, 2=16:9(横屏), 13=21:9(电影), 3=9:16(竖屏), 4=4:3, 5=3:4, 6=1:1" +const ratioUsage = "integer enum from 'model describe \"MODEL_NAME\" --type image' (e.g. 0=adaptive, 2=16:9, 3=9:16, 6=1:1)" // Options is the stable command-facing request shape for generate-image. type Options struct { @@ -29,6 +30,7 @@ type Options struct { Model string Ratio string Resolution string + Effort string GenerateImageCount *int } @@ -36,6 +38,7 @@ type generalAgentSettings struct { ImageModel string `json:"image_model"` Ratio *int `json:"ratio,omitempty"` Resolution string `json:"resolution,omitempty"` + ImageEffort string `json:"image_effort,omitempty"` GenerateImageCount *int `json:"generate_image_count,omitempty"` } @@ -53,23 +56,31 @@ func Run(ctx context.Context, opts *Options, runner *common.Runner) (*Result, er if err := ValidateOptions(opts); err != nil { return nil, err } + catalog, err := models.NewService(runner).Get(ctx, "image", false) + if err != nil { + return nil, err + } + modelKey, err := catalog.Catalog.ImageModelKey(opts.Model) + if err != nil { + return nil, err + } imageAssetIDs, err := uploadImageList(ctx, opts.ImagePaths, runner) if err != nil { return nil, fmt.Errorf("上传图片失败: %w", err) } - body := buildSubmitRunBody(opts, imageAssetIDs) + body := buildSubmitRunBody(opts, modelKey, imageAssetIDs) var resp common.SubmitRunResponse if err := runner.Client.SendRequest(ctx, common.SubmitRunPath(runner), body, &resp); err != nil { - return nil, fmt.Errorf("提交 generate-image 请求失败: %w", err) + return nil, fmt.Errorf("提交 generate-image 请求失败: %s", catalog.Catalog.ImageDisplayMessage(err.Error())) } if resp.Ret != "0" { if resp.Errmsg == "" { resp.Errmsg = "未知错误" } - return nil, common.NewLogIDError(fmt.Sprintf("generate-image 请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID) + return nil, common.NewLogIDError(fmt.Sprintf("generate-image 请求返回失败: ret=%s errmsg=%s", resp.Ret, catalog.Catalog.ImageDisplayMessage(resp.Errmsg)), resp.LogID) } if resp.Data.Run.ThreadID == "" { return nil, fmt.Errorf("generate-image 响应缺少 data.run.thread_id") @@ -137,15 +148,16 @@ func uploadImageList(ctx context.Context, paths []string, runner *common.Runner) return assetIDs, nil } -func buildSubmitRunBody(opts *Options, imageAssetIDs []string) map[string]any { +func buildSubmitRunBody(opts *Options, modelKey string, imageAssetIDs []string) map[string]any { ratio, _ := parseRatio(opts.Ratio) body := map[string]any{ "agent_name": agentNameNest, "message": strings.TrimSpace(opts.Prompt), "general_agent_settings": generalAgentSettings{ - ImageModel: strings.TrimSpace(opts.Model), + ImageModel: modelKey, Ratio: ratio, Resolution: strings.ToUpper(strings.TrimSpace(opts.Resolution)), + ImageEffort: strings.ToLower(strings.TrimSpace(opts.Effort)), GenerateImageCount: opts.GenerateImageCount, }, } @@ -162,7 +174,7 @@ func parseRatio(raw string) (*int, error) { } value, err := strconv.Atoi(ratio) if err != nil { - return nil, fmt.Errorf("ratio %q 必须是整数枚举值;可参考:%s", ratio, ratioUsage) + return nil, fmt.Errorf("ratio %q 必须是整数枚举值(如 3 表示 9:16);请用 model describe \"模型名称\" --type image 查询", ratio) } return &value, nil } diff --git a/internal/generate_image/generate_image_test.go b/internal/generate_image/generate_image_test.go index 6f4cc2e..192834f 100644 --- a/internal/generate_image/generate_image_test.go +++ b/internal/generate_image/generate_image_test.go @@ -1,6 +1,7 @@ package generate_image import ( + "encoding/json" "strings" "testing" ) @@ -19,10 +20,10 @@ func TestValidateOptionsRequiresModel(t *testing.T) { } } -func TestValidateOptionsAllowsServerDecidedModel(t *testing.T) { +func TestValidateOptionsAllowsDynamicModelName(t *testing.T) { opts := &Options{ Prompt: "x", - Model: "seedream_3.0", + Model: "图片测试模型", } if err := ValidateOptions(opts); err != nil { @@ -33,7 +34,7 @@ func TestValidateOptionsAllowsServerDecidedModel(t *testing.T) { func TestValidateOptionsAllowsServerDecidedRatio(t *testing.T) { opts := &Options{ Prompt: "x", - Model: "seedream_4.5", + Model: "图片测试模型", Ratio: "99", } @@ -45,7 +46,7 @@ func TestValidateOptionsAllowsServerDecidedRatio(t *testing.T) { func TestValidateOptionsAllowsServerDecidedResolution(t *testing.T) { opts := &Options{ Prompt: "x", - Model: "seedream_4.5", + Model: "图片测试模型", Resolution: "8K", } @@ -58,7 +59,7 @@ func TestValidateOptionsRejectsNegativeGenerateImageCount(t *testing.T) { count := -1 opts := &Options{ Prompt: "x", - Model: "seedream_4.5", + Model: "图片测试模型", GenerateImageCount: &count, } @@ -83,6 +84,13 @@ func TestParseRatioSupportsVisibleEnumValues(t *testing.T) { {ratio: "4", want: 4}, {ratio: "5", want: 5}, {ratio: "6", want: 6}, + {ratio: "7", want: 7}, + {ratio: "8", want: 8}, + {ratio: "9", want: 9}, + {ratio: "10", want: 10}, + {ratio: "11", want: 11}, + {ratio: "12", want: 12}, + {ratio: "99", want: 99}, } for _, tt := range cases { @@ -98,20 +106,55 @@ func TestParseRatioSupportsVisibleEnumValues(t *testing.T) { } } -func TestParseRatioRejectsNonInteger(t *testing.T) { - _, err := parseRatio("1:1") - if err == nil { - t.Fatal("parseRatio() error = nil, want integer validation") +func TestImageOptionalParametersAndEffortPassThrough(t *testing.T) { + for _, effort := range []string{"", " HIGH ", "future-effort"} { + opts := &Options{Prompt: "image", Model: "未来图片模型", Effort: effort} + if err := ValidateOptions(opts); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(buildSubmitRunBody(opts, "future-image-model", nil)) + if err != nil { + t.Fatal(err) + } + var body struct { + Settings map[string]json.RawMessage `json:"general_agent_settings"` + } + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatal(err) + } + for _, key := range []string{"ratio", "resolution", "generate_image_count"} { + if _, exists := body.Settings[key]; exists { + t.Fatalf("unspecified %s was filled: %s", key, raw) + } + } + if effort == "" { + if _, exists := body.Settings["image_effort"]; exists { + t.Fatalf("unspecified effort was filled: %s", raw) + } + } else { + var got string + if err := json.Unmarshal(body.Settings["image_effort"], &got); err != nil || got != strings.ToLower(strings.TrimSpace(effort)) { + t.Fatalf("effort not forwarded: %s, %v", raw, err) + } + } } - if !strings.Contains(err.Error(), `ratio "1:1" 必须是整数枚举值`) { - t.Fatalf("error = %q, want integer validation", err) +} + +func TestParseRatioRejectsNonInteger(t *testing.T) { + for _, ratio := range []string{"9:16", "adaptive", "17:11", "3.0"} { + t.Run(ratio, func(t *testing.T) { + err := ValidateOptions(&Options{Prompt: "image", Model: "未来图片模型", Ratio: ratio}) + if err == nil || !strings.Contains(err.Error(), "必须是整数枚举值") { + t.Fatalf("ratio %q: error = %v, want integer enum validation", ratio, err) + } + }) } } func TestValidateOptionsRejectsUnsupportedImageExtension(t *testing.T) { opts := &Options{ Prompt: "x", - Model: "seedream_4.5", + Model: "图片测试模型", ImagePaths: []string{"ref.tiff"}, } @@ -128,13 +171,13 @@ func TestBuildSubmitRunBodyWithGeneralAgentSettings(t *testing.T) { count := 2 opts := &Options{ Prompt: " 生成小猫海报 ", - Model: " seedream_5.0_pro ", + Model: " Seedream 5.0 Pro ", Ratio: "6", Resolution: " 4k ", GenerateImageCount: &count, } - body := buildSubmitRunBody(opts, []string{"asset_1"}) + body := buildSubmitRunBody(opts, "seedream_5.0_pro", []string{"asset_1"}) if body["agent_name"] != agentNameNest { t.Fatalf("agent_name = %v, want nest agent", body["agent_name"]) } diff --git a/internal/models/describe.go b/internal/models/describe.go index 91329a1..be91319 100644 --- a/internal/models/describe.go +++ b/internal/models/describe.go @@ -6,17 +6,15 @@ import ( "slices" "strconv" "strings" -) -// Mirrors capcut_business_common.Ratio and the Skill API's skillVideoRatio. -// Custom (1) has no corresponding generate-video --ratio value. -var ratioValues = map[int64]string{ - 0: "adaptive", 1: "", 2: "16:9", 3: "9:16", 4: "4:3", 5: "3:4", 6: "1:1", - 7: "2:1", 8: "2.35:1", 9: "1.85:1", 10: "1.125:2.436", 11: "3:2", 12: "2:3", 13: "21:9", -} + "github.com/Pippit-dev/pippit-cli/internal/common" +) type dimensionConfig struct { Key string `json:"key"` + Label string `json:"label"` + Description string `json:"description"` + Required *bool `json:"required_field"` DefaultValue *string `json:"default_value"` OptionList []struct { Value string `json:"value"` @@ -36,6 +34,8 @@ func describeModel(raw json.RawMessage) (json.RawMessage, error) { var fields map[string]json.RawMessage var source struct { Key string `json:"key"` + Name string `json:"name"` + Kind string `json:"kind"` Ratios []int64 `json:"supported_ratio_list"` Default *int64 `json:"default_ratio"` Parameter struct { @@ -57,18 +57,27 @@ func describeModel(raw json.RawMessage) (json.RawMessage, error) { } delete(out, "config_key") delete(out, "is_default") // A server default does not authorize model selection. + if source.Kind == "image" { + // Billing maps and other Web-only metadata can contain wire model names. + // Keep them in the raw catalog, not in host-facing command output. + out = map[string]any{"name": strings.TrimSpace(source.Name), "kind": source.Kind} + for _, key := range []string{"description", "parameter_config", "creation_mode_config"} { + if value, exists := fields[key]; exists { + out[key] = value + } + } + } warnings := []string{} warn := func(message string) { warnings = append(warnings, message) } - ratios := make([]string, 0, len(source.Ratios)) + ratios := make([]any, 0, len(source.Ratios)) for _, value := range source.Ratios { - // Unknown enums and Custom cannot be submitted as CLI ratio strings. - if value := ratioValues[value]; value != "" && !slices.Contains(ratios, value) { + if value, ok := cliRatioValue(value, source.Kind); ok && !slices.Contains(ratios, value) { ratios = append(ratios, value) } } ratio := map[string]any{"options": ratios} if source.Default != nil { - if value := ratioValues[*source.Default]; value != "" { + if value, ok := cliRatioValue(*source.Default, source.Kind); ok { if slices.Contains(ratios, value) { ratio["default"] = value } else { @@ -84,17 +93,37 @@ func describeModel(raw json.RawMessage) (json.RawMessage, error) { seen := make(map[string]bool) for _, dimension := range source.Parameter.Dimensions { - if dimension == nil || (dimension.Key != "resolution" && dimension.Key != "duration") { + if dimension == nil { + continue + } + if source.Kind == "image" { + if dimension.Key != "resolution" && dimension.Key != "ratio" && dimension.Key != "effort" { + continue + } + } else if dimension.Key != "resolution" && dimension.Key != "duration" { continue } if seen[dimension.Key] { return nil, fmt.Errorf("模型配置包含重复 %s 维度,请 --refresh 重试", dimension.Key) } seen[dimension.Key] = true - out[dimension.Key] = describeDimension(dimension, warn) + out[dimension.Key] = describeDimension(dimension, source.Kind, warn) + } + if source.Kind == "image" { + if imageRatio, ok := out["ratio"].(map[string]any); ok { + if options, ok := imageRatio["options"].([]any); ok { + labels := make(map[string]string, len(options)) + for _, option := range options { + value := option.(int64) + labels[strconv.FormatInt(value, 10)] = common.RatioValue(value) + } + imageRatio["option_labels"] = labels + } + } } - // Keep unrelated parameter dimensions and combination constraints as supplied. - if parameterRaw, ok := fields["parameter_config"]; ok && string(parameterRaw) != "null" { + // Image dimensions retain their complete source metadata and combination rules. + // Video descriptions keep the existing compact representation. + if parameterRaw, ok := fields["parameter_config"]; source.Kind != "image" && ok && string(parameterRaw) != "null" { var parameter map[string]json.RawMessage if err := json.Unmarshal(parameterRaw, ¶meter); err != nil { return nil, err @@ -146,7 +175,7 @@ func describeModel(raw json.RawMessage) (json.RawMessage, error) { if len(limits) > 0 { out["material_limits"] = limits } - if source.Creation != nil { + if source.Creation != nil && source.Kind != "image" { modes := make([]map[string]any, 0, len(source.Creation.Modes)) for _, rawMode := range source.Creation.Modes { if string(rawMode) == "null" { @@ -181,7 +210,7 @@ func describeModel(raw json.RawMessage) (json.RawMessage, error) { case "", "inherit": entry["ratio"] = ratio if mode.Key == "text_to_video" && (source.Key == "MiniMax-H3" || source.Key == "MiniMax-H3-Max") { - fixed := make([]string, 0, len(ratios)) + fixed := make([]any, 0, len(ratios)) for _, value := range ratios { if value != "adaptive" { fixed = append(fixed, value) @@ -210,7 +239,7 @@ func describeModel(raw json.RawMessage) (json.RawMessage, error) { } out["creation_modes"] = modes } - if (source.Key == "MiniMax-H3" || source.Key == "MiniMax-H3-Max") && slices.Contains(ratios, "adaptive") { + if source.Kind != "image" && (source.Key == "MiniMax-H3" || source.Key == "MiniMax-H3-Max") && slices.Contains(ratios, "adaptive") { out["notes"] = []string{"MiniMax 使用 adaptive 需要参考图片或视频;纯文生视频请选择固定比例。创作模式的比例策略优先于模型级选项。"} } if len(warnings) > 0 { @@ -219,8 +248,29 @@ func describeModel(raw json.RawMessage) (json.RawMessage, error) { return json.Marshal(out) } -func describeDimension(d *dimensionConfig, warn func(string)) map[string]any { +// Image generation accepts wire enums; video generation accepts ratio strings. +func cliRatioValue(value int64, kind string) (any, bool) { + label := common.RatioValue(value) + if label == "" { + return nil, false + } + if kind == "image" { + return value, true + } + return label, true +} + +func describeDimension(d *dimensionConfig, kind string, warn func(string)) map[string]any { out := map[string]any{} + if d.Label != "" { + out["label"] = d.Label + } + if d.Description != "" { + out["description"] = d.Description + } + if d.Required != nil { + out["required_field"] = *d.Required + } if len(d.ActiveWhenAny) > 0 { out["active_when_any"] = d.ActiveWhenAny } @@ -230,6 +280,16 @@ func describeDimension(d *dimensionConfig, warn func(string)) map[string]any { number, err := strconv.ParseInt(value, 10, 32) return number, err == nil && number > 0 } + if d.Key == "ratio" { + number, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return nil, false + } + return cliRatioValue(number, kind) + } + if kind == "image" && d.Key == "resolution" { + return strings.ToUpper(value), value != "" + } return strings.ToLower(value), value != "" } if d.Key == "duration" { diff --git a/internal/models/describe_test.go b/internal/models/describe_test.go index 6cc2449..4846e7a 100644 --- a/internal/models/describe_test.go +++ b/internal/models/describe_test.go @@ -21,12 +21,12 @@ func description(t *testing.T, raw string) map[string]json.RawMessage { } func TestDescriptionCLIParametersAndRawPreservation(t *testing.T) { - raw := `{"key":"MiniMax-H3","is_default":true,"supported_ratio_list":[0,2,13,3,4,5,6],"default_ratio":3,"config_key":"internal", + raw := `{"key":"MiniMax-H3","kind":"video","is_default":true,"supported_ratio_list":[0,2,13,3,4,5,6],"default_ratio":3,"config_key":"internal", "future_field":9007199254740993,"audio_total_limit":0,"max_image_size":31457280,"min_video_duration":2000, "supported_duration_list":[{"value":999}],"default_duration_value":999, "parameter_config":{"dimensions":[ - {"key":"duration","default_value":"10","range_config":{"min_value":4,"max_value":15,"step":1}}, - {"key":"resolution","default_value":" 768P ","option_list":[{"value":"768p"},{"value":"2k"},{"value":"4k","disabled":true}]}, + {"key":"duration","label":"视频时长","description":"生成的视频时长","required_field":true,"default_value":"10","range_config":{"min_value":4,"max_value":15,"step":1}}, + {"key":"resolution","label":"视频分辨率","description":"输出清晰度","required_field":false,"default_value":" 768P ","option_list":[{"value":"768p"},{"value":"2k"},{"value":"4k","disabled":true}]}, {"key":"seed","default_value":"random"}],"need_available_combinations":true}} ` out := description(t, raw) @@ -46,8 +46,10 @@ func TestDescriptionCLIParametersAndRawPreservation(t *testing.T) { t.Fatalf("ratio=%+v", ratio) } var resolution struct { - Options []string - Default string + Options []string + Default string + Label, Description string + Required *bool `json:"required_field"` } if err := json.Unmarshal(out["resolution"], &resolution); err != nil { t.Fatal(err) @@ -55,9 +57,14 @@ func TestDescriptionCLIParametersAndRawPreservation(t *testing.T) { if !reflect.DeepEqual(resolution.Options, []string{"768p", "2k"}) || resolution.Default != "768p" { t.Fatalf("resolution=%+v", resolution) } + if resolution.Label != "视频分辨率" || resolution.Description != "输出清晰度" || resolution.Required == nil || *resolution.Required { + t.Fatalf("video resolution metadata lost: %+v", resolution) + } var duration struct { Min, Max, Step, Default int Unit string + Label, Description string + Required *bool `json:"required_field"` } if err := json.Unmarshal(out["duration"], &duration); err != nil { t.Fatal(err) @@ -65,6 +72,9 @@ func TestDescriptionCLIParametersAndRawPreservation(t *testing.T) { if duration.Min != 4 || duration.Max != 15 || duration.Step != 1 || duration.Default != 10 || duration.Unit != "seconds" { t.Fatalf("duration=%+v", duration) } + if duration.Label != "视频时长" || duration.Description != "生成的视频时长" || duration.Required == nil || !*duration.Required { + t.Fatalf("video duration metadata lost: %+v", duration) + } if string(out["future_field"]) != "9007199254740993" { t.Fatal("integer precision lost") } @@ -197,3 +207,119 @@ func TestDescriptionCreationModesAndCacheNotMutated(t *testing.T) { t.Fatal("description mutated cached raw config") } } + +func TestImageDescriptionParametersAndConstraints(t *testing.T) { + raw := `{"key":"future-image","name":"未来图片模型","report_name":"internal-report-model","kind":"image","is_default":true, + "supported_ratio_list":[0,2,6,13,1,999],"default_ratio":6, + "parameter_config":{"dimensions":[ + {"key":"resolution","label":"图片分辨率","required_field":false,"default_value":"2k","option_list":[{"value":"2k","label":"高清"},{"value":"4K","disabled":true}]}, + {"key":"effort","label":"推理强度","description":"计算档位","required_field":true,"default_value":"HIGH","active_when_any":[{"resolution":["2k"]}],"option_list":[{"value":"low"},{"value":"HIGH","label":"高","description":"更多计算"},{"value":"max","disabled":true}]}, + {"key":"future-dimension","option_list":[{"value":"future-choice"}]}], + "need_available_combinations":true,"combination_dimension_keys":["resolution","effort"], + "default_combination":{"resolution":"2k","effort":"HIGH"}, + "available_combinations":[{"option_values":{"resolution":"2k","effort":"HIGH"}}]}, + "creation_mode_config":{"modes":[{"key":"reference_generation","enabled":true}]}} + ` + out := description(t, raw) + if _, exists := out["key"]; exists || string(out["name"]) != `"未来图片模型"` { + t.Fatalf("image identity must contain only the display name: %s", out) + } + if _, exists := out["report_name"]; exists { + t.Fatal("internal reporting model name exposed") + } + var ratio struct { + Options []int64 + Default int64 + Labels map[string]string `json:"option_labels"` + } + var resolution, effort struct { + Options []string + Default string + Required *bool `json:"required_field"` + Active json.RawMessage `json:"active_when_any"` + Label string + Description string + } + for key, target := range map[string]any{"ratio": &ratio, "resolution": &resolution, "effort": &effort} { + if err := json.Unmarshal(out[key], target); err != nil { + t.Fatal(err) + } + } + if !reflect.DeepEqual(ratio.Options, []int64{0, 2, 6, 13}) || ratio.Default != 6 || !reflect.DeepEqual(ratio.Labels, map[string]string{"0": "adaptive", "2": "16:9", "6": "1:1", "13": "21:9"}) { + t.Fatalf("ratio=%+v", ratio) + } + if !reflect.DeepEqual(resolution.Options, []string{"2K"}) || resolution.Default != "2K" || resolution.Required == nil || *resolution.Required { + t.Fatalf("resolution=%+v", resolution) + } + if !reflect.DeepEqual(effort.Options, []string{"low", "high"}) || effort.Default != "high" || effort.Required == nil || !*effort.Required || effort.Label != "推理强度" || effort.Description != "计算档位" || string(effort.Active) != `[{"resolution":["2k"]}]` { + t.Fatalf("effort=%+v", effort) + } + var source map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &source); err != nil { + t.Fatal(err) + } + var compact bytes.Buffer + if err := json.Compact(&compact, source["parameter_config"]); err != nil { + t.Fatal(err) + } + if !bytes.Equal(out["parameter_config"], compact.Bytes()) { + t.Fatalf("raw dimension metadata or combination rules changed: %s", out["parameter_config"]) + } + if bytes.Contains(out["creation_mode_config"], []byte("generate_type")) || len(out["creation_modes"]) != 0 { + t.Fatal("image modes must not acquire video generation selectors") + } + if _, exists := out["is_default"]; exists { + t.Fatal("model default marker exposed") + } +} + +func TestImageDescriptionOptionalEffortAndRatioDimension(t *testing.T) { + out := description(t, `{"kind":"image","supported_ratio_list":[2],"parameter_config":{"dimensions":[{"key":"ratio","default_value":"13","option_list":[{"value":"13"},{"value":"6"},{"value":"2","disabled":true},{"value":"999"},{"value":"1"}]}]}}`) + if len(out["effort"]) != 0 || len(out["resolution"]) != 0 { + t.Fatal("absent capabilities must not be invented") + } + var ratio struct { + Options []int64 + Default int64 + Labels map[string]string `json:"option_labels"` + } + if err := json.Unmarshal(out["ratio"], &ratio); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(ratio.Options, []int64{13, 6}) || ratio.Default != 13 || !reflect.DeepEqual(ratio.Labels, map[string]string{"13": "21:9", "6": "1:1"}) { + t.Fatalf("ratio dimension did not override legacy list: %+v", ratio) + } + for _, raw := range []string{ + `{"kind":"image","parameter_config":{"dimensions":[{"key":"effort","default_value":"high","option_list":[{"value":"low"},{"value":"high","disabled":true}]}]}}`, + `{"kind":"image","parameter_config":{"dimensions":[{"key":"effort","option_list":[]}]}}`, + } { + out := description(t, raw) + if len(out["warnings"]) == 0 || bytes.Contains(out["effort"], []byte(`"default"`)) { + t.Fatalf("invalid effort configuration accepted: %s", out["effort"]) + } + } + _, err := describeModel(json.RawMessage(`{"kind":"image","parameter_config":{"dimensions":[{"key":"effort"},{"key":"effort"}]}}`)) + if err == nil { + t.Fatal("duplicate effort dimension accepted") + } +} + +func TestImageRatioZeroDefaultAndUnavailableValues(t *testing.T) { + for _, raw := range []string{ + `{"kind":"image","supported_ratio_list":[0,0,3,1,999],"default_ratio":0}`, + `{"kind":"image","parameter_config":{"dimensions":[{"key":"ratio","default_value":"0","option_list":[{"value":"0"},{"value":"0"},{"value":"3"},{"value":"6","disabled":true},{"value":"1"},{"value":"999"}]}]}}`, + } { + out := description(t, raw) + var ratio struct { + Options []int64 + Default *int64 + Labels map[string]string `json:"option_labels"` + } + if err := json.Unmarshal(out["ratio"], &ratio); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(ratio.Options, []int64{0, 3}) || ratio.Default == nil || *ratio.Default != 0 || !reflect.DeepEqual(ratio.Labels, map[string]string{"0": "adaptive", "3": "9:16"}) { + t.Fatalf("numeric zero default or available ratio values changed: %s", out["ratio"]) + } + } +} diff --git a/internal/models/models.go b/internal/models/models.go index 8ac0649..a607360 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "time" @@ -16,12 +17,14 @@ import ( ) const ( - Scene = "web_turbo_video_generator" - CacheTTL = 5 * time.Minute + VideoScene = "web_turbo_video_generator" + ImageScene = "web_image_agent" + CacheTTL = 5 * time.Minute ) // Catalog preserves the server's model configuration, including unknown fields -// and integer enums. It is never used as a local generation allowlist. +// and integer enums. Image names are resolved here before submission; admission +// and parameter validation remain the server's responsibility. type Catalog struct { Scene string `json:"scene"` ConfigKey string `json:"config_key"` @@ -31,7 +34,7 @@ type Catalog struct { } type Summary struct { - Key string `json:"key"` + Key string `json:"key,omitempty"` Name string `json:"name"` Kind string `json:"kind"` } @@ -62,7 +65,22 @@ func NewService(runner *common.Runner) *Service { return &Service{runner: runner, cacheDir: dir, now: time.Now} } -func (s *Service) Get(ctx context.Context, refresh bool) (*Result, error) { +func sceneForType(modelType string) (string, error) { + switch modelType { + case "video": + return VideoScene, nil + case "image": + return ImageScene, nil + default: + return "", fmt.Errorf("不支持的模型类型 %q;--type 可选 video 或 image", modelType) + } +} + +func (s *Service) Get(ctx context.Context, modelType string, refresh bool) (*Result, error) { + scene, err := sceneForType(modelType) + if err != nil { + return nil, err + } if err := ctx.Err(); err != nil { return nil, err } @@ -79,9 +97,9 @@ func (s *Service) Get(ctx context.Context, refresh bool) (*Result, error) { if s.runner.Config.Paths != nil && s.runner.Config.Paths.GetAvailableModelList != "" { path = s.runner.Config.Paths.GetAvailableModelList } - cachePath := s.cachePath(accessKey, path) + cachePath := s.cachePath(accessKey, path, scene) if !refresh && cachePath != "" { - if entry := s.readCache(cachePath); entry != nil { + if entry := s.readCache(cachePath, scene); entry != nil { return &Result{Catalog: entry.Catalog, Cached: true, FetchedAt: entry.FetchedAt}, nil } } @@ -100,14 +118,14 @@ func (s *Service) Get(ctx context.Context, refresh bool) (*Result, error) { err = s.runner.Client.SendRequest(queryCtx, path, struct { Scene string `json:"scene"` SourceModelKey string `json:"source_model_key"` - }{Scene: Scene}, &resp) + }{Scene: scene}, &resp) if err != nil { return nil, fmt.Errorf("模型查询失败,请稍后重试(可加 --refresh): %w", err) } if resp.Ret != "0" { return nil, common.NewLogIDError(fmt.Sprintf("模型查询失败,请稍后重试(可加 --refresh): ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID) } - if err := validateCatalog(resp.Data); err != nil { + if err := validateCatalog(resp.Data, scene); err != nil { return nil, common.NewLogIDError("模型配置无效,请稍后重试(可加 --refresh): "+err.Error(), resp.LogID) } currentKey, err := s.runner.Auth.ResolveAccessKey(ctx) @@ -121,24 +139,24 @@ func (s *Service) Get(ctx context.Context, refresh bool) (*Result, error) { return result, nil } -func (s *Service) cachePath(accessKey, path string) string { +func (s *Service) cachePath(accessKey, path, scene string) string { if s.cacheDir == "" { return "" } scope, _ := json.Marshal([]string{ - "v1", strings.TrimRight(s.runner.Config.BaseURL, "/"), path, Scene, + "v1", strings.TrimRight(s.runner.Config.BaseURL, "/"), path, scene, version.Current(), accessKey, }) return filepath.Join(s.cacheDir, fmt.Sprintf("%x.json", sha256.Sum256(scope))) } -func (s *Service) readCache(path string) *cacheEntry { +func (s *Service) readCache(path, scene string) *cacheEntry { raw, err := os.ReadFile(path) if err != nil { return nil } var entry cacheEntry - if json.Unmarshal(raw, &entry) != nil || validateCatalog(entry.Catalog) != nil { + if json.Unmarshal(raw, &entry) != nil || validateCatalog(entry.Catalog, scene) != nil { return nil } age := s.now().Sub(entry.FetchedAt) @@ -172,20 +190,37 @@ func (s *Service) writeCache(path string, result *Result) error { return os.Rename(f.Name(), path) } -func validateCatalog(catalog *Catalog) error { - if catalog == nil || catalog.Config == nil || catalog.Scene != Scene || catalog.ConfigKey == "" { +func validateCatalog(catalog *Catalog, scene string) error { + kind := "video" + if scene == ImageScene { + kind = "image" + } else if scene != VideoScene { + return fmt.Errorf("不支持的模型场景 %q", scene) + } + if catalog == nil || catalog.Config == nil || catalog.Scene != scene || catalog.ConfigKey == "" { return fmt.Errorf("缺少配置或场景不匹配") } seen := make(map[string]bool) + seenNames := make(map[string]bool) for _, raw := range catalog.Config.Models { var model Summary - if json.Unmarshal(raw, &model) != nil || strings.TrimSpace(model.Key) == "" || model.Kind != "video" { + if json.Unmarshal(raw, &model) != nil || strings.TrimSpace(model.Key) == "" || model.Kind != kind { return fmt.Errorf("模型条目缺少有效 key 或 kind") } if seen[model.Key] { + if kind == "image" { + return fmt.Errorf("图片模型配置包含重复条目") + } return fmt.Errorf("模型 key 重复: %s", model.Key) } seen[model.Key] = true + if kind == "image" { + name := strings.TrimSpace(model.Name) + if name == "" || strings.EqualFold(name, strings.TrimSpace(model.Key)) || seenNames[name] { + return fmt.Errorf("图片模型展示名称缺失、与模型标识相同或重复,请刷新模型列表") + } + seenNames[name] = true + } } return nil } @@ -196,7 +231,10 @@ func (c *Catalog) Search(query string) []Summary { for _, raw := range c.Config.Models { var model Summary _ = json.Unmarshal(raw, &model) // validated at the network/cache boundary - if model.Key == query { + if c.Scene == ImageScene { + model.Key = "" // Wire identifiers stay in the raw catalog only. + model.Name = strings.TrimSpace(model.Name) + } else if model.Key == query { return []Summary{model} } if query == "" || strings.Contains(strings.ToLower(model.Name), strings.ToLower(query)) || strings.Contains(strings.ToLower(model.Key), strings.ToLower(query)) { @@ -206,14 +244,78 @@ func (c *Catalog) Search(query string) []Summary { return matches } -func (c *Catalog) Describe(key string) (json.RawMessage, error) { - key = strings.TrimSpace(key) +func (c *Catalog) findModel(selector string) (json.RawMessage, error) { + selector = strings.TrimSpace(selector) + var found json.RawMessage for _, raw := range c.Config.Models { var model Summary _ = json.Unmarshal(raw, &model) - if model.Key == key { - return describeModel(raw) + value := model.Key + if c.Scene == ImageScene { + value = strings.TrimSpace(model.Name) } + if value != "" && value == selector { + if found != nil { + return nil, fmt.Errorf("模型名称重复,请执行 model list --type image --refresh 刷新后重试") + } + found = raw + } + } + if found != nil { + return found, nil + } + if c.Scene == ImageScene { + return nil, fmt.Errorf("未找到该图片模型名称;请执行 model list --type image --refresh,并使用列表中的完整名称") + } + return nil, fmt.Errorf("未找到可用模型 %q;请执行 model list --type video --refresh 查看当前模型", selector) +} + +func (c *Catalog) Describe(selector string) (json.RawMessage, error) { + raw, err := c.findModel(selector) + if err != nil { + return nil, err + } + return describeModel(raw) +} + +// ImageModelKey translates the user's exact display name into a wire value. +// Never guess keys from names or send an unresolved name to the server. +func (c *Catalog) ImageModelKey(name string) (string, error) { + if c.Scene != ImageScene { + return "", fmt.Errorf("图片生成需要图片模型配置") + } + raw, err := c.findModel(name) + if err != nil { + return "", err + } + var model Summary + _ = json.Unmarshal(raw, &model) + return model.Key, nil +} + +// ImageDisplayMessage keeps known wire identifiers out of server error messages. +func (c *Catalog) ImageDisplayMessage(message string) string { + var models []Summary + for _, raw := range c.Config.Models { + var model struct { + Summary + ReportName string `json:"report_name"` + } + _ = json.Unmarshal(raw, &model) + name := strings.TrimSpace(model.Name) + if model.Kind == "image" && name != "" { + for _, value := range []string{model.Key, model.ReportName} { + if value != "" && value != name { + models = append(models, Summary{Key: value, Name: name}) + } + } + } + } + // A key can be a prefix of another key. Replace the longer one first. + sort.Slice(models, func(i, j int) bool { return len(models[i].Key) > len(models[j].Key) }) + pairs := make([]string, 0, len(models)*2) + for _, model := range models { + pairs = append(pairs, model.Key, strings.TrimSpace(model.Name)) } - return nil, fmt.Errorf("未找到可用模型 %q;请执行 model list --refresh 查看当前模型", key) + return strings.NewReplacer(pairs...).Replace(message) } diff --git a/internal/models/models_test.go b/internal/models/models_test.go index b5d71f6..e017390 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -45,11 +45,11 @@ func TestMissingCredentialsCannotReadFreshCache(t *testing.T) { requests++ _, _ = w.Write([]byte(validResponse)) }) - if _, err := s.Get(context.Background(), false); err != nil { + if _, err := s.Get(context.Background(), "video", false); err != nil { t.Fatal(err) } s.runner.Auth = unavailableAuth{} - result, err := s.Get(context.Background(), false) + result, err := s.Get(context.Background(), "video", false) if err == nil || result != nil || requests != 1 || !strings.Contains(err.Error(), "login") { t.Fatalf("must require login before cache: result=%+v err=%v requests=%d", result, err, requests) } @@ -63,7 +63,7 @@ func TestCacheTTLRefreshAndIsolation(t *testing.T) { t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) } var body map[string]string - if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["scene"] != Scene || body["source_model_key"] != "" { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["scene"] != VideoScene || body["source_model_key"] != "" { t.Errorf("unexpected body: %v, err=%v", body, err) } _, _ = w.Write([]byte(validResponse)) @@ -72,7 +72,7 @@ func TestCacheTTLRefreshAndIsolation(t *testing.T) { s.now = func() time.Time { return now } check := func(refresh, cached bool, count int) *Result { t.Helper() - result, err := s.Get(context.Background(), refresh) + result, err := s.Get(context.Background(), "video", refresh) if err != nil || result.Cached != cached || requests != count { t.Fatalf("Get: result=%+v err=%v requests=%d, want cached=%v requests=%d", result, err, requests, cached, count) } @@ -89,9 +89,9 @@ func TestCacheTTLRefreshAndIsolation(t *testing.T) { check(true, false, 3) s.runner.Config.AccessKey = "second-account" check(false, false, 4) - oldPath := s.cachePath("second-account", config.GetAvailableModelListPath) + oldPath := s.cachePath("second-account", config.GetAvailableModelListPath, VideoScene) s.runner.Config.BaseURL += "/other-api" - if s.cachePath("second-account", config.GetAvailableModelListPath) == oldPath { + if s.cachePath("second-account", config.GetAvailableModelListPath, VideoScene) == oldPath { t.Fatal("API base URLs must not share cache") } files, _ := os.ReadDir(s.cacheDir) @@ -110,7 +110,7 @@ func TestFailedQueryDoesNotUseOrCacheStaleData(t *testing.T) { requests++ _, _ = w.Write([]byte(response)) }) - if _, err := s.Get(context.Background(), false); err != nil { + if _, err := s.Get(context.Background(), "video", false); err != nil { t.Fatal(err) } for _, invalid := range []string{ @@ -120,29 +120,29 @@ func TestFailedQueryDoesNotUseOrCacheStaleData(t *testing.T) { `not-json`, } { response = invalid - if result, err := s.Get(context.Background(), true); err == nil || result != nil || !strings.Contains(err.Error(), "重试") { + if result, err := s.Get(context.Background(), "video", true); err == nil || result != nil || !strings.Contains(err.Error(), "重试") { t.Fatalf("failed query must suggest retry: result=%+v err=%v", result, err) } before := requests - if _, err := s.Get(context.Background(), false); err == nil || requests != before+1 { + if _, err := s.Get(context.Background(), "video", false); err == nil || requests != before+1 { t.Fatal("retry must query the server, not reuse cached success or failure") } } response = validResponse - if _, err := s.Get(context.Background(), false); err != nil { + if _, err := s.Get(context.Background(), "video", false); err != nil { t.Fatal(err) } - cachePath := s.cachePath(s.runner.Config.AccessKey, config.GetAvailableModelListPath) + cachePath := s.cachePath(s.runner.Config.AccessKey, config.GetAvailableModelListPath, VideoScene) if err := os.WriteFile(cachePath, []byte("broken"), 0600); err != nil { t.Fatal(err) } - result, err := s.Get(context.Background(), false) + result, err := s.Get(context.Background(), "video", false) if err != nil || result.Cached { t.Fatalf("corrupt cache should refresh: %+v %v", result, err) } // A timestamp in the future must not extend visibility indefinitely. s.now = func() time.Time { return result.FetchedAt.Add(-time.Second) } - if s.readCache(cachePath) != nil { + if s.readCache(cachePath, VideoScene) != nil { t.Fatal("future-dated cache must be ignored") } } @@ -150,7 +150,7 @@ func TestFailedQueryDoesNotUseOrCacheStaleData(t *testing.T) { func TestModelConfigurationPreservedAcrossCache(t *testing.T) { s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(validResponse)) }) for i := 0; i < 2; i++ { - result, err := s.Get(context.Background(), false) + result, err := s.Get(context.Background(), "video", false) if err != nil { t.Fatal(err) } @@ -188,7 +188,7 @@ func TestCacheWriteFailureStillReturnsServerResult(t *testing.T) { t.Fatal(err) } s.cacheDir = file - result, err := s.Get(context.Background(), false) + result, err := s.Get(context.Background(), "video", false) if err != nil || result.Cached || result.Warning == "" { t.Fatalf("result=%+v err=%v", result, err) } @@ -207,8 +207,150 @@ func TestEmptyAndDuplicateModels(t *testing.T) { if err := json.Unmarshal([]byte(tc.data), &catalog); err != nil { t.Fatal(err) } - if err := validateCatalog(&catalog); (err == nil) != tc.valid { + if err := validateCatalog(&catalog, VideoScene); (err == nil) != tc.valid { t.Fatalf("validateCatalog = %v", err) } } } + +func TestImageAndVideoCacheIsolation(t *testing.T) { + requests := map[string]int{} + failImage := false + s := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + scene := body["scene"] + requests[scene]++ + if body["source_model_key"] != "" || len(body) != 2 { + t.Errorf("unexpected identity or selector fields: %v", body) + } + response := validResponse + switch scene { + case ImageScene: + response = strings.ReplaceAll(strings.ReplaceAll(validResponse, VideoScene, ImageScene), `"kind":"video"`, `"kind":"image"`) + if failImage { + response = `{"ret":"1001","errmsg":"scene not supported"}` + } + case VideoScene: + default: + t.Errorf("unexpected scene %q", scene) + } + _, _ = w.Write([]byte(response)) + }) + for _, kind := range []string{"image", "video"} { + for i := 0; i < 2; i++ { + result, err := s.Get(context.Background(), kind, false) + if err != nil { + t.Fatal(err) + } + if result.Cached != (i == 1) || result.Catalog.Search("")[0].Kind != kind { + t.Fatalf("cache crossed model type: %+v", result) + } + } + } + if requests[ImageScene] != 1 || requests[VideoScene] != 1 { + t.Fatalf("requests=%v", requests) + } + // Even a syntactically valid video cache at the image path must be ignored. + videoPath := s.cachePath(s.runner.Config.AccessKey, config.GetAvailableModelListPath, VideoScene) + imagePath := s.cachePath(s.runner.Config.AccessKey, config.GetAvailableModelListPath, ImageScene) + raw, err := os.ReadFile(videoPath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(imagePath, raw, 0600); err != nil { + t.Fatal(err) + } + result, err := s.Get(context.Background(), "image", false) + if err != nil || result.Cached || requests[ImageScene] != 2 { + t.Fatalf("mismatched cache not refreshed: result=%+v err=%v requests=%v", result, err, requests) + } + failImage = true + for _, refresh := range []bool{true, false} { + if result, err := s.Get(context.Background(), "image", refresh); err == nil || result != nil { + t.Fatalf("image failure reused a catalog: result=%+v err=%v", result, err) + } + } + result, err = s.Get(context.Background(), "video", false) + if err != nil || !result.Cached || requests[ImageScene] != 4 || requests[VideoScene] != 1 { + t.Fatalf("image refresh invalidated video: result=%+v err=%v requests=%v", result, err, requests) + } +} + +func TestImageCatalogValidation(t *testing.T) { + for _, tc := range []struct { + name, scene, models string + valid bool + }{ + {"empty", ImageScene, `[]`, true}, + {"image", ImageScene, `[{"key":"dynamic-image","name":"图片测试模型","kind":"image"}]`, true}, + {"missing name", ImageScene, `[{"key":"x","kind":"image"}]`, false}, + {"blank name", ImageScene, `[{"key":"x","kind":"image","name":" "}]`, false}, + {"unknown enum is not a display name", ImageScene, `[{"key":"future_code","name":"future_code","kind":"image"}]`, false}, + {"duplicate name", ImageScene, `[{"key":"x","kind":"image","name":"名称"},{"key":"y","kind":"image","name":" 名称 "}]`, false}, + {"wrong scene", VideoScene, `[{"key":"x","kind":"image"}]`, false}, + {"wrong kind", ImageScene, `[{"key":"x","kind":"video"}]`, false}, + {"duplicate", ImageScene, `[{"key":"x","kind":"image"},{"key":"x","kind":"image"}]`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"` + tc.scene + `","config_key":"key","config":{"models":` + tc.models + `}}}`)) + }) + result, err := s.Get(context.Background(), "image", false) + if (err == nil) != tc.valid { + t.Fatalf("result=%+v err=%v valid=%v", result, err, tc.valid) + } + }) + } +} + +func TestImageNamesKeepWireKeysInternalAcrossCache(t *testing.T) { + const response = `{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-config","config":{"models":[{"key":"wire-image","name":"智能图片V2","kind":"image"},{"key":"wire-image-fast","report_name":"wire-image-fast","name":"智能图片V2.5 Fast","kind":"image","is_default":true,"supported_ratio_list":[3,6]}]}}}` + s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(response)) }) + for i := 0; i < 2; i++ { + result, err := s.Get(context.Background(), "image", false) + if err != nil { + t.Fatal(err) + } + if result.Cached != (i == 1) { + t.Fatalf("cached=%v", result.Cached) + } + catalog := result.Catalog + list := catalog.Search("智能图片") + if len(list) != 2 || list[1].Name != "智能图片V2.5 Fast" { + t.Fatalf("unexpected image names: %+v", list) + } + if len(catalog.Search("wire-image")) != 0 || len(catalog.Search("fast")) != 1 { + t.Fatal("image search must use display names only") + } + raw, err := json.Marshal(list) + if err != nil || strings.Contains(string(raw), `"key"`) || strings.Contains(string(raw), "wire-image") { + t.Fatalf("list leaked identifiers: %s, %v", raw, err) + } + detail, err := catalog.Describe(list[1].Name) + if err != nil || strings.Contains(string(detail), "wire-image") || !strings.Contains(string(detail), "智能图片V2.5 Fast") { + t.Fatalf("detail leaked identifiers or lost name: %s, %v", detail, err) + } + key, err := catalog.ImageModelKey(" 智能图片V2.5 Fast ") + if err != nil || key != "wire-image-fast" { + t.Fatalf("name resolution: key=%q, err=%v", key, err) + } + for _, invalid := range []string{"", "智能图片", "wire-image-fast", "不存在"} { + if _, err := catalog.ImageModelKey(invalid); err == nil || strings.Contains(err.Error(), "wire-image") { + t.Fatalf("unresolved input accepted or leaked: %q, %v", invalid, err) + } + if _, err := catalog.Describe(invalid); err == nil || strings.Contains(err.Error(), "wire-image") { + t.Fatalf("detail accepted unresolved input: %q, %v", invalid, err) + } + } + if !strings.Contains(string(catalog.Config.Models[1]), "wire-image-fast") { + t.Fatal("presentation mutated the cached request identifier") + } + if got := catalog.ImageDisplayMessage("wire-image-fast / wire-image"); got != "智能图片V2.5 Fast / 智能图片V2" { + t.Fatalf("error must use names: %s", got) + } + } +} diff --git a/package-lock.json b/package-lock.json index d424e5c..1a63879 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pippit-dev/cli", - "version": "1.0.31", + "version": "1.0.33", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pippit-dev/cli", - "version": "1.0.31", + "version": "1.0.33", "hasInstallScript": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 2174d51..644e24b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pippit-dev/cli", - "version": "1.0.31", + "version": "1.0.33", "description": "Pippit CLI", "bin": { "pippit-tool-cli": "scripts/run.js" diff --git a/skills/xyq-nest-skill/SKILL.md b/skills/xyq-nest-skill/SKILL.md index a9bc7f3..22601ec 100644 --- a/skills/xyq-nest-skill/SKILL.md +++ b/skills/xyq-nest-skill/SKILL.md @@ -10,8 +10,6 @@ metadata: 通过 CLI 完成生成、处理、结果下载与媒体交付。支持下表中的操作;不提供多轮会话续写或自动拆分剧本、分镜并编排成片的能力。复杂需求先确认能由所列命令完成的具体操作,不承诺未覆盖的流程。 -商品图文营销一键成片(剧情广告、品牌大片、达人带货)由独立的 `xyq-marketing-skill` 提供,不用普通生视频代替营销编排;未安装该 Skill 时先说明当前能力边界。 - ## 开始执行 1. 画布任务运行 `node "{baseDir}/scripts/ensure-cli.js" --canvas`,其他任务运行 `node "{baseDir}/scripts/ensure-cli.js"`。保存返回的 `cli_path`;Canvas 还需保存 `canvas_entry`。文档中的 `pippit-tool-cli` 替换为带引号的 `cli_path`;画布语义命令按模块说明通过 Node 入口执行。同一任务复用,安装细节见 [安装说明](scripts/install.md)。 @@ -29,7 +27,7 @@ metadata: | 查看登录状态、登录、退出或切换账号 | `status` / `login` / `logout` | [授权](commands/auth.md) | | 创建或查询小云雀个人画布,编辑节点、布局、连线、角色/场景、提示词、3D 或多轨草稿 | `canvas` | [Canvas 能力与命令发现](commands/canvas.md) | | 生成图片,或基于参考图修改图片 | `generate-image` | [生图与图片编辑](commands/generate-image.md) | -| 查询当前可用的视频模型、参数配置 | `model list` / `model describe` | [模型发现](commands/model.md) | +| 查询当前可用的图片/视频模型、比例、分辨率及推理强度等参数配置 | `model list` / `model describe` | [模型发现](commands/model.md) | | 生成视频,使用图/视频/音频参考,首尾帧生视频 | `generate-video` | [生视频](commands/generate-video.md) | | 提升已有视频分辨率、视频超分 | `video-super-resolution` | [超分](commands/video-super-resolution.md) | | 去除已有视频字幕 | `erase-video-subtitle` | [擦字幕](commands/erase-video-subtitle.md) | @@ -45,6 +43,7 @@ metadata: ## 执行总则 - 保留用户原始 prompt,不擅自扩写、润色、翻译或增加风格词;参数转换按对应命令文档执行。生成和视频处理只传用户给定的可选创作参数,缺少必填项先询问。画布输入还需使用实际查询的 ID、版本和当前 schema。模型和参数最终合法性由服务端判断。 +- 图片模型对用户只展示服务端返回的名称;列表、选择、详情与生成命令均使用完整名称,不解释或展示对应底层枚举。名称到提交标识由 CLI 解析,详见 [模型发现](commands/model.md)。 - 用户明确要求生成或处理,即可在该范围内执行;仅咨询用法、费用或方案时不提交。范围、必填信息或消耗 credits 的授权不明确时,先确认,不重复索要已给出的授权。 - 提问优先使用宿主实际提供且当前模式允许的工具:Codex 的 `request_user_input` 或 `request_user_input_async`,WorkBuddy 的 `ask_user_question`;不可用时用普通聊天。需要答案时等待答复。 - 素材参数接收本地文件路径,CLI 内部上传。远程链接不能冒充本地路径;缺少可访问文件时先解决素材获取。单文件必须小于 500 MB(500000000 字节)。 diff --git a/skills/xyq-nest-skill/commands/generate-image.md b/skills/xyq-nest-skill/commands/generate-image.md index 7588892..1facc6d 100644 --- a/skills/xyq-nest-skill/commands/generate-image.md +++ b/skills/xyq-nest-skill/commands/generate-image.md @@ -7,25 +7,30 @@ | 参数 | 必填 | 规则 | | --- | --- | --- | | `--prompt` | 是 | 用户原始描述,不能全为空白 | -| `--model` | 是 | 用户选择的图片模型;缺少时先询问 | +| `--model` | 是 | 用户选择的图片模型完整名称;包含空格时加引号,缺少时先询问 | | `--image` | 否 | 本地参考图路径,多张图重复此参数,保留用户指定的角色与顺序 | -| `--ratio` | 否 | 整数枚举,按下表转换明确的比例要求 | -| `--resolution` | 否 | 仅 `seedream_5.0_pro` 支持 `1K`、`2K`、`4K` 选项 | +| `--ratio` | 否 | 查询返回的数字枚举(如 `3` 表示 `9:16`);比例含义见 `option_labels` | +| `--resolution` | 否 | 对应模型查询返回的分辨率;提交时转为大写 | +| `--effort` | 否 | 对应模型查询返回的推理强度;提交为 `general_agent_settings.image_effort`,转为小写 | | `--generate-image-count` | 否 | 用户指定的生成数量 | -比例映射:`0=原始比例/自动`、`2=16:9`、`13=21:9`、`3=9:16`、`4=4:3`、`5=3:4`、`6=1:1`。此命令接收整数,不传 `--ratio "16:9"`。用户未给比例时省略;不明确的比例先确认,不猜枚举。 +先用 `pippit-tool-cli model list --type image` 查询模型,再用 `pippit-tool-cli model describe "模型名称" --type image` 查询比例、分辨率和可选推理强度。对用户只展示名称,不提供底层模型枚举。生成命令用 `--model "完整名称"`,CLI 负责从当前配置中解析提交标识;名称不存在或重复时停止。查询失败时按错误处理,不回退静态模型目录;细节见 [模型发现](model.md)。 -当前 CLI 帮助列出的模型包括 `seedream_5.0_pro`、`seedream_5.0`、`seedream_4.3`、`nova2`、`seedream_4.5`、`seedream_4.1`、`seedream_4`。用于提示选择,实际支持情况以当前帮助和服务端为准,不在 Skill 中增加模型白名单校验。 +图片比例只传数字,例如用户指定 `9:16` 时使用 `--ratio 3`。从模型查询的 `option_labels` 确认含义,再将对应的数字 `options` 传入;`0` 表示自动比例。CLI 校验整数格式,模型是否支持该枚举由服务端决定。 + +推理强度只有部分模型包含;未下发 `effort` 维度时不主动提供档位选择。用户未指定的比例、分辨率、推理强度均省略,不从查询默认值自动补齐。条件维度和参数组合以服务端下发为准,不将每个维度的选项任意组合;不在 Skill 或 CLI 维护模型白名单。 + +使用旧版 CLI 时先检查 `generate-image --help`;若 `--model` 仍要求 key,或缺少用户指定的 `--effort`,先升级,不能把底层枚举交给用户或静默丢弃推理强度。 本地图片后缀支持 `.jpg/.jpeg/.png/.gif/.bmp/.webp/.svg`。参考图由命令内部上传,不需要自行获取资产 ID。 ## 最小调用 ```bash -pippit-tool-cli generate-image --prompt "用户原始描述" --model IMAGE_MODEL +pippit-tool-cli generate-image --prompt "用户原始描述" --model "智能图片V2.5 Fast" ``` -只追加用户已提供的可选参数。多图编辑见 [参考图编辑示例](../examples/image-edit.md)。 +上例名称须存在于实际查询结果,不能当作静态可用列表。CLI 在上传前复用当前凭证下的有效缓存或查询模型列表,提交时才使用底层枚举,不让用户填写枚举。只追加用户已提供的可选参数。多图编辑见 [参考图编辑示例](../examples/image-edit.md)。 ## 返回与处理 diff --git a/skills/xyq-nest-skill/commands/model.md b/skills/xyq-nest-skill/commands/model.md index 61e4bdf..f164559 100644 --- a/skills/xyq-nest-skill/commands/model.md +++ b/skills/xyq-nest-skill/commands/model.md @@ -1,8 +1,17 @@ -# model:发现可用视频模型 +# model:发现可用图片与视频模型 -需要有效登录或 `XYQ_ACCESS_KEY`。生成前可查询当前账号可用的模型及参数配置;目前只支持视频模型。 +需要有效登录或 `XYQ_ACCESS_KEY`。生成前可查询当前账号可用的模型及参数配置。`--type image` 查询图片,`--type video` 查询视频;省略时保持查询视频。两类查询共用 Skill 模型接口,图片请求的 `scene=web_image_agent`,视频请求的 `scene=web_turbo_video_generator`,`source_model_key` 为空。身份沿用个人 AK,不传入团队范围。 + +使用旧版 CLI 时先检查 `model --help` 是否支持图片名称查询,并确认 `generate-image --help` 的 `--model` 接受完整名称;仅支持视频或仍要求图片 key 时先升级,不能用视频结果回答图片模型问题。 ```bash +# 图片模型列表、按名称检索、按完整名称查看详情 +pippit-tool-cli model list --type image +pippit-tool-cli model search "智能图片" --type image +pippit-tool-cli model describe "智能图片V2.5 Fast" --type image +pippit-tool-cli model "智能图片V2.5 Fast" -t image +pippit-tool-cli model list --type image --refresh + # 全部可见视频模型 pippit-tool-cli model list @@ -18,9 +27,13 @@ pippit-tool-cli model list --refresh pippit-tool-cli model describe MiniMax-H3 --refresh ``` -`list` 输出 `models`,每项包含 `key`、`name`、`kind`。关键词与 key 完全一致时优先返回该项,否则按 key / name 不区分大小写检索。`describe` 只接受准确 key,输出整理后的 `model` 参数详情;不知道 key 时先查列表,不用展示名猜枚举。 +`list` 输出 `models`。图片每项只用 `name` 展示模型名称,另保留 `kind`,不输出底层模型枚举。图片搜索按名称不区分大小写匹配;`describe` 和 `generate-image --model` 使用列表中的完整名称,包含空格时加引号。名称示例以实际查询结果为准;不硬编码名称到枚举的映射。 + +视频每项继续包含 `key`、`name`、`kind`;关键词与 key 完全一致时优先返回该项,否则按 key / name 不区分大小写检索,`describe` 使用准确 key。 + +向用户列模型、展示详情或解释选择时只称图片模型名称,例如“智能图片V2.5 Fast”,不补充对应底层模型值。即使旧版工具仍返回枚举,也不要向用户展示;执行当前名称契约需要升级 CLI。 -列表与详情不输出模型级 `is_default`;服务端默认标记不代表用户授权自动选模型。用户未明确模型且未授权代选时先确认,不按列表顺序代选;比例、分辨率、时长等参数默认值继续展示。 +列表与详情不输出模型级 `is_default`;服务端默认标记不代表用户授权自动选模型。用户未明确模型且未授权代选时先确认,不按列表顺序代选;比例、分辨率、时长、推理强度等参数默认值继续展示,但不据此自动填入用户未指定的生成参数。 两种输出均包含 `scene`、`cached`、`fetched_at`、`expires_at`。合法空列表输出 `models: []`,表示当前没有可见模型。 @@ -32,7 +45,33 @@ pippit-tool-cli model describe MiniMax-H3 --refresh 缓存写入失败不丢弃本次服务端成功结果,stderr 会提示;stdout 仍为 JSON。 -## 参数配置的使用 +## 图片参数配置 + +图片详情的 `ratio`、`resolution`、`effort` 提供可用于生图命令的 `options/default`。以下只是结构示例,不代表任意模型都有这些选项: + +```json +{ + "name": "智能图片V2.5 Fast", + "kind": "image", + "ratio": { + "options": [0, 2, 6], + "default": 6, + "option_labels": {"0": "adaptive", "2": "16:9", "6": "1:1"} + }, + "resolution": {"options": ["2K", "4K"], "default": "2K"}, + "effort": {"options": ["low", "high"], "default": "low"} +} +``` + +- 画面比例的 `options/default` 保留服务端数字枚举,可直接用于图片 `--ratio`;`option_labels` 标注每个数字的比例含义,不作为输入值。存在 `ratio` 参数维度时优先使用该维度,否则读取模型级 `supported_ratio_list/default_ratio`。未知枚举和没有宽高参数支持的 Custom 不展示为可选值。 +- 图片分辨率统一为大写(如 `2K`),推理强度统一为小写(如 `high`)。模型没有下发 `effort` 时不展示该能力,也不添加推理强度参数。档位以配置为准,不固定为所有协议档位。 +- 展示有效选项及合法默认值;保留维度 `label/description/required_field/active_when_any`。维度有生效条件时先判断条件,不能将条件必选误当作始终必填。 +- 图片详情完整保留原始 `parameter_config`,其中的选项文案、未知维度、`default_combination`、`need_available_combinations`、`combination_dimension_keys` 和 `available_combinations` 都可供核对。原始条件和组合里的值保留服务端格式;比对时将比例值按整数枚举解析,并统一分辨率/推理强度大小写。 +- 顶层可选值不表示可以任意组合;只在用户提供参数时,按生效条件及合法组合选择。原始配置中的 disabled 选项不可用;未知维度不能直接拼成 CLI 参数。图片创作模式保留原始配置,不套用视频的 `generate_type`。 + +生成参数见 [生图命令](generate-image.md)。CLI 使用查询配置解析图片模型名称;名称缺失、重复或未找到时失败,刷新后重试,不回退猜测。最终模型准入和参数校验由服务端执行,用户未指定的参数不自动补齐。 + +## 视频参数配置 `describe` 将配置整理成可直接选择生成参数的结构。以下为示例片段,实际值以查询为准: @@ -50,12 +89,15 @@ pippit-tool-cli model describe MiniMax-H3 --refresh - `ratio.options/default` 按 IDL 转为字符串,可直接用于 `--ratio`。支持全部有对应生成参数的已有比例枚举;`0` 为 `adaptive`。自定义比例 `1` 没有对应的 CLI 尺寸参数,和未知枚举一样跳过;未知默认值不输出,不猜测替代值。 - `resolution.options` 去除 disabled 选项,默认值必须在有效选项内。 +- 参数维度统一保留 `label/description/required_field/active_when_any`;显式的 `required_field: false` 仍输出,未下发时省略。通用元数据不因图片或视频类型而被丢弃。 - `Seedance_2.0_mini`、`Seedance_2.0_mini_lite` 的生成请求可以省略 `--resolution`,服务端默认 `720p`。查询未返回分辨率维度时,不因此要求用户补填,也不在查询结果中伪造选项。 - `duration` 来自参数维度:范围输出 `min/max/step`,选项输出数字 `options`,均以秒为单位,不把选项枚举号当秒数。若只下发旧 `supported_duration_list`,暂保留该原始字段并明确提示不能直接用于 `--duration`。 - `material_limits` 保留数量字段;大小字段使用 `max_image_size_bytes`,视频时长字段使用 `min_video_duration_ms/max_video_duration_ms/max_total_video_duration_ms`。字段未返回与值为 `0` 保持区别。 - `creation_modes` 保留 `enabled`、素材校验等服务端策略,并为文本/参考/首尾帧模式标注对应 `generate_type`。`smart` 比例策略显示 `adaptive`;其他可识别模式继承模型比例。MiniMax 的文本模式排除 `adaptive`,纯文生视频使用固定比例。原始模式 key 不是生成参数,未标注 `generate_type` 的模式不代表 CLI 已接入。 - 条件维度的 `active_when_any`、参数组合约束及其他未转换字段保留;模型级选项不保证任意组合都可用。配置不一致时通过 `warnings` 提示刷新,未知比例枚举直接跳过。 -内部 `config_key` 不输出。缓存仍保存服务端原始配置,列表/详情展示时转换,不修改生成请求、不新增本地模型准入限制。生成参数格式见 [生视频命令](generate-video.md)。 +内部 `config_key` 不输出。缓存仍保存服务端原始配置,列表/详情展示时转换。图片模型标识仅在提交时从名称解析,视频请求契约保持不变,不新增静态模型准入限制。生成参数格式见 [生视频命令](generate-video.md)。 + +查询结果反映当前账号的服务端可见配置与 Skill 准入范围;最终提交仍由服务端判断权限、参数、余额等条件。缓存最多滞后 5 分钟,需要最新值时使用 `--refresh`。图片生成在上传前使用当前凭证的有效缓存解析完整名称;没有有效缓存时先请求图片模型列表。解析失败时不上传、不提交,不把名称直接传给服务端。视频生成仍不自动查询模型列表。 -查询结果反映当前账号的服务端可见配置及 Skill 模型白名单;最终提交仍由服务端判断权限、参数、余额等条件。缓存最多滞后 5 分钟,需要最新值时使用 `--refresh`。生成命令不会自动请求模型列表或凭缓存拦截生成。 +图片查询依赖服务端 Skill 接口开放 `web_image_agent`,生成依赖对应模型和参数的提交支持。接口返回场景不支持或参数拒绝时如实报告;不能用 Web 模型截图、静态列表或视频目录替代真实查询结果。 diff --git a/skills/xyq-nest-skill/examples/generate-and-deliver.md b/skills/xyq-nest-skill/examples/generate-and-deliver.md index e57db8c..e708a5a 100644 --- a/skills/xyq-nest-skill/examples/generate-and-deliver.md +++ b/skills/xyq-nest-skill/examples/generate-and-deliver.md @@ -1,11 +1,13 @@ # 基础示例:生成一张图并交付 -用户请求:“用 seedream_5.0_pro 生成一张白底红色马克杯图片。” +用户请求:“用 Seedream 5.0 Pro 生成一张白底红色马克杯图片。” 先按 [入口](../SKILL.md) 完成安装检查与登录,读取 [生图命令](../commands/generate-image.md) 和 [异步交付流程](../workflows/async-delivery.md)。下列命令名替换为检查返回的 `cli_path`。 +先查询图片模型列表,确认其中包含用户指定的完整名称;以下名称仅为示例。 + ```bash -pippit-tool-cli generate-image --prompt "用 seedream_5.0_pro 生成一张白底红色马克杯图片。" --model seedream_5.0_pro --generate-image-count 1 +pippit-tool-cli generate-image --prompt "用 Seedream 5.0 Pro 生成一张白底红色马克杯图片。" --model "Seedream 5.0 Pro" --generate-image-count 1 ``` 此处模型与数量均来自用户请求,不添加比例或分辨率。保存实际返回的任务 ID 并展示任务链接,然后将下面的占位符替换为真实值: diff --git a/skills/xyq-nest-skill/examples/image-edit.md b/skills/xyq-nest-skill/examples/image-edit.md index d1afacc..91dddd6 100644 --- a/skills/xyq-nest-skill/examples/image-edit.md +++ b/skills/xyq-nest-skill/examples/image-edit.md @@ -1,13 +1,15 @@ # 场景:保留多张参考图的角色 -用户请求:“用 seedream_5.0_pro,图1是底图,图2只提供猫的形象,把图1的猫换成图2的猫,背景和其他物体不变。” +用户请求:“用 Seedream 5.0 Pro,图1是底图,图2只提供猫的形象,把图1的猫换成图2的猫,背景和其他物体不变。” 用户明确图1为 `/path/to/scene.png`,图2为 `/path/to/cat.png`。完成 [入口](../SKILL.md) 的前置步骤后,读取 [生图命令](../commands/generate-image.md)。 +先查询图片模型列表,确认其中包含用户指定的完整名称;以下名称仅为示例。 + ```bash pippit-tool-cli generate-image \ - --prompt "用 seedream_5.0_pro,图1是底图,图2只提供猫的形象,把图1的猫换成图2的猫,背景和其他物体不变。" \ - --model seedream_5.0_pro \ + --prompt "用 Seedream 5.0 Pro,图1是底图,图2只提供猫的形象,把图1的猫换成图2的猫,背景和其他物体不变。" \ + --model "Seedream 5.0 Pro" \ --image "/path/to/scene.png" \ --image "/path/to/cat.png" ``` diff --git a/skills/xyq-nest-skill/tests/agent_test_cases.md b/skills/xyq-nest-skill/tests/agent_test_cases.md index 430686e..430689b 100644 --- a/skills/xyq-nest-skill/tests/agent_test_cases.md +++ b/skills/xyq-nest-skill/tests/agent_test_cases.md @@ -6,11 +6,19 @@ | 场景 | 用户输入/条件 | 期望行为 | | --- | --- | --- | -| 基础生图 | 用 seedream_5.0_pro 生成一张白底红色马克杯图片 | 读取生图模块与异步交付流程,保留原文,模型和数量来自请求,不补比例/分辨率 | +| 基础生图 | 用 Seedream 5.0 Pro 生成一张白底红色马克杯图片 | 读取生图模块与异步交付流程,保留原文,模型和数量来自请求,不补比例/分辨率 | | 缺少模型 | 生成一张猫咪图片 | 询问图片模型;答案到达前不提交 | | 仅咨询 | 怎么生成一张猫咪图片? | 解释命令,不发起收费生成 | | 图片编辑 | 图1是底图,图2只提供猫形象,替换猫但保留背景;已指定模型 | 两次 --image 顺序与用户角色一致,原始指令不改写 | -| 生图比例 | 用 seedream_5.0_pro,16:9 生图 | --ratio 2;不把视频比例字符串用在生图命令中 | +| 生图比例 | 用 Seedream 5.0 Pro,16:9 生图 | 按模型查询的 option_labels 确认数字值,传 --ratio 2;用户未给的参数不补齐 | +| 图片模型发现 | 当前图片模型有哪些 | 使用 model list --type image;只展示模型名称,不展示底层枚举,不列静态名单或视频模型 | +| 图片参数查询 | 这个图片模型有哪些分辨率和推理强度 | 用完整模型名称查询 model describe --type image;仅展示实际返回的维度和有效选项 | +| 图片推理强度 | 用已查询模型、2K、high 生图 | 查询支持情况后传 --resolution 2K --effort high,保留原 prompt;旧版无 effort 时先升级 | +| 名称生成 | 用智能图片V2.5 Fast 生成一张猫咪图片 | 用完整名称传 --model,CLI 解析后仅在请求中传模型标识;对用户不解释底层枚举 | +| 名称不可用 | 模型名称缺失、重复或列表找不到 | 刷新并报告问题,不猜模型、不上传、不提交 | +| 未指定推理强度 | 模型支持 effort,但用户只指定模型 | 不从默认值自动补 --effort | +| 场景未开放 | 图片模型接口返回不支持 web_image_agent | 如实报告错误,不回退视频目录、截图名单或静态列表 | +| 参数组合受限 | 分辨率和 effort 都有多个选项,配置限定组合 | 按生效条件和合法组合解释,不承诺任意组合可用 | | 普通生视频 | 生成一个猫咪跳舞的视频 | 读取生视频模块,不要求用户说“模型直出”,未指定模型时省略 | | 视频参数 | 用 Seedance_2.5,9:16,720p,8 秒生成猫咪视频 | 保留 prompt,--ratio 9:16、--duration 8,不改成整数比例或时长范围 | | 时长范围 | 生成一个 5 到 10 秒的视频 | 先询问具体秒数,不臆造范围参数 |