Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# 模型发现输出约定

- 模型查询详情应提供可直接用于生成命令的参数值;数字枚举按对应 IDL 显式转换,不按数组位置或展示文案推断。
- 模型查询详情应提供可直接用于生成命令的参数值;图片 `ratio` 只接受服务端数字枚举,查询的 `options/default` 同样使用数字,并用 `option_labels` 展示比例含义;视频 `ratio` 保持其接口要求的比例字符串。不提供数字和比例字符串两套图片输入,映射不按数组位置或展示文案推断。
- 覆盖 IDL 已定义且生成入口可消费的枚举;未知枚举直接跳过,不猜测或回退到其他值。已定义但没有 CLI 请求表达方式的枚举须明确识别为不可用。
- 内部配置指纹不向用户输出;原始配置缓存和用户展示结构分离,保留可选字段缺失与零值的区别。
- 内部配置指纹不向用户输出;原始配置缓存和用户展示结构分离,保留可选字段缺失与零值的区别。参数通用元数据(`label`、`description`、`required_field`、`active_when_any`)统一保留,只在确有契约差异时按模型类型分支。
- 模型级 `is_default` 不向 CLI 用户输出;服务端默认标记不等于用户授权代选模型。参数级默认值继续展示,原始配置缓存保持完整。
- API 模型查询保留上游 `name` 展示名称和 `key` 模型枚举,白名单只控制准入,不用枚举覆盖名称。CLI 图片模型列表、搜索、详情、帮助和示例只向宿主展示 `name`,详情不透传含枚举的计费等内部配置;查询和生成使用完整名称,仅提交时从原始配置解析 key。不维护静态名称映射,不按简称猜模型;名称缺失、与 key 相同、重复或未找到时明确失败。视频保持 key 契约。
- 模型发现验收必须分别核对用户确认的预期模型集合与真实接口返回值转换;CLI 与接口同时少返回模型不能判为完整通过。请求携带 PPE 头也不能替代实际路由和生效配置证据。
- `Seedance_2.0_mini` 和 `Seedance_2.0_mini_lite` 允许省略生成分辨率,由服务端默认 `720p`;CLI 不主动补值,不因模型查询缺少分辨率维度而将该参数标为必填或伪造配置。

Expand Down
27 changes: 19 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ node skills/xyq-marketing-skill/scripts/marketing.js query --thread-id THREAD_ID
| 登录授权 | `status` / `login` / `logout` | [授权](skills/xyq-nest-skill/commands/auth.md) |
| 个人 Canvas 画布与节点编辑 | `canvas` | [画布](skills/xyq-nest-skill/commands/canvas.md) |
| 生图、参考图编辑 | `generate-image` | [图片](skills/xyq-nest-skill/commands/generate-image.md) |
| 查看可用视频模型、参数配置 | `model list` / `model describe` | [模型发现](skills/xyq-nest-skill/commands/model.md) |
| 查看可用图片/视频模型、参数配置 | `model list` / `model describe` | [模型发现](skills/xyq-nest-skill/commands/model.md) |
| 生视频、首尾帧 | `generate-video` | [视频](skills/xyq-nest-skill/commands/generate-video.md) |
| 视频超分 | `video-super-resolution` | [超分](skills/xyq-nest-skill/commands/video-super-resolution.md) |
| 擦字幕 | `erase-video-subtitle` | [擦字幕](skills/xyq-nest-skill/commands/erase-video-subtitle.md) |
Expand Down Expand Up @@ -205,20 +205,22 @@ pippit-tool-cli canvas command run xyq.generation.update_prompt \

## 生图 CLI

`generate-image` 会上传本地参考图片,然后向综合 Nest Agent 提交生图请求:
`generate-image` 使用图片模型的展示名称;先查询当前列表并选择模型,再上传本地参考图片并提交生图请求。以下假设列表包含该名称:

```bash
pippit-tool-cli generate-image \
--prompt "生成一张小猫海报" \
--image "~/images/cat.png" \
--model "seedream_4.5" \
--model "智能图片V2.5 Fast" \
--ratio 6 \
--generate-image-count 2
```

