feat: Ask AI 刘海弹窗输出模式
- 新增 AssistOutputMode 配置(inject/notch) - Assist 设置界面添加输出方式弹出菜单 - 新建 NotchDisplayManager:DynamicNotchKit 弹窗,支持滚动、复制、15s 自动消失 - DynamicNotchKit 固化为本地依赖,修复假刘海高度问题(0→内容贴近顶部)
This commit is contained in:
parent
d43d1dbb38
commit
c708ff32ac
27
Dependencies/DynamicNotchKit/Package.swift
vendored
Normal file
27
Dependencies/DynamicNotchKit/Package.swift
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
// swift-tools-version: 5.9
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "DynamicNotchKit",
|
||||
platforms: [
|
||||
.macOS(.v12)
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||
.library(
|
||||
name: "DynamicNotchKit",
|
||||
targets: ["DynamicNotchKit"]),
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||
// Targets can depend on other targets in this package and products from dependencies.
|
||||
.target(
|
||||
name: "DynamicNotchKit",
|
||||
path: "Sources"),
|
||||
.testTarget(
|
||||
name: "DynamicNotchKitTests",
|
||||
dependencies: ["DynamicNotchKit"]),
|
||||
]
|
||||
)
|
||||
29
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/BlurModifier.swift
vendored
Normal file
29
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/BlurModifier.swift
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
//
|
||||
// BlurModifier.swift
|
||||
// DynamicNotchKit
|
||||
//
|
||||
// Created by Kai Azim on 2024-08-30.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// This transition is used to animate the blur effect
|
||||
private struct BlurModifier: ViewModifier {
|
||||
public let isIdentity: Bool
|
||||
public var intensity: CGFloat
|
||||
|
||||
public func body(content: Content) -> some View {
|
||||
content
|
||||
.blur(radius: isIdentity ? intensity : 0)
|
||||
.opacity(isIdentity ? 0 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyTransition {
|
||||
static var blur: AnyTransition {
|
||||
.modifier(
|
||||
active: BlurModifier(isIdentity: true, intensity: 5),
|
||||
identity: BlurModifier(isIdentity: false, intensity: 5)
|
||||
)
|
||||
}
|
||||
}
|
||||
205
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotch.swift
vendored
Normal file
205
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotch.swift
vendored
Normal file
@ -0,0 +1,205 @@
|
||||
//
|
||||
// DynamicNotch.swift
|
||||
// DynamicNotchKit
|
||||
//
|
||||
// Created by Kai Azim on 2023-08-24.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - DynamicNotch
|
||||
|
||||
public class DynamicNotch<Content>: ObservableObject where Content: View {
|
||||
|
||||
public var windowController: NSWindowController? // Make public in case user wants to modify the NSPanel
|
||||
|
||||
// Content Properties
|
||||
@Published var content: () -> Content
|
||||
@Published var contentID: UUID
|
||||
@Published var isVisible: Bool = false // Used to animate the fading in/out of the user's view
|
||||
|
||||
// Notch Size
|
||||
@Published var notchWidth: CGFloat = 0
|
||||
@Published var notchHeight: CGFloat = 0
|
||||
|
||||
// Notch Closing Properties
|
||||
@Published var isMouseInside: Bool = false // If the mouse is inside, the notch will not auto-hide
|
||||
private var timer: Timer?
|
||||
var workItem: DispatchWorkItem?
|
||||
private var subscription: AnyCancellable?
|
||||
|
||||
// Notch Style
|
||||
private var notchStyle: Style = .notch
|
||||
public enum Style {
|
||||
case notch
|
||||
case floating
|
||||
case auto
|
||||
}
|
||||
|
||||
private var maxAnimationDuration: Double = 0.8 // This is a timer to de-init the window after closing
|
||||
var animation: Animation {
|
||||
if #available(macOS 14.0, *), notchStyle == .notch {
|
||||
Animation.spring(.bouncy(duration: 0.4))
|
||||
} else {
|
||||
Animation.timingCurve(0.16, 1, 0.3, 1, duration: 0.7)
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes a new DynamicNotch with custom content and style.
|
||||
/// - Parameters:
|
||||
/// - content: A SwiftUI View
|
||||
/// - style: The popover's style. If unspecified, the style will be automatically set according to the screen.
|
||||
public init(contentID: UUID = .init(), style: DynamicNotch.Style = .auto, @ViewBuilder content: @escaping () -> Content) {
|
||||
self.contentID = contentID
|
||||
self.content = content
|
||||
self.notchStyle = style
|
||||
self.subscription = NotificationCenter.default
|
||||
.publisher(for: NSApplication.didChangeScreenParametersNotification)
|
||||
.sink { [weak self] _ in
|
||||
guard let self, let screen = NSScreen.screens.first else { return }
|
||||
initializeWindow(screen: screen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
public extension DynamicNotch {
|
||||
|
||||
/// Set this DynamicNotch's content.
|
||||
/// - Parameter content: A SwiftUI View
|
||||
func setContent(contentID: UUID = .init(), content: @escaping () -> Content) {
|
||||
self.content = content
|
||||
self.contentID = .init()
|
||||
}
|
||||
|
||||
/// Show the DynamicNotch.
|
||||
/// - Parameters:
|
||||
/// - screen: Screen to show on. Default is the primary screen.
|
||||
/// - time: Time to show in seconds. If 0, the DynamicNotch will stay visible until `hide()` is called.
|
||||
func show(on screen: NSScreen = NSScreen.screens[0], for time: Double = 0) {
|
||||
func scheduleHide(_ time: Double) {
|
||||
let workItem = DispatchWorkItem { self.hide() }
|
||||
self.workItem?.cancel()
|
||||
self.workItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + time, execute: workItem)
|
||||
}
|
||||
|
||||
guard !isVisible else {
|
||||
if time > 0 {
|
||||
scheduleHide(time)
|
||||
}
|
||||
return
|
||||
}
|
||||
timer?.invalidate()
|
||||
|
||||
initializeWindow(screen: screen)
|
||||
DispatchQueue.main.async {
|
||||
withAnimation(self.animation) {
|
||||
self.isVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
if time != 0 {
|
||||
scheduleHide(time)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide the DynamicNotch.
|
||||
func hide(ignoreMouse: Bool = false) {
|
||||
guard isVisible else { return }
|
||||
|
||||
if !ignoreMouse, isMouseInside {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
self.hide()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
withAnimation(animation) {
|
||||
self.isVisible = false
|
||||
}
|
||||
|
||||
timer = Timer.scheduledTimer(withTimeInterval: maxAnimationDuration, repeats: false) { _ in
|
||||
self.deinitializeWindow()
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle the DynamicNotch's visibility.
|
||||
func toggle() {
|
||||
if isVisible {
|
||||
hide()
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the cursor is inside the screen's notch area.
|
||||
/// - Returns: If the cursor is inside the notch area.
|
||||
static func checkIfMouseIsInNotch() -> Bool {
|
||||
guard let screen = NSScreen.screenWithMouse else {
|
||||
return false
|
||||
}
|
||||
|
||||
let notchWidth: CGFloat = 300
|
||||
let notchHeight: CGFloat = screen.frame.maxY - screen.visibleFrame.maxY // menubar height
|
||||
|
||||
let notchFrame = screen.notchFrame ?? NSRect(
|
||||
x: screen.frame.midX - (notchWidth / 2),
|
||||
y: screen.frame.maxY - notchHeight,
|
||||
width: notchWidth,
|
||||
height: notchHeight
|
||||
)
|
||||
|
||||
return notchFrame.contains(NSEvent.mouseLocation)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
extension DynamicNotch {
|
||||
|
||||
func refreshNotchSize(_ screen: NSScreen) {
|
||||
if let notchSize = screen.notchSize {
|
||||
notchWidth = notchSize.width
|
||||
notchHeight = notchSize.height
|
||||
} else {
|
||||
notchWidth = 300
|
||||
notchHeight = 0
|
||||
}
|
||||
}
|
||||
|
||||
func initializeWindow(screen: NSScreen) {
|
||||
// so that we don't have a duplicate window
|
||||
deinitializeWindow()
|
||||
|
||||
refreshNotchSize(screen)
|
||||
|
||||
let view: NSView = {
|
||||
switch notchStyle {
|
||||
case .notch: NSHostingView(rootView: NotchView(dynamicNotch: self).foregroundStyle(.white))
|
||||
case .floating: NSHostingView(rootView: NotchlessView(dynamicNotch: self))
|
||||
case .auto: screen.hasNotch ? NSHostingView(rootView: NotchView(dynamicNotch: self).foregroundStyle(.white)) : NSHostingView(rootView: NotchlessView(dynamicNotch: self))
|
||||
}
|
||||
}()
|
||||
|
||||
let panel = DynamicNotchPanel(
|
||||
contentRect: .zero,
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: true
|
||||
)
|
||||
panel.contentView = view
|
||||
panel.orderFrontRegardless()
|
||||
panel.setFrame(screen.frame, display: false)
|
||||
|
||||
windowController = .init(window: panel)
|
||||
}
|
||||
|
||||
func deinitializeWindow() {
|
||||
guard let windowController else { return }
|
||||
windowController.close()
|
||||
self.windowController = nil
|
||||
}
|
||||
}
|
||||
169
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchInfo.swift
vendored
Normal file
169
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchInfo.swift
vendored
Normal file
@ -0,0 +1,169 @@
|
||||
//
|
||||
// DynamicNotchInfoWindow.swift
|
||||
// DynamicNotchKit
|
||||
//
|
||||
// Created by Kai Azim on 2023-08-25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
internal final class DynamicNotchInfoPublisher<IconView>: ObservableObject where IconView: View {
|
||||
@Published var iconView: IconView?
|
||||
@Published var iconColor: Color
|
||||
@Published var title: String
|
||||
@Published var description: String?
|
||||
@Published var textColor: Color
|
||||
|
||||
init(icon: Image?, iconColor: Color, title: String, description: String? = nil, textColor: Color) where IconView == Image {
|
||||
self.iconView = icon
|
||||
self.iconColor = iconColor
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.textColor = textColor
|
||||
}
|
||||
|
||||
init(title: String, description: String? = nil, textColor: Color, iconView: (() -> IconView)?) {
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.textColor = textColor
|
||||
self.iconColor = .clear
|
||||
self.iconView = iconView?()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func publish(icon: Image?, iconColor: Color, title: String, description: String?, textColor: Color) where IconView == Image {
|
||||
self.iconView = icon
|
||||
self.iconColor = iconColor
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.textColor = textColor
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func publish(icon: IconView?, title: String, description: String?) {
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.iconView = iconView
|
||||
}
|
||||
}
|
||||
|
||||
public class DynamicNotchInfo<IconView> where IconView: View {
|
||||
private var internalDynamicNotch: DynamicNotch<InfoView>
|
||||
private let publisher: DynamicNotchInfoPublisher<IconView>
|
||||
|
||||
public init(contentID: UUID = .init(), icon: Image! = nil, title: String, description: String? = nil, iconColor: Color = .white, textColor: Color = .white, style: DynamicNotch<InfoView>.Style = .auto) where IconView == Image {
|
||||
let publisher = DynamicNotchInfoPublisher(icon: icon, iconColor: iconColor, title: title, description: description, textColor: textColor)
|
||||
self.publisher = publisher
|
||||
internalDynamicNotch = DynamicNotch(contentID: contentID, style: style) {
|
||||
InfoView(publisher: publisher)
|
||||
}
|
||||
}
|
||||
|
||||
public init(contentID: UUID = .init(), title: String, description: String? = nil, textColor: Color = .white, style: DynamicNotch<InfoView>.Style = .auto, iconView: (() -> IconView)? = nil) {
|
||||
let publisher = DynamicNotchInfoPublisher<IconView>(title: title, description: description, textColor: textColor, iconView: iconView)
|
||||
self.publisher = publisher
|
||||
internalDynamicNotch = DynamicNotch(contentID: contentID, style: style) {
|
||||
InfoView(publisher: publisher)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func setContent(contentID: UUID = .init(), icon: Image? = nil, title: String, description: String? = nil, iconColor: Color = .white, textColor: Color = .white) where IconView == Image {
|
||||
withAnimation {
|
||||
publisher.publish(icon: icon, iconColor: iconColor, title: title, description: description, textColor: textColor)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func setContent(contentID: UUID = .init(), title: String, description: String? = nil, iconView: IconView? = nil) {
|
||||
withAnimation {
|
||||
publisher.publish(icon: iconView, title: title, description: description)
|
||||
}
|
||||
}
|
||||
|
||||
public func show(on screen: NSScreen = NSScreen.screens[0], for time: Double = 0) {
|
||||
internalDynamicNotch.show(on: screen, for: time)
|
||||
}
|
||||
|
||||
public func hide() {
|
||||
internalDynamicNotch.hide()
|
||||
}
|
||||
|
||||
public func toggle() {
|
||||
internalDynamicNotch.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
public extension DynamicNotchInfo {
|
||||
struct InfoView: View {
|
||||
private var publisher: DynamicNotchInfoPublisher<IconView>
|
||||
|
||||
init(publisher: DynamicNotchInfoPublisher<IconView>) {
|
||||
self.publisher = publisher
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
InfoImageView(publisher: publisher)
|
||||
InfoTextView(publisher: publisher)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(height: 40)
|
||||
}
|
||||
}
|
||||
|
||||
struct InfoImageView: View {
|
||||
@ObservedObject private var publisher: DynamicNotchInfoPublisher<IconView>
|
||||
|
||||
init(publisher: DynamicNotchInfoPublisher<IconView>) {
|
||||
self.publisher = publisher
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
if let image = publisher.iconView as? Image {
|
||||
image
|
||||
.resizable()
|
||||
.foregroundStyle(publisher.iconColor)
|
||||
.padding(3)
|
||||
.scaledToFit()
|
||||
} else if let iconView = publisher.iconView {
|
||||
iconView
|
||||
} else {
|
||||
Image(nsImage: NSApplication.shared.applicationIconImage)
|
||||
.resizable()
|
||||
.padding(-5)
|
||||
.scaledToFit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct InfoTextView: View {
|
||||
@ObservedObject private var publisher: DynamicNotchInfoPublisher<IconView>
|
||||
|
||||
init(publisher: DynamicNotchInfoPublisher<IconView>) {
|
||||
self.publisher = publisher
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: publisher.description != nil ? nil : 0) {
|
||||
Text(publisher.title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(publisher.textColor)
|
||||
Text(publisher.description ?? "")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(publisher.textColor.opacity(0.8))
|
||||
.opacity(publisher.description != nil ? 1 : 0)
|
||||
.frame(maxHeight: publisher.description != nil ? nil : 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DynamicNotchInfo_Previews: PreviewProvider {
|
||||
static let publisher = DynamicNotchInfoPublisher(icon: nil, iconColor: .blue, title: "testing", textColor: .black)
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
DynamicNotchInfo.InfoView(publisher: publisher)
|
||||
}
|
||||
}
|
||||
}
|
||||
22
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchPanel.swift
vendored
Normal file
22
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchPanel.swift
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
//
|
||||
// DynamicNotchPanel.swift
|
||||
// DynamicNotchKit
|
||||
//
|
||||
// Created by <Huy D.> on 2024-11-01.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
|
||||
class DynamicNotchPanel: NSPanel {
|
||||
override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) {
|
||||
super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
|
||||
self.hasShadow = false
|
||||
self.backgroundColor = .clear
|
||||
self.level = .screenSaver
|
||||
self.collectionBehavior = [.canJoinAllSpaces, .stationary]
|
||||
}
|
||||
|
||||
override var canBecomeKey: Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
168
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchProgress.swift
vendored
Normal file
168
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchProgress.swift
vendored
Normal file
@ -0,0 +1,168 @@
|
||||
//
|
||||
// DynamicNotchProgress.swift
|
||||
// DynamicNotchKit
|
||||
//
|
||||
// Created by Kai Azim on 2024-08-30.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public class DynamicNotchProgress {
|
||||
private var internalDynamicNotch: DynamicNotch<InfoView>
|
||||
|
||||
public init(progress: Binding<CGFloat>, title: String, description: String? = nil, progressText: Bool = false, progressBarColor: Color = .white, textColor: Color = .white, style: DynamicNotch<InfoView>.Style = .auto) {
|
||||
internalDynamicNotch = DynamicNotch(style: style) {
|
||||
InfoView(progress: progress, progressBarColor: progressBarColor, title: title, description: description, textColor: textColor, numberOverlay: progressText)
|
||||
}
|
||||
}
|
||||
|
||||
public func setContent(progress: Binding<CGFloat>, title: String, description: String? = nil, progressText: Bool = false, progressBarColor: Color = .white, textColor: Color = .white) {
|
||||
internalDynamicNotch.setContent {
|
||||
InfoView(progress: progress, progressBarColor: progressBarColor, title: title, description: description, textColor: textColor, numberOverlay: progressText)
|
||||
}
|
||||
}
|
||||
|
||||
public init(progress: Binding<CGFloat>, title: String, description: String? = nil, iconOverlay: Image! = nil, progressBarColor: Color = .white, textColor: Color = .white, style: DynamicNotch<InfoView>.Style = .auto) {
|
||||
internalDynamicNotch = DynamicNotch(style: style) {
|
||||
InfoView(progress: progress, progressBarColor: progressBarColor, title: title, description: description, textColor: textColor, iconOverlay: iconOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
public func setContent(progress: Binding<CGFloat>, title: String, description: String? = nil, iconOverlay: Image! = nil, progressBarColor: Color = .white, textColor: Color = .white) {
|
||||
internalDynamicNotch.setContent {
|
||||
InfoView(progress: progress, progressBarColor: progressBarColor, title: title, description: description, textColor: textColor, iconOverlay: iconOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
public func show(on screen: NSScreen = NSScreen.screens[0], for time: Double = 0) {
|
||||
internalDynamicNotch.show(on: screen, for: time)
|
||||
}
|
||||
|
||||
public func hide() {
|
||||
internalDynamicNotch.hide()
|
||||
}
|
||||
|
||||
public func toggle() {
|
||||
internalDynamicNotch.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
public extension DynamicNotchProgress {
|
||||
struct InfoView: View {
|
||||
@Binding var progress: CGFloat
|
||||
let progressBarColor: Color
|
||||
|
||||
let title: String
|
||||
let description: String?
|
||||
let textColor: Color
|
||||
|
||||
let numberOverlay: Bool?
|
||||
let iconOverlay: Image?
|
||||
|
||||
init(progress: Binding<CGFloat>, progressBarColor: Color, title: String, description: String? = nil, textColor: Color, numberOverlay: Bool? = nil, iconOverlay: Image? = nil) {
|
||||
_progress = progress
|
||||
self.progressBarColor = progressBarColor
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.textColor = textColor
|
||||
self.numberOverlay = numberOverlay
|
||||
self.iconOverlay = iconOverlay
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
ProgressRing(to: $progress, color: progressBarColor)
|
||||
.overlay {
|
||||
if numberOverlay == true {
|
||||
if #available(macOS 13.0, *) {
|
||||
Text("\(Int(progress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundStyle(textColor)
|
||||
.contentTransition(.numericText()) // .numericText() is only available on macOS 13+
|
||||
.animation(.smooth, value: progress)
|
||||
} else {
|
||||
Text("\(Int(progress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundStyle(textColor)
|
||||
}
|
||||
}
|
||||
|
||||
if iconOverlay != nil {
|
||||
iconOverlay
|
||||
.foregroundStyle(progressBarColor)
|
||||
}
|
||||
}
|
||||
textView()
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(height: 40)
|
||||
}
|
||||
|
||||
func textView() -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(textColor)
|
||||
|
||||
if let description {
|
||||
Text(description)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(textColor.opacity(0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProgressRing: View {
|
||||
@Binding var target: CGFloat
|
||||
let color: Color
|
||||
let thickness: CGFloat
|
||||
|
||||
@State private var isLoaded = false
|
||||
|
||||
public init(to target: Binding<CGFloat>, color: Color = .white, thickness: CGFloat = 5) {
|
||||
self._target = target
|
||||
self.color = color
|
||||
self.thickness = thickness
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Circle()
|
||||
.stroke(style: StrokeStyle(lineWidth: thickness))
|
||||
.foregroundStyle(.tertiary)
|
||||
.overlay {
|
||||
// Foreground ring
|
||||
if #available(macOS 13.0, *) {
|
||||
Circle()
|
||||
.trim(from: 0, to: isLoaded ? target : 0)
|
||||
.stroke(
|
||||
color.gradient, // Gradient is only available on macOS 13+
|
||||
style: StrokeStyle(
|
||||
lineWidth: thickness,
|
||||
lineCap: .round
|
||||
)
|
||||
)
|
||||
.opacity(isLoaded ? 1 : 0)
|
||||
} else {
|
||||
Circle()
|
||||
.trim(from: 0, to: isLoaded ? target : 0)
|
||||
.stroke(
|
||||
color,
|
||||
style: StrokeStyle(
|
||||
lineWidth: thickness,
|
||||
lineCap: .round
|
||||
)
|
||||
)
|
||||
.opacity(isLoaded ? 1 : 0)
|
||||
}
|
||||
}
|
||||
.rotationEffect(.degrees(-90))
|
||||
.padding(thickness / 2)
|
||||
.task {
|
||||
withAnimation(Animation.timingCurve(0.22, 1, 0.36, 1, duration: 1)) {
|
||||
isLoaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NSScreen+Extensions.swift
vendored
Normal file
45
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NSScreen+Extensions.swift
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
//
|
||||
// NSScreen+Extensions.swift
|
||||
// DynamicNotchKit
|
||||
//
|
||||
// Created by Kai Azim on 2024-04-06.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension NSScreen {
|
||||
static var screenWithMouse: NSScreen? {
|
||||
let mouseLocation = NSEvent.mouseLocation
|
||||
let screens = NSScreen.screens
|
||||
let screenWithMouse = (screens.first { NSMouseInRect(mouseLocation, $0.frame, false) })
|
||||
|
||||
return screenWithMouse
|
||||
}
|
||||
|
||||
var hasNotch: Bool {
|
||||
auxiliaryTopLeftArea?.width != nil && auxiliaryTopRightArea?.width != nil
|
||||
}
|
||||
|
||||
var notchSize: NSSize? {
|
||||
guard
|
||||
let topLeftNotchpadding: CGFloat = auxiliaryTopLeftArea?.width,
|
||||
let topRightNotchpadding: CGFloat = auxiliaryTopRightArea?.width
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let notchHeight = safeAreaInsets.top
|
||||
let notchWidth = frame.width - topLeftNotchpadding - topRightNotchpadding + 10 // 10 is for the top rounded part of the notch, created by DynamicNotchKit
|
||||
return .init(width: notchWidth, height: notchHeight)
|
||||
}
|
||||
|
||||
var notchFrame: NSRect? {
|
||||
guard let notchSize else { return nil }
|
||||
return .init(
|
||||
x: frame.midX - (notchSize.width / 2),
|
||||
y: frame.maxY - notchSize.height,
|
||||
width: notchSize.width,
|
||||
height: notchSize.height
|
||||
)
|
||||
}
|
||||
}
|
||||
51
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchShape.swift
vendored
Normal file
51
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchShape.swift
vendored
Normal file
@ -0,0 +1,51 @@
|
||||
//
|
||||
// NotchShape.swift
|
||||
// DynamicNotchKit
|
||||
//
|
||||
// Created by Kai Azim on 2023-08-24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct NotchShape: Shape {
|
||||
var topCornerRadius: CGFloat {
|
||||
bottomCornerRadius - 5
|
||||
}
|
||||
|
||||
var bottomCornerRadius: CGFloat
|
||||
|
||||
init(cornerRadius: CGFloat? = nil) {
|
||||
if cornerRadius == nil {
|
||||
self.bottomCornerRadius = 11
|
||||
} else {
|
||||
self.bottomCornerRadius = cornerRadius!
|
||||
}
|
||||
}
|
||||
|
||||
var animatableData: CGFloat {
|
||||
get { bottomCornerRadius }
|
||||
set { bottomCornerRadius = newValue }
|
||||
}
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
|
||||
path.addArc(center: CGPoint(x: rect.minX, y: topCornerRadius), radius: topCornerRadius, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false)
|
||||
path.addLine(to: CGPoint(x: rect.minX + topCornerRadius, y: rect.maxY - bottomCornerRadius))
|
||||
path.addArc(center: CGPoint(x: rect.minX + topCornerRadius + bottomCornerRadius, y: rect.maxY - bottomCornerRadius), radius: bottomCornerRadius, startAngle: .degrees(180), endAngle: .degrees(90), clockwise: true)
|
||||
path.addLine(to: CGPoint(x: rect.maxX - topCornerRadius - bottomCornerRadius, y: rect.maxY))
|
||||
path.addArc(center: CGPoint(x: rect.maxX - topCornerRadius - bottomCornerRadius, y: rect.maxY - bottomCornerRadius), radius: bottomCornerRadius, startAngle: .degrees(90), endAngle: .degrees(0), clockwise: true)
|
||||
path.addLine(to: CGPoint(x: rect.maxX - topCornerRadius, y: rect.minY + bottomCornerRadius))
|
||||
|
||||
path.addArc(center: CGPoint(x: rect.maxX, y: topCornerRadius), radius: topCornerRadius, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false)
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NotchShape()
|
||||
.frame(width: 200, height: 32)
|
||||
.padding(10)
|
||||
}
|
||||
67
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchView.swift
vendored
Normal file
67
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchView.swift
vendored
Normal file
@ -0,0 +1,67 @@
|
||||
//
|
||||
// NotchView.swift
|
||||
// DynamicNotchKit
|
||||
//
|
||||
// Created by Kai Azim on 2023-08-24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct NotchView<Content>: View where Content: View {
|
||||
@ObservedObject var dynamicNotch: DynamicNotch<Content>
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
.frame(width: dynamicNotch.notchWidth + 20, height: dynamicNotch.notchHeight)
|
||||
// We add an extra 20 here because the corner radius of the top increases when shown.
|
||||
// (the remaining 10 has already been accounted for in refreshNotchSize)
|
||||
|
||||
dynamicNotch.content()
|
||||
.id(dynamicNotch.contentID)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) { Color.clear.frame(height: 15) }
|
||||
.safeAreaInset(edge: .leading, spacing: 0) { Color.clear.frame(width: 15) }
|
||||
.safeAreaInset(edge: .trailing, spacing: 0) { Color.clear.frame(width: 15) }
|
||||
|
||||
.blur(radius: dynamicNotch.isVisible ? 0 : 10)
|
||||
.scaleEffect(dynamicNotch.isVisible ? 1 : 0.8)
|
||||
.offset(y: dynamicNotch.isVisible ? 0 : 5)
|
||||
.padding(.horizontal, 15) // Small corner radius of the TOP of the notch
|
||||
.transition(.blur.animation(.smooth))
|
||||
}
|
||||
.fixedSize()
|
||||
.frame(minWidth: dynamicNotch.notchWidth)
|
||||
.onHover { hovering in
|
||||
dynamicNotch.isMouseInside = hovering
|
||||
}
|
||||
.background {
|
||||
Rectangle()
|
||||
.foregroundStyle(.black)
|
||||
.padding(-50) // The opening/closing animation can overshoot, so this makes sure that it's still black
|
||||
}
|
||||
.mask {
|
||||
GeometryReader { _ in // This helps with positioning everything
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
NotchShape(cornerRadius: dynamicNotch.isVisible ? 20 : nil)
|
||||
.frame(
|
||||
width: dynamicNotch.isVisible ? nil : dynamicNotch.notchWidth,
|
||||
height: dynamicNotch.isVisible ? nil : dynamicNotch.notchHeight
|
||||
)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
.shadow(color: .black.opacity(0.5), radius: dynamicNotch.isVisible ? 10 : 0)
|
||||
.animation(dynamicNotch.animation, value: dynamicNotch.contentID)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
56
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchlessView.swift
vendored
Normal file
56
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchlessView.swift
vendored
Normal file
@ -0,0 +1,56 @@
|
||||
//
|
||||
// NotchlessView.swift
|
||||
// DynamicNotchKit
|
||||
//
|
||||
// Created by Kai Azim on 2024-04-06.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct NotchlessView<Content>: View where Content: View {
|
||||
@ObservedObject var dynamicNotch: DynamicNotch<Content>
|
||||
@State var windowHeight: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
dynamicNotch.content()
|
||||
.id(dynamicNotch.contentID)
|
||||
.safeAreaInset(edge: .top, spacing: 0) { Color.clear.frame(height: 15) }
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) { Color.clear.frame(height: 15) }
|
||||
.safeAreaInset(edge: .leading, spacing: 0) { Color.clear.frame(width: 15) }
|
||||
.safeAreaInset(edge: .trailing, spacing: 0) { Color.clear.frame(width: 15) }
|
||||
.fixedSize()
|
||||
|
||||
.onHover { hovering in
|
||||
dynamicNotch.isMouseInside = hovering
|
||||
}
|
||||
.background {
|
||||
VisualEffectView(material: .popover, blendingMode: .behindWindow)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.strokeBorder(.quaternary, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.clipShape(.rect(cornerRadius: 20))
|
||||
.shadow(color: .black.opacity(0.5), radius: dynamicNotch.isVisible ? 10 : 0)
|
||||
.padding(20)
|
||||
.background {
|
||||
GeometryReader { geo in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
windowHeight = geo.size.height // This makes sure that the floating window FULLY slides off before disappearing
|
||||
}
|
||||
}
|
||||
}
|
||||
.offset(y: dynamicNotch.isVisible ? dynamicNotch.notchHeight : -windowHeight)
|
||||
.transition(.blur.animation(.smooth))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
27
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/VisualEffectView.swift
vendored
Normal file
27
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/VisualEffectView.swift
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
//
|
||||
// VisualEffectView.swift
|
||||
// DynamicNotchKit
|
||||
//
|
||||
// Created by Kai Azim on 2024-04-06.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct VisualEffectView: NSViewRepresentable {
|
||||
let material: NSVisualEffectView.Material
|
||||
let blendingMode: NSVisualEffectView.BlendingMode
|
||||
|
||||
func makeNSView(context _: Context) -> NSVisualEffectView {
|
||||
let visualEffectView = NSVisualEffectView()
|
||||
visualEffectView.material = material
|
||||
visualEffectView.blendingMode = blendingMode
|
||||
visualEffectView.state = NSVisualEffectView.State.active
|
||||
visualEffectView.isEmphasized = true
|
||||
return visualEffectView
|
||||
}
|
||||
|
||||
func updateNSView(_ visualEffectView: NSVisualEffectView, context _: Context) {
|
||||
visualEffectView.material = material
|
||||
visualEffectView.blendingMode = blendingMode
|
||||
}
|
||||
}
|
||||
@ -4,9 +4,13 @@ import PackageDescription
|
||||
let package = Package(
|
||||
name: "VoiceInput",
|
||||
platforms: [.macOS(.v14)],
|
||||
dependencies: [
|
||||
.package(path: "Dependencies/DynamicNotchKit")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "VoiceInput",
|
||||
dependencies: ["DynamicNotchKit"],
|
||||
path: "Sources",
|
||||
linkerSettings: [
|
||||
.linkedFramework("Speech"),
|
||||
|
||||
@ -316,9 +316,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let result = try await llmRefiner.chat(text: text, config: config)
|
||||
|
||||
if Config.shared.assistOutputMode == .notch {
|
||||
// 刘海弹窗模式
|
||||
recordingPanel.hideAnimated()
|
||||
NotchDisplayManager.shared.show(text: result.text)
|
||||
} else {
|
||||
// 直接输入模式(默认)
|
||||
recordingPanel.hideAnimated {
|
||||
TextInjector.inject(result.text)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("[App] LLM chat error: \(error)")
|
||||
recordingPanel.updateText("请求失败,请检查配置")
|
||||
|
||||
96
Sources/Core/NotchDisplayManager.swift
Normal file
96
Sources/Core/NotchDisplayManager.swift
Normal file
@ -0,0 +1,96 @@
|
||||
import Cocoa
|
||||
import SwiftUI
|
||||
import DynamicNotchKit
|
||||
|
||||
/// 管理 AI 回复的刘海弹窗展示
|
||||
final class NotchDisplayManager {
|
||||
|
||||
static let shared = NotchDisplayManager()
|
||||
|
||||
private var currentNotch: DynamicNotch<NotchContentView>?
|
||||
private var isActive = false
|
||||
|
||||
private init() {}
|
||||
|
||||
/// 在刘海/浮动区域展示 AI 回复
|
||||
func show(text: String) {
|
||||
currentNotch?.hide()
|
||||
|
||||
let content = NotchContentView(
|
||||
text: text,
|
||||
onCopy: {
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
}
|
||||
)
|
||||
|
||||
currentNotch = DynamicNotch(style: .notch) {
|
||||
content
|
||||
}
|
||||
isActive = true
|
||||
|
||||
// 显示 15 秒;用户鼠标悬停时自动暂停倒计时
|
||||
currentNotch?.show(for: 15)
|
||||
}
|
||||
|
||||
func hide() {
|
||||
currentNotch?.hide()
|
||||
currentNotch = nil
|
||||
isActive = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 弹窗内容视图
|
||||
|
||||
struct NotchContentView: View {
|
||||
let text: String
|
||||
var onCopy: () -> Void
|
||||
|
||||
private let maxHeight: CGFloat = 220
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// 标题栏
|
||||
HStack(spacing: 0) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 10))
|
||||
Text("AI 助手回复")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.5))
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onCopy) {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "doc.on.doc")
|
||||
.font(.system(size: 9))
|
||||
Text("复制")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(.white.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// 文本区域
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
Text(text)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
.lineSpacing(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: maxHeight)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.frame(width: 320)
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,10 @@ final class SettingsWindow: NSWindow {
|
||||
private let copyFromOtherButton: NSButton
|
||||
private let statusLabel: NSTextField
|
||||
|
||||
// Assist 模式专属:输出方式
|
||||
private let outputModeLabel: NSTextField
|
||||
private let outputModePopup: NSPopUpButton
|
||||
|
||||
init(mode: LLMMode) {
|
||||
self.mode = mode
|
||||
|
||||
@ -22,8 +26,10 @@ final class SettingsWindow: NSWindow {
|
||||
saveButton = NSButton(title: "Save", target: nil, action: nil)
|
||||
copyFromOtherButton = NSButton(title: "", target: nil, action: nil)
|
||||
statusLabel = NSTextField(labelWithString: "")
|
||||
outputModeLabel = NSTextField(labelWithString: "")
|
||||
outputModePopup = NSPopUpButton(frame: .zero, pullsDown: false)
|
||||
|
||||
let windowRect = NSRect(x: 0, y: 0, width: 420, height: 320)
|
||||
let windowRect = NSRect(x: 0, y: 0, width: 420, height: mode == .assist ? 360 : 320)
|
||||
super.init(
|
||||
contentRect: windowRect,
|
||||
styleMask: [.titled, .closable, .miniaturizable],
|
||||
@ -148,12 +154,42 @@ final class SettingsWindow: NSWindow {
|
||||
content.addSubview(copyFromOtherButton)
|
||||
content.addSubview(statusLabel)
|
||||
|
||||
// Assist 模式专属:输出方式选择
|
||||
let outputLabel: NSTextField?
|
||||
if mode == .assist {
|
||||
let label = makeLabel("输出方式:")
|
||||
outputModePopup.translatesAutoresizingMaskIntoConstraints = false
|
||||
outputModePopup.addItems(withTitles: AssistOutputMode.allCases.map { $0.displayName })
|
||||
outputModePopup.selectItem(at: Config.shared.assistOutputMode == .notch ? 1 : 0)
|
||||
outputModePopup.target = self
|
||||
outputModePopup.action = #selector(outputModeChanged)
|
||||
|
||||
content.addSubview(label)
|
||||
content.addSubview(outputModePopup)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
label.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20),
|
||||
label.topAnchor.constraint(equalTo: grid.bottomAnchor, constant: 16),
|
||||
label.widthAnchor.constraint(equalToConstant: 90),
|
||||
|
||||
outputModePopup.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 8),
|
||||
outputModePopup.centerYAnchor.constraint(equalTo: label.centerYAnchor),
|
||||
outputModePopup.widthAnchor.constraint(equalToConstant: 160)
|
||||
])
|
||||
outputLabel = label
|
||||
} else {
|
||||
outputLabel = nil
|
||||
}
|
||||
|
||||
// 按钮位置:assist 模式下在输出选择下方,否则在 grid 下方
|
||||
let buttonTopItem = (outputLabel ?? grid)!
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
grid.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
|
||||
grid.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20),
|
||||
grid.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20),
|
||||
|
||||
buttonStack.topAnchor.constraint(equalTo: grid.bottomAnchor, constant: 20),
|
||||
buttonStack.topAnchor.constraint(equalTo: buttonTopItem.bottomAnchor, constant: 20),
|
||||
buttonStack.centerXAnchor.constraint(equalTo: content.centerXAnchor),
|
||||
buttonStack.widthAnchor.constraint(equalToConstant: 220),
|
||||
|
||||
@ -171,6 +207,9 @@ final class SettingsWindow: NSWindow {
|
||||
baseURLField.stringValue = config.baseURL
|
||||
apiKeyField.stringValue = config.apiKey
|
||||
modelField.stringValue = config.model
|
||||
if mode == .assist {
|
||||
outputModePopup.selectItem(at: Config.shared.assistOutputMode == .notch ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func testConnection() {
|
||||
@ -220,6 +259,16 @@ final class SettingsWindow: NSWindow {
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func outputModeChanged() {
|
||||
let mode = AssistOutputMode.allCases[outputModePopup.indexOfSelectedItem]
|
||||
Config.shared.assistOutputMode = mode
|
||||
statusLabel.stringValue = "✅ 输出方式已更新"
|
||||
statusLabel.textColor = .systemGreen
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
||||
self?.statusLabel.stringValue = ""
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func saveConfig() {
|
||||
let url = baseURLField.stringValue.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let key = apiKeyField.stringValue
|
||||
|
||||
@ -28,6 +28,18 @@ enum TriggerMode: String {
|
||||
case assist = "assist" // AI 助手
|
||||
}
|
||||
|
||||
enum AssistOutputMode: String, CaseIterable {
|
||||
case inject = "inject" // 直接输入到输入栏
|
||||
case notch = "notch" // 刘海弹窗展示
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .inject: return "直接输入"
|
||||
case .notch: return "刘海弹窗"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LLMConfig {
|
||||
var enabled: Bool
|
||||
var baseURL: String
|
||||
@ -61,6 +73,9 @@ final class Config {
|
||||
static let assistLLMBaseURL = "assist_llm_base_url"
|
||||
static let assistLLMAPIKey = "assist_llm_api_key"
|
||||
static let assistLLMModel = "assist_llm_model"
|
||||
|
||||
// 助手模式输出方式
|
||||
static let assistOutputMode = "assist_output_mode"
|
||||
}
|
||||
|
||||
var language: RecognitionLanguage {
|
||||
@ -168,6 +183,21 @@ final class Config {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 助手模式输出方式
|
||||
|
||||
var assistOutputMode: AssistOutputMode {
|
||||
get {
|
||||
guard let raw = defaults.string(forKey: Keys.assistOutputMode),
|
||||
let mode = AssistOutputMode(rawValue: raw) else {
|
||||
return .inject
|
||||
}
|
||||
return mode
|
||||
}
|
||||
set {
|
||||
defaults.set(newValue.rawValue, forKey: Keys.assistOutputMode)
|
||||
}
|
||||
}
|
||||
|
||||
func getLLMConfig(for mode: LLMMode) -> LLMConfig {
|
||||
switch mode {
|
||||
case .input: return inputLLM
|
||||
|
||||
Loading…
Reference in New Issue
Block a user