@pippit-dev/cli 1.0.11 → 1.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,11 +9,19 @@
9
9
  | 技能 | 说明 | 路径 |
10
10
  |-------|-------------|------|
11
11
  | `xyq-short-drama-skill` | 短剧工作流技能,支持提交创作任务、上传参考文件、查询进度、列出会话文件和下载产物。 | `skills/short-drama/` |
12
- | `xyq-skill` | 通用 NestAgent 技能,支持图片/视频生成、编辑、图片/视频/mp3或wav音频文件上传、进度查询和结果下载。 | `skills/xyq-nest-skill/` |
12
+ | `xyq-skill` | 通用创作技能,支持 NestAgent 图片/视频生成与编辑,并在视频模型直出时调用 `pippit-tool-cli generate-video`。 | `skills/xyq-nest-skill/` |
13
+
14
+ ### 技能路由
15
+
16
+ - 通用图片/视频生成、编辑和复杂参考素材编排使用 `xyq-skill`。
17
+ - 用户明确要求视频模型直出、指定视频模型或直接调用 CLI 时,由 `xyq-skill` 调用 `pippit-tool-cli generate-video`,再用 `query-result` 查询和下载结果。
18
+ - 短剧生成、续写、改写、人物设定、分集创作和短剧会话文件处理使用 `xyq-short-drama-skill`,不要与通用创作流程混用。
19
+
20
+ 两份技能需要用户补充、选择或确认时,优先调用宿主的结构化提问工具:Codex 使用 `request_user_input`,WorkBuddy 使用 `ask_user_question`,Trae 和其他宿主使用实际暴露的同类工具;没有同类工具时退回普通聊天提问。
13
21
 
14
22
  ## 通用 NestAgent 技能
15
23
 
16
- `xyq-skill` 通过接入小云雀 NestAgent 的综合创作能力,实现 AI 图片/视频生成、编辑、风格转换、图片/视频/mp3或wav音频文件上传、进度查询和结果下载。
24
+ `xyq-skill` 通过接入小云雀 NestAgent 的综合创作能力,实现 AI 图片/视频生成、编辑、风格转换、图片/视频/mp3或wav音频文件上传、进度查询和结果下载;视频模型直出请求直接使用 `pippit-tool-cli generate-video`。
17
25
 
18
26
  ### 功能特性
19
27
 
@@ -23,6 +31,7 @@
23
31
  | 查询会话进展 | 增量拉取会话消息,轮询创作进度和产物结果。 |
24
32
  | 上传文件 | 上传图片/视频/mp3或wav音频到小云雀资产库,获取 `asset_id` 用于编辑和参考。 |
25
33
  | 下载结果 | 批量下载生成的图片/视频到本地,支持并行下载。 |
34
+ | 视频模型直出 | 调用 `generate-video` 提交请求,展示 `web_thread_link`,再用 `query-result` 查询并下载视频。 |
26
35
 
27
36
  小云雀平台能力覆盖:
28
37
 
@@ -213,6 +222,37 @@ pippit-tool-cli download-result --output-path ./thread_123/results/result.mp4 --
213
222
 
214
223
  短剧命令的错误日志会追加写入本地每日日志文件:`~/.pippit_tool_cli/logs/yyyy-mm-dd.log`。日志路径会基于当前用户主目录和系统路径分隔符生成,因此可在 macOS、Linux 和 Windows 上使用。
215
224
 
225
+ ## 生图 CLI
226
+
227
+ `generate-image` 会上传本地参考图片,然后向综合 Nest Agent 提交生图请求:
228
+
229
+ ```bash
230
+ pippit-tool-cli generate-image \
231
+ --prompt "生成一张小猫海报" \
232
+ --image "~/images/cat.png" \
233
+ --model "seedream_4.5" \
234
+ --ratio 6 \
235
+ --generate-image-count 2
236
+ ```
237
+
238
+ 命令输出 `thread_id`、`run_id` 和 `web_thread_link`。提交 HTTP 请求时,`agent_name` 固定为 `pippit_nest_agent`,参考图会使用上传接口返回的 `pippit_asset_id` 写入顶层 `asset_ids`,生图模型写入 `general_agent_settings.image_model`,比例写入 `general_agent_settings.ratio`,生图数量写入 `general_agent_settings.generate_image_count`。`--model` 为必填参数,CLI 只做非空校验,具体模型值是否可用由服务端决定。
239
+
240
+ `--ratio` 可选,填写服务端 `Ratio` 枚举值。CLI 只做整数格式解析,不检查枚举值是否在下表范围内;具体值是否可用由服务端决定。常用枚举值含义如下:
241
+
242
+ | ratio 参数 | IDL 枚举 | 含义 |
243
+ | ---: | --- | --- |
244
+ | `0` | `CanvasRatioOriginal` | 原始比例(自动) |
245
+ | `2` | `CanvasRatio16To9` | 16:9(横屏) |
246
+ | `13` | `CanvasRatio21To9` | 21:9(电影) |
247
+ | `3` | `CanvasRatio9To16` | 9:16(竖屏) |
248
+ | `4` | `CanvasRatio4To3` | 4:3 |
249
+ | `5` | `CanvasRatio3To4` | 3:4 |
250
+ | `6` | `CanvasRatio1To1` | 1:1 |
251
+
252
+ `--generate-image-count` 可选,填写生图数量,对应 IDL 字段 `GeneralSettingsPart.GenerateImageCount` / JSON 字段 `generate_image_count`。CLI 只校验不能为负数;具体数量范围由服务端决定。
253
+
254
+ 图片支持 `.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.webp`、`.svg`。CLI 会在提交前校验 prompt、model 必填、ratio 整数格式、generate-image-count 非负和文件后缀。
255
+
216
256
  ## 生视频 CLI
217
257
 
218
258
  `generate-video` 会上传本地参考图片、视频和音频,然后向视频片段 Agent 提交生视频请求:
@@ -233,7 +273,7 @@ pippit-tool-cli generate-video \
233
273
 
234
274
  命令输出 `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`。图片最多 9 张,支持 `.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.webp`、`.svg`;视频最多 3 个,支持 `.mp4`、`.avi`、`.mov`、`.wmv`、`.flv`、`.webm`、`.mkv`、`.m4v`;音频最多 3 个,仅支持 `.mp3`、`.wav`。普通用户支持模型 `Seedance_2.0_mini_lite`;`seedance2.0_vision`、`seedance2.0_fast_vision` 和 `Seedance_2.0_mini` 为 VIP 专属模型。CLI 会在提交前校验 prompt、素材数量和文件后缀;模型、比例、分辨率等语义校验由服务端处理。
235
275
 
236
- 查询并下载生视频结果:
276
+ 查询并下载生图/生视频结果:
237
277
 
238
278
  ```bash
239
279
  pippit-tool-cli query-result \
@@ -242,7 +282,7 @@ pippit-tool-cli query-result \
242
282
  --download-dir "./output"
243
283
  ```
244
284
 
245
- `query-result` 会查询指定 Run 并输出 JSON。Run 成功完成后下载视频产物,`completed=true`,`videos` 中只包含 `download_url` 和 `output_path`;Run 失败也视为终态,`completed=true` 且填充 `error_message`;Run 未到终态时 `completed=false`。
285
+ `query-result` 会查询指定 Run 并输出 JSON。Run 成功完成后下载视频和图片产物,`completed=true`,`videos` `images` 中各包含 `download_url` 和 `output_path`;图片扩展名取自产物 `metadata.format`,缺省时兜底 `.png`。Run 失败也视为终态,`completed=true` 且填充 `error_message`;Run 未到终态时 `completed=false`。
246
286
 
247
287
  ## HTTP 客户端
248
288
 