命令输出 `thread_id`、`run_id` 和 `web_thread_link`。提交 HTTP 请求时,`agent_name` 固定为 `pippit_nest_agent`,参考图会使用上传接口返回的 `pippit_asset_id` 写入顶层 `asset_ids`,生图模型写入 `general_agent_settings.image_model`,比例写入 `general_agent_settings.ratio`,生图数量写入 `general_agent_settings.generate_image_count`。`--model` 为必填参数,CLI 只做非空校验,具体模型值是否可用由服务端决定。
命令输出 `thread_id`、`run_id` 和 `web_thread_link`。提交 HTTP 请求时,`agent_name` 固定为 `pippit_nest_agent`,参考图会使用上传接口返回的 `pippit_asset_id` 写入顶层 `asset_ids`,生图模型写入 `general_agent_settings.image_model`,比例写入 `general_agent_settings.ratio`,生图数量写入 `general_agent_settings.generate_image_count`。`--model` 为必填参数,填写 `model list --type image` 返回的完整名称,例如 `"智能图片V2.5 Fast"`。CLI 在上传素材前查询或复用有效缓存,解析名称后仅在提交请求中填写服务端模型标识;名称缺失、重复或未找到时停止,不猜测模型。

`--ratio` 可选,填写服务端 `Ratio` 枚举值。CLI 只做整数格式解析,不检查枚举值是否在下表范围内;具体值是否可用由服务端决定。常用枚举值含义如下:
图片 `model list/search/describe` 返回给宿主的是 API 下发的展示名称 `name`,例如 `美学模型 8.2`。生成时填写 `--model "美学模型 8.2"`,CLI 仅在提交请求中使用该条目对应的 `key`。名称、可用模型和参数均由接口动态提供,不维护静态名称映射。

`--ratio` 可选,只接受服务端 `Ratio` 数字枚举,例如 `--ratio 3` 表示 `9:16`。通过 `model describe "模型名称" --type image` 查看当前可用的数字 `options/default`,`option_labels` 说明每个数字对应的比例。CLI 校验整数格式,模型是否支持该枚举由服务端决定。常用枚举值含义如下:

| ratio 参数 | IDL 枚举 | 含义 |
| ---: | --- | --- |
Expand All @@ -232,18 +234,27 @@ pippit-tool-cli generate-image \

`--generate-image-count` 可选,填写生图数量,对应 IDL 字段 `GeneralSettingsPart.GenerateImageCount` / JSON 字段 `generate_image_count`。CLI 只校验不能为负数;具体数量范围由服务端决定。

图片支持 `.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.webp`、`.svg`。CLI 会在提交前校验 prompt、model 必填、ratio 整数格式、generate-image-count 非负和文件后缀。
`--resolution` 写入 `general_agent_settings.resolution`(转大写),新增 `--effort` 写入 `general_agent_settings.image_effort`(转小写)。分辨率和推理强度选项都来自该模型的动态配置;没有 `effort` 维度的模型不展示推理强度选择。只传用户指定的参数,未指定时省略,不自动补查询默认值。

图片支持 `.jpg`、`.jpeg`、`.png`、`.gif`、`.bmp`、`.webp`、`.svg`。CLI 会在提交前校验 prompt、model 必填、ratio 格式、generate-image-count 非负和文件后缀;不新增模型或参数组合白名单。

## 视频模型发现
## 图片与视频模型发现

```bash
# 图片模型及可选参数
pippit-tool-cli model list --type image
pippit-tool-cli model search "智能图片" --type image
pippit-tool-cli model describe "智能图片V2.5 Fast" --type image
pippit-tool-cli model list --type image --refresh

# 省略 --type 保持查询视频
pippit-tool-cli model list
pippit-tool-cli model search MiniMax
pippit-tool-cli model describe MiniMax-H3
pippit-tool-cli model list --refresh
```

使用当前登录凭证查询服务端;成功结果按账号和环境隔离缓存 5 分钟,`--refresh` 强制刷新。失败时提示重试,不回退静态列表或过期缓存。`describe` 将比例枚举转换为可直接传给 `--ratio` 的字符串,并整理分辨率、时长和素材限制;不展示内部 `config_key`,未知比例枚举跳过。生成仍由服务端校验。详见 [模型发现](skills/xyq-nest-skill/commands/model.md)。
使用当前个人登录凭证查询服务端 Skill 模型接口;图片 scene 为 `web_image_agent`,视频为 `web_turbo_video_generator`,不传 TeamID。成功结果按账号、环境和场景隔离缓存 5 分钟,`--refresh` 强制刷新。失败时提示重试,不回退静态列表或过期缓存。`describe` 提供可直接传给生成命令的参数:图片比例保留数字枚举并附比例说明,视频比例转换为字符串,同时整理分辨率、图片推理强度、视频时长和素材限制;不展示内部 `config_key`,未知比例枚举跳过。图片列表和详情仅用 `name` 标识模型,不展示底层模型枚举;搜索、详情查询和 `generate-image --model` 均使用名称,包含空格时加引号。图片详情保留原始 `parameter_config`,包括条件、必选标记、选项说明和参数组合约束。生成仍由服务端校验;服务端尚未开放图片场景或提交参数时会返回错误,不回退静态模型清单。详见 [模型发现](skills/xyq-nest-skill/commands/model.md)。

