From b7304a2da900fdf234ea5bf6475ffae18791c428 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 22:08:40 +0800 Subject: [PATCH] feat: global provider management + card-based IM provider add - Add global [[providers]] config with project-level provider_refs - Add provider management page in web admin with preset support - Implement card-based /provider add flow: preset selection card with sponsor presets, API key prompt, and manual "Other" option - Add inline button support for Telegram-style platforms - Add remote provider-presets.json with sponsor data - Add REST API endpoints for global providers and provider refs - Update project detail providers tab with unified list UI Made-with: Cursor --- cmd/cc-connect/main.go | 60 +++ cmd/cc-connect/provider.go | 185 ++++++++- config.example.toml | 46 ++- config/config.go | 185 ++++++++- config/config_test.go | 171 ++++++++ core/engine.go | 319 ++++++++++++++- core/i18n.go | 26 ++ core/management.go | 233 +++++++++++ core/provider_presets.go | 120 ++++++ provider-presets.json | 180 +++++++++ web/dist/.keep | 1 - web/src/App.tsx | 2 + web/src/api/client.ts | 1 + web/src/api/projects.ts | 1 + web/src/api/providers.ts | 58 +++ web/src/components/Layout/Sidebar.tsx | 2 + web/src/components/ui/Badge.tsx | 3 +- web/src/i18n/locales/en.json | 35 +- web/src/i18n/locales/es.json | 35 +- web/src/i18n/locales/ja.json | 35 +- web/src/i18n/locales/zh-TW.json | 35 +- web/src/i18n/locales/zh.json | 35 +- web/src/pages/Projects/ProjectDetail.tsx | 175 ++++++-- web/src/pages/Providers/ProviderList.tsx | 484 +++++++++++++++++++++++ web/tsconfig.tsbuildinfo | 2 +- 25 files changed, 2372 insertions(+), 57 deletions(-) create mode 100644 core/provider_presets.go create mode 100644 provider-presets.json delete mode 100644 web/dist/.keep create mode 100644 web/src/pages/Providers/ProviderList.tsx diff --git a/cmd/cc-connect/main.go b/cmd/cc-connect/main.go index 1173a79..13c8078 100644 --- a/cmd/cc-connect/main.go +++ b/cmd/cc-connect/main.go @@ -829,6 +829,7 @@ func main() { }) }) mgmtSrv.SetGetProjectConfig(config.GetProjectConfigDetails) + mgmtSrv.SetSaveProviderRefs(config.SaveProviderRefs) mgmtSrv.SetConfigFilePath(configPath) mgmtSrv.SetGetGlobalSettings(config.GetGlobalSettings) mgmtSrv.SetSaveGlobalSettings(func(updates map[string]any) error { @@ -877,6 +878,30 @@ func main() { } return config.SaveGlobalSettings(u) }) + mgmtSrv.SetListGlobalProviders(func() ([]core.GlobalProviderInfo, error) { + providers, err := config.ListGlobalProviders() + if err != nil { + return nil, err + } + out := make([]core.GlobalProviderInfo, len(providers)) + for i, p := range providers { + out[i] = configProviderToGlobal(p) + } + return out, nil + }) + mgmtSrv.SetAddGlobalProvider(func(info core.GlobalProviderInfo) error { + return config.AddGlobalProvider(globalProviderToConfig(info)) + }) + mgmtSrv.SetUpdateGlobalProvider(func(name string, info core.GlobalProviderInfo) error { + return config.UpdateGlobalProvider(name, globalProviderToConfig(info)) + }) + mgmtSrv.SetRemoveGlobalProvider(func(name string) error { + return config.RemoveGlobalProvider(name) + }) + mgmtSrv.SetFetchPresets(core.FetchProviderPresets) + if cfg.ProviderPresetsURL != "" { + core.SetPresetsURL(cfg.ProviderPresetsURL) + } mgmtSrv.Start() } @@ -1477,6 +1502,41 @@ func startInitialRefreshIfReady(agent core.Agent, result providerWiringResult) { } } +func configProviderToGlobal(p config.ProviderConfig) core.GlobalProviderInfo { + info := core.GlobalProviderInfo{ + Name: p.Name, + APIKey: p.APIKey, + BaseURL: p.BaseURL, + Model: p.Model, + Thinking: p.Thinking, + Env: p.Env, + AgentTypes: p.AgentTypes, + } + for _, m := range p.Models { + info.Models = append(info.Models, struct { + Model string `json:"model"` + Alias string `json:"alias,omitempty"` + }{Model: m.Model, Alias: m.Alias}) + } + return info +} + +func globalProviderToConfig(info core.GlobalProviderInfo) config.ProviderConfig { + p := config.ProviderConfig{ + Name: info.Name, + APIKey: info.APIKey, + BaseURL: info.BaseURL, + Model: info.Model, + Thinking: info.Thinking, + Env: info.Env, + AgentTypes: info.AgentTypes, + } + for _, m := range info.Models { + p.Models = append(p.Models, config.ProviderModelConfig{Model: m.Model, Alias: m.Alias}) + } + return p +} + func convertCoreModels(ms []core.ModelOption) []config.ProviderModelConfig { if len(ms) == 0 { return nil diff --git a/cmd/cc-connect/provider.go b/cmd/cc-connect/provider.go index 3ef88a6..ea2de95 100644 --- a/cmd/cc-connect/provider.go +++ b/cmd/cc-connect/provider.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/chenhg5/cc-connect/config" + "github.com/chenhg5/cc-connect/core" ) func runProviderCommand(args []string) { @@ -28,6 +29,10 @@ func runProviderCommand(args []string) { runProviderRemove(args[1:]) case "import": runProviderImport(args[1:]) + case "presets": + runProviderPresets(args[1:]) + case "global": + runProviderGlobal(args[1:]) case "help", "--help", "-h": printProviderUsage() default: @@ -45,13 +50,18 @@ Commands: list List providers for a project remove Remove a provider from a project import Import providers from cc-switch + presets Browse recommended provider presets + global Manage global shared providers Examples: cc-connect provider add --project my-backend --name relay --api-key sk-xxx cc-connect provider add --project my-backend --name bedrock --env CLAUDE_CODE_USE_BEDROCK=1,AWS_PROFILE=bedrock cc-connect provider list --project my-backend cc-connect provider remove --project my-backend --name relay - cc-connect provider import --project my-backend`) + cc-connect provider import --project my-backend + cc-connect provider presets + cc-connect provider global list + cc-connect provider global add --name minimaxi --api-key sk-xxx --base-url https://api.minimaxi.chat/v1`) } // initConfigPath resolves the config path and sets config.ConfigPath. @@ -483,3 +493,176 @@ func parseEnvStr(s string) map[string]string { } return env } + +// ── Presets ──────────────────────────────────────────────────── + +func runProviderPresets(args []string) { + fs := flag.NewFlagSet("provider presets", flag.ExitOnError) + url := fs.String("url", "", "override presets URL") + _ = fs.Parse(args) + + if *url != "" { + core.SetPresetsURL(*url) + } + + data, err := core.FetchProviderPresets() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if len(data.Providers) == 0 { + fmt.Println("No presets available.") + return + } + + fmt.Printf("Provider Presets (v%d)\n\n", data.Version) + for i, p := range data.Providers { + sponsor := "" + if p.Tier <= 1 { + sponsor = " ⭐ SPONSOR" + } + fmt.Printf("%d. %s%s\n", i+1, p.DisplayName, sponsor) + if p.Description != "" { + fmt.Printf(" %s\n", p.Description) + } + fmt.Printf(" Base URL: %s\n", p.BaseURL) + if len(p.Models) > 0 { + fmt.Printf(" Models: %s\n", strings.Join(p.Models, ", ")) + } + if len(p.Agents) > 0 { + fmt.Printf(" Agents: %s\n", strings.Join(p.Agents, ", ")) + } + if p.InviteURL != "" { + fmt.Printf(" Register: %s\n", p.InviteURL) + } + fmt.Println() + } +} + +// ── Global provider management ───────────────────────────────── + +func runProviderGlobal(args []string) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, `Usage: cc-connect provider global + +Commands: + list List global providers + add Add a global provider + remove Remove a global provider`) + os.Exit(1) + } + + switch args[0] { + case "list": + runGlobalProviderList(args[1:]) + case "add": + runGlobalProviderAdd(args[1:]) + case "remove": + runGlobalProviderRemove(args[1:]) + default: + fmt.Fprintf(os.Stderr, "Unknown global subcommand: %s\n", args[0]) + os.Exit(1) + } +} + +func runGlobalProviderList(args []string) { + fs := flag.NewFlagSet("provider global list", flag.ExitOnError) + configFile := fs.String("config", "", "path to config file") + _ = fs.Parse(args) + + initConfigPath(*configFile) + + providers, err := config.ListGlobalProviders() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if len(providers) == 0 { + fmt.Println("No global providers configured.") + fmt.Println("\nAdd one with: cc-connect provider global add --name --api-key ") + return + } + + fmt.Printf("Global Providers (%d)\n\n", len(providers)) + for _, p := range providers { + info := p.Name + if p.BaseURL != "" { + info += fmt.Sprintf(" (%s)", p.BaseURL) + } + if p.Model != "" { + info += fmt.Sprintf(" [%s]", p.Model) + } + apiKeyHint := "(not set)" + if p.APIKey != "" { + if len(p.APIKey) > 8 { + apiKeyHint = p.APIKey[:4] + "..." + p.APIKey[len(p.APIKey)-4:] + } else { + apiKeyHint = "****" + } + } + fmt.Printf(" %s api_key: %s\n", info, apiKeyHint) + } +} + +func runGlobalProviderAdd(args []string) { + fs := flag.NewFlagSet("provider global add", flag.ExitOnError) + configFile := fs.String("config", "", "path to config file") + name := fs.String("name", "", "provider name (required)") + apiKey := fs.String("api-key", "", "API key") + baseURL := fs.String("base-url", "", "API base URL") + model := fs.String("model", "", "default model") + thinking := fs.String("thinking", "", "thinking override: enabled/disabled") + envStr := fs.String("env", "", "extra env vars as KEY=VAL,KEY2=VAL2") + _ = fs.Parse(args) + + if *name == "" { + fmt.Fprintln(os.Stderr, "Error: --name is required") + fs.Usage() + os.Exit(1) + } + + initConfigPath(*configFile) + + p := config.ProviderConfig{ + Name: *name, + APIKey: *apiKey, + BaseURL: *baseURL, + Model: *model, + Thinking: *thinking, + } + if *envStr != "" { + p.Env = parseEnvStr(*envStr) + } + + if err := config.AddGlobalProvider(p); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("✅ Global provider %q added\n", *name) + fmt.Println("\nTo use in a project, add to config.toml:") + fmt.Printf(" [projects.agent]\n provider_refs = [\"%s\"]\n", *name) +} + +func runGlobalProviderRemove(args []string) { + fs := flag.NewFlagSet("provider global remove", flag.ExitOnError) + configFile := fs.String("config", "", "path to config file") + name := fs.String("name", "", "provider name (required)") + _ = fs.Parse(args) + + if *name == "" { + fmt.Fprintln(os.Stderr, "Error: --name is required") + fs.Usage() + os.Exit(1) + } + + initConfigPath(*configFile) + + if err := config.RemoveGlobalProvider(*name); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("✅ Global provider %q removed\n", *name) +} diff --git a/config.example.toml b/config.example.toml index 3944ebd..555d218 100644 --- a/config.example.toml +++ b/config.example.toml @@ -39,6 +39,44 @@ [log] level = "info" # debug, info, warn, error +# ============================================================================= +# Global Providers / 全局服务商 +# ============================================================================= +# Define providers once globally; projects reference them by name via provider_refs. +# 在此统一定义服务商,项目通过 provider_refs 按名称引用。 +# This avoids duplicating API keys across projects. +# 避免在多个项目中重复配置 API Key。 +# +# Remote presets URL (fetches recommended providers list from GitHub/Gitee) +# 远程预设列表 URL(从 GitHub/Gitee 拉取推荐服务商) +# provider_presets_url = "https://raw.githubusercontent.com/chenhg5/cc-connect/main/provider-presets.json" + +# [[providers]] +# name = "anthropic" +# api_key = "${ANTHROPIC_API_KEY}" +# agent_types = ["claudecode"] # only for Claude Code projects + +# [[providers]] +# name = "minimaxi-claude" +# api_key = "${MINIMAXI_API_KEY}" +# base_url = "https://api.minimaxi.chat/v1" +# agent_types = ["claudecode"] +# model = "claude-sonnet-4-20250514" + +# [[providers]] +# name = "minimaxi-codex" +# api_key = "${MINIMAXI_API_KEY}" +# base_url = "https://api.minimaxi.chat/v1" +# agent_types = ["codex"] +# model = "MiniMax-M2.7" + +# [[providers]] +# name = "dashscope" +# api_key = "${DASHSCOPE_API_KEY}" +# base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1" +# model = "glm-5.1" +# thinking = "disabled" + # ============================================================================= # Display Settings / 显示设置 # ============================================================================= @@ -627,10 +665,14 @@ mode = "default" # "default" | "acceptEdits" (edit) | "plan" | "auto" | "bypassP # Note: When using router, provider settings below are ignored as the router handles model selection. # 注意:使用 router 时,下方的 provider 设置将被忽略,因为 router 负责模型选择。 -# Active provider (matches a name in [[projects.agent.providers]]) -# 当前激活的 provider(对应下方 providers 中的 name) +# Active provider (matches a name in [[projects.agent.providers]] or provider_refs) +# 当前激活的 provider(对应下方 providers 或 provider_refs 中的 name) # provider = "anthropic" +# Reference global [[providers]] by name — avoids duplicating keys per project +# 引用全局 [[providers]] — 避免每个项目重复配置 +# provider_refs = ["minimaxi", "dashscope"] + # API Providers — switch between them via /provider command in chat # or via CLI: cc-connect provider add --project my-backend --name relay --api-key sk-xxx # API Provider 管理 — 可通过聊天命令 /provider 或 CLI 命令切换 diff --git a/config/config.go b/config/config.go index 59dca7b..525d244 100644 --- a/config/config.go +++ b/config/config.go @@ -88,6 +88,8 @@ type Config struct { // Quiet is legacy: when true and [display] does not set thinking_messages / tool_messages, // engines behave as if those flags were false. Per-project quiet overrides when set. Quiet *bool `toml:"quiet,omitempty"` + Providers []ProviderConfig `toml:"providers"` // global shared providers + ProviderPresetsURL string `toml:"provider_presets_url,omitempty"` // remote JSON URL for provider presets Projects []ProjectConfig `toml:"projects"` Commands []CommandConfig `toml:"commands"` // global custom slash commands Aliases []AliasConfig `toml:"aliases"` // global command aliases @@ -320,9 +322,10 @@ type ProjectConfig struct { } type AgentConfig struct { - Type string `toml:"type"` - Options map[string]any `toml:"options"` - Providers []ProviderConfig `toml:"providers"` + Type string `toml:"type"` + Options map[string]any `toml:"options"` + ProviderRefs []string `toml:"provider_refs,omitempty"` // references to global [[providers]] by name + Providers []ProviderConfig `toml:"providers"` } // ProviderModelConfig defines a selectable model entry for a provider, @@ -333,13 +336,14 @@ type ProviderModelConfig struct { } type ProviderConfig struct { - Name string `toml:"name"` - APIKey string `toml:"api_key"` - BaseURL string `toml:"base_url,omitempty"` - Model string `toml:"model,omitempty"` - Models []ProviderModelConfig `toml:"models,omitempty"` - Thinking string `toml:"thinking,omitempty"` - Env map[string]string `toml:"env,omitempty"` + Name string `toml:"name"` + APIKey string `toml:"api_key"` + BaseURL string `toml:"base_url,omitempty"` + Model string `toml:"model,omitempty"` + Models []ProviderModelConfig `toml:"models,omitempty"` + Thinking string `toml:"thinking,omitempty"` + Env map[string]string `toml:"env,omitempty"` + AgentTypes []string `toml:"agent_types,omitempty"` // optional: restrict to specific agent types (e.g. ["claudecode", "codex"]) } type PlatformConfig struct { @@ -392,6 +396,8 @@ func Load(path string) (*Config, error) { cfg.AttachmentSend = "on" } + cfg.ResolveProviderRefs() + if err := cfg.validate(); err != nil { return nil, err } @@ -880,6 +886,138 @@ func RemoveProviderFromConfig(projectName, providerName string) error { return saveConfig(cfg) } +// ResolveProviderRefs merges global [[providers]] into each project that uses +// provider_refs. Inline [[projects.agent.providers]] entries are appended after +// resolved refs; if an inline entry has the same name as a global one, the +// inline entry wins (override). +func (cfg *Config) ResolveProviderRefs() { + if len(cfg.Providers) == 0 { + return + } + globalByName := make(map[string]ProviderConfig, len(cfg.Providers)) + for _, p := range cfg.Providers { + globalByName[p.Name] = p + } + for i := range cfg.Projects { + refs := cfg.Projects[i].Agent.ProviderRefs + if len(refs) == 0 { + continue + } + agentType := cfg.Projects[i].Agent.Type + inlineNames := make(map[string]bool, len(cfg.Projects[i].Agent.Providers)) + for _, p := range cfg.Projects[i].Agent.Providers { + inlineNames[p.Name] = true + } + var resolved []ProviderConfig + for _, name := range refs { + if inlineNames[name] { + continue // inline override takes precedence + } + gp, ok := globalByName[name] + if !ok { + slog.Warn("provider ref not found in global [[providers]]", "project", cfg.Projects[i].Name, "ref", name) + continue + } + if len(gp.AgentTypes) > 0 && !containsString(gp.AgentTypes, agentType) { + slog.Debug("skipping provider: agent type mismatch", "provider", name, "project", cfg.Projects[i].Name, + "provider_agents", gp.AgentTypes, "project_agent", agentType) + continue + } + resolved = append(resolved, gp) + } + cfg.Projects[i].Agent.Providers = append(resolved, cfg.Projects[i].Agent.Providers...) + } +} + +func containsString(ss []string, s string) bool { + for _, v := range ss { + if v == s { + return true + } + } + return false +} + +// ── Global provider CRUD ─────────────────────────────────────── + +// ListGlobalProviders returns the top-level [[providers]] list. +func ListGlobalProviders() ([]ProviderConfig, error) { + configMu.Lock() + defer configMu.Unlock() + cfg, err := loadLocked() + if err != nil { + return nil, err + } + return cfg.Providers, nil +} + +// AddGlobalProvider appends a provider to the top-level [[providers]] and saves. +func AddGlobalProvider(provider ProviderConfig) error { + configMu.Lock() + defer configMu.Unlock() + cfg, err := loadLocked() + if err != nil { + return err + } + for _, existing := range cfg.Providers { + if existing.Name == provider.Name { + return fmt.Errorf("global provider %q already exists", provider.Name) + } + } + cfg.Providers = append(cfg.Providers, provider) + return saveConfig(cfg) +} + +// UpdateGlobalProvider replaces an existing global provider by name. +func UpdateGlobalProvider(name string, provider ProviderConfig) error { + configMu.Lock() + defer configMu.Unlock() + cfg, err := loadLocked() + if err != nil { + return err + } + for i := range cfg.Providers { + if cfg.Providers[i].Name == name { + provider.Name = name // name is immutable in update + cfg.Providers[i] = provider + return saveConfig(cfg) + } + } + return fmt.Errorf("global provider %q not found", name) +} + +// RemoveGlobalProvider removes a provider from top-level [[providers]] and saves. +func RemoveGlobalProvider(name string) error { + configMu.Lock() + defer configMu.Unlock() + cfg, err := loadLocked() + if err != nil { + return err + } + for i := range cfg.Providers { + if cfg.Providers[i].Name == name { + cfg.Providers = append(cfg.Providers[:i], cfg.Providers[i+1:]...) + return saveConfig(cfg) + } + } + return fmt.Errorf("global provider %q not found", name) +} + +func loadLocked() (*Config, error) { + if ConfigPath == "" { + return nil, fmt.Errorf("config path not set") + } + data, err := os.ReadFile(ConfigPath) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + cfg := &Config{} + if err := toml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + return cfg, nil +} + func saveConfig(cfg *Config) error { dir := filepath.Dir(ConfigPath) tmp, err := os.CreateTemp(dir, ".config-*.tmp") @@ -2302,11 +2440,38 @@ func GetProjectConfigDetails(projectName string) map[string]any { platConfigs[j] = pc } result["platform_configs"] = platConfigs + if len(p.Agent.ProviderRefs) > 0 { + result["provider_refs"] = p.Agent.ProviderRefs + } return result } return nil } +// SaveProviderRefs updates provider_refs for a project. +func SaveProviderRefs(projectName string, refs []string) error { + configMu.Lock() + defer configMu.Unlock() + if ConfigPath == "" { + return fmt.Errorf("config path not set") + } + data, err := os.ReadFile(ConfigPath) + if err != nil { + return fmt.Errorf("read config: %w", err) + } + cfg := &Config{} + if err := toml.Unmarshal(data, cfg); err != nil { + return fmt.Errorf("parse config: %w", err) + } + for i := range cfg.Projects { + if cfg.Projects[i].Name == projectName { + cfg.Projects[i].Agent.ProviderRefs = refs + return saveConfig(cfg) + } + } + return fmt.Errorf("project %q not found", projectName) +} + // RemoveProject removes a project from the config file. func RemoveProject(projectName string) error { configMu.Lock() diff --git a/config/config_test.go b/config/config_test.go index 103bd21..f07e90a 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -2058,3 +2058,174 @@ func TestFormatConfigFile(t *testing.T) { } }) } + +func TestResolveProviderRefs(t *testing.T) { + cfg := &Config{ + Providers: []ProviderConfig{ + {Name: "global-a", APIKey: "key-a", BaseURL: "https://a.com"}, + {Name: "global-b", APIKey: "key-b", BaseURL: "https://b.com"}, + }, + Projects: []ProjectConfig{ + { + Name: "proj-with-refs", + Agent: AgentConfig{ + Type: "claudecode", + ProviderRefs: []string{"global-a", "global-b"}, + }, + }, + { + Name: "proj-inline-only", + Agent: AgentConfig{ + Type: "codex", + Providers: []ProviderConfig{ + {Name: "inline-p", APIKey: "inline-key"}, + }, + }, + }, + { + Name: "proj-mixed", + Agent: AgentConfig{ + Type: "claudecode", + ProviderRefs: []string{"global-a", "global-b"}, + Providers: []ProviderConfig{ + {Name: "global-a", APIKey: "override-key", BaseURL: "https://override.com"}, + }, + }, + }, + }, + } + + cfg.ResolveProviderRefs() + + // proj-with-refs: should have both global providers + p0 := cfg.Projects[0].Agent.Providers + if len(p0) != 2 { + t.Fatalf("proj-with-refs: expected 2 providers, got %d", len(p0)) + } + if p0[0].Name != "global-a" || p0[0].APIKey != "key-a" { + t.Errorf("proj-with-refs[0]: expected global-a/key-a, got %s/%s", p0[0].Name, p0[0].APIKey) + } + if p0[1].Name != "global-b" || p0[1].APIKey != "key-b" { + t.Errorf("proj-with-refs[1]: expected global-b/key-b, got %s/%s", p0[1].Name, p0[1].APIKey) + } + + // proj-inline-only: should remain unchanged + p1 := cfg.Projects[1].Agent.Providers + if len(p1) != 1 || p1[0].Name != "inline-p" { + t.Errorf("proj-inline-only: expected 1 inline provider, got %d", len(p1)) + } + + // proj-mixed: inline override takes precedence for global-a, global-b from ref + p2 := cfg.Projects[2].Agent.Providers + if len(p2) != 2 { + t.Fatalf("proj-mixed: expected 2 providers, got %d", len(p2)) + } + // global-b is resolved from ref (since no inline override) + if p2[0].Name != "global-b" || p2[0].APIKey != "key-b" { + t.Errorf("proj-mixed[0]: expected global-b from ref, got %s/%s", p2[0].Name, p2[0].APIKey) + } + // global-a is from inline override + if p2[1].Name != "global-a" || p2[1].APIKey != "override-key" { + t.Errorf("proj-mixed[1]: expected global-a override, got %s/%s", p2[1].Name, p2[1].APIKey) + } +} + +func TestResolveProviderRefs_MissingRef(t *testing.T) { + cfg := &Config{ + Providers: []ProviderConfig{ + {Name: "exists", APIKey: "key"}, + }, + Projects: []ProjectConfig{ + { + Name: "proj", + Agent: AgentConfig{ + Type: "claudecode", + ProviderRefs: []string{"exists", "nonexistent"}, + }, + }, + }, + } + + cfg.ResolveProviderRefs() + + providers := cfg.Projects[0].Agent.Providers + if len(providers) != 1 || providers[0].Name != "exists" { + t.Errorf("expected 1 resolved provider 'exists', got %d: %+v", len(providers), providers) + } +} + +func TestResolveProviderRefs_AgentTypeFiltering(t *testing.T) { + cfg := &Config{ + Providers: []ProviderConfig{ + {Name: "claude-only", APIKey: "key-c", AgentTypes: []string{"claudecode"}}, + {Name: "codex-only", APIKey: "key-x", AgentTypes: []string{"codex"}}, + {Name: "universal", APIKey: "key-u"}, // no agent_types = works for all + }, + Projects: []ProjectConfig{ + { + Name: "proj-claude", + Agent: AgentConfig{ + Type: "claudecode", + ProviderRefs: []string{"claude-only", "codex-only", "universal"}, + }, + }, + { + Name: "proj-codex", + Agent: AgentConfig{ + Type: "codex", + ProviderRefs: []string{"claude-only", "codex-only", "universal"}, + }, + }, + }, + } + + cfg.ResolveProviderRefs() + + // claudecode project: gets claude-only + universal, skips codex-only + p0 := cfg.Projects[0].Agent.Providers + if len(p0) != 2 { + t.Fatalf("proj-claude: expected 2 providers, got %d: %+v", len(p0), p0) + } + if p0[0].Name != "claude-only" { + t.Errorf("proj-claude[0]: expected claude-only, got %s", p0[0].Name) + } + if p0[1].Name != "universal" { + t.Errorf("proj-claude[1]: expected universal, got %s", p0[1].Name) + } + + // codex project: gets codex-only + universal, skips claude-only + p1 := cfg.Projects[1].Agent.Providers + if len(p1) != 2 { + t.Fatalf("proj-codex: expected 2 providers, got %d: %+v", len(p1), p1) + } + if p1[0].Name != "codex-only" { + t.Errorf("proj-codex[0]: expected codex-only, got %s", p1[0].Name) + } + if p1[1].Name != "universal" { + t.Errorf("proj-codex[1]: expected universal, got %s", p1[1].Name) + } +} + +func TestResolveProviderRefs_NoGlobalProviders(t *testing.T) { + cfg := &Config{ + Projects: []ProjectConfig{ + { + Name: "proj", + Agent: AgentConfig{ + Type: "claudecode", + ProviderRefs: []string{"foo"}, + Providers: []ProviderConfig{ + {Name: "bar", APIKey: "key"}, + }, + }, + }, + }, + } + + cfg.ResolveProviderRefs() + + providers := cfg.Projects[0].Agent.Providers + if len(providers) != 1 || providers[0].Name != "bar" { + t.Errorf("expected only inline provider 'bar', got %+v", providers) + } +} diff --git a/core/engine.go b/core/engine.go index d58d2b4..e0fb6b0 100644 --- a/core/engine.go +++ b/core/engine.go @@ -284,10 +284,18 @@ type interactiveState struct { sideText string deleteMode *deleteModeState modelSwitch *modelSwitchState + pendingProviderAdd *pendingProviderAddState lastAutoCompressAt time.Time lastAutoCompressTokens int } +type pendingProviderAddState struct { + phase string // "preset" = waiting for API key; "other" = waiting for name api_key base_url [model] + name string + baseURL string + model string +} + type deleteModeState struct { page int selectedIDs map[string]struct{} @@ -1527,6 +1535,11 @@ func (e *Engine) handleMessage(p Platform, msg *Message) { return } + // Pending provider add (card-driven multi-step flow) + if e.handlePendingProviderAdd(p, msg, content) { + return + } + // Select session manager and agent based on workspace mode sessions := e.sessions agent := e.agent @@ -6586,10 +6599,28 @@ func (e *Engine) cmdProvider(p Platform, msg *Message, args []string) { func (e *Engine) cmdProviderAdd(p Platform, msg *Message, switcher ProviderSwitcher, args []string) { if len(args) == 0 { + if supportsCards(p) { + e.replyWithCard(p, msg.ReplyCtx, e.renderProviderAddCard(msg.SessionKey)) + return + } + if _, ok := p.(InlineButtonSender); ok { + if btns := e.providerAddPresetButtons(); len(btns) > 0 { + e.replyWithButtons(p, msg.ReplyCtx, + e.i18n.T(MsgProviderAddPickHint), btns) + return + } + } e.reply(p, msg.ReplyCtx, e.i18n.T(MsgProviderAddUsage)) return } + // "/provider add " (1 arg) — check if it matches a preset + if len(args) == 1 { + if e.tryProviderAddPreset(p, msg, switcher, args[0]) { + return + } + } + var prov ProviderConfig // Join args back; detect JSON (starts with '{') vs positional @@ -6709,6 +6740,184 @@ func (e *Engine) switchProvider(p Platform, msg *Message, switcher ProviderSwitc e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderSwitched), name)) } +// handlePendingProviderAdd checks for a pending provider add state (from the +// card-driven add flow) and completes the add if the user sends the required input. +func (e *Engine) handlePendingProviderAdd(p Platform, msg *Message, content string) bool { + if strings.HasPrefix(content, "/") { + return false + } + interactiveKey := e.interactiveKeyForSessionKey(msg.SessionKey) + e.interactiveMu.Lock() + state := e.interactiveStates[interactiveKey] + e.interactiveMu.Unlock() + if state == nil { + return false + } + state.mu.Lock() + pa := state.pendingProviderAdd + if pa == nil { + state.mu.Unlock() + return false + } + paCopy := *pa + state.pendingProviderAdd = nil + state.mu.Unlock() + + switcher, ok := e.agent.(ProviderSwitcher) + if !ok { + return false + } + + var prov ProviderConfig + switch paCopy.phase { + case "preset": + apiKey := strings.TrimSpace(content) + if apiKey == "" { + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgProviderAddUsage)) + return true + } + prov = ProviderConfig{ + Name: paCopy.name, + APIKey: apiKey, + BaseURL: paCopy.baseURL, + Model: paCopy.model, + } + case "other": + fields := strings.Fields(content) + if len(fields) < 2 { + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgProviderAddUsage)) + return true + } + prov.Name = fields[0] + prov.APIKey = fields[1] + if len(fields) > 2 { + prov.BaseURL = fields[2] + } + if len(fields) > 3 { + prov.Model = fields[3] + } + default: + return false + } + + for _, existing := range switcher.ListProviders() { + if existing.Name == prov.Name { + e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderAddFailed), fmt.Sprintf("provider %q already exists", prov.Name))) + return true + } + } + + updated := append(switcher.ListProviders(), prov) + switcher.SetProviders(updated) + if e.providerAddSaveFunc != nil { + if err := e.providerAddSaveFunc(prov); err != nil { + slog.Error("failed to persist provider", "error", err) + } + } + e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderAdded), prov.Name, prov.Name)) + return true +} + +// setPendingProviderAdd stores a pending provider add state for the card-driven flow. +func (e *Engine) setPendingProviderAdd(sessionKey string, pa *pendingProviderAddState) { + interactiveKey := e.interactiveKeyForSessionKey(sessionKey) + e.interactiveMu.Lock() + state, ok := e.interactiveStates[interactiveKey] + if !ok { + state = &interactiveState{} + e.interactiveStates[interactiveKey] = state + } + e.interactiveMu.Unlock() + state.mu.Lock() + state.pendingProviderAdd = pa + state.mu.Unlock() +} + +// getPendingProviderAdd retrieves pending provider add state without removing it. +func (e *Engine) getPendingProviderAdd(sessionKey string) *pendingProviderAddState { + interactiveKey := e.interactiveKeyForSessionKey(sessionKey) + e.interactiveMu.Lock() + state := e.interactiveStates[interactiveKey] + e.interactiveMu.Unlock() + if state == nil { + return nil + } + state.mu.Lock() + defer state.mu.Unlock() + if state.pendingProviderAdd == nil { + return nil + } + cp := *state.pendingProviderAdd + return &cp +} + +// providerAddPresetButtons builds inline keyboard rows for platforms +// that support InlineButtonSender but not full cards. +func (e *Engine) providerAddPresetButtons() [][]ButtonOption { + agentType := e.agent.Name() + presets, err := FetchProviderPresets() + if err != nil || presets == nil || len(presets.Providers) == 0 { + return nil + } + var rows [][]ButtonOption + var row []ButtonOption + for _, preset := range presets.Providers { + if len(preset.Agents) > 0 && !stringSliceContains(preset.Agents, agentType) { + continue + } + row = append(row, ButtonOption{ + Text: preset.DisplayName, + Data: "cmd:/provider add " + preset.Name, + }) + if len(row) == 2 { + rows = append(rows, row) + row = nil + } + } + if len(row) > 0 { + rows = append(rows, row) + } + return rows +} + +// tryProviderAddPreset handles "/provider add " with a single arg that +// matches a preset name — sets up the pending API key flow. +func (e *Engine) tryProviderAddPreset(p Platform, msg *Message, switcher ProviderSwitcher, presetName string) bool { + agentType := e.agent.Name() + presets, err := FetchProviderPresets() + if err != nil || presets == nil { + return false + } + for _, preset := range presets.Providers { + if preset.Name != presetName { + continue + } + baseURL := preset.BaseURL + if ep, ok := preset.Endpoints[agentType]; ok && ep != "" { + baseURL = ep + } + model := "" + if m, ok := preset.AgentModels[agentType]; ok && m != "" { + model = m + } else if len(preset.Models) > 0 { + model = preset.Models[0] + } + e.setPendingProviderAdd(msg.SessionKey, &pendingProviderAddState{ + phase: "preset", + name: preset.Name, + baseURL: baseURL, + model: model, + }) + displayName := preset.DisplayName + if displayName == "" { + displayName = preset.Name + } + e.reply(p, msg.ReplyCtx, fmt.Sprintf(e.i18n.T(MsgProviderAddApiKeyPrompt), displayName)) + return true + } + return false +} + // ────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────── @@ -7297,6 +7506,8 @@ func (e *Engine) handleCardNav(action string, sessionKey string) *Card { return e.renderHistoryCard(sessionKey) case "/provider": return e.renderProviderCard() + case "/provider/add", "/provider/add-other", "/provider/add-cancel": + return e.renderProviderAddCard(sessionKey) case "/cron": return e.renderCronCard(sessionKey, extractUserID(sessionKey)) case "/heartbeat": @@ -7494,6 +7705,46 @@ func (e *Engine) executeCardAction(cmd, args, sessionKey string) { } } + case "/provider/add": + if args == "" { + return + } + agentType := e.agent.Name() + presets, err := FetchProviderPresets() + if err != nil || presets == nil { + return + } + for _, preset := range presets.Providers { + if preset.Name != args { + continue + } + baseURL := preset.BaseURL + if ep, ok := preset.Endpoints[agentType]; ok && ep != "" { + baseURL = ep + } + model := "" + if m, ok := preset.AgentModels[agentType]; ok && m != "" { + model = m + } else if len(preset.Models) > 0 { + model = preset.Models[0] + } + e.setPendingProviderAdd(sessionKey, &pendingProviderAddState{ + phase: "preset", + name: preset.Name, + baseURL: baseURL, + model: model, + }) + return + } + + case "/provider/add-other": + e.setPendingProviderAdd(sessionKey, &pendingProviderAddState{ + phase: "other", + }) + + case "/provider/add-cancel": + e.setPendingProviderAdd(sessionKey, nil) + case "/new": interactiveKey := e.interactiveKeyForSessionKey(sessionKey) _, sessions := e.sessionContextForKey(sessionKey) @@ -8478,7 +8729,10 @@ func (e *Engine) renderProviderCard() *Card { providers := switcher.ListProviders() if current == nil && len(providers) == 0 { - return e.simpleCard(e.i18n.T(MsgCardTitleProvider), "indigo", e.i18n.T(MsgProviderNone)) + cb := NewCard().Title(e.i18n.T(MsgCardTitleProvider), "indigo"). + Markdown(e.i18n.T(MsgProviderNone)) + cb.Buttons(PrimaryBtn("➕ "+e.i18n.T(MsgCardTitleProviderAdd), "nav:/provider/add"), e.cardBackButton()) + return cb.Build() } var body strings.Builder @@ -8504,7 +8758,59 @@ func (e *Engine) renderProviderCard() *Card { } cb.Select(e.i18n.T(MsgProviderSelectPlaceholder), opts, initVal) } - return cb.Buttons(e.cardBackButton()).Build() + cb.Buttons(PrimaryBtn("➕ "+e.i18n.T(MsgCardTitleProviderAdd), "nav:/provider/add"), e.cardBackButton()) + return cb.Build() +} + +func (e *Engine) renderProviderAddCard(sessionKey string) *Card { + if pa := e.getPendingProviderAdd(sessionKey); pa != nil { + switch pa.phase { + case "preset": + cb := NewCard().Title(e.i18n.T(MsgCardTitleProviderAdd), "indigo"). + Markdown(fmt.Sprintf(e.i18n.T(MsgProviderAddApiKeyPrompt), pa.name)) + cb.Buttons(DefaultBtn(e.i18n.T(MsgCardBack), "act:/provider/add-cancel")) + return cb.Build() + case "other": + cb := NewCard().Title(e.i18n.T(MsgCardTitleProviderAdd), "indigo"). + Markdown(e.i18n.T(MsgProviderAddUsage)) + cb.Buttons(DefaultBtn(e.i18n.T(MsgCardBack), "act:/provider/add-cancel")) + return cb.Build() + } + } + + // Show preset selection card + agentType := e.agent.Name() + lang := e.i18n.CurrentLang() + + cb := NewCard().Title(e.i18n.T(MsgCardTitleProviderAdd), "indigo"). + Markdown(e.i18n.T(MsgProviderAddPickHint)) + + presets, err := FetchProviderPresets() + if err == nil && presets != nil { + for _, preset := range presets.Providers { + if len(preset.Agents) > 0 && !stringSliceContains(preset.Agents, agentType) { + continue + } + desc := preset.Description + if lang == LangChinese || lang == LangTraditionalChinese { + if preset.DescriptionZh != "" { + desc = preset.DescriptionZh + } + } + label := preset.DisplayName + if desc != "" { + label += " — " + desc + } + cb.ListItem(label, preset.DisplayName, "act:/provider/add "+preset.Name) + } + } + + cb.Divider() + cb.Buttons( + DefaultBtn("✏️ "+e.i18n.T(MsgProviderAddOther), "act:/provider/add-other"), + DefaultBtn(e.i18n.T(MsgCardBack), "nav:/provider"), + ) + return cb.Build() } func (e *Engine) renderCronCard(sessionKey string, userID string) *Card { @@ -10817,6 +11123,15 @@ func extractUserID(sessionKey string) string { return "" } +func stringSliceContains(ss []string, target string) bool { + for _, s := range ss { + if s == target { + return true + } + } + return false +} + func extractPlatformName(sessionKey string) string { if i := strings.IndexByte(sessionKey, ':'); i >= 0 { return sessionKey[:i] diff --git a/core/i18n.go b/core/i18n.go index 01b7fc4..7eb77cc 100644 --- a/core/i18n.go +++ b/core/i18n.go @@ -191,6 +191,10 @@ const ( MsgProviderAddFailed MsgKey = "provider_add_failed" MsgProviderRemoved MsgKey = "provider_removed" MsgProviderRemoveFailed MsgKey = "provider_remove_failed" + MsgCardTitleProviderAdd MsgKey = "card_title_provider_add" + MsgProviderAddPickHint MsgKey = "provider_add_pick_hint" + MsgProviderAddOther MsgKey = "provider_add_other" + MsgProviderAddApiKeyPrompt MsgKey = "provider_add_api_key_prompt" MsgVoiceNotEnabled MsgKey = "voice_not_enabled" MsgVoiceUsingPlatformRecognition MsgKey = "voice_using_platform_recognition" @@ -1434,6 +1438,28 @@ var messages = map[MsgKey]map[Language]string{ LangJapanese: "❌ プロバイダの削除に失敗しました: %v", LangSpanish: "❌ Error al eliminar proveedor: %v", }, + MsgCardTitleProviderAdd: { + LangEnglish: "Add Provider", LangChinese: "添加服务商", LangTraditionalChinese: "新增服務商", + LangJapanese: "プロバイダーを追加", LangSpanish: "Añadir proveedor", + }, + MsgProviderAddPickHint: { + LangEnglish: "Pick a provider below, or choose **Other** to enter manually.\nAfter selecting, send your API key to complete.", + LangChinese: "选择一个服务商,或选择 **自定义** 手动填写。\n选择后,请发送你的 API Key 来完成添加。", + LangTraditionalChinese: "選擇一個服務商,或選擇 **自訂** 手動填寫。\n選擇後,請傳送你的 API Key 來完成新增。", + LangJapanese: "プロバイダーを選択するか、**その他** を選んで手動入力してください。\n選択後、API キーを送信して完了します。", + LangSpanish: "Elige un proveedor o selecciona **Otro** para ingresar manualmente.\nDespués de seleccionar, envía tu API Key para completar.", + }, + MsgProviderAddOther: { + LangEnglish: "Other (manual)", LangChinese: "自定义 (手动)", LangTraditionalChinese: "自訂 (手動)", + LangJapanese: "その他 (手動)", LangSpanish: "Otro (manual)", + }, + MsgProviderAddApiKeyPrompt: { + LangEnglish: "✅ Selected **%s**.\n\nPlease send your **API Key** for this provider.\nFormat: just the key, e.g. `sk-xxxxxxxx`", + LangChinese: "✅ 已选择 **%s**。\n\n请发送你的 **API Key**。\n格式:直接发送密钥即可,如 `sk-xxxxxxxx`", + LangTraditionalChinese: "✅ 已選擇 **%s**。\n\n請傳送你的 **API Key**。\n格式:直接傳送金鑰即可,如 `sk-xxxxxxxx`", + LangJapanese: "✅ **%s** を選択しました。\n\n**API キー** を送信してください。\n形式: キーをそのまま送信(例: `sk-xxxxxxxx`)", + LangSpanish: "✅ Seleccionado **%s**.\n\nPor favor envía tu **API Key** para este proveedor.\nFormato: solo la clave, por ejemplo `sk-xxxxxxxx`", + }, MsgVoiceNotEnabled: { LangEnglish: "🎙 Voice messages are not enabled. Please configure `[speech]` in config.toml.", LangChinese: "🎙 语音消息未启用,请在 config.toml 中配置 `[speech]` 部分。", diff --git a/core/management.go b/core/management.go index 905d39c..510d982 100644 --- a/core/management.go +++ b/core/management.go @@ -49,9 +49,17 @@ type ManagementServer struct { removeProject func(projectName string) error saveProjectSettings func(projectName string, update ProjectSettingsUpdate) error getProjectConfig func(projectName string) map[string]any + saveProviderRefs func(projectName string, refs []string) error configFilePath string getGlobalSettings func() map[string]any saveGlobalSettings func(map[string]any) error + + // Global provider callbacks (set by cmd/cc-connect) + listGlobalProviders func() ([]GlobalProviderInfo, error) + addGlobalProvider func(GlobalProviderInfo) error + updateGlobalProvider func(name string, info GlobalProviderInfo) error + removeGlobalProvider func(name string) error + fetchPresets func() (*ProviderPresetsResponse, error) } // NewManagementServer creates a new management API server. @@ -101,6 +109,10 @@ func (m *ManagementServer) SetGetProjectConfig(fn func(string) map[string]any) { m.getProjectConfig = fn } +func (m *ManagementServer) SetSaveProviderRefs(fn func(string, []string) error) { + m.saveProviderRefs = fn +} + func (m *ManagementServer) SetGetGlobalSettings(fn func() map[string]any) { m.getGlobalSettings = fn } @@ -109,6 +121,37 @@ func (m *ManagementServer) SetSaveGlobalSettings(fn func(map[string]any) error) m.saveGlobalSettings = fn } +// GlobalProviderInfo is the wire type for global provider CRUD in the management API. +type GlobalProviderInfo struct { + Name string `json:"name"` + APIKey string `json:"api_key,omitempty"` + BaseURL string `json:"base_url,omitempty"` + Model string `json:"model,omitempty"` + Thinking string `json:"thinking,omitempty"` + Env map[string]string `json:"env,omitempty"` + AgentTypes []string `json:"agent_types,omitempty"` + Models []struct { + Model string `json:"model"` + Alias string `json:"alias,omitempty"` + } `json:"models,omitempty"` +} + +func (m *ManagementServer) SetListGlobalProviders(fn func() ([]GlobalProviderInfo, error)) { + m.listGlobalProviders = fn +} +func (m *ManagementServer) SetAddGlobalProvider(fn func(GlobalProviderInfo) error) { + m.addGlobalProvider = fn +} +func (m *ManagementServer) SetUpdateGlobalProvider(fn func(string, GlobalProviderInfo) error) { + m.updateGlobalProvider = fn +} +func (m *ManagementServer) SetRemoveGlobalProvider(fn func(string) error) { + m.removeGlobalProvider = fn +} +func (m *ManagementServer) SetFetchPresets(fn func() (*ProviderPresetsResponse, error)) { + m.fetchPresets = fn +} + func (m *ManagementServer) Start() { mux := http.NewServeMux() handler := m.buildHandler(mux) @@ -151,6 +194,10 @@ func (m *ManagementServer) buildHandler(mux *http.ServeMux) http.Handler { mux.HandleFunc(prefix+"/setup/weixin/poll", m.wrap(m.handleSetupWeixinPoll)) mux.HandleFunc(prefix+"/setup/weixin/save", m.wrap(m.handleSetupWeixinSave)) + // Global Providers + mux.HandleFunc(prefix+"/providers", m.wrap(m.handleGlobalProviders)) + mux.HandleFunc(prefix+"/providers/", m.wrap(m.handleGlobalProviderRoutes)) + // Bridge mux.HandleFunc(prefix+"/bridge/adapters", m.wrap(m.handleBridgeAdapters)) @@ -508,6 +555,8 @@ func (m *ManagementServer) handleProjectRoutes(w http.ResponseWriter, r *http.Re m.handleProjectSend(w, r, engine) case "providers": m.handleProjectProviders(w, r, engine, rest) + case "provider-refs": + m.handleProjectProviderRefs(w, r, projName, engine) case "models": m.handleProjectModels(w, r, engine) case "model": @@ -1108,6 +1157,70 @@ func (m *ManagementServer) handleProjectProviders(w http.ResponseWriter, r *http } } +func (m *ManagementServer) handleProjectProviderRefs(w http.ResponseWriter, r *http.Request, projName string, e *Engine) { + switch r.Method { + case http.MethodGet: + if m.getProjectConfig == nil { + mgmtJSON(w, http.StatusOK, map[string]any{"provider_refs": []string{}}) + return + } + cfg := m.getProjectConfig(projName) + refs, _ := cfg["provider_refs"].([]string) + if refs == nil { + refs = []string{} + } + mgmtJSON(w, http.StatusOK, map[string]any{"provider_refs": refs}) + + case http.MethodPut: + if m.saveProviderRefs == nil { + mgmtError(w, http.StatusNotImplemented, "provider refs saving not available") + return + } + var body struct { + ProviderRefs []string `json:"provider_refs"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + mgmtError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if err := m.saveProviderRefs(projName, body.ProviderRefs); err != nil { + mgmtError(w, http.StatusInternalServerError, err.Error()) + return + } + // Reload providers into the running engine + ps, ok := e.agent.(ProviderSwitcher) + if ok && m.listGlobalProviders != nil { + globals, _ := m.listGlobalProviders() + globalMap := make(map[string]GlobalProviderInfo, len(globals)) + for _, g := range globals { + globalMap[g.Name] = g + } + existing := ps.ListProviders() + existingNames := make(map[string]bool, len(existing)) + for _, p := range existing { + existingNames[p.Name] = true + } + for _, ref := range body.ProviderRefs { + if existingNames[ref] { + continue + } + if g, ok := globalMap[ref]; ok { + ps.SetProviders(append(ps.ListProviders(), ProviderConfig{ + Name: g.Name, + APIKey: g.APIKey, + BaseURL: g.BaseURL, + Model: g.Model, + })) + } + } + } + mgmtOK(w, "provider refs updated") + + default: + mgmtError(w, http.StatusMethodNotAllowed, "GET or PUT only") + } +} + func (m *ManagementServer) handleProjectModels(w http.ResponseWriter, r *http.Request, e *Engine) { if r.Method != http.MethodGet { mgmtError(w, http.StatusMethodNotAllowed, "GET only") @@ -1427,3 +1540,123 @@ func (m *ManagementServer) listBridgeAdapters() []map[string]any { } return adapters } + +// ── Global provider endpoints ───────────────────────────────── + +func (m *ManagementServer) handleGlobalProviders(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + if m.listGlobalProviders == nil { + mgmtJSON(w, http.StatusOK, map[string]any{"providers": []any{}}) + return + } + providers, err := m.listGlobalProviders() + if err != nil { + mgmtError(w, http.StatusInternalServerError, err.Error()) + return + } + mgmtJSON(w, http.StatusOK, map[string]any{"providers": providers}) + + case http.MethodPost: + if m.addGlobalProvider == nil { + mgmtError(w, http.StatusNotImplemented, "not configured") + return + } + var body GlobalProviderInfo + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + mgmtError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if body.Name == "" { + mgmtError(w, http.StatusBadRequest, "name is required") + return + } + if err := m.addGlobalProvider(body); err != nil { + if strings.Contains(err.Error(), "already exists") { + mgmtError(w, http.StatusConflict, err.Error()) + } else { + mgmtError(w, http.StatusInternalServerError, err.Error()) + } + return + } + mgmtJSON(w, http.StatusOK, map[string]any{"name": body.Name, "message": "provider added"}) + + default: + mgmtError(w, http.StatusMethodNotAllowed, "GET or POST only") + } +} + +func (m *ManagementServer) handleGlobalProviderRoutes(w http.ResponseWriter, r *http.Request) { + rest := strings.TrimPrefix(r.URL.Path, "/api/v1/providers/") + if rest == "" { + m.handleGlobalProviders(w, r) + return + } + + // /providers/presets + if rest == "presets" { + m.handleProviderPresets(w, r) + return + } + + // /providers/{name} or /providers/{name}/... + parts := strings.SplitN(rest, "/", 2) + name := parts[0] + + switch r.Method { + case http.MethodPut, http.MethodPatch: + if m.updateGlobalProvider == nil { + mgmtError(w, http.StatusNotImplemented, "not configured") + return + } + var body GlobalProviderInfo + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + mgmtError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if err := m.updateGlobalProvider(name, body); err != nil { + if strings.Contains(err.Error(), "not found") { + mgmtError(w, http.StatusNotFound, err.Error()) + } else { + mgmtError(w, http.StatusInternalServerError, err.Error()) + } + return + } + mgmtOK(w, "provider updated") + + case http.MethodDelete: + if m.removeGlobalProvider == nil { + mgmtError(w, http.StatusNotImplemented, "not configured") + return + } + if err := m.removeGlobalProvider(name); err != nil { + if strings.Contains(err.Error(), "not found") { + mgmtError(w, http.StatusNotFound, err.Error()) + } else { + mgmtError(w, http.StatusInternalServerError, err.Error()) + } + return + } + mgmtOK(w, "provider removed") + + default: + mgmtError(w, http.StatusMethodNotAllowed, "PUT, PATCH or DELETE only") + } +} + +func (m *ManagementServer) handleProviderPresets(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + mgmtError(w, http.StatusMethodNotAllowed, "GET only") + return + } + if m.fetchPresets == nil { + mgmtJSON(w, http.StatusOK, &ProviderPresetsResponse{Version: 1}) + return + } + data, err := m.fetchPresets() + if err != nil { + mgmtError(w, http.StatusBadGateway, "fetch presets: "+err.Error()) + return + } + mgmtJSON(w, http.StatusOK, data) +} diff --git a/core/provider_presets.go b/core/provider_presets.go new file mode 100644 index 0000000..ebfa7cc --- /dev/null +++ b/core/provider_presets.go @@ -0,0 +1,120 @@ +package core + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "sync" + "time" +) + +const ( + defaultPresetsURL = "https://raw.githubusercontent.com/chenhg5/cc-connect/main/provider-presets.json" + presetsCacheTTL = 6 * time.Hour + presetsHTTPTimeout = 15 * time.Second +) + +// ProviderPreset describes a recommended provider available from the remote presets list. +type ProviderPreset struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + BaseURL string `json:"base_url"` // default base URL + Endpoints map[string]string `json:"endpoints,omitempty"` // per-agent-type base URL overrides (e.g. {"claudecode": "https://x/anthropic", "codex": "https://x/v1"}) + Models []string `json:"models,omitempty"` // common models across agents + AgentModels map[string]string `json:"agent_models,omitempty"` // per-agent-type default model (e.g. {"codex": "gpt-5.4"}) + Agents []string `json:"agents,omitempty"` // supported agent types + InviteURL string `json:"invite_url,omitempty"` + Description string `json:"description,omitempty"` + DescriptionZh string `json:"description_zh,omitempty"` + Features []string `json:"features,omitempty"` + Thinking string `json:"thinking,omitempty"` + Tier int `json:"tier"` // sponsor ranking: lower = higher priority + Website string `json:"website,omitempty"` +} + +// ProviderPresetsResponse is the top-level JSON schema for remote presets. +type ProviderPresetsResponse struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at,omitempty"` + Providers []ProviderPreset `json:"providers"` +} + +type presetsCache struct { + mu sync.RWMutex + data *ProviderPresetsResponse + fetchedAt time.Time + url string +} + +var globalPresetsCache = &presetsCache{} + +// SetPresetsURL overrides the default presets URL. Call before first fetch. +func SetPresetsURL(url string) { + globalPresetsCache.mu.Lock() + defer globalPresetsCache.mu.Unlock() + globalPresetsCache.url = url + globalPresetsCache.data = nil // invalidate cache on URL change +} + +// FetchProviderPresets returns cached or freshly-fetched provider presets. +func FetchProviderPresets() (*ProviderPresetsResponse, error) { + return globalPresetsCache.fetch() +} + +func (c *presetsCache) fetch() (*ProviderPresetsResponse, error) { + c.mu.RLock() + if c.data != nil && time.Since(c.fetchedAt) < presetsCacheTTL { + defer c.mu.RUnlock() + return c.data, nil + } + c.mu.RUnlock() + + c.mu.Lock() + defer c.mu.Unlock() + + // double-check after acquiring write lock + if c.data != nil && time.Since(c.fetchedAt) < presetsCacheTTL { + return c.data, nil + } + + url := c.url + if url == "" { + url = defaultPresetsURL + } + + slog.Debug("fetching provider presets", "url", url) + client := &http.Client{Timeout: presetsHTTPTimeout} + resp, err := client.Get(url) + if err != nil { + if c.data != nil { + slog.Warn("failed to refresh presets, using stale cache", "error", err) + return c.data, nil + } + return nil, fmt.Errorf("fetch presets: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if c.data != nil { + slog.Warn("presets HTTP error, using stale cache", "status", resp.StatusCode) + return c.data, nil + } + return nil, fmt.Errorf("fetch presets: HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB max + if err != nil { + return nil, fmt.Errorf("read presets body: %w", err) + } + + var result ProviderPresetsResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse presets JSON: %w", err) + } + + c.data = &result + c.fetchedAt = time.Now() + return c.data, nil +} diff --git a/provider-presets.json b/provider-presets.json new file mode 100644 index 0000000..e8e9531 --- /dev/null +++ b/provider-presets.json @@ -0,0 +1,180 @@ +{ + "version": 1, + "updated_at": "2026-04-16", + "providers": [ + { + "name": "minimax", + "display_name": "MiniMax", + "base_url": "https://api.minimax.io/v1", + "endpoints": { + "claudecode": "https://api.minimax.io/anthropic", + "codex": "https://api.minimax.io/v1" + }, + "models": ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5"], + "agent_models": { + "claudecode": "MiniMax-M2.7", + "codex": "MiniMax-M2.7" + }, + "agents": ["claudecode", "codex", "opencode"], + "invite_url": "https://platform.minimax.io/subscribe/token-plan?code=lqYrKBvjke&source=link", + "description": "Next-gen LLM with 1M context window, strong SWE performance (56.2% SWE-Pro). Exclusive 12% off Token Plan for cc-connect users!", + "description_zh": "新一代大模型,支持 1M 超长上下文,软件工程能力突出(SWE-Pro 56.2%)。cc-connect 用户专享 Token 套餐 88 折!", + "features": ["1M Context", "Extended Thinking", "SWE-Pro 56.2%"], + "thinking": "disabled", + "tier": 1, + "website": "https://platform.minimax.io" + }, + { + "name": "minimax-cn", + "display_name": "MiniMax (中国站)", + "base_url": "https://api.minimaxi.com/v1", + "endpoints": { + "claudecode": "https://api.minimaxi.com/anthropic", + "codex": "https://api.minimaxi.com/v1" + }, + "models": ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5"], + "agent_models": { + "claudecode": "MiniMax-M2.7", + "codex": "MiniMax-M2.7" + }, + "agents": ["claudecode", "codex", "opencode"], + "invite_url": "https://platform.minimaxi.com/subscribe/token-plan?code=lqYrKBvjke&source=link", + "description": "MiniMax China endpoint — lower latency for users in mainland China.", + "description_zh": "MiniMax 国内站 — 大陆用户延迟更低。cc-connect 用户专享 Token 套餐 88 折!", + "features": ["1M Context", "低延迟 (CN)", "Extended Thinking"], + "thinking": "disabled", + "tier": 1, + "website": "https://platform.minimaxi.com" + }, + { + "name": "aigocode", + "display_name": "AIGoCode", + "base_url": "https://api.aigocode.com", + "endpoints": { + "claudecode": "https://api.aigocode.com", + "codex": "https://api.aigocode.com", + "gemini": "https://api.aigocode.com" + }, + "models": ["claude-sonnet-4-20250514", "claude-opus-4-20250514", "gpt-5.4", "gemini-2.5-pro"], + "agent_models": { + "claudecode": "claude-sonnet-4-20250514", + "codex": "gpt-5.4", + "gemini": "gemini-2.5-pro" + }, + "agents": ["claudecode", "codex", "gemini", "opencode"], + "invite_url": "https://aigocode.com/invite/CYY3C85C", + "description": "All-in-one platform: Claude Code, Codex & Gemini. Flexible plans, no VPN needed, fast response. 10% bonus credit on first top-up via link!", + "description_zh": "集成 Claude Code、Codex、Gemini 的全能平台。灵活套餐,免翻墙,响应快速。通过链接注册首充额外赠送 10%!", + "features": ["Claude Code", "Codex", "Gemini", "No VPN"], + "tier": 2, + "website": "https://aigocode.com" + }, + { + "name": "dmxapi", + "display_name": "DMXAPI", + "base_url": "https://www.dmxapi.cn", + "endpoints": { + "claudecode": "https://www.dmxapi.cn", + "codex": "https://www.dmxapi.cn/v1" + }, + "models": ["claude-sonnet-4-20250514", "claude-opus-4-20250514", "gpt-5.4"], + "agent_models": { + "claudecode": "claude-sonnet-4-20250514", + "codex": "gpt-5.4" + }, + "agents": ["claudecode", "codex", "opencode"], + "invite_url": "https://www.dmxapi.cn/register?aff=NDln", + "description": "Global LLM API for 200+ enterprises. One key for all models. GPT/Claude/Gemini at 32% off, Claude Code models at 66% off!", + "description_zh": "服务 200+ 企业的全球大模型 API。一个 Key 接入所有模型。GPT/Claude/Gemini 3.2 折,Claude Code 专属模型低至 6.6 折!", + "features": ["All Models", "Unlimited Concurrency", "24/7 Support"], + "tier": 2, + "website": "https://www.dmxapi.cn" + }, + { + "name": "shengsuanyun", + "display_name": "Shengsuanyun (省算云)", + "base_url": "https://router.shengsuanyun.com/api", + "endpoints": { + "claudecode": "https://router.shengsuanyun.com/api", + "codex": "https://router.shengsuanyun.com/api/v1", + "gemini": "https://router.shengsuanyun.com/api" + }, + "models": ["claude-sonnet-4-20250514", "claude-opus-4-20250514", "gpt-5.4", "gemini-2.5-pro"], + "agent_models": { + "claudecode": "claude-sonnet-4-20250514", + "codex": "gpt-5.4", + "gemini": "gemini-2.5-pro" + }, + "agents": ["claudecode", "codex", "gemini", "opencode"], + "invite_url": "https://www.shengsuanyun.com/?from=CH_67XCLZGS", + "description": "Industrial-grade AI platform with 99.7% SLA. Smart routing, BYOK hosting, pay-as-you-go. Free $2 credit for new users!", + "description_zh": "工业级 AI 平台,99.7% SLA 保障。智能路由,BYOK 密钥托管,按量计费。新用户注册赠送 $2!", + "features": ["99.7% SLA", "Smart Routing", "BYOK", "Claude/Codex/Gemini"], + "tier": 2, + "website": "https://www.shengsuanyun.com" + }, + { + "name": "aicodemirror", + "display_name": "AICodeMirror", + "base_url": "https://api.aicodemirror.com/api/claudecode", + "endpoints": { + "claudecode": "https://api.aicodemirror.com/api/claudecode", + "codex": "https://api.aicodemirror.com/api/codex/backend-api/codex", + "gemini": "https://api.aicodemirror.com/api/gemini" + }, + "models": ["claude-sonnet-4-20250514", "claude-opus-4-20250514"], + "agent_models": { + "claudecode": "claude-sonnet-4-20250514", + "codex": "gpt-5.4", + "gemini": "gemini-2.5-pro" + }, + "agents": ["claudecode", "codex", "gemini", "opencode"], + "invite_url": "https://www.aicodemirror.com/register?invitecode=KDHMUP", + "description": "Official high-stability relay for Claude Code / Codex / Gemini. Enterprise concurrency, 24/7 support. 20% off first top-up for cc-connect users!", + "description_zh": "Claude Code / Codex / Gemini 官方高稳定中转。企业级并发,24/7 技术支持。cc-connect 用户首充 8 折,企业最高 75 折!", + "features": ["Claude Code", "Codex", "Gemini", "Enterprise"], + "tier": 2, + "website": "https://www.aicodemirror.com" + }, + { + "name": "code0", + "display_name": "Code0", + "base_url": "https://api.code0.ai", + "endpoints": { + "claudecode": "https://api.code0.ai", + "codex": "https://api.code0.ai/v1" + }, + "models": ["claude-sonnet-4-20250514", "claude-opus-4-20250514", "gpt-5.4"], + "agent_models": { + "claudecode": "claude-sonnet-4-20250514", + "codex": "gpt-5.4" + }, + "agents": ["claudecode", "codex", "gemini"], + "invite_url": "https://code0.ai/register?aff=5cGO", + "description": "AI model aggregation relay for Chinese developers. OpenAI/Anthropic/Gemini compatible. ¥1.5 = $1, transparent pricing, domestic direct.", + "description_zh": "面向国内开发者的 AI 模型聚合中转。兼容 OpenAI/Anthropic/Gemini 协议。¥1.5 = $1 固定汇率,透明定价,国内直连。", + "features": ["All Protocols", "¥1.5=$1", "Domestic Direct"], + "tier": 2, + "website": "https://code0.ai" + }, + { + "name": "claudeapi", + "display_name": "claudeapi.com", + "base_url": "https://api.claudeapi.com", + "endpoints": { + "claudecode": "https://api.claudeapi.com" + }, + "models": ["claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-haiku-3-5-20241022"], + "agent_models": { + "claudecode": "claude-sonnet-4-20250514" + }, + "agents": ["claudecode", "opencode"], + "invite_url": "https://console.claudeapi.com/register?aff=GDbA", + "description": "Premium direct Claude connection — official 1st-party Keys & AWS Bedrock. No reverse engineering, full capabilities preserved. For power users & enterprise teams.", + "description_zh": "高品质 Claude 直连服务 — 官方一手 Key + AWS Bedrock 官方通道。无逆向无降智,完整保留官方能力。面向 Claude Code 重度用户和企业团队。", + "features": ["Official Channel", "No Reverse Eng.", "Enterprise"], + "tier": 2, + "website": "https://console.claudeapi.com" + } + ] +} diff --git a/web/dist/.keep b/web/dist/.keep deleted file mode 100644 index 8b13789..0000000 --- a/web/dist/.keep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/web/src/App.tsx b/web/src/App.tsx index d58d975..5875713 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import ChatList from '@/pages/Chat/ChatList'; import ChatView from '@/pages/Chat/ChatView'; import CronList from '@/pages/Cron/CronList'; import SystemConfig from '@/pages/System/Config'; +import ProviderList from '@/pages/Providers/ProviderList'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); @@ -26,6 +27,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f503b31..42fe6ca 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -37,6 +37,7 @@ class ApiClient { get(path: string, params?: Record) { return this.request('GET', path, undefined, params); } post(path: string, body?: any) { return this.request('POST', path, body); } + put(path: string, body?: any) { return this.request('PUT', path, body); } patch(path: string, body?: any) { return this.request('PATCH', path, body); } delete(path: string) { return this.request('DELETE', path); } diff --git a/web/src/api/projects.ts b/web/src/api/projects.ts index f9ffcb2..f03fb0f 100644 --- a/web/src/api/projects.ts +++ b/web/src/api/projects.ts @@ -19,6 +19,7 @@ export interface ProjectDetail { work_dir?: string; agent_mode?: string; show_context_indicator?: boolean; + provider_refs?: string[]; platform_configs?: PlatformConfigInfo[]; platforms: { type: string; connected: boolean }[]; sessions_count: number; diff --git a/web/src/api/providers.ts b/web/src/api/providers.ts index 109a768..a842ead 100644 --- a/web/src/api/providers.ts +++ b/web/src/api/providers.ts @@ -1,5 +1,10 @@ import api from './client'; +export interface ProviderModel { + model: string; + alias?: string; +} + export interface Provider { name: string; active: boolean; @@ -7,6 +12,41 @@ export interface Provider { base_url: string; } +export interface GlobalProvider { + name: string; + api_key?: string; + base_url?: string; + model?: string; + thinking?: string; + env?: Record; + agent_types?: string[]; + models?: ProviderModel[]; +} + +export interface ProviderPreset { + name: string; + display_name: string; + base_url: string; + endpoints?: Record; + models?: string[]; + agent_models?: Record; + agents?: string[]; + invite_url?: string; + description?: string; + description_zh?: string; + features?: string[]; + thinking?: string; + tier: number; + website?: string; +} + +export interface PresetsResponse { + version: number; + updated_at?: string; + providers: ProviderPreset[]; +} + +// Project-level provider APIs (existing) export const listProviders = (project: string) => api.get<{ providers: Provider[]; active_provider: string }>(`/projects/${project}/providers`); export const addProvider = (project: string, body: any) => api.post(`/projects/${project}/providers`, body); @@ -14,3 +54,21 @@ export const removeProvider = (project: string, provider: string) => api.delete( export const activateProvider = (project: string, provider: string) => api.post(`/projects/${project}/providers/${provider}/activate`); export const listModels = (project: string) => api.get<{ models: string[]; current: string }>(`/projects/${project}/models`); export const setModel = (project: string, model: string) => api.post(`/projects/${project}/model`, { model }); + +// Project provider_refs APIs +export const getProviderRefs = (project: string) => + api.get<{ provider_refs: string[] }>(`/projects/${project}/provider-refs`); +export const saveProviderRefs = (project: string, refs: string[]) => + api.put<{ message: string }>(`/projects/${project}/provider-refs`, { provider_refs: refs }); + +// Global provider APIs +export const listGlobalProviders = () => + api.get<{ providers: GlobalProvider[] }>('/providers'); +export const addGlobalProvider = (body: GlobalProvider) => + api.post<{ name: string; message: string }>('/providers', body); +export const updateGlobalProvider = (name: string, body: Partial) => + api.put<{ message: string }>(`/providers/${name}`, body); +export const removeGlobalProvider = (name: string) => + api.delete<{ message: string }>(`/providers/${name}`); +export const fetchProviderPresets = () => + api.get('/providers/presets'); diff --git a/web/src/components/Layout/Sidebar.tsx b/web/src/components/Layout/Sidebar.tsx index aad1229..ef80ede 100644 --- a/web/src/components/Layout/Sidebar.tsx +++ b/web/src/components/Layout/Sidebar.tsx @@ -8,6 +8,7 @@ import { Settings, ChevronLeft, ChevronRight, + Plug, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useState } from 'react'; @@ -15,6 +16,7 @@ import { useState } from 'react'; const navItems = [ { key: 'dashboard', path: '/', icon: LayoutDashboard }, { key: 'projects', path: '/projects', icon: FolderKanban }, + { key: 'providers', path: '/providers', icon: Plug }, { key: 'chat', path: '/chat', icon: MessageSquare }, { key: 'cron', path: '/cron', icon: Clock }, { key: 'system', path: '/system', icon: Settings }, diff --git a/web/src/components/ui/Badge.tsx b/web/src/components/ui/Badge.tsx index bd90d96..724e9e3 100644 --- a/web/src/components/ui/Badge.tsx +++ b/web/src/components/ui/Badge.tsx @@ -2,7 +2,7 @@ import { cn } from '@/lib/utils'; interface BadgeProps { children: React.ReactNode; - variant?: 'default' | 'success' | 'warning' | 'danger' | 'info'; + variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' | 'outline'; className?: string; } @@ -15,6 +15,7 @@ const variants = { danger: 'bg-red-100/90 dark:bg-red-900/25 text-red-700 dark:text-red-400 border border-red-200/50 dark:border-red-500/20', info: 'bg-accent/10 text-accent border border-accent/20', + outline: 'bg-transparent text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-white/[0.12]', }; export function Badge({ children, variant = 'default', className }: BadgeProps) { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 85cefbf..7be9207 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2,6 +2,7 @@ "nav": { "dashboard": "Dashboard", "projects": "Projects", + "providers": "Providers", "sessions": "Sessions", "chat": "Chat", "cron": "Cron", @@ -86,7 +87,14 @@ "remove": "Remove", "activate": "Activate", "setModel": "Set model", - "models": "Models" + "models": "Models", + "global": "global", + "emptyProject": "No providers configured for this project.", + "emptyProjectHint": "Link a global provider or add a custom one.", + "linkGlobal": "Link global", + "addCustom": "Add custom", + "allLinked": "All global providers are already linked.", + "manageGlobal": "Manage global providers" }, "cron": { "title": "Scheduled jobs", @@ -311,6 +319,31 @@ "log": "Log", "logLevel": "Log level" }, + "globalProviders": { + "title": "Providers", + "subtitle": "Manage shared API providers across all projects", + "add": "Add Provider", + "edit": "Edit Provider", + "empty": "No providers configured", + "emptyHint": "Add a global provider or import from presets to get started.", + "deleteHint": "Remove provider \"{{name}}\"? Projects referencing it will lose access.", + "noPresets": "No presets available", + "noPresetsHint": "Provider presets could not be loaded. Check your network connection.", + "register": "Register", + "addPreset": "Add", + "added": "Added", + "tab": { + "providers": "My Providers", + "presets": "Presets" + }, + "form": { + "name": "Name", + "model": "Default model", + "agentTypes": "Agent types", + "agentTypesHint": "Leave empty for all agent types", + "thinkingDefault": "Default (auto)" + } + }, "theme": { "light": "Light", "dark": "Dark", diff --git a/web/src/i18n/locales/es.json b/web/src/i18n/locales/es.json index 20d76bd..07dd5cd 100644 --- a/web/src/i18n/locales/es.json +++ b/web/src/i18n/locales/es.json @@ -2,6 +2,7 @@ "nav": { "dashboard": "Panel", "projects": "Proyectos", + "providers": "Proveedores", "sessions": "Sesiones", "chat": "Chat", "cron": "Cron", @@ -86,7 +87,14 @@ "remove": "Quitar", "activate": "Activar", "setModel": "Establecer modelo", - "models": "Modelos disponibles" + "models": "Modelos disponibles", + "global": "global", + "emptyProject": "No hay proveedores configurados para este proyecto.", + "emptyProjectHint": "Vincula un proveedor global o añade uno personalizado.", + "linkGlobal": "Vincular global", + "addCustom": "Añadir personalizado", + "allLinked": "Todos los proveedores globales ya están vinculados.", + "manageGlobal": "Gestionar proveedores globales" }, "cron": { "title": "Tareas programadas", @@ -311,6 +319,31 @@ "log": "Registro", "logLevel": "Nivel de registro" }, + "globalProviders": { + "title": "Gestión de proveedores", + "subtitle": "Administra proveedores de API compartidos globalmente entre todos los proyectos", + "add": "Agregar proveedor", + "edit": "Editar proveedor", + "empty": "No hay proveedores configurados", + "emptyHint": "Agrega un proveedor global o importa desde los preajustes.", + "deleteHint": "¿Eliminar proveedor \"{{name}}\"? Los proyectos que lo referencien perderán acceso.", + "noPresets": "Sin preajustes", + "noPresetsHint": "No se pudieron cargar los preajustes. Verifica tu conexión de red.", + "register": "Registrar", + "addPreset": "Agregar", + "added": "Agregado", + "tab": { + "providers": "Mis proveedores", + "presets": "Preajustes" + }, + "form": { + "name": "Nombre", + "model": "Modelo predeterminado", + "agentTypes": "Tipos de agente", + "agentTypesHint": "Dejar vacío para todos los tipos de agente", + "thinkingDefault": "Predeterminado (auto)" + } + }, "theme": { "light": "Claro", "dark": "Oscuro", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 56bbd66..70d6946 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -2,6 +2,7 @@ "nav": { "dashboard": "ダッシュボード", "projects": "プロジェクト", + "providers": "プロバイダー", "sessions": "セッション", "chat": "チャット", "cron": "Cron", @@ -86,7 +87,14 @@ "remove": "削除", "activate": "有効化", "setModel": "モデルを設定", - "models": "利用可能なモデル" + "models": "利用可能なモデル", + "global": "グローバル", + "emptyProject": "このプロジェクトにはプロバイダーが設定されていません。", + "emptyProjectHint": "グローバルプロバイダーをリンクするか、カスタムで追加してください。", + "linkGlobal": "グローバルをリンク", + "addCustom": "カスタム追加", + "allLinked": "すべてのグローバルプロバイダーがリンク済みです。", + "manageGlobal": "グローバルプロバイダーの管理" }, "cron": { "title": "スケジュールジョブ", @@ -311,6 +319,31 @@ "log": "ログ", "logLevel": "ログレベル" }, + "globalProviders": { + "title": "プロバイダー管理", + "subtitle": "全プロジェクトで共有できるグローバル API プロバイダーを管理", + "add": "プロバイダーを追加", + "edit": "プロバイダーを編集", + "empty": "プロバイダーが設定されていません", + "emptyHint": "グローバルプロバイダーを追加するか、プリセットからインポートしてください。", + "deleteHint": "プロバイダー「{{name}}」を削除しますか?参照しているプロジェクトはアクセスできなくなります。", + "noPresets": "プリセットなし", + "noPresetsHint": "プリセット一覧を読み込めませんでした。ネットワーク接続をご確認ください。", + "register": "登録", + "addPreset": "追加", + "added": "追加済み", + "tab": { + "providers": "マイプロバイダー", + "presets": "おすすめプリセット" + }, + "form": { + "name": "名前", + "model": "デフォルトモデル", + "agentTypes": "対応エージェント", + "agentTypesHint": "空の場合はすべてのエージェントに対応", + "thinkingDefault": "デフォルト(自動)" + } + }, "theme": { "light": "ライト", "dark": "ダーク", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index ad05151..d6db182 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -2,6 +2,7 @@ "nav": { "dashboard": "總覽", "projects": "專案", + "providers": "服務商", "sessions": "工作階段", "chat": "對話", "cron": "排程工作", @@ -86,7 +87,14 @@ "remove": "移除", "activate": "啟用", "setModel": "設定模型", - "models": "可用模型" + "models": "可用模型", + "global": "全域", + "emptyProject": "此專案尚未設定服務商。", + "emptyProjectHint": "關聯全域服務商或新增自訂服務商。", + "linkGlobal": "關聯全域服務商", + "addCustom": "自訂新增", + "allLinked": "所有全域服務商已關聯。", + "manageGlobal": "管理全域服務商" }, "cron": { "title": "排程工作", @@ -311,6 +319,31 @@ "log": "記錄", "logLevel": "記錄層級" }, + "globalProviders": { + "title": "服務商管理", + "subtitle": "管理全域共用的 API 服務商,可被所有專案引用", + "add": "新增服務商", + "edit": "編輯服務商", + "empty": "尚未配置服務商", + "emptyHint": "新增全域服務商或從預設清單中匯入。", + "deleteHint": "移除服務商「{{name}}」?引用該服務商的專案將失去存取權限。", + "noPresets": "暫無預設", + "noPresetsHint": "無法載入服務商預設清單,請確認網路連線。", + "register": "註冊", + "addPreset": "新增", + "added": "已新增", + "tab": { + "providers": "我的服務商", + "presets": "推薦預設" + }, + "form": { + "name": "名稱", + "model": "預設模型", + "agentTypes": "適用 Agent 類型", + "agentTypesHint": "留空表示適用所有 Agent 類型", + "thinkingDefault": "預設(自動)" + } + }, "theme": { "light": "淺色", "dark": "深色", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index d4966c3..486b4ef 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -2,6 +2,7 @@ "nav": { "dashboard": "概览", "projects": "项目", + "providers": "服务商", "sessions": "会话", "chat": "对话", "cron": "定时任务", @@ -86,7 +87,14 @@ "remove": "移除", "activate": "启用", "setModel": "设置模型", - "models": "可用模型" + "models": "可用模型", + "global": "全局", + "emptyProject": "该项目尚未配置服务商。", + "emptyProjectHint": "关联全局服务商或添加自定义服务商。", + "linkGlobal": "关联全局服务商", + "addCustom": "自定义添加", + "allLinked": "所有全局服务商已关联。", + "manageGlobal": "管理全局服务商" }, "cron": { "title": "定时任务", @@ -311,6 +319,31 @@ "log": "日志", "logLevel": "日志级别" }, + "globalProviders": { + "title": "服务商管理", + "subtitle": "管理全局共享的 API 服务商,可被所有项目引用", + "add": "添加服务商", + "edit": "编辑服务商", + "empty": "尚未配置服务商", + "emptyHint": "添加全局服务商或从预设列表中导入。", + "deleteHint": "移除服务商「{{name}}」?引用该服务商的项目将失去访问权限。", + "noPresets": "暂无预设", + "noPresetsHint": "无法加载服务商预设列表,请检查网络连接。", + "register": "注册", + "addPreset": "添加", + "added": "已添加", + "tab": { + "providers": "我的服务商", + "presets": "推荐预设" + }, + "form": { + "name": "名称", + "model": "默认模型", + "agentTypes": "适用 Agent 类型", + "agentTypesHint": "留空表示适用所有 Agent 类型", + "thinkingDefault": "默认(自动)" + } + }, "theme": { "light": "浅色", "dark": "深色", diff --git a/web/src/pages/Projects/ProjectDetail.tsx b/web/src/pages/Projects/ProjectDetail.tsx index 95d51d9..dcadf0a 100644 --- a/web/src/pages/Projects/ProjectDetail.tsx +++ b/web/src/pages/Projects/ProjectDetail.tsx @@ -3,11 +3,11 @@ import { useTranslation } from 'react-i18next'; import { useParams, Link, useNavigate } from 'react-router-dom'; import { ArrowLeft, Plug, Heart, Settings, Layers, Zap, Pause, Play, - Trash2, Plus, Check, Clock, + Trash2, Plus, Check, Clock, ExternalLink, Link2, } from 'lucide-react'; import { Card, Badge, Button, Input, Modal, EmptyState } from '@/components/ui'; import { getProject, updateProject, deleteProject, type ProjectDetail as ProjectDetailType } from '@/api/projects'; -import { listProviders, addProvider, removeProvider, activateProvider, listModels, setModel, type Provider } from '@/api/providers'; +import { listProviders, addProvider, removeProvider, activateProvider, listModels, setModel, type Provider, listGlobalProviders, type GlobalProvider, saveProviderRefs } from '@/api/providers'; import { getHeartbeat, pauseHeartbeat, resumeHeartbeat, triggerHeartbeat, setHeartbeatInterval, type HeartbeatStatus } from '@/api/heartbeat'; import { restartSystem } from '@/api/status'; import { formatTime, cn } from '@/lib/utils'; @@ -54,8 +54,14 @@ export default function ProjectDetail() { const [platformAllowFrom, setPlatformAllowFrom] = useState>({}); const [saving, setSaving] = useState(false); + // Global providers & refs + const [globalProviders, setGlobalProviders] = useState([]); + const [providerRefs, setProviderRefs] = useState([]); + const [savingRefs, setSavingRefs] = useState(false); + // Add provider modal const [showAddProvider, setShowAddProvider] = useState(false); + const [addMode, setAddMode] = useState<'pick' | 'custom'>('pick'); const [newProvider, setNewProvider] = useState({ name: '', api_key: '', base_url: '', model: '' }); // Interval modal @@ -109,11 +115,12 @@ export default function ProjectDetail() { if (!name) return; try { setLoading(true); - const [proj, provs, hb, mdls] = await Promise.allSettled([ + const [proj, provs, hb, mdls, gp] = await Promise.allSettled([ getProject(name), listProviders(name), getHeartbeat(name), listModels(name), + listGlobalProviders(), ]); if (proj.status === 'fulfilled') { setProject(proj.value); @@ -123,6 +130,7 @@ export default function ProjectDetail() { setWorkDir(proj.value.work_dir || ''); setAgentMode(proj.value.agent_mode || 'default'); setShowCtxIndicator(proj.value.show_context_indicator !== false); + setProviderRefs(proj.value.provider_refs || []); const afMap: Record = {}; proj.value.platform_configs?.forEach(pc => { if (pc.allow_from !== undefined) afMap[pc.type] = pc.allow_from; @@ -138,6 +146,9 @@ export default function ProjectDetail() { setModels(mdls.value.models || []); setCurrentModel(mdls.value.current || ''); } + if (gp.status === 'fulfilled') { + setGlobalProviders(gp.value.providers || []); + } } finally { setLoading(false); } @@ -259,40 +270,85 @@ export default function ProjectDetail() { )} - {tab === 'providers' && ( + {tab === 'providers' && (() => { + const globalNames = new Set(globalProviders.map(g => g.name)); + const isGlobal = (pName: string) => globalNames.has(pName) && providerRefs.includes(pName); + const unlinkedGlobals = globalProviders.filter(g => !providerRefs.includes(g.name)); + return (
+ {/* Header */}

{t('providers.title')}

- +
+ + {/* Unified provider list */} {providers.length === 0 ? ( - + +
+ +

{t('providers.emptyProject', 'No providers configured for this project.')}

+

{t('providers.emptyProjectHint', 'Link a global provider or add a custom one.')}

+
+
) : (
{providers.map((p) => ( - -
-
-
- {p.name} - {p.active && {t('providers.active')}} -
-

{p.model} {p.base_url ? `· ${p.base_url}` : ''}

-
-
- {!p.active && ( - +
+
+
+ {p.name} + {p.active && {t('providers.active')}} + {isGlobal(p.name) && ( + + {t('providers.global', 'global')} + )} - {!p.active && ( -
+

+ {p.model}{p.base_url ? ` · ${p.base_url}` : ''} +

+
+
+ {!p.active && ( + + )} + {!p.active && ( + isGlobal(p.name) ? ( + - )} -
+ ) : ( + + ) + )}
- +
))}
)} @@ -322,19 +378,70 @@ export default function ProjectDetail() { {/* Add Provider Modal */} setShowAddProvider(false)} title={t('providers.add')}> -
- setNewProvider({...newProvider, name: e.target.value})} /> - setNewProvider({...newProvider, api_key: e.target.value})} /> - setNewProvider({...newProvider, base_url: e.target.value})} placeholder="https://api.example.com" /> - setNewProvider({...newProvider, model: e.target.value})} /> -
- - +
+ {/* Toggle */} +
+ +
+ + {addMode === 'pick' ? ( + unlinkedGlobals.length === 0 ? ( +
+

{t('providers.allLinked', 'All global providers are already linked.')}

+ + {t('providers.manageGlobal', 'Manage global providers')} + +
+ ) : ( +
+ {unlinkedGlobals.map(gp => ( + + ))} +
+ ) + ) : ( +
+ setNewProvider({...newProvider, name: e.target.value})} /> + setNewProvider({...newProvider, api_key: e.target.value})} /> + setNewProvider({...newProvider, base_url: e.target.value})} placeholder="https://api.example.com" /> + setNewProvider({...newProvider, model: e.target.value})} /> +
+ + +
+
+ )}
- )} + ); + })()} {tab === 'heartbeat' && (
diff --git a/web/src/pages/Providers/ProviderList.tsx b/web/src/pages/Providers/ProviderList.tsx new file mode 100644 index 0000000..71e167e --- /dev/null +++ b/web/src/pages/Providers/ProviderList.tsx @@ -0,0 +1,484 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Plug, Plus, Trash2, Pencil, ExternalLink, Star, Sparkles, X, Eye, EyeOff, +} from 'lucide-react'; +import { Card, Button, Badge, Modal, Input } from '@/components/ui'; +import { + listGlobalProviders, addGlobalProvider, updateGlobalProvider, removeGlobalProvider, + fetchProviderPresets, + type GlobalProvider, type ProviderPreset, +} from '@/api/providers'; +import { cn } from '@/lib/utils'; + +type Tab = 'providers' | 'presets'; + +export default function ProviderList() { + const { t, i18n } = useTranslation(); + const [tab, setTab] = useState('providers'); + const [providers, setProviders] = useState([]); + const [presets, setPresets] = useState([]); + const [loading, setLoading] = useState(true); + const [presetsLoading, setPresetsLoading] = useState(false); + const [showAddModal, setShowAddModal] = useState(false); + const [editProvider, setEditProvider] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const data = await listGlobalProviders(); + setProviders(data.providers || []); + } catch { /* empty */ } + setLoading(false); + }, []); + + const loadPresets = useCallback(async () => { + setPresetsLoading(true); + try { + const data = await fetchProviderPresets(); + setPresets(data.providers || []); + } catch { /* empty */ } + setPresetsLoading(false); + }, []); + + useEffect(() => { refresh(); }, [refresh]); + useEffect(() => { + if (tab === 'presets' && presets.length === 0) loadPresets(); + }, [tab, presets.length, loadPresets]); + + const handleDelete = async () => { + if (!deleteTarget) return; + try { + await removeGlobalProvider(deleteTarget); + await refresh(); + } catch { /* empty */ } + setDeleteTarget(null); + }; + + const handleAddFromPreset = (preset: ProviderPreset) => { + const baseUrl = preset.endpoints?.['claudecode'] || preset.base_url; + setEditProvider({ + name: preset.name, + base_url: baseUrl, + model: preset.agent_models?.['claudecode'] || preset.models?.[0] || '', + thinking: preset.thinking || '', + models: preset.models?.map(m => ({ model: m })), + agent_types: preset.agents || [], + _preset: preset, + } as any); + setShowAddModal(true); + }; + + return ( +
+ {/* Header */} +
+
+

+ {t('globalProviders.title')} +

+

+ {t('globalProviders.subtitle')} +

+
+ +
+ + {/* Tabs */} +
+ {(['providers', 'presets'] as const).map(key => ( + + ))} +
+ + {/* Content */} + {tab === 'providers' && ( + { setEditProvider(p); setShowAddModal(true); }} + onDelete={name => setDeleteTarget(name)} + t={t} + /> + )} + {tab === 'presets' && ( + p.name))} + onAdd={handleAddFromPreset} + onRefresh={loadPresets} + t={t} + lang={i18n.language || 'en'} + /> + )} + + {/* Add/Edit Modal */} + {showAddModal && ( + setShowAddModal(false)} + onSave={async (p) => { + if (editProvider?.name && providers.some(x => x.name === editProvider.name)) { + await updateGlobalProvider(editProvider.name, p); + } else { + await addGlobalProvider(p); + } + setShowAddModal(false); + await refresh(); + }} + t={t} + /> + )} + + {/* Delete confirm */} + setDeleteTarget(null)} title={t('common.confirmDelete')}> +

