@pippit-dev/cli 1.0.10 → 1.0.12

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
@@ -213,6 +213,37 @@ pippit-tool-cli download-result --output-path ./thread_123/results/result.mp4 --
213
213
 
214
214
  短剧命令的错误日志会追加写入本地每日日志文件:`~/.pippit_tool_cli/logs/yyyy-mm-dd.log`。日志路径会基于当前用户主目录和系统路径分隔符生成,因此可在 macOS、Linux 和 Windows 上使用。
215
215
 
216
+ ## 生图 CLI
217
+
218
+ `generate-image` 会上传本地参考图片,然后向综合 Nest Agent 提交生图请求:
219
+
220
+ ```bash
221
+ pippit-tool-cli generate-image \
222
+ --prompt "生成一张小猫海报" \
223
+ --image "~/images/cat.png" \
224
+ --model "seedream_4.5" \
225
+ --ratio 6 \
226
+ --generate-image-count 2
227
+ ```
228
+
229
+ 命令输出 `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 只做非空校验,具体模型值是否可用由服务端决定。
230
+
231
+ `--ratio` 可选,填写服务端 `Ratio` 枚举值。CLI 只做整数格式解析,不检查枚举值是否在下表范围内;具体值是否可用由服务端决定。常用枚举值含义如下:
232
+
233
+ | ratio 参数 | IDL 枚举 | 含义 |
234
+ | ---: | --- | --- |
235
+ | `0` | `CanvasRatioOriginal` | 原始比例(自动) |
236
+ | `2` | `CanvasRatio16To9` | 16:9(横屏) |
237
+ | `13` | `CanvasRatio21To9` | 21:9(电影) |
238
+ | `3` | `CanvasRatio9To16` | 9:16(竖屏) |
239
+ | `4` | `CanvasRatio4To3` | 4:3 |
240
+ | `5` | `CanvasRatio3To4` | 3:4 |
241
+ | `6` | `CanvasRatio1To1` | 1:1 |
242
+
243
+ `--generate-image-count` 可选,填写生图数量,对应 IDL 字段 `GeneralSettingsPart.GenerateImageCount` / JSON 字段 `generate_image_count`。CLI 只校验不能为负数;具体数量范围由服务端决定。
244
+
245
+ 图片支持 `.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.webp`、`.svg`。CLI 会在提交前校验 prompt、model 必填、ratio 整数格式、generate-image-count 非负和文件后缀。
246
+
216
247
  ## 生视频 CLI
217
248
 
218
249
  `generate-video` 会上传本地参考图片、视频和音频,然后向视频片段 Agent 提交生视频请求:
@@ -233,7 +264,7 @@ pippit-tool-cli generate-video \
233
264
 
234
265
  命令输出 `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
266
 
236
- 查询并下载生视频结果:
267
+ 查询并下载生图/生视频结果:
237
268
 
238
269
  ```bash
239
270
  pippit-tool-cli query-result \
@@ -242,7 +273,7 @@ pippit-tool-cli query-result \
242
273
  --download-dir "./output"
243
274
  ```
244
275
 
