diff --git a/app.go b/app.go index eabc3b5..62aa73c 100644 --- a/app.go +++ b/app.go @@ -22,6 +22,7 @@ import ( "github.com/mmmy/snapgo/internal/infrastructure/config" "github.com/mmmy/snapgo/internal/infrastructure/display" "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/screencapture" sshpkg "github.com/mmmy/snapgo/internal/infrastructure/ssh" @@ -96,6 +97,15 @@ type CaptureActionResult struct { 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. func NewApp() *App { store, err := config.NewFileStore() @@ -110,6 +120,7 @@ func NewApp() *App { slog.Warn("config load failed, using defaults", "err", lerr) } } + cfg.Normalize() return &App{ cfg: cfg, configFile: store, @@ -297,7 +308,13 @@ func (a *App) runUploadPipeline(provider domain.OSSProvider, pngBytes []byte) er FallbackDir: filepath.Join(userPicturesDir(), "SnapGo"), 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 { @@ -332,6 +349,7 @@ func (a *App) runSaveRemotePipeline(pngBytes []byte) error { err := fmt.Errorf("SSH host/user is not configured") slog.Warn("save-remote rejected: ssh not configured", "host", cfg.SSH.Host, "user", cfg.SSH.User) + a.emitOperationStatus("save-remote", "需要配置 SSH", err.Error(), "error") wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) return err } @@ -355,7 +373,78 @@ func (a *App) runSaveRemotePipeline(pngBytes []byte) error { Notifier: &runtimeNotifier{ctx: a.ctx}, 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) { @@ -408,6 +497,7 @@ func (a *App) GetConfig() domain.AppConfig { // SaveConfig persists the supplied configuration and re-registers the hotkey // if it changed. func (a *App) SaveConfig(cfg domain.AppConfig) error { + cfg.Normalize() a.mu.Lock() prev := a.cfg a.cfg = cfg @@ -705,6 +795,35 @@ func (a *App) SaveRegionToRemote(result CaptureResult) error { 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 // SaveRegionToRemote. The AppKit panel is already closed by the time this // runs (see saveRemoteSelection in native_overlay_darwin.go), so we only @@ -735,6 +854,33 @@ func (a *App) SaveNativeRegionToRemote(result CaptureResult) error { 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 { if raw == "" { return nil @@ -784,6 +930,27 @@ func (a *App) QuitApp() { 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. type runtimeNotifier struct{ ctx context.Context } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a1d3e4b..7794ea8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,9 +3,7 @@ * App shell — switches between two distinct UI modes that share the same * Wails window: * - * • "settings" : full settings UI (self-drawn title bar + sidebar + form). - * Self-drawn because the window is now Frameless to make - * the overlay paint edge-to-edge. + * • "settings" : full settings UI (native title bar + sidebar + form). * • "overlay" : Snipaste-style region picker that fills the whole * primary display. * @@ -24,6 +22,7 @@ import { CopyRegionImage, SaveRegionImage, SaveRegionToRemote, + SummarizeRegion, CancelRegion, } from '../wailsjs/go/main/App' @@ -39,7 +38,7 @@ const mode = ref('settings') // `SettingsTab` is hoisted to the App shell so the sidebar (which lives // here) and the inner SettingsView (which renders the matching card) can // share a single source of truth without an event bus. -type SettingsTab = 'general' | 's3' | 'ssh' +type SettingsTab = 'general' | 's3' | 'ssh' | 'llm' const activeTab = ref('general') // Sidebar entries are declarative so adding a destination type later is @@ -48,6 +47,7 @@ const sidebarItems: Array<{ id: SettingsTab; label: string }> = [ { id: 'general', label: 'General' }, { id: 's3', label: 'S3' }, { id: 'ssh', label: 'SSH' }, + { id: 'llm', label: 'LLM' }, ] interface OverlayPayload { @@ -62,6 +62,14 @@ const capturing = ref(false) const toast = ref<{ kind: 'success' | 'error'; text: string } | null>(null) 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) { toast.value = { kind, text } window.clearTimeout(toastTimer) @@ -70,6 +78,21 @@ function showToast(kind: 'success' | 'error', text: string) { }, 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() { try { await RetryRegisterHotkey() @@ -168,6 +191,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() { mode.value = 'settings' overlayPayload.value = null @@ -194,7 +239,12 @@ onMounted(() => { capturing.value = false }) 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) => { showToast('error', reason) @@ -205,6 +255,7 @@ onMounted(() => { EventsOn('hotkey:error', (reason: string) => { hotkeyStatus.value = { state: 'error', reason } }) + EventsOn('operation:status', updateOperationStatus) }) onUnmounted(() => { @@ -216,6 +267,7 @@ onUnmounted(() => { EventsOff('upload:failure') EventsOff('hotkey:ready') EventsOff('hotkey:error') + EventsOff('operation:status') }) @@ -229,6 +281,7 @@ onUnmounted(() => { @copy="onOverlayCopy" @save="onOverlaySave" @save-remote="onOverlaySaveRemote" + @summarize="onOverlaySummarize" @cancel="onOverlayCancel" /> @@ -264,6 +317,20 @@ onUnmounted(() => { +
+ + + {{ operationStatus.state === 'success' ? 'OK' : '!' }} + +
+ {{ operationStatus.phase }} +

{{ operationStatus.message }}

+
+
@@ -371,6 +438,66 @@ onUnmounted(() => { 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); + } +} + @media (prefers-color-scheme: dark) { .app-root { background: #1c1d22; diff --git a/frontend/src/views/CaptureOverlay.vue b/frontend/src/views/CaptureOverlay.vue index 3fdf4e0..468e0ad 100644 --- a/frontend/src/views/CaptureOverlay.vue +++ b/frontend/src/views/CaptureOverlay.vue @@ -51,6 +51,10 @@ const emit = defineEmits<{ e: 'save-remote', payload: { rect: Rect; annotations: Annotation[] } ): void + ( + e: 'summarize', + payload: { rect: Rect; annotations: Annotation[] } + ): void (e: 'cancel'): void }>() @@ -112,7 +116,7 @@ const sizeLabel = computed(() => { }) const MARK_TOOLBAR_W = 190 -const ACTION_TOOLBAR_W = 180 +const ACTION_TOOLBAR_W = 216 const TOOLBAR_GROUP_GAP = 8 const rightToolbarPos = computed(() => { @@ -363,7 +367,11 @@ function onSaveRemote() { 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 const payload = { rect: { ...rect.value }, @@ -373,6 +381,7 @@ function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote') { if (action === 'copy') emit('copy', payload) if (action === 'save') emit('save', payload) if (action === 'save-remote') emit('save-remote', payload) + if (action === 'summarize') emit('summarize', payload) } function onCancel() { @@ -593,8 +602,8 @@ onUnmounted(() => { />