package/checksums.txt CHANGED
@@ -1,6 +1,6 @@
1
- c85941f7ca83a0c948dfe59ee139ab6255eda8f7e10c3dcd4a1e2e244c239229 pippit-tool-cli-1.0.11-darwin-amd64.tar.gz
2
- ed9a1bde1cf391d5a7347214cf73315ecff11186d8ebf563954faf8b0ebc0c83 pippit-tool-cli-1.0.11-darwin-arm64.tar.gz
3
- 7bd0507fa68ac3ca07537f669469b5495485344c6bf305bd5254570516e6e6e9 pippit-tool-cli-1.0.11-linux-amd64.tar.gz
4
- eac223202657b5e086ef7f79283d2724d9bc539646e5bdd0f37e0ee9c1daeb14 pippit-tool-cli-1.0.11-linux-arm64.tar.gz
5
- 809e06799e7e712af87d45f825ca3e1d84fe9ba6d8885b56727e84f9ee76749d pippit-tool-cli-1.0.11-windows-amd64.zip
6
- 09a2703e7de552b5d2a319ac99c144eca690aa43bfb1368c458d23db56440574 pippit-tool-cli-1.0.11-windows-arm64.zip
1
+ 10344df2fea39ac74dc362fce1dbf60e0dd8a73afa45402bdc3f3f3fee2aab03 pippit-tool-cli-1.0.13-darwin-amd64.tar.gz
2
+ 8bf05a6e9acea5eb4057102eda3c58d0ec17843bd800a8b0aa820688477f903b pippit-tool-cli-1.0.13-darwin-arm64.tar.gz
3
+ 7f444b3b6089d9c1d6006c1888dad75717f259a3af3b511b2fc26aaff21f1ff4 pippit-tool-cli-1.0.13-linux-amd64.tar.gz
4
+ f25ade56bd1b3b99b9fe4858fe1fc766878a79ef650961e7042f5de0d2bdda98 pippit-tool-cli-1.0.13-linux-arm64.tar.gz
5
+ 665bfaad8505fea2669dc8cbd8a4577f0179f8fe6c8a20c84b3882343b336d2d pippit-tool-cli-1.0.13-windows-amd64.zip
6
+ b4833e6dce0f7bc2981648bfe7fa029438f6ab7e58636c50134b2100302379cd pippit-tool-cli-1.0.13-windows-arm64.zip
@@ -0,0 +1,59 @@
1
+ package generate_image
2
+
3
+ import (
4
+ "io"
5
+ "strconv"
6
+ "strings"
7
+
8
+ "github.com/Pippit-dev/pippit-cli/internal/common"
9
+ internalgen "github.com/Pippit-dev/pippit-cli/internal/generate_image"
10
+ "github.com/spf13/cobra"
11
+ )
12
+
13
+ // NewCommand builds the generate-image command.
14
+ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
15
+ opts := &internalgen.Options{}
16
+ var generateImageCount int
17
+
18
+ cmd := &cobra.Command{
19
+ Use: "generate-image",
20
+ Short: "Generate an image with the nest agent",
21
+ Args: cobra.NoArgs,
22
+ RunE: func(cmd *cobra.Command, args []string) error {
23
+ if cmd.Flags().Changed("generate-image-count") {
24
+ opts.GenerateImageCount = &generateImageCount
25
+ } else {
26
+ opts.GenerateImageCount = nil
27
+ }
28
+
29
+ result, err := internalgen.Run(cmd.Context(), opts, runner)
30
+ if err != nil {
31
+ _ = common.AppendDailyErrorLog("generate-image", err, map[string]string{
32
+ "prompt": strings.TrimSpace(opts.Prompt),
33
+ "image": strings.Join(opts.ImagePaths, ","),
34
+ "model": strings.TrimSpace(opts.Model),
35
+ "ratio": strings.TrimSpace(opts.Ratio),
36
+ "generate_image_count": optionalIntString(opts.GenerateImageCount),
37
+ })
38
+ return err
39
+ }
40
+ return common.WriteJSON(stdout, result)
41
+ },
42
+ }
43
+ cmd.SetOut(stdout)
44
+ cmd.SetErr(stderr)
45
+ flags := cmd.Flags()
46
+ flags.StringVar(&opts.Prompt, "prompt", "", "image generation prompt")
47
+ flags.StringArrayVar(&opts.ImagePaths, "image", nil, "local reference image path; repeat for multiple images")
48
+ 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")
49
+ flags.StringVar(&opts.Ratio, "ratio", "", "image ratio; "+internalgen.SupportedRatioUsage())
50
+ flags.IntVar(&generateImageCount, "generate-image-count", 0, "generated image count")
51
+ return cmd
52
+ }
53
+
54
+ func optionalIntString(value *int) string {
55
+ if value == nil {
56
+ return ""
57
+ }
58
+ return strconv.Itoa(*value)
59
+ }
@@ -0,0 +1,135 @@
1
+ package cmd
2
+
3
+ import (
4
+ "bytes"
5
+ "io"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "testing"
12
+
13
+ "github.com/bytedance/sonic"
14
+ )
15
+
16
+ func TestGenerateImage(t *testing.T) {
17
+ var uploaded bool
18
+
19
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20
+ if r.Header.Get("Authorization") != "Bearer test-token" {
21
+ t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
22
+ }
23
+ switch r.URL.Path {
24
+ case "/api/biz/v1/skill/upload_file":
25
+ if r.Method != http.MethodPost {
26
+ t.Fatalf("upload method = %s, want POST", r.Method)
27
+ }
28
+ if err := r.ParseMultipartForm(1 << 20); err != nil {
29
+ t.Fatalf("ParseMultipartForm(): %v", err)
30
+ }
31
+ files := r.MultipartForm.File["file"]
32
+ if len(files) != 1 {
33
+ t.Fatalf("file parts = %d, want 1", len(files))
34
+ }
35
+ if files[0].Filename != "cat.png" {
36
+ t.Fatalf("filename = %q, want cat.png", files[0].Filename)
37
+ }
38
+ uploaded = true
39
+ _, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"pippit_asset_id":"image_asset_1"}}`))
40
+ case "/api/biz/v1/skill/submit_run":
41
+ if !uploaded {
42
+ t.Fatal("submit called before upload")
43
+ }
44
+ data, err := io.ReadAll(r.Body)
45
+ if err != nil {
46
+ t.Fatalf("read body: %v", err)
47
+ }
48
+ var body map[string]any
49
+ if err := sonic.Unmarshal(data, &body); err != nil {
50
+ t.Fatalf("decode body: %v", err)
51
+ }
52
+ if body["agent_name"] != "pippit_nest_agent" {
53
+ t.Fatalf("agent_name = %v, want nest agent", body["agent_name"])
54
+ }
55
+ if body["message"] != "生成小猫海报" {
56
+ t.Fatalf("message = %v, want prompt", body["message"])
57
+ }
58
+ if _, ok := body["video_part_tool_param"]; ok {
59
+ t.Fatalf("video_part_tool_param should be omitted: %#v", body)
60
+ }
61
+ assetIDs, ok := body["asset_ids"].([]any)
62
+ if !ok || len(assetIDs) != 1 || assetIDs[0] != "image_asset_1" {
63
+ t.Fatalf("asset_ids = %#v, want uploaded asset", body["asset_ids"])
64
+ }
65
+ settings, ok := body["general_agent_settings"].(map[string]any)
66
+ if !ok {
67
+ t.Fatalf("general_agent_settings = %#v, want object", body["general_agent_settings"])
68
+ }
69
+ if settings["image_model"] != "seedream_4.5" {
70
+ t.Fatalf("image_model = %v, want seedream_4.5", settings["image_model"])
71
+ }
72
+ if settings["ratio"] != float64(6) {
73
+ t.Fatalf("ratio = %v, want 6", settings["ratio"])
74
+ }
75
+ if settings["generate_image_count"] != float64(2) {
76
+ t.Fatalf("generate_image_count = %v, want 2", settings["generate_image_count"])
77
+ }
78
+ if _, ok := settings["video_model"]; ok {
79
+ t.Fatalf("video_model should be omitted: %#v", settings)
80
+ }
81
+ _, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"run":{"thread_id":"thread_123","run_id":"run_456"},"web_thread_link":"https://xyq.example/thread_123"}}`))
82
+ default:
83
+ t.Fatalf("unexpected path %s", r.URL.Path)
84
+ }
85
+ }))
86
+ defer server.Close()
87
+
88
+ cwd := chdirTemp(t)
89
+ image := filepath.Join(cwd, "cat.png")
90
+ if err := os.WriteFile(image, []byte("image-data"), 0o644); err != nil {
91
+ t.Fatalf("WriteFile(%s): %v", image, err)
92
+ }
93
+
94
+ var stdout, stderr bytes.Buffer
95
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
96
+ root.SetArgs([]string{
97
+ "generate-image",
98
+ "--prompt", "生成小猫海报",
99
+ "--image", image,
100
+ "--model", "seedream_4.5",
101
+ "--ratio", "6",
102
+ "--generate-image-count", "2",
103
+ })
104
+
105
+ if err := root.Execute(); err != nil {
106
+ t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
107
+ }
108
+
109
+ got := decodeJSON(t, stdout.Bytes())
110
+ if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" {
111
+ t.Fatalf("output = %#v, want thread and run IDs", got)
112
+ }
113
+ }
114
+
115
+ func TestGenerateImageRequiresModel(t *testing.T) {
116
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117
+ t.Fatal("server should not receive request without model")
118
+ }))
119
+ defer server.Close()
120
+
121
+ var stdout, stderr bytes.Buffer
122
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
123
+ root.SetArgs([]string{
124
+ "generate-image",
125
+ "--prompt", "x",
126
+ })
127
+
128
+ err := root.Execute()
129
+ if err == nil {
130
+ t.Fatal("Execute() error = nil, want model validation")
131
+ }
132
+ if !strings.Contains(err.Error(), "缺少必填参数 --model") {
133
+ t.Fatalf("error = %q, want model validation", err)
134
+ }
135
+ }
@@ -16,7 +16,7 @@ func NewQueryResultCommand(stdout, stderr io.Writer, runner *common.Runner) *cob
16
16
 
