From 29d63ce6d10ff877e6372bc92b21ef18721e70b1 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 16:39:00 +0800 Subject: [PATCH 01/13] feat(figlens): introduce Engine enum + RemapEngineForDisplay helper --- client/figlens/engine.go | 43 ++++++++++++++++++++++++++++++ client/figlens/engine_test.go | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 client/figlens/engine.go create mode 100644 client/figlens/engine_test.go diff --git a/client/figlens/engine.go b/client/figlens/engine.go new file mode 100644 index 0000000..560a861 --- /dev/null +++ b/client/figlens/engine.go @@ -0,0 +1,43 @@ +package figlens + +// Engine selects which go-figlens video generation pipeline to invoke. +// The zero value (EnginePipeline) preserves 0.4.2 behavior for callers +// that don't set Engine explicitly. +type Engine int + +const ( + EnginePipeline Engine = 0 // → wire v=3, /agent3forVideo/stream + EngineAgent Engine = 1 // → wire v=2, /agent2forVideo/stream +) + +// Wire returns the backend's "v" field value for this engine. +// 3 = pipeline (PipelineForVideo handler, WorkEngine="suite" in DB). +// 2 = agent (AgentOnlyForVideo handler, WorkEngine="agent" in DB). +func (e Engine) Wire() int { + if e == EngineAgent { + return 2 + } + return 3 +} + +// StreamPath returns the SSE endpoint path for this engine. +func (e Engine) StreamPath() string { + if e == EngineAgent { + return "/v1/agent2forVideo/stream" + } + return "/v1/agent3forVideo/stream" +} + +// RemapEngineForDisplay translates the backend's Work.Engine DB enum +// value to the CLI's user-facing vocabulary used by --engine. +// Unknown values pass through unchanged so a future backend engine +// doesn't disappear from snapshot output. +func RemapEngineForDisplay(dbEnum string) string { + switch dbEnum { + case "suite": + return "pipeline" + case "agent": + return "agent" + } + return dbEnum +} diff --git a/client/figlens/engine_test.go b/client/figlens/engine_test.go new file mode 100644 index 0000000..2ed8979 --- /dev/null +++ b/client/figlens/engine_test.go @@ -0,0 +1,50 @@ +package figlens_test + +import ( + "testing" + + "github.com/vibeknow/cli/client/figlens" +) + +func TestEngineWireValue(t *testing.T) { + if got := figlens.EnginePipeline.Wire(); got != 3 { + t.Fatalf("EnginePipeline.Wire() = %d, want 3", got) + } + if got := figlens.EngineAgent.Wire(); got != 2 { + t.Fatalf("EngineAgent.Wire() = %d, want 2", got) + } +} + +func TestEngineDefault(t *testing.T) { + var zero figlens.Engine + if zero != figlens.EnginePipeline { + t.Fatalf("zero Engine = %v, want EnginePipeline (so InitTask{}/StreamParams{} stay backward-compat)", zero) + } +} + +func TestEngineStreamPath(t *testing.T) { + if got := figlens.EnginePipeline.StreamPath(); got != "/v1/agent3forVideo/stream" { + t.Fatalf("EnginePipeline.StreamPath() = %q, want /v1/agent3forVideo/stream", got) + } + if got := figlens.EngineAgent.StreamPath(); got != "/v1/agent2forVideo/stream" { + t.Fatalf("EngineAgent.StreamPath() = %q, want /v1/agent2forVideo/stream", got) + } +} + +func TestRemapEngineForDisplay(t *testing.T) { + tests := []struct { + in, want string + }{ + {"suite", "pipeline"}, + {"agent", "agent"}, + {"", ""}, + {"unknown_future_engine", "unknown_future_engine"}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + if got := figlens.RemapEngineForDisplay(tt.in); got != tt.want { + t.Fatalf("RemapEngineForDisplay(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} From 0c6359470b93972bfe6b03f22aa98fa1562ce29a Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 16:40:22 +0800 Subject: [PATCH 02/13] feat(figlens): InitTask routes v=2/v=3 based on Engine field --- client/figlens/figlens_test.go | 22 ++++++++++++++++++++++ client/figlens/task.go | 3 ++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/client/figlens/figlens_test.go b/client/figlens/figlens_test.go index 7e330eb..ec81794 100644 --- a/client/figlens/figlens_test.go +++ b/client/figlens/figlens_test.go @@ -217,3 +217,25 @@ data: [DONE] t.Fatalf("video_kind unexpectedly present in wire body: %s", raw) } } + +func TestInitTask_AgentEngineSendsV2(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) + figlensResp(w, map[string]any{ + "task_id": 1, "session_id": "s_x", "work_id": 2, "v": 2, + }) + })) + defer srv.Close() + + c := figlens.New(srv.URL, staticToken("tok")) + _, err := c.InitTask(context.Background(), figlens.InitTaskParams{ + Engine: figlens.EngineAgent, + }) + if err != nil { + t.Fatalf("InitTask: %v", err) + } + if gotBody["v"] != float64(2) { + t.Fatalf("v on wire = %v, want 2", gotBody["v"]) + } +} diff --git a/client/figlens/task.go b/client/figlens/task.go index 053f824..00eeec4 100644 --- a/client/figlens/task.go +++ b/client/figlens/task.go @@ -20,6 +20,7 @@ type Task struct { } type InitTaskParams struct { + Engine Engine `json:"-"` // selects wire v field, never emitted as a body key KnowledgeID string `json:"knowledge_id,omitempty"` DocID string `json:"doc_id,omitempty"` VideoKind string `json:"video_kind,omitempty"` @@ -32,7 +33,7 @@ type initTaskWire struct { func (c *Client) InitTask(ctx context.Context, p InitTaskParams) (*Task, error) { var t Task - body := initTaskWire{V: 3, InitTaskParams: p} + body := initTaskWire{V: p.Engine.Wire(), InitTaskParams: p} if err := c.http.Do(ctx, "POST", "/v1/tasks/init", body, &t); err != nil { return nil, fmt.Errorf("init task: %w", err) } From 3bc97eb89d5e110e0540ca3d73dcd37886bb57c8 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 16:41:25 +0800 Subject: [PATCH 03/13] feat(figlens): StreamChat picks endpoint by Engine (v=2 agent2, v=3 agent3) --- client/figlens/stream.go | 3 ++- client/figlens/stream_test.go | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/client/figlens/stream.go b/client/figlens/stream.go index 1d1e7c9..f1b4b05 100644 --- a/client/figlens/stream.go +++ b/client/figlens/stream.go @@ -22,6 +22,7 @@ type StreamParams struct { BGMEnabled bool `json:"bgm_enabled,omitempty"` Aspect string `json:"aspect,omitempty"` VideoKind string `json:"video_kind,omitempty"` + Engine Engine `json:"-"` // selects endpoint, never emitted in body } type StreamEvent struct { @@ -61,7 +62,7 @@ func mapSSECode(code int) string { } func (c *Client) StreamChat(ctx context.Context, params StreamParams, onEvent func(StreamEvent)) error { - resp, err := c.http.DoRaw(ctx, "POST", "/v1/agent3forVideo/stream", params) + resp, err := c.http.DoRaw(ctx, "POST", params.Engine.StreamPath(), params) if err != nil { return fmt.Errorf("stream chat: %w", err) } diff --git a/client/figlens/stream_test.go b/client/figlens/stream_test.go index 9777f03..c3c25ae 100644 --- a/client/figlens/stream_test.go +++ b/client/figlens/stream_test.go @@ -141,3 +141,24 @@ func TestStreamChat_ScriptInvalidCode(t *testing.T) { t.Fatalf("expected backend message verbatim, got %q", events[0].Message) } } + +func TestStreamChat_AgentEngineUsesAgent2Path(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: {\"code\":200,\"data\":{\"type\":\"aim_result\",\"session_id\":\"s\"}}\n\ndata: [DONE]\n\n") + })) + defer srv.Close() + + c := figlens.New(srv.URL, staticToken("tok")) + err := c.StreamChat(context.Background(), figlens.StreamParams{ + TaskID: 1, SessionID: "s", Query: "q", Engine: figlens.EngineAgent, + }, func(figlens.StreamEvent) {}) + if err != nil { + t.Fatalf("StreamChat: %v", err) + } + if gotPath != "/v1/agent2forVideo/stream" { + t.Fatalf("path = %q, want /v1/agent2forVideo/stream", gotPath) + } +} From 54de247d7e25fd05f68688efaa51075b9cf70e06 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 16:42:31 +0800 Subject: [PATCH 04/13] feat(figlens): decode Work.Engine + WorkListItem.Engine from backend --- client/figlens/figlens_test.go | 4 ++++ client/figlens/work.go | 2 ++ 2 files changed, 6 insertions(+) diff --git a/client/figlens/figlens_test.go b/client/figlens/figlens_test.go index ec81794..2812726 100644 --- a/client/figlens/figlens_test.go +++ b/client/figlens/figlens_test.go @@ -91,6 +91,7 @@ func TestGetWorkBySession(t *testing.T) { "cover_url": "https://cover.jpg", "share_token": "tok_xyz", "exporting": 1, "duration": 120, + "engine": "suite", }) })) defer srv.Close() @@ -115,6 +116,9 @@ func TestGetWorkBySession(t *testing.T) { if work.Exporting != 1 { t.Fatalf("exporting = %d", work.Exporting) } + if work.Engine != "suite" { + t.Fatalf("engine = %q, want \"suite\"", work.Engine) + } } func TestExportVideo(t *testing.T) { diff --git a/client/figlens/work.go b/client/figlens/work.go index d9d7fff..d232845 100644 --- a/client/figlens/work.go +++ b/client/figlens/work.go @@ -15,6 +15,7 @@ type Work struct { ShareToken string `json:"share_token"` Exporting int `json:"exporting"` Duration int64 `json:"duration"` + Engine string `json:"engine,omitempty"` } func (c *Client) GetWorkBySession(ctx context.Context, sessionID string) (*Work, error) { @@ -36,6 +37,7 @@ type WorkListItem struct { ShareToken string `json:"share_token"` Exporting int `json:"exporting"` Duration int64 `json:"duration"` + Engine string `json:"engine,omitempty"` Status int `json:"status"` CreatedAt string `json:"created_at"` } From b904937d223ea3728cb6006427cbf435ed547108 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 16:44:03 +0800 Subject: [PATCH 05/13] =?UTF-8?q?feat(snapshot):=20expose=20engine=20field?= =?UTF-8?q?=20(remapped=20suite=E2=86=92pipeline=20for=20users)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/video/snapshot/snapshot.go | 2 ++ internal/video/snapshot/snapshot_test.go | 33 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/internal/video/snapshot/snapshot.go b/internal/video/snapshot/snapshot.go index 53f6353..8053097 100644 --- a/internal/video/snapshot/snapshot.go +++ b/internal/video/snapshot/snapshot.go @@ -22,6 +22,7 @@ type Snapshot struct { SessionID string `json:"session_id"` WorkID int64 `json:"work_id,omitempty"` Title string `json:"title,omitempty"` + Engine string `json:"engine,omitempty"` DurationMs int64 `json:"duration_ms,omitempty"` CoverURL string `json:"cover_url,omitempty"` Preview Preview `json:"preview"` @@ -87,6 +88,7 @@ func Build(in BuildInput) Snapshot { if in.Work != nil { s.WorkID = in.Work.ID s.Title = in.Work.Title + s.Engine = figlens.RemapEngineForDisplay(in.Work.Engine) s.DurationMs = in.Work.Duration s.CoverURL = in.Work.CoverURL s.Preview.Ready = in.Work.ShareToken != "" diff --git a/internal/video/snapshot/snapshot_test.go b/internal/video/snapshot/snapshot_test.go index 7c4444d..693ee90 100644 --- a/internal/video/snapshot/snapshot_test.go +++ b/internal/video/snapshot/snapshot_test.go @@ -167,3 +167,36 @@ func containsCmd(actions []snapshot.Action, substr string) bool { } return false } + +func TestBuild_RemapsEngineSuiteToPipeline(t *testing.T) { + work := &figlens.Work{ + ID: 1, SessionID: "s", ShareToken: "tok", + Engine: "suite", + } + s := snapshot.Build(snapshot.BuildInput{ + TaskID: 42, SessionID: "s", Work: work, ShareBase: "https://x/share", + }) + if s.Engine != "pipeline" { + t.Fatalf("Engine = %q, want \"pipeline\" (remapped from \"suite\")", s.Engine) + } +} + +func TestBuild_EngineAgentPassesThrough(t *testing.T) { + work := &figlens.Work{ID: 1, SessionID: "s", Engine: "agent"} + s := snapshot.Build(snapshot.BuildInput{ + TaskID: 1, SessionID: "s", Work: work, + }) + if s.Engine != "agent" { + t.Fatalf("Engine = %q, want \"agent\"", s.Engine) + } +} + +func TestBuild_EngineEmptyOmitted(t *testing.T) { + work := &figlens.Work{ID: 1, SessionID: "s"} + s := snapshot.Build(snapshot.BuildInput{ + TaskID: 1, SessionID: "s", Work: work, + }) + if s.Engine != "" { + t.Fatalf("Engine = %q, want \"\" (omitempty)", s.Engine) + } +} From 08ffc478f984333f34b3b4641d9721d74e837342 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 16:46:21 +0800 Subject: [PATCH 06/13] feat(figlens): emit node.progress for empty-step_id SSE events (agent engine) --- client/figlens/stream.go | 10 +++++++++ client/figlens/stream_test.go | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/client/figlens/stream.go b/client/figlens/stream.go index f1b4b05..a836ef2 100644 --- a/client/figlens/stream.go +++ b/client/figlens/stream.go @@ -28,6 +28,7 @@ type StreamParams struct { type StreamEvent struct { Type string Code string // set on task.failed when payload carries an envelope code + Status string // set on node.progress: "start" / "success" / "error" Stage string Node string Message string @@ -119,6 +120,15 @@ func (c *Client) StreamChat(ctx context.Context, params StreamParams, onEvent fu if err := json.Unmarshal(d.Log, &log); err != nil { continue } + if log.StepID == "" { + // v=2 agent path: free-form progress, no node graph. + onEvent(StreamEvent{ + Type: "node.progress", + Status: log.Status, + Message: log.Message, + }) + continue + } if !stage.IsKnownNode(log.StepID) { continue } diff --git a/client/figlens/stream_test.go b/client/figlens/stream_test.go index c3c25ae..1f9d5b4 100644 --- a/client/figlens/stream_test.go +++ b/client/figlens/stream_test.go @@ -162,3 +162,44 @@ func TestStreamChat_AgentEngineUsesAgent2Path(t *testing.T) { t.Fatalf("path = %q, want /v1/agent2forVideo/stream", gotPath) } } + +func TestStreamChat_AgentProgressEvents(t *testing.T) { + // v=2 events have empty step_id and a human-readable message. + sseBody := `data: {"code":200,"data":{"type":"process","log":{"step_id":"","status":"start","message":"正在调用知识库..."}}} + +data: {"code":200,"data":{"type":"process","log":{"step_id":"","status":"success","message":"知识库就绪"}}} + +data: {"code":200,"data":{"type":"aim_result","session_id":"s_agent"}} + +data: [DONE] + +` + 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_agent", Engine: figlens.EngineAgent, + }, func(ev figlens.StreamEvent) { + events = append(events, ev) + }) + if err != nil { + t.Fatalf("StreamChat: %v", err) + } + if len(events) != 3 { + t.Fatalf("len(events) = %d, want 3; events=%+v", len(events), events) + } + if events[0].Type != "node.progress" || events[0].Status != "start" || events[0].Message != "正在调用知识库..." { + t.Fatalf("event[0] = %+v", events[0]) + } + if events[1].Type != "node.progress" || events[1].Status != "success" || events[1].Message != "知识库就绪" { + t.Fatalf("event[1] = %+v", events[1]) + } + if events[2].Type != "task.succeeded" { + t.Fatalf("event[2] = %+v", events[2]) + } +} From 7d4b52f2e151d4afe484c6dfe3a27293d29e6b56 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 16:48:31 +0800 Subject: [PATCH 07/13] feat(create): add --engine flag with validation and replica-mode guard --- cmd/create.go | 28 +++++++++++++++++++ cmd/create_test.go | 59 ++++++++++++++++++++++++++++++++++++++++ internal/i18n/strings.go | 6 ++++ 3 files changed, 93 insertions(+) diff --git a/cmd/create.go b/cmd/create.go index f974bcf..98bd283 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -34,6 +34,7 @@ var ( flagCreateMode string flagCreateAspect string flagCreateBGM bool + flagCreateEngine string ) var docIDRe = regexp.MustCompile(`^doc_[a-zA-Z0-9]{8,}$`) @@ -344,6 +345,7 @@ func init() { 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")) + createCmd.Flags().StringVar(&flagCreateEngine, "engine", "", i18n.T("create.flag.engine")) } // uploadFile uploads a local file to vectoria and returns kb_id + doc_id. @@ -469,3 +471,29 @@ func resolveAspect(flag string) (string, error) { } } +// resolveEngine maps the --engine flag to a figlens.Engine value. +// Empty input passes through as EnginePipeline (the zero value) +// so the default invocation is byte-identical to 0.4.2 on the wire. +func resolveEngine(flag string) (figlens.Engine, error) { + switch strings.ToLower(strings.TrimSpace(flag)) { + case "", "pipeline": + return figlens.EnginePipeline, nil + case "agent": + return figlens.EngineAgent, nil + default: + return figlens.EnginePipeline, clerr.Validation(i18n.T("create.err.engine_invalid", flag)) + } +} + +// validateEngineModeCombo rejects combinations that the backend doesn't +// support, so users get a CLI-side error rather than silent fallback. +// --engine agent has no replica branch (verified by go-figlens source +// grep, see spec §Differences point 2). --mode script_lock works on +// both engines. +func validateEngineModeCombo(engine figlens.Engine, videoKind string) error { + if engine == figlens.EngineAgent && videoKind == figlens.VideoKindReplica { + return clerr.Validation(i18n.T("create.err.replica_needs_pipeline")) + } + return nil +} + diff --git a/cmd/create_test.go b/cmd/create_test.go index 73b6bd1..7ed491c 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -3,6 +3,8 @@ package cmd import ( "strings" "testing" + + "github.com/vibeknow/cli/client/figlens" ) func TestResolveVideoKind(t *testing.T) { @@ -75,3 +77,60 @@ func TestResolveVideoKind_ErrorMessageMentionsValues(t *testing.T) { } } +func TestResolveEngine(t *testing.T) { + tests := []struct { + flag string + want figlens.Engine + wantErr bool + }{ + {"", figlens.EnginePipeline, false}, + {"pipeline", figlens.EnginePipeline, false}, + {"PIPELINE", figlens.EnginePipeline, false}, + {"agent", figlens.EngineAgent, false}, + {"Agent", figlens.EngineAgent, false}, + {"suite", figlens.EnginePipeline, true}, + {"v2", figlens.EnginePipeline, true}, + {"bogus", figlens.EnginePipeline, true}, + } + for _, tt := range tests { + t.Run(tt.flag, func(t *testing.T) { + got, err := resolveEngine(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 !tt.wantErr && got != tt.want { + t.Fatalf("resolveEngine(%q) = %v, want %v", tt.flag, got, tt.want) + } + }) + } +} + +func TestEngineAgentRejectsReplica(t *testing.T) { + err := validateEngineModeCombo(figlens.EngineAgent, figlens.VideoKindReplica) + if err == nil { + t.Fatal("expected error: --engine agent + --mode replica should be rejected") + } + if !strings.Contains(err.Error(), "replica") || !strings.Contains(err.Error(), "pipeline") { + t.Fatalf("error message should mention both replica and pipeline, got: %q", err.Error()) + } +} + +func TestEngineAgentAllowsOtherModes(t *testing.T) { + for _, mode := range []string{"", figlens.VideoKindScriptLock} { + if err := validateEngineModeCombo(figlens.EngineAgent, mode); err != nil { + t.Fatalf("--engine agent + mode %q should be allowed: %v", mode, err) + } + } +} + +func TestEnginePipelineAllowsAllModes(t *testing.T) { + for _, mode := range []string{"", figlens.VideoKindReplica, figlens.VideoKindScriptLock} { + if err := validateEngineModeCombo(figlens.EnginePipeline, mode); err != nil { + t.Fatalf("--engine pipeline + mode %q should be allowed: %v", mode, err) + } + } +} + diff --git a/internal/i18n/strings.go b/internal/i18n/strings.go index a1bb9b6..e71d104 100644 --- a/internal/i18n/strings.go +++ b/internal/i18n/strings.go @@ -75,6 +75,9 @@ func init() { "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.flag.engine": "video engine: pipeline (default, v=3) or agent (v=2, frontend-parity)", + "create.err.engine_invalid": "--engine must be one of: pipeline, agent (got %q)", + "create.err.replica_needs_pipeline": "--mode replica is only supported with --engine pipeline (default); agent mode has no replica branch", "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)", @@ -212,6 +215,9 @@ func init() { "create.flag.mode": "视频模式:replica(PPT/PDF 逐页还原)或 script(用文档原文作为讲稿)", "create.flag.aspect": "画幅:horizontal(16:9,默认)或 vertical(9:16)", "create.flag.bgm": "启用背景音乐(默认关闭)", + "create.flag.engine": "视频引擎:pipeline(默认,v=3)或 agent(v=2,与前端选项对齐)", + "create.err.engine_invalid": "--engine 必须是 pipeline 或 agent(当前为 %q)", + "create.err.replica_needs_pipeline": "--mode replica 仅在 --engine pipeline(默认)下可用;agent 模式没有 replica 分支", "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)", From 66ad8b015116f32763c88b213719eb2fd036f955 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 16:50:15 +0800 Subject: [PATCH 08/13] feat(create): wire --engine to InitTask + StreamChat --- cmd/create.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd/create.go b/cmd/create.go index 98bd283..c2ae6ae 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -61,6 +61,13 @@ var createCmd = &cobra.Command{ if err != nil { return err } + engine, err := resolveEngine(flagCreateEngine) + if err != nil { + return err + } + if err := validateEngineModeCombo(engine, videoKind); err != nil { + return err + } ctx := context.Background() @@ -134,7 +141,7 @@ var createCmd = &cobra.Command{ // Step 3: init figlens task. fmt.Fprintln(os.Stderr, i18n.T("create.init_task")) - initParams := figlens.InitTaskParams{VideoKind: videoKind} + initParams := figlens.InitTaskParams{Engine: engine, VideoKind: videoKind} if videoKind == figlens.VideoKindScriptLock { initParams.KnowledgeID = kbID initParams.DocID = docID @@ -182,6 +189,7 @@ var createCmd = &cobra.Command{ BGMEnabled: flagCreateBGM, Aspect: aspect, VideoKind: videoKind, + Engine: engine, }, func(ev figlens.StreamEvent) { switch ev.Type { case "node.started", "node.succeeded", "node.failed": From 099ae7f7de991fe8cf8792688ba56f33ca6c6cef Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 16:51:31 +0800 Subject: [PATCH 09/13] feat(create): render node.progress events with [agent] prefix in text mode --- cmd/create.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cmd/create.go b/cmd/create.go index c2ae6ae..4981a3d 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -207,6 +207,18 @@ var createCmd = &cobra.Command{ fmt.Fprintln(os.Stderr, i18n.T("create.node_failed", ev.Node, ev.Message)) } } + case "node.progress": + if isNDJSONCreate { + _ = output.NewNDJSON(cmd.OutOrStdout()).Event(map[string]any{ + "type": "node.progress", + "status": ev.Status, + "message": ev.Message, + }) + } else { + // Agent-engine free-form progress: no stage/node, just message. + // [agent] prefix mirrors v=3's [] shape for scannability. + fmt.Fprintf(os.Stderr, "[agent] %s\n", ev.Message) + } case "task.succeeded": successSessionID = ev.SessionID if successSessionID == "" { From acacd2fc3f1bde024aa9e57b284924ac2e4437b0 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 16:53:14 +0800 Subject: [PATCH 10/13] test(integration): cover vk create --engine agent wire routing + progress visibility --- tests/integration/create_engine_test.go | 144 ++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/integration/create_engine_test.go diff --git a/tests/integration/create_engine_test.go b/tests/integration/create_engine_test.go new file mode 100644 index 0000000..fade7ab --- /dev/null +++ b/tests/integration/create_engine_test.go @@ -0,0 +1,144 @@ +package integration + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "sync" + "testing" +) + +func TestCreate_EngineAgent_WiresV2AndSurfacesProgress(t *testing.T) { + if testing.Short() { + t.Skip("integration test") + } + + var mu sync.Mutex + bodies := map[string]map[string]any{} + paths := []string{} + + mux := http.NewServeMux() + mux.HandleFunc("/v1/tasks/init", func(w http.ResponseWriter, r *http.Request) { + 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": 99, "session_id": "s_agent_e2e", "work_id": 100, "v": 2}, + }) + }) + mux.HandleFunc("/v1/agent2forVideo/stream", func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + paths = append(paths, r.URL.Path) + mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + flusher, _ := w.(http.Flusher) + for _, e := range []string{ + `data: {"code":200,"data":{"type":"process","log":{"step_id":"","status":"start","message":"正在调用知识库..."}}}`, + `data: {"code":200,"data":{"type":"process","log":{"step_id":"","status":"success","message":"知识库就绪"}}}`, + `data: {"code":200,"data":{"type":"aim_result","session_id":"s_agent_e2e"}}`, + `data: [DONE]`, + } { + fmt.Fprintln(w, e) + fmt.Fprintln(w) + if flusher != nil { + flusher.Flush() + } + } + }) + // v=3 stream must NOT be called when --engine agent is set. + mux.HandleFunc("/v1/agent3forVideo/stream", func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + paths = append(paths, r.URL.Path) + mu.Unlock() + w.WriteHeader(500) // loud failure if mis-routed + }) + mux.HandleFunc("/v1/works/detailBySession", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "data": map[string]any{ + "id": 100, + "session_id": "s_agent_e2e", + "title": "Agent Test", + "html_path": "works/agent/index.html", + "share_token": "tok_agent", + "exporting": 0, + "engine": "agent", + }, + }) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + bin := build(t) + configHome := buildVideoProfile(t, srv.URL) + + cmd := exec.Command(bin, "create", "--engine", "agent", "--from", "doc_abc12345") + var stdout, stderr strings.Builder + 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() + + // 1. Wire body: v=2 on init. + if bodies["init"]["v"] != float64(2) { + t.Fatalf("init body v = %v, want 2 (Engine=agent should produce v=2 wire)", bodies["init"]["v"]) + } + + // 2. Endpoint routing. + hitAgent2 := false + hitAgent3 := false + for _, p := range paths { + if p == "/v1/agent2forVideo/stream" { + hitAgent2 = true + } + if p == "/v1/agent3forVideo/stream" { + hitAgent3 = true + } + } + if hitAgent3 { + t.Fatalf("CLI hit /v1/agent3forVideo/stream when --engine agent was set; should have gone to /agent2/") + } + if !hitAgent2 { + t.Fatalf("CLI never hit /v1/agent2forVideo/stream; routing broken (paths=%v)", paths) + } + + // 3. Progress visibility: stderr contains [agent] prefixed lines. + out := stdout.String() + stderr.String() + if !strings.Contains(out, "[agent] 正在调用知识库") { + t.Fatalf("missing [agent] progress prefix for first message:\n%s", out) + } + if !strings.Contains(out, "知识库就绪") { + t.Fatalf("missing second progress message:\n%s", out) + } + + // 4. Snapshot engine field is remapped/passed-through. + // stdout in text mode includes a snapshot rendering; just verify engine appears somewhere. + // In JSON mode it'd be {"engine":"agent"}, but text mode rendering may vary. + // Skip strict format check; just confirm the word "agent" appears in output (already trivially true via [agent] prefix). +} From 3c25f6a39c3982dea91cdf18ce0ea603f3727a65 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 16:56:23 +0800 Subject: [PATCH 11/13] docs: document --engine for 0.5.0 --- AGENTS.md | 18 ++++++++++++++++++ CHANGELOG.md | 23 +++++++++++++++++++++++ README.md | 7 +++++++ README.zh.md | 7 +++++++ 4 files changed, 55 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 6b98a17..5930c98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,3 +63,21 @@ 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. + +## Engine selection + +`vk create --engine ` picks which go-figlens engine generates the +video. Two engines exist and are both actively maintained on the +backend: + +- **`--engine pipeline`** (default) — v=3, graph-based pipeline. Has + rich SSE node events (`[parse] prepare started`, `[outline] + text_speech done`, ...). All `--mode` values supported. +- **`--engine agent`** — v=2, agent-driven flow. SSE events are + free-form progress messages without a node graph; CLI shows them as + `[agent] ` in text mode or `node.progress` in NDJSON. + Supports `--mode script` (script_lock) but **not** `--mode replica` + — the CLI rejects that combination with exit 2. + +The `engine` field in JSON snapshot output reflects which engine ran +(`"pipeline"` / `"agent"`), letting agents verify routing. diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ca282..9acc469 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.5.0 — 2026-05-DD + +### New + +- `vk create --engine pipeline|agent` selects which figlens engine to + invoke. Default `pipeline` keeps 0.4.2 behavior bit-identical; + `--engine agent` routes to the v=2 agent engine + (`/agent2forVideo/stream`, mirrors the web frontend's engine toggle). +- The v=2 agent engine emits free-form progress events without a node + graph. The CLI now surfaces these as `node.progress` events in + NDJSON output and `[agent] ` lines in text output, instead + of silently filtering them out. +- `vk video status` / `vk create` JSON snapshot now includes an + `engine` field (`"pipeline"` or `"agent"`) so agents can confirm + which engine actually ran. The DB enum `"suite"` is remapped to + `"pipeline"` for output to match the `--engine` flag's vocabulary. + +### Changed + +- `--engine agent --mode replica` is rejected at the CLI boundary with + exit 2 and a clear message: replica is a v=3-only pipeline feature + with no agent-engine analog (verified against go-figlens source). + ## 0.4.2 — 2026-05-14 ### New diff --git a/README.md b/README.md index 1e12b39..33a1f28 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,13 @@ vk create --from talk.docx --mode script # narrate the doc verbatim vk create --from --aspect vertical --bgm ``` +### Pick a generation engine (optional) + +```bash +vk create --from --engine agent # v=2 agent engine (frontend toggle parity) +vk create --from --engine pipeline # v=3 pipeline (default) +``` + ### Voice Templates ```bash diff --git a/README.zh.md b/README.zh.md index aedbe64..aae36c7 100644 --- a/README.zh.md +++ b/README.zh.md @@ -211,6 +211,13 @@ vk create --from talk.docx --mode script # 讲稿模式(用文档原文做 vk create --from --aspect vertical --bgm ``` +### 选择生成引擎(可选) + +```bash +vk create --from --engine agent # v=2 agent 引擎(与前端选项对齐) +vk create --from --engine pipeline # v=3 pipeline(默认) +``` + ### 音色模板 ```bash From dd07d259aac3793dd31f270123b7f5541def02a9 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 16:57:53 +0800 Subject: [PATCH 12/13] chore: bump version to 0.5.0 --- 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 9acc469..2d1b403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.5.0 — 2026-05-DD +## 0.5.0 — 2026-05-14 ### New diff --git a/package.json b/package.json index d1430e1..4b9624b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vibeknow-cli", - "version": "0.4.2", + "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 45d207c..587f706 100644 --- a/skills/vibeknow-core/SKILL.md +++ b/skills/vibeknow-core/SKILL.md @@ -1,6 +1,6 @@ --- name: vibeknow-core -version: 0.4.2 +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 9b77e13..5b1451d 100644 --- a/skills/vibeknow-create/SKILL.md +++ b/skills/vibeknow-create/SKILL.md @@ -1,6 +1,6 @@ --- name: vibeknow-create -version: 0.4.2 +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 de3b358..b8ee7e3 100644 --- a/skills/vibeknow-doc/SKILL.md +++ b/skills/vibeknow-doc/SKILL.md @@ -1,6 +1,6 @@ --- name: vibeknow-doc -version: 0.4.2 +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 da212f6163394fa381db38c7e5d262941f638700 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 17:12:58 +0800 Subject: [PATCH 13/13] =?UTF-8?q?refactor:=20review=20pass=20=E2=80=94=20s?= =?UTF-8?q?witches,=20list=20JSON=20engine,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply findings from /simplify review: - client/figlens/engine.go: replace if-chains in Wire() and StreamPath() with switches (clearer when a third engine is added); remove the no-op `case "agent"` identity arm in RemapEngineForDisplay — the default arm produces identical output. - cmd/video/list.go: emit `engine` field in `--output json` for each work, remapped via RemapEngineForDisplay. Closes a gap where agents using `vk video list --output json` got no engine info even though the backend returns it. - cmd/create.go: trim two over-narrated comments (validateEngineModeCombo doc + node.progress inline rationale) — keep WHY, drop WHAT. - internal/video/snapshot/snapshot.go: move Engine field above Title in the struct/JSON ordering so provenance/identity fields cluster before display content. - tests/integration/create_engine_test.go: replace the vacuous "skip strict format check" comment block with a pointer to the unit-level engine remap coverage in snapshot_test.go (RenderText intentionally omits engine from human output, so the integration test's text-mode path can't usefully assert on it). No behavior changes for end users; JSON shapes for `vk video list` gain an additive `engine` field (omitempty), and the snapshot field-order is purely cosmetic. --- client/figlens/engine.go | 21 +++++++++++---------- cmd/create.go | 9 ++------- cmd/video/list.go | 4 ++++ internal/video/snapshot/snapshot.go | 2 +- tests/integration/create_engine_test.go | 8 ++++---- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/client/figlens/engine.go b/client/figlens/engine.go index 560a861..1c89e77 100644 --- a/client/figlens/engine.go +++ b/client/figlens/engine.go @@ -14,30 +14,31 @@ const ( // 3 = pipeline (PipelineForVideo handler, WorkEngine="suite" in DB). // 2 = agent (AgentOnlyForVideo handler, WorkEngine="agent" in DB). func (e Engine) Wire() int { - if e == EngineAgent { + switch e { + case EngineAgent: return 2 + default: + return 3 } - return 3 } // StreamPath returns the SSE endpoint path for this engine. func (e Engine) StreamPath() string { - if e == EngineAgent { + switch e { + case EngineAgent: return "/v1/agent2forVideo/stream" + default: + return "/v1/agent3forVideo/stream" } - return "/v1/agent3forVideo/stream" } // RemapEngineForDisplay translates the backend's Work.Engine DB enum // value to the CLI's user-facing vocabulary used by --engine. -// Unknown values pass through unchanged so a future backend engine -// doesn't disappear from snapshot output. +// Unknown values (including "agent" which is already user-facing) +// pass through unchanged. func RemapEngineForDisplay(dbEnum string) string { - switch dbEnum { - case "suite": + if dbEnum == "suite" { return "pipeline" - case "agent": - return "agent" } return dbEnum } diff --git a/cmd/create.go b/cmd/create.go index 4981a3d..340a46c 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -215,8 +215,7 @@ var createCmd = &cobra.Command{ "message": ev.Message, }) } else { - // Agent-engine free-form progress: no stage/node, just message. - // [agent] prefix mirrors v=3's [] shape for scannability. + // [agent] prefix keeps output scannable alongside v=3's [] lines. fmt.Fprintf(os.Stderr, "[agent] %s\n", ev.Message) } case "task.succeeded": @@ -505,11 +504,7 @@ func resolveEngine(flag string) (figlens.Engine, error) { } } -// validateEngineModeCombo rejects combinations that the backend doesn't -// support, so users get a CLI-side error rather than silent fallback. -// --engine agent has no replica branch (verified by go-figlens source -// grep, see spec §Differences point 2). --mode script_lock works on -// both engines. +// validateEngineModeCombo rejects engine+mode combinations the backend doesn't support. func validateEngineModeCombo(engine figlens.Engine, videoKind string) error { if engine == figlens.EngineAgent && videoKind == figlens.VideoKindReplica { return clerr.Validation(i18n.T("create.err.replica_needs_pipeline")) diff --git a/cmd/video/list.go b/cmd/video/list.go index ef4c406..fae0431 100644 --- a/cmd/video/list.go +++ b/cmd/video/list.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" + "github.com/vibeknow/cli/client/figlens" "github.com/vibeknow/cli/internal/cmdutil" "github.com/vibeknow/cli/internal/i18n" "github.com/vibeknow/cli/internal/output" @@ -52,6 +53,9 @@ var listCmd = &cobra.Command{ if w.VideoPath != "" { item["video_path"] = w.VideoPath } + if w.Engine != "" { + item["engine"] = figlens.RemapEngineForDisplay(w.Engine) + } items = append(items, item) } return output.NewJSON(cmd.OutOrStdout()).Object(map[string]any{ diff --git a/internal/video/snapshot/snapshot.go b/internal/video/snapshot/snapshot.go index 8053097..1f44b62 100644 --- a/internal/video/snapshot/snapshot.go +++ b/internal/video/snapshot/snapshot.go @@ -21,8 +21,8 @@ type Snapshot struct { TaskID int64 `json:"task_id"` SessionID string `json:"session_id"` WorkID int64 `json:"work_id,omitempty"` - Title string `json:"title,omitempty"` Engine string `json:"engine,omitempty"` + Title string `json:"title,omitempty"` DurationMs int64 `json:"duration_ms,omitempty"` CoverURL string `json:"cover_url,omitempty"` Preview Preview `json:"preview"` diff --git a/tests/integration/create_engine_test.go b/tests/integration/create_engine_test.go index fade7ab..68e2bc9 100644 --- a/tests/integration/create_engine_test.go +++ b/tests/integration/create_engine_test.go @@ -137,8 +137,8 @@ func TestCreate_EngineAgent_WiresV2AndSurfacesProgress(t *testing.T) { t.Fatalf("missing second progress message:\n%s", out) } - // 4. Snapshot engine field is remapped/passed-through. - // stdout in text mode includes a snapshot rendering; just verify engine appears somewhere. - // In JSON mode it'd be {"engine":"agent"}, but text mode rendering may vary. - // Skip strict format check; just confirm the word "agent" appears in output (already trivially true via [agent] prefix). + // Engine remap is covered at the snapshot layer + // (internal/video/snapshot/snapshot_test.go: TestBuild_*Engine*) — no + // duplicate integration assertion needed; text-mode RenderText + // intentionally omits the engine field from human output. }