Skip to content

Commit ee748db

Browse files
committed
feat: migrate skill submit and media upload to native CLI
1 parent f7e0251 commit ee748db

16 files changed

Lines changed: 587 additions & 304 deletions

File tree

‎.agents/skills/xyq-skill‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../skills/xyq-nest-skill

‎README.md‎

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -43,49 +43,44 @@
4343

4444
### 配置
4545

46-
所有 `xyq-skill` 脚本都使用 Bearer 令牌鉴权:
46+
`submit-run` 和 `upload-file` 使用原生 CLI 登录凭证(`pippit-tool-cli login`),也可通过 `XYQ_ACCESS_KEY` 显式覆盖。保留的查询脚本 `get_thread.py` 仍需配置同一用户的 Bearer 凭证:
4747

4848
```bash
4949
export XYQ_ACCESS_KEY="<access-key>"
5050
```
5151

52-
可选 API 地址:
53-
54-
```bash
55-
export XYQ_OPENAPI_BASE="https://xyq.jianying.com"
56-
# 或
57-
export XYQ_BASE_URL="https://xyq.jianying.com"
58-
```
52+
CLI 和 Python 脚本携带用户密钥的 API 请求地址固定为 `https://xyq.jianying.com`,不接受 `XYQ_OPENAPI_BASE` / `XYQ_BASE_URL` 覆盖。CLI 拒绝 API 跨域重定向,Python API 脚本禁止自动重定向。上传只在 Authorization 请求头携带密钥。
5953

6054
### 创建会话 / 发送消息
6155

6256
```bash
6357
# 创建新会话
64-
python3 skills/xyq-nest-skill/scripts/submit_run.py --message "生一个动漫视频"
58+
pippit-tool-cli submit-run --message "生一个动漫视频"
6559

6660
# 向已有会话发送消息
67-
python3 skills/xyq-nest-skill/scripts/submit_run.py \
61+
pippit-tool-cli submit-run \
6862
--message "再生成一个故事视频" \
6963
--thread-id THREAD_ID
7064

7165
# 携带参考文件发送
72-
python3 skills/xyq-nest-skill/scripts/submit_run.py \
66+
pippit-tool-cli submit-run \
7367
--message "参考这个视频做修改" \
74-
--asset-ids asset_id1 asset_id2
68+
--asset-ids asset_id1 --asset-ids asset_id2
7569
```
7670

7771
| 参数 | 必填 | 说明 |
7872
|------|------|------|
79-
| `--message` | 是 | 创作指令内容。 |
73+
| `--message` | 是 | 非空白的创作指令,原样发送。 |
8074
| `--thread-id` | 否 | 已有会话 ID,不传则创建新会话。 |
81-
| `--asset-ids` | 否 | 资产 ID 列表,支持多个。 |
75+
| `--asset-ids` | 否 | 每次传一个资产 ID;多个素材重复该参数。 |
8276

8377
返回示例:
8478

8579
```json
8680
{
8781
"thread_id": "90f05e0c-...",
88-
"run_id": "abc123-..."
82+
"run_id": "abc123-...",
83+
"web_thread_link": "https://xyq.jianying.com/..."
8984
}
9085
```
9186

@@ -108,18 +103,20 @@ python3 skills/xyq-nest-skill/scripts/get_thread.py \
108103

109104
### 上传文件
110105

106+
使用顶层 `upload-file --path`,每次上传一个本地图片、视频或 MP3/WAV 音频文件,文件须小于 500 MB(500000000 字节)。成功返回 `{"asset_id":"..."}`,可直接传给 `submit-run --asset-ids`。
107+
111108
```bash
112109
# 上传图片
113-
python3 skills/xyq-nest-skill/scripts/upload_file.py /path/to/image.png
110+
pippit-tool-cli upload-file --path /path/to/image.png
114111

115112
# 上传视频
116-
python3 skills/xyq-nest-skill/scripts/upload_file.py /path/to/video.mp4
113+
pippit-tool-cli upload-file --path /path/to/video.mp4
117114

118115
# 上传音频
119-
python3 skills/xyq-nest-skill/scripts/upload_file.py /path/to/audio.mp3
116+
pippit-tool-cli upload-file --path /path/to/audio.mp3
120117
```
121118

