@pippit-dev/cli 1.0.44 → 1.0.46

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/checksums.txt CHANGED
@@ -1,6 +1,6 @@
1
- 8bf338b7fc4bb3de289778896059f364f91743e7fe3127380e193f29bb10fecf pippit-tool-cli-1.0.44-darwin-amd64.tar.gz
2
- fd7aac74fda167481b76e3a7aa2f30e45271670cad11f0e969080db1b135411a pippit-tool-cli-1.0.44-darwin-arm64.tar.gz
3
- 526e3641047d7e997fb5c271d795337692b0a47c092622747f6e2b4e9d22e473 pippit-tool-cli-1.0.44-linux-amd64.tar.gz
4
- 6170436a3ec60dc2a2681e6f790c6a52d9daa4248ce8c3eede83136a62ccb4b6 pippit-tool-cli-1.0.44-linux-arm64.tar.gz
5
- d3c7166314bb52deae5e0caf383ac7e3cdde27c61325d6556de43f8f46aa9963 pippit-tool-cli-1.0.44-windows-amd64.zip
6
- 9cb3d470688cd89a42a2267d9d181159dd624937c9590d3b744db25a66edbac9 pippit-tool-cli-1.0.44-windows-arm64.zip
1
+ a22b8aaebe337a2617f238dfcdd425a4133a9c63bebf812f15b834955881233b pippit-tool-cli-1.0.46-darwin-amd64.tar.gz
2
+ a28e74b130adb63620bd3bb5d382d621e7dfc8a826c7e4663b1fba7f4a623449 pippit-tool-cli-1.0.46-darwin-arm64.tar.gz
3
+ 6c7e1a7998e48de298bb1c0263ff26e06a9acd55c8469edc6a7731914109aa3d pippit-tool-cli-1.0.46-linux-amd64.tar.gz
4
+ 1641f4cc8161e4b1f012211c690db07a3fd100ff18845130f77f603e06298cd4 pippit-tool-cli-1.0.46-linux-arm64.tar.gz
5
+ 938743186947edac0e27627891066c946d99d8facc80f27b6395eaab88fda0f9 pippit-tool-cli-1.0.46-windows-amd64.zip
6
+ 1f8903e4c94214e21c5e86556243a8e95175e076f41261da6210fb51fdca72c2 pippit-tool-cli-1.0.46-windows-arm64.zip
@@ -8,14 +8,18 @@ import (
8
8
  "os"
9
9
  "path/filepath"
10
10
  "strings"
11
+ "sync/atomic"
11
12
  "testing"
12
13
 
13
14
  "github.com/bytedance/sonic"
14
15
  )
15
16
 
16
17
  func TestGenerateVideo(t *testing.T) {
17
- assetIDs := []string{"image_asset_1", "image_asset_2", "video_asset_1", "video_asset_2", "audio_asset_1"}
18
- uploadIndex := 0
18
+ assetIDs := map[string]string{
19
+ "cat1.jpg": "image_asset_1", "cat2.jpg": "image_asset_2",
20
+ "video1.mp4": "video_asset_1", "video2.mp4": "video_asset_2", "bgm.wav": "audio_asset_1",
21
+ }
22
+ var uploadCount atomic.Int32
19
23
 
20
24
  server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21
25
  if r.Header.Get("Authorization") != "Bearer test-token" {
@@ -26,9 +30,6 @@ func TestGenerateVideo(t *testing.T) {
26
30
  if r.Method != http.MethodPost {
27
31
  t.Fatalf("upload method = %s, want POST", r.Method)
28
32
  }
29
- if uploadIndex >= len(assetIDs) {
30
- t.Fatalf("unexpected upload %d", uploadIndex)
31
- }
32
33
  if err := r.ParseMultipartForm(1 << 20); err != nil {
33
34
  t.Fatalf("ParseMultipartForm(): %v", err)
34
35
  }
@@ -39,11 +40,15 @@ func TestGenerateVideo(t *testing.T) {
39
40
  if files[0].Filename == "bgm.wav" && !strings.HasPrefix(files[0].Header.Get("Content-Type"), "audio/") {
40
41
  t.Fatalf("audio content type = %q, want audio content type", files[0].Header.Get("Content-Type"))
41
42
  }
42
- uploadIndex++
43
- _, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"pippit_asset_id":"` + assetIDs[uploadIndex-1] + `"}}`))
43
+ assetID, ok := assetIDs[files[0].Filename]
44
+ if !ok {
45
+ t.Errorf("unexpected upload %q", files[0].Filename)
46
+ }
47
+ uploadCount.Add(1)
48
+ _, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"pippit_asset_id":"` + assetID + `"}}`))
44
49
  case "/api/biz/v1/skill/submit_run":
