@pippit-dev/cli 1.0.32 → 1.0.33
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 +19 -8
- package/checksums.txt +6 -6
- package/cmd/generate_image/generate_image.go +4 -2
- package/cmd/generate_image_test.go +150 -1
- package/cmd/model.go +7 -10
- package/cmd/model_test.go +59 -1
- package/cmd/short_drama_test.go +2 -0
- package/cmd/source_test.go +6 -1
- package/dist/checksums.txt +6 -6
- package/internal/common/ratio.go +12 -0
- package/internal/generate_image/generate_image.go +19 -7
- package/internal/generate_image/generate_image_test.go +57 -14
- package/internal/models/describe.go +79 -19
- package/internal/models/describe_test.go +131 -5
- package/internal/models/models.go +124 -22
- package/internal/models/models_test.go +158 -16
- package/package.json +1 -1
- package/skills/xyq-nest-skill/SKILL.md +2 -3
- package/skills/xyq-nest-skill/commands/generate-image.md +12 -7
- package/skills/xyq-nest-skill/commands/model.md +49 -7
- package/skills/xyq-nest-skill/examples/generate-and-deliver.md +4 -2
- package/skills/xyq-nest-skill/examples/image-edit.md +5 -3
|
@@ -7,6 +7,7 @@ import (
|
|
|
7
7
|
"fmt"
|
|
8
8
|
"os"
|
|
9
9
|
"path/filepath"
|
|
10
|
+
"sort"
|
|
10
11
|
"strings"
|
|
11
12
|
"time"
|
|
12
13
|
|
|
@@ -16,12 +17,14 @@ import (
|
|
|
16
17
|
)
|
|
17
18
|
|
|
18
19
|
const (
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
VideoScene = "web_turbo_video_generator"
|
|
21
|
+
ImageScene = "web_image_agent"
|
|
22
|
+
CacheTTL = 5 * time.Minute
|
|
21
23
|
)
|
|
22
24
|
|
|
23
25
|
// Catalog preserves the server's model configuration, including unknown fields
|
|
24
|
-
// and integer enums.
|
|
26
|
+
// and integer enums. Image names are resolved here before submission; admission
|
|
27
|
+
// and parameter validation remain the server's responsibility.
|
|
25
28
|
type Catalog struct {
|
|
26
29
|
Scene string `json:"scene"`
|
|
27
30
|
ConfigKey string `json:"config_key"`
|
|
@@ -31,7 +34,7 @@ type Catalog struct {
|
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
type Summary struct {
|
|
34
|
-
Key string `json:"key"`
|
|
37
|
+
Key string `json:"key,omitempty"`
|
|
35
38
|
Name string `json:"name"`
|
|
36
39
|
Kind string `json:"kind"`
|
|
37
40
|
}
|
|
@@ -62,7 +65,22 @@ func NewService(runner *common.Runner) *Service {
|
|
|
62
65
|
return &Service{runner: runner, cacheDir: dir, now: time.Now}
|
|
63
66
|
}
|
|
64
67
|
|
|
65
|
-
func (
|
|
68
|
+
func sceneForType(modelType string) (string, error) {
|
|
69
|
+
switch modelType {
|
|
70
|
+
case "video":
|
|
71
|
+
return VideoScene, nil
|
|
72
|
+
case "image":
|
|
73
|
+
return ImageScene, nil
|
|
74
|
+
default:
|
|
75
|
+
return "", fmt.Errorf("不支持的模型类型 %q;--type 可选 video 或 image", modelType)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func (s *Service) Get(ctx context.Context, modelType string, refresh bool) (*Result, error) {
|
|
80
|
+
scene, err := sceneForType(modelType)
|
|
81
|
+
if err != nil {
|
|
82
|
+
return nil, err
|
|
83
|
+
}
|
|
66
84
|
if err := ctx.Err(); err != nil {
|
|
67
85
|
return nil, err
|
|
68
86
|
}
|
|
@@ -79,9 +97,9 @@ func (s *Service) Get(ctx context.Context, refresh bool) (*Result, error) {
|
|
|
79
97
|
if s.runner.Config.Paths != nil && s.runner.Config.Paths.GetAvailableModelList != "" {
|
|
80
98
|
path = s.runner.Config.Paths.GetAvailableModelList
|
|
81
99
|
}
|
|
82
|
-
cachePath := s.cachePath(accessKey, path)
|
|
100
|
+
cachePath := s.cachePath(accessKey, path, scene)
|
|
83
101
|
if !refresh && cachePath != "" {
|
|
84
|
-
if entry := s.readCache(cachePath); entry != nil {
|
|
102
|
+
if entry := s.readCache(cachePath, scene); entry != nil {
|
|
85
103
|
return &Result{Catalog: entry.Catalog, Cached: true, FetchedAt: entry.FetchedAt}, nil
|
|
86
104
|
}
|
|
87
105
|
}
|
|
@@ -100,14 +118,14 @@ func (s *Service) Get(ctx context.Context, refresh bool) (*Result, error) {
|
|
|
100
118
|
err = s.runner.Client.SendRequest(queryCtx, path, struct {
|
|
101
119
|
Scene string `json:"scene"`
|
|
102
120
|
SourceModelKey string `json:"source_model_key"`
|
|
103
|
-
}{Scene:
|
|
121
|
+
}{Scene: scene}, &resp)
|
|
104
122
|
if err != nil {
|
|
105
123
|
return nil, fmt.Errorf("模型查询失败,请稍后重试(可加 --refresh): %w", err)
|
|
106
124
|
}
|
|
107
125
|
if resp.Ret != "0" {
|
|
108
126
|
return nil, common.NewLogIDError(fmt.Sprintf("模型查询失败,请稍后重试(可加 --refresh): ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID)
|
|
109
127
|
}
|
|
110
|
-
if err := validateCatalog(resp.Data); err != nil {
|
|
128
|
+
if err := validateCatalog(resp.Data, scene); err != nil {
|
|
111
129
|
return nil, common.NewLogIDError("模型配置无效,请稍后重试(可加 --refresh): "+err.Error(), resp.LogID)
|
|
112
130
|
}
|
|
113
131
|
currentKey, err := s.runner.Auth.ResolveAccessKey(ctx)
|
|
@@ -121,24 +139,24 @@ func (s *Service) Get(ctx context.Context, refresh bool) (*Result, error) {
|
|
|
121
139
|
return result, nil
|
|
122
140
|
}
|
|
123
141
|
|
|
124
|
-
func (s *Service) cachePath(accessKey, path string) string {
|
|
142
|
+
func (s *Service) cachePath(accessKey, path, scene string) string {
|
|
125
143
|
if s.cacheDir == "" {
|
|
126
144
|
return ""
|
|
127
145
|
}
|
|
128
146
|
scope, _ := json.Marshal([]string{
|
|
129
|
-
"v1", strings.TrimRight(s.runner.Config.BaseURL, "/"), path,
|
|
147
|
+
"v1", strings.TrimRight(s.runner.Config.BaseURL, "/"), path, scene,
|
|
130
148
|
version.Current(), accessKey,
|
|
131
149
|
})
|
|
132
150
|
return filepath.Join(s.cacheDir, fmt.Sprintf("%x.json", sha256.Sum256(scope)))
|
|
133
151
|
}
|
|
134
152
|
|
|
135
|
-
func (s *Service) readCache(path string) *cacheEntry {
|
|
153
|
+
func (s *Service) readCache(path, scene string) *cacheEntry {
|
|
136
154
|
raw, err := os.ReadFile(path)
|
|
137
155
|
if err != nil {
|
|
138
156
|
return nil
|
|
139
157
|
}
|
|
140
158
|
var entry cacheEntry
|
|
141
|
-
if json.Unmarshal(raw, &entry) != nil || validateCatalog(entry.Catalog) != nil {
|
|
159
|
+
if json.Unmarshal(raw, &entry) != nil || validateCatalog(entry.Catalog, scene) != nil {
|
|
142
160
|
return nil
|
|
143
161
|
}
|
|
144
162
|
age := s.now().Sub(entry.FetchedAt)
|
|
@@ -172,20 +190,37 @@ func (s *Service) writeCache(path string, result *Result) error {
|
|
|
172
190
|
return os.Rename(f.Name(), path)
|
|
173
191
|
}
|
|
174
192
|
|
|
175
|
-
func validateCatalog(catalog *Catalog) error {
|
|
176
|
-
|
|
193
|
+
func validateCatalog(catalog *Catalog, scene string) error {
|
|
194
|
+
kind := "video"
|
|
195
|
+
if scene == ImageScene {
|
|
196
|
+
kind = "image"
|
|
197
|
+
} else if scene != VideoScene {
|
|
198
|
+
return fmt.Errorf("不支持的模型场景 %q", scene)
|
|
199
|
+
}
|
|
200
|
+
if catalog == nil || catalog.Config == nil || catalog.Scene != scene || catalog.ConfigKey == "" {
|
|
177
201
|
return fmt.Errorf("缺少配置或场景不匹配")
|
|
178
202
|
}
|
|
179
203
|
seen := make(map[string]bool)
|
|
204
|
+
seenNames := make(map[string]bool)
|
|
180
205
|
for _, raw := range catalog.Config.Models {
|
|
181
206
|
var model Summary
|
|
182
|
-
if json.Unmarshal(raw, &model) != nil || strings.TrimSpace(model.Key) == "" || model.Kind !=
|
|
207
|
+
if json.Unmarshal(raw, &model) != nil || strings.TrimSpace(model.Key) == "" || model.Kind != kind {
|
|
183
208
|
return fmt.Errorf("模型条目缺少有效 key 或 kind")
|
|
184
209
|
}
|
|
185
210
|
if seen[model.Key] {
|
|
211
|
+
if kind == "image" {
|
|
212
|
+
return fmt.Errorf("图片模型配置包含重复条目")
|
|
213
|
+
}
|
|
186
214
|
return fmt.Errorf("模型 key 重复: %s", model.Key)
|
|
187
215
|
}
|
|
188
216
|
seen[model.Key] = true
|
|
217
|
+
if kind == "image" {
|
|
218
|
+
name := strings.TrimSpace(model.Name)
|
|
219
|
+
if name == "" || strings.EqualFold(name, strings.TrimSpace(model.Key)) || seenNames[name] {
|
|
220
|
+
return fmt.Errorf("图片模型展示名称缺失、与模型标识相同或重复,请刷新模型列表")
|
|
221
|
+
}
|
|
222
|
+
seenNames[name] = true
|
|
223
|
+
}
|
|
189
224
|
}
|
|
190
225
|
return nil
|
|
191
226
|
}
|
|
@@ -196,7 +231,10 @@ func (c *Catalog) Search(query string) []Summary {
|
|
|
196
231
|
for _, raw := range c.Config.Models {
|
|
197
232
|
var model Summary
|
|
198
233
|
_ = json.Unmarshal(raw, &model) // validated at the network/cache boundary
|
|
199
|
-
if
|
|
234
|
+
if c.Scene == ImageScene {
|
|
235
|
+
model.Key = "" // Wire identifiers stay in the raw catalog only.
|
|
236
|
+
model.Name = strings.TrimSpace(model.Name)
|
|
237
|
+
} else if model.Key == query {
|
|
200
238
|
return []Summary{model}
|
|
201
239
|
}
|
|
202
240
|
if query == "" || strings.Contains(strings.ToLower(model.Name), strings.ToLower(query)) || strings.Contains(strings.ToLower(model.Key), strings.ToLower(query)) {
|
|
@@ -206,14 +244,78 @@ func (c *Catalog) Search(query string) []Summary {
|
|
|
206
244
|
return matches
|
|
207
245
|
}
|
|
208
246
|
|
|
209
|
-
func (c *Catalog)
|
|
210
|
-
|
|
247
|
+
func (c *Catalog) findModel(selector string) (json.RawMessage, error) {
|
|
248
|
+
selector = strings.TrimSpace(selector)
|
|
249
|
+
var found json.RawMessage
|
|
211
250
|
for _, raw := range c.Config.Models {
|
|
212
251
|
var model Summary
|
|
213
252
|
_ = json.Unmarshal(raw, &model)
|
|
214
|
-
|
|
215
|
-
|
|
253
|
+
value := model.Key
|
|
254
|
+
if c.Scene == ImageScene {
|
|
255
|
+
value = strings.TrimSpace(model.Name)
|
|
216
256
|
}
|
|
257
|
+
if value != "" && value == selector {
|
|
258
|
+
if found != nil {
|
|
259
|
+
return nil, fmt.Errorf("模型名称重复,请执行 model list --type image --refresh 刷新后重试")
|
|
260
|
+
}
|
|
261
|
+
found = raw
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if found != nil {
|
|
265
|
+
return found, nil
|
|
266
|
+
}
|
|
267
|
+
if c.Scene == ImageScene {
|
|
268
|
+
return nil, fmt.Errorf("未找到该图片模型名称;请执行 model list --type image --refresh,并使用列表中的完整名称")
|
|
269
|
+
}
|
|
270
|
+
return nil, fmt.Errorf("未找到可用模型 %q;请执行 model list --type video --refresh 查看当前模型", selector)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
func (c *Catalog) Describe(selector string) (json.RawMessage, error) {
|
|
274
|
+
raw, err := c.findModel(selector)
|
|
275
|
+
if err != nil {
|
|
276
|
+
return nil, err
|
|
277
|
+
}
|
|
278
|
+
return describeModel(raw)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ImageModelKey translates the user's exact display name into a wire value.
|
|
282
|
+
// Never guess keys from names or send an unresolved name to the server.
|
|
283
|
+
func (c *Catalog) ImageModelKey(name string) (string, error) {
|
|
284
|
+
if c.Scene != ImageScene {
|
|
285
|
+
return "", fmt.Errorf("图片生成需要图片模型配置")
|
|
286
|
+
}
|
|
287
|
+
raw, err := c.findModel(name)
|
|
288
|
+
if err != nil {
|
|
289
|
+
return "", err
|
|
290
|
+
}
|
|
291
|
+
var model Summary
|
|
292
|
+
_ = json.Unmarshal(raw, &model)
|
|
293
|
+
return model.Key, nil
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ImageDisplayMessage keeps known wire identifiers out of server error messages.
|
|
297
|
+
func (c *Catalog) ImageDisplayMessage(message string) string {
|
|
298
|
+
var models []Summary
|
|
299
|
+
for _, raw := range c.Config.Models {
|
|
300
|
+
var model struct {
|
|
301
|
+
Summary
|
|
302
|
+
ReportName string `json:"report_name"`
|
|
303
|
+
}
|
|
304
|
+
_ = json.Unmarshal(raw, &model)
|
|
305
|
+
name := strings.TrimSpace(model.Name)
|
|
306
|
+
if model.Kind == "image" && name != "" {
|
|
307
|
+
for _, value := range []string{model.Key, model.ReportName} {
|
|
308
|
+
if value != "" && value != name {
|
|
309
|
+
models = append(models, Summary{Key: value, Name: name})
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// A key can be a prefix of another key. Replace the longer one first.
|
|
315
|
+
sort.Slice(models, func(i, j int) bool { return len(models[i].Key) > len(models[j].Key) })
|
|
316
|
+
pairs := make([]string, 0, len(models)*2)
|
|
317
|
+
for _, model := range models {
|
|
318
|
+
pairs = append(pairs, model.Key, strings.TrimSpace(model.Name))
|
|
217
319
|
}
|
|
218
|
-
return
|
|
320
|
+
return strings.NewReplacer(pairs...).Replace(message)
|
|
219
321
|
}
|
|
@@ -45,11 +45,11 @@ func TestMissingCredentialsCannotReadFreshCache(t *testing.T) {
|
|
|
45
45
|
requests++
|
|
46
46
|
_, _ = w.Write([]byte(validResponse))
|
|
47
47
|
})
|
|
48
|
-
if _, err := s.Get(context.Background(), false); err != nil {
|
|
48
|
+
if _, err := s.Get(context.Background(), "video", false); err != nil {
|
|
49
49
|
t.Fatal(err)
|
|
50
50
|
}
|
|
51
51
|
s.runner.Auth = unavailableAuth{}
|
|
52
|
-
result, err := s.Get(context.Background(), false)
|
|
52
|
+
result, err := s.Get(context.Background(), "video", false)
|
|
53
53
|
if err == nil || result != nil || requests != 1 || !strings.Contains(err.Error(), "login") {
|
|
54
54
|
t.Fatalf("must require login before cache: result=%+v err=%v requests=%d", result, err, requests)
|
|
55
55
|
}
|
|
@@ -63,7 +63,7 @@ func TestCacheTTLRefreshAndIsolation(t *testing.T) {
|
|
|
63
63
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
|
64
64
|
}
|
|
65
65
|
var body map[string]string
|
|
66
|
-
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["scene"] !=
|
|
66
|
+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["scene"] != VideoScene || body["source_model_key"] != "" {
|
|
67
67
|
t.Errorf("unexpected body: %v, err=%v", body, err)
|
|
68
68
|
}
|
|
69
69
|
_, _ = w.Write([]byte(validResponse))
|
|
@@ -72,7 +72,7 @@ func TestCacheTTLRefreshAndIsolation(t *testing.T) {
|
|
|
72
72
|
s.now = func() time.Time { return now }
|
|
73
73
|
check := func(refresh, cached bool, count int) *Result {
|
|
74
74
|
t.Helper()
|
|
75
|
-
result, err := s.Get(context.Background(), refresh)
|
|
75
|
+
result, err := s.Get(context.Background(), "video", refresh)
|
|
76
76
|
if err != nil || result.Cached != cached || requests != count {
|
|
77
77
|
t.Fatalf("Get: result=%+v err=%v requests=%d, want cached=%v requests=%d", result, err, requests, cached, count)
|
|
78
78
|
}
|
|
@@ -89,9 +89,9 @@ func TestCacheTTLRefreshAndIsolation(t *testing.T) {
|
|
|
89
89
|
check(true, false, 3)
|
|
90
90
|
s.runner.Config.AccessKey = "second-account"
|
|
91
91
|
check(false, false, 4)
|
|
92
|
-
oldPath := s.cachePath("second-account", config.GetAvailableModelListPath)
|
|
92
|
+
oldPath := s.cachePath("second-account", config.GetAvailableModelListPath, VideoScene)
|
|
93
93
|
s.runner.Config.BaseURL += "/other-api"
|
|
94
|
-
if s.cachePath("second-account", config.GetAvailableModelListPath) == oldPath {
|
|
94
|
+
if s.cachePath("second-account", config.GetAvailableModelListPath, VideoScene) == oldPath {
|
|
95
95
|
t.Fatal("API base URLs must not share cache")
|
|
96
96
|
}
|
|
97
97
|
files, _ := os.ReadDir(s.cacheDir)
|
|
@@ -110,7 +110,7 @@ func TestFailedQueryDoesNotUseOrCacheStaleData(t *testing.T) {
|
|
|
110
110
|
requests++
|
|
111
111
|
_, _ = w.Write([]byte(response))
|
|
112
112
|
})
|
|
113
|
-
if _, err := s.Get(context.Background(), false); err != nil {
|
|
113
|
+
if _, err := s.Get(context.Background(), "video", false); err != nil {
|
|
114
114
|
t.Fatal(err)
|
|
115
115
|
}
|
|
116
116
|
for _, invalid := range []string{
|
|
@@ -120,29 +120,29 @@ func TestFailedQueryDoesNotUseOrCacheStaleData(t *testing.T) {
|
|
|
120
120
|
`not-json`,
|
|
121
121
|
} {
|
|
122
122
|
response = invalid
|
|
123
|
-
if result, err := s.Get(context.Background(), true); err == nil || result != nil || !strings.Contains(err.Error(), "重试") {
|
|
123
|
+
if result, err := s.Get(context.Background(), "video", true); err == nil || result != nil || !strings.Contains(err.Error(), "重试") {
|
|
124
124
|
t.Fatalf("failed query must suggest retry: result=%+v err=%v", result, err)
|
|
125
125
|
}
|
|
126
126
|
before := requests
|
|
127
|
-
if _, err := s.Get(context.Background(), false); err == nil || requests != before+1 {
|
|
127
|
+
if _, err := s.Get(context.Background(), "video", false); err == nil || requests != before+1 {
|
|
128
128
|
t.Fatal("retry must query the server, not reuse cached success or failure")
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
response = validResponse
|
|
132
|
-
if _, err := s.Get(context.Background(), false); err != nil {
|
|
132
|
+
if _, err := s.Get(context.Background(), "video", false); err != nil {
|
|
133
133
|
t.Fatal(err)
|
|
134
134
|
}
|
|
135
|
-
cachePath := s.cachePath(s.runner.Config.AccessKey, config.GetAvailableModelListPath)
|
|
135
|
+
cachePath := s.cachePath(s.runner.Config.AccessKey, config.GetAvailableModelListPath, VideoScene)
|
|
136
136
|
if err := os.WriteFile(cachePath, []byte("broken"), 0600); err != nil {
|
|
137
137
|
t.Fatal(err)
|
|
138
138
|
}
|
|
139
|
-
result, err := s.Get(context.Background(), false)
|
|
139
|
+
result, err := s.Get(context.Background(), "video", false)
|
|
140
140
|
if err != nil || result.Cached {
|
|
141
141
|
t.Fatalf("corrupt cache should refresh: %+v %v", result, err)
|
|
142
142
|
}
|
|
143
143
|
// A timestamp in the future must not extend visibility indefinitely.
|
|
144
144
|
s.now = func() time.Time { return result.FetchedAt.Add(-time.Second) }
|
|
145
|
-
if s.readCache(cachePath) != nil {
|
|
145
|
+
if s.readCache(cachePath, VideoScene) != nil {
|
|
146
146
|
t.Fatal("future-dated cache must be ignored")
|
|
147
147
|
}
|
|
148
148
|
}
|
|
@@ -150,7 +150,7 @@ func TestFailedQueryDoesNotUseOrCacheStaleData(t *testing.T) {
|
|
|
150
150
|
func TestModelConfigurationPreservedAcrossCache(t *testing.T) {
|
|
151
151
|
s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(validResponse)) })
|
|
152
152
|
for i := 0; i < 2; i++ {
|
|
153
|
-
result, err := s.Get(context.Background(), false)
|
|
153
|
+
result, err := s.Get(context.Background(), "video", false)
|
|
154
154
|
if err != nil {
|
|
155
155
|
t.Fatal(err)
|
|
156
156
|
}
|
|
@@ -188,7 +188,7 @@ func TestCacheWriteFailureStillReturnsServerResult(t *testing.T) {
|
|
|
188
188
|
t.Fatal(err)
|
|
189
189
|
}
|
|
190
190
|
s.cacheDir = file
|
|
191
|
-
result, err := s.Get(context.Background(), false)
|
|
191
|
+
result, err := s.Get(context.Background(), "video", false)
|
|
192
192
|
if err != nil || result.Cached || result.Warning == "" {
|
|
193
193
|
t.Fatalf("result=%+v err=%v", result, err)
|
|
194
194
|
}
|
|
@@ -207,8 +207,150 @@ func TestEmptyAndDuplicateModels(t *testing.T) {
|
|
|
207
207
|
if err := json.Unmarshal([]byte(tc.data), &catalog); err != nil {
|
|
208
208
|
t.Fatal(err)
|
|
209
209
|
}
|
|
210
|
-
if err := validateCatalog(&catalog); (err == nil) != tc.valid {
|
|
210
|
+
if err := validateCatalog(&catalog, VideoScene); (err == nil) != tc.valid {
|
|
211
211
|
t.Fatalf("validateCatalog = %v", err)
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
|
+
|
|
216
|
+
func TestImageAndVideoCacheIsolation(t *testing.T) {
|
|
217
|
+
requests := map[string]int{}
|
|
218
|
+
failImage := false
|
|
219
|
+
s := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
|
|
220
|
+
var body map[string]string
|
|
221
|
+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
222
|
+
t.Error(err)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
scene := body["scene"]
|
|
226
|
+
requests[scene]++
|
|
227
|
+
if body["source_model_key"] != "" || len(body) != 2 {
|
|
228
|
+
t.Errorf("unexpected identity or selector fields: %v", body)
|
|
229
|
+
}
|
|
230
|
+
response := validResponse
|
|
231
|
+
switch scene {
|
|
232
|
+
case ImageScene:
|
|
233
|
+
response = strings.ReplaceAll(strings.ReplaceAll(validResponse, VideoScene, ImageScene), `"kind":"video"`, `"kind":"image"`)
|
|
234
|
+
if failImage {
|
|
235
|
+
response = `{"ret":"1001","errmsg":"scene not supported"}`
|
|
236
|
+
}
|
|
237
|
+
case VideoScene:
|
|
238
|
+
default:
|
|
239
|
+
t.Errorf("unexpected scene %q", scene)
|
|
240
|
+
}
|
|
241
|
+
_, _ = w.Write([]byte(response))
|
|
242
|
+
})
|
|
243
|
+
for _, kind := range []string{"image", "video"} {
|
|
244
|
+
for i := 0; i < 2; i++ {
|
|
245
|
+
result, err := s.Get(context.Background(), kind, false)
|
|
246
|
+
if err != nil {
|
|
247
|
+
t.Fatal(err)
|
|
248
|
+
}
|
|
249
|
+
if result.Cached != (i == 1) || result.Catalog.Search("")[0].Kind != kind {
|
|
250
|
+
t.Fatalf("cache crossed model type: %+v", result)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if requests[ImageScene] != 1 || requests[VideoScene] != 1 {
|
|
255
|
+
t.Fatalf("requests=%v", requests)
|
|
256
|
+
}
|
|
257
|
+
// Even a syntactically valid video cache at the image path must be ignored.
|
|
258
|
+
videoPath := s.cachePath(s.runner.Config.AccessKey, config.GetAvailableModelListPath, VideoScene)
|
|
259
|
+
imagePath := s.cachePath(s.runner.Config.AccessKey, config.GetAvailableModelListPath, ImageScene)
|
|
260
|
+
raw, err := os.ReadFile(videoPath)
|
|
261
|
+
if err != nil {
|
|
262
|
+
t.Fatal(err)
|
|
263
|
+
}
|
|
264
|
+
if err := os.WriteFile(imagePath, raw, 0600); err != nil {
|
|
265
|
+
t.Fatal(err)
|
|
266
|
+
}
|
|
267
|
+
result, err := s.Get(context.Background(), "image", false)
|
|
268
|
+
if err != nil || result.Cached || requests[ImageScene] != 2 {
|
|
269
|
+
t.Fatalf("mismatched cache not refreshed: result=%+v err=%v requests=%v", result, err, requests)
|
|
270
|
+
}
|
|
271
|
+
failImage = true
|
|
272
|
+
for _, refresh := range []bool{true, false} {
|
|
273
|
+
if result, err := s.Get(context.Background(), "image", refresh); err == nil || result != nil {
|
|
274
|
+
t.Fatalf("image failure reused a catalog: result=%+v err=%v", result, err)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
result, err = s.Get(context.Background(), "video", false)
|
|
278
|
+
if err != nil || !result.Cached || requests[ImageScene] != 4 || requests[VideoScene] != 1 {
|
|
279
|
+
t.Fatalf("image refresh invalidated video: result=%+v err=%v requests=%v", result, err, requests)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
func TestImageCatalogValidation(t *testing.T) {
|
|
284
|
+
for _, tc := range []struct {
|
|
285
|
+
name, scene, models string
|
|
286
|
+
valid bool
|
|
287
|
+
}{
|
|
288
|
+
{"empty", ImageScene, `[]`, true},
|
|
289
|
+
{"image", ImageScene, `[{"key":"dynamic-image","name":"图片测试模型","kind":"image"}]`, true},
|
|
290
|
+
{"missing name", ImageScene, `[{"key":"x","kind":"image"}]`, false},
|
|
291
|
+
{"blank name", ImageScene, `[{"key":"x","kind":"image","name":" "}]`, false},
|
|
292
|
+
{"unknown enum is not a display name", ImageScene, `[{"key":"future_code","name":"future_code","kind":"image"}]`, false},
|
|
293
|
+
{"duplicate name", ImageScene, `[{"key":"x","kind":"image","name":"名称"},{"key":"y","kind":"image","name":" 名称 "}]`, false},
|
|
294
|
+
{"wrong scene", VideoScene, `[{"key":"x","kind":"image"}]`, false},
|
|
295
|
+
{"wrong kind", ImageScene, `[{"key":"x","kind":"video"}]`, false},
|
|
296
|
+
{"duplicate", ImageScene, `[{"key":"x","kind":"image"},{"key":"x","kind":"image"}]`, false},
|
|
297
|
+
} {
|
|
298
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
299
|
+
s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
300
|
+
_, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"` + tc.scene + `","config_key":"key","config":{"models":` + tc.models + `}}}`))
|
|
301
|
+
})
|
|
302
|
+
result, err := s.Get(context.Background(), "image", false)
|
|
303
|
+
if (err == nil) != tc.valid {
|
|
304
|
+
t.Fatalf("result=%+v err=%v valid=%v", result, err, tc.valid)
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
func TestImageNamesKeepWireKeysInternalAcrossCache(t *testing.T) {
|
|
311
|
+
const response = `{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-config","config":{"models":[{"key":"wire-image","name":"智能图片V2","kind":"image"},{"key":"wire-image-fast","report_name":"wire-image-fast","name":"智能图片V2.5 Fast","kind":"image","is_default":true,"supported_ratio_list":[3,6]}]}}}`
|
|
312
|
+
s := newTestService(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(response)) })
|
|
313
|
+
for i := 0; i < 2; i++ {
|
|
314
|
+
result, err := s.Get(context.Background(), "image", false)
|
|
315
|
+
if err != nil {
|
|
316
|
+
t.Fatal(err)
|
|
317
|
+
}
|
|
318
|
+
if result.Cached != (i == 1) {
|
|
319
|
+
t.Fatalf("cached=%v", result.Cached)
|
|
320
|
+
}
|
|
321
|
+
catalog := result.Catalog
|
|
322
|
+
list := catalog.Search("智能图片")
|
|
323
|
+
if len(list) != 2 || list[1].Name != "智能图片V2.5 Fast" {
|
|
324
|
+
t.Fatalf("unexpected image names: %+v", list)
|
|
325
|
+
}
|
|
326
|
+
if len(catalog.Search("wire-image")) != 0 || len(catalog.Search("fast")) != 1 {
|
|
327
|
+
t.Fatal("image search must use display names only")
|
|
328
|
+
}
|
|
329
|
+
raw, err := json.Marshal(list)
|
|
330
|
+
if err != nil || strings.Contains(string(raw), `"key"`) || strings.Contains(string(raw), "wire-image") {
|
|
331
|
+
t.Fatalf("list leaked identifiers: %s, %v", raw, err)
|
|
332
|
+
}
|
|
333
|
+
detail, err := catalog.Describe(list[1].Name)
|
|
334
|
+
if err != nil || strings.Contains(string(detail), "wire-image") || !strings.Contains(string(detail), "智能图片V2.5 Fast") {
|
|
335
|
+
t.Fatalf("detail leaked identifiers or lost name: %s, %v", detail, err)
|
|
336
|
+
}
|
|
337
|
+
key, err := catalog.ImageModelKey(" 智能图片V2.5 Fast ")
|
|
338
|
+
if err != nil || key != "wire-image-fast" {
|
|
339
|
+
t.Fatalf("name resolution: key=%q, err=%v", key, err)
|
|
340
|
+
}
|
|
341
|
+
for _, invalid := range []string{"", "智能图片", "wire-image-fast", "不存在"} {
|
|
342
|
+
if _, err := catalog.ImageModelKey(invalid); err == nil || strings.Contains(err.Error(), "wire-image") {
|
|
343
|
+
t.Fatalf("unresolved input accepted or leaked: %q, %v", invalid, err)
|
|
344
|
+
}
|
|
345
|
+
if _, err := catalog.Describe(invalid); err == nil || strings.Contains(err.Error(), "wire-image") {
|
|
346
|
+
t.Fatalf("detail accepted unresolved input: %q, %v", invalid, err)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if !strings.Contains(string(catalog.Config.Models[1]), "wire-image-fast") {
|
|
350
|
+
t.Fatal("presentation mutated the cached request identifier")
|
|
351
|
+
}
|
|
352
|
+
if got := catalog.ImageDisplayMessage("wire-image-fast / wire-image"); got != "智能图片V2.5 Fast / 智能图片V2" {
|
|
353
|
+
t.Fatalf("error must use names: %s", got)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
package/package.json
CHANGED
|
@@ -10,8 +10,6 @@ metadata:
|
|
|
10
10
|
|
|
11
11
|
通过 CLI 完成生成、处理、结果下载与媒体交付。支持下表中的操作;不提供多轮会话续写或自动拆分剧本、分镜并编排成片的能力。复杂需求先确认能由所列命令完成的具体操作,不承诺未覆盖的流程。
|
|
12
12
|
|
|
13
|
-
商品图文营销一键成片(剧情广告、品牌大片、达人带货)由独立的 `xyq-marketing-skill` 提供,不用普通生视频代替营销编排;未安装该 Skill 时先说明当前能力边界。
|
|
14
|
-
|
|
15
13
|
## 开始执行
|
|
16
14
|
|
|
17
15
|
1. 画布任务运行 `node "{baseDir}/scripts/ensure-cli.js" --canvas`,其他任务运行 `node "{baseDir}/scripts/ensure-cli.js"`。保存返回的 `cli_path`;Canvas 还需保存 `canvas_entry`。文档中的 `pippit-tool-cli` 替换为带引号的 `cli_path`;画布语义命令按模块说明通过 Node 入口执行。同一任务复用,安装细节见 [安装说明](scripts/install.md)。
|
|
@@ -29,7 +27,7 @@ metadata:
|
|
|
29
27
|
| 查看登录状态、登录、退出或切换账号 | `status` / `login` / `logout` | [授权](commands/auth.md) |
|
|
30
28
|
| 创建或查询小云雀个人画布,编辑节点、布局、连线、角色/场景、提示词、3D 或多轨草稿 | `canvas` | [Canvas 能力与命令发现](commands/canvas.md) |
|
|
31
29
|
| 生成图片,或基于参考图修改图片 | `generate-image` | [生图与图片编辑](commands/generate-image.md) |
|
|
32
|
-
|
|
|
30
|
+
| 查询当前可用的图片/视频模型、比例、分辨率及推理强度等参数配置 | `model list` / `model describe` | [模型发现](commands/model.md) |
|
|
33
31
|
| 生成视频,使用图/视频/音频参考,首尾帧生视频 | `generate-video` | [生视频](commands/generate-video.md) |
|
|
34
32
|
| 提升已有视频分辨率、视频超分 | `video-super-resolution` | [超分](commands/video-super-resolution.md) |
|
|
35
33
|
| 去除已有视频字幕 | `erase-video-subtitle` | [擦字幕](commands/erase-video-subtitle.md) |
|
|
@@ -45,6 +43,7 @@ metadata:
|
|
|
45
43
|
## 执行总则
|
|
46
44
|
|
|
47
45
|
- 保留用户原始 prompt,不擅自扩写、润色、翻译或增加风格词;参数转换按对应命令文档执行。生成和视频处理只传用户给定的可选创作参数,缺少必填项先询问。画布输入还需使用实际查询的 ID、版本和当前 schema。模型和参数最终合法性由服务端判断。
|
|
46
|
+
- 图片模型对用户只展示服务端返回的名称;列表、选择、详情与生成命令均使用完整名称,不解释或展示对应底层枚举。名称到提交标识由 CLI 解析,详见 [模型发现](commands/model.md)。
|
|
48
47
|
- 用户明确要求生成或处理,即可在该范围内执行;仅咨询用法、费用或方案时不提交。范围、必填信息或消耗 credits 的授权不明确时,先确认,不重复索要已给出的授权。
|
|
49
48
|
- 提问优先使用宿主实际提供且当前模式允许的工具:Codex 的 `request_user_input` 或 `request_user_input_async`,WorkBuddy 的 `ask_user_question`;不可用时用普通聊天。需要答案时等待答复。
|
|
50
49
|
- 素材参数接收本地文件路径,CLI 内部上传。远程链接不能冒充本地路径;缺少可访问文件时先解决素材获取。单文件必须小于 500 MB(500000000 字节)。
|
|
@@ -7,25 +7,30 @@
|
|
|
7
7
|
| 参数 | 必填 | 规则 |
|
|
8
8
|
| --- | --- | --- |
|
|
9
9
|
| `--prompt` | 是 | 用户原始描述,不能全为空白 |
|
|
10
|
-
| `--model` | 是 |
|
|
10
|
+
| `--model` | 是 | 用户选择的图片模型完整名称;包含空格时加引号,缺少时先询问 |
|
|
11
11
|
| `--image` | 否 | 本地参考图路径,多张图重复此参数,保留用户指定的角色与顺序 |
|
|
12
|
-
| `--ratio` | 否 |
|
|
13
|
-
| `--resolution` | 否 |
|
|
12
|
+
| `--ratio` | 否 | 查询返回的数字枚举(如 `3` 表示 `9:16`);比例含义见 `option_labels` |
|
|
13
|
+
| `--resolution` | 否 | 对应模型查询返回的分辨率;提交时转为大写 |
|
|
14
|
+
| `--effort` | 否 | 对应模型查询返回的推理强度;提交为 `general_agent_settings.image_effort`,转为小写 |
|
|
14
15
|
| `--generate-image-count` | 否 | 用户指定的生成数量 |
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
先用 `pippit-tool-cli model list --type image` 查询模型,再用 `pippit-tool-cli model describe "模型名称" --type image` 查询比例、分辨率和可选推理强度。对用户只展示名称,不提供底层模型枚举。生成命令用 `--model "完整名称"`,CLI 负责从当前配置中解析提交标识;名称不存在或重复时停止。查询失败时按错误处理,不回退静态模型目录;细节见 [模型发现](model.md)。
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
图片比例只传数字,例如用户指定 `9:16` 时使用 `--ratio 3`。从模型查询的 `option_labels` 确认含义,再将对应的数字 `options` 传入;`0` 表示自动比例。CLI 校验整数格式,模型是否支持该枚举由服务端决定。
|
|
20
|
+
|
|
21
|
+
推理强度只有部分模型包含;未下发 `effort` 维度时不主动提供档位选择。用户未指定的比例、分辨率、推理强度均省略,不从查询默认值自动补齐。条件维度和参数组合以服务端下发为准,不将每个维度的选项任意组合;不在 Skill 或 CLI 维护模型白名单。
|
|
22
|
+
|
|
23
|
+
使用旧版 CLI 时先检查 `generate-image --help`;若 `--model` 仍要求 key,或缺少用户指定的 `--effort`,先升级,不能把底层枚举交给用户或静默丢弃推理强度。
|
|
19
24
|
|
|
20
25
|
本地图片后缀支持 `.jpg/.jpeg/.png/.gif/.bmp/.webp/.svg`。参考图由命令内部上传,不需要自行获取资产 ID。
|
|
21
26
|
|
|
22
27
|
## 最小调用
|
|
23
28
|
|
|
24
29
|
```bash
|
|
25
|
-
pippit-tool-cli generate-image --prompt "用户原始描述" --model
|
|
30
|
+
pippit-tool-cli generate-image --prompt "用户原始描述" --model "智能图片V2.5 Fast"
|
|
26
31
|
```
|
|
27
32
|
|
|
28
|
-
|
|
33
|
+
上例名称须存在于实际查询结果,不能当作静态可用列表。CLI 在上传前复用当前凭证下的有效缓存或查询模型列表,提交时才使用底层枚举,不让用户填写枚举。只追加用户已提供的可选参数。多图编辑见 [参考图编辑示例](../examples/image-edit.md)。
|
|
29
34
|
|
|
30
35
|
## 返回与处理
|
|
31
36
|
|