@pippit-dev/cli 1.0.1 → 1.0.3
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 +33 -3
- package/checksums.txt +6 -6
- package/cmd/download_result.go +1 -1
- package/cmd/generate_video/generate_video.go +48 -0
- package/cmd/generate_video/query_result.go +42 -0
- package/cmd/generate_video_test.go +542 -0
- package/cmd/get_thread.go +1 -0
- package/cmd/list_thread_file.go +1 -1
- package/cmd/root.go +27 -2
- package/cmd/short_drama/short_drama.go +52 -9
- package/cmd/short_drama_test.go +19 -19
- package/cmd/update/update.go +8 -8
- package/internal/common/client.go +29 -21
- package/internal/common/download_results.go +20 -14
- package/internal/common/error_log.go +62 -1
- package/internal/common/error_log_test.go +30 -0
- package/internal/common/get_thread.go +28 -14
- package/internal/common/get_thread_test.go +74 -0
- package/{cmd → internal/common}/json.go +3 -2
- package/internal/common/list_thread_file.go +6 -6
- package/internal/common/submit_run.go +29 -0
- package/internal/common/upload_file.go +23 -19
- package/internal/config/config.go +1 -1
- package/internal/generate_video/generate_video.go +199 -0
- package/internal/generate_video/query_result.go +344 -0
- package/internal/generate_video/query_result_test.go +31 -0
- package/internal/short_drama/submit_run.go +8 -28
- package/package.json +1 -1
- package/skills/short-drama/SKILL.md +8 -8
- package/skills/xyq-nest-skill/scripts/download_results.py +3 -1
- package/skills/xyq-nest-skill/scripts/upload_file.py +2 -2
- package/skills/xyq-nest-skill/scripts/xyq_common.py +4 -3
- package/cmd/upload_file.go +0 -50
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
package generate_video
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strconv"
|
|
9
|
+
"strings"
|
|
10
|
+
"unicode"
|
|
11
|
+
|
|
12
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const (
|
|
16
|
+
successRunState = 3
|
|
17
|
+
failedRunState = 4
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// QueryResultOptions is the command-facing request shape for query-result.
|
|
21
|
+
type QueryResultOptions struct {
|
|
22
|
+
ThreadID string
|
|
23
|
+
RunID string
|
|
24
|
+
DownloadDir string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// QueryResultResult describes the user-facing query-result outcome.
|
|
28
|
+
type QueryResultResult struct {
|
|
29
|
+
Completed bool `json:"completed"`
|
|
30
|
+
ThreadID string `json:"thread_id"`
|
|
31
|
+
RunID string `json:"run_id"`
|
|
32
|
+
ErrorMessage string `json:"error_message"`
|
|
33
|
+
Videos []QueryResultVideo `json:"videos"`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// QueryResultVideo describes a downloaded video from query-result.
|
|
37
|
+
type QueryResultVideo struct {
|
|
38
|
+
DownloadURL string `json:"download_url"`
|
|
39
|
+
OutputPath string `json:"output_path"`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type queryThread struct {
|
|
43
|
+
ThreadID string `json:"thread_id"`
|
|
44
|
+
RunList []queryRun `json:"run_list"`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type queryRun struct {
|
|
48
|
+
RunID string `json:"run_id"`
|
|
49
|
+
State int `json:"state"`
|
|
50
|
+
ErrorMessage string `json:"error_message"`
|
|
51
|
+
ErrorMsg string `json:"error_msg"`
|
|
52
|
+
Errmsg string `json:"errmsg"`
|
|
53
|
+
EntryList []queryEntry `json:"entry_list"`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type queryEntry struct {
|
|
57
|
+
Artifact queryArtifact `json:"artifact"`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type queryArtifact struct {
|
|
61
|
+
Content []queryContent `json:"content"`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type queryContent struct {
|
|
65
|
+
SubType string `json:"sub_type"`
|
|
66
|
+
Data queryContentData `json:"data"`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type queryContentData struct {
|
|
70
|
+
Video *queryVideo `json:"video"`
|
|
71
|
+
ErrorMessage string `json:"error_message"`
|
|
72
|
+
ErrorCode json.RawMessage `json:"error_code"`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type queryVideo struct {
|
|
76
|
+
DownloadURL string `json:"download_url"`
|
|
77
|
+
Title string `json:"title"`
|
|
78
|
+
VID string `json:"vid"`
|
|
79
|
+
AssetID string `json:"asset_id"`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.Runner) (*QueryResultResult, error) {
|
|
83
|
+
if err := validateQueryResultOptions(opts); err != nil {
|
|
84
|
+
return nil, err
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
threadResult, err := common.GetThread(ctx, &common.GetThreadOptions{
|
|
88
|
+
ThreadID: opts.ThreadID,
|
|
89
|
+
RunID: opts.RunID,
|
|
90
|
+
}, runner)
|
|
91
|
+
if err != nil {
|
|
92
|
+
return nil, fmt.Errorf("查询失败:%w", err)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
thread, err := parseQueryThread(threadResult)
|
|
96
|
+
if err != nil {
|
|
97
|
+
return nil, fmt.Errorf("查询失败:%w", err)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
run, ok := findQueryRun(thread, opts.RunID)
|
|
101
|
+
if !ok {
|
|
102
|
+
return nil, fmt.Errorf("查询失败:未找到 run_id=%s 对应的 Run", opts.RunID)
|
|
103
|
+
}
|
|
104
|
+
if run.State != successRunState {
|
|
105
|
+
result := &QueryResultResult{
|
|
106
|
+
Completed: run.State == failedRunState,
|
|
107
|
+
ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID),
|
|
108
|
+
RunID: opts.RunID,
|
|
109
|
+
Videos: []QueryResultVideo{},
|
|
110
|
+
}
|
|
111
|
+
if run.State == failedRunState {
|
|
112
|
+
result.ErrorMessage = firstNonEmpty(extractQueryErrorMessage(run), "Run 失败")
|
|
113
|
+
}
|
|
114
|
+
return result, nil
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
videos := extractQueryVideos(run)
|
|
118
|
+
if len(videos) == 0 {
|
|
119
|
+
return nil, fmt.Errorf("下载失败:未找到可下载的视频产物")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
downloadDir, err := expandPath(opts.DownloadDir)
|
|
123
|
+
if err != nil {
|
|
124
|
+
return nil, fmt.Errorf("下载失败:解析下载目录失败:%w", err)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
resultVideos := make([]QueryResultVideo, 0, len(videos))
|
|
128
|
+
usedNames := make(map[string]int, len(videos))
|
|
129
|
+
for i, video := range videos {
|
|
130
|
+
if strings.TrimSpace(video.DownloadURL) == "" {
|
|
131
|
+
return nil, fmt.Errorf("下载失败:第 %d 个视频产物 download_url 为空", i+1)
|
|
132
|
+
}
|
|
133
|
+
outputPath := filepath.Join(downloadDir, uniqueQueryResultFileName(videoFileName(video, i+1), usedNames))
|
|
134
|
+
download, err := common.DownloadResult(ctx, common.DownloadResultOptions{
|
|
135
|
+
URL: video.DownloadURL,
|
|
136
|
+
OutputPath: outputPath,
|
|
137
|
+
Workers: 5,
|
|
138
|
+
}, runner)
|
|
139
|
+
if err != nil {
|
|
140
|
+
return nil, fmt.Errorf("下载失败:%w", err)
|
|
141
|
+
}
|
|
142
|
+
actualOutputPath := outputPath
|
|
143
|
+
if len(download.Downloaded) > 0 {
|
|
144
|
+
actualOutputPath = download.Downloaded[0]
|
|
145
|
+
} else if len(download.AlreadyExist) > 0 {
|
|
146
|
+
actualOutputPath = download.AlreadyExist[0]
|
|
147
|
+
}
|
|
148
|
+
resultVideos = append(resultVideos, QueryResultVideo{
|
|
149
|
+
DownloadURL: video.DownloadURL,
|
|
150
|
+
OutputPath: actualOutputPath,
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return &QueryResultResult{
|
|
155
|
+
Completed: true,
|
|
156
|
+
ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID),
|
|
157
|
+
RunID: opts.RunID,
|
|
158
|
+
Videos: resultVideos,
|
|
159
|
+
}, nil
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func validateQueryResultOptions(opts *QueryResultOptions) error {
|
|
163
|
+
if opts == nil {
|
|
164
|
+
return fmt.Errorf("查询失败:缺少必填参数 --thread-id")
|
|
165
|
+
}
|
|
166
|
+
opts.ThreadID = strings.TrimSpace(opts.ThreadID)
|
|
167
|
+
opts.RunID = strings.TrimSpace(opts.RunID)
|
|
168
|
+
opts.DownloadDir = strings.TrimSpace(opts.DownloadDir)
|
|
169
|
+
if opts.ThreadID == "" {
|
|
170
|
+
return fmt.Errorf("查询失败:缺少必填参数 --thread-id")
|
|
171
|
+
}
|
|
172
|
+
if opts.RunID == "" {
|
|
173
|
+
return fmt.Errorf("查询失败:缺少必填参数 --run-id")
|
|
174
|
+
}
|
|
175
|
+
if opts.DownloadDir == "" {
|
|
176
|
+
return fmt.Errorf("查询失败:缺少必填参数 --download-dir")
|
|
177
|
+
}
|
|
178
|
+
return nil
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func parseQueryThread(result *common.GetThreadResult) (*queryThread, error) {
|
|
182
|
+
if result == nil {
|
|
183
|
+
return nil, fmt.Errorf("get_thread 响应为空")
|
|
184
|
+
}
|
|
185
|
+
if len(result.RawData) > 0 {
|
|
186
|
+
var data map[string]json.RawMessage
|
|
187
|
+
if err := json.Unmarshal(result.RawData, &data); err == nil {
|
|
188
|
+
if raw := data["thread"]; len(raw) > 0 {
|
|
189
|
+
if thread, ok := decodeQueryThread(raw); ok {
|
|
190
|
+
return thread, nil
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return nil, fmt.Errorf("get_thread 响应中未找到 data.thread")
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
func decodeQueryThread(raw []byte) (*queryThread, bool) {
|
|
199
|
+
var thread queryThread
|
|
200
|
+
if err := json.Unmarshal(raw, &thread); err != nil {
|
|
201
|
+
return nil, false
|
|
202
|
+
}
|
|
203
|
+
if thread.ThreadID == "" && len(thread.RunList) == 0 {
|
|
204
|
+
return nil, false
|
|
205
|
+
}
|
|
206
|
+
return &thread, true
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
func findQueryRun(thread *queryThread, runID string) (queryRun, bool) {
|
|
210
|
+
for _, run := range thread.RunList {
|
|
211
|
+
if run.RunID == runID {
|
|
212
|
+
return run, true
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return queryRun{}, false
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
func (data *queryContentData) UnmarshalJSON(raw []byte) error {
|
|
219
|
+
raw = []byte(strings.TrimSpace(string(raw)))
|
|
220
|
+
if len(raw) == 0 || string(raw) == "null" {
|
|
221
|
+
return nil
|
|
222
|
+
}
|
|
223
|
+
if raw[0] == '"' {
|
|
224
|
+
var encoded string
|
|
225
|
+
if err := json.Unmarshal(raw, &encoded); err != nil {
|
|
226
|
+
return err
|
|
227
|
+
}
|
|
228
|
+
encoded = strings.TrimSpace(encoded)
|
|
229
|
+
if encoded == "" {
|
|
230
|
+
return nil
|
|
231
|
+
}
|
|
232
|
+
raw = []byte(encoded)
|
|
233
|
+
}
|
|
234
|
+
if len(raw) == 0 || raw[0] != '{' {
|
|
235
|
+
return nil
|
|
236
|
+
}
|
|
237
|
+
type alias queryContentData
|
|
238
|
+
return json.Unmarshal(raw, (*alias)(data))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
func extractQueryVideos(run queryRun) []queryVideo {
|
|
242
|
+
videos := make([]queryVideo, 0)
|
|
243
|
+
for _, entry := range run.EntryList {
|
|
244
|
+
artifact := entry.Artifact
|
|
245
|
+
for _, content := range artifact.Content {
|
|
246
|
+
if content.SubType != "biz/x_data_video" {
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
data := content.Data
|
|
250
|
+
if data.Video != nil {
|
|
251
|
+
videos = append(videos, *data.Video)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return videos
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
func extractQueryErrorMessage(run queryRun) string {
|
|
259
|
+
if message := firstNonEmpty(run.ErrorMessage, run.ErrorMsg, run.Errmsg); message != "" {
|
|
260
|
+
return message
|
|
261
|
+
}
|
|
262
|
+
for _, entry := range run.EntryList {
|
|
263
|
+
for _, content := range entry.Artifact.Content {
|
|
264
|
+
data := content.Data
|
|
265
|
+
if message := firstNonEmpty(data.ErrorMessage); message != "" {
|
|
266
|
+
if code := rawMessageString(data.ErrorCode); code != "" {
|
|
267
|
+
return fmt.Sprintf("%s (error_code=%s)", message, code)
|
|
268
|
+
}
|
|
269
|
+
return message
|
|
270
|
+
}
|
|
271
|
+
if code := rawMessageString(data.ErrorCode); code != "" {
|
|
272
|
+
return "error_code=" + code
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return ""
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
func rawMessageString(raw json.RawMessage) string {
|
|
280
|
+
raw = json.RawMessage(strings.TrimSpace(string(raw)))
|
|
281
|
+
if len(raw) == 0 || string(raw) == "null" {
|
|
282
|
+
return ""
|
|
283
|
+
}
|
|
284
|
+
var value string
|
|
285
|
+
if err := json.Unmarshal(raw, &value); err == nil {
|
|
286
|
+
return strings.TrimSpace(value)
|
|
287
|
+
}
|
|
288
|
+
return strings.TrimSpace(string(raw))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
func videoFileName(video queryVideo, index int) string {
|
|
292
|
+
name := firstNonEmpty(video.VID, video.Title, video.AssetID)
|
|
293
|
+
if name == "" {
|
|
294
|
+
name = "result_" + strconv.Itoa(index)
|
|
295
|
+
}
|
|
296
|
+
name = sanitizeFileName(name)
|
|
297
|
+
if !hasVideoExtension(name) {
|
|
298
|
+
name += ".mp4"
|
|
299
|
+
}
|
|
300
|
+
return name
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
func hasVideoExtension(name string) bool {
|
|
304
|
+
switch strings.ToLower(strings.TrimSpace(filepath.Ext(name))) {
|
|
305
|
+
case ".mp4", ".mov", ".m4v", ".webm":
|
|
306
|
+
return true
|
|
307
|
+
default:
|
|
308
|
+
return false
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
func firstNonEmpty(values ...string) string {
|
|
313
|
+
for _, value := range values {
|
|
314
|
+
value = strings.TrimSpace(value)
|
|
315
|
+
if value != "" {
|
|
316
|
+
return value
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return ""
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
func sanitizeFileName(name string) string {
|
|
323
|
+
name = strings.TrimSpace(name)
|
|
324
|
+
if name == "" {
|
|
325
|
+
return "result.mp4"
|
|
326
|
+
}
|
|
327
|
+
return strings.Map(func(r rune) rune {
|
|
328
|
+
if unicode.IsControl(r) || r == '/' || r == '\\' || strings.ContainsRune(`<>:"|?*`, r) {
|
|
329
|
+
return '_'
|
|
330
|
+
}
|
|
331
|
+
return r
|
|
332
|
+
}, name)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
func uniqueQueryResultFileName(name string, used map[string]int) string {
|
|
336
|
+
count := used[name] + 1
|
|
337
|
+
used[name] = count
|
|
338
|
+
if count == 1 {
|
|
339
|
+
return name
|
|
340
|
+
}
|
|
341
|
+
ext := filepath.Ext(name)
|
|
342
|
+
base := strings.TrimSuffix(name, ext)
|
|
343
|
+
return fmt.Sprintf("%s-%d%s", base, count, ext)
|
|
344
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package generate_video
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
func TestVideoFileNameUsesVIDBeforeTimestampTitle(t *testing.T) {
|
|
6
|
+
got := videoFileName(queryVideo{
|
|
7
|
+
Title: "v03c76g10004d8jp38iljhtepa11k25g_2026-06-09T120814.516",
|
|
8
|
+
VID: "v03c76g10004d8jp38iljhtepa11k25g",
|
|
9
|
+
}, 1)
|
|
10
|
+
want := "v03c76g10004d8jp38iljhtepa11k25g.mp4"
|
|
11
|
+
if got != want {
|
|
12
|
+
t.Fatalf("videoFileName() = %q, want %q", got, want)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func TestVideoFileNameAddsMP4ForTimestampTitleWithoutVID(t *testing.T) {
|
|
17
|
+
got := videoFileName(queryVideo{
|
|
18
|
+
Title: "v03c76g10004d8jp38iljhtepa11k25g_2026-06-09T120814.516",
|
|
19
|
+
}, 1)
|
|
20
|
+
want := "v03c76g10004d8jp38iljhtepa11k25g_2026-06-09T120814.516.mp4"
|
|
21
|
+
if got != want {
|
|
22
|
+
t.Fatalf("videoFileName() = %q, want %q", got, want)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func TestVideoFileNameKeepsVideoExtension(t *testing.T) {
|
|
27
|
+
got := videoFileName(queryVideo{Title: "cat_video.mp4"}, 1)
|
|
28
|
+
if got != "cat_video.mp4" {
|
|
29
|
+
t.Fatalf("videoFileName() = %q, want cat_video.mp4", got)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -5,7 +5,6 @@ import (
|
|
|
5
5
|
"fmt"
|
|
6
6
|
|
|
7
7
|
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
8
|
-
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
9
8
|
)
|
|
10
9
|
|
|
11
10
|
// SubmitRunOptions is the stable command-facing request shape for short drama run submission.
|
|
@@ -22,21 +21,9 @@ type SubmitRunResult struct {
|
|
|
22
21
|
WebThreadLink string `json:"web_thread_link"`
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
type submitRunResponse struct {
|
|
26
|
-
Ret string `json:"ret"`
|
|
27
|
-
Errmsg string `json:"errmsg"`
|
|
28
|
-
Data struct {
|
|
29
|
-
WebThreadLink string `json:"web_thread_link"`
|
|
30
|
-
Run struct {
|
|
31
|
-
ThreadID string `json:"thread_id"`
|
|
32
|
-
RunID string `json:"run_id"`
|
|
33
|
-
} `json:"run"`
|
|
34
|
-
} `json:"data"`
|
|
35
|
-
}
|
|
36
|
-
|
|
37
24
|
func SubmitRun(ctx context.Context, opts *SubmitRunOptions, runner *common.Runner) (*SubmitRunResult, error) {
|
|
38
25
|
if runner == nil || runner.Client == nil {
|
|
39
|
-
return nil, fmt.Errorf("submit_run
|
|
26
|
+
return nil, fmt.Errorf("submit_run 运行器客户端缺失")
|
|
40
27
|
}
|
|
41
28
|
|
|
42
29
|
body := map[string]any{
|
|
@@ -50,21 +37,21 @@ func SubmitRun(ctx context.Context, opts *SubmitRunOptions, runner *common.Runne
|
|
|
50
37
|
}
|
|
51
38
|
body["agent_name"] = "pippit_nest_novel_agent"
|
|
52
39
|
|
|
53
|
-
var resp
|
|
54
|
-
if err := runner.Client.SendRequest(ctx,
|
|
55
|
-
return nil, fmt.Errorf("
|
|
40
|
+
var resp common.SubmitRunResponse
|
|
41
|
+
if err := runner.Client.SendRequest(ctx, common.SubmitRunPath(runner), body, &resp); err != nil {
|
|
42
|
+
return nil, fmt.Errorf("提交 short_drama 请求失败: %w", err)
|
|
56
43
|
}
|
|
57
44
|
if resp.Ret != "0" {
|
|
58
45
|
if resp.Errmsg == "" {
|
|
59
|
-
resp.Errmsg = "
|
|
46
|
+
resp.Errmsg = "未知错误"
|
|
60
47
|
}
|
|
61
|
-
return nil, fmt.Errorf("
|
|
48
|
+
return nil, fmt.Errorf("short_drama 请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg)
|
|
62
49
|
}
|
|
63
50
|
if resp.Data.Run.ThreadID == "" {
|
|
64
|
-
return nil, fmt.Errorf("
|
|
51
|
+
return nil, fmt.Errorf("short_drama 响应缺少 data.run.thread_id")
|
|
65
52
|
}
|
|
66
53
|
if resp.Data.Run.RunID == "" {
|
|
67
|
-
return nil, fmt.Errorf("
|
|
54
|
+
return nil, fmt.Errorf("short_drama 响应缺少 data.run.run_id")
|
|
68
55
|
}
|
|
69
56
|
return &SubmitRunResult{
|
|
70
57
|
ThreadID: resp.Data.Run.ThreadID,
|
|
@@ -72,10 +59,3 @@ func SubmitRun(ctx context.Context, opts *SubmitRunOptions, runner *common.Runne
|
|
|
72
59
|
WebThreadLink: resp.Data.WebThreadLink,
|
|
73
60
|
}, nil
|
|
74
61
|
}
|
|
75
|
-
|
|
76
|
-
func submitRunPath(runner *common.Runner) string {
|
|
77
|
-
if runner != nil && runner.Config != nil && runner.Config.Paths != nil && runner.Config.Paths.SubmitRun != "" {
|
|
78
|
-
return runner.Config.Paths.SubmitRun
|
|
79
|
-
}
|
|
80
|
-
return config.SubmitRunPath
|
|
81
|
-
}
|
package/package.json
CHANGED
|
@@ -101,10 +101,10 @@ pippit-tool-cli get-thread --thread-id THREAD_ID --run-id RUN_ID
|
|
|
101
101
|
|
|
102
102
|
### 3. 上传文件
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
当用户提供短剧大纲、人物设定、世界观设定、已有分集或剧本等本地参考文件时,可先上传文件。`+upload-file` 当前只接收本地文件路径,并且只支持 `.doc`、`.docx` 和 `.txt` 后缀;不要把 `.md`、`.pdf`、图片、视频或 URL 传给该命令。
|
|
105
105
|
|
|
106
106
|
```bash
|
|
107
|
-
pippit-tool-cli upload-file --path /path/to/outline.txt
|
|
107
|
+
pippit-tool-cli short-drama +upload-file --path /path/to/outline.txt
|
|
108
108
|
```
|
|
109
109
|
|
|
110
110
|
上传成功后命令只返回 `asset_id`:
|
|
@@ -179,7 +179,7 @@ pippit-tool-cli download-result --url DOWNLOAD_URL --output-path FILE_PATH --upd
|
|
|
179
179
|
|
|
180
180
|
```
|
|
181
181
|
1. 检查用户提供的是一个本地 `.doc`、`.docx` 或 `.txt` 剧本文件路径;如果不是,告知当前上传命令只支持这三类文件,不要擅自转换或改写文件。
|
|
182
|
-
2. pippit-tool-cli upload-file --path /path/to/file.txt
|
|
182
|
+
2. pippit-tool-cli short-drama +upload-file --path /path/to/file.txt
|
|
183
183
|
→ 拿到 asset_id
|
|
184
184
|
3. pippit-tool-cli short-drama +submit-run --message "用户的原始短剧需求" --asset-ids asset_id
|
|
185
185
|
→ 拿到 thread_id、run_id 和 web_thread_link
|
|
@@ -242,7 +242,7 @@ Thread: thread_...
|
|
|
242
242
|
[assistant] ...
|
|
243
243
|
```
|
|
244
244
|
|
|
245
|
-
**upload-file** 返回:
|
|
245
|
+
**short-drama +upload-file** 返回:
|
|
246
246
|
|
|
247
247
|
```json
|
|
248
248
|
{
|
|
@@ -250,7 +250,7 @@ Thread: thread_...
|
|
|
250
250
|
}
|
|
251
251
|
```
|
|
252
252
|
|
|
253
|
-
|
|
253
|
+
`+upload-file` 通过 `multipart/form-data` 上传文件,表单文件字段名为 `file`。本地文件必须存在、不能是目录,后缀必须是 `.doc`、`.docx` 或 `.txt`;不支持的后缀会直接报错。返回的 `asset_id` 来自服务端 `pippit_asset_id`,如果没有该字段才回退到 `asset_id`。
|
|
254
254
|
|
|
255
255
|
**list-thread-file** 返回:
|
|
256
256
|
|
|
@@ -323,7 +323,7 @@ Thread: thread_...
|
|
|
323
323
|
|
|
324
324
|
你要做的只有三件事:
|
|
325
325
|
|
|
326
|
-
1. **上传**:如果用户给了本地 `.doc` / `.docx` / `.txt` 参考文件,先调用
|
|
326
|
+
1. **上传**:如果用户给了本地 `.doc` / `.docx` / `.txt` 参考文件,先调用 `+upload-file`。
|
|
327
327
|
2. **提交任务**:首次创作时把用户原始短剧需求和唯一剧本 `asset_id` 通过 `+submit-run --asset-ids` 发给后端;同一 `thread_id` 后续续写或修改不再追加新的剧本文件。
|
|
328
328
|
3. **传话、取文件、下载资源**:根据 `get-thread` 返回的 `readable_text` 展示进展、问题和结果;遇到表单、问卷、选项或按钮时,只做流程合理性清洗,不替用户决定创作内容;根据 `list-thread-file` 获取文件列表;再根据 `download_url` 调用 `download-result` 把缺失资源下载到用户本地。
|
|
329
329
|
|
|
@@ -341,8 +341,8 @@ Thread: thread_...
|
|
|
341
341
|
- `--message` 是用户的原始短剧需求,不能为空。
|
|
342
342
|
- 查询进展时优先使用 `+submit-run` 返回的 `thread_id` 和 `run_id`;如果需要查看整个会话,可以省略 `--run-id`。
|
|
343
343
|
- `get-thread` 当前固定走服务端 v2 响应,输出字段是 `readable_text`;不要解析旧版 `messages` 数组。
|
|
344
|
-
-
|
|
345
|
-
-
|
|
344
|
+
- `+upload-file` 当前用于短剧场景文件上传链路,只支持本地 `.doc` / `.docx` / `.txt` 文件;`--path` 不能为空,路径必须指向真实文件,不能是目录。
|
|
345
|
+
- `+upload-file` 上传成功后只返回 `asset_id`;把该值原样作为 `+submit-run --asset-ids` 的参数。
|
|
346
346
|
- 单次创作会话中(相同 `thread_id`),`+submit-run` 只支持绑定一个剧本文件。不要在同一 `thread_id` 下重复上传并追加第二个剧本 `asset_id`;用户给多个剧本时,先让用户选择一个,或分别开启新的创作会话。
|
|
347
347
|
- `list-thread-file` 只需要 `thread_id`;分页参数使用 `--page-num 1 --page-size 200` 起步,`total` 达到 200 时下一轮递增 `page-num`。
|
|
348
348
|
- `list-thread-file` 和 `download-result` 是两个不同的 CLI 指令:前者获取会话文件元信息,后者下载 URL 资源并写入到本地目标文件路径。
|
|
@@ -11,6 +11,8 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
11
11
|
|
|
12
12
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
13
13
|
|
|
14
|
+
HTTP_TIMEOUT_SECONDS = 30 * 60
|
|
15
|
+
|
|
14
16
|
|
|
15
17
|
def download_file(url, filepath):
|
|
16
18
|
"""下载单个文件"""
|
|
@@ -18,7 +20,7 @@ def download_file(url, filepath):
|
|
|
18
20
|
req = urllib.request.Request(url, headers={"User-Agent": "XYQ-Nest-Skill/1.0"})
|
|
19
21
|
tmp_path = filepath + ".tmp"
|
|
20
22
|
try:
|
|
21
|
-
with urllib.request.urlopen(req, timeout=
|
|
23
|
+
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_SECONDS) as resp:
|
|
22
24
|
with open(tmp_path, "wb") as f:
|
|
23
25
|
shutil.copyfileobj(resp, f, length=1024 * 1024)
|
|
24
26
|
os.replace(tmp_path, filepath)
|
|
@@ -11,7 +11,7 @@ import urllib.request
|
|
|
11
11
|
import urllib.error
|
|
12
12
|
|
|
13
13
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
14
|
-
from xyq_common import XYQ_BASE, ACCESS_KEY, UPLOAD_FILE_PATH, parse_response
|
|
14
|
+
from xyq_common import XYQ_BASE, ACCESS_KEY, UPLOAD_FILE_PATH, HTTP_TIMEOUT_SECONDS, parse_response
|
|
15
15
|
|
|
16
16
|
# 允许的 MIME 类型前缀
|
|
17
17
|
ALLOWED_PREFIXES = ("image/", "video/")
|
|
@@ -70,7 +70,7 @@ def upload_file(file_path: str) -> dict:
|
|
|
70
70
|
},
|
|
71
71
|
)
|
|
72
72
|
try:
|
|
73
|
-
with urllib.request.urlopen(req, timeout=
|
|
73
|
+
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_SECONDS) as resp:
|
|
74
74
|
result = json.loads(resp.read().decode("utf-8"))
|
|
75
75
|
return parse_response(result)
|
|
76
76
|
except urllib.error.HTTPError as e:
|
|
@@ -13,6 +13,7 @@ ACCESS_KEY = os.environ.get("XYQ_ACCESS_KEY", "")
|
|
|
13
13
|
SUBMIT_RUN_PATH = "/api/biz/v1/skill/submit_run"
|
|
14
14
|
GET_THREAD_PATH = "/api/biz/v1/skill/get_thread"
|
|
15
15
|
UPLOAD_FILE_PATH = "/api/biz/v1/skill/upload_file"
|
|
16
|
+
HTTP_TIMEOUT_SECONDS = 30 * 60
|
|
16
17
|
|
|
17
18
|
if not ACCESS_KEY:
|
|
18
19
|
print("错误:请设置 XYQ_ACCESS_KEY 环境变量", file=sys.stderr)
|
|
@@ -22,7 +23,7 @@ if not ACCESS_KEY:
|
|
|
22
23
|
def _headers():
|
|
23
24
|
return {
|
|
24
25
|
"Authorization": f"Bearer {ACCESS_KEY}",
|
|
25
|
-
"Content-Type": "application/json"
|
|
26
|
+
"Content-Type": "application/json",
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
|
|
@@ -37,7 +38,7 @@ def api_post(path: str, body: dict) -> dict:
|
|
|
37
38
|
headers=_headers(),
|
|
38
39
|
)
|
|
39
40
|
try:
|
|
40
|
-
with urllib.request.urlopen(req, timeout=
|
|
41
|
+
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_SECONDS) as resp:
|
|
41
42
|
return json.loads(resp.read().decode("utf-8"))
|
|
42
43
|
except urllib.error.HTTPError as e:
|
|
43
44
|
err_body = e.read().decode("utf-8") if e.fp else ""
|
|
@@ -53,7 +54,7 @@ def api_get(path: str) -> dict:
|
|
|
53
54
|
url = f"{XYQ_BASE.rstrip('/')}{path}"
|
|
54
55
|
req = urllib.request.Request(url, method="GET", headers=_headers())
|
|
55
56
|
try:
|
|
56
|
-
with urllib.request.urlopen(req, timeout=
|
|
57
|
+
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_SECONDS) as resp:
|
|
57
58
|
return json.loads(resp.read().decode("utf-8"))
|
|
58
59
|
except urllib.error.HTTPError as e:
|
|
59
60
|
err_body = e.read().decode("utf-8") if e.fp else ""
|
package/cmd/upload_file.go
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
package cmd
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"fmt"
|
|
5
|
-
"io"
|
|
6
|
-
"path/filepath"
|
|
7
|
-
"strings"
|
|
8
|
-
|
|
9
|
-
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
10
|
-
"github.com/spf13/cobra"
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
func newUploadFileCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
|
|
14
|
-
var opts common.UploadFileOptions
|
|
15
|
-
|
|
16
|
-
cmd := &cobra.Command{
|
|
17
|
-
Use: "upload-file",
|
|
18
|
-
Short: "Upload a file",
|
|
19
|
-
Args: cobra.NoArgs,
|
|
20
|
-
RunE: withErrorLog("upload-file", func() map[string]string {
|
|
21
|
-
return map[string]string{
|
|
22
|
-
"file_name": fileNameForLog(opts.Path),
|
|
23
|
-
}
|
|
24
|
-
}, func(cmd *cobra.Command, _ []string) error {
|
|
25
|
-
opts.Path = strings.TrimSpace(opts.Path)
|
|
26
|
-
|
|
27
|
-
if opts.Path == "" {
|
|
28
|
-
return fmt.Errorf("--path is required")
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
result, err := common.UploadFile(cmd.Context(), opts, runner)
|
|
32
|
-
if err != nil {
|
|
33
|
-
return err
|
|
34
|
-
}
|
|
35
|
-
return writeJSON(stdout, result)
|
|
36
|
-
}),
|
|
37
|
-
}
|
|
38
|
-
cmd.SetOut(stdout)
|
|
39
|
-
cmd.SetErr(stderr)
|
|
40
|
-
cmd.Flags().StringVar(&opts.Path, "path", "", "local file path to upload")
|
|
41
|
-
return cmd
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
func fileNameForLog(path string) string {
|
|
45
|
-
path = strings.TrimSpace(path)
|
|
46
|
-
if path == "" {
|
|
47
|
-
return ""
|
|
48
|
-
}
|
|
49
|
-
return filepath.Base(path)
|
|
50
|
-
}
|