// Package domain — configuration types. // // S3Config and SSHConfig are intentionally split into their own structs so // that future providers can introduce their own configuration types side- // by-side without polluting the core domain types file. package domain // S3Config describes the connection parameters for any S3-compatible // endpoint (AWS S3, MinIO, Cloudflare R2, Backblaze B2, Aliyun OSS S3 // endpoint, etc.). // // Field design notes: // - PathPrefix supports object name templating so users can group screenshots // by date. // - PublicURLBase is optional to support CDN-fronted buckets; otherwise the // adapter falls back to "{Endpoint}/{Bucket}/{Key}" (path-style). // - UsePathStyle defaults to true because many self-hosted MinIO / R2 // deployments do not support virtual-hosted-style addressing. type S3Config struct { Endpoint string `json:"endpoint"` Region string `json:"region"` Bucket string `json:"bucket"` AccessKeyID string `json:"accessKeyId"` SecretAccessKey string `json:"secretAccessKey"` PathPrefix string `json:"pathPrefix"` PublicURLBase string `json:"publicUrlBase"` UsePathStyle bool `json:"usePathStyle"` } // SSHConfig describes the parameters required to upload a screenshot via // SSH/SCP to a remote host. // // Field design notes: // - Host / Port form the destination endpoint. Port defaults to 22 when 0. // - User is the SSH login name. // - AuthMethod selects how the connection authenticates: // "password" → password only, via the in-process Go SSH client. // "key" → ssh-agent + ~/.ssh/id_* key files (no password), // via the in-process Go SSH client. // "kerberos" → delegate to the system /usr/bin/ssh binary so the // existing Kerberos (GSSAPI) credential cache from // `kinit` is reused. Native GSSAPI is required here // because macOS stores tickets in an API: ccache // that pure-Go SSH libraries cannot read. // "" / "builtin" → legacy combined behaviour (password → agent → key // files) kept only for backward compatibility with // configs written before the method was split. // - Password is optional; when empty the implementation falls back to the // local SSH agent or ~/.ssh/id_* keys (i.e. the user is responsible for // having password-less access already configured on this machine). // It is ignored entirely when AuthMethod is "kerberos". // - PathPrefix is interpreted *relative to the remote user's $HOME*. The // UI presents it as "~/" so the user cannot escape the home // directory by writing absolute paths. Leading "/" or "~" markers are // stripped before the path is used. // - StrictHostKey toggles host key verification. When false the client // uses ssh.InsecureIgnoreHostKey() to mimic `scp -o StrictHostKeyChecking=no`, // trading some security for first-launch usability on personal LANs. // - KnownHostsPath defaults to ~/.ssh/known_hosts when empty and is only // used while StrictHostKey is true. // - ConnectTimeoutSecs caps the network handshake duration so a wrong // endpoint does not hang the capture flow indefinitely. type SSHConfig struct { Host string `json:"host"` Port int `json:"port"` User string `json:"user"` AuthMethod string `json:"authMethod"` Password string `json:"password"` PathPrefix string `json:"pathPrefix"` StrictHostKey bool `json:"strictHostKey"` KnownHostsPath string `json:"knownHostsPath"` ConnectTimeoutSecs int `json:"connectTimeoutSecs"` } // LLMProviderConfig stores one OpenAI-compatible multimodal chat endpoint. // // The three built-in providers (Qwen, Doubao/Ark, OpenAI-compatible) all use // the same request shape: chat/completions with a user content array that // contains text plus an image_url item. Keeping them as provider records lets // users switch between credentials/models without retyping every field. type LLMProviderConfig struct { Label string `json:"label"` BaseURL string `json:"baseUrl"` APIKey string `json:"apiKey"` Model string `json:"model"` MaxTokens int `json:"maxTokens"` Temperature float64 `json:"temperature"` TimeoutSecs int `json:"timeoutSecs"` // MaxInlineBytes caps the screenshot size (in bytes of the PNG payload) // that may be sent inline as a base64 data URL. Above this threshold the // summary pipeline falls back to uploading the image to S3 and passing a // public URL instead, because most providers reject oversized inline // images. A value <= 0 means "use the built-in default". MaxInlineBytes int `json:"maxInlineBytes"` } // DefaultMaxInlineBytes is the fallback inline-image cap (~4 MiB) used when a // provider config does not specify MaxInlineBytes. It is a conservative bound // that keeps a single base64 data URL within the request-size limits accepted // by the common OpenAI-compatible multimodal endpoints. const DefaultMaxInlineBytes = 4 << 20 // LLMConfig controls screenshot summarisation. type LLMConfig struct { ActiveProvider string `json:"activeProvider"` Prompt string `json:"prompt"` Providers map[string]LLMProviderConfig `json:"providers"` } // SSH authentication method identifiers stored in SSHConfig.AuthMethod. const ( // SSHAuthBuiltin is the legacy combined method (password → agent → key // files). Retained for backward compatibility with older config files; // new configs use the explicit methods below. SSHAuthBuiltin = "builtin" // SSHAuthPassword authenticates with the password field only. SSHAuthPassword = "password" // SSHAuthKey authenticates with ssh-agent / ~/.ssh/id_* key files only. SSHAuthKey = "key" // SSHAuthKerberos delegates to the system ssh binary so an existing // Kerberos credential cache (populated by `kinit`) is reused via GSSAPI. SSHAuthKerberos = "kerberos" ) // Built-in LLM provider identifiers. const ( LLMProviderQwen = "qwen" LLMProviderDoubao = "doubao" LLMProviderOpenAI = "openai" ) // Theme preference identifiers stored in AppConfig.Theme. const ( ThemeAuto = "auto" ThemeLight = "light" ThemeDark = "dark" ) const DefaultSummaryPrompt = `你是一个截图内容总结助手。请阅读截图,并用中文输出一段适合直接粘贴到聊天、工单、Issue 或 PR 中的说明。 要求: - 先说明截图中最重要的信息和用户可能想表达的意图。 - 如果截图包含报错、异常状态、表格、代码、界面控件或关键数字,请准确提取。 - 如果截图内容不足以判断,不要编造;直接说明不确定点。 - 输出尽量简洁,默认 3 到 6 条要点。 - 不要输出 Markdown 标题,不要寒暄。` // IsKerberos reports whether this config requests Kerberos/GSSAPI auth. func (c SSHConfig) IsKerberos() bool { return c.AuthMethod == SSHAuthKerberos } // AppConfig is the top-level on-disk configuration document. // // We keep S3 / SSH nested so that adding more providers later (e.g. AliyunOSS, // COS, FTP) only requires a new sibling field rather than a schema rewrite. type AppConfig struct { // Hotkey describes the global shortcut that triggers a capture. // Stored as a human-readable string like "cmd+shift+a"; parsing happens // in the infrastructure hotkey adapter. Hotkey string `json:"hotkey"` // Theme controls the settings UI appearance: "auto", "light", or "dark". Theme string `json:"theme"` // S3 holds the active S3-compatible storage configuration. S3 S3Config `json:"s3"` // SSH holds the configuration for the optional "save to remote via scp" // destination triggered by the save-remote toolbar button. SSH SSHConfig `json:"ssh"` // LLM holds provider settings for the "copy summary" screenshot action. LLM LLMConfig `json:"llm"` } // DefaultAppConfig returns sane zero-value defaults used on first launch. func DefaultAppConfig() AppConfig { cfg := AppConfig{ Hotkey: "cmd+shift+a", Theme: ThemeAuto, S3: S3Config{ PathPrefix: "snapgo/", UsePathStyle: true, }, SSH: SSHConfig{ Port: 22, PathPrefix: "snapgo/", ConnectTimeoutSecs: 10, StrictHostKey: false, }, LLM: DefaultLLMConfig(), } cfg.Normalize() return cfg } // DefaultLLMConfig returns the built-in provider presets. API keys and exact // models remain user-editable because each provider account may expose // different model names / endpoint IDs. func DefaultLLMConfig() LLMConfig { return LLMConfig{ ActiveProvider: LLMProviderOpenAI, Prompt: DefaultSummaryPrompt, Providers: map[string]LLMProviderConfig{ LLMProviderQwen: { Label: "阿里千问多模态", BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", Model: "qwen-vl-plus", MaxTokens: 600, Temperature: 0.2, TimeoutSecs: 60, MaxInlineBytes: DefaultMaxInlineBytes, }, LLMProviderDoubao: { Label: "火山方舟豆包多模态", BaseURL: "https://ark.cn-beijing.volces.com/api/v3", Model: "", MaxTokens: 600, Temperature: 0.2, TimeoutSecs: 60, MaxInlineBytes: DefaultMaxInlineBytes, }, LLMProviderOpenAI: { Label: "ChatGPT / OpenAI-compatible", BaseURL: "https://api.openai.com/v1", Model: "gpt-5.5", MaxTokens: 600, Temperature: 0.2, TimeoutSecs: 60, MaxInlineBytes: DefaultMaxInlineBytes, }, }, } } // Normalize fills defaults into configs written by older app versions while // preserving any user-provided provider fields. func (c *AppConfig) Normalize() { if c.Hotkey == "" { c.Hotkey = "cmd+shift+a" } if c.Theme != ThemeLight && c.Theme != ThemeDark && c.Theme != ThemeAuto { c.Theme = ThemeAuto } if c.S3.PathPrefix == "" { c.S3.PathPrefix = "snapgo/" } if c.SSH.Port == 0 { c.SSH.Port = 22 } if c.SSH.PathPrefix == "" { c.SSH.PathPrefix = "snapgo/" } if c.SSH.ConnectTimeoutSecs == 0 { c.SSH.ConnectTimeoutSecs = 10 } defaultLLM := DefaultLLMConfig() if c.LLM.ActiveProvider == "" { c.LLM.ActiveProvider = defaultLLM.ActiveProvider } if c.LLM.Prompt == "" { c.LLM.Prompt = defaultLLM.Prompt } if c.LLM.Providers == nil { c.LLM.Providers = map[string]LLMProviderConfig{} } for id, def := range defaultLLM.Providers { current, ok := c.LLM.Providers[id] if !ok { c.LLM.Providers[id] = def continue } if current.Label == "" { current.Label = def.Label } if current.BaseURL == "" { current.BaseURL = def.BaseURL } if current.Model == "" && id != LLMProviderDoubao { current.Model = def.Model } if current.MaxTokens == 0 { current.MaxTokens = def.MaxTokens } if current.Temperature < 0 { current.Temperature = def.Temperature } if current.TimeoutSecs == 0 { current.TimeoutSecs = def.TimeoutSecs } if current.MaxInlineBytes <= 0 { current.MaxInlineBytes = DefaultMaxInlineBytes } c.LLM.Providers[id] = current } } // IsS3Configured reports whether the user has filled the mandatory S3 fields. func (c AppConfig) IsS3Configured() bool { return c.S3.Endpoint != "" && c.S3.Bucket != "" && c.S3.AccessKeyID != "" && c.S3.SecretAccessKey != "" } // IsSSHConfigured reports whether the SSH destination has the minimum // fields required to attempt a connection. Password is intentionally NOT // checked because empty password means "use agent / key auth". func (c AppConfig) IsSSHConfigured() bool { return c.SSH.Host != "" && c.SSH.User != "" } // ActiveLLMProvider returns the selected provider config plus a boolean // indicating whether the selection exists. func (c AppConfig) ActiveLLMProvider() (string, LLMProviderConfig, bool) { id := c.LLM.ActiveProvider if id == "" { id = LLMProviderOpenAI } provider, ok := c.LLM.Providers[id] return id, provider, ok } // IsLLMConfigured reports whether the selected multimodal provider has the // fields required to make a request. func (c AppConfig) IsLLMConfigured() bool { _, provider, ok := c.ActiveLLMProvider() return ok && provider.BaseURL != "" && provider.APIKey != "" && provider.Model != "" }