Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ docs/private
# AI 도구 로컬 설정 (팀 공유는 .claude/settings.json)
.claude/settings.local.json
CLAUDE.local.md

# scripts/measure-latency.sh 결과 (원자료는 PR에 요약만)
latency-*.csv
7 changes: 6 additions & 1 deletion Paxo/AI/GeminiService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ struct GeminiService {
do {
(data, response) = try await Self.session.data(for: request)
} catch let error as URLError {
throw GeminiError.network(error)
throw Self.classify(error)
}

guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
Expand All @@ -127,6 +127,11 @@ struct GeminiService {
return text
}

/// 사용자가 취소한 요청은 네트워크 오류가 아니다. 오류 화면으로 보내지 않도록 취소로 구분한다.
static func classify(_ error: URLError) -> Error {
error.code == .cancelled ? CancellationError() : GeminiError.network(error)
}

private static func serverMessage(from data: Data) -> String? {
struct ErrorEnvelope: Decodable {
struct Inner: Decodable { let message: String? }
Expand Down
147 changes: 126 additions & 21 deletions Paxo/AppState.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AppKit
import Combine
import ImageIO
import ServiceManagement

@MainActor
Expand All @@ -20,6 +21,8 @@ final class AppState: ObservableObject {
@Published private(set) var phase: SolvePhase = .idle
@Published private(set) var current: SolveResult?
@Published private(set) var history: [SolveResult] = []
/// 지금 기다리는 요청이 시작된 시각. 대기 화면이 경과 시간으로 안내 단계를 고른다.
@Published private(set) var waitingSince: Date?

// MARK: - 설정

Expand All @@ -46,6 +49,10 @@ final class AppState: ObservableObject {
@Published var toastDuration: Double {
didSet { UserDefaults.standard.set(toastDuration, forKey: "toastDuration") }
}
/// 토스트가 기다리는 동안의 모습 (단계 안내 / 간단히)
@Published var toastWaitingStyle: ToastWaitingStyle {
didSet { UserDefaults.standard.set(toastWaitingStyle.rawValue, forKey: "toastWaitingStyle") }
}
/// 전역 단축키 (설정에서 변경 가능)
@Published var hotkey: HotkeySpec {
didSet {
Expand Down Expand Up @@ -117,6 +124,11 @@ final class AppState: ObservableObject {
return imageCache[current.id] != nil
}

/// 대기 화면에 보여줄 캡처 미리보기. 결과 ID로 찾으므로 기록을 바꾸면 따라 바뀐다.
var currentThumbnail: NSImage? {
current.flatMap { thumbnailCache[$0.id] }
}

private let hotkeyManager = HotkeyManager()
private let capturer = ScreenCapturer()
private let resultPanel = ResultPanelController()
Expand All @@ -127,7 +139,10 @@ final class AppState: ObservableObject {
private let usage = UsageTracker()
/// 세션 내 캡처 이미지 캐시 (SolveResult.id 키). 해설 재생성용. 디스크 저장 안 함.
private var imageCache: [UUID: Data] = [:]
private var thumbnailCache: [UUID: NSImage] = [:]
private var imageCacheOrder: [UUID] = []
private let requestGate = SolveRequestGate()
private var activeTask: Task<Void, Never>?
private static let imageCacheLimit = 8
/// 마지막으로 캡처한 화면 — 결과 패널/토스트를 같은 화면에 띄우기 위함
private var lastCaptureScreen: NSScreen?
Expand All @@ -142,6 +157,7 @@ final class AppState: ObservableObject {
resultDisplayMode =
ResultDisplayMode(rawValue: UserDefaults.standard.string(forKey: "resultDisplayMode") ?? "") ?? .panel
toastDuration = UserDefaults.standard.object(forKey: "toastDuration") as? Double ?? 4.0
toastWaitingStyle = ToastWaitingStyle.restored(from: UserDefaults.standard.string(forKey: "toastWaitingStyle"))
if let data = UserDefaults.standard.data(forKey: "hotkeySpec"),
let spec = try? JSONDecoder().decode(HotkeySpec.self, from: data)
{
Expand Down Expand Up @@ -202,7 +218,7 @@ final class AppState: ObservableObject {
return
}
phase = .capturing
Task { await runSolve() }
activeTask = Task { await runSolve() }
}

func showPaywall() {
Expand All @@ -217,33 +233,82 @@ final class AppState: ObservableObject {
func showFromHistory(_ item: SolveResult) {
// 기록 다시 보기는 항상 패널로 (해설 확인·재생성 가능).
// 같은 세션에 캡처 이미지가 캐시에 남아 있으면 "해설 보기"로 재생성 가능.
// 진행 중인 요청은 먼저 끊는다. 두면 늦은 응답이 방금 연 기록을 덮어쓴다.
cancelSolve()
toastDismissTask?.cancel()
toast.dismiss()
toast.dismiss(animated: false)
current = item
phase = item.explanation == nil ? .answerReady : .done
resultPanel.show(appState: self, on: Self.screenUnderMouse())
}

func requestExplanation() {
Task { await runExplanation() }
// 이미 날아가는 요청이 있으면 연타를 무시한다
guard !requestGate.isActive else { return }
let requestID = requestGate.begin()
activeTask = Task { await runExplanation(requestID: requestID) }
}

/// 대기 중인 요청을 취소한다.
/// 정답 대기 중이면 풀이 자체를 접는다 — 정답을 확정하지 않았으므로 무료 횟수는 그대로다.
/// 해설 대기 중이면 정답은 남기고 "해설 보기"로 다시 요청할 수 있게 둔다.
func cancelSolve() {
guard requestGate.isActive else { return }
requestGate.invalidate()
activeTask?.cancel()
activeTask = nil
waitingSince = nil
switch phase {
case .solvingAnswer:
phase = .idle
resultPanel.hide()
toast.dismiss(animated: false)
case .solvingExplanation:
phase = .answerReady
default:
break
}
}

private func runSolve() async {
toastDismissTask?.cancel()
// 작은 정답 토스트가 남은 채 넓은 대기 화면으로 바뀌면 잘린다. 대기 토스트는 요청 시작 때 새 크기로 다시 뜬다.
toast.dismiss(animated: false)
// 영역을 고르는 동안 기록으로 화면을 바꿀 수 있으므로 소유권은 캡처 전부터 잡는다
let requestID = requestGate.begin()
let capture: ScreenCapturer.Capture
do {
guard let capture = try await capturer.captureInteractive(mode: captureMode) else {
let captured = try await capturer.captureInteractive(mode: captureMode)
guard requestGate.isCurrent(requestID) else { return }
guard let captured else {
requestGate.finish(requestID)
phase = .idle
return
}
lastCaptureScreen = capture.screen
let result = SolveResult(preset: preset)
cacheImage(capture.data, for: result.id)
current = result
phase = .solvingAnswer
presentSolving()
capture = captured
} catch {
guard requestGate.isCurrent(requestID) else { return }
requestGate.finish(requestID)
toast.dismiss(animated: false)
phase = .failedAnswer(errorMessage(from: error))
resultPanel.show(appState: self, on: lastCaptureScreen)
return
}

lastCaptureScreen = capture.screen
let result = SolveResult(preset: preset)
cacheImage(capture.data, for: result.id)
current = result
waitingSince = Date()
phase = .solvingAnswer
presentSolving()

do {
let answer = try await makeService()
.answer(imageData: capture.data, preset: preset)
.answer(imageData: capture.data, preset: result.preset)
// 취소가 먼저 처리됐으면 응답을 버린다. 정답을 확정하지 않았으니 차감도 하지 않는다.
guard requestGate.isCurrent(requestID) else { return }
waitingSince = nil
current?.answer = answer
phase = .answerReady
upsertHistory()
Expand All @@ -255,13 +320,24 @@ final class AppState: ObservableObject {
}

if resultDisplayMode == .toast {
requestGate.finish(requestID)
toast.show(appState: self, on: lastCaptureScreen)
scheduleToastDismiss()
} else if !quickCheckMode {
await runExplanation()
await runExplanation(requestID: requestID)
} else {
requestGate.finish(requestID)
}
} catch {
toast.dismiss()
guard requestGate.isCurrent(requestID) else { return }
requestGate.finish(requestID)
waitingSince = nil
toast.dismiss(animated: false)
if error is CancellationError {
phase = .idle
resultPanel.hide()
return
}
phase = .failedAnswer(errorMessage(from: error))
resultPanel.show(appState: self, on: lastCaptureScreen)
}
Expand All @@ -270,7 +346,7 @@ final class AppState: ObservableObject {
private func presentSolving() {
switch resultDisplayMode {
case .panel:
toast.dismiss()
toast.dismiss(animated: false)
resultPanel.show(appState: self, on: lastCaptureScreen)
case .toast:
resultPanel.hide()
Expand All @@ -281,41 +357,70 @@ final class AppState: ObservableObject {
private func scheduleToastDismiss() {
toastDismissTask?.cancel()
let seconds = toastDuration
let resultID = current?.id
toastDismissTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
guard !Task.isCancelled else { return }
self?.toast.dismiss()
self?.phase = .idle
// 그 사이 새 풀이가 시작됐으면 오래된 타이머가 새 화면을 지우지 않게 한다
guard !Task.isCancelled, let self, self.current?.id == resultID else { return }
self.toast.dismiss()
self.phase = .idle
}
}

private func runExplanation() async {
private func runExplanation(requestID: UUID) async {
// 예약만 되고 실행 전에 취소된 작업이 새 화면을 건드리지 않게 한다
guard requestGate.isCurrent(requestID), !Task.isCancelled else { return }
guard let current,
let image = imageCache[current.id],
let answer = current.answer,
current.explanation == nil
else { return }
else {
requestGate.finish(requestID)
return
}
waitingSince = Date()
phase = .solvingExplanation
do {
let explanation = try await makeService()
.explain(imageData: image, answer: answer, preset: preset)
.explain(imageData: image, answer: answer, preset: current.preset)
guard requestGate.isCurrent(requestID) else { return }
requestGate.finish(requestID)
waitingSince = nil
guard self.current?.id == current.id else { return }
self.current?.explanation = explanation
phase = .done
upsertHistory()
} catch {
phase = .failedExplanation(errorMessage(from: error))
guard requestGate.isCurrent(requestID) else { return }
requestGate.finish(requestID)
waitingSince = nil
phase = error is CancellationError ? .answerReady : .failedExplanation(errorMessage(from: error))
}
}

private func cacheImage(_ data: Data, for id: UUID) {
imageCache[id] = data
thumbnailCache[id] = Self.makeThumbnail(from: data)
imageCacheOrder.append(id)
while imageCacheOrder.count > Self.imageCacheLimit {
let old = imageCacheOrder.removeFirst()
imageCache[old] = nil
thumbnailCache[old] = nil
}
}

/// 대기 화면용 작은 미리보기. 원본(최대 2000px)을 그대로 쥐면 캐시 8장이 메모리를 크게 먹는다.
private static func makeThumbnail(from data: Data) -> NSImage? {
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: 240,
]
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil }
return NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height))
}

static func screenUnderMouse() -> NSScreen? {
let location = NSEvent.mouseLocation
return NSScreen.screens.first { $0.frame.contains(location) }
Expand Down
93 changes: 93 additions & 0 deletions Paxo/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,96 @@ struct SolveResult: Identifiable, Codable, Equatable {
self.preset = preset
}
}

/// 응답을 기다리는 동안의 안내 단계.
/// 스피너만 돌면 멈춘 것처럼 느껴져서, 기다린 시간에 맞춰 문구를 바꾸고 오래 걸리면 취소를 연다.
enum WaitingStage: CaseIterable, Equatable {
case reading
case thinking
case finishing
case slow
case explaining

/// 이 시간을 넘기면 오래 걸린다고 알린다
static let slowThreshold: TimeInterval = 15

/// 취소를 여는 시점. 캡처 직후 실수로 눌러 멀쩡한 요청을 끊는 것만 막는다.
static let cancelThreshold: TimeInterval = 3

/// 정답 대기. 시계가 뒤로 가서 음수가 나와도 첫 단계로 본다.
static func answerStage(elapsed: TimeInterval) -> WaitingStage {
switch elapsed {
case ..<3: return .reading
case ..<8: return .thinking
case ..<slowThreshold: return .finishing
default: return .slow
}
}

static func explanationStage(elapsed: TimeInterval) -> WaitingStage {
elapsed < slowThreshold ? .explaining : .slow
}

static func allowsCancel(elapsed: TimeInterval) -> Bool {
elapsed >= cancelThreshold
}

/// 실제 진행률을 모르므로 "곧 끝난다"고 약속하는 문구는 쓰지 않는다
var message: String {
switch self {
case .reading: return "문제를 읽고 있어요"
case .thinking: return "풀이를 떠올리는 중이에요"
case .finishing: return "답을 정리하고 있어요"
case .slow: return "조금 오래 걸리고 있어요"
case .explaining: return "해설을 정리하고 있어요"
}
}

var detail: String? {
self == .slow ? "복잡한 문제일수록 시간이 더 걸려요." : nil
}

var symbolName: String {
switch self {
case .reading: return "eye"
case .thinking: return "lightbulb"
case .finishing: return "pencil"
case .slow: return "hourglass"
case .explaining: return "book"
}
}
}

/// 토스트가 기다리는 동안 보여줄 모습
enum ToastWaitingStyle: String, CaseIterable, Codable, Identifiable {
case detailed
case simple

var id: String { rawValue }

var displayName: String {
switch self {
case .detailed: return "단계 안내 (기본)"
case .simple: return "간단히"
}
}

/// 저장된 값이 없거나 알 수 없으면 개선된 안내를 기본으로 쓴다
static func restored(from rawValue: String?) -> ToastWaitingStyle {
rawValue.flatMap(ToastWaitingStyle.init(rawValue:)) ?? .detailed
}
}

/// 토스트의 원형 진행 표시에 쓰는 추정 진행률.
/// 서버는 답을 한 번에 보내서 실제 진행률을 알 수 없다. 기다린 시간으로 처음엔 빠르게, 점점 느리게 채우고
/// 답이 오기 전에는 가득 차지 않도록 90%에서 멈춘다.
enum WaitingProgress {
static let ceiling = 0.9
/// 곡선이 차오르는 속도. 측정 스크립트로 정답 응답 시간을 잰 뒤 조정한다.
static let timeConstant: TimeInterval = 5

static func estimatedFraction(elapsed: TimeInterval) -> Double {
guard elapsed > 0 else { return 0 }
return ceiling * (1 - exp(-elapsed / timeConstant))
}
}
Loading
Loading