3161 lines
104 KiB
Go
3161 lines
104 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/boot"
|
|
"reasonix/internal/botruntime"
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/control"
|
|
"reasonix/internal/provider"
|
|
"reasonix/internal/sandbox"
|
|
)
|
|
|
|
// settings_app.go is the desktop Settings panel's command surface: it reads the
|
|
// resolved config and applies edits through internal/config/edit.go (the
|
|
// purpose-built mutation API), then rebuilds the controller so the change takes
|
|
// effect live — the same snapshot→reload→resume pattern as SetModel. Secrets are
|
|
// the exception: they go to Reasonix's global .env (upsertDotEnv), since config
|
|
// stores only the env-var name, not the key.
|
|
|
|
// --- read ---
|
|
|
|
type ProviderView struct {
|
|
Name string `json:"name"`
|
|
BuiltIn bool `json:"builtIn"`
|
|
Added bool `json:"added"`
|
|
Kind string `json:"kind"`
|
|
BaseURL string `json:"baseUrl"`
|
|
ChatURL string `json:"chatUrl"`
|
|
Models []string `json:"models"`
|
|
VisionModels []string `json:"visionModels"`
|
|
VisionModelsSet bool `json:"visionModelsConfigured"`
|
|
ModelsURL string `json:"modelsUrl"`
|
|
Default string `json:"default"`
|
|
APIKeyEnv string `json:"apiKeyEnv"`
|
|
Headers map[string]string `json:"headers"`
|
|
ExtraBody map[string]any `json:"extraBody"`
|
|
AuthHeader bool `json:"authHeader"`
|
|
KeySet bool `json:"keySet"` // the env var currently resolves to a non-empty value
|
|
RequiresKey bool `json:"requiresKey"`
|
|
Configured bool `json:"configured"` // selectable: either key is present or no key is required
|
|
KeySource string `json:"keySource,omitempty"`
|
|
KeySourcePath string `json:"keySourcePath,omitempty"`
|
|
BalanceURL string `json:"balanceUrl"`
|
|
ContextWindow int `json:"contextWindow"`
|
|
ReasoningProtocol string `json:"reasoningProtocol"`
|
|
Thinking string `json:"thinking"`
|
|
SupportedEfforts []string `json:"supportedEfforts"`
|
|
DefaultEffort string `json:"defaultEffort"`
|
|
ModelOverrides []ProviderModelOverrideView `json:"modelOverrides"`
|
|
}
|
|
|
|
type ProviderPresetView struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Description string `json:"description"`
|
|
KeyEnv string `json:"keyEnv"`
|
|
ProviderNames []string `json:"providerNames"`
|
|
Models []string `json:"models"`
|
|
Added bool `json:"added"`
|
|
Status string `json:"status"`
|
|
StatusProviderNames []string `json:"statusProviderNames"`
|
|
KeySet bool `json:"keySet"`
|
|
RequiresKey bool `json:"requiresKey"`
|
|
Configured bool `json:"configured"`
|
|
KeySource string `json:"keySource,omitempty"`
|
|
KeySourcePath string `json:"keySourcePath,omitempty"`
|
|
}
|
|
|
|
const (
|
|
providerPresetStatusAvailable = "available"
|
|
providerPresetStatusInstalled = "installed"
|
|
providerPresetStatusInstalledModified = "installed_modified"
|
|
providerPresetStatusNameConflict = "name_conflict"
|
|
providerPresetStatusSimilarExisting = "similar_existing"
|
|
)
|
|
|
|
type ProviderModelOverrideView struct {
|
|
Model string `json:"model"`
|
|
ReasoningProtocol string `json:"reasoningProtocol"`
|
|
Thinking string `json:"thinking"`
|
|
SupportedEfforts []string `json:"supportedEfforts"`
|
|
DefaultEffort string `json:"defaultEffort"`
|
|
Vision *bool `json:"vision"`
|
|
}
|
|
|
|
type PermissionsView struct {
|
|
Mode string `json:"mode"`
|
|
Allow []string `json:"allow"`
|
|
Ask []string `json:"ask"`
|
|
Deny []string `json:"deny"`
|
|
}
|
|
|
|
type SandboxView struct {
|
|
Bash string `json:"bash"`
|
|
Network bool `json:"network"`
|
|
WorkspaceRoot string `json:"workspaceRoot"`
|
|
AllowWrite []string `json:"allowWrite"`
|
|
EffectiveWorkspaceRoot string `json:"effectiveWorkspaceRoot"`
|
|
EffectiveWriteRoots []string `json:"effectiveWriteRoots"`
|
|
Shell string `json:"shell"` // [tools.shell] prefer: auto|bash|powershell|pwsh
|
|
EffectiveShell string `json:"effectiveShell,omitempty"`
|
|
}
|
|
|
|
type NetworkProxyView struct {
|
|
Type string `json:"type"`
|
|
Server string `json:"server"`
|
|
Port int `json:"port"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type NetworkView struct {
|
|
ProxyMode string `json:"proxyMode"`
|
|
ProxyURL string `json:"proxyUrl"`
|
|
NoProxy string `json:"noProxy"`
|
|
Proxy NetworkProxyView `json:"proxy"`
|
|
}
|
|
|
|
type AgentView struct {
|
|
Temperature float64 `json:"temperature"`
|
|
MaxSteps int `json:"maxSteps"`
|
|
PlannerMaxSteps int `json:"plannerMaxSteps"`
|
|
MaxSubagentDepth int `json:"maxSubagentDepth"`
|
|
SystemPrompt string `json:"systemPrompt"`
|
|
ColdResumePrune bool `json:"coldResumePrune"`
|
|
ReasoningLanguage string `json:"reasoningLanguage"`
|
|
}
|
|
|
|
type BotAllowlistView struct {
|
|
Enabled bool `json:"enabled"`
|
|
AllowAll bool `json:"allowAll"`
|
|
QQUsers []string `json:"qqUsers"`
|
|
FeishuUsers []string `json:"feishuUsers"`
|
|
WeixinUsers []string `json:"weixinUsers"`
|
|
QQApprovers []string `json:"qqApprovers"`
|
|
FeishuApprovers []string `json:"feishuApprovers"`
|
|
WeixinApprovers []string `json:"weixinApprovers"`
|
|
QQAdmins []string `json:"qqAdmins"`
|
|
FeishuAdmins []string `json:"feishuAdmins"`
|
|
WeixinAdmins []string `json:"weixinAdmins"`
|
|
QQGroups []string `json:"qqGroups"`
|
|
FeishuGroups []string `json:"feishuGroups"`
|
|
WeixinGroups []string `json:"weixinGroups"`
|
|
}
|
|
|
|
type BotAccessView struct {
|
|
Enabled bool `json:"enabled"`
|
|
AllowAll bool `json:"allowAll"`
|
|
PairingEnabled bool `json:"pairingEnabled"`
|
|
Users []string `json:"users"`
|
|
Groups []string `json:"groups"`
|
|
Approvers []string `json:"approvers"`
|
|
Admins []string `json:"admins"`
|
|
}
|
|
|
|
type BotSelfUserIDsView struct {
|
|
QQ []string `json:"qq"`
|
|
Feishu []string `json:"feishu"`
|
|
Weixin []string `json:"weixin"`
|
|
}
|
|
|
|
type BotPairingView struct {
|
|
Enabled bool `json:"enabled"`
|
|
RequestTTLMinutes int `json:"requestTtlMinutes"`
|
|
MaxPendingPerPlatform int `json:"maxPendingPerPlatform"`
|
|
}
|
|
|
|
type BotControlView struct {
|
|
Enabled bool `json:"enabled"`
|
|
Addr string `json:"addr"`
|
|
TokenEnv string `json:"tokenEnv"`
|
|
}
|
|
|
|
type BotRouteView struct {
|
|
ConnectionID string `json:"connectionId"`
|
|
Platform string `json:"platform"`
|
|
ChatType string `json:"chatType"`
|
|
ChatID string `json:"chatId"`
|
|
UserID string `json:"userId"`
|
|
ThreadID string `json:"threadId"`
|
|
Model string `json:"model"`
|
|
ToolApprovalMode string `json:"toolApprovalMode"`
|
|
WorkspaceRoot string `json:"workspaceRoot"`
|
|
}
|
|
|
|
type QQBotView struct {
|
|
Enabled bool `json:"enabled"`
|
|
AppID string `json:"appId"`
|
|
AppSecretEnv string `json:"appSecretEnv"`
|
|
SecretSet bool `json:"secretSet"`
|
|
Sandbox bool `json:"sandbox"`
|
|
Model string `json:"model"`
|
|
ToolApprovalMode string `json:"toolApprovalMode"`
|
|
WorkspaceRoot string `json:"workspaceRoot"`
|
|
Access BotAccessView `json:"access"`
|
|
}
|
|
|
|
type FeishuBotView struct {
|
|
Enabled bool `json:"enabled"`
|
|
Domain string `json:"domain"`
|
|
AppID string `json:"appId"`
|
|
AppSecretEnv string `json:"appSecretEnv"`
|
|
SecretSet bool `json:"secretSet"`
|
|
VerificationToken string `json:"verificationToken"`
|
|
Mode string `json:"mode"`
|
|
WebhookPort int `json:"webhookPort"`
|
|
RequireMention bool `json:"requireMention"`
|
|
}
|
|
|
|
type WeixinBotView struct {
|
|
Enabled bool `json:"enabled"`
|
|
AccountID string `json:"accountId"`
|
|
TokenEnv string `json:"tokenEnv"`
|
|
TokenSet bool `json:"tokenSet"`
|
|
APIBase string `json:"apiBase"`
|
|
}
|
|
|
|
type BotSettingsView struct {
|
|
Enabled bool `json:"enabled"`
|
|
Model string `json:"model"`
|
|
ToolApprovalMode string `json:"toolApprovalMode"`
|
|
MaxSteps int `json:"maxSteps"`
|
|
DebounceMs int `json:"debounceMs"`
|
|
QueueMode string `json:"queueMode"`
|
|
QueueCap int `json:"queueCap"`
|
|
QueueDrop string `json:"queueDrop"`
|
|
IgnoreSelfMessages bool `json:"ignoreSelfMessages"`
|
|
SelfUserIDs BotSelfUserIDsView `json:"selfUserIds"`
|
|
Control BotControlView `json:"control"`
|
|
Pairing BotPairingView `json:"pairing"`
|
|
Routes []BotRouteView `json:"routes"`
|
|
Allowlist BotAllowlistView `json:"allowlist"`
|
|
QQ QQBotView `json:"qq"`
|
|
Feishu FeishuBotView `json:"feishu"`
|
|
Weixin WeixinBotView `json:"weixin"`
|
|
Connections []BotConnectionView `json:"connections"`
|
|
}
|
|
|
|
// SettingsView is the whole Settings panel payload.
|
|
type SettingsView struct {
|
|
DefaultModel string `json:"defaultModel"`
|
|
PlannerModel string `json:"plannerModel"`
|
|
SubagentModel string `json:"subagentModel"`
|
|
SubagentEffort string `json:"subagentEffort"`
|
|
AutoPlan string `json:"autoPlan"`
|
|
Providers []ProviderView `json:"providers"`
|
|
OfficialProviders []ProviderView `json:"officialProviders"`
|
|
ProviderPresets []ProviderPresetView `json:"providerPresets"`
|
|
Permissions PermissionsView `json:"permissions"`
|
|
Sandbox SandboxView `json:"sandbox"`
|
|
Network NetworkView `json:"network"`
|
|
Agent AgentView `json:"agent"`
|
|
Bot BotSettingsView `json:"bot"`
|
|
DesktopLanguage string `json:"desktopLanguage"`
|
|
DesktopLayoutStyle string `json:"desktopLayoutStyle"`
|
|
DesktopTheme string `json:"desktopTheme"`
|
|
DesktopThemeStyle string `json:"desktopThemeStyle"`
|
|
CloseBehavior string `json:"closeBehavior"`
|
|
DisplayMode string `json:"displayMode"`
|
|
StatusBarStyle string `json:"statusBarStyle"`
|
|
StatusBarItems []string `json:"statusBarItems"`
|
|
DefaultToolApprovalMode string `json:"defaultToolApprovalMode"`
|
|
CheckUpdates bool `json:"checkUpdates"`
|
|
Telemetry bool `json:"telemetry"`
|
|
Metrics bool `json:"metrics"`
|
|
MemoryCompiler bool `json:"memoryCompilerEnabled"`
|
|
ExpandThinking bool `json:"expandThinking"`
|
|
ConfigPath string `json:"configPath"`
|
|
// ProviderKinds lists the provider implementations the kernel actually
|
|
// registered (provider.Kinds()), so the editor's "kind" picker offers only
|
|
// kinds that resolve — selecting an unregistered one would fail the rebuild.
|
|
ProviderKinds []string `json:"providerKinds"`
|
|
// AutoApproveTools is the live YOLO/full-access state (runtime-only, not from
|
|
// config), so the panel's toggle reflects whether tool approvals are currently
|
|
// being skipped this session.
|
|
AutoApproveTools bool `json:"autoApproveTools"`
|
|
// Bypass is the legacy JSON key for the same live state.
|
|
Bypass bool `json:"bypass"`
|
|
}
|
|
|
|
// DesktopStartupSettingsView is the lightweight Settings subset needed during
|
|
// frontend startup. It deliberately excludes providers and credential state so
|
|
// slow keychain/env resolution stays off the first-render path.
|
|
type DesktopStartupSettingsView struct {
|
|
Bot BotSettingsView `json:"bot"`
|
|
DesktopLanguage string `json:"desktopLanguage"`
|
|
DesktopLayoutStyle string `json:"desktopLayoutStyle"`
|
|
DesktopTheme string `json:"desktopTheme"`
|
|
DesktopThemeStyle string `json:"desktopThemeStyle"`
|
|
DisplayMode string `json:"displayMode"`
|
|
StatusBarStyle string `json:"statusBarStyle"`
|
|
StatusBarItems []string `json:"statusBarItems"`
|
|
CheckUpdates bool `json:"checkUpdates"`
|
|
}
|
|
|
|
func nonNil(s []string) []string {
|
|
if s == nil {
|
|
return []string{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func nonNilStringMap(m map[string]string) map[string]string {
|
|
if m == nil {
|
|
return map[string]string{}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func nonNilAnyMap(m map[string]any) map[string]any {
|
|
if m == nil {
|
|
return map[string]any{}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func providerModelOverridesForView(overrides map[string]config.ProviderModelOverride, models []string) []ProviderModelOverrideView {
|
|
if len(overrides) == 0 {
|
|
return []ProviderModelOverrideView{}
|
|
}
|
|
modelSet := map[string]bool{}
|
|
for _, model := range models {
|
|
modelSet[model] = true
|
|
}
|
|
keys := make([]string, 0, len(overrides))
|
|
for model := range overrides {
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
continue
|
|
}
|
|
if len(modelSet) > 0 && !modelSet[model] {
|
|
continue
|
|
}
|
|
keys = append(keys, model)
|
|
}
|
|
sort.Strings(keys)
|
|
out := make([]ProviderModelOverrideView, 0, len(keys))
|
|
for _, model := range keys {
|
|
ov := overrides[model]
|
|
out = append(out, ProviderModelOverrideView{
|
|
Model: model,
|
|
ReasoningProtocol: ov.ReasoningProtocol,
|
|
SupportedEfforts: nonNil(ov.SupportedEfforts),
|
|
DefaultEffort: ov.DefaultEffort,
|
|
Vision: ov.Vision,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func providerModelOverridesForSave(overrides []ProviderModelOverrideView, models []string) map[string]config.ProviderModelOverride {
|
|
if len(overrides) == 0 {
|
|
return nil
|
|
}
|
|
modelSet := map[string]bool{}
|
|
for _, model := range models {
|
|
modelSet[model] = true
|
|
}
|
|
out := map[string]config.ProviderModelOverride{}
|
|
for _, item := range overrides {
|
|
model := strings.TrimSpace(item.Model)
|
|
if model == "" || (len(modelSet) > 0 && !modelSet[model]) {
|
|
continue
|
|
}
|
|
ov := config.ProviderModelOverride{
|
|
ReasoningProtocol: strings.TrimSpace(item.ReasoningProtocol),
|
|
SupportedEfforts: nonNil(item.SupportedEfforts),
|
|
DefaultEffort: strings.TrimSpace(item.DefaultEffort),
|
|
Vision: item.Vision,
|
|
}
|
|
if strings.TrimSpace(ov.ReasoningProtocol) == "" && len(ov.SupportedEfforts) == 0 && strings.TrimSpace(ov.DefaultEffort) == "" && ov.Vision == nil {
|
|
continue
|
|
}
|
|
out[model] = ov
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func providerRemovalFallbackRef(c *config.Config, name string) string {
|
|
for i := range c.Providers {
|
|
p := &c.Providers[i]
|
|
if p.Name == name || !p.Configured() || len(p.ModelList()) == 0 {
|
|
continue
|
|
}
|
|
return p.Name + "/" + p.DefaultModel()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func desktopModelRefsProvider(c *config.Config, ref, name string) bool {
|
|
if config.ModelRefsProvider(ref, name) {
|
|
return true
|
|
}
|
|
if e, ok := c.ResolveModel(ref); ok {
|
|
return e.Name == name
|
|
}
|
|
return false
|
|
}
|
|
|
|
func officialProviderHost(baseURL string) string {
|
|
u, err := url.Parse(strings.TrimSpace(baseURL))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.ToLower(u.Hostname())
|
|
}
|
|
|
|
func officialProviderKindFromEntry(p config.ProviderEntry) string {
|
|
host := officialProviderHost(p.BaseURL)
|
|
switch config.CanonicalDesktopOfficialProviderName(p.Name) {
|
|
case "deepseek":
|
|
if host == "api.deepseek.com" {
|
|
return "deepseek"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isOfficialBuiltInProvider(p config.ProviderEntry) bool {
|
|
return officialProviderKindFromEntry(p) != ""
|
|
}
|
|
|
|
func providerAccessSet(names []string) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, name := range names {
|
|
name = strings.TrimSpace(name)
|
|
if name != "" {
|
|
out[name] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func addProviderAccess(c *config.Config, names ...string) {
|
|
seen := providerAccessSet(c.Desktop.ProviderAccess)
|
|
for _, name := range names {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || seen[name] {
|
|
continue
|
|
}
|
|
c.Desktop.ProviderAccess = append(c.Desktop.ProviderAccess, name)
|
|
seen[name] = true
|
|
}
|
|
}
|
|
|
|
func removeProviderAccess(c *config.Config, names ...string) {
|
|
remove := providerAccessSet(names)
|
|
if len(remove) == 0 {
|
|
return
|
|
}
|
|
out := c.Desktop.ProviderAccess[:0]
|
|
for _, name := range c.Desktop.ProviderAccess {
|
|
if !remove[name] {
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
c.Desktop.ProviderAccess = out
|
|
}
|
|
|
|
func providerViewFromEntry(p config.ProviderEntry, builtIn, added bool) ProviderView {
|
|
return providerViewFromEntryForRoot(p, builtIn, added, ".")
|
|
}
|
|
|
|
func providerViewFromEntryForRoot(p config.ProviderEntry, builtIn, added bool, root string) ProviderView {
|
|
return providerViewFromEntryForRootWithResolver(p, builtIn, added, root, nil)
|
|
}
|
|
|
|
func providerViewFromEntryForRootWithResolver(p config.ProviderEntry, builtIn, added bool, root string, resolver *config.CredentialResolver) ProviderView {
|
|
models := p.ChatModelList()
|
|
visionModels := p.VisionModels
|
|
visionModelsSet := p.Vision || p.VisionModels != nil
|
|
if p.Vision {
|
|
visionModels = models
|
|
}
|
|
if resolver == nil {
|
|
resolver = config.NewCredentialResolverForRoot(root)
|
|
}
|
|
key := resolver.ResolveGlobalFirst(p.APIKeyEnv)
|
|
requiresKey := p.RequiresAPIKey()
|
|
return ProviderView{
|
|
Name: p.Name, BuiltIn: builtIn, Added: added, Kind: p.Kind, BaseURL: p.BaseURL, ChatURL: p.ChatURL,
|
|
Models: nonNil(models), VisionModels: nonNil(providerVisionModels(models, visionModels)), VisionModelsSet: visionModelsSet, ModelsURL: p.ModelsURL, Default: p.DefaultModel(),
|
|
APIKeyEnv: p.APIKeyEnv,
|
|
Headers: nonNilStringMap(p.Headers),
|
|
ExtraBody: nonNilAnyMap(p.ExtraBody),
|
|
AuthHeader: p.AuthHeader,
|
|
KeySet: key.Set,
|
|
RequiresKey: requiresKey,
|
|
Configured: !requiresKey || key.Set,
|
|
KeySource: key.Source.Label,
|
|
KeySourcePath: key.Source.Path,
|
|
BalanceURL: p.BalanceURL,
|
|
ContextWindow: p.ContextWindow,
|
|
ReasoningProtocol: p.ReasoningProtocol,
|
|
Thinking: providerThinkingForSettings(p.Thinking),
|
|
SupportedEfforts: nonNil(p.SupportedEfforts),
|
|
DefaultEffort: p.DefaultEffort,
|
|
ModelOverrides: providerModelOverridesForView(p.ModelOverrides, models),
|
|
}
|
|
}
|
|
|
|
func providerThinkingForSettings(thinking string) string {
|
|
normalized := strings.ToLower(strings.TrimSpace(thinking))
|
|
switch normalized {
|
|
case "enabled", "disabled", "adaptive":
|
|
return normalized
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func officialProviderViews(added map[string]bool, pricingLanguage string) []ProviderView {
|
|
return officialProviderViewsForRoot(added, pricingLanguage, ".")
|
|
}
|
|
|
|
func officialProviderViewsForRoot(added map[string]bool, pricingLanguage, root string) []ProviderView {
|
|
return officialProviderViewsForRootWithResolver(added, pricingLanguage, root, nil)
|
|
}
|
|
|
|
func officialProviderViewsForRootWithResolver(added map[string]bool, pricingLanguage, root string, resolver *config.CredentialResolver) []ProviderView {
|
|
var out []ProviderView
|
|
if resolver == nil {
|
|
resolver = config.NewCredentialResolverForRoot(root)
|
|
}
|
|
for _, kind := range []string{"deepseek"} {
|
|
entries, _, err := officialProviderTemplate(kind, pricingLanguage)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, entry := range entries {
|
|
out = append(out, providerViewFromEntryForRootWithResolver(entry, true, added[entry.Name], root, resolver))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func providerPresetViewsForRootWithResolver(cfg *config.Config, root string, resolver *config.CredentialResolver) []ProviderPresetView {
|
|
if resolver == nil {
|
|
resolver = config.NewCredentialResolverForRoot(root)
|
|
}
|
|
presets := config.CuratedProviderPresets()
|
|
out := make([]ProviderPresetView, 0, len(presets))
|
|
for _, preset := range presets {
|
|
keyEnv := strings.TrimSpace(preset.KeyEnv)
|
|
names := make([]string, 0, len(preset.Entries))
|
|
models := make([]string, 0)
|
|
modelSeen := map[string]bool{}
|
|
requiresKey := false
|
|
for _, entry := range preset.Entries {
|
|
if keyEnv == "" {
|
|
keyEnv = strings.TrimSpace(entry.APIKeyEnv)
|
|
}
|
|
if entry.RequiresAPIKey() {
|
|
requiresKey = true
|
|
}
|
|
name := strings.TrimSpace(entry.Name)
|
|
if name != "" {
|
|
names = append(names, name)
|
|
}
|
|
for _, model := range chatProviderModels(entry.ChatModelList()) {
|
|
if modelSeen[model] {
|
|
continue
|
|
}
|
|
modelSeen[model] = true
|
|
models = append(models, model)
|
|
}
|
|
}
|
|
key := config.CredentialResolution{}
|
|
if keyEnv != "" {
|
|
key = resolver.ResolveGlobalFirst(keyEnv)
|
|
}
|
|
status, statusNames := classifyProviderPresetStatus(cfg, preset)
|
|
added := status == providerPresetStatusInstalled || status == providerPresetStatusInstalledModified || status == providerPresetStatusNameConflict
|
|
out = append(out, ProviderPresetView{
|
|
ID: preset.ID,
|
|
Label: preset.Label,
|
|
Description: preset.Description,
|
|
KeyEnv: keyEnv,
|
|
ProviderNames: nonNil(names),
|
|
Models: nonNil(models),
|
|
Added: added,
|
|
Status: status,
|
|
StatusProviderNames: nonNil(statusNames),
|
|
KeySet: key.Set,
|
|
RequiresKey: requiresKey,
|
|
Configured: !requiresKey || key.Set,
|
|
KeySource: key.Source.Label,
|
|
KeySourcePath: key.Source.Path,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func classifyProviderPresetStatus(cfg *config.Config, preset config.ProviderPreset) (string, []string) {
|
|
if cfg == nil {
|
|
return providerPresetStatusAvailable, nil
|
|
}
|
|
installed := make([]string, 0)
|
|
modified := make([]string, 0)
|
|
conflicts := make([]string, 0)
|
|
similar := make([]string, 0)
|
|
presetID := strings.TrimSpace(preset.ID)
|
|
for _, entry := range preset.Entries {
|
|
name := strings.TrimSpace(entry.Name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
existing, ok := cfg.Provider(name)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if providerEntryMatchesPreset(*existing, entry, presetID) {
|
|
installed = append(installed, name)
|
|
} else if providerEntryUsesPresetID(*existing, presetID) {
|
|
modified = append(modified, name)
|
|
} else {
|
|
conflicts = append(conflicts, name)
|
|
}
|
|
}
|
|
if len(conflicts) > 0 {
|
|
return providerPresetStatusNameConflict, uniqueNonEmptyStrings(conflicts)
|
|
}
|
|
if len(modified) > 0 {
|
|
return providerPresetStatusInstalledModified, uniqueNonEmptyStrings(modified)
|
|
}
|
|
if len(installed) > 0 {
|
|
return providerPresetStatusInstalled, uniqueNonEmptyStrings(installed)
|
|
}
|
|
for i := range cfg.Providers {
|
|
existing := cfg.Providers[i]
|
|
existingName := strings.TrimSpace(existing.Name)
|
|
if existingName == "" {
|
|
continue
|
|
}
|
|
for _, entry := range preset.Entries {
|
|
if existingName == strings.TrimSpace(entry.Name) {
|
|
continue
|
|
}
|
|
if providerEntrySimilarToPreset(existing, entry, presetID) {
|
|
similar = append(similar, existingName)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if len(similar) > 0 {
|
|
return providerPresetStatusSimilarExisting, uniqueNonEmptyStrings(similar)
|
|
}
|
|
return providerPresetStatusAvailable, nil
|
|
}
|
|
|
|
func providerEntryMatchesPreset(existing, preset config.ProviderEntry, presetID string) bool {
|
|
if strings.TrimSpace(existing.PresetID) != "" {
|
|
if providerEntryUsesPresetID(existing, presetID) {
|
|
return providerEntryCoreMatches(existing, preset)
|
|
}
|
|
return false
|
|
}
|
|
return providerEntryCoreMatches(existing, preset)
|
|
}
|
|
|
|
func providerEntrySimilarToPreset(existing, preset config.ProviderEntry, presetID string) bool {
|
|
if providerEntryUsesPresetID(existing, presetID) {
|
|
return true
|
|
}
|
|
return providerEntryCoreMatches(existing, preset)
|
|
}
|
|
|
|
func providerEntryUsesPresetID(existing config.ProviderEntry, presetID string) bool {
|
|
presetID = strings.TrimSpace(presetID)
|
|
return presetID != "" && strings.TrimSpace(existing.PresetID) == presetID
|
|
}
|
|
|
|
func providerEntryCoreMatches(existing, preset config.ProviderEntry) bool {
|
|
return strings.EqualFold(strings.TrimSpace(existing.Kind), strings.TrimSpace(preset.Kind)) &&
|
|
normalizeProviderURL(existing.BaseURL) == normalizeProviderURL(preset.BaseURL) &&
|
|
strings.TrimSpace(existing.ChatURL) == strings.TrimSpace(preset.ChatURL) &&
|
|
strings.TrimSpace(existing.APIKeyEnv) == strings.TrimSpace(preset.APIKeyEnv) &&
|
|
existing.AuthHeader == preset.AuthHeader
|
|
}
|
|
|
|
func normalizeProviderURL(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err == nil && u.Scheme != "" && u.Host != "" {
|
|
u.Scheme = strings.ToLower(u.Scheme)
|
|
u.Host = strings.ToLower(u.Host)
|
|
u.Path = strings.TrimRight(u.Path, "/")
|
|
u.RawPath = ""
|
|
u.RawQuery = ""
|
|
u.Fragment = ""
|
|
return strings.TrimRight(u.String(), "/")
|
|
}
|
|
return strings.TrimRight(raw, "/")
|
|
}
|
|
|
|
func uniqueNonEmptyStrings(in []string) []string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(in))
|
|
seen := map[string]bool{}
|
|
for _, s := range in {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" || seen[s] {
|
|
continue
|
|
}
|
|
seen[s] = true
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func officialProviderAddedSet(cfg *config.Config) map[string]bool {
|
|
out := map[string]bool{}
|
|
if cfg == nil {
|
|
return out
|
|
}
|
|
access := providerAccessSet(cfg.Desktop.ProviderAccess)
|
|
for i := range cfg.Providers {
|
|
p := cfg.Providers[i]
|
|
if !access[p.Name] {
|
|
continue
|
|
}
|
|
if kind := officialProviderKindFromEntry(p); kind != "" {
|
|
out[kind] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func desktopStartupSettingsFromConfig(cfg *config.Config) DesktopStartupSettingsView {
|
|
if cfg == nil {
|
|
return DesktopStartupSettingsView{
|
|
Bot: botSettingsView(config.BotConfig{}),
|
|
DesktopLayoutStyle: "workbench",
|
|
DesktopTheme: "auto",
|
|
DesktopThemeStyle: "graphite",
|
|
DisplayMode: "standard",
|
|
StatusBarStyle: "text",
|
|
StatusBarItems: config.DefaultDesktopStatusBarItems(),
|
|
CheckUpdates: true,
|
|
}
|
|
}
|
|
return DesktopStartupSettingsView{
|
|
Bot: botSettingsView(cfg.Bot),
|
|
DesktopLanguage: cfg.DesktopLanguage(),
|
|
DesktopLayoutStyle: cfg.DesktopLayoutStyle(),
|
|
DesktopTheme: cfg.DesktopTheme(),
|
|
DesktopThemeStyle: cfg.DesktopThemeStyle(),
|
|
DisplayMode: cfg.DesktopDisplayMode(),
|
|
StatusBarStyle: cfg.DesktopStatusBarStyle(),
|
|
StatusBarItems: cfg.DesktopStatusBarItems(),
|
|
CheckUpdates: cfg.DesktopCheckUpdates(),
|
|
}
|
|
}
|
|
|
|
// DesktopStartupSettings returns only the desktop chrome preferences needed at
|
|
// app startup. Keep provider/key status in Settings(), where the Settings panel
|
|
// actually needs it.
|
|
func (a *App) DesktopStartupSettings() DesktopStartupSettingsView {
|
|
cfg, _, err := a.loadDesktopUserConfigForView()
|
|
if err != nil {
|
|
return desktopStartupSettingsFromConfig(nil)
|
|
}
|
|
return desktopStartupSettingsFromConfig(cfg)
|
|
}
|
|
|
|
// Settings returns the current configuration for the Settings panel.
|
|
func (a *App) Settings() SettingsView {
|
|
cfg, cfgPath, err := a.loadDesktopUserConfigForView()
|
|
if err != nil {
|
|
return SettingsView{
|
|
Providers: []ProviderView{},
|
|
OfficialProviders: officialProviderViews(map[string]bool{}, ""),
|
|
ProviderPresets: providerPresetViewsForRootWithResolver(nil, a.activeWorkspaceRoot(), nil),
|
|
ProviderKinds: nonNil(provider.Kinds()),
|
|
Permissions: PermissionsView{
|
|
Mode: "ask",
|
|
Allow: []string{},
|
|
Ask: []string{},
|
|
Deny: []string{},
|
|
},
|
|
Sandbox: SandboxView{Bash: config.Default().BashMode(), AllowWrite: []string{}, EffectiveWriteRoots: []string{}, Shell: "auto", EffectiveShell: sandboxEffectiveShellView(sandbox.ResolveShell("", "", nil))},
|
|
Agent: AgentView{PlannerMaxSteps: 0, MaxSubagentDepth: agent.DefaultMaxSubagentDepth, ColdResumePrune: true, ReasoningLanguage: "auto"},
|
|
Bot: botSettingsView(config.BotConfig{}),
|
|
AutoPlan: "off",
|
|
DesktopLayoutStyle: "workbench",
|
|
DesktopTheme: "auto",
|
|
DesktopThemeStyle: "graphite",
|
|
CloseBehavior: "background",
|
|
DisplayMode: "standard",
|
|
StatusBarStyle: "text",
|
|
StatusBarItems: config.DefaultDesktopStatusBarItems(),
|
|
DefaultToolApprovalMode: "auto",
|
|
CheckUpdates: true,
|
|
Telemetry: true,
|
|
Metrics: true,
|
|
MemoryCompiler: true,
|
|
ExpandThinking: false,
|
|
}
|
|
}
|
|
ctrl := a.activeCtrl()
|
|
bash := cfg.BashMode()
|
|
shell := cfg.Tools.Shell.Prefer
|
|
if shell == "" {
|
|
shell = "auto"
|
|
}
|
|
root := a.activeWorkspaceRoot()
|
|
writeRoots := cfg.WriteRootsForRoot(root)
|
|
effectiveWorkspaceRoot := ""
|
|
if len(writeRoots) > 0 {
|
|
effectiveWorkspaceRoot = writeRoots[0]
|
|
}
|
|
effectiveShell := sandbox.ResolveShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path, nil)
|
|
v := SettingsView{
|
|
DefaultModel: cfg.DefaultModel,
|
|
PlannerModel: cfg.Agent.PlannerModel,
|
|
SubagentModel: cfg.Agent.SubagentModel,
|
|
SubagentEffort: cfg.Agent.SubagentEffort,
|
|
AutoPlan: desktopAutoPlanMode(cfg.Agent.AutoPlan),
|
|
Providers: []ProviderView{},
|
|
OfficialProviders: []ProviderView{},
|
|
ProviderPresets: []ProviderPresetView{},
|
|
Permissions: PermissionsView{
|
|
Mode: orDefault(cfg.Permissions.Mode, "ask"),
|
|
Allow: nonNil(cfg.Permissions.Allow),
|
|
Ask: nonNil(cfg.Permissions.Ask),
|
|
Deny: nonNil(cfg.Permissions.Deny),
|
|
},
|
|
Sandbox: SandboxView{
|
|
Bash: bash, Network: cfg.Sandbox.Network,
|
|
WorkspaceRoot: cfg.Sandbox.WorkspaceRoot, AllowWrite: nonNil(cfg.Sandbox.AllowWrite),
|
|
EffectiveWorkspaceRoot: effectiveWorkspaceRoot, EffectiveWriteRoots: nonNil(writeRoots),
|
|
Shell: shell, EffectiveShell: sandboxEffectiveShellView(effectiveShell),
|
|
},
|
|
Network: NetworkView{
|
|
ProxyMode: cfg.NetworkProxyMode(),
|
|
ProxyURL: cfg.Network.ProxyURL,
|
|
NoProxy: cfg.Network.NoProxy,
|
|
Proxy: NetworkProxyView{
|
|
Type: orDefault(cfg.Network.Proxy.Type, "socks5"),
|
|
Server: cfg.Network.Proxy.Server,
|
|
Port: cfg.Network.Proxy.Port,
|
|
Username: cfg.Network.Proxy.Username,
|
|
Password: cfg.Network.Proxy.Password,
|
|
},
|
|
},
|
|
Agent: AgentView{Temperature: cfg.Agent.Temperature, MaxSteps: cfg.Agent.MaxSteps, PlannerMaxSteps: cfg.Agent.PlannerMaxSteps, MaxSubagentDepth: desktopMaxSubagentDepth(cfg.Agent.MaxSubagentDepth), SystemPrompt: cfg.Agent.SystemPrompt, ColdResumePrune: cfg.ColdResumePruneEnabled(), ReasoningLanguage: cfg.ReasoningLanguage()},
|
|
Bot: botSettingsView(cfg.Bot),
|
|
DesktopLanguage: cfg.DesktopLanguage(),
|
|
DesktopLayoutStyle: cfg.DesktopLayoutStyle(),
|
|
DesktopTheme: cfg.DesktopTheme(),
|
|
DesktopThemeStyle: cfg.DesktopThemeStyle(),
|
|
CloseBehavior: cfg.DesktopCloseBehavior(),
|
|
DisplayMode: cfg.DesktopDisplayMode(),
|
|
StatusBarStyle: cfg.DesktopStatusBarStyle(),
|
|
StatusBarItems: cfg.DesktopStatusBarItems(),
|
|
DefaultToolApprovalMode: cfg.DesktopDefaultToolApprovalMode(),
|
|
CheckUpdates: cfg.DesktopCheckUpdates(),
|
|
Telemetry: cfg.DesktopTelemetry(),
|
|
Metrics: cfg.DesktopMetrics(),
|
|
MemoryCompiler: cfg.MemoryCompilerEnabled(),
|
|
ExpandThinking: cfg.Desktop.ExpandThinking,
|
|
ConfigPath: cfgPath,
|
|
ProviderKinds: nonNil(provider.Kinds()),
|
|
AutoApproveTools: ctrl != nil && ctrl.AutoApproveTools(),
|
|
Bypass: ctrl != nil && ctrl.AutoApproveTools(),
|
|
}
|
|
added := providerAccessSet(cfg.Desktop.ProviderAccess)
|
|
resolver := config.NewCredentialResolverForRoot(root)
|
|
v.OfficialProviders = officialProviderViewsForRootWithResolver(officialProviderAddedSet(cfg), cfg.DeepSeekOfficialPricingLanguage(), root, resolver)
|
|
v.ProviderPresets = providerPresetViewsForRootWithResolver(cfg, root, resolver)
|
|
for i := range cfg.Providers {
|
|
p := &cfg.Providers[i]
|
|
v.Providers = append(v.Providers, providerViewFromEntryForRootWithResolver(*p, isOfficialBuiltInProvider(*p), added[p.Name], root, resolver))
|
|
}
|
|
return v
|
|
}
|
|
|
|
func sandboxEffectiveShellView(sh sandbox.Shell) string {
|
|
if sh.Kind == sandbox.ShellPowerShell {
|
|
if sh.SupportsChaining() {
|
|
return "pwsh"
|
|
}
|
|
return "powershell"
|
|
}
|
|
path := strings.ToLower(strings.ReplaceAll(sh.Path, "\\", "/"))
|
|
if strings.Contains(path, "/git/") && strings.HasSuffix(path, "bash.exe") {
|
|
return "git-bash"
|
|
}
|
|
return "bash"
|
|
}
|
|
|
|
func botSettingsView(b config.BotConfig) BotSettingsView {
|
|
mode := strings.TrimSpace(b.Feishu.Mode)
|
|
if mode == "" {
|
|
mode = "webhook"
|
|
}
|
|
return BotSettingsView{
|
|
Enabled: b.Enabled,
|
|
Model: b.Model,
|
|
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(b.ToolApprovalMode),
|
|
MaxSteps: b.MaxSteps,
|
|
DebounceMs: b.DebounceMs,
|
|
QueueMode: b.QueueMode,
|
|
QueueCap: b.QueueCap,
|
|
QueueDrop: b.QueueDrop,
|
|
IgnoreSelfMessages: b.IgnoreSelfMessages,
|
|
SelfUserIDs: BotSelfUserIDsView{
|
|
QQ: nonNil(b.SelfUserIDs.QQ),
|
|
Feishu: nonNil(b.SelfUserIDs.Feishu),
|
|
Weixin: nonNil(b.SelfUserIDs.Weixin),
|
|
},
|
|
Control: BotControlView{
|
|
Enabled: b.Control.Enabled,
|
|
Addr: b.Control.Addr,
|
|
TokenEnv: b.Control.TokenEnv,
|
|
},
|
|
Pairing: BotPairingView{
|
|
Enabled: b.Pairing.Enabled,
|
|
RequestTTLMinutes: b.Pairing.RequestTTLMinutes,
|
|
MaxPendingPerPlatform: b.Pairing.MaxPendingPerPlatform,
|
|
},
|
|
Routes: botRouteViews(b.Routes),
|
|
Allowlist: BotAllowlistView{
|
|
Enabled: b.Allowlist.Enabled,
|
|
AllowAll: b.Allowlist.AllowAll,
|
|
QQUsers: nonNil(b.Allowlist.QQUsers),
|
|
FeishuUsers: nonNil(b.Allowlist.FeishuUsers),
|
|
WeixinUsers: nonNil(b.Allowlist.WeixinUsers),
|
|
QQApprovers: nonNil(b.Allowlist.QQApprovers),
|
|
FeishuApprovers: nonNil(b.Allowlist.FeishuApprovers),
|
|
WeixinApprovers: nonNil(b.Allowlist.WeixinApprovers),
|
|
QQAdmins: nonNil(b.Allowlist.QQAdmins),
|
|
FeishuAdmins: nonNil(b.Allowlist.FeishuAdmins),
|
|
WeixinAdmins: nonNil(b.Allowlist.WeixinAdmins),
|
|
QQGroups: nonNil(b.Allowlist.QQGroups),
|
|
FeishuGroups: nonNil(b.Allowlist.FeishuGroups),
|
|
WeixinGroups: nonNil(b.Allowlist.WeixinGroups),
|
|
},
|
|
QQ: QQBotView{
|
|
Enabled: b.QQ.Enabled,
|
|
AppID: b.QQ.AppID,
|
|
AppSecretEnv: b.QQ.AppSecretEnv,
|
|
SecretSet: strings.TrimSpace(b.QQ.AppSecretEnv) != "" && os.Getenv(b.QQ.AppSecretEnv) != "",
|
|
Sandbox: b.QQ.Sandbox,
|
|
Model: b.QQ.Model,
|
|
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(b.QQ.ToolApprovalMode),
|
|
WorkspaceRoot: b.QQ.WorkspaceRoot,
|
|
Access: botAccessViewFromConfig(b.QQ.Access),
|
|
},
|
|
Feishu: FeishuBotView{
|
|
Enabled: b.Feishu.Enabled,
|
|
Domain: orDefault(strings.TrimSpace(b.Feishu.Domain), "feishu"),
|
|
AppID: b.Feishu.AppID,
|
|
AppSecretEnv: b.Feishu.AppSecretEnv,
|
|
SecretSet: strings.TrimSpace(b.Feishu.AppSecretEnv) != "" && os.Getenv(b.Feishu.AppSecretEnv) != "",
|
|
VerificationToken: b.Feishu.VerificationToken,
|
|
Mode: mode,
|
|
WebhookPort: b.Feishu.WebhookPort,
|
|
RequireMention: b.Feishu.RequireMention,
|
|
},
|
|
Weixin: WeixinBotView{
|
|
Enabled: b.Weixin.Enabled,
|
|
AccountID: b.Weixin.AccountID,
|
|
TokenEnv: b.Weixin.TokenEnv,
|
|
TokenSet: strings.TrimSpace(b.Weixin.TokenEnv) != "" && os.Getenv(b.Weixin.TokenEnv) != "",
|
|
APIBase: b.Weixin.APIBase,
|
|
},
|
|
Connections: botConnectionViews(b.Connections),
|
|
}
|
|
}
|
|
|
|
func orDefault(s, def string) string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return def
|
|
}
|
|
return s
|
|
}
|
|
|
|
func botRouteViews(routes []config.BotRouteConfig) []BotRouteView {
|
|
if len(routes) == 0 {
|
|
return []BotRouteView{}
|
|
}
|
|
out := make([]BotRouteView, 0, len(routes))
|
|
for _, route := range routes {
|
|
out = append(out, BotRouteView{
|
|
ConnectionID: route.ConnectionID,
|
|
Platform: route.Platform,
|
|
ChatType: route.ChatType,
|
|
ChatID: route.ChatID,
|
|
UserID: route.UserID,
|
|
ThreadID: route.ThreadID,
|
|
Model: route.Model,
|
|
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(route.ToolApprovalMode),
|
|
WorkspaceRoot: route.WorkspaceRoot,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func botRouteConfigs(routes []BotRouteView) []config.BotRouteConfig {
|
|
if len(routes) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]config.BotRouteConfig, 0, len(routes))
|
|
for _, route := range routes {
|
|
cfg := config.BotRouteConfig{
|
|
ConnectionID: strings.TrimSpace(route.ConnectionID),
|
|
Platform: strings.TrimSpace(route.Platform),
|
|
ChatType: strings.TrimSpace(route.ChatType),
|
|
ChatID: strings.TrimSpace(route.ChatID),
|
|
UserID: strings.TrimSpace(route.UserID),
|
|
ThreadID: strings.TrimSpace(route.ThreadID),
|
|
Model: strings.TrimSpace(route.Model),
|
|
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(route.ToolApprovalMode),
|
|
WorkspaceRoot: strings.TrimSpace(route.WorkspaceRoot),
|
|
}
|
|
if cfg.ConnectionID == "" && cfg.Platform == "" && cfg.ChatType == "" && cfg.ChatID == "" && cfg.UserID == "" && cfg.ThreadID == "" &&
|
|
cfg.Model == "" && cfg.ToolApprovalMode == "" && cfg.WorkspaceRoot == "" {
|
|
continue
|
|
}
|
|
out = append(out, cfg)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func botAccessViewFromConfig(access config.BotAccessConfig) BotAccessView {
|
|
return BotAccessView{
|
|
Enabled: access.Enabled,
|
|
AllowAll: access.AllowAll,
|
|
PairingEnabled: access.PairingEnabled,
|
|
Users: nonNil(access.Users),
|
|
Groups: nonNil(access.Groups),
|
|
Approvers: nonNil(access.Approvers),
|
|
Admins: nonNil(access.Admins),
|
|
}
|
|
}
|
|
|
|
func botAccessConfigFromView(access BotAccessView) config.BotAccessConfig {
|
|
return config.BotAccessConfig{
|
|
Enabled: access.Enabled,
|
|
AllowAll: access.AllowAll,
|
|
PairingEnabled: access.PairingEnabled,
|
|
Users: trimList(access.Users),
|
|
Groups: trimList(access.Groups),
|
|
Approvers: trimList(access.Approvers),
|
|
Admins: trimList(access.Admins),
|
|
}
|
|
}
|
|
|
|
func botDomainOrDefault(domain string) string {
|
|
if strings.EqualFold(strings.TrimSpace(domain), "lark") {
|
|
return "lark"
|
|
}
|
|
return "feishu"
|
|
}
|
|
|
|
// --- apply (write config, then rebuild the controller so it's live) ---
|
|
|
|
// applyConfigChange mutates the user-global config and rebuilds the controller so
|
|
// the change takes effect this session. Desktop settings such as providers and
|
|
// keys are account-level, not per-project: writing them to the global config
|
|
// rather than the cwd's reasonix.toml is what lets them survive a workspace switch.
|
|
func (a *App) applyConfigChange(mutate func(*config.Config) error) error {
|
|
_, err := a.applyConfigChangeWithWarning("settings", mutate)
|
|
return err
|
|
}
|
|
|
|
func (a *App) applyConfigChangeWithWarning(setting string, mutate func(*config.Config) error) (string, error) {
|
|
if err := a.ensureActiveTabRebuildAllowed(setting); err != nil {
|
|
return "", err
|
|
}
|
|
if err := func() error {
|
|
// Serialize the load-modify-save against other in-process config editors
|
|
// (bot auto-session persistence, applyConfigOnly) so neither drops the
|
|
// other's fields. rebuild() runs after unlocking — it does slow work and
|
|
// must not hold the config edit lock.
|
|
unlock := config.LockUserConfigEdits()
|
|
defer unlock()
|
|
cfg, path, err := a.loadDesktopUserConfigForEdit()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := mutate(cfg); err != nil {
|
|
return err
|
|
}
|
|
return cfg.SaveTo(path)
|
|
}(); err != nil {
|
|
return "", err
|
|
}
|
|
if err := a.rebuildSetting(setting); err != nil {
|
|
if warning, ok := a.deferredRebuildWarning(setting, err); ok {
|
|
return warning, nil
|
|
}
|
|
return "", err
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func (a *App) applyConfigOnly(mutate func(*config.Config) error) error {
|
|
unlock := config.LockUserConfigEdits()
|
|
defer unlock()
|
|
cfg, path, err := a.loadDesktopUserConfigForEdit()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := mutate(cfg); err != nil {
|
|
return err
|
|
}
|
|
return cfg.SaveTo(path)
|
|
}
|
|
|
|
func (a *App) ensureActiveTabRebuildAllowed(setting string) error {
|
|
tab := a.activeTab()
|
|
if tab == nil {
|
|
if a.ctx == nil {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("no active tab")
|
|
}
|
|
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
|
|
return rebuildControllerActiveWorkError(setting)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) ensureLiveControllersRuntimeMutationAllowed(setting string) error {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
for _, tab := range a.tabs {
|
|
if tab == nil {
|
|
continue
|
|
}
|
|
if controllerHasActiveRuntimeWork(tab.Ctrl) {
|
|
return rebuildControllerActiveWorkError(setting)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) deferredRebuildWarning(setting string, err error) (string, bool) {
|
|
if err == nil || !errors.Is(err, agent.ErrSessionLeaseHeld) {
|
|
return "", false
|
|
}
|
|
setting = strings.TrimSpace(setting)
|
|
if setting == "" {
|
|
setting = "settings"
|
|
}
|
|
userErr := userFacingSessionLeaseError(setting, err)
|
|
warning := fmt.Sprintf("%s saved, but the current session could not refresh yet: %s", setting, userErr.Error())
|
|
slog.Warn("desktop: deferred settings rebuild", "setting", setting, "err", err)
|
|
// Bind both the warning and the retry to the tab whose refresh failed (the
|
|
// rebuild acts on the active tab), so a tab switch right after the failure
|
|
// cannot misroute the notice or the deferred rebuild.
|
|
if tab := a.activeTab(); tab != nil {
|
|
a.warnForTab(tab.ID, warning)
|
|
a.scheduleDeferredRebuild(tab.ID, setting)
|
|
}
|
|
return warning, true
|
|
}
|
|
|
|
func appendSettingsWarning(existing, warning string) string {
|
|
existing = strings.TrimSpace(existing)
|
|
warning = strings.TrimSpace(warning)
|
|
if existing == "" {
|
|
return warning
|
|
}
|
|
if warning == "" {
|
|
return existing
|
|
}
|
|
return existing + "\n" + warning
|
|
}
|
|
|
|
// loadDesktopUserConfigForEdit loads the user config for a write path and
|
|
// persists any pending one-time legacy migrations (provider-access normalize,
|
|
// legacy bot-config move) to disk as part of the load.
|
|
//
|
|
// Contract: the caller must already hold config.LockUserConfigEdits() across
|
|
// its whole load→mutate→SaveTo cycle, so the migration write-back cannot race
|
|
// other in-process config editors. This helper must never acquire that lock
|
|
// itself: applyConfigChange/applyConfigOnly (and every other caller) invoke it
|
|
// with the lock held, so an inner acquire would self-deadlock. Read-only
|
|
// callers must use loadDesktopUserConfigForView (or its WithCredentials
|
|
// variant), which never writes to disk.
|
|
func (a *App) loadDesktopUserConfigForEdit() (*config.Config, string, error) {
|
|
userPath := config.UserConfigPath()
|
|
if userPath == "" {
|
|
return nil, "", fmt.Errorf("cannot resolve user config directory")
|
|
}
|
|
if _, err := os.Stat(userPath); err == nil {
|
|
cfg := config.LoadForEdit(userPath)
|
|
if err := normalizeLegacyDesktopProviderAccessForSettings(cfg, userPath); err != nil {
|
|
return nil, "", err
|
|
}
|
|
if err := a.migrateLegacyBotConfigToUser(cfg, userPath); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return cfg, userPath, nil
|
|
}
|
|
cfg := config.LoadForEdit(userPath)
|
|
legacyPath := config.SourcePathForRoot(a.activeWorkspaceRoot())
|
|
if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
|
|
if err := normalizeLegacyDesktopProviderAccessForSettings(cfg, userPath); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return cfg, userPath, nil
|
|
}
|
|
legacyCfg := config.LoadForEdit(legacyPath)
|
|
if err := normalizeLegacyDesktopProviderAccessForSettings(legacyCfg, legacyPath); err != nil {
|
|
return nil, "", err
|
|
}
|
|
legacyCfg.ConfigVersion = config.Default().ConfigVersion
|
|
if err := migrateLegacyBotConfigToUser(cfg, legacyCfg, userPath); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return legacyCfg, userPath, nil
|
|
}
|
|
|
|
// loadDesktopUserConfigForView loads the user config for read-only callers.
|
|
// Contract: it never writes to disk, so it is safe without
|
|
// config.LockUserConfigEdits(). Legacy migrations (provider-access normalize,
|
|
// legacy bot-config merge) are applied to the returned copy in memory only;
|
|
// the on-disk file migrates the first time a locked write path runs
|
|
// loadDesktopUserConfigForEdit. Credentials (Reasonix global .env) are not
|
|
// loaded; callers that hand the config to a runtime resolving secrets from the
|
|
// process env must use loadDesktopUserConfigForViewWithCredentials.
|
|
func (a *App) loadDesktopUserConfigForView() (*config.Config, string, error) {
|
|
return a.loadDesktopUserConfigReadOnly(config.LoadForEditWithoutCredentials)
|
|
}
|
|
|
|
// loadDesktopUserConfigForViewWithCredentials is loadDesktopUserConfigForView
|
|
// plus credential resolution: like config.LoadForEdit it loads Reasonix's
|
|
// global .env into the process env. Use it for read-only loads whose result
|
|
// feeds a runtime that resolves env-based secrets — the bot runtime
|
|
// (app-secret/control-token envs) and MCP server connects. It still never
|
|
// writes to disk.
|
|
func (a *App) loadDesktopUserConfigForViewWithCredentials() (*config.Config, string, error) {
|
|
return a.loadDesktopUserConfigReadOnly(config.LoadForEdit)
|
|
}
|
|
|
|
// loadDesktopUserConfigReadOnly is the shared pure-read loader behind the View
|
|
// variants: same shape as loadDesktopUserConfigForEdit, but every legacy
|
|
// migration stays in memory (zero SaveTo).
|
|
func (a *App) loadDesktopUserConfigReadOnly(load func(string) *config.Config) (*config.Config, string, error) {
|
|
userPath := config.UserConfigPath()
|
|
if userPath == "" {
|
|
return nil, "", fmt.Errorf("cannot resolve user config directory")
|
|
}
|
|
if _, err := os.Stat(userPath); err == nil {
|
|
cfg := load(userPath)
|
|
normalizeLegacyDesktopProviderAccessInMemory(cfg, userPath)
|
|
legacyPath := config.SourcePathForRoot(a.activeWorkspaceRoot())
|
|
if legacyPath != "" && !sameConfigPath(legacyPath, userPath) {
|
|
mergeLegacyBotConfigInMemory(cfg, load(legacyPath))
|
|
}
|
|
return cfg, userPath, nil
|
|
}
|
|
cfg := load(userPath)
|
|
legacyPath := config.SourcePathForRoot(a.activeWorkspaceRoot())
|
|
if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
|
|
normalizeLegacyDesktopProviderAccessInMemory(cfg, userPath)
|
|
return cfg, userPath, nil
|
|
}
|
|
// The user config does not exist yet: serve the legacy config as the view.
|
|
// It already carries any legacy bot config, so no merge is needed; the
|
|
// write path creates the migrated user file later.
|
|
legacyCfg := load(legacyPath)
|
|
normalizeLegacyDesktopProviderAccessInMemory(legacyCfg, legacyPath)
|
|
legacyCfg.ConfigVersion = config.Default().ConfigVersion
|
|
return legacyCfg, userPath, nil
|
|
}
|
|
|
|
// migrateLegacyBotConfigToUser (method) is the write-path legacy bot-config
|
|
// migration against the active workspace's legacy config file. Callers must
|
|
// hold config.LockUserConfigEdits() (see loadDesktopUserConfigForEdit).
|
|
func (a *App) migrateLegacyBotConfigToUser(userCfg *config.Config, userPath string) error {
|
|
if userCfg == nil {
|
|
return nil
|
|
}
|
|
legacyPath := config.SourcePathForRoot(a.activeWorkspaceRoot())
|
|
if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
|
|
return nil
|
|
}
|
|
legacyCfg := config.LoadForEdit(legacyPath)
|
|
return migrateLegacyBotConfigToUser(userCfg, legacyCfg, userPath)
|
|
}
|
|
|
|
// migrateLegacyBotConfigToUser is the write-path variant: it merges the legacy
|
|
// bot config in memory and persists the result to userPath. Callers must hold
|
|
// config.LockUserConfigEdits() (see loadDesktopUserConfigForEdit). Read paths
|
|
// use mergeLegacyBotConfigInMemory instead.
|
|
func migrateLegacyBotConfigToUser(userCfg, legacyCfg *config.Config, userPath string) error {
|
|
if !mergeLegacyBotConfigInMemory(userCfg, legacyCfg) {
|
|
return nil
|
|
}
|
|
if err := userCfg.SaveTo(userPath); err != nil {
|
|
return fmt.Errorf("migrate legacy bot config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mergeLegacyBotConfigInMemory copies the legacy bot config onto userCfg when
|
|
// the user config has none of its own. It never touches disk; it reports
|
|
// whether userCfg changed (i.e. whether a write path should persist it).
|
|
func mergeLegacyBotConfigInMemory(userCfg, legacyCfg *config.Config) bool {
|
|
if userCfg == nil || legacyCfg == nil || desktopBotConfigConfigured(userCfg.Bot) {
|
|
return false
|
|
}
|
|
if !desktopBotConfigConfigured(legacyCfg.Bot) {
|
|
return false
|
|
}
|
|
userCfg.Bot = legacyCfg.Bot
|
|
return true
|
|
}
|
|
|
|
func desktopBotConfigConfigured(bot config.BotConfig) bool {
|
|
defaults := config.Default().Bot
|
|
if bot.Enabled || strings.TrimSpace(bot.Model) != "" || len(bot.Connections) > 0 {
|
|
return true
|
|
}
|
|
if (bot.MaxSteps != 0 && bot.MaxSteps != defaults.MaxSteps) ||
|
|
(bot.DebounceMs != 0 && bot.DebounceMs != defaults.DebounceMs) ||
|
|
(strings.TrimSpace(bot.QueueMode) != "" && bot.QueueMode != defaults.QueueMode) ||
|
|
(bot.QueueCap != 0 && bot.QueueCap != defaults.QueueCap) ||
|
|
(strings.TrimSpace(bot.QueueDrop) != "" && bot.QueueDrop != defaults.QueueDrop) ||
|
|
bot.IgnoreSelfMessages != defaults.IgnoreSelfMessages ||
|
|
bot.Pairing.Enabled != defaults.Pairing.Enabled ||
|
|
(bot.Pairing.RequestTTLMinutes != 0 && bot.Pairing.RequestTTLMinutes != defaults.Pairing.RequestTTLMinutes) ||
|
|
(bot.Pairing.MaxPendingPerPlatform != 0 && bot.Pairing.MaxPendingPerPlatform != defaults.Pairing.MaxPendingPerPlatform) ||
|
|
bot.Control.Enabled != defaults.Control.Enabled ||
|
|
(strings.TrimSpace(bot.Control.Addr) != "" && bot.Control.Addr != defaults.Control.Addr) ||
|
|
(strings.TrimSpace(bot.Control.TokenEnv) != "" && bot.Control.TokenEnv != defaults.Control.TokenEnv) ||
|
|
len(bot.Routes) > 0 ||
|
|
len(bot.SelfUserIDs.QQ)+len(bot.SelfUserIDs.Feishu)+len(bot.SelfUserIDs.Weixin) > 0 {
|
|
return true
|
|
}
|
|
if bot.Allowlist.AllowAll ||
|
|
len(bot.Allowlist.QQUsers)+len(bot.Allowlist.FeishuUsers)+len(bot.Allowlist.WeixinUsers) > 0 ||
|
|
len(bot.Allowlist.QQApprovers)+len(bot.Allowlist.FeishuApprovers)+len(bot.Allowlist.WeixinApprovers) > 0 ||
|
|
len(bot.Allowlist.QQAdmins)+len(bot.Allowlist.FeishuAdmins)+len(bot.Allowlist.WeixinAdmins) > 0 ||
|
|
len(bot.Allowlist.QQGroups)+len(bot.Allowlist.FeishuGroups)+len(bot.Allowlist.WeixinGroups) > 0 {
|
|
return true
|
|
}
|
|
if bot.QQ.Enabled ||
|
|
strings.TrimSpace(bot.QQ.AppID) != "" ||
|
|
bot.QQ.AppSecretEnv != defaults.QQ.AppSecretEnv ||
|
|
bot.QQ.Sandbox != defaults.QQ.Sandbox ||
|
|
strings.TrimSpace(bot.QQ.Model) != "" ||
|
|
strings.TrimSpace(bot.QQ.ToolApprovalMode) != "" ||
|
|
strings.TrimSpace(bot.QQ.WorkspaceRoot) != "" ||
|
|
botruntime.BotAccessActive(bot.QQ.Access) {
|
|
return true
|
|
}
|
|
if bot.Feishu.Enabled ||
|
|
strings.TrimSpace(bot.Feishu.AppID) != "" ||
|
|
bot.Feishu.Domain != defaults.Feishu.Domain ||
|
|
bot.Feishu.AppSecretEnv != defaults.Feishu.AppSecretEnv ||
|
|
strings.TrimSpace(bot.Feishu.VerificationToken) != "" ||
|
|
bot.Feishu.Mode != defaults.Feishu.Mode ||
|
|
bot.Feishu.WebhookPort != defaults.Feishu.WebhookPort ||
|
|
bot.Feishu.RequireMention != defaults.Feishu.RequireMention {
|
|
return true
|
|
}
|
|
if bot.Weixin.Enabled ||
|
|
bot.Weixin.AccountID != defaults.Weixin.AccountID ||
|
|
bot.Weixin.TokenEnv != defaults.Weixin.TokenEnv ||
|
|
bot.Weixin.APIBase != defaults.Weixin.APIBase {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// normalizeLegacyDesktopProviderAccessForSettings is the write-path variant:
|
|
// it normalizes in memory and persists the migrated form to path. Callers must
|
|
// hold config.LockUserConfigEdits() (see loadDesktopUserConfigForEdit). Read
|
|
// paths use normalizeLegacyDesktopProviderAccessInMemory instead.
|
|
func normalizeLegacyDesktopProviderAccessForSettings(cfg *config.Config, path string) error {
|
|
if !normalizeLegacyDesktopProviderAccessInMemory(cfg, path) {
|
|
return nil
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return cfg.SaveTo(path)
|
|
}
|
|
|
|
// normalizeLegacyDesktopProviderAccessInMemory seeds cfg.Desktop.ProviderAccess
|
|
// from configs written before Settings tracked explicit provider access. It
|
|
// never touches disk; it reports whether cfg now carries a normalized list
|
|
// that the file at path does not declare (i.e. whether a write path should
|
|
// persist it).
|
|
func normalizeLegacyDesktopProviderAccessInMemory(cfg *config.Config, path string) bool {
|
|
if cfg == nil || len(cfg.Desktop.ProviderAccess) > 0 || configDeclaresProviderAccess(path) {
|
|
return false
|
|
}
|
|
config.NormalizeLegacyDesktopProviderAccess(cfg)
|
|
return len(cfg.Desktop.ProviderAccess) > 0 && strings.TrimSpace(path) != ""
|
|
}
|
|
|
|
func configDeclaresProviderAccess(path string) bool {
|
|
if strings.TrimSpace(path) == "" {
|
|
return false
|
|
}
|
|
body, err := readFileUTF8(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, line := range strings.Split(string(body), "\n") {
|
|
if before, _, ok := strings.Cut(line, "#"); ok {
|
|
line = before
|
|
}
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "provider_access") {
|
|
rest := strings.TrimSpace(strings.TrimPrefix(line, "provider_access"))
|
|
return strings.HasPrefix(rest, "=")
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *App) activeWorkspaceRoot() string {
|
|
tab := a.activeTab()
|
|
if tab != nil {
|
|
a.reconcileTabWithPinnedSessionMeta(tab)
|
|
if strings.TrimSpace(tab.WorkspaceRoot) != "" {
|
|
return tab.WorkspaceRoot
|
|
}
|
|
}
|
|
return "."
|
|
}
|
|
|
|
func (a *App) saveProviderCredential(apiKeyEnv, value string) (string, error) {
|
|
apiKeyEnv = strings.TrimSpace(apiKeyEnv)
|
|
value = strings.TrimSpace(value)
|
|
if err := upsertDotEnv(apiKeyEnv, value); err != nil {
|
|
return "", err
|
|
}
|
|
return providerCredentialSourceNotice(apiKeyEnv, value), nil
|
|
}
|
|
|
|
func providerCredentialSourceNotice(apiKeyEnv, value string) string {
|
|
return ""
|
|
}
|
|
|
|
func projectConfigPathForRoot(root string) string {
|
|
if strings.TrimSpace(root) == "" || root == "." {
|
|
return "reasonix.toml"
|
|
}
|
|
return filepath.Join(root, "reasonix.toml")
|
|
}
|
|
|
|
func sameConfigPath(a, b string) bool {
|
|
a = strings.TrimSpace(a)
|
|
b = strings.TrimSpace(b)
|
|
if a == "" || b == "" {
|
|
return false
|
|
}
|
|
aAbs, aErr := filepath.Abs(a)
|
|
bAbs, bErr := filepath.Abs(b)
|
|
if aErr == nil && bErr == nil {
|
|
return filepath.Clean(aAbs) == filepath.Clean(bAbs)
|
|
}
|
|
return filepath.Clean(a) == filepath.Clean(b)
|
|
}
|
|
|
|
// rebuild builds a replacement controller from the (just-changed) config and
|
|
// swaps it in only after the target session lease is available. The old
|
|
// controller stays usable if the rebuild fails.
|
|
func (a *App) rebuild() error {
|
|
return a.rebuildSetting("settings")
|
|
}
|
|
|
|
func (a *App) rebuildSetting(setting string) error {
|
|
if a.ctx == nil {
|
|
return nil
|
|
}
|
|
// Serialize with SetModelForTab and the deferred-rebuild retry loop: two
|
|
// concurrent build+swap sequences on the same tab leak the first-swapped
|
|
// controller and double-close the old one.
|
|
a.runtimeRebuildMu.Lock()
|
|
defer a.runtimeRebuildMu.Unlock()
|
|
return a.rebuildSettingLocked(setting)
|
|
}
|
|
|
|
// rebuildSettingLocked is rebuildSetting's body; callers must already hold
|
|
// runtimeRebuildMu. The deferred-rebuild retry loop calls this directly because
|
|
// it takes the lock across its lease probe.
|
|
func (a *App) rebuildSettingLocked(setting string) error {
|
|
if a.ctx == nil {
|
|
return nil
|
|
}
|
|
tab := a.activeTab()
|
|
if tab == nil {
|
|
return fmt.Errorf("no active tab")
|
|
}
|
|
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
|
|
return rebuildControllerActiveWorkError(setting)
|
|
}
|
|
if err := a.ensureTabControllerWorkspace(tab); err != nil {
|
|
return err
|
|
}
|
|
prevPath := a.reconciledSessionPathForTab(tab)
|
|
if prevPath == "" {
|
|
prevPath = a.currentSessionPathFor(tab)
|
|
}
|
|
if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
|
|
prevPath = a.reconciledSessionPathForTab(tab)
|
|
if prevPath == "" {
|
|
prevPath = a.currentSessionPathFor(tab)
|
|
}
|
|
}
|
|
if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
|
|
return rebuildControllerActiveWorkError(setting)
|
|
}
|
|
if err := a.ensureTabControllerWorkspace(tab); err != nil {
|
|
return err
|
|
}
|
|
|
|
var carried []provider.Message
|
|
oldCtrl := a.controllerForTab(tab)
|
|
if oldCtrl != nil {
|
|
if prevPath == "" {
|
|
prevPath = oldCtrl.SessionPath()
|
|
}
|
|
if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, setting); err != nil {
|
|
return err
|
|
}
|
|
if err := a.snapshotTabForAction(tab, "rebuilding settings"); err != nil {
|
|
return err
|
|
}
|
|
prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
|
|
carried = oldCtrl.History()
|
|
}
|
|
snap := a.tabRuntimeSnapshot(tab)
|
|
model := snap.model
|
|
if cfg, err := config.LoadForRoot(snap.workspaceRoot); err == nil {
|
|
if resolved, fallback, ok := cfg.ResolveModelWithFallback(model); ok {
|
|
if fallback && strings.TrimSpace(model) != "" {
|
|
a.noticeForTab(tab.ID, fmt.Sprintf("model %q is no longer available; switched to %s", model, resolved))
|
|
}
|
|
model = resolved
|
|
}
|
|
}
|
|
sharedHost := a.lookupSharedHost(snap.sharedHostKey)
|
|
ctrl, err := boot.Build(a.bootContext(), boot.Options{
|
|
Model: model, RequireKey: false,
|
|
Sink: snap.sink,
|
|
WorkspaceRoot: snap.workspaceRoot,
|
|
SessionDir: sessionDirForSnapshot(snap),
|
|
EffortOverride: cloneStringPtr(snap.effort),
|
|
TokenMode: snap.currentTokenMode(),
|
|
SharedHost: sharedHost,
|
|
CleanupPendingReconciler: reconcileDesktopCleanupPending,
|
|
SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
|
|
OnSessionRecovered: a.handleTabSessionRecovered(tab),
|
|
})
|
|
if err != nil {
|
|
if oldCtrl == nil {
|
|
leaseHeld := false
|
|
a.mu.Lock()
|
|
leaseHeld = setTabStartupError(tab, err)
|
|
tab.Ready = true
|
|
a.mu.Unlock()
|
|
if leaseHeld {
|
|
a.scheduleDeferredStartupBuild(tab.ID)
|
|
}
|
|
a.emitReady(a.ctx)
|
|
}
|
|
return err
|
|
}
|
|
a.bindControllerDisplayRecorder(ctrl)
|
|
ctrl.EnableInteractiveApproval()
|
|
applyTabModeToController(ctrl, snap.mode)
|
|
// applyTabModeToController only encodes plan+yolo from tab.mode.
|
|
// Apply the explicit toolApprovalMode (ask/auto/yolo) afterwards so
|
|
// "auto" is not lost — otherwise rebuild would silently downgrade
|
|
// auto to ask (#5424 regression).
|
|
if mode := strings.TrimSpace(snap.toolApprovalMode); mode != "" {
|
|
applyTabToolApprovalModeToController(ctrl, mode)
|
|
}
|
|
path := agent.ContinueSessionPath(prevPath, ctrl.SessionDir(), ctrl.Label())
|
|
if err := a.ensureTabSessionLeaseForRebuild(tab, path, setting); err != nil {
|
|
ctrl.Close()
|
|
return err
|
|
}
|
|
resumeWithFreshSystemPrompt(ctrl, carried, path)
|
|
if oldCtrl != nil {
|
|
oldCtrl.Close()
|
|
}
|
|
a.persistTabSessionPath(tab, path)
|
|
a.mu.Lock()
|
|
if current := a.tabs[tab.ID]; current != tab {
|
|
a.mu.Unlock()
|
|
ctrl.Close()
|
|
tab.releaseSessionLease()
|
|
return fmt.Errorf("tab %q changed while rebuilding settings; retry", tab.ID)
|
|
}
|
|
tab.Ctrl = ctrl
|
|
tab.model = model
|
|
tab.Label = ctrl.Label()
|
|
clearTabStartupError(tab)
|
|
tab.Ready = true
|
|
// Supersede any in-flight startup build: it would otherwise finish later,
|
|
// pass its generation check, and overwrite the controller just installed.
|
|
a.supersedeTabBuildLocked(tab)
|
|
a.saveTabsLocked()
|
|
a.mu.Unlock()
|
|
a.clearDeferredRebuild(tab.ID)
|
|
a.emitReady(a.ctx)
|
|
return nil
|
|
}
|
|
|
|
// SetDefaultModel sets the config default and switches the live model to it.
|
|
func (a *App) SetDefaultModel(ref string) error {
|
|
tab := a.activeTab()
|
|
if tab == nil {
|
|
return fmt.Errorf("no active tab")
|
|
}
|
|
// applyConfigChange ends in rebuild(), which reads tab.model to pick the
|
|
// runtime model — the new ref must be visible on the tab before that runs.
|
|
a.mu.Lock()
|
|
prev := tab.model
|
|
tab.model = ref
|
|
a.mu.Unlock()
|
|
if err := a.applyConfigChange(func(c *config.Config) error {
|
|
resolved, err := selectableDesktopModelRef(c, ref)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.DefaultModel = resolved
|
|
a.mu.Lock()
|
|
tab.model = resolved
|
|
a.mu.Unlock()
|
|
return nil
|
|
}); err != nil {
|
|
a.mu.Lock()
|
|
tab.model = prev
|
|
a.mu.Unlock()
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetPlannerModel sets (or, with "", clears) the two-model planner.
|
|
func (a *App) SetPlannerModel(ref string) error {
|
|
return a.applyConfigChange(func(c *config.Config) error {
|
|
if ref != "" {
|
|
resolved, err := selectableDesktopModelRef(c, ref)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ref = resolved
|
|
}
|
|
c.Agent.PlannerModel = ref
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// SetSubagentModel sets (or clears) the default model used by subagent entry points.
|
|
func (a *App) SetSubagentModel(ref string) error {
|
|
return a.applyConfigChange(func(c *config.Config) error {
|
|
ref = strings.TrimSpace(ref)
|
|
if ref != "" {
|
|
resolved, err := selectableDesktopModelRef(c, ref)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ref = resolved
|
|
}
|
|
c.Agent.SubagentModel = ref
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func selectableDesktopModelRef(c *config.Config, ref string) (string, error) {
|
|
entry, ok := c.ResolveModel(ref)
|
|
if !ok {
|
|
return "", fmt.Errorf("unknown model %q", ref)
|
|
}
|
|
if !modelProviderAccessAllowed(providerAccessSet(c.Desktop.ProviderAccess), entry.Name) {
|
|
return "", fmt.Errorf("model %q is not available because provider %q is not added", ref, entry.Name)
|
|
}
|
|
if !entry.Configured() {
|
|
return "", fmt.Errorf("model %q is not available because provider %q has no key", ref, entry.Name)
|
|
}
|
|
return entry.Name + "/" + entry.Model, nil
|
|
}
|
|
|
|
// SetSubagentEffort sets (or clears) the default effort used by subagent entry points.
|
|
func (a *App) SetSubagentEffort(level string) error {
|
|
return a.applyConfigChange(func(c *config.Config) error {
|
|
level = strings.TrimSpace(level)
|
|
if level == "" || level == "auto" {
|
|
c.Agent.SubagentEffort = ""
|
|
return nil
|
|
}
|
|
model := strings.TrimSpace(c.Agent.SubagentModel)
|
|
if model == "" {
|
|
model = c.DefaultModel
|
|
}
|
|
entry, ok := c.ResolveModel(model)
|
|
if !ok {
|
|
return fmt.Errorf("unknown subagent model %q", model)
|
|
}
|
|
effort, err := config.NormalizeEffort(entry, level)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.Agent.SubagentEffort = effort
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// deleteSubagentOverrideAliases removes every underscore/hyphen alias entry
|
|
// for name (boot.SubagentModelKeys — the same key set runtime dispatch
|
|
// reads). Deleting only the exact key would leave a legacy alias entry (e.g.
|
|
// `security_review` for the security-review skill) silently active.
|
|
func deleteSubagentOverrideAliases(overrides map[string]string, name string) {
|
|
for _, key := range boot.SubagentModelKeys(name) {
|
|
delete(overrides, key)
|
|
}
|
|
}
|
|
|
|
// SetSubagentProfileModel sets (or clears) a per-name model override for a
|
|
// subagent — the only way to influence a built-in subagent's model in the
|
|
// Subagents settings page, since built-ins have no editable frontmatter file
|
|
// to carry a `model:` line. Writes into the same cfg.Agent.SubagentModels map
|
|
// internal/boot's subagentModelRef already reads at dispatch time. Set and
|
|
// clear both sweep the underscore/hyphen alias keys so a legacy alias entry
|
|
// can neither shadow the new value nor survive a clear.
|
|
func (a *App) SetSubagentProfileModel(name, ref string) error {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return fmt.Errorf("name is required")
|
|
}
|
|
return a.applyConfigChange(func(c *config.Config) error {
|
|
ref = strings.TrimSpace(ref)
|
|
if ref == "" {
|
|
deleteSubagentOverrideAliases(c.Agent.SubagentModels, name)
|
|
return nil
|
|
}
|
|
resolved, err := selectableDesktopModelRef(c, ref)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if c.Agent.SubagentModels == nil {
|
|
c.Agent.SubagentModels = map[string]string{}
|
|
}
|
|
deleteSubagentOverrideAliases(c.Agent.SubagentModels, name)
|
|
c.Agent.SubagentModels[name] = resolved
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// SetSubagentProfileEffort sets (or clears) a per-name effort override. See
|
|
// SetSubagentProfileModel.
|
|
func (a *App) SetSubagentProfileEffort(name, level string) error {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return fmt.Errorf("name is required")
|
|
}
|
|
return a.applyConfigChange(func(c *config.Config) error {
|
|
level = strings.TrimSpace(level)
|
|
if level == "" || level == "auto" {
|
|
deleteSubagentOverrideAliases(c.Agent.SubagentEfforts, name)
|
|
return nil
|
|
}
|
|
// Validate against the model the override will actually apply to:
|
|
// the alias-aware per-name model override first, then the global
|
|
// subagent default, then the session default.
|
|
model := subagentOverrideFor(c.Agent.SubagentModels, name)
|
|
if model == "" {
|
|
model = strings.TrimSpace(c.Agent.SubagentModel)
|
|
}
|
|
if model == "" {
|
|
model = c.DefaultModel
|
|
}
|
|
entry, ok := c.ResolveModel(model)
|
|
if !ok {
|
|
return fmt.Errorf("unknown subagent model %q", model)
|
|
}
|
|
effort, err := config.NormalizeEffort(entry, level)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if c.Agent.SubagentEfforts == nil {
|
|
c.Agent.SubagentEfforts = map[string]string{}
|
|
}
|
|
deleteSubagentOverrideAliases(c.Agent.SubagentEfforts, name)
|
|
c.Agent.SubagentEfforts[name] = effort
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func desktopMaxSubagentDepth(depth int) int {
|
|
if depth <= 0 {
|
|
return agent.DefaultMaxSubagentDepth
|
|
}
|
|
if depth == 1 {
|
|
return 1
|
|
}
|
|
return agent.DefaultMaxSubagentDepth
|
|
}
|
|
|
|
// SetMaxSubagentDepth controls whether first-layer subagents may delegate once more.
|
|
func (a *App) SetMaxSubagentDepth(depth int) error {
|
|
return a.applyConfigChange(func(c *config.Config) error {
|
|
c.Agent.MaxSubagentDepth = desktopMaxSubagentDepth(depth)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// SetAutoPlan updates the automatic plan-mode gate (off|on).
|
|
func (a *App) SetAutoPlan(mode string) error {
|
|
if err := a.ensureLiveControllersRuntimeMutationAllowed("auto-plan"); err != nil {
|
|
return err
|
|
}
|
|
var cfg *config.Config
|
|
// Lock only the load-modify-save cycle; the live-controller fan-out and the
|
|
// optional rebuild below are slow and must not hold the config edit lock.
|
|
if err := func() error {
|
|
unlock := config.LockUserConfigEdits()
|
|
defer unlock()
|
|
loaded, path, err := a.loadDesktopUserConfigForEdit()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := loaded.SetAutoPlan(mode); err != nil {
|
|
return err
|
|
}
|
|
if err := loaded.SaveTo(path); err != nil {
|
|
return err
|
|
}
|
|
cfg = loaded
|
|
return nil
|
|
}(); err != nil {
|
|
return err
|
|
}
|
|
a.applyAutoPlanToLiveControllers(cfg.Agent.AutoPlan)
|
|
if desktopAutoPlanMode(cfg.Agent.AutoPlan) == "on" && strings.TrimSpace(cfg.Agent.AutoPlanClassifier) != "" {
|
|
if err := a.rebuild(); err != nil {
|
|
if _, ok := a.deferredRebuildWarning("auto-plan", err); ok {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetDefaultToolApprovalMode updates the global Ask/Auto/YOLO default used only
|
|
// for newly-created desktop sessions. Existing tabs keep their persisted mode.
|
|
func (a *App) SetDefaultToolApprovalMode(mode string) error {
|
|
return a.applyConfigOnly(func(c *config.Config) error {
|
|
return c.SetDesktopDefaultToolApprovalMode(mode)
|
|
})
|
|
}
|
|
|
|
// SetMemoryCompilerEnabled toggles the Memory v5 execution compiler.
|
|
func (a *App) SetMemoryCompilerEnabled(enabled bool) error {
|
|
// Lock only the load-modify-save cycle; the live-controller fan-out below
|
|
// must not hold the config edit lock.
|
|
if err := func() error {
|
|
unlock := config.LockUserConfigEdits()
|
|
defer unlock()
|
|
cfg, path, err := a.loadDesktopUserConfigForEdit()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := cfg.SetMemoryCompilerEnabled(enabled); err != nil {
|
|
return err
|
|
}
|
|
return cfg.SaveTo(path)
|
|
}(); err != nil {
|
|
return err
|
|
}
|
|
a.applyMemoryCompilerToLiveControllers(enabled)
|
|
return nil
|
|
}
|
|
|
|
func (a *App) applyMemoryCompilerToLiveControllers(enabled bool) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
var controllers []memoryCompilerTarget
|
|
a.mu.RLock()
|
|
for _, id := range a.orderedTabIDsLocked() {
|
|
tab := a.tabs[id]
|
|
if tab == nil || tab.Ctrl == nil {
|
|
continue
|
|
}
|
|
controllers = append(controllers, tab.Ctrl)
|
|
}
|
|
a.mu.RUnlock()
|
|
applyMemoryCompilerToControllers(enabled, controllers)
|
|
}
|
|
|
|
type memoryCompilerTarget interface {
|
|
SetMemoryCompilerEnabled(enabled bool)
|
|
}
|
|
|
|
func applyMemoryCompilerToControllers(enabled bool, controllers []memoryCompilerTarget) {
|
|
for _, ctrl := range controllers {
|
|
if ctrl != nil {
|
|
ctrl.SetMemoryCompilerEnabled(enabled)
|
|
}
|
|
}
|
|
}
|
|
|
|
func desktopAutoPlanMode(mode string) string {
|
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
case "on", "ask":
|
|
return "on"
|
|
default:
|
|
return "off"
|
|
}
|
|
}
|
|
|
|
func (a *App) applyAutoPlanToLiveControllers(fallback string) {
|
|
type liveTab struct {
|
|
root string
|
|
ctrl control.SessionAPI
|
|
}
|
|
var tabs []liveTab
|
|
a.mu.RLock()
|
|
for _, tab := range a.tabs {
|
|
if tab != nil && tab.Ctrl != nil {
|
|
tabs = append(tabs, liveTab{root: tab.WorkspaceRoot, ctrl: tab.Ctrl})
|
|
}
|
|
}
|
|
a.mu.RUnlock()
|
|
for _, tab := range tabs {
|
|
mode := fallback
|
|
if cfg, err := config.LoadForRoot(tab.root); err == nil {
|
|
mode = cfg.Agent.AutoPlan
|
|
}
|
|
tab.ctrl.SetAutoPlan(mode)
|
|
}
|
|
}
|
|
|
|
func officialProviderTemplate(kind, pricingLanguage string) ([]config.ProviderEntry, string, error) {
|
|
switch strings.ToLower(strings.TrimSpace(kind)) {
|
|
case "deepseek", "deepseek-official":
|
|
return []config.ProviderEntry{{
|
|
Name: "deepseek",
|
|
Kind: "openai",
|
|
BaseURL: "https://api.deepseek.com",
|
|
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
|
|
Default: "deepseek-v4-flash",
|
|
APIKeyEnv: "DEEPSEEK_API_KEY",
|
|
BalanceURL: "https://api.deepseek.com/user/balance",
|
|
ContextWindow: 1_000_000,
|
|
Prices: config.DeepSeekV4PricesForLanguage(pricingLanguage),
|
|
}}, "DEEPSEEK_API_KEY", nil
|
|
default:
|
|
return nil, "", fmt.Errorf("unknown official provider template %q", kind)
|
|
}
|
|
}
|
|
|
|
func chatProviderModels(models []string) []string {
|
|
out := make([]string, 0, len(models))
|
|
seen := map[string]bool{}
|
|
for _, model := range models {
|
|
model = strings.TrimSpace(model)
|
|
if model == "" || seen[model] || !config.IsLikelyChatModel(model) {
|
|
continue
|
|
}
|
|
seen[model] = true
|
|
out = append(out, model)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func providerVisionModels(models, visionModels []string) []string {
|
|
enabled := map[string]bool{}
|
|
for _, model := range models {
|
|
enabled[model] = true
|
|
}
|
|
out := make([]string, 0, len(visionModels))
|
|
for _, model := range chatProviderModels(visionModels) {
|
|
if enabled[model] {
|
|
out = append(out, model)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func providerDefaultForModels(currentDefault string, models []string) string {
|
|
currentDefault = strings.TrimSpace(currentDefault)
|
|
if currentDefault != "" {
|
|
for _, model := range models {
|
|
if model == currentDefault {
|
|
return currentDefault
|
|
}
|
|
}
|
|
}
|
|
if len(models) > 0 {
|
|
return models[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func saveProviderConfig(c *config.Config, p ProviderView) error {
|
|
if c == nil {
|
|
return fmt.Errorf("config is nil")
|
|
}
|
|
e := config.ProviderEntry{Name: p.Name}
|
|
for i := range c.Providers {
|
|
if c.Providers[i].Name == p.Name {
|
|
e = c.Providers[i]
|
|
break
|
|
}
|
|
}
|
|
e.Name = p.Name
|
|
e.Kind = p.Kind
|
|
e.BaseURL = p.BaseURL
|
|
e.ChatURL = strings.TrimSpace(p.ChatURL)
|
|
e.ModelsURL = strings.TrimSpace(p.ModelsURL)
|
|
e.APIKeyEnv = p.APIKeyEnv
|
|
e.Headers = p.Headers
|
|
e.ExtraBody = p.ExtraBody
|
|
e.AuthHeader = p.AuthHeader
|
|
e.BalanceURL = strings.TrimSpace(p.BalanceURL)
|
|
e.ContextWindow = p.ContextWindow
|
|
e.ReasoningProtocol = p.ReasoningProtocol
|
|
e.Thinking = providerThinkingForSettings(p.Thinking)
|
|
e.SupportedEfforts = p.SupportedEfforts
|
|
e.DefaultEffort = p.DefaultEffort
|
|
e.Model = ""
|
|
e.Models = nil
|
|
e.Default = ""
|
|
e.VisionModels = nil
|
|
models := chatProviderModels(p.Models)
|
|
if len(models) > 0 {
|
|
e.Model = models[0] // also satisfies validateProvider's model requirement
|
|
e.Models = models
|
|
e.ModelOverrides = providerModelOverridesForSave(p.ModelOverrides, models)
|
|
if p.VisionModelsSet || len(p.VisionModels) > 0 {
|
|
e.Vision = false
|
|
e.VisionModels = providerVisionModels(models, p.VisionModels)
|
|
}
|
|
if len(models) > 1 {
|
|
e.Default = providerDefaultForModels(p.Default, models)
|
|
}
|
|
} else {
|
|
e.Vision = false
|
|
e.VisionModels = nil
|
|
e.ModelOverrides = nil
|
|
}
|
|
if err := c.UpsertProvider(e); err != nil {
|
|
return err
|
|
}
|
|
addProviderAccess(c, p.Name)
|
|
return nil
|
|
}
|
|
|
|
// SaveProvider adds or updates a provider. Enabled models are persisted through
|
|
// `models` even when only one model is selected, while `model` remains populated
|
|
// in-memory for validation/back-compat. The shared key/endpoint live on the entry.
|
|
func (a *App) SaveProvider(p ProviderView) error {
|
|
return a.applyConfigChange(func(c *config.Config) error {
|
|
return saveProviderConfig(c, p)
|
|
})
|
|
}
|
|
|
|
// SaveProviderWithKey saves a custom provider and its credential as one settings
|
|
// transaction, then rebuilds once after both are visible to the runtime.
|
|
func (a *App) SaveProviderWithKey(p ProviderView, key string) (string, error) {
|
|
apiKeyEnv := strings.TrimSpace(p.APIKeyEnv)
|
|
if apiKeyEnv == "" {
|
|
return "", fmt.Errorf("this provider has no api_key_env set")
|
|
}
|
|
if err := a.ensureActiveTabRebuildAllowed("provider"); err != nil {
|
|
return "", err
|
|
}
|
|
warning, err := a.saveProviderCredential(apiKeyEnv, key)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := func() error {
|
|
unlock := config.LockUserConfigEdits()
|
|
defer unlock()
|
|
cfg, path, err := a.loadDesktopUserConfigForEdit()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := saveProviderConfig(cfg, p); err != nil {
|
|
return err
|
|
}
|
|
return cfg.SaveTo(path)
|
|
}(); err != nil {
|
|
return "", err
|
|
}
|
|
if err := a.rebuildSetting("provider"); err != nil {
|
|
if rebuildWarning, ok := a.deferredRebuildWarning("provider", err); ok {
|
|
return appendSettingsWarning(warning, rebuildWarning), nil
|
|
}
|
|
return "", err
|
|
}
|
|
return warning, nil
|
|
}
|
|
|
|
// AddOfficialProviderAccess adds one curated desktop provider template to the
|
|
// Settings > Model > Access list. The runtime default providers still exist
|
|
// independently; this only records the user's explicit access setup.
|
|
func (a *App) AddOfficialProviderAccess(kind, key string) (string, error) {
|
|
// Read-only pre-read (pricing language); the actual write happens inside
|
|
// applyConfigChange below, under the config edit lock.
|
|
cfg, _, err := a.loadDesktopUserConfigForView()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
entries, keyEnv, err := officialProviderTemplate(kind, cfg.DeepSeekOfficialPricingLanguage())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := a.ensureActiveTabRebuildAllowed("provider access"); err != nil {
|
|
return "", err
|
|
}
|
|
keyWarning := ""
|
|
if strings.TrimSpace(key) != "" && keyEnv != "" {
|
|
var err error
|
|
keyWarning, err = a.saveProviderCredential(keyEnv, key)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
rebuildWarning, err := a.applyConfigChangeWithWarning("provider access", func(c *config.Config) error {
|
|
names := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
if err := c.UpsertProvider(e); err != nil {
|
|
return err
|
|
}
|
|
names = append(names, e.Name)
|
|
}
|
|
addProviderAccess(c, names...)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return appendSettingsWarning(keyWarning, rebuildWarning), nil
|
|
}
|
|
|
|
// AddProviderPresetAccess installs one editable custom-provider preset. Unlike
|
|
// official built-ins, these entries are saved as normal providers so users can
|
|
// tweak endpoints, model lists, and capability overrides after the one-click
|
|
// setup path.
|
|
func (a *App) AddProviderPresetAccess(id, key string) (string, error) {
|
|
preset, ok := config.CuratedProviderPreset(id)
|
|
if !ok {
|
|
return "", fmt.Errorf("unknown provider preset %q", id)
|
|
}
|
|
if len(preset.Entries) == 0 {
|
|
return "", fmt.Errorf("provider preset %q has no provider entries", id)
|
|
}
|
|
if err := a.ensureActiveTabRebuildAllowed("provider access"); err != nil {
|
|
return "", err
|
|
}
|
|
// Read-only duplicate-name pre-check; applyConfigChange re-checks under the
|
|
// config edit lock before writing.
|
|
cfg, _, err := a.loadDesktopUserConfigForView()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if existing := existingProviderNames(cfg, preset.Entries); len(existing) > 0 {
|
|
return "", providerPresetAlreadyAddedError(preset.ID, existing)
|
|
}
|
|
keyEnv := strings.TrimSpace(preset.KeyEnv)
|
|
if keyEnv == "" {
|
|
for _, e := range preset.Entries {
|
|
if keyEnv = strings.TrimSpace(e.APIKeyEnv); keyEnv != "" {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
keyWarning := ""
|
|
if strings.TrimSpace(key) != "" && keyEnv != "" {
|
|
var err error
|
|
keyWarning, err = a.saveProviderCredential(keyEnv, key)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
rebuildWarning, err := a.applyConfigChangeWithWarning("provider access", func(c *config.Config) error {
|
|
if existing := existingProviderNames(c, preset.Entries); len(existing) > 0 {
|
|
return providerPresetAlreadyAddedError(preset.ID, existing)
|
|
}
|
|
names := make([]string, 0, len(preset.Entries))
|
|
for _, e := range preset.Entries {
|
|
if err := c.UpsertProvider(e); err != nil {
|
|
return err
|
|
}
|
|
names = append(names, e.Name)
|
|
}
|
|
addProviderAccess(c, names...)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return appendSettingsWarning(keyWarning, rebuildWarning), nil
|
|
}
|
|
|
|
// ResetProviderPresetAccess intentionally overwrites same-name provider entries
|
|
// with the curated preset template. It only mutates config; provider secrets stay
|
|
// in Reasonix home .env under whichever api_key_env the resulting preset uses.
|
|
func (a *App) ResetProviderPresetAccess(id string) error {
|
|
preset, ok := config.CuratedProviderPreset(id)
|
|
if !ok {
|
|
return fmt.Errorf("unknown provider preset %q", id)
|
|
}
|
|
if len(preset.Entries) == 0 {
|
|
return fmt.Errorf("provider preset %q has no provider entries", id)
|
|
}
|
|
if err := a.ensureActiveTabRebuildAllowed("provider access"); err != nil {
|
|
return err
|
|
}
|
|
// Read-only existence pre-check; applyConfigChange re-checks under the
|
|
// config edit lock before writing.
|
|
cfg, _, err := a.loadDesktopUserConfigForView()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existing := existingProviderNames(cfg, preset.Entries); len(existing) == 0 {
|
|
return providerPresetNoExistingProviderError(preset.ID)
|
|
}
|
|
return a.applyConfigChange(func(c *config.Config) error {
|
|
if existing := existingProviderNames(c, preset.Entries); len(existing) == 0 {
|
|
return providerPresetNoExistingProviderError(preset.ID)
|
|
}
|
|
names := make([]string, 0, len(preset.Entries))
|
|
for _, e := range preset.Entries {
|
|
if err := c.UpsertProvider(e); err != nil {
|
|
return err
|
|
}
|
|
names = append(names, e.Name)
|
|
}
|
|
addProviderAccess(c, names...)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func existingProviderNames(c *config.Config, entries []config.ProviderEntry) []string {
|
|
if c == nil || len(entries) == 0 {
|
|
return nil
|
|
}
|
|
names := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
name := strings.TrimSpace(entry.Name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if _, ok := c.Provider(name); ok {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
func providerPresetAlreadyAddedError(id string, names []string) error {
|
|
return fmt.Errorf("provider preset %q cannot be added because provider name(s) already exist: %s; edit, rename, or remove the existing provider before adding it again", id, strings.Join(names, ", "))
|
|
}
|
|
|
|
func providerPresetNoExistingProviderError(id string) error {
|
|
return fmt.Errorf("provider preset %q cannot be reset because no same-name provider exists; add the preset instead", id)
|
|
}
|
|
|
|
// FetchProviderModels probes the provider's OpenAI-compatible model-list
|
|
// endpoint and returns the available model IDs. This is a settings-only helper:
|
|
// it never touches chat request serialization or provider-visible prompt data.
|
|
func (a *App) FetchProviderModels(p ProviderView) ([]string, error) {
|
|
e := config.ProviderEntry{
|
|
Name: p.Name,
|
|
Kind: p.Kind,
|
|
BaseURL: p.BaseURL,
|
|
ModelsURL: strings.TrimSpace(p.ModelsURL),
|
|
APIKeyEnv: p.APIKeyEnv,
|
|
Headers: p.Headers,
|
|
AuthHeader: p.AuthHeader,
|
|
}
|
|
e.ResolveAPIKeyForRoot(a.activeWorkspaceRoot())
|
|
ctx, cancel := context.WithTimeout(a.reqCtx(), 15*time.Second)
|
|
defer cancel()
|
|
models, err := e.FetchModels(ctx)
|
|
if err != nil {
|
|
return []string{}, err
|
|
}
|
|
return nonNil(chatProviderModels(models)), nil
|
|
}
|
|
|
|
// DeleteProvider removes a provider and retargets open idle tabs that used it.
|
|
func (a *App) DeleteProvider(name string) error {
|
|
return a.deleteProviderAndRetargetTabs(name)
|
|
}
|
|
|
|
// RemoveProviderAccess hides a provider from Settings > Model > Access and from
|
|
// settings model pickers. Built-in provider entries remain in the runtime config
|
|
// for back-compat, but visible defaults and idle tabs are retargeted away from
|
|
// the removed access entry when another accessed provider is available. Custom
|
|
// providers are deleted outright.
|
|
func (a *App) RemoveProviderAccess(name string) error {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return fmt.Errorf("remove provider access: empty provider name")
|
|
}
|
|
// Read-only dispatch check (built-in vs custom); the removal paths below
|
|
// reload and write under the config edit lock.
|
|
cfg, _, err := a.loadDesktopUserConfigForView()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if p, ok := cfg.Provider(name); ok && isOfficialBuiltInProvider(*p) {
|
|
return a.removeBuiltInProviderAccessAndRetargetTabs(name)
|
|
}
|
|
return a.deleteProviderAndRetargetTabs(name)
|
|
}
|
|
|
|
type providerRemovalTab struct {
|
|
id string
|
|
ctrl control.SessionAPI
|
|
readOnly bool
|
|
}
|
|
|
|
func providerAccessFallbackRef(c *config.Config, name string) string {
|
|
name = strings.TrimSpace(name)
|
|
for _, candidate := range c.Desktop.ProviderAccess {
|
|
candidate = strings.TrimSpace(candidate)
|
|
if candidate == "" || candidate == name {
|
|
continue
|
|
}
|
|
p, ok := c.Provider(candidate)
|
|
if !ok || len(p.ModelList()) == 0 {
|
|
continue
|
|
}
|
|
return p.Name + "/" + p.DefaultModel()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func retargetProviderReferences(c *config.Config, name, fallbackRef string) {
|
|
if strings.TrimSpace(fallbackRef) == "" {
|
|
return
|
|
}
|
|
if desktopModelRefsProvider(c, c.DefaultModel, name) {
|
|
c.DefaultModel = fallbackRef
|
|
}
|
|
if desktopModelRefsProvider(c, c.Agent.PlannerModel, name) {
|
|
c.Agent.PlannerModel = fallbackRef
|
|
}
|
|
if desktopModelRefsProvider(c, c.Agent.SubagentModel, name) {
|
|
c.Agent.SubagentModel = fallbackRef
|
|
}
|
|
for skill, ref := range c.Agent.SubagentModels {
|
|
if desktopModelRefsProvider(c, ref, name) {
|
|
c.Agent.SubagentModels[skill] = fallbackRef
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *App) removeBuiltInProviderAccessAndRetargetTabs(name string) error {
|
|
// This first load is a read-only planning copy (fallback ref + affected-tab
|
|
// scan); it loads credentials because the fallback choice depends on which
|
|
// providers resolve a key. The saved edit below reloads under the config
|
|
// edit lock so the slow snapshot work in between cannot widen the
|
|
// read-modify-write window.
|
|
cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fallbackRef := providerAccessFallbackRef(cfg, name)
|
|
|
|
var affected []providerRemovalTab
|
|
if fallbackRef != "" {
|
|
a.mu.RLock()
|
|
for _, id := range a.orderedTabIDsLocked() {
|
|
tab := a.tabs[id]
|
|
if tab == nil {
|
|
continue
|
|
}
|
|
ref := tab.model
|
|
if strings.TrimSpace(ref) == "" {
|
|
ref = cfg.DefaultModel
|
|
}
|
|
if !desktopModelRefsProvider(cfg, ref, name) {
|
|
continue
|
|
}
|
|
if controllerHasActiveRuntimeWork(tab.Ctrl) {
|
|
a.mu.RUnlock()
|
|
return fmt.Errorf("finish or cancel active work using %q before removing the provider access", name)
|
|
}
|
|
affected = append(affected, providerRemovalTab{id: id, ctrl: tab.Ctrl, readOnly: tab.ReadOnly})
|
|
}
|
|
a.mu.RUnlock()
|
|
}
|
|
|
|
if len(affected) == 0 {
|
|
if err := a.ensureActiveTabRebuildAllowed("provider access"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, item := range affected {
|
|
if item.ctrl != nil && !item.readOnly {
|
|
if err := item.ctrl.Snapshot(); err != nil {
|
|
slog.Warn("desktop: snapshot before removing provider access failed", "tab", item.id, "provider", name, "err", err)
|
|
return fmt.Errorf("save current session before removing provider access: %w", err)
|
|
}
|
|
}
|
|
}
|
|
// Reload-modify-save under the config edit lock: the pre-save snapshots
|
|
// above are slow and must not hold the lock, so mutate a fresh copy here
|
|
// instead of the stale planning copy loaded before them.
|
|
if err := func() error {
|
|
unlock := config.LockUserConfigEdits()
|
|
defer unlock()
|
|
fresh, path, err := a.loadDesktopUserConfigForEdit()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
retargetProviderReferences(fresh, name, fallbackRef)
|
|
removeProviderAccess(fresh, name)
|
|
return fresh.SaveTo(path)
|
|
}(); err != nil {
|
|
return err
|
|
}
|
|
if len(affected) == 0 {
|
|
if err := a.rebuild(); err != nil {
|
|
if _, ok := a.deferredRebuildWarning("provider access", err); ok {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
for _, item := range affected {
|
|
if item.ctrl != nil {
|
|
item.ctrl.Close()
|
|
}
|
|
}
|
|
|
|
var rebuildTabs []*WorkspaceTab
|
|
a.mu.Lock()
|
|
for _, item := range affected {
|
|
tab := a.tabs[item.id]
|
|
if tab == nil {
|
|
continue
|
|
}
|
|
if tab.Ctrl != item.ctrl {
|
|
// The tab swapped controllers while we worked off-lock; nil-ing the
|
|
// replacement would leak it. Leave the new runtime alone.
|
|
continue
|
|
}
|
|
tab.Ctrl = nil
|
|
// Supersede any in-flight startup build: it was planned against the
|
|
// removed provider and would otherwise finish later, pass its
|
|
// generation check, and reinstall a controller for it.
|
|
a.supersedeTabBuildLocked(tab)
|
|
tab.model = fallbackRef
|
|
tab.Label = fallbackRef
|
|
clearTabStartupError(tab)
|
|
tab.Ready = a.ctx == nil
|
|
if a.ctx != nil {
|
|
rebuildTabs = append(rebuildTabs, tab)
|
|
}
|
|
}
|
|
a.saveTabsLocked()
|
|
a.mu.Unlock()
|
|
|
|
for _, tab := range rebuildTabs {
|
|
go a.buildTabController(tab)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) deleteProviderAndRetargetTabs(name string) error {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return fmt.Errorf("remove provider: empty provider name")
|
|
}
|
|
// Read-only planning copy (with credentials — the fallback choice depends
|
|
// on which providers resolve a key); the saved edit below reloads under the
|
|
// config edit lock (see removeBuiltInProviderAccessAndRetargetTabs).
|
|
cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fallbackRef := providerRemovalFallbackRef(cfg, name)
|
|
|
|
var affected []providerRemovalTab
|
|
a.mu.RLock()
|
|
for _, id := range a.orderedTabIDsLocked() {
|
|
tab := a.tabs[id]
|
|
if tab == nil {
|
|
continue
|
|
}
|
|
ref := tab.model
|
|
if strings.TrimSpace(ref) == "" {
|
|
ref = cfg.DefaultModel
|
|
}
|
|
if !desktopModelRefsProvider(cfg, ref, name) {
|
|
continue
|
|
}
|
|
if controllerHasActiveRuntimeWork(tab.Ctrl) {
|
|
a.mu.RUnlock()
|
|
return fmt.Errorf("finish or cancel active work using %q before deleting the provider", name)
|
|
}
|
|
affected = append(affected, providerRemovalTab{id: id, ctrl: tab.Ctrl, readOnly: tab.ReadOnly})
|
|
}
|
|
a.mu.RUnlock()
|
|
|
|
if len(affected) > 0 && fallbackRef == "" {
|
|
return fmt.Errorf("remove provider: %q is used by open tabs and no other configured provider exists", name)
|
|
}
|
|
if len(affected) == 0 {
|
|
if err := a.ensureActiveTabRebuildAllowed("provider"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, item := range affected {
|
|
if item.ctrl != nil && !item.readOnly {
|
|
if err := item.ctrl.Snapshot(); err != nil {
|
|
slog.Warn("desktop: snapshot before deleting provider failed", "tab", item.id, "provider", name, "err", err)
|
|
return fmt.Errorf("save current session before deleting provider: %w", err)
|
|
}
|
|
}
|
|
}
|
|
// Reload-modify-save under the config edit lock; the snapshots above ran
|
|
// off-lock against the stale planning copy.
|
|
if err := func() error {
|
|
unlock := config.LockUserConfigEdits()
|
|
defer unlock()
|
|
fresh, path, err := a.loadDesktopUserConfigForEdit()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := fresh.RemoveProvider(name); err != nil {
|
|
return err
|
|
}
|
|
removeProviderAccess(fresh, name)
|
|
return fresh.SaveTo(path)
|
|
}(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(affected) == 0 {
|
|
if err := a.rebuild(); err != nil {
|
|
if _, ok := a.deferredRebuildWarning("provider", err); ok {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
for _, item := range affected {
|
|
if item.ctrl != nil {
|
|
item.ctrl.Close()
|
|
}
|
|
}
|
|
|
|
var rebuildTabs []*WorkspaceTab
|
|
a.mu.Lock()
|
|
for _, item := range affected {
|
|
tab := a.tabs[item.id]
|
|
if tab == nil {
|
|
continue
|
|
}
|
|
if tab.Ctrl != item.ctrl {
|
|
// The tab swapped controllers while we worked off-lock; nil-ing the
|
|
// replacement would leak it. Leave the new runtime alone.
|
|
continue
|
|
}
|
|
tab.Ctrl = nil
|
|
// Supersede any in-flight startup build: it was planned against the
|
|
// removed provider and would otherwise finish later, pass its
|
|
// generation check, and reinstall a controller for it.
|
|
a.supersedeTabBuildLocked(tab)
|
|
tab.model = fallbackRef
|
|
tab.Label = fallbackRef
|
|
clearTabStartupError(tab)
|
|
tab.Ready = a.ctx == nil
|
|
if a.ctx != nil {
|
|
rebuildTabs = append(rebuildTabs, tab)
|
|
}
|
|
}
|
|
a.saveTabsLocked()
|
|
a.mu.Unlock()
|
|
|
|
for _, tab := range rebuildTabs {
|
|
go a.buildTabController(tab)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetProviderKey writes a secret to Reasonix's global .env under the given
|
|
// env-var name (the one a provider's api_key_env points at) and rebuilds so it
|
|
// resolves immediately.
|
|
func (a *App) SetProviderKey(apiKeyEnv, value string) (string, error) {
|
|
if strings.TrimSpace(apiKeyEnv) == "" {
|
|
return "", fmt.Errorf("this provider has no api_key_env set")
|
|
}
|
|
if err := a.ensureActiveTabRebuildAllowed("provider key"); err != nil {
|
|
return "", err
|
|
}
|
|
warning, err := a.saveProviderCredential(apiKeyEnv, value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := a.ensureProviderAccessForKey(apiKeyEnv); err != nil {
|
|
return "", err
|
|
}
|
|
if err := a.rebuildSetting("provider key"); err != nil {
|
|
if rebuildWarning, ok := a.deferredRebuildWarning("provider key", err); ok {
|
|
return appendSettingsWarning(warning, rebuildWarning), nil
|
|
}
|
|
return "", err
|
|
}
|
|
return warning, nil
|
|
}
|
|
|
|
// SaveProviderKey writes a provider secret without rebuilding the chat runtime.
|
|
// It is used by settings probes that need credentials only for a model-list
|
|
// request; explicit "save key" actions still call SetProviderKey.
|
|
func (a *App) SaveProviderKey(apiKeyEnv, value string) (string, error) {
|
|
if strings.TrimSpace(apiKeyEnv) == "" {
|
|
return "", fmt.Errorf("this provider has no api_key_env set")
|
|
}
|
|
return a.saveProviderCredential(apiKeyEnv, value)
|
|
}
|
|
|
|
func (a *App) ensureProviderAccessForKey(apiKeyEnv string) error {
|
|
apiKeyEnv = strings.TrimSpace(apiKeyEnv)
|
|
if apiKeyEnv == "" {
|
|
return nil
|
|
}
|
|
// Pure load-modify-save on the user config; the caller (SetProviderKey)
|
|
// rebuilds after we return, outside the config edit lock.
|
|
unlock := config.LockUserConfigEdits()
|
|
defer unlock()
|
|
cfg, path, err := a.loadDesktopUserConfigForEdit()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
access := providerAccessSet(cfg.Desktop.ProviderAccess)
|
|
changed := false
|
|
addAccess := func(name string) {
|
|
if name == "" || access[name] {
|
|
return
|
|
}
|
|
addProviderAccess(cfg, name)
|
|
access[name] = true
|
|
changed = true
|
|
}
|
|
for i := range cfg.Providers {
|
|
p := cfg.Providers[i]
|
|
if strings.TrimSpace(p.APIKeyEnv) != apiKeyEnv {
|
|
continue
|
|
}
|
|
if len(p.ModelList()) == 0 {
|
|
continue
|
|
}
|
|
if isOfficialBuiltInProvider(p) {
|
|
addAccess(config.CanonicalDesktopOfficialProviderName(p.Name))
|
|
} else {
|
|
addAccess(strings.TrimSpace(p.Name))
|
|
}
|
|
}
|
|
if !changed && apiKeyEnv == "DEEPSEEK_API_KEY" {
|
|
entries, _, err := officialProviderTemplate("deepseek", cfg.DeepSeekOfficialPricingLanguage())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
if err := cfg.UpsertProvider(e); err != nil {
|
|
return err
|
|
}
|
|
addAccess(e.Name)
|
|
}
|
|
}
|
|
if !changed {
|
|
return nil
|
|
}
|
|
return cfg.SaveTo(path)
|
|
}
|
|
|
|
// ClearProviderKey removes a provider secret from Reasonix's global .env
|
|
// and rebuilds so the provider immediately becomes unauthenticated.
|
|
func (a *App) ClearProviderKey(apiKeyEnv string) error {
|
|
if strings.TrimSpace(apiKeyEnv) == "" {
|
|
return fmt.Errorf("this provider has no api_key_env set")
|
|
}
|
|
if err := a.ensureActiveTabRebuildAllowed("provider key"); err != nil {
|
|
return err
|
|
}
|
|
if err := removeDotEnv(apiKeyEnv); err != nil {
|
|
return err
|
|
}
|
|
if err := a.rebuildSetting("provider key"); err != nil {
|
|
if _, ok := a.deferredRebuildWarning("provider key", err); ok {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetPermissionMode sets the writer-fallback mode (ask|allow|deny).
|
|
func (a *App) SetPermissionMode(mode string) error {
|
|
return a.applyConfigChange(func(c *config.Config) error { return c.SetPermissionMode(mode) })
|
|
}
|
|
|
|
// AddPermissionRule appends a rule to the allow/ask/deny list.
|
|
func (a *App) AddPermissionRule(list, rule string) error {
|
|
return a.applyConfigChange(func(c *config.Config) error { return c.AddPermissionRule(list, rule) })
|
|
}
|
|
|
|
// RemovePermissionRule drops a rule from the allow/ask/deny list.
|
|
func (a *App) RemovePermissionRule(list, rule string) error {
|
|
return a.applyConfigChange(func(c *config.Config) error {
|
|
_, err := c.RemovePermissionRule(list, rule)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// ReloadSettings rebuilds the active controller from the current config without
|
|
// changing any config file. It lets manual config.toml edits take effect.
|
|
func (a *App) ReloadSettings() error {
|
|
if err := a.ensureActiveTabRebuildAllowed("settings"); err != nil {
|
|
return err
|
|
}
|
|
if err := a.rebuild(); err != nil {
|
|
// The on-disk config already diverged from the runtime; retry the
|
|
// refresh once the other window releases the session lease.
|
|
if _, ok := a.deferredRebuildWarning("settings", err); ok {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetSandbox updates the bash sandbox mode, network egress, and write roots.
|
|
func (a *App) SetSandbox(bash string, network bool, workspaceRoot string, allowWrite []string, shell string) error {
|
|
return a.applyConfigChange(func(c *config.Config) error {
|
|
c.Sandbox.Bash = bash
|
|
c.Sandbox.Network = network
|
|
c.Sandbox.WorkspaceRoot = strings.TrimSpace(workspaceRoot)
|
|
c.Sandbox.AllowWrite = trimList(allowWrite)
|
|
c.Tools.Shell.Prefer = strings.TrimSpace(shell)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// SetNetwork updates ordinary outbound proxy settings.
|
|
func (a *App) SetNetwork(n NetworkView) error {
|
|
return a.applyConfigChange(func(c *config.Config) error {
|
|
return c.SetNetwork(config.NetworkConfig{
|
|
ProxyMode: n.ProxyMode,
|
|
ProxyURL: n.ProxyURL,
|
|
NoProxy: n.NoProxy,
|
|
Proxy: config.NetworkProxyConfig{
|
|
Type: n.Proxy.Type,
|
|
Server: n.Proxy.Server,
|
|
Port: n.Proxy.Port,
|
|
Username: n.Proxy.Username,
|
|
Password: n.Proxy.Password,
|
|
},
|
|
})
|
|
})
|
|
}
|
|
|
|
func (a *App) SetBotSettings(b BotSettingsView) error {
|
|
err := a.applyConfigOnly(func(c *config.Config) error {
|
|
c.Bot.Enabled = b.Enabled
|
|
c.Bot.Model = strings.TrimSpace(b.Model)
|
|
c.Bot.ToolApprovalMode = normalizeBotConnectionToolApprovalMode(b.ToolApprovalMode)
|
|
c.Bot.MaxSteps = b.MaxSteps
|
|
c.Bot.DebounceMs = b.DebounceMs
|
|
c.Bot.QueueMode = strings.TrimSpace(b.QueueMode)
|
|
c.Bot.QueueCap = b.QueueCap
|
|
c.Bot.QueueDrop = strings.TrimSpace(b.QueueDrop)
|
|
c.Bot.IgnoreSelfMessages = b.IgnoreSelfMessages
|
|
c.Bot.SelfUserIDs = config.BotSelfUserIDs{
|
|
QQ: trimList(b.SelfUserIDs.QQ),
|
|
Feishu: trimList(b.SelfUserIDs.Feishu),
|
|
Weixin: trimList(b.SelfUserIDs.Weixin),
|
|
}
|
|
c.Bot.Control = config.BotControlConfig{
|
|
Enabled: b.Control.Enabled,
|
|
Addr: strings.TrimSpace(b.Control.Addr),
|
|
TokenEnv: strings.TrimSpace(b.Control.TokenEnv),
|
|
}
|
|
c.Bot.Pairing = config.BotPairingConfig{
|
|
Enabled: b.Pairing.Enabled,
|
|
RequestTTLMinutes: b.Pairing.RequestTTLMinutes,
|
|
MaxPendingPerPlatform: b.Pairing.MaxPendingPerPlatform,
|
|
}
|
|
c.Bot.Routes = botRouteConfigs(b.Routes)
|
|
c.Bot.Allowlist = config.BotAllowlist{
|
|
Enabled: b.Allowlist.Enabled,
|
|
AllowAll: b.Allowlist.AllowAll,
|
|
QQUsers: trimList(b.Allowlist.QQUsers),
|
|
FeishuUsers: trimList(b.Allowlist.FeishuUsers),
|
|
WeixinUsers: trimList(b.Allowlist.WeixinUsers),
|
|
QQApprovers: trimList(b.Allowlist.QQApprovers),
|
|
FeishuApprovers: trimList(b.Allowlist.FeishuApprovers),
|
|
WeixinApprovers: trimList(b.Allowlist.WeixinApprovers),
|
|
QQAdmins: trimList(b.Allowlist.QQAdmins),
|
|
FeishuAdmins: trimList(b.Allowlist.FeishuAdmins),
|
|
WeixinAdmins: trimList(b.Allowlist.WeixinAdmins),
|
|
QQGroups: trimList(b.Allowlist.QQGroups),
|
|
FeishuGroups: trimList(b.Allowlist.FeishuGroups),
|
|
WeixinGroups: trimList(b.Allowlist.WeixinGroups),
|
|
}
|
|
c.Bot.QQ = config.QQBotConfig{
|
|
Enabled: b.QQ.Enabled,
|
|
AppID: strings.TrimSpace(b.QQ.AppID),
|
|
AppSecretEnv: strings.TrimSpace(b.QQ.AppSecretEnv),
|
|
Sandbox: b.QQ.Sandbox,
|
|
Model: strings.TrimSpace(b.QQ.Model),
|
|
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(b.QQ.ToolApprovalMode),
|
|
WorkspaceRoot: strings.TrimSpace(b.QQ.WorkspaceRoot),
|
|
Access: botAccessConfigFromView(b.QQ.Access),
|
|
}
|
|
c.Bot.Feishu = config.FeishuBotConfig{
|
|
Enabled: b.Feishu.Enabled,
|
|
Domain: botDomainOrDefault(b.Feishu.Domain),
|
|
AppID: strings.TrimSpace(b.Feishu.AppID),
|
|
AppSecretEnv: strings.TrimSpace(b.Feishu.AppSecretEnv),
|
|
VerificationToken: strings.TrimSpace(b.Feishu.VerificationToken),
|
|
Mode: strings.TrimSpace(b.Feishu.Mode),
|
|
WebhookPort: b.Feishu.WebhookPort,
|
|
RequireMention: b.Feishu.RequireMention,
|
|
OutboundMediaRoots: append([]string(nil), c.Bot.Feishu.OutboundMediaRoots...),
|
|
}
|
|
c.Bot.Weixin = config.WeixinBotConfig{
|
|
Enabled: b.Weixin.Enabled,
|
|
AccountID: strings.TrimSpace(b.Weixin.AccountID),
|
|
TokenEnv: strings.TrimSpace(b.Weixin.TokenEnv),
|
|
APIBase: strings.TrimRight(strings.TrimSpace(b.Weixin.APIBase), "/"),
|
|
}
|
|
c.Bot.Connections = botConnectionConfigs(b.Connections)
|
|
return nil
|
|
})
|
|
if err == nil {
|
|
a.refreshBotRuntimeAsync()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// SetBotConnectionToolApprovalMode updates a single connection's tool approval
|
|
// mode without restarting the bot gateway. Only the connection's mode field is
|
|
// persisted; existing sessions on the running gateway are updated in-place.
|
|
func (a *App) SetBotConnectionToolApprovalMode(connID, mode string) error {
|
|
connID = strings.TrimSpace(connID)
|
|
mode = normalizeBotConnectionToolApprovalMode(mode)
|
|
runtimeConnID := connID
|
|
err := a.applyConfigOnly(func(c *config.Config) error {
|
|
for i := range c.Bot.Connections {
|
|
candidateRuntimeID := botruntime.ConnectionRuntimeID(c.Bot.Connections[i])
|
|
if candidateRuntimeID == "" {
|
|
candidateRuntimeID = strings.TrimSpace(c.Bot.Connections[i].ID)
|
|
}
|
|
if c.Bot.Connections[i].ID == connID || candidateRuntimeID == connID {
|
|
c.Bot.Connections[i].ToolApprovalMode = mode
|
|
c.Bot.Connections[i].UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
|
runtimeConnID = candidateRuntimeID
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("connection %q not found", connID)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if a.botRuntime != nil {
|
|
a.botRuntime.updateConnectionToolApprovalMode(runtimeConnID, mode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) SetBotSecret(envName, value string) error {
|
|
envName = strings.TrimSpace(envName)
|
|
if envName == "" {
|
|
return fmt.Errorf("bot secret env name is empty")
|
|
}
|
|
if err := upsertDotEnv(envName, value); err != nil {
|
|
return err
|
|
}
|
|
a.refreshBotRuntimeAsync()
|
|
return nil
|
|
}
|
|
|
|
func (a *App) ClearBotSecret(envName string) error {
|
|
envName = strings.TrimSpace(envName)
|
|
if envName == "" {
|
|
return fmt.Errorf("bot secret env name is empty")
|
|
}
|
|
if err := removeDotEnv(envName); err != nil {
|
|
return err
|
|
}
|
|
a.refreshBotRuntimeAsync()
|
|
return nil
|
|
}
|
|
|
|
// SetCloseBehavior updates desktop-only window close behavior without rebuilding
|
|
// the active controller. It must stay out of provider-visible prompt/request data.
|
|
func (a *App) SetCloseBehavior(mode string) error {
|
|
return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopCloseBehavior(mode) })
|
|
}
|
|
|
|
// SetDisplayMode updates the transcript display mode. UI-only, no rebuild needed.
|
|
func (a *App) SetDisplayMode(mode string) error {
|
|
return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopDisplayMode(mode) })
|
|
}
|
|
|
|
// SetStatusBarStyle updates the desktop status bar metric label style. UI-only,
|
|
// no rebuild needed.
|
|
func (a *App) SetStatusBarStyle(style string) error {
|
|
return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopStatusBarStyle(style) })
|
|
}
|
|
|
|
// SetStatusBarItems updates the ordered visible desktop status bar items.
|
|
// UI-only, no rebuild needed.
|
|
func (a *App) SetStatusBarItems(items []string) error {
|
|
return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopStatusBarItems(items) })
|
|
}
|
|
|
|
// SetDesktopLanguage updates the desktop UI language and the user-level response
|
|
// language preference used by model-facing desktop sessions.
|
|
func (a *App) SetDesktopLanguage(lang string) error {
|
|
responseLanguage := ""
|
|
if err := a.applyConfigOnly(func(c *config.Config) error {
|
|
if err := c.SetDesktopLanguage(lang); err != nil {
|
|
return err
|
|
}
|
|
if err := c.SetLanguage(lang); err != nil {
|
|
return err
|
|
}
|
|
responseLanguage = c.ResponseLanguage()
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
a.updateTrayLocale(lang)
|
|
a.applyResponseLanguageToLiveControllers(responseLanguage)
|
|
return nil
|
|
}
|
|
|
|
// SetTrayLocale mirrors the resolved desktop UI language into the native tray
|
|
// menu. It is runtime-only; the persisted preference remains [desktop].language.
|
|
func (a *App) SetTrayLocale(locale string) error {
|
|
if locale != "zh" {
|
|
locale = "en"
|
|
}
|
|
a.updateTrayLocale(locale)
|
|
return nil
|
|
}
|
|
|
|
// SetDesktopAppearance updates only desktop theme preferences. It does not
|
|
// rebuild the active controller and must stay out of provider-visible requests.
|
|
func (a *App) SetDesktopAppearance(theme, style string) error {
|
|
return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopAppearance(theme, style) })
|
|
}
|
|
|
|
// SetDesktopLayoutStyle updates only the desktop layout style. It does not
|
|
// rebuild the active controller and must stay out of provider-visible requests.
|
|
func (a *App) SetDesktopLayoutStyle(style string) error {
|
|
normalized := ""
|
|
if err := a.applyConfigOnly(func(c *config.Config) error {
|
|
if err := c.SetDesktopLayoutStyle(style); err != nil {
|
|
return err
|
|
}
|
|
normalized = c.DesktopLayoutStyle()
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if singleSurfaceLayoutStyle(normalized) {
|
|
return a.applySingleSurfaceTabPolicy()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetDesktopCheckUpdates updates only the desktop startup update-check
|
|
// preference. Manual checks in Settings are unaffected.
|
|
func (a *App) SetDesktopCheckUpdates(enabled bool) error {
|
|
return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopCheckUpdates(enabled) })
|
|
}
|
|
|
|
// SetDesktopTelemetry sets whether the desktop sends the anonymous launch ping.
|
|
func (a *App) SetDesktopTelemetry(enabled bool) error {
|
|
return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopTelemetry(enabled) })
|
|
}
|
|
|
|
// SetDesktopMetrics sets whether the desktop sends aggregate desktop metrics,
|
|
// starting or stopping the live aggregator so the toggle takes effect immediately.
|
|
func (a *App) SetDesktopMetrics(enabled bool) error {
|
|
if err := a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopMetrics(enabled) }); err != nil {
|
|
return err
|
|
}
|
|
switch {
|
|
case enabled && a.metrics.Load() == nil && version != "dev":
|
|
a.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))
|
|
if cfg, err := config.Load(); err == nil {
|
|
a.recordSettingsMetricsSnapshot(cfg)
|
|
}
|
|
case !enabled:
|
|
a.metrics.Store(nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetExpandThinking sets whether reasoning text is expanded by default on
|
|
// the desktop. It is desktop-only and does not rebuild the controller.
|
|
func (a *App) SetExpandThinking(on bool) error {
|
|
return a.applyConfigOnly(func(c *config.Config) error { return c.SetExpandThinking(on) })
|
|
}
|
|
|
|
// MigrateDesktopPreferences imports old browser-local desktop preferences into
|
|
// the user config once. Existing [desktop] values win so stale localStorage never
|
|
// overwrites an explicit config edit.
|
|
func (a *App) MigrateDesktopPreferences(language, theme, style string) error {
|
|
return a.applyConfigOnly(func(c *config.Config) error {
|
|
if strings.TrimSpace(c.Desktop.Language) == "" {
|
|
if err := c.SetDesktopLanguage(language); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if strings.TrimSpace(c.Desktop.Theme) == "" && strings.TrimSpace(c.Desktop.ThemeStyle) == "" {
|
|
if err := c.SetDesktopAppearance(theme, style); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// SetAgentParams updates sampling temperature, optional step guards, and the
|
|
// base system prompt.
|
|
func (a *App) SetAgentParams(temperature float64, maxSteps int, plannerMaxSteps int, systemPrompt string) error {
|
|
return a.applyConfigChange(func(c *config.Config) error {
|
|
c.Agent.Temperature = temperature
|
|
c.Agent.MaxSteps = maxSteps
|
|
c.Agent.PlannerMaxSteps = plannerMaxSteps
|
|
c.Agent.SystemPrompt = systemPrompt
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (a *App) SetColdResumePrune(enabled bool) error {
|
|
return a.applyConfigChange(func(c *config.Config) error { return c.SetColdResumePrune(enabled) })
|
|
}
|
|
|
|
func (a *App) SetReasoningLanguage(lang string) error {
|
|
if err := a.ensureLiveControllersRuntimeMutationAllowed("reasoning language"); err != nil {
|
|
return err
|
|
}
|
|
var cfg *config.Config
|
|
// Lock only the load-modify-save cycle; the live-controller fan-out below
|
|
// must not hold the config edit lock.
|
|
if err := func() error {
|
|
unlock := config.LockUserConfigEdits()
|
|
defer unlock()
|
|
loaded, path, err := a.loadDesktopUserConfigForEdit()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := loaded.SetReasoningLanguage(lang); err != nil {
|
|
return err
|
|
}
|
|
if err := loaded.SaveTo(path); err != nil {
|
|
return err
|
|
}
|
|
cfg = loaded
|
|
return nil
|
|
}(); err != nil {
|
|
return err
|
|
}
|
|
a.applyReasoningLanguageToLiveControllers(cfg.ReasoningLanguage())
|
|
return nil
|
|
}
|
|
|
|
func (a *App) applyReasoningLanguageToLiveControllers(fallback string) {
|
|
type liveTab struct {
|
|
root string
|
|
ctrl control.SessionAPI
|
|
}
|
|
var tabs []liveTab
|
|
a.mu.RLock()
|
|
for _, tab := range a.tabs {
|
|
if tab != nil && tab.Ctrl != nil {
|
|
tabs = append(tabs, liveTab{root: tab.WorkspaceRoot, ctrl: tab.Ctrl})
|
|
}
|
|
}
|
|
a.mu.RUnlock()
|
|
for _, tab := range tabs {
|
|
mode := fallback
|
|
if cfg, err := config.LoadForRoot(tab.root); err == nil {
|
|
mode = cfg.ReasoningLanguage()
|
|
}
|
|
tab.ctrl.SetReasoningLanguage(mode)
|
|
}
|
|
}
|
|
|
|
func (a *App) applyResponseLanguageToLiveControllers(fallback string) {
|
|
type liveTab struct {
|
|
root string
|
|
ctrl control.SessionAPI
|
|
}
|
|
var tabs []liveTab
|
|
a.mu.RLock()
|
|
for _, tab := range a.tabs {
|
|
if tab != nil && tab.Ctrl != nil {
|
|
tabs = append(tabs, liveTab{root: tab.WorkspaceRoot, ctrl: tab.Ctrl})
|
|
}
|
|
}
|
|
a.mu.RUnlock()
|
|
for _, tab := range tabs {
|
|
mode := fallback
|
|
if cfg, err := config.LoadForRoot(tab.root); err == nil {
|
|
mode = cfg.ResponseLanguage()
|
|
}
|
|
tab.ctrl.SetResponseLanguage(mode)
|
|
}
|
|
}
|
|
|
|
// trimList drops blank entries from a string slice (and returns a non-nil slice).
|
|
func trimList(in []string) []string {
|
|
out := []string{}
|
|
for _, s := range in {
|
|
if t := strings.TrimSpace(s); t != "" {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out
|
|
}
|