@pippit-dev/cli 0.0.19 → 0.0.21

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
@@ -1,11 +1,195 @@
1
1
  # pippit-tool-cli
2
2
 
3
- Minimal demo CLI for Pippit workflows.
3
+ 面向 Pippit / 小云雀工作流的命令行工具与智能体技能集合。
4
4
 
5
- ## Short Drama Run Demo
5
+ ## 技能列表
6
6
 
7
- Install from npm after the package is published. The installer downloads the
8
- matching prebuilt binary for macOS, Linux, or Windows:
7
+ 本仓库在 `skills/` 目录下包含两个智能体技能:
8
+
9
+ | 技能 | 说明 | 路径 |
10
+ |-------|-------------|------|
11
+ | `pippit-short-drama-skill` | 短剧工作流技能,支持提交创作任务、上传参考文件、查询进度、列出会话文件和下载产物。 | `skills/short-drama/` |
12
+ | `xyq-nest-skill` | 通用 NestAgent 技能,支持图片/视频生成、编辑、文件上传、进度查询和结果下载。 | `skills/xyq-nest-skill/` |
13
+
14
+ ## 通用 NestAgent 技能
15
+
16
+ `xyq-nest-skill` 通过接入小云雀 NestAgent 的综合创作能力,实现 AI 图片/视频生成、编辑、风格转换、文件上传、进度查询和结果下载。
17
+
18
+ ### 功能特性
19
+
20
+ | 功能 | 说明 |
21
+ |------|------|
22
+ | 创建会话 / 发送消息 | 向小云雀发送自然语言指令,生成图片或视频。 |
23
+ | 查询会话进展 | 增量拉取会话消息,轮询创作进度和产物结果。 |
24
+ | 上传文件 | 上传图片/视频到小云雀资产库,获取 `asset_id` 用于编辑和参考。 |
25
+ | 下载结果 | 批量下载生成的图片/视频到本地,支持并行下载。 |
26
+
27
+ 小云雀平台能力覆盖:
28
+
29
+ - 生成:文生图、文生视频、图生视频、视频续写。
30
+ - 编辑:局部修改、元素替换、镜头调整、风格迁移。
31
+ - 复杂创作:一句话生成短剧、复刻视频/TVC/宣传片、音乐 MV 生成、产品展示片制作。
32
+
33
+ ### 配置
34
+
35
+ 所有 `xyq-nest-skill` 脚本都使用 Bearer 令牌鉴权:
36
+
37
+ ```bash
38
+ export XYQ_ACCESS_KEY="<access-key>"
39
+ ```
40
+
41
+ 可选 API 地址:
42
+
43
+ ```bash
44
+ export XYQ_OPENAPI_BASE="https://xyq.jianying.com"
45
+ # 或
46
+ export XYQ_BASE_URL="https://xyq.jianying.com"
47
+ ```
48
+
49
+ ### 创建会话 / 发送消息
50
+
51
+ ```bash
52
+ # 创建新会话
53
+ python3 skills/xyq-nest-skill/scripts/submit_run.py --message "生一个动漫视频"
54
+
55
+ # 向已有会话发送消息
56
+ python3 skills/xyq-nest-skill/scripts/submit_run.py \
57
+ --message "再生成一个故事视频" \
58
+ --thread-id THREAD_ID
59
+
60
+ # 携带参考文件发送
61
+ python3 skills/xyq-nest-skill/scripts/submit_run.py \
62
+ --message "参考这个视频做修改" \
63
+ --asset-ids asset_id1 asset_id2
64
+ ```
65
+
66
+ | 参数 | 必填 | 说明 |
67
+ |------|------|------|
68
+ | `--message` | 是 | 创作指令内容。 |
69
+ | `--thread-id` | 否 | 已有会话 ID,不传则创建新会话。 |
70
+ | `--asset-ids` | 否 | 资产 ID 列表,支持多个。 |
71
+
72
+ 返回示例:
73
+
74
+ ```json
75
+ {
76
+ "thread_id": "90f05e0c-...",
77
+ "run_id": "abc123-..."
78
+ }
79
+ ```
80
+
81
+ ### 查询会话进展
82
+
83
+ ```bash
84
+ python3 skills/xyq-nest-skill/scripts/get_thread.py \
85
+ --thread-id THREAD_ID \
86
+ --run-id RUN_ID \
87
+ --after-seq 0
88
+ ```
89
+
90
+ | 参数 | 必填 | 说明 |
91
+ |------|------|------|
92
+ | `--thread-id` | 是 | 会话 ID。 |
93
+ | `--run-id` | 否 | 运行 ID。 |
94
+ | `--after-seq` | 否 | 增量拉取起始序号,默认 `0`。 |
95
+
96
+ 脚本会返回会话消息和产物条目。后续轮询时,根据已获取消息更新 `after_seq`。
97
+
98
+ ### 上传文件
99
+
100
+ ```bash
101
+ # 上传图片
102
+ python3 skills/xyq-nest-skill/scripts/upload_file.py /path/to/image.png
103
+
104
+ # 上传视频
105
+ python3 skills/xyq-nest-skill/scripts/upload_file.py /path/to/video.mp4
106
+ ```
107
+
108
+ 仅支持 `image/*` 和 `video/*` 类型,单文件大小限制 200 MB。
109
+
110
+ 返回示例:
111
+
112
+ ```json
113
+ {
114
+ "asset_id": "asset_xxx"
115
+ }
116
+ ```
117
+
118
+ ### 下载结果
119
+
120
+ ```bash
121
+ python3 skills/xyq-nest-skill/scripts/download_results.py \
122
+ --urls URL1 URL2 URL3 \
123
+ --output-dir ./xyq_output \
124
+ --prefix "storyboard" \
125
+ --workers 5
126
+ ```
127
+
128
+ | 参数 | 必填 | 说明 |
129
+ |------|------|------|
130
+ | `--urls` | 是 | 要下载的 URL 列表。 |
131
+ | `--output-dir` | 否 | 输出目录,默认 `./xyq_output`。 |
132
+ | `--prefix` | 否 | 文件名前缀,例如 `storyboard_01.png`。 |
133
+ | `--workers` | 否 | 并行下载线程数,默认 `5`。 |
134
+
135
+ 返回示例:
136
+
137
+ ```json
138
+ {
139
+ "output_dir": "./xyq_output",
140
+ "downloaded": ["./xyq_output/storyboard_01.png"],
141
+ "total": 1
142
+ }
143
+ ```
144
+
145
+ ### 典型示例
146
+
147
+ 文生视频:
148
+
149
+ ```text
150
+ 1. submit_run.py --message "生成一个赛博朋克风格的城市夜景视频"
151
+ 2. 每 10 秒轮询:
152
+ get_thread.py --thread-id THREAD_ID --run-id RUN_ID --after-seq SEQUENCE
153
+ 3. 拿到产物 URL 后下载:
154
+ download_results.py --urls URL1 URL2 --output-dir ./output --prefix "cyberpunk"
155
+ ```
156
+
157
+ 编辑已有视频:
158
+
159
+ ```text
160
+ 1. upload_file.py /path/to/video.mp4
161
+ 2. submit_run.py --message "把背景换成星空" --asset-ids asset_id
162
+ 3. 按文生视频流程轮询和下载。
163
+ ```
164
+
165
+ 多参考图/视频生成:
166
+
167
+ ```text
168
+ 1. upload_file.py /path/to/ref1.png
169
+ 2. upload_file.py /path/to/ref2.png
170
+ 3. upload_file.py /path/to/ref3.mp4
171
+ 4. submit_run.py --message "根据参考图和视频生成科普故事视频" --asset-ids asset_id1 asset_id2 asset_id3
172
+ 5. 按文生视频流程轮询和下载。
173
+ ```
174
+
175
+ 在已有会话中追加需求:
176
+
177
+ ```text
178
+ 1. submit_run.py --message "把刚才的视频加个片头" --thread-id EXISTING_THREAD_ID
179
+ 2. 使用新的 run_id 轮询和下载。
180
+ ```
181
+
182
+ 轮询策略:
183
+
184
+ - 间隔:每 10 秒查询一次。
185
+ - 增量拉取:首次 `--after-seq 0`,后续根据已获取消息数更新 seq。
186
+ - 意图确认:如果智能体追问用户,先展示问题,再用同一个 `thread_id` 提交用户回复。
187
+ - 超时:连续轮询 48 小时无结果则停止。
188
+ - 错误重试:单次失败可重试 1 次,连续 3 次失败则停止。
189
+
190
+ ## 短剧工作流技能
191
+
192
+ 包发布后可以通过 npm 安装。安装器会按当前系统下载匹配的预构建二进制文件,支持 macOS、Linux 和 Windows:
9
193
 