## 生视频 CLI

Expand Down
6 changes: 4 additions & 2 deletions cmd/generate_image/generate_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command
"model": strings.TrimSpace(opts.Model),
"ratio": strings.TrimSpace(opts.Ratio),
"resolution": strings.ToUpper(strings.TrimSpace(opts.Resolution)),
"effort": strings.ToLower(strings.TrimSpace(opts.Effort)),
"generate_image_count": optionalIntString(opts.GenerateImageCount),
})
return err
Expand All @@ -47,9 +48,10 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command
flags.StringVar(&opts.Source, "source", "", "optional host agent/platform identifier for statistics only; filled silently by the host agent (e.g. doubao_office, workbuddy, codex)")
flags.StringVar(&opts.Prompt, "prompt", "", "image generation prompt")
flags.StringArrayVar(&opts.ImagePaths, "image", nil, "local reference image path; repeat for multiple images")
flags.StringVar(&opts.Model, "model", "", "image model; supported: seedream_5.0_pro, seedream_5.0, seedream_4.3, nova2, seedream_4.5, seedream_4.1, seedream_4")
flags.StringVar(&opts.Model, "model", "", "exact image model name from 'model list --type image' (quote names containing spaces)")
flags.StringVar(&opts.Ratio, "ratio", "", "image ratio; "+internalgen.SupportedRatioUsage())
flags.StringVar(&opts.Resolution, "resolution", "", "image resolution; only seedream_5.0_pro supports these options: 1K, 2K, 4K")
flags.StringVar(&opts.Resolution, "resolution", "", "image resolution; use 'model describe \"MODEL_NAME\" --type image' for available values")
flags.StringVar(&opts.Effort, "effort", "", "image reasoning effort; only use values exposed by 'model describe \"MODEL_NAME\" --type image'")
flags.IntVar(&generateImageCount, "generate-image-count", 0, "generated image count")
return cmd
}
Expand Down
151 changes: 150 additions & 1 deletion cmd/generate_image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
Expand All @@ -10,18 +11,29 @@ import (
"strings"
"testing"

"github.com/Pippit-dev/pippit-cli/internal/config"
"github.com/bytedance/sonic"
)

func TestGenerateImage(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("LocalAppData", t.TempDir())
var uploaded bool
var modelQueried bool

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" {
t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
}
switch r.URL.Path {
case "/api/biz/v1/skill/get_available_model_list":
modelQueried = true
_, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-key","config":{"models":[{"key":"seedream_5.0_pro","name":"Seedream 5.0 Pro","kind":"image"}]}}}`))
case "/api/biz/v1/skill/upload_file":
if !modelQueried {
t.Fatal("image name must be resolved before uploading")
}
if r.Method != http.MethodPost {
t.Fatalf("upload method = %s, want POST", r.Method)
}
Expand Down Expand Up @@ -100,7 +112,7 @@ func TestGenerateImage(t *testing.T) {
"generate-image",
"--prompt", "生成小猫海报",
"--image", image,
"--model", "seedream_5.0_pro",
"--model", "Seedream 5.0 Pro",
"--ratio", "6",
"--resolution", "4K",
"--generate-image-count", "2",
Expand Down Expand Up @@ -137,3 +149,140 @@ func TestGenerateImageRequiresModel(t *testing.T) {
t.Fatalf("error = %q, want model validation", err)
}
}

func TestGenerateImageDynamicParameters(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("LocalAppData", t.TempDir())
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.URL.Path == "/api/biz/v1/skill/get_available_model_list" {
_, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-key","config":{"models":[{"key":"future-image-model","name":"未来图片模型","kind":"image"}]}}}`))
return
}
if r.URL.Path != "/api/biz/v1/skill/submit_run" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Error(err)
return
}
var body struct {
AgentName string `json:"agent_name"`
Settings struct {
Model string `json:"image_model"`
Ratio int `json:"ratio"`
Resolution string `json:"resolution"`
Effort string `json:"image_effort"`
} `json:"general_agent_settings"`
}
if err := sonic.Unmarshal(raw, &body); err != nil {
t.Error(err)
return
}
if body.AgentName != "pippit_nest_agent" || body.Settings.Model != "future-image-model" || body.Settings.Ratio != 13 || body.Settings.Resolution != "8K" || body.Settings.Effort != "future-effort" {
t.Errorf("incorrect request mapping: %s", raw)
}
_, _ = w.Write([]byte(`{"ret":"0","data":{"run":{"thread_id":"image-thread","run_id":"image-run"}}}`))
}))
defer server.Close()
var stdout, stderr bytes.Buffer
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
root.SetArgs([]string{"generate-image", "--prompt", "image", "--model", "未来图片模型", "--ratio", "13", "--resolution", "8k", "--effort", " FUTURE-EFFORT "})
if err := root.Execute(); err != nil || requests != 2 {
t.Fatalf("Execute: err=%v requests=%d", err, requests)
}
}