+ {t('globalProviders.deleteHint', { name: deleteTarget })} +

+
+ + +
+
+
+ ); +} + +/* ── Provider Grid ── */ + +function ProviderGrid({ + providers, loading, onEdit, onDelete, t, +}: { + providers: GlobalProvider[]; + loading: boolean; + onEdit: (p: GlobalProvider) => void; + onDelete: (name: string) => void; + t: (k: string) => string; +}) { + if (loading) return

{t('common.loading')}

; + if (providers.length === 0) { + return ( +
+ +

{t('globalProviders.empty')}

+

{t('globalProviders.emptyHint')}

+
+ ); + } + return ( +
+ {providers.map(p => ( + +
+
+
+ +

{p.name}

+
+ {p.base_url && ( +

{p.base_url}

+ )} + {p.model && ( + {p.model} + )} + {p.models && p.models.length > 0 && ( +
+ {p.models.map(m => ( + + {m.alias || m.model} + + ))} +
+ )} + {p.agent_types && p.agent_types.length > 0 && ( +
+ {p.agent_types.map(a => ( + {a} + ))} +
+ )} + {p.thinking && ( +

thinking: {p.thinking}

+ )} +
+
+ + +
+
+
+ ))} +
+ ); +} + +/* ── Presets Grid ── */ + +function PresetGrid({ + presets, loading, existingNames, onAdd, onRefresh, t, lang, +}: { + presets: ProviderPreset[]; + loading: boolean; + existingNames: Set; + onAdd: (p: ProviderPreset) => void; + onRefresh: () => void; + t: (k: string, opts?: any) => string; + lang: string; +}) { + if (loading) return

{t('common.loading')}

; + if (presets.length === 0) { + return ( +
+ +

{t('globalProviders.noPresets')}

+

{t('globalProviders.noPresetsHint')}

+ +
+ ); + } + const sorted = [...presets].sort((a, b) => a.tier - b.tier); + return ( +
+ {sorted.map(p => { + const added = existingNames.has(p.name); + return ( + + {p.tier <= 1 && ( +
+ SPONSOR +
+ )} +
+
+

{p.display_name || p.name}

+ {(p.description || p.description_zh) && ( +

+ {lang.startsWith('zh') && p.description_zh ? p.description_zh : p.description} +

+ )} +
+ {p.agents && p.agents.length > 0 && ( +
+ {p.agents.map(a => ( + {a} + ))} +
+ )} + {p.features && p.features.length > 0 && ( +
+ {p.features.map(f => ( + {f} + ))} +
+ )} + {p.models && p.models.length > 0 && ( +
+ {p.models.slice(0, 4).map(m => ( + {m} + ))} + {p.models.length > 4 && ( + +{p.models.length - 4} + )} +
+ )} +
+ {p.invite_url ? ( + + {t('globalProviders.register')} + + ) : } + +
+
+
+ ); + })} +
+ ); +} + +/* ── Add/Edit Form Modal ── */ + +function ProviderFormModal({ + provider, onClose, onSave, t, +}: { + provider: GlobalProvider | null; + onClose: () => void; + onSave: (p: GlobalProvider) => Promise; + t: (k: string) => string; +}) { + const isEdit = !!provider?.name; + const [form, setForm] = useState(provider || { name: '' }); + const [saving, setSaving] = useState(false); + const [showKey, setShowKey] = useState(false); + + const set = (key: keyof GlobalProvider, value: any) => + setForm(f => ({ ...f, [key]: value })); + + const handleSubmit = async () => { + if (!form.name) return; + setSaving(true); + try { + await onSave(form); + } catch { /* empty */ } + setSaving(false); + }; + + return ( + +
+
+
+ + set('name', e.target.value)} + placeholder="e.g. minimaxi" + disabled={isEdit} + /> +
+ +
+ +
+ set('api_key', e.target.value)} + placeholder="sk-..." + className="pr-10" + /> + +
+
+ +
+ + set('base_url', e.target.value)} + placeholder="https://api.example.com/v1" + /> +
+ +
+ + set('model', e.target.value)} + placeholder="claude-sonnet-4-20250514" + /> +
+ +
+ +
+ {['claudecode', 'codex', 'gemini', 'opencode', 'cursor', 'kimi', 'qoder', 'acp'].map(at => { + const selected = form.agent_types?.includes(at) ?? false; + return ( + + ); + })} +
+

