diff --git a/internal/command/web.go b/internal/command/web.go index 807889d..3baa967 100644 --- a/internal/command/web.go +++ b/internal/command/web.go @@ -51,10 +51,21 @@ func NewWebCmd() *cobra.Command { } func runWebServer(port int, host string, openBrowser bool) error { + // Check if we need setup (no providers configured). + needsSetup := config.NeedsSetup() - cfg, err := config.LoadConfig() - if err != nil { - return fmt.Errorf("config error: %w", err) + var cfg *config.Config + if !needsSetup { + var err error + cfg, err = config.LoadConfig() + if err != nil { + return fmt.Errorf("config error: %w", err) + } + } else { + // Create a minimal config for setup mode. + cfg = &config.Config{ + MaxIterations: 1000, + } } ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) @@ -68,12 +79,15 @@ func runWebServer(port int, host string, openBrowser bool) error { skillLoader.ScanProjectSkills(pwd) systemPrompt := prompts.GetSystemPrompt(platform, pwd, "local", envInfo, skillLoader.Descriptions()) - providerName, modelName := cfg.GetProviderModel() - providers := cfg.GetProviders() - providerCfg := providers[providerName] - if providerCfg == nil { - return fmt.Errorf("provider %q not found in config", providerName) + var providerName, modelName string + if !needsSetup { + providerName, modelName = cfg.GetProviderModel() + providers := cfg.GetProviders() + providerCfg := providers[providerName] + if providerCfg == nil { + return fmt.Errorf("provider %q not found in config", providerName) + } } registry := internalmodel.NewModelRegistry() @@ -171,7 +185,12 @@ func runWebServer(port int, host string, openBrowser bool) error { createAgent := func(prov, mod string) (*adk.ChatModelAgent, error) { // Resolve provider config. - provCfg := providers[prov] + // Reload config to pick up any new providers added via setup. + currentCfg, err := config.LoadConfig() + if err != nil { + return nil, fmt.Errorf("config error: %w", err) + } + provCfg := currentCfg.GetProviders()[prov] if provCfg == nil { return nil, fmt.Errorf("provider %q not configured", prov) } @@ -240,9 +259,13 @@ func runWebServer(port int, host string, openBrowser bool) error { return agent.NewAgent(ctx, cm, tools, systemPrompt, approvalState.RequestApproval, middlewares, handlers) } - ag, err := createAgent(providerName, modelName) - if err != nil { - return fmt.Errorf("error creating agent: %w", err) + var ag *adk.ChatModelAgent + var agentErr error + if !needsSetup { + ag, agentErr = createAgent(providerName, modelName) + if agentErr != nil { + return fmt.Errorf("error creating agent: %w", agentErr) + } } switchProject := func(newPwd string) (*adk.ChatModelAgent, *session.Recorder, error) { @@ -301,6 +324,7 @@ func runWebServer(port int, host string, openBrowser bool) error { WechatClient: wechatClient, WebHandler: webHandler, EventHandler: finalHandler, + NeedsSetup: needsSetup, }) // Set handler for approval routing. diff --git a/internal/config/model_state.go b/internal/config/model_state.go new file mode 100644 index 0000000..4afd916 --- /dev/null +++ b/internal/config/model_state.go @@ -0,0 +1,161 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" +) + +const modelStateFile = "model_state.json" + +// ModelState tracks recent, favorite, and visibility settings for models. +type ModelState struct { + Recent []ModelRef `json:"recent,omitempty"` + Favorite []ModelRef `json:"favorite,omitempty"` + // EnabledModels lists models explicitly enabled by the user (shown in model selector). + // If nil/empty, default-enabled models from the registry are used. + EnabledModels []ModelRef `json:"enabled_models,omitempty"` + // DisabledModels lists models explicitly disabled by the user (hidden from model selector). + DisabledModels []ModelRef `json:"disabled_models,omitempty"` +} + +// ModelRef uniquely identifies a model in "provider/model" format. +type ModelRef struct { + Provider string `json:"provider"` + Model string `json:"model"` +} + +var ( + modelStateMu sync.Mutex +) + +// modelStatePath returns the path to the model state file. +func modelStatePath() (string, error) { + return filepath.Join(ConfigDir(), modelStateFile), nil +} + +// LoadModelState loads the model state from disk. +func LoadModelState() (*ModelState, error) { + modelStateMu.Lock() + defer modelStateMu.Unlock() + + p, err := modelStatePath() + if err != nil { + return &ModelState{}, nil + } + + data, err := os.ReadFile(p) + if err != nil { + return &ModelState{}, nil + } + + var state ModelState + if err := json.Unmarshal(data, &state); err != nil { + return &ModelState{}, nil + } + return &state, nil +} + +// SaveModelState writes the model state to disk. +func SaveModelState(state *ModelState) error { + modelStateMu.Lock() + defer modelStateMu.Unlock() + + p, err := modelStatePath() + if err != nil { + return err + } + + dir := filepath.Dir(p) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return os.WriteFile(p, data, 0644) +} + +// AddRecent adds a model to the recent list (deduped, max 10). +func (s *ModelState) AddRecent(ref ModelRef) { + // Remove if already present + filtered := make([]ModelRef, 0, len(s.Recent)) + for _, r := range s.Recent { + if r.Provider != ref.Provider || r.Model != ref.Model { + filtered = append(filtered, r) + } + } + // Prepend + s.Recent = append([]ModelRef{ref}, filtered...) + // Cap at 10 + if len(s.Recent) > 10 { + s.Recent = s.Recent[:10] + } +} + +// ToggleFavorite adds or removes a model from favorites. Returns true if now favorite. +func (s *ModelState) ToggleFavorite(ref ModelRef) bool { + for i, r := range s.Favorite { + if r.Provider == ref.Provider && r.Model == ref.Model { + s.Favorite = append(s.Favorite[:i], s.Favorite[i+1:]...) + return false + } + } + s.Favorite = append(s.Favorite, ref) + return true +} + +// IsFavorite returns whether the given model is in the favorites list. +func (s *ModelState) IsFavorite(ref ModelRef) bool { + for _, r := range s.Favorite { + if r.Provider == ref.Provider && r.Model == ref.Model { + return true + } + } + return false +} + +// IsModelEnabled returns whether the given model should be shown in the model selector. +// Logic: if the model is in EnabledModels, it's enabled. +// If the model is in DisabledModels, it's disabled. +// Otherwise, fallback to the defaultEnabled parameter (from registry). +func (s *ModelState) IsModelEnabled(ref ModelRef, defaultEnabled bool) bool { + for _, r := range s.DisabledModels { + if r.Provider == ref.Provider && r.Model == ref.Model { + return false + } + } + for _, r := range s.EnabledModels { + if r.Provider == ref.Provider && r.Model == ref.Model { + return true + } + } + return defaultEnabled +} + +// SetModelEnabled explicitly enables or disables a model in the model selector. +func (s *ModelState) SetModelEnabled(ref ModelRef, enabled bool) { + // Remove from both lists first + s.EnabledModels = removeModelRef(s.EnabledModels, ref) + s.DisabledModels = removeModelRef(s.DisabledModels, ref) + + if enabled { + s.EnabledModels = append(s.EnabledModels, ref) + } else { + s.DisabledModels = append(s.DisabledModels, ref) + } +} + +// removeModelRef removes a model ref from a slice. +func removeModelRef(refs []ModelRef, ref ModelRef) []ModelRef { + result := make([]ModelRef, 0, len(refs)) + for _, r := range refs { + if r.Provider != ref.Provider || r.Model != ref.Model { + result = append(result, r) + } + } + return result +} diff --git a/internal/model/registry.go b/internal/model/registry.go index 05258fe..9b71e01 100644 --- a/internal/model/registry.go +++ b/internal/model/registry.go @@ -34,6 +34,8 @@ type RegistryModel struct { Cost *ModelCost `json:"cost,omitempty"` Limit *ModelLimit `json:"limit,omitempty"` Status string `json:"status,omitempty"` + Recommended bool `json:"recommended,omitempty"` + DefaultEnabled bool `json:"default_enabled,omitempty"` } // ModelModalities describes input/output modalities. @@ -151,8 +153,8 @@ func (r *ModelRegistry) ListProviderModels(providerID string, toolCallOnly bool) } models = append(models, m) } - // Sort by ID for consistent ordering - sortModelsByID(models) + // Sort: recommended first, then by ID + sortModels(models) return models } @@ -183,3 +185,80 @@ func sortModelsByID(models []*RegistryModel) { } } } + +// sortModels sorts models: recommended first, then by ID. +func sortModels(models []*RegistryModel) { + for i := 0; i < len(models); i++ { + for j := i + 1; j < len(models); j++ { + iRec := models[i].Recommended + jRec := models[j].Recommended + if (!iRec && jRec) || (iRec == jRec && models[i].ID > models[j].ID) { + models[i], models[j] = models[j], models[i] + } + } + } +} + +// recommendedModels defines recommended and default-enabled models per provider. +// Key: provider ID, Value: map of model ID β true (recommended + default enabled). +var recommendedModels = map[string]map[string]bool{ + "zhipuai": { + "glm-5.1": true, + "glm-5": true, + }, + "zhipuai-coding-plan": { + "glm-5.1": true, + "glm-5": true, + }, + "deepseek": { + "deepseek-v4-pro": true, + }, + "alibaba-cn": { + "qwen3.6-plus": true, + "MiniMax/MiniMax-M2.7": true, + "deepseek-v3-2-exp": true, + "kimi-k2.6": true, + }, + "alibaba-coding-plan-cn": { + "qwen3.6-plus": true, + }, + "moonshotai": { + "kimi-k2.6": true, + }, + "minimax": { + "MiniMax-M2.7": true, + }, + "minimax-coding-plan": { + "MiniMax-M2.7": true, + }, + "openai": { + "gpt-4.1": true, + "o4-mini": true, + }, + "anthropic": { + "claude-sonnet-4-20250514": true, + }, + "google": { + "gemini-2.5-pro": true, + }, +} + +func init() { + applyRecommendedModels() +} + +// applyRecommendedModels sets Recommended and DefaultEnabled on models in the generated registry. +func applyRecommendedModels() { + for provID, models := range recommendedModels { + prov, ok := generatedProviders[provID] + if !ok { + continue + } + for modelID := range models { + if m, ok := prov.Models[modelID]; ok { + m.Recommended = true + m.DefaultEnabled = true + } + } + } +} diff --git a/internal/model/validate.go b/internal/model/validate.go new file mode 100644 index 0000000..8d012b6 --- /dev/null +++ b/internal/model/validate.go @@ -0,0 +1,44 @@ +package model + +import ( + "context" + "fmt" + "net/http" + "time" +) + +// ValidateProvider tests connectivity to a provider by making a lightweight +// GET /models request. Returns nil on success, or a descriptive error. +func ValidateProvider(ctx context.Context, apiKey, baseURL string) error { + if baseURL == "" { + return fmt.Errorf("base URL is empty") + } + + client := &http.Client{Timeout: 10 * time.Second} + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("connection failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("invalid API key (401 Unauthorized)") + } + if resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("access denied (403 Forbidden) β check API key permissions") + } + if resp.StatusCode >= 400 { + return fmt.Errorf("server returned %d %s", resp.StatusCode, resp.Status) + } + + return nil +} diff --git a/internal/tui/input_views.go b/internal/tui/input_views.go index 5c2d306..a4da453 100644 --- a/internal/tui/input_views.go +++ b/internal/tui/input_views.go @@ -200,7 +200,8 @@ func (m Model) channelStateDesc(channelID string) string { func (m Model) handleSettingInput(cmds []tea.Cmd) (tea.Model, tea.Cmd) { items := []list.Item{ settingItem{title: "π Switch Model", desc: "Switch to a different configured model", key: "switch_model"}, - settingItem{title: "β Add New Model", desc: "Add a new model provider via setup wizard", key: "add_model"}, + settingItem{title: "β Manage Models", desc: "Choose which models appear in the model selector", key: "manage_models"}, + settingItem{title: "β Add New Provider", desc: "Add a new model provider via setup wizard", key: "add_model"}, settingItem{title: "π Edit Config File", desc: "Manually edit " + config.ConfigPath(), key: "edit_config"}, } m.settingMenu.SetItems(items) diff --git a/internal/tui/manage_models.go b/internal/tui/manage_models.go new file mode 100644 index 0000000..6aa8219 --- /dev/null +++ b/internal/tui/manage_models.go @@ -0,0 +1,249 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/cnjack/jcode/internal/config" + "github.com/cnjack/jcode/internal/model" +) + +// manageModelItem represents a model in the manage models view with a toggle state. +type manageModelItem struct { + provider string + providerName string + model string + modelName string + enabled bool + recommended bool + isHeader bool // provider group header +} + +func (i manageModelItem) Title() string { + if i.isHeader { + return i.providerName + } + toggle := " " + if i.enabled { + toggle = "β " + } + title := toggle + i.modelName + if i.recommended { + title += " [recommended]" + } + return title +} + +func (i manageModelItem) Description() string { + if i.isHeader { + return "" + } + return "" +} + +func (i manageModelItem) FilterValue() string { + if i.isHeader { + return "" + } + return i.modelName + " " + i.model +} + +// openManageModels transitions to the manage models view. +func (m Model) openManageModels(cmds []tea.Cmd) (tea.Model, tea.Cmd) { + cfg, err := config.LoadConfig() + if err != nil { + m.lines = append(m.lines, textLine(toolErrorStyle.Render("β Failed to load config: "+err.Error()))) + return m, tea.Batch(cmds...) + } + + configuredProviders := cfg.GetProviders() + registry := model.NewModelRegistry() + modelState, _ := config.LoadModelState() + + var items []list.Item + + for _, rp := range registry.ListProviders() { + // Only show providers that the user has configured. + if _, configured := configuredProviders[rp.ID]; !configured { + continue + } + + models := registry.ListProviderModels(rp.ID, false) + if len(models) == 0 { + continue + } + + // Add provider header + items = append(items, manageModelItem{ + provider: rp.ID, + providerName: "βββ " + strings.ToUpper(rp.Name) + " βββ", + model: "", + modelName: "", + enabled: false, + recommended: false, + isHeader: true, + }) + + for _, rm := range models { + ref := config.ModelRef{Provider: rp.ID, Model: rm.ID} + enabled := modelState.IsModelEnabled(ref, rm.DefaultEnabled) + + items = append(items, manageModelItem{ + provider: rp.ID, + providerName: rp.Name, + model: rm.ID, + modelName: rm.Name, + enabled: enabled, + recommended: rm.Recommended, + isHeader: false, + }) + } + } + + del := list.NewDefaultDelegate() + del.SetSpacing(0) + m.manageModelsPicker = list.New(items, del, 60, 15) + m.manageModelsPicker.Title = "Space toggle Β· Enter done Β· Esc cancel" + m.manageModelsPicker.SetShowHelp(false) + m.manageModelsPicker.SetShowStatusBar(true) + m.manageModelsPicker.SetShowPagination(true) + m.manageModelsPicker.SetFilteringEnabled(true) + m.managingModels = true + m.textarea.Blur() + return m, tea.Batch(cmds...) +} + +// handleManageModelsKey processes key input in the manage models view. +func (m Model) handleManageModelsKey(msg tea.KeyPressMsg, cmds []tea.Cmd) (tea.Model, tea.Cmd) { + // When the list is actively filtering, let all keys pass through to the list + if m.manageModelsPicker.FilterState() == list.Filtering { + var cmd tea.Cmd + m.manageModelsPicker, cmd = m.manageModelsPicker.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } + switch msg.String() { + case " ", "space": + // Toggle the selected model's visibility + selected := m.manageModelsPicker.SelectedItem() + if selected != nil { + item := selected.(manageModelItem) + // Skip headers + if item.isHeader { + return m, tea.Batch(cmds...) + } + newEnabled := !item.enabled + + // Update the model state + modelState, _ := config.LoadModelState() + ref := config.ModelRef{Provider: item.provider, Model: item.model} + modelState.SetModelEnabled(ref, newEnabled) + _ = config.SaveModelState(modelState) + + // Update the item in the list + idx := m.manageModelsPicker.Index() + items := m.manageModelsPicker.Items() + if idx < len(items) { + updatedItem := items[idx].(manageModelItem) + updatedItem.enabled = newEnabled + items[idx] = updatedItem + // Force list to update by setting items again + cmd := m.manageModelsPicker.SetItems(items) + cmds = append(cmds, cmd) + } + } + return m, tea.Batch(cmds...) + + case "enter": + // Done β close manage models and return to model picker + m.manageModelsPicker.ResetFilter() + m.managingModels = false + return m.handleModelInput(cmds) + + case "ctrl+c", "esc": + // Cancel β close manage models and return to model picker + m.manageModelsPicker.ResetFilter() + m.managingModels = false + return m.handleModelInput(cmds) + } + + var cmd tea.Cmd + m.manageModelsPicker, cmd = m.manageModelsPicker.Update(msg) + cmds = append(cmds, cmd) + + // Skip provider headers when navigating with arrow keys + if msg.String() == "up" || msg.String() == "down" { + if selected := m.manageModelsPicker.SelectedItem(); selected != nil { + if item, ok := selected.(manageModelItem); ok && item.isHeader { + // Item is a header, move again in the same direction + m.manageModelsPicker, cmd = m.manageModelsPicker.Update(msg) + cmds = append(cmds, cmd) + // Check again in case there are consecutive headers + if selected := m.manageModelsPicker.SelectedItem(); selected != nil { + if item, ok := selected.(manageModelItem); ok && item.isHeader { + m.manageModelsPicker, cmd = m.manageModelsPicker.Update(msg) + cmds = append(cmds, cmd) + } + } + } + } + } + + return m, tea.Batch(cmds...) +} + +// manageModelsView renders the manage models screen. +func (m Model) manageModelsView() string { + w, h := m.width, m.height + if w <= 0 { + w = 80 + } + if h <= 0 { + h = 24 + } + + contentW := w - 12 + if contentW > 80 { + contentW = 80 + } + if contentW < 30 { + contentW = 30 + } + listH := h - 10 + if listH < 4 { + listH = 4 + } + + boxStyle := dialogBoxStyle.Width(contentW) + + headerText := lipgloss.NewStyle().Bold(true).Foreground(colorPrimary). + Render("β Manage Models") + + subtitle := lipgloss.NewStyle().Foreground(colorDimText). + Render("Toggle which models appear in the model selector") + + m.manageModelsPicker.SetSize(contentW-4, listH) + m.manageModelsPicker.SetShowHelp(false) + m.manageModelsPicker.SetShowPagination(true) + + var contentParts []string + contentParts = append(contentParts, headerText) + contentParts = append(contentParts, subtitle) + contentParts = append(contentParts, "") + contentParts = append(contentParts, m.manageModelsPicker.View()) + + // Footer with key hints + footer := lipgloss.NewStyle().Foreground(colorDimText). + Render(fmt.Sprintf(" %s toggle Β· %s done Β· %s cancel", + strings.ToUpper("space"), + strings.ToUpper("enter"), + strings.ToUpper("esc"))) + contentParts = append(contentParts, footer) + + content := lipgloss.JoinVertical(lipgloss.Left, contentParts...) + + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, boxStyle.Render(content)) +} diff --git a/internal/tui/pickers.go b/internal/tui/pickers.go index 08187b9..fa0ab62 100644 --- a/internal/tui/pickers.go +++ b/internal/tui/pickers.go @@ -2,7 +2,6 @@ package tui import ( "fmt" - "sort" "strings" "charm.land/bubbles/v2/list" @@ -23,24 +22,89 @@ func (m Model) handleModelInput(cmds []tea.Cmd) (tea.Model, tea.Cmd) { currentProvider, currentModel := cfg.GetProviderModel() registry := model.NewModelRegistry() - // Collect providers and sort them for stable ordering - providers := cfg.GetProviders() - providerNames := make([]string, 0, len(providers)) - for name := range providers { - providerNames = append(providerNames, name) - } - sort.Strings(providerNames) + // Configured providers (have API key) + configuredProviders := cfg.GetProviders() var items []list.Item - for _, provider := range providerNames { - models := registry.ListProviderModels(provider, false) - if len(models) > 0 { - for _, rm := range models { - isCurrent := provider == currentProvider && rm.ID == currentModel + // Load model state for favorites, recent, and visibility. + modelState, _ := config.LoadModelState() + favSet := make(map[string]bool) + for _, r := range modelState.Favorite { + favSet[r.Provider+"/"+r.Model] = true + } + + // Track already-shown models to avoid duplicates. + shownSet := make(map[string]bool) + + // Add current model section (only the current model). + currentKey := currentProvider + "/" + currentModel + items = append(items, modelItem{ + title: "βββ CURRENT MODEL βββ", + desc: "", + isProviderHeader: true, + }) - // Build rich description with metadata + // Get current model info from registry + reg := model.NewModelRegistry() + if _, rm, ok := reg.LookupModel(currentProvider, currentModel); ok && rm != nil { + var tags []string + if rm.Recommended { + tags = append(tags, "recommended") + } + if rm.Limit != nil && rm.Limit.Context > 0 { + tags = append(tags, fmt.Sprintf("%dk ctx", rm.Limit.Context/1000)) + } + if rm.ToolCall { + tags = append(tags, "tools") + } + if rm.Reasoning { + tags = append(tags, "reasoning") + } + desc := "" + if len(tags) > 0 { + desc = strings.Join(tags, " Β· ") + } + items = append(items, modelItem{ + provider: currentProvider, + model: currentModel, + title: "β " + rm.Name, + desc: desc, + isCurrent: true, + }) + } else { + // Fallback if not in registry + items = append(items, modelItem{ + provider: currentProvider, + model: currentModel, + title: "β " + currentModel, + desc: currentProvider, + isCurrent: true, + }) + } + shownSet[currentKey] = true + + // Add favorites section if any exist. + if len(modelState.Favorite) > 0 { + items = append(items, modelItem{ + title: "βββ β FAVORITES βββ", + desc: "", + isProviderHeader: true, + }) + for _, r := range modelState.Favorite { + key := r.Provider + "/" + r.Model + if shownSet[key] { + continue // Skip if it's the current model (already shown above) + } + shownSet[key] = true + + // Get model info for tags + var desc string + if _, rm, ok := reg.LookupModel(r.Provider, r.Model); ok && rm != nil { var tags []string + if rm.Recommended { + tags = append(tags, "recommended") + } if rm.Limit != nil && rm.Limit.Context > 0 { tags = append(tags, fmt.Sprintf("%dk ctx", rm.Limit.Context/1000)) } @@ -50,41 +114,108 @@ func (m Model) handleModelInput(cmds []tea.Cmd) (tea.Model, tea.Cmd) { if rm.Reasoning { tags = append(tags, "reasoning") } - desc := provider if len(tags) > 0 { - desc += " Β· " + strings.Join(tags, " Β· ") + desc = strings.Join(tags, " Β· ") } + } + + items = append(items, modelItem{ + provider: r.Provider, + model: r.Model, + title: "β " + r.Model, + desc: desc, + isCurrent: false, + }) + } + } + + // Add models grouped by provider (in registry order), only showing enabled models. + for _, rp := range registry.ListProviders() { + // Only show providers that the user has configured (has API key). + if _, configured := configuredProviders[rp.ID]; !configured { + continue + } + + models := registry.ListProviderModels(rp.ID, false) - title := rm.ID - if isCurrent { - title = "β " + title + // Filter to only enabled models not already shown + var providerModels []*model.RegistryModel + for _, rm := range models { + key := rp.ID + "/" + rm.ID + if shownSet[key] { + continue + } + ref := config.ModelRef{Provider: rp.ID, Model: rm.ID} + if !modelState.IsModelEnabled(ref, rm.DefaultEnabled) { + continue + } + providerModels = append(providerModels, rm) + } + + // Only add provider header if there are models to show + if len(providerModels) > 0 { + items = append(items, modelItem{ + title: "βββ " + strings.ToUpper(rp.Name) + " βββ", + desc: "", + isProviderHeader: true, + }) + + for _, rm := range providerModels { + key := rp.ID + "/" + rm.ID + shownSet[key] = true + + // Build description with tags only (no provider name) + var tags []string + if rm.Recommended { + tags = append(tags, "recommended") + } + if rm.Limit != nil && rm.Limit.Context > 0 { + tags = append(tags, fmt.Sprintf("%dk ctx", rm.Limit.Context/1000)) + } + if rm.ToolCall { + tags = append(tags, "tools") + } + if rm.Reasoning { + tags = append(tags, "reasoning") + } + desc := "" + if len(tags) > 0 { + desc = strings.Join(tags, " Β· ") } items = append(items, modelItem{ - provider: provider, + provider: rp.ID, model: rm.ID, - title: title, + title: rm.Name, desc: desc, - isCurrent: isCurrent, + isCurrent: false, }) } - } else if provider == currentProvider { - // Provider not in registry β show configured model only - items = append(items, modelItem{ - provider: provider, - model: currentModel, - title: "β " + currentModel, - desc: provider, - isCurrent: true, - }) } } - // Add "Add New Model" option at the end + // Handle configured providers not in registry (custom OpenAI-compatible, etc.) + for provID := range configuredProviders { + if registry.HasProvider(provID) { + continue + } + // Only show if not already shown (e.g., as current model) + // For custom providers, we only have the current model + // which was already shown in the CURRENT MODEL section + } + + // Add action items at the end + items = append(items, modelItem{ + title: "β Manage Modelsβ¦", + desc: "Choose which models appear in this list", + isAction: true, + actionID: "manage_models", + }) items = append(items, modelItem{ - title: "β Add New Modelβ¦", + title: "β Add New Providerβ¦", desc: "Configure a new provider and model", isAction: true, + actionID: "add_model", }) m.modelPicker.SetItems(items) diff --git a/internal/tui/setup.go b/internal/tui/setup.go index cf996a5..8f37c90 100644 --- a/internal/tui/setup.go +++ b/internal/tui/setup.go @@ -230,6 +230,13 @@ func (m SetupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.state { case StateProvider: + // When filtering, let keys pass through to the list + if m.providerList.FilterState() == list.Filtering { + var cmd tea.Cmd + m.providerList, cmd = m.providerList.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } if msg.String() == "enter" { sel := m.providerList.SelectedItem() if sel != nil { @@ -260,6 +267,13 @@ func (m SetupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) case StateModel: + // When filtering, let keys pass through to the list + if m.modelList.FilterState() == list.Filtering { + var cmd tea.Cmd + m.modelList, cmd = m.modelList.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } if msg.String() == "enter" { sel := m.modelList.SelectedItem() if sel != nil { @@ -352,6 +366,22 @@ func (m SetupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.modelList.SetSize(msg.Width-4, 15) } + // Forward non-key/non-mouse messages (e.g. list.FilterMatchesMsg) to active list + if _, isKey := msg.(tea.KeyPressMsg); !isKey { + if _, isMouse := msg.(tea.MouseMsg); !isMouse { + switch m.state { + case StateProvider: + var cmd tea.Cmd + m.providerList, cmd = m.providerList.Update(msg) + cmds = append(cmds, cmd) + case StateModel: + var cmd tea.Cmd + m.modelList, cmd = m.modelList.Update(msg) + cmds = append(cmds, cmd) + } + } + } + return m, tea.Batch(cmds...) } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 771e5f7..757261a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -183,6 +183,11 @@ type Model struct { showSidebar bool // whether sidebar is currently visible sidebarScrollOffset int // scroll offset for todo list in sidebar sidebarComp *SidebarComponent + + // βββ Manage models state βββ + managingModels bool + manageModelsPicker list.Model + manageModelsProvider string // currently selected provider ID for filtering } // --- Content line types for resize-aware rendering --- @@ -191,7 +196,7 @@ type Model struct { // Most lines are plain rendered text; tool results are stored as structured // data so they can be re-rendered when the terminal width changes. type contentLine struct { - text string // plain rendered text (default) + text string // plain rendered text (default) tool *toolResultData // non-nil for tool results that need dynamic rendering } @@ -253,17 +258,25 @@ func (i dirItem) Description() string { return i.desc } func (i dirItem) FilterValue() string { return i.title } type modelItem struct { - provider string - model string - title string - desc string - isCurrent bool // currently active model - isAction bool // action item (e.g. "Add New Model") + provider string + model string + title string + desc string + isCurrent bool // currently active model + isAction bool // action item (e.g. "Add New Model") + actionID string // action identifier (e.g. "add_model", "manage_models") + isProviderHeader bool // provider group header } func (i modelItem) Title() string { return i.title } func (i modelItem) Description() string { return i.desc } -func (i modelItem) FilterValue() string { return i.provider + "/" + i.model + " " + i.title } +func (i modelItem) FilterValue() string { + // Provider headers should not be filterable individually, but should match if any of their models match + if i.isProviderHeader { + return "" + } + return i.model + " " + i.title +} // settingItem is used for the /setting menu type settingItem struct { @@ -379,6 +392,7 @@ func NewModel(hasPrompt bool, pwd string, todoStore *tools.TodoStore) Model { ml := list.New([]list.Item{}, modelDel, 0, 0) ml.Title = "Select Model" ml.SetShowHelp(false) + ml.SetFilteringEnabled(true) // Setting menu list settingDel := list.NewDefaultDelegate() @@ -514,7 +528,7 @@ func (m *Model) confirmCancelAgent() { } func (m Model) inputActive() bool { - return (m.mode == ModeAgent || m.sshStep > 0 || m.sshSavePrompt) && !m.pickingModel && !m.showingSetting && !m.showingHelp && !m.pickingSSHAlias && !m.pickingSession && !m.approvalPending && !m.planReviewActive && !m.askUserActive + return (m.mode == ModeAgent || m.sshStep > 0 || m.sshSavePrompt) && !m.pickingModel && !m.managingModels && !m.showingSetting && !m.showingHelp && !m.pickingSSHAlias && !m.pickingSession && !m.approvalPending && !m.planReviewActive && !m.askUserActive } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen @@ -882,6 +896,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen case "switch_model": m.showingSetting = false return m.handleModelInput(cmds) + case "manage_models": + m.showingSetting = false + return m.openManageModels(cmds) case "add_model": m.showingSetting = false m.textarea.Focus() @@ -991,23 +1008,46 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen return m, tea.Batch(cmds...) } + if m.managingModels { + return m.handleManageModelsKey(msg, cmds) + } + if m.pickingModel { + // When the list is actively filtering, let all keys pass through to the list + if m.modelPicker.FilterState() == list.Filtering { + var cmd tea.Cmd + m.modelPicker, cmd = m.modelPicker.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } switch msg.String() { case "enter": selected := m.modelPicker.SelectedItem() if selected != nil { selItem := selected.(modelItem) + // Skip provider headers + if selItem.isProviderHeader { + return m, tea.Batch(cmds...) + } if selItem.isAction { - // "Add New Model" β launch setup wizard + m.modelPicker.ResetFilter() m.pickingModel = false m.textarea.Focus() - cmds = append(cmds, func() tea.Msg { - return AddModelMsg{} - }) + switch selItem.actionID { + case "manage_models": + // Open model management view + return m.openManageModels(cmds) + default: + // "Add New Provider" β launch setup wizard + cmds = append(cmds, func() tea.Msg { + return AddModelMsg{} + }) + } return m, tea.Batch(cmds...) } if selItem.isCurrent { // Already active β just close picker + m.modelPicker.ResetFilter() m.pickingModel = false m.textarea.Focus() m.refreshViewport() @@ -1019,11 +1059,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen _ = config.SaveConfig(cfg) m.activeProvider = selItem.provider m.activeModel = selItem.model + // Track in recent models. + if state, err := config.LoadModelState(); err == nil { + state.AddRecent(config.ModelRef{Provider: selItem.provider, Model: selItem.model}) + _ = config.SaveModelState(state) + } select { case configCh <- cfg: default: } } + m.modelPicker.ResetFilter() m.pickingModel = false m.lines = append(m.lines, textLine(fmt.Sprintf(" %s Switched to %s", toolSuccessStyle.Render("β"), @@ -1033,6 +1079,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen return m, tea.Batch(cmds...) } case "ctrl+c", "esc": + // Reset filter state before closing + m.modelPicker.ResetFilter() m.pickingModel = false m.textarea.Focus() m.refreshViewport() @@ -1041,6 +1089,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen var cmd tea.Cmd m.modelPicker, cmd = m.modelPicker.Update(msg) cmds = append(cmds, cmd) + + // Skip provider headers when navigating with arrow keys + if msg.String() == "up" || msg.String() == "down" { + if selected := m.modelPicker.SelectedItem(); selected != nil { + if selItem, ok := selected.(modelItem); ok && selItem.isProviderHeader { + // Item is a header, move again in the same direction + m.modelPicker, cmd = m.modelPicker.Update(msg) + cmds = append(cmds, cmd) + // Check again in case there are consecutive headers + if selected := m.modelPicker.SelectedItem(); selected != nil { + if selItem, ok := selected.(modelItem); ok && selItem.isProviderHeader { + m.modelPicker, cmd = m.modelPicker.Update(msg) + cmds = append(cmds, cmd) + } + } + } + } + } + return m, tea.Batch(cmds...) } @@ -2340,6 +2407,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:funlen } + // Forward non-key/non-mouse messages (e.g. list.FilterMatchesMsg) to active pickers + if _, isKey := msg.(tea.KeyPressMsg); !isKey { + if _, isMouse := msg.(tea.MouseMsg); !isMouse { + switch { + case m.managingModels: + var cmd tea.Cmd + m.manageModelsPicker, cmd = m.manageModelsPicker.Update(msg) + cmds = append(cmds, cmd) + case m.pickingModel: + var cmd tea.Cmd + m.modelPicker, cmd = m.modelPicker.Update(msg) + cmds = append(cmds, cmd) + case m.pickingSession: + var cmd tea.Cmd + m.sessionPicker, cmd = m.sessionPicker.Update(msg) + cmds = append(cmds, cmd) + } + } + } + if m.ready && m.mode == ModeAgent { var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) @@ -2454,6 +2541,10 @@ func (m Model) View() tea.View { return m.newView(m.modelPickerView()) } + if m.managingModels { + return m.newView(m.manageModelsView()) + } + if m.pickingSession { return m.newView(m.sessionPickerView()) } diff --git a/internal/web/server.go b/internal/web/server.go index 80acc42..c42e6bc 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -39,9 +39,9 @@ type Server struct { host string openBrowser bool pwd string - handler *handler.WebHandler - broker *SSEBroker - wsBroker *WSBroker + handler *handler.WebHandler + broker *SSEBroker + wsBroker *WSBroker mu sync.RWMutex agent *adk.ChatModelAgent @@ -96,6 +96,10 @@ type Server struct { // eventHandler is the handler passed to the runner β may be a NotifyingHandler // wrapping the WebHandler, or the WebHandler itself. eventHandler handler.AgentEventHandler + + // needsSetup is true when no providers are configured. The server starts in + // setup mode and exposes setup API endpoints while blocking chat operations. + needsSetup bool } // ServerConfig holds the configuration for creating a new Server. @@ -120,6 +124,7 @@ type ServerConfig struct { WechatClient channel.Channel // optional WeChat channel WebHandler *handler.WebHandler // optional: pre-created handler for sharing with tools EventHandler handler.AgentEventHandler // optional: handler for runner (e.g. NotifyingHandler) + NeedsSetup bool // true when no providers are configured (setup mode) } // NewServer creates a new web server. @@ -158,6 +163,7 @@ func NewServer(cfg *ServerConfig) *Server { disabledMCP: make(map[string]bool), wechatClient: cfg.WechatClient, eventHandler: eh, + needsSetup: cfg.NeedsSetup, } // Wire TodoStore β session recording. @@ -232,6 +238,23 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("POST /api/channel/enable", s.handleChannelEnable) mux.HandleFunc("POST /api/channel/disable", s.handleChannelDisable) + // Setup API β available in setup mode (no provider configured yet). + mux.HandleFunc("GET /api/setup/providers", s.handleSetupProviders) + mux.HandleFunc("GET /api/setup/providers/{id}/models", s.handleSetupProviderModels) + mux.HandleFunc("POST /api/setup/complete", s.handleSetupComplete) + mux.HandleFunc("GET /api/setup/status", s.handleSetupStatus) + mux.HandleFunc("POST /api/setup/validate", s.handleSetupValidate) + + // Provider management API β add/remove providers after initial setup. + mux.HandleFunc("GET /api/providers", s.handleListProviders) + mux.HandleFunc("POST /api/providers", s.handleAddProvider) + mux.HandleFunc("DELETE /api/providers/{id}", s.handleDeleteProvider) + + // Model state API β favorites & recent. + mux.HandleFunc("GET /api/model-state", s.handleGetModelState) + mux.HandleFunc("POST /api/model-state/favorite", s.handleToggleFavorite) + mux.HandleFunc("POST /api/model-state/enabled", s.handleToggleModelEnabled) + // Serve embedded frontend (SPA with fallback to index.html) mux.Handle("GET /", newSPAHandler()) @@ -304,6 +327,21 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { } s.mu.RUnlock() + if s.needsSetup { + writeJSON(w, http.StatusOK, map[string]any{ + "status": "needs_setup", + "version": "0.2.0", + "pwd": s.pwd, + "provider": "", + "model": "", + "mode": "build", + "session_id": "", + "running": false, + "needs_setup": true, + }) + return + } + writeJSON(w, http.StatusOK, map[string]any{ "status": "ok", "version": "0.2.0", @@ -335,6 +373,11 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { + if s.needsSetup { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "setup required: please configure a provider first"}) + return + } + // Use CompareAndSwap to atomically check and set running, preventing // two concurrent requests from both entering submitMessage. if !s.running.CompareAndSwap(false, true) { @@ -630,10 +673,14 @@ func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { } type modelInfo struct { - ID string `json:"id"` - Name string `json:"name"` - ToolCall bool `json:"tool_call"` - ContextLimit int `json:"context_limit,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + ToolCall bool `json:"tool_call"` + ContextLimit int `json:"context_limit,omitempty"` + Reasoning bool `json:"reasoning,omitempty"` + Recommended bool `json:"recommended,omitempty"` + DefaultEnabled bool `json:"default_enabled,omitempty"` + Enabled bool `json:"enabled"` } type providerInfo struct { ID string `json:"id"` @@ -641,26 +688,34 @@ func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { Models []modelInfo `json:"models"` } + modelState, _ := config.LoadModelState() + var result []providerInfo - providers := s.cfg.GetProviders() - for name := range providers { - models := s.registry.ListProviderModels(name, true) + configuredProviders := s.cfg.GetProviders() + for _, rp := range s.registry.ListProviders() { + if _, configured := configuredProviders[rp.ID]; !configured { + continue + } + models := s.registry.ListProviderModels(rp.ID, true) if len(models) == 0 { continue } - pi := providerInfo{ID: name, Name: name} + pi := providerInfo{ID: rp.ID, Name: rp.Name} for _, m := range models { ctx := 0 if m.Limit != nil { ctx = m.Limit.Context } + ref := config.ModelRef{Provider: rp.ID, Model: m.ID} + enabled := modelState.IsModelEnabled(ref, m.DefaultEnabled) pi.Models = append(pi.Models, modelInfo{ ID: m.ID, Name: m.Name, ToolCall: m.ToolCall, ContextLimit: ctx, + Reasoning: m.Reasoning, Recommended: m.Recommended, + DefaultEnabled: m.DefaultEnabled, Enabled: enabled, }) } result = append(result, pi) } - sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) writeJSON(w, http.StatusOK, map[string]any{ "current": map[string]string{"provider": s.providerName, "model": s.modelName}, @@ -700,6 +755,12 @@ func (s *Server) handleSwitchModel(w http.ResponseWriter, r *http.Request) { // Keep history β allow continuing the conversation with a different model. s.mu.Unlock() + // Track in recent models. + if state, err := config.LoadModelState(); err == nil { + state.AddRecent(config.ModelRef{Provider: req.Provider, Model: req.Model}) + _ = config.SaveModelState(state) + } + // Notify clients. s.broker.Broadcast(SSEEvent{Event: "model_changed", Data: map[string]string{ "provider": req.Provider, @@ -1473,6 +1534,444 @@ func (s *Server) handleListSkills(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, items) } +// --- Setup & Provider Management Handlers --- + +// handleSetupStatus returns whether the server is in setup mode. +func (s *Server) handleSetupStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "needs_setup": s.needsSetup, + }) +} + +// handleSetupValidate tests connectivity to a provider with the given API key. +func (s *Server) handleSetupValidate(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + APIKey string `json:"api_key"` + BaseURL string `json:"base_url,omitempty"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if req.APIKey == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "api_key is required"}) + return + } + + baseURL := req.BaseURL + if baseURL == "" && s.registry != nil { + baseURL = s.registry.GetProviderAPI(req.Provider) + } + if baseURL == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no base URL available for this provider"}) + return + } + + if err := model.ValidateProvider(r.Context(), req.APIKey, baseURL); err != nil { + writeJSON(w, http.StatusOK, map[string]any{ + "valid": false, + "error": err.Error(), + }) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "valid": true, + }) +} + +// handleSetupProviders returns all available providers from the registry. +func (s *Server) handleSetupProviders(w http.ResponseWriter, r *http.Request) { + if s.registry == nil { + writeJSON(w, http.StatusOK, []any{}) + return + } + + type providerItem struct { + ID string `json:"id"` + Name string `json:"name"` + Doc string `json:"doc,omitempty"` + API string `json:"api,omitempty"` + Env []string `json:"env,omitempty"` + Configured bool `json:"configured"` + Tag string `json:"tag,omitempty"` // "recommended", "free", "local" + } + + providers := s.registry.ListProviders() + cfg, _ := config.LoadConfig() + configured := map[string]bool{} + if cfg != nil { + for k := range cfg.GetProviders() { + configured[k] = true + } + } + + // Provider tags for recommendation. + tags := map[string]string{ + "openai": "recommended", + "anthropic": "recommended", + "ollama": "local", + } + + result := make([]providerItem, 0, len(providers)) + for _, p := range providers { + result = append(result, providerItem{ + ID: p.ID, + Name: p.Name, + Doc: p.Doc, + API: p.API, + Env: p.Env, + Configured: configured[p.ID], + Tag: tags[p.ID], + }) + } + + // Sort: configured first, then by tag (recommended > local > ""), then by name. + sort.SliceStable(result, func(i, j int) bool { + ri, rj := result[i], result[j] + if ri.Configured != rj.Configured { + return ri.Configured + } + tagOrder := map[string]int{"recommended": 0, "local": 1, "": 2} + oi, _ := tagOrder[ri.Tag] + oj, _ := tagOrder[rj.Tag] + if oi != oj { + return oi < oj + } + return ri.Name < rj.Name + }) + + writeJSON(w, http.StatusOK, result) +} + +// handleSetupProviderModels returns models for a specific provider from the registry. +func (s *Server) handleSetupProviderModels(w http.ResponseWriter, r *http.Request) { + providerID := r.PathValue("id") + if providerID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider id is required"}) + return + } + + if s.registry == nil { + writeJSON(w, http.StatusOK, []any{}) + return + } + + models := s.registry.ListProviderModels(providerID, true) + type modelItem struct { + ID string `json:"id"` + Name string `json:"name"` + ToolCall bool `json:"tool_call"` + ContextLimit int `json:"context_limit,omitempty"` + Reasoning bool `json:"reasoning,omitempty"` + } + + result := make([]modelItem, 0, len(models)) + for _, m := range models { + ctx := 0 + if m.Limit != nil { + ctx = m.Limit.Context + } + result = append(result, modelItem{ + ID: m.ID, + Name: m.Name, + ToolCall: m.ToolCall, + ContextLimit: ctx, + Reasoning: m.Reasoning, + }) + } + + writeJSON(w, http.StatusOK, result) +} + +// handleSetupComplete handles the initial setup submission. +// It saves the provider config and creates the agent. +func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + Model string `json:"model"` + APIKey string `json:"api_key"` + BaseURL string `json:"base_url,omitempty"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if req.Provider == "" || req.Model == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model are required"}) + return + } + + // Build or update config. + var cfg *config.Config + cfg, err := config.LoadConfig() + if err != nil { + // First time β create fresh config. + cfg = &config.Config{ + MaxIterations: 1000, + } + } + + if cfg.Providers == nil { + cfg.Providers = make(map[string]*config.ProviderConfig) + } + cfg.Providers[req.Provider] = &config.ProviderConfig{ + APIKey: req.APIKey, + BaseURL: req.BaseURL, + } + cfg.Model = req.Provider + "/" + req.Model + + if err := config.SaveConfig(cfg); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + err.Error()}) + return + } + + // Create the agent with the new config. + ag, err := s.createAgent(req.Provider, req.Model) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create agent: " + err.Error()}) + return + } + + s.mu.Lock() + s.agent = ag + s.providerName = req.Provider + s.modelName = req.Model + s.needsSetup = false + s.mu.Unlock() + + // Notify clients that setup is complete. + s.broker.Broadcast(SSEEvent{Event: "model_changed", Data: map[string]string{ + "provider": req.Provider, + "model": req.Model, + }}) + s.wsBroker.Broadcast(WSEvent{Type: "model_changed", Data: map[string]string{ + "provider": req.Provider, + "model": req.Model, + }}) + + writeJSON(w, http.StatusOK, map[string]string{ + "status": "ok", + "provider": req.Provider, + "model": req.Model, + }) +} + +// handleListProviders returns all configured providers (key masked). +func (s *Server) handleListProviders(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig() + if err != nil { + writeJSON(w, http.StatusOK, []any{}) + return + } + + type providerDetail struct { + ID string `json:"id"` + APIKeySet bool `json:"api_key_set"` + APIKey string `json:"api_key,omitempty"` // masked + BaseURL string `json:"base_url,omitempty"` + } + + result := make([]providerDetail, 0) + for id, pc := range cfg.GetProviders() { + detail := providerDetail{ + ID: id, + APIKeySet: pc.APIKey != "", + BaseURL: pc.BaseURL, + } + if pc.APIKey != "" { + // Mask API key: show first 4 and last 4 chars. + key := pc.APIKey + if len(key) > 8 { + detail.APIKey = key[:4] + "..." + key[len(key)-4:] + } else { + detail.APIKey = "****" + } + } + result = append(result, detail) + } + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + + writeJSON(w, http.StatusOK, result) +} + +// handleAddProvider adds a new provider to the config. +func (s *Server) handleAddProvider(w http.ResponseWriter, r *http.Request) { + var req struct { + ID string `json:"id"` + APIKey string `json:"api_key"` + BaseURL string `json:"base_url,omitempty"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if req.ID == "" || req.APIKey == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id and api_key are required"}) + return + } + + cfg, err := config.LoadConfig() + if err != nil { + cfg = &config.Config{MaxIterations: 1000} + } + if cfg.Providers == nil { + cfg.Providers = make(map[string]*config.ProviderConfig) + } + cfg.Providers[req.ID] = &config.ProviderConfig{ + APIKey: req.APIKey, + BaseURL: req.BaseURL, + } + if err := config.SaveConfig(cfg); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// handleDeleteProvider removes a provider from the config. +func (s *Server) handleDeleteProvider(w http.ResponseWriter, r *http.Request) { + providerID := r.PathValue("id") + if providerID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider id is required"}) + return + } + + cfg, err := config.LoadConfig() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + providers := cfg.GetProviders() + if providers == nil || providers[providerID] == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"}) + return + } + + // Don't allow deleting the active provider. + activeProvider, _ := cfg.GetProviderModel() + if activeProvider == providerID { + remaining := 0 + for k := range providers { + if k != providerID { + remaining++ + } + } + if remaining == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot delete the only provider"}) + return + } + } + + delete(cfg.Providers, providerID) + if err := config.SaveConfig(cfg); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// handleGetModelState returns the recent, favorite, and visibility settings. +func (s *Server) handleGetModelState(w http.ResponseWriter, r *http.Request) { + state, err := config.LoadModelState() + if err != nil { + state = &config.ModelState{} + } + type modelRefJSON struct { + Provider string `json:"provider"` + Model string `json:"model"` + } + + recent := make([]modelRefJSON, 0, len(state.Recent)) + for _, r := range state.Recent { + recent = append(recent, modelRefJSON{Provider: r.Provider, Model: r.Model}) + } + favorites := make([]modelRefJSON, 0, len(state.Favorite)) + for _, r := range state.Favorite { + favorites = append(favorites, modelRefJSON{Provider: r.Provider, Model: r.Model}) + } + enabledModels := make([]modelRefJSON, 0, len(state.EnabledModels)) + for _, r := range state.EnabledModels { + enabledModels = append(enabledModels, modelRefJSON{Provider: r.Provider, Model: r.Model}) + } + disabledModels := make([]modelRefJSON, 0, len(state.DisabledModels)) + for _, r := range state.DisabledModels { + disabledModels = append(disabledModels, modelRefJSON{Provider: r.Provider, Model: r.Model}) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "recent": recent, + "favorite": favorites, + "enabled_models": enabledModels, + "disabled_models": disabledModels, + }) +} + +// handleToggleFavorite toggles a model in the favorites list. +func (s *Server) handleToggleFavorite(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + Model string `json:"model"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if req.Provider == "" || req.Model == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model are required"}) + return + } + + state, err := config.LoadModelState() + if err != nil { + state = &config.ModelState{} + } + nowFavorite := state.ToggleFavorite(config.ModelRef{Provider: req.Provider, Model: req.Model}) + if err := config.SaveModelState(state); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save"}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "favorite": nowFavorite, + }) +} + +// handleToggleModelEnabled toggles whether a model is shown in the model selector. +func (s *Server) handleToggleModelEnabled(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + Model string `json:"model"` + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if req.Provider == "" || req.Model == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model are required"}) + return + } + + state, err := config.LoadModelState() + if err != nil { + state = &config.ModelState{} + } + state.SetModelEnabled(config.ModelRef{Provider: req.Provider, Model: req.Model}, req.Enabled) + if err := config.SaveModelState(state); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save"}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "enabled": req.Enabled, + }) +} + // --- Helpers --- func writeJSON(w http.ResponseWriter, status int, data any) { diff --git a/web/src/App.vue b/web/src/App.vue index d92d214..31a64c2 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -15,6 +15,7 @@ import SettingsDialog from '@/components/SettingsDialog.vue' import ProjectSwitcher from '@/components/ProjectSwitcher.vue' import TerminalPanel from '@/components/TerminalPanel.vue' import DiffViewer from '@/components/DiffViewer.vue' +import SetupView from '@/components/SetupView.vue' const store = useChatStore() const projectStore = useProjectStore() @@ -26,6 +27,7 @@ const fileViewerOpen = ref(false) const fileViewerPath = ref('') const fileViewerContent = ref('') const sidebarCollapsed = ref(false) +const needsSetup = ref(false) const bottomPanel = ref<'none' | 'terminal' | 'diff'>('none') const bottomPanelHeight = ref(260) @@ -124,10 +126,16 @@ function handleGlobalKeydown(e: KeyboardEvent) { onMounted(async () => { document.addEventListener('keydown', handleGlobalKeydown) - await store.fetchHealth() + const health = await store.fetchHealth() + // Check if setup is needed β health returns needs_setup status + if ((health as any)?.needs_setup) { + needsSetup.value = true + return + } store.fetchConfig() store.fetchTodos() store.fetchModels() + store.fetchModelState() store.fetchSessions() store.fetchApprovalMode() store.fetchChannelState() @@ -161,6 +169,21 @@ async function onProjectSwitched() { await store.restoreCurrentSession() } +function onSetupComplete() { + needsSetup.value = false + // Now load everything + store.fetchConfig() + store.fetchTodos() + store.fetchModels() + store.fetchModelState() + store.fetchSessions() + store.fetchApprovalMode() + store.fetchChannelState() + if (store.pwd) { + projectStore.ensureCurrentProject(store.pwd) + } +} + // Panel resize function startResize(e: MouseEvent) { e.preventDefault() @@ -342,5 +365,8 @@ function startResize(e: MouseEvent) { + + + diff --git a/web/src/components/ChatInput.vue b/web/src/components/ChatInput.vue index bc7549b..e19022a 100644 --- a/web/src/components/ChatInput.vue +++ b/web/src/components/ChatInput.vue @@ -9,6 +9,8 @@ const input = ref('') const textarea = ref(null) const showModelPicker = ref(false) const showModePicker = ref(false) +const showManageModels = ref(false) +const modelFilter = ref('') const containerRef = ref(null) const skills = ref([]) @@ -28,6 +30,32 @@ const filteredSlashCommands = computed(() => { ) }) +const filteredProviders = computed(() => { + const filter = modelFilter.value.toLowerCase() + if (!filter) return store.providers + + return store.providers + .map(p => ({ + ...p, + models: p.models.filter(m => + (m.name || m.id).toLowerCase().includes(filter) || + p.name.toLowerCase().includes(filter) + ) + })) + .filter(p => p.models.length > 0) +}) + +// Get full display name for a model (e.g., "DeepSeek V4 Pro") +function getModelDisplayName(providerId: string, modelId: string): string { + for (const p of store.providers) { + if (p.id === providerId) { + const m = p.models.find(model => model.id === modelId) + return m?.name || modelId + } + } + return modelId +} + function autoResize() { const el = textarea.value if (!el) return @@ -36,6 +64,26 @@ function autoResize() { } function handleKeyDown(e: KeyboardEvent) { + // Handle ESC key for dialogs + if (e.key === 'Escape') { + if (showManageModels.value) { + e.preventDefault() + showManageModels.value = false + modelFilter.value = '' + return + } + if (showModelPicker.value) { + e.preventDefault() + showModelPicker.value = false + return + } + if (showSlashMenu.value) { + e.preventDefault() + showSlashMenu.value = false + return + } + } + if (showSlashMenu.value) { if (e.key === 'ArrowDown') { e.preventDefault() @@ -111,6 +159,10 @@ function handleClickOutside(e: MouseEvent) { showModelPicker.value = false showModePicker.value = false showSlashMenu.value = false + if (showManageModels.value) { + showManageModels.value = false + modelFilter.value = '' + } } } @@ -119,6 +171,20 @@ function handleGlobalKey(e: KeyboardEvent) { e.preventDefault() textarea.value?.focus() } + // Global ESC handler for dialogs + if (e.key === 'Escape') { + if (showManageModels.value) { + e.preventDefault() + showManageModels.value = false + modelFilter.value = '' + return + } + if (showModelPicker.value) { + e.preventDefault() + showModelPicker.value = false + return + } + } } onMounted(async () => { @@ -220,28 +286,124 @@ watch(() => store.isRunning, (running) => { v-if="showModelPicker" class="absolute bottom-full mb-1 left-0 z-20 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-md shadow-lg dark:shadow-2xl py-1.5 max-h-72 overflow-y-auto min-w-56" > - - + + + + β Favorites + + + β {{ getModelDisplayName(r.provider, r.model) }} + + + + + + + Current Model + + + β {{ getModelDisplayName(store.providerName, store.modelName) }} + + + + + + {{ p.name }} - {{ m.name || m.id }} + {{ m.name || m.id }} + recommended + β - + No models available + + + + β Manage modelsβ¦ + + + + + + + + + + Manage Models + Toggle which models appear in the model selector + + β + + + + + + + {{ p.name }} + + + + {{ m.name || m.id }} + recommended + + + + + + + () const store = useChatStore() -const activeTab = ref<'general' | 'mcp' | 'ssh' | 'channels' | 'shortcuts'>('general') +const activeTab = ref<'general' | 'providers' | 'mcp' | 'ssh' | 'channels' | 'shortcuts'>('general') const mcpServers = ref>({}) const sshAliases = ref([]) const sshCurrent = ref('local') @@ -34,6 +34,21 @@ const channelQRContent = ref('') const channelLoginReminder = ref(false) const qrCanvas = ref(null) +// Provider management state +const configuredProviders = ref([]) +const showAddProvider = ref(false) +const addProviderStep = ref<'select' | 'model' | 'apikey'>('select') +const addProviderList = ref([]) +const addProviderModels = ref([]) +const addSelectedProvider = ref('') +const addSelectedModel = ref('') +const addApiKey = ref('') +const addBaseURL = ref('') +const addShowApiKey = ref(false) +const addLoading = ref(false) +const addError = ref('') +const deleteConfirmId = ref('') + watch(() => props.open, async (isOpen) => { if (isOpen) { mcpLoading.value = true @@ -54,8 +69,16 @@ watch(() => props.open, async (isOpen) => { channelAvailable.value = ch.available channelState.value = ch.state ?? 'none' } catch { /* ignore */ } + + // Load configured providers + try { + configuredProviders.value = await api.listProviders() + } catch { /* ignore */ } } else { channelQRContent.value = '' + showAddProvider.value = false + addError.value = '' + deleteConfirmId.value = '' } }) @@ -146,11 +169,78 @@ function pollChannelState() { const tabLabel: Record = { general: 'General', + providers: 'Providers', mcp: 'MCP Servers', ssh: 'SSH', channels: 'Channels', shortcuts: 'Shortcuts', } + +async function startAddProvider() { + showAddProvider.value = true + addProviderStep.value = 'select' + addSelectedProvider.value = '' + addSelectedModel.value = '' + addApiKey.value = '' + addBaseURL.value = '' + addError.value = '' + addLoading.value = true + try { + addProviderList.value = await api.setupProviders() + } catch { /* ignore */ } + addLoading.value = false +} + +async function selectAddProvider(id: string) { + addSelectedProvider.value = id + addLoading.value = true + addError.value = '' + try { + addProviderModels.value = await api.setupProviderModels(id) + addProviderStep.value = 'model' + } catch { + addError.value = 'Failed to load models' + } + addLoading.value = false +} + +function selectAddModel(id: string) { + addSelectedModel.value = id + addProviderStep.value = 'apikey' +} + +async function submitAddProvider() { + addLoading.value = true + addError.value = '' + try { + await api.addProvider({ + id: addSelectedProvider.value, + api_key: addApiKey.value, + base_url: addBaseURL.value || undefined, + }) + // Refresh provider list + configuredProviders.value = await api.listProviders() + showAddProvider.value = false + // Also refresh models in the chat store + store.fetchModels() + } catch (err: unknown) { + addError.value = err instanceof Error ? err.message : 'Failed to add provider' + } + addLoading.value = false +} + +async function deleteProvider(id: string) { + try { + await api.deleteProvider(id) + configuredProviders.value = configuredProviders.value.filter(p => p.id !== id) + deleteConfirmId.value = '' + store.fetchModels() + } catch (err: unknown) { + console.error('Failed to delete provider:', err) + } +} + +const addProviderInfo = () => addProviderList.value.find(p => p.id === addSelectedProvider.value) @@ -183,7 +273,7 @@ const tabLabel: Record = { + + Providers + + + Add Provider + + + + + + + + {{ addProviderStep === 'select' ? 'Select Provider' : addProviderStep === 'model' ? 'Select Model' : 'Enter API Key' }} + + β + + + + + Loading... + + + {{ p.name }} + {{ p.id }} + + + All providers configured + + + + + + + + + + {{ addProviderInfo()?.name }} + + Loading... + + + {{ m.id }} + + + + + + + + + + {{ addSelectedProvider }} / {{ addSelectedModel }} + + + + {{ addError }} + + {{ addLoading ? 'Saving...' : 'Add' }} + + + + + + + + No providers configured + + Click "Add Provider" above to get started. + + + + + π + + {{ p.id }} + + {{ p.api_key || 'β' }} + Β· {{ p.base_url }} + + + + active + + + + + + Delete + Cancel + + + + + MCP Servers diff --git a/web/src/components/SetupView.vue b/web/src/components/SetupView.vue new file mode 100644 index 0000000..82ed271 --- /dev/null +++ b/web/src/components/SetupView.vue @@ -0,0 +1,378 @@ + + + + + + + + [JCODE] + + + + + + + + + + You're all set! + + Using {{ selectedModel }} via {{ selectedProviderInfo?.name || selectedProvider }} + + + Start coding + + + + + + + + + + + + {{ step === 'provider' ? 'Step 1: Choose Provider' : step === 'model' ? 'Step 2: Choose Model' : 'Step 3: API Key' }} + + + + + + Choose a Provider + Select the AI provider you'd like to use. + + + + Loading providers... + No providers found + + + + + {{ p.name }} + {{ p.doc }} + + + Recommended + Local + configured + + + + + + + + + + + + + + + + + + Choose a Model + + For {{ selectedProviderInfo?.name }} + + + + Loading models... + No models found + + + + + {{ m.id }} + {{ m.name }} + + + {{ (m.context_limit / 1000).toFixed(0) }}k ctx + reasoning + + + + + + + + + + + + + + + + + + Enter API Key + + + For {{ selectedProviderInfo?.name }} Β· {{ selectedModel }} + + + + + Environment variable + {{ selectedProviderInfo.env[0] }} + + + + API Key + + + + + + + + + + + + + + + + + Base URL (optional) + + + + + + + {{ validating ? 'Checking...' : 'Test Connection' }} + + + + Connected + + {{ validationResult.error }} + + + + + {{ error }} + + + + {{ loading ? 'Setting up...' : 'Complete Setup' }} + + + + + + + + Configuration saved to ~/.jcode/config.json + + + + diff --git a/web/src/composables/api.ts b/web/src/composables/api.ts index a5ea313..372a63d 100644 --- a/web/src/composables/api.ts +++ b/web/src/composables/api.ts @@ -1,5 +1,5 @@ // API client for jcode backend -import type { ModelsResponse, AgentMode, ExecResponse, DiffResponse, MCPListResponse, BrowseResponse, SSHListResponse, SkillInfo, TodoItem, SessionItem, SessionEntry, FileItem } from '@/types/api' +import type { ModelsResponse, AgentMode, ExecResponse, DiffResponse, MCPListResponse, BrowseResponse, SSHListResponse, SkillInfo, TodoItem, SessionItem, SessionEntry, FileItem, SetupProvider, SetupModel, ProviderDetail, ModelStateResponse } from '@/types/api' const BASE = '' @@ -123,4 +123,47 @@ export const api = { request<{ status: string; state: string }>('/api/channel/enable', { method: 'POST' }), channelDisable: () => request<{ status: string; state: string }>('/api/channel/disable', { method: 'POST' }), + + // Setup API + setupProviders: () => + request('/api/setup/providers'), + setupProviderModels: (providerId: string) => + request(`/api/setup/providers/${encodeURIComponent(providerId)}/models`), + setupComplete: (data: { provider: string; model: string; api_key: string; base_url?: string }) => + request<{ status: string; provider: string; model: string }>('/api/setup/complete', { + method: 'POST', + body: JSON.stringify(data), + }), + setupStatus: () => + request<{ needs_setup: boolean }>('/api/setup/status'), + setupValidate: (data: { provider: string; api_key: string; base_url?: string }) => + request<{ valid: boolean; error?: string }>('/api/setup/validate', { + method: 'POST', + body: JSON.stringify(data), + }), + + // Provider management + listProviders: () => + request('/api/providers'), + addProvider: (data: { id: string; api_key: string; base_url?: string }) => + request<{ status: string }>('/api/providers', { + method: 'POST', + body: JSON.stringify(data), + }), + deleteProvider: (id: string) => + request<{ status: string }>(`/api/providers/${encodeURIComponent(id)}`, { method: 'DELETE' }), + + // Model state + modelState: () => + request('/api/model-state'), + toggleFavorite: (provider: string, model: string) => + request<{ favorite: boolean }>('/api/model-state/favorite', { + method: 'POST', + body: JSON.stringify({ provider, model }), + }), + toggleModelEnabled: (provider: string, model: string, enabled: boolean) => + request<{ enabled: boolean }>('/api/model-state/enabled', { + method: 'POST', + body: JSON.stringify({ provider, model, enabled }), + }), } diff --git a/web/src/stores/chat.ts b/web/src/stores/chat.ts index e1b1cc3..4d13b2d 100644 --- a/web/src/stores/chat.ts +++ b/web/src/stores/chat.ts @@ -11,6 +11,7 @@ import type { AgentMode, ProviderInfo, ToolDisplayInfo, + ModelRef, } from '@/types/api' import { api } from '@/composables/api' import { extractToolDisplayInfo } from '@/composables/toolInfo' @@ -54,6 +55,10 @@ export const useChatStore = defineStore('chat', () => { const channelAvailable = ref(false) const channelEnabled = ref(false) + // Model favorites & recent + const favoriteModels = ref>(new Set()) + const recentModels = ref([]) + // Current session tracking const currentSessionId = ref('') @@ -314,6 +319,7 @@ export const useChatStore = defineStore('chat', () => { mode.value = m === 'build' ? 'agent' : (m as AgentMode) currentSessionId.value = h.session_id || '' isRunning.value = h.running || false + return h } catch (err) { console.error('Failed to fetch health:', err) } @@ -400,6 +406,67 @@ export const useChatStore = defineStore('chat', () => { } } + async function fetchModelState() { + try { + const data = await api.modelState() + recentModels.value = data.recent || [] + const favs = new Set() + for (const r of data.favorite || []) { + favs.add(`${r.provider}/${r.model}`) + } + favoriteModels.value = favs + } catch { + /* ignore */ + } + } + + async function toggleFavorite(provider: string, model: string) { + try { + const data = await api.toggleFavorite(provider, model) + const key = `${provider}/${model}` + const favs = new Set(favoriteModels.value) + if (data.favorite) { + favs.add(key) + } else { + favs.delete(key) + } + favoriteModels.value = favs + } catch { + /* ignore */ + } + } + + async function toggleModelEnabled(provider: string, model: string, enabled: boolean) { + try { + await api.toggleModelEnabled(provider, model, enabled) + // Update the local providers list to reflect the change + for (const p of providers.value) { + if (p.id === provider) { + const m = p.models.find(m => m.id === model) + if (m) { + m.enabled = enabled + } + } + } + } catch { + /* ignore */ + } + } + + function isFavorite(provider: string, model: string): boolean { + return favoriteModels.value.has(`${provider}/${model}`) + } + + // Providers with only enabled models (for model picker) + const enabledProviders = computed(() => { + return providers.value + .map(p => ({ + ...p, + models: p.models.filter(m => m.enabled !== false), + })) + .filter(p => p.models.length > 0) + }) + /** Restore the current session content if available (called on page load). */ async function restoreCurrentSession() { if (!currentSessionId.value) return @@ -582,5 +649,12 @@ export const useChatStore = defineStore('chat', () => { fetchChannelState, toggleChannel, restoreCurrentSession, + fetchModelState, + toggleFavorite, + toggleModelEnabled, + isFavorite, + recentModels, + favoriteModels, + enabledProviders, } }) diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 0f75549..24c9853 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -90,6 +90,10 @@ export interface ModelInfo { name: string tool_call: boolean context_limit?: number + reasoning?: boolean + recommended?: boolean + default_enabled?: boolean + enabled?: boolean } export interface ProviderInfo { @@ -272,3 +276,42 @@ export interface BrowseResponse { current: string folders: BrowseFolder[] } + +// Setup types +export interface SetupProvider { + id: string + name: string + doc?: string + api?: string + env?: string[] + configured: boolean + tag?: string // "recommended", "local", etc. +} + +export interface SetupModel { + id: string + name: string + tool_call: boolean + context_limit?: number + reasoning?: boolean +} + +export interface ProviderDetail { + id: string + api_key_set: boolean + api_key?: string + base_url?: string +} + +// Model state types +export interface ModelRef { + provider: string + model: string +} + +export interface ModelStateResponse { + recent: ModelRef[] + favorite: ModelRef[] + enabled_models: ModelRef[] + disabled_models: ModelRef[] +}
Toggle which models appear in the model selector
+ Using {{ selectedModel }} via {{ selectedProviderInfo?.name || selectedProvider }} +
Select the AI provider you'd like to use.
For {{ selectedProviderInfo?.name }}
+ For {{ selectedProviderInfo?.name }} Β· {{ selectedModel }} +
+ Configuration saved to ~/.jcode/config.json +