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..2d1b403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.5.0 — 2026-05-14 + +### 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 diff --git a/client/figlens/engine.go b/client/figlens/engine.go new file mode 100644 index 0000000..1c89e77 --- /dev/null +++ b/client/figlens/engine.go @@ -0,0 +1,44 @@ +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 { + switch e { + case EngineAgent: + return 2 + default: + return 3 + } +} + +// StreamPath returns the SSE endpoint path for this engine. +func (e Engine) StreamPath() string { + switch e { + case EngineAgent: + return "/v1/agent2forVideo/stream" + default: + 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 (including "agent" which is already user-facing) +// pass through unchanged. +func RemapEngineForDisplay(dbEnum string) string { + if dbEnum == "suite" { + return "pipeline" + } + 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) + } + }) + } +} diff --git a/client/figlens/figlens_test.go b/client/figlens/figlens_test.go index 7e330eb..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) { @@ -217,3 +221,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/stream.go b/client/figlens/stream.go index 1d1e7c9..a836ef2 100644 --- a/client/figlens/stream.go +++ b/client/figlens/stream.go @@ -22,11 +22,13 @@ 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 { 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 @@ -61,7 +63,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) } @@ -118,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 9777f03..1f9d5b4 100644 --- a/client/figlens/stream_test.go +++ b/client/figlens/stream_test.go @@ -141,3 +141,65 @@ 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) + } +} + +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]) + } +} 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) } 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"` } diff --git a/cmd/create.go b/cmd/create.go index f974bcf..340a46c 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,}$`) @@ -60,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() @@ -133,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 @@ -181,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": @@ -198,6 +207,17 @@ 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] prefix keeps output scannable alongside v=3's [] lines. + fmt.Fprintf(os.Stderr, "[agent] %s\n", ev.Message) + } case "task.succeeded": successSessionID = ev.SessionID if successSessionID == "" { @@ -344,6 +364,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 +490,25 @@ 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 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")) + } + 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/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/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)", diff --git a/internal/video/snapshot/snapshot.go b/internal/video/snapshot/snapshot.go index 53f6353..1f44b62 100644 --- a/internal/video/snapshot/snapshot.go +++ b/internal/video/snapshot/snapshot.go @@ -21,6 +21,7 @@ type Snapshot struct { TaskID int64 `json:"task_id"` SessionID string `json:"session_id"` WorkID int64 `json:"work_id,omitempty"` + Engine string `json:"engine,omitempty"` Title string `json:"title,omitempty"` DurationMs int64 `json:"duration_ms,omitempty"` CoverURL string `json:"cover_url,omitempty"` @@ -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) + } +} 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: diff --git a/tests/integration/create_engine_test.go b/tests/integration/create_engine_test.go new file mode 100644 index 0000000..68e2bc9 --- /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) + } + + // 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. +}