245
- `query-result` 会查询指定 Run 并输出 JSON。Run 成功完成后下载视频产物,`completed=true`,`videos` 中只包含 `download_url` 和 `output_path`;Run 失败也视为终态,`completed=true` 且填充 `error_message`;Run 未到终态时 `completed=false`。
276
+ `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
277
 
247
278
  ## HTTP 客户端
248
279
 
package/checksums.txt CHANGED
@@ -1,6 +1,6 @@
1
- 44e0b20d9ce770e4a1bfc8ae474aaec8b6fc7e17beb7e9c00468024caa3888dd pippit-tool-cli-1.0.10-darwin-amd64.tar.gz
2
- 0d77c0aae453c3a64c61699823b775367816f7300aca04f0d47aec1151038092 pippit-tool-cli-1.0.10-darwin-arm64.tar.gz
3
- 7ee3fd6064406864ce4f3bbb8886421dc1cde462cb678e3faac66d81c6e64cbb pippit-tool-cli-1.0.10-linux-amd64.tar.gz
4
- 95c1b80c2e743eafce3ab91c94f86df727d236c859d8f9d581eb7a7d5b055125 pippit-tool-cli-1.0.10-linux-arm64.tar.gz
5
- cd9997499e4a217c456b18b021e0215705bf5fba64325bc3461380fed315cf10 pippit-tool-cli-1.0.10-windows-amd64.zip
6
- b82a6022de7f4cdd9ffbcc6e00c8390fcf5ae323630c202a72861eee411c1c52 pippit-tool-cli-1.0.10-windows-arm64.zip
1
+ 127a8f5d5f5c655e671fcd9dee1ce5007ce85f5cd3fbb8bd96310bbfb9c36233 pippit-tool-cli-1.0.12-darwin-amd64.tar.gz
2
+ 2f51e8957dae53550f07b63aedb38aafa39c42b07a5d7bf2bc9591cceea933e4 pippit-tool-cli-1.0.12-darwin-arm64.tar.gz
3
+ d25be6ff1afc4ca2f16028993f7340d210bd20e10a98e81dcd29122f156934ba pippit-tool-cli-1.0.12-linux-amd64.tar.gz
4
+ 85f5edd2da2a143f7b41145a3e090d84dce2aaf877189c95b04769bde081bda2 pippit-tool-cli-1.0.12-linux-arm64.tar.gz
5
+ 205e82c8982ce275a87884b056304e595815dafc62c9be186299b5b684066aba pippit-tool-cli-1.0.12-windows-amd64.zip
6
+ 4264f068bf7bbdc6bada501eec7867d83b2c494543a53c0b82cdecf55205188f pippit-tool-cli-1.0.12-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)
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.10",
3
+ "version": "1.0.12",
4
4
  "description": "Pippit CLI",
5
5
  "bin": {
6
6
  "pippit-tool-cli": "scripts/run.js"
@@ -59,6 +59,17 @@ metadata:
59
59
  - 不替用户决定创意内容,例如风格、剧情方向、角色设定、镜头方案。只能清洗流程选项,不能代替用户选择创作偏好。
60
60
  - 如果服务端问题混入跨度过大的多个流程选项,重新组织成当前阶段可回答的问题,并说明已按主流程剔除不合理或跳跃选项。
61
61
 
62
+ ## 宿主提问工具优先
63
+
64
+ 当后端 Agent 通过 `readable_text` 要求用户补充信息、选择选项、确认流程或确认创意内容时,优先使用当前宿主提供的 ask-question / confirmation / form 类工具向用户提问,而不是只在普通聊天里输出问题。
65
+
66
+ 使用宿主提问工具前,先按“表单与问卷选项处理原则”清洗问题和选项:
67
+
68
+ - 只把当前阶段合理、必要、可执行的选项放进提问工具。
69
+ - 不把已剔除的跳跃流程、倒退流程或不合理选项放进提问工具。
70
+ - 对普通开放问题,用单个清晰问题询问用户;对明确互斥选项,用宿主支持的选择控件。
71
+ - 如果当前宿主没有暴露可调用的 ask-question / confirmation / form 工具,才退回普通聊天提问,并说明需要用户回复后才能继续。
72
+
62
73
  ## 前置要求
63
74
 
64
75
  需要已安装 `pippit-tool-cli`:
@@ -75,6 +86,40 @@ Access Key 创建地址:https://xyq.jianying.com/home?tab_name=home
75
86
  export XYQ_ACCESS_KEY="<access-key>"
76
87
  ```
77
88
 
89
+ ## 小云雀界面打开契约
90
+
91
+ `+submit-run` 返回 `web_thread_link` 后,用户侧 Agent 必须优先把小云雀短剧 WebUI 打开给用户,而不是只展示链接。
92
+
93
+ 按当前宿主适配打开方式:
94
+
95
+ **Codex Desktop**
96
+
97
+ 1. 如果 `browser:control-in-app-browser` skill 可用,先读取并按该 skill 连接 Codex in-app browser。
98
+ 2. 使用 in-app browser 打开 `web_thread_link`,并让浏览器可见。
99
+ 3. 继续执行 `get-thread`、`list-thread-file` 和 `download-result`;打开 WebUI 不替代 CLI 轮询和产物下载。
100
+
101
+ **WorkBuddy**
102
+
103
+ 1. 如果当前 WorkBuddy 会话暴露内置浏览器或页面打开能力,使用宿主提供的能力打开 `web_thread_link`。
104
+ 2. 不要套用 Codex Desktop 的 `browser:control-in-app-browser`、`node_repl` 或 `agent.browsers.get("iab")` 实现。
105
+ 3. 如果 WorkBuddy 当前没有暴露可调用浏览器工具,说明无法自动打开,并把 `web_thread_link` 交给用户在 WorkBuddy 内置浏览器或普通浏览器中打开。
106
+
107
+ **TRAE Work**
108
+
109
+ 1. 如果当前 TRAE Work 会话暴露内置浏览器或页面打开能力,使用宿主提供的能力打开 `web_thread_link`。
110
+ 2. 不要套用 Codex Desktop 的 in-app browser 实现。
111
+ 3. 如果 TRAE Work 当前没有暴露可调用浏览器工具,说明无法自动打开,并把 `web_thread_link` 交给用户手动打开。
112
+
113
+ **其他宿主或未知环境**
114
+
115
+ 如果没有明确的宿主浏览器能力、工具不可用或连接失败:
116
+
117
+ - 明确说明未能自动打开小云雀界面的具体原因。
118
+ - 仍然把 `web_thread_link` 展示给用户,作为手动打开入口。
119
+ - 不要因此跳过后续进展查询和文件下载。
120
+
121
+ 打开界面的目的只是让用户能进入小云雀编辑/确认界面做视觉 review、流程确认或手动调整;短剧任务提交、状态查询和重要资产落盘仍以 `pippit-tool-cli` 为准。
122
+
78
123
  ## 使用方法
