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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,21 @@ Exit-code summary for new modes:
- `2` — `--mode <bad>`, `--aspect <bad>`, 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 <name>` 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] <message>` 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.
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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] <message>` 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
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,13 @@ vk create --from talk.docx --mode script # narrate the doc verbatim
vk create --from <src> --aspect vertical --bgm
```

### Pick a generation engine (optional)

```bash
vk create --from <src> --engine agent # v=2 agent engine (frontend toggle parity)
vk create --from <src> --engine pipeline # v=3 pipeline (default)
```

### Voice Templates

```bash
Expand Down
7 changes: 7 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,13 @@ vk create --from talk.docx --mode script # 讲稿模式(用文档原文做
vk create --from <src> --aspect vertical --bgm
```

### 选择生成引擎(可选)

```bash
vk create --from <src> --engine agent # v=2 agent 引擎(与前端选项对齐)
vk create --from <src> --engine pipeline # v=3 pipeline(默认)
```

### 音色模板

```bash
Expand Down
44 changes: 44 additions & 0 deletions client/figlens/engine.go
Original file line number Diff line number Diff line change
@@ -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
}
50 changes: 50 additions & 0 deletions client/figlens/engine_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
26 changes: 26 additions & 0 deletions client/figlens/figlens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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) {
Expand Down Expand Up @@ -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"])
}
}
13 changes: 12 additions & 1 deletion client/figlens/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down
62 changes: 62 additions & 0 deletions client/figlens/stream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}
3 changes: 2 additions & 1 deletion client/figlens/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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)
}
Expand Down
2 changes: 2 additions & 0 deletions client/figlens/work.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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"`
}
Expand Down
Loading
Loading