feat: prefer base64 inline image for summary, fall back to S3

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.
This commit is contained in:
2026-07-07 13:39:23 +08:00
parent 7c53c0f184
commit 87874e64cf
6 changed files with 258 additions and 47 deletions
+59 -22
View File
@@ -387,12 +387,6 @@ func (a *App) runSummaryPipeline(pngBytes []byte) error {
cfg := a.cfg cfg := a.cfg
a.mu.RUnlock() a.mu.RUnlock()
if !cfg.IsS3Configured() {
err := fmt.Errorf("请先在 S3 配置页填写可用的对象存储配置")
a.emitOperationStatus("summary", "需要配置 S3", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
if !cfg.IsLLMConfigured() { if !cfg.IsLLMConfigured() {
err := fmt.Errorf("请先在 LLM 配置页填写 API Key 和模型") err := fmt.Errorf("请先在 LLM 配置页填写 API Key 和模型")
a.emitOperationStatus("summary", "需要配置 LLM", err.Error(), "error") a.emitOperationStatus("summary", "需要配置 LLM", err.Error(), "error")
@@ -400,28 +394,52 @@ func (a *App) runSummaryPipeline(pngBytes []byte) error {
return err return err
} }
providerID, llmCfg, _ := cfg.ActiveLLMProvider()
visionClient, err := llmpkg.NewVisionClient(providerID, llmCfg)
if err != nil {
a.emitOperationStatus("summary", "LLM 配置错误", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
svc := &application.CaptureSummaryService{
Summarizer: visionClient,
Clipboard: a.clip,
Prompt: cfg.LLM.Prompt,
PathPrefix: cfg.S3.PathPrefix,
MaxInlineBytes: llmCfg.MaxInlineBytes,
}
// Preferred path: send the screenshot inline as a base64 data URL so the
// summary never depends on S3 being configured or publicly reachable.
if svc.CanInline(pngBytes) {
a.emitOperationStatus("summary", "识别中", "正在请求多模态模型(本地内联图片)", "running")
summary, err := svc.Summarize(a.ctx, svc.InlineDataURL(pngBytes))
if err != nil {
a.emitOperationStatus("summary", "识别失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.finishSummary(svc, summary)
}
// Oversized image: fall back to uploading to S3 and passing a public URL.
if !cfg.IsS3Configured() {
err := fmt.Errorf("截图尺寸超过内联上限(%d 字节),请缩小截图区域,或在 S3 配置页填写公网可达的对象存储后重试", llmCfg.MaxInlineBytes)
a.emitOperationStatus("summary", "尺寸超限", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
s3Provider, err := oss.NewS3Provider(cfg.S3) s3Provider, err := oss.NewS3Provider(cfg.S3)
if err != nil { if err != nil {
a.emitOperationStatus("summary", "S3 配置错误", err.Error(), "error") a.emitOperationStatus("summary", "S3 配置错误", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err return err
} }
providerID, llmCfg, _ := cfg.ActiveLLMProvider() svc.Provider = s3Provider
visionClient, err := llmpkg.NewVisionClient(providerID, llmCfg)
if err != nil {
a.emitOperationStatus("summary", "LLM 配置错误", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
svc := &application.CaptureSummaryService{
Provider: s3Provider,
Summarizer: visionClient,
Clipboard: a.clip,
Prompt: cfg.LLM.Prompt,
PathPrefix: cfg.S3.PathPrefix,
}
a.emitOperationStatus("summary", "上传中", "正在上传截图供模型读取", "running") a.emitOperationStatus("summary", "上传中", "截图较大,正在上传供模型读取", "running")
uploaded, err := svc.UploadImage(a.ctx, pngBytes) uploaded, err := svc.UploadImage(a.ctx, pngBytes)
if err != nil { if err != nil {
a.emitOperationStatus("summary", "上传失败", err.Error(), "error") a.emitOperationStatus("summary", "上传失败", err.Error(), "error")
@@ -429,6 +447,20 @@ func (a *App) runSummaryPipeline(pngBytes []byte) error {
return err return err
} }
// The remote model fetches the URL itself, so verify it is reachable and
// clean up the temporary object once we are done with it either way.
defer func() {
if derr := svc.DeleteUploaded(a.ctx, uploaded.Key); derr != nil {
slog.Warn("summary: failed to delete temporary object", "key", uploaded.Key, "err", derr)
}
}()
if err := svc.EnsureReachable(a.ctx, uploaded.URL); err != nil {
a.emitOperationStatus("summary", "链接不可达", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
a.emitOperationStatus("summary", "识别中", "图片已上传,正在请求多模态模型", "running") a.emitOperationStatus("summary", "识别中", "图片已上传,正在请求多模态模型", "running")
summary, err := svc.Summarize(a.ctx, uploaded.URL) summary, err := svc.Summarize(a.ctx, uploaded.URL)
if err != nil { if err != nil {
@@ -436,12 +468,17 @@ func (a *App) runSummaryPipeline(pngBytes []byte) error {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err return err
} }
return a.finishSummary(svc, summary)
}
// finishSummary copies the summary to the clipboard and emits the terminal
// success/failure UI state shared by the inline and S3 paths.
func (a *App) finishSummary(svc *application.CaptureSummaryService, summary string) error {
if err := svc.CopySummary(a.ctx, summary); err != nil { if err := svc.CopySummary(a.ctx, summary); err != nil {
a.emitOperationStatus("summary", "复制失败", err.Error(), "error") a.emitOperationStatus("summary", "复制失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err return err
} }
a.emitOperationStatus("summary", "识别完成", "总结已复制到剪贴板", "success") a.emitOperationStatus("summary", "识别完成", "总结已复制到剪贴板", "success")
wruntime.EventsEmit(a.ctx, "upload:success", "summary copied to clipboard") wruntime.EventsEmit(a.ctx, "upload:success", "summary copied to clipboard")
return nil return nil
+2
View File
@@ -61,6 +61,7 @@ export namespace domain {
maxTokens: number; maxTokens: number;
temperature: number; temperature: number;
timeoutSecs: number; timeoutSecs: number;
maxInlineBytes: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new LLMProviderConfig(source); return new LLMProviderConfig(source);
@@ -75,6 +76,7 @@ export namespace domain {
this.maxTokens = source["maxTokens"]; this.maxTokens = source["maxTokens"];
this.temperature = source["temperature"]; this.temperature = source["temperature"];
this.timeoutSecs = source["timeoutSecs"]; this.timeoutSecs = source["timeoutSecs"];
this.maxInlineBytes = source["maxInlineBytes"];
} }
} }
export class LLMConfig { export class LLMConfig {
+93 -7
View File
@@ -2,32 +2,65 @@ package application
import ( import (
"context" "context"
"encoding/base64"
"fmt" "fmt"
"net/http"
"time" "time"
"github.com/mmmy/snapgo/internal/domain" "github.com/mmmy/snapgo/internal/domain"
"github.com/mmmy/snapgo/internal/infrastructure/clipboard" "github.com/mmmy/snapgo/internal/infrastructure/clipboard"
) )
// VisionSummarizer describes a multimodal model capable of reading a public // VisionSummarizer describes a multimodal model capable of reading an image
// image URL and returning a textual summary. // reference (either a public https URL or an inline base64 data URL) and
// returning a textual summary.
type VisionSummarizer interface { type VisionSummarizer interface {
SummarizeImage(ctx context.Context, prompt string, imageURL string) (string, error) SummarizeImage(ctx context.Context, prompt string, imageURL string) (string, error)
} }
// CaptureSummaryService wires screenshot bytes -> S3 public URL -> LLM // ObjectDeleter is an optional capability an OSSProvider may implement so the
// summary -> clipboard. It intentionally does not notify UI state itself: // summary pipeline can delete the temporary screenshot it uploaded purely so a
// the caller emits fine-grained "uploading / recognizing / done" progress. // 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 { type CaptureSummaryService struct {
Provider domain.OSSProvider Provider domain.OSSProvider
Summarizer VisionSummarizer Summarizer VisionSummarizer
Clipboard clipboard.Writer Clipboard clipboard.Writer
Prompt string Prompt string
PathPrefix 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 // UploadImage uploads the screenshot and returns the public URL visible to
// the multimodal provider. // 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) { func (s *CaptureSummaryService) UploadImage(ctx context.Context, pngBytes []byte) (*domain.UploadResult, error) {
if s.Provider == nil { if s.Provider == nil {
return nil, fmt.Errorf("s3 is not configured") return nil, fmt.Errorf("s3 is not configured")
@@ -49,7 +82,60 @@ func (s *CaptureSummaryService) UploadImage(ctx context.Context, pngBytes []byte
}, nil }, nil
} }
// Summarize asks the configured model to summarize the uploaded image URL. // 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) { func (s *CaptureSummaryService) Summarize(ctx context.Context, imageURL string) (string, error) {
if s.Summarizer == nil { if s.Summarizer == nil {
return "", fmt.Errorf("llm is not configured") return "", fmt.Errorf("llm is not configured")
@@ -2,6 +2,7 @@ package application
import ( import (
"context" "context"
"strings"
"testing" "testing"
) )
@@ -10,6 +11,7 @@ type fakeOSSProvider struct {
data []byte data []byte
contentType string contentType string
url string url string
deletedKey string
} }
func (f *fakeOSSProvider) Upload(_ context.Context, key string, data []byte, contentType string) (string, error) { func (f *fakeOSSProvider) Upload(_ context.Context, key string, data []byte, contentType string) (string, error) {
@@ -24,6 +26,11 @@ func (f *fakeOSSProvider) Upload(_ context.Context, key string, data []byte, con
func (f *fakeOSSProvider) Name() string { return "fake" } func (f *fakeOSSProvider) Name() string { return "fake" }
func (f *fakeOSSProvider) Delete(_ context.Context, key string) error {
f.deletedKey = key
return nil
}
type fakeSummarizer struct { type fakeSummarizer struct {
prompt string prompt string
url string url string
@@ -84,3 +91,50 @@ func TestCaptureSummaryServiceUploadsSummarizesAndCopies(t *testing.T) {
t.Fatalf("expected summary copied, got %q", clip.text) t.Fatalf("expected summary copied, got %q", clip.text)
} }
} }
func TestCaptureSummaryServiceInlineDataURL(t *testing.T) {
svc := &CaptureSummaryService{MaxInlineBytes: 10}
if !svc.CanInline([]byte("small")) {
t.Fatalf("expected 5-byte payload to be inlinable under 10-byte cap")
}
if svc.CanInline([]byte("this is definitely too big")) {
t.Fatalf("expected oversized payload to be rejected for inlining")
}
url := svc.InlineDataURL([]byte("png"))
if !strings.HasPrefix(url, "data:image/png;base64,") {
t.Fatalf("expected base64 data URL prefix, got %q", url)
}
if strings.Contains(url, "https://") {
t.Fatalf("inline URL must not be a remote URL, got %q", url)
}
}
func TestCaptureSummaryServiceDeleteUploadedUsesDeleter(t *testing.T) {
provider := &fakeOSSProvider{}
svc := &CaptureSummaryService{Provider: provider}
if err := svc.DeleteUploaded(context.Background(), "snapgo/a.png"); err != nil {
t.Fatalf("delete uploaded: %v", err)
}
if provider.deletedKey != "snapgo/a.png" {
t.Fatalf("expected object deleted, got %q", provider.deletedKey)
}
}
func TestCaptureSummaryServiceDeleteUploadedNoDeleterIsNoop(t *testing.T) {
// A provider without Delete must not error when cleanup is requested.
svc := &CaptureSummaryService{Provider: nonDeletingProvider{}}
if err := svc.DeleteUploaded(context.Background(), "snapgo/a.png"); err != nil {
t.Fatalf("expected no-op delete, got %v", err)
}
}
type nonDeletingProvider struct{}
func (nonDeletingProvider) Upload(_ context.Context, _ string, _ []byte, _ string) (string, error) {
return "https://cdn.example.com/x.png", nil
}
func (nonDeletingProvider) Name() string { return "no-delete" }
+36 -18
View File
@@ -86,8 +86,20 @@ type LLMProviderConfig struct {
MaxTokens int `json:"maxTokens"` MaxTokens int `json:"maxTokens"`
Temperature float64 `json:"temperature"` Temperature float64 `json:"temperature"`
TimeoutSecs int `json:"timeoutSecs"` 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. // LLMConfig controls screenshot summarisation.
type LLMConfig struct { type LLMConfig struct {
ActiveProvider string `json:"activeProvider"` ActiveProvider string `json:"activeProvider"`
@@ -192,28 +204,31 @@ func DefaultLLMConfig() LLMConfig {
Prompt: DefaultSummaryPrompt, Prompt: DefaultSummaryPrompt,
Providers: map[string]LLMProviderConfig{ Providers: map[string]LLMProviderConfig{
LLMProviderQwen: { LLMProviderQwen: {
Label: "阿里千问多模态", Label: "阿里千问多模态",
BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
Model: "qwen-vl-plus", Model: "qwen-vl-plus",
MaxTokens: 600, MaxTokens: 600,
Temperature: 0.2, Temperature: 0.2,
TimeoutSecs: 60, TimeoutSecs: 60,
MaxInlineBytes: DefaultMaxInlineBytes,
}, },
LLMProviderDoubao: { LLMProviderDoubao: {
Label: "火山方舟豆包多模态", Label: "火山方舟豆包多模态",
BaseURL: "https://ark.cn-beijing.volces.com/api/v3", BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
Model: "", Model: "",
MaxTokens: 600, MaxTokens: 600,
Temperature: 0.2, Temperature: 0.2,
TimeoutSecs: 60, TimeoutSecs: 60,
MaxInlineBytes: DefaultMaxInlineBytes,
}, },
LLMProviderOpenAI: { LLMProviderOpenAI: {
Label: "ChatGPT / OpenAI-compatible", Label: "ChatGPT / OpenAI-compatible",
BaseURL: "https://api.openai.com/v1", BaseURL: "https://api.openai.com/v1",
Model: "gpt-5.5", Model: "gpt-5.5",
MaxTokens: 600, MaxTokens: 600,
Temperature: 0.2, Temperature: 0.2,
TimeoutSecs: 60, TimeoutSecs: 60,
MaxInlineBytes: DefaultMaxInlineBytes,
}, },
}, },
} }
@@ -275,6 +290,9 @@ func (c *AppConfig) Normalize() {
if current.TimeoutSecs == 0 { if current.TimeoutSecs == 0 {
current.TimeoutSecs = def.TimeoutSecs current.TimeoutSecs = def.TimeoutSecs
} }
if current.MaxInlineBytes <= 0 {
current.MaxInlineBytes = DefaultMaxInlineBytes
}
c.LLM.Providers[id] = current c.LLM.Providers[id] = current
} }
} }
+14
View File
@@ -101,6 +101,20 @@ func (p *S3Provider) BuildPublicURL(key string) string {
return endpoint + "/" + p.cfg.Bucket + "/" + key return endpoint + "/" + p.cfg.Bucket + "/" + key
} }
// Delete removes a previously uploaded object by key.
//
// The summary pipeline uses this to clean up the temporary screenshot it had
// to upload only so a remote multimodal model could fetch it via public URL.
func (p *S3Provider) Delete(ctx context.Context, key string) error {
if _, err := p.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: awsv2.String(p.cfg.Bucket),
Key: awsv2.String(key),
}); err != nil {
return fmt.Errorf("s3 delete object: %w", err)
}
return nil
}
// TestConnection performs a small put + delete to verify credentials and // TestConnection performs a small put + delete to verify credentials and
// bucket reachability. Used by the "Test connection" button in settings. // bucket reachability. Used by the "Test connection" button in settings.
func (p *S3Provider) TestConnection(ctx context.Context) error { func (p *S3Provider) TestConnection(ctx context.Context) error {