From 83062a271a57f2de9f2054f65340d3567cf10460 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 14:09:00 +0800 Subject: [PATCH 01/14] feat(figlens): thread video_kind through FastQueryOptimize --- client/figlens/figlens_test.go | 50 ++++++++++++++++++++++++++++++++++ client/figlens/optimize.go | 1 + 2 files changed, 51 insertions(+) diff --git a/client/figlens/figlens_test.go b/client/figlens/figlens_test.go index f70e097..f774803 100644 --- a/client/figlens/figlens_test.go +++ b/client/figlens/figlens_test.go @@ -3,8 +3,11 @@ package figlens_test import ( "context" "encoding/json" + "fmt" + "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/vibeknow/cli/client/figlens" @@ -134,3 +137,50 @@ func TestSignedURL(t *testing.T) { t.Fatalf("url = %q", u) } } + +func TestFastQueryOptimize_SendsVideoKind(t *testing.T) { + var gotBody map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, `data: {"code":200,"data":{"type":"aim_result","answer_done":{"text":"ok"}}} + +data: [DONE] + +`) + })) + defer srv.Close() + + c := figlens.New(srv.URL, staticToken("tok")) + _, err := c.FastQueryOptimize(context.Background(), figlens.OptimizeParams{ + KnowledgeID: "kb_1", DocID: "doc_1", VideoKind: "script_lock", + }, nil) + if err != nil { + t.Fatalf("FastQueryOptimize: %v", err) + } + if gotBody["video_kind"] != "script_lock" { + t.Fatalf("video_kind on wire = %q, want %q", gotBody["video_kind"], "script_lock") + } +} + +func TestFastQueryOptimize_OmitsVideoKindWhenEmpty(t *testing.T) { + var raw []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, `data: {"code":200,"data":{"type":"aim_result","answer_done":{"text":"ok"}}} + +data: [DONE] + +`) + })) + defer srv.Close() + + c := figlens.New(srv.URL, staticToken("tok")) + _, _ = c.FastQueryOptimize(context.Background(), figlens.OptimizeParams{ + KnowledgeID: "kb_1", DocID: "doc_1", + }, nil) + if strings.Contains(string(raw), "video_kind") { + t.Fatalf("video_kind unexpectedly present in wire body: %s", raw) + } +} diff --git a/client/figlens/optimize.go b/client/figlens/optimize.go index 988b82c..7f80feb 100644 --- a/client/figlens/optimize.go +++ b/client/figlens/optimize.go @@ -14,6 +14,7 @@ type OptimizeParams struct { KnowledgeID string `json:"knowledge_id"` DocID string `json:"doc_id"` Query string `json:"query,omitempty"` + VideoKind string `json:"video_kind,omitempty"` } type optimizePayload struct { From 1d4e20cf8719a0317e2de63e81c2f67d641fe04b Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 14:11:02 +0800 Subject: [PATCH 02/14] feat(figlens): thread video_kind + aspect through StreamChat --- client/figlens/stream.go | 2 ++ client/figlens/stream_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/client/figlens/stream.go b/client/figlens/stream.go index 5a86c42..148439f 100644 --- a/client/figlens/stream.go +++ b/client/figlens/stream.go @@ -19,6 +19,8 @@ type StreamParams struct { DocID string `json:"doc_id,omitempty"` VoiceID string `json:"voice_id,omitempty"` BGMEnabled bool `json:"bgm_enabled,omitempty"` + Aspect string `json:"aspect,omitempty"` + VideoKind string `json:"video_kind,omitempty"` } type StreamEvent struct { diff --git a/client/figlens/stream_test.go b/client/figlens/stream_test.go index 39d9b7a..1686b35 100644 --- a/client/figlens/stream_test.go +++ b/client/figlens/stream_test.go @@ -2,6 +2,7 @@ package figlens_test import ( "context" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -79,3 +80,35 @@ func TestStreamChat_ErrorEvent(t *testing.T) { t.Fatalf("expected task.failed event, got %v", events) } } + +func TestStreamChat_SendsVideoKindAndAspect(t *testing.T) { + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, `data: {"code":200,"data":{"type":"aim_result","session_id":"s_abc"}} + +data: [DONE] + +`) + })) + defer srv.Close() + + c := figlens.New(srv.URL, staticToken("tok")) + err := c.StreamChat(context.Background(), figlens.StreamParams{ + TaskID: 1, SessionID: "s_abc", Query: "q", + VideoKind: "replica", Aspect: "vertical", BGMEnabled: true, + }, func(figlens.StreamEvent) {}) + if err != nil { + t.Fatalf("StreamChat: %v", err) + } + if gotBody["video_kind"] != "replica" { + t.Fatalf("video_kind = %v, want \"replica\"", gotBody["video_kind"]) + } + if gotBody["aspect"] != "vertical" { + t.Fatalf("aspect = %v, want \"vertical\"", gotBody["aspect"]) + } + if gotBody["bgm_enabled"] != true { + t.Fatalf("bgm_enabled = %v, want true", gotBody["bgm_enabled"]) + } +} From 95cdfe7fe9ce8ed06d4f1951fabb0d7583da26ec Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 14:13:03 +0800 Subject: [PATCH 03/14] refactor(figlens): InitTask takes InitTaskParams for video_kind plumbing --- client/figlens/figlens_test.go | 39 +++++++++++++++++++++++++++++++--- client/figlens/task.go | 23 ++++++++++++++++++-- cmd/create.go | 2 +- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/client/figlens/figlens_test.go b/client/figlens/figlens_test.go index f774803..7e330eb 100644 --- a/client/figlens/figlens_test.go +++ b/client/figlens/figlens_test.go @@ -23,10 +23,12 @@ func figlensResp(w http.ResponseWriter, data any) { } func TestInitTask(t *testing.T) { + var gotBody map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/tasks/init" || r.Method != "POST" { t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) } + _ = json.NewDecoder(r.Body).Decode(&gotBody) figlensResp(w, map[string]any{ "task_id": 123, "session_id": "s_abc", "work_id": 456, "v": 3, }) @@ -34,15 +36,46 @@ func TestInitTask(t *testing.T) { defer srv.Close() c := figlens.New(srv.URL, staticToken("tok")) - task, err := c.InitTask(context.Background()) + task, err := c.InitTask(context.Background(), figlens.InitTaskParams{ + KnowledgeID: "kb_1", DocID: "doc_1", VideoKind: "script_lock", + }) if err != nil { t.Fatalf("InitTask: %v", err) } if task.TaskID != 123 { t.Fatalf("task_id = %d", task.TaskID) } - if task.SessionID != "s_abc" { - t.Fatalf("session_id = %q", task.SessionID) + if gotBody["v"] != float64(3) { + t.Fatalf("v = %v, want 3", gotBody["v"]) + } + if gotBody["knowledge_id"] != "kb_1" { + t.Fatalf("knowledge_id = %v", gotBody["knowledge_id"]) + } + if gotBody["video_kind"] != "script_lock" { + t.Fatalf("video_kind = %v", gotBody["video_kind"]) + } +} + +func TestInitTask_OmitsEmptyFields(t *testing.T) { + var raw []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ = io.ReadAll(r.Body) + figlensResp(w, map[string]any{ + "task_id": 1, "session_id": "s_x", "work_id": 2, "v": 3, + }) + })) + defer srv.Close() + + c := figlens.New(srv.URL, staticToken("tok")) + _, err := c.InitTask(context.Background(), figlens.InitTaskParams{}) + if err != nil { + t.Fatalf("InitTask: %v", err) + } + body := string(raw) + for _, f := range []string{"knowledge_id", "doc_id", "video_kind"} { + if strings.Contains(body, f) { + t.Fatalf("%s unexpectedly present in empty-params body: %s", f, body) + } } } diff --git a/client/figlens/task.go b/client/figlens/task.go index ec620dd..31dbdbd 100644 --- a/client/figlens/task.go +++ b/client/figlens/task.go @@ -12,9 +12,28 @@ type Task struct { V int `json:"v,omitempty"` } -func (c *Client) InitTask(ctx context.Context) (*Task, error) { +type InitTaskParams struct { + KnowledgeID string `json:"knowledge_id,omitempty"` + DocID string `json:"doc_id,omitempty"` + VideoKind string `json:"video_kind,omitempty"` +} + +type initTaskWire struct { + V int `json:"v"` + KnowledgeID string `json:"knowledge_id,omitempty"` + DocID string `json:"doc_id,omitempty"` + VideoKind string `json:"video_kind,omitempty"` +} + +func (c *Client) InitTask(ctx context.Context, p InitTaskParams) (*Task, error) { var t Task - if err := c.http.Do(ctx, "POST", "/v1/tasks/init", map[string]int{"v": 3}, &t); err != nil { + body := initTaskWire{ + V: 3, + KnowledgeID: p.KnowledgeID, + DocID: p.DocID, + VideoKind: p.VideoKind, + } + if err := c.http.Do(ctx, "POST", "/v1/tasks/init", body, &t); err != nil { return nil, fmt.Errorf("init task: %w", err) } return &t, nil diff --git a/cmd/create.go b/cmd/create.go index 7917175..ee58552 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -114,7 +114,7 @@ var createCmd = &cobra.Command{ // Step 3: init figlens task. fmt.Fprintln(os.Stderr, i18n.T("create.init_task")) - task, err := fc.InitTask(ctx) + task, err := fc.InitTask(ctx, figlens.InitTaskParams{}) if err != nil { if errs.HasCode(err, "insufficient_credits") { return fmt.Errorf("%s", i18n.T("credits.insufficient")) From fcd606698a62a52cc218d4d6899ffac121456490 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 14:14:14 +0800 Subject: [PATCH 04/14] feat(stage): map doc_replica_plan + doc_replica_shoot for replica mode SSE --- internal/stage/stage.go | 4 ++++ internal/stage/stage_test.go | 2 ++ 2 files changed, 6 insertions(+) diff --git a/internal/stage/stage.go b/internal/stage/stage.go index 16ce3c6..fb01831 100644 --- a/internal/stage/stage.go +++ b/internal/stage/stage.go @@ -17,6 +17,8 @@ var nodeToStage = map[string]string{ "video_package": "publish", "video_finish": "publish", "suggest": "suggest", + "doc_replica_plan": "outline", + "doc_replica_shoot": "render", } var nodeDisplayName = map[string]string{ @@ -34,6 +36,8 @@ var nodeDisplayName = map[string]string{ "video_package": "video_package", "video_finish": "video_finish", "suggest": "suggest", + "doc_replica_plan": "doc_replica_plan", + "doc_replica_shoot": "doc_replica_shoot", } var orderedStages = []string{"parse", "outline", "tts", "render", "publish", "suggest"} diff --git a/internal/stage/stage_test.go b/internal/stage/stage_test.go index 74e30d6..5fc0464 100644 --- a/internal/stage/stage_test.go +++ b/internal/stage/stage_test.go @@ -25,6 +25,8 @@ func TestNodeToStage(t *testing.T) { {"video_package", "publish"}, {"video_finish", "publish"}, {"suggest", "suggest"}, + {"doc_replica_plan", "outline"}, + {"doc_replica_shoot", "render"}, } for _, tt := range tests { t.Run(tt.node, func(t *testing.T) { From bc8c20e94d423c956eb380c69952bd61eb4ee966 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 14:15:31 +0800 Subject: [PATCH 05/14] feat(figlens): map SSE 100004 to script_invalid for script-mode preflight failures --- client/figlens/stream.go | 2 ++ client/figlens/stream_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/client/figlens/stream.go b/client/figlens/stream.go index 148439f..66faeb3 100644 --- a/client/figlens/stream.go +++ b/client/figlens/stream.go @@ -58,6 +58,8 @@ func mapSSECode(code int) string { return "freeze_not_found" case 100003: return "concurrent_work_limit" + case 100004: + return "script_invalid" default: return "business_error" } diff --git a/client/figlens/stream_test.go b/client/figlens/stream_test.go index 1686b35..24d1e38 100644 --- a/client/figlens/stream_test.go +++ b/client/figlens/stream_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "github.com/vibeknow/cli/client/figlens" @@ -112,3 +113,32 @@ data: [DONE] t.Fatalf("bgm_enabled = %v, want true", gotBody["bgm_enabled"]) } } + +func TestStreamChat_ScriptInvalidCode(t *testing.T) { + sseBody := `data: {"code":100004,"data":{"message":"讲稿超过 8000 字"}} + +` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, sseBody) + })) + defer srv.Close() + + c := figlens.New(srv.URL, staticToken("tok")) + var events []figlens.StreamEvent + err := c.StreamChat(context.Background(), figlens.StreamParams{TaskID: 1, SessionID: "s"}, func(ev figlens.StreamEvent) { + events = append(events, ev) + }) + if err != nil { + t.Fatalf("StreamChat: %v", err) + } + if len(events) == 0 || events[0].Type != "task.failed" { + t.Fatalf("expected task.failed, got %v", events) + } + if !strings.Contains(events[0].Message, "script_invalid") { + t.Fatalf("expected script_invalid in message, got %q", events[0].Message) + } + if !strings.Contains(events[0].Message, "讲稿超过 8000 字") { + t.Fatalf("expected backend message preserved, got %q", events[0].Message) + } +} From c122faaee75db7a7e6ee52cc6b3ac9de582683f8 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 14:18:25 +0800 Subject: [PATCH 06/14] feat(create): add --mode, --aspect, --bgm flags with validation helpers --- cmd/create.go | 39 +++++++++++++++++++++ cmd/create_test.go | 76 ++++++++++++++++++++++++++++++++++++++++ internal/i18n/strings.go | 14 ++++++++ 3 files changed, 129 insertions(+) create mode 100644 cmd/create_test.go diff --git a/cmd/create.go b/cmd/create.go index ee58552..b93266c 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -14,6 +14,7 @@ import ( "github.com/vibeknow/cli/client/figlens" "github.com/vibeknow/cli/client/vectoria" "github.com/vibeknow/cli/internal/cliauth" + "github.com/vibeknow/cli/internal/clerr" "github.com/vibeknow/cli/internal/cmdutil" "github.com/vibeknow/cli/internal/errs" "github.com/vibeknow/cli/internal/i18n" @@ -29,6 +30,9 @@ var ( flagCreateAsync bool flagCreateExport bool flagCreateYes bool + flagCreateMode string + flagCreateAspect string + flagCreateBGM bool ) var docIDRe = regexp.MustCompile(`^doc_[a-zA-Z0-9]{8,}$`) @@ -297,6 +301,9 @@ func init() { createCmd.Flags().BoolVar(&flagCreateAsync, "async", false, "print task_id/session_id and exit without waiting") createCmd.Flags().BoolVar(&flagCreateExport, "export", false, "after preview, also render MP4 (extra credits + time)") createCmd.Flags().BoolVarP(&flagCreateYes, "yes", "y", false, "skip export confirmation prompt") + createCmd.Flags().StringVar(&flagCreateMode, "mode", "", i18n.T("create.flag.mode")) + createCmd.Flags().StringVar(&flagCreateAspect, "aspect", "", i18n.T("create.flag.aspect")) + createCmd.Flags().BoolVar(&flagCreateBGM, "bgm", false, i18n.T("create.flag.bgm")) } // uploadFile uploads a local file to vectoria and returns kb_id + doc_id. @@ -392,3 +399,35 @@ func pollDocReady(ctx context.Context, vc *vectoria.Client, kbID, docID string) } } +// resolveVideoKind translates the user-facing --mode value to the +// backend video_kind wire value, or returns a clerr.Validation error +// listing allowed values. Empty input → empty output (caller omits +// video_kind from the wire). +func resolveVideoKind(flag string) (string, error) { + switch strings.ToLower(strings.TrimSpace(flag)) { + case "": + return "", nil + case "replica": + return "replica", nil + case "script": + return "script_lock", nil + default: + return "", clerr.Validation(i18n.T("create.err.mode_invalid", flag)) + } +} + +// resolveAspect normalizes --aspect (canonical words + 16:9 / 9:16 +// aliases) to the backend's wire value. +func resolveAspect(flag string) (string, error) { + switch strings.ToLower(strings.TrimSpace(flag)) { + case "": + return "", nil + case "horizontal", "16:9": + return "horizontal", nil + case "vertical", "9:16": + return "vertical", nil + default: + return "", clerr.Validation(i18n.T("create.err.aspect_invalid", flag)) + } +} + diff --git a/cmd/create_test.go b/cmd/create_test.go new file mode 100644 index 0000000..68b1f45 --- /dev/null +++ b/cmd/create_test.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestResolveVideoKind(t *testing.T) { + tests := []struct { + flag string + want string + wantErr bool + }{ + {"", "", false}, + {"replica", "replica", false}, + {"script", "script_lock", false}, + {"SCRIPT", "script_lock", false}, + {"script_lock", "", true}, // backend jargon, not a CLI flag value + {"bogus", "", true}, + } + for _, tt := range tests { + t.Run(tt.flag, func(t *testing.T) { + got, err := resolveVideoKind(tt.flag) + if tt.wantErr && err == nil { + t.Fatalf("expected error for %q", tt.flag) + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("resolveVideoKind(%q) = %q, want %q", tt.flag, got, tt.want) + } + }) + } +} + +func TestResolveAspect(t *testing.T) { + tests := []struct { + flag string + want string + wantErr bool + }{ + {"", "", false}, + {"horizontal", "horizontal", false}, + {"vertical", "vertical", false}, + {"16:9", "horizontal", false}, + {"9:16", "vertical", false}, + {"HORIZONTAL", "horizontal", false}, + {"square", "", true}, + } + for _, tt := range tests { + t.Run(tt.flag, func(t *testing.T) { + got, err := resolveAspect(tt.flag) + if tt.wantErr && err == nil { + t.Fatalf("expected error for %q", tt.flag) + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("resolveAspect(%q) = %q, want %q", tt.flag, got, tt.want) + } + }) + } +} + +func TestResolveVideoKind_ErrorMessageMentionsValues(t *testing.T) { + _, err := resolveVideoKind("xyz") + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "replica") || !strings.Contains(msg, "script") { + t.Fatalf("error must list allowed values, got: %q", msg) + } +} diff --git a/internal/i18n/strings.go b/internal/i18n/strings.go index 354542f..a1bb9b6 100644 --- a/internal/i18n/strings.go +++ b/internal/i18n/strings.go @@ -72,6 +72,13 @@ func init() { "create.doc_timeout": "timed out waiting for document processing (10m)", "create.async.hint": "hint: run `vibeknow video wait %d --session-id %s` to track progress", + "create.flag.mode": "video mode: replica (PPT/PDF page-by-page) or script (use the doc as the verbatim narration script)", + "create.flag.aspect": "aspect ratio: horizontal (16:9, default) or vertical (9:16)", + "create.flag.bgm": "enable background music (default off)", + "create.err.mode_invalid": "--mode must be one of: replica, script (got %q)", + "create.err.aspect_invalid": "--aspect must be one of: horizontal, vertical, 16:9, 9:16 (got %q)", + "create.err.script_needs_doc": "--mode script requires an uploaded document (--from must be a file or URL, not a doc_id)", + // auth status (text mode) "auth.status.signed_in_as": "✓ Signed in as %s", "auth.status.signed_in": "✓ Signed in", @@ -202,6 +209,13 @@ func init() { "create.doc_timeout": "等待文档解析超时(10 分钟)", "create.async.hint": "提示: 运行 `vibeknow video wait %d --session-id %s` 查看进度", + "create.flag.mode": "视频模式:replica(PPT/PDF 逐页还原)或 script(用文档原文作为讲稿)", + "create.flag.aspect": "画幅:horizontal(16:9,默认)或 vertical(9:16)", + "create.flag.bgm": "启用背景音乐(默认关闭)", + "create.err.mode_invalid": "--mode 必须是 replica 或 script(当前为 %q)", + "create.err.aspect_invalid": "--aspect 必须是 horizontal、vertical、16:9 或 9:16(当前为 %q)", + "create.err.script_needs_doc": "--mode script 需要上传文档(--from 必须是文件或 URL,不能是 doc_id)", + // auth status (text mode) "auth.status.signed_in_as": "✓ 已登录为 %s", "auth.status.signed_in": "✓ 已登录", From 2590cbb1647c5c02275574c9d82031483e3fd419 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 14:19:58 +0800 Subject: [PATCH 07/14] feat(create): wire --mode/--aspect/--bgm to figlens calls --- cmd/create.go | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/cmd/create.go b/cmd/create.go index b93266c..dac7b87 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -51,6 +51,15 @@ var createCmd = &cobra.Command{ return fmt.Errorf("--from is required") } + videoKind, err := resolveVideoKind(flagCreateMode) + if err != nil { + return err + } + aspect, err := resolveAspect(flagCreateAspect) + if err != nil { + return err + } + ctx := context.Background() // Step 1: resolve --from to kb_id + doc_id. @@ -79,6 +88,10 @@ var createCmd = &cobra.Command{ } } + if videoKind == "script_lock" && kbID == "" { + return clerr.Validation(i18n.T("create.err.script_needs_doc")) + } + // Step 2: optimize prompt (skip if user provided --prompt). _, url, tp, err := cmdutil.Default().Service("figlens") if err != nil { @@ -99,6 +112,7 @@ var createCmd = &cobra.Command{ optimized, err := fc.FastQueryOptimize(ctx, figlens.OptimizeParams{ KnowledgeID: kbID, DocID: docID, + VideoKind: videoKind, }, onDelta) if streaming { fmt.Fprintln(os.Stderr) @@ -118,7 +132,14 @@ var createCmd = &cobra.Command{ // Step 3: init figlens task. fmt.Fprintln(os.Stderr, i18n.T("create.init_task")) - task, err := fc.InitTask(ctx, figlens.InitTaskParams{}) + initParams := figlens.InitTaskParams{VideoKind: videoKind} + if videoKind == "script_lock" { + // Script preflight needs kb_id + doc_id; guaranteed by the + // check above (we'd have exited if kbID were empty). + initParams.KnowledgeID = kbID + initParams.DocID = docID + } + task, err := fc.InitTask(ctx, initParams) if err != nil { if errs.HasCode(err, "insufficient_credits") { return fmt.Errorf("%s", i18n.T("credits.insufficient")) @@ -149,6 +170,9 @@ var createCmd = &cobra.Command{ KnowledgeID: kbID, DocID: docID, VoiceID: flagCreateVoiceID, + BGMEnabled: flagCreateBGM, + Aspect: aspect, + VideoKind: videoKind, }, func(ev figlens.StreamEvent) { switch ev.Type { case "node.started", "node.succeeded", "node.failed": From fbfe35657f8181b8a46cf7d76e041b154f23ad7e Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 14:21:41 +0800 Subject: [PATCH 08/14] feat(create): exit 2 on script_invalid with backend's localized message --- cmd/create.go | 24 ++++++++++++++++++++++-- cmd/create_test.go | 19 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index dac7b87..18c715a 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -161,6 +161,7 @@ var createCmd = &cobra.Command{ isNDJSONCreate := format == "ndjson" var taskFailed bool + var scriptInvalid bool var successSessionID string err = fc.StreamChat(ctx, figlens.StreamParams{ @@ -204,14 +205,20 @@ var createCmd = &cobra.Command{ } case "task.failed": taskFailed = true + if isScriptInvalidMessage(ev.Message) { + scriptInvalid = true + } if isNDJSONCreate { _ = output.NewNDJSON(cmd.OutOrStdout()).Event(map[string]any{ "type": "task.failed", "message": ev.Message, }) } else { - if strings.Contains(ev.Message, "insufficient_credits") { + switch { + case strings.Contains(ev.Message, "insufficient_credits"): fmt.Fprintln(os.Stderr, i18n.T("credits.insufficient")) - } else { + case isScriptInvalidMessage(ev.Message): + fmt.Fprintln(os.Stderr, extractScriptInvalidUserMessage(ev.Message)) + default: fmt.Fprintln(os.Stderr, i18n.T("create.task_failed", ev.Message)) } } @@ -228,6 +235,9 @@ var createCmd = &cobra.Command{ } if taskFailed { + if scriptInvalid { + os.Exit(2) + } os.Exit(5) } @@ -455,3 +465,13 @@ func resolveAspect(flag string) (string, error) { } } +const scriptInvalidPrefix = "[script_invalid] " + +func isScriptInvalidMessage(msg string) bool { + return strings.HasPrefix(msg, scriptInvalidPrefix) +} + +func extractScriptInvalidUserMessage(msg string) string { + return strings.TrimPrefix(msg, scriptInvalidPrefix) +} + diff --git a/cmd/create_test.go b/cmd/create_test.go index 68b1f45..3807104 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -74,3 +74,22 @@ func TestResolveVideoKind_ErrorMessageMentionsValues(t *testing.T) { t.Fatalf("error must list allowed values, got: %q", msg) } } + +func TestIsScriptInvalidMessage(t *testing.T) { + if !isScriptInvalidMessage("[script_invalid] 讲稿超过 8000 字") { + t.Fatal("expected script_invalid match") + } + if isScriptInvalidMessage("[insufficient_credits] no credits") { + t.Fatal("unexpected match") + } + if isScriptInvalidMessage("plain error") { + t.Fatal("unexpected match") + } +} + +func TestExtractScriptInvalidUserMessage(t *testing.T) { + got := extractScriptInvalidUserMessage("[script_invalid] 讲稿超过 8000 字") + if got != "讲稿超过 8000 字" { + t.Fatalf("got %q", got) + } +} From 13fc5243b5f706b5a84e540da01972cfe225a867 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 14:24:03 +0800 Subject: [PATCH 09/14] test(integration): cover vk create --mode replica wire shape + SSE visibility --- tests/integration/create_mode_test.go | 138 ++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/integration/create_mode_test.go diff --git a/tests/integration/create_mode_test.go b/tests/integration/create_mode_test.go new file mode 100644 index 0000000..de37cb9 --- /dev/null +++ b/tests/integration/create_mode_test.go @@ -0,0 +1,138 @@ +package integration + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "sync" + "testing" +) + +func TestCreate_ModeReplica_WiresVideoKind(t *testing.T) { + if testing.Short() { + t.Skip("integration test") + } + + var mu sync.Mutex + bodies := map[string]map[string]any{} + + figlens := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/tasks/init": + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + mu.Lock() + bodies["init"] = body + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "data": map[string]any{"task_id": 42, "session_id": "s_replica", "work_id": 43, "v": 3}, + }) + + case "/v1/agent2forVideo/fastQueryOptimize": + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + mu.Lock() + bodies["optimize"] = body + mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + flusher, _ := w.(http.Flusher) + fmt.Fprintln(w, `data: {"code":200,"data":{"type":"aim_result","answer_done":{"text":"replica prompt"}}}`) + fmt.Fprintln(w) + fmt.Fprintln(w, `data: [DONE]`) + fmt.Fprintln(w) + if flusher != nil { + flusher.Flush() + } + + case "/v1/agent3forVideo/stream": + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + mu.Lock() + bodies["stream"] = body + mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + flusher, _ := w.(http.Flusher) + events := []string{ + `data: {"code":200,"data":{"type":"process","log":{"step_id":"doc_replica_plan","status":"start","message":"go"}}}`, + `data: {"code":200,"data":{"type":"process","log":{"step_id":"doc_replica_plan","status":"success","message":"ok"}}}`, + `data: {"code":200,"data":{"type":"aim_result","session_id":"s_replica"}}`, + `data: [DONE]`, + } + for _, e := range events { + fmt.Fprintln(w, e) + fmt.Fprintln(w) + if flusher != nil { + flusher.Flush() + } + } + + case "/v1/works/detailBySession": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "data": map[string]any{ + "id": 43, "session_id": "s_replica", + "title": "Replica Test", + "html_path": "works/replica/index.html", + "share_token": "tok_replica", + "exporting": 0, + }, + }) + + default: + w.WriteHeader(404) + } + })) + defer figlens.Close() + + bin := build(t) + configHome := buildVideoProfile(t, figlens.URL) + + cmd := exec.Command(bin, "create", "--mode", "replica", "--from", "doc_abc12345") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Env = append(os.Environ(), + "VIBEKNOW_TOKEN=fake-token", + "VIBEKNOW_CONFIG_HOME="+configHome, + ) + + err := cmd.Run() + code := 0 + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } else if err != nil { + t.Fatalf("run: %v\nstderr: %s", err, stderr.String()) + } + + if code != 0 { + t.Fatalf("create exit %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String()) + } + + mu.Lock() + defer mu.Unlock() + + if bodies["init"]["video_kind"] != "replica" { + t.Fatalf("init body video_kind = %v, want \"replica\"", bodies["init"]["video_kind"]) + } + if _, ok := bodies["init"]["knowledge_id"]; ok { + t.Fatalf("init body should not carry knowledge_id for non-script mode: %v", bodies["init"]) + } + if _, ok := bodies["init"]["doc_id"]; ok { + t.Fatalf("init body should not carry doc_id for non-script mode: %v", bodies["init"]) + } + if bodies["stream"]["video_kind"] != "replica" { + t.Fatalf("stream body video_kind = %v, want \"replica\"", bodies["stream"]["video_kind"]) + } + out := stdout.String() + stderr.String() + if !strings.Contains(out, "doc_replica_plan") { + t.Fatalf("expected doc_replica_plan in output (proves stage map), got:\nstdout: %s\nstderr: %s", stdout.String(), stderr.String()) + } +} From 71e505c3db375eb3d1458e8d73bd9aed781e43cc Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 14:26:19 +0800 Subject: [PATCH 10/14] docs: document --mode/--aspect/--bgm for 0.5.0 --- AGENTS.md | 22 ++++++++++++++++++++++ CHANGELOG.md | 28 ++++++++++++++++++++++++++++ README.md | 8 ++++++++ README.zh.md | 8 ++++++++ 4 files changed, 66 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5b51da1..6b98a17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,3 +41,25 @@ Commands: Exit codes: `0` success, `2` user error, `5` business failure, `6` interrupted, `7` partial success (preview ready, export failed). + +## Video kinds + +`vk create --mode ` picks which figlens pipeline runs: + +- **default (no flag)** — generative video from the document. +- **`--mode replica`** — PPT/PDF page-by-page reproduction. Suited + for slide decks where the visual structure is the message. +- **`--mode script`** — uses the uploaded document text *verbatim* + as the narration. The doc must pass a quality preflight (length, + characters, content). Preflight failures exit **2** with a clear + message; agents should treat them as user-input problems, not + retries. + +Modes combine freely with `--aspect horizontal|vertical` (or `16:9` / +`9:16`) and `--bgm`. All three flags are independent of `--from`, +`--prompt`, `--voice`, and `--export`. + +Exit-code summary for new modes: +- `2` — `--mode `, `--aspect `, or script preflight rejected the document. +- `5` — pipeline business failure (e.g., insufficient credits) — same as today. +- `7` — preview ok, MP4 export failed — same as today. diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e77d6..6185dec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.5.0 — 2026-05-14 + +### New + +- `vk create --mode replica` runs the figlens PPT/PDF page-by-page + replica pipeline. `vk create --mode script` runs the verbatim-script + ("讲稿锁定") pipeline that uses the uploaded document as the + narration. Both modes are now visible to humans and AI agents + through the same single-flag surface; default invocation (no + `--mode`) is unchanged. +- `vk create --aspect horizontal|vertical` selects 16:9 or 9:16 + output. Accepts `16:9` / `9:16` as aliases. +- `vk create --bgm` enables background music (off by default). +- SSE progress events for the replica pipeline's new nodes + (`doc_replica_plan`, `doc_replica_shoot`) now surface in `text` and + `ndjson` output — previously filtered out by the CLI's stage map. + +### Changed + +- Script-mode preflight failures (`POST /v1/tasks/init` returns code + `100004`) now exit **2** (validation, user fixes input) with the + backend's localized message, instead of exit 5 with a generic + "business error" label. +- `figlens.InitTask` now takes `InitTaskParams{KnowledgeID, DocID, + VideoKind}`. Default-zero params produce the same wire body as + before (`{"v": 3}`), so callers that don't use script mode are + unaffected. + ## 0.4.1 — 2026-04-24 ### Fixed diff --git a/README.md b/README.md index 2d58b54..1e12b39 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,14 @@ output=sess_xxx.mp4 Or one-shot: `vk create --from ... --export --yes`. +### Choose a video mode + +```bash +vk create --from deck.pdf --mode replica # PPT/PDF page-by-page +vk create --from talk.docx --mode script # narrate the doc verbatim +vk create --from --aspect vertical --bgm +``` + ### Voice Templates ```bash diff --git a/README.zh.md b/README.zh.md index e91b186..aedbe64 100644 --- a/README.zh.md +++ b/README.zh.md @@ -203,6 +203,14 @@ output=sess_xxx.mp4 或者一键搞定:`vk create --from ... --export --yes`。 +### 选择视频模式 + +```bash +vk create --from deck.pdf --mode replica # PPT/PDF 逐页还原 +vk create --from talk.docx --mode script # 讲稿模式(用文档原文做旁白) +vk create --from --aspect vertical --bgm +``` + ### 音色模板 ```bash From 9a977dabbc6044e95e182df32639a559ea34b195 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 14:30:01 +0800 Subject: [PATCH 11/14] chore: bump version to 0.5.0 --- package.json | 2 +- skills/vibeknow-core/SKILL.md | 2 +- skills/vibeknow-create/SKILL.md | 2 +- skills/vibeknow-doc/SKILL.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index e6c1962..4b9624b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vibeknow-cli", - "version": "0.4.1", + "version": "0.5.0", "description": "VibeKnow CLI — turn docs / URLs into videos", "license": "MIT", "bin": { diff --git a/skills/vibeknow-core/SKILL.md b/skills/vibeknow-core/SKILL.md index 2104ad8..587f706 100644 --- a/skills/vibeknow-core/SKILL.md +++ b/skills/vibeknow-core/SKILL.md @@ -1,6 +1,6 @@ --- name: vibeknow-core -version: 0.4.1 +version: 0.5.0 description: "vibeknow CLI setup, authentication, profile management, and diagnostics. Use when: first-time setup, auth errors, switching environments, diagnosing connection issues." metadata: requires: diff --git a/skills/vibeknow-create/SKILL.md b/skills/vibeknow-create/SKILL.md index 5864dc7..5b1451d 100644 --- a/skills/vibeknow-create/SKILL.md +++ b/skills/vibeknow-create/SKILL.md @@ -1,6 +1,6 @@ --- name: vibeknow-create -version: 0.4.1 +version: 0.5.0 description: "Generate videos from documents/URLs/files, track video task progress, download results, list voice templates. Use when: user wants to create a video, check task status, download video, or browse voices." metadata: requires: diff --git a/skills/vibeknow-doc/SKILL.md b/skills/vibeknow-doc/SKILL.md index 40ca172..b8ee7e3 100644 --- a/skills/vibeknow-doc/SKILL.md +++ b/skills/vibeknow-doc/SKILL.md @@ -1,6 +1,6 @@ --- name: vibeknow-doc -version: 0.4.1 +version: 0.5.0 description: "Upload documents to vectoria and check processing status. Use when: user wants to upload a document, check if a document is ready, or get a doc_id for use with vibeknow create." metadata: requires: From 5641532311afd415e07f672dfbe2e62b5057bac8 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 14:39:35 +0800 Subject: [PATCH 12/14] fix(create): handle 100004 script_invalid on InitTask HTTP path --- cmd/create.go | 10 ++++++++++ internal/httpclient/errors.go | 2 ++ internal/httpclient/errors_test.go | 1 + 3 files changed, 13 insertions(+) diff --git a/cmd/create.go b/cmd/create.go index 18c715a..148de51 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "os" "regexp" @@ -144,6 +145,15 @@ var createCmd = &cobra.Command{ if errs.HasCode(err, "insufficient_credits") { return fmt.Errorf("%s", i18n.T("credits.insufficient")) } + if errs.HasCode(err, "script_invalid") { + // Backend's localized message already lives on the error. + // Exit 2 via clerr.Validation — this is a user-input problem. + var o *errs.Object + if errors.As(err, &o) { + return clerr.Validation(o.Message) + } + return clerr.Validation(err.Error()) + } return err } diff --git a/internal/httpclient/errors.go b/internal/httpclient/errors.go index ec65f1d..a6eb9c9 100644 --- a/internal/httpclient/errors.go +++ b/internal/httpclient/errors.go @@ -124,6 +124,8 @@ func mapEnvelopeCode(envCode, httpStatus int) string { return "freeze_not_found" case envCode == 100003: return "concurrent_work_limit" + case envCode == 100004: + return "script_invalid" case envCode >= 100000: return "business_error" default: diff --git a/internal/httpclient/errors_test.go b/internal/httpclient/errors_test.go index fc22c2b..471e818 100644 --- a/internal/httpclient/errors_test.go +++ b/internal/httpclient/errors_test.go @@ -34,6 +34,7 @@ func TestMapEnvelopeCode(t *testing.T) { {"internal_error", 50001, http.StatusInternalServerError, "internal_error"}, // Known business codes retain their specific labels. {"insufficient_credits", 100001, http.StatusPaymentRequired, "insufficient_credits"}, + {"script_invalid", 100004, http.StatusBadRequest, "script_invalid"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { From 338666bf6e3b54d3a5ab6ae0b024d660d10a8528 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 14:50:00 +0800 Subject: [PATCH 13/14] =?UTF-8?q?refactor:=20review=20pass=20=E2=80=94=20t?= =?UTF-8?q?yped=20code=20field,=20shared=20mapper,=20struct=20embed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply findings from /simplify review: - client/figlens/stream.go: add typed Code field to StreamEvent so task.failed events carry the SSE envelope code as data, not stuffed into Message as a "[code] msg" string prefix. Eliminates the leaky cross-package string protocol the cmd package was parsing. - internal/httpclient/errors.go: extract MapBusinessCode(envCode) as the single source of truth for 100xxx → label mappings; SSE and HTTP paths now both delegate, so a new business code only needs one edit. - client/figlens/task.go: embed InitTaskParams in initTaskWire (was copy-pasted field-by-field); export VideoKindReplica / VideoKindScriptLock constants used by cmd/create.go. - cmd/create.go: switch on ev.Code instead of string-prefix scanning ev.Message; collapse scriptInvalid+taskFailed booleans into a single failExitCode int; delete dead helpers (scriptInvalidPrefix, isScriptInvalidMessage, extractScriptInvalidUserMessage); trim resolver comments and the redundant invariant comment above the script_lock kbID/docID setup. - tests/integration/create_mode_test.go: flush after each SSE event in the fastQueryOptimize mock, matching the stream handler pattern. No behavior changes for users. All existing tests pass; the NDJSON task.failed envelope now also carries a "code" field alongside "message" (purely additive). --- client/figlens/stream.go | 26 ++++++------ client/figlens/stream_test.go | 9 ++--- client/figlens/task.go | 20 ++++----- cmd/create.go | 58 ++++++++++----------------- cmd/create_test.go | 18 --------- internal/httpclient/errors.go | 40 ++++++++++++------ tests/integration/create_mode_test.go | 15 ++++--- 7 files changed, 83 insertions(+), 103 deletions(-) diff --git a/client/figlens/stream.go b/client/figlens/stream.go index 66faeb3..1d1e7c9 100644 --- a/client/figlens/stream.go +++ b/client/figlens/stream.go @@ -7,6 +7,7 @@ import ( "io" "strings" + "github.com/vibeknow/cli/internal/httpclient" "github.com/vibeknow/cli/internal/sse" "github.com/vibeknow/cli/internal/stage" ) @@ -25,6 +26,7 @@ type StreamParams struct { type StreamEvent struct { Type string + Code string // set on task.failed when payload carries an envelope code Stage string Node string Message string @@ -49,20 +51,13 @@ type processLog struct { Message string `json:"message"` } -// mapSSECode maps a backend envelope code from an SSE payload to a CLI error code string. +// mapSSECode maps an SSE envelope code to a CLI error code label, delegating +// to httpclient.MapBusinessCode so the two transports never diverge. func mapSSECode(code int) string { - switch code { - case 100001: - return "insufficient_credits" - case 100002: - return "freeze_not_found" - case 100003: - return "concurrent_work_limit" - case 100004: - return "script_invalid" - default: - return "business_error" + if label, ok := httpclient.MapBusinessCode(code); ok { + return label } + return "business_error" } func (c *Client) StreamChat(ctx context.Context, params StreamParams, onEvent func(StreamEvent)) error { @@ -104,8 +99,11 @@ func (c *Client) StreamChat(ctx context.Context, params StreamParams, onEvent fu msg = d.Message } } - code := mapSSECode(payload.Code) - onEvent(StreamEvent{Type: "task.failed", Message: fmt.Sprintf("[%s] %s", code, msg)}) + onEvent(StreamEvent{ + Type: "task.failed", + Code: mapSSECode(payload.Code), + Message: msg, + }) return nil } diff --git a/client/figlens/stream_test.go b/client/figlens/stream_test.go index 24d1e38..9777f03 100644 --- a/client/figlens/stream_test.go +++ b/client/figlens/stream_test.go @@ -6,7 +6,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "strings" "testing" "github.com/vibeknow/cli/client/figlens" @@ -135,10 +134,10 @@ func TestStreamChat_ScriptInvalidCode(t *testing.T) { if len(events) == 0 || events[0].Type != "task.failed" { t.Fatalf("expected task.failed, got %v", events) } - if !strings.Contains(events[0].Message, "script_invalid") { - t.Fatalf("expected script_invalid in message, got %q", events[0].Message) + if events[0].Code != "script_invalid" { + t.Fatalf("expected Code=script_invalid, got %q", events[0].Code) } - if !strings.Contains(events[0].Message, "讲稿超过 8000 字") { - t.Fatalf("expected backend message preserved, got %q", events[0].Message) + if events[0].Message != "讲稿超过 8000 字" { + t.Fatalf("expected backend message verbatim, got %q", events[0].Message) } } diff --git a/client/figlens/task.go b/client/figlens/task.go index 31dbdbd..053f824 100644 --- a/client/figlens/task.go +++ b/client/figlens/task.go @@ -5,6 +5,13 @@ import ( "fmt" ) +// Backend video_kind wire values. The CLI flag names (`replica`, `script`) +// map to these via cmd.resolveVideoKind. +const ( + VideoKindReplica = "replica" + VideoKindScriptLock = "script_lock" +) + type Task struct { TaskID int64 `json:"task_id"` SessionID string `json:"session_id"` @@ -19,20 +26,13 @@ type InitTaskParams struct { } type initTaskWire struct { - V int `json:"v"` - KnowledgeID string `json:"knowledge_id,omitempty"` - DocID string `json:"doc_id,omitempty"` - VideoKind string `json:"video_kind,omitempty"` + V int `json:"v"` + InitTaskParams } func (c *Client) InitTask(ctx context.Context, p InitTaskParams) (*Task, error) { var t Task - body := initTaskWire{ - V: 3, - KnowledgeID: p.KnowledgeID, - DocID: p.DocID, - VideoKind: p.VideoKind, - } + body := initTaskWire{V: 3, InitTaskParams: p} if err := c.http.Do(ctx, "POST", "/v1/tasks/init", body, &t); err != nil { return nil, fmt.Errorf("init task: %w", err) } diff --git a/cmd/create.go b/cmd/create.go index 148de51..f974bcf 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -89,7 +89,7 @@ var createCmd = &cobra.Command{ } } - if videoKind == "script_lock" && kbID == "" { + if videoKind == figlens.VideoKindScriptLock && kbID == "" { return clerr.Validation(i18n.T("create.err.script_needs_doc")) } @@ -134,9 +134,7 @@ var createCmd = &cobra.Command{ // Step 3: init figlens task. fmt.Fprintln(os.Stderr, i18n.T("create.init_task")) initParams := figlens.InitTaskParams{VideoKind: videoKind} - if videoKind == "script_lock" { - // Script preflight needs kb_id + doc_id; guaranteed by the - // check above (we'd have exited if kbID were empty). + if videoKind == figlens.VideoKindScriptLock { initParams.KnowledgeID = kbID initParams.DocID = docID } @@ -170,8 +168,7 @@ var createCmd = &cobra.Command{ format, _ := cmd.Flags().GetString("output") isNDJSONCreate := format == "ndjson" - var taskFailed bool - var scriptInvalid bool + var failExitCode int // 0 = not failed; 5 = business; 2 = script_invalid (user-fixable input) var successSessionID string err = fc.StreamChat(ctx, figlens.StreamParams{ @@ -214,20 +211,22 @@ var createCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, i18n.T("create.task_succeeded")) } case "task.failed": - taskFailed = true - if isScriptInvalidMessage(ev.Message) { - scriptInvalid = true + failExitCode = 5 + if ev.Code == "script_invalid" { + failExitCode = 2 } if isNDJSONCreate { _ = output.NewNDJSON(cmd.OutOrStdout()).Event(map[string]any{ - "type": "task.failed", "message": ev.Message, + "type": "task.failed", + "code": ev.Code, + "message": ev.Message, }) } else { - switch { - case strings.Contains(ev.Message, "insufficient_credits"): + switch ev.Code { + case "insufficient_credits": fmt.Fprintln(os.Stderr, i18n.T("credits.insufficient")) - case isScriptInvalidMessage(ev.Message): - fmt.Fprintln(os.Stderr, extractScriptInvalidUserMessage(ev.Message)) + case "script_invalid": + fmt.Fprintln(os.Stderr, ev.Message) default: fmt.Fprintln(os.Stderr, i18n.T("create.task_failed", ev.Message)) } @@ -244,11 +243,8 @@ var createCmd = &cobra.Command{ os.Exit(6) } - if taskFailed { - if scriptInvalid { - os.Exit(2) - } - os.Exit(5) + if failExitCode != 0 { + os.Exit(failExitCode) } if successSessionID == "" { @@ -443,25 +439,23 @@ func pollDocReady(ctx context.Context, vc *vectoria.Client, kbID, docID string) } } -// resolveVideoKind translates the user-facing --mode value to the -// backend video_kind wire value, or returns a clerr.Validation error -// listing allowed values. Empty input → empty output (caller omits -// video_kind from the wire). +// resolveVideoKind maps the --mode flag to the backend video_kind wire value. +// Empty passes through (caller omits the field); unrecognized → Validation error. func resolveVideoKind(flag string) (string, error) { switch strings.ToLower(strings.TrimSpace(flag)) { case "": return "", nil case "replica": - return "replica", nil + return figlens.VideoKindReplica, nil case "script": - return "script_lock", nil + return figlens.VideoKindScriptLock, nil default: return "", clerr.Validation(i18n.T("create.err.mode_invalid", flag)) } } -// resolveAspect normalizes --aspect (canonical words + 16:9 / 9:16 -// aliases) to the backend's wire value. +// resolveAspect normalizes --aspect (canonical words + 16:9 / 9:16 aliases) +// to the backend wire value. func resolveAspect(flag string) (string, error) { switch strings.ToLower(strings.TrimSpace(flag)) { case "": @@ -475,13 +469,3 @@ func resolveAspect(flag string) (string, error) { } } -const scriptInvalidPrefix = "[script_invalid] " - -func isScriptInvalidMessage(msg string) bool { - return strings.HasPrefix(msg, scriptInvalidPrefix) -} - -func extractScriptInvalidUserMessage(msg string) string { - return strings.TrimPrefix(msg, scriptInvalidPrefix) -} - diff --git a/cmd/create_test.go b/cmd/create_test.go index 3807104..73b6bd1 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -75,21 +75,3 @@ func TestResolveVideoKind_ErrorMessageMentionsValues(t *testing.T) { } } -func TestIsScriptInvalidMessage(t *testing.T) { - if !isScriptInvalidMessage("[script_invalid] 讲稿超过 8000 字") { - t.Fatal("expected script_invalid match") - } - if isScriptInvalidMessage("[insufficient_credits] no credits") { - t.Fatal("unexpected match") - } - if isScriptInvalidMessage("plain error") { - t.Fatal("unexpected match") - } -} - -func TestExtractScriptInvalidUserMessage(t *testing.T) { - got := extractScriptInvalidUserMessage("[script_invalid] 讲稿超过 8000 字") - if got != "讲稿超过 8000 字" { - t.Fatalf("got %q", got) - } -} diff --git a/internal/httpclient/errors.go b/internal/httpclient/errors.go index a6eb9c9..37c8455 100644 --- a/internal/httpclient/errors.go +++ b/internal/httpclient/errors.go @@ -94,6 +94,29 @@ const ( CodeAccountPendingDeletion = "account_pending_deletion" ) +// MapBusinessCode maps a 100xxx-range backend envelope code to a stable +// CLI error code label. Returns ok=false for codes outside that range so +// callers can fall back to their own mapping (e.g. HTTP-class for HTTP, +// "business_error" for SSE). Shared by mapEnvelopeCode (HTTP path) and +// client/figlens.mapSSECode (SSE path) so a new business code only needs +// to be added once. +func MapBusinessCode(envCode int) (string, bool) { + switch envCode { + case 100001: + return "insufficient_credits", true + case 100002: + return "freeze_not_found", true + case 100003: + return "concurrent_work_limit", true + case 100004: + return "script_invalid", true + } + if envCode >= 100000 { + return "business_error", true + } + return "", false +} + // mapEnvelopeCode maps a backend envelope code + HTTP status to a CLI error code. // Backend aether codes: 40xxx = 4xx class, 50xxx = 5xx class, 100xxx+ = business errors. func mapEnvelopeCode(envCode, httpStatus int) string { @@ -117,20 +140,11 @@ func mapEnvelopeCode(envCode, httpStatus int) string { return CodeSessionReplaced case envCode == 110013: return CodeAccountPendingDeletion - // Business errors (100xxx). - case envCode == 100001: - return "insufficient_credits" - case envCode == 100002: - return "freeze_not_found" - case envCode == 100003: - return "concurrent_work_limit" - case envCode == 100004: - return "script_invalid" - case envCode >= 100000: - return "business_error" - default: - return mapHTTPCode(httpStatus) } + if label, ok := MapBusinessCode(envCode); ok { + return label + } + return mapHTTPCode(httpStatus) } func mapHTTPCode(status int) string { diff --git a/tests/integration/create_mode_test.go b/tests/integration/create_mode_test.go index de37cb9..545ef63 100644 --- a/tests/integration/create_mode_test.go +++ b/tests/integration/create_mode_test.go @@ -43,12 +43,15 @@ func TestCreate_ModeReplica_WiresVideoKind(t *testing.T) { mu.Unlock() w.Header().Set("Content-Type", "text/event-stream") flusher, _ := w.(http.Flusher) - fmt.Fprintln(w, `data: {"code":200,"data":{"type":"aim_result","answer_done":{"text":"replica prompt"}}}`) - fmt.Fprintln(w) - fmt.Fprintln(w, `data: [DONE]`) - fmt.Fprintln(w) - if flusher != nil { - flusher.Flush() + for _, e := range []string{ + `data: {"code":200,"data":{"type":"aim_result","answer_done":{"text":"replica prompt"}}}`, + `data: [DONE]`, + } { + fmt.Fprintln(w, e) + fmt.Fprintln(w) + if flusher != nil { + flusher.Flush() + } } case "/v1/agent3forVideo/stream": From 56a47717236b2f9257fb1442a9a3fb84a0809c2d Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 16:26:05 +0800 Subject: [PATCH 14/14] =?UTF-8?q?chore:=20rename=20release=200.5.0=20?= =?UTF-8?q?=E2=86=92=200.4.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-cut this branch as a patch release. The next minor (0.5.0) is reserved for engine selection (vk create --engine agent|pipeline), which is a wider-surface change introducing a new SSE event type (node.progress) and cross-flag validation. Video-kinds is comparatively a "thread new flags through existing pipeline" patch and fits 0.4.2. Source-of-truth flips: - package.json - skills/vibeknow-{core,create,doc}/SKILL.md - CHANGELOG.md heading Spec files (untracked docs/) reflect the new numbering separately. --- CHANGELOG.md | 2 +- package.json | 2 +- skills/vibeknow-core/SKILL.md | 2 +- skills/vibeknow-create/SKILL.md | 2 +- skills/vibeknow-doc/SKILL.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6185dec..a1ca282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.5.0 — 2026-05-14 +## 0.4.2 — 2026-05-14 ### New diff --git a/package.json b/package.json index 4b9624b..d1430e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vibeknow-cli", - "version": "0.5.0", + "version": "0.4.2", "description": "VibeKnow CLI — turn docs / URLs into videos", "license": "MIT", "bin": { diff --git a/skills/vibeknow-core/SKILL.md b/skills/vibeknow-core/SKILL.md index 587f706..45d207c 100644 --- a/skills/vibeknow-core/SKILL.md +++ b/skills/vibeknow-core/SKILL.md @@ -1,6 +1,6 @@ --- name: vibeknow-core -version: 0.5.0 +version: 0.4.2 description: "vibeknow CLI setup, authentication, profile management, and diagnostics. Use when: first-time setup, auth errors, switching environments, diagnosing connection issues." metadata: requires: diff --git a/skills/vibeknow-create/SKILL.md b/skills/vibeknow-create/SKILL.md index 5b1451d..9b77e13 100644 --- a/skills/vibeknow-create/SKILL.md +++ b/skills/vibeknow-create/SKILL.md @@ -1,6 +1,6 @@ --- name: vibeknow-create -version: 0.5.0 +version: 0.4.2 description: "Generate videos from documents/URLs/files, track video task progress, download results, list voice templates. Use when: user wants to create a video, check task status, download video, or browse voices." metadata: requires: diff --git a/skills/vibeknow-doc/SKILL.md b/skills/vibeknow-doc/SKILL.md index b8ee7e3..de3b358 100644 --- a/skills/vibeknow-doc/SKILL.md +++ b/skills/vibeknow-doc/SKILL.md @@ -1,6 +1,6 @@ --- name: vibeknow-doc -version: 0.5.0 +version: 0.4.2 description: "Upload documents to vectoria and check processing status. Use when: user wants to upload a document, check if a document is ready, or get a doc_id for use with vibeknow create." metadata: requires: