@pippit-dev/cli 1.0.0 → 1.0.2
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 +37 -5
- package/checksums.txt +6 -6
- package/cmd/download_result.go +57 -0
- package/cmd/generate_video/generate_video.go +48 -0
- package/cmd/generate_video/query_result.go +58 -0
- package/cmd/generate_video_test.go +468 -0
- package/cmd/get_thread.go +59 -0
- package/cmd/list_thread_file.go +51 -0
- package/cmd/root.go +30 -1
- package/cmd/short_drama/short_drama.go +12 -133
- package/cmd/short_drama_test.go +76 -49
- package/cmd/update/update.go +8 -8
- package/internal/common/client.go +29 -21
- package/internal/common/download_results.go +21 -15
- package/internal/common/error_log.go +62 -1
- package/internal/common/error_log_test.go +33 -3
- package/internal/common/get_thread.go +29 -15
- package/internal/common/get_thread_test.go +74 -0
- package/internal/common/json.go +18 -0
- package/internal/common/list_thread_file.go +7 -7
- package/internal/common/submit_run.go +29 -0
- package/internal/common/upload_file.go +22 -18
- package/internal/config/config.go +1 -1
- package/internal/generate_video/generate_video.go +199 -0
- package/internal/generate_video/query_result.go +291 -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 +34 -34
- 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
|
@@ -20,6 +20,7 @@ import (
|
|
|
20
20
|
|
|
21
21
|
type Client interface {
|
|
22
22
|
SendRequest(ctx context.Context, path string, body any, out any) error
|
|
23
|
+
SendRequestWithHeaders(ctx context.Context, path string, body any, headers map[string]string, out any) error
|
|
23
24
|
SendMultipartRequest(ctx context.Context, path string, fields map[string]string, file MultipartFile, out any) error
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -53,6 +54,10 @@ func NewHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuth
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
func (c *httpClient) SendRequest(ctx context.Context, path string, body any, out any) error {
|
|
57
|
+
return c.SendRequestWithHeaders(ctx, path, body, nil, out)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func (c *httpClient) SendRequestWithHeaders(ctx context.Context, path string, body any, headers map[string]string, out any) error {
|
|
56
61
|
method := http.MethodPost
|
|
57
62
|
if body == nil {
|
|
58
63
|
method = http.MethodGet
|
|
@@ -67,27 +72,27 @@ func (c *httpClient) SendRequest(ctx context.Context, path string, body any, out
|
|
|
67
72
|
if body != nil {
|
|
68
73
|
payload, err := sonic.Marshal(body)
|
|
69
74
|
if err != nil {
|
|
70
|
-
return fmt.Errorf("
|
|
75
|
+
return fmt.Errorf("编码请求体失败: %w", err)
|
|
71
76
|
}
|
|
72
77
|
reader = bytes.NewReader(payload)
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
req, err := http.NewRequestWithContext(ctx, method, reqURL, reader)
|
|
76
81
|
if err != nil {
|
|
77
|
-
return fmt.Errorf("
|
|
82
|
+
return fmt.Errorf("构造 %s 请求失败: %w", method, err)
|
|
78
83
|
}
|
|
79
84
|
if body != nil {
|
|
80
85
|
req.Header.Set("Content-Type", "application/json")
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
if c.authorizer == nil {
|
|
84
|
-
return fmt.Errorf("
|
|
89
|
+
return fmt.Errorf("授权请求缺少认证器")
|
|
85
90
|
}
|
|
86
91
|
if err := c.authorizer.Inject(ctx, req); err != nil {
|
|
87
|
-
return fmt.Errorf("
|
|
92
|
+
return fmt.Errorf("写入认证请求头失败: %w", err)
|
|
88
93
|
}
|
|
89
94
|
|
|
90
|
-
c.injectHeaders(req)
|
|
95
|
+
c.injectHeaders(req, headers)
|
|
91
96
|
|
|
92
97
|
// If out is **http.Response, return the raw response for streaming (e.g. file download).
|
|
93
98
|
if out != nil {
|
|
@@ -95,11 +100,11 @@ func (c *httpClient) SendRequest(ctx context.Context, path string, body any, out
|
|
|
95
100
|
if rv.Elem().Type().Elem() == reflect.TypeOf(http.Response{}) {
|
|
96
101
|
resp, err := c.httpClient.Do(req)
|
|
97
102
|
if err != nil {
|
|
98
|
-
return fmt.Errorf("%s %s
|
|
103
|
+
return fmt.Errorf("%s %s 请求失败: %w", method, reqURL, err)
|
|
99
104
|
}
|
|
100
105
|
if resp.StatusCode >= 400 {
|
|
101
106
|
defer resp.Body.Close()
|
|
102
|
-
return fmt.Errorf("%s %s
|
|
107
|
+
return fmt.Errorf("%s %s 返回 HTTP %d", method, reqURL, resp.StatusCode)
|
|
103
108
|
}
|
|
104
109
|
rv.Elem().Set(reflect.ValueOf(resp))
|
|
105
110
|
return nil
|
|
@@ -118,7 +123,7 @@ func (c *httpClient) SendMultipartRequest(ctx context.Context, path string, fiel
|
|
|
118
123
|
return err
|
|
119
124
|
}
|
|
120
125
|
if file.FieldName == "" {
|
|
121
|
-
return fmt.Errorf("multipart
|
|
126
|
+
return fmt.Errorf("multipart 文件字段名不能为空")
|
|
122
127
|
}
|
|
123
128
|
if file.FileName == "" {
|
|
124
129
|
file.FileName = filepath.Base(file.Path)
|
|
@@ -133,7 +138,7 @@ func (c *httpClient) SendMultipartRequest(ctx context.Context, path string, fiel
|
|
|
133
138
|
if err != nil {
|
|
134
139
|
_ = pr.Close()
|
|
135
140
|
_ = pw.Close()
|
|
136
|
-
return fmt.Errorf("
|
|
141
|
+
return fmt.Errorf("构造 POST 请求失败: %w", err)
|
|
137
142
|
}
|
|
138
143
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
139
144
|
req.Header.Set("Accept", "application/json")
|
|
@@ -141,14 +146,14 @@ func (c *httpClient) SendMultipartRequest(ctx context.Context, path string, fiel
|
|
|
141
146
|
if c.authorizer == nil {
|
|
142
147
|
_ = pr.Close()
|
|
143
148
|
_ = pw.Close()
|
|
144
|
-
return fmt.Errorf("
|
|
149
|
+
return fmt.Errorf("授权请求缺少认证器")
|
|
145
150
|
}
|
|
146
151
|
if err := c.authorizer.Inject(ctx, req); err != nil {
|
|
147
152
|
_ = pr.Close()
|
|
148
153
|
_ = pw.Close()
|
|
149
|
-
return fmt.Errorf("
|
|
154
|
+
return fmt.Errorf("写入认证请求头失败: %w", err)
|
|
150
155
|
}
|
|
151
|
-
c.injectHeaders(req)
|
|
156
|
+
c.injectHeaders(req, nil)
|
|
152
157
|
|
|
153
158
|
go func() {
|
|
154
159
|
err := writeMultipartBody(writer, fields, file)
|
|
@@ -172,7 +177,7 @@ func writeMultipartBody(writer *multipart.Writer, fields map[string]string, file
|
|
|
172
177
|
|
|
173
178
|
f, err := os.Open(file.Path)
|
|
174
179
|
if err != nil {
|
|
175
|
-
return fmt.Errorf("
|
|
180
|
+
return fmt.Errorf("打开上传文件失败: %w", err)
|
|
176
181
|
}
|
|
177
182
|
defer f.Close()
|
|
178
183
|
|
|
@@ -184,7 +189,7 @@ func writeMultipartBody(writer *multipart.Writer, fields map[string]string, file
|
|
|
184
189
|
return err
|
|
185
190
|
}
|
|
186
191
|
if _, err := io.Copy(part, f); err != nil {
|
|
187
|
-
return fmt.Errorf("
|
|
192
|
+
return fmt.Errorf("写入上传文件失败: %w", err)
|
|
188
193
|
}
|
|
189
194
|
return nil
|
|
190
195
|
}
|
|
@@ -195,38 +200,41 @@ func escapeQuotes(s string) string {
|
|
|
195
200
|
return quotesReplacer.Replace(s)
|
|
196
201
|
}
|
|
197
202
|
|
|
198
|
-
func (c *httpClient) injectHeaders(req *http.Request) {
|
|
203
|
+
func (c *httpClient) injectHeaders(req *http.Request, headers map[string]string) {
|
|
199
204
|
for k, values := range c.headers {
|
|
200
205
|
for _, v := range values {
|
|
201
206
|
req.Header.Add(k, v)
|
|
202
207
|
}
|
|
203
208
|
}
|
|
204
209
|
req.Header.Set("User-Agent", "Pippit-CLI/1.0")
|
|
210
|
+
for k, v := range headers {
|
|
211
|
+
req.Header.Set(k, v)
|
|
212
|
+
}
|
|
205
213
|
}
|
|
206
214
|
|
|
207
215
|
func (c *httpClient) do(req *http.Request, out any) error {
|
|
208
216
|
resp, err := c.httpClient.Do(req)
|
|
209
217
|
if err != nil {
|
|
210
|
-
return fmt.Errorf("%s %s
|
|
218
|
+
return fmt.Errorf("%s %s 请求失败: %w", req.Method, req.URL.String(), err)
|
|
211
219
|
}
|
|
212
220
|
defer resp.Body.Close()
|
|
213
221
|
|
|
214
222
|
data, err := io.ReadAll(resp.Body)
|
|
215
223
|
if err != nil {
|
|
216
|
-
return fmt.Errorf("
|
|
224
|
+
return fmt.Errorf("读取响应体失败: %w", err)
|
|
217
225
|
}
|
|
218
226
|
if resp.StatusCode >= 400 {
|
|
219
227
|
msg := strings.TrimSpace(string(data))
|
|
220
228
|
if msg == "" {
|
|
221
229
|
msg = http.StatusText(resp.StatusCode)
|
|
222
230
|
}
|
|
223
|
-
return fmt.Errorf("%s %s
|
|
231
|
+
return fmt.Errorf("%s %s 返回 HTTP %d: %s", req.Method, req.URL.String(), resp.StatusCode, msg)
|
|
224
232
|
}
|
|
225
233
|
if out == nil || len(data) == 0 {
|
|
226
234
|
return nil
|
|
227
235
|
}
|
|
228
236
|
if err := sonic.Unmarshal(data, out); err != nil {
|
|
229
|
-
return fmt.Errorf("
|
|
237
|
+
return fmt.Errorf("解析响应体失败: %w", err)
|
|
230
238
|
}
|
|
231
239
|
return nil
|
|
232
240
|
}
|
|
@@ -236,7 +244,7 @@ func (c *httpClient) resolveURL(path string, query map[string]string) (string, e
|
|
|
236
244
|
return appendQuery(path, query)
|
|
237
245
|
}
|
|
238
246
|
if c.baseURL == "" {
|
|
239
|
-
return "", fmt.Errorf("
|
|
247
|
+
return "", fmt.Errorf("相对路径 %q 需要配置 base URL", path)
|
|
240
248
|
}
|
|
241
249
|
if !strings.HasPrefix(path, "/") {
|
|
242
250
|
path = "/" + path
|
|
@@ -247,7 +255,7 @@ func (c *httpClient) resolveURL(path string, query map[string]string) (string, e
|
|
|
247
255
|
func appendQuery(raw string, query map[string]string) (string, error) {
|
|
248
256
|
u, err := url.Parse(raw)
|
|
249
257
|
if err != nil {
|
|
250
|
-
return "", fmt.Errorf("
|
|
258
|
+
return "", fmt.Errorf("解析 URL 失败: %w", err)
|
|
251
259
|
}
|
|
252
260
|
values := u.Query()
|
|
253
261
|
for k, v := range query {
|
|
@@ -32,7 +32,7 @@ type DownloadResultError struct {
|
|
|
32
32
|
Error string `json:"error"`
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
// DownloadResultResponse is the JSON envelope printed by `pippit-tool-cli
|
|
35
|
+
// DownloadResultResponse is the JSON envelope printed by `pippit-tool-cli download-result`.
|
|
36
36
|
type DownloadResultResponse struct {
|
|
37
37
|
OutputPath string `json:"output_path"`
|
|
38
38
|
Downloaded []string `json:"downloaded"`
|
|
@@ -58,20 +58,26 @@ type downloadTaskResult struct {
|
|
|
58
58
|
|
|
59
59
|
func DownloadResult(ctx context.Context, opts DownloadResultOptions, runner *Runner) (*DownloadResultResponse, error) {
|
|
60
60
|
if err := ctx.Err(); err != nil {
|
|
61
|
-
|
|
61
|
+
if errors.Is(err, context.Canceled) {
|
|
62
|
+
return nil, fmt.Errorf("下载已取消")
|
|
63
|
+
}
|
|
64
|
+
if errors.Is(err, context.DeadlineExceeded) {
|
|
65
|
+
return nil, fmt.Errorf("下载超时")
|
|
66
|
+
}
|
|
67
|
+
return nil, fmt.Errorf("下载上下文异常: %w", err)
|
|
62
68
|
}
|
|
63
69
|
rawURL := strings.TrimSpace(opts.URL)
|
|
64
70
|
if rawURL == "" {
|
|
65
|
-
return nil, fmt.Errorf("
|
|
71
|
+
return nil, fmt.Errorf("下载 URL 不能为空")
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
outputPath := strings.TrimSpace(opts.OutputPath)
|
|
69
75
|
if outputPath == "" {
|
|
70
|
-
return nil, fmt.Errorf("
|
|
76
|
+
return nil, fmt.Errorf("输出路径不能为空")
|
|
71
77
|
}
|
|
72
78
|
if info, err := os.Stat(outputPath); err == nil {
|
|
73
79
|
if info.IsDir() {
|
|
74
|
-
return nil, fmt.Errorf("
|
|
80
|
+
return nil, fmt.Errorf("输出路径 %q 是目录,请指定文件", outputPath)
|
|
75
81
|
}
|
|
76
82
|
if shouldSkipExistingFile(info, opts.UpdatedAt) {
|
|
77
83
|
return &DownloadResultResponse{
|
|
@@ -80,19 +86,19 @@ func DownloadResult(ctx context.Context, opts DownloadResultOptions, runner *Run
|
|
|
80
86
|
}, nil
|
|
81
87
|
}
|
|
82
88
|
} else if !os.IsNotExist(err) {
|
|
83
|
-
return nil, fmt.Errorf("
|
|
89
|
+
return nil, fmt.Errorf("获取输出路径信息失败: %w", err)
|
|
84
90
|
}
|
|
85
91
|
outputDir := filepath.Dir(outputPath)
|
|
86
92
|
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
|
87
|
-
return nil, fmt.Errorf("
|
|
93
|
+
return nil, fmt.Errorf("创建输出目录失败: %w", err)
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
parsed, err := url.Parse(rawURL)
|
|
91
97
|
if err != nil {
|
|
92
|
-
return nil, fmt.Errorf("
|
|
98
|
+
return nil, fmt.Errorf("URL %q 不合法: %w", rawURL, err)
|
|
93
99
|
}
|
|
94
100
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
95
|
-
return nil, fmt.Errorf("
|
|
101
|
+
return nil, fmt.Errorf("URL %q 的协议 %q 不支持,仅支持 http 和 https", rawURL, parsed.Scheme)
|
|
96
102
|
}
|
|
97
103
|
tasks := []downloadTask{
|
|
98
104
|
{
|
|
@@ -166,7 +172,7 @@ func DownloadResult(ctx context.Context, opts DownloadResultOptions, runner *Run
|
|
|
166
172
|
Errors: errorList,
|
|
167
173
|
}
|
|
168
174
|
if len(downloaded) == 0 && len(errorList) > 0 {
|
|
169
|
-
return result, fmt.Errorf("
|
|
175
|
+
return result, fmt.Errorf("全部 %d 个下载任务失败", len(errorList))
|
|
170
176
|
}
|
|
171
177
|
return result, nil
|
|
172
178
|
}
|
|
@@ -200,7 +206,7 @@ func downloadFileWithRetry(ctx context.Context, client Client, rawURL string, ta
|
|
|
200
206
|
return err
|
|
201
207
|
}
|
|
202
208
|
}
|
|
203
|
-
return fmt.Errorf("
|
|
209
|
+
return fmt.Errorf("重试 %d 次后仍失败: %w", maxDownloadRetries, lastErr)
|
|
204
210
|
}
|
|
205
211
|
|
|
206
212
|
func isRetryableError(err error) bool {
|
|
@@ -230,18 +236,18 @@ func downloadFile(ctx context.Context, client Client, rawURL string, targetPath
|
|
|
230
236
|
}()
|
|
231
237
|
out, err := os.Create(tmpPath)
|
|
232
238
|
if err != nil {
|
|
233
|
-
return fmt.Errorf("
|
|
239
|
+
return fmt.Errorf("创建临时文件失败: %w", err)
|
|
234
240
|
}
|
|
235
241
|
_, copyErr := io.Copy(out, resp.Body)
|
|
236
242
|
closeErr := out.Close()
|
|
237
243
|
if copyErr != nil {
|
|
238
|
-
return fmt.Errorf("
|
|
244
|
+
return fmt.Errorf("写入临时文件失败: %w", copyErr)
|
|
239
245
|
}
|
|
240
246
|
if closeErr != nil {
|
|
241
|
-
return fmt.Errorf("
|
|
247
|
+
return fmt.Errorf("关闭临时文件失败: %w", closeErr)
|
|
242
248
|
}
|
|
243
249
|
if err := os.Rename(tmpPath, targetPath); err != nil {
|
|
244
|
-
return fmt.Errorf("
|
|
250
|
+
return fmt.Errorf("替换目标文件失败: %w", err)
|
|
245
251
|
}
|
|
246
252
|
tempActive = false
|
|
247
253
|
if updatedAt > 0 {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
package common
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"fmt"
|
|
4
6
|
"os"
|
|
5
7
|
"path/filepath"
|
|
6
8
|
"strings"
|
|
@@ -21,6 +23,42 @@ type errorLogEntry struct {
|
|
|
21
23
|
Error string `json:"error"`
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
type logIDCarrier interface {
|
|
27
|
+
LogID() string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type LogIDError struct {
|
|
31
|
+
Message string
|
|
32
|
+
ID string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func NewLogIDError(message string, logID string) error {
|
|
36
|
+
return &LogIDError{
|
|
37
|
+
Message: strings.TrimSpace(message),
|
|
38
|
+
ID: strings.TrimSpace(logID),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func (e *LogIDError) Error() string {
|
|
43
|
+
if e == nil {
|
|
44
|
+
return ""
|
|
45
|
+
}
|
|
46
|
+
if e.ID == "" {
|
|
47
|
+
return e.Message
|
|
48
|
+
}
|
|
49
|
+
if e.Message == "" {
|
|
50
|
+
return fmt.Sprintf("log_id=%s", e.ID)
|
|
51
|
+
}
|
|
52
|
+
return fmt.Sprintf("%s log_id=%s", e.Message, e.ID)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func (e *LogIDError) LogID() string {
|
|
56
|
+
if e == nil {
|
|
57
|
+
return ""
|
|
58
|
+
}
|
|
59
|
+
return strings.TrimSpace(e.ID)
|
|
60
|
+
}
|
|
61
|
+
|
|
24
62
|
// AppendDailyErrorLog appends one CLI error record to ~/.pippit_tool_cli/logs/yyyy-mm-dd.log.
|
|
25
63
|
func AppendDailyErrorLog(command string, err error, fields map[string]string) error {
|
|
26
64
|
if err == nil {
|
|
@@ -38,7 +76,7 @@ func AppendDailyErrorLog(command string, err error, fields map[string]string) er
|
|
|
38
76
|
entry := errorLogEntry{
|
|
39
77
|
Time: now.Format(time.RFC3339),
|
|
40
78
|
Command: strings.TrimSpace(command),
|
|
41
|
-
Fields: cleanErrorLogFields(fields),
|
|
79
|
+
Fields: cleanErrorLogFields(addLogIDField(fields, err)),
|
|
42
80
|
Error: err.Error(),
|
|
43
81
|
}
|
|
44
82
|
data, marshalErr := sonic.Marshal(entry)
|
|
@@ -55,6 +93,29 @@ func AppendDailyErrorLog(command string, err error, fields map[string]string) er
|
|
|
55
93
|
return writeErr
|
|
56
94
|
}
|
|
57
95
|
|
|
96
|
+
func addLogIDField(fields map[string]string, err error) map[string]string {
|
|
97
|
+
logID := logIDFromError(err)
|
|
98
|
+
if logID == "" {
|
|
99
|
+
return fields
|
|
100
|
+
}
|
|
101
|
+
next := make(map[string]string, len(fields)+1)
|
|
102
|
+
for key, value := range fields {
|
|
103
|
+
next[key] = value
|
|
104
|
+
}
|
|
105
|
+
if strings.TrimSpace(next["log_id"]) == "" {
|
|
106
|
+
next["log_id"] = logID
|
|
107
|
+
}
|
|
108
|
+
return next
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func logIDFromError(err error) string {
|
|
112
|
+
var carrier logIDCarrier
|
|
113
|
+
if errors.As(err, &carrier) {
|
|
114
|
+
return strings.TrimSpace(carrier.LogID())
|
|
115
|
+
}
|
|
116
|
+
return ""
|
|
117
|
+
}
|
|
118
|
+
|
|
58
119
|
func dailyErrorLogPath(now time.Time) (string, error) {
|
|
59
120
|
home, err := os.UserHomeDir()
|
|
60
121
|
if err != nil {
|
|
@@ -16,7 +16,7 @@ func TestAppendDailyErrorLog(t *testing.T) {
|
|
|
16
16
|
t.Setenv("HOME", home)
|
|
17
17
|
t.Setenv("USERPROFILE", home)
|
|
18
18
|
|
|
19
|
-
err := AppendDailyErrorLog("
|
|
19
|
+
err := AppendDailyErrorLog("get-thread", fmt.Errorf("request failed"), map[string]string{
|
|
20
20
|
"thread_id": "thread_123",
|
|
21
21
|
"access_key": "secret",
|
|
22
22
|
"empty": "",
|
|
@@ -24,7 +24,7 @@ func TestAppendDailyErrorLog(t *testing.T) {
|
|
|
24
24
|
if err != nil {
|
|
25
25
|
t.Fatalf("AppendDailyErrorLog(): %v", err)
|
|
26
26
|
}
|
|
27
|
-
if err := AppendDailyErrorLog("
|
|
27
|
+
if err := AppendDailyErrorLog("get-thread", fmt.Errorf("second failure"), nil); err != nil {
|
|
28
28
|
t.Fatalf("AppendDailyErrorLog() second call: %v", err)
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -42,7 +42,7 @@ func TestAppendDailyErrorLog(t *testing.T) {
|
|
|
42
42
|
if err := sonic.Unmarshal([]byte(lines[0]), &first); err != nil {
|
|
43
43
|
t.Fatalf("decode first log line: %v\n%s", err, lines[0])
|
|
44
44
|
}
|
|
45
|
-
if first["command"] != "
|
|
45
|
+
if first["command"] != "get-thread" {
|
|
46
46
|
t.Fatalf("command = %v, want get-thread command", first["command"])
|
|
47
47
|
}
|
|
48
48
|
if first["error"] != "request failed" {
|
|
@@ -59,3 +59,33 @@ func TestAppendDailyErrorLog(t *testing.T) {
|
|
|
59
59
|
t.Fatalf("access_key should be omitted from log fields: %#v", fields)
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
func TestAppendDailyErrorLogAddsLogID(t *testing.T) {
|
|
64
|
+
home := t.TempDir()
|
|
65
|
+
t.Setenv("HOME", home)
|
|
66
|
+
t.Setenv("USERPROFILE", home)
|
|
67
|
+
|
|
68
|
+
err := AppendDailyErrorLog("query-result", NewLogIDError("request failed", "log_123"), map[string]string{
|
|
69
|
+
"thread_id": "thread_123",
|
|
70
|
+
})
|
|
71
|
+
if err != nil {
|
|
72
|
+
t.Fatalf("AppendDailyErrorLog(): %v", err)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
path := filepath.Join(home, ".pippit_tool_cli", "logs", time.Now().Format("2006-01-02")+".log")
|
|
76
|
+
data, err := os.ReadFile(path)
|
|
77
|
+
if err != nil {
|
|
78
|
+
t.Fatalf("ReadFile(%s): %v", path, err)
|
|
79
|
+
}
|
|
80
|
+
var entry map[string]any
|
|
81
|
+
if err := sonic.Unmarshal([]byte(strings.TrimSpace(string(data))), &entry); err != nil {
|
|
82
|
+
t.Fatalf("decode log line: %v\n%s", err, string(data))
|
|
83
|
+
}
|
|
84
|
+
fields, ok := entry["fields"].(map[string]any)
|
|
85
|
+
if !ok {
|
|
86
|
+
t.Fatalf("fields = %#v, want object", entry["fields"])
|
|
87
|
+
}
|
|
88
|
+
if fields["log_id"] != "log_123" {
|
|
89
|
+
t.Fatalf("log_id = %v, want log_123", fields["log_id"])
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -2,61 +2,75 @@ package common
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
+
"encoding/json"
|
|
5
6
|
"fmt"
|
|
6
7
|
|
|
7
8
|
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
8
9
|
)
|
|
9
10
|
|
|
10
|
-
const
|
|
11
|
+
const GetThreadVersionV2 = "v2"
|
|
11
12
|
|
|
12
13
|
// GetThreadOptions is the stable command-facing request shape for thread lookup.
|
|
13
14
|
type GetThreadOptions struct {
|
|
14
15
|
ThreadID string `json:"thread_id"`
|
|
15
16
|
RunID string `json:"run_id,omitempty"`
|
|
17
|
+
Version string `json:"version,omitempty"`
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
// GetThreadResult is the parsed get_thread response used by `pippit-tool-cli
|
|
20
|
+
// GetThreadResult is the parsed get_thread response used by `pippit-tool-cli get-thread`.
|
|
19
21
|
type GetThreadResult struct {
|
|
20
22
|
ReadableText string `json:"readable_text"`
|
|
23
|
+
RawData []byte `json:"-"`
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
type getThreadResponse struct {
|
|
24
|
-
Ret string
|
|
25
|
-
Errmsg string
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
} `json:"data"`
|
|
27
|
+
Ret string `json:"ret"`
|
|
28
|
+
Errmsg string `json:"errmsg"`
|
|
29
|
+
LogID string `json:"log_id"`
|
|
30
|
+
Data json.RawMessage `json:"data"`
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
func GetThread(ctx context.Context, opts *GetThreadOptions, runner *Runner) (*GetThreadResult, error) {
|
|
32
34
|
if runner == nil || runner.Client == nil {
|
|
33
|
-
return nil, fmt.Errorf("get_thread
|
|
35
|
+
return nil, fmt.Errorf("get_thread 运行器客户端缺失")
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
body := map[string]any{
|
|
37
39
|
"thread_id": opts.ThreadID,
|
|
38
|
-
"version": getThreadVersionV2,
|
|
39
40
|
}
|
|
40
41
|
if opts.RunID != "" {
|
|
41
42
|
body["run_id"] = opts.RunID
|
|
42
43
|
}
|
|
44
|
+
if opts.Version != "" {
|
|
45
|
+
body["version"] = opts.Version
|
|
46
|
+
}
|
|
43
47
|
|
|
44
48
|
var resp getThreadResponse
|
|
45
49
|
if err := runner.Client.SendRequest(ctx, getThreadPath(runner), body, &resp); err != nil {
|
|
46
|
-
return nil, fmt.Errorf("
|
|
50
|
+
return nil, fmt.Errorf("获取线程请求失败: %w", err)
|
|
47
51
|
}
|
|
48
52
|
if resp.Ret != "0" {
|
|
49
53
|
if resp.Errmsg == "" {
|
|
50
|
-
resp.Errmsg = "
|
|
54
|
+
resp.Errmsg = "未知错误"
|
|
51
55
|
}
|
|
52
|
-
return nil, fmt.
|
|
56
|
+
return nil, NewLogIDError(fmt.Sprintf("获取线程请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID)
|
|
57
|
+
}
|
|
58
|
+
if len(resp.Data) == 0 {
|
|
59
|
+
return nil, fmt.Errorf("get_thread 响应缺少 data")
|
|
53
60
|
}
|
|
54
|
-
|
|
55
|
-
|
|
61
|
+
readable := struct {
|
|
62
|
+
ReadableText string `json:"readable_text"`
|
|
63
|
+
}{}
|
|
64
|
+
if opts.Version == GetThreadVersionV2 {
|
|
65
|
+
_ = json.Unmarshal(resp.Data, &readable)
|
|
66
|
+
if readable.ReadableText == "" {
|
|
67
|
+
return nil, fmt.Errorf("get_thread v2 响应缺少 data.readable_text")
|
|
68
|
+
}
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
return &GetThreadResult{
|
|
59
|
-
ReadableText:
|
|
72
|
+
ReadableText: readable.ReadableText,
|
|
73
|
+
RawData: resp.Data,
|
|
60
74
|
}, nil
|
|
61
75
|
}
|
|
62
76
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
package common
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/bytedance/sonic"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
type getThreadFakeClient struct {
|
|
12
|
+
response string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func (c getThreadFakeClient) SendRequest(_ context.Context, _ string, _ any, out any) error {
|
|
16
|
+
return sonic.Unmarshal([]byte(c.response), out)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func (c getThreadFakeClient) SendRequestWithHeaders(ctx context.Context, path string, body any, _ map[string]string, out any) error {
|
|
20
|
+
return c.SendRequest(ctx, path, body, out)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func (c getThreadFakeClient) SendMultipartRequest(context.Context, string, map[string]string, MultipartFile, any) error {
|
|
24
|
+
return nil
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func TestGetThreadAllowsStructuredDataWithoutVersion(t *testing.T) {
|
|
28
|
+
result, err := GetThread(context.Background(), &GetThreadOptions{
|
|
29
|
+
ThreadID: "thread_123",
|
|
30
|
+
RunID: "run_456",
|
|
31
|
+
}, &Runner{
|
|
32
|
+
Client: getThreadFakeClient{response: `{"ret":"0","errmsg":"","data":{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":3}]}}}`},
|
|
33
|
+
})
|
|
34
|
+
if err != nil {
|
|
35
|
+
t.Fatalf("GetThread() error = %v", err)
|
|
36
|
+
}
|
|
37
|
+
if result.ReadableText != "" {
|
|
38
|
+
t.Fatalf("ReadableText = %q, want empty for non-v2 response", result.ReadableText)
|
|
39
|
+
}
|
|
40
|
+
if !strings.Contains(string(result.RawData), `"thread"`) {
|
|
41
|
+
t.Fatalf("RawData = %s, want structured thread data", string(result.RawData))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func TestGetThreadRequiresRetZero(t *testing.T) {
|
|
46
|
+
_, err := GetThread(context.Background(), &GetThreadOptions{
|
|
47
|
+
ThreadID: "thread_123",
|
|
48
|
+
RunID: "run_456",
|
|
49
|
+
}, &Runner{
|
|
50
|
+
Client: getThreadFakeClient{response: `{"errmsg":"","data":{"thread":{"thread_id":"thread_123"}}}`},
|
|
51
|
+
})
|
|
52
|
+
if err == nil {
|
|
53
|
+
t.Fatal("GetThread() error = nil, want missing ret validation")
|
|
54
|
+
}
|
|
55
|
+
if !strings.Contains(err.Error(), "获取线程请求返回失败: ret=") {
|
|
56
|
+
t.Fatalf("error = %q, want missing ret validation", err)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func TestGetThreadV2RequiresReadableText(t *testing.T) {
|
|
61
|
+
_, err := GetThread(context.Background(), &GetThreadOptions{
|
|
62
|
+
ThreadID: "thread_123",
|
|
63
|
+
RunID: "run_456",
|
|
64
|
+
Version: GetThreadVersionV2,
|
|
65
|
+
}, &Runner{
|
|
66
|
+
Client: getThreadFakeClient{response: `{"ret":"0","errmsg":"","data":{"thread":{"thread_id":"thread_123"}}}`},
|
|
67
|
+
})
|
|
68
|
+
if err == nil {
|
|
69
|
+
t.Fatal("GetThread() error = nil, want v2 readable_text validation")
|
|
70
|
+
}
|
|
71
|
+
if !strings.Contains(err.Error(), "get_thread v2 响应缺少 data.readable_text") {
|
|
72
|
+
t.Fatalf("error = %q, want readable_text validation", err)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
package common
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"io"
|
|
6
|
+
|
|
7
|
+
"github.com/bytedance/sonic"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// WriteJSON writes v as one JSON line.
|
|
11
|
+
func WriteJSON(w io.Writer, v any) error {
|
|
12
|
+
data, err := sonic.Marshal(v)
|
|
13
|
+
if err != nil {
|
|
14
|
+
return err
|
|
15
|
+
}
|
|
16
|
+
_, err = fmt.Fprintln(w, string(data))
|
|
17
|
+
return err
|
|
18
|
+
}
|
|
@@ -25,7 +25,7 @@ type ThreadFile struct {
|
|
|
25
25
|
UpdatedAt *int64 `json:"updated_at,omitempty"`
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
// ListThreadFileResult is the JSON envelope printed by `pippit-tool-cli
|
|
28
|
+
// ListThreadFileResult is the JSON envelope printed by `pippit-tool-cli list-thread-file`.
|
|
29
29
|
type ListThreadFileResult struct {
|
|
30
30
|
Files []*ThreadFile `json:"files"`
|
|
31
31
|
Total int64 `json:"total"`
|
|
@@ -51,7 +51,7 @@ type threadFileResponse struct {
|
|
|
51
51
|
|
|
52
52
|
func ListThreadFile(ctx context.Context, opts *ListThreadFileOptions, runner *Runner) (*ListThreadFileResult, error) {
|
|
53
53
|
if runner == nil || runner.Client == nil {
|
|
54
|
-
return nil, fmt.Errorf("list_thread_file
|
|
54
|
+
return nil, fmt.Errorf("list_thread_file 运行器客户端缺失")
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
body := map[string]any{
|
|
@@ -66,13 +66,13 @@ func ListThreadFile(ctx context.Context, opts *ListThreadFileOptions, runner *Ru
|
|
|
66
66
|
|
|
67
67
|
var resp listThreadFileResponse
|
|
68
68
|
if err := runner.Client.SendRequest(ctx, listThreadFilePath(runner), body, &resp); err != nil {
|
|
69
|
-
return nil, fmt.Errorf("
|
|
69
|
+
return nil, fmt.Errorf("获取线程文件列表请求失败: %w", err)
|
|
70
70
|
}
|
|
71
71
|
if resp.Ret != "0" {
|
|
72
72
|
if resp.Errmsg == "" {
|
|
73
|
-
resp.Errmsg = "
|
|
73
|
+
resp.Errmsg = "未知错误"
|
|
74
74
|
}
|
|
75
|
-
return nil, fmt.Errorf("
|
|
75
|
+
return nil, fmt.Errorf("获取线程文件列表请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg)
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
files := make([]*ThreadFile, 0, len(resp.Data.Files))
|
|
@@ -101,9 +101,9 @@ func listThreadFileMessage(total int64, pageNum int) string {
|
|
|
101
101
|
}
|
|
102
102
|
var message string
|
|
103
103
|
if total >= MaxListThreadFilePageSize {
|
|
104
|
-
message = fmt.Sprintf("
|
|
104
|
+
message = fmt.Sprintf("文件总数已达到 %d;请使用 --page-num %d 查询下一页", MaxListThreadFilePageSize, pageNum+1)
|
|
105
105
|
} else {
|
|
106
|
-
message = fmt.Sprintf("
|
|
106
|
+
message = fmt.Sprintf("文件总数小于 %d;继续使用当前 --page-num %d 查询", MaxListThreadFilePageSize, pageNum)
|
|
107
107
|
}
|
|
108
108
|
return fmt.Sprintf("%s\n- %s\n%s", start, message, end)
|
|
109
109
|
}
|