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
This commit is contained in:
Claude committed 2026-04-17 22:08:40 +08:00
1 parent a36d4a91c4
commit b7304a2da9
25 files changed
+2372 -57

No files matched your search

+60
View File
@@ -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
+184 -1
View File
@@ -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 <command>
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 <name> --api-key <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)
}
+44 -2
View File
@@ -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 命令切换
+175 -10
View File
@@ -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()
+171
View File
@@ -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)
}
}
+317 -2
View File
@@ -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 <preset_name>" (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 <name>" 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]
+26
View File
@@ -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]` 部分。",
+233
View File
@@ -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)
}
+120
View File
@@ -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
}
+180
View File
@@ -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"
}
]
}
-1
View File
@@ -1 +0,0 @@
+2
View File
@@ -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() {
<Route index element={<Dashboard />} />
<Route path="projects" element={<ProjectList />} />
<Route path="projects/:name" element={<ProjectDetail />} />
<Route path="providers" element={<ProviderList />} />
<Route path="chat" element={<ChatList />} />
<Route path="chat/:name" element={<ChatView />} />
<Route path="cron" element={<CronList />} />
+1
View File
@@ -37,6 +37,7 @@ class ApiClient {
get<T = any>(path: string, params?: Record<string, string>) { return this.request<T>('GET', path, undefined, params); }
post<T = any>(path: string, body?: any) { return this.request<T>('POST', path, body); }
put<T = any>(path: string, body?: any) { return this.request<T>('PUT', path, body); }
patch<T = any>(path: string, body?: any) { return this.request<T>('PATCH', path, body); }
delete<T = any>(path: string) { return this.request<T>('DELETE', path); }
+1
View File
@@ -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;
+58
View File
@@ -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<string, string>;
agent_types?: string[];
models?: ProviderModel[];
}
export interface ProviderPreset {
name: string;
display_name: string;
base_url: string;
endpoints?: Record<string, string>;
models?: string[];
agent_models?: Record<string, string>;
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<GlobalProvider>) =>
api.put<{ message: string }>(`/providers/${name}`, body);
export const removeGlobalProvider = (name: string) =>
api.delete<{ message: string }>(`/providers/${name}`);
export const fetchProviderPresets = () =>
api.get<PresetsResponse>('/providers/presets');
+2
View File
@@ -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 },
+2 -1
View File
@@ -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) {
+34 -1
View File
@@ -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",
+34 -1
View File
@@ -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",
+34 -1
View File
@@ -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": "ダーク",
+34 -1
View File
@@ -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": "深色",
+34 -1
View File
@@ -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": "深色",
+141 -34
View File
@@ -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<Record<string, string>>({});
const [saving, setSaving] = useState(false);
// Global providers & refs
const [globalProviders, setGlobalProviders] = useState<GlobalProvider[]>([]);
const [providerRefs, setProviderRefs] = useState<string[]>([]);
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<string, string> = {};
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() {
</div>
)}
{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 (
<div className="space-y-4">
{/* Header */}
<div className="flex justify-between items-center">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{t('providers.title')}</h3>
<Button size="sm" onClick={() => setShowAddProvider(true)}><Plus size={14} /> {t('providers.add')}</Button>
<Button size="sm" onClick={() => { setAddMode('pick'); setShowAddProvider(true); }}><Plus size={14} /> {t('providers.add')}</Button>
</div>
{/* Unified provider list */}
{providers.length === 0 ? (
<EmptyState message={t('common.noData')} />
<Card>
<div className="py-6 text-center">
<Plug size={32} className="mx-auto text-gray-300 dark:text-gray-600 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">{t('providers.emptyProject', 'No providers configured for this project.')}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">{t('providers.emptyProjectHint', 'Link a global provider or add a custom one.')}</p>
</div>
</Card>
) : (
<div className="space-y-2">
{providers.map((p) => (
<Card key={p.name}>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">{p.name}</span>
{p.active && <Badge variant="success">{t('providers.active')}</Badge>}
</div>
<p className="text-xs text-gray-500 mt-1">{p.model} {p.base_url ? `· ${p.base_url}` : ''}</p>
</div>
<div className="flex gap-2">
{!p.active && (
<Button size="sm" variant="secondary" onClick={() => { activateProvider(name!, p.name).then(fetchAll); }}>
<Check size={14} /> {t('providers.activate')}
</Button>
<div
key={p.name}
className={cn(
'flex items-center justify-between px-4 py-3 rounded-xl border transition-all',
p.active
? 'border-emerald-200 dark:border-emerald-500/20 bg-emerald-50/50 dark:bg-emerald-900/10'
: 'border-gray-200 dark:border-gray-700/60 bg-white dark:bg-gray-800/40',
)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-white">{p.name}</span>
{p.active && <Badge variant="success">{t('providers.active')}</Badge>}
{isGlobal(p.name) && (
<Link to="/providers" className="inline-flex items-center gap-0.5 text-[10px] text-gray-400 hover:text-accent transition-colors">
<Link2 size={10} /> {t('providers.global', 'global')}
</Link>
)}
{!p.active && (
<Button size="sm" variant="danger" onClick={() => { removeProvider(name!, p.name).then(fetchAll); }}>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{p.model}{p.base_url ? ` · ${p.base_url}` : ''}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0 ml-3">
{!p.active && (
<Button size="sm" variant="ghost" onClick={() => { activateProvider(name!, p.name).then(fetchAll); }}>
<Zap size={14} /> {t('providers.activate')}
</Button>
)}
{!p.active && (
isGlobal(p.name) ? (
<Button
size="sm"
variant="ghost"
className="text-gray-400 hover:text-red-500"
onClick={async () => {
const next = providerRefs.filter(r => r !== p.name);
setSavingRefs(true);
try {
await saveProviderRefs(name!, next);
await removeProvider(name!, p.name);
await fetchAll();
} finally { setSavingRefs(false); }
}}
>
<Trash2 size={14} />
</Button>
)}
</div>
) : (
<Button size="sm" variant="ghost" className="text-gray-400 hover:text-red-500" onClick={() => { removeProvider(name!, p.name).then(fetchAll); }}>
<Trash2 size={14} />
</Button>
)
)}
</div>
</Card>
</div>
))}
</div>
)}
@@ -322,19 +378,70 @@ export default function ProjectDetail() {
{/* Add Provider Modal */}
<Modal open={showAddProvider} onClose={() => setShowAddProvider(false)} title={t('providers.add')}>
<div className="space-y-3">
<Input label={t('providers.name')} value={newProvider.name} onChange={(e) => setNewProvider({...newProvider, name: e.target.value})} />
<Input label="API Key" type="password" value={newProvider.api_key} onChange={(e) => setNewProvider({...newProvider, api_key: e.target.value})} />
<Input label={t('providers.baseUrl')} value={newProvider.base_url} onChange={(e) => setNewProvider({...newProvider, base_url: e.target.value})} placeholder="https://api.example.com" />
<Input label={t('providers.model')} value={newProvider.model} onChange={(e) => setNewProvider({...newProvider, model: e.target.value})} />
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setShowAddProvider(false)}>{t('common.cancel')}</Button>
<Button onClick={handleAddProvider}>{t('providers.add')}</Button>
<div className="space-y-4">
{/* Toggle */}
<div className="flex rounded-lg bg-gray-100 dark:bg-gray-800 p-0.5">
<button
className={cn('flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-all', addMode === 'pick' ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm' : 'text-gray-500')}
onClick={() => setAddMode('pick')}
>{t('providers.linkGlobal', 'Link global')}</button>
<button
className={cn('flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-all', addMode === 'custom' ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm' : 'text-gray-500')}
onClick={() => setAddMode('custom')}
>{t('providers.addCustom', 'Add custom')}</button>
</div>
{addMode === 'pick' ? (
unlinkedGlobals.length === 0 ? (
<div className="py-4 text-center">
<p className="text-sm text-gray-500 dark:text-gray-400">{t('providers.allLinked', 'All global providers are already linked.')}</p>
<Link to="/providers" className="inline-flex items-center gap-1 mt-2 text-xs text-accent hover:underline">
{t('providers.manageGlobal', 'Manage global providers')} <ExternalLink size={11} />
</Link>
</div>
) : (
<div className="space-y-1.5 max-h-64 overflow-y-auto">
{unlinkedGlobals.map(gp => (
<button
key={gp.name}
className="flex items-center justify-between w-full px-3 py-2.5 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-accent/40 hover:bg-accent/5 transition-all text-left"
onClick={async () => {
const next = [...providerRefs, gp.name];
setSavingRefs(true);
try {
await saveProviderRefs(name!, next);
await addProvider(name!, { name: gp.name, api_key: gp.api_key || '', base_url: gp.base_url || '', model: gp.model || '' });
await fetchAll();
} finally { setSavingRefs(false); }
setShowAddProvider(false);
}}
>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">{gp.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{gp.model}{gp.base_url ? ` · ${gp.base_url}` : ''}</div>
</div>
<Plus size={16} className="shrink-0 text-gray-400" />
</button>
))}
</div>
)
) : (
<div className="space-y-3">
<Input label={t('providers.name')} value={newProvider.name} onChange={(e) => setNewProvider({...newProvider, name: e.target.value})} />
<Input label="API Key" type="password" value={newProvider.api_key} onChange={(e) => setNewProvider({...newProvider, api_key: e.target.value})} />
<Input label={t('providers.baseUrl')} value={newProvider.base_url} onChange={(e) => setNewProvider({...newProvider, base_url: e.target.value})} placeholder="https://api.example.com" />
<Input label={t('providers.model')} value={newProvider.model} onChange={(e) => setNewProvider({...newProvider, model: e.target.value})} />
<div className="flex justify-end gap-2 pt-1">
<Button variant="secondary" onClick={() => setShowAddProvider(false)}>{t('common.cancel')}</Button>
<Button onClick={handleAddProvider}>{t('providers.add')}</Button>
</div>
</div>
)}
</div>
</Modal>
</div>
)}
);
})()}
{tab === 'heartbeat' && (
<div className="space-y-4">
+484
View File
@@ -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<Tab>('providers');
const [providers, setProviders] = useState<GlobalProvider[]>([]);
const [presets, setPresets] = useState<ProviderPreset[]>([]);
const [loading, setLoading] = useState(true);
const [presetsLoading, setPresetsLoading] = useState(false);
const [showAddModal, setShowAddModal] = useState(false);
const [editProvider, setEditProvider] = useState<GlobalProvider | null>(null);
const [deleteTarget, setDeleteTarget] = useState<string | null>(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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
{t('globalProviders.title')}
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t('globalProviders.subtitle')}
</p>
</div>
<Button onClick={() => { setEditProvider(null); setShowAddModal(true); }}>
<Plus size={16} className="mr-1.5" /> {t('globalProviders.add')}
</Button>
</div>
{/* Tabs */}
<div className="flex gap-1 p-1 rounded-xl bg-gray-100 dark:bg-white/[0.06] w-fit">
{(['providers', 'presets'] as const).map(key => (
<button
key={key}
onClick={() => setTab(key)}
className={cn(
'px-4 py-1.5 rounded-lg text-sm font-medium transition-all',
tab === key
? 'bg-white dark:bg-white/10 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300',
)}
>
{t(`globalProviders.tab.${key}`)}
</button>
))}
</div>
{/* Content */}
{tab === 'providers' && (
<ProviderGrid
providers={providers}
loading={loading}
onEdit={p => { setEditProvider(p); setShowAddModal(true); }}
onDelete={name => setDeleteTarget(name)}
t={t}
/>
)}
{tab === 'presets' && (
<PresetGrid
presets={presets}
loading={presetsLoading}
existingNames={new Set(providers.map(p => p.name))}
onAdd={handleAddFromPreset}
onRefresh={loadPresets}
t={t}
lang={i18n.language || 'en'}
/>
)}
{/* Add/Edit Modal */}
{showAddModal && (
<ProviderFormModal
provider={editProvider}
onClose={() => 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 */}
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title={t('common.confirmDelete')}>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{t('globalProviders.deleteHint', { name: deleteTarget })}
</p>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setDeleteTarget(null)}>{t('common.cancel')}</Button>
<Button variant="danger" onClick={handleDelete}>{t('common.delete')}</Button>
</div>
</Modal>
</div>
);
}
/* ── 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 <p className="text-sm text-gray-400">{t('common.loading')}</p>;
if (providers.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Plug size={40} className="text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{t('globalProviders.empty')}</p>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">{t('globalProviders.emptyHint')}</p>
</div>
);
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{providers.map(p => (
<Card key={p.name} className="group relative">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Plug size={16} className="text-accent shrink-0" />
<h3 className="font-medium text-gray-900 dark:text-white truncate">{p.name}</h3>
</div>
{p.base_url && (
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500 truncate">{p.base_url}</p>
)}
{p.model && (
<Badge className="mt-2">{p.model}</Badge>
)}
{p.models && p.models.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{p.models.map(m => (
<Badge key={m.model} variant="outline" className="text-xs">
{m.alias || m.model}
</Badge>
))}
</div>
)}
{p.agent_types && p.agent_types.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{p.agent_types.map(a => (
<Badge key={a} variant="info" className="text-xs">{a}</Badge>
))}
</div>
)}
{p.thinking && (
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">thinking: {p.thinking}</p>
)}
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => onEdit(p)}
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-white/[0.06] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<Pencil size={14} />
</button>
<button
onClick={() => onDelete(p.name)}
className="p-1.5 rounded-lg hover:bg-red-50 dark:hover:bg-red-500/10 text-gray-400 hover:text-red-500"
>
<Trash2 size={14} />
</button>
</div>
</div>
</Card>
))}
</div>
);
}
/* ── Presets Grid ── */
function PresetGrid({
presets, loading, existingNames, onAdd, onRefresh, t, lang,
}: {
presets: ProviderPreset[];
loading: boolean;
existingNames: Set<string>;
onAdd: (p: ProviderPreset) => void;
onRefresh: () => void;
t: (k: string, opts?: any) => string;
lang: string;
}) {
if (loading) return <p className="text-sm text-gray-400">{t('common.loading')}</p>;
if (presets.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Sparkles size={40} className="text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{t('globalProviders.noPresets')}</p>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">{t('globalProviders.noPresetsHint')}</p>
<Button variant="ghost" onClick={onRefresh} className="mt-3">{t('common.refresh')}</Button>
</div>
);
}
const sorted = [...presets].sort((a, b) => a.tier - b.tier);
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sorted.map(p => {
const added = existingNames.has(p.name);
return (
<Card key={p.name} className="relative overflow-hidden">
{p.tier <= 1 && (
<div className="absolute top-0 right-0 bg-amber-400/90 text-white text-[10px] font-bold px-2 py-0.5 rounded-bl-lg">
<Star size={10} className="inline mr-0.5 -mt-0.5" /> SPONSOR
</div>
)}
<div className="space-y-3">
<div>
<h3 className="font-medium text-gray-900 dark:text-white">{p.display_name || p.name}</h3>
{(p.description || p.description_zh) && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{lang.startsWith('zh') && p.description_zh ? p.description_zh : p.description}
</p>
)}
</div>
{p.agents && p.agents.length > 0 && (
<div className="flex flex-wrap gap-1">
{p.agents.map(a => (
<Badge key={a} variant="info" className="text-xs">{a}</Badge>
))}
</div>
)}
{p.features && p.features.length > 0 && (
<div className="flex flex-wrap gap-1">
{p.features.map(f => (
<Badge key={f} variant="outline" className="text-xs">{f}</Badge>
))}
</div>
)}
{p.models && p.models.length > 0 && (
<div className="flex flex-wrap gap-1">
{p.models.slice(0, 4).map(m => (
<Badge key={m} className="text-xs">{m}</Badge>
))}
{p.models.length > 4 && (
<Badge className="text-xs">+{p.models.length - 4}</Badge>
)}
</div>
)}
<div className="flex items-center justify-between pt-1">
{p.invite_url ? (
<a
href={p.invite_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-accent hover:underline inline-flex items-center gap-1"
>
{t('globalProviders.register')} <ExternalLink size={11} />
</a>
) : <span />}
<Button
size="sm"
variant={added ? 'ghost' : 'primary'}
disabled={added}
onClick={() => onAdd(p)}
>
{added ? t('globalProviders.added') : t('globalProviders.addPreset')}
</Button>
</div>
</div>
</Card>
);
})}
</div>
);
}
/* ── Add/Edit Form Modal ── */
function ProviderFormModal({
provider, onClose, onSave, t,
}: {
provider: GlobalProvider | null;
onClose: () => void;
onSave: (p: GlobalProvider) => Promise<void>;
t: (k: string) => string;
}) {
const isEdit = !!provider?.name;
const [form, setForm] = useState<GlobalProvider>(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 (
<Modal open onClose={onClose} title={isEdit ? t('globalProviders.edit') : t('globalProviders.add')}>
<div className="space-y-5">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('globalProviders.form.name')} *
</label>
<Input
value={form.name}
onChange={e => set('name', e.target.value)}
placeholder="e.g. minimaxi"
disabled={isEdit}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
API Key
</label>
<div className="relative">
<Input
type={showKey ? 'text' : 'password'}
value={form.api_key || ''}
onChange={e => set('api_key', e.target.value)}
placeholder="sk-..."
className="pr-10"
/>
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Base URL
</label>
<Input
value={form.base_url || ''}
onChange={e => set('base_url', e.target.value)}
placeholder="https://api.example.com/v1"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('globalProviders.form.model')}
</label>
<Input
value={form.model || ''}
onChange={e => set('model', e.target.value)}
placeholder="claude-sonnet-4-20250514"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('globalProviders.form.agentTypes')}
</label>
<div className="flex flex-wrap gap-2">
{['claudecode', 'codex', 'gemini', 'opencode', 'cursor', 'kimi', 'qoder', 'acp'].map(at => {
const selected = form.agent_types?.includes(at) ?? false;
return (
<button
key={at}
type="button"
onClick={() => {
const current = form.agent_types || [];
set('agent_types', selected ? current.filter(x => x !== at) : [...current, at]);
}}
className={cn(
'px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors',
selected
? 'bg-accent/10 text-accent border-accent/30'
: 'bg-transparent text-gray-400 border-gray-200 dark:border-white/10 hover:border-gray-300',
)}
>
{at}
</button>
);
})}
</div>
<p className="mt-1 text-xs text-gray-400">{t('globalProviders.form.agentTypesHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Thinking
</label>
<select
value={form.thinking || ''}
onChange={e => set('thinking', e.target.value)}
className={cn(
'w-full rounded-xl border px-3 py-2 text-sm outline-none transition-colors',
'border-gray-200 bg-white text-gray-900',
'dark:border-white/10 dark:bg-white/[0.04] dark:text-white',
'focus:border-accent focus:ring-1 focus:ring-accent/30',
)}
>
<option value="">{t('globalProviders.form.thinkingDefault')}</option>
<option value="enabled">enabled</option>
<option value="disabled">disabled</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={!form.name || saving}>
{saving ? t('common.loading') : t('common.save')}
</Button>
</div>
</div>
</Modal>
);
}
+1 -1
View File
@@ -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"}
{"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"}