458 lines
16 KiB
Go
458 lines
16 KiB
Go
// 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 "~/<PathPrefix>" 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"`
|
|
}
|
|
|
|
// OCRProviderConfig stores one cloud OCR endpoint that accepts a screenshot
|
|
// image and returns extracted text. Aliyun and Volcengine both authenticate
|
|
// with an AccessKey pair, but their signing schemes use slightly different
|
|
// endpoint metadata; keeping Region/Service/Action/Version configurable lets
|
|
// the presets track official API changes without a schema rewrite.
|
|
type OCRProviderConfig struct {
|
|
Label string `json:"label"`
|
|
Endpoint string `json:"endpoint"`
|
|
AccessKeyID string `json:"accessKeyId"`
|
|
AccessKeySecret string `json:"accessKeySecret"`
|
|
Region string `json:"region"`
|
|
Service string `json:"service"`
|
|
Action string `json:"action"`
|
|
Version string `json:"version"`
|
|
TimeoutSecs int `json:"timeoutSecs"`
|
|
}
|
|
|
|
// OCRConfig controls screenshot text extraction.
|
|
type OCRConfig struct {
|
|
ActiveProvider string `json:"activeProvider"`
|
|
Providers map[string]OCRProviderConfig `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"
|
|
)
|
|
|
|
// Built-in OCR provider identifiers.
|
|
const (
|
|
OCRProviderAliyun = "aliyun"
|
|
OCRProviderVolcengine = "volcengine"
|
|
)
|
|
|
|
// 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"`
|
|
|
|
// OCR holds provider settings for the "extract text" screenshot action.
|
|
OCR OCRConfig `json:"ocr"`
|
|
}
|
|
|
|
// 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(),
|
|
OCR: DefaultOCRConfig(),
|
|
}
|
|
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,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// DefaultOCRConfig returns the built-in OCR provider presets. Users only need
|
|
// to fill AccessKey credentials for the common regions/actions, while advanced
|
|
// accounts can still override endpoint metadata from the settings UI.
|
|
func DefaultOCRConfig() OCRConfig {
|
|
return OCRConfig{
|
|
ActiveProvider: OCRProviderAliyun,
|
|
Providers: map[string]OCRProviderConfig{
|
|
OCRProviderAliyun: {
|
|
Label: "阿里云文字识别",
|
|
Endpoint: "https://ocr-api.cn-hangzhou.aliyuncs.com",
|
|
Action: "RecognizeGeneral",
|
|
Version: "2021-07-07",
|
|
TimeoutSecs: 30,
|
|
},
|
|
OCRProviderVolcengine: {
|
|
Label: "火山引擎文字识别",
|
|
Endpoint: "https://visual.volcengineapi.com",
|
|
Region: "cn-north-1",
|
|
Service: "cv",
|
|
Action: "OCRNormal",
|
|
Version: "2020-08-26",
|
|
TimeoutSecs: 30,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
defaultOCR := DefaultOCRConfig()
|
|
if c.OCR.ActiveProvider == "" {
|
|
c.OCR.ActiveProvider = defaultOCR.ActiveProvider
|
|
}
|
|
if c.OCR.Providers == nil {
|
|
c.OCR.Providers = map[string]OCRProviderConfig{}
|
|
}
|
|
for id, def := range defaultOCR.Providers {
|
|
current, ok := c.OCR.Providers[id]
|
|
if !ok {
|
|
c.OCR.Providers[id] = def
|
|
continue
|
|
}
|
|
if current.Label == "" {
|
|
current.Label = def.Label
|
|
}
|
|
if current.Endpoint == "" {
|
|
current.Endpoint = def.Endpoint
|
|
}
|
|
if current.Region == "" {
|
|
current.Region = def.Region
|
|
}
|
|
if current.Service == "" {
|
|
current.Service = def.Service
|
|
}
|
|
if current.Action == "" {
|
|
current.Action = def.Action
|
|
}
|
|
if current.Version == "" {
|
|
current.Version = def.Version
|
|
}
|
|
if current.TimeoutSecs == 0 {
|
|
current.TimeoutSecs = def.TimeoutSecs
|
|
}
|
|
c.OCR.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 != ""
|
|
}
|
|
|
|
// ActiveOCRProvider returns the selected OCR provider config plus a boolean
|
|
// indicating whether the selection exists.
|
|
func (c AppConfig) ActiveOCRProvider() (string, OCRProviderConfig, bool) {
|
|
id := c.OCR.ActiveProvider
|
|
if id == "" {
|
|
id = OCRProviderAliyun
|
|
}
|
|
provider, ok := c.OCR.Providers[id]
|
|
return id, provider, ok
|
|
}
|
|
|
|
// IsOCRConfigured reports whether the selected OCR provider has the fields
|
|
// required to sign and send a request.
|
|
func (c AppConfig) IsOCRConfigured() bool {
|
|
id, provider, ok := c.ActiveOCRProvider()
|
|
if !ok ||
|
|
provider.Endpoint == "" ||
|
|
provider.AccessKeyID == "" ||
|
|
provider.AccessKeySecret == "" ||
|
|
provider.Action == "" ||
|
|
provider.Version == "" {
|
|
return false
|
|
}
|
|
if id == OCRProviderVolcengine && (provider.Region == "" || provider.Service == "") {
|
|
return false
|
|
}
|
|
return true
|
|
}
|