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.
159 lines
5.5 KiB
Go
159 lines
5.5 KiB
Go
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
|
|
}
|