17
17
  cmd := &cobra.Command{
18
18
  Use: "query-result",
19
- Short: "Query a generate-video run result and download completed videos",
19
+ Short: "Query a run result and download completed videos or images",
20
20
  Args: cobra.NoArgs,
21
21
  RunE: func(cmd *cobra.Command, _ []string) error {
22
22
  result, err := internalgen.QueryResult(cmd.Context(), opts, runner)
@@ -37,7 +37,7 @@ func NewQueryResultCommand(stdout, stderr io.Writer, runner *common.Runner) *cob
37
37
  cmd.SetErr(stderr)
38
38
  cmd.Flags().StringVar(&opts.ThreadID, "thread-id", "", "thread_id from generate-video output")
39
39
  cmd.Flags().StringVar(&opts.RunID, "run-id", "", "run_id from generate-video output")
40
- cmd.Flags().StringVar(&opts.DownloadDir, "download-dir", "", "directory to download completed videos into")
40
+ cmd.Flags().StringVar(&opts.DownloadDir, "download-dir", "", "directory to download completed videos or images into")
41
41
  return cmd
42
42
  }
43
43
 
@@ -45,6 +45,7 @@ func queryResultFromError(err error, opts *internalgen.QueryResultOptions) *inte
45
45
  result := &internalgen.QueryResultResult{
46
46
  ErrorMessage: err.Error(),
47
47
  Videos: []internalgen.QueryResultVideo{},
48
+ Images: []internalgen.QueryResultImage{},
48
49
  }
49
50
  if opts != nil {
50
51
  result.ThreadID = strings.TrimSpace(opts.ThreadID)
@@ -446,7 +446,7 @@ func TestQueryResultIgnoresVideoDataWithoutVideoSubType(t *testing.T) {
446
446
  if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" {
447
447
  t.Fatalf("ids = (%v, %v), want thread/run ids", got["thread_id"], got["run_id"])
448
448
  }
449
- if got["error_message"] != "下载失败:未找到可下载的视频产物" {
449
+ if got["error_message"] != "下载失败:未找到可下载的产物" {
450
450
  t.Fatalf("error_message = %v, want no downloadable video error", got["error_message"])
451
451
  }
452
452
  videos, ok := got["videos"].([]any)
package/cmd/root.go CHANGED
@@ -7,6 +7,7 @@ import (
7
7
  "strings"
8
8
 
9
9
  // authcmd "github.com/Pippit-dev/pippit-cli/cmd/auth"
10
+ "github.com/Pippit-dev/pippit-cli/cmd/generate_image"
10
11
  "github.com/Pippit-dev/pippit-cli/cmd/generate_video"
11
12
  "github.com/Pippit-dev/pippit-cli/cmd/short_drama"
12
13
  updatecmd "github.com/Pippit-dev/pippit-cli/cmd/update"
@@ -32,7 +33,7 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm
32
33
  root := &cobra.Command{
33
34
  Use: "pippit-tool-cli",
34
35
  Short: "Pippit CLI",
35
- Long: "Pippit CLI generates videos, submits short-drama workflows, downloads generated assets, and updates the installed CLI package.",
36
+ Long: "Pippit CLI generates videos and images, submits short-drama workflows, downloads generated assets, and updates the installed CLI package.",
36
37
  Version: version.Current(),
37
38
  SilenceUsage: true,
38
39
  SilenceErrors: true,
@@ -45,6 +46,7 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm
45
46
  root.AddCommand(newDownloadResultCommand(stdout, stderr, runner))
46
47
  root.AddCommand(newGetThreadCommand(stdout, stderr, runner))
47
48
  root.AddCommand(newListThreadFileCommand(stdout, stderr, runner))
49
+ root.AddCommand(generate_image.NewCommand(stdout, stderr, runner))
48
50
  root.AddCommand(generate_video.NewCommand(stdout, stderr, runner))
49
51
  root.AddCommand(generate_video.NewQueryResultCommand(stdout, stderr, runner))
50
52
  root.AddCommand(short_drama.NewCommand(stdout, stderr, runner))
@@ -0,0 +1,24 @@
1
+ package common
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+ )
9
+
10
+ // ExpandPath expands a leading "~" to the current user's home directory.
11
+ func ExpandPath(path string) (string, error) {
12
+ path = strings.TrimSpace(path)
13
+ if path == "~" {
14
+ return os.UserHomeDir()
15
+ }
16
+ if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) {
17
+ home, err := os.UserHomeDir()
18
+ if err != nil {
19
+ return "", fmt.Errorf("解析用户主目录失败: %w", err)
20
+ }
21
+ return filepath.Join(home, path[2:]), nil
22
+ }
23
+ return path, nil
24
+ }
@@ -0,0 +1,10 @@
1
+ package common
2
+
3
+ // StringSet builds a membership set from a string list.
4
+ func StringSet(list []string) map[string]struct{} {
5
+ set := make(map[string]struct{}, len(list))
6
+ for _, value := range list {
7
+ set[value] = struct{}{}
8
+ }
9
+ return set
10
+ }
@@ -0,0 +1,164 @@
1
+ package generate_image
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "path/filepath"
7
+ "strconv"
8
+ "strings"
9
+
10
+ "github.com/Pippit-dev/pippit-cli/internal/common"
11
+ )
12
+
13
+ const (
14
+ agentNameNest = "pippit_nest_agent"
15
+ )
16
+
17
+ var (
18
+ allowedImageExtensionList = []string{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"}
19
+ allowedImageExtensions = common.StringSet(allowedImageExtensionList)
20
+ )
21
+
22
+ const ratioUsage = "enum values: 0=原始比例/自动, 2=16:9(横屏), 13=21:9(电影), 3=9:16(竖屏), 4=4:3, 5=3:4, 6=1:1"
23
+
24
+ // Options is the stable command-facing request shape for generate-image.
25
+ type Options struct {
26
+ Prompt string
27
+ ImagePaths []string
28
+ Model string
29
+ Ratio string
30
+ GenerateImageCount *int
31
+ }
32
+
33
+ type generalAgentSettings struct {
34
+ ImageModel string `json:"image_model"`
35
+ Ratio *int `json:"ratio,omitempty"`
36
+ GenerateImageCount *int `json:"generate_image_count,omitempty"`
37
+ }
38
+
39
+ // Result is the JSON envelope printed by `pippit-tool-cli generate-image`.
40
+ type Result struct {
41
+ ThreadID string `json:"thread_id"`
42
+ RunID string `json:"run_id"`
43
+ WebThreadLink string `json:"web_thread_link"`
44
+ }
45
+
46
+ func Run(ctx context.Context, opts *Options, runner *common.Runner) (*Result, error) {
47
+ if runner == nil || runner.Client == nil {
48
+ return nil, fmt.Errorf("generate-image 运行器客户端缺失")
49
+ }
50
+ if err := ValidateOptions(opts); err != nil {
51
+ return nil, err
52
+ }
53
+
54
+ imageAssetIDs, err := uploadImageList(ctx, opts.ImagePaths, runner)
55
+ if err != nil {
56
+ return nil, fmt.Errorf("上传图片失败: %w", err)
57
+ }
58
+
59
+ body := buildSubmitRunBody(opts, imageAssetIDs)
60
+
61
+ var resp common.SubmitRunResponse
62
+ if err := runner.Client.SendRequest(ctx, common.SubmitRunPath(runner), body, &resp); err != nil {
63
+ return nil, fmt.Errorf("提交 generate-image 请求失败: %w", err)
64
+ }
65
+ if resp.Ret != "0" {
66
+ if resp.Errmsg == "" {
67
+ resp.Errmsg = "未知错误"
68
+ }
69
+ return nil, common.NewLogIDError(fmt.Sprintf("generate-image 请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID)
70
+ }
71
+ if resp.Data.Run.ThreadID == "" {
72
+ return nil, fmt.Errorf("generate-image 响应缺少 data.run.thread_id")
73
+ }
74
+ if resp.Data.Run.RunID == "" {
75
+ return nil, fmt.Errorf("generate-image 响应缺少 data.run.run_id")
76
+ }
77
+
78
+ return &Result{
79
+ ThreadID: resp.Data.Run.ThreadID,
80
+ RunID: resp.Data.Run.RunID,
81
+ WebThreadLink: resp.Data.WebThreadLink,
82
+ }, nil
83
+ }
84
+
85
+ func ValidateOptions(opts *Options) error {
86
+ if opts == nil {
87
+ return fmt.Errorf("缺少必填参数 --prompt")
88
+ }
89
+ if strings.TrimSpace(opts.Prompt) == "" {
90
+ return fmt.Errorf("缺少必填参数 --prompt")
91
+ }
92
+ if strings.TrimSpace(opts.Model) == "" {
93
+ return fmt.Errorf("缺少必填参数 --model")
94
+ }
95
+ if _, err := parseRatio(opts.Ratio); err != nil {
96
+ return err
97
+ }
98
+ if opts.GenerateImageCount != nil && *opts.GenerateImageCount < 0 {
99
+ return fmt.Errorf("--generate-image-count 不能为负数")
100
+ }
101
+ if err := validateImageExtensions(opts.ImagePaths); err != nil {
102
+ return err
103
+ }
104
+ return nil
105
+ }
106
+
107
+ func validateImageExtensions(paths []string) error {
108
+ for _, path := range paths {
109
+ ext := strings.ToLower(filepath.Ext(strings.TrimSpace(path)))
110
+ if _, ok := allowedImageExtensions[ext]; !ok {
111
+ return fmt.Errorf("不支持的图片文件后缀 %q,文件:%q;支持的后缀:%s", ext, path, strings.Join(allowedImageExtensionList, ", "))
112
+ }
113
+ }
114
+ return nil
115
+ }
116
+
117
+ func SupportedRatioUsage() string {
118
+ return ratioUsage
119
+ }
120
+
121
+ func uploadImageList(ctx context.Context, paths []string, runner *common.Runner) ([]string, error) {
122
+ assetIDs := make([]string, 0, len(paths))
123
+ for _, path := range paths {
124
+ expanded, err := common.ExpandPath(path)
125
+ if err != nil {
126
+ return nil, err
127
+ }
128
+ result, err := common.UploadFile(ctx, common.UploadFileOptions{Path: expanded}, runner)
129
+ if err != nil {
130
+ return nil, err
131
+ }
132
+ assetIDs = append(assetIDs, result.AssetID)
133
+ }
134
+ return assetIDs, nil
135
+ }
136
+
137
+ func buildSubmitRunBody(opts *Options, imageAssetIDs []string) map[string]any {
138
+ ratio, _ := parseRatio(opts.Ratio)
139
+ body := map[string]any{
140
+ "agent_name": agentNameNest,
141
+ "message": strings.TrimSpace(opts.Prompt),
142
+ "general_agent_settings": generalAgentSettings{
143
+ ImageModel: strings.TrimSpace(opts.Model),
144
+ Ratio: ratio,
145
+ GenerateImageCount: opts.GenerateImageCount,
146
+ },
147
+ }
148
+ if len(imageAssetIDs) > 0 {
149
+ body["asset_ids"] = imageAssetIDs
150
+ }
151
+ return body
152
+ }
153
+
154
+ func parseRatio(raw string) (*int, error) {
155
+ ratio := strings.TrimSpace(raw)
156
+ if ratio == "" {
157
+ return nil, nil
158
+ }
159
+ value, err := strconv.Atoi(ratio)
160
+ if err != nil {
161
+ return nil, fmt.Errorf("ratio %q 必须是整数枚举值;可参考:%s", ratio, ratioUsage)
162
+ }
163
+ return &value, nil
164
+ }
@@ -0,0 +1,148 @@
1
+ package generate_image
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+ )
7
+
8
+ func TestValidateOptionsRequiresModel(t *testing.T) {
9
+ opts := &Options{
10
+ Prompt: "x",
11
+ }
12
+
13
+ err := ValidateOptions(opts)
14
+ if err == nil {
15
+ t.Fatal("ValidateOptions() error = nil, want model validation")
16
+ }
17
+ if !strings.Contains(err.Error(), "缺少必填参数 --model") {
18
+ t.Fatalf("error = %q, want model validation", err)
19
+ }
20
+ }
21
+
22
+ func TestValidateOptionsAllowsServerDecidedModel(t *testing.T) {
23
+ opts := &Options{
24
+ Prompt: "x",
25
+ Model: "seedream_3.0",
26
+ }
27
+
28
+ if err := ValidateOptions(opts); err != nil {
29
+ t.Fatalf("ValidateOptions() error = %v, want nil", err)
30
+ }
31
+ }
32
+
33
+ func TestValidateOptionsAllowsServerDecidedRatio(t *testing.T) {
34
+ opts := &Options{
35
+ Prompt: "x",
36
+ Model: "seedream_4.5",
37
+ Ratio: "99",
38
+ }
39
+
40
+ if err := ValidateOptions(opts); err != nil {
41
+ t.Fatalf("ValidateOptions() error = %v, want nil", err)
42
+ }
43
+ }
44
+
45
+ func TestValidateOptionsRejectsNegativeGenerateImageCount(t *testing.T) {
46
+ count := -1
47
+ opts := &Options{
48
+ Prompt: "x",
49
+ Model: "seedream_4.5",
50
+ GenerateImageCount: &count,
51
+ }
52
+
53
+ err := ValidateOptions(opts)
54
+ if err == nil {
55
+ t.Fatal("ValidateOptions() error = nil, want generate-image-count validation")
56
+ }
57
+ if !strings.Contains(err.Error(), "--generate-image-count 不能为负数") {
58
+ t.Fatalf("error = %q, want generate-image-count validation", err)
59
+ }
60
+ }
61
+
62
+ func TestParseRatioSupportsVisibleEnumValues(t *testing.T) {
63
+ cases := []struct {
64
+ ratio string
65
+ want int
66
+ }{
67
+ {ratio: "0", want: 0},
68
+ {ratio: "2", want: 2},
69
+ {ratio: "13", want: 13},
70
+ {ratio: "3", want: 3},
71
+ {ratio: "4", want: 4},
72
+ {ratio: "5", want: 5},
73
+ {ratio: "6", want: 6},
74
+ }
75
+
76
+ for _, tt := range cases {
77
+ t.Run(tt.ratio, func(t *testing.T) {
78
+ got, err := parseRatio(tt.ratio)
79
+ if err != nil {
80
+ t.Fatalf("parseRatio(%q) error = %v", tt.ratio, err)
81
+ }
82
+ if got == nil || *got != tt.want {
83
+ t.Fatalf("parseRatio(%q) = %#v, want %d", tt.ratio, got, tt.want)
84
+ }
85
+ })
86
+ }
87
+ }
88
+
89
+ func TestParseRatioRejectsNonInteger(t *testing.T) {
90
+ _, err := parseRatio("1:1")
91
+ if err == nil {
92
+ t.Fatal("parseRatio() error = nil, want integer validation")
93
+ }
94
+ if !strings.Contains(err.Error(), `ratio "1:1" 必须是整数枚举值`) {
95
+ t.Fatalf("error = %q, want integer validation", err)
96
+ }
97
+ }
98
+
99
+ func TestValidateOptionsRejectsUnsupportedImageExtension(t *testing.T) {
100
+ opts := &Options{
101
+ Prompt: "x",
102
+ Model: "seedream_4.5",
103
+ ImagePaths: []string{"ref.tiff"},
104
+ }
105
+
106
+ err := ValidateOptions(opts)
107
+ if err == nil {
108
+ t.Fatal("ValidateOptions() error = nil, want image extension validation")
109
+ }
110
+ if !strings.Contains(err.Error(), `不支持的图片文件后缀 ".tiff"`) {
111
+ t.Fatalf("error = %q, want image extension validation", err)
112
+ }
113
+ }
114
+
115
+ func TestBuildSubmitRunBodyWithGeneralAgentSettings(t *testing.T) {
116
+ count := 2
117
+ opts := &Options{
118
+ Prompt: " 生成小猫海报 ",
119
+ Model: " seedream_4.5 ",
120
+ Ratio: "6",
121
+ GenerateImageCount: &count,
122
+ }
123
+
124
+ body := buildSubmitRunBody(opts, []string{"asset_1"})
125
+ if body["agent_name"] != agentNameNest {
126
+ t.Fatalf("agent_name = %v, want nest agent", body["agent_name"])
127
+ }
128
+ if body["message"] != "生成小猫海报" {
129
+ t.Fatalf("message = %v, want trimmed prompt", body["message"])
130
+ }
131
+ settings, ok := body["general_agent_settings"].(generalAgentSettings)
132
+ if !ok {
133
+ t.Fatalf("general_agent_settings = %#v, want object", body["general_agent_settings"])
134
+ }
135
+ if settings.ImageModel != "seedream_4.5" {
136
+ t.Fatalf("image_model = %q, want seedream_4.5", settings.ImageModel)
137
+ }
138
+ if settings.Ratio == nil || *settings.Ratio != 6 {
139
+ t.Fatalf("ratio = %#v, want 6", settings.Ratio)
140
+ }
141
+ if settings.GenerateImageCount == nil || *settings.GenerateImageCount != 2 {
142
+ t.Fatalf("generate_image_count = %#v, want 2", settings.GenerateImageCount)
143
+ }
144
+ assetIDs, ok := body["asset_ids"].([]string)
145
+ if !ok || len(assetIDs) != 1 || assetIDs[0] != "asset_1" {
146
+ t.Fatalf("asset_ids = %#v, want asset_1", body["asset_ids"])
147
+ }
148
+ }
@@ -32,6 +32,7 @@ type QueryResultResult struct {
32
32
  RunID string `json:"run_id"`
33
33
  ErrorMessage string `json:"error_message"`
34
34
  Videos []QueryResultVideo `json:"videos"`
35
+ Images []QueryResultImage `json:"images"`
35
36
  }
36
37
 
37
38
  // QueryResultVideo describes a downloaded video from query-result.
@@ -40,6 +41,12 @@ type QueryResultVideo struct {
40
41
  OutputPath string `json:"output_path"`
41
42
  }
42
43
 
44
+ // QueryResultImage describes a downloaded image from query-result.
45
+ type QueryResultImage struct {
46
+ DownloadURL string `json:"download_url"`
47
+ OutputPath string `json:"output_path"`
48
+ }
49
+
43
50
  type queryThread struct {
44
51
  ThreadID string `json:"thread_id"`
45
52
  RunList []queryRun `json:"run_list"`
@@ -69,6 +76,7 @@ type queryContent struct {
69
76
 
70
77
  type queryContentData struct {
71
78
  Video *queryVideo `json:"video"`
79
+ Image *queryImage `json:"image"`
72
80
  ErrorMessage string `json:"error_message"`
73
81
  ErrorCode json.RawMessage `json:"error_code"`
74
82
  }
@@ -80,6 +88,16 @@ type queryVideo struct {
80
88
  AssetID string `json:"asset_id"`
81
89
  }
82
90
 
91
+ type queryImage struct {
92
+ DownloadURL string `json:"url"`
93
+ AssetID string `json:"asset_id"`
94
+ Metadata queryImageMeta `json:"metadata"`
95
+ }
96
+
97
+ type queryImageMeta struct {
98
+ Format string `json:"format"`
99
+ }
100
+
83
101
  func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.Runner) (*QueryResultResult, error) {
84
102
  if err := validateQueryResultOptions(opts); err != nil {
85
103
  return nil, err
@@ -111,6 +129,7 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
111
129
  ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID),
112
130
  RunID: opts.RunID,
113
131
  Videos: []QueryResultVideo{},
132
+ Images: []QueryResultImage{},
114
133
  }
115
134
  if run.State == failedRunState {
116
135
  result.ErrorMessage = firstNonEmpty(extractQueryErrorMessage(run), "Run 失败")
@@ -119,17 +138,19 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
119
138
  }
120
139
 
121
140
  videos := extractQueryVideos(run)
122
- if len(videos) == 0 {
123
- return nil, fmt.Errorf("下载失败:未找到可下载的视频产物")
141
+ images := extractQueryImages(run)
142
+ if len(videos) == 0 && len(images) == 0 {
143
+ return nil, fmt.Errorf("下载失败:未找到可下载的产物")
124
144
  }
125
145
 
126
- downloadDir, err := expandPath(opts.DownloadDir)
146
+ downloadDir, err := common.ExpandPath(opts.DownloadDir)
127
147
  if err != nil {
128
148
  return nil, fmt.Errorf("下载失败:解析下载目录失败:%w", err)
129
149
  }
130
150
 
151
+ usedNames := make(map[string]int, len(videos)+len(images))
152
+
131
153
  resultVideos := make([]QueryResultVideo, 0, len(videos))
132
- usedNames := make(map[string]int, len(videos))
133
154
  for i, video := range videos {
134
155
  if strings.TrimSpace(video.DownloadURL) == "" {
135
156
  return nil, fmt.Errorf("下载失败:第 %d 个视频产物 download_url 为空", i+1)
@@ -155,11 +176,38 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
155
176
  })
156
177
  }
157
178
 
179
+ resultImages := make([]QueryResultImage, 0, len(images))
180
+ for i, image := range images {
181
+ if strings.TrimSpace(image.DownloadURL) == "" {
182
+ return nil, fmt.Errorf("下载失败:第 %d 个图片产物 download_url 为空", i+1)
183
+ }
184
+ outputPath := filepath.Join(downloadDir, uniqueQueryResultFileName(imageFileName(image, i+1), usedNames))
185
+ download, err := common.DownloadResult(ctx, common.DownloadResultOptions{
186
+ URL: image.DownloadURL,
187
+ OutputPath: outputPath,
188
+ Workers: 5,
189
+ }, runner)
190
+ if err != nil {
191
+ return nil, fmt.Errorf("下载失败:%w", err)
192
+ }
193
+ actualOutputPath := outputPath
194
+ if len(download.Downloaded) > 0 {
195
+ actualOutputPath = download.Downloaded[0]
196
+ } else if len(download.AlreadyExist) > 0 {
197
+ actualOutputPath = download.AlreadyExist[0]
198
+ }
199
+ resultImages = append(resultImages, QueryResultImage{
200
+ DownloadURL: image.DownloadURL,
201
+ OutputPath: actualOutputPath,
202
+ })
203
+ }
204
+
158
205
  return &QueryResultResult{
159
206
  Completed: true,
160
207
  ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID),
161
208
  RunID: opts.RunID,
162
209
  Videos: resultVideos,
210
+ Images: resultImages,
163
211
  }, nil
164
212
  }
165
213
 
@@ -181,6 +229,7 @@ func queryResultFromGetThreadBusinessError(err error, opts *QueryResultOptions)
181
229
  RunID: opts.RunID,
182
230
  ErrorMessage: message,
183
231
  Videos: []QueryResultVideo{},
232
+ Images: []QueryResultImage{},
184
233
  }, true
185
234
  }
186
235
 
@@ -288,6 +337,23 @@ func extractQueryVideos(run queryRun) []queryVideo {
288
337
  return videos
289
338
  }
290
339
 
340
+ func extractQueryImages(run queryRun) []queryImage {
341
+ images := make([]queryImage, 0)
342
+ for _, entry := range run.EntryList {
343
+ artifact := entry.Artifact
344
+ for _, content := range artifact.Content {
345
+ if content.SubType != "biz/x_data_image" {
346
+ continue
347
+ }
348
+ data := content.Data
349
+ if data.Image != nil {
350
+ images = append(images, *data.Image)
351
+ }
352
+ }
353
+ }
354
+ return images
355
+ }
356
+
291
357
  func extractQueryErrorMessage(run queryRun) string {
292
358
  if message := firstNonEmpty(run.ErrorMessage, run.ErrorMsg, run.Errmsg); message != "" {
293
359
  return message
@@ -342,6 +408,42 @@ func hasVideoExtension(name string) bool {
342
408
  }
343
409
  }
344
410
 
411
+ func imageFileName(image queryImage, index int) string {
412
+ name := image.AssetID
413
+ if name == "" {
414
+ name = "result_" + strconv.Itoa(index)
415
+ }
416
+ name = sanitizeFileName(name)
417
+ if !hasImageExtension(name) {
418
+ name += "." + normalizeImageFormatExt(image.Metadata.Format)
419
+ }
420
+ return name
421
+ }
422
+
423
+ // normalizeImageFormatExt maps the server-provided metadata.format to a safe
424
+ // file extension. Only a known allowlist is accepted; anything else (including
425
+ // "image/jpeg", ".jpeg", or empty values) falls back to "png".
426
+ func normalizeImageFormatExt(format string) string {
427
+ format = strings.ToLower(strings.TrimSpace(format))
428
+ format = strings.TrimPrefix(format, "image/")
429
+ format = strings.TrimPrefix(format, ".")
430
+ switch format {
431
+ case "jpg", "jpeg", "png", "gif", "bmp", "webp", "svg":
432
+ return format
433
+ default:
434
+ return "png"
435
+ }
436
+ }
437
+
438
+ func hasImageExtension(name string) bool {
439
+ switch strings.ToLower(strings.TrimSpace(filepath.Ext(name))) {
440
+ case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg":
441
+ return true
442
+ default:
443
+ return false
444
+ }
445
+ }
446
+
345
447
  func firstNonEmpty(values ...string) string {
346
448
  for _, value := range values {
347
449
  value = strings.TrimSpace(value)
@@ -355,7 +457,7 @@ func firstNonEmpty(values ...string) string {
355
457
  func sanitizeFileName(name string) string {
356
458
  name = strings.TrimSpace(name)
357
459
  if name == "" {
358
- return "result.mp4"
460
+ return "result"
359
461
  }
360
462
  return strings.Map(func(r rune) rune {
361
463
  if unicode.IsControl(r) || r == '/' || r == '\\' || strings.ContainsRune(`<>:"|?*`, r) {
@@ -29,3 +29,98 @@ func TestVideoFileNameKeepsVideoExtension(t *testing.T) {
29
29
  t.Fatalf("videoFileName() = %q, want cat_video.mp4", got)
30
30
  }
31
31
  }
32
+
33
+ func TestImageFileNameUsesAssetID(t *testing.T) {
34
+ got := imageFileName(queryImage{
35
+ AssetID: "7659311708893512254",
36
+ Metadata: queryImageMeta{Format: "jpeg"},
37
+ }, 1)
38
+ want := "7659311708893512254.jpeg"
39
+ if got != want {
40
+ t.Fatalf("imageFileName() = %q, want %q", got, want)
41
+ }
42
+ }
43
+
44
+ func TestImageFileNameAddsExtensionFromFormat(t *testing.T) {
45
+ got := imageFileName(queryImage{
46
+ AssetID: "pic1",
47
+ Metadata: queryImageMeta{Format: "png"},
48
+ }, 1)
49
+ if got != "pic1.png" {
50
+ t.Fatalf("imageFileName() = %q, want pic1.png", got)
51
+ }
52
+ }
53
+
54
+ func TestImageFileNameFallsBackToPngWhenFormatEmpty(t *testing.T) {
55
+ got := imageFileName(queryImage{
56
+ AssetID: "pic1",
57
+ Metadata: queryImageMeta{Format: ""},
58
+ }, 1)
59
+ if got != "pic1.png" {
60
+ t.Fatalf("imageFileName() = %q, want pic1.png", got)
61
+ }
62
+ }
63
+
64
+ func TestImageFileNameNormalizesMimeTypeFormat(t *testing.T) {
65
+ cases := []struct {
66
+ format string
67
+ want string
68
+ }{
69
+ {format: "image/jpeg", want: "pic1.jpeg"},
70
+ {format: ".jpeg", want: "pic1.jpeg"},
71
+ {format: "JPEG", want: "pic1.jpeg"},
72
+ {format: "image/png", want: "pic1.png"},
73
+ {format: "image/webp", want: "pic1.webp"},
74
+ {format: "image/svg+xml", want: "pic1.png"}, // unsupported, falls back
75
+ {format: "application/octet-stream", want: "pic1.png"},
76
+ }
77
+ for _, tt := range cases {
78
+ t.Run(tt.format, func(t *testing.T) {
79
+ got := imageFileName(queryImage{AssetID: "pic1", Metadata: queryImageMeta{Format: tt.format}}, 1)
80
+ if got != tt.want {
81
+ t.Fatalf("imageFileName(format=%q) = %q, want %q", tt.format, got, tt.want)
82
+ }
83
+ })
84
+ }
85
+ }
86
+
87
+ func TestImageFileNameUsesResultIndexWhenNoID(t *testing.T) {
88
+ got := imageFileName(queryImage{
89
+ Metadata: queryImageMeta{Format: "jpeg"},
90
+ }, 2)
91
+ if got != "result_2.jpeg" {
92
+ t.Fatalf("imageFileName() = %q, want result_2.jpeg", got)
93
+ }
94
+ }
95
+
96
+ func TestImageFileNameKeepsExistingImageExtension(t *testing.T) {
97
+ got := imageFileName(queryImage{
98
+ AssetID: "cat_poster.png",
99
+ Metadata: queryImageMeta{Format: "jpeg"},
100
+ }, 1)
101
+ if got != "cat_poster.png" {
102
+ t.Fatalf("imageFileName() = %q, want cat_poster.png", got)
103
+ }
104
+ }
105
+
106
+ func TestExtractQueryImagesFiltersBySubType(t *testing.T) {
107
+ run := queryRun{
108
+ EntryList: []queryEntry{
109
+ {Artifact: queryArtifact{Content: []queryContent{
110
+ {SubType: "biz/x_data_image", Data: queryContentData{Image: &queryImage{DownloadURL: "https://x/a.jpeg", AssetID: "p1"}}},
111
+ {SubType: "biz/x_data_video", Data: queryContentData{Video: &queryVideo{DownloadURL: "https://x/v.mp4", VID: "v1"}}},
112
+ }}},
113
+ {Artifact: queryArtifact{Content: []queryContent{
114
+ {SubType: "biz/x_data_image", Data: queryContentData{Image: &queryImage{DownloadURL: "https://x/b.png", AssetID: "p2"}}},
115
+ {SubType: "text/plain", Data: queryContentData{}},
116
+ }}},
117
+ },
118
+ }
119
+ got := extractQueryImages(run)
120
+ if len(got) != 2 {
121
+ t.Fatalf("extractQueryImages() = %d images, want 2", len(got))
122
+ }
123
+ if got[0].AssetID != "p1" || got[1].AssetID != "p2" {
124
+ t.Fatalf("extractQueryImages() = %q,%q, want p1,p2", got[0].AssetID, got[1].AssetID)
125
+ }
126
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pippit-dev/cli",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "Pippit CLI",
5
5
  "bin": {
6
6
  "pippit-tool-cli": "scripts/run.js"
@@ -63,6 +63,12 @@ metadata:
63
63
 
64
64
  当后端 Agent 通过 `readable_text` 要求用户补充信息、选择选项、确认流程或确认创意内容时,优先使用当前宿主提供的 ask-question / confirmation / form 类工具向用户提问,而不是只在普通聊天里输出问题。
65
65
 
66
+ 按宿主选择准确工具:
67
+
68
+ - **Codex**:优先调用 `request_user_input`。仅在工具已暴露且当前模式允许时调用;不可用时退回普通聊天提问。不要在 Codex 中调用 `ask_user_question`。
69
+ - **WorkBuddy**:优先调用 `ask_user_question`(Ask User Question);工具未暴露时才退回普通聊天提问。
70
+ - **Trae 及其他宿主**:先查看当前宿主实际暴露的工具,再使用同类结构化提问、确认或表单工具;不要臆造具体工具名。没有同类工具时退回普通聊天提问。
71
+
66
72
  使用宿主提问工具前,先按“表单与问卷选项处理原则”清洗问题和选项:
67
73
 
68
74
  - 只把当前阶段合理、必要、可执行的选项放进提问工具。
@@ -70,6 +76,8 @@ metadata:
70
76
  - 对普通开放问题,用单个清晰问题询问用户;对明确互斥选项,用宿主支持的选择控件。
71
77
  - 如果当前宿主没有暴露可调用的 ask-question / confirmation / form 工具,才退回普通聊天提问,并说明需要用户回复后才能继续。
72
78
 
79
+ 真实提交将进入消耗 credits 的图片生成、视频生成或编辑阶段时,如果用户本轮尚未明确确认执行,必须使用上述工具征得明确确认。不要设置默认同意、自动选择或超时后继续;纯文本规划和查询进展不需要额外确认。
80
+
73
81
  ## 前置要求
74
82
 
75
83
  需要已安装 `pippit-tool-cli`:
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: xyq-skill
3
- description: 通过小云雀的 AI 能力进行综合创作,支持生成和编辑图片/视频。覆盖场景包括:生成(文生图、文生视频、图生视频、做动画、画一个xxx、来段xxx)、编辑修改(把xxx换成yyy、去掉xxx、加上xxx、改成xxx、调整xxx、局部修改、改镜头)、风格转换(风格迁移、转绘、换风格)、视频续写延长、复刻视频/TVC/宣传片、短剧/短漫剧生成、音乐MV生成、产品广告/展示片制作、分镜/故事板设计、教育视频/短视频制作。当用户提到小云雀、xyq、上传参考图/视频/mp3或wav音频、查看生成进度时也应触发。关键判断:只要用户的请求涉及 AI 视频的创作、生成、编辑、修改,无论措辞如何(如"画只猫"、"做个海报"、"做个视频"、"这个视频帮我改一下"、"帮我复刻这段视频"、"用这首歌做个MV"、"一句话生成短剧"),都必须触发此技能。
3
+ description: 通过小云雀的 AI 能力进行综合创作,支持生成和编辑图片/视频,并在用户明确要求视频模型直出、指定视频模型或直接调用 CLI 时使用 pippit-tool-cli generate-video。覆盖文生图、文生视频、图生视频、视频编辑、风格转换、视频续写、视频复刻、TVC、宣传片、音乐 MV、产品广告、分镜和教育短视频等场景。当用户提到小云雀、xyq、上传参考图/视频/mp3或wav音频、查看生成进度时也应触发。短剧生成、续写、改写、人物设定和分集创作应使用 xyq-short-drama-skill,不在本技能中执行。
4
4
  user-invocable: true
5
5
  metadata:
6
6
  {
@@ -17,7 +17,7 @@ metadata:
17
17
  }
18
18
  ---
19
19
 
20
- # 小云雀会话(生视频)
20
+ # 小云雀创作与视频模型直出
21
21
 
22
22
  通过 小云雀的API 创建会话、发送消息(生图、生视频、编辑视频等)、上传图片/视频/mp3或wav音频文件,并查询会话消息进展。
23
23
 
@@ -26,9 +26,60 @@ metadata:
26
26
  **平台核心能力:**
27
27
  - **生成**:文生图、文生视频、图生视频、视频续写
28
28
  - **编辑**:局部修改、元素替换、镜头调整、风格迁移
29
- - **复杂创作**:一句话生成完整短剧(剧本→分镜→成片)、复刻已有视频风格做 TVC/宣传片、用音乐生成 MV、产品展示片制作
29
+ - **复杂创作**:复刻已有视频风格做 TVC/宣传片、用音乐生成 MV、产品展示片制作
30
30
 
31
- 用户的所有创作和编辑需求都通过发送自然语言消息来完成,Agent 会自主编排工作流。复杂任务(短剧、MV)耗时较长,需耐心轮询。
31
+ 除“视频模型直出”外,创作和编辑需求通过发送自然语言消息来完成,后端 Agent 会自主编排工作流。复杂任务耗时较长,需耐心轮询。
32
+
33
+ ## 执行路由(必须先判断)
34
+
35
+ ### 路由 A:视频模型直出
36
+
37
+ 满足任一条件时,必须直接使用 `pippit-tool-cli generate-video`,不要改走 `submit_run.py`:
38
+
39
+ - 用户明确说“视频模型直出”、“直接调模型”或“直接调用 CLI”。
40
+ - 用户指定了具体视频模型(如 Seedance),并希望单次直接生成视频。
41
+ - 上游流程已明确将任务标记为 direct-model / 模型直出。
42
+
43
+ 执行原则:
44
+
45
+ 1. 执行前用 `command -v pippit-tool-cli` 确认 CLI 可用;不可用时报告阻塞,不要悄悄降级到会话 API。
46
+ 2. 真实提交会消耗 credits;如果用户本轮尚未明确确认生成,按“用户确认与反问”规则征得明确确认后再运行。
47
+ 3. 保留用户原始 prompt,不要自行扩写、润色、翻译或增加风格词。
48
+ 4. 只添加用户已经给出的 `--model`、`--duration`、`--ratio`、`--resolution`、`--image`、`--video`、`--audio` 参数;未给参数交给 CLI 默认值。
49
+ 5. `generate-video` 返回后,保存 `thread_id`、`run_id`,并立即向用户展示 `web_thread_link`。
50
+ 6. 每隔 10 秒调用 `query-result`,直到 `completed=true`。出现 `error_message` 时停止并报告;成功时展示并下载 `videos[].output_path`。
51
+
52
+ ```bash
53
+ pippit-tool-cli generate-video --prompt "用户原始描述"
54
+
55
+ pippit-tool-cli query-result \
56
+ --thread-id THREAD_ID \
57
+ --run-id RUN_ID \
58
+ --download-dir OUTPUT_DIR
59
+ ```
60
+
61
+ `--image` 最多重复 9 次,`--video` 和 `--audio` 最多各重复 3 次。
62
+
63
+ ### 路由 B:小云雀后端 Agent 编排
64
+
65
+ 需要意图确认、脚本/分镜拆解、MV、TVC、局部编辑、复杂参考素材编排,或者用户未明确要求模型直出时,继续使用本技能内置的 `submit_run.py` / `get_thread.py` 会话工作流。
66
+
67
+ ### 路由 C:短剧工作流
68
+
69
+ 用户要求短剧生成、续写、改写、剧情扩展、人物设定、分集草稿或短剧会话文件处理时,停止本技能流程并转交 `xyq-short-drama-skill`,不要用 `submit_run.py` 或 `generate-video` 假装执行完整短剧流程。
70
+
71
+ ## 用户确认与反问
72
+
73
+ 后端返回意图确认问题、真实提交前需要 credits 确认,或缺少无法安全推断的必需信息时,暂停执行并向用户提问。
74
+
75
+ 1. 优先使用当前 Agent 宿主提供的结构化用户提问或确认工具。
76
+ - **Codex**:准确工具名是 `request_user_input`。仅在工具已暴露且当前模式允许时调用;不可用时退回普通聊天提问。不要在 Codex 中调用 `ask_user_question`。
77
+ - **WorkBuddy**:准确工具名是 `ask_user_question`(Ask User Question)。需要用户补充、选择或确认时优先调用;工具未暴露时才退回普通聊天提问。
78
+ - **Trae 及其他宿主**:先查看当前宿主实际暴露的工具,再使用同类结构化提问、确认或表单工具;不要臆造具体工具名。没有同类工具时退回普通聊天提问。
79
+ 2. 涉及 credits、真实生成、外部提交或不可逆操作时,必须等待用户明确答复;不要默认同意或超时后继续。
80
+ 3. 后端已经给出问题或选项时,保持原意传给用户,不要代替用户回答。
81
+ 4. 当前宿主没有结构化提问工具,或当前模式不允许调用时,使用一条简洁的普通聊天问题并暂停。
82
+ 5. 收到回复后,把用户答案原样发回同一 `thread_id`,获取新的 `run_id`,再继续轮询;不要新开会话。
32
83
 
33
84
  ## 功能
34
85
 
@@ -36,6 +87,7 @@ metadata:
36
87
  2. **查询会话进展** - 根据 `thread_id` 、 `run_id`、`after_seq` 增量拉取该会话的消息列表,用于轮询创作过程的消息和最终产物结果
37
88
  3. **上传文件** - 支持上传`单张图片`、`单个视频文件`或`单个mp3/wav音频文件`到小云雀资产库,得到文件对应的 `asset_id`(编辑或参考已有图片/视频/音频时需要先上传)
38
89
  4. **下载结果** - 将会话中生成的图片/视频批量下载到本地,支持指定输出目录和文件名前缀。
90
+ 5. **视频模型直出** - 使用 `pippit-tool-cli generate-video` 直接调用视频模型,使用 `query-result` 查询并下载结果。
39
91
 
40
92
 
41
93
  ## 前置要求
@@ -46,7 +98,7 @@ export XYQ_ACCESS_KEY="your-access-key"
46
98
 
47
99
  可选:`XYQ_OPENAPI_BASE` 或 `XYQ_BASE_URL`,默认 `https://xyq.jianying.com`。
48
100
 
49
- 无需安装额外依赖,仅使用 Python 标准库。
101
+ 会话 API 路由无需安装额外依赖,仅使用 Python 标准库。视频模型直出路由额外要求 `pippit-tool-cli` 在 `PATH` 中可用。
50
102
 
51
103
  ## 使用方法
52
104
 
@@ -98,7 +150,7 @@ python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir
98
150
 
99
151
  理解这些工作流,才能正确组合上面的脚本完成用户需求。
100
152
 
101
- ### 场景 1:用户要求生成图片或视频(最常见)
153
+ ### 场景 1:用户要求生成图片或视频(非模型直出)
102
154
 
103
155
  ```
104
156
  1. submit_run.py --message "用户的描述" → 拿到 thread_id、run_id 和 web_thread_link
@@ -109,7 +161,7 @@ python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir
109
161
  - 将过程创作信息展示给用户,继续轮询
110
162
  - 当任务完成(run 结束):
111
163
  - 如果涉及意图确认/流程中断(如"请回答以下问题"):
112
- 向用户展示问题,等待用户回复
164
+ 优先调用当前宿主的结构化用户提问工具展示问题,等待用户回复
113
165
  → 使用 `thread_id` 重新提交任务(保持同一会话,产生新的 run_id)
114
166
  → 回到步骤 2 继续轮询(可能多轮,直到不再意图确认)
115
167
  - 如果 content 中包含产物 URL:
@@ -211,16 +263,16 @@ python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir
211
263
  - 任务在创作中:
212
264
  - 展示过程中的创作信息等,继续轮询
213
265
  - 任务完成(run 结束):
214
- - 若涉及意图确认/流程中断(如"请回答以下问题")→ 展示问题 → 等待用户回复 → 使用同一 `thread_id` 重新提交任务 → 继续轮询(可能多轮)
266
+ - 若涉及意图确认/流程中断(如"请回答以下问题")→ 按“用户确认与反问”规则优先调用结构化提问工具 → 等待用户回复 → 使用同一 `thread_id` 重新提交任务 → 继续轮询(可能多轮)
215
267
  - 若 content 中包含产物 URL:
216
268
  - 结果地址:来自 `get_thread` 返回的 `messages` 中,任务创作完成会包含产物 URL,将产物链接、下载的本地文件等信息告知用户。
217
269
 
218
270
  ## 核心原则:用户侧不做创作,只做传话
219
271
 
220
- 你(用户侧 Agent)的职责是**搬运工**,不是创作者。后端有专门的 Agent 负责理解需求、拆解分镜、编排工作流、选模型、写 prompt。你要做的只有三件事:
272
+ 你(用户侧 Agent)的职责是**搬运工**,不是创作者。会话 API 路由由后端 Agent 负责理解需求、拆解分镜、编排工作流、选模型、写 prompt;模型直出路由把用户原始参数传给 CLI。你要做的是:
221
273
 
222
274
  1. **上传**:如果用户给了本地文件 → `upload_file.py` 拿到 asset_id
223
- 2. **提交任务**:把用户的原始描述 + asset_id 原封不动发给 `submit_run.py`
275
+ 2. **提交任务**:先按“执行路由”判断;模型直出调用 `pippit-tool-cli generate-video`,其余任务把用户的原始描述 + asset_id 原封不动发给 `submit_run.py`
224
276
  3. **传话**:根据 `get_thread.py` 返回的消息列表,展示过程中的意图询问、创作信息等
225
277
  4. **取件**:`get_thread.py` 轮询结果 → 检查结果 → 下载产物 → 结果展示给用户
226
278
 
@@ -258,3 +310,4 @@ python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir
258
310
  - 查询会话时可用 --after-seq 做增量拉取,便于轮询新消息(含 assistant 回复与生图/生视频结果)
259
311
  - 上传文件仅支持图片(image/*)、视频(video/*)和 `.mp3/.wav` 音频文件,其他类型会被拒绝,文件大小须在 200MB 以下
260
312
  - 生成过程中将过程中的创作信息展示给用户;任务完成后给出**产物结果(图片/视频)URL链接**和下载的**本地文件列表**。
313
+ - 模型直出任务必须保留 `generate-video` 返回的 `thread_id` / `run_id`,并用 `query-result` 取回最终视频。