87874e64cf
Send the screenshot to the multimodal model inline as a base64 data URL so summarisation no longer requires S3 to be configured or publicly reachable. Only when the PNG exceeds the provider MaxInlineBytes limit do we upload to S3, verify the public URL is reachable, and delete the temporary object once done. Oversized images without S3 now surface a clear hint instead of a hard S3 requirement.
332 lines
12 KiB
Go
332 lines
12 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"`
|
|
}
|
|
|
|
// 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 != ""
|
|
}
|