10
194
  ```bash
11
195
  npx @pippit-dev/cli@latest install
@@ -17,43 +201,17 @@ pippit-tool-cli short-drama +get-thread --thread-id thread_123 --run-id run_456
17
201
  pippit-tool-cli short-drama +download-result --output-path ./thread_123/results/result.mp4 --url URL
18
202
  ```
19
203
 
20
- NPM package names must be lowercase, so the publishable package name is
21
- `@pippit-dev/cli` rather than `@Pippit-dev/cli`.
204
+ `+submit-run`: 输出 `thread_id`、`run_id` `web_thread_link`;其中 `--message` 为必填参数。
205
+ `+get-thread`: 请求中带 `version=v2`,并输出 `readable_text`。
206
+ `+upload-file`: 输出返回的 `asset_id`。 当前仅支持 `.doc`、`.docx` 和 `.txt` 文件。
207
+ `+download-result`: 会把结果 URL 下载到 `--output-path` 指定的文件路径。
22
208
 
23
- Submit a Run task for the short drama scene:
209
+ 短剧命令的错误日志会追加写入本地每日日志文件:`~/.pippit_tool_cli/logs/yyyy-mm-dd.log`。日志路径会基于当前用户主目录和系统路径分隔符生成,因此可在 macOS、Linux Windows 上使用。
24
210
 
25
- ```bash
26
- export XYQ_ACCESS_KEY="<access-key>"
27
- go run . --version
28
- go run . short-drama +submit-run --message "写一个赛博朋克短剧开头"
29
- go run . short-drama +upload-file --path ./reference.doc
30
- go run . short-drama +get-thread --thread-id thread_123 --run-id run_456
31
- go run . short-drama +download-result --output-path ./thread_123/results/result.mp4 --url URL
32
- ```
33
-
34
- `+submit-run` calls `/api/biz/v1/skill/submit_run` and prints `thread_id`,
35
- `run_id`, and `web_thread_link`; `--message` is required.
36
- `+get-thread` calls
37
- `/api/biz/v1/skill/get_thread` with `version=v2` and prints `readable_text`.
38
- `+upload-file` calls `/api/biz/v1/skill/upload_file` with
39
- `multipart/form-data` and prints the returned `asset_id`.
40
- Only `.doc`, `.docx`, and `.txt` file extensions are supported.
41
- `+download-result` downloads the result URL to
42
- the `--output-path` file path.
43
-
44
- Short drama command errors are appended to a daily local log file under
45
- `~/.pippit_tool_cli/logs/yyyy-mm-dd.log`. The path is built with the current
46
- user home directory and the platform path separator, so it works on macOS,
47
- Linux, and Windows.
48
-
49
- ## HTTP Client
50
-
51
- Command modules should receive `common.Runner` for service calls. Runtime
52
- settings such as base URL, HTTP timeout, and API paths are loaded by
53
- `internal/config` and paired with `common.Client` in the runner.
54
-
55
- ## Auth
56
-
57
- `short-drama +submit-run`, `short-drama +get-thread`, and `short-drama +upload-file` authenticate with
58
- `Authorization: Bearer <XYQ_ACCESS_KEY>`. OAuth command code remains available,
59
- but runtime short drama requests do not use it.
211
+ ## HTTP 客户端
212
+
213
+ 命令模块通过 `common.Runner` 发起服务调用。运行时配置,例如基础地址、HTTP 超时时间和接口路径,由 `internal/config` 加载,并在运行器中与 `common.Client` 组合使用。
214
+
215
+ ## 鉴权
216
+
217
+ `short-drama +submit-run`、`short-drama +get-thread`、`short-drama +upload-file` 以及 `xyq-nest-skill` Python 脚本都使用 `Authorization: Bearer <XYQ_ACCESS_KEY>` 鉴权。OAuth 命令代码仍保留在仓库中,但短剧运行时请求不使用 OAuth。
package/checksums.txt CHANGED
@@ -1,6 +1,6 @@
1
- 35669c5443abd32d8318c30a48a4c8f2985c97b9719bdae2cb141cb0d08c8e73 pippit-tool-cli-0.0.19-darwin-amd64.tar.gz
2
- 168316fdf953f4a22b76281512368d04ab299577b2ee53cf4ce65f254474dd8e pippit-tool-cli-0.0.19-darwin-arm64.tar.gz
3
- 016af69efae30199ecc117a4e227e47c9c96c2a8ef24b86ac9e7d61108ef73cb pippit-tool-cli-0.0.19-linux-amd64.tar.gz
4
- 38e096dc71c3509c47943dd1f37076fbc525d2d8874f299fa000885ab34c574a pippit-tool-cli-0.0.19-linux-arm64.tar.gz
5
- cc3ded92e540d36af090679a95b8fc5ddc81a9d47c6ff56456ed0c89b5518674 pippit-tool-cli-0.0.19-windows-amd64.zip
6
- 7db4bb48c1fcbd16ca1940dcaa06d7998ddf814a95e377bdbfa80c4075b07ed4 pippit-tool-cli-0.0.19-windows-arm64.zip
1
+ ccd86bb8dcfc02ab6d199c951d93a946a9a594436d68f096b3d400e770117101 pippit-tool-cli-0.0.21-darwin-amd64.tar.gz
2
+ 7aebaaffe3ae5115e6896889316d79662591816deaac7db629029c44b692c18b pippit-tool-cli-0.0.21-darwin-arm64.tar.gz
3
+ 8b7b63d04e778eedb0acddf9ad922c6db45c4e2ae98f8432dc5912fde3b11ead pippit-tool-cli-0.0.21-linux-amd64.tar.gz
4
+ baf3b5fccbad34bdcfb374680536ec9da7a38b5dad988dcdf8ad82d9647c2c4d pippit-tool-cli-0.0.21-linux-arm64.tar.gz
5
+ dca29c43a2d968eaef1d69236920479893c0e7351b822577645b7e9ed747ce21 pippit-tool-cli-0.0.21-windows-amd64.zip
6
+ a309a1b47d1116be922d92826df75933f721cfab1f9fa99c95332611f8a9cbf9 pippit-tool-cli-0.0.21-windows-arm64.zip
@@ -74,7 +74,7 @@ func installSkills(root string, stderr io.Writer) error {
74
74
  } else if !info.IsDir() {
75
75
  return fmt.Errorf("%s is not a directory", filepath.Join(root, "skills"))
76
76
  }
