@pippit-dev/cli 1.0.26 → 1.0.27
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 +23 -1
- package/checksums.txt +6 -6
- package/cmd/generate_image/generate_image.go +1 -0
- package/cmd/generate_video/generate_video.go +4 -3
- package/cmd/model.go +74 -0
- package/cmd/model_test.go +81 -0
- package/cmd/root.go +1 -0
- package/cmd/short_drama/short_drama.go +1 -0
- package/cmd/source_test.go +179 -0
- package/cmd/submit_run.go +3 -0
- package/cmd/update/update.go +1 -1
- package/cmd/video_tool/video_tool.go +2 -0
- package/dist/checksums.txt +6 -6
- package/internal/common/submit_run.go +38 -1
- package/internal/config/config.go +13 -10
- package/internal/config/config_test.go +3 -0
- package/internal/generate_image/generate_image.go +2 -1
- package/internal/generate_video/generate_video.go +12 -3
- package/internal/generate_video/generate_video_test.go +126 -0
- package/internal/models/describe.go +281 -0
- package/internal/models/describe_test.go +199 -0
- package/internal/models/models.go +220 -0
- package/internal/models/models_test.go +200 -0
- package/internal/short_drama/submit_run.go +2 -0
- package/internal/video_tool/video_tool.go +4 -2
- package/package.json +1 -1
- package/skills/short-drama/SKILL.md +4 -0
- package/skills/xyq-nest-skill/SKILL.md +10 -0
- package/skills/xyq-nest-skill/commands/erase-video-subtitle.md +2 -0
- package/skills/xyq-nest-skill/commands/generate-image.md +2 -0
- package/skills/xyq-nest-skill/commands/generate-video.md +31 -7
- package/skills/xyq-nest-skill/commands/model.md +59 -0
- package/skills/xyq-nest-skill/commands/video-super-resolution.md +2 -0
- package/skills/xyq-nest-skill/scripts/ensure-cli.js +2 -2
- package/skills/xyq-nest-skill/scripts/install.md +1 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
package models
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"fmt"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
14
|
+
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
15
|
+
"github.com/Pippit-dev/pippit-cli/internal/version"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const (
|
|
19
|
+
Scene = "web_turbo_video_generator"
|
|
20
|
+
CacheTTL = 5 * time.Minute
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
// Catalog preserves the server's model configuration, including unknown fields
|
|
24
|
+
// and integer enums. It is never used as a local generation allowlist.
|
|
25
|
+
type Catalog struct {
|
|
26
|
+
Scene string `json:"scene"`
|
|
27
|
+
ConfigKey string `json:"config_key"`
|
|
28
|
+
Config *struct {
|
|
29
|
+
Models []json.RawMessage `json:"models"`
|
|
30
|
+
} `json:"config"`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Summary struct {
|
|
34
|
+
Key string `json:"key"`
|
|
35
|
+
Name string `json:"name"`
|
|
36
|
+
Kind string `json:"kind"`
|
|
37
|
+
IsDefault bool `json:"is_default"`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Result struct {
|
|
41
|
+
Catalog *Catalog
|
|
42
|
+
Cached bool
|
|
43
|
+
FetchedAt time.Time
|
|
44
|
+
Warning string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type cacheEntry struct {
|
|
48
|
+
FetchedAt time.Time `json:"fetched_at"`
|
|
49
|
+
Catalog *Catalog `json:"catalog"`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Service struct {
|
|
53
|
+
runner *common.Runner
|
|
54
|
+
cacheDir string
|
|
55
|
+
now func() time.Time
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func NewService(runner *common.Runner) *Service {
|
|
59
|
+
dir, err := os.UserCacheDir()
|
|
60
|
+
if err == nil {
|
|
61
|
+
dir = filepath.Join(dir, "pippit-cli", "models")
|
|
62
|
+
}
|
|
63
|
+
return &Service{runner: runner, cacheDir: dir, now: time.Now}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func (s *Service) Get(ctx context.Context, refresh bool) (*Result, error) {
|
|
67
|
+
if err := ctx.Err(); err != nil {
|
|
68
|
+
return nil, err
|
|
69
|
+
}
|
|
70
|
+
if s.runner == nil || s.runner.Config == nil || s.runner.Client == nil || s.runner.Auth == nil {
|
|
71
|
+
return nil, fmt.Errorf("模型查询运行器未初始化")
|
|
72
|
+
}
|
|
73
|
+
// Resolve credentials before reading cache: logout/expiry must not expose a
|
|
74
|
+
// previous account's model list. Only a hash is used in the cache filename.
|
|
75
|
+
accessKey, err := s.runner.Auth.ResolveAccessKey(ctx)
|
|
76
|
+
if err != nil || strings.TrimSpace(accessKey) == "" {
|
|
77
|
+
return nil, fmt.Errorf("模型查询需要有效登录,请执行 pippit-tool-cli login 或检查 XYQ_ACCESS_KEY 后重试")
|
|
78
|
+
}
|
|
79
|
+
path := config.GetAvailableModelListPath
|
|
80
|
+
if s.runner.Config.Paths != nil && s.runner.Config.Paths.GetAvailableModelList != "" {
|
|
81
|
+
path = s.runner.Config.Paths.GetAvailableModelList
|
|
82
|
+
}
|
|
83
|
+
cachePath := s.cachePath(accessKey, path)
|
|
84
|
+
if !refresh && cachePath != "" {
|
|
85
|
+
if entry := s.readCache(cachePath); entry != nil {
|
|
86
|
+
return &Result{Catalog: entry.Catalog, Cached: true, FetchedAt: entry.FetchedAt}, nil
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// A failed refresh must not make the next retry reuse an older fresh entry.
|
|
90
|
+
if cachePath != "" {
|
|
91
|
+
_ = os.Remove(cachePath)
|
|
92
|
+
}
|
|
93
|
+
queryCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
94
|
+
defer cancel()
|
|
95
|
+
var resp struct {
|
|
96
|
+
Ret string `json:"ret"`
|
|
97
|
+
Errmsg string `json:"errmsg"`
|
|
98
|
+
LogID string `json:"log_id"`
|
|
99
|
+
Data *Catalog `json:"data"`
|
|
100
|
+
}
|
|
101
|
+
err = s.runner.Client.SendRequest(queryCtx, path, struct {
|
|
102
|
+
Scene string `json:"scene"`
|
|
103
|
+
SourceModelKey string `json:"source_model_key"`
|
|
104
|
+
}{Scene: Scene}, &resp)
|
|
105
|
+
if err != nil {
|
|
106
|
+
return nil, fmt.Errorf("模型查询失败,请稍后重试(可加 --refresh): %w", err)
|
|
107
|
+
}
|
|
108
|
+
if resp.Ret != "0" {
|
|
109
|
+
return nil, common.NewLogIDError(fmt.Sprintf("模型查询失败,请稍后重试(可加 --refresh): ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID)
|
|
110
|
+
}
|
|
111
|
+
if err := validateCatalog(resp.Data); err != nil {
|
|
112
|
+
return nil, common.NewLogIDError("模型配置无效,请稍后重试(可加 --refresh): "+err.Error(), resp.LogID)
|
|
113
|
+
}
|
|
114
|
+
currentKey, err := s.runner.Auth.ResolveAccessKey(ctx)
|
|
115
|
+
if err != nil || currentKey != accessKey {
|
|
116
|
+
return nil, fmt.Errorf("查询期间登录凭证已变化,请重试模型查询")
|
|
117
|
+
}
|
|
118
|
+
result := &Result{Catalog: resp.Data, FetchedAt: s.now().UTC()}
|
|
119
|
+
if cachePath == "" || s.writeCache(cachePath, result) != nil {
|
|
120
|
+
result.Warning = "模型查询成功,但本地缓存写入失败;下次查询将重新请求服务端"
|
|
121
|
+
}
|
|
122
|
+
return result, nil
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func (s *Service) cachePath(accessKey, path string) string {
|
|
126
|
+
if s.cacheDir == "" {
|
|
127
|
+
return ""
|
|
128
|
+
}
|
|
129
|
+
scope, _ := json.Marshal([]string{
|
|
130
|
+
"v1", strings.TrimRight(s.runner.Config.BaseURL, "/"), path, Scene,
|
|
131
|
+
version.Current(), accessKey,
|
|
132
|
+
})
|
|
133
|
+
return filepath.Join(s.cacheDir, fmt.Sprintf("%x.json", sha256.Sum256(scope)))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
func (s *Service) readCache(path string) *cacheEntry {
|
|
137
|
+
raw, err := os.ReadFile(path)
|
|
138
|
+
if err != nil {
|
|
139
|
+
return nil
|
|
140
|
+
}
|
|
141
|
+
var entry cacheEntry
|
|
142
|
+
if json.Unmarshal(raw, &entry) != nil || validateCatalog(entry.Catalog) != nil {
|
|
143
|
+
return nil
|
|
144
|
+
}
|
|
145
|
+
age := s.now().Sub(entry.FetchedAt)
|
|
146
|
+
if age < 0 || age >= CacheTTL {
|
|
147
|
+
return nil
|
|
148
|
+
}
|
|
149
|
+
return &entry
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func (s *Service) writeCache(path string, result *Result) error {
|
|
153
|
+
raw, err := json.Marshal(cacheEntry{FetchedAt: result.FetchedAt, Catalog: result.Catalog})
|
|
154
|
+
if err != nil {
|
|
155
|
+
return err
|
|
156
|
+
}
|
|
157
|
+
if err := os.MkdirAll(s.cacheDir, 0700); err != nil {
|
|
158
|
+
return err
|
|
159
|
+
}
|
|
160
|
+
f, err := os.CreateTemp(s.cacheDir, ".models-*.tmp")
|
|
161
|
+
if err != nil {
|
|
162
|
+
return err
|
|
163
|
+
}
|
|
164
|
+
defer os.Remove(f.Name())
|
|
165
|
+
_, writeErr := f.Write(raw)
|
|
166
|
+
closeErr := f.Close()
|
|
167
|
+
if writeErr != nil {
|
|
168
|
+
return writeErr
|
|
169
|
+
}
|
|
170
|
+
if closeErr != nil {
|
|
171
|
+
return closeErr
|
|
172
|
+
}
|
|
173
|
+
return os.Rename(f.Name(), path)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
func validateCatalog(catalog *Catalog) error {
|
|
177
|
+
if catalog == nil || catalog.Config == nil || catalog.Scene != Scene || catalog.ConfigKey == "" {
|
|
178
|
+
return fmt.Errorf("缺少配置或场景不匹配")
|
|
179
|
+
}
|
|
180
|
+
seen := make(map[string]bool)
|
|
181
|
+
for _, raw := range catalog.Config.Models {
|
|
182
|
+
var model Summary
|
|
183
|
+
if json.Unmarshal(raw, &model) != nil || strings.TrimSpace(model.Key) == "" || model.Kind != "video" {
|
|
184
|
+
return fmt.Errorf("模型条目缺少有效 key 或 kind")
|
|
185
|
+
}
|
|
186
|
+
if seen[model.Key] {
|
|
187
|
+
return fmt.Errorf("模型 key 重复: %s", model.Key)
|
|
188
|
+
}
|
|
189
|
+
seen[model.Key] = true
|
|
190
|
+
}
|
|
191
|
+
return nil
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func (c *Catalog) Search(query string) []Summary {
|
|
195
|
+
query = strings.TrimSpace(query)
|
|
196
|
+
matches := make([]Summary, 0)
|
|
197
|
+
for _, raw := range c.Config.Models {
|
|
198
|
+
var model Summary
|
|
199
|
+
_ = json.Unmarshal(raw, &model) // validated at the network/cache boundary
|
|
200
|
+
if model.Key == query {
|
|
201
|
+
return []Summary{model}
|
|
202
|
+
}
|
|
203
|
+
if query == "" || strings.Contains(strings.ToLower(model.Name), strings.ToLower(query)) || strings.Contains(strings.ToLower(model.Key), strings.ToLower(query)) {
|
|
204
|
+
matches = append(matches, model)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return matches
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
func (c *Catalog) Describe(key string) (json.RawMessage, error) {
|
|
211
|
+
key = strings.TrimSpace(key)
|
|
212
|
+
for _, raw := range c.Config.Models {
|
|
213
|
+
var model Summary
|
|
214
|
+
_ = json.Unmarshal(raw, &model)
|
|
215
|
+
if model.Key == key {
|
|
216
|
+
return describeModel(raw)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return nil, fmt.Errorf("未找到可用模型 %q;请执行 model list --refresh 查看当前模型", key)
|
|
220
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
package models
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"net/http"
|
|
8
|
+
"net/http/httptest"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"strings"
|
|
12
|
+
"testing"
|
|
13
|
+
"time"
|
|
14
|
+
|
|
15
|
+
"github.com/Pippit-dev/pippit-cli/internal/auth"
|
|
16
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
17
|
+
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const validResponse = `{"ret":"0","data":{"scene":"web_turbo_video_generator","config_key":"key1","config":{"models":[{"key":"new-model","kind":"video","name":"新模型","is_default":true,"supported_ratio_list":[0,1],"audio_total_limit":0,"future_field":9007199254740993}]}}}`
|
|
21
|
+
|
|
22
|
+
func newTestService(t *testing.T, handler http.HandlerFunc) *Service {
|
|
23
|
+
t.Helper()
|
|
24
|
+
server := httptest.NewServer(handler)
|
|
25
|
+
t.Cleanup(server.Close)
|
|
26
|
+
cfg := config.Load()
|
|
27
|
+
cfg.BaseURL, cfg.AccessKey = server.URL, "private-test-key"
|
|
28
|
+
runner := common.NewRunner(cfg, nil)
|
|
29
|
+
runner.Auth = auth.NewManager(cfg)
|
|
30
|
+
runner.Client = common.NewHTTPClient(server.URL, time.Second, common.NewAccessKeyContextProviderAuthorizer(runner.Auth.ResolveAccessKey))
|
|
31
|
+
s := NewService(runner)
|
|
32
|
+
s.cacheDir = t.TempDir()
|
|
33
|
+
return s
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type unavailableAuth struct{ common.AuthManager }
|
|
37
|
+
|
|
38
|
+
func (unavailableAuth) ResolveAccessKey(context.Context) (string, error) {
|
|
39
|
+
return "", errors.New("expired credential")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func TestMissingCredentialsCannotReadFreshCache(t *testing.T) {
|
|
43
|
+
requests := 0
|
|
44
|
+
s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
45
|
+
requests++
|
|
46
|
+
_, _ = w.Write([]byte(validResponse))
|
|
47
|
+
})
|
|
48
|
+
if _, err := s.Get(context.Background(), false); err != nil {
|
|
49
|
+
t.Fatal(err)
|
|
50
|
+
}
|
|
51
|
+
s.runner.Auth = unavailableAuth{}
|
|
52
|
+
result, err := s.Get(context.Background(), false)
|
|
53
|
+
if err == nil || result != nil || requests != 1 || !strings.Contains(err.Error(), "login") {
|
|
54
|
+
t.Fatalf("must require login before cache: result=%+v err=%v requests=%d", result, err, requests)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func TestCacheTTLRefreshAndIsolation(t *testing.T) {
|
|
59
|
+
requests := 0
|
|
60
|
+
s := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
|
|
61
|
+
requests++
|
|
62
|
+
if r.Method != "POST" || r.URL.Path != config.GetAvailableModelListPath || !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
|
|
63
|
+
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
64
|
+
}
|
|
65
|
+
var body map[string]string
|
|
66
|
+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["scene"] != Scene || body["source_model_key"] != "" {
|
|
67
|
+
t.Errorf("unexpected body: %v, err=%v", body, err)
|
|
68
|
+
}
|
|
69
|
+
_, _ = w.Write([]byte(validResponse))
|
|
70
|
+
})
|
|
71
|
+
now := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC)
|
|
72
|
+
s.now = func() time.Time { return now }
|
|
73
|
+
check := func(refresh, cached bool, count int) *Result {
|
|
74
|
+
t.Helper()
|
|
75
|
+
result, err := s.Get(context.Background(), refresh)
|
|
76
|
+
if err != nil || result.Cached != cached || requests != count {
|
|
77
|
+
t.Fatalf("Get: result=%+v err=%v requests=%d, want cached=%v requests=%d", result, err, requests, cached, count)
|
|
78
|
+
}
|
|
79
|
+
return result
|
|
80
|
+
}
|
|
81
|
+
first := check(false, false, 1)
|
|
82
|
+
now = now.Add(CacheTTL - time.Second)
|
|
83
|
+
cached := check(false, true, 1)
|
|
84
|
+
if !cached.FetchedAt.Equal(first.FetchedAt) {
|
|
85
|
+
t.Fatal("cache hit must not renew TTL")
|
|
86
|
+
}
|
|
87
|
+
now = now.Add(time.Second)
|
|
88
|
+
check(false, false, 2) // expires exactly at five minutes
|
|
89
|
+
check(true, false, 3)
|
|
90
|
+
s.runner.Config.AccessKey = "second-account"
|
|
91
|
+
check(false, false, 4)
|
|
92
|
+
oldPath := s.cachePath("second-account", config.GetAvailableModelListPath)
|
|
93
|
+
s.runner.Config.BaseURL += "/other-api"
|
|
94
|
+
if s.cachePath("second-account", config.GetAvailableModelListPath) == oldPath {
|
|
95
|
+
t.Fatal("API base URLs must not share cache")
|
|
96
|
+
}
|
|
97
|
+
files, _ := os.ReadDir(s.cacheDir)
|
|
98
|
+
for _, file := range files {
|
|
99
|
+
raw, _ := os.ReadFile(filepath.Join(s.cacheDir, file.Name()))
|
|
100
|
+
if strings.Contains(string(raw)+file.Name(), "private-test-key") || strings.Contains(string(raw)+file.Name(), "second-account") {
|
|
101
|
+
t.Fatal("cache must not persist raw credentials")
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func TestFailedQueryDoesNotUseOrCacheStaleData(t *testing.T) {
|
|
107
|
+
response := validResponse
|
|
108
|
+
requests := 0
|
|
109
|
+
s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
110
|
+
requests++
|
|
111
|
+
_, _ = w.Write([]byte(response))
|
|
112
|
+
})
|
|
113
|
+
if _, err := s.Get(context.Background(), false); err != nil {
|
|
114
|
+
t.Fatal(err)
|
|
115
|
+
}
|
|
116
|
+
for _, invalid := range []string{
|
|
117
|
+
`{"ret":"1001","errmsg":"unavailable","log_id":"log_123"}`,
|
|
118
|
+
`{"ret":"0","data":null}`,
|
|
119
|
+
`{"ret":"0","data":{"scene":"other","config_key":"key","config":{}}}`,
|
|
120
|
+
`not-json`,
|
|
121
|
+
} {
|
|
122
|
+
response = invalid
|
|
123
|
+
if result, err := s.Get(context.Background(), true); err == nil || result != nil || !strings.Contains(err.Error(), "重试") {
|
|
124
|
+
t.Fatalf("failed query must suggest retry: result=%+v err=%v", result, err)
|
|
125
|
+
}
|
|
126
|
+
before := requests
|
|
127
|
+
if _, err := s.Get(context.Background(), false); err == nil || requests != before+1 {
|
|
128
|
+
t.Fatal("retry must query the server, not reuse cached success or failure")
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
response = validResponse
|
|
132
|
+
if _, err := s.Get(context.Background(), false); err != nil {
|
|
133
|
+
t.Fatal(err)
|
|
134
|
+
}
|
|
135
|
+
cachePath := s.cachePath(s.runner.Config.AccessKey, config.GetAvailableModelListPath)
|
|
136
|
+
if err := os.WriteFile(cachePath, []byte("broken"), 0600); err != nil {
|
|
137
|
+
t.Fatal(err)
|
|
138
|
+
}
|
|
139
|
+
result, err := s.Get(context.Background(), false)
|
|
140
|
+
if err != nil || result.Cached {
|
|
141
|
+
t.Fatalf("corrupt cache should refresh: %+v %v", result, err)
|
|
142
|
+
}
|
|
143
|
+
// A timestamp in the future must not extend visibility indefinitely.
|
|
144
|
+
s.now = func() time.Time { return result.FetchedAt.Add(-time.Second) }
|
|
145
|
+
if s.readCache(cachePath) != nil {
|
|
146
|
+
t.Fatal("future-dated cache must be ignored")
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func TestModelConfigurationPreservedAcrossCache(t *testing.T) {
|
|
151
|
+
s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(validResponse)) })
|
|
152
|
+
for i := 0; i < 2; i++ {
|
|
153
|
+
result, err := s.Get(context.Background(), false)
|
|
154
|
+
if err != nil {
|
|
155
|
+
t.Fatal(err)
|
|
156
|
+
}
|
|
157
|
+
raw, err := result.Catalog.Describe("new-model")
|
|
158
|
+
if err != nil || !strings.Contains(string(raw), "9007199254740993") || !strings.Contains(string(raw), `"audio_total_limit":0`) {
|
|
159
|
+
t.Fatalf("configuration lost values: %s %v", raw, err)
|
|
160
|
+
}
|
|
161
|
+
if len(result.Catalog.Search("新模")) != 1 || len(result.Catalog.Search("missing")) != 0 {
|
|
162
|
+
t.Fatal("unexpected search result")
|
|
163
|
+
}
|
|
164
|
+
if _, err := result.Catalog.Describe("missing"); err == nil {
|
|
165
|
+
t.Fatal("missing model must not invent a config")
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func TestCacheWriteFailureStillReturnsServerResult(t *testing.T) {
|
|
171
|
+
s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(validResponse)) })
|
|
172
|
+
file := filepath.Join(t.TempDir(), "not-a-directory")
|
|
173
|
+
if err := os.WriteFile(file, []byte("x"), 0600); err != nil {
|
|
174
|
+
t.Fatal(err)
|
|
175
|
+
}
|
|
176
|
+
s.cacheDir = file
|
|
177
|
+
result, err := s.Get(context.Background(), false)
|
|
178
|
+
if err != nil || result.Cached || result.Warning == "" {
|
|
179
|
+
t.Fatalf("result=%+v err=%v", result, err)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func TestEmptyAndDuplicateModels(t *testing.T) {
|
|
184
|
+
for _, tc := range []struct {
|
|
185
|
+
data string
|
|
186
|
+
valid bool
|
|
187
|
+
}{
|
|
188
|
+
{`{"scene":"web_turbo_video_generator","config_key":"empty","config":{}}`, true},
|
|
189
|
+
{`{"scene":"web_turbo_video_generator","config_key":"dup","config":{"models":[{"key":"x","kind":"video"},{"key":"x","kind":"video"}]}}`, false},
|
|
190
|
+
{`{"scene":"web_turbo_video_generator","config_key":"null","config":{"models":[null]}}`, false},
|
|
191
|
+
} {
|
|
192
|
+
var catalog Catalog
|
|
193
|
+
if err := json.Unmarshal([]byte(tc.data), &catalog); err != nil {
|
|
194
|
+
t.Fatal(err)
|
|
195
|
+
}
|
|
196
|
+
if err := validateCatalog(&catalog); (err == nil) != tc.valid {
|
|
197
|
+
t.Fatalf("validateCatalog = %v", err)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -9,6 +9,7 @@ import (
|
|
|
9
9
|
|
|
10
10
|
// SubmitRunOptions is the stable command-facing request shape for short drama run submission.
|
|
11
11
|
type SubmitRunOptions struct {
|
|
12
|
+
Source string `json:"platform,omitempty"`
|
|
12
13
|
Message string `json:"message"`
|
|
13
14
|
ThreadID string `json:"thread_id,omitempty"`
|
|
14
15
|
AssetIDs []string `json:"asset_ids,omitempty"`
|
|
@@ -36,6 +37,7 @@ func SubmitRun(ctx context.Context, opts *SubmitRunOptions, runner *common.Runne
|
|
|
36
37
|
body["asset_ids"] = opts.AssetIDs
|
|
37
38
|
}
|
|
38
39
|
body["agent_name"] = "pippit_nest_novel_agent"
|
|
40
|
+
body = common.WithSubmitRunSource(body, opts.Source)
|
|
39
41
|
|
|
40
42
|
var resp common.SubmitRunResponse
|
|
41
43
|
if err := runner.Client.SendRequest(ctx, common.SubmitRunPath(runner), body, &resp); err != nil {
|
|
@@ -17,6 +17,7 @@ const (
|
|
|
17
17
|
|
|
18
18
|
// SuperResolutionOptions is the command-facing request shape for video-super-resolution.
|
|
19
19
|
type SuperResolutionOptions struct {
|
|
20
|
+
Source string
|
|
20
21
|
VideoPath string
|
|
21
22
|
ToolVersion string
|
|
22
23
|
OutputResolution string
|
|
@@ -24,6 +25,7 @@ type SuperResolutionOptions struct {
|
|
|
24
25
|
|
|
25
26
|
// EraseSubtitleOptions is the command-facing request shape for erase-video-subtitle.
|
|
26
27
|
type EraseSubtitleOptions struct {
|
|
28
|
+
Source string
|
|
27
29
|
VideoPath string
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -43,7 +45,7 @@ func RunSuperResolution(ctx context.Context, opts *SuperResolutionOptions, runne
|
|
|
43
45
|
if err != nil {
|
|
44
46
|
return nil, err
|
|
45
47
|
}
|
|
46
|
-
return common.SubmitRun(ctx, "video-super-resolution", buildSuperResolutionSubmitRunBody(opts, assetID), runner)
|
|
48
|
+
return common.SubmitRun(ctx, "video-super-resolution", common.WithSubmitRunSource(buildSuperResolutionSubmitRunBody(opts, assetID), opts.Source), runner)
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
// RunEraseSubtitle uploads one video and submits a subtitle-erasing run.
|
|
@@ -59,7 +61,7 @@ func RunEraseSubtitle(ctx context.Context, opts *EraseSubtitleOptions, runner *c
|
|
|
59
61
|
if err != nil {
|
|
60
62
|
return nil, err
|
|
61
63
|
}
|
|
62
|
-
return common.SubmitRun(ctx, "erase-video-subtitle", buildEraseSubtitleSubmitRunBody(assetID), runner)
|
|
64
|
+
return common.SubmitRun(ctx, "erase-video-subtitle", common.WithSubmitRunSource(buildEraseSubtitleSubmitRunBody(assetID), opts.Source), runner)
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
// ValidateSuperResolutionOptions validates only command shape; semantic enums remain service-owned.
|
package/package.json
CHANGED
|
@@ -21,6 +21,10 @@ metadata:
|
|
|
21
21
|
|
|
22
22
|
短剧场景面向剧情、人物、分集与画面化叙事创作,用户的原始需求通过 `--message` 发送给后端 Agent。后端 Agent 负责理解任务、编排流程和生成内容;用户侧 Agent 负责提交任务、并行查询进展与产物、主动下载重要资产并展示结果。
|
|
23
23
|
|
|
24
|
+
## 宿主来源统计
|
|
25
|
+
|
|
26
|
+
调用 `short-drama +submit-run` 时,宿主 Agent 根据可信的实际运行环境静默附加可选 `--source`:豆包办公为 `doubao_office`,WorkBuddy 为 `workbuddy`,Codex 为 `codex`;其它已知宿主使用真实、稳定的产品标识。不附带版本、会话 ID、用户信息或 prompt,不从用户创作内容猜测来源,不向用户询问或增加确认。来源不明时省略;仅用于统计,不影响实际工具效果,不改写 `--message`。上传、查询和下载命令不附加此参数。旧版 CLI 以 `--help` 为准,不支持时省略,不因统计字段阻塞任务或重复提交。
|
|
27
|
+
|
|
24
28
|
## 功能
|
|
25
29
|
|
|
26
30
|
1. **提交短剧 Run 任务** - 创建新会话或向已有会话发送短剧创作需求。
|
|
@@ -27,6 +27,7 @@ metadata:
|
|
|
27
27
|
| 查看登录状态、登录、退出或切换账号 | `status` / `login` / `logout` | [授权](commands/auth.md) |
|
|
28
28
|
| 创建或查询小云雀个人画布,编辑节点、布局、连线、角色/场景、提示词、3D 或多轨草稿 | `canvas` | [Canvas 能力与命令发现](commands/canvas.md) |
|
|
29
29
|
| 生成图片,或基于参考图修改图片 | `generate-image` | [生图与图片编辑](commands/generate-image.md) |
|
|
30
|
+
| 查询当前可用的视频模型、参数配置 | `model list` / `model describe` | [模型发现](commands/model.md) |
|
|
30
31
|
| 生成视频,使用图/视频/音频参考,首尾帧生视频 | `generate-video` | [生视频](commands/generate-video.md) |
|
|
31
32
|
| 提升已有视频分辨率、视频超分 | `video-super-resolution` | [超分](commands/video-super-resolution.md) |
|
|
32
33
|
| 去除已有视频字幕 | `erase-video-subtitle` | [擦字幕](commands/erase-video-subtitle.md) |
|
|
@@ -48,6 +49,15 @@ metadata:
|
|
|
48
49
|
- 提交成功后立即展示真实 `web_thread_link`;未返回链接时如实说明,保留任务 ID。后续查询和下载失败不能触发重复生成。
|
|
49
50
|
- 每个最终图片/视频都通过宿主文件交付或媒体渲染能力展示为真实附件或可预览媒体。URL、路径列表仅作补充;详细完成标准见共用交付流程。
|
|
50
51
|
|
|
52
|
+
## 宿主来源统计
|
|
53
|
+
|
|
54
|
+
调用 `generate-image`、`generate-video`、`video-super-resolution`、`erase-video-subtitle` 时,由宿主 Agent 根据实际运行环境静默附加可选 `--source`,仅用于来源统计,不影响创作参数或工具效果。
|
|
55
|
+
|
|
56
|
+
- 使用稳定的宿主标识:豆包办公填 `doubao_office`,WorkBuddy 填 `workbuddy`,Codex 填 `codex`;其它已知宿主使用其真实、稳定的产品标识,不附带版本、会话 ID、用户信息或 prompt。
|
|
57
|
+
- 从宿主提供的可信环境信息判断;不能因用户在创作内容中提到某个平台就认定它是来源,也不要把后端 Agent 名当作宿主来源。
|
|
58
|
+
- 不向用户询问、不增加确认步骤,不为此改写 prompt。无法确认来源时直接省略;不传或空值均不阻塞提交。
|
|
59
|
+
- 来源只随本次提交发送,不加入上传、查询、下载或 Canvas 命令。使用已有旧版 CLI 时先按 `--help` 确认是否支持;不支持则省略,不因统计字段中断任务或在未知提交结果时重提。
|
|
60
|
+
|
|
51
61
|
## 按需参考的完整场景
|
|
52
62
|
|
|
53
63
|
命令文档含最小调用示例;需要了解从需求到交付的组合过程时,再读对应场景:
|
|
@@ -11,3 +11,5 @@ pippit-tool-cli erase-video-subtitle --video "/path/to/source.mp4"
|
|
|
11
11
|
成功返回 `thread_id`、`run_id`、`web_thread_link`,继续 [异步结果与媒体交付](../workflows/async-delivery.md)。失败时报告原因,不把其他生成命令当作字幕处理的自动降级方案。
|
|
12
12
|
|
|
13
13
|
用户还明确要求超分时,参考 [擦字幕后超分](../examples/video-process-chain.md),用第一步下载得到的实际视频路径衔接第二步。
|
|
14
|
+
|
|
15
|
+
`--source` 是可选来源统计参数,由宿主 Agent 根据真实环境静默填写(如 `doubao_office`、`workbuddy`、`codex`);来源不明时省略,不询问用户,不改变 prompt 或创作参数。详见 [宿主来源统计](../SKILL.md#宿主来源统计)。
|
|
@@ -30,3 +30,5 @@ pippit-tool-cli generate-image --prompt "用户原始描述" --model IMAGE_MODEL
|
|
|
30
30
|
## 返回与处理
|
|
31
31
|
|
|
32
32
|
成功返回 JSON 中的 `thread_id`、`run_id`、`web_thread_link`;随后执行 [异步结果与媒体交付](../workflows/async-delivery.md)。参数错误、上传失败或服务端拒绝时停止并说明原因,不切换模型或重新提交。仅在已明确失败、问题已修正且原授权仍适用时重试;提交结果不确定时避免重复收费。
|
|
33
|
+
|
|
34
|
+
`--source` 是可选来源统计参数,由宿主 Agent 根据真实环境静默填写(如 `doubao_office`、`workbuddy`、`codex`);来源不明时省略,不询问用户,不改变 prompt 或创作参数。详见 [宿主来源统计](../SKILL.md#宿主来源统计)。
|
|
@@ -7,27 +7,51 @@
|
|
|
7
7
|
| 参数 | 必填 | 规则 |
|
|
8
8
|
| --- | --- | --- |
|
|
9
9
|
| `--prompt` | 是 | 用户原始描述,不能全为空白 |
|
|
10
|
-
| `--model` |
|
|
10
|
+
| `--model` | 是 | 用户指定的准确模型枚举;未明确模型时先确认,不猜测 |
|
|
11
11
|
| `--image` | 否 | 本地图片路径,重复参数 |
|
|
12
12
|
| `--video` | 否 | 本地参考视频路径,重复参数 |
|
|
13
13
|
| `--audio` | 否 | 本地 `.mp3/.wav` 音频路径,重复参数 |
|
|
14
|
-
| `--duration` |
|
|
15
|
-
| `--ratio` |
|
|
16
|
-
| `--resolution` |
|
|
14
|
+
| `--duration` | 按模型 | 整数秒;MiniMax、Wan、HappyHorse 可省略由服务端补齐默认值,其他模型需明确提供;用户只给时长范围时先确认具体秒数 |
|
|
15
|
+
| `--ratio` | 按模式 | 比例字符串,不转换为生图枚举;MiniMax、Wan、HappyHorse 可省略使用服务端配置默认值;其他模型需明确提供 |
|
|
16
|
+
| `--resolution` | 按模型 | `Seedance_2.0_mini`、`Seedance_2.0_mini_lite` 可省略,服务端默认 `720p`;MiniMax、Wan、HappyHorse 可省略使用服务端配置默认值;其他模型需明确提供 |
|
|
17
17
|
| `--generate-type` | 否 | 首尾帧任务传 `1`;其他显式值交服务端处理 |
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
当前支持的模型以 `pippit-tool-cli model list` 为准,完整参数配置用 `pippit-tool-cli model describe MODEL_KEY` 查询;缓存有效期为 5 分钟,需要最新结果时加 `--refresh`。详情见 [模型发现](model.md)。CLI 不自行维护模型白名单或分辨率组合校验。
|
|
20
20
|
|
|
21
21
|
本地图片支持 `.jpg/.jpeg/.png/.gif/.bmp/.webp/.svg`;视频支持 `.mp4/.avi/.mov/.wmv/.flv/.webm/.mkv/.m4v`。CLI 内部上传参考素材。
|
|
22
22
|
|
|
23
|
+
## 模型与模式
|
|
24
|
+
|
|
25
|
+
先从模型列表取得准确 key,再查看配置中的分辨率、时长、比例和创作模式。配置会随服务端调整,本页示例不作为可用模型或参数范围清单。
|
|
26
|
+
|
|
27
|
+
MiniMax、Wan、HappyHorse 可省略分辨率、时长、比例,由服务端配置补默认值。MiniMax 的兼容输入 `720p` 会映射到 `768p`,Wan/HappyHorse 的 `720p` 保持不变。Max 首尾帧模式会按素材采用自适应比例。素材可用组合以当前配置和服务端校验为准,CLI 不限制素材数量。
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pippit-tool-cli model list
|
|
31
|
+
pippit-tool-cli model describe MiniMax-H3
|
|
32
|
+
pippit-tool-cli generate-video --prompt "用户原始描述" --model MiniMax-H3 --ratio 16:9
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
模型查询失败时提示重试,不从本文示例推定模型仍然可用,不自行替换用户指定的模型。Wan 同样必须有提示词;文件和网页链接输入不在本命令范围内。
|
|
36
|
+
|
|
23
37
|
## 最小调用
|
|
24
38
|
|
|
39
|
+
`Seedance_2.0_mini` 和 `Seedance_2.0_mini_lite` 可以省略分辨率,CLI 不补值,交由服务端默认使用 `720p`:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pippit-tool-cli generate-video --prompt "用户原始描述" --model Seedance_2.0_mini --duration 5 --ratio 16:9
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`seedance2.0_vision` 仍需指定分辨率:
|
|
46
|
+
|
|
25
47
|
```bash
|
|
26
|
-
pippit-tool-cli generate-video --prompt "用户原始描述"
|
|
48
|
+
pippit-tool-cli generate-video --prompt "用户原始描述" --model seedance2.0_vision --duration 5 --ratio 16:9 --resolution 720p
|
|
27
49
|
```
|
|
28
50
|
|
|
29
|
-
首尾帧场景:明确两张图片的角色,按首帧、尾帧顺序传两次 `--image`,固定传 `--generate-type 1`。缺少图片、角色不清或无法确定原始描述时先询问。完整示例见 [首尾帧生视频](../examples/first-last-frame.md)。
|
|
51
|
+
首尾帧场景:明确两张图片的角色,按首帧、尾帧顺序传两次 `--image`,固定传 `--generate-type 1`。MiniMax 只给首帧时也可传一张图片,仍需 `--generate-type 1`。缺少图片、角色不清或无法确定原始描述时先询问。完整示例见 [首尾帧生视频](../examples/first-last-frame.md)。
|
|
30
52
|
|
|
31
53
|
## 返回与处理
|
|
32
54
|
|
|
33
55
|
成功返回 `thread_id`、`run_id`、`web_thread_link`,继续 [异步结果与媒体交付](../workflows/async-delivery.md)。素材、权限或参数失败时说明原因,不自动降低用户指定的模型、分辨率或删减素材。无法确认提交是否成功时,不重复生成。
|
|
56
|
+
|
|
57
|
+
`--source` 是可选来源统计参数,由宿主 Agent 根据真实环境静默填写(如 `doubao_office`、`workbuddy`、`codex`);来源不明时省略,不询问用户,不改变 prompt 或创作参数。详见 [宿主来源统计](../SKILL.md#宿主来源统计)。
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# model:发现可用视频模型
|
|
2
|
+
|
|
3
|
+
需要有效登录或 `XYQ_ACCESS_KEY`。生成前可查询当前账号可用的模型及参数配置;目前只支持视频模型。
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
# 全部可见视频模型
|
|
7
|
+
pippit-tool-cli model list
|
|
8
|
+
|
|
9
|
+
# 按 key 或展示名检索;search 是 list 的别名
|
|
10
|
+
pippit-tool-cli model search MiniMax --type video
|
|
11
|
+
|
|
12
|
+
# 按准确 key 查看参数详情;两种写法等价
|
|
13
|
+
pippit-tool-cli model describe MiniMax-H3
|
|
14
|
+
pippit-tool-cli model MiniMax-H3
|
|
15
|
+
|
|
16
|
+
# 强制重新查询服务端
|
|
17
|
+
pippit-tool-cli model list --refresh
|
|
18
|
+
pippit-tool-cli model describe MiniMax-H3 --refresh
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`list` 输出 `models`,每项包含 `key`、`name`、`kind`、`is_default`。关键词与 key 完全一致时优先返回该项,否则按 key / name 不区分大小写检索。`describe` 只接受准确 key,输出整理后的 `model` 参数详情;不知道 key 时先查列表,不用展示名猜枚举。
|
|
22
|
+
|
|
23
|
+
两种输出均包含 `scene`、`cached`、`fetched_at`、`expires_at`。合法空列表输出 `models: []`,表示当前没有可见模型。
|
|
24
|
+
|
|
25
|
+
## 缓存与失败处理
|
|
26
|
+
|
|
27
|
+
成功结果在系统用户缓存目录的 `pippit-cli/models/` 下保留 5 分钟,命中不会延长有效期;按凭证指纹、API 地址、接口、场景及 CLI 版本隔离。缓存不保存 AK 原文。切换凭证或退出登录后,不读取先前凭证的缓存。
|
|
28
|
+
|
|
29
|
+
`--refresh` 跳过缓存重新查询。缓存过期、损坏或时间戳异常时重新请求;服务端查询失败返回非零退出状态并提示重试,不返回过期缓存或静态模型列表,也不缓存失败响应。可稍后重试原命令或加 `--refresh`;若是登录错误,先恢复登录。单次查询最长 30 秒,不自动重复请求。
|
|
30
|
+
|
|
31
|
+
缓存写入失败不丢弃本次服务端成功结果,stderr 会提示;stdout 仍为 JSON。
|
|
32
|
+
|
|
33
|
+
## 参数配置的使用
|
|
34
|
+
|
|
35
|
+
`describe` 将配置整理成可直接选择生成参数的结构。以下为示例片段,实际值以查询为准:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"key": "MiniMax-H3",
|
|
40
|
+
"ratio": {
|
|
41
|
+
"options": ["adaptive", "16:9", "21:9", "9:16", "4:3", "3:4", "1:1"],
|
|
42
|
+
"default": "9:16"
|
|
43
|
+
},
|
|
44
|
+
"resolution": {"options": ["768p", "2k"], "default": "768p"},
|
|
45
|
+
"duration": {"min": 4, "max": 15, "step": 1, "default": 10, "unit": "seconds"}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- `ratio.options/default` 按 IDL 转为字符串,可直接用于 `--ratio`。支持全部有对应生成参数的已有比例枚举;`0` 为 `adaptive`。自定义比例 `1` 没有对应的 CLI 尺寸参数,和未知枚举一样跳过;未知默认值不输出,不猜测替代值。
|
|
50
|
+
- `resolution.options` 去除 disabled 选项,默认值必须在有效选项内。
|
|
51
|
+
- `Seedance_2.0_mini`、`Seedance_2.0_mini_lite` 的生成请求可以省略 `--resolution`,服务端默认 `720p`。查询未返回分辨率维度时,不因此要求用户补填,也不在查询结果中伪造选项。
|
|
52
|
+
- `duration` 来自参数维度:范围输出 `min/max/step`,选项输出数字 `options`,均以秒为单位,不把选项枚举号当秒数。若只下发旧 `supported_duration_list`,暂保留该原始字段并明确提示不能直接用于 `--duration`。
|
|
53
|
+
- `material_limits` 保留数量字段;大小字段使用 `max_image_size_bytes`,视频时长字段使用 `min_video_duration_ms/max_video_duration_ms/max_total_video_duration_ms`。字段未返回与值为 `0` 保持区别。
|
|
54
|
+
- `creation_modes` 保留 `enabled`、素材校验等服务端策略,并为文本/参考/首尾帧模式标注对应 `generate_type`。`smart` 比例策略显示 `adaptive`;其他可识别模式继承模型比例。MiniMax 的文本模式排除 `adaptive`,纯文生视频使用固定比例。原始模式 key 不是生成参数,未标注 `generate_type` 的模式不代表 CLI 已接入。
|
|
55
|
+
- 条件维度的 `active_when_any`、参数组合约束及其他未转换字段保留;模型级选项不保证任意组合都可用。配置不一致时通过 `warnings` 提示刷新,未知比例枚举直接跳过。
|
|
56
|
+
|
|
57
|
+
内部 `config_key` 不输出。缓存仍保存服务端原始配置,列表/详情展示时转换,不修改生成请求、不新增本地模型准入限制。生成参数格式见 [生视频命令](generate-video.md)。
|
|
58
|
+
|
|
59
|
+
查询结果反映当前账号的服务端可见配置及 Skill 模型白名单;最终提交仍由服务端判断权限、参数、余额等条件。缓存最多滞后 5 分钟,需要最新值时使用 `--refresh`。生成命令不会自动请求模型列表或凭缓存拦截生成。
|
|
@@ -15,3 +15,5 @@ pippit-tool-cli video-super-resolution --video "/path/to/source.mp4" --output-re
|
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
示例以用户要求 1080p 为前提。成功返回 `thread_id`、`run_id`、`web_thread_link`,按 [异步结果与媒体交付](../workflows/async-delivery.md) 查询、下载并交付。输入失败或服务端拒绝时停止,不擅自切换版本或分辨率。
|
|
18
|
+
|
|
19
|
+
`--source` 是可选来源统计参数,由宿主 Agent 根据真实环境静默填写(如 `doubao_office`、`workbuddy`、`codex`);来源不明时省略,不询问用户,不改变 prompt 或创作参数。详见 [宿主来源统计](../SKILL.md#宿主来源统计)。
|