diff --git a/internal/cli/paywalls_ai.go b/internal/cli/paywalls_ai.go index 1919e2d9..6e001417 100644 --- a/internal/cli/paywalls_ai.go +++ b/internal/cli/paywalls_ai.go @@ -249,6 +249,7 @@ screenshots via --image, audience via --context.`, } func newPaywallsEditCmd() *cobra.Command { + var stepID string opts := paywallAIOptions{ prompt: os.Getenv("RC_PAYWALL_PROMPT"), baseURL: devEnvOrDefault("RC_PAYWALL_AI_BASE_URL", paywallai.DefaultBaseURL), @@ -310,6 +311,15 @@ Using it well: Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { rt := RuntimeFrom(cmd.Context()) + if stepID != "" { + if opts.sessionPath != "" { + return fmt.Errorf("--step-id keeps its own per-step session; drop --session") + } + if argAt(args, 0) == "" { + return WithHint(fmt.Errorf("--step-id needs the flow id"), "rc paywalls edit --step-id ; find ids with: rc flows list --type paywall") + } + return runFlowEdit(cmd.Context(), rt, opts, args[0], stepID) + } var session *paywallAISession var err error switch { @@ -350,6 +360,8 @@ Using it well: }, } addPaywallAIFlags(cmd, &opts) + cmd.Flags().StringVar(&stepID, "step-id", "", "edit one screen step of a flow (the argument is then the flow id)") + _ = cmd.Flags().MarkHidden("step-id") return cmd } @@ -882,7 +894,7 @@ func loadPaywallAISession(rt *Runtime, path string) (*paywallAISession, error) { return &session, nil } -func savePaywallAISession(path string, session *paywallAISession) error { +func savePaywallAISession(path string, session any) error { payload, err := json.MarshalIndent(session, "", " ") if err != nil { return err diff --git a/internal/cli/paywalls_ai_flow.go b/internal/cli/paywalls_ai_flow.go new file mode 100644 index 00000000..9c2f8ec2 --- /dev/null +++ b/internal/cli/paywalls_ai_flow.go @@ -0,0 +1,272 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/revenuecat/cli/internal/api" + "github.com/revenuecat/cli/internal/paywallai" +) + +// paywallAIFlowSession is the conversation memory for one Flow step. Astra +// reads the step's content from the Flow itself, so no paywall is stored. +type paywallAIFlowSession struct { + SessionID string `json:"session_id,omitempty"` + TraceID string `json:"trace_id,omitempty"` + UIConfig json.RawMessage `json:"ui_config"` + ProductVariables map[string]string `json:"product_variables"` + SessionItems json.RawMessage `json:"__unstable_session_items"` + AppContext json.RawMessage `json:"app_context,omitempty"` +} + +func defaultFlowSessionPath(projectID, flowID, stepID string) (string, error) { + base, err := defaultPaywallSessionPath(projectID, flowID) + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(base), "session."+stepID+paywallSessionSuffix), nil +} + +func loadFlowSession(path string) (*paywallAIFlowSession, error) { + payload, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return &paywallAIFlowSession{ + UIConfig: json.RawMessage(minimalUIConfig), + ProductVariables: map[string]string{}, + SessionItems: json.RawMessage(`{}`), + }, nil + } + if err != nil { + return nil, fmt.Errorf("reading session file: %w", err) + } + var session paywallAIFlowSession + if err := json.Unmarshal(payload, &session); err != nil { + return nil, fmt.Errorf("parsing session file %s: %w", path, err) + } + return &session, nil +} + +func applyFlowEvent(session *paywallAIFlowSession, event *paywallai.Event) { + if event.SessionID != "" { + session.SessionID = event.SessionID + } + if event.TraceID != "" { + session.TraceID = event.TraceID + } + if len(event.SessionItems) > 0 { + session.SessionItems = event.SessionItems + } + if len(event.AppContext) > 0 { + session.AppContext = event.AppContext + } +} + +// runFlowEdit drives one Astra run against a screen step of a Flow and saves +// the edited steps onto the Flow draft itself. +func runFlowEdit(ctx context.Context, rt *Runtime, opts paywallAIOptions, flowID, stepID string) error { + client, err := rt.API() + if err != nil { + return err + } + projectID, err := requireProject(rt) + if err != nil { + return err + } + draft, err := client.Flows.GetDraft(ctx, projectID, flowID) + if err != nil { + if api.IsNotFound(err) { + return WithHint(err, "Pass the flow id: rc flows list --type paywall") + } + return err + } + if err := requireScreenStep(draft, stepID); err != nil { + return err + } + if err := requirePaywallAIPrompt(rt, &opts.prompt, "What should change?"); err != nil { + return err + } + if opts.sessionPath, err = defaultFlowSessionPath(projectID, flowID, stepID); err != nil { + return err + } + session, err := loadFlowSession(opts.sessionPath) + if err != nil { + return err + } + + astra, err := paywallAIClient(rt, opts.baseURL) + if err != nil { + return err + } + attachments, textRefs, err := loadPaywallAIAttachments(opts.images, opts.attachments) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(ctx, opts.timeout) + defer cancel() + + rt.Out.Info("Designing with the Paywalls AI Editor — this can take a few minutes…") + stream, err := astra.StreamV3(ctx, paywallai.EditorRequestV3{ + ProjectID: projectID, + SessionID: session.SessionID, + UIConfig: session.UIConfig, + ProductVariables: session.ProductVariables, + Message: withPaywallContext(opts.prompt, opts.context) + textRefs, + InputAttachments: attachments, + SessionItems: session.SessionItems, + AppContext: session.AppContext, + WorkflowContext: paywallai.WorkflowContext{ + Version: 3, + WorkflowID: flowID, + FocusedStepID: stepID, + WorkflowRevision: draft.Revision, + }, + IncludeResultScreenshots: true, + }) + if err != nil { + return err + } + defer stream.Close() + + reportedActivity := 0 + for { + event, err := stream.Next() + if err != nil { + return WithHint(fmt.Errorf("the Paywall AI editor stream ended before the run finished: %w", err), "Nothing was saved. Re-run the command to try again.") + } + switch event.Type { + case paywallai.EventRunStarted, paywallai.EventTurnSnapshot: + applyFlowEvent(session, event) + _ = savePaywallAISession(opts.sessionPath, session) + reportedActivity = reportPaywallAIActivity(rt, event.Activity, reportedActivity) + case paywallai.EventRunFailed: + // Nothing is saved on a failed run, even if it carries partial edits. + if event.SessionID != "" { + session.SessionID = event.SessionID + _ = savePaywallAISession(opts.sessionPath, session) + } + return WithHint(fmt.Errorf("paywall AI editor run failed (%s): %s", event.Error.Code, event.Error.Message), "Nothing was saved. Re-run the command to try again.") + case paywallai.EventRunCompleted: + reportPaywallAIActivity(rt, event.Activity, reportedActivity) + applyFlowEvent(session, event) + if err := savePaywallAISession(opts.sessionPath, session); err != nil { + return err + } + return finishFlowEdit(ctx, rt, opts, client, projectID, draft, stepID, session, event) + } + } +} + +func finishFlowEdit(ctx context.Context, rt *Runtime, opts paywallAIOptions, client *api.Client, projectID string, draft *api.FlowDraft, stepID string, session *paywallAIFlowSession, event *paywallai.Event) error { + screenshots := savePaywallScreenshots(rt, opts.sessionPath, event.ResultScreenshots) + saved, err := saveEditedSteps(ctx, client, projectID, draft, event.EditedPaywallsByStepID) + switch { + case err == nil && len(saved) == 0: + rt.Out.Info("The editor made no changes to the flow.") + case err == nil: + rt.Out.Success(fmt.Sprintf("Design saved to flow draft %s (%s)", draft.ID, strings.Join(saved, ", "))) + if path, ok := screenshots["light"]; ok { + rt.Out.Field("Preview", path) + } + if path, ok := screenshots["dark"]; ok { + rt.Out.Field("Preview (dark)", path) + } + rt.Out.Field("Keep designing", fmt.Sprintf("rc paywalls edit %s --step-id %s", draft.ID, stepID)) + default: + var apiErr *api.APIError + if errors.As(err, &apiErr) && apiErr.Status == 409 { + // The edits were made against a stale revision, so they are never replayed automatically. + rt.Out.Warn("Nothing was saved: the flow changed during the run (dashboard or another client).") + rt.Out.Hint(fmt.Sprintf("Re-run against the current draft: rc paywalls edit %s --step-id %s", draft.ID, stepID)) + } else { + rt.Out.Warn("Could not save the design to RevenueCat: " + err.Error()) + rt.Out.Hint("Nothing was saved. Re-run the command to try again.") + } + } + if rt.Out.IsJSON() { + return rt.Out.Render(map[string]any{ + "flow_id": draft.ID, + "step_id": stepID, + "saved_steps": saved, + "saved_to_draft": len(saved) > 0, + "session_id": session.SessionID, + "trace_id": session.TraceID, + "session_file": opts.sessionPath, + "screenshot_paths": screenshots, + "activity": event.Activity, + }) + } + return nil +} + +// saveEditedSteps writes every step the run edited back in one draft update, +// guarded by the revision the run started from. +func saveEditedSteps(ctx context.Context, client *api.Client, projectID string, draft *api.FlowDraft, edited map[string]paywallai.PaywallData) ([]string, error) { + if len(edited) == 0 { + return nil, nil + } + updated := map[string]json.RawMessage{} + for stepID, paywall := range edited { + if _, ok := draft.Steps[stepID]; !ok { + return nil, fmt.Errorf("the editor returned step %q, which is not in flow %s", stepID, draft.ID) + } + patch, err := editedStepPatch(paywall) + if err != nil { + return nil, err + } + updated[stepID] = patch + } + if _, err := client.Flows.UpdateDraft(ctx, projectID, draft.ID, api.UpdateFlowDraft{ + Revision: draft.Revision, + UpdatedSteps: updated, + }); err != nil { + return nil, err + } + ids := make([]string, 0, len(updated)) + for id := range updated { + ids = append(ids, id) + } + sort.Strings(ids) + return ids, nil +} + +// editedStepPatch is the updated_steps entry for an edited screen: only the +// editable paywall fields, which the server merges onto the stored step. +func editedStepPatch(paywall paywallai.PaywallData) (json.RawMessage, error) { + pw := map[string]any{ + "default_locale": paywall.DefaultLocale, + "components_config": paywall.ComponentsConfig, + "components_localizations": paywall.ComponentsLocalizations, + } + if raw := presentJSON(paywall.StateDeclarations); raw != nil { + pw["state_declarations"] = raw + } + if paywall.AutomaticallyScaleFontSize != nil { + pw["automatically_scale_font_size"] = *paywall.AutomaticallyScaleFontSize + } + return json.Marshal(map[string]any{"paywall": pw}) +} + +func requireScreenStep(draft *api.FlowDraft, stepID string) error { + raw, ok := draft.Steps[stepID] + if !ok { + return WithHint(fmt.Errorf("flow %s has no step %q", draft.ID, stepID), "List the flow's steps with: rc flows show "+draft.ID) + } + var step struct { + Type string `json:"type"` + } + if err := json.Unmarshal(raw, &step); err != nil { + return fmt.Errorf("decoding flow step %q: %w", stepID, err) + } + if step.Type != "screen" { + return fmt.Errorf("step %q is a %q step; only screens can be edited", stepID, step.Type) + } + return nil +} diff --git a/internal/cli/paywalls_ai_flow_test.go b/internal/cli/paywalls_ai_flow_test.go new file mode 100644 index 00000000..516fb270 --- /dev/null +++ b/internal/cli/paywalls_ai_flow_test.go @@ -0,0 +1,172 @@ +package cli_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +const flowDraftJSON = `{"id":"wf_1","object":"flow_draft","revision":4,"type":"paywall","steps":{` + + `"screen":{"type":"screen","name":"Main","paywall":{"offering_id":"ofrng1","components_config":{"old":true},"components_localizations":{},"haptics_enabled":false}},` + + `"other":{"type":"screen","paywall":{"components_config":{"keep":true}}},` + + `"auth":{"type":"auth"}}}` + +const ( + streamStarted = "data: {\"type\":\"run.started\",\"session_id\":\"sess1\"}\n\n" + streamEdited = "data: {\"type\":\"run.completed\",\"session_id\":\"sess1\",\"edited_paywalls_by_step_id\":{\"screen\":{\"default_locale\":\"en_US\",\"offering_id\":null,\"components_config\":{\"new\":true},\"components_localizations\":{\"en_US\":{}}}}}\n\n" + streamEditedFull = "data: {\"type\":\"run.completed\",\"session_id\":\"sess1\",\"edited_paywalls_by_step_id\":{" + + "\"screen\":{\"default_locale\":\"en_US\",\"components_config\":{\"a\":1},\"components_localizations\":{},\"state_declarations\":{\"x\":1},\"automatically_scale_font_size\":false}," + + "\"other\":{\"default_locale\":\"fr_FR\",\"state_declarations\":null,\"components_config\":{\"b\":2},\"components_localizations\":{}}}}\n\n" + streamNoEdits = "data: {\"type\":\"run.completed\",\"session_id\":\"sess1\",\"edited_paywalls_by_step_id\":{}}\n\n" + streamFailed = "data: {\"type\":\"run.failed\",\"session_id\":\"sess1\",\"error\":{\"code\":\"internal\",\"message\":\"boom\"},\"edited_paywalls_by_step_id\":{\"screen\":{\"default_locale\":\"en_US\",\"components_config\":{\"partial\":true},\"components_localizations\":{}}}}\n\n" +) + +func TestPaywallsEditStepID(t *testing.T) { + tests := []struct { + name string + args []string + stream string + saveStatus int + wantErr string + wantStream bool + wantPatches int + wantOut string + wantPatch string + }{ + { + name: "saves the edited step against the draft revision", + args: []string{"wf_1", "--step-id", "screen"}, + stream: streamStarted + streamEdited, + saveStatus: http.StatusOK, + wantStream: true, + wantPatches: 1, + wantOut: "saved_to_draft", + wantPatch: `{"revision":4,"updated_steps":{"screen":{"paywall":{"default_locale":"en_US","components_config":{"new":true},"components_localizations":{"en_US":{}}}}}}`, + }, + { + name: "every edited step sends only its paywall", + args: []string{"wf_1", "--step-id", "screen"}, + stream: streamStarted + streamEditedFull, + saveStatus: http.StatusOK, + wantStream: true, + wantPatches: 1, + wantOut: "saved_to_draft", + wantPatch: `{"revision":4,"updated_steps":{` + + `"screen":{"paywall":{"default_locale":"en_US","components_config":{"a":1},"components_localizations":{},"state_declarations":{"x":1},"automatically_scale_font_size":false}},` + + `"other":{"paywall":{"default_locale":"fr_FR","components_config":{"b":2},"components_localizations":{}}}}}`, + }, + { + name: "no edits means no save", + args: []string{"wf_1", "--step-id", "screen"}, + stream: streamStarted + streamNoEdits, + wantStream: true, + wantPatches: 0, + wantOut: `"saved_to_draft": false`, + }, + { + name: "failed run saves nothing", + args: []string{"wf_1", "--step-id", "screen"}, + stream: streamStarted + streamFailed, + wantStream: true, + wantErr: "boom", + wantPatches: 0, + }, + { + name: "revision conflict is reported and not retried", + args: []string{"wf_1", "--step-id", "screen"}, + stream: streamStarted + streamEdited, + saveStatus: http.StatusConflict, + wantStream: true, + wantPatches: 1, + wantOut: `"saved_to_draft": false`, + }, + { + name: "unknown step", + args: []string{"wf_1", "--step-id", "nope"}, + wantErr: `no step "nope"`, + }, + { + name: "non-screen step", + args: []string{"wf_1", "--step-id", "auth"}, + wantErr: "only screens can be edited", + }, + { + name: "session file is rejected with a step", + args: []string{"wf_1", "--step-id", "screen", "--session", "x.paywall.json"}, + wantErr: "drop --session", + }, + { + name: "flow id is required", + args: []string{"--step-id", "screen"}, + wantErr: "needs the flow id", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var streamed bool + var patchBody []byte + var patches int + var streamBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/projects/proj/flows/wf_1/draft": + _, _ = io.WriteString(w, flowDraftJSON) + case r.Method == http.MethodPost && r.URL.Path == "/editor/v3/stream": + streamed = true + _ = json.NewDecoder(r.Body).Decode(&streamBody) + _, _ = io.WriteString(w, tt.stream) + case r.Method == http.MethodPatch && r.URL.Path == "/projects/proj/flows/wf_1/draft": + patchBody, _ = io.ReadAll(r.Body) + patches++ + w.WriteHeader(tt.saveStatus) + _, _ = io.WriteString(w, `{"id":"wf_1","revision":5}`) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + + args := append([]string{"paywalls", "edit"}, tt.args...) + args = append(args, "--prompt", "make it blue", "--base-url", server.URL, "--json", "--no-input") + out, _, err := runProjectSetupCommand(t, server.URL, args...) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want %q", err, tt.wantErr) + } + } else if err != nil { + t.Fatal(err) + } + if streamed != tt.wantStream { + t.Fatalf("streamed = %v, want %v", streamed, tt.wantStream) + } + if patches != tt.wantPatches { + t.Fatalf("patches = %d, want %d", patches, tt.wantPatches) + } + if tt.wantOut != "" && !strings.Contains(out, tt.wantOut) { + t.Fatalf("output missing %q: %s", tt.wantOut, out) + } + if tt.wantStream { + if ctx, _ := streamBody["workflow_context"].(map[string]any); ctx["workflow_revision"] != float64(4) { + t.Fatalf("workflow_context = %v", streamBody["workflow_context"]) + } + } + if tt.wantPatch != "" { + var want, got any + if err := json.Unmarshal([]byte(tt.wantPatch), &want); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(patchBody, &got); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("PATCH body = %s\nwant %s", patchBody, tt.wantPatch) + } + } + }) + } +} diff --git a/internal/paywallai/paywallai.go b/internal/paywallai/paywallai.go index 5a515b0b..d80e9427 100644 --- a/internal/paywallai/paywallai.go +++ b/internal/paywallai/paywallai.go @@ -69,6 +69,30 @@ type EditorRequest struct { Source string `json:"source"` } +// WorkflowContext scopes a v3 run to one step of a Flow. +type WorkflowContext struct { + Version int `json:"version"` + WorkflowID string `json:"workflow_id"` + FocusedStepID string `json:"focused_step_id"` + WorkflowRevision int `json:"workflow_revision"` +} + +// EditorRequestV3 is the POST /editor/v3/stream body. Astra reads the Flow +// itself, so no paywall is sent. +type EditorRequestV3 struct { + ProjectID string `json:"project_id"` + SessionID string `json:"session_id,omitempty"` + UIConfig json.RawMessage `json:"ui_config"` + ProductVariables map[string]string `json:"product_variables"` + Message string `json:"message"` + InputAttachments []InputAttachment `json:"input_attachments,omitempty"` + SessionItems json.RawMessage `json:"__unstable_session_items"` + AppContext json.RawMessage `json:"app_context,omitempty"` + WorkflowContext WorkflowContext `json:"workflow_context"` + IncludeResultScreenshots bool `json:"include_result_screenshots,omitempty"` + Source string `json:"source"` +} + type ToolActivity struct { ID string `json:"id"` Type string `json:"type"` // "tool" | "assistant_message" @@ -106,6 +130,8 @@ type Event struct { SessionItems json.RawMessage `json:"__unstable_session_items,omitempty"` AppContext json.RawMessage `json:"app_context,omitempty"` ResultScreenshots []ResultScreenshot `json:"result_screenshots,omitempty"` + // v3 only: every step the run edited, keyed by step id. + EditedPaywallsByStepID map[string]PaywallData `json:"edited_paywalls_by_step_id,omitempty"` } func (e *Event) Terminal() bool { @@ -214,7 +240,17 @@ func errorMessage(body []byte) string { // Stream starts an editor run and returns the live event stream. func (c *Client) Stream(ctx context.Context, request EditorRequest) (*Stream, error) { request.Source = editorSource - req, err := c.newRequest(ctx, "/editor/v1/stream", request) + return c.openStream(ctx, "/editor/v1/stream", request) +} + +// StreamV3 starts a Flow-step editor run and returns the live event stream. +func (c *Client) StreamV3(ctx context.Context, request EditorRequestV3) (*Stream, error) { + request.Source = editorSource + return c.openStream(ctx, "/editor/v3/stream", request) +} + +func (c *Client) openStream(ctx context.Context, path string, body any) (*Stream, error) { + req, err := c.newRequest(ctx, path, body) if err != nil { return nil, err } diff --git a/internal/paywallai/paywallai_test.go b/internal/paywallai/paywallai_test.go index c6d6d3c0..100b839d 100644 --- a/internal/paywallai/paywallai_test.go +++ b/internal/paywallai/paywallai_test.go @@ -148,3 +148,45 @@ func TestFeedbackAndRewind(t *testing.T) { t.Fatalf("paths = %v", paths) } } + +func TestStreamV3PostsWorkflowContext(t *testing.T) { + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/editor/v3/stream" { + t.Errorf("path = %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Errorf("decode body: %v", err) + } + io.WriteString(w, "data: {\"type\":\"run.completed\",\"session_id\":\"sess1\",\"edited_paywalls_by_step_id\":{\"screen\":{\"default_locale\":\"en_US\",\"offering_id\":null,\"components_config\":{\"a\":1},\"components_localizations\":{}}}}\n\n") + })) + defer server.Close() + + client := NewClient(Options{BaseURL: server.URL, Token: "tok"}) + stream, err := client.StreamV3(context.Background(), EditorRequestV3{ + ProjectID: "proj1", + Message: "make it blue", + WorkflowContext: WorkflowContext{Version: 3, WorkflowID: "wf_1", FocusedStepID: "screen", WorkflowRevision: 4}, + }) + if err != nil { + t.Fatal(err) + } + defer stream.Close() + + if _, ok := gotBody["paywall"]; ok { + t.Error("v3 request must not carry a paywall") + } + if gotBody["source"] != "cli" { + t.Errorf("source = %v", gotBody["source"]) + } + if got, _ := gotBody["workflow_context"].(map[string]any); got["workflow_id"] != "wf_1" || got["focused_step_id"] != "screen" || got["workflow_revision"] != float64(4) || got["version"] != float64(3) { + t.Errorf("workflow_context = %v", gotBody["workflow_context"]) + } + event, err := stream.Next() + if err != nil || event.Type != EventRunCompleted { + t.Fatalf("event = %+v, err %v", event, err) + } + if got := event.EditedPaywallsByStepID["screen"]; string(got.ComponentsConfig) != `{"a":1}` { + t.Errorf("edited paywalls = %+v", event.EditedPaywallsByStepID) + } +}