package application import ( "context" "encoding/base64" "fmt" "net/http" "time" "github.com/mmmy/snapgo/internal/domain" "github.com/mmmy/snapgo/internal/infrastructure/clipboard" ) // VisionSummarizer describes a multimodal model capable of reading an image // reference (either a public https URL or an inline base64 data URL) and // returning a textual summary. type VisionSummarizer interface { SummarizeImage(ctx context.Context, prompt string, imageURL string) (string, error) } // ObjectDeleter is an optional capability an OSSProvider may implement so the // summary pipeline can delete the temporary screenshot it uploaded purely so a // remote model could fetch it. It is kept separate from domain.OSSProvider to // avoid forcing every provider (or test fake) to implement deletion. type ObjectDeleter interface { Delete(ctx context.Context, key string) error } // CaptureSummaryService wires screenshot bytes -> multimodal LLM summary -> // clipboard. It prefers sending the image inline as a base64 data URL and only // falls back to an S3 upload (public URL) when the payload exceeds the // provider's inline size limit. It intentionally does not notify UI state // itself: the caller emits fine-grained "uploading / recognizing / done" // progress. type CaptureSummaryService struct { Provider domain.OSSProvider Summarizer VisionSummarizer Clipboard clipboard.Writer Prompt string PathPrefix string // MaxInlineBytes caps the PNG size eligible for inline base64. Payloads // larger than this must go through S3. A value <= 0 disables the inline // path entirely (always upload). MaxInlineBytes int // HTTPClient is used for the public-URL reachability probe. Defaults to a // short-timeout client when nil. HTTPClient *http.Client } // CanInline reports whether the screenshot is small enough to be embedded // directly in the request as a base64 data URL. func (s *CaptureSummaryService) CanInline(pngBytes []byte) bool { return s.MaxInlineBytes > 0 && len(pngBytes) <= s.MaxInlineBytes } // InlineDataURL encodes the PNG as an RFC 2397 data URL suitable for the // image_url field of an OpenAI-compatible request. func (s *CaptureSummaryService) InlineDataURL(pngBytes []byte) string { return "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBytes) } // UploadImage uploads the screenshot and returns the public URL visible to // the multimodal provider. Used only when the image is too large to inline. func (s *CaptureSummaryService) UploadImage(ctx context.Context, pngBytes []byte) (*domain.UploadResult, error) { if s.Provider == nil { return nil, fmt.Errorf("s3 is not configured") } if len(pngBytes) == 0 { return nil, fmt.Errorf("empty screenshot") } key := buildObjectKey(s.PathPrefix) start := time.Now() url, err := s.Provider.Upload(ctx, key, pngBytes, "image/png") if err != nil { return nil, fmt.Errorf("s3 put object: %w", err) } return &domain.UploadResult{ URL: url, Key: key, Provider: s.Provider.Name(), Elapsed: time.Since(start), }, nil } // EnsureReachable verifies the public URL can actually be fetched, so we can // surface a clear "link not reachable" message instead of an opaque provider // error when the bucket is private or fronted by an inaccessible CDN. func (s *CaptureSummaryService) EnsureReachable(ctx context.Context, imageURL string) error { client := s.HTTPClient if client == nil { client = &http.Client{Timeout: 10 * time.Second} } probe := func(method string) (int, error) { req, err := http.NewRequestWithContext(ctx, method, imageURL, nil) if err != nil { return 0, err } resp, err := client.Do(req) if err != nil { return 0, err } resp.Body.Close() return resp.StatusCode, nil } status, err := probe(http.MethodHead) // Some S3-compatible endpoints reject HEAD; retry with GET before failing. if err != nil || status == http.StatusMethodNotAllowed || status == http.StatusForbidden { if s, gerr := probe(http.MethodGet); gerr == nil { status, err = s, nil } else if err == nil { err = gerr } } if err != nil { return fmt.Errorf("图片链接不可达(模型无法读取上传的截图):%w", err) } if status < 200 || status >= 300 { return fmt.Errorf("图片链接不可达:HTTP %d,请确认对象存储 bucket 公网可读或已正确配置 PublicURLBase", status) } return nil } // DeleteUploaded removes the temporary object from S3 if the provider supports // deletion. It is best-effort: a nil return means either success or that the // provider cannot delete. func (s *CaptureSummaryService) DeleteUploaded(ctx context.Context, key string) error { if key == "" { return nil } deleter, ok := s.Provider.(ObjectDeleter) if !ok { return nil } return deleter.Delete(ctx, key) } // Summarize asks the configured model to summarize the image reference, which // may be a public URL or an inline base64 data URL. func (s *CaptureSummaryService) Summarize(ctx context.Context, imageURL string) (string, error) { if s.Summarizer == nil { return "", fmt.Errorf("llm is not configured") } return s.Summarizer.SummarizeImage(ctx, s.Prompt, imageURL) } // CopySummary writes the final summary text to the clipboard. func (s *CaptureSummaryService) CopySummary(_ context.Context, summary string) error { if summary == "" { return fmt.Errorf("empty summary") } if s.Clipboard == nil { return fmt.Errorf("clipboard is not configured") } if err := s.Clipboard.WriteText(summary); err != nil { return fmt.Errorf("clipboard write failed: %w", err) } return nil }