func TestImageModelNameFromDiscoveryToSubmission(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("LocalAppData", t.TempDir())
queries, submits := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case config.GetAvailableModelListPath:
queries++
_, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-key","config":{"models":[{"key":"ali_midjourney_8_2","name":"美学模型 8.2","benefit_map_by_combination":{"ali_midjourney_8_2":{"resource_id":"ali_midjourney_8_2"}},"kind":"image"}]}}}`))
case config.SubmitRunPath:
submits++
var body struct {
Settings map[string]json.RawMessage `json:"general_agent_settings"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
}
if string(body.Settings["image_model"]) != `"ali_midjourney_8_2"` || len(body.Settings) != 1 {
t.Errorf("wire model not resolved or unrequested defaults filled: %v", body.Settings)
}
_, _ = w.Write([]byte(`{"ret":"1","errmsg":"ali_midjourney_8_2 暂时不可用","log_id":"image-log"}`))
default:
t.Errorf("unexpected request: %s", r.URL.Path)
}
}))
defer server.Close()
execute := func(args ...string) (string, string, error) {
var stdout, stderr bytes.Buffer
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
root.SetArgs(args)
err := root.Execute()
return stdout.String(), stderr.String(), err
}
list, _, err := execute("model", "list", "--type", "image")
if err != nil {
t.Fatal(err)
}
var output struct{ Models []struct{ Name string } }
if err := json.Unmarshal([]byte(list), &output); err != nil || len(output.Models) != 1 {
t.Fatalf("list=%s err=%v", list, err)
}
name := output.Models[0].Name
if name != "美学模型 8.2" {
t.Fatalf("unexpected display name: %q", name)
}
detail, _, err := execute("model", "describe", name, "--type", "image")
if err != nil || !strings.Contains(detail, name) || strings.Contains(list+detail, "ali_midjourney_8_2") {
t.Fatalf("user-facing identity changed: list=%s detail=%s err=%v", list, detail, err)
}
stdout, stderr, err := execute("generate-image", "--prompt", "一张猫咪图片", "--model", name)
if err == nil || !strings.Contains(err.Error(), name) || !strings.Contains(err.Error(), "image-log") || strings.Contains(stdout+stderr+err.Error(), "ali_midjourney_8_2") {
t.Fatalf("server rejection must keep name and log ID: stdout=%s stderr=%s err=%v", stdout, stderr, err)
}
if queries != 1 || submits != 1 {
t.Fatalf("expected shared cache and no submit retry: queries=%d submits=%d", queries, submits)
}
}

func TestImageNameFailureStopsBeforeUploadAndSubmit(t *testing.T) {
for _, tc := range []struct{ name, selection, models string }{
{"unknown name", "不存在", `[{"key":"private-image","name":"图片模型","kind":"image"}]`},
{"wire key", "private-image", `[{"key":"private-image","name":"图片模型","kind":"image"}]`},
{"missing name", "图片模型", `[{"key":"private-image","kind":"image"}]`},
{"duplicate name", "图片模型", `[{"key":"private-image","name":"图片模型","kind":"image"},{"key":"private-image-2","name":"图片模型","kind":"image"}]`},
{"empty list", "图片模型", `[]`},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("LocalAppData", t.TempDir())
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.URL.Path != config.GetAvailableModelListPath {
t.Errorf("must not upload or submit: %s", r.URL.Path)
}
_, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_image_agent","config_key":"image-key","config":{"models":` + tc.models + `}}}`))
}))
defer server.Close()
var stdout, stderr bytes.Buffer
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
root.SetArgs([]string{"generate-image", "--prompt", "image", "--model", tc.selection, "--image", "/missing.png"})
err := root.Execute()
if err == nil || requests != 1 || strings.Contains(stdout.String()+stderr.String()+err.Error(), "private-image") {
t.Fatalf("invalid name accepted or exposed: requests=%d stdout=%s stderr=%s err=%v", requests, stdout.String(), stderr.String(), err)
}
})
}
}
Loading
Loading