4 Commits

Author SHA1 Message Date
mamamiyear 71ca67c0ee refactor: change default size of settings panel 2026-07-07 01:12:18 +08:00
mamamiyear 777d5cb6c3 refactor: change dark theme color 2026-07-07 01:09:22 +08:00
mamamiyear 712a2cbb6a feat: you can summary screen shot directly 2026-07-07 00:59:26 +08:00
mamamiyear 39f0aaae02 refactor: merge mark and action toolbars to the right side
Place the mark toolbar to the left of the action toolbar on the same row
with an 8px gap, so the two button groups never overlap on tiny selections.
2026-06-14 14:30:43 +08:00
20 changed files with 1653 additions and 336 deletions
+175 -21
View File
@@ -22,6 +22,7 @@ import (
"github.com/mmmy/snapgo/internal/infrastructure/config" "github.com/mmmy/snapgo/internal/infrastructure/config"
"github.com/mmmy/snapgo/internal/infrastructure/display" "github.com/mmmy/snapgo/internal/infrastructure/display"
"github.com/mmmy/snapgo/internal/infrastructure/hotkey" "github.com/mmmy/snapgo/internal/infrastructure/hotkey"
llmpkg "github.com/mmmy/snapgo/internal/infrastructure/llm"
"github.com/mmmy/snapgo/internal/infrastructure/oss" "github.com/mmmy/snapgo/internal/infrastructure/oss"
"github.com/mmmy/snapgo/internal/infrastructure/screencapture" "github.com/mmmy/snapgo/internal/infrastructure/screencapture"
sshpkg "github.com/mmmy/snapgo/internal/infrastructure/ssh" sshpkg "github.com/mmmy/snapgo/internal/infrastructure/ssh"
@@ -96,6 +97,15 @@ type CaptureActionResult struct {
Path string `json:"path,omitempty"` Path string `json:"path,omitempty"`
} }
// OperationStatusPayload drives the frontend status HUD and mirrors the
// native macOS HUD state.
type OperationStatusPayload struct {
Operation string `json:"operation"`
Phase string `json:"phase"`
Message string `json:"message"`
State string `json:"state"`
}
// NewApp creates a new App with collaborators already initialised. // NewApp creates a new App with collaborators already initialised.
func NewApp() *App { func NewApp() *App {
store, err := config.NewFileStore() store, err := config.NewFileStore()
@@ -110,6 +120,7 @@ func NewApp() *App {
slog.Warn("config load failed, using defaults", "err", lerr) slog.Warn("config load failed, using defaults", "err", lerr)
} }
} }
cfg.Normalize()
return &App{ return &App{
cfg: cfg, cfg: cfg,
configFile: store, configFile: store,
@@ -227,8 +238,8 @@ func (a *App) runInteractiveCapture() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const ( const (
settingsWidth = 1080 settingsWidth = 1000
settingsHeight = 720 settingsHeight = 820
) )
// showOverlayWindow prepares the hidden main window as a borderless, // showOverlayWindow prepares the hidden main window as a borderless,
@@ -297,7 +308,13 @@ func (a *App) runUploadPipeline(provider domain.OSSProvider, pngBytes []byte) er
FallbackDir: filepath.Join(userPicturesDir(), "SnapGo"), FallbackDir: filepath.Join(userPicturesDir(), "SnapGo"),
PathPrefix: cfg.S3.PathPrefix, PathPrefix: cfg.S3.PathPrefix,
} }
return svc.ExecuteWithBytes(a.ctx, pngBytes) a.emitOperationStatus("upload", "上传中", "正在上传截图到 S3", "running")
if err := svc.ExecuteWithBytes(a.ctx, pngBytes); err != nil {
a.emitOperationStatus("upload", "上传失败", err.Error(), "error")
return err
}
a.emitOperationStatus("upload", "上传完成", "链接已复制到剪贴板", "success")
return nil
} }
func (a *App) runCopyImagePipeline(pngBytes []byte) error { func (a *App) runCopyImagePipeline(pngBytes []byte) error {
@@ -332,6 +349,7 @@ func (a *App) runSaveRemotePipeline(pngBytes []byte) error {
err := fmt.Errorf("SSH host/user is not configured") err := fmt.Errorf("SSH host/user is not configured")
slog.Warn("save-remote rejected: ssh not configured", slog.Warn("save-remote rejected: ssh not configured",
"host", cfg.SSH.Host, "user", cfg.SSH.User) "host", cfg.SSH.Host, "user", cfg.SSH.User)
a.emitOperationStatus("save-remote", "需要配置 SSH", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err return err
} }
@@ -340,16 +358,12 @@ func (a *App) runSaveRemotePipeline(pngBytes []byte) error {
"auth_method", cfg.SSH.AuthMethod, "png_size", len(pngBytes)) "auth_method", cfg.SSH.AuthMethod, "png_size", len(pngBytes))
// Select the uploader by auth method: Kerberos delegates to the system // Select the uploader by auth method: Kerberos delegates to the system
// ssh/scp binaries (reusing the kinit credential cache), bgo delegates to // ssh/scp binaries (reusing the kinit credential cache), while builtin
// the internal `bgo scp` client (under a PTY), while builtin uses the // uses the in-process Go SSH client.
// in-process Go SSH client.
var uploader application.SSHUploader var uploader application.SSHUploader
switch { if cfg.SSH.IsKerberos() {
case cfg.SSH.IsKerberos():
uploader = sshpkg.NewKerberosUploader(cfg.SSH) uploader = sshpkg.NewKerberosUploader(cfg.SSH)
case cfg.SSH.IsBgo(): } else {
uploader = sshpkg.NewBgoUploader(cfg.SSH)
default:
uploader = sshpkg.NewUploader(cfg.SSH) uploader = sshpkg.NewUploader(cfg.SSH)
} }
@@ -359,7 +373,78 @@ func (a *App) runSaveRemotePipeline(pngBytes []byte) error {
Notifier: &runtimeNotifier{ctx: a.ctx}, Notifier: &runtimeNotifier{ctx: a.ctx},
Cfg: cfg.SSH, Cfg: cfg.SSH,
} }
return svc.ExecuteWithBytes(a.ctx, pngBytes) a.emitOperationStatus("save-remote", "保存中", "正在保存截图到远端 SSH", "running")
if err := svc.ExecuteWithBytes(a.ctx, pngBytes); err != nil {
a.emitOperationStatus("save-remote", "保存失败", err.Error(), "error")
return err
}
a.emitOperationStatus("save-remote", "保存完成", "远端路径已复制到剪贴板", "success")
return nil
}
func (a *App) runSummaryPipeline(pngBytes []byte) error {
a.mu.RLock()
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")
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,
}
a.emitOperationStatus("summary", "上传中", "正在上传截图供模型读取", "running")
uploaded, err := svc.UploadImage(a.ctx, pngBytes)
if 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 {
a.emitOperationStatus("summary", "识别失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
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
} }
func (a *App) consumePendingCapture() (*pendingCapture, error) { func (a *App) consumePendingCapture() (*pendingCapture, error) {
@@ -412,6 +497,7 @@ func (a *App) GetConfig() domain.AppConfig {
// SaveConfig persists the supplied configuration and re-registers the hotkey // SaveConfig persists the supplied configuration and re-registers the hotkey
// if it changed. // if it changed.
func (a *App) SaveConfig(cfg domain.AppConfig) error { func (a *App) SaveConfig(cfg domain.AppConfig) error {
cfg.Normalize()
a.mu.Lock() a.mu.Lock()
prev := a.cfg prev := a.cfg
a.cfg = cfg a.cfg = cfg
@@ -471,15 +557,6 @@ func (a *App) TestSSHConnection(cfg domain.SSHConfig) error {
} }
return nil return nil
} }
// bgo manages its own auth; we can only confirm the binary is installed
// and resolvable (a real transfer would need a live remote + PTY).
if cfg.IsBgo() {
if err := sshpkg.TestBgoConnection(a.ctx, cfg); err != nil {
slog.Error("RPC TestSSHConnection (bgo) failed", "err", err)
return err
}
return nil
}
if err := sshpkg.TestConnection(a.ctx, cfg); err != nil { if err := sshpkg.TestConnection(a.ctx, cfg); err != nil {
slog.Error("RPC TestSSHConnection failed", "err", err) slog.Error("RPC TestSSHConnection failed", "err", err)
return err return err
@@ -718,6 +795,35 @@ func (a *App) SaveRegionToRemote(result CaptureResult) error {
return a.runSaveRemotePipeline(cropped) return a.runSaveRemotePipeline(cropped)
} }
// SummarizeRegion uploads the selected screenshot to S3, sends the public URL
// to the configured multimodal LLM, and copies the resulting summary.
func (a *App) SummarizeRegion(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
slog.Warn("SummarizeRegion: no pending capture", "err", err)
return err
}
defer func() {
a.capturing.Store(false)
a.dismissOverlay()
}()
a.dismissOverlay()
flushFrame()
slog.Info("SummarizeRegion: capturing region",
"x", result.Rect.X, "y", result.Rect.Y,
"w", result.Rect.W, "h", result.Rect.H,
"annotations", len(result.Annotations))
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
slog.Error("SummarizeRegion: capture failed", "err", err)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runSummaryPipeline(cropped)
}
// SaveNativeRegionToRemote is the macOS-native overlay equivalent of // SaveNativeRegionToRemote is the macOS-native overlay equivalent of
// SaveRegionToRemote. The AppKit panel is already closed by the time this // SaveRegionToRemote. The AppKit panel is already closed by the time this
// runs (see saveRemoteSelection in native_overlay_darwin.go), so we only // runs (see saveRemoteSelection in native_overlay_darwin.go), so we only
@@ -748,6 +854,33 @@ func (a *App) SaveNativeRegionToRemote(result CaptureResult) error {
return a.runSaveRemotePipeline(cropped) return a.runSaveRemotePipeline(cropped)
} }
// SummarizeNativeRegion is the macOS-native overlay equivalent of
// SummarizeRegion. The AppKit panel is already closed before this runs.
func (a *App) SummarizeNativeRegion(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
slog.Warn("SummarizeNativeRegion: no pending capture", "err", err)
return err
}
defer func() {
a.capturing.Store(false)
hideDockIcon()
}()
flushFrame()
slog.Info("SummarizeNativeRegion: capturing region",
"x", result.Rect.X, "y", result.Rect.Y,
"w", result.Rect.W, "h", result.Rect.H,
"annotations", len(result.Annotations))
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
slog.Error("SummarizeNativeRegion: capture failed", "err", err)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runSummaryPipeline(cropped)
}
func parseNativeAnnotations(raw string) []application.Annotation { func parseNativeAnnotations(raw string) []application.Annotation {
if raw == "" { if raw == "" {
return nil return nil
@@ -797,6 +930,27 @@ func (a *App) QuitApp() {
os.Exit(0) os.Exit(0)
} }
func (a *App) emitOperationStatus(operation, phase, message, state string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "operation:status", OperationStatusPayload{
Operation: operation,
Phase: phase,
Message: message,
State: state,
})
}
switch state {
case "success":
showOperationStatus(phase, message, operationStatusSuccess)
hideOperationStatusAfter(1400 * time.Millisecond)
case "error":
showOperationStatus(phase, message, operationStatusError)
hideOperationStatusAfter(2600 * time.Millisecond)
default:
showOperationStatus(phase, message, operationStatusRunning)
}
}
// runtimeNotifier emits success / failure events via the Wails runtime. // runtimeNotifier emits success / failure events via the Wails runtime.
type runtimeNotifier struct{ ctx context.Context } type runtimeNotifier struct{ ctx context.Context }
+208 -16
View File
@@ -3,9 +3,7 @@
* App shell — switches between two distinct UI modes that share the same * App shell — switches between two distinct UI modes that share the same
* Wails window: * Wails window:
* *
* • "settings" : full settings UI (self-drawn title bar + sidebar + form). * • "settings" : full settings UI (native title bar + sidebar + form).
* Self-drawn because the window is now Frameless to make
* the overlay paint edge-to-edge.
* • "overlay" : Snipaste-style region picker that fills the whole * • "overlay" : Snipaste-style region picker that fills the whole
* primary display. * primary display.
* *
@@ -13,7 +11,7 @@
* emits a `capture:overlay` event with the screenshot payload so the * emits a `capture:overlay` event with the screenshot payload so the
* frontend knows when (and what) to render. * frontend knows when (and what) to render.
*/ */
import { onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import SettingsView from './views/SettingsView.vue' import SettingsView from './views/SettingsView.vue'
import Toast from './components/Toast.vue' import Toast from './components/Toast.vue'
import CaptureOverlay from './views/CaptureOverlay.vue' import CaptureOverlay from './views/CaptureOverlay.vue'
@@ -24,7 +22,9 @@ import {
CopyRegionImage, CopyRegionImage,
SaveRegionImage, SaveRegionImage,
SaveRegionToRemote, SaveRegionToRemote,
SummarizeRegion,
CancelRegion, CancelRegion,
GetConfig,
} from '../wailsjs/go/main/App' } from '../wailsjs/go/main/App'
type HotkeyStatus = type HotkeyStatus =
@@ -36,10 +36,29 @@ const hotkeyStatus = ref<HotkeyStatus>({ state: 'unknown' })
type Mode = 'settings' | 'overlay' type Mode = 'settings' | 'overlay'
const mode = ref<Mode>('settings') const mode = ref<Mode>('settings')
type ThemeMode = 'auto' | 'light' | 'dark'
const themeMode = ref<ThemeMode>('auto')
const appThemeClass = computed(() => `theme-${themeMode.value}`)
function normalizeTheme(theme: unknown): ThemeMode {
return theme === 'light' || theme === 'dark' || theme === 'auto'
? theme
: 'auto'
}
async function loadThemePreference() {
try {
const cfg = await GetConfig()
themeMode.value = normalizeTheme((cfg as any)?.theme)
} catch {
themeMode.value = 'auto'
}
}
// `SettingsTab` is hoisted to the App shell so the sidebar (which lives // `SettingsTab` is hoisted to the App shell so the sidebar (which lives
// here) and the inner SettingsView (which renders the matching card) can // here) and the inner SettingsView (which renders the matching card) can
// share a single source of truth without an event bus. // share a single source of truth without an event bus.
type SettingsTab = 'general' | 's3' | 'ssh' type SettingsTab = 'general' | 's3' | 'ssh' | 'llm'
const activeTab = ref<SettingsTab>('general') const activeTab = ref<SettingsTab>('general')
// Sidebar entries are declarative so adding a destination type later is // Sidebar entries are declarative so adding a destination type later is
@@ -48,6 +67,7 @@ const sidebarItems: Array<{ id: SettingsTab; label: string }> = [
{ id: 'general', label: 'General' }, { id: 'general', label: 'General' },
{ id: 's3', label: 'S3' }, { id: 's3', label: 'S3' },
{ id: 'ssh', label: 'SSH' }, { id: 'ssh', label: 'SSH' },
{ id: 'llm', label: 'LLM' },
] ]
interface OverlayPayload { interface OverlayPayload {
@@ -62,6 +82,14 @@ const capturing = ref(false)
const toast = ref<{ kind: 'success' | 'error'; text: string } | null>(null) const toast = ref<{ kind: 'success' | 'error'; text: string } | null>(null)
let toastTimer: number | undefined let toastTimer: number | undefined
const operationStatus = ref<{
operation: string
phase: string
message: string
state: 'running' | 'success' | 'error'
} | null>(null)
let operationTimer: number | undefined
function showToast(kind: 'success' | 'error', text: string) { function showToast(kind: 'success' | 'error', text: string) {
toast.value = { kind, text } toast.value = { kind, text }
window.clearTimeout(toastTimer) window.clearTimeout(toastTimer)
@@ -70,6 +98,21 @@ function showToast(kind: 'success' | 'error', text: string) {
}, 3000) }, 3000)
} }
function updateOperationStatus(payload: {
operation: string
phase: string
message: string
state: 'running' | 'success' | 'error'
}) {
operationStatus.value = payload
window.clearTimeout(operationTimer)
if (payload.state !== 'running') {
operationTimer = window.setTimeout(() => {
operationStatus.value = null
}, payload.state === 'success' ? 1600 : 3200)
}
}
async function retryHotkey() { async function retryHotkey() {
try { try {
await RetryRegisterHotkey() await RetryRegisterHotkey()
@@ -168,6 +211,28 @@ async function onOverlaySaveRemote(rect: {
} }
} }
async function onOverlaySummarize(rect: {
rect: {
x: number
y: number
w: number
h: number
}
annotations: Array<{
tool: string
color: string
points: Array<{ x: number; y: number }>
}>
}) {
mode.value = 'settings'
overlayPayload.value = null
try {
await SummarizeRegion(rect as any)
} catch {
/* Surfaced via upload:failure */
}
}
async function onOverlayCancel() { async function onOverlayCancel() {
mode.value = 'settings' mode.value = 'settings'
overlayPayload.value = null overlayPayload.value = null
@@ -179,6 +244,7 @@ async function onOverlayCancel() {
} }
onMounted(() => { onMounted(() => {
void loadThemePreference()
EventsOn('capture:start', () => { EventsOn('capture:start', () => {
capturing.value = true capturing.value = true
}) })
@@ -194,7 +260,12 @@ onMounted(() => {
capturing.value = false capturing.value = false
}) })
EventsOn('upload:success', (url: string) => { EventsOn('upload:success', (url: string) => {
showToast('success', `Copied: ${url}`) showToast(
'success',
url === 'summary copied to clipboard'
? 'Summary copied to clipboard'
: `Copied: ${url}`
)
}) })
EventsOn('upload:failure', (reason: string) => { EventsOn('upload:failure', (reason: string) => {
showToast('error', reason) showToast('error', reason)
@@ -205,6 +276,7 @@ onMounted(() => {
EventsOn('hotkey:error', (reason: string) => { EventsOn('hotkey:error', (reason: string) => {
hotkeyStatus.value = { state: 'error', reason } hotkeyStatus.value = { state: 'error', reason }
}) })
EventsOn('operation:status', updateOperationStatus)
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -216,6 +288,7 @@ onUnmounted(() => {
EventsOff('upload:failure') EventsOff('upload:failure')
EventsOff('hotkey:ready') EventsOff('hotkey:ready')
EventsOff('hotkey:error') EventsOff('hotkey:error')
EventsOff('operation:status')
}) })
</script> </script>
@@ -229,11 +302,12 @@ onUnmounted(() => {
@copy="onOverlayCopy" @copy="onOverlayCopy"
@save="onOverlaySave" @save="onOverlaySave"
@save-remote="onOverlaySaveRemote" @save-remote="onOverlaySaveRemote"
@summarize="onOverlaySummarize"
@cancel="onOverlayCancel" @cancel="onOverlayCancel"
/> />
<!-- Settings mode: standard desktop window; macOS title bar is native. --> <!-- Settings mode: standard desktop window; macOS title bar is native. -->
<div v-else class="app-root"> <div v-else class="app-root" :class="appThemeClass">
<div v-if="hotkeyStatus.state === 'error'" class="permission-banner"> <div v-if="hotkeyStatus.state === 'error'" class="permission-banner">
<div> <div>
<strong>Global hotkey is not active.</strong> <strong>Global hotkey is not active.</strong>
@@ -259,11 +333,29 @@ onUnmounted(() => {
</div> </div>
</aside> </aside>
<section class="content"> <section class="content">
<SettingsView :tab="activeTab" /> <SettingsView
:tab="activeTab"
:theme="themeMode"
@theme-change="themeMode = normalizeTheme($event)"
/>
</section> </section>
</main> </main>
<Toast v-if="toast" :kind="toast.kind" :text="toast.text" /> <Toast v-if="toast" :kind="toast.kind" :text="toast.text" />
<div
v-if="operationStatus"
class="operation-hud"
:class="operationStatus.state"
>
<span v-if="operationStatus.state === 'running'" class="spinner" />
<span v-else class="status-mark">
{{ operationStatus.state === 'success' ? 'OK' : '!' }}
</span>
<div>
<strong>{{ operationStatus.phase }}</strong>
<p>{{ operationStatus.message }}</p>
</div>
</div>
</div> </div>
</template> </template>
@@ -277,6 +369,15 @@ onUnmounted(() => {
color: #111827; color: #111827;
font-size: 13px; font-size: 13px;
} }
.app-root.theme-light {
color-scheme: light;
}
.app-root.theme-dark {
color-scheme: dark;
}
.app-root.theme-auto {
color-scheme: light dark;
}
.status-pill { .status-pill {
font-size: 11px; font-size: 11px;
@@ -371,33 +472,124 @@ onUnmounted(() => {
min-width: 0; min-width: 0;
} }
.operation-hud {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 20;
display: flex;
align-items: center;
gap: 12px;
min-width: 260px;
max-width: 360px;
padding: 12px 14px;
color: #f9fafb;
background: rgba(17, 24, 39, 0.94);
border-radius: 8px;
box-shadow: 0 12px 34px rgba(15, 23, 42, 0.28);
}
.operation-hud strong {
display: block;
margin-bottom: 2px;
font-size: 13px;
}
.operation-hud p {
margin: 0;
color: #d1d5db;
font-size: 12px;
line-height: 1.35;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.25);
border-top-color: #60a5fa;
border-radius: 50%;
animation: spin 0.75s linear infinite;
flex: 0 0 auto;
}
.status-mark {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
flex: 0 0 auto;
}
.operation-hud.success .status-mark {
color: #052e16;
background: #86efac;
}
.operation-hud.error .status-mark {
color: #450a0a;
background: #fca5a5;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.app-root.theme-dark {
background: #1c1d22;
color: #e5e7eb;
}
.app-root.theme-dark .titlebar {
background: rgba(28, 29, 34, 0.7);
border-bottom-color: #2c2f36;
}
.app-root.theme-dark .titlebar-title {
color: #f3f4f6;
}
.app-root.theme-dark .sidebar {
background: rgba(0, 0, 0, 0.15);
border-right-color: #2c2f36;
}
.app-root.theme-dark .sidebar-item {
color: #d1d5db;
}
.app-root.theme-dark .sidebar-item:hover:not(.active) {
background: rgba(255, 255, 255, 0.05);
}
.app-root.theme-dark .sidebar-item.active {
background: rgba(59, 130, 246, 0.18);
color: #93c5fd;
}
.app-root.theme-dark .permission-banner {
background: rgba(120, 53, 15, 0.3);
border-color: rgba(253, 186, 116, 0.4);
color: #fed7aa;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.app-root { .app-root.theme-auto {
background: #1c1d22; background: #1c1d22;
color: #e5e7eb; color: #e5e7eb;
} }
.titlebar { .app-root.theme-auto .titlebar {
background: rgba(28, 29, 34, 0.7); background: rgba(28, 29, 34, 0.7);
border-bottom-color: #2c2f36; border-bottom-color: #2c2f36;
} }
.titlebar-title { .app-root.theme-auto .titlebar-title {
color: #f3f4f6; color: #f3f4f6;
} }
.sidebar { .app-root.theme-auto .sidebar {
background: rgba(0, 0, 0, 0.15); background: rgba(0, 0, 0, 0.15);
border-right-color: #2c2f36; border-right-color: #2c2f36;
} }
.sidebar-item { .app-root.theme-auto .sidebar-item {
color: #d1d5db; color: #d1d5db;
} }
.sidebar-item:hover:not(.active) { .app-root.theme-auto .sidebar-item:hover:not(.active) {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
} }
.sidebar-item.active { .app-root.theme-auto .sidebar-item.active {
background: rgba(59, 130, 246, 0.18); background: rgba(59, 130, 246, 0.18);
color: #93c5fd; color: #93c5fd;
} }
.permission-banner { .app-root.theme-auto .permission-banner {
background: rgba(120, 53, 15, 0.3); background: rgba(120, 53, 15, 0.3);
border-color: rgba(253, 186, 116, 0.4); border-color: rgba(253, 186, 116, 0.4);
color: #fed7aa; color: #fed7aa;
+49 -7
View File
@@ -51,6 +51,10 @@ const emit = defineEmits<{
e: 'save-remote', e: 'save-remote',
payload: { rect: Rect; annotations: Annotation[] } payload: { rect: Rect; annotations: Annotation[] }
): void ): void
(
e: 'summarize',
payload: { rect: Rect; annotations: Annotation[] }
): void
(e: 'cancel'): void (e: 'cancel'): void
}>() }>()
@@ -111,14 +115,24 @@ const sizeLabel = computed(() => {
return `${Math.round(rect.value.w)} × ${Math.round(rect.value.h)}` return `${Math.round(rect.value.w)} × ${Math.round(rect.value.h)}`
}) })
const MARK_TOOLBAR_W = 190
const ACTION_TOOLBAR_W = 216
const TOOLBAR_GROUP_GAP = 8
const rightToolbarPos = computed(() => { const rightToolbarPos = computed(() => {
if (!rect.value) return null if (!rect.value) return null
return placeToolbar(rect.value, 180, 40, 'right') return placeToolbar(rect.value, ACTION_TOOLBAR_W, 40, 'right')
}) })
const leftToolbarPos = computed(() => { const leftToolbarPos = computed(() => {
if (!rect.value) return null if (!rect.value || !rightToolbarPos.value) return null
return placeToolbar(rect.value, 190, 40, 'left') // 标记组与操作组合并到右侧,紧贴在操作组左侧,中间保留间隔
const x = clamp(
rightToolbarPos.value.x - TOOLBAR_GROUP_GAP - MARK_TOOLBAR_W,
0,
props.width - MARK_TOOLBAR_W
)
return { x, y: rightToolbarPos.value.y }
}) })
const handles = computed(() => { const handles = computed(() => {
@@ -353,7 +367,11 @@ function onSaveRemote() {
emitAction('save-remote') emitAction('save-remote')
} }
function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote') { function onSummarize() {
emitAction('summarize')
}
function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote' | 'summarize') {
if (!rect.value) return if (!rect.value) return
const payload = { const payload = {
rect: { ...rect.value }, rect: { ...rect.value },
@@ -363,6 +381,7 @@ function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote') {
if (action === 'copy') emit('copy', payload) if (action === 'copy') emit('copy', payload)
if (action === 'save') emit('save', payload) if (action === 'save') emit('save', payload)
if (action === 'save-remote') emit('save-remote', payload) if (action === 'save-remote') emit('save-remote', payload)
if (action === 'summarize') emit('summarize', payload)
} }
function onCancel() { function onCancel() {
@@ -583,8 +602,8 @@ onUnmounted(() => {
/> />
<button <button
class="action-btn" class="action-btn"
data-tip="复制图" data-tip="复制图"
aria-label="复制图" aria-label="复制图"
@click="onCopy" @click="onCopy"
v-html="copyIcon" v-html="copyIcon"
/> />
@@ -602,6 +621,19 @@ onUnmounted(() => {
@click="onSaveRemote" @click="onSaveRemote"
v-html="saveRemoteIcon" v-html="saveRemoteIcon"
/> />
<button
class="action-btn"
data-tip="复制总结"
aria-label="复制总结"
@click="onSummarize"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 3h8l4 4v14H6z" />
<path d="M14 3v5h4" />
<path d="M9 12h6" />
<path d="M9 16h5" />
</svg>
</button>
<button <button
class="action-btn primary" class="action-btn primary"
data-tip="上传云端" data-tip="上传云端"
@@ -696,7 +728,7 @@ onUnmounted(() => {
width: 190px; width: 190px;
} }
.action-toolbar { .action-toolbar {
width: 180px; width: 216px;
} }
.icon-btn, .icon-btn,
@@ -752,6 +784,16 @@ onUnmounted(() => {
fill: currentColor; fill: currentColor;
pointer-events: none; pointer-events: none;
} }
.action-btn > svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
}
.action-btn::after { .action-btn::after {
content: attr(data-tip); content: attr(data-tip);
position: absolute; position: absolute;
+323 -43
View File
@@ -8,10 +8,11 @@
* • "general" — the global hotkey / capture parameters. * • "general" — the global hotkey / capture parameters.
* • "s3" — S3-compatible object-storage credentials. * • "s3" — S3-compatible object-storage credentials.
* • "ssh" — SSH/SCP destination for the "save remote" button. * • "ssh" — SSH/SCP destination for the "save remote" button.
* • "llm" — multimodal screenshot summary provider settings.
* *
* State flow: load() pulls config from Go on mount, save() pushes back. * State flow: load() pulls config from Go on mount, save() pushes back.
*/ */
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { import {
GetConfig, GetConfig,
SaveConfig, SaveConfig,
@@ -22,10 +23,16 @@ import {
// Tab discriminator shared with the App shell. Kept as a string union so // Tab discriminator shared with the App shell. Kept as a string union so
// the parent can pass the value without importing a type from this view. // the parent can pass the value without importing a type from this view.
type TabId = 'general' | 's3' | 'ssh' type TabId = 'general' | 's3' | 'ssh' | 'llm'
type ThemeMode = 'auto' | 'light' | 'dark'
const props = defineProps<{ const props = defineProps<{
tab: TabId tab: TabId
theme: ThemeMode
}>()
const emit = defineEmits<{
(event: 'theme-change', theme: ThemeMode): void
}>() }>()
interface S3Config { interface S3Config {
@@ -51,12 +58,51 @@ interface SSHConfig {
connectTimeoutSecs: number connectTimeoutSecs: number
} }
interface LLMProviderConfig {
label: string
baseUrl: string
apiKey: string
model: string
maxTokens: number
temperature: number
timeoutSecs: number
}
interface LLMConfig {
activeProvider: string
prompt: string
providers: Record<string, LLMProviderConfig>
}
interface AppConfig { interface AppConfig {
hotkey: string hotkey: string
theme: ThemeMode
s3: S3Config s3: S3Config
ssh: SSHConfig ssh: SSHConfig
llm: LLMConfig
} }
const llmProviderOptions = [
{ id: 'qwen', label: '阿里千问多模态' },
{ id: 'doubao', label: '火山方舟豆包多模态' },
{ id: 'openai', label: 'ChatGPT / OpenAI-compatible' },
]
const themeOptions: Array<{ id: ThemeMode; label: string }> = [
{ id: 'light', label: '浅色' },
{ id: 'dark', label: '深色' },
{ id: 'auto', label: '自动' },
]
const DEFAULT_SUMMARY_PROMPT = `你是一个截图内容总结助手。请阅读截图,并用中文输出一段适合直接粘贴到聊天、工单、Issue 或 PR 中的说明。
要求:
- 先说明截图中最重要的信息和用户可能想表达的意图。
- 如果截图包含报错、异常状态、表格、代码、界面控件或关键数字,请准确提取。
- 如果截图内容不足以判断,不要编造;直接说明不确定点。
- 输出尽量简洁,默认 3 到 6 条要点。
- 不要输出 Markdown 标题,不要寒暄。`
// `defaultConfig` keeps the initial render in sync with the Go-side // `defaultConfig` keeps the initial render in sync with the Go-side
// DefaultAppConfig so the UI never flashes empty fields before load() // DefaultAppConfig so the UI never flashes empty fields before load()
// completes. Centralising the defaults also avoids subtle drift between // completes. Centralising the defaults also avoids subtle drift between
@@ -64,6 +110,7 @@ interface AppConfig {
function defaultConfig(): AppConfig { function defaultConfig(): AppConfig {
return { return {
hotkey: 'cmd+shift+a', hotkey: 'cmd+shift+a',
theme: 'auto',
s3: { s3: {
endpoint: '', endpoint: '',
region: 'us-east-1', region: 'us-east-1',
@@ -85,6 +132,39 @@ function defaultConfig(): AppConfig {
knownHostsPath: '', knownHostsPath: '',
connectTimeoutSecs: 10, connectTimeoutSecs: 10,
}, },
llm: {
activeProvider: 'openai',
prompt: DEFAULT_SUMMARY_PROMPT,
providers: {
qwen: {
label: '阿里千问多模态',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
apiKey: '',
model: 'qwen-vl-plus',
maxTokens: 600,
temperature: 0.2,
timeoutSecs: 60,
},
doubao: {
label: '火山方舟豆包多模态',
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
apiKey: '',
model: '',
maxTokens: 600,
temperature: 0.2,
timeoutSecs: 60,
},
openai: {
label: 'ChatGPT / OpenAI-compatible',
baseUrl: 'https://api.openai.com/v1',
apiKey: '',
model: 'gpt-5.5',
maxTokens: 600,
temperature: 0.2,
timeoutSecs: 60,
},
},
},
} }
} }
@@ -109,6 +189,21 @@ const sshPathDisplay = computed({
}, },
}) })
const activeLLMProvider = computed(() => {
const id = config.value.llm.activeProvider || 'openai'
if (!config.value.llm.providers[id]) {
config.value.llm.activeProvider = 'openai'
return config.value.llm.providers.openai
}
return config.value.llm.providers[id]
})
function normalizeTheme(theme: unknown): ThemeMode {
return theme === 'light' || theme === 'dark' || theme === 'auto'
? theme
: 'auto'
}
function flash(kind: 'ok' | 'err', text: string) { function flash(kind: 'ok' | 'err', text: string) {
message.value = { kind, text } message.value = { kind, text }
window.setTimeout(() => { window.setTimeout(() => {
@@ -125,7 +220,14 @@ async function load() {
Object.assign(merged, cfg) Object.assign(merged, cfg)
merged.s3 = { ...merged.s3, ...(cfg as any).s3 } merged.s3 = { ...merged.s3, ...(cfg as any).s3 }
merged.ssh = { ...merged.ssh, ...(cfg as any).ssh } merged.ssh = { ...merged.ssh, ...(cfg as any).ssh }
merged.llm = { ...merged.llm, ...(cfg as any).llm }
merged.llm.providers = {
...defaultConfig().llm.providers,
...((cfg as any).llm?.providers ?? {}),
}
merged.theme = normalizeTheme((cfg as any).theme)
config.value = merged config.value = merged
emit('theme-change', config.value.theme)
} }
} }
@@ -169,11 +271,16 @@ async function manualCapture() {
await CaptureNow() await CaptureNow()
} }
watch(
() => config.value.theme,
(theme) => emit('theme-change', normalizeTheme(theme))
)
onMounted(load) onMounted(load)
</script> </script>
<template> <template>
<div class="settings"> <div class="settings" :class="`theme-${props.theme}`">
<header class="hero"> <header class="hero">
<div> <div>
<h1>SnapGo</h1> <h1>SnapGo</h1>
@@ -185,17 +292,31 @@ onMounted(load)
<button class="btn primary" @click="manualCapture">Capture now</button> <button class="btn primary" @click="manualCapture">Capture now</button>
</header> </header>
<!-- General tab only the shortcut configuration lives here. --> <!-- General tab shortcut and appearance configuration. -->
<section v-if="props.tab === 'general'" class="card"> <section v-if="props.tab === 'general'" class="card">
<h2>Shortcut</h2> <h2>General</h2>
<label class="field"> <div class="grid">
<span>Global hotkey</span> <label class="field">
<input <span>Global hotkey</span>
v-model="config.hotkey" <input
placeholder="cmd+shift+a" v-model="config.hotkey"
spellcheck="false" placeholder="cmd+shift+a"
/> spellcheck="false"
</label> />
</label>
<label class="field">
<span>主题选择</span>
<select v-model="config.theme">
<option
v-for="themeOption in themeOptions"
:key="themeOption.id"
:value="themeOption.id"
>
{{ themeOption.label }}
</option>
</select>
</label>
</div>
<p class="hint"> <p class="hint">
Tokens (case-insensitive, "+"-separated): cmd / ctrl / option / shift + Tokens (case-insensitive, "+"-separated): cmd / ctrl / option / shift +
az or 09. Example: <code>cmd+shift+a</code>. az or 09. Example: <code>cmd+shift+a</code>.
@@ -289,7 +410,6 @@ onMounted(load)
<option value="password">Password</option> <option value="password">Password</option>
<option value="key">SSH-Key</option> <option value="key">SSH-Key</option>
<option value="kerberos">Kerberos</option> <option value="kerberos">Kerberos</option>
<option value="bgo">bgo</option>
</select> </select>
</label> </label>
<label class="field"> <label class="field">
@@ -351,18 +471,6 @@ onMounted(load)
keys. Make sure password-less login already works (e.g. via keys. Make sure password-less login already works (e.g. via
<code>ssh-copy-id</code>). <code>ssh-copy-id</code>).
</p> </p>
<p v-else-if="config.ssh.authMethod === 'bgo'" class="hint">
bgo mode delegates uploads to the internal <code>bgo scp</code> client,
which handles authentication itself. Install bgo and add it to your
<code>PATH</code> first see the
<a
href="https://bytedance.larkoffice.com/wiki/HpUCw7qRDiW66YkzKdwcXYrTnLf"
target="_blank"
rel="noopener"
>setup guide</a>.
Note: bgo cannot create remote directories, so screenshots are saved
flat into the remote home directory (the Remote path setting is ignored).
</p>
<p <p
v-else-if="config.ssh.authMethod === 'password' && !config.ssh.password" v-else-if="config.ssh.authMethod === 'password' && !config.ssh.password"
class="hint warn" class="hint warn"
@@ -381,6 +489,103 @@ onMounted(load)
</div> </div>
</section> </section>
<!-- LLM tab multimodal screenshot summary provider settings. -->
<section v-if="props.tab === 'llm'" class="card">
<h2>LLM screenshot summary</h2>
<div class="grid">
<label class="field full">
<span>Provider</span>
<select v-model="config.llm.activeProvider">
<option
v-for="provider in llmProviderOptions"
:key="provider.id"
:value="provider.id"
>
{{ provider.label }}
</option>
</select>
</label>
<label class="field full">
<span>Base URL *</span>
<input
v-model="activeLLMProvider.baseUrl"
placeholder="https://api.openai.com/v1"
spellcheck="false"
/>
</label>
<label class="field">
<span>API Key *</span>
<input v-model="activeLLMProvider.apiKey" type="password" />
</label>
<label class="field">
<span>Model *</span>
<input
v-model="activeLLMProvider.model"
placeholder="gpt-5.5 / qwen-vl-plus / Ark endpoint ID"
spellcheck="false"
/>
</label>
<label class="field">
<span>Max tokens</span>
<input
v-model.number="activeLLMProvider.maxTokens"
type="number"
min="64"
max="4096"
placeholder="600"
/>
</label>
<label class="field">
<span>Temperature</span>
<input
v-model.number="activeLLMProvider.temperature"
type="number"
min="0"
max="2"
step="0.1"
placeholder="0.2"
/>
</label>
<label class="field">
<span>Timeout (sec)</span>
<input
v-model.number="activeLLMProvider.timeoutSecs"
type="number"
min="10"
max="300"
placeholder="60"
/>
</label>
<label class="field full">
<span>Prompt</span>
<textarea
v-model="config.llm.prompt"
class="prompt-textarea"
spellcheck="false"
/>
</label>
</div>
<p class="hint">
The "copy summary" screenshot action first uploads the image through the
S3 configuration, then sends the public image URL to this multimodal
provider. Keep S3 configured before using it.
</p>
<p v-if="config.llm.activeProvider === 'doubao'" class="hint">
Volcengine Ark usually uses the inference endpoint ID as the model
value; paste the endpoint/model identifier from your Ark console.
</p>
<p v-else-if="config.llm.activeProvider === 'qwen'" class="hint">
DashScope OpenAI-compatible mode is used here. If your workspace uses a
regional endpoint, replace Base URL with that compatible-mode URL.
</p>
<div class="actions">
<button class="btn primary" :disabled="saving" @click="save">
{{ saving ? 'Saving' : 'Save' }}
</button>
</div>
</section>
<transition name="fade"> <transition name="fade">
<div <div
v-if="message" v-if="message"
@@ -400,6 +605,15 @@ onMounted(load)
padding: 28px 32px 60px; padding: 28px 32px 60px;
color: #1f2937; color: #1f2937;
} }
.settings.theme-light {
color-scheme: light;
}
.settings.theme-dark {
color-scheme: dark;
}
.settings.theme-auto {
color-scheme: light dark;
}
.hero { .hero {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -473,13 +687,13 @@ kbd {
.field input[type='text'], .field input[type='text'],
.field input[type='number'], .field input[type='number'],
.field input:not([type='checkbox']), .field input:not([type='checkbox']),
.field select { .field select,
.field textarea {
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 6px; border-radius: 6px;
padding: 7px 10px; padding: 7px 10px;
font-size: 13px; font-size: 13px;
line-height: 1.4; line-height: 1.4;
height: 36px;
background: #fff; background: #fff;
color: inherit; color: inherit;
outline: none; outline: none;
@@ -487,8 +701,20 @@ kbd {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
.field input[type='text'],
.field input[type='number'],
.field input:not([type='checkbox']),
.field select {
height: 36px;
}
.field textarea {
min-height: 168px;
resize: vertical;
font-family: inherit;
}
.field input:focus, .field input:focus,
.field select:focus { .field select:focus,
.field textarea:focus {
border-color: #3b82f6; border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18);
} }
@@ -603,46 +829,100 @@ kbd {
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
.settings.theme-dark {
color: #e5e7eb;
}
.settings.theme-dark .card {
background: #1f2937;
border-color: #374151;
}
.settings.theme-dark .field span {
color: #d1d5db;
}
.settings.theme-dark .field input[type='text'],
.settings.theme-dark .field input[type='number'],
.settings.theme-dark .field input:not([type='checkbox']),
.settings.theme-dark .field select,
.settings.theme-dark .field textarea {
background: #111827;
border-color: #374151;
color: #fff;
}
.settings.theme-dark .field input::placeholder,
.settings.theme-dark .field textarea::placeholder {
color: #9ca3af;
}
.settings.theme-dark .field input:disabled {
background: #1f2937;
color: #6b7280;
}
.settings.theme-dark .prefixed-input {
background: #111827;
border-color: #374151;
}
.settings.theme-dark .input-prefix {
background: #1f2937;
border-right-color: #374151;
color: #d1d5db;
}
.settings.theme-dark .btn:not(.primary) {
background: #1f2937;
border-color: #374151;
color: #e5e7eb;
}
.settings.theme-dark .btn:not(.primary):hover:not(:disabled) {
background: #374151;
}
.settings.theme-dark .subtitle {
color: #9ca3af;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.settings { .settings.theme-auto {
color: #e5e7eb; color: #e5e7eb;
} }
.card { .settings.theme-auto .card {
background: #1f2937; background: #1f2937;
border-color: #374151; border-color: #374151;
} }
.field input { .settings.theme-auto .field span {
color: #d1d5db;
}
.settings.theme-auto .field input[type='text'],
.settings.theme-auto .field input[type='number'],
.settings.theme-auto .field input:not([type='checkbox']),
.settings.theme-auto .field select,
.settings.theme-auto .field textarea {
background: #111827; background: #111827;
border-color: #374151; border-color: #374151;
color: #e5e7eb; color: #fff;
} }
.field select { .settings.theme-auto .field input::placeholder,
background: #111827; .settings.theme-auto .field textarea::placeholder {
border-color: #374151; color: #9ca3af;
color: #e5e7eb;
} }
.field input:disabled { .settings.theme-auto .field input:disabled {
background: #1f2937; background: #1f2937;
color: #6b7280; color: #6b7280;
} }
.prefixed-input { .settings.theme-auto .prefixed-input {
background: #111827; background: #111827;
border-color: #374151; border-color: #374151;
} }
.input-prefix { .settings.theme-auto .input-prefix {
background: #1f2937; background: #1f2937;
border-right-color: #374151; border-right-color: #374151;
color: #d1d5db; color: #d1d5db;
} }
.btn { .settings.theme-auto .btn:not(.primary) {
background: #1f2937; background: #1f2937;
border-color: #374151; border-color: #374151;
color: #e5e7eb; color: #e5e7eb;
} }
.btn:hover:not(:disabled) { .settings.theme-auto .btn:not(.primary):hover:not(:disabled) {
background: #374151; background: #374151;
} }
.subtitle { .settings.theme-auto .subtitle {
color: #9ca3af; color: #9ca3af;
} }
} }
+4
View File
@@ -37,6 +37,10 @@ export function SaveRegionToRemote(arg1:main.CaptureResult):Promise<void>;
export function ShowWindow():Promise<void>; export function ShowWindow():Promise<void>;
export function SummarizeNativeRegion(arg1:main.CaptureResult):Promise<void>;
export function SummarizeRegion(arg1:main.CaptureResult):Promise<void>;
export function TestConnection(arg1:domain.S3Config):Promise<void>; export function TestConnection(arg1:domain.S3Config):Promise<void>;
export function TestSSHConnection(arg1:domain.SSHConfig):Promise<void>; export function TestSSHConnection(arg1:domain.SSHConfig):Promise<void>;
+8
View File
@@ -70,6 +70,14 @@ export function ShowWindow() {
return window['go']['main']['App']['ShowWindow'](); return window['go']['main']['App']['ShowWindow']();
} }
export function SummarizeNativeRegion(arg1) {
return window['go']['main']['App']['SummarizeNativeRegion'](arg1);
}
export function SummarizeRegion(arg1) {
return window['go']['main']['App']['SummarizeRegion'](arg1);
}
export function TestConnection(arg1) { export function TestConnection(arg1) {
return window['go']['main']['App']['TestConnection'](arg1); return window['go']['main']['App']['TestConnection'](arg1);
} }
+64
View File
@@ -53,6 +53,64 @@ export namespace application {
export namespace domain { export namespace domain {
export class LLMProviderConfig {
label: string;
baseUrl: string;
apiKey: string;
model: string;
maxTokens: number;
temperature: number;
timeoutSecs: number;
static createFrom(source: any = {}) {
return new LLMProviderConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.label = source["label"];
this.baseUrl = source["baseUrl"];
this.apiKey = source["apiKey"];
this.model = source["model"];
this.maxTokens = source["maxTokens"];
this.temperature = source["temperature"];
this.timeoutSecs = source["timeoutSecs"];
}
}
export class LLMConfig {
activeProvider: string;
prompt: string;
providers: Record<string, LLMProviderConfig>;
static createFrom(source: any = {}) {
return new LLMConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.activeProvider = source["activeProvider"];
this.prompt = source["prompt"];
this.providers = this.convertValues(source["providers"], LLMProviderConfig, true);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SSHConfig { export class SSHConfig {
host: string; host: string;
port: number; port: number;
@@ -109,8 +167,10 @@ export namespace domain {
} }
export class AppConfig { export class AppConfig {
hotkey: string; hotkey: string;
theme: string;
s3: S3Config; s3: S3Config;
ssh: SSHConfig; ssh: SSHConfig;
llm: LLMConfig;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new AppConfig(source); return new AppConfig(source);
@@ -119,8 +179,10 @@ export namespace domain {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.hotkey = source["hotkey"]; this.hotkey = source["hotkey"];
this.theme = source["theme"];
this.s3 = this.convertValues(source["s3"], S3Config); this.s3 = this.convertValues(source["s3"], S3Config);
this.ssh = this.convertValues(source["ssh"], SSHConfig); this.ssh = this.convertValues(source["ssh"], SSHConfig);
this.llm = this.convertValues(source["llm"], LLMConfig);
} }
convertValues(a: any, classs: any, asMap: boolean = false): any { convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -143,6 +205,8 @@ export namespace domain {
} }
} }
export namespace main { export namespace main {
-17
View File
@@ -69,12 +69,6 @@ func (s *CaptureAndSSHService) ExecuteWithBytes(ctx context.Context, pngBytes []
return fmt.Errorf("empty screenshot") return fmt.Errorf("empty screenshot")
} }
relPath := buildRemoteRelPath(s.Cfg.PathPrefix) relPath := buildRemoteRelPath(s.Cfg.PathPrefix)
// bgo cannot create remote directories, so it uploads flat into the
// remote $HOME with a timestamped filename rather than a date-grouped
// subdirectory tree (see BgoUploader for the rationale).
if s.Cfg.IsBgo() {
relPath = buildRemoteFlatName()
}
sshSvcLog().Info("save-remote pipeline start", sshSvcLog().Info("save-remote pipeline start",
"host", s.Cfg.Host, "user", s.Cfg.User, "port", s.Cfg.Port, "host", s.Cfg.Host, "user", s.Cfg.User, "port", s.Cfg.Port,
"size", len(pngBytes), "remote_path", relPath, "size", len(pngBytes), "remote_path", relPath,
@@ -134,17 +128,6 @@ func buildRemoteRelPath(prefix string) string {
) )
} }
// buildRemoteFlatName produces a flat, timestamped filename (no directory
// components) for destinations that cannot create remote directories, such
// as the bgo client. The file lands directly in the remote $HOME.
func buildRemoteFlatName() string {
now := time.Now()
return fmt.Sprintf("%s-%s.png",
now.Format("20060102-150405"),
randomSuffix(6),
)
}
func (s *CaptureAndSSHService) notifyFailure(reason string) { func (s *CaptureAndSSHService) notifyFailure(reason string) {
if s.Notifier != nil { if s.Notifier != nil {
s.Notifier.NotifyFailure(reason) s.Notifier.NotifyFailure(reason)
+72
View File
@@ -0,0 +1,72 @@
package application
import (
"context"
"fmt"
"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.
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.
type CaptureSummaryService struct {
Provider domain.OSSProvider
Summarizer VisionSummarizer
Clipboard clipboard.Writer
Prompt string
PathPrefix string
}
// UploadImage uploads the screenshot and returns the public URL visible to
// the multimodal provider.
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
}
// Summarize asks the configured model to summarize the uploaded image 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
}
@@ -0,0 +1,86 @@
package application
import (
"context"
"testing"
)
type fakeOSSProvider struct {
key string
data []byte
contentType string
url string
}
func (f *fakeOSSProvider) Upload(_ context.Context, key string, data []byte, contentType string) (string, error) {
f.key = key
f.data = append([]byte(nil), data...)
f.contentType = contentType
if f.url == "" {
return "https://cdn.example.com/" + key, nil
}
return f.url, nil
}
func (f *fakeOSSProvider) Name() string { return "fake" }
type fakeSummarizer struct {
prompt string
url string
text string
}
func (f *fakeSummarizer) SummarizeImage(_ context.Context, prompt string, imageURL string) (string, error) {
f.prompt = prompt
f.url = imageURL
if f.text == "" {
return "summary text", nil
}
return f.text, nil
}
func TestCaptureSummaryServiceUploadsSummarizesAndCopies(t *testing.T) {
ctx := context.Background()
provider := &fakeOSSProvider{}
summarizer := &fakeSummarizer{}
clip := &fakeClipboard{}
svc := &CaptureSummaryService{
Provider: provider,
Summarizer: summarizer,
Clipboard: clip,
Prompt: "describe screenshot",
PathPrefix: "snapgo/",
}
uploaded, err := svc.UploadImage(ctx, []byte("png"))
if err != nil {
t.Fatalf("upload image: %v", err)
}
if provider.contentType != "image/png" {
t.Fatalf("expected image/png content type, got %q", provider.contentType)
}
if string(provider.data) != "png" {
t.Fatalf("expected uploaded png bytes, got %q", string(provider.data))
}
summary, err := svc.Summarize(ctx, uploaded.URL)
if err != nil {
t.Fatalf("summarize: %v", err)
}
if summarizer.prompt != "describe screenshot" {
t.Fatalf("expected prompt forwarded, got %q", summarizer.prompt)
}
if summarizer.url != uploaded.URL {
t.Fatalf("expected uploaded url forwarded, got %q", summarizer.url)
}
if summary != "summary text" {
t.Fatalf("expected summary text, got %q", summary)
}
if err := svc.CopySummary(ctx, summary); err != nil {
t.Fatalf("copy summary: %v", err)
}
if clip.text != "summary text" {
t.Fatalf("expected summary copied, got %q", clip.text)
}
}
+171 -15
View File
@@ -42,11 +42,6 @@ type S3Config struct {
// `kinit` is reused. Native GSSAPI is required here // `kinit` is reused. Native GSSAPI is required here
// because macOS stores tickets in an API: ccache // because macOS stores tickets in an API: ccache
// that pure-Go SSH libraries cannot read. // that pure-Go SSH libraries cannot read.
// "bgo" → delegate to the internal `bgo scp` client which
// handles its own auth/credential management. bgo
// must run under a PTY and cannot create remote
// directories, so this mode uploads to the user's
// remote $HOME with a flat, timestamped filename.
// "" / "builtin" → legacy combined behaviour (password → agent → key // "" / "builtin" → legacy combined behaviour (password → agent → key
// files) kept only for backward compatibility with // files) kept only for backward compatibility with
// configs written before the method was split. // configs written before the method was split.
@@ -77,6 +72,29 @@ type SSHConfig struct {
ConnectTimeoutSecs int `json:"connectTimeoutSecs"` 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"`
}
// 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. // SSH authentication method identifiers stored in SSHConfig.AuthMethod.
const ( const (
// SSHAuthBuiltin is the legacy combined method (password → agent → key // SSHAuthBuiltin is the legacy combined method (password → agent → key
@@ -90,22 +108,36 @@ const (
// SSHAuthKerberos delegates to the system ssh binary so an existing // SSHAuthKerberos delegates to the system ssh binary so an existing
// Kerberos credential cache (populated by `kinit`) is reused via GSSAPI. // Kerberos credential cache (populated by `kinit`) is reused via GSSAPI.
SSHAuthKerberos = "kerberos" SSHAuthKerberos = "kerberos"
// SSHAuthBgo delegates to the internal `bgo scp` client, which manages
// its own authentication. bgo requires a PTY and cannot create remote
// directories, so uploads land flat in the remote $HOME.
SSHAuthBgo = "bgo"
) )
// 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. // IsKerberos reports whether this config requests Kerberos/GSSAPI auth.
func (c SSHConfig) IsKerberos() bool { func (c SSHConfig) IsKerberos() bool {
return c.AuthMethod == SSHAuthKerberos return c.AuthMethod == SSHAuthKerberos
} }
// IsBgo reports whether this config requests the internal bgo client.
func (c SSHConfig) IsBgo() bool {
return c.AuthMethod == SSHAuthBgo
}
// AppConfig is the top-level on-disk configuration document. // AppConfig is the top-level on-disk configuration document.
// //
// We keep S3 / SSH nested so that adding more providers later (e.g. AliyunOSS, // We keep S3 / SSH nested so that adding more providers later (e.g. AliyunOSS,
@@ -116,18 +148,25 @@ type AppConfig struct {
// in the infrastructure hotkey adapter. // in the infrastructure hotkey adapter.
Hotkey string `json:"hotkey"` 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 holds the active S3-compatible storage configuration.
S3 S3Config `json:"s3"` S3 S3Config `json:"s3"`
// SSH holds the configuration for the optional "save to remote via scp" // SSH holds the configuration for the optional "save to remote via scp"
// destination triggered by the save-remote toolbar button. // destination triggered by the save-remote toolbar button.
SSH SSHConfig `json:"ssh"` 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. // DefaultAppConfig returns sane zero-value defaults used on first launch.
func DefaultAppConfig() AppConfig { func DefaultAppConfig() AppConfig {
return AppConfig{ cfg := AppConfig{
Hotkey: "cmd+shift+a", Hotkey: "cmd+shift+a",
Theme: ThemeAuto,
S3: S3Config{ S3: S3Config{
PathPrefix: "snapgo/", PathPrefix: "snapgo/",
UsePathStyle: true, UsePathStyle: true,
@@ -138,6 +177,105 @@ func DefaultAppConfig() AppConfig {
ConnectTimeoutSecs: 10, ConnectTimeoutSecs: 10,
StrictHostKey: false, 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,
},
LLMProviderDoubao: {
Label: "火山方舟豆包多模态",
BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
Model: "",
MaxTokens: 600,
Temperature: 0.2,
TimeoutSecs: 60,
},
LLMProviderOpenAI: {
Label: "ChatGPT / OpenAI-compatible",
BaseURL: "https://api.openai.com/v1",
Model: "gpt-5.5",
MaxTokens: 600,
Temperature: 0.2,
TimeoutSecs: 60,
},
},
}
}
// 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
}
c.LLM.Providers[id] = current
} }
} }
@@ -155,3 +293,21 @@ func (c AppConfig) IsS3Configured() bool {
func (c AppConfig) IsSSHConfigured() bool { func (c AppConfig) IsSSHConfigured() bool {
return c.SSH.Host != "" && c.SSH.User != "" 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 != ""
}
+8 -6
View File
@@ -1,12 +1,12 @@
// Package config provides JSON-file backed persistence for AppConfig. // Package config provides JSON-file backed persistence for AppConfig.
// //
// Design rationale: // Design rationale:
// - JSON keeps the file human-readable, which is useful while the MVP has // - JSON keeps the file human-readable, which is useful while the MVP has
// no settings UI for every option. // no settings UI for every option.
// - Storage location follows os.UserConfigDir() so each platform places it // - Storage location follows os.UserConfigDir() so each platform places it
// in the canonical spot (macOS: ~/Library/Application Support/SnapGo). // in the canonical spot (macOS: ~/Library/Application Support/SnapGo).
// - File mode 0600 / dir mode 0700 mitigate accidental leakage of access // - File mode 0600 / dir mode 0700 mitigate accidental leakage of access
// keys before we wire up the OS keychain (deferred to a later spec). // keys before we wire up the OS keychain (deferred to a later spec).
package config package config
import ( import (
@@ -65,6 +65,7 @@ func (s *FileStore) Load() (domain.AppConfig, error) {
if err := json.Unmarshal(data, &cfg); err != nil { if err := json.Unmarshal(data, &cfg); err != nil {
return domain.DefaultAppConfig(), fmt.Errorf("parse config: %w", err) return domain.DefaultAppConfig(), fmt.Errorf("parse config: %w", err)
} }
cfg.Normalize()
return cfg, nil return cfg, nil
} }
@@ -74,6 +75,7 @@ func (s *FileStore) Save(cfg domain.AppConfig) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
cfg.Normalize()
data, err := json.MarshalIndent(cfg, "", " ") data, err := json.MarshalIndent(cfg, "", " ")
if err != nil { if err != nil {
return fmt.Errorf("marshal config: %w", err) return fmt.Errorf("marshal config: %w", err)
@@ -0,0 +1,174 @@
// Package llm contains multimodal LLM adapters.
//
// The built-in providers are intentionally implemented through the
// OpenAI-compatible chat/completions shape. Qwen DashScope, Volcengine Ark,
// and OpenAI-compatible gateways all accept the same "text + image_url"
// message content pattern, so one small HTTP adapter is enough here.
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/mmmy/snapgo/internal/domain"
)
// VisionClient calls one configured multimodal chat endpoint.
type VisionClient struct {
providerID string
cfg domain.LLMProviderConfig
httpClient *http.Client
}
// NewVisionClient validates cfg and returns a reusable client.
func NewVisionClient(providerID string, cfg domain.LLMProviderConfig) (*VisionClient, error) {
if strings.TrimSpace(cfg.BaseURL) == "" {
return nil, fmt.Errorf("llm base url is required")
}
if strings.TrimSpace(cfg.APIKey) == "" {
return nil, fmt.Errorf("llm api key is required")
}
if strings.TrimSpace(cfg.Model) == "" {
return nil, fmt.Errorf("llm model is required")
}
timeout := cfg.TimeoutSecs
if timeout <= 0 {
timeout = 60
}
return &VisionClient{
providerID: providerID,
cfg: cfg,
httpClient: &http.Client{Timeout: time.Duration(timeout) * time.Second},
}, nil
}
// SummarizeImage asks the model to summarise an image available at imageURL.
func (c *VisionClient) SummarizeImage(ctx context.Context, prompt string, imageURL string) (string, error) {
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return "", fmt.Errorf("llm prompt is empty")
}
imageURL = strings.TrimSpace(imageURL)
if imageURL == "" {
return "", fmt.Errorf("image url is empty")
}
body := map[string]any{
"model": c.cfg.Model,
"messages": []map[string]any{
{
"role": "user",
"content": []map[string]any{
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": map[string]any{"url": imageURL}},
},
},
},
}
if c.cfg.MaxTokens > 0 {
if c.providerID == domain.LLMProviderOpenAI {
body["max_completion_tokens"] = c.cfg.MaxTokens
} else {
body["max_tokens"] = c.cfg.MaxTokens
}
}
if c.cfg.Temperature >= 0 {
body["temperature"] = c.cfg.Temperature
}
payload, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("marshal llm request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatCompletionsURL(c.cfg.BaseURL), bytes.NewReader(payload))
if err != nil {
return "", fmt.Errorf("build llm request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("llm request: %w", err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("llm status %d: %s", resp.StatusCode, compactBody(data))
}
var decoded chatCompletionResponse
if err := json.Unmarshal(data, &decoded); err != nil {
return "", fmt.Errorf("decode llm response: %w", err)
}
if decoded.Error != nil && decoded.Error.Message != "" {
return "", fmt.Errorf("llm error: %s", decoded.Error.Message)
}
if len(decoded.Choices) == 0 {
return "", fmt.Errorf("llm response has no choices")
}
text := strings.TrimSpace(contentText(decoded.Choices[0].Message.Content))
if text == "" {
return "", fmt.Errorf("llm response is empty")
}
return text, nil
}
func chatCompletionsURL(baseURL string) string {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if strings.HasSuffix(base, "/chat/completions") {
return base
}
return base + "/chat/completions"
}
type chatCompletionResponse struct {
Choices []struct {
Message struct {
Content any `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
Code any `json:"code"`
} `json:"error"`
}
func contentText(content any) string {
switch v := content.(type) {
case string:
return v
case []any:
parts := make([]string, 0, len(v))
for _, item := range v {
obj, ok := item.(map[string]any)
if !ok {
continue
}
if text, ok := obj["text"].(string); ok {
parts = append(parts, text)
}
}
return strings.Join(parts, "\n")
default:
return ""
}
}
func compactBody(data []byte) string {
text := strings.TrimSpace(string(data))
if text == "" {
return "<empty body>"
}
if len(text) > 800 {
return text[:800] + "..."
}
return text
}
@@ -0,0 +1,86 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/mmmy/snapgo/internal/domain"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestVisionClientSummarizeImageUsesChatCompletionImageURL(t *testing.T) {
var requestBody map[string]any
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("expected /v1/chat/completions, got %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Fatalf("unexpected auth header %q", got)
}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
t.Fatalf("decode request: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewBufferString(`{"choices":[{"message":{"content":"summary ok"}}]}`)),
}, nil
})
client, err := NewVisionClient(domain.LLMProviderQwen, domain.LLMProviderConfig{
BaseURL: "https://example.test/v1",
APIKey: "test-key",
Model: "qwen-vl-plus",
MaxTokens: 321,
Temperature: 0.2,
TimeoutSecs: 5,
})
if err != nil {
t.Fatalf("new client: %v", err)
}
client.httpClient.Transport = transport
text, err := client.SummarizeImage(context.Background(), "describe", "https://cdn.example.com/a.png")
if err != nil {
t.Fatalf("summarize image: %v", err)
}
if text != "summary ok" {
t.Fatalf("expected response text, got %q", text)
}
if requestBody["model"] != "qwen-vl-plus" {
t.Fatalf("expected model forwarded, got %#v", requestBody["model"])
}
if requestBody["max_tokens"] != float64(321) {
t.Fatalf("expected max_tokens forwarded, got %#v", requestBody["max_tokens"])
}
messages := requestBody["messages"].([]any)
content := messages[0].(map[string]any)["content"].([]any)
if content[0].(map[string]any)["text"] != "describe" {
t.Fatalf("expected prompt content, got %#v", content[0])
}
image := content[1].(map[string]any)
if image["type"] != "image_url" {
t.Fatalf("expected image_url item, got %#v", image)
}
url := image["image_url"].(map[string]any)["url"]
if url != "https://cdn.example.com/a.png" {
t.Fatalf("expected image url forwarded, got %#v", url)
}
}
func TestChatCompletionsURLAcceptsFullEndpoint(t *testing.T) {
full := "https://example.com/v1/chat/completions"
if got := chatCompletionsURL(full); got != full {
t.Fatalf("expected full endpoint unchanged, got %q", got)
}
}
-200
View File
@@ -1,200 +0,0 @@
// bgo_uploader.go — an SSHUploader implementation that delegates to the
// internal `bgo scp` client. bgo is ByteDance's internal credential/auth
// management tool; it wraps ssh/scp and handles login transparently once the
// user has configured it (see the Lark guide linked in the Settings UI).
//
// 设计理由 (为什么不能直接 exec bgo, 也不能复用 KerberosUploader):
// - bgo 强制要求在 PTY (伪终端) 下运行: 它会对 stdin/stdout 做终端 ioctl
// (resize pty). 没有 PTY 时直接 panic ("operation not supported by
// device"). 因此必须用系统 /usr/bin/script 为其分配一个伪终端:
// script -q /dev/null <bgo> scp <src> <user@host:dst>
// 这样既不引入额外的 PTY 依赖, 又满足 bgo 的运行约束.
// - bgo scp 不会自动创建远程目录, 且 bgo ssh 无法执行远程命令 (会 panic),
// 所以无法像 Kerberos 那样先 `mkdir -p`. 故 bgo 模式只能把文件平铺上传
// 到远端用户的 $HOME 根目录 (文件名内嵌时间戳保证唯一), 上层 service
// 负责生成扁平文件名.
// - bgo 自带鉴权, 不需要也不应传 GSSAPI / 密码 / 公钥相关的 ssh 选项.
package ssh
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/mmmy/snapgo/internal/domain"
)
// systemScriptPath is the absolute path to the macOS BSD `script` utility,
// which we use purely to allocate a PTY for bgo. Absolute path avoids PATH
// hijacking and the minimal PATH GUI apps inherit on macOS.
const systemScriptPath = "/usr/bin/script"
// bgoSearchPaths lists the locations we probe for the bgo binary when it is
// not already resolvable via $PATH. GUI apps on macOS launch with a minimal
// PATH that usually omits ~/.local/bin and Homebrew dirs, so we look there
// explicitly rather than failing with a confusing "command not found".
func bgoSearchPaths() []string {
home, _ := os.UserHomeDir()
return []string{
filepath.Join(home, ".local", "bin", "bgo"),
"/usr/local/bin/bgo",
"/opt/homebrew/bin/bgo",
"/opt/bin/bgo",
}
}
// BgoUploader satisfies application.SSHUploader by shelling out to `bgo scp`
// under a PTY allocated via the system `script` utility.
type BgoUploader struct {
cfg domain.SSHConfig
}
// NewBgoUploader returns an uploader that delegates to the bgo client.
func NewBgoUploader(cfg domain.SSHConfig) *BgoUploader {
return &BgoUploader{cfg: cfg}
}
// resolveBgoBinary locates the bgo executable, first via $PATH and then via
// the well-known install locations. Returns a clear error pointing the user
// to the setup guide when bgo cannot be found.
func resolveBgoBinary() (string, error) {
if p, err := exec.LookPath("bgo"); err == nil {
return p, nil
}
for _, candidate := range bgoSearchPaths() {
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
}
return "", fmt.Errorf("bgo executable not found — install bgo and add it to PATH (see the setup guide)")
}
// Upload writes data to a flat remote filename in the user's remote $HOME by
// staging it locally and invoking `bgo scp` once under a PTY.
//
// remoteRelPath is expected to be a flat filename (no directory components),
// because bgo cannot create remote directories. Any directory components are
// collapsed to the base name defensively.
func (u *BgoUploader) Upload(ctx context.Context, remoteRelPath string, data []byte, mode os.FileMode) error {
start := time.Now()
// Collapse to a flat filename: bgo scp cannot create remote directories.
name := path.Base(normaliseRemotePath(remoteRelPath))
if name == "" || name == "." || name == "/" {
return fmt.Errorf("ssh(bgo): remote filename is empty")
}
bgoBin, err := resolveBgoBinary()
if err != nil {
sshLog().Error("bgo uploader: binary not found", "err", err)
return err
}
sshLog().Info("bgo uploader start",
"host", u.cfg.Host, "user", u.cfg.User, "port", u.cfg.Port,
"remote_file", name, "size", len(data), "bgo", bgoBin)
// 1. Stage the payload to a local temp file for scp to read.
tmp, err := os.CreateTemp("", "snapgo-bgo-*.png")
if err != nil {
return fmt.Errorf("ssh(bgo): create temp: %w", err)
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("ssh(bgo): write temp: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("ssh(bgo): close temp: %w", err)
}
if err := os.Chmod(tmpPath, mode.Perm()); err != nil {
sshLog().Debug("bgo uploader chmod temp failed", "err", err)
}
// 2. bgo scp <tmp> <user@host:name> under a PTY (via `script`).
remoteTarget := fmt.Sprintf("%s@%s:%s", u.cfg.User, bracketHost(u.cfg.Host), name)
bgoArgs := u.bgoScpArgs(bgoBin, tmpPath, remoteTarget)
if err := u.runUnderPTY(ctx, bgoArgs); err != nil {
sshLog().Error("bgo uploader scp failed",
"remote_file", name, "elapsed", time.Since(start), "err", err)
return err
}
sshLog().Info("bgo uploader done",
"remote_file", name, "size", len(data), "total_elapsed", time.Since(start))
return nil
}
// bgoScpArgs builds the argument vector "bgo scp [-P port] <src> <dst>".
// bgo manages its own auth, so we pass no ssh -o options here.
func (u *BgoUploader) bgoScpArgs(bgoBin, src, dst string) []string {
args := []string{bgoBin, "scp"}
if u.cfg.Port > 0 && u.cfg.Port != 22 {
args = append(args, "-P", strconv.Itoa(u.cfg.Port))
}
args = append(args, src, dst)
return args
}
// runUnderPTY runs the given command vector under a PTY allocated by the
// system `script` utility, capturing combined output for diagnostics.
//
// macOS BSD script signature: `script -q <typescript-file> command [args...]`.
// We discard the typescript by writing to /dev/null.
func (u *BgoUploader) runUnderPTY(ctx context.Context, cmdVec []string) error {
args := append([]string{"-q", "/dev/null"}, cmdVec...)
cmd := exec.CommandContext(ctx, systemScriptPath, args...)
var combined bytes.Buffer
cmd.Stdout = &combined
cmd.Stderr = &combined
sshLog().Debug("bgo exec", "args", strings.Join(args, " "))
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(combined.String())
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("bgo scp failed: %s", msg)
}
return nil
}
// bracketHost wraps a bare IPv6 literal in [] so the "user@host:path" target
// parses correctly (e.g. fdbd:dc03:8:379::130 → [fdbd:dc03:8:379::130]).
// IPv4 addresses and hostnames are returned unchanged. Already-bracketed
// inputs are left as-is.
func bracketHost(host string) string {
host = strings.TrimSpace(host)
if host == "" {
return host
}
if strings.HasPrefix(host, "[") {
return host
}
// An IPv6 literal contains multiple ':' separators; a hostname:port style
// is not expected here because the port lives in cfg.Port.
if strings.Count(host, ":") >= 2 {
return "[" + host + "]"
}
return host
}
// TestBgoConnection verifies that the bgo binary is resolvable. A real
// transfer test is intentionally avoided: bgo requires a PTY and a live
// remote, and bgo ssh cannot run a harmless remote probe command (it panics).
// Confirming bgo is installed and on PATH is the most useful signal we can
// give without performing an actual upload.
func TestBgoConnection(ctx context.Context, cfg domain.SSHConfig) error {
sshLog().Info("bgo test connection start", "host", cfg.Host, "user", cfg.User)
bin, err := resolveBgoBinary()
if err != nil {
return err
}
sshLog().Info("bgo test connection ok", "bgo", bin)
return nil
}
+2 -2
View File
@@ -36,8 +36,8 @@ func main() {
err := wails.Run(&options.App{ err := wails.Run(&options.App{
Title: "SnapGo", Title: "SnapGo",
Width: 1080, Width: 1000,
Height: 720, Height: 820,
MinWidth: 720, MinWidth: 720,
MinHeight: 520, MinHeight: 520,
AssetServer: &assetserver.Options{ AssetServer: &assetserver.Options{
+12
View File
@@ -74,6 +74,18 @@ func nativeOverlaySaveRemote(x, y, w, h C.int, annotationsJSON *C.char) {
}() }()
} }
//export nativeOverlaySummarize
func nativeOverlaySummarize(x, y, w, h C.int, annotationsJSON *C.char) {
app := consumeNativeOverlayApp()
if app == nil {
return
}
result := nativeCaptureResult(x, y, w, h, annotationsJSON)
go func() {
_ = app.SummarizeNativeRegion(result)
}()
}
//export nativeOverlayCancel //export nativeOverlayCancel
func nativeOverlayCancel() { func nativeOverlayCancel() {
app := consumeNativeOverlayApp() app := consumeNativeOverlayApp()
+33 -9
View File
@@ -15,6 +15,7 @@ extern void nativeOverlayConfirm(int x, int y, int w, int h, const char *annotat
extern void nativeOverlayCopy(int x, int y, int w, int h, const char *annotationsJSON); extern void nativeOverlayCopy(int x, int y, int w, int h, const char *annotationsJSON);
extern void nativeOverlaySave(int x, int y, int w, int h, const char *annotationsJSON, const char *dir); extern void nativeOverlaySave(int x, int y, int w, int h, const char *annotationsJSON, const char *dir);
extern void nativeOverlaySaveRemote(int x, int y, int w, int h, const char *annotationsJSON); extern void nativeOverlaySaveRemote(int x, int y, int w, int h, const char *annotationsJSON);
extern void nativeOverlaySummarize(int x, int y, int w, int h, const char *annotationsJSON);
extern void nativeOverlayCancel(void); extern void nativeOverlayCancel(void);
static NSWindow *nativeOverlayWindow = nil; static NSWindow *nativeOverlayWindow = nil;
@@ -101,9 +102,8 @@ static id nativeOverlayKeyMonitor = nil;
// as returning a +1 retained object. // as returning a +1 retained object.
@property(strong) NSButton *clipboardButton; @property(strong) NSButton *clipboardButton;
@property(strong) NSButton *saveButton; @property(strong) NSButton *saveButton;
// `saveRemoteButton` triggers a placeholder action ("not yet supported")
// per product spec; the icon comes from save-remote.svg.
@property(strong) NSButton *saveRemoteButton; @property(strong) NSButton *saveRemoteButton;
@property(strong) NSButton *summaryButton;
@property(strong) NSButton *uploadButton; @property(strong) NSButton *uploadButton;
@property(strong) NSButton *penButton; @property(strong) NSButton *penButton;
@property(strong) NSButton *rectButton; @property(strong) NSButton *rectButton;
@@ -120,6 +120,7 @@ static id nativeOverlayKeyMonitor = nil;
- (void)copySelection; - (void)copySelection;
- (void)saveSelection; - (void)saveSelection;
- (void)saveRemoteSelection; - (void)saveRemoteSelection;
- (void)summarizeSelection;
- (void)cancelSelection; - (void)cancelSelection;
@end @end
@@ -144,6 +145,7 @@ static id nativeOverlayKeyMonitor = nil;
_clipboardButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(copySelection)]; _clipboardButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(copySelection)];
_saveButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(saveSelection)]; _saveButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(saveSelection)];
_saveRemoteButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(saveRemoteSelection)]; _saveRemoteButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(saveRemoteSelection)];
_summaryButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(summarizeSelection)];
_uploadButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(confirmSelection)]; _uploadButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(confirmSelection)];
// Tooltip strings shown on hover for each action button. // Tooltip strings shown on hover for each action button.
// Localized in Chinese to match the rest of the action UI surface. // Localized in Chinese to match the rest of the action UI surface.
@@ -151,6 +153,7 @@ static id nativeOverlayKeyMonitor = nil;
[_clipboardButton setToolTip:@"复制图片"]; [_clipboardButton setToolTip:@"复制图片"];
[_saveButton setToolTip:@"保存本地"]; [_saveButton setToolTip:@"保存本地"];
[_saveRemoteButton setToolTip:@"保存远端"]; [_saveRemoteButton setToolTip:@"保存远端"];
[_summaryButton setToolTip:@"复制总结"];
[_uploadButton setToolTip:@"上传云端"]; [_uploadButton setToolTip:@"上传云端"];
_penButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectPen)]; _penButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectPen)];
_rectButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectRect)]; _rectButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectRect)];
@@ -171,10 +174,10 @@ static id nativeOverlayKeyMonitor = nil;
// Add the action toolbar background BEFORE the buttons so it sits // Add the action toolbar background BEFORE the buttons so it sits
// behind them in the view hierarchy (AppKit z-order = subview order). // behind them in the view hierarchy (AppKit z-order = subview order).
[self addSubview:_actionToolbarBg]; [self addSubview:_actionToolbarBg];
for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _sizeLabel, _hintLabel]) { for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _summaryButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _sizeLabel, _hintLabel]) {
[self addSubview:view]; [self addSubview:view];
} }
for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _actionToolbarBg]) { for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _summaryButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _actionToolbarBg]) {
[view setHidden:YES]; [view setHidden:YES];
} }
[_sizeLabel setHidden:YES]; [_sizeLabel setHidden:YES];
@@ -248,6 +251,7 @@ static id nativeOverlayKeyMonitor = nil;
// save-remote.svg — provided by the user; its content is the same arrow // save-remote.svg — provided by the user; its content is the same arrow
// icon as the previous upload, so we reuse it verbatim here. // icon as the previous upload, so we reuse it verbatim here.
NSImage *saveRemoteIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M481.749333 353.792c16.682667-16.64 43.690667-16.64 60.373334 0l120.917333 120.832 3.541333 4.010667a42.666667 42.666667 0 0 1-63.872 56.32L554.666667 486.954667V896a42.666667 42.666667 0 0 1-85.333334 0v-409.173333l-48.170666 48.128a42.666667 42.666667 0 0 1-60.330667 0l-3.541333-4.010667a42.666667 42.666667 0 0 1 3.541333-56.32zM512 85.333333c122.538667 0 227.84 73.813333 273.92 179.370667A213.333333 213.333333 0 0 1 725.333333 682.666667a42.666667 42.666667 0 0 1-4.992-85.034667L725.333333 597.333333a128 128 0 0 0 36.394667-250.794666l-38.144-11.264-15.914667-36.437334a213.376 213.376 0 0 0-407.296 58.026667l-7.381333 58.368-57.173333 13.824A85.418667 85.418667 0 0 0 256 597.333333h42.666667a42.666667 42.666667 0 0 1 0 85.333334H256a170.666667 170.666667 0 0 1-40.277333-336.554667A298.709333 298.709333 0 0 1 512 85.333333z' fill='white'/></svg>"]; NSImage *saveRemoteIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M481.749333 353.792c16.682667-16.64 43.690667-16.64 60.373334 0l120.917333 120.832 3.541333 4.010667a42.666667 42.666667 0 0 1-63.872 56.32L554.666667 486.954667V896a42.666667 42.666667 0 0 1-85.333334 0v-409.173333l-48.170666 48.128a42.666667 42.666667 0 0 1-60.330667 0l-3.541333-4.010667a42.666667 42.666667 0 0 1 3.541333-56.32zM512 85.333333c122.538667 0 227.84 73.813333 273.92 179.370667A213.333333 213.333333 0 0 1 725.333333 682.666667a42.666667 42.666667 0 0 1-4.992-85.034667L725.333333 597.333333a128 128 0 0 0 36.394667-250.794666l-38.144-11.264-15.914667-36.437334a213.376 213.376 0 0 0-407.296 58.026667l-7.381333 58.368-57.173333 13.824A85.418667 85.418667 0 0 0 256 597.333333h42.666667a42.666667 42.666667 0 0 1 0 85.333334H256a170.666667 170.666667 0 0 1-40.277333-336.554667A298.709333 298.709333 0 0 1 512 85.333333z' fill='white'/></svg>"];
NSImage *summaryIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M256 128h448l192 192v576H256V128z m64 64v640h512V352H672V192H320z m416 45.248V288h50.752L736 237.248zM384 448h384v64H384v-64z m0 128h384v64H384v-64z m0 128h256v64H384v-64zM192 256v704h512v64H128V256h64z' fill='white'/></svg>"];
// upload.svg — newly replaced "send/cloud" icon supplied by the user. // upload.svg — newly replaced "send/cloud" icon supplied by the user.
NSImage *uploadIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M550.528 544.128L640 539.776V678.4l178.005333-178.005333L640 322.389333v129.024l-72.618667 10.922667c-155.52 23.424-251.733333 73.557333-312.874666 164.437333 84.906667-50.602667 178.261333-76.928 296.021333-82.645333zM554.666667 629.376c-27.392 1.322667-53.034667 3.968-77.141334 7.808l-8.192 1.365333c-136.704 23.637333-224.810667 87.168-310.954666 176.128-26.154667 27.008-51.882667 59.648-51.882667 59.648-14.848 18.176-24.021333 14.037333-20.352-9.173333 0 0 4.394667-31.402667 11.690667-65.792C144.938667 577.066667 250.581333 423.68 554.666667 377.941333V209.066667a38.4 38.4 0 0 1 65.536-27.136l291.328 291.285333a38.4 38.4 0 0 1 0 54.314667l-291.328 291.285333A38.4 38.4 0 0 1 554.666667 791.637333v-162.261333z' fill='white'/></svg>"]; NSImage *uploadIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M550.528 544.128L640 539.776V678.4l178.005333-178.005333L640 322.389333v129.024l-72.618667 10.922667c-155.52 23.424-251.733333 73.557333-312.874666 164.437333 84.906667-50.602667 178.261333-76.928 296.021333-82.645333zM554.666667 629.376c-27.392 1.322667-53.034667 3.968-77.141334 7.808l-8.192 1.365333c-136.704 23.637333-224.810667 87.168-310.954666 176.128-26.154667 27.008-51.882667 59.648-51.882667 59.648-14.848 18.176-24.021333 14.037333-20.352-9.173333 0 0 4.394667-31.402667 11.690667-65.792C144.938667 577.066667 250.581333 423.68 554.666667 377.941333V209.066667a38.4 38.4 0 0 1 65.536-27.136l291.328 291.285333a38.4 38.4 0 0 1 0 54.314667l-291.328 291.285333A38.4 38.4 0 0 1 554.666667 791.637333v-162.261333z' fill='white'/></svg>"];
@@ -255,6 +259,7 @@ static id nativeOverlayKeyMonitor = nil;
[self styleIconButton:_clipboardButton image:copyIcon]; [self styleIconButton:_clipboardButton image:copyIcon];
[self styleIconButton:_saveButton image:saveIcon]; [self styleIconButton:_saveButton image:saveIcon];
[self styleIconButton:_saveRemoteButton image:saveRemoteIcon]; [self styleIconButton:_saveRemoteButton image:saveRemoteIcon];
[self styleIconButton:_summaryButton image:summaryIcon];
[self styleIconButton:_uploadButton image:uploadIcon]; [self styleIconButton:_uploadButton image:uploadIcon];
// Configure hover colors for the 5 action buttons. White is the shared // Configure hover colors for the 5 action buttons. White is the shared
@@ -267,11 +272,13 @@ static id nativeOverlayKeyMonitor = nil;
SnipHoverButton *clipboardHB = (SnipHoverButton *)_clipboardButton; SnipHoverButton *clipboardHB = (SnipHoverButton *)_clipboardButton;
SnipHoverButton *saveHB = (SnipHoverButton *)_saveButton; SnipHoverButton *saveHB = (SnipHoverButton *)_saveButton;
SnipHoverButton *saveRemoteHB = (SnipHoverButton *)_saveRemoteButton; SnipHoverButton *saveRemoteHB = (SnipHoverButton *)_saveRemoteButton;
SnipHoverButton *summaryHB = (SnipHoverButton *)_summaryButton;
SnipHoverButton *uploadHB = (SnipHoverButton *)_uploadButton; SnipHoverButton *uploadHB = (SnipHoverButton *)_uploadButton;
cancelHB.baseColor = baseWhite; cancelHB.hoverColor = hoverRed; cancelHB.baseColor = baseWhite; cancelHB.hoverColor = hoverRed;
clipboardHB.baseColor = baseWhite; clipboardHB.hoverColor = hoverBlue; clipboardHB.baseColor = baseWhite; clipboardHB.hoverColor = hoverBlue;
saveHB.baseColor = baseWhite; saveHB.hoverColor = hoverBlue; saveHB.baseColor = baseWhite; saveHB.hoverColor = hoverBlue;
saveRemoteHB.baseColor = baseWhite; saveRemoteHB.hoverColor = hoverBlue; saveRemoteHB.baseColor = baseWhite; saveRemoteHB.hoverColor = hoverBlue;
summaryHB.baseColor = baseWhite; summaryHB.hoverColor = hoverBlue;
uploadHB.baseColor = baseWhite; uploadHB.hoverColor = hoverBlue; uploadHB.baseColor = baseWhite; uploadHB.hoverColor = hoverBlue;
[self styleIconButton:_penButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M742.72 752.064c-29.696 28.576-42.976 48.96-42.976 77.12 0 98.4 143.776 142.464 229.728 65.536l-21.344-23.84c-66.976 59.936-176.384 26.4-176.384-41.696 0-16.832 9.28-31.104 33.184-54.08l13.44-12.736c61.024-58.016 75.328-102.72 29.312-179.84-43.84-73.44-96.8-88.288-162.784-50.56-47.232 27.04-68.64 47.456-208.32 190.4-70.624 72.288-153.92 77.088-217.344 26.784-59.712-47.328-79.872-127.36-42.176-188.64 24.512-39.84 62.656-72.896 136.64-124.48 5.952-4.192 27.04-18.816 31.104-21.664 12.16-8.448 21.536-15.104 30.4-21.536 107.552-78.272 140.8-139.136 86.048-219.904-76.992-113.472-202.304-90.016-367.744 61.472l21.6 23.616c153.088-140.224 256.896-159.616 319.68-67.104 41.632 61.408 16.896 106.688-78.4 176.032-8.64 6.304-17.92 12.832-29.888 21.184l-31.136 21.632c-77.504 54.08-117.984 89.184-145.536 133.984-46.784 76.032-22.176 173.664 49.536 230.496 76.224 60.416 177.952 54.56 260.096-29.504 136.16-139.36 158.08-160.224 201.312-184.96 50.656-28.96 84.416-19.52 119.424 39.168 36.896 61.824 27.52 91.392-23.808 140.16 0.096-0.064-10.976 10.368-13.632 12.96z' fill='white'/></svg>"]]; [self styleIconButton:_penButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M742.72 752.064c-29.696 28.576-42.976 48.96-42.976 77.12 0 98.4 143.776 142.464 229.728 65.536l-21.344-23.84c-66.976 59.936-176.384 26.4-176.384-41.696 0-16.832 9.28-31.104 33.184-54.08l13.44-12.736c61.024-58.016 75.328-102.72 29.312-179.84-43.84-73.44-96.8-88.288-162.784-50.56-47.232 27.04-68.64 47.456-208.32 190.4-70.624 72.288-153.92 77.088-217.344 26.784-59.712-47.328-79.872-127.36-42.176-188.64 24.512-39.84 62.656-72.896 136.64-124.48 5.952-4.192 27.04-18.816 31.104-21.664 12.16-8.448 21.536-15.104 30.4-21.536 107.552-78.272 140.8-139.136 86.048-219.904-76.992-113.472-202.304-90.016-367.744 61.472l21.6 23.616c153.088-140.224 256.896-159.616 319.68-67.104 41.632 61.408 16.896 106.688-78.4 176.032-8.64 6.304-17.92 12.832-29.888 21.184l-31.136 21.632c-77.504 54.08-117.984 89.184-145.536 133.984-46.784 76.032-22.176 173.664 49.536 230.496 76.224 60.416 177.952 54.56 260.096-29.504 136.16-139.36 158.08-160.224 201.312-184.96 50.656-28.96 84.416-19.52 119.424 39.168 36.896 61.824 27.52 91.392-23.808 140.16 0.096-0.064-10.976 10.368-13.632 12.96z' fill='white'/></svg>"]];
@@ -591,6 +598,7 @@ static id nativeOverlayKeyMonitor = nil;
[_clipboardButton setHidden:!visible]; [_clipboardButton setHidden:!visible];
[_saveButton setHidden:!visible]; [_saveButton setHidden:!visible];
[_saveRemoteButton setHidden:!visible]; [_saveRemoteButton setHidden:!visible];
[_summaryButton setHidden:!visible];
[_uploadButton setHidden:!visible]; [_uploadButton setHidden:!visible];
[_actionToolbarBg setHidden:!visible]; [_actionToolbarBg setHidden:!visible];
[_penButton setHidden:!visible]; [_penButton setHidden:!visible];
@@ -608,12 +616,12 @@ static id nativeOverlayKeyMonitor = nil;
[_sizeLabel setStringValue:[NSString stringWithFormat:@"%.0f × %.0f", _selection.size.width, _selection.size.height]]; [_sizeLabel setStringValue:[NSString stringWithFormat:@"%.0f × %.0f", _selection.size.width, _selection.size.height]];
[_sizeLabel setFrame:NSMakeRect(_selection.origin.x, MAX(0, _selection.origin.y - 26), 110, 22)]; [_sizeLabel setFrame:NSMakeRect(_selection.origin.x, MAX(0, _selection.origin.y - 26), 110, 22)];
// Action toolbar layout — 5 icon buttons, each 28x28, separated by 8px, // Action toolbar layout — 6 icon buttons, each 28x28, separated by 8px,
// with 4px outer padding. Total = 5*28 + 4*8 + 2*4 = 180px wide. // with 4px outer padding. Total = 6*28 + 5*8 + 2*4 = 216px wide.
CGFloat actionBtnSize = 28; CGFloat actionBtnSize = 28;
CGFloat actionGap = 8; CGFloat actionGap = 8;
CGFloat actionPad = 4; CGFloat actionPad = 4;
NSInteger actionCount = 5; NSInteger actionCount = 6;
CGFloat toolbarW = actionPad * 2 + actionBtnSize * actionCount + actionGap * (actionCount - 1); CGFloat toolbarW = actionPad * 2 + actionBtnSize * actionCount + actionGap * (actionCount - 1);
CGFloat toolbarH = 40; CGFloat toolbarH = 40;
CGFloat x = _selection.origin.x + _selection.size.width - toolbarW; CGFloat x = _selection.origin.x + _selection.size.width - toolbarW;
@@ -631,10 +639,14 @@ static id nativeOverlayKeyMonitor = nil;
[_clipboardButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 1, btnY, actionBtnSize, actionBtnSize)]; [_clipboardButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 1, btnY, actionBtnSize, actionBtnSize)];
[_saveButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 2, btnY, actionBtnSize, actionBtnSize)]; [_saveButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 2, btnY, actionBtnSize, actionBtnSize)];
[_saveRemoteButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 3, btnY, actionBtnSize, actionBtnSize)]; [_saveRemoteButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 3, btnY, actionBtnSize, actionBtnSize)];
[_uploadButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 4, btnY, actionBtnSize, actionBtnSize)]; [_summaryButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 4, btnY, actionBtnSize, actionBtnSize)];
[_uploadButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 5, btnY, actionBtnSize, actionBtnSize)];
// Mark toolbar sits to the LEFT of the action toolbar (same row) with an
// 8px gap between the two groups, so they never overlap on tiny selections.
CGFloat markW = 170; CGFloat markW = 170;
CGFloat markX = MAX(0, MIN(_selection.origin.x, self.bounds.size.width - markW)); CGFloat markGap = 8;
CGFloat markX = MAX(0, x - markGap - markW);
[_penButton setFrame:NSMakeRect(markX + 4, y + 4, 28, 28)]; [_penButton setFrame:NSMakeRect(markX + 4, y + 4, 28, 28)];
[_rectButton setFrame:NSMakeRect(markX + 36, y + 4, 28, 28)]; [_rectButton setFrame:NSMakeRect(markX + 36, y + 4, 28, 28)];
[_ellipseButton setFrame:NSMakeRect(markX + 68, y + 4, 28, 28)]; [_ellipseButton setFrame:NSMakeRect(markX + 68, y + 4, 28, 28)];
@@ -796,6 +808,18 @@ static id nativeOverlayKeyMonitor = nil;
NSString *json = [self annotationsJSON]; NSString *json = [self annotationsJSON];
nativeOverlaySaveRemote((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]); nativeOverlaySaveRemote((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]);
} }
// `summarizeSelection` uploads the screenshot to S3, sends it to the
// configured multimodal model, and copies the generated summary.
- (void)summarizeSelection {
if (!_hasSelection) {
return;
}
NSRect r = [self globalRectForSelection:_selection];
[self closeOverlayWindow];
NSString *json = [self annotationsJSON];
nativeOverlaySummarize((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]);
}
@end @end
// screenContainingCursor returns the NSScreen the cursor is currently on, // screenContainingCursor returns the NSScreen the cursor is currently on,
+163
View File
@@ -0,0 +1,163 @@
//go:build darwin
package main
/*
#cgo CFLAGS: -x objective-c -fobjc-arc -fblocks
#cgo LDFLAGS: -framework AppKit -framework QuartzCore
#include <stdlib.h>
#include <dispatch/dispatch.h>
#import <AppKit/AppKit.h>
#import <QuartzCore/QuartzCore.h>
static NSPanel *operationPanel = nil;
static NSTextField *operationTitleLabel = nil;
static NSTextField *operationDetailLabel = nil;
static NSTextField *operationIconLabel = nil;
static NSProgressIndicator *operationSpinner = nil;
static NSString *statusString(const char *value) {
if (value == NULL) {
return @"";
}
NSString *text = [NSString stringWithUTF8String:value];
return text ?: @"";
}
static void ensureOperationPanel(void) {
if (operationPanel != nil) {
return;
}
NSRect frame = NSMakeRect(0, 0, 360, 118);
operationPanel = [[NSPanel alloc]
initWithContentRect:frame
styleMask:NSWindowStyleMaskBorderless
backing:NSBackingStoreBuffered
defer:NO];
[operationPanel setOpaque:NO];
[operationPanel setBackgroundColor:[NSColor clearColor]];
[operationPanel setLevel:NSFloatingWindowLevel];
[operationPanel setHidesOnDeactivate:NO];
[operationPanel setCollectionBehavior:
NSWindowCollectionBehaviorCanJoinAllSpaces |
NSWindowCollectionBehaviorFullScreenAuxiliary |
NSWindowCollectionBehaviorStationary];
NSView *root = [[NSView alloc] initWithFrame:frame];
[root setWantsLayer:YES];
[[root layer] setCornerRadius:14];
[[root layer] setBackgroundColor:[[NSColor colorWithCalibratedWhite:0.08 alpha:0.92] CGColor]];
[[root layer] setShadowColor:[[NSColor blackColor] CGColor]];
[[root layer] setShadowOpacity:0.28];
[[root layer] setShadowRadius:18];
[[root layer] setShadowOffset:CGSizeMake(0, -8)];
operationSpinner = [[NSProgressIndicator alloc] initWithFrame:NSMakeRect(24, 42, 34, 34)];
[operationSpinner setStyle:NSProgressIndicatorStyleSpinning];
[operationSpinner setIndeterminate:YES];
operationIconLabel = [NSTextField labelWithString:@"OK"];
[operationIconLabel setFrame:NSMakeRect(21, 43, 42, 30)];
[operationIconLabel setAlignment:NSTextAlignmentCenter];
[operationIconLabel setFont:[NSFont systemFontOfSize:17 weight:NSFontWeightSemibold]];
[operationIconLabel setTextColor:[NSColor colorWithCalibratedRed:74.0/255.0 green:222.0/255.0 blue:128.0/255.0 alpha:1.0]];
[operationIconLabel setHidden:YES];
operationTitleLabel = [NSTextField labelWithString:@""];
[operationTitleLabel setFrame:NSMakeRect(76, 61, 260, 24)];
[operationTitleLabel setFont:[NSFont systemFontOfSize:15 weight:NSFontWeightSemibold]];
[operationTitleLabel setTextColor:[NSColor whiteColor]];
operationDetailLabel = [NSTextField labelWithString:@""];
[operationDetailLabel setFrame:NSMakeRect(76, 34, 260, 22)];
[operationDetailLabel setFont:[NSFont systemFontOfSize:12 weight:NSFontWeightRegular]];
[operationDetailLabel setTextColor:[NSColor colorWithCalibratedWhite:0.82 alpha:1.0]];
[operationDetailLabel setLineBreakMode:NSLineBreakByTruncatingTail];
[root addSubview:operationSpinner];
[root addSubview:operationIconLabel];
[root addSubview:operationTitleLabel];
[root addSubview:operationDetailLabel];
[operationPanel setContentView:root];
}
static void snipShowOperationStatus(const char *titleC, const char *detailC, int state) {
NSString *title = statusString(titleC);
NSString *detail = statusString(detailC);
dispatch_async(dispatch_get_main_queue(), ^{
ensureOperationPanel();
[operationTitleLabel setStringValue:title];
[operationDetailLabel setStringValue:detail];
if (state == 0) {
[operationIconLabel setHidden:YES];
[operationSpinner setHidden:NO];
[operationSpinner startAnimation:nil];
} else {
[operationSpinner stopAnimation:nil];
[operationSpinner setHidden:YES];
[operationIconLabel setHidden:NO];
if (state == 2) {
[operationIconLabel setStringValue:@"!"];
[operationIconLabel setTextColor:[NSColor colorWithCalibratedRed:248.0/255.0 green:113.0/255.0 blue:113.0/255.0 alpha:1.0]];
} else {
[operationIconLabel setStringValue:@"OK"];
[operationIconLabel setTextColor:[NSColor colorWithCalibratedRed:74.0/255.0 green:222.0/255.0 blue:128.0/255.0 alpha:1.0]];
}
}
NSScreen *screen = [NSScreen mainScreen];
if (screen != nil) {
NSRect visible = [screen visibleFrame];
NSRect panelFrame = [operationPanel frame];
panelFrame.origin.x = NSMidX(visible) - panelFrame.size.width / 2;
panelFrame.origin.y = NSMaxY(visible) - panelFrame.size.height - 48;
[operationPanel setFrame:panelFrame display:YES];
}
[operationPanel orderFrontRegardless];
});
}
static void snipHideOperationStatus(void) {
dispatch_async(dispatch_get_main_queue(), ^{
if (operationPanel != nil) {
[operationSpinner stopAnimation:nil];
[operationPanel orderOut:nil];
}
});
}
*/
import "C"
import (
"sync/atomic"
"time"
"unsafe"
)
const (
operationStatusRunning = iota
operationStatusSuccess
operationStatusError
)
var operationStatusSeq atomic.Uint64
func showOperationStatus(title, detail string, state int) {
operationStatusSeq.Add(1)
ctitle := C.CString(title)
cdetail := C.CString(detail)
defer C.free(unsafe.Pointer(ctitle))
defer C.free(unsafe.Pointer(cdetail))
C.snipShowOperationStatus(ctitle, cdetail, C.int(state))
}
func hideOperationStatusAfter(delay time.Duration) {
seq := operationStatusSeq.Load()
time.AfterFunc(delay, func() {
if operationStatusSeq.Load() == seq {
C.snipHideOperationStatus()
}
})
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !darwin
package main
import "time"
const (
operationStatusRunning = iota
operationStatusSuccess
operationStatusError
)
func showOperationStatus(_ string, _ string, _ int) {}
func hideOperationStatusAfter(_ time.Duration) {}