79
124
 
80
125
  ### 1. 提交短剧任务
@@ -152,7 +197,7 @@ pippit-tool-cli download-result --url DOWNLOAD_URL --output-path FILE_PATH --upd
152
197
  ```
153
198
  1. pippit-tool-cli short-drama +submit-run --message "用户的原始短剧需求"
154
199
  → 拿到 thread_id、run_id 和 web_thread_link
155
- 2. 立即将 web_thread_link 展示给用户
200
+ 2. 立即展示 web_thread_link,并按“小云雀界面打开契约”优先用 in-app browser 打开该链接
156
201
  3. 并行发起,二者同等重要:
157
202
  a. pippit-tool-cli get-thread --thread-id THREAD_ID --run-id RUN_ID
158
203
  b. pippit-tool-cli list-thread-file --thread-id THREAD_ID --page-num PAGE_NUM --page-size 200
@@ -183,8 +228,9 @@ pippit-tool-cli download-result --url DOWNLOAD_URL --output-path FILE_PATH --upd
183
228
  → 拿到 asset_id
184
229
  3. pippit-tool-cli short-drama +submit-run --message "用户的原始短剧需求" --asset-ids asset_id
185
230
  → 拿到 thread_id、run_id 和 web_thread_link
186
- 4. 记录该 thread_id 已绑定这个剧本文件;后续同一 thread_id 的续写或修改只传 --thread-id,不再传新的剧本 asset_id
187
- 5. 后续同场景 1 的并行查询、重要资产发现和文件下载流程
231
+ 4. 立即展示 web_thread_link,并按“小云雀界面打开契约”优先用 in-app browser 打开该链接
232
+ 5. 记录该 thread_id 已绑定这个剧本文件;后续同一 thread_id 的续写或修改只传 --thread-id,不再传新的剧本 asset_id
233
+ 6. 后续同场景 1 的并行查询、重要资产发现和文件下载流程
188
234
  ```
189
235
 
190
236
  ### 场景 3:在已有短剧会话中续写或修改
@@ -192,8 +238,9 @@ pippit-tool-cli download-result --url DOWNLOAD_URL --output-path FILE_PATH --upd
192
238
  ```
193
239
  1. pippit-tool-cli short-drama +submit-run --message "用户的新需求" --thread-id THREAD_ID
194
240
  → 拿到新的 run_id 和 web_thread_link
195
- 2. 如果该 THREAD_ID 已经绑定过剧本文件,不要再上传或通过 --asset-ids 追加第二个剧本文件
196
- 3. 继续按场景 1 展示进展、处理用户补充问题、获取新增会话文件列表,并及时下载新增重要资产
241
+ 2. 立即展示 web_thread_link,并按“小云雀界面打开契约”优先用 in-app browser 打开该链接
242
+ 3. 如果该 THREAD_ID 已经绑定过剧本文件,不要再上传或通过 --asset-ids 追加第二个剧本文件
243
+ 4. 继续按场景 1 展示进展、处理用户补充问题、获取新增会话文件列表,并及时下载新增重要资产
197
244
  ```
198
245
 
199
246
  ## 轮询策略
@@ -205,7 +252,7 @@ pippit-tool-cli download-result --url DOWNLOAD_URL --output-path FILE_PATH --upd
205
252
  - **重要资产识别**:每轮都检查 `list-thread-file` 返回的文件。剧本设计、场景设计、场景图、人物角色设计、人物图、分集草稿、故事板、最终视频产物都是重要资产。
206
253
  - **文件下载**:解析 `list-thread-file` 的结果后,对带 `download_url` 的重要资产立即调用 `download-result` 下载资源;不要在 `list-thread-file` 阶段检查文件是否已存在,存在性检查由下载工具内部处理。
207
254
  - **下载完成标准**:不要把文件元信息展示当成下载完成;必须拿到本地 `file_path`,或明确记录该文件在重试后仍下载失败。