77
- return runInherit(stderr, "npx", "-y", "skills", "add", root, "-g", "-y")
77
+ return runInherit(stderr, "npx", "-y", "skills", "add", root, "-g", "-y", "--skill", "*")
78
78
  }
79
79
 
80
80
  func runInherit(stderr io.Writer, name string, args ...string) error {
@@ -0,0 +1,47 @@
1
+ package updatecmd
2
+
3
+ import (
4
+ "bytes"
5
+ "os"
6
+ "path/filepath"
7
+ "runtime"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ func TestInstallSkillsInstallsAllBundledSkills(t *testing.T) {
13
+ if runtime.GOOS == "windows" {
14
+ t.Skip("test uses a POSIX shell script to stub npx")
15
+ }
16
+
17
+ root := t.TempDir()
18
+ if err := os.Mkdir(filepath.Join(root, "skills"), 0o755); err != nil {
19
+ t.Fatal(err)
20
+ }
21
+
22
+ binDir := t.TempDir()
23
+ capturePath := filepath.Join(t.TempDir(), "args.txt")
24
+ npxPath := filepath.Join(binDir, "npx")
25
+ script := "#!/bin/sh\nfor arg in \"$@\"; do printf '%s\\n' \"$arg\"; done > \"$CAPTURE_ARGS\"\n"
26
+ if err := os.WriteFile(npxPath, []byte(script), 0o755); err != nil {
27
+ t.Fatal(err)
28
+ }
29
+
30
+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
31
+ t.Setenv("CAPTURE_ARGS", capturePath)
32
+
33
+ var stderr bytes.Buffer
34
+ if err := installSkills(root, &stderr); err != nil {
35
+ t.Fatalf("installSkills() error = %v, stderr = %s", err, stderr.String())
36
+ }
37
+
38
+ gotBytes, err := os.ReadFile(capturePath)
39
+ if err != nil {
40
+ t.Fatal(err)
41
+ }
42
+ got := strings.Split(strings.TrimSpace(string(gotBytes)), "\n")
43
+ want := []string{"-y", "skills", "add", root, "-g", "-y", "--skill", "*"}
44
+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
45
+ t.Fatalf("npx args mismatch\ngot: %#v\nwant: %#v", got, want)
46
+ }
47
+ }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@pippit-dev/cli",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "description": "Pippit CLI",
5
5
  "bin": {
6
6
  "pippit-tool-cli": "scripts/run.js"
7
7
  },
8
8
  "scripts": {
9
9
  "postinstall": "node scripts/install.js",
10
- "test": "go test ./... && go vet ./..."
10
+ "test": "node scripts/version-check.test.js && go test ./... && go vet ./..."
11
11
  },
12
12
  "os": [
13
13
  "darwin",
package/scripts/skills.js CHANGED
@@ -10,7 +10,7 @@ function installSkillsFromRoot(root, opts = {}) {
10
10
  if (!fs.existsSync(skillsDir)) {
11
11
  throw new Error(`skills directory not found: ${skillsDir}`);
12
12
  }
13
- run("npx", ["-y", "skills", "add", source, "-g", "-y"], {
13
+ run("npx", ["-y", "skills", "add", source, "-g", "-y", "--skill", "*"], {
14
14
  timeout: opts.timeout || 120000,
15
15
  });
16
16
  }
@@ -7,7 +7,7 @@ const { DEFAULT_PKG } = require("./skills");
7
7
  const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
8
8
 
9
9
  function defaultCacheFile() {
10
- return path.join(os.homedir(), ".pippit-cli", "version-check.json");
10
+ return path.join(os.homedir(), ".pippit_tool_cli", "version-check.json");
11
11
  }
12
12
 
13
13
  function currentVersion() {
@@ -91,6 +91,7 @@ function maybeWarnNewVersion(args = [], opts = {}) {
91
91
  module.exports = {
92
92
  CHECK_INTERVAL_MS,
93
93
  compareSemver,
94
+ defaultCacheFile,
94
95
  maybeWarnNewVersion,
95
96
  parseSemver,
96
97
  };
@@ -0,0 +1,255 @@
1
+ ---
2
+ name: xyq-nest-skill
3
+ description: 通过小云雀的 AI 能力进行综合创作,支持生成和编辑图片/视频。覆盖场景包括:生成(文生图、文生视频、图生视频、做动画、画一个xxx、来段xxx)、编辑修改(把xxx换成yyy、去掉xxx、加上xxx、改成xxx、调整xxx、局部修改、改镜头)、风格转换(风格迁移、转绘、换风格)、视频续写延长、复刻视频/TVC/宣传片、短剧/短漫剧生成、音乐MV生成、产品广告/展示片制作、分镜/故事板设计、教育视频/短视频制作。当用户提到小云雀、xyq、上传参考图/视频、查看生成进度时也应触发。关键判断:只要用户的请求涉及 AI 视频的创作、生成、编辑、修改,无论措辞如何(如"画只猫"、"做个海报"、"做个视频"、"这个视频帮我改一下"、"帮我复刻这段视频"、"用这首歌做个MV"、"一句话生成短剧"),都必须触发此技能。
4
+ user-invocable: true
5
+ metadata:
6
+ {
7
+ "openclaw":
8
+ {
9
+ "emoji": "💬",
10
+ "requires":
11
+ {
12
+ "bins": ["python3"],
13
+ "env": ["XYQ_ACCESS_KEY"]
14
+ },
15
+ "primaryEnv": "XYQ_ACCESS_KEY"
16
+ }
17
+ }
18
+ ---
19
+
20
+ # 小云雀会话(生视频)
21
+
22
+ 通过 小云雀的API 创建会话、发送消息(生图、生视频、编辑视频等)、上传图片/视频文件,并查询会话消息进展。
23
+
24
+ 小云雀是一个 AI 综合创作平台,同时为人类创作者和 Agent 设计。Agent 通过 Skill 入口理解任务、调用模型并自动编排工作流。
25
+
26
+ **平台核心能力:**
27
+ - **生成**:文生图、文生视频、图生视频、视频续写
28
+ - **编辑**:局部修改、元素替换、镜头调整、风格迁移
29
+ - **复杂创作**:一句话生成完整短剧(剧本→分镜→成片)、复刻已有视频风格做 TVC/宣传片、用音乐生成 MV、产品展示片制作
30
+
31
+ 用户的所有创作和编辑需求都通过发送自然语言消息来完成,Agent 会自主编排工作流。复杂任务(短剧、MV)耗时较长,需耐心轮询。
32
+
33
+ ## 功能
34
+
35
+ 1. **创建会话 / 发消息** - 创建新会话或向已有会话发送一条消息(如「创作一个视频」)
36
+ 2. **查询会话进展** - 根据 `thread_id` 、 `run_id`、`after_seq` 增量拉取该会话的消息列表,用于轮询创作过程的消息和最终产物结果
37
+ 3. **上传文件** - 支持上传`单张图片`或`单个视频文件`到小云雀资产库,得到文件对应的 `asset_id`(编辑已有视频/图片时需要先上传)
38
+ 4. **下载结果** - 将会话中生成的图片/视频批量下载到本地,支持指定输出目录和文件名前缀。
39
+
40
+
41
+ ## 前置要求
42
+
43
+ ```bash
44
+ export XYQ_ACCESS_KEY="your-access-key"
45
+ ```
46
+
47
+ 可选:`XYQ_OPENAPI_BASE` 或 `XYQ_BASE_URL`,默认 `https://xyq.jianying.com`。
48
+
49
+ 无需安装额外依赖,仅使用 Python 标准库。
50
+
51
+ ## 使用方法
52
+
53
+ ### 1. 创建会话 / 发送消息
54
+
55
+ ```bash
56
+ # 创建新会话并发送「生一个动漫视频」
57
+ python3 {baseDir}/scripts/submit_run.py --message "生一个动漫视频"
58
+
59
+ # 向已有会话发送消息
60
+ python3 {baseDir}/scripts/submit_run.py --message "再生成一个故事视频" --thread-id THREAD_ID
61
+ ```
62
+
63
+ ### 2. 查询会话进展
64
+
65
+ ```bash
66
+ # 查询会话消息列表
67
+ python3 {baseDir}/scripts/get_thread.py --thread-id THREAD_ID --run-id RUN_ID --after-seq SEQUENCE
68
+ ```
69
+
70
+ > `run_id` 由 `submit_run` 返回,用于指定查询某次具体运行的结果。
71
+
72
+ ### 3. 上传文件
73
+
74
+ - 当用户提供了参考的文件地址时,先进行文件上传,仅支持图片、视频。
75
+ - 单次指令执行仅支持单个文件,多个文件可并行调用,单个文件大小必须在200MB以下。
76
+
77
+ ```bash
78
+ # 上传图片
79
+ python3 {baseDir}/scripts/upload_file.py /path/to/image.png
80
+
81
+ # 上传视频
82
+ python3 {baseDir}/scripts/upload_file.py /path/to/video.mp4
83
+ ```
84
+
85
+ ### 4. 下载结果
86
+
87
+ 任务完成后,可以将会话中的所有产物批量下载到本地。
88
+
89
+ ```bash
90
+ # 指定 URL 列表,指定输出目录,指定文件名前缀(如 artifact_01.png, artifact_02.png ...)进行下载
91
+ python3 {baseDir}/scripts/download_results.py --urls URL1 URL2 URL3 --output-dir ./xyq_output --prefix "artifact"
92
+ ```
93
+
94
+ ## 典型工作流
95
+
96
+ 理解这些工作流,才能正确组合上面的脚本完成用户需求。
97
+
98
+ ### 场景 1:用户要求生成图片或视频(最常见)
99
+
100
+ ```
101
+ 1. submit_run.py --message "用户的描述" → 拿到 thread_id、run_id 和 web_thread_link
102
+ 2. **立即**将 `web_thread_link` 展示给用户(如"任务已提交,可在此查看:{web_thread_link}")
103
+ 3. 每隔 `10` 秒钟调用 get_thread.py --thread-id THREAD_ID --run-id RUN_ID --after-seq SEQUENCE 进行轮询
104
+ 4. 检查 messages:
105
+ - 当任务还在创作中:
106
+ - 将过程创作信息展示给用户,继续轮询
107
+ - 当任务完成(run 结束):
108
+ - 如果涉及意图确认/流程中断(如"请回答以下问题"):
109
+ → 向用户展示问题,等待用户回复
110
+ → 使用 `thread_id` 重新提交任务(保持同一会话,产生新的 run_id)
111
+ → 回到步骤 2 继续轮询(可能多轮,直到不再意图确认)
112
+ - 如果 content 中包含产物 URL:
113
+ → 信息展示 → 下载产物 → 结果展示
114
+ 5. 自动下载:download_results.py --urls URL1 URL2 URL3 --output-dir 输出目录 --prefix 有意义的前缀
115
+ 6. 向用户展示:过程中的创作信息,以及下载后的本地文件列表
116
+ ```
117
+
118
+ ### 场景 2:用户提供图片/视频要求编辑修改(如"参考这个视频做一个新的")
119
+
120
+ ```
121
+ 1. upload_file.py /path/to/video.mp4 → 拿到 asset_id
122
+ 2. submit_run.py --message "参考这个视频做一个新的" --asset-ids asset_id → 拿到 thread_id、run_id、web_thread_link
123
+ 3. 后续同场景 1 的步骤 2-6
124
+ ```
125
+
126
+ 用户给了文件路径 + 编辑指令 = 先上传文件,再把编辑指令和 所有asset_id 一起发送。
127
+
128
+ ### 场景 3:用户提供参考图/视频要求生成新内容
129
+
130
+ ```
131
+ 1. upload_file.py /path/to/ref1.png → 拿到 asset_id1
132
+ 2. upload_file.py /path/to/ref2.mp4 → 拿到 asset_id2
133
+ 3. 直到所有文件上传完成,拿到所有 asset_id
134
+ 4. submit_run.py --message "根据参考图、视频生成xxx" --asset-ids asset_id1 asset_id2, ... → 拿到 thread_id、run_id、web_thread_link
135
+ 5. 后续同场景 1 的步骤 2-6
136
+ ```
137
+
138
+ ### 场景 4:在已有会话中追加新需求
139
+
140
+ ```
141
+ 1. submit_run.py --message "新的描述" --thread-id THREAD_ID → 拿到 thread_id、run_id、web_thread_link
142
+ 2. 后续同场景 1 的步骤 2-6
143
+ ```
144
+
145
+ ### 轮询策略
146
+
147
+ - **间隔**:每 10 秒查询一次
148
+ - **增量拉取**:首次用 --after-seq 0,后续根据messages消息列表长度,计算新的 seq 值
149
+ - **完成判断**:当创作任务完成且messages的content中包含产物结果 URL(图片/视频地址)
150
+ - **超时**:连续轮询 `48 小时`仍无结果,告知用户"生成时间较长,可稍后查看",不再继续轮询
151
+ - **错误重试**:单次查询失败可重试 1 次,连续 3 次失败则停止并告知用户
152
+
153
+ ## 输出格式
154
+
155
+ **submit_run** 返回:
156
+ ```json
157
+ {
158
+ "thread_id": "90f05e0c-...",
159
+ "run_id": "abc123-...",
160
+ "web_thread_link": "https://xyq.jianying.com/..."
161
+ }
162
+ ```
163
+
164
+ **get_thread** 返回:
165
+ ```json
166
+ {
167
+ "messages": [
168
+ {"id": "1", "role": "user", "content": "生一个动漫视频"},
169
+ {"id": "2", "role": "assistant", "content": [
170
+ {
171
+ "type": "{type}",
172
+ "subtype": "{sub_type}",
173
+ "data": {...}
174
+ }
175
+ ]},
176
+ {"id": "3", "role": "assistant", "content": [
177
+ {
178
+ "type": "{type}",
179
+ "subtype": "{sub_type}",
180
+ "data": {..., "url": "{url}"....}
181
+ }
182
+ ]}
183
+ ]
184
+ }
185
+ ```
186
+
187
+ **upload_file** 返回:
188
+ ```json
189
+ {
190
+ "asset_id": "{asset_id}"
191
+ }
192
+ ```
193
+
194
+ **download_results** 返回:
195
+ ```json
196
+ {
197
+ "output_dir": "./xyq_output",
198
+ "downloaded": ["./xyq_output/01.png", "..."],
199
+ "total": 10
200
+ }
201
+ ```
202
+
203
+ ## 向用户展示内容
204
+
205
+ - 任务提交后:立即将 `web_thread_link` 展示给用户,方便用户直接打开浏览器查看任务页面
206
+ - 任务在创作中:
207
+ - 展示过程中的创作信息等,继续轮询
208
+ - 任务完成(run 结束):
209
+ - 若涉及意图确认/流程中断(如"请回答以下问题")→ 展示问题 → 等待用户回复 → 使用同一 `thread_id` 重新提交任务 → 继续轮询(可能多轮)
210
+ - 若 content 中包含产物 URL:
211
+ - 结果地址:来自 `get_thread` 返回的 `messages` 中,任务创作完成会包含产物 URL,将产物链接、下载的本地文件等信息告知用户。
212
+
213
+ ## 核心原则:用户侧不做创作,只做传话
214
+
215
+ 你(用户侧 Agent)的职责是**搬运工**,不是创作者。后端有专门的 Agent 负责理解需求、拆解分镜、编排工作流、选模型、写 prompt。你要做的只有三件事:
216
+
217
+ 1. **上传**:如果用户给了本地文件 → `upload_file.py` 拿到 asset_id
218
+ 2. **提交任务**:把用户的原始描述 + asset_id 原封不动发给 `submit_run.py`
219
+ 3. **传话**:根据 `get_thread.py` 返回的消息列表,展示过程中的意图询问、创作信息等
220
+ 4. **取件**:`get_thread.py` 轮询结果 → 检查结果 → 下载产物 → 结果展示给用户
221
+
222
+ **绝对不要做的事:**
223
+ - 不要替用户扩写、润色、翻译 prompt(用户说"帮我推演分镜",就直接传"帮我推演分镜",不要自己先写个分镜表再逐条发)
224
+ - 不要自行编排镜头描述、剧情推演、风格分析
225
+ - 不要在消息中添加自己编的 prompt(如"超写实风格,电影级光影,8K分辨率"之类的描述词)
226
+
227
+ 后端 Agent 对模型能力、参数配置、prompt 工程远比用户侧更专业。用户侧越俎代庖只会降低生成质量,换个弱模型更是灾难。
228
+
229
+ **正确示例:**
230
+ ```
231
+ 用户说:「根据多张参考图,做个科普故事视频」
232
+ 用户给了参考图:/path/to/ref1.png, /path/to/ref2.png, /path/to/ref3.png
233
+
234
+ → upload_file.py /path/to/ref1.png → 拿到 asset_id1
235
+ → upload_file.py /path/to/ref2.png → 拿到 asset_id2
236
+ → upload_file.py /path/to/ref3.png → 拿到 asset_id3
237
+ → submit_run.py --message "根据参考图、视频生成xxx" --asset-ids asset_id1 asset_id2, asset_id3 → 拿到 web_thread_link,立即展示给用户
238
+ → 轮询 ─┬─ 意图确认 → 用户确认 → 使用 thread_id 重新提交 → 继续轮询
239
+ └─ 无意图确认 → 信息展示 → 下载产物 → 结果展示
240
+ ```
241
+
242
+ **错误示例:**
243
+ ```
244
+ ❌ 用户侧自己先写了个九宫格分镜表(对峙、交锋、危机...)
245
+ ❌ 然后把自己编的描述发给后端
246
+ ❌ 或者拆成9次 submit_run 分别发送
247
+ ```
248
+
249
+ ## 注意事项
250
+
251
+ - 鉴权方式为请求头 `Authorization: Bearer <XYQ_ACCESS_KEY>`
252
+ - 创建会话时 `message` 是用户的指令要求,不能为空
253
+ - 查询会话时可用 --after-seq 做增量拉取,便于轮询新消息(含 assistant 回复与生图/生视频结果)
254
+ - 上传文件仅支持图片(image/*)和视频(video/*)类型,其他类型会被拒绝,文件大小须在 200MB 以下
255
+ - 生成过程中将过程中的创作信息展示给用户;任务完成后给出**产物结果(图片/视频)URL链接**和下载的**本地文件列表**。
@@ -0,0 +1,158 @@
1
+ """小云雀 agent-im OpenAPI 公共模块:创建会话、查询会话(鉴权为 Authorization: Bearer <access_key>)"""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ import urllib.request
7
+ import urllib.error
8
+
9
+ XYQ_BASE = os.environ.get("XYQ_OPENAPI_BASE", os.environ.get("XYQ_BASE_URL", "https://xyq.jianying.com"))
10
+ ACCESS_KEY = os.environ.get("XYQ_ACCESS_KEY", "")
11
+
12
+ # API 路径常量
13
+ SUBMIT_RUN_PATH = "/api/biz/v1/skill/submit_run"
14
+ GET_THREAD_PATH = "/api/biz/v1/skill/get_thread"
15
+ UPLOAD_FILE_PATH = "/api/biz/v1/skill/upload_file"
16
+
17
+ if not ACCESS_KEY:
18
+ print("错误:请设置 XYQ_ACCESS_KEY 环境变量", file=sys.stderr)
19
+ sys.exit(1)
20
+
21
+
22
+ def _headers():
23
+ return {
24
+ "Authorization": f"Bearer {ACCESS_KEY}",
25
+ "Content-Type": "application/json"
26
+ }
27
+
28
+
29
+ def api_post(path: str, body: dict) -> dict:
30
+ """POST 请求 agent-im OpenAPI"""
31
+ url = f"{XYQ_BASE.rstrip('/')}{path}"
32
+ data = json.dumps(body).encode("utf-8")
33
+ req = urllib.request.Request(
34
+ url,
35
+ data=data,
36
+ method="POST",
37
+ headers=_headers(),
38
+ )
39
+ try:
40
+ with urllib.request.urlopen(req, timeout=30) as resp:
41
+ return json.loads(resp.read().decode("utf-8"))
42
+ except urllib.error.HTTPError as e:
43
+ err_body = e.read().decode("utf-8") if e.fp else ""
44
+ print(f"API 错误 {e.code}: {err_body}", file=sys.stderr)
45
+ sys.exit(1)
46
+ except urllib.error.URLError as e:
47
+ print(f"网络错误: {e.reason}", file=sys.stderr)
48
+ sys.exit(1)
49
+
50
+
51
+ def api_get(path: str) -> dict:
52
+ """GET 请求 agent-im OpenAPI"""
53
+ url = f"{XYQ_BASE.rstrip('/')}{path}"
54
+ req = urllib.request.Request(url, method="GET", headers=_headers())
55
+ try:
56
+ with urllib.request.urlopen(req, timeout=30) as resp:
57
+ return json.loads(resp.read().decode("utf-8"))
58
+ except urllib.error.HTTPError as e:
59
+ err_body = e.read().decode("utf-8") if e.fp else ""
60
+ print(f"API 错误 {e.code}: {err_body}", file=sys.stderr)
61
+ sys.exit(1)
62
+ except urllib.error.URLError as e:
63
+ print(f"网络错误: {e.reason}", file=sys.stderr)
64
+ sys.exit(1)
65
+
66
+
67
+ def parse_response(resp: dict) -> dict:
68
+ """
69
+ 分析 API 响应。
70
+ 响应结构:{"ret":"0","errmsg":"","data":{}}
71
+ 如果 ret 不是 "0",打印错误信息并退出;否则返回 data。
72
+ """
73
+ ret = resp.get("ret", "")
74
+ if ret != "0":
75
+ errmsg = resp.get("errmsg", "未知错误")
76
+ print(f"错误码: {ret}, 错误信息: {errmsg}", file=sys.stderr)
77
+ sys.exit(1)
78
+ return resp.get("data", {})
79
+
80
+
81
+ def submit_run(thread_id: str = "", message: str = "", asset_ids: list = None) -> dict:
82
+ """
83
+ 创建会话或向已有会话发消息。
84
+ 返回 data: { projectUuid, sessionId }。
85
+ """
86
+ body = {}
87
+ if thread_id:
88
+ body["thread_id"] = thread_id
89
+ if message:
90
+ body["message"] = message
91
+ if asset_ids:
92
+ body["asset_ids"] = asset_ids
93
+ resp = api_post(SUBMIT_RUN_PATH, body)
94
+ return parse_response(resp)
95
+
96
+
97
+ def get_thread(thread_id: str, run_id: str = "", after_seq: int = 0) -> dict:
98
+ """
99
+ 查询会话消息列表。
100
+ 返回 data: { messages: [...] }。
101
+ """
102
+ body = {}
103
+ if thread_id:
104
+ body["thread_id"] = thread_id
105
+ if run_id:
106
+ body["run_id"] = run_id
107
+ body["after_seq"] = after_seq
108
+ resp = api_post(GET_THREAD_PATH, body)
109
+ resp = parse_response(resp)
110
+ thread = resp.get("thread", {})
111
+ run_list = thread.get("run_list", [])
112
+ if len(run_list) == 0:
113
+ print("错误:未返回 run_list", file=sys.stderr)
114
+ sys.exit(1)
115
+ run = run_list[0]
116
+ run_state = run.get("state", "")
117
+
118
+ # 判断 run_state
119
+ if run_state == 3:
120
+ # 成功
121
+ print("成功:本次创作已完成", file=sys.stderr)
122
+ return run
123
+ elif run_state == 4:
124
+ # 失败
125
+ fail_reason = run.get("fail_reason", "未知失败原因")
126
+ print(f"错误:{fail_reason}", file=sys.stderr)
127
+ sys.exit(1)
128
+ elif run_state == 5:
129
+ # 取消
130
+ print("错误:本次创作已被终止", file=sys.stderr)
131
+ sys.exit(1)
132
+ else:
133
+ print("本次创作进行中", file=sys.stdout)
134
+ return run
135
+
136
+
137
+ def extract_entries_from_run(run: dict) -> list:
138
+ """
139
+ 从 Run 的 EntryList 中提取符合条件的 entry。
140
+ """
141
+ matched = []
142
+ for entry in run.get("entry_list") or []:
143
+ e = {}
144
+ message = entry.get("message")
145
+ artifact = entry.get("artifact")
146
+ if message:
147
+ e["id"] = message.get("message_id", "")
148
+ e["role"] = message.get("role", "")
149
+ e["content"] = message.get("content", [])
150
+ client_tool_calls = message.get("client_tool_calls", [])
151
+ if len(client_tool_calls) > 0:
152
+ e["content"].extend(client_tool_calls)
153
+ if artifact:
154
+ e["id"] = artifact.get("artifact_id", "")
155
+ e["role"] = artifact.get("role", "")
156
+ e["content"] = artifact.get("content", [])
157
+ matched.append(e)
158
+ return matched
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env python3
2
+ """下载生成结果:从会话中提取所有图片/视频 URL 并批量下载到本地"""
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import sys
8
+ import urllib.request
9
+ import urllib.error
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
+
12
+ sys.path.insert(0, os.path.dirname(__file__))
13
+
14
+
15
+ def download_file(url, filepath):
16
+ """下载单个文件"""
17
+ import shutil
18
+ req = urllib.request.Request(url, headers={"User-Agent": "XYQ-Nest-Skill/1.0"})
19
+ tmp_path = filepath + ".tmp"
20
+ try:
21
+ with urllib.request.urlopen(req, timeout=600) as resp:
22
+ with open(tmp_path, "wb") as f:
23
+ shutil.copyfileobj(resp, f, length=1024 * 1024)
24
+ os.replace(tmp_path, filepath)
25
+ return filepath, None
26
+ except Exception as e:
27
+ if os.path.exists(tmp_path):
28
+ os.remove(tmp_path)
29
+ return filepath, str(e)
30
+
31
+
32
+ def main():
33
+ parser = argparse.ArgumentParser(
34
+ description="根据产物URL,下载生成的产物到本地,支持指定输出目录和文件名前缀",
35
+ epilog="""
36
+ 使用方式:
37
+ # 直接下载指定 URL 列表
38
+ python3 download_results.py --urls URL1 URL2 URL3 --output-dir ./output --prefix "storyboard"
39
+ """,
40
+ formatter_class=argparse.RawDescriptionHelpFormatter,
41
+ )
42
+ parser.add_argument("--urls", nargs="+", required=True, help="直接指定要下载的 URL 列表")
43
+ parser.add_argument("--output-dir", default="", help="输出目录(默认 ./xyq_output")
44
+ parser.add_argument("--prefix", default="", help="文件名前缀(如 'storyboard' → storyboard_01.png)")
45
+ parser.add_argument("--workers", type=int, default=5, help="并行下载线程数(默认 5)")
46
+ args = parser.parse_args()
47
+
48
+ # 准备输出目录
49
+ output_dir = args.output_dir or "./xyq_output"
50
+ os.makedirs(output_dir, exist_ok=True)
51
+
52
+ def _get_ext(url):
53
+ """从 URL 中提取文件扩展名,优先从 query 的 filename 参数取,其次从路径取"""
54
+ from urllib.parse import urlparse, parse_qs
55
+ parsed = urlparse(url)
56
+ qs = parse_qs(parsed.query)
57
+ filenames = qs.get("filename", [])
58
+ if filenames:
59
+ _, ext = os.path.splitext(filenames[0])
60
+ if ext:
61
+ return ext
62
+ _, ext = os.path.splitext(parsed.path)
63
+ return ext or ".bin"
64
+
65
+ # 构建下载任务
66
+ tasks = []
67
+ for i, url in enumerate(args.urls, 1):
68
+ ext = _get_ext(url)
69
+ if args.prefix:
70
+ filename = f"{args.prefix}_{i:02d}{ext}"
71
+ else:
72
+ filename = f"{i:02d}{ext}"
73
+ filepath = os.path.join(output_dir, filename)
74
+ tasks.append((url, filepath))
75
+
76
+ # 并行下载
77
+ results = []
78
+ errors = []
79
+ with ThreadPoolExecutor(max_workers=args.workers) as pool:
80
+ futures = {pool.submit(download_file, url, fp): (url, fp) for url, fp in tasks}
81
+ for future in as_completed(futures):
82
+ fp, err = future.result()
83
+ if err:
84
+ errors.append({"file": fp, "error": err})
85
+ else:
86
+ results.append(fp)
87
+
88
+ # 按文件名排序输出
89
+ results.sort()
90
+
91
+ output = {
92
+ "output_dir": output_dir,
93
+ "downloaded": results,
94
+ "total": len(results),
95
+ }
96
+ if errors:
97
+ output["errors"] = errors
98
+
99
+ print(json.dumps(output, ensure_ascii=False, indent=2))
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env python3
2
+ """查询会话进展:POST /api/biz/v1/skill/get_thread,返回消息列表"""
3
+
4
+ from _common import extract_entries_from_run
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import os
9
+
10
+ sys.path.insert(0, os.path.dirname(__file__))
11
+ from _common import get_thread
12
+
13
+
14
+ def main():
15
+ parser = argparse.ArgumentParser(
16
+ description="查询会话消息列表(会话进展)",
17
+ epilog="""
18
+ 环境变量:
19
+ XYQ_ACCESS_KEY 必填,Bearer 鉴权
20
+ XYQ_OPENAPI_BASE 或 XYQ_BASE_URL 可选,默认 https://xyq.jianying.com
21
+
22
+ 示例:
23
+ python3 get_thread.py --thread-id abc123 --run-id def456 --after-seq 0
24
+ """,
25
+ formatter_class=argparse.RawDescriptionHelpFormatter,
26
+ )
27
+ parser.add_argument(
28
+ "--thread-id",
29
+ required=True,
30
+ help="会话 ID(由 submit_run 返回)",
31
+ )
32
+ parser.add_argument(
33
+ "--run-id",
34
+ default="",
35
+ help="运行 ID(由 submit_run 返回)",
36
+ )
37
+ parser.add_argument(
38
+ "--after-seq",
39
+ type=int,
40
+ default=0,
41
+ help="只返回 seq 大于等于该值的消息,用于增量拉取(默认 0)",
42
+ )
43
+ args = parser.parse_args()
44
+
45
+ run = get_thread(args.thread_id, run_id=args.run_id, after_seq=args.after_seq)
46
+ # 从run中提取Message和Artifact
47
+ entries = extract_entries_from_run(run)
48
+ out = {"messages": entries}
49
+ print(json.dumps(out, ensure_ascii=False, indent=2))
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env python3
2
+ """创建会话 / 向会话发送消息(生图、生视频等):POST /api/biz/v1/skill/submit_run"""
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ import os
8
+
9
+ sys.path.insert(0, os.path.dirname(__file__))
10
+ from _common import submit_run
11
+
12
+
13
+ def main():
14
+ parser = argparse.ArgumentParser(
15
+ description="创建会话或向已有会话发送消息(仅用于生视频)",
16
+ epilog="""
17
+ 环境变量:
18
+ XYQ_ACCESS_KEY 必填,Bearer 鉴权
19
+ XYQ_OPENAPI_BASE 或 XYQ_BASE_URL 可选,默认 https://xyq.jianying.com
20
+
21
+ 示例:
22
+ # 创建新会话并发送「生一个动漫视频」
23
+ python3 submit_run.py --message 生一个动漫视频
24
+
25
+ # 向已有会话发送消息
26
+ python3 submit_run.py --message 再生成一个动漫视频 --thread-id 90f05e0c-5d08-4148-be40-e30fc7c7bedf
27
+
28
+ # 传入文件资产 ID
29
+ python3 submit_run.py --message 生成视频 --asset-ids asset123
30
+
31
+ # 传入多个文件资产 ID
32
+ python3 submit_run.py --message 生成视频 --asset-ids asset123 asset456 asset789
33
+ """,
34
+ formatter_class=argparse.RawDescriptionHelpFormatter,
35
+ )
36
+ parser.add_argument(
37
+ "--message",
38
+ required=True,
39
+ help="要发送的消息内容(生图/生视频描述等),必填",
40
+ )
41
+ parser.add_argument(
42
+ "--thread-id",
43
+ default="",
44
+ help="已有会话 ID,不传则创建新会话或返回已有默认会话",
45
+ )
46
+ parser.add_argument(
47
+ "--asset-ids",
48
+ nargs="+",
49
+ default=[],
50
+ help="资产 ID 列表,可传入多个,例如:--asset-ids id1 id2 id3",
51
+ )
52
+ args = parser.parse_args()
53
+
54
+ data = submit_run(
55
+ thread_id=args.thread_id or "",
56
+ message=args.message or "",
57
+ asset_ids=args.asset_ids if args.asset_ids else None
58
+ )
59
+ run_data = data.get("run", {})
60
+ web_thread_link = data.get("web_thread_link", "")
61
+ thread_id = run_data.get("thread_id", "")
62
+ run_id = run_data.get("run_id", "")
63
+
64
+ if not thread_id:
65
+ print("错误:未返回 thread_id", file=sys.stderr)
66
+ sys.exit(1)
67
+ if not run_id:
68
+ print("错误:未返回 run_id", file=sys.stderr)
69
+ sys.exit(1)
70
+
71
+ out = {"thread_id": thread_id, "run_id": run_id, "web_thread_link": web_thread_link}
72
+ print(json.dumps(out, ensure_ascii=False, indent=2))
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """上传图片/视频到小云雀资产库:POST /api/biz/v1/skill/upload_file(multipart/form-data)"""
3
+
4
+ import argparse
5
+ import json
6
+ import mimetypes
7
+ import os
8
+ import sys
9
+ import uuid
10
+ import urllib.request
11
+ import urllib.error
12
+
13
+ sys.path.insert(0, os.path.dirname(__file__))
14
+ from _common import XYQ_BASE, ACCESS_KEY, UPLOAD_FILE_PATH, parse_response
15
+
16
+ # 允许的 MIME 类型前缀
17
+ ALLOWED_PREFIXES = ("image/", "video/")
18
+
19
+
20
+ def upload_file(file_path: str) -> dict:
21
+ """
22
+ 上传本地文件到小云雀资产库。
23
+ 返回 data: { asset_id: str }。
24
+ """
25
+ if not os.path.isfile(file_path):
26
+ print(f"错误:文件不存在: {file_path}", file=sys.stderr)
27
+ sys.exit(1)
28
+
29
+ # 检查 MIME 类型
30
+ mime_type, _ = mimetypes.guess_type(file_path)
31
+ if mime_type and not any(mime_type.startswith(p) for p in ALLOWED_PREFIXES):
32
+ print(f"错误:不支持的文件类型: {mime_type},仅支持图片和视频", file=sys.stderr)
33
+ sys.exit(1)
34
+
35
+ # 构建 multipart/form-data 请求体
36
+ boundary = f"----PythonUpload{uuid.uuid4().hex}"
37
+ filename = os.path.basename(file_path)
38
+
39
+ body_parts = []
40
+
41
+ # accessKey 字段
42
+ body_parts.append(f"--{boundary}\r\n".encode())
43
+ body_parts.append(b'Content-Disposition: form-data; name="accessKey"\r\n\r\n')
44
+ body_parts.append(f"{ACCESS_KEY}\r\n".encode())
45
+
46
+ # file 字段
47
+ content_type = mime_type or "application/octet-stream"
48
+ body_parts.append(f"--{boundary}\r\n".encode())
49
+ body_parts.append(
50
+ f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode()
51
+ )
52
+ body_parts.append(f"Content-Type: {content_type}\r\n\r\n".encode())
53
+ with open(file_path, "rb") as f:
54
+ body_parts.append(f.read())
55
+ body_parts.append(b"\r\n")
56
+
57
+ # 结束边界
58
+ body_parts.append(f"--{boundary}--\r\n".encode())
59
+
60
+ data = b"".join(body_parts)
61
+
62
+ url = f"{XYQ_BASE.rstrip('/')}{UPLOAD_FILE_PATH}"
63
+ req = urllib.request.Request(
64
+ url,
65
+ data=data,
66
+ method="POST",
67
+ headers={
68
+ "Authorization": f"Bearer {ACCESS_KEY}",
69
+ "Content-Type": f"multipart/form-data; boundary={boundary}"
70
+ },
71
+ )
72
+ try:
73
+ with urllib.request.urlopen(req, timeout=120) as resp:
74
+ result = json.loads(resp.read().decode("utf-8"))
75
+ return parse_response(result)
76
+ except urllib.error.HTTPError as e:
77
+ err_body = e.read().decode("utf-8") if e.fp else ""
78
+ print(f"API 错误 {e.code}: {err_body}", file=sys.stderr)
79
+ sys.exit(1)
80
+ except urllib.error.URLError as e:
81
+ print(f"网络错误: {e.reason}", file=sys.stderr)
82
+ sys.exit(1)
83
+
84
+
85
+ def main():
86
+ parser = argparse.ArgumentParser(
87
+ description="上传图片或视频文件到小云雀资产库",
88
+ epilog="""
89
+ 环境变量:
90
+ XYQ_ACCESS_KEY 必填,Bearer 鉴权
91
+ XYQ_OPENAPI_BASE 或 XYQ_BASE_URL 可选,默认 https://xyq.jianying.com
92
+
93
+ 示例:
94
+ # 上传图片
95
+ python3 upload_file.py /path/to/image.png
96
+
97
+ # 上传视频
98
+ python3 upload_file.py /path/to/video.mp4
99
+ """,
100
+ formatter_class=argparse.RawDescriptionHelpFormatter,
101
+ )
102
+ parser.add_argument(
103
+ "file",
104
+ help="要上传的图片或视频文件路径",
105
+ )
106
+ args = parser.parse_args()
107
+
108
+ data = upload_file(args.file)
109
+ asset_id = data.get("pippit_asset_id", "")
110
+
111
+ if not asset_id:
112
+ print("错误:未返回 asset_id", file=sys.stderr)
113
+ sys.exit(1)
114
+
115
+ out = {"asset_id": asset_id}
116
+ print(json.dumps(out, ensure_ascii=False, indent=2))
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()