From 8cec1bfd1e80e99287d289880ee5023b1faa449d Mon Sep 17 00:00:00 2001 From: Yoftahe Date: Mon, 23 Feb 2026 13:45:03 +0300 Subject: [PATCH] chore: Modularize the questions and prompts --- pkg/setup/prompts.go | 373 +++++++++++++++++++++++++++++++++++++++++ pkg/setup/questions.go | 243 +++++++++++++++++++++++++++ 2 files changed, 616 insertions(+) create mode 100644 pkg/setup/prompts.go create mode 100644 pkg/setup/questions.go diff --git a/pkg/setup/prompts.go b/pkg/setup/prompts.go new file mode 100644 index 000000000..222662415 --- /dev/null +++ b/pkg/setup/prompts.go @@ -0,0 +1,373 @@ +package setup + +import ( + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// StepDef describes a single prompt step in the setup wizard. +type StepDef struct { + Kind string // "text", "select", "yesno" + Prompt string + Info string + Options []string // for select/yesno + ID string // logical identifier for mapping answers into config +} + +// BuildDefs creates the per-step tui definitions based on the current config. +// It returns a modular list of steps for workspace, provider, model, and channel setup. +func (s *Setup) BuildDefs() []StepDef { + defs := []StepDef{} + + // Step 1: Workspace setup + defs = append(defs, StepDef{ID: "workspace", Kind: "text", Prompt: "1. Workspace path", Info: "Directory for agent workspace files"}) + defs = append(defs, StepDef{ID: "restrict_workspace", Kind: "select", Prompt: "1b. Restrict to workspace?", Options: []string{"yes", "no"}}) + + // Step 2: Provider setup (ordered by popularity) + defs = append(defs, buildProviderSelectStep(s.Cfg)...) + + // Step 3: Model selection (popular suggestions + custom option) + defs = append(defs, buildModelSelectStep(s.Cfg)...) + + // Step 4: Channel setup + defs = append(defs, buildChannelSelectStep(s.Cfg)...) + + // Final confirmation step + defs = append(defs, StepDef{ID: "confirm", Kind: "yesno", Prompt: "6. Confirm and save configuration?", Options: []string{"yes", "no"}}) + + return defs +} + +// buildProviderSelectStep returns provider selection step and credential prompts for missing required fields. +func buildProviderSelectStep(cfg *config.Config) []StepDef { + var steps []StepDef + + provInfo := config.GetProvidersInfo(cfg) + ordered := config.GetOrderedProviderNames() + + provOptions := []string{} + added := map[string]struct{}{} + + // Merge ordered list with available provider info + for _, name := range ordered { + if _, ok := provInfoLookup(provInfo, name); ok { + provOptions = append(provOptions, name) + added[name] = struct{}{} + } + } + // Add any remaining providers not in priority list + for _, p := range provInfo { + if _, ok := added[p.Name]; !ok { + provOptions = append(provOptions, p.Name) + } + } + + // Provider selection + steps = append(steps, StepDef{ID: "provider", Kind: "select", Prompt: "2. Choose provider", Options: provOptions}) + + // Determine selected provider (from config or first in list) + selProvider := cfg.Agents.Defaults.Provider + if selProvider == "" && len(provOptions) > 0 { + selProvider = provOptions[0] + } + + // Add credential prompts for missing required fields + if selProvider != "" { + provInfoItem, ok := provInfoLookup(provInfo, selProvider) + if ok { + for _, cred := range provInfoItem.RequiredCredentials { + if !ProviderCredentialPresent(cfg, selProvider, cred) { + steps = append(steps, StepDef{ + ID: "provider_" + cred, + Kind: "text", + Prompt: fmt.Sprintf("2b. %s for %s", cred, selProvider), + }) + } + } + } + } + + return steps +} + +// buildModelSelectStep returns model selection step and custom model input. +func buildModelSelectStep(cfg *config.Config) []StepDef { + var steps []StepDef + + selProvider := cfg.Agents.Defaults.Provider + modelSuggestions := config.GetPopularModels(selProvider) + + modelOptions := make([]string, len(modelSuggestions)+1) + copy(modelOptions, modelSuggestions) + modelOptions[len(modelSuggestions)] = "custom" + + steps = append(steps, StepDef{ + ID: "model_select", + Kind: "select", + Prompt: "3. Choose model", + Options: modelOptions, + }) + + steps = append(steps, StepDef{ + ID: "default_model", + Kind: "text", + Prompt: "3b. Enter custom model name", + }) + + return steps +} + +// buildChannelSelectStep returns channel selection step and credential prompts. +func buildChannelSelectStep(cfg *config.Config) []StepDef { + var steps []StepDef + + channels := config.GetAllChannelNames() + + steps = append(steps, StepDef{ + ID: "channel_select", + Kind: "select", + Prompt: "4. Choose channel", + Options: channels, + }) + + // Add placeholder for channel credentials (filled dynamically based on selection) + steps = append(steps, StepDef{ + ID: "channel_token", + Kind: "text", + Prompt: "4b. Channel credential/token", + }) + + return steps +} + +// provInfoLookup returns (ProviderInfo, true) if provider name exists in list. +func provInfoLookup(list []config.ProviderInfo, name string) (config.ProviderInfo, bool) { + for _, p := range list { + if p.Name == name { + return p, true + } + } + return config.ProviderInfo{}, false +} + +// ProviderCredentialPresent checks whether a given provider credential is present +// in the legacy ProvidersConfig or within any ModelConfig for that provider. +func ProviderCredentialPresent(c *config.Config, provider, cred string) bool { + if c == nil { + return false + } + p := strings.ToLower(provider) + switch p { + case "anthropic": + if cred == "api_key" { + return c.Providers.Anthropic.APIKey != "" + } + if cred == "api_base" { + return c.Providers.Anthropic.APIBase != "" + } + case "openai": + if cred == "api_key" { + return c.Providers.OpenAI.APIKey != "" + } + if cred == "api_base" { + return c.Providers.OpenAI.APIBase != "" + } + case "openrouter": + if cred == "api_key" { + return c.Providers.OpenRouter.APIKey != "" + } + if cred == "api_base" { + return c.Providers.OpenRouter.APIBase != "" + } + case "groq": + if cred == "api_key" { + return c.Providers.Groq.APIKey != "" + } + case "zhipu": + if cred == "api_key" { + return c.Providers.Zhipu.APIKey != "" + } + case "vllm": + if cred == "api_key" { + return c.Providers.VLLM.APIKey != "" + } + case "gemini": + if cred == "api_key" { + return c.Providers.Gemini.APIKey != "" + } + case "nvidia": + if cred == "api_key" { + return c.Providers.Nvidia.APIKey != "" + } + case "ollama": + if cred == "api_key" { + return c.Providers.Ollama.APIKey != "" + } + case "moonshot": + if cred == "api_key" { + return c.Providers.Moonshot.APIKey != "" + } + case "shengsuanyun": + if cred == "api_key" { + return c.Providers.ShengSuanYun.APIKey != "" + } + case "deepseek": + if cred == "api_key" { + return c.Providers.DeepSeek.APIKey != "" + } + case "cerebras": + if cred == "api_key" { + return c.Providers.Cerebras.APIKey != "" + } + case "volcengine": + if cred == "api_key" { + return c.Providers.VolcEngine.APIKey != "" + } + case "github_copilot": + if cred == "api_key" { + return c.Providers.GitHubCopilot.APIKey != "" + } + case "antigravity": + if cred == "api_key" { + return c.Providers.Antigravity.APIKey != "" + } + case "qwen": + if cred == "api_key" { + return c.Providers.Qwen.APIKey != "" + } + } + + // Check model_list entries for provider credentials + for _, m := range c.ModelList { + pfx := config.ParseProtocol(m.Model) + if pfx == "" { + pfx = "openai" + } + if pfx == p { + if cred == "api_key" && m.APIKey != "" { + return true + } + if cred == "api_base" && m.APIBase != "" { + return true + } + } + } + + return false +} + +// SetProviderCredential sets a credential value for the named provider into the +// appropriate place in the config (legacy ProvidersConfig or model_list fallback). +func SetProviderCredential(c *config.Config, provider, cred, value string) { + if c == nil || provider == "" || cred == "" { + return + } + p := strings.ToLower(provider) + switch p { + case "anthropic": + if cred == "api_key" { + c.Providers.Anthropic.APIKey = value + } else if cred == "api_base" { + c.Providers.Anthropic.APIBase = value + } + case "openai": + if cred == "api_key" { + c.Providers.OpenAI.APIKey = value + } else if cred == "api_base" { + c.Providers.OpenAI.APIBase = value + } + case "openrouter": + if cred == "api_key" { + c.Providers.OpenRouter.APIKey = value + } else if cred == "api_base" { + c.Providers.OpenRouter.APIBase = value + } + case "groq": + if cred == "api_key" { + c.Providers.Groq.APIKey = value + } + case "zhipu": + if cred == "api_key" { + c.Providers.Zhipu.APIKey = value + } + case "vllm": + if cred == "api_key" { + c.Providers.VLLM.APIKey = value + } + case "gemini": + if cred == "api_key" { + c.Providers.Gemini.APIKey = value + } + case "nvidia": + if cred == "api_key" { + c.Providers.Nvidia.APIKey = value + } + case "ollama": + if cred == "api_key" { + c.Providers.Ollama.APIKey = value + } + case "moonshot": + if cred == "api_key" { + c.Providers.Moonshot.APIKey = value + } + case "shengsuanyun": + if cred == "api_key" { + c.Providers.ShengSuanYun.APIKey = value + } + case "deepseek": + if cred == "api_key" { + c.Providers.DeepSeek.APIKey = value + } + case "cerebras": + if cred == "api_key" { + c.Providers.Cerebras.APIKey = value + } + case "volcengine": + if cred == "api_key" { + c.Providers.VolcEngine.APIKey = value + } + case "github_copilot": + if cred == "api_key" { + c.Providers.GitHubCopilot.APIKey = value + } + case "antigravity": + if cred == "api_key" { + c.Providers.Antigravity.APIKey = value + } + case "qwen": + if cred == "api_key" { + c.Providers.Qwen.APIKey = value + } + default: + // If provider not in legacy ProvidersConfig, try to set on a matching ModelConfig + for i := range c.ModelList { + pfx := config.ParseProtocol(c.ModelList[i].Model) + if pfx == "" { + pfx = "openai" + } + if pfx == p { + if cred == "api_key" { + c.ModelList[i].APIKey = value + } else if cred == "api_base" { + c.ModelList[i].APIBase = value + } + return + } + } + } +} + +// BuildSummary returns a human-readable summary of the current configuration. +func BuildSummary(cfg *config.Config) []string { + summary := []string{} + summary = append(summary, "Configuration summary:") + summary = append(summary, fmt.Sprintf("Workspace: %s", cfg.Agents.Defaults.Workspace)) + summary = append(summary, fmt.Sprintf("Restrict to workspace: %v", cfg.Agents.Defaults.RestrictToWorkspace)) + summary = append(summary, fmt.Sprintf("Provider: %s", cfg.Agents.Defaults.Provider)) + if cfg.Agents.Defaults.Model != "" { + summary = append(summary, fmt.Sprintf("Model: %s", cfg.Agents.Defaults.Model)) + } + return summary +} diff --git a/pkg/setup/questions.go b/pkg/setup/questions.go new file mode 100644 index 000000000..6ea54f2b6 --- /dev/null +++ b/pkg/setup/questions.go @@ -0,0 +1,243 @@ +package setup + +import ( + "github.com/sipeed/picoclaw/pkg/config" +) + +// QuestionType defines the type of response expected for a question. +type QuestionType string + +const ( + QuestionTypeText QuestionType = "text" + QuestionTypeSelect QuestionType = "select" + QuestionTypeYesNo QuestionType = "yesno" +) + +// Question represents a single question in the setup wizard. +type Question struct { + ID string // unique identifier for mapping answer to config + Type QuestionType // response type: text, select, yesno + Prompt string // question text shown to user + Info string // optional helper text + Options []string // options for select/yesno types + DefaultValue string // default value to prefill + DependsOn string // question ID this depends on (optional) + DependsValue string // value that must be matched (optional) + ConfigPath string // dot-notation path to config field (e.g. "Agents.Defaults.Workspace") + Transformer string // optional: "lowercase", "bool_yesno", "channel_enable" +} + +// QuestionGroup represents a group of related questions. +type QuestionGroup struct { + Name string + Questions []Question +} + +// AllQuestions returns the complete list of all possible questions in the setup wizard. +// This is the declarative data structure - questions are defined here, not built procedurally. +var AllQuestions = []QuestionGroup{ + { + Name: "Workspace", + Questions: []Question{ + { + ID: "workspace", + Type: QuestionTypeText, + Prompt: "1. Workspace path", + Info: "Directory for agent workspace files", + ConfigPath: "Agents.Defaults.Workspace", + DefaultValue: "~/.picoclaw/workspace", + }, + { + ID: "restrict_workspace", + Type: QuestionTypeYesNo, + Prompt: "1b. Restrict to workspace?", + ConfigPath: "Agents.Defaults.RestrictToWorkspace", + Transformer: "bool_yesno", + }, + }, + }, + { + Name: "Provider", + Questions: []Question{ + { + ID: "provider", + Type: QuestionTypeSelect, + Prompt: "2. Choose provider", + ConfigPath: "Agents.Defaults.Provider", + }, + { + ID: "provider_api_key", + Type: QuestionTypeText, + Prompt: "2b. API key for provider", + ConfigPath: "Providers.{provider}.APIKey", + DependsOn: "provider", + }, + { + ID: "provider_api_base", + Type: QuestionTypeText, + Prompt: "2c. API base URL (optional)", + ConfigPath: "Providers.{provider}.APIBase", + DependsOn: "provider", + }, + }, + }, + { + Name: "Model", + Questions: []Question{ + { + ID: "model_select", + Type: QuestionTypeSelect, + Prompt: "3. Choose model", + ConfigPath: "Agents.Defaults.Model", + }, + { + ID: "custom_model", + Type: QuestionTypeText, + Prompt: "3b. Enter custom model name", + ConfigPath: "Agents.Defaults.Model", + DependsOn: "model_select", + DependsValue: "custom", + }, + }, + }, + { + Name: "Channel", + Questions: []Question{ + { + ID: "channel_select", + Type: QuestionTypeSelect, + Prompt: "4. Choose channel", + ConfigPath: "channel_enabled", + }, + { + ID: "channel_token", + Type: QuestionTypeText, + Prompt: "4b. Channel token/credential", + ConfigPath: "channel.{channel}.token", + DependsOn: "channel_select", + }, + }, + }, + { + Name: "Confirmation", + Questions: []Question{ + { + ID: "confirm", + Type: QuestionTypeYesNo, + Prompt: "5. Confirm and save configuration?", + }, + }, + }, +} + +// QuestionRegistry holds resolved questions for a specific config state. +type QuestionRegistry struct { + Questions []Question + Defaults map[string]string // questionID -> default value +} + +// BuildQuestionRegistry creates the question registry based on current config. +// It resolves dynamic options (provider list, channel list, model suggestions) from config. +func BuildQuestionRegistry(cfg *config.Config) QuestionRegistry { + registry := QuestionRegistry{ + Questions: []Question{}, + Defaults: map[string]string{}, + } + + // Collect provider options + provInfo := config.GetProvidersInfo(cfg) + ordered := config.GetOrderedProviderNames() + provOptions := []string{} + added := map[string]struct{}{} + for _, name := range ordered { + if _, ok := provInfoLookup(provInfo, name); ok { + provOptions = append(provOptions, name) + added[name] = struct{}{} + } + } + for _, p := range provInfo { + if _, ok := added[p.Name]; !ok { + provOptions = append(provOptions, p.Name) + } + } + + // Collect channel options + channelOptions := config.GetAllChannelNames() + + // Collect model options based on selected provider + selProvider := cfg.Agents.Defaults.Provider + modelSuggestions := config.GetPopularModels(selProvider) + modelOptions := make([]string, len(modelSuggestions)+1) + copy(modelOptions, modelSuggestions) + modelOptions[len(modelSuggestions)] = "custom" + + // Build flat list of questions with resolved options + for _, group := range AllQuestions { + for _, q := range group.Questions { + question := q + + // Set options based on question type + if question.Type == QuestionTypeSelect { + switch question.ID { + case "provider": + question.Options = provOptions + case "channel_select": + question.Options = channelOptions + case "model_select": + question.Options = modelOptions + } + } + + // Set defaults from config + if question.DefaultValue != "" { + registry.Defaults[question.ID] = question.DefaultValue + } + + registry.Questions = append(registry.Questions, question) + } + } + + return registry +} + +// GetQuestionByID returns a question by its ID. +func (r *QuestionRegistry) GetQuestionByID(id string) *Question { + for i := range r.Questions { + if r.Questions[i].ID == id { + return &r.Questions[i] + } + } + return nil +} + +// GetDependentQuestions returns questions that depend on a specific question. +func (r *QuestionRegistry) GetDependentQuestions(dependsOn string) []Question { + var deps []Question + for _, q := range r.Questions { + if q.DependsOn == dependsOn { + deps = append(deps, q) + } + } + return deps +} + +// ShouldShowQuestion checks if a question should be shown based on its dependencies. +func (r *QuestionRegistry) ShouldShowQuestion(questionID string, answers map[string]string) bool { + q := r.GetQuestionByID(questionID) + if q == nil || q.DependsOn == "" { + return true + } + + depValue, exists := answers[q.DependsOn] + if !exists { + return false + } + + // If DependsValue is set, check for exact match + if q.DependsValue != "" { + return depValue == q.DependsValue + } + + // Otherwise show if dependency has any value + return depValue != "" +}