From 78879f99b1b48b64080ca34701c4166ba7c6adae Mon Sep 17 00:00:00 2001 From: ykb Date: Sun, 20 Sep 2026 16:20:16 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20MiniMax=20?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E6=A8=A1=E5=9E=8B=E7=9A=84=20CLI=20=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E8=AF=B4=E6=98=8E=E4=B8=8E=E9=80=8F=E4=BC=A0=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/generate_video/generate_video.go | 6 ++-- .../generate_video/generate_video_test.go | 35 +++++++++++++++++++ .../xyq-nest-skill/commands/generate-video.md | 34 ++++++++++++++---- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/cmd/generate_video/generate_video.go b/cmd/generate_video/generate_video.go index cf8e9dc..4127fb9 100644 --- a/cmd/generate_video/generate_video.go +++ b/cmd/generate_video/generate_video.go @@ -48,8 +48,8 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command flags.StringArrayVar(&opts.AudioPaths, "audio", nil, "local reference audio path; repeat for multiple audios, up to 3") flags.IntVar(&durationSec, "duration", 0, "video duration in seconds") flags.StringVar(&opts.Ratio, "ratio", "", "video ratio, such as 9:16, 16:9, 3:4, 4:3") - flags.StringVar(&opts.Model, "model", "", "video model; normal users: Seedance_2.0_mini_lite; VIP-only: seedance2.0_vision, seedance2.0_fast_vision, Seedance_2.0_mini, Seedance_2.5") - flags.StringVar(&opts.Resolution, "resolution", "", "video resolution, such as 720p, 1080p") - flags.Int64Var(&generateType, "generate-type", 0, "generation type passed to the service; set 1 for first-and-last-frame generation and provide two --image values in first-frame, last-frame order") + flags.StringVar(&opts.Model, "model", "", "supported video models: Seedance_2.0_mini_lite, seedance2.0_vision, seedance2.0_fast_vision, Seedance_2.0_mini, Seedance_2.5, MiniMax-H3, MiniMax-H3-Max") + flags.StringVar(&opts.Resolution, "resolution", "", "video resolution; MiniMax-H3: 768p/2k; MiniMax-H3-Max: 480p/768p; other models, such as 720p/1080p") + flags.Int64Var(&generateType, "generate-type", 0, "generation type passed to the service; set 1 for first-and-last-frame generation and provide two --image values in first-frame, last-frame order; MiniMax also accepts a single first-frame image in this mode") return cmd } diff --git a/internal/generate_video/generate_video_test.go b/internal/generate_video/generate_video_test.go index 6e13d29..dc785d7 100644 --- a/internal/generate_video/generate_video_test.go +++ b/internal/generate_video/generate_video_test.go @@ -15,3 +15,38 @@ func TestBuildSubmitRunBodyPreservesEmptyPrompt(t *testing.T) { t.Fatalf("video_part_tool_param = %s, want explicit empty prompt", got) } } + +func TestMiniMaxModelParametersPassThrough(t *testing.T) { + for _, model := range []string{"MiniMax-H3", "MiniMax-H3-Max"} { + t.Run(model, func(t *testing.T) { + opts := &Options{Prompt: "cat walking", Model: model, Ratio: "16:9"} + if err := ValidateOptions(opts); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(buildSubmitRunBody(opts, nil, nil, nil)) + if err != nil { + t.Fatal(err) + } + var body struct { + AgentName string `json:"agent_name"` + Param map[string]any `json:"video_part_tool_param"` + } + if err := json.Unmarshal(data, &body); err != nil { + t.Fatal(err) + } + if body.AgentName != "pippit_video_part_agent" || body.Param["model"] != model || body.Param["ratio"] != "16:9" { + t.Fatalf("unexpected body: %s", data) + } + for _, field := range []string{"duration_sec", "resolution", "generate_type"} { + if _, exists := body.Param[field]; exists { + t.Fatalf("CLI must leave %s defaults to the API: %s", field, data) + } + } + // API owns the Max 21:9 rejection; the CLI must not add an enum gate. + opts.Ratio = "21:9" + if err := ValidateOptions(opts); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/skills/xyq-nest-skill/commands/generate-video.md b/skills/xyq-nest-skill/commands/generate-video.md index ec24d9d..fb6f146 100644 --- a/skills/xyq-nest-skill/commands/generate-video.md +++ b/skills/xyq-nest-skill/commands/generate-video.md @@ -7,26 +7,46 @@ | 参数 | 必填 | 规则 | | --- | --- | --- | | `--prompt` | 是 | 用户原始描述,不能全为空白 | -| `--model` | 否 | 用户指定的模型;未提供时省略,由服务端处理默认配置 | +| `--model` | 是 | 用户指定的准确模型枚举;未明确模型时先确认,不猜测 | | `--image` | 否 | 本地图片路径,重复参数,最多 9 张 | | `--video` | 否 | 本地参考视频路径,重复参数,最多 3 个 | | `--audio` | 否 | 本地 `.mp3/.wav` 音频路径,重复参数,最多 3 个 | -| `--duration` | 否 | 整数秒;用户只给时长范围时先确认具体秒数 | -| `--ratio` | 否 | 比例字符串,如 `9:16`、`16:9`、`3:4`、`4:3`;不转换为生图枚举 | -| `--resolution` | 否 | 用户指定值,如 `720p`、`1080p` | +| `--duration` | 按模型 | 整数秒;MiniMax 可省略使用下表默认值,其他模型需明确提供;用户只给时长范围时先确认具体秒数 | +| `--ratio` | 按模式 | 比例字符串,不转换为生图枚举;MiniMax 可省略使用服务端配置默认值;其他模型需明确提供 | +| `--resolution` | 按模型 | MiniMax 可省略使用下表默认值;其他模型需明确提供,如 `720p`、`1080p` | | `--generate-type` | 否 | 首尾帧任务传 `1`;其他显式值交服务端处理 | -普通用户模型为 `Seedance_2.0_mini_lite`;VIP 模型包括 `seedance2.0_vision`、`seedance2.0_fast_vision`、`Seedance_2.0_mini`、`Seedance_2.5`。该列表仅用于选择提示,以当前帮助和服务端为准,不自行新增模型或分辨率组合校验。 +支持的模型枚举:`Seedance_2.0_mini_lite`、`seedance2.0_vision`、`seedance2.0_fast_vision`、`Seedance_2.0_mini`、`Seedance_2.5`、`MiniMax-H3`、`MiniMax-H3-Max`。该列表仅用于选择提示,不自行新增本地模型或分辨率组合校验。 本地图片支持 `.jpg/.jpeg/.png/.gif/.bmp/.webp/.svg`;视频支持 `.mp4/.avi/.mov/.wmv/.flv/.webm/.mkv/.m4v`。CLI 内部上传参考素材。 +## MiniMax 模型 + +| 能力 | `MiniMax-H3` | `MiniMax-H3-Max` | +| --- | --- | --- | +| 分辨率 | `768p`、`2k`;默认 `768p` | `480p`、`768p`;默认 `768p` | +| 生成时长 | 整数 4–15 秒;默认 10 秒 | 整数 5–15 秒;默认 10 秒 | +| 固定比例 | `21:9`、`16:9`、`4:3`、`1:1`、`3:4`、`9:16` | `16:9`、`4:3`、`1:1`、`3:4`、`9:16`;不允许 `21:9` | +| 文生视频 | 支持;省略比例默认 `9:16`,不能用 `adaptive` | 支持;省略比例默认 `16:9`,不能用 `adaptive` | +| 普通参考图 | 最多 9 张 | 不支持 | +| 首帧/首尾帧 | 传 `--generate-type 1`,1–2 张图片 | 同左;单图也必须指定此模式 | +| 视频参考 | 最多 3 个,单条 2–15 秒,总时长不超过 15 秒 | 不支持 | +| 音频参考 | 必须同时有参考图片或视频;CLI 最多上传 3 个,Agent 继续执行自身限制 | 不支持 | + +以上是当前服务端配置对应的能力,实际以请求时返回的配置为准。图片单张不超过 30MB;H3 素材合计最多 12 个,Max 最多 2 个。两模型首尾帧模式都不能混用参考视频或音频。H3 带视觉素材时可显式使用 `adaptive`,省略比例仍取配置默认值;Max 首尾帧模式统一使用 `adaptive`;即使传入固定比例,服务端也会自动改为自适应比例。`720p` 是兼容输入,会映射为实际输出 `768p`;新调用优先直接使用 `768p`。当前 H3 配置未提供音频独立数量和时长上限,API 不额外硬编码这些限制;CLI 上传限制及 Agent 校验仍生效。 + +```bash +pippit-tool-cli generate-video --prompt "用户原始描述" --model MiniMax-H3 --ratio 16:9 +pippit-tool-cli generate-video --prompt "用户原始描述" --model MiniMax-H3-Max --generate-type 1 --image FIRST_FRAME_PATH --resolution 768p --duration 10 +``` + ## 最小调用 ```bash -pippit-tool-cli generate-video --prompt "用户原始描述" +pippit-tool-cli generate-video --prompt "用户原始描述" --model seedance2.0_vision --duration 5 --ratio 16:9 --resolution 720p ``` -首尾帧场景:明确两张图片的角色,按首帧、尾帧顺序传两次 `--image`,固定传 `--generate-type 1`。缺少图片、角色不清或无法确定原始描述时先询问。完整示例见 [首尾帧生视频](../examples/first-last-frame.md)。 +首尾帧场景:明确两张图片的角色,按首帧、尾帧顺序传两次 `--image`,固定传 `--generate-type 1`。MiniMax 只给首帧时也可传一张图片,仍需 `--generate-type 1`。缺少图片、角色不清或无法确定原始描述时先询问。完整示例见 [首尾帧生视频](../examples/first-last-frame.md)。 ## 返回与处理 From e2d444c6f05fd1551461a2df4a2363e2e2b2045f Mon Sep 17 00:00:00 2001 From: ykb Date: Sun, 20 Sep 2026 16:56:25 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20CLI=20=E6=9B=B4=E6=96=B0=E6=97=B6?= =?UTF-8?q?=E4=BC=98=E5=85=88=E5=88=B7=E6=96=B0=20npm=20=E5=85=83=E6=95=B0?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/update/update.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/update/update.go b/cmd/update/update.go index ae15d1d..4e12a4b 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -63,7 +63,7 @@ func runUpdate(stdout, stderr io.Writer) error { if err != nil { return fmt.Errorf("准备替换当前可执行文件失败: %w", err) } - if err := runInheritEnv(stderr, []string{"PIPPIT_CLI_SKIP_SKILLS=1"}, "npm", "install", "-g", pkg); err != nil { + if err := runInheritEnv(stderr, []string{"PIPPIT_CLI_SKIP_SKILLS=1"}, "npm", "install", "-g", "--prefer-online", pkg); err != nil { restore() return fmt.Errorf("更新 pippit-tool-cli 失败: %w", err) } From ce3a6f13e536f2dd06160075ee6001ca67c1d58a Mon Sep 17 00:00:00 2001 From: ykb Date: Sun, 20 Sep 2026 17:09:53 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=E6=8C=87=E5=AE=9A?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E6=B5=8B=E8=AF=95=20PPE=20=E6=B3=B3=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/common/client.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/common/client.go b/internal/common/client.go index b81ab32..4cc5b41 100644 --- a/internal/common/client.go +++ b/internal/common/client.go @@ -226,6 +226,11 @@ func (c *httpClient) prepareRequest(ctx context.Context, req *http.Request, head if c.authorizer == nil { return fmt.Errorf("授权请求缺少认证器") } + // Local PPE validation: keep API requests in the same test lane. + if lane := strings.TrimSpace(os.Getenv("PIPPIT_GENERATE_VIDEO_SUBMIT_X_TT_ENV")); lane != "" { + req.Header.Set("x-tt-env", lane) + req.Header.Set("x-use-ppe", "1") + } if err := c.authorizer.Inject(ctx, req); err != nil { return fmt.Errorf("写入认证请求头失败: %w", err) } From 36f3d23736d6eb997262f514fd1afd32d83974af Mon Sep 17 00:00:00 2001 From: ykb Date: Sun, 20 Sep 2026 19:20:08 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20CLI=20=E6=8E=A5=E5=85=A5=20Wan=20?= =?UTF-8?q?=E5=92=8C=20HappyHorse=20=E5=B9=B6=E9=80=8F=E4=BC=A0=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E6=88=90=E6=9C=AC=E5=BD=92=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/generate_video/generate_video.go | 6 +- internal/common/submit_run.go | 29 +++++- internal/generate_video/generate_video.go | 12 +++ .../generate_video/generate_video_test.go | 93 ++++++++++++++++++- .../xyq-nest-skill/commands/generate-video.md | 34 +++++-- 5 files changed, 160 insertions(+), 14 deletions(-) diff --git a/cmd/generate_video/generate_video.go b/cmd/generate_video/generate_video.go index 52ced7f..5090d0a 100644 --- a/cmd/generate_video/generate_video.go +++ b/cmd/generate_video/generate_video.go @@ -48,8 +48,8 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command flags.StringArrayVar(&opts.AudioPaths, "audio", nil, "local reference audio path; repeat for multiple audios") flags.IntVar(&durationSec, "duration", 0, "video duration in seconds") flags.StringVar(&opts.Ratio, "ratio", "", "video ratio, such as 9:16, 16:9, 3:4, 4:3") - flags.StringVar(&opts.Model, "model", "", "supported video models: Seedance_2.0_mini_lite, seedance2.0_vision, seedance2.0_fast_vision, Seedance_2.0_mini, Seedance_2.5, MiniMax-H3, MiniMax-H3-Max") - flags.StringVar(&opts.Resolution, "resolution", "", "video resolution; MiniMax-H3: 768p/2k; MiniMax-H3-Max: 480p/768p; other models, such as 720p/1080p") - flags.Int64Var(&generateType, "generate-type", 0, "generation type passed to the service; set 1 for first-and-last-frame generation and provide two --image values in first-frame, last-frame order; MiniMax also accepts a single first-frame image in this mode") + flags.StringVar(&opts.Model, "model", "", "supported video models: Seedance_2.0_mini_lite, seedance2.0_vision, seedance2.0_fast_vision, Seedance_2.0_mini, Seedance_2.5, MiniMax-H3, MiniMax-H3-Max, wan3.0, happyhorse-1.1") + flags.StringVar(&opts.Resolution, "resolution", "", "video resolution; MiniMax-H3: 768p/2k; MiniMax-H3-Max: 480p/768p; wan3.0: 480p/720p/1080p; happyhorse-1.1: 720p/1080p; other models, such as 720p/1080p") + flags.Int64Var(&generateType, "generate-type", 0, "generation type passed to the service; set 1 for first-and-last-frame generation and provide --image values in first-frame, last-frame order; MiniMax and Wan also accept a single first-frame image in this mode") return cmd } diff --git a/internal/common/submit_run.go b/internal/common/submit_run.go index d9d1d46..3d9fff4 100644 --- a/internal/common/submit_run.go +++ b/internal/common/submit_run.go @@ -2,7 +2,9 @@ package common import ( "context" + "encoding/json" "fmt" + "net/url" "github.com/Pippit-dev/pippit-cli/internal/config" ) @@ -37,11 +39,36 @@ type SubmitRunResponseRun struct { // SubmitRun sends a submit_run request and validates its shared response envelope. func SubmitRun(ctx context.Context, command string, body any, runner *Runner) (*SubmitRunResult, error) { + return submitRun(ctx, command, SubmitRunPath(runner), body, runner) +} + +// SubmitRunWithBabiParam carries client attribution in the same query field as Web. +func SubmitRunWithBabiParam(ctx context.Context, command string, body any, runner *Runner, babiParam map[string]string) (*SubmitRunResult, error) { + path, err := url.Parse(SubmitRunPath(runner)) + if err != nil { + return nil, fmt.Errorf("解析 submit_run 路径失败: %w", err) + } + query, err := url.ParseQuery(path.RawQuery) + if err != nil { + return nil, fmt.Errorf("解析 submit_run 查询参数失败: %w", err) + } + raw, err := json.Marshal(babiParam) + if err != nil { + return nil, fmt.Errorf("序列化 babi_param 失败: %w", err) + } + if !query.Has("babi_param") { + query.Set("babi_param", string(raw)) + } + path.RawQuery = query.Encode() + return submitRun(ctx, command, path.String(), body, runner) +} + +func submitRun(ctx context.Context, command, path string, body any, runner *Runner) (*SubmitRunResult, error) { if runner == nil || runner.Client == nil { return nil, fmt.Errorf("%s 运行器客户端缺失", command) } var resp SubmitRunResponse - if err := runner.Client.SendRequest(ctx, SubmitRunPath(runner), body, &resp); err != nil { + if err := runner.Client.SendRequest(ctx, path, body, &resp); err != nil { return nil, fmt.Errorf("提交 %s 请求失败: %w", command, err) } if resp.Ret != "0" { diff --git a/internal/generate_video/generate_video.go b/internal/generate_video/generate_video.go index be2762a..7183faa 100644 --- a/internal/generate_video/generate_video.go +++ b/internal/generate_video/generate_video.go @@ -54,6 +54,18 @@ func Run(ctx context.Context, opts *Options, runner *common.Runner) (*Result, er } body := buildSubmitRunBody(opts, imageAssetIDs, videoAssetIDs, audioAssetIDs) + switch strings.TrimSpace(opts.Model) { + case "MiniMax-H3", "MiniMax-H3-Max", "wan3.0", "happyhorse-1.1": + // Follow Web video generation's business scene; this command enters through Skill. + return common.SubmitRunWithBabiParam(ctx, "generate-video", body, runner, map[string]string{ + "scene_lv1": "ai_agent", + "scene_lv2": "front_tool", + "tool_id": "instant_video", + "tab_name": "other", + "edit_type": "instant_video", + "enter_from": "skill", + }) + } return common.SubmitRun(ctx, "generate-video", body, runner) } diff --git a/internal/generate_video/generate_video_test.go b/internal/generate_video/generate_video_test.go index dc785d7..36119e1 100644 --- a/internal/generate_video/generate_video_test.go +++ b/internal/generate_video/generate_video_test.go @@ -1,8 +1,13 @@ package generate_video import ( + "context" "encoding/json" + "net/url" "testing" + + "github.com/Pippit-dev/pippit-cli/internal/common" + "github.com/Pippit-dev/pippit-cli/internal/config" ) func TestBuildSubmitRunBodyPreservesEmptyPrompt(t *testing.T) { @@ -16,8 +21,92 @@ func TestBuildSubmitRunBodyPreservesEmptyPrompt(t *testing.T) { } } -func TestMiniMaxModelParametersPassThrough(t *testing.T) { - for _, model := range []string{"MiniMax-H3", "MiniMax-H3-Max"} { +type videoSubmitRecordingClient struct { + common.Client + path string + body any +} + +func (c *videoSubmitRecordingClient) SendRequest(_ context.Context, path string, body, out any) error { + c.path, c.body = path, body + return json.Unmarshal([]byte(`{"ret":"0","data":{"run":{"thread_id":"thread_123","run_id":"run_123"}}}`), out) +} + +func TestRunCarriesModelHubROIQuery(t *testing.T) { + for _, model := range []string{"MiniMax-H3", "MiniMax-H3-Max", "wan3.0", "happyhorse-1.1", "Seedance_2.5", ""} { + t.Run(model, func(t *testing.T) { + client := &videoSubmitRecordingClient{} + runner := &common.Runner{ + Client: client, + Config: &config.Config{Paths: &config.Paths{SubmitRun: "/custom/submit_run?source=a%2Bb&item=1&item=2"}}, + } + if _, err := Run(context.Background(), &Options{Prompt: "cat walking", Model: model}, runner); err != nil { + t.Fatal(err) + } + path, err := url.Parse(client.path) + if err != nil { + t.Fatal(err) + } + query := path.Query() + if path.Path != "/custom/submit_run" || query.Get("source") != "a+b" || len(query["item"]) != 2 { + t.Fatalf("configured path/query lost: %s", client.path) + } + if model == "Seedance_2.5" || model == "" { + if query.Has("babi_param") { + t.Fatal("existing Seedance path must remain unchanged") + } + return + } + var babi map[string]string + if err := json.Unmarshal([]byte(query.Get("babi_param")), &babi); err != nil { + t.Fatal(err) + } + for key, want := range map[string]string{ + "scene_lv1": "ai_agent", "scene_lv2": "front_tool", "tool_id": "instant_video", + "tab_name": "other", "edit_type": "instant_video", "enter_from": "skill", + } { + if babi[key] != want { + t.Fatalf("babi_param[%s] = %q, want %q", key, babi[key], want) + } + } + body := client.body.(map[string]any) + if body["agent_name"] != common.AgentNameVideoPart || body["message"] != "cat walking" || len(body) != 3 { + t.Fatalf("ROI must not change the request body contract: %#v", body) + } + }) + } +} + +func TestRunPreservesExplicitROIQuery(t *testing.T) { + client := &videoSubmitRecordingClient{} + raw := `{"scene_lv1":"ai_agent","scene_lv2":"front_tool","tool_id":"custom_video"}` + runner := &common.Runner{Client: client, Config: &config.Config{Paths: &config.Paths{ + SubmitRun: "/custom/submit_run?babi_param=" + url.QueryEscape(raw), + }}} + if _, err := Run(context.Background(), &Options{Prompt: "cat walking", Model: "MiniMax-H3"}, runner); err != nil { + t.Fatal(err) + } + path, err := url.Parse(client.path) + if err != nil || path.Query().Get("babi_param") != raw { + t.Fatalf("explicit attribution overwritten: %s, %v", client.path, err) + } +} + +func TestRunRejectsMalformedQueryBeforeSubmit(t *testing.T) { + client := &videoSubmitRecordingClient{} + runner := &common.Runner{Client: client, Config: &config.Config{Paths: &config.Paths{ + SubmitRun: "/custom/submit_run?source=%invalid", + }}} + if _, err := Run(context.Background(), &Options{Prompt: "cat walking", Model: "MiniMax-H3"}, runner); err == nil { + t.Fatal("expected malformed URL query error") + } + if client.path != "" { + t.Fatal("malformed URL must not submit a request") + } +} + +func TestConfiguredVideoModelParametersPassThrough(t *testing.T) { + for _, model := range []string{"MiniMax-H3", "MiniMax-H3-Max", "wan3.0", "happyhorse-1.1"} { t.Run(model, func(t *testing.T) { opts := &Options{Prompt: "cat walking", Model: model, Ratio: "16:9"} if err := ValidateOptions(opts); err != nil { diff --git a/skills/xyq-nest-skill/commands/generate-video.md b/skills/xyq-nest-skill/commands/generate-video.md index f86cbbd..6a23f98 100644 --- a/skills/xyq-nest-skill/commands/generate-video.md +++ b/skills/xyq-nest-skill/commands/generate-video.md @@ -11,12 +11,12 @@ | `--image` | 否 | 本地图片路径,重复参数 | | `--video` | 否 | 本地参考视频路径,重复参数 | | `--audio` | 否 | 本地 `.mp3/.wav` 音频路径,重复参数 | -| `--duration` | 按模型 | 整数秒;MiniMax 可省略使用下表默认值,其他模型需明确提供;用户只给时长范围时先确认具体秒数 | -| `--ratio` | 按模式 | 比例字符串,不转换为生图枚举;MiniMax 可省略使用服务端配置默认值;其他模型需明确提供 | -| `--resolution` | 按模型 | MiniMax 可省略使用下表默认值;其他模型需明确提供,如 `720p`、`1080p` | +| `--duration` | 按模型 | 整数秒;MiniMax、Wan、HappyHorse 可省略使用下表默认值,其他模型需明确提供;用户只给时长范围时先确认具体秒数 | +| `--ratio` | 按模式 | 比例字符串,不转换为生图枚举;MiniMax、Wan、HappyHorse 可省略使用服务端配置默认值;其他模型需明确提供 | +| `--resolution` | 按模型 | MiniMax、Wan、HappyHorse 可省略使用下表默认值;其他模型需明确提供,如 `720p`、`1080p` | | `--generate-type` | 否 | 首尾帧任务传 `1`;其他显式值交服务端处理 | -支持的模型枚举:`Seedance_2.0_mini_lite`、`seedance2.0_vision`、`seedance2.0_fast_vision`、`Seedance_2.0_mini`、`Seedance_2.5`、`MiniMax-H3`、`MiniMax-H3-Max`。该列表仅用于选择提示,不自行新增本地模型或分辨率组合校验。 +支持的模型枚举:`Seedance_2.0_mini_lite`、`seedance2.0_vision`、`seedance2.0_fast_vision`、`Seedance_2.0_mini`、`Seedance_2.5`、`MiniMax-H3`、`MiniMax-H3-Max`、`wan3.0`、`happyhorse-1.1`。该列表仅用于选择提示,不自行新增本地模型或分辨率组合校验。 本地图片支持 `.jpg/.jpeg/.png/.gif/.bmp/.webp/.svg`;视频支持 `.mp4/.avi/.mov/.wmv/.flv/.webm/.mkv/.m4v`。CLI 内部上传参考素材。 @@ -28,18 +28,36 @@ | 生成时长 | 整数 4–15 秒;默认 10 秒 | 整数 5–15 秒;默认 10 秒 | | 固定比例 | `21:9`、`16:9`、`4:3`、`1:1`、`3:4`、`9:16` | `16:9`、`4:3`、`1:1`、`3:4`、`9:16`;不允许 `21:9` | | 文生视频 | 支持;省略比例默认 `9:16`,不能用 `adaptive` | 支持;省略比例默认 `16:9`,不能用 `adaptive` | -| 普通参考图 | 最多 9 张 | 不支持 | -| 首帧/首尾帧 | 传 `--generate-type 1`,1–2 张图片 | 同左;单图也必须指定此模式 | -| 视频参考 | 最多 3 个,单条 2–15 秒,总时长不超过 15 秒 | 不支持 | +| 普通参考图 | 支持 | 不支持 | +| 首帧/首尾帧 | 传 `--generate-type 1` | 同左;单图也必须指定此模式 | +| 视频参考 | 支持,单条 2–15 秒,总时长不超过 15 秒 | 不支持 | | 音频参考 | 必须同时有参考图片或视频;Agent 继续执行自身限制 | 不支持 | -以上是当前服务端配置对应的能力,实际以请求时返回的配置为准。图片单张不超过 30MB;H3 素材合计最多 12 个,Max 最多 2 个。两模型首尾帧模式都不能混用参考视频或音频。H3 带视觉素材时可显式使用 `adaptive`,省略比例仍取配置默认值;Max 首尾帧模式统一使用 `adaptive`;即使传入固定比例,服务端也会自动改为自适应比例。`720p` 是兼容输入,会映射为实际输出 `768p`;新调用优先直接使用 `768p`。当前 H3 配置未提供音频独立数量和时长上限,API 不额外硬编码这些限制;CLI 不限制素材数量,Agent 校验仍生效。 +以上是当前服务端配置对应的能力,实际以请求时返回的配置为准。图片单张不超过 30MB。两模型首尾帧模式都不能混用参考视频或音频。H3 带视觉素材时可显式使用 `adaptive`,省略比例仍取配置默认值;Max 首尾帧模式统一使用 `adaptive`;即使传入固定比例,服务端也会自动改为自适应比例。`720p` 是兼容输入,会映射为实际输出 `768p`;新调用优先直接使用 `768p`。当前 H3 配置未提供音频独立数量和时长上限,API 不额外硬编码这些限制;CLI 不限制素材数量,Agent 校验仍生效。 ```bash pippit-tool-cli generate-video --prompt "用户原始描述" --model MiniMax-H3 --ratio 16:9 pippit-tool-cli generate-video --prompt "用户原始描述" --model MiniMax-H3-Max --generate-type 1 --image FIRST_FRAME_PATH --resolution 768p --duration 10 ``` +## Wan 3.0 与 HappyHorse 1.1 + +两模型均必须提供 `--prompt`。素材通过现有 `--image`、`--video`、`--audio` 参数上传,是否支持由服务端校验;本次不提供文件或网页链接输入。 + +| 参数 | `wan3.0` | `happyhorse-1.1` | +| --- | --- | --- | +| 分辨率 | `480p`、`720p`、`1080p` | `720p`、`1080p` | +| 生成时长 | 整数 4–30 秒,不支持 `-1` | 整数 3–15 秒 | +| 比例 | `adaptive`(智能)、`16:9`、`9:16`、`4:3`、`3:4`、`1:1` | `16:9`、`21:9`、`9:16`、`4:3`、`3:4`、`1:1` | +| 模式 | 全能参考;首尾帧传 `--generate-type 1` | 文生视频或图片参考;不使用首尾帧模式 | + +省略分辨率、时长、比例时使用服务端配置默认值。当前配置两模型均默认 `720p`、10 秒;Wan 默认 `adaptive`,HappyHorse 默认 `16:9`。Wan、HappyHorse 的 `720p` 不转换为 `768p`。HappyHorse 当前不支持视频或音频参考。实际规则以请求时的生效配置及服务端校验为准。 + +```bash +pippit-tool-cli generate-video --prompt "用户原始描述" --model wan3.0 --duration 10 --resolution 720p --ratio 16:9 +pippit-tool-cli generate-video --prompt "用户原始描述" --model happyhorse-1.1 --duration 10 --resolution 720p --ratio 16:9 +``` + ## 最小调用 ```bash From f918b99c9f90b297af2924d8dc04aedc7322c580 Mon Sep 17 00:00:00 2001 From: ykb Date: Mon, 21 Sep 2026 16:31:52 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=8F=91=E7=8E=B0=E5=92=8C=E6=9D=A5=E6=BA=90=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E5=B9=B6=E7=A7=BB=E9=99=A4=20PPE=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 7 + README.md | 24 +- cmd/generate_image/generate_image.go | 1 + cmd/generate_video/generate_video.go | 5 +- cmd/model.go | 74 +++++ cmd/model_test.go | 81 +++++ cmd/root.go | 1 + cmd/short_drama/short_drama.go | 1 + cmd/source_test.go | 179 +++++++++++ cmd/submit_run.go | 3 + cmd/video_tool/video_tool.go | 2 + internal/common/client.go | 5 - internal/common/submit_run.go | 10 + internal/config/config.go | 23 +- internal/config/config_test.go | 3 + internal/generate_image/generate_image.go | 3 +- internal/generate_video/generate_video.go | 27 +- .../generate_video/generate_video_test.go | 40 +-- internal/models/describe.go | 281 ++++++++++++++++++ internal/models/describe_test.go | 199 +++++++++++++ internal/models/models.go | 220 ++++++++++++++ internal/models/models_test.go | 200 +++++++++++++ internal/short_drama/submit_run.go | 2 + internal/video_tool/video_tool.go | 6 +- scripts/skills.test.js | 7 + skills/short-drama/SKILL.md | 4 + skills/xyq-nest-skill/SKILL.md | 10 + .../commands/erase-video-subtitle.md | 2 + .../xyq-nest-skill/commands/generate-image.md | 2 + .../xyq-nest-skill/commands/generate-video.md | 44 +-- skills/xyq-nest-skill/commands/model.md | 59 ++++ .../commands/video-super-resolution.md | 2 + 32 files changed, 1443 insertions(+), 84 deletions(-) create mode 100644 AGENTS.md create mode 100644 cmd/model.go create mode 100644 cmd/model_test.go create mode 100644 cmd/source_test.go create mode 100644 internal/models/describe.go create mode 100644 internal/models/describe_test.go create mode 100644 internal/models/models.go create mode 100644 internal/models/models_test.go create mode 100644 skills/xyq-nest-skill/commands/model.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8929e94 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# 模型发现输出约定 + +- 模型查询详情应提供可直接用于生成命令的参数值;数字枚举按对应 IDL 显式转换,不按数组位置或展示文案推断。 +- 覆盖 IDL 已定义且生成入口可消费的枚举;未知枚举直接跳过,不猜测或回退到其他值。已定义但没有 CLI 请求表达方式的枚举须明确识别为不可用。 +- 内部配置指纹不向用户输出;原始配置缓存和用户展示结构分离,保留可选字段缺失与零值的区别。 +- 模型发现验收必须分别核对用户确认的预期模型集合与真实接口返回值转换;CLI 与接口同时少返回模型不能判为完整通过。请求携带 PPE 头也不能替代实际路由和生效配置证据。 +- `Seedance_2.0_mini` 和 `Seedance_2.0_mini_lite` 允许省略生成分辨率,由服务端默认 `720p`;CLI 不主动补值,不因模型查询缺少分辨率维度而将该参数标为必填或伪造配置。 diff --git a/README.md b/README.md index 40385c7..76b04dc 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ | 登录授权 | `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) | | 生视频、首尾帧 | `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) | @@ -209,6 +210,17 @@ pippit-tool-cli generate-image \ 图片支持 `.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.webp`、`.svg`。CLI 会在提交前校验 prompt、model 必填、ratio 整数格式、generate-image-count 非负和文件后缀。 +## 视频模型发现 + +```bash +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)。 + ## 生视频 CLI `generate-video` 会上传本地参考图片、视频和音频,然后向视频片段 Agent 提交生视频请求: @@ -227,7 +239,7 @@ pippit-tool-cli generate-video \ --resolution "720p" ``` -命令输出 `thread_id`、`run_id` 和 `web_thread_link`。提交生视频 HTTP 请求时,参考图、参考视频和参考音频会使用上传接口返回的 `pippit_asset_id`,并分别写入 `video_part_tool_param.images`、`video_part_tool_param.videos` 和 `video_part_tool_param.audios`。图片支持 `.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.webp`、`.svg`;视频支持 `.mp4`、`.avi`、`.mov`、`.wmv`、`.flv`、`.webm`、`.mkv`、`.m4v`;音频仅支持 `.mp3`、`.wav`。普通用户支持模型 `Seedance_2.0_mini_lite`;`seedance2.0_vision`、`seedance2.0_fast_vision`、`Seedance_2.0_mini` 和 `Seedance_2.5` 为 VIP 专属模型。CLI 会在提交前校验 prompt 和文件后缀;模型、比例、分辨率等语义校验由服务端处理。 +命令输出 `thread_id`、`run_id` 和 `web_thread_link`。提交生视频 HTTP 请求时,参考图、参考视频和参考音频会使用上传接口返回的 `pippit_asset_id`,并分别写入 `video_part_tool_param.images`、`video_part_tool_param.videos` 和 `video_part_tool_param.audios`。图片支持 `.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.webp`、`.svg`;视频支持 `.mp4`、`.avi`、`.mov`、`.wmv`、`.flv`、`.webm`、`.mkv`、`.m4v`;音频仅支持 `.mp3`、`.wav`。当前可用模型通过 `model list` 查询,参数详情通过 `model describe MODEL_KEY` 查询。CLI 会在提交前校验 prompt 和文件后缀;模型、比例、分辨率等语义校验由服务端处理。 首尾帧生视频时,按首帧、尾帧的顺序传入两次 `--image`,并设置 `--generate-type 1`: @@ -292,3 +304,13 @@ pippit-tool-cli query-result \ 原生 CLI 命令通过 `pippit-tool-cli login` 打开小云雀网页授权,并把本机设备专属凭证保存到系统安全凭证库;Access Key 不会显示在终端。可用 `pippit-tool-cli status` 查看状态、`pippit-tool-cli logout` 清除本机登录。 CI 或 Agent 可继续显式设置 `XYQ_ACCESS_KEY`,它会覆盖本机网页登录凭证;配置错误时不会静默回退到个人登录。会话提交和查询共享上述凭据。 + +### 宿主来源统计 + +调用 Skill 提交接口的命令均支持可选 `--source`:`submit-run`、`generate-image`、`generate-video`、`video-super-resolution`、`erase-video-subtitle`、`short-drama +submit-run`。 + +由宿主 Agent 根据实际环境静默填写稳定标识,例如豆包办公 `doubao_office`、WorkBuddy `workbuddy`、Codex `codex`。其它来源可使用其真实产品标识;来源未知时省略,不询问用户,也不从 prompt 猜测。该值去掉首尾空白后写入请求顶层 `platform`,仅供统计,不参与创作、模型选择或鉴权;未提供/空值时不发送该字段。不会自动读取环境变量、持久化来源或影响查询、上传、下载、Canvas 命令。 + +```bash +pippit-tool-cli generate-video --prompt "小猫在花园散步" --model Seedance_2.0_mini --source workbuddy +``` diff --git a/cmd/generate_image/generate_image.go b/cmd/generate_image/generate_image.go index 2a03519..5739627 100644 --- a/cmd/generate_image/generate_image.go +++ b/cmd/generate_image/generate_image.go @@ -44,6 +44,7 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command cmd.SetOut(stdout) cmd.SetErr(stderr) flags := cmd.Flags() + 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") diff --git a/cmd/generate_video/generate_video.go b/cmd/generate_video/generate_video.go index 5090d0a..9e2c650 100644 --- a/cmd/generate_video/generate_video.go +++ b/cmd/generate_video/generate_video.go @@ -42,14 +42,15 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command cmd.SetOut(stdout) cmd.SetErr(stderr) flags := cmd.Flags() + 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", "", "video generation prompt") flags.StringArrayVar(&opts.ImagePaths, "image", nil, "local reference image path; repeat for multiple images") flags.StringArrayVar(&opts.VideoPaths, "video", nil, "local reference video path; repeat for multiple videos") flags.StringArrayVar(&opts.AudioPaths, "audio", nil, "local reference audio path; repeat for multiple audios") flags.IntVar(&durationSec, "duration", 0, "video duration in seconds") flags.StringVar(&opts.Ratio, "ratio", "", "video ratio, such as 9:16, 16:9, 3:4, 4:3") - flags.StringVar(&opts.Model, "model", "", "supported video models: Seedance_2.0_mini_lite, seedance2.0_vision, seedance2.0_fast_vision, Seedance_2.0_mini, Seedance_2.5, MiniMax-H3, MiniMax-H3-Max, wan3.0, happyhorse-1.1") - flags.StringVar(&opts.Resolution, "resolution", "", "video resolution; MiniMax-H3: 768p/2k; MiniMax-H3-Max: 480p/768p; wan3.0: 480p/720p/1080p; happyhorse-1.1: 720p/1080p; other models, such as 720p/1080p") + flags.StringVar(&opts.Model, "model", "", "video model key; use 'model list' to discover available models") + flags.StringVar(&opts.Resolution, "resolution", "", "video resolution; optional for Seedance_2.0_mini and Seedance_2.0_mini_lite (server defaults to 720p); use 'model describe ' for current configuration") flags.Int64Var(&generateType, "generate-type", 0, "generation type passed to the service; set 1 for first-and-last-frame generation and provide --image values in first-frame, last-frame order; MiniMax and Wan also accept a single first-frame image in this mode") return cmd } diff --git a/cmd/model.go b/cmd/model.go new file mode 100644 index 0000000..afde89f --- /dev/null +++ b/cmd/model.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "fmt" + "io" + "strings" + "time" + + "github.com/Pippit-dev/pippit-cli/internal/common" + "github.com/Pippit-dev/pippit-cli/internal/models" + "github.com/spf13/cobra" +) + +func newModelCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { + var refresh bool + 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) + if err != nil { + return err + } + if result.Warning != "" { + fmt.Fprintln(stderr, result.Warning) + } + output := map[string]any{ + "scene": result.Catalog.Scene, + "cached": result.Cached, "fetched_at": result.FetchedAt, + "expires_at": result.FetchedAt.Add(models.CacheTTL).Format(time.RFC3339Nano), + } + if describe { + model, err := result.Catalog.Describe(strings.Join(args, " ")) + if err != nil { + return err + } + output["model"] = model + } else { + output["models"] = result.Catalog.Search(strings.Join(args, " ")) + } + 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.", + Args: cobra.MaximumNArgs(1), + RunE: withErrorLog("model", nil, func(cmd *cobra.Command, args []string) error { + return query(cmd, args, len(args) > 0) + }), + } + 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.AddCommand(&cobra.Command{ + Use: "list [query]", Aliases: []string{"search"}, + Short: "List video models, optionally filtered 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", + Args: cobra.ExactArgs(1), + RunE: withErrorLog("model describe", nil, func(cmd *cobra.Command, args []string) error { + return query(cmd, args, true) + }), + }) + return cmd +} diff --git a/cmd/model_test.go b/cmd/model_test.go new file mode 100644 index 0000000..f81ed3c --- /dev/null +++ b/cmd/model_test.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Pippit-dev/pippit-cli/internal/config" +) + +func TestModelCommands(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("path=%s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_turbo_video_generator","config_key":"key","config":{"models":[{"key":"MiniMax-H3","name":"MiniMax","kind":"video","supported_ratio_list":[0,2,3],"default_ratio":3,"audio_total_limit":0}]}}}`)) + })) + defer server.Close() + for i, args := range [][]string{ + {"model", "list"}, + {"model", "search", "MiniMax", "--type", "video"}, + {"model", "describe", "MiniMax-H3"}, + {"model", "MiniMax-H3"}, + {"model", "list", "--refresh"}, + } { + var stdout, stderr bytes.Buffer + cfg := config.Load() + cfg.BaseURL, cfg.AccessKey = server.URL, "model-command-test-key" + root := newRootCommand(&stdout, &stderr, newRootRunner(cfg)) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("%v: %v", args, err) + } + var output map[string]json.RawMessage + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatal(err) + } + if _, exists := output["config_key"]; exists { + t.Fatal("internal config key must not be exposed") + } + if i == 2 || i == 3 { + if bytes.Contains(output["model"], []byte("supported_ratio_list")) || !bytes.Contains(output["model"], []byte(`"default":"9:16"`)) { + t.Fatalf("ratio must use CLI strings: %s", stdout.String()) + } + if !bytes.Contains(output["model"], []byte(`"audio_total_limit":0`)) { + t.Fatalf("missing config: %s", stdout.String()) + } + } else if !bytes.Contains(output["models"], []byte("MiniMax-H3")) { + t.Fatalf("missing model: %s", stdout.String()) + } + want := 1 + if i == 4 { + want = 2 + } + if requests != want { + t.Fatalf("requests=%d want=%d", requests, want) + } + } +} + +func TestModelHelpDoesNotRequireLoginOrRequest(t *testing.T) { + for _, args := range [][]string{{"model", "--help"}, {"generate-video", "--help"}} { + var stdout, stderr bytes.Buffer + root := NewRootCommand(&stdout, &stderr) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "list") { + t.Fatalf("help must show discovery: %s", stdout.String()) + } + } +} diff --git a/cmd/root.go b/cmd/root.go index d93309f..a768fa3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -72,6 +72,7 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm root.AddCommand(canvascmd.NewCommand(stdout, stderr, runner)) root.AddCommand(newDownloadResultCommand(stdout, stderr, runner)) root.AddCommand(newGetCreditBalanceCommand(stdout, stderr, runner)) + root.AddCommand(newModelCommand(stdout, stderr, runner)) root.AddCommand(newGetThreadCommand(stdout, stderr, runner)) root.AddCommand(newSubmitRunCommand(stdout, stderr, runner)) root.AddCommand(newUploadFileCommand(stdout, stderr, runner)) diff --git a/cmd/short_drama/short_drama.go b/cmd/short_drama/short_drama.go index 060d0d5..cc56bb9 100644 --- a/cmd/short_drama/short_drama.go +++ b/cmd/short_drama/short_drama.go @@ -55,6 +55,7 @@ func newShortDramaSubmitRunCommand(stdout, stderr io.Writer, runner *common.Runn } cmd.SetOut(stdout) cmd.SetErr(stderr) + cmd.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)") cmd.Flags().StringVar(&opts.Message, "message", "", "message to send to the short drama agent") cmd.Flags().StringVar(&opts.ThreadID, "thread-id", "", "existing thread ID; omit to create a new thread") cmd.Flags().StringArrayVar(&opts.AssetIDs, "asset-ids", nil, "asset ID to attach; repeat for multiple assets") diff --git a/cmd/source_test.go b/cmd/source_test.go new file mode 100644 index 0000000..58a73a3 --- /dev/null +++ b/cmd/source_test.go @@ -0,0 +1,179 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Pippit-dev/pippit-cli/internal/config" +) + +func TestSkillSubmitSourcePreservesCreativeRequest(t *testing.T) { + 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") + if err := os.WriteFile(video, []byte("test-video"), 0600); err != nil { + t.Fatal(err) + } + 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-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}, + "short-drama": {"short-drama", "+submit-run", "--message", "剧本", "--thread-id", "skill_existing", "--asset-ids", "asset_1"}, + }[name] + var received map[string]any + var query string + response := `{"ret":"0","data":{"run":{"thread_id":"thread_1","run_id":"run_1"},"web_thread_link":"https://xyq.example/thread_1"}}` + submitCount := 0 + uploadFailure := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case config.SubmitRunPath: + submitCount++ + if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer test-token" { + t.Error("method or authorization changed") + } + if err := json.NewDecoder(r.Body).Decode(&received); err != nil { + t.Error(err) + } + query = r.URL.RawQuery + _, _ = w.Write([]byte(response)) + case "/api/biz/v1/skill/upload_file": + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Error(err) + } + if r.FormValue("platform") != "" || r.FormValue("source") != "" || r.URL.Query().Has("source") || r.URL.Query().Has("platform") { + t.Error("host attribution leaked into upload") + } + if uploadFailure { + _, _ = w.Write([]byte(`{"ret":"1","errmsg":"upload rejected"}`)) + return + } + _, _ = w.Write([]byte(`{"ret":"0","data":{"pippit_asset_id":"video_1"}}`)) + default: + t.Errorf("unexpected request path: %s", r.URL.Path) + } + })) + defer server.Close() + execute := func(sourceFlags ...string) string { + t.Helper() + received = nil + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + commandArgs := append([]string(nil), args...) + commandArgs = append(commandArgs, sourceFlags...) + root.SetArgs(commandArgs) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if received == nil { + t.Fatal("no skill submission received") + } + return stdout.String() + } + wantOutput := execute() + if _, exists := received["platform"]; exists { + t.Fatal("missing source must omit platform") + } + wantBody, wantQuery := received, query + for i, tc := range []struct { + flags []string + want string + }{ + {[]string{"--source", "workbuddy"}, "workbuddy"}, + {[]string{"--source", " doubao_office "}, "doubao_office"}, + {[]string{"--source", "codex"}, "codex"}, + {[]string{"--source", "新的宿主"}, "新的宿主"}, + {[]string{"--source", ""}, ""}, + {[]string{"--source", " \t "}, ""}, + {[]string{"--source=workbuddy"}, "workbuddy"}, + {[]string{"--source="}, ""}, + {[]string{"--source=codex", "--source=workbuddy"}, "workbuddy"}, + {[]string{"--source=codex", "--source="}, ""}, + {[]string{"--source", "\u3000codex\u00a0"}, "codex"}, + {[]string{"--source", "host\"&=?/\\测试\nnext"}, "host\"&=?/\\测试\nnext"}, + } { + t.Run(fmt.Sprintf("source_%02d", i), func(t *testing.T) { + if got := execute(tc.flags...); got != wantOutput { + t.Errorf("source changed output: %s", got) + } + value, exists := received["platform"] + if (tc.want == "" && exists) || (tc.want != "" && value != tc.want) { + t.Errorf("platform = %#v (present=%v), want=%q", value, exists, tc.want) + } + delete(received, "platform") + if !reflect.DeepEqual(received, wantBody) || query != wantQuery { + t.Errorf("source changed creative request or ROI query: %#v / %s", received, query) + } + }) + } + // A fresh invocation without source must not reuse previous attribution. + execute() + if _, exists := received["platform"]; exists { + t.Error("source leaked into next invocation") + } + for _, failure := range []struct{ name, body string }{ + {"business_error", `{"ret":"1","errmsg":"rejected"}`}, + {"invalid_json", `invalid-json`}, + } { + t.Run(failure.name, func(t *testing.T) { + response = failure.body + submitCount = 0 + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs(append(append([]string(nil), args...), "--source=codex")) + if err := root.Execute(); err == nil { + t.Fatal("source swallowed submit error") + } + if submitCount != 1 { + t.Errorf("submission count = %d, want 1", submitCount) + } + if received["platform"] != "codex" { + t.Error("failed submission lost source") + } + }) + } + if name == "video-super-resolution" || name == "erase-video-subtitle" { + t.Run("upload_failure", func(t *testing.T) { + uploadFailure = true + submitCount = 0 + var stdout, stderr bytes.Buffer + root := newTestRootCommand(t, &stdout, &stderr, server.URL) + root.SetArgs(append(append([]string(nil), args...), "--source=workbuddy")) + if err := root.Execute(); err == nil { + t.Fatal("source swallowed upload error") + } + if submitCount != 0 { + t.Errorf("submitted %d times after failed upload", submitCount) + } + }) + } + }) + } +} + +func TestSourceHelpWithoutCredentials(t *testing.T) { + for _, args := range [][]string{ + {"submit-run"}, {"generate-image"}, {"generate-video"}, + {"video-super-resolution"}, {"erase-video-subtitle"}, {"short-drama", "+submit-run"}, + } { + var stdout, stderr bytes.Buffer + root := newRootCommand(&stdout, &stderr, nil) + root.SetArgs(append(args, "--help")) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "--source") { + t.Errorf("help for %v missing --source", args) + } + } +} diff --git a/cmd/submit_run.go b/cmd/submit_run.go index cbdc520..d3a2d7e 100644 --- a/cmd/submit_run.go +++ b/cmd/submit_run.go @@ -11,6 +11,7 @@ import ( func newSubmitRunCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { var body struct { + Platform string `json:"platform,omitempty"` Message string `json:"message"` ThreadID string `json:"thread_id,omitempty"` AssetIDs []string `json:"asset_ids,omitempty"` @@ -23,6 +24,7 @@ func newSubmitRunCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra if strings.TrimSpace(body.Message) == "" { return fmt.Errorf("缺少必填参数 --message") } + body.Platform = strings.TrimSpace(body.Platform) // Preserve the original message and identifiers; the backend owns routing. result, err := common.SubmitRun(cmd.Context(), "submit-run", body, runner) if err != nil { @@ -33,6 +35,7 @@ func newSubmitRunCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra } cmd.SetOut(stdout) cmd.SetErr(stderr) + cmd.Flags().StringVar(&body.Platform, "source", "", "optional host agent/platform identifier for statistics only; filled silently by the host agent (e.g. doubao_office, workbuddy, codex)") cmd.Flags().StringVar(&body.Message, "message", "", "original message to send (required)") cmd.Flags().StringVar(&body.ThreadID, "thread-id", "", "existing thread ID; omit to create a new thread") cmd.Flags().StringArrayVar(&body.AssetIDs, "asset-ids", nil, "asset ID to attach; repeat for multiple assets") diff --git a/cmd/video_tool/video_tool.go b/cmd/video_tool/video_tool.go index 8c3b5e7..6a82e65 100644 --- a/cmd/video_tool/video_tool.go +++ b/cmd/video_tool/video_tool.go @@ -31,6 +31,7 @@ func NewSuperResolutionCommand(stdout, stderr io.Writer, runner *common.Runner) cmd.SetOut(stdout) cmd.SetErr(stderr) flags := cmd.Flags() + 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.VideoPath, "video", "", "local input video path") flags.StringVar(&opts.OutputResolution, "output-resolution", "", "output resolution passed to the service: 720p, 1080p, 2k, or 4k") flags.StringVar(&opts.ToolVersion, "tool-version", "", "optional tool version passed to the service: standard, professional_v1, or professional_v2") @@ -58,6 +59,7 @@ func NewEraseSubtitleCommand(stdout, stderr io.Writer, runner *common.Runner) *c cmd.SetOut(stdout) cmd.SetErr(stderr) flags := cmd.Flags() + 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.VideoPath, "video", "", "local input video path") return cmd } diff --git a/internal/common/client.go b/internal/common/client.go index 4cc5b41..b81ab32 100644 --- a/internal/common/client.go +++ b/internal/common/client.go @@ -226,11 +226,6 @@ func (c *httpClient) prepareRequest(ctx context.Context, req *http.Request, head if c.authorizer == nil { return fmt.Errorf("授权请求缺少认证器") } - // Local PPE validation: keep API requests in the same test lane. - if lane := strings.TrimSpace(os.Getenv("PIPPIT_GENERATE_VIDEO_SUBMIT_X_TT_ENV")); lane != "" { - req.Header.Set("x-tt-env", lane) - req.Header.Set("x-use-ppe", "1") - } if err := c.authorizer.Inject(ctx, req); err != nil { return fmt.Errorf("写入认证请求头失败: %w", err) } diff --git a/internal/common/submit_run.go b/internal/common/submit_run.go index 3d9fff4..9bfd334 100644 --- a/internal/common/submit_run.go +++ b/internal/common/submit_run.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/url" + "strings" "github.com/Pippit-dev/pippit-cli/internal/config" ) @@ -98,3 +99,12 @@ func SubmitRunPath(runner *Runner) string { } return config.SubmitRunPath } + +// WithSubmitRunSource adds optional host attribution to a newly constructed skill request. +// The API consumes this metadata as platform; it is not part of the creative input. +func WithSubmitRunSource(body map[string]any, source string) map[string]any { + if source = strings.TrimSpace(source); source != "" { + body["platform"] = source + } + return body +} diff --git a/internal/config/config.go b/internal/config/config.go index 950b761..340e904 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,7 @@ const ( DefaultAuthStoreServiceName = "pippit-cli" SubmitRunPath = "/api/biz/v1/skill/submit_run" GetCreditBalancePath = "/api/biz/v1/skill/get_credit_balance" + GetAvailableModelListPath = "/api/biz/v1/skill/get_available_model_list" GetThreadPath = "/api/biz/v1/skill/get_thread" UploadFilePath = "/api/biz/v1/skill/upload_file" ListThreadFilePath = "/api/biz/v1/skill/list_thread_file" @@ -30,11 +31,12 @@ type Config struct { } type Paths struct { - SubmitRun string - GetCreditBalance string - GetThread string - UploadFile string - ListThreadFile string + SubmitRun string + GetCreditBalance string + GetAvailableModelList string + GetThread string + UploadFile string + ListThreadFile string } // Load resolves the built-in runtime config. @@ -45,11 +47,12 @@ func Load() *Config { AuthTTL: DefaultAuthTTL, AccessKey: strings.TrimSpace(os.Getenv(EnvXYQAccessKey)), Paths: &Paths{ - SubmitRun: SubmitRunPath, - GetCreditBalance: GetCreditBalancePath, - GetThread: GetThreadPath, - UploadFile: UploadFilePath, - ListThreadFile: ListThreadFilePath, + SubmitRun: SubmitRunPath, + GetCreditBalance: GetCreditBalancePath, + GetAvailableModelList: GetAvailableModelListPath, + GetThread: GetThreadPath, + UploadFile: UploadFilePath, + ListThreadFile: ListThreadFilePath, }, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5237cfe..67bcdab 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -23,6 +23,9 @@ func TestLoadUsesDefaultConfig(t *testing.T) { if cfg.Paths.GetCreditBalance != GetCreditBalancePath { t.Fatalf("GetCreditBalance path = %q, want %q", cfg.Paths.GetCreditBalance, GetCreditBalancePath) } + if cfg.Paths.GetAvailableModelList != GetAvailableModelListPath { + t.Fatalf("GetAvailableModelList path = %q, want %q", cfg.Paths.GetAvailableModelList, GetAvailableModelListPath) + } } func TestLoadReadsAccessKey(t *testing.T) { diff --git a/internal/generate_image/generate_image.go b/internal/generate_image/generate_image.go index 59efbb3..63255b6 100644 --- a/internal/generate_image/generate_image.go +++ b/internal/generate_image/generate_image.go @@ -23,6 +23,7 @@ const ratioUsage = "enum values: 0=原始比例/自动, 2=16:9(横屏), 13=21:9( // Options is the stable command-facing request shape for generate-image. type Options struct { + Source string Prompt string ImagePaths []string Model string @@ -151,7 +152,7 @@ func buildSubmitRunBody(opts *Options, imageAssetIDs []string) map[string]any { if len(imageAssetIDs) > 0 { body["asset_ids"] = imageAssetIDs } - return body + return common.WithSubmitRunSource(body, opts.Source) } func parseRatio(raw string) (*int, error) { diff --git a/internal/generate_video/generate_video.go b/internal/generate_video/generate_video.go index 7183faa..d0c87b4 100644 --- a/internal/generate_video/generate_video.go +++ b/internal/generate_video/generate_video.go @@ -18,6 +18,7 @@ var ( // Options is the stable command-facing request shape for generate-video. type Options struct { + Source string Prompt string ImagePaths []string VideoPaths []string @@ -54,19 +55,15 @@ func Run(ctx context.Context, opts *Options, runner *common.Runner) (*Result, er } body := buildSubmitRunBody(opts, imageAssetIDs, videoAssetIDs, audioAssetIDs) - switch strings.TrimSpace(opts.Model) { - case "MiniMax-H3", "MiniMax-H3-Max", "wan3.0", "happyhorse-1.1": - // Follow Web video generation's business scene; this command enters through Skill. - return common.SubmitRunWithBabiParam(ctx, "generate-video", body, runner, map[string]string{ - "scene_lv1": "ai_agent", - "scene_lv2": "front_tool", - "tool_id": "instant_video", - "tab_name": "other", - "edit_type": "instant_video", - "enter_from": "skill", - }) - } - return common.SubmitRun(ctx, "generate-video", body, runner) + // Business attribution belongs to this command, regardless of the selected model. + return common.SubmitRunWithBabiParam(ctx, "generate-video", body, runner, map[string]string{ + "scene_lv1": "ai_agent", + "scene_lv2": "front_tool", + "tool_id": "instant_video", + "tab_name": "other", + "edit_type": "instant_video", + "enter_from": "skill", + }) } func ValidateOptions(opts *Options) error { @@ -128,11 +125,11 @@ func buildSubmitRunBody(opts *Options, imageAssetIDs []string, videoAssetIDs []s GenerateType: opts.GenerateType, } - return map[string]any{ + return common.WithSubmitRunSource(map[string]any{ "agent_name": common.AgentNameVideoPart, "message": prompt, "video_part_tool_param": param, - } + }, opts.Source) } func assetRefs(assetIDs []string) []*common.MediaAsset { diff --git a/internal/generate_video/generate_video_test.go b/internal/generate_video/generate_video_test.go index 36119e1..563b350 100644 --- a/internal/generate_video/generate_video_test.go +++ b/internal/generate_video/generate_video_test.go @@ -32,8 +32,12 @@ func (c *videoSubmitRecordingClient) SendRequest(_ context.Context, path string, return json.Unmarshal([]byte(`{"ret":"0","data":{"run":{"thread_id":"thread_123","run_id":"run_123"}}}`), out) } -func TestRunCarriesModelHubROIQuery(t *testing.T) { - for _, model := range []string{"MiniMax-H3", "MiniMax-H3-Max", "wan3.0", "happyhorse-1.1", "Seedance_2.5", ""} { +func TestRunCarriesVideoROIQuery(t *testing.T) { + for _, model := range []string{ + "MiniMax-H3", "MiniMax-H3-Max", "wan3.0", "happyhorse-1.1", + "Seedance_2.5", "seedance2.0_vision", "seedance2.0_fast_vision", + "Seedance_2.0_mini", "Seedance_2.0_mini_lite", "", "future-model", + } { t.Run(model, func(t *testing.T) { client := &videoSubmitRecordingClient{} runner := &common.Runner{ @@ -51,12 +55,6 @@ func TestRunCarriesModelHubROIQuery(t *testing.T) { if path.Path != "/custom/submit_run" || query.Get("source") != "a+b" || len(query["item"]) != 2 { t.Fatalf("configured path/query lost: %s", client.path) } - if model == "Seedance_2.5" || model == "" { - if query.Has("babi_param") { - t.Fatal("existing Seedance path must remain unchanged") - } - return - } var babi map[string]string if err := json.Unmarshal([]byte(query.Get("babi_param")), &babi); err != nil { t.Fatal(err) @@ -78,17 +76,21 @@ func TestRunCarriesModelHubROIQuery(t *testing.T) { } func TestRunPreservesExplicitROIQuery(t *testing.T) { - client := &videoSubmitRecordingClient{} - raw := `{"scene_lv1":"ai_agent","scene_lv2":"front_tool","tool_id":"custom_video"}` - runner := &common.Runner{Client: client, Config: &config.Config{Paths: &config.Paths{ - SubmitRun: "/custom/submit_run?babi_param=" + url.QueryEscape(raw), - }}} - if _, err := Run(context.Background(), &Options{Prompt: "cat walking", Model: "MiniMax-H3"}, runner); err != nil { - t.Fatal(err) - } - path, err := url.Parse(client.path) - if err != nil || path.Query().Get("babi_param") != raw { - t.Fatalf("explicit attribution overwritten: %s, %v", client.path, err) + for _, model := range []string{"MiniMax-H3", "Seedance_2.0_mini"} { + t.Run(model, func(t *testing.T) { + client := &videoSubmitRecordingClient{} + raw := `{"scene_lv1":"ai_agent","scene_lv2":"front_tool","tool_id":"custom_video"}` + runner := &common.Runner{Client: client, Config: &config.Config{Paths: &config.Paths{ + SubmitRun: "/custom/submit_run?babi_param=" + url.QueryEscape(raw), + }}} + if _, err := Run(context.Background(), &Options{Prompt: "cat walking", Model: model}, runner); err != nil { + t.Fatal(err) + } + path, err := url.Parse(client.path) + if err != nil || path.Query().Get("babi_param") != raw { + t.Fatalf("explicit attribution overwritten: %s, %v", client.path, err) + } + }) } } diff --git a/internal/models/describe.go b/internal/models/describe.go new file mode 100644 index 0000000..9e059be --- /dev/null +++ b/internal/models/describe.go @@ -0,0 +1,281 @@ +package models + +import ( + "encoding/json" + "fmt" + "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", +} + +type dimensionConfig struct { + Key string `json:"key"` + DefaultValue *string `json:"default_value"` + OptionList []struct { + Value string `json:"value"` + Disabled bool `json:"disabled"` + } `json:"option_list"` + RangeConfig *struct { + Min *int64 `json:"min_value"` + Max *int64 `json:"max_value"` + Step int64 `json:"step"` + } `json:"range_config"` + ActiveWhenAny json.RawMessage `json:"active_when_any"` +} + +// Normalize only fields with an explicit CLI contract. RawMessage preserves +// unknown fields and large integers; the cached catalog is never mutated. +func describeModel(raw json.RawMessage) (json.RawMessage, error) { + var fields map[string]json.RawMessage + var source struct { + Key string `json:"key"` + Ratios []int64 `json:"supported_ratio_list"` + Default *int64 `json:"default_ratio"` + Parameter struct { + Dimensions []*dimensionConfig `json:"dimensions"` + } `json:"parameter_config"` + Creation *struct { + Modes []json.RawMessage `json:"modes"` + } `json:"creation_mode_config"` + } + if err := json.Unmarshal(raw, &fields); err != nil { + return nil, err + } + if err := json.Unmarshal(raw, &source); err != nil { + return nil, fmt.Errorf("模型参数配置无法解析,请 --refresh 重试或升级 CLI: %w", err) + } + out := make(map[string]any, len(fields)) + for key, value := range fields { + out[key] = value + } + delete(out, "config_key") + warnings := []string{} + warn := func(message string) { warnings = append(warnings, message) } + ratios := make([]string, 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) { + ratios = append(ratios, value) + } + } + ratio := map[string]any{"options": ratios} + if source.Default != nil { + if value := ratioValues[*source.Default]; value != "" { + if slices.Contains(ratios, value) { + ratio["default"] = value + } else { + warn("默认比例不在可用比例中,未输出默认值;请 --refresh 重试") + } + } + } + if _, exists := fields["supported_ratio_list"]; exists || source.Default != nil { + out["ratio"] = ratio + } + delete(out, "supported_ratio_list") + delete(out, "default_ratio") + + seen := make(map[string]bool) + for _, dimension := range source.Parameter.Dimensions { + if dimension == nil || (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) + } + // Keep unrelated parameter dimensions and combination constraints as supplied. + if parameterRaw, ok := fields["parameter_config"]; ok && string(parameterRaw) != "null" { + var parameter map[string]json.RawMessage + if err := json.Unmarshal(parameterRaw, ¶meter); err != nil { + return nil, err + } + var dimensions []json.RawMessage + if err := json.Unmarshal(parameter["dimensions"], &dimensions); len(parameter["dimensions"]) != 0 && err != nil { + return nil, err + } + remaining := []json.RawMessage{} + for _, dimension := range dimensions { + var key struct { + Key string `json:"key"` + } + if err := json.Unmarshal(dimension, &key); err != nil { + return nil, err + } + if key.Key != "resolution" && key.Key != "duration" { + remaining = append(remaining, dimension) + } + } + delete(parameter, "dimensions") + if len(remaining) > 0 { + parameter["dimensions"], _ = json.Marshal(remaining) + } + delete(out, "parameter_config") + if len(parameter) > 0 { + out["parameter_config"] = parameter + } + } + if seen["duration"] { + delete(out, "supported_duration_list") + delete(out, "default_duration_value") + } else if _, exists := fields["supported_duration_list"]; exists { + warn("服务端未提供 duration 参数维度;supported_duration_list 为旧时长枚举,不能直接当作 --duration 秒数") + } + + limits := map[string]json.RawMessage{} + for from, to := range map[string]string{ + "total_limit": "total_limit", "image_total_limit": "image_total_limit", + "video_total_limit": "video_total_limit", "audio_total_limit": "audio_total_limit", + "max_image_size": "max_image_size_bytes", "min_video_duration": "min_video_duration_ms", + "max_video_duration": "max_video_duration_ms", "max_total_video_duration": "max_total_video_duration_ms", + } { + if value, exists := fields[from]; exists { + limits[to] = value + delete(out, from) + } + } + if len(limits) > 0 { + out["material_limits"] = limits + } + if source.Creation != nil { + modes := make([]map[string]any, 0, len(source.Creation.Modes)) + for _, rawMode := range source.Creation.Modes { + if string(rawMode) == "null" { + continue + } + var modeFields map[string]json.RawMessage + var mode struct { + Key string `json:"key"` + RatioPolicy struct { + ValueMode string `json:"value_mode"` + } `json:"ratio_policy"` + } + if err := json.Unmarshal(rawMode, &modeFields); err != nil { + return nil, err + } + if err := json.Unmarshal(rawMode, &mode); err != nil { + return nil, err + } + entry := make(map[string]any, len(modeFields)+2) + for key, value := range modeFields { + entry[key] = value + } + switch mode.Key { + case "text_to_video", "reference_generation": + entry["generate_type"] = 0 + case "first_last_frame": + entry["generate_type"] = 1 + } + switch mode.RatioPolicy.ValueMode { + case "smart": + entry["ratio"] = map[string]any{"options": []string{"adaptive"}, "default": "adaptive"} + 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)) + for _, value := range ratios { + if value != "adaptive" { + fixed = append(fixed, value) + } + } + modeRatio := map[string]any{"options": fixed} + if value, ok := ratio["default"].(string); ok && value != "adaptive" { + modeRatio["default"] = value + } + entry["ratio"] = modeRatio + } + default: + warn("创作模式 " + mode.Key + " 的比例策略无法识别,请 --refresh 重试或升级 CLI") + } + modes = append(modes, entry) + } + // Retain schema/default-mode metadata, but expose interpreted modes separately. + var creation map[string]json.RawMessage + if err := json.Unmarshal(fields["creation_mode_config"], &creation); err != nil { + return nil, err + } + delete(creation, "modes") + delete(out, "creation_mode_config") + if len(creation) > 0 { + out["creation_mode_config"] = creation + } + out["creation_modes"] = modes + } + if (source.Key == "MiniMax-H3" || source.Key == "MiniMax-H3-Max") && slices.Contains(ratios, "adaptive") { + out["notes"] = []string{"MiniMax 使用 adaptive 需要参考图片或视频;纯文生视频请选择固定比例。创作模式的比例策略优先于模型级选项。"} + } + if len(warnings) > 0 { + out["warnings"] = warnings + } + return json.Marshal(out) +} + +func describeDimension(d *dimensionConfig, warn func(string)) map[string]any { + out := map[string]any{} + if len(d.ActiveWhenAny) > 0 { + out["active_when_any"] = d.ActiveWhenAny + } + convert := func(value string) (any, bool) { + value = strings.TrimSpace(value) + if d.Key == "duration" { + number, err := strconv.ParseInt(value, 10, 32) + return number, err == nil && number > 0 + } + return strings.ToLower(value), value != "" + } + if d.Key == "duration" { + out["unit"] = "seconds" + } + validDefault := func(any) bool { return false } + if bounds := d.RangeConfig; bounds != nil { + if d.Key != "duration" || len(d.OptionList) != 0 || bounds.Min == nil || bounds.Max == nil || *bounds.Min <= 0 || *bounds.Max < *bounds.Min { + warn(d.Key + " 范围配置不合法,请 --refresh 重试") + return out + } + step := bounds.Step + if step < 1 { + step = 1 + } + out["min"], out["max"], out["step"] = *bounds.Min, *bounds.Max, step + validDefault = func(value any) bool { + n := value.(int64) + return n >= *bounds.Min && n <= *bounds.Max && (n-*bounds.Min)%step == 0 + } + } else { + options := []any{} + for _, option := range d.OptionList { + if option.Disabled { + continue + } + value, ok := convert(option.Value) + if !ok { + warn(d.Key + " 包含无法转换的选项,已排除;请 --refresh 重试") + continue + } + if !slices.Contains(options, value) { + options = append(options, value) + } + } + out["options"] = options + if len(options) == 0 { + warn(d.Key + " 没有可用选项,请 --refresh 重试") + } + validDefault = func(value any) bool { return slices.Contains(options, value) } + } + if d.DefaultValue != nil { + if value, ok := convert(*d.DefaultValue); ok && validDefault(value) { + out["default"] = value + } else { + warn(d.Key + " 默认值不在可用范围内,未输出默认值;请 --refresh 重试") + } + } + return out +} diff --git a/internal/models/describe_test.go b/internal/models/describe_test.go new file mode 100644 index 0000000..b43fd9c --- /dev/null +++ b/internal/models/describe_test.go @@ -0,0 +1,199 @@ +package models + +import ( + "bytes" + "encoding/json" + "reflect" + "testing" +) + +func description(t *testing.T, raw string) map[string]json.RawMessage { + t.Helper() + out, err := describeModel(json.RawMessage(raw)) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(out, &fields); err != nil { + t.Fatal(err) + } + return fields +} + +func TestDescriptionCLIParametersAndRawPreservation(t *testing.T) { + raw := `{"key":"MiniMax-H3","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":"seed","default_value":"random"}],"need_available_combinations":true}} + ` + out := description(t, raw) + for _, key := range []string{"config_key", "supported_ratio_list", "default_ratio", "supported_duration_list", "default_duration_value", "audio_total_limit", "max_image_size"} { + if _, ok := out[key]; ok { + t.Fatalf("unconverted field %s", key) + } + } + var ratio struct { + Options []string + Default string + } + if err := json.Unmarshal(out["ratio"], &ratio); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(ratio.Options, []string{"adaptive", "16:9", "21:9", "9:16", "4:3", "3:4", "1:1"}) || ratio.Default != "9:16" { + t.Fatalf("ratio=%+v", ratio) + } + var resolution struct { + Options []string + Default string + } + if err := json.Unmarshal(out["resolution"], &resolution); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(resolution.Options, []string{"768p", "2k"}) || resolution.Default != "768p" { + t.Fatalf("resolution=%+v", resolution) + } + var duration struct { + Min, Max, Step, Default int + Unit string + } + if err := json.Unmarshal(out["duration"], &duration); err != nil { + t.Fatal(err) + } + if duration.Min != 4 || duration.Max != 15 || duration.Step != 1 || duration.Default != 10 || duration.Unit != "seconds" { + t.Fatalf("duration=%+v", duration) + } + if string(out["future_field"]) != "9007199254740993" { + t.Fatal("integer precision lost") + } + var limits map[string]json.RawMessage + if err := json.Unmarshal(out["material_limits"], &limits); err != nil { + t.Fatal(err) + } + if string(limits["audio_total_limit"]) != "0" || string(limits["max_image_size_bytes"]) != "31457280" || string(limits["min_video_duration_ms"]) != "2000" { + t.Fatalf("limits=%s", out["material_limits"]) + } + if _, ok := limits["video_total_limit"]; ok { + t.Fatal("absent limit must stay absent") + } + if !bytes.Contains(out["parameter_config"], []byte("seed")) || !bytes.Contains(out["parameter_config"], []byte("need_available_combinations")) { + t.Fatal("unrelated configuration lost") + } + if _, ok := out["warnings"]; ok { + t.Fatalf("valid dimensions must override legacy durations: %s", out["warnings"]) + } + if !bytes.Contains(out["notes"], []byte("纯文生视频")) { + t.Fatal("missing MiniMax adaptive restriction") + } +} + +func TestDescriptionUnknownRatioAndDefault(t *testing.T) { + for _, value := range []int{1, 999} { + raw, _ := json.Marshal(map[string]any{"supported_ratio_list": []int{value, 2, 2}, "default_ratio": value}) + out := description(t, string(raw)) + var ratio struct { + Options []string + Default *string + } + if err := json.Unmarshal(out["ratio"], &ratio); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(ratio.Options, []string{"16:9"}) || ratio.Default != nil { + t.Fatalf("invented usable value: %+v", ratio) + } + if _, exists := out["warnings"]; exists { + t.Fatal("unknown and custom enums should be silently skipped") + } + } + out := description(t, `{"supported_ratio_list":[0,7,8,9,10,11,12],"default_ratio":0}`) + var ratio struct { + Options []string + Default string + } + if err := json.Unmarshal(out["ratio"], &ratio); err != nil { + t.Fatal(err) + } + if ratio.Default != "adaptive" || !reflect.DeepEqual(ratio.Options, []string{"adaptive", "2:1", "2.35:1", "1.85:1", "1.125:2.436", "3:2", "2:3"}) { + t.Fatalf("ratio=%+v", ratio) + } +} + +func TestDescriptionDimensionFailuresAndOptions(t *testing.T) { + for _, dimension := range []string{ + `{"key":"resolution","default_value":"2k","option_list":[{"value":"768p"},{"value":"2k","disabled":true}]}`, + `{"key":"resolution","default_value":"","option_list":[]}`, + `{"key":"duration","default_value":"7","range_config":{"min_value":4,"max_value":10,"step":2}}`, + `{"key":"duration","range_config":{"min_value":10,"max_value":4}}`, + `{"key":"duration","option_list":[{"value":"auto"}]}`, + } { + out := description(t, `{"parameter_config":{"dimensions":[`+dimension+`]}}`) + if _, ok := out["warnings"]; !ok { + t.Fatalf("missing warning: %s", dimension) + } + for _, key := range []string{"resolution", "duration"} { + var value map[string]json.RawMessage + if len(out[key]) == 0 { + continue + } + if err := json.Unmarshal(out[key], &value); err != nil { + t.Fatal(err) + } + if _, ok := value["default"]; ok { + t.Fatalf("invalid default exposed: %s", out[key]) + } + } + } + out := description(t, `{"parameter_config":{"dimensions":[{"key":"duration","default_value":"10","option_list":[{"value":"5"},{"value":"10"},{"value":"15","disabled":true}]}]}}`) + var duration struct { + Options []int + Default int + Min *int + } + if err := json.Unmarshal(out["duration"], &duration); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(duration.Options, []int{5, 10}) || duration.Default != 10 || duration.Min != nil { + t.Fatalf("duration=%+v", duration) + } +} + +func TestDescriptionCreationModesAndCacheNotMutated(t *testing.T) { + raw := json.RawMessage(`{"key":"MiniMax-H3-Max","kind":"video","supported_ratio_list":[0,2],"default_ratio":2,"creation_mode_config":{"schema_version":1,"default_mode":"text_to_video","modes":[{"key":"text_to_video","enabled":true,"ratio_policy":{"value_mode":"inherit"}},{"key":"first_last_frame","enabled":true,"ratio_policy":{"value_mode":"smart"},"validation_policy":{"required_video_count":0,"forbidden_input_types":["video","audio"]}}]}}`) + before := append([]byte(nil), raw...) + var catalog Catalog + if err := json.Unmarshal([]byte(`{"config":{"models":[]}}`), &catalog); err != nil { + t.Fatal(err) + } + catalog.Config.Models = append(catalog.Config.Models, raw) + result, err := catalog.Describe("MiniMax-H3-Max") + if err != nil { + t.Fatal(err) + } + var out struct { + Modes []struct { + GenerateType int `json:"generate_type"` + Ratio struct { + Options []string + Default string + } + Validation struct { + Required *int `json:"required_video_count"` + Forbidden []string `json:"forbidden_input_types"` + } `json:"validation_policy"` + } `json:"creation_modes"` + } + if err := json.Unmarshal(result, &out); err != nil { + t.Fatal(err) + } + if len(out.Modes) != 2 || out.Modes[0].Ratio.Default != "16:9" || !reflect.DeepEqual(out.Modes[0].Ratio.Options, []string{"16:9"}) || out.Modes[1].GenerateType != 1 || out.Modes[1].Ratio.Default != "adaptive" || !reflect.DeepEqual(out.Modes[1].Ratio.Options, []string{"adaptive"}) { + t.Fatalf("modes=%+v", out.Modes) + } + if out.Modes[1].Validation.Required == nil || *out.Modes[1].Validation.Required != 0 || !reflect.DeepEqual(out.Modes[1].Validation.Forbidden, []string{"video", "audio"}) { + t.Fatal("mode requirements lost") + } + if !bytes.Equal(before, catalog.Config.Models[0]) { + t.Fatal("description mutated cached raw config") + } +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..2ef8835 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,220 @@ +package models + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Pippit-dev/pippit-cli/internal/common" + "github.com/Pippit-dev/pippit-cli/internal/config" + "github.com/Pippit-dev/pippit-cli/internal/version" +) + +const ( + Scene = "web_turbo_video_generator" + 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. +type Catalog struct { + Scene string `json:"scene"` + ConfigKey string `json:"config_key"` + Config *struct { + Models []json.RawMessage `json:"models"` + } `json:"config"` +} + +type Summary struct { + Key string `json:"key"` + Name string `json:"name"` + Kind string `json:"kind"` + IsDefault bool `json:"is_default"` +} + +type Result struct { + Catalog *Catalog + Cached bool + FetchedAt time.Time + Warning string +} + +type cacheEntry struct { + FetchedAt time.Time `json:"fetched_at"` + Catalog *Catalog `json:"catalog"` +} + +type Service struct { + runner *common.Runner + cacheDir string + now func() time.Time +} + +func NewService(runner *common.Runner) *Service { + dir, err := os.UserCacheDir() + if err == nil { + dir = filepath.Join(dir, "pippit-cli", "models") + } + return &Service{runner: runner, cacheDir: dir, now: time.Now} +} + +func (s *Service) Get(ctx context.Context, refresh bool) (*Result, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if s.runner == nil || s.runner.Config == nil || s.runner.Client == nil || s.runner.Auth == nil { + return nil, fmt.Errorf("模型查询运行器未初始化") + } + // Resolve credentials before reading cache: logout/expiry must not expose a + // previous account's model list. Only a hash is used in the cache filename. + accessKey, err := s.runner.Auth.ResolveAccessKey(ctx) + if err != nil || strings.TrimSpace(accessKey) == "" { + return nil, fmt.Errorf("模型查询需要有效登录,请执行 pippit-tool-cli login 或检查 XYQ_ACCESS_KEY 后重试") + } + path := config.GetAvailableModelListPath + if s.runner.Config.Paths != nil && s.runner.Config.Paths.GetAvailableModelList != "" { + path = s.runner.Config.Paths.GetAvailableModelList + } + cachePath := s.cachePath(accessKey, path) + if !refresh && cachePath != "" { + if entry := s.readCache(cachePath); entry != nil { + return &Result{Catalog: entry.Catalog, Cached: true, FetchedAt: entry.FetchedAt}, nil + } + } + // A failed refresh must not make the next retry reuse an older fresh entry. + if cachePath != "" { + _ = os.Remove(cachePath) + } + queryCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + var resp struct { + Ret string `json:"ret"` + Errmsg string `json:"errmsg"` + LogID string `json:"log_id"` + Data *Catalog `json:"data"` + } + err = s.runner.Client.SendRequest(queryCtx, path, struct { + Scene string `json:"scene"` + SourceModelKey string `json:"source_model_key"` + }{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 { + return nil, common.NewLogIDError("模型配置无效,请稍后重试(可加 --refresh): "+err.Error(), resp.LogID) + } + currentKey, err := s.runner.Auth.ResolveAccessKey(ctx) + if err != nil || currentKey != accessKey { + return nil, fmt.Errorf("查询期间登录凭证已变化,请重试模型查询") + } + result := &Result{Catalog: resp.Data, FetchedAt: s.now().UTC()} + if cachePath == "" || s.writeCache(cachePath, result) != nil { + result.Warning = "模型查询成功,但本地缓存写入失败;下次查询将重新请求服务端" + } + return result, nil +} + +func (s *Service) cachePath(accessKey, path string) string { + if s.cacheDir == "" { + return "" + } + scope, _ := json.Marshal([]string{ + "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 { + raw, err := os.ReadFile(path) + if err != nil { + return nil + } + var entry cacheEntry + if json.Unmarshal(raw, &entry) != nil || validateCatalog(entry.Catalog) != nil { + return nil + } + age := s.now().Sub(entry.FetchedAt) + if age < 0 || age >= CacheTTL { + return nil + } + return &entry +} + +func (s *Service) writeCache(path string, result *Result) error { + raw, err := json.Marshal(cacheEntry{FetchedAt: result.FetchedAt, Catalog: result.Catalog}) + if err != nil { + return err + } + if err := os.MkdirAll(s.cacheDir, 0700); err != nil { + return err + } + f, err := os.CreateTemp(s.cacheDir, ".models-*.tmp") + if err != nil { + return err + } + defer os.Remove(f.Name()) + _, writeErr := f.Write(raw) + closeErr := f.Close() + if writeErr != nil { + return writeErr + } + if closeErr != nil { + return closeErr + } + return os.Rename(f.Name(), path) +} + +func validateCatalog(catalog *Catalog) error { + if catalog == nil || catalog.Config == nil || catalog.Scene != Scene || catalog.ConfigKey == "" { + return fmt.Errorf("缺少配置或场景不匹配") + } + seen := 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" { + return fmt.Errorf("模型条目缺少有效 key 或 kind") + } + if seen[model.Key] { + return fmt.Errorf("模型 key 重复: %s", model.Key) + } + seen[model.Key] = true + } + return nil +} + +func (c *Catalog) Search(query string) []Summary { + query = strings.TrimSpace(query) + matches := make([]Summary, 0) + for _, raw := range c.Config.Models { + var model Summary + _ = json.Unmarshal(raw, &model) // validated at the network/cache boundary + 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)) { + matches = append(matches, model) + } + } + return matches +} + +func (c *Catalog) Describe(key string) (json.RawMessage, error) { + key = strings.TrimSpace(key) + for _, raw := range c.Config.Models { + var model Summary + _ = json.Unmarshal(raw, &model) + if model.Key == key { + return describeModel(raw) + } + } + return nil, fmt.Errorf("未找到可用模型 %q;请执行 model list --refresh 查看当前模型", key) +} diff --git a/internal/models/models_test.go b/internal/models/models_test.go new file mode 100644 index 0000000..a678291 --- /dev/null +++ b/internal/models/models_test.go @@ -0,0 +1,200 @@ +package models + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "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" +) + +const validResponse = `{"ret":"0","data":{"scene":"web_turbo_video_generator","config_key":"key1","config":{"models":[{"key":"new-model","kind":"video","name":"新模型","is_default":true,"supported_ratio_list":[0,1],"audio_total_limit":0,"future_field":9007199254740993}]}}}` + +func newTestService(t *testing.T, handler http.HandlerFunc) *Service { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + cfg := config.Load() + cfg.BaseURL, cfg.AccessKey = server.URL, "private-test-key" + runner := common.NewRunner(cfg, nil) + runner.Auth = auth.NewManager(cfg) + runner.Client = common.NewHTTPClient(server.URL, time.Second, common.NewAccessKeyContextProviderAuthorizer(runner.Auth.ResolveAccessKey)) + s := NewService(runner) + s.cacheDir = t.TempDir() + return s +} + +type unavailableAuth struct{ common.AuthManager } + +func (unavailableAuth) ResolveAccessKey(context.Context) (string, error) { + return "", errors.New("expired credential") +} + +func TestMissingCredentialsCannotReadFreshCache(t *testing.T) { + requests := 0 + s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) { + requests++ + _, _ = w.Write([]byte(validResponse)) + }) + if _, err := s.Get(context.Background(), false); err != nil { + t.Fatal(err) + } + s.runner.Auth = unavailableAuth{} + result, err := s.Get(context.Background(), 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) + } +} + +func TestCacheTTLRefreshAndIsolation(t *testing.T) { + requests := 0 + s := newTestService(t, func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != "POST" || r.URL.Path != config.GetAvailableModelListPath || !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + 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"] != "" { + t.Errorf("unexpected body: %v, err=%v", body, err) + } + _, _ = w.Write([]byte(validResponse)) + }) + now := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC) + s.now = func() time.Time { return now } + check := func(refresh, cached bool, count int) *Result { + t.Helper() + result, err := s.Get(context.Background(), 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) + } + return result + } + first := check(false, false, 1) + now = now.Add(CacheTTL - time.Second) + cached := check(false, true, 1) + if !cached.FetchedAt.Equal(first.FetchedAt) { + t.Fatal("cache hit must not renew TTL") + } + now = now.Add(time.Second) + check(false, false, 2) // expires exactly at five minutes + check(true, false, 3) + s.runner.Config.AccessKey = "second-account" + check(false, false, 4) + oldPath := s.cachePath("second-account", config.GetAvailableModelListPath) + s.runner.Config.BaseURL += "/other-api" + if s.cachePath("second-account", config.GetAvailableModelListPath) == oldPath { + t.Fatal("API base URLs must not share cache") + } + files, _ := os.ReadDir(s.cacheDir) + for _, file := range files { + raw, _ := os.ReadFile(filepath.Join(s.cacheDir, file.Name())) + if strings.Contains(string(raw)+file.Name(), "private-test-key") || strings.Contains(string(raw)+file.Name(), "second-account") { + t.Fatal("cache must not persist raw credentials") + } + } +} + +func TestFailedQueryDoesNotUseOrCacheStaleData(t *testing.T) { + response := validResponse + requests := 0 + s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) { + requests++ + _, _ = w.Write([]byte(response)) + }) + if _, err := s.Get(context.Background(), false); err != nil { + t.Fatal(err) + } + for _, invalid := range []string{ + `{"ret":"1001","errmsg":"unavailable","log_id":"log_123"}`, + `{"ret":"0","data":null}`, + `{"ret":"0","data":{"scene":"other","config_key":"key","config":{}}}`, + `not-json`, + } { + response = invalid + if result, err := s.Get(context.Background(), 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 { + t.Fatal("retry must query the server, not reuse cached success or failure") + } + } + response = validResponse + if _, err := s.Get(context.Background(), false); err != nil { + t.Fatal(err) + } + cachePath := s.cachePath(s.runner.Config.AccessKey, config.GetAvailableModelListPath) + if err := os.WriteFile(cachePath, []byte("broken"), 0600); err != nil { + t.Fatal(err) + } + result, err := s.Get(context.Background(), 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 { + t.Fatal("future-dated cache must be ignored") + } +} + +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) + if err != nil { + t.Fatal(err) + } + raw, err := result.Catalog.Describe("new-model") + if err != nil || !strings.Contains(string(raw), "9007199254740993") || !strings.Contains(string(raw), `"audio_total_limit":0`) { + t.Fatalf("configuration lost values: %s %v", raw, err) + } + if len(result.Catalog.Search("新模")) != 1 || len(result.Catalog.Search("missing")) != 0 { + t.Fatal("unexpected search result") + } + if _, err := result.Catalog.Describe("missing"); err == nil { + t.Fatal("missing model must not invent a config") + } + } +} + +func TestCacheWriteFailureStillReturnsServerResult(t *testing.T) { + s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(validResponse)) }) + file := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(file, []byte("x"), 0600); err != nil { + t.Fatal(err) + } + s.cacheDir = file + result, err := s.Get(context.Background(), false) + if err != nil || result.Cached || result.Warning == "" { + t.Fatalf("result=%+v err=%v", result, err) + } +} + +func TestEmptyAndDuplicateModels(t *testing.T) { + for _, tc := range []struct { + data string + valid bool + }{ + {`{"scene":"web_turbo_video_generator","config_key":"empty","config":{}}`, true}, + {`{"scene":"web_turbo_video_generator","config_key":"dup","config":{"models":[{"key":"x","kind":"video"},{"key":"x","kind":"video"}]}}`, false}, + {`{"scene":"web_turbo_video_generator","config_key":"null","config":{"models":[null]}}`, false}, + } { + var catalog Catalog + if err := json.Unmarshal([]byte(tc.data), &catalog); err != nil { + t.Fatal(err) + } + if err := validateCatalog(&catalog); (err == nil) != tc.valid { + t.Fatalf("validateCatalog = %v", err) + } + } +} diff --git a/internal/short_drama/submit_run.go b/internal/short_drama/submit_run.go index 2a43ddf..18d01b8 100644 --- a/internal/short_drama/submit_run.go +++ b/internal/short_drama/submit_run.go @@ -9,6 +9,7 @@ import ( // SubmitRunOptions is the stable command-facing request shape for short drama run submission. type SubmitRunOptions struct { + Source string `json:"platform,omitempty"` Message string `json:"message"` ThreadID string `json:"thread_id,omitempty"` AssetIDs []string `json:"asset_ids,omitempty"` @@ -36,6 +37,7 @@ func SubmitRun(ctx context.Context, opts *SubmitRunOptions, runner *common.Runne body["asset_ids"] = opts.AssetIDs } body["agent_name"] = "pippit_nest_novel_agent" + body = common.WithSubmitRunSource(body, opts.Source) var resp common.SubmitRunResponse if err := runner.Client.SendRequest(ctx, common.SubmitRunPath(runner), body, &resp); err != nil { diff --git a/internal/video_tool/video_tool.go b/internal/video_tool/video_tool.go index e0b9b55..23cfd2a 100644 --- a/internal/video_tool/video_tool.go +++ b/internal/video_tool/video_tool.go @@ -17,6 +17,7 @@ const ( // SuperResolutionOptions is the command-facing request shape for video-super-resolution. type SuperResolutionOptions struct { + Source string VideoPath string ToolVersion string OutputResolution string @@ -24,6 +25,7 @@ type SuperResolutionOptions struct { // EraseSubtitleOptions is the command-facing request shape for erase-video-subtitle. type EraseSubtitleOptions struct { + Source string VideoPath string } @@ -43,7 +45,7 @@ func RunSuperResolution(ctx context.Context, opts *SuperResolutionOptions, runne if err != nil { return nil, err } - return common.SubmitRun(ctx, "video-super-resolution", buildSuperResolutionSubmitRunBody(opts, assetID), runner) + return common.SubmitRun(ctx, "video-super-resolution", common.WithSubmitRunSource(buildSuperResolutionSubmitRunBody(opts, assetID), opts.Source), runner) } // RunEraseSubtitle uploads one video and submits a subtitle-erasing run. @@ -59,7 +61,7 @@ func RunEraseSubtitle(ctx context.Context, opts *EraseSubtitleOptions, runner *c if err != nil { return nil, err } - return common.SubmitRun(ctx, "erase-video-subtitle", buildEraseSubtitleSubmitRunBody(assetID), runner) + return common.SubmitRun(ctx, "erase-video-subtitle", common.WithSubmitRunSource(buildEraseSubtitleSubmitRunBody(assetID), opts.Source), runner) } // ValidateSuperResolutionOptions validates only command shape; semantic enums remain service-owned. diff --git a/scripts/skills.test.js b/scripts/skills.test.js index 2020b8d..8cf4e7f 100644 --- a/scripts/skills.test.js +++ b/scripts/skills.test.js @@ -32,6 +32,12 @@ assert.ok( "xyq-short-drama-skill must remain user-invocable", ); +for (const content of [generalSkill, shortDramaSkill]) { + for (const expected of ["--source", "doubao_office", "workbuddy", "codex", "静默", "统计"]) { + assert(content.includes(expected), `Skill missing source attribution contract: ${expected}`); + } +} + // The Skill is a self-contained document graph: follow only the selected module // at runtime, but verify all shipped references and examples offline here. const skillRoot = path.dirname(generalSkillPath); @@ -57,6 +63,7 @@ const commandModules = { canvas: ["canvas"], "generate-image": ["generate-image"], "generate-video": ["generate-video"], + model: ["model"], "video-super-resolution": ["video-super-resolution"], "erase-video-subtitle": ["erase-video-subtitle"], "query-result": ["query-result"], diff --git a/skills/short-drama/SKILL.md b/skills/short-drama/SKILL.md index 597e927..14237d7 100644 --- a/skills/short-drama/SKILL.md +++ b/skills/short-drama/SKILL.md @@ -21,6 +21,10 @@ metadata: 短剧场景面向剧情、人物、分集与画面化叙事创作,用户的原始需求通过 `--message` 发送给后端 Agent。后端 Agent 负责理解任务、编排流程和生成内容;用户侧 Agent 负责提交任务、并行查询进展与产物、主动下载重要资产并展示结果。 +## 宿主来源统计 + +调用 `short-drama +submit-run` 时,宿主 Agent 根据可信的实际运行环境静默附加可选 `--source`:豆包办公为 `doubao_office`,WorkBuddy 为 `workbuddy`,Codex 为 `codex`;其它已知宿主使用真实、稳定的产品标识。不附带版本、会话 ID、用户信息或 prompt,不从用户创作内容猜测来源,不向用户询问或增加确认。来源不明时省略;仅用于统计,不影响实际工具效果,不改写 `--message`。上传、查询和下载命令不附加此参数。旧版 CLI 以 `--help` 为准,不支持时省略,不因统计字段阻塞任务或重复提交。 + ## 功能 1. **提交短剧 Run 任务** - 创建新会话或向已有会话发送短剧创作需求。 diff --git a/skills/xyq-nest-skill/SKILL.md b/skills/xyq-nest-skill/SKILL.md index d14e508..17c8ba2 100644 --- a/skills/xyq-nest-skill/SKILL.md +++ b/skills/xyq-nest-skill/SKILL.md @@ -27,6 +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) | | 生成视频,使用图/视频/音频参考,首尾帧生视频 | `generate-video` | [生视频](commands/generate-video.md) | | 提升已有视频分辨率、视频超分 | `video-super-resolution` | [超分](commands/video-super-resolution.md) | | 去除已有视频字幕 | `erase-video-subtitle` | [擦字幕](commands/erase-video-subtitle.md) | @@ -48,6 +49,15 @@ metadata: - 提交成功后立即展示真实 `web_thread_link`;未返回链接时如实说明,保留任务 ID。后续查询和下载失败不能触发重复生成。 - 每个最终图片/视频都通过宿主文件交付或媒体渲染能力展示为真实附件或可预览媒体。URL、路径列表仅作补充;详细完成标准见共用交付流程。 +## 宿主来源统计 + +调用 `generate-image`、`generate-video`、`video-super-resolution`、`erase-video-subtitle` 时,由宿主 Agent 根据实际运行环境静默附加可选 `--source`,仅用于来源统计,不影响创作参数或工具效果。 + +- 使用稳定的宿主标识:豆包办公填 `doubao_office`,WorkBuddy 填 `workbuddy`,Codex 填 `codex`;其它已知宿主使用其真实、稳定的产品标识,不附带版本、会话 ID、用户信息或 prompt。 +- 从宿主提供的可信环境信息判断;不能因用户在创作内容中提到某个平台就认定它是来源,也不要把后端 Agent 名当作宿主来源。 +- 不向用户询问、不增加确认步骤,不为此改写 prompt。无法确认来源时直接省略;不传或空值均不阻塞提交。 +- 来源只随本次提交发送,不加入上传、查询、下载或 Canvas 命令。使用已有旧版 CLI 时先按 `--help` 确认是否支持;不支持则省略,不因统计字段中断任务或在未知提交结果时重提。 + ## 按需参考的完整场景 命令文档含最小调用示例;需要了解从需求到交付的组合过程时,再读对应场景: diff --git a/skills/xyq-nest-skill/commands/erase-video-subtitle.md b/skills/xyq-nest-skill/commands/erase-video-subtitle.md index d059169..5f1999b 100644 --- a/skills/xyq-nest-skill/commands/erase-video-subtitle.md +++ b/skills/xyq-nest-skill/commands/erase-video-subtitle.md @@ -11,3 +11,5 @@ pippit-tool-cli erase-video-subtitle --video "/path/to/source.mp4" 成功返回 `thread_id`、`run_id`、`web_thread_link`,继续 [异步结果与媒体交付](../workflows/async-delivery.md)。失败时报告原因,不把其他生成命令当作字幕处理的自动降级方案。 用户还明确要求超分时,参考 [擦字幕后超分](../examples/video-process-chain.md),用第一步下载得到的实际视频路径衔接第二步。 + +`--source` 是可选来源统计参数,由宿主 Agent 根据真实环境静默填写(如 `doubao_office`、`workbuddy`、`codex`);来源不明时省略,不询问用户,不改变 prompt 或创作参数。详见 [宿主来源统计](../SKILL.md#宿主来源统计)。 diff --git a/skills/xyq-nest-skill/commands/generate-image.md b/skills/xyq-nest-skill/commands/generate-image.md index 4f75131..7588892 100644 --- a/skills/xyq-nest-skill/commands/generate-image.md +++ b/skills/xyq-nest-skill/commands/generate-image.md @@ -30,3 +30,5 @@ pippit-tool-cli generate-image --prompt "用户原始描述" --model IMAGE_MODEL ## 返回与处理 成功返回 JSON 中的 `thread_id`、`run_id`、`web_thread_link`;随后执行 [异步结果与媒体交付](../workflows/async-delivery.md)。参数错误、上传失败或服务端拒绝时停止并说明原因,不切换模型或重新提交。仅在已明确失败、问题已修正且原授权仍适用时重试;提交结果不确定时避免重复收费。 + +`--source` 是可选来源统计参数,由宿主 Agent 根据真实环境静默填写(如 `doubao_office`、`workbuddy`、`codex`);来源不明时省略,不询问用户,不改变 prompt 或创作参数。详见 [宿主来源统计](../SKILL.md#宿主来源统计)。 diff --git a/skills/xyq-nest-skill/commands/generate-video.md b/skills/xyq-nest-skill/commands/generate-video.md index 6a23f98..7d8cd8c 100644 --- a/skills/xyq-nest-skill/commands/generate-video.md +++ b/skills/xyq-nest-skill/commands/generate-video.md @@ -11,54 +11,38 @@ | `--image` | 否 | 本地图片路径,重复参数 | | `--video` | 否 | 本地参考视频路径,重复参数 | | `--audio` | 否 | 本地 `.mp3/.wav` 音频路径,重复参数 | -| `--duration` | 按模型 | 整数秒;MiniMax、Wan、HappyHorse 可省略使用下表默认值,其他模型需明确提供;用户只给时长范围时先确认具体秒数 | +| `--duration` | 按模型 | 整数秒;MiniMax、Wan、HappyHorse 可省略由服务端补齐默认值,其他模型需明确提供;用户只给时长范围时先确认具体秒数 | | `--ratio` | 按模式 | 比例字符串,不转换为生图枚举;MiniMax、Wan、HappyHorse 可省略使用服务端配置默认值;其他模型需明确提供 | -| `--resolution` | 按模型 | MiniMax、Wan、HappyHorse 可省略使用下表默认值;其他模型需明确提供,如 `720p`、`1080p` | +| `--resolution` | 按模型 | `Seedance_2.0_mini`、`Seedance_2.0_mini_lite` 可省略,服务端默认 `720p`;MiniMax、Wan、HappyHorse 可省略使用服务端配置默认值;其他模型需明确提供 | | `--generate-type` | 否 | 首尾帧任务传 `1`;其他显式值交服务端处理 | -支持的模型枚举:`Seedance_2.0_mini_lite`、`seedance2.0_vision`、`seedance2.0_fast_vision`、`Seedance_2.0_mini`、`Seedance_2.5`、`MiniMax-H3`、`MiniMax-H3-Max`、`wan3.0`、`happyhorse-1.1`。该列表仅用于选择提示,不自行新增本地模型或分辨率组合校验。 +当前支持的模型以 `pippit-tool-cli model list` 为准,完整参数配置用 `pippit-tool-cli model describe MODEL_KEY` 查询;缓存有效期为 5 分钟,需要最新结果时加 `--refresh`。详情见 [模型发现](model.md)。CLI 不自行维护模型白名单或分辨率组合校验。 本地图片支持 `.jpg/.jpeg/.png/.gif/.bmp/.webp/.svg`;视频支持 `.mp4/.avi/.mov/.wmv/.flv/.webm/.mkv/.m4v`。CLI 内部上传参考素材。 -## MiniMax 模型 +## 模型与模式 -| 能力 | `MiniMax-H3` | `MiniMax-H3-Max` | -| --- | --- | --- | -| 分辨率 | `768p`、`2k`;默认 `768p` | `480p`、`768p`;默认 `768p` | -| 生成时长 | 整数 4–15 秒;默认 10 秒 | 整数 5–15 秒;默认 10 秒 | -| 固定比例 | `21:9`、`16:9`、`4:3`、`1:1`、`3:4`、`9:16` | `16:9`、`4:3`、`1:1`、`3:4`、`9:16`;不允许 `21:9` | -| 文生视频 | 支持;省略比例默认 `9:16`,不能用 `adaptive` | 支持;省略比例默认 `16:9`,不能用 `adaptive` | -| 普通参考图 | 支持 | 不支持 | -| 首帧/首尾帧 | 传 `--generate-type 1` | 同左;单图也必须指定此模式 | -| 视频参考 | 支持,单条 2–15 秒,总时长不超过 15 秒 | 不支持 | -| 音频参考 | 必须同时有参考图片或视频;Agent 继续执行自身限制 | 不支持 | +先从模型列表取得准确 key,再查看配置中的分辨率、时长、比例和创作模式。配置会随服务端调整,本页示例不作为可用模型或参数范围清单。 -以上是当前服务端配置对应的能力,实际以请求时返回的配置为准。图片单张不超过 30MB。两模型首尾帧模式都不能混用参考视频或音频。H3 带视觉素材时可显式使用 `adaptive`,省略比例仍取配置默认值;Max 首尾帧模式统一使用 `adaptive`;即使传入固定比例,服务端也会自动改为自适应比例。`720p` 是兼容输入,会映射为实际输出 `768p`;新调用优先直接使用 `768p`。当前 H3 配置未提供音频独立数量和时长上限,API 不额外硬编码这些限制;CLI 不限制素材数量,Agent 校验仍生效。 +MiniMax、Wan、HappyHorse 可省略分辨率、时长、比例,由服务端配置补默认值。MiniMax 的兼容输入 `720p` 会映射到 `768p`,Wan/HappyHorse 的 `720p` 保持不变。Max 首尾帧模式会按素材采用自适应比例。素材可用组合以当前配置和服务端校验为准,CLI 不限制素材数量。 ```bash +pippit-tool-cli model list +pippit-tool-cli model describe MiniMax-H3 pippit-tool-cli generate-video --prompt "用户原始描述" --model MiniMax-H3 --ratio 16:9 -pippit-tool-cli generate-video --prompt "用户原始描述" --model MiniMax-H3-Max --generate-type 1 --image FIRST_FRAME_PATH --resolution 768p --duration 10 ``` -## Wan 3.0 与 HappyHorse 1.1 - -两模型均必须提供 `--prompt`。素材通过现有 `--image`、`--video`、`--audio` 参数上传,是否支持由服务端校验;本次不提供文件或网页链接输入。 +模型查询失败时提示重试,不从本文示例推定模型仍然可用,不自行替换用户指定的模型。Wan 同样必须有提示词;文件和网页链接输入不在本命令范围内。 -| 参数 | `wan3.0` | `happyhorse-1.1` | -| --- | --- | --- | -| 分辨率 | `480p`、`720p`、`1080p` | `720p`、`1080p` | -| 生成时长 | 整数 4–30 秒,不支持 `-1` | 整数 3–15 秒 | -| 比例 | `adaptive`(智能)、`16:9`、`9:16`、`4:3`、`3:4`、`1:1` | `16:9`、`21:9`、`9:16`、`4:3`、`3:4`、`1:1` | -| 模式 | 全能参考;首尾帧传 `--generate-type 1` | 文生视频或图片参考;不使用首尾帧模式 | +## 最小调用 -省略分辨率、时长、比例时使用服务端配置默认值。当前配置两模型均默认 `720p`、10 秒;Wan 默认 `adaptive`,HappyHorse 默认 `16:9`。Wan、HappyHorse 的 `720p` 不转换为 `768p`。HappyHorse 当前不支持视频或音频参考。实际规则以请求时的生效配置及服务端校验为准。 +`Seedance_2.0_mini` 和 `Seedance_2.0_mini_lite` 可以省略分辨率,CLI 不补值,交由服务端默认使用 `720p`: ```bash -pippit-tool-cli generate-video --prompt "用户原始描述" --model wan3.0 --duration 10 --resolution 720p --ratio 16:9 -pippit-tool-cli generate-video --prompt "用户原始描述" --model happyhorse-1.1 --duration 10 --resolution 720p --ratio 16:9 +pippit-tool-cli generate-video --prompt "用户原始描述" --model Seedance_2.0_mini --duration 5 --ratio 16:9 ``` -## 最小调用 +`seedance2.0_vision` 仍需指定分辨率: ```bash pippit-tool-cli generate-video --prompt "用户原始描述" --model seedance2.0_vision --duration 5 --ratio 16:9 --resolution 720p @@ -69,3 +53,5 @@ pippit-tool-cli generate-video --prompt "用户原始描述" --model seedance2.0 ## 返回与处理 成功返回 `thread_id`、`run_id`、`web_thread_link`,继续 [异步结果与媒体交付](../workflows/async-delivery.md)。素材、权限或参数失败时说明原因,不自动降低用户指定的模型、分辨率或删减素材。无法确认提交是否成功时,不重复生成。 + +`--source` 是可选来源统计参数,由宿主 Agent 根据真实环境静默填写(如 `doubao_office`、`workbuddy`、`codex`);来源不明时省略,不询问用户,不改变 prompt 或创作参数。详见 [宿主来源统计](../SKILL.md#宿主来源统计)。 diff --git a/skills/xyq-nest-skill/commands/model.md b/skills/xyq-nest-skill/commands/model.md new file mode 100644 index 0000000..0448777 --- /dev/null +++ b/skills/xyq-nest-skill/commands/model.md @@ -0,0 +1,59 @@ +# model:发现可用视频模型 + +需要有效登录或 `XYQ_ACCESS_KEY`。生成前可查询当前账号可用的模型及参数配置;目前只支持视频模型。 + +```bash +# 全部可见视频模型 +pippit-tool-cli model list + +# 按 key 或展示名检索;search 是 list 的别名 +pippit-tool-cli model search MiniMax --type video + +# 按准确 key 查看参数详情;两种写法等价 +pippit-tool-cli model describe MiniMax-H3 +pippit-tool-cli model MiniMax-H3 + +# 强制重新查询服务端 +pippit-tool-cli model list --refresh +pippit-tool-cli model describe MiniMax-H3 --refresh +``` + +`list` 输出 `models`,每项包含 `key`、`name`、`kind`、`is_default`。关键词与 key 完全一致时优先返回该项,否则按 key / name 不区分大小写检索。`describe` 只接受准确 key,输出整理后的 `model` 参数详情;不知道 key 时先查列表,不用展示名猜枚举。 + +两种输出均包含 `scene`、`cached`、`fetched_at`、`expires_at`。合法空列表输出 `models: []`,表示当前没有可见模型。 + +## 缓存与失败处理 + +成功结果在系统用户缓存目录的 `pippit-cli/models/` 下保留 5 分钟,命中不会延长有效期;按凭证指纹、API 地址、接口、场景及 CLI 版本隔离。缓存不保存 AK 原文。切换凭证或退出登录后,不读取先前凭证的缓存。 + +`--refresh` 跳过缓存重新查询。缓存过期、损坏或时间戳异常时重新请求;服务端查询失败返回非零退出状态并提示重试,不返回过期缓存或静态模型列表,也不缓存失败响应。可稍后重试原命令或加 `--refresh`;若是登录错误,先恢复登录。单次查询最长 30 秒,不自动重复请求。 + +缓存写入失败不丢弃本次服务端成功结果,stderr 会提示;stdout 仍为 JSON。 + +## 参数配置的使用 + +`describe` 将配置整理成可直接选择生成参数的结构。以下为示例片段,实际值以查询为准: + +```json +{ + "key": "MiniMax-H3", + "ratio": { + "options": ["adaptive", "16:9", "21:9", "9:16", "4:3", "3:4", "1:1"], + "default": "9:16" + }, + "resolution": {"options": ["768p", "2k"], "default": "768p"}, + "duration": {"min": 4, "max": 15, "step": 1, "default": 10, "unit": "seconds"} +} +``` + +- `ratio.options/default` 按 IDL 转为字符串,可直接用于 `--ratio`。支持全部有对应生成参数的已有比例枚举;`0` 为 `adaptive`。自定义比例 `1` 没有对应的 CLI 尺寸参数,和未知枚举一样跳过;未知默认值不输出,不猜测替代值。 +- `resolution.options` 去除 disabled 选项,默认值必须在有效选项内。 +- `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)。 + +查询结果反映当前账号的服务端可见配置及 Skill 模型白名单;最终提交仍由服务端判断权限、参数、余额等条件。缓存最多滞后 5 分钟,需要最新值时使用 `--refresh`。生成命令不会自动请求模型列表或凭缓存拦截生成。 diff --git a/skills/xyq-nest-skill/commands/video-super-resolution.md b/skills/xyq-nest-skill/commands/video-super-resolution.md index ca1f130..2ba59b7 100644 --- a/skills/xyq-nest-skill/commands/video-super-resolution.md +++ b/skills/xyq-nest-skill/commands/video-super-resolution.md @@ -15,3 +15,5 @@ pippit-tool-cli video-super-resolution --video "/path/to/source.mp4" --output-re ``` 示例以用户要求 1080p 为前提。成功返回 `thread_id`、`run_id`、`web_thread_link`,按 [异步结果与媒体交付](../workflows/async-delivery.md) 查询、下载并交付。输入失败或服务端拒绝时停止,不擅自切换版本或分辨率。 + +`--source` 是可选来源统计参数,由宿主 Agent 根据真实环境静默填写(如 `doubao_office`、`workbuddy`、`codex`);来源不明时省略,不询问用户,不改变 prompt 或创作参数。详见 [宿主来源统计](../SKILL.md#宿主来源统计)。 From 39fd89db3a786fb51e0a67e7a27741ffa2f38a78 Mon Sep 17 00:00:00 2001 From: ykb Date: Mon, 21 Sep 2026 17:49:35 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=E5=AE=89=E8=A3=85=E5=BC=95=E5=AF=BC?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=A8=A1=E5=9E=8B=E6=9F=A5=E8=AF=A2=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 ++ scripts/install-cli.test.js | 8 ++++---- skills/xyq-nest-skill/scripts/ensure-cli.js | 4 ++-- skills/xyq-nest-skill/scripts/install.md | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8929e94..ee4ca23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,3 +5,5 @@ - 内部配置指纹不向用户输出;原始配置缓存和用户展示结构分离,保留可选字段缺失与零值的区别。 - 模型发现验收必须分别核对用户确认的预期模型集合与真实接口返回值转换;CLI 与接口同时少返回模型不能判为完整通过。请求携带 PPE 头也不能替代实际路由和生效配置证据。 - `Seedance_2.0_mini` 和 `Seedance_2.0_mini_lite` 允许省略生成分辨率,由服务端默认 `720p`;CLI 不主动补值,不因模型查询缺少分辨率维度而将该参数标为必填或伪造配置。 + +- 安装引导的最小兼容修复只补必需命令检查;可选参数已有 `--help` 判断约定时复用该约定,不另增能力返回字段或升级条件。 diff --git a/scripts/install-cli.test.js b/scripts/install-cli.test.js index 7a4b21d..d3e0f9e 100644 --- a/scripts/install-cli.test.js +++ b/scripts/install-cli.test.js @@ -182,8 +182,8 @@ function checkBootstrap() { assert.strictEqual(result.cli_path, JSON.parse(next.output[0]).cli_path); assert.strictEqual(fixture.dirs.length, 1, "Subsequent invocations must reuse the cached CLI"); for (const command of ["status", "login", "logout", "query-result", - "generate-image", "generate-video", "video-super-resolution", "erase-video-subtitle", "get-credit-balance"]) { - assert.strictEqual(fixture.calls.filter((call) => call.args[0] === command).length, 2); + "generate-image", "generate-video", "video-super-resolution", "erase-video-subtitle", "get-credit-balance", "model list", "model describe"]) { + assert.strictEqual(fixture.calls.filter((call) => call.args.slice(0, -1).join(" ") === command).length, 2); } assert.strictEqual(fixture.calls.filter((call) => call.args[0].endsWith("install-cli.js")).length, 1); // If the binary is removed, repair the incomplete cache with a fresh installation. @@ -213,7 +213,7 @@ function checkBootstrap() { } // Every retained command participates in compatibility checks and cache reuse. for (const missingCommand of ["status", "login", "logout", "query-result", "generate-image", - "generate-video", "video-super-resolution", "erase-video-subtitle", "get-credit-balance"]) { + "generate-video", "video-super-resolution", "erase-video-subtitle", "get-credit-balance", "model list", "model describe"]) { const fixture = bootstrapFixture({ missingCommand }); const existing = path.join(fixture.npmDir, "pippit-tool-cli"); fs.writeFileSync(existing, "missing-command"); @@ -262,7 +262,7 @@ function checkBootstrap() { assert.strictEqual(load(bootstrap, failedUpgrade.options).proc.exitCode, 1); assert.strictEqual(failedUpgrade.dirs.length, 1, "An incompatible latest release must fail without an upgrade loop"); assert.strictEqual(fs.readFileSync(oldPath, "utf8"), "missing-command"); - for (const failure of ["npm", "missing-installer", "download", "version", "status", "logout", "generate-image", "query-result"]) { + for (const failure of ["npm", "missing-installer", "download", "version", "status", "logout", "generate-image", "query-result", "model list", "model describe"]) { const fixture = bootstrapFixture({ failure }); const result = load(bootstrap, fixture.options); assert.strictEqual(result.proc.exitCode, 1, failure); diff --git a/skills/xyq-nest-skill/scripts/ensure-cli.js b/skills/xyq-nest-skill/scripts/ensure-cli.js index 7a620a1..fabc507 100644 --- a/skills/xyq-nest-skill/scripts/ensure-cli.js +++ b/skills/xyq-nest-skill/scripts/ensure-cli.js @@ -9,7 +9,7 @@ const path = require("path"); const REQUIRED_COMMANDS = [ "status", "login", "logout", "query-result", "generate-image", "generate-video", "video-super-resolution", - "erase-video-subtitle", "get-credit-balance", + "erase-video-subtitle", "get-credit-balance", "model list", "model describe", ]; function npmCommand() { @@ -79,7 +79,7 @@ function ensureCLI({ canvas = false } = {}) { if (expectedVersion && version !== expectedVersion) { throw new Error(`CLI 版本 ${version} 与 npm 包版本 ${expectedVersion} 不一致。`); } - const commands = REQUIRED_COMMANDS.map((command) => [command]); + const commands = REQUIRED_COMMANDS.map((command) => command.split(" ")); if (canvas) { for (const command of ["create", "get", "allocate", "upload", "apply"]) commands.push(["canvas", command]); } diff --git a/skills/xyq-nest-skill/scripts/install.md b/skills/xyq-nest-skill/scripts/install.md index 1e4da1c..423b6bc 100644 --- a/skills/xyq-nest-skill/scripts/install.md +++ b/skills/xyq-nest-skill/scripts/install.md @@ -10,7 +10,7 @@ node "{baseDir}/scripts/ensure-cli.js" ## 查找与复用 -脚本依次检查 PATH 中的 CLI 和自身缓存,验证版本及本 Skill 使用命令的 `--help`;命令集合维护在脚本的 `REQUIRED_COMMANDS`。帮助检查不调用生成服务,也不需要凭据,不证明账号权限或服务端运行状态。 +脚本依次检查 PATH 中的 CLI 和自身缓存,验证版本及本 Skill 使用命令的 `--help`;命令集合维护在脚本的 `REQUIRED_COMMANDS`,包含 `model list` 和 `model describe`。帮助检查不调用生成服务,也不需要凭据,不证明账号权限或服务端运行状态。 命令齐全则直接复用,不检查最新版本;不存在或缺少必需命令时,获取 `@pippit-dev/cli@latest`。PATH 旧版本缺少命令但缓存完整时复用缓存,避免每次升级。版本命令不能运行或检查超时则报告运行错误。 From 423e329c878ab1ae65e7d66c509b276ee2251012 Mon Sep 17 00:00:00 2001 From: ykb Date: Mon, 21 Sep 2026 18:20:11 +0800 Subject: [PATCH 7/8] =?UTF-8?q?chore:=20=E5=8F=91=E5=B8=83=20v1.0.27?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6e153bd..1943671 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pippit-dev/cli", - "version": "1.0.26", + "version": "1.0.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pippit-dev/cli", - "version": "1.0.26", + "version": "1.0.27", "hasInstallScript": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 90fe444..b629484 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pippit-dev/cli", - "version": "1.0.26", + "version": "1.0.27", "description": "Pippit CLI", "bin": { "pippit-tool-cli": "scripts/run.js" From b4b0e6a19a8a8ccc31f77185313afab9233be833 Mon Sep 17 00:00:00 2001 From: ykb Date: Mon, 21 Sep 2026 18:36:22 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=E5=BB=B6=E9=95=BF=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E5=B9=B6=E5=B1=95=E7=A4=BA=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=EF=BC=8C=E5=8F=91=E5=B8=83=20v1.0.28?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/update/update.go | 2 +- package-lock.json | 4 ++-- package.json | 2 +- scripts/install-cli.test.js | 3 +++ scripts/install.js | 5 +++-- skills/xyq-nest-skill/scripts/ensure-cli.js | 2 +- 6 files changed, 11 insertions(+), 7 deletions(-) diff --git a/cmd/update/update.go b/cmd/update/update.go index 4e12a4b..4d84715 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -63,7 +63,7 @@ func runUpdate(stdout, stderr io.Writer) error { if err != nil { return fmt.Errorf("准备替换当前可执行文件失败: %w", err) } - if err := runInheritEnv(stderr, []string{"PIPPIT_CLI_SKIP_SKILLS=1"}, "npm", "install", "-g", "--prefer-online", pkg); err != nil { + if err := runInheritEnv(stderr, []string{"PIPPIT_CLI_SKIP_SKILLS=1"}, "npm", "install", "-g", "--prefer-online", "--foreground-scripts", pkg); err != nil { restore() return fmt.Errorf("更新 pippit-tool-cli 失败: %w", err) } diff --git a/package-lock.json b/package-lock.json index 1943671..5f64550 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pippit-dev/cli", - "version": "1.0.27", + "version": "1.0.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pippit-dev/cli", - "version": "1.0.27", + "version": "1.0.28", "hasInstallScript": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index b629484..cf23abb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pippit-dev/cli", - "version": "1.0.27", + "version": "1.0.28", "description": "Pippit CLI", "bin": { "pippit-tool-cli": "scripts/run.js" diff --git a/scripts/install-cli.test.js b/scripts/install-cli.test.js index d3e0f9e..be66d21 100644 --- a/scripts/install-cli.test.js +++ b/scripts/install-cli.test.js @@ -44,6 +44,9 @@ function checkInstaller() { isWindows: process.platform === "win32", run(command, args, opts) { if (command === "curl") { + assert(args.includes("--progress-bar")); + assert(!args.includes("--silent")); + assert.strictEqual(args[args.indexOf("--max-time") + 1], "600"); fs.writeFileSync(args[args.indexOf("--output") + 1], archive); } else if (command === "tar" || command === "powershell.exe") { const dest = command === "tar" ? args[args.indexOf("-C") + 1] : opts.env.PIPPIT_CLI_DEST; diff --git a/scripts/install.js b/scripts/install.js index f0cd135..b77bbbe 100644 --- a/scripts/install.js +++ b/scripts/install.js @@ -36,12 +36,12 @@ function download(url, destPath) { const args = [ "--fail", "--location", - "--silent", + "--progress-bar", "--show-error", "--connect-timeout", "10", "--max-time", - "120", + "600", "--max-redirs", "3", "--output", @@ -51,6 +51,7 @@ function download(url, destPath) { args.unshift("--ssl-revoke-best-effort"); } args.push(url); + console.error(`Downloading ${archiveName} (timeout: 10 minutes)...`); run("curl", args); } diff --git a/skills/xyq-nest-skill/scripts/ensure-cli.js b/skills/xyq-nest-skill/scripts/ensure-cli.js index fabc507..6e7ba2f 100644 --- a/skills/xyq-nest-skill/scripts/ensure-cli.js +++ b/skills/xyq-nest-skill/scripts/ensure-cli.js @@ -64,7 +64,7 @@ function ensureCLI({ canvas = false } = {}) { function run(command, args, label, quiet = false) { try { return execFileSync(command, args, { - cwd: installDir, env, timeout: quiet ? 10000 : 180000, + cwd: installDir, env, timeout: quiet ? 10000 : 660000, stdio: quiet ? ["ignore", "pipe", "pipe"] : ["ignore", 2, 2], }); } catch (err) {