208
- - **用户确认**:如果消息中出现需要用户确认、补充设定或回答问题的内容,先判断是否包含表单、问卷、选项或按钮;包含时按“短剧主流程顺序”和“表单与问卷选项处理原则”清洗选项,再展示给用户并等待回复。
255
+ - **用户确认**:如果消息中出现需要用户确认、补充设定或回答问题的内容,先判断是否包含表单、问卷、选项或按钮;包含时按“短剧主流程顺序”和“表单与问卷选项处理原则”清洗选项,再按“宿主提问工具优先”向用户提问并等待回复。
209
256
  - **超时**:如果长时间无结果,告知用户任务仍在生成中,可稍后通过 `web_thread_link` 查看。
210
257
  - **错误处理**:`get-thread`、`list-thread-file` 或 `download-result` 任一调用失败时,记录失败原因和参数,在后续轮询中主动重试;重试期间继续处理其他成功返回的消息和文件。连续多轮失败后再向用户说明仍未完成的查询或下载项。
211
258
 
@@ -214,10 +261,11 @@ pippit-tool-cli download-result --url DOWNLOAD_URL --output-path FILE_PATH --upd
214
261
  一次短剧任务不能只以 `get-thread` 返回的 `readable_text` 作为结束条件。完成前必须同时检查:
215
262
 
216
263
  1. 已处理 `get-thread` 返回的最新 `readable_text`、用户确认问题和最终消息。
217
- 2. 已用 `--page-size 200` 调用 `list-thread-file` 获取会话文件列表;如果本轮 `total` 达到 200,已在后续轮询中递增 `page-num` 查询新一页。
218
- 3. 对所有带 `download_url` 的重要资产,已调用 `download-result` 下载到本地 `file_path`。
219
- 4. 已按短剧主流程顺序检查服务端表单、问卷和选项,没有把跳过必要阶段的选项直接呈现给用户;如果跳过 `剧本标准化`,已明确这是可选阶段。
220
- 5. 对查询失败或下载失败的资产,已在后续轮询中主动重试,并在最终回复中列出仍失败的文件或命令。
264
+ 2. 已展示 `web_thread_link`,并按当前宿主尝试打开小云雀 WebUI:Codex Desktop 用 in-app browser;WorkBuddy / TRAE Work 用各自宿主提供的内置浏览器或页面打开能力;如果不能自动打开,已说明原因并提供手动链接。
265
+ 3. 已用 `--page-size 200` 调用 `list-thread-file` 获取会话文件列表;如果本轮 `total` 达到 200,已在后续轮询中递增 `page-num` 查询新一页。
266
+ 4. 对所有带 `download_url` 的重要资产,已调用 `download-result` 下载到本地 `file_path`。
267
+ 5. 已按短剧主流程顺序检查服务端表单、问卷和选项,没有把跳过必要阶段的选项直接呈现给用户;如果跳过 `剧本标准化`,已明确这是可选阶段。
268
+ 6. 对查询失败或下载失败的资产,已在后续轮询中主动重试,并在最终回复中列出仍失败的文件或命令。
221
269
 
222
270
  ## 输出格式
223
271
 
@@ -310,8 +358,9 @@ Thread: thread_...
310
358
  ## 向用户展示内容
311
359
 
312
360
  - 任务提交后:立即展示 `web_thread_link`。
361
+ - 在支持内置浏览器或页面打开能力的宿主中:任务提交后按“小云雀界面打开契约”优先打开 `web_thread_link`,让用户能进入小云雀 WebUI 查看和调整;不同宿主只使用各自提供的浏览器能力,不复用 Codex Desktop 的实现细节。
313
362
  - 任务进行中:展示后端 Agent 返回的过程消息。
314
- - 需要用户补充信息时:如果是普通问题,展示后端 Agent 的问题并等待用户回复;如果包含表单、问卷、选项或按钮,先按短剧主流程清洗不合理或跳跃的流程选项,再展示给用户。
363
+ - 需要用户补充信息时:如果是普通问题,按“宿主提问工具优先”提问并等待用户回复;如果包含表单、问卷、选项或按钮,先按短剧主流程清洗不合理或跳跃的流程选项,再用宿主提问工具呈现;没有可用提问工具时才退回普通聊天。
315
364
  - 任务完成后:展示短剧内容、分集草稿、设定说明或其他结果信息,同时检查是否有未下载的重要资产。
316
365
  - 获取会话文件后:展示或记录文件元信息,不把它当成已下载结果。
317
366
  - 文件资源下载后:展示已落盘的本地文件路径;已存在而跳过下载的文件也要标明。