122-
仅支持 `image/*`、`video/*` 和 `.mp3/.wav` 音频文件,单文件大小限制 200 MB。
119+
仅支持 `image/*`、`video/*` 和 `.mp3/.wav` 音频文件,单文件大小限制 500 MB。
123120

124121
返回示例:
125122

@@ -161,7 +158,7 @@ python3 skills/xyq-nest-skill/scripts/download_results.py \
161158
文生视频:
162159

163160
```text
164-
1. submit_run.py --message "生成一个赛博朋克风格的城市夜景视频"
161+
1. pippit-tool-cli submit-run --message "生成一个赛博朋克风格的城市夜景视频"
165162
2. 每 10 秒轮询:
166163
get_thread.py --thread-id THREAD_ID --run-id RUN_ID --after-seq SEQUENCE
167164
3. 拿到产物 URL 后下载:
@@ -171,25 +168,25 @@ python3 skills/xyq-nest-skill/scripts/download_results.py \
171168
编辑已有视频:
172169

173170
```text
174-
1. upload_file.py /path/to/video.mp4
175-
2. submit_run.py --message "把背景换成星空" --asset-ids asset_id
171+
1. pippit-tool-cli upload-file --path /path/to/video.mp4
172+
2. pippit-tool-cli submit-run --message "把背景换成星空" --asset-ids asset_id
176173
3. 按文生视频流程轮询和下载。
177174
```
178175

179176
多参考图/视频生成:
180177

181178
```text
182-
1. upload_file.py /path/to/ref1.png
183-
2. upload_file.py /path/to/ref2.png
184-
3. upload_file.py /path/to/ref3.mp4
185-
4. submit_run.py --message "根据参考图和视频生成科普故事视频" --asset-ids asset_id1 asset_id2 asset_id3
179+
1. pippit-tool-cli upload-file --path /path/to/ref1.png
180+
2. pippit-tool-cli upload-file --path /path/to/ref2.png
181+
3. pippit-tool-cli upload-file --path /path/to/ref3.mp4
182+
4. pippit-tool-cli submit-run --message "根据参考图和视频生成科普故事视频" --asset-ids asset_id1 --asset-ids asset_id2 --asset-ids asset_id3
186183
5. 按文生视频流程轮询和下载。
187184
```
188185

189186
在已有会话中追加需求:
190187

191188
```text
192-
1. submit_run.py --message "把刚才的视频加个片头" --thread-id EXISTING_THREAD_ID
189+
1. pippit-tool-cli submit-run --message "把刚才的视频加个片头" --thread-id EXISTING_THREAD_ID
193190
2. 使用新的 run_id 轮询和下载。
194191
```
195192

‎cmd/root.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm
5656
root := &cobra.Command{
5757
Use: "pippit-tool-cli",
5858
Short: "Pippit CLI",
59-
Long: "Pippit CLI generates and processes videos and images, queries credit balances, submits short-drama workflows, downloads generated assets, and updates the installed CLI package.",
59+
Long: "Pippit CLI generates and processes videos and images, queries credit balances, submits creative conversations and short-drama workflows, downloads generated assets, and updates the installed CLI package.",
6060
Version: version.Current(),
6161
SilenceUsage: true,
6262
SilenceErrors: true,
@@ -72,6 +72,8 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm
7272
root.AddCommand(newDownloadResultCommand(stdout, stderr, runner))
7373
root.AddCommand(newGetCreditBalanceCommand(stdout, stderr, runner))
7474
root.AddCommand(newGetThreadCommand(stdout, stderr, runner))
75+
root.AddCommand(newSubmitRunCommand(stdout, stderr, runner))
76+
root.AddCommand(newUploadFileCommand(stdout, stderr, runner))
7577
root.AddCommand(newListThreadFileCommand(stdout, stderr, runner))
7678
root.AddCommand(generate_image.NewCommand(stdout, stderr, runner))
7779
root.AddCommand(generate_video.NewCommand(stdout, stderr, runner))

‎cmd/submit_run.go‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"strings"
7+
8+
"github.com/Pippit-dev/pippit-cli/internal/common"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
func newSubmitRunCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
13+
var body struct {
14+
Message string `json:"message"`
15+
ThreadID string `json:"thread_id,omitempty"`
16+
AssetIDs []string `json:"asset_ids,omitempty"`
17+
}
18+
cmd := &cobra.Command{
19+
Use: "submit-run",
20+
Short: "Create a creative conversation or send a message to an existing thread",
21+
Args: cobra.NoArgs,
22+
RunE: withErrorLog("submit-run", nil, func(cmd *cobra.Command, _ []string) error {
23+
if strings.TrimSpace(body.Message) == "" {
24+
return fmt.Errorf("缺少必填参数 --message")
25+
}
26+
// Preserve the original message and identifiers; the backend owns routing.
27+
result, err := common.SubmitRun(cmd.Context(), "submit-run", body, runner)
28+
if err != nil {
29+
return err
30+
}
31+
return common.WriteJSON(stdout, result)
32+
}),
33+
}
34+
cmd.SetOut(stdout)
35+
cmd.SetErr(stderr)
36+
cmd.Flags().StringVar(&body.Message, "message", "", "original message to send (required)")
37+
cmd.Flags().StringVar(&body.ThreadID, "thread-id", "", "existing thread ID; omit to create a new thread")
38+
cmd.Flags().StringArrayVar(&body.AssetIDs, "asset-ids", nil, "asset ID to attach; repeat for multiple assets")
39+
return cmd
40+
}

‎cmd/submit_run_test.go‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"reflect"
9+
"strings"
10+
"testing"
11+
12+
"github.com/Pippit-dev/pippit-cli/internal/config"
13+
)
14+
15+
func TestSubmitRunCommandPreservesScriptContract(t *testing.T) {
16+
for _, existing := range []bool{false, true} {
17+
t.Run(map[bool]string{false: "new", true: "existing_with_assets"}[existing], func(t *testing.T) {
18+
message := " 根据参考素材生成视频\n保留原文 "
19+
want := map[string]any{"message": message}
20+
args := []string{"submit-run", "--message", message}
21+
if existing {
22+
want["thread_id"] = "skill_original_thread"
23+
want["asset_ids"] = []any{"asset_original_1", "asset_original_2"}
24+
args = append(args, "--thread-id", "skill_original_thread", "--asset-ids", "asset_original_1", "--asset-ids", "asset_original_2")
25+
}
26+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+
if r.Method != http.MethodPost || r.URL.Path != config.SubmitRunPath {
28+
t.Errorf("request = %s %s", r.Method, r.URL.Path)
29+
}
30+
if r.Header.Get("Authorization") != "Bearer test-token" {
31+
t.Error("missing bearer authorization")
32+
}
33+
var got map[string]any
34+
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
35+
t.Error(err)
36+
}
37+
if !reflect.DeepEqual(got, want) {
38+
t.Errorf("body = %#v, want %#v", got, want)
39+
}
40+
_, _ = w.Write([]byte(`{"ret":"0","data":{"run":{"thread_id":"thread_1","run_id":"run_1"},"web_thread_link":"https://xyq.jianying.com/thread_1"}}`))
41+
}))
42+
defer server.Close()
43+
var stdout, stderr bytes.Buffer
44+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
45+
root.SetArgs(args)
46+
if err := root.Execute(); err != nil {
47+
t.Fatal(err)
48+
}
49+
if got := stdout.String(); got != "{\"thread_id\":\"thread_1\",\"run_id\":\"run_1\",\"web_thread_link\":\"https://xyq.jianying.com/thread_1\"}\n" {
50+
t.Fatalf("unexpected output: %s", got)
51+
}
52+
})
53+
}
54+
}
55+
56+
func TestSubmitRunCommandValidatesBeforeRequest(t *testing.T) {
57+
for _, args := range [][]string{
58+
{"submit-run"},
59+
{"submit-run", "--message", " \n "},
60+
{"submit-run", "--message", "hello", "unexpected"},
61+
} {
62+
var stdout, stderr bytes.Buffer
63+
root := newRootCommand(&stdout, &stderr, nil)
64+
root.SetArgs(args)
65+
if err := root.Execute(); err == nil {
66+
t.Fatalf("Execute(%v) should fail before accessing the runner", args)
67+
}
68+
}
69+
}
70+
71+
func TestSubmitRunHelpDoesNotRequireCredentials(t *testing.T) {
72+
var stdout, stderr bytes.Buffer
73+
root := newRootCommand(&stdout, &stderr, nil)
74+
root.SetArgs([]string{"submit-run", "--help"})
75+
if err := root.Execute(); err != nil {
76+
t.Fatal(err)
77+
}
78+
for _, flag := range []string{"--message", "--thread-id", "--asset-ids"} {
79+
if !strings.Contains(stdout.String(), flag) {
80+
t.Errorf("help missing %s", flag)
81+
}
82+
}
83+
}
84+
85+
func TestSubmitRunCommandRejectsInvalidResponse(t *testing.T) {
86+
for _, response := range []string{
87+
`{"ret":"1","errmsg":"rejected"}`,
88+
`{"ret":"0","data":{"run":{"run_id":"run_1"}}}`,
89+
`{"ret":"0","data":{"run":{"thread_id":"thread_1"}}}`,
90+
} {
91+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
92+
_, _ = w.Write([]byte(response))
93+
}))
94+
var stdout, stderr bytes.Buffer
95+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
96+
root.SetArgs([]string{"submit-run", "--message", "hello"})
97+
err := root.Execute()
98+
server.Close()
99+
if err == nil || stdout.Len() != 0 {
100+
t.Fatalf("response %s: error = %v, stdout = %s", response, err, stdout.String())
101+
}
102+
}
103+
}

‎cmd/upload_file.go‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"mime"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/Pippit-dev/pippit-cli/internal/common"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
const maxMediaUploadBytes int64 = 500 * 1000 * 1000
16+
17+
func newUploadFileCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
18+
var opts common.UploadFileOptions
19+
cmd := &cobra.Command{
20+
Use: "upload-file",
21+
Short: "Upload an image, video, or MP3/WAV audio file",
22+
Args: cobra.NoArgs,
23+
RunE: withErrorLog("upload-file", nil, func(cmd *cobra.Command, _ []string) error {
24+
opts.Path = strings.TrimSpace(opts.Path)
25+
if err := validateMediaUpload(opts.Path); err != nil {
26+
return err
27+
}
28+
result, err := common.UploadFile(cmd.Context(), opts, runner)
29+
if err != nil {
30+
return err
31+
}
32+
return common.WriteJSON(stdout, result)
33+
}),
34+
}
35+
cmd.SetOut(stdout)
36+
cmd.SetErr(stderr)
37+
cmd.Flags().StringVar(&opts.Path, "path", "", "local media file to upload (required, less than 500 MB)")
38+
return cmd
39+
}
40+
41+
func validateMediaUpload(path string) error {
42+
if path == "" {
43+
return fmt.Errorf("缺少必填参数 --path")
44+
}
45+
info, err := os.Stat(path)
46+
if err != nil {
47+
return fmt.Errorf("获取上传文件信息失败: %w", err)
48+
}
49+
if !info.Mode().IsRegular() {
50+
return fmt.Errorf("上传路径必须是普通文件")
51+
}
52+
if info.Size() >= maxMediaUploadBytes {
53+
return fmt.Errorf("上传文件必须小于 500 MB(500000000 字节)")
54+
}
55+
ext := strings.ToLower(filepath.Ext(path))
56+
contentType := mime.TypeByExtension(ext)
57+
if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
58+
return nil
59+
}
60+
if ext == ".mp3" || ext == ".wav" {
61+
return nil
62+
}
63+
return fmt.Errorf("不支持的文件类型 %q,仅支持图片、视频和 .mp3/.wav 音频", ext)
64+
}

0 commit comments

Comments
 (0)