@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.
@@ -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("encode body: %w", err)
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("build %s request: %w", method, err)
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("authorized request requires authorizer")
89
+ return fmt.Errorf("授权请求缺少认证器")
85
90
  }
86
91
  if err := c.authorizer.Inject(ctx, req); err != nil {
87
- return fmt.Errorf("inject auth headers: %w", err)
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 failed: %w", method, reqURL, err)
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 returned HTTP %d", method, reqURL, resp.StatusCode)
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 file field name is required")
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("build POST request: %w", err)
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("authorized request requires authorizer")
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("inject auth headers: %w", err)
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("open upload file: %w", err)
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("write upload file: %w", err)
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 failed: %w", req.Method, req.URL.String(), err)
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("read response body: %w", err)
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 returned HTTP %d: %s", req.Method, req.URL.String(), resp.StatusCode, msg)
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("decode response body: %w", err)
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("base URL is required for relative path %q", path)
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("parse URL: %w", err)
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 short-drama +download-result`.
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
- return nil, err
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("download url is required")
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("output_path is required")
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("output path %q is a directory", outputPath)
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("stat output path: %w", err)
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("create output dir: %w", err)
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("invalid url %q: %w", rawURL, err)
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("invalid url scheme %q in %q, only http and https are allowed", parsed.Scheme, rawURL)
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("all %d download(s) failed", len(errorList))
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("failed after %d retries: %w", maxDownloadRetries, lastErr)
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("create temp file: %w", err)
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("write temp file: %w", copyErr)
244
+ return fmt.Errorf("写入临时文件失败: %w", copyErr)
239
245
  }
240
246
  if closeErr != nil {
241
- return fmt.Errorf("close temp file: %w", closeErr)
247
+ return fmt.Errorf("关闭临时文件失败: %w", closeErr)
242
248
  }
243
249
  if err := os.Rename(tmpPath, targetPath); err != nil {
244
- return fmt.Errorf("replace target file: %w", err)
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("short-drama +get-thread", fmt.Errorf("request failed"), map[string]string{
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("short-drama +get-thread", fmt.Errorf("second failure"), nil); err != nil {
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"] != "short-drama +get-thread" {
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 getThreadVersionV2 = "v2"
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 short-drama +get-thread`.
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 `json:"ret"`
25
- Errmsg string `json:"errmsg"`
26
- Data struct {
27
- ReadableText string `json:"readable_text"`
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 runner client is required")
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("get_thread request failed: %w", err)
50
+ return nil, fmt.Errorf("获取线程请求失败: %w", err)
47
51
  }
48
52
  if resp.Ret != "0" {
49
53
  if resp.Errmsg == "" {
50
- resp.Errmsg = "unknown error"
54
+ resp.Errmsg = "未知错误"
51
55
  }
52
- return nil, fmt.Errorf("get_thread failed: ret=%s errmsg=%s", resp.Ret, resp.Errmsg)
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
- if resp.Data.ReadableText == "" {
55
- return nil, fmt.Errorf("get_thread response missing data.readable_text")
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: resp.Data.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 short-drama +list-thread-file`.
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 runner client is required")
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("list_thread_file request failed: %w", err)
69
+ return nil, fmt.Errorf("获取线程文件列表请求失败: %w", err)
70
70
  }
71
71
  if resp.Ret != "0" {
72
72
  if resp.Errmsg == "" {
73
- resp.Errmsg = "unknown error"
73
+ resp.Errmsg = "未知错误"
74
74
  }
75
- return nil, fmt.Errorf("list_thread_file failed: ret=%s errmsg=%s", resp.Ret, resp.Errmsg)
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("total reached %d; query the next page with --page-num %d", MaxListThreadFilePageSize, pageNum+1)
104
+ message = fmt.Sprintf("文件总数已达到 %d;请使用 --page-num %d 查询下一页", MaxListThreadFilePageSize, pageNum+1)
105
105
  } else {
106
- message = fmt.Sprintf("total is below %d; continue querying with current --page-num %d", MaxListThreadFilePageSize, pageNum)
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
  }