Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aad93e7e8 | |||
| 85d5fcc722 | |||
| 778055cd1d | |||
| 87874e64cf | |||
| 7c53c0f184 | |||
| 5ce3eba455 |
@@ -0,0 +1,67 @@
|
||||
# AGENTS.md
|
||||
|
||||
## 项目概述
|
||||
|
||||
SnapGo 是一个常驻菜单栏的轻量截图工具:全局快捷键唤起选区截图,确认后自动上传到 S3 兼容对象存储(或通过 SSH/SCP 保存到远端),并把可分享链接复制到剪贴板。目前主要打磨 macOS 体验。
|
||||
|
||||
技术栈:**Wails v2**(Go 后端 + WebView 前端)+ **Vue 3 / TypeScript / Vite** 前端。Go 后端遵循分层架构(domain → application → infrastructure),macOS 上截图选区使用原生 AppKit overlay,其它平台回退到 Wails 透明窗口 overlay。
|
||||
|
||||
### 分层架构(`internal/`)
|
||||
|
||||
- `domain/`:核心业务类型与接口,**不依赖任何第三方 SDK 或 OS API**(仅标准库)。如 `OSSProvider` 接口、`AppConfig`/`S3Config`/`SSHConfig`、`Screenshot`/`UploadResult`。
|
||||
- `application/`:用例编排(capture → upload/save → clipboard → notify)。**只依赖 domain 接口**,具体适配器从 `main.go`/`app.go` 注入,便于单测。如 `CaptureAndUploadService`、`CaptureActionsService`、`CaptureAndSSHService`、`ApplyAnnotations`。
|
||||
- `infrastructure/`:对接外部世界的适配器实现 —— `oss/`(S3)、`ssh/`(内置 Go client + Kerberos 委托系统 ssh)、`clipboard/`、`config/`(JSON 文件存储)、`screencapture/`、`display/`、`cursor/`、`hotkey/`、`tray/`(菜单栏)、`logging/`。
|
||||
|
||||
### 顶层文件(`main` 包,根目录)
|
||||
|
||||
- `main.go`:Wails 启动入口,embed `frontend/dist`,装配 tray 与 `App`。
|
||||
- `app.go`:`App` 结构体,通过 Wails `Bind` 暴露给前端的方法(RPC),编排截图/上传/保存流程与 overlay 窗口生命周期。
|
||||
- `*_darwin.go` / `*_other.go`:平台分离的 overlay、dock 图标、激活策略、帧刷新等实现。`native_overlay_darwin.go` 使用 cgo + AppKit。
|
||||
|
||||
### 关键约定
|
||||
|
||||
- 前端通过 Wails 自动生成的 `frontend/wailsjs/go/main/App.js` 调用后端 `App` 的导出方法;后端通过 `wruntime.EventsEmit` 向前端推事件(如 `upload:success`、`upload:failure`、`hotkey:ready`、`capture:overlay`)。
|
||||
- 新增 OSS provider 只需在 `infrastructure/oss/` 加适配器并实现 `domain.OSSProvider`,无需改 application 层。
|
||||
- S3 与 SSH 走独立 pipeline(`runUploadPipeline` vs `runSaveRemotePipeline`),避免 provider 分支泄漏进彼此。
|
||||
|
||||
## 构建与命令
|
||||
|
||||
依赖前置:Go 1.23+、Node/npm、`wails` CLI(`go install github.com/wailsapp/wails/v2/cmd/wails@latest`)。
|
||||
|
||||
- `go mod tidy`:同步 Go 依赖。
|
||||
- `wails dev`:开发模式(热重载前端 + 后端),日志可直接在终端看到。
|
||||
- `wails build -platform darwin/arm64 -clean`:构建 `.app`(产物在 `build/bin/`)。
|
||||
- `./scripts/create-dev-cert.sh`:首次创建本地自签开发证书(保证 macOS TCC 权限不被反复重置)。
|
||||
- `./scripts/dev-build.sh`:本地迭代用 —— 构建 + 稳定证书重签名 + 同步到 `/Applications` + 打开。优先用它而非裸 `wails build`。
|
||||
- `./scripts/release.sh`:一键 build → sign → DMG → notarize(notarize 需 `NOTARIZE=1` 与凭证,默认跳过)。`ARCH=universal` 可出 fat binary。
|
||||
- 前端单独命令(`frontend/`):`npm run dev` / `npm run build`(`vue-tsc --noEmit && vite build`)/ `npm run preview`。
|
||||
|
||||
## 代码风格
|
||||
|
||||
- **Go**:标准 `gofmt`(tab 缩进)。包级 doc 注释说明“设计动机/rationale”是本仓库的强约定,注释解释“为什么”而非“是什么”。导出标识符必须有注释。错误用 `fmt.Errorf("...: %w", err)` 包装。日志统一用 `log/slog`(结构化 kv,如 `slog.Info("...", "host", h)`)。
|
||||
- **平台分离**:用 `_darwin.go` / `_other.go` 文件名后缀或 `//go:build` 标签隔离平台代码,保持跨平台编译通过。
|
||||
- **TypeScript/Vue**:Vue 3 `<script setup>` + Composition API;2 空格缩进。视图在 `frontend/src/views/`,组件在 `components/`。
|
||||
- 注释中可使用中文(部分 infra 包如 logging 已用中文 rationale),与现有文件保持一致即可。
|
||||
- 不要手改 `frontend/wailsjs/` 下文件,它们由 Wails 生成。
|
||||
|
||||
## 测试
|
||||
|
||||
- 框架:Go 标准 `testing` 包(无第三方断言库)。运行:`go test ./...`。
|
||||
- 现有测试集中在 `internal/application/`(如 `capture_actions_test.go`、`annotation_test.go`)。
|
||||
- 约定:application 层依赖 domain 接口,测试用手写 fake 实现(如 `fakeClipboard`、`fakeNotifier`)注入,不触碰真实 OS/网络。文件 IO 测试使用 `t.TempDir()`。
|
||||
- infrastructure 适配器(S3/SSH/screencapture 等)依赖真实环境,通常不写单测;SSH 连通性可借助 `cmd/sshdiag/` 诊断工具与设置页的 “Test connection” 按钮验证。
|
||||
|
||||
## 安全
|
||||
|
||||
- **凭证落盘**:配置(含 S3 access key、SSH 密码)以 JSON 存于 `os.UserConfigDir()/SnapGo/config.json`,文件权限 `0600`、目录 `0700`。尚未接入 OS keychain(后续计划),改动配置存储时务必保持严格权限并避免把密钥写入日志。
|
||||
- **日志**:写入 `~/Library/Logs/SnapGo/snapgo.log`(大小滚动,5MiB×2 备份)。记录连接信息时只记 host/user/port 等非敏感字段,**切勿记录密码/密钥**(参考 `app.go` 中的 `slog` 用法)。
|
||||
- **SSH**:`StrictHostKey=false` 时使用 `InsecureIgnoreHostKey()`(牺牲安全换首启可用性,仅限个人 LAN);Kerberos 模式委托系统 `/usr/bin/ssh` 复用 `kinit` 凭证缓存。`PathPrefix` 相对远端 `$HOME`,会剥离前导 `/` 和 `~` 防止逃逸。
|
||||
- **上传兜底**:上传失败时把 PNG 落地到 `~/Pictures/SnapGo` 并复制本地路径,保证不丢截图。
|
||||
- macOS 需 Screen Recording / Accessibility(TCC)权限;稳定的代码签名身份与安装路径是权限保持的关键(见 `dev-build.sh` 注释)。
|
||||
|
||||
## 配置
|
||||
|
||||
- Wails 项目配置:`wails.json`(产物名、frontend install/build/dev 命令、应用元信息)。
|
||||
- 运行时配置:首启写入默认值(`domain.DefaultAppConfig()`:快捷键 `cmd+shift+a`、S3 `pathPrefix=snapgo/`、SSH port 22 / timeout 10s)。原子写入(temp + rename)。
|
||||
- 环境变量:`SNAPGO_LOG_LEVEL=debug|info|warn|error`(默认 debug)。Release 相关:`ARCH`、`NOTARIZE`、`SIGN_MODE`、`DEVELOPER_ID_APPLICATION`、`KEYCHAIN_PROFILE`、`APPLE_ID`/`APPLE_TEAM_ID`/`APPLE_APP_SPECIFIC_PASSWORD`。
|
||||
- S3 兼容性:`UsePathStyle` 默认 true(适配 MinIO/R2);`PublicURLBase` 可选用于 CDN/自定义域名,否则回退 `{Endpoint}/{Bucket}/{Key}`。
|
||||
@@ -5,6 +5,12 @@ package main
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c -fobjc-arc
|
||||
#cgo LDFLAGS: -framework AppKit
|
||||
// Silence the "ignoring duplicate libraries: '-lobjc'" note emitted by the
|
||||
// Xcode 15+ linker (ld-prime): cgo links -lobjc for our Objective-C code and
|
||||
// the Go darwin runtime pulls it in as well, which the new linker flags as a
|
||||
// duplicate. Suppressing it here keeps the build output clean; the flag is a
|
||||
// no-op on the legacy ld64 path.
|
||||
#cgo LDFLAGS: -Wl,-no_warn_duplicate_libraries
|
||||
#include <dispatch/dispatch.h>
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
|
||||
@@ -320,17 +320,32 @@ func (a *App) runUploadPipeline(provider domain.OSSProvider, pngBytes []byte) er
|
||||
func (a *App) runCopyImagePipeline(pngBytes []byte) error {
|
||||
svc := &application.CaptureActionsService{
|
||||
Clipboard: a.clip,
|
||||
Notifier: &runtimeNotifier{ctx: a.ctx},
|
||||
}
|
||||
return svc.CopyImage(a.ctx, pngBytes)
|
||||
a.emitOperationStatus("copy", "复制中", "正在把截图复制到剪贴板", "running")
|
||||
if err := svc.CopyImage(a.ctx, pngBytes); err != nil {
|
||||
a.emitOperationStatus("copy", "复制失败", err.Error(), "error")
|
||||
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||
return err
|
||||
}
|
||||
a.emitOperationStatus("copy", "复制完成", "截图已复制到剪贴板", "success")
|
||||
wruntime.EventsEmit(a.ctx, "upload:success", "image copied to clipboard")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) runSaveImagePipeline(pngBytes []byte, dir string) (string, error) {
|
||||
svc := &application.CaptureActionsService{
|
||||
Clipboard: a.clip,
|
||||
Notifier: &runtimeNotifier{ctx: a.ctx},
|
||||
}
|
||||
return svc.SaveImage(a.ctx, pngBytes, dir)
|
||||
a.emitOperationStatus("save", "保存中", "正在保存截图到本地", "running")
|
||||
path, err := svc.SaveImage(a.ctx, pngBytes, dir)
|
||||
if err != nil {
|
||||
a.emitOperationStatus("save", "保存失败", err.Error(), "error")
|
||||
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||
return "", err
|
||||
}
|
||||
a.emitOperationStatus("save", "保存完成", "文件路径已复制到剪贴板:"+path, "success")
|
||||
wruntime.EventsEmit(a.ctx, "upload:success", path)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// runSaveRemotePipeline uploads the captured PNG to the configured SSH host
|
||||
@@ -387,12 +402,6 @@ func (a *App) runSummaryPipeline(pngBytes []byte) error {
|
||||
cfg := a.cfg
|
||||
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() {
|
||||
err := fmt.Errorf("请先在 LLM 配置页填写 API Key 和模型")
|
||||
a.emitOperationStatus("summary", "需要配置 LLM", err.Error(), "error")
|
||||
@@ -400,28 +409,52 @@ func (a *App) runSummaryPipeline(pngBytes []byte) error {
|
||||
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)
|
||||
if err != nil {
|
||||
a.emitOperationStatus("summary", "S3 配置错误", err.Error(), "error")
|
||||
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||
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{
|
||||
Provider: s3Provider,
|
||||
Summarizer: visionClient,
|
||||
Clipboard: a.clip,
|
||||
Prompt: cfg.LLM.Prompt,
|
||||
PathPrefix: cfg.S3.PathPrefix,
|
||||
}
|
||||
svc.Provider = s3Provider
|
||||
|
||||
a.emitOperationStatus("summary", "上传中", "正在上传截图供模型读取", "running")
|
||||
a.emitOperationStatus("summary", "上传中", "截图较大,正在上传供模型读取", "running")
|
||||
uploaded, err := svc.UploadImage(a.ctx, pngBytes)
|
||||
if err != nil {
|
||||
a.emitOperationStatus("summary", "上传失败", err.Error(), "error")
|
||||
@@ -429,6 +462,20 @@ func (a *App) runSummaryPipeline(pngBytes []byte) error {
|
||||
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")
|
||||
summary, err := svc.Summarize(a.ctx, uploaded.URL)
|
||||
if err != nil {
|
||||
@@ -436,12 +483,17 @@ func (a *App) runSummaryPipeline(pngBytes []byte) error {
|
||||
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||
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 {
|
||||
a.emitOperationStatus("summary", "复制失败", err.Error(), "error")
|
||||
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
a.emitOperationStatus("summary", "识别完成", "总结已复制到剪贴板", "success")
|
||||
wruntime.EventsEmit(a.ctx, "upload:success", "summary copied to clipboard")
|
||||
return nil
|
||||
@@ -902,6 +954,7 @@ func (a *App) CancelRegion() {
|
||||
a.pendingMu.Unlock()
|
||||
a.capturing.Store(false)
|
||||
a.dismissOverlay()
|
||||
a.emitOperationStatus("capture", "已取消", "本次截图已取消", "success")
|
||||
}
|
||||
|
||||
// CancelNativeRegion releases native-overlay state without touching the
|
||||
@@ -912,6 +965,7 @@ func (a *App) CancelNativeRegion() {
|
||||
a.pendingMu.Unlock()
|
||||
a.capturing.Store(false)
|
||||
hideDockIcon()
|
||||
a.emitOperationStatus("capture", "已取消", "本次截图已取消", "success")
|
||||
}
|
||||
|
||||
// ShowWindow brings the main window back to the foreground in its normal
|
||||
|
||||
@@ -61,6 +61,7 @@ export namespace domain {
|
||||
maxTokens: number;
|
||||
temperature: number;
|
||||
timeoutSecs: number;
|
||||
maxInlineBytes: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new LLMProviderConfig(source);
|
||||
@@ -75,6 +76,7 @@ export namespace domain {
|
||||
this.maxTokens = source["maxTokens"];
|
||||
this.temperature = source["temperature"];
|
||||
this.timeoutSecs = source["timeoutSecs"];
|
||||
this.maxInlineBytes = source["maxInlineBytes"];
|
||||
}
|
||||
}
|
||||
export class LLMConfig {
|
||||
|
||||
@@ -61,6 +61,14 @@ func (s *CaptureActionsService) SaveImage(_ context.Context, pngBytes []byte, di
|
||||
s.notifyFailure("save failed: " + err.Error())
|
||||
return "", err
|
||||
}
|
||||
// Copy the saved path to the clipboard so the user can paste it right
|
||||
// away, mirroring how the upload/summary flows copy their result. A
|
||||
// clipboard failure must not fail the save itself.
|
||||
if s.Clipboard != nil {
|
||||
if err := s.Clipboard.WriteText(path); err != nil {
|
||||
s.notifyFailure("clipboard write failed: " + err.Error())
|
||||
}
|
||||
}
|
||||
if s.Notifier != nil {
|
||||
s.Notifier.NotifySuccess(path)
|
||||
}
|
||||
|
||||
@@ -2,32 +2,65 @@ 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 a public
|
||||
// image URL and returning a textual summary.
|
||||
// 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)
|
||||
}
|
||||
|
||||
// CaptureSummaryService wires screenshot bytes -> S3 public URL -> LLM
|
||||
// summary -> clipboard. It intentionally does not notify UI state itself:
|
||||
// the caller emits fine-grained "uploading / recognizing / done" progress.
|
||||
// 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.
|
||||
// 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")
|
||||
@@ -49,7 +82,60 @@ func (s *CaptureSummaryService) UploadImage(ctx context.Context, pngBytes []byte
|
||||
}, 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) {
|
||||
if s.Summarizer == nil {
|
||||
return "", fmt.Errorf("llm is not configured")
|
||||
|
||||
@@ -2,6 +2,7 @@ package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -10,6 +11,7 @@ type fakeOSSProvider struct {
|
||||
data []byte
|
||||
contentType string
|
||||
url string
|
||||
deletedKey string
|
||||
}
|
||||
|
||||
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) Delete(_ context.Context, key string) error {
|
||||
f.deletedKey = key
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeSummarizer struct {
|
||||
prompt string
|
||||
url string
|
||||
@@ -84,3 +91,50 @@ func TestCaptureSummaryServiceUploadsSummarizesAndCopies(t *testing.T) {
|
||||
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
@@ -86,8 +86,20 @@ type LLMProviderConfig struct {
|
||||
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"`
|
||||
@@ -192,28 +204,31 @@ func DefaultLLMConfig() LLMConfig {
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
Label: "ChatGPT / OpenAI-compatible",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "gpt-5.5",
|
||||
MaxTokens: 600,
|
||||
Temperature: 0.2,
|
||||
TimeoutSecs: 60,
|
||||
MaxInlineBytes: DefaultMaxInlineBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -275,6 +290,9 @@ func (c *AppConfig) Normalize() {
|
||||
if current.TimeoutSecs == 0 {
|
||||
current.TimeoutSecs = def.TimeoutSecs
|
||||
}
|
||||
if current.MaxInlineBytes <= 0 {
|
||||
current.MaxInlineBytes = DefaultMaxInlineBytes
|
||||
}
|
||||
c.LLM.Providers[id] = current
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func parseSpec(spec string) ([]hk.Modifier, hk.Key, error) {
|
||||
case "option", "alt":
|
||||
mods = append(mods, modOption())
|
||||
case "shift":
|
||||
mods = append(mods, hk.ModShift)
|
||||
mods = append(mods, modShift())
|
||||
default:
|
||||
k, ok := lookupKey(p)
|
||||
if !ok {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
//go:build darwin
|
||||
//go:build darwin && cgo
|
||||
|
||||
package hotkey
|
||||
|
||||
import hk "golang.design/x/hotkey"
|
||||
|
||||
// macOS uses ⌘ as the primary modifier; Option corresponds to Alt.
|
||||
//
|
||||
// This file is gated on cgo because golang.design/x/hotkey only exposes the
|
||||
// Mod* constants from its cgo-backed darwin implementation; with CGO_ENABLED=0
|
||||
// the package falls back to a stub that defines no constants.
|
||||
func modCmd() hk.Modifier { return hk.ModCmd }
|
||||
func modCtrl() hk.Modifier { return hk.ModCtrl }
|
||||
func modOption() hk.Modifier { return hk.ModOption }
|
||||
func modShift() hk.Modifier { return hk.ModShift }
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build linux && cgo
|
||||
|
||||
package hotkey
|
||||
|
||||
import hk "golang.design/x/hotkey"
|
||||
|
||||
// On Linux we map "cmd" to Ctrl so the same config string ("cmd+shift+a")
|
||||
// stays meaningful across platforms.
|
||||
//
|
||||
// The X11 backend exposes modifiers as Mod1..Mod5 rather than named Alt/Option
|
||||
// constants; Mod1 is the conventional Alt mask under X11, so "option"/"alt"
|
||||
// resolve to hk.Mod1. This file is gated on cgo because the constants only
|
||||
// exist in the cgo-backed linux implementation (CGO_ENABLED=0 falls back to a
|
||||
// stub with no constants).
|
||||
func modCmd() hk.Modifier { return hk.ModCtrl }
|
||||
func modCtrl() hk.Modifier { return hk.ModCtrl }
|
||||
func modOption() hk.Modifier { return hk.Mod1 }
|
||||
func modShift() hk.Modifier { return hk.ModShift }
|
||||
@@ -1,11 +0,0 @@
|
||||
//go:build !darwin
|
||||
|
||||
package hotkey
|
||||
|
||||
import hk "golang.design/x/hotkey"
|
||||
|
||||
// On Windows / Linux we map "cmd" to Ctrl so the same config string works
|
||||
// across platforms; "option" is treated as Alt.
|
||||
func modCmd() hk.Modifier { return hk.ModCtrl }
|
||||
func modCtrl() hk.Modifier { return hk.ModCtrl }
|
||||
func modOption() hk.Modifier { return hk.ModAlt }
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build !windows && !cgo
|
||||
|
||||
package hotkey
|
||||
|
||||
import hk "golang.design/x/hotkey"
|
||||
|
||||
// This file covers the configuration where golang.design/x/hotkey compiles its
|
||||
// cgo-less stub (any non-Windows platform built with CGO_ENABLED=0). In that
|
||||
// stub the package defines the Modifier/Key types but no Mod*/Key* constants,
|
||||
// and Register panics at runtime. We still provide the package-internal
|
||||
// helpers so that snapgo keeps cross-compiling (e.g. `go vet`, editor analysis,
|
||||
// or CI cross-builds) without pulling in a C toolchain.
|
||||
//
|
||||
// The returned values are inert zero modifiers and lookupKey always reports
|
||||
// "unsupported": callers on this build cannot register a real global hotkey,
|
||||
// so surfacing an "unsupported token" parse error is preferable to reaching
|
||||
// the upstream panic.
|
||||
func modCmd() hk.Modifier { return 0 }
|
||||
func modCtrl() hk.Modifier { return 0 }
|
||||
func modOption() hk.Modifier { return 0 }
|
||||
func modShift() hk.Modifier { return 0 }
|
||||
|
||||
// lookupKey has no key constants to map against in the cgo-less stub, so it
|
||||
// always reports the token as unsupported.
|
||||
func lookupKey(string) (hk.Key, bool) { return 0, false }
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
|
||||
package hotkey
|
||||
|
||||
import hk "golang.design/x/hotkey"
|
||||
|
||||
// On Windows we map "cmd" to Ctrl so the same config string ("cmd+shift+a")
|
||||
// stays meaningful across platforms; "option" is treated as Alt. The Windows
|
||||
// backend does not require cgo, hence the single-tag guard.
|
||||
func modCmd() hk.Modifier { return hk.ModCtrl }
|
||||
func modCtrl() hk.Modifier { return hk.ModCtrl }
|
||||
func modOption() hk.Modifier { return hk.ModAlt }
|
||||
func modShift() hk.Modifier { return hk.ModShift }
|
||||
@@ -1,9 +1,15 @@
|
||||
//go:build windows || cgo
|
||||
|
||||
package hotkey
|
||||
|
||||
import hk "golang.design/x/hotkey"
|
||||
|
||||
// lookupKey maps a token from the user-supplied hotkey spec to a hk.Key.
|
||||
//
|
||||
// The Key* constants only exist in the platform backends that ship them
|
||||
// (windows, or the cgo-backed darwin/linux implementations); the cgo-less stub
|
||||
// in hotkey_unsupported.go provides a fallback lookupKey for the complement.
|
||||
//
|
||||
// The map is intentionally exhaustive over the alphanumeric set so we never
|
||||
// rely on an assumption that hk.KeyA..hk.KeyZ are contiguous values — the
|
||||
// upstream package gives no such guarantee.
|
||||
|
||||
@@ -101,6 +101,20 @@ func (p *S3Provider) BuildPublicURL(key string) string {
|
||||
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
|
||||
// bucket reachability. Used by the "Test connection" button in settings.
|
||||
func (p *S3Provider) TestConnection(ctx context.Context) error {
|
||||
|
||||
@@ -43,6 +43,9 @@ sleep 0.4
|
||||
# --identity flag in v2.
|
||||
ARCH="${ARCH:-arm64}"
|
||||
echo "[dev-build.sh] building darwin/${ARCH}..."
|
||||
# Allow the -Wl,-no_warn_duplicate_libraries flag declared in the darwin cgo
|
||||
# files past Go's cgo LDFLAGS allowlist (see activation_policy_darwin.go).
|
||||
export CGO_LDFLAGS_ALLOW='-Wl,-no_warn_duplicate_libraries'
|
||||
wails build -platform "darwin/${ARCH}" -clean
|
||||
|
||||
# 5. Re-sign with the stable dev cert. We invoke sign.sh through env var
|
||||
|
||||
+3
-1
@@ -35,7 +35,9 @@ echo "================================================================"
|
||||
# ---- 1. Build ----
|
||||
echo ""
|
||||
echo "[release.sh] (1/4) wails build"
|
||||
( cd "${ROOT_DIR}" && wails build -platform "${WAILS_PLATFORM}" -clean )
|
||||
# Allow the -Wl,-no_warn_duplicate_libraries flag declared in the darwin cgo
|
||||
# files past Go's cgo LDFLAGS allowlist (see activation_policy_darwin.go).
|
||||
( cd "${ROOT_DIR}" && CGO_LDFLAGS_ALLOW='-Wl,-no_warn_duplicate_libraries' wails build -platform "${WAILS_PLATFORM}" -clean )
|
||||
|
||||
# ---- 2. Sign .app ----
|
||||
echo ""
|
||||
|
||||
Reference in New Issue
Block a user