feat: Ask AI 刘海弹窗输出模式

- 新增 AssistOutputMode 配置(inject/notch)
- Assist 设置界面添加输出方式弹出菜单
- 新建 NotchDisplayManager:DynamicNotchKit 弹窗,支持滚动、复制、15s 自动消失
- DynamicNotchKit 固化为本地依赖,修复假刘海高度问题(0→内容贴近顶部)
This commit is contained in:
JOJO 2026-04-28 23:18:13 +08:00
parent d43d1dbb38
commit c708ff32ac
16 changed files with 1057 additions and 4 deletions

View 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"]),
]
)

View 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)
)
}
}

View 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
}
}

View 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)
}
}
}

View 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
}
}

View 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
}
}
}
}
}

View 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
)
}
}

View 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)
}

View 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()
}
}
}

View 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()
}
}
}

View 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
}
}

View File

@ -4,9 +4,13 @@ import PackageDescription
let package = Package( let package = Package(
name: "VoiceInput", name: "VoiceInput",
platforms: [.macOS(.v14)], platforms: [.macOS(.v14)],
dependencies: [
.package(path: "Dependencies/DynamicNotchKit")
],
targets: [ targets: [
.executableTarget( .executableTarget(
name: "VoiceInput", name: "VoiceInput",
dependencies: ["DynamicNotchKit"],
path: "Sources", path: "Sources",
linkerSettings: [ linkerSettings: [
.linkedFramework("Speech"), .linkedFramework("Speech"),

View File

@ -316,8 +316,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Task { @MainActor in Task { @MainActor in
do { do {
let result = try await llmRefiner.chat(text: text, config: config) let result = try await llmRefiner.chat(text: text, config: config)
recordingPanel.hideAnimated {
TextInjector.inject(result.text) if Config.shared.assistOutputMode == .notch {
//
recordingPanel.hideAnimated()
NotchDisplayManager.shared.show(text: result.text)
} else {
//
recordingPanel.hideAnimated {
TextInjector.inject(result.text)
}
} }
} catch { } catch {
print("[App] LLM chat error: \(error)") print("[App] LLM chat error: \(error)")

View 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)
}
}

View File

@ -12,6 +12,10 @@ final class SettingsWindow: NSWindow {
private let copyFromOtherButton: NSButton private let copyFromOtherButton: NSButton
private let statusLabel: NSTextField private let statusLabel: NSTextField
// Assist
private let outputModeLabel: NSTextField
private let outputModePopup: NSPopUpButton
init(mode: LLMMode) { init(mode: LLMMode) {
self.mode = mode self.mode = mode
@ -22,8 +26,10 @@ final class SettingsWindow: NSWindow {
saveButton = NSButton(title: "Save", target: nil, action: nil) saveButton = NSButton(title: "Save", target: nil, action: nil)
copyFromOtherButton = NSButton(title: "", target: nil, action: nil) copyFromOtherButton = NSButton(title: "", target: nil, action: nil)
statusLabel = NSTextField(labelWithString: "") 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( super.init(
contentRect: windowRect, contentRect: windowRect,
styleMask: [.titled, .closable, .miniaturizable], styleMask: [.titled, .closable, .miniaturizable],
@ -148,12 +154,42 @@ final class SettingsWindow: NSWindow {
content.addSubview(copyFromOtherButton) content.addSubview(copyFromOtherButton)
content.addSubview(statusLabel) 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([ NSLayoutConstraint.activate([
grid.topAnchor.constraint(equalTo: content.topAnchor, constant: 24), grid.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
grid.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), grid.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20),
grid.trailingAnchor.constraint(equalTo: content.trailingAnchor, 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.centerXAnchor.constraint(equalTo: content.centerXAnchor),
buttonStack.widthAnchor.constraint(equalToConstant: 220), buttonStack.widthAnchor.constraint(equalToConstant: 220),
@ -171,6 +207,9 @@ final class SettingsWindow: NSWindow {
baseURLField.stringValue = config.baseURL baseURLField.stringValue = config.baseURL
apiKeyField.stringValue = config.apiKey apiKeyField.stringValue = config.apiKey
modelField.stringValue = config.model modelField.stringValue = config.model
if mode == .assist {
outputModePopup.selectItem(at: Config.shared.assistOutputMode == .notch ? 1 : 0)
}
} }
@objc private func testConnection() { @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() { @objc private func saveConfig() {
let url = baseURLField.stringValue.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let url = baseURLField.stringValue.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let key = apiKeyField.stringValue let key = apiKeyField.stringValue

View File

@ -28,6 +28,18 @@ enum TriggerMode: String {
case assist = "assist" // AI 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 { struct LLMConfig {
var enabled: Bool var enabled: Bool
var baseURL: String var baseURL: String
@ -61,6 +73,9 @@ final class Config {
static let assistLLMBaseURL = "assist_llm_base_url" static let assistLLMBaseURL = "assist_llm_base_url"
static let assistLLMAPIKey = "assist_llm_api_key" static let assistLLMAPIKey = "assist_llm_api_key"
static let assistLLMModel = "assist_llm_model" static let assistLLMModel = "assist_llm_model"
//
static let assistOutputMode = "assist_output_mode"
} }
var language: RecognitionLanguage { 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 { func getLLMConfig(for mode: LLMMode) -> LLMConfig {
switch mode { switch mode {
case .input: return inputLLM case .input: return inputLLM