{t('globalProviders.form.agentTypesHint')}

+
+ +
+ + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index 60ec59f..8145e85 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/api/bridge.ts","./src/api/client.ts","./src/api/cron.ts","./src/api/heartbeat.ts","./src/api/index.ts","./src/api/projects.ts","./src/api/providers.ts","./src/api/sessions.ts","./src/api/settings.ts","./src/api/setup.ts","./src/api/status.ts","./src/components/Layout/Footer.tsx","./src/components/Layout/Header.tsx","./src/components/Layout/Layout.tsx","./src/components/Layout/Sidebar.tsx","./src/components/ui/Badge.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/EmptyState.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/index.ts","./src/hooks/useBridgeSocket.ts","./src/i18n/index.ts","./src/lib/platformMeta.ts","./src/lib/utils.ts","./src/pages/Dashboard.tsx","./src/pages/Login.tsx","./src/pages/Bridge/BridgeAdapters.tsx","./src/pages/Chat/ChatList.tsx","./src/pages/Chat/ChatView.tsx","./src/pages/Chat/CommandPalette.tsx","./src/pages/Chat/CommandResultPanel.tsx","./src/pages/Chat/SessionDrawer.tsx","./src/pages/Cron/CronList.tsx","./src/pages/Projects/PlatformManualForm.tsx","./src/pages/Projects/PlatformSetupQR.tsx","./src/pages/Projects/ProjectDetail.tsx","./src/pages/Projects/ProjectList.tsx","./src/pages/Sessions/SessionChat.tsx","./src/pages/Sessions/SessionList.tsx","./src/pages/System/Config.tsx","./src/pages/System/GlobalSettings.tsx","./src/store/auth.ts","./src/store/theme.ts","./vite-env.d.ts"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/api/bridge.ts","./src/api/client.ts","./src/api/cron.ts","./src/api/heartbeat.ts","./src/api/index.ts","./src/api/projects.ts","./src/api/providers.ts","./src/api/sessions.ts","./src/api/settings.ts","./src/api/setup.ts","./src/api/status.ts","./src/components/Layout/Footer.tsx","./src/components/Layout/Header.tsx","./src/components/Layout/Layout.tsx","./src/components/Layout/Sidebar.tsx","./src/components/ui/Badge.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/EmptyState.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/index.ts","./src/hooks/useBridgeSocket.ts","./src/i18n/index.ts","./src/lib/platformMeta.ts","./src/lib/utils.ts","./src/pages/Dashboard.tsx","./src/pages/Login.tsx","./src/pages/Bridge/BridgeAdapters.tsx","./src/pages/Chat/ChatList.tsx","./src/pages/Chat/ChatView.tsx","./src/pages/Chat/CommandPalette.tsx","./src/pages/Chat/CommandResultPanel.tsx","./src/pages/Chat/SessionDrawer.tsx","./src/pages/Cron/CronList.tsx","./src/pages/Projects/PlatformManualForm.tsx","./src/pages/Projects/PlatformSetupQR.tsx","./src/pages/Projects/ProjectDetail.tsx","./src/pages/Projects/ProjectList.tsx","./src/pages/Providers/ProviderList.tsx","./src/pages/Sessions/SessionChat.tsx","./src/pages/Sessions/SessionList.tsx","./src/pages/System/Config.tsx","./src/pages/System/GlobalSettings.tsx","./src/store/auth.ts","./src/store/theme.ts","./vite-env.d.ts"],"version":"5.8.3"} \ No newline at end of file