45
- if uploadIndex != len(assetIDs) {
46
- t.Fatalf("submit called after %d uploads, want %d", uploadIndex, len(assetIDs))
50
+ if got := uploadCount.Load(); int(got) != len(assetIDs) {
51
+ t.Fatalf("submit called after %d uploads, want %d", got, len(assetIDs))
47
52
  }
48
53
  data, err := io.ReadAll(r.Body)
49
54
  if err != nil {
@@ -288,19 +293,24 @@ func TestGenerateVideoAcceptsReferencesBeyondFormerLimits(t *testing.T) {
288
293
  }
289
294
  }
290
295
 
291
- uploadIndex := 0
296
+ var uploadCount atomic.Int32
292
297
  submitted := false
293
298
  server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
294
299
  switch r.URL.Path {
295
300
  case "/api/biz/v1/skill/upload_file":
296
- if uploadIndex >= len(assetIDs) {
297
- t.Fatalf("unexpected upload %d", uploadIndex)
298
- }
299
- _, _ = w.Write([]byte(`{"ret":"0","data":{"pippit_asset_id":"` + assetIDs[uploadIndex] + `"}}`))
300
- uploadIndex++
301
+ file, header, err := r.FormFile("file")
302
+ if err != nil {
303
+ t.Errorf("FormFile(): %v", err)
304
+ http.Error(w, "invalid upload", http.StatusBadRequest)
305
+ return
306
+ }
307
+ file.Close()
308
+ defer r.MultipartForm.RemoveAll()
309
+ uploadCount.Add(1)
310
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"pippit_asset_id":"` + header.Filename + `"}}`))
301
311
  case "/api/biz/v1/skill/submit_run":
302
- if uploadIndex != len(assetIDs) {
303
- t.Fatalf("uploaded %d references, want %d", uploadIndex, len(assetIDs))
312
+ if got := uploadCount.Load(); int(got) != len(assetIDs) {
313
+ t.Fatalf("uploaded %d references, want %d", got, len(assetIDs))
304
314
  }
305
315
  data, err := io.ReadAll(r.Body)
306
316
  if err != nil {
@@ -0,0 +1,132 @@
1
+ package cmd
2
+
3
+ import (
4
+ "bytes"
5
+ "errors"
6
+ "fmt"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "sync"
13
+ "sync/atomic"
14
+ "testing"
15
+
16
+ "github.com/Pippit-dev/pippit-cli/internal/common"
17
+ "github.com/Pippit-dev/pippit-cli/internal/config"
18
+ )
19
+
20
+ func TestGenerationCollectsUploadFailuresBeforeReturning(t *testing.T) {
21
+ for _, command := range []string{"generate-image", "generate-video"} {
22
+ t.Run(command, func(t *testing.T) {
23
+ t.Setenv("HOME", t.TempDir())
24
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
25
+ t.Setenv("LocalAppData", t.TempDir())
26
+ type material struct {
27
+ flag, name, failure string
28
+ missing bool
29
+ }
30
+ materials := []material{
31
+ {flag: "image", name: "rejected-a.jpg", failure: `第1张参考图片 "rejected-a.jpg" 上传失败: 上传文件请求返回失败: ret=1 errmsg=图片不符合社区规范 log_id=log-rejected-a.jpg`},
32
+ {flag: "image", name: "accepted.jpg"},
33
+ {flag: "image", name: "rejected-b.jpg", failure: `第3张参考图片 "rejected-b.jpg" 上传失败: 上传文件请求返回失败: ret=1 errmsg=图片不符合社区规范 log_id=log-rejected-b.jpg`},
34
+ }
35
+ if command == "generate-video" {
36
+ materials = append(materials,
37
+ material{flag: "video", name: "rejected.mp4", failure: `第1个参考视频 "rejected.mp4" 上传失败: 上传文件请求返回失败: ret=1 errmsg=视频不符合社区规范 log_id=log-rejected.mp4`},
38
+ material{flag: "video", name: "accepted.mp4"},
39
+ material{flag: "audio", name: "missing.wav", missing: true, failure: `第1段参考音频 "missing.wav" 上传失败: 上传文件不存在:`},
40
+ material{flag: "audio", name: "unavailable.wav", failure: `第2段参考音频 "unavailable.wav" 上传失败: 上传文件请求返回失败: ret=1 errmsg=服务暂不可用 log_id=log-unavailable.wav`},
41
+ )
42
+ }
43
+ args := []string{command, "--prompt", "test"}
44
+ if command == "generate-image" {
45
+ args = append(args, "--model", "Seedream 5.0 Pro")
46
+ }
47
+ dir := t.TempDir()
48
+ for _, item := range materials {
49
+ path := filepath.Join(dir, item.name)
50
+ if !item.missing {
51
+ if err := os.WriteFile(path, []byte("media"), 0600); err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ }
55
+ args = append(args, "--"+item.flag, path)
56
+ }
57
+ var mu sync.Mutex
58
+ seen := make(map[string]int)
59
+ var submits atomic.Int32
60
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
61
+ switch r.URL.Path {
62
+ case config.GetAvailableModelListPath:
63
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-key","config":{"models":[{"key":"seedream_5.0_pro","name":"Seedream 5.0 Pro","kind":"image"}]}}}`))
64
+ case config.UploadFilePath:
65
+ file, header, err := r.FormFile("file")
66
+ if err != nil {
67
+ t.Errorf("FormFile(): %v", err)
68
+ http.Error(w, "invalid upload", http.StatusBadRequest)
69
+ return
70
+ }
71
+ file.Close()
72
+ defer r.MultipartForm.RemoveAll()
73
+ mu.Lock()
74
+ seen[header.Filename]++
75
+ mu.Unlock()
76
+ switch {
77
+ case strings.HasPrefix(header.Filename, "accepted"):
78
+ fmt.Fprintf(w, `{"ret":"0","data":{"pippit_asset_id":%q}}`, header.Filename)
79
+ case header.Filename == "unavailable.wav":
80
+ fmt.Fprintf(w, `{"ret":"1","errmsg":"服务暂不可用","log_id":%q}`, "log-"+header.Filename)
81
+ default:
82
+ reason := "图片不符合社区规范"
83
+ if strings.HasSuffix(header.Filename, ".mp4") {
84
+ reason = "视频不符合社区规范"
85
+ }
86
+ fmt.Fprintf(w, `{"ret":"1","errmsg":%q,"log_id":%q}`, reason, "log-"+header.Filename)
87
+ }
88
+ case config.SubmitRunPath:
89
+ submits.Add(1)
90
+ http.Error(w, "must not submit", http.StatusInternalServerError)
91
+ default:
92
+ t.Errorf("unexpected request %s", r.URL.Path)
93
+ http.NotFound(w, r)
94
+ }
95
+ }))
96
+ defer server.Close()
97
+ var stdout, stderr bytes.Buffer
98
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
99
+ root.SetArgs(args)
100
+ err := root.Execute()
101
+ if err == nil || !strings.Contains(err.Error(), "未提交生成") {
102
+ t.Fatalf("error = %v, want upload failure summary", err)
103
+ }
104
+ var logErr *common.LogIDError
105
+ if !errors.As(err, &logErr) || logErr.LogID() != "log-rejected-a.jpg" {
106
+ t.Errorf("aggregated error lost the original LogID error chain: %v", err)
107
+ }
108
+ lastPosition := -1
109
+ mu.Lock()
110
+ defer mu.Unlock()
111
+ for _, item := range materials {
112
+ wantRequests := 1
113
+ if item.missing {
114
+ wantRequests = 0
115
+ }
116
+ if seen[item.name] != wantRequests {
117
+ t.Errorf("%s uploaded %d times, want %d", item.name, seen[item.name], wantRequests)
118
+ }
119
+ if item.failure != "" {
120
+ position := strings.Index(err.Error(), item.failure)
121
+ if position <= lastPosition {
122
+ t.Errorf("failure missing or out of order: %s; error: %v", item.failure, err)
123
+ }
124
+ lastPosition = position
125
+ }
126
+ }
127
+ if submits.Load() != 0 || stdout.Len() != 0 {
128
+ t.Fatalf("failed uploads created a task: submits=%d stdout=%s", submits.Load(), stdout.String())
129
+ }
130
+ })
131
+ }
132
+ }
@@ -94,12 +94,12 @@ func UploadFile(ctx context.Context, opts UploadFileOptions, runner *Runner) (*U
94
94
  if resp.Errmsg == "" {
95
95
  resp.Errmsg = "未知错误"
96
96
  }
97
- return nil, fmt.Errorf("上传文件请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg)
97
+ return nil, NewLogIDError(fmt.Sprintf("上传文件请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID)
98
98
  }
99
99
 
100
100
  assetID := strings.TrimSpace(resp.Data.PippitAssetID)
101
101
  if assetID == "" {
102
- return nil, fmt.Errorf("上传文件响应缺少 pippit_asset_id")
102
+ return nil, NewLogIDError("上传文件响应缺少 pippit_asset_id", resp.LogID)
103
103
  }
104
104
 
105
105
  return &UploadFileResult{
@@ -0,0 +1,69 @@
1
+ package common
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "path/filepath"
8
+ "sync"
9
+ )
10
+
11
+ const mediaUploadWorkers = 10
12
+
13
+ // UploadMediaGroup keeps the original order and type-local numbering of references.
14
+ type UploadMediaGroup struct {
15
+ Label string
16
+ Paths []string
17
+ }
18
+
19
+ // UploadMediaGroups waits for every upload, preserving group and path order even
20
+ // when requests finish out of order. Any failure prevents generation callers
21
+ // from receiving a partial set of asset IDs.
22
+ func UploadMediaGroups(ctx context.Context, runner *Runner, groups ...UploadMediaGroup) ([][]string, error) {
23
+ type task struct {
24
+ group int
25
+ index int
26
+ slot int
27
+ }
28
+ assetIDs := make([][]string, len(groups))
29
+ var tasks []task
30
+ for g, group := range groups {
31
+ assetIDs[g] = make([]string, len(group.Paths))
32
+ for i := range group.Paths {
33
+ tasks = append(tasks, task{group: g, index: i, slot: len(tasks)})
34
+ }
35
+ }
36
+ errs := make([]error, len(tasks))
37
+ taskCh := make(chan task)
38
+ var wg sync.WaitGroup
39
+ for worker := 0; worker < min(mediaUploadWorkers, len(tasks)); worker++ {
40
+ wg.Add(1)
41
+ go func() {
42
+ defer wg.Done()
43
+ for task := range taskCh {
44
+ group := groups[task.group]
45
+ path := group.Paths[task.index]
46
+ expanded, err := ExpandPath(path)
47
+ if err != nil {
48
+ errs[task.slot] = fmt.Errorf("第%d%s %q 路径解析失败: %w", task.index+1, group.Label, filepath.Base(path), err)
49
+ continue
50
+ }
51
+ result, err := UploadFile(ctx, UploadFileOptions{Path: expanded}, runner)
52
+ if err != nil {
53
+ errs[task.slot] = fmt.Errorf("第%d%s %q 上传失败: %w", task.index+1, group.Label, filepath.Base(path), err)
54
+ continue
55
+ }
56
+ assetIDs[task.group][task.index] = result.AssetID
57
+ }
58
+ }()
59
+ }
60
+ for _, task := range tasks {
61
+ taskCh <- task
62
+ }
63
+ close(taskCh)
64
+ wg.Wait()
65
+ if err := errors.Join(errs...); err != nil {
66
+ return nil, fmt.Errorf("参考素材上传失败,未提交生成:\n%w", err)
67
+ }
68
+ return assetIDs, nil
69
+ }
@@ -0,0 +1,192 @@
1
+ package common
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "fmt"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "os"
10
+ "path/filepath"
11
+ "reflect"
12
+ "strings"
13
+ "sync/atomic"
14
+ "testing"
15
+ "time"
16
+
17
+ "github.com/Pippit-dev/pippit-cli/internal/config"
18
+ "github.com/Pippit-dev/pippit-cli/internal/space"
19
+ )
20
+
21
+ type mediaUploadClient struct {
22
+ Client
23
+ upload func(context.Context, MultipartFile, any) error
24
+ }
25
+
26
+ func (c mediaUploadClient) SendMultipartRequest(ctx context.Context, _ string, _ map[string]string, file MultipartFile, out any) error {
27
+ return c.upload(ctx, file, out)
28
+ }
29
+
30
+ func TestUploadMediaGroupsConcurrentAndOrdered(t *testing.T) {
31
+ const wantConcurrency = 10
32
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
33
+ defer cancel()
34
+ groups := []UploadMediaGroup{{Label: "张参考图片"}, {Label: "个参考视频"}, {Label: "段参考音频"}}
35
+ want := [][]string{
36
+ {"a.jpg", "b.jpg", "c.jpg", "d.jpg"},
37
+ {"a.mp4", "b.mp4", "c.mp4", "d.mp4"},
38
+ {"a.wav", "b.wav", "c.wav", "d.wav"},
39
+ }
40
+ release := make(map[string]chan struct{})
41
+ dir := t.TempDir()
42
+ for g, names := range want {
43
+ for _, name := range names {
44
+ path := filepath.Join(dir, name)
45
+ if err := os.WriteFile(path, []byte("media"), 0600); err != nil {
46
+ t.Fatal(err)
47
+ }
48
+ groups[g].Paths = append(groups[g].Paths, path)
49
+ release[name] = make(chan struct{})
50
+ }
51
+ }
52
+ started := make(chan string, len(release))
53
+ var active, peak atomic.Int32
54
+ client := mediaUploadClient{upload: func(ctx context.Context, file MultipartFile, out any) error {
55
+ n := active.Add(1)
56
+ defer active.Add(-1)
57
+ for old := peak.Load(); n > old; old = peak.Load() {
58
+ if peak.CompareAndSwap(old, n) {
59
+ break
60
+ }
61
+ }
62
+ started <- file.FileName
63
+ select {
64
+ case <-release[file.FileName]:
65
+ case <-ctx.Done():
66
+ return ctx.Err()
67
+ }
68
+ response := out.(*uploadFileResponse)
69
+ response.Ret = "0"
70
+ response.Data.PippitAssetID = file.FileName
71
+ return nil
72
+ }}
73
+ type outcome struct {
74
+ ids [][]string
75
+ err error
76
+ }
77
+ done := make(chan outcome, 1)
78
+ go func() {
79
+ ids, err := UploadMediaGroups(ctx, &Runner{Client: client}, groups...)
80
+ done <- outcome{ids, err}
81
+ }()
82
+ var first []string
83
+ for i := 0; i < wantConcurrency; i++ {
84
+ select {
85
+ case name := <-started:
86
+ first = append(first, name)
87
+ case <-ctx.Done():
88
+ t.Fatal("uploads did not start concurrently")
89
+ }
90
+ }
91
+ // Release the first requests in reverse order; IDs must still match inputs.
92
+ for i := len(first) - 1; i >= 0; i-- {
93
+ close(release[first[i]])
94
+ }
95
+ for i := len(first); i < len(release); i++ {
96
+ select {
97
+ case name := <-started:
98
+ close(release[name])
99
+ case <-ctx.Done():
100
+ t.Fatal("remaining uploads did not start")
101
+ }
102
+ }
103
+ select {
104
+ case got := <-done:
105
+ if got.err != nil || !reflect.DeepEqual(got.ids, want) {
106
+ t.Fatalf("UploadMediaGroups() = %v, %v; want %v", got.ids, got.err, want)
107
+ }
108
+ case <-ctx.Done():
109
+ t.Fatal("uploads did not finish")
110
+ }
111
+ if got := peak.Load(); got != wantConcurrency {
112
+ t.Fatalf("peak uploads = %d, want %d", got, wantConcurrency)
113
+ }
114
+ }
115
+
116
+ func TestUploadMediaGroupsPreservesSpace(t *testing.T) {
117
+ for _, teamID := range []string{"0", "12"} {
118
+ t.Run(teamID, func(t *testing.T) {
119
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
120
+ wantHeader := teamID
121
+ if teamID == "0" {
122
+ wantHeader = ""
123
+ }
124
+ if r.Header.Get("X-Team-Id") != wantHeader || r.Header.Get("Authorization") != "Bearer test-key" {
125
+ t.Error("upload lost selected space or authentication")
126
+ }
127
+ if r.URL.Path != config.UploadFilePath {
128
+ t.Errorf("unexpected path %s", r.URL.Path)
129
+ }
130
+ file, header, err := r.FormFile("file")
131
+ if err != nil {
132
+ t.Errorf("FormFile(): %v", err)
133
+ http.Error(w, "invalid upload", http.StatusBadRequest)
134
+ return
135
+ }
136
+ file.Close()
137
+ defer r.MultipartForm.RemoveAll()
138
+ fmt.Fprintf(w, `{"ret":"0","data":{"pippit_asset_id":%q}}`, header.Filename)
139
+ }))
140
+ defer api.Close()
141
+ store := &space.Store{Dir: t.TempDir(), Account: "account", Origin: api.URL}
142
+ var stderr bytes.Buffer
143
+ session := &space.Session{Store: store, Explicit: &teamID, Stderr: &stderr}
144
+ ctx := WithSpace(context.Background(), session)
145
+ var paths []string
146
+ dir := t.TempDir()
147
+ for i := 0; i < 7; i++ {
148
+ path := filepath.Join(dir, fmt.Sprintf("image-%d.jpg", i))
149
+ if err := os.WriteFile(path, []byte("image"), 0600); err != nil {
150
+ t.Fatal(err)
151
+ }
152
+ paths = append(paths, path)
153
+ }
154
+ runner := &Runner{Client: NewHTTPClient(api.URL, 5*time.Second, NewAccessKeyAuthorizer("test-key"))}
155
+ ids, err := UploadMediaGroups(ctx, runner, UploadMediaGroup{Label: "张参考图片", Paths: paths})
156
+ if err != nil {
157
+ t.Fatal(err)
158
+ }
159
+ if strings.Count(stderr.String(), "本次空间:") != 1 || session.TeamID() != teamID {
160
+ t.Fatalf("space selected more than once or changed: %s", stderr.String())
161
+ }
162
+ binding, err := space.DecodeBinding(session.Token())
163
+ if err != nil || binding.TeamID != teamID {
164
+ t.Fatalf("space token changed: %+v, %v", binding, err)
165
+ }
166
+ for _, id := range ids[0] {
167
+ ref := space.References{AssetIDs: []string{id}}
168
+ selected, err := (&space.Session{Store: store, Explicit: &teamID}).Resolve(ref)
169
+ if err != nil || selected.TeamID != teamID {
170
+ t.Fatalf("asset %s lost its space: %+v, %v", id, selected, err)
171
+ }
172
+ other := "34"
173
+ if _, err := (&space.Session{Store: store, Explicit: &other}).Resolve(ref); err == nil {
174
+ t.Fatalf("asset %s accepted in another team", id)
175
+ }
176
+ }
177
+ })
178
+ }
179
+ }
180
+
181
+ func TestUploadMediaGroupsCanceledBeforeUpload(t *testing.T) {
182
+ ctx, cancel := context.WithCancel(context.Background())
183
+ cancel()
184
+ client := mediaUploadClient{upload: func(context.Context, MultipartFile, any) error {
185
+ t.Error("canceled batch sent an upload request")
186
+ return nil
187
+ }}
188
+ ids, err := UploadMediaGroups(ctx, &Runner{Client: client}, UploadMediaGroup{Label: "张参考图片", Paths: []string{"a.jpg", "b.jpg"}})
189
+ if ids != nil || err == nil || strings.Count(err.Error(), "上传文件已取消") != 2 {
190
+ t.Fatalf("canceled batch = %v, %v", ids, err)
191
+ }
192
+ }
@@ -58,12 +58,14 @@ func Run(ctx context.Context, opts *Options, runner *common.Runner) (*common.Sub
58
58
  return nil, err
59
59
  }
