@pippit-dev/cli 1.0.31 → 1.0.33
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 +20 -9
- package/checksums.txt +6 -6
- package/cmd/generate_image/generate_image.go +4 -2
- package/cmd/generate_image_test.go +150 -1
- package/cmd/marketing.go +144 -0
- package/cmd/marketing_test.go +179 -0
- package/cmd/model.go +7 -10
- package/cmd/model_test.go +59 -1
- package/cmd/root.go +1 -0
- package/cmd/short_drama_test.go +2 -0
- package/cmd/source_test.go +6 -1
- package/dist/checksums.txt +6 -6
- package/internal/common/client.go +11 -1
- package/internal/common/ratio.go +12 -0
- package/internal/generate_image/generate_image.go +19 -7
- package/internal/generate_image/generate_image_test.go +57 -14
- package/internal/models/describe.go +79 -19
- package/internal/models/describe_test.go +131 -5
- package/internal/models/models.go +124 -22
- package/internal/models/models_test.go +158 -16
- package/package.json +1 -1
- package/skills/xyq-marketing-skill/SKILL.md +8 -6
- package/skills/xyq-marketing-skill/examples/product-video.md +1 -1
- package/skills/xyq-marketing-skill/references/api.md +2 -2
- package/skills/xyq-marketing-skill/scripts/marketing.js +76 -38
- package/skills/xyq-nest-skill/SKILL.md +2 -3
- package/skills/xyq-nest-skill/commands/generate-image.md +12 -7
- package/skills/xyq-nest-skill/commands/model.md +49 -7
- package/skills/xyq-nest-skill/examples/generate-and-deliver.md +4 -2
- package/skills/xyq-nest-skill/examples/image-edit.md +5 -3
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
npx skills add Pippit-dev/cli --skill xyq-marketing-skill
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
需要 Node.js 16
|
|
31
|
+
需要 Node.js 16+ 和支持 `marketing` 命令的 CLI。Skill 脚本通过原生 `marketing` 命令复用 CLI 登录态;先运行 `pippit-tool-cli status`,未登录时执行 `pippit-tool-cli login` 完成浏览器授权,无需用户提供 access_token 或 Access Key。生成请求通过 stdin 传入原生 CLI,凭据仅在 CLI 内用于鉴权。
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
34
|
# 从仓库根目录执行;营销请求字段见接口契约,默认只预览
|
|
@@ -51,7 +51,7 @@ node skills/xyq-marketing-skill/scripts/marketing.js query --thread-id THREAD_ID
|
|
|
51
51
|
| 登录授权 | `status` / `login` / `logout` | [授权](skills/xyq-nest-skill/commands/auth.md) |
|
|
52
52
|
| 个人 Canvas 画布与节点编辑 | `canvas` | [画布](skills/xyq-nest-skill/commands/canvas.md) |
|
|
53
53
|
| 生图、参考图编辑 | `generate-image` | [图片](skills/xyq-nest-skill/commands/generate-image.md) |
|
|
54
|
-
|
|
|
54
|
+
| 查看可用图片/视频模型、参数配置 | `model list` / `model describe` | [模型发现](skills/xyq-nest-skill/commands/model.md) |
|
|
55
55
|
| 生视频、首尾帧 | `generate-video` | [视频](skills/xyq-nest-skill/commands/generate-video.md) |
|
|
56
56
|
| 视频超分 | `video-super-resolution` | [超分](skills/xyq-nest-skill/commands/video-super-resolution.md) |
|
|
57
57
|
| 擦字幕 | `erase-video-subtitle` | [擦字幕](skills/xyq-nest-skill/commands/erase-video-subtitle.md) |
|
|
@@ -205,20 +205,22 @@ pippit-tool-cli canvas command run xyq.generation.update_prompt \
|
|
|
205
205
|
|
|
206
206
|
## 生图 CLI
|
|
207
207
|
|
|
208
|
-
`generate-image`
|
|
208
|
+
`generate-image` 使用图片模型的展示名称;先查询当前列表并选择模型,再上传本地参考图片并提交生图请求。以下假设列表包含该名称:
|
|
209
209
|
|
|
210
210
|
```bash
|
|
211
211
|
pippit-tool-cli generate-image \
|
|
212
212
|
--prompt "生成一张小猫海报" \
|
|
213
213
|
--image "~/images/cat.png" \
|
|
214
|
-
--model "
|
|
214
|
+
--model "智能图片V2.5 Fast" \
|
|
215
215
|
--ratio 6 \
|
|
216
216
|
--generate-image-count 2
|
|
217
217
|
```
|
|
218
218
|
|
|
219
|
-
命令输出 `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`
|
|
219
|
+
命令输出 `thread_id`、`run_id` 和 `web_thread_link`。提交 HTTP 请求时,`agent_name` 固定为 `pippit_nest_agent`,参考图会使用上传接口返回的 `pippit_asset_id` 写入顶层 `asset_ids`,生图模型写入 `general_agent_settings.image_model`,比例写入 `general_agent_settings.ratio`,生图数量写入 `general_agent_settings.generate_image_count`。`--model` 为必填参数,填写 `model list --type image` 返回的完整名称,例如 `"智能图片V2.5 Fast"`。CLI 在上传素材前查询或复用有效缓存,解析名称后仅在提交请求中填写服务端模型标识;名称缺失、重复或未找到时停止,不猜测模型。
|
|
220
220
|
|
|
221
|
-
|
|
221
|
+
图片 `model list/search/describe` 返回给宿主的是 API 下发的展示名称 `name`,例如 `美学模型 8.2`。生成时填写 `--model "美学模型 8.2"`,CLI 仅在提交请求中使用该条目对应的 `key`。名称、可用模型和参数均由接口动态提供,不维护静态名称映射。
|
|
222
|
+
|
|
223
|
+
`--ratio` 可选,只接受服务端 `Ratio` 数字枚举,例如 `--ratio 3` 表示 `9:16`。通过 `model describe "模型名称" --type image` 查看当前可用的数字 `options/default`,`option_labels` 说明每个数字对应的比例。CLI 校验整数格式,模型是否支持该枚举由服务端决定。常用枚举值含义如下:
|
|
222
224
|
|
|
223
225
|
| ratio 参数 | IDL 枚举 | 含义 |
|
|
224
226
|
| ---: | --- | --- |
|
|
@@ -232,18 +234,27 @@ pippit-tool-cli generate-image \
|
|
|
232
234
|
|
|
233
235
|
`--generate-image-count` 可选,填写生图数量,对应 IDL 字段 `GeneralSettingsPart.GenerateImageCount` / JSON 字段 `generate_image_count`。CLI 只校验不能为负数;具体数量范围由服务端决定。
|
|
234
236
|
|
|
235
|
-
|
|
237
|
+
`--resolution` 写入 `general_agent_settings.resolution`(转大写),新增 `--effort` 写入 `general_agent_settings.image_effort`(转小写)。分辨率和推理强度选项都来自该模型的动态配置;没有 `effort` 维度的模型不展示推理强度选择。只传用户指定的参数,未指定时省略,不自动补查询默认值。
|
|
238
|
+
|
|
239
|
+
图片支持 `.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.webp`、`.svg`。CLI 会在提交前校验 prompt、model 必填、ratio 格式、generate-image-count 非负和文件后缀;不新增模型或参数组合白名单。
|
|
236
240
|
|
|
237
|
-
##
|
|
241
|
+
## 图片与视频模型发现
|
|
238
242
|
|
|
239
243
|
```bash
|
|
244
|
+
# 图片模型及可选参数
|
|
245
|
+
pippit-tool-cli model list --type image
|
|
246
|
+
pippit-tool-cli model search "智能图片" --type image
|
|
247
|
+
pippit-tool-cli model describe "智能图片V2.5 Fast" --type image
|
|
248
|
+
pippit-tool-cli model list --type image --refresh
|
|
249
|
+
|
|
250
|
+
# 省略 --type 保持查询视频
|
|
240
251
|
pippit-tool-cli model list
|
|
241
252
|
pippit-tool-cli model search MiniMax
|
|
242
253
|
pippit-tool-cli model describe MiniMax-H3
|
|
243
254
|
pippit-tool-cli model list --refresh
|
|
244
255
|
```
|
|
245
256
|
|
|
246
|
-
|
|
257
|
+
使用当前个人登录凭证查询服务端 Skill 模型接口;图片 scene 为 `web_image_agent`,视频为 `web_turbo_video_generator`,不传 TeamID。成功结果按账号、环境和场景隔离缓存 5 分钟,`--refresh` 强制刷新。失败时提示重试,不回退静态列表或过期缓存。`describe` 提供可直接传给生成命令的参数:图片比例保留数字枚举并附比例说明,视频比例转换为字符串,同时整理分辨率、图片推理强度、视频时长和素材限制;不展示内部 `config_key`,未知比例枚举跳过。图片列表和详情仅用 `name` 标识模型,不展示底层模型枚举;搜索、详情查询和 `generate-image --model` 均使用名称,包含空格时加引号。图片详情保留原始 `parameter_config`,包括条件、必选标记、选项说明和参数组合约束。生成仍由服务端校验;服务端尚未开放图片场景或提交参数时会返回错误,不回退静态模型清单。详见 [模型发现](skills/xyq-nest-skill/commands/model.md)。
|
|
247
258
|
|
|
248
259
|
## 生视频 CLI
|
|
249
260
|
|
package/checksums.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
dd742dedf06e2645d02bc13a4cfa1af45778ae0200a8a6e67df177ba7e069775 pippit-tool-cli-1.0.33-darwin-amd64.tar.gz
|
|
2
|
+
8d9fc0112650839c430e58cb7d6917e5bb1b95fde49659aecbbf14d1421c28f5 pippit-tool-cli-1.0.33-darwin-arm64.tar.gz
|
|
3
|
+
f2f2f741c920b115b2a1a9c4c4f074c1a07fd6cbc3a442cf4dc4d793c1b9ef5a pippit-tool-cli-1.0.33-linux-amd64.tar.gz
|
|
4
|
+
2ed6e791c7dbea0e2ea8a1d05a0db298442218ecfc17ec47d70d49639f1d91b6 pippit-tool-cli-1.0.33-linux-arm64.tar.gz
|
|
5
|
+
f713c05325043f177f4a9e01a433b6a7fc8f57fea123f712ee459707955d199e pippit-tool-cli-1.0.33-windows-amd64.zip
|
|
6
|
+
7e637f67fbbb818db0e66c49f7814d7e79cd71857d8c3bd6a5397af9d18533ce pippit-tool-cli-1.0.33-windows-arm64.zip
|
|
@@ -34,6 +34,7 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command
|
|
|
34
34
|
"model": strings.TrimSpace(opts.Model),
|
|
35
35
|
"ratio": strings.TrimSpace(opts.Ratio),
|
|
36
36
|
"resolution": strings.ToUpper(strings.TrimSpace(opts.Resolution)),
|
|
37
|
+
"effort": strings.ToLower(strings.TrimSpace(opts.Effort)),
|
|
37
38
|
"generate_image_count": optionalIntString(opts.GenerateImageCount),
|
|
38
39
|
})
|
|
39
40
|
return err
|
|
@@ -47,9 +48,10 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command
|
|
|
47
48
|
flags.StringVar(&opts.Source, "source", "", "optional host agent/platform identifier for statistics only; filled silently by the host agent (e.g. doubao_office, workbuddy, codex)")
|
|
48
49
|
flags.StringVar(&opts.Prompt, "prompt", "", "image generation prompt")
|
|
49
50
|
flags.StringArrayVar(&opts.ImagePaths, "image", nil, "local reference image path; repeat for multiple images")
|
|
50
|
-
flags.StringVar(&opts.Model, "model", "", "image model
|
|
51
|
+
flags.StringVar(&opts.Model, "model", "", "exact image model name from 'model list --type image' (quote names containing spaces)")
|
|
51
52
|
flags.StringVar(&opts.Ratio, "ratio", "", "image ratio; "+internalgen.SupportedRatioUsage())
|
|
52
|
-
flags.StringVar(&opts.Resolution, "resolution", "", "image resolution;
|
|
53
|
+
flags.StringVar(&opts.Resolution, "resolution", "", "image resolution; use 'model describe \"MODEL_NAME\" --type image' for available values")
|
|
54
|
+
flags.StringVar(&opts.Effort, "effort", "", "image reasoning effort; only use values exposed by 'model describe \"MODEL_NAME\" --type image'")
|
|
53
55
|
flags.IntVar(&generateImageCount, "generate-image-count", 0, "generated image count")
|
|
54
56
|
return cmd
|
|
55
57
|
}
|
|
@@ -2,6 +2,7 @@ package cmd
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"bytes"
|
|
5
|
+
"encoding/json"
|
|
5
6
|
"io"
|
|
6
7
|
"net/http"
|
|
7
8
|
"net/http/httptest"
|
|
@@ -10,18 +11,29 @@ import (
|
|
|
10
11
|
"strings"
|
|
11
12
|
"testing"
|
|
12
13
|
|
|
14
|
+
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
13
15
|
"github.com/bytedance/sonic"
|
|
14
16
|
)
|
|
15
17
|
|
|
16
18
|
func TestGenerateImage(t *testing.T) {
|
|
19
|
+
t.Setenv("HOME", t.TempDir())
|
|
20
|
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
|
21
|
+
t.Setenv("LocalAppData", t.TempDir())
|
|
17
22
|
var uploaded bool
|
|
23
|
+
var modelQueried bool
|
|
18
24
|
|
|
19
25
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
20
26
|
if r.Header.Get("Authorization") != "Bearer test-token" {
|
|
21
27
|
t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
|
|
22
28
|
}
|
|
23
29
|
switch r.URL.Path {
|
|
30
|
+
case "/api/biz/v1/skill/get_available_model_list":
|
|
31
|
+
modelQueried = true
|
|
32
|
+
_, _ = 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"}]}}}`))
|
|
24
33
|
case "/api/biz/v1/skill/upload_file":
|
|
34
|
+
if !modelQueried {
|
|
35
|
+
t.Fatal("image name must be resolved before uploading")
|
|
36
|
+
}
|
|
25
37
|
if r.Method != http.MethodPost {
|
|
26
38
|
t.Fatalf("upload method = %s, want POST", r.Method)
|
|
27
39
|
}
|
|
@@ -100,7 +112,7 @@ func TestGenerateImage(t *testing.T) {
|
|
|
100
112
|
"generate-image",
|
|
101
113
|
"--prompt", "生成小猫海报",
|
|
102
114
|
"--image", image,
|
|
103
|
-
"--model", "
|
|
115
|
+
"--model", "Seedream 5.0 Pro",
|
|
104
116
|
"--ratio", "6",
|
|
105
117
|
"--resolution", "4K",
|
|
106
118
|
"--generate-image-count", "2",
|
|
@@ -137,3 +149,140 @@ func TestGenerateImageRequiresModel(t *testing.T) {
|
|
|
137
149
|
t.Fatalf("error = %q, want model validation", err)
|
|
138
150
|
}
|
|
139
151
|
}
|
|
152
|
+
|
|
153
|
+
func TestGenerateImageDynamicParameters(t *testing.T) {
|
|
154
|
+
t.Setenv("HOME", t.TempDir())
|
|
155
|
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
|
156
|
+
t.Setenv("LocalAppData", t.TempDir())
|
|
157
|
+
requests := 0
|
|
158
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
159
|
+
requests++
|
|
160
|
+
if r.URL.Path == "/api/biz/v1/skill/get_available_model_list" {
|
|
161
|
+
_, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-key","config":{"models":[{"key":"future-image-model","name":"未来图片模型","kind":"image"}]}}}`))
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
if r.URL.Path != "/api/biz/v1/skill/submit_run" {
|
|
165
|
+
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
166
|
+
}
|
|
167
|
+
raw, err := io.ReadAll(r.Body)
|
|
168
|
+
if err != nil {
|
|
169
|
+
t.Error(err)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
var body struct {
|
|
173
|
+
AgentName string `json:"agent_name"`
|
|
174
|
+
Settings struct {
|
|
175
|
+
Model string `json:"image_model"`
|
|
176
|
+
Ratio int `json:"ratio"`
|
|
177
|
+
Resolution string `json:"resolution"`
|
|
178
|
+
Effort string `json:"image_effort"`
|
|
179
|
+
} `json:"general_agent_settings"`
|
|
180
|
+
}
|
|
181
|
+
if err := sonic.Unmarshal(raw, &body); err != nil {
|
|
182
|
+
t.Error(err)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
if body.AgentName != "pippit_nest_agent" || body.Settings.Model != "future-image-model" || body.Settings.Ratio != 13 || body.Settings.Resolution != "8K" || body.Settings.Effort != "future-effort" {
|
|
186
|
+
t.Errorf("incorrect request mapping: %s", raw)
|
|
187
|
+
}
|
|
188
|
+
_, _ = w.Write([]byte(`{"ret":"0","data":{"run":{"thread_id":"image-thread","run_id":"image-run"}}}`))
|
|
189
|
+
}))
|
|
190
|
+
defer server.Close()
|
|
191
|
+
var stdout, stderr bytes.Buffer
|
|
192
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
193
|
+
root.SetArgs([]string{"generate-image", "--prompt", "image", "--model", "未来图片模型", "--ratio", "13", "--resolution", "8k", "--effort", " FUTURE-EFFORT "})
|
|
194
|
+
if err := root.Execute(); err != nil || requests != 2 {
|
|
195
|
+
t.Fatalf("Execute: err=%v requests=%d", err, requests)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
func TestImageModelNameFromDiscoveryToSubmission(t *testing.T) {
|
|
200
|
+
t.Setenv("HOME", t.TempDir())
|
|
201
|
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
|
202
|
+
t.Setenv("LocalAppData", t.TempDir())
|
|
203
|
+
queries, submits := 0, 0
|
|
204
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
205
|
+
switch r.URL.Path {
|
|
206
|
+
case config.GetAvailableModelListPath:
|
|
207
|
+
queries++
|
|
208
|
+
_, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-key","config":{"models":[{"key":"ali_midjourney_8_2","name":"美学模型 8.2","benefit_map_by_combination":{"ali_midjourney_8_2":{"resource_id":"ali_midjourney_8_2"}},"kind":"image"}]}}}`))
|
|
209
|
+
case config.SubmitRunPath:
|
|
210
|
+
submits++
|
|
211
|
+
var body struct {
|
|
212
|
+
Settings map[string]json.RawMessage `json:"general_agent_settings"`
|
|
213
|
+
}
|
|
214
|
+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
215
|
+
t.Error(err)
|
|
216
|
+
}
|
|
217
|
+
if string(body.Settings["image_model"]) != `"ali_midjourney_8_2"` || len(body.Settings) != 1 {
|
|
218
|
+
t.Errorf("wire model not resolved or unrequested defaults filled: %v", body.Settings)
|
|
219
|
+
}
|
|
220
|
+
_, _ = w.Write([]byte(`{"ret":"1","errmsg":"ali_midjourney_8_2 暂时不可用","log_id":"image-log"}`))
|
|
221
|
+
default:
|
|
222
|
+
t.Errorf("unexpected request: %s", r.URL.Path)
|
|
223
|
+
}
|
|
224
|
+
}))
|
|
225
|
+
defer server.Close()
|
|
226
|
+
execute := func(args ...string) (string, string, error) {
|
|
227
|
+
var stdout, stderr bytes.Buffer
|
|
228
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
229
|
+
root.SetArgs(args)
|
|
230
|
+
err := root.Execute()
|
|
231
|
+
return stdout.String(), stderr.String(), err
|
|
232
|
+
}
|
|
233
|
+
list, _, err := execute("model", "list", "--type", "image")
|
|
234
|
+
if err != nil {
|
|
235
|
+
t.Fatal(err)
|
|
236
|
+
}
|
|
237
|
+
var output struct{ Models []struct{ Name string } }
|
|
238
|
+
if err := json.Unmarshal([]byte(list), &output); err != nil || len(output.Models) != 1 {
|
|
239
|
+
t.Fatalf("list=%s err=%v", list, err)
|
|
240
|
+
}
|
|
241
|
+
name := output.Models[0].Name
|
|
242
|
+
if name != "美学模型 8.2" {
|
|
243
|
+
t.Fatalf("unexpected display name: %q", name)
|
|
244
|
+
}
|
|
245
|
+
detail, _, err := execute("model", "describe", name, "--type", "image")
|
|
246
|
+
if err != nil || !strings.Contains(detail, name) || strings.Contains(list+detail, "ali_midjourney_8_2") {
|
|
247
|
+
t.Fatalf("user-facing identity changed: list=%s detail=%s err=%v", list, detail, err)
|
|
248
|
+
}
|
|
249
|
+
stdout, stderr, err := execute("generate-image", "--prompt", "一张猫咪图片", "--model", name)
|
|
250
|
+
if err == nil || !strings.Contains(err.Error(), name) || !strings.Contains(err.Error(), "image-log") || strings.Contains(stdout+stderr+err.Error(), "ali_midjourney_8_2") {
|
|
251
|
+
t.Fatalf("server rejection must keep name and log ID: stdout=%s stderr=%s err=%v", stdout, stderr, err)
|
|
252
|
+
}
|
|
253
|
+
if queries != 1 || submits != 1 {
|
|
254
|
+
t.Fatalf("expected shared cache and no submit retry: queries=%d submits=%d", queries, submits)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
func TestImageNameFailureStopsBeforeUploadAndSubmit(t *testing.T) {
|
|
259
|
+
for _, tc := range []struct{ name, selection, models string }{
|
|
260
|
+
{"unknown name", "不存在", `[{"key":"private-image","name":"图片模型","kind":"image"}]`},
|
|
261
|
+
{"wire key", "private-image", `[{"key":"private-image","name":"图片模型","kind":"image"}]`},
|
|
262
|
+
{"missing name", "图片模型", `[{"key":"private-image","kind":"image"}]`},
|
|
263
|
+
{"duplicate name", "图片模型", `[{"key":"private-image","name":"图片模型","kind":"image"},{"key":"private-image-2","name":"图片模型","kind":"image"}]`},
|
|
264
|
+
{"empty list", "图片模型", `[]`},
|
|
265
|
+
} {
|
|
266
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
267
|
+
t.Setenv("HOME", t.TempDir())
|
|
268
|
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
|
269
|
+
t.Setenv("LocalAppData", t.TempDir())
|
|
270
|
+
requests := 0
|
|
271
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
272
|
+
requests++
|
|
273
|
+
if r.URL.Path != config.GetAvailableModelListPath {
|
|
274
|
+
t.Errorf("must not upload or submit: %s", r.URL.Path)
|
|
275
|
+
}
|
|
276
|
+
_, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-key","config":{"models":` + tc.models + `}}}`))
|
|
277
|
+
}))
|
|
278
|
+
defer server.Close()
|
|
279
|
+
var stdout, stderr bytes.Buffer
|
|
280
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
281
|
+
root.SetArgs([]string{"generate-image", "--prompt", "image", "--model", tc.selection, "--image", "/missing.png"})
|
|
282
|
+
err := root.Execute()
|
|
283
|
+
if err == nil || requests != 1 || strings.Contains(stdout.String()+stderr.String()+err.Error(), "private-image") {
|
|
284
|
+
t.Fatalf("invalid name accepted or exposed: requests=%d stdout=%s stderr=%s err=%v", requests, stdout.String(), stderr.String(), err)
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
}
|
package/cmd/marketing.go
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"mime"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"strings"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
15
|
+
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
16
|
+
"github.com/spf13/cobra"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
var marketingPaths = map[string]string{
|
|
20
|
+
"generate": "/api/biz/v1/agent/submit_marketing_run",
|
|
21
|
+
"query": "/api/biz/v1/agent/query_generate_video_result",
|
|
22
|
+
"upload": config.UploadFilePath,
|
|
23
|
+
"balance": config.GetCreditBalancePath,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Marketing uses the same AuthManager as login/status and all other commands.
|
|
27
|
+
// Only fixed public endpoints are exposed; credentials never leave the CLI.
|
|
28
|
+
func newMarketingCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
|
|
29
|
+
root := &cobra.Command{Use: "marketing", Short: "Marketing API using the shared CLI login"}
|
|
30
|
+
root.SetOut(stdout)
|
|
31
|
+
root.SetErr(stderr)
|
|
32
|
+
for _, action := range []string{"generate", "query", "upload", "balance"} {
|
|
33
|
+
root.AddCommand(newMarketingAction(action, stdout, stderr, runner))
|
|
34
|
+
}
|
|
35
|
+
return root
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func newMarketingAction(action string, stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
|
|
39
|
+
var requestFile, file, threadID, runID string
|
|
40
|
+
var execute bool
|
|
41
|
+
var timeout time.Duration
|
|
42
|
+
command := &cobra.Command{Use: action, Args: cobra.NoArgs, Short: "Call marketing " + action}
|
|
43
|
+
command.SetOut(stdout)
|
|
44
|
+
command.SetErr(stderr)
|
|
45
|
+
command.Flags().DurationVar(&timeout, "timeout", 60*time.Second, "request deadline (e.g. 60s)")
|
|
46
|
+
switch action {
|
|
47
|
+
case "generate":
|
|
48
|
+
command.Flags().StringVar(&requestFile, "request", "", "request JSON file, or - for stdin")
|
|
49
|
+
command.Flags().BoolVar(&execute, "execute", false, "submit generation; otherwise preview only")
|
|
50
|
+
case "query":
|
|
51
|
+
command.Flags().StringVar(&threadID, "thread-id", "", "marketing thread ID")
|
|
52
|
+
command.Flags().StringVar(&runID, "run-id", "", "marketing run ID")
|
|
53
|
+
case "upload":
|
|
54
|
+
command.Flags().StringVar(&file, "file", "", "local media file")
|
|
55
|
+
}
|
|
56
|
+
command.RunE = func(cmd *cobra.Command, _ []string) error {
|
|
57
|
+
if timeout <= 0 || timeout > 30*time.Minute {
|
|
58
|
+
return fmt.Errorf("timeout 必须大于 0 且不超过 30m")
|
|
59
|
+
}
|
|
60
|
+
var body any = map[string]any{}
|
|
61
|
+
switch action {
|
|
62
|
+
case "generate":
|
|
63
|
+
if requestFile == "" {
|
|
64
|
+
return fmt.Errorf("缺少必填参数 --request")
|
|
65
|
+
}
|
|
66
|
+
reader := cmd.InOrStdin()
|
|
67
|
+
if requestFile != "-" {
|
|
68
|
+
f, err := os.Open(requestFile)
|
|
69
|
+
if err != nil {
|
|
70
|
+
return err
|
|
71
|
+
}
|
|
72
|
+
defer f.Close()
|
|
73
|
+
reader = f
|
|
74
|
+
}
|
|
75
|
+
var value map[string]json.RawMessage
|
|
76
|
+
decoder := json.NewDecoder(io.LimitReader(reader, 8*1024*1024+1))
|
|
77
|
+
if err := decoder.Decode(&value); err != nil {
|
|
78
|
+
return fmt.Errorf("请求 JSON 无效: %w", err)
|
|
79
|
+
}
|
|
80
|
+
var extra any
|
|
81
|
+
if err := decoder.Decode(&extra); err != io.EOF {
|
|
82
|
+
return fmt.Errorf("请求只能包含一个 JSON 对象")
|
|
83
|
+
}
|
|
84
|
+
for key := range value {
|
|
85
|
+
if key != "message" && key != "asset_ids" && key != "thread_id" && key != "general_agent_settings" {
|
|
86
|
+
return fmt.Errorf("未支持的营销请求字段: %s", key)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
var message string
|
|
90
|
+
var settings struct {
|
|
91
|
+
VideoModel string `json:"video_model"`
|
|
92
|
+
}
|
|
93
|
+
if json.Unmarshal(value["message"], &message) != nil || strings.TrimSpace(message) == "" {
|
|
94
|
+
return fmt.Errorf("message 必须为非空字符串")
|
|
95
|
+
}
|
|
96
|
+
if json.Unmarshal(value["general_agent_settings"], &settings) != nil || strings.TrimSpace(settings.VideoModel) == "" {
|
|
97
|
+
return fmt.Errorf("general_agent_settings.video_model 必填")
|
|
98
|
+
}
|
|
99
|
+
body = value
|
|
100
|
+
if !execute {
|
|
101
|
+
return common.WriteJSON(stdout, map[string]any{"dry_run": true, "url": config.DefaultBaseURL + marketingPaths[action], "body": body})
|
|
102
|
+
}
|
|
103
|
+
case "query":
|
|
104
|
+
if strings.TrimSpace(threadID) == "" || strings.TrimSpace(runID) == "" {
|
|
105
|
+
return fmt.Errorf("query 需要 --thread-id 和 --run-id")
|
|
106
|
+
}
|
|
107
|
+
body = map[string]string{"thread_id": threadID, "run_id": runID}
|
|
108
|
+
case "upload":
|
|
109
|
+
if err := validateMediaUpload(file); err != nil {
|
|
110
|
+
return err
|
|
111
|
+
}
|
|
112
|
+
info, err := os.Stat(file)
|
|
113
|
+
if err != nil {
|
|
114
|
+
return err
|
|
115
|
+
}
|
|
116
|
+
if info.Size() == 0 {
|
|
117
|
+
return fmt.Errorf("上传需要非空文件")
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
|
|
121
|
+
defer cancel()
|
|
122
|
+
client := common.NewNonRedirectingHTTPClient(runner.Config.BaseURL, timeout, newRunnerAuthorizer(runner))
|
|
123
|
+
var result map[string]json.RawMessage
|
|
124
|
+
var err error
|
|
125
|
+
if action == "upload" {
|
|
126
|
+
contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(file)))
|
|
127
|
+
err = client.SendMultipartRequest(ctx, marketingPaths[action], nil, common.MultipartFile{FieldName: "file", Path: file, ContentType: contentType}, &result)
|
|
128
|
+
} else {
|
|
129
|
+
err = client.SendRequest(ctx, marketingPaths[action], body, &result)
|
|
130
|
+
}
|
|
131
|
+
if err != nil {
|
|
132
|
+
return err
|
|
133
|
+
}
|
|
134
|
+
ret := strings.TrimSpace(string(result["ret"]))
|
|
135
|
+
if ret != `"0"` && ret != "0" {
|
|
136
|
+
var message, logID string
|
|
137
|
+
_ = json.Unmarshal(result["errmsg"], &message)
|
|
138
|
+
_ = json.Unmarshal(result["log_id"], &logID)
|
|
139
|
+
return common.NewLogIDError(fmt.Sprintf("营销 API 失败: ret=%s errmsg=%s", ret, message), logID)
|
|
140
|
+
}
|
|
141
|
+
return common.WriteJSON(stdout, result)
|
|
142
|
+
}
|
|
143
|
+
return command
|
|
144
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/http/httptest"
|
|
10
|
+
"os"
|
|
11
|
+
"path/filepath"
|
|
12
|
+
"strings"
|
|
13
|
+
"testing"
|
|
14
|
+
"time"
|
|
15
|
+
|
|
16
|
+
"github.com/Pippit-dev/pippit-cli/internal/auth"
|
|
17
|
+
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
type marketingCredentialStore struct {
|
|
21
|
+
auth.CredentialStore
|
|
22
|
+
credential *auth.Credential
|
|
23
|
+
loads int
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func (s *marketingCredentialStore) Load(context.Context) (*auth.Credential, error) {
|
|
27
|
+
s.loads++
|
|
28
|
+
if s.credential == nil {
|
|
29
|
+
return nil, auth.ErrCredentialNotFound
|
|
30
|
+
}
|
|
31
|
+
return s.credential, nil
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const marketingRequest = `{"message":"make an ad","general_agent_settings":{"video_model":"chosen-model","show_subtitle":false}}`
|
|
35
|
+
|
|
36
|
+
func TestMarketingUsesSharedBrowserAuth(t *testing.T) {
|
|
37
|
+
for _, action := range []string{"generate", "query", "upload", "balance"} {
|
|
38
|
+
t.Run(action, func(t *testing.T) {
|
|
39
|
+
calls := 0
|
|
40
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
41
|
+
calls++
|
|
42
|
+
if r.Header.Get("Authorization") != "Bearer browser-secret" {
|
|
43
|
+
t.Error("browser login not reused")
|
|
44
|
+
}
|
|
45
|
+
if r.URL.Path != marketingPaths[action] || r.Method != "POST" {
|
|
46
|
+
t.Error("wrong endpoint")
|
|
47
|
+
}
|
|
48
|
+
data, _ := io.ReadAll(r.Body)
|
|
49
|
+
if bytes.Contains(data, []byte("browser-secret")) {
|
|
50
|
+
t.Error("credential leaked into payload")
|
|
51
|
+
}
|
|
52
|
+
if action == "upload" && !bytes.Contains(data, []byte(`name="file"`)) {
|
|
53
|
+
t.Error("missing multipart file")
|
|
54
|
+
}
|
|
55
|
+
fmt.Fprint(w, `{"ret":"0","log_id":"log-test","data":{"thread_id":"thread","run_id":"run"}}`)
|
|
56
|
+
}))
|
|
57
|
+
defer server.Close()
|
|
58
|
+
cfg := config.Load()
|
|
59
|
+
cfg.BaseURL = server.URL
|
|
60
|
+
cfg.AccessKey = ""
|
|
61
|
+
store := &marketingCredentialStore{credential: &auth.Credential{AccessKey: "browser-secret", UID: "user", DeviceID: "device", ExpiredAt: time.Now().Add(time.Hour).Unix()}}
|
|
62
|
+
runner := newRootRunner(cfg)
|
|
63
|
+
runner.Auth = auth.NewManager(cfg, auth.WithCredentialStore(store))
|
|
64
|
+
var output bytes.Buffer
|
|
65
|
+
root := newRootCommand(&output, io.Discard, runner)
|
|
66
|
+
args := []string{"marketing", action}
|
|
67
|
+
switch action {
|
|
68
|
+
case "generate":
|
|
69
|
+
args = append(args, "--request", "-", "--execute")
|
|
70
|
+
root.SetIn(strings.NewReader(marketingRequest))
|
|
71
|
+
case "query":
|
|
72
|
+
args = append(args, "--thread-id", "thread", "--run-id", "run")
|
|
73
|
+
case "upload":
|
|
74
|
+
file := filepath.Join(t.TempDir(), "product.png")
|
|
75
|
+
if err := os.WriteFile(file, []byte("image"), 0600); err != nil {
|
|
76
|
+
t.Fatal(err)
|
|
77
|
+
}
|
|
78
|
+
args = append(args, "--file", file)
|
|
79
|
+
}
|
|
80
|
+
root.SetArgs(args)
|
|
81
|
+
if err := root.Execute(); err != nil {
|
|
82
|
+
t.Fatal(err)
|
|
83
|
+
}
|
|
84
|
+
if calls != 1 || store.loads != 1 {
|
|
85
|
+
t.Fatalf("HTTP calls=%d credential loads=%d", calls, store.loads)
|
|
86
|
+
}
|
|
87
|
+
if strings.Contains(output.String(), "browser-secret") || !strings.Contains(output.String(), "log-test") {
|
|
88
|
+
t.Fatal("raw result or credential boundary broken")
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func TestMarketingAuthFailureAndEnvironmentPrecedence(t *testing.T) {
|
|
95
|
+
for _, scenario := range []string{"missing", "expired", "override"} {
|
|
96
|
+
t.Run(scenario, func(t *testing.T) {
|
|
97
|
+
calls := 0
|
|
98
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
99
|
+
calls++
|
|
100
|
+
if r.Header.Get("Authorization") != "Bearer explicit-key" {
|
|
101
|
+
t.Error("environment override not preserved")
|
|
102
|
+
}
|
|
103
|
+
fmt.Fprint(w, `{"ret":"0","data":{}}`)
|
|
104
|
+
}))
|
|
105
|
+
defer server.Close()
|
|
106
|
+
cfg := config.Load()
|
|
107
|
+
cfg.BaseURL = server.URL
|
|
108
|
+
cfg.AccessKey = ""
|
|
109
|
+
store := &marketingCredentialStore{}
|
|
110
|
+
if scenario == "expired" {
|
|
111
|
+
store.credential = &auth.Credential{AccessKey: "expired", ExpiredAt: 1}
|
|
112
|
+
}
|
|
113
|
+
if scenario == "override" {
|
|
114
|
+
cfg.AccessKey = "explicit-key"
|
|
115
|
+
}
|
|
116
|
+
runner := newRootRunner(cfg)
|
|
117
|
+
runner.Auth = auth.NewManager(cfg, auth.WithCredentialStore(store))
|
|
118
|
+
root := newRootCommand(io.Discard, io.Discard, runner)
|
|
119
|
+
root.SetArgs([]string{"marketing", "balance"})
|
|
120
|
+
err := root.Execute()
|
|
121
|
+
if scenario == "override" {
|
|
122
|
+
if err != nil || calls != 1 || store.loads != 0 {
|
|
123
|
+
t.Fatalf("override: err=%v calls=%d loads=%d", err, calls, store.loads)
|
|
124
|
+
}
|
|
125
|
+
} else if err == nil || !strings.Contains(err.Error(), "pippit-tool-cli login") || calls != 0 {
|
|
126
|
+
t.Fatalf("missing/expired: err=%v calls=%d", err, calls)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
func TestMarketingPreviewAndHelpDoNotReadCredentials(t *testing.T) {
|
|
133
|
+
for _, args := range [][]string{{"marketing", "--help"}, {"marketing", "generate", "--request", "-"}, {"marketing", "query"}} {
|
|
134
|
+
cfg := config.Load()
|
|
135
|
+
cfg.AccessKey = ""
|
|
136
|
+
store := &marketingCredentialStore{}
|
|
137
|
+
runner := newRootRunner(cfg)
|
|
138
|
+
runner.Auth = auth.NewManager(cfg, auth.WithCredentialStore(store))
|
|
139
|
+
root := newRootCommand(io.Discard, io.Discard, runner)
|
|
140
|
+
root.SetIn(strings.NewReader(marketingRequest))
|
|
141
|
+
root.SetArgs(args)
|
|
142
|
+
err := root.Execute()
|
|
143
|
+
if args[1] != "query" && err != nil {
|
|
144
|
+
t.Fatal(err)
|
|
145
|
+
}
|
|
146
|
+
if args[1] == "query" && err == nil {
|
|
147
|
+
t.Fatal("missing IDs accepted")
|
|
148
|
+
}
|
|
149
|
+
if store.loads != 0 {
|
|
150
|
+
t.Fatal("offline operation accessed credentials")
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
func TestMarketingDoesNotReplaySubmission(t *testing.T) {
|
|
156
|
+
for _, status := range []int{302, 307, 504} {
|
|
157
|
+
t.Run(fmt.Sprint(status), func(t *testing.T) {
|
|
158
|
+
calls := 0
|
|
159
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
160
|
+
calls++
|
|
161
|
+
w.Header().Set("Location", "/unexpected")
|
|
162
|
+
w.WriteHeader(status)
|
|
163
|
+
}))
|
|
164
|
+
defer server.Close()
|
|
165
|
+
cfg := config.Load()
|
|
166
|
+
cfg.BaseURL = server.URL
|
|
167
|
+
cfg.AccessKey = "test-key"
|
|
168
|
+
root := newRootCommand(io.Discard, io.Discard, newRootRunner(cfg))
|
|
169
|
+
root.SetIn(strings.NewReader(marketingRequest))
|
|
170
|
+
root.SetArgs([]string{"marketing", "generate", "--request", "-", "--execute"})
|
|
171
|
+
if err := root.Execute(); err == nil {
|
|
172
|
+
t.Fatal("failed/redirected request succeeded")
|
|
173
|
+
}
|
|
174
|
+
if calls != 1 {
|
|
175
|
+
t.Fatalf("submission replayed %d times", calls)
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
}
|