60
60
 
61
- imageAssetIDs, err := uploadImageList(ctx, opts.ImagePaths, runner)
61
+ assetIDs, err := common.UploadMediaGroups(ctx, runner,
62
+ common.UploadMediaGroup{Label: "张参考图片", Paths: opts.ImagePaths},
63
+ )
62
64
  if err != nil {
63
- return nil, fmt.Errorf("上传图片失败: %w", err)
65
+ return nil, err
64
66
  }
65
67
 
66
- body := buildSubmitRunBody(opts, modelKey, imageAssetIDs)
68
+ body := buildSubmitRunBody(opts, modelKey, assetIDs[0])
67
69
 
68
70
  var resp common.SubmitRunResponse
69
71
  if err := runner.Client.SendRequest(ctx, common.SubmitRunPath(runner), body, &resp); err != nil {
@@ -126,22 +128,6 @@ func SupportedRatioUsage() string {
126
128
  return ratioUsage
127
129
  }
128
130
 
129
- func uploadImageList(ctx context.Context, paths []string, runner *common.Runner) ([]string, error) {
130
- assetIDs := make([]string, 0, len(paths))
131
- for _, path := range paths {
132
- expanded, err := common.ExpandPath(path)
133
- if err != nil {
134
- return nil, err
135
- }
136
- result, err := common.UploadFile(ctx, common.UploadFileOptions{Path: expanded}, runner)
137
- if err != nil {
138
- return nil, err
139
- }
140
- assetIDs = append(assetIDs, result.AssetID)
141
- }
142
- return assetIDs, nil
143
- }
144
-
145
131
  func buildSubmitRunBody(opts *Options, modelKey string, imageAssetIDs []string) map[string]any {
146
132
  ratio, _ := parseRatio(opts.Ratio)
147
133
  body := map[string]any{
@@ -45,20 +45,16 @@ func Run(ctx context.Context, opts *Options, runner *common.Runner) (*Result, er
45
45
  return nil, err
46
46
  }
47
47
 
48
- imageAssetIDs, err := uploadMediaList(ctx, opts.ImagePaths, runner)
48
+ assetIDs, err := common.UploadMediaGroups(ctx, runner,
49
+ common.UploadMediaGroup{Label: "张参考图片", Paths: opts.ImagePaths},
50
+ common.UploadMediaGroup{Label: "个参考视频", Paths: opts.VideoPaths},
51
+ common.UploadMediaGroup{Label: "段参考音频", Paths: opts.AudioPaths},
52
+ )
49
53
  if err != nil {
50
- return nil, fmt.Errorf("上传图片失败: %w", err)
51
- }
52
- videoAssetIDs, err := uploadMediaList(ctx, opts.VideoPaths, runner)
53
- if err != nil {
54
- return nil, fmt.Errorf("上传视频失败: %w", err)
55
- }
56
- audioAssetIDs, err := uploadMediaList(ctx, opts.AudioPaths, runner)
57
- if err != nil {
58
- return nil, fmt.Errorf("上传音频失败: %w", err)
54
+ return nil, err
59
55
  }
60
56
 
61
- body := buildSubmitRunBody(opts, imageAssetIDs, videoAssetIDs, audioAssetIDs)
57
+ body := buildSubmitRunBody(opts, assetIDs[0], assetIDs[1], assetIDs[2])
62
58
  // Business attribution belongs to this command, regardless of the selected model.
63
59
  return common.SubmitRunWithBabiParam(ctx, "generate-video", body, runner, map[string]string{
64
60
  "scene_lv1": "ai_agent",
@@ -99,22 +95,6 @@ func validateMediaExtensions(kind string, paths []string, allowed map[string]str
99
95
  return nil
100
96
  }
101
97
 
102
- func uploadMediaList(ctx context.Context, paths []string, runner *common.Runner) ([]string, error) {
103
- assetIDs := make([]string, 0, len(paths))
104
- for _, path := range paths {
105
- expanded, err := common.ExpandPath(path)
106
- if err != nil {
107
- return nil, err
108
- }
109
- result, err := common.UploadFile(ctx, common.UploadFileOptions{Path: expanded}, runner)
110
- if err != nil {
111
- return nil, err
112
- }
113
- assetIDs = append(assetIDs, result.AssetID)
114
- }
115
- return assetIDs, nil
116
- }
117
-
118
98
  func buildSubmitRunBody(opts *Options, imageAssetIDs []string, videoAssetIDs []string, audioAssetIDs []string) map[string]any {
119
99
  prompt := strings.TrimSpace(opts.Prompt)
120
100
  param := common.VideoPartToolParam{
@@ -6,17 +6,21 @@ import (
6
6
  "encoding/json"
7
7
  "fmt"
8
8
  "io"
9
+ "sync"
9
10
  )
10
11
 
11
12
  // Session is one command's immutable account/origin/team choice. It is not an
12
13
  // authorization grant: every request still uses the current credential.
13
14
  // Store is optional when a command explicitly selects its space.
15
+ // Concurrent requests share one selection; local records and diagnostics are
16
+ // serialized without holding the lock during network uploads.
14
17
  type Session struct {
15
18
  Store *Store
16
19
  Explicit *string
17
20
  Expected *Binding
18
21
  Identity func(context.Context) (string, error)
19
22
  Stderr io.Writer
23
+ mu sync.Mutex
20
24
  selected *Selection
21
25
  }
22
26
 
@@ -49,6 +53,9 @@ func (s *Session) CheckIdentity(ctx context.Context) error {
49
53
  // Resolve selects and freezes the command's space, checking each request's
50
54
  // records even after freezing. CheckIdentity belongs at the request boundary.
51
55
  func (s *Session) Resolve(ref References) (Selection, error) {
56
+ s.mu.Lock()
57
+ defer s.mu.Unlock()
58
+
52
59
  explicit := s.Explicit
53
60
  fixed := s.selected
54
61
  if s.Expected != nil {
@@ -129,25 +136,40 @@ func (s *Session) Resolve(ref References) (Selection, error) {
129
136
  }
130
137
 
131
138
  func (s *Session) TeamID() string {
132
- if s == nil || s.selected == nil {
139
+ if s == nil {
140
+ return "0"
141
+ }
142
+ s.mu.Lock()
143
+ defer s.mu.Unlock()
144
+ if s.selected == nil {
133
145
  return "0"
134
146
  }
135
147
  return s.selected.TeamID
136
148
  }
137
149
 
138
150
  func (s *Session) Token() string {
139
- if s == nil || s.selected == nil || s.Store == nil {
151
+ if s == nil {
140
152
  return ""
141
153
  }
142
- data, _ := json.Marshal(Binding{Selection: Selection{TeamID: s.TeamID()}, Account: s.Store.Account, Origin: s.Store.Origin})
154
+ s.mu.Lock()
155
+ defer s.mu.Unlock()
156
+ if s.selected == nil || s.Store == nil {
157
+ return ""
158
+ }
159
+ data, _ := json.Marshal(Binding{Selection: Selection{TeamID: s.selected.TeamID}, Account: s.Store.Account, Origin: s.Store.Origin})
143
160
  return base64.RawURLEncoding.EncodeToString(data)
144
161
  }
145
162
 
146
163
  func (s *Session) Remember(ref References) {
147
- if s == nil || s.selected == nil || s.Store == nil || s.Store.Account == "" {
164
+ if s == nil {
165
+ return
166
+ }
167
+ s.mu.Lock()
168
+ defer s.mu.Unlock()
169
+ if s.selected == nil || s.Store == nil || s.Store.Account == "" {
148
170
  return
149
171
  }
150
172
  if err := s.Store.Remember(ref, *s.selected); err != nil && s.Stderr != nil {
151
- fmt.Fprintf(s.Stderr, "请求已成功,但空间记录保存失败:%v;保留 thread_id=%s run_id=%s asset_ids=%v,后续使用 --team-id %s 查询,不要重复提交。\n", err, ref.ThreadID, ref.RunID, ref.AssetIDs, s.TeamID())
173
+ fmt.Fprintf(s.Stderr, "请求已成功,但空间记录保存失败:%v;保留 thread_id=%s run_id=%s asset_ids=%v,后续使用 --team-id %s 查询,不要重复提交。\n", err, ref.ThreadID, ref.RunID, ref.AssetIDs, s.selected.TeamID)
152
174
  }
153
175
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pippit-dev/cli",
3
- "version": "1.0.44",
3
+ "version": "1.0.46",
4
4
  "description": "Pippit CLI",
5
5
  "bin": {
6
6
  "pippit-tool-cli": "scripts/run.js"
@@ -58,6 +58,8 @@ metadata:
58
58
  - 用户明确要求生成或处理,即可在该范围内执行;仅咨询用法、费用或方案时不提交。范围、必填信息或消耗 credits 的授权不明确时,先确认,不重复索要已给出的授权。
59
59
  - 提问优先使用宿主实际提供且当前模式允许的工具:Codex 的 `request_user_input` 或 `request_user_input_async`,WorkBuddy 的 `ask_user_question`;不可用时用普通聊天。需要答案时等待答复。
60
60
  - 素材参数接收本地文件路径,CLI 内部上传。远程链接不能冒充本地路径;缺少可访问文件时先解决素材获取。单文件必须小于 500 MB(500000000 字节)。
61
+ - `generate-image` / `generate-video` 上传素材失败时,向用户说明 CLI 返回的素材类型、类型内序号、文件名、原始原因和可用的 LogID,并说明尚未提交生成任务。序号按同类型参数的传入顺序从 1 开始,不是所有附件的混合序号;只有服务端明确返回审核拒绝时才说明审核未通过,不能把网络、权限等失败都归为审核问题。
62
+ - `generate-image` / `generate-video` 将所有参考素材按最多 10 个并发上传,等待本批上传结束后统一返回全部失败项;某个素材失败不会跳过其他素材。错误按图片、视频、音频分组及各自输入顺序列出,生成引用也保留输入顺序,不按上传完成先后排序。任一素材失败均不提交生成;向用户一次性列全失败素材,不只转述第一项。网络失败、取消或超时不代表审核通过。根据失败原因等待用户替换对应素材或解决问题,不自动删减素材、重传或重新提交生成。旧 CLI 如首错即停或未返回文件定位,须如实说明其结果范围,不凭图片内容猜测未检查素材的审核结论。
61
63
  - 提交成功后立即展示真实 `web_thread_link`;未返回链接时如实说明,保留任务 ID。后续查询和下载失败不能触发重复生成。
62
64
  - 每个最终图片/视频都通过宿主文件交付或媒体渲染能力展示为真实附件或可预览媒体。URL、路径列表仅作补充;详细完成标准见共用交付流程。
63
65
 
@@ -1,6 +0,0 @@
1
- 8bf338b7fc4bb3de289778896059f364f91743e7fe3127380e193f29bb10fecf pippit-tool-cli-1.0.44-darwin-amd64.tar.gz
2
- fd7aac74fda167481b76e3a7aa2f30e45271670cad11f0e969080db1b135411a pippit-tool-cli-1.0.44-darwin-arm64.tar.gz
3
- 526e3641047d7e997fb5c271d795337692b0a47c092622747f6e2b4e9d22e473 pippit-tool-cli-1.0.44-linux-amd64.tar.gz
4
- 6170436a3ec60dc2a2681e6f790c6a52d9daa4248ce8c3eede83136a62ccb4b6 pippit-tool-cli-1.0.44-linux-arm64.tar.gz
5
- d3c7166314bb52deae5e0caf383ac7e3cdde27c61325d6556de43f8f46aa9963 pippit-tool-cli-1.0.44-windows-amd64.zip
6
- 9cb3d470688cd89a42a2267d9d181159dd624937c9590d3b744db25a66edbac9 pippit-tool-cli-1.0.44-windows-arm64.zip