VoiceInput/Sources/UI/SettingsWindow.swift
JOJO c708ff32ac feat: Ask AI 刘海弹窗输出模式
- 新增 AssistOutputMode 配置(inject/notch)
- Assist 设置界面添加输出方式弹出菜单
- 新建 NotchDisplayManager:DynamicNotchKit 弹窗,支持滚动、复制、15s 自动消失
- DynamicNotchKit 固化为本地依赖,修复假刘海高度问题(0→内容贴近顶部)
2026-04-28 23:18:13 +08:00

288 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Cocoa
/// LLM
final class SettingsWindow: NSWindow {
private let mode: LLMMode
private let baseURLField: NSTextField
private let apiKeyField: NSSecureTextField
private let modelField: NSTextField
private let testButton: NSButton
private let saveButton: NSButton
private let copyFromOtherButton: NSButton
private let statusLabel: NSTextField
// Assist
private let outputModeLabel: NSTextField
private let outputModePopup: NSPopUpButton
init(mode: LLMMode) {
self.mode = mode
baseURLField = NSTextField(frame: .zero)
apiKeyField = NSSecureTextField(frame: .zero)
modelField = NSTextField(frame: .zero)
testButton = NSButton(title: "Test", target: nil, action: nil)
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: mode == .assist ? 360 : 320)
super.init(
contentRect: windowRect,
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: false
)
configureWindow()
buildUI()
loadCurrentConfig()
}
private func configureWindow() {
title = mode == .input ? "Input LLM Settings (Fn)" : "Assist LLM Settings (Control)"
isReleasedWhenClosed = false
center()
}
// MARK: - LSUIElement Edit
override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Cmd
guard event.modifierFlags.contains(.command) else {
return super.performKeyEquivalent(with: event)
}
let selector: Selector?
switch event.charactersIgnoringModifiers?.lowercased() {
case "a": selector = #selector(NSText.selectAll(_:))
case "c": selector = Selector(("copy:"))
case "v": selector = Selector(("paste:"))
case "x": selector = Selector(("cut:"))
default: selector = nil
}
guard let sel = selector else {
return super.performKeyEquivalent(with: event)
}
//
if NSApp.sendAction(sel, to: nil, from: self) {
return true
}
return super.performKeyEquivalent(with: event)
}
private func buildUI() {
guard let content = contentView else { return }
func makeLabel(_ text: String) -> NSTextField {
let label = NSTextField(labelWithString: text)
label.font = .systemFont(ofSize: 11, weight: .semibold)
label.textColor = .secondaryLabelColor
label.alignment = .right
label.translatesAutoresizingMaskIntoConstraints = false
return label
}
let baseURLLabel = makeLabel("API Base URL:")
let apiKeyLabel = makeLabel("API Key:")
let modelLabel = makeLabel("Model:")
baseURLField.placeholderString = "https://api.openai.com"
baseURLField.font = .systemFont(ofSize: 13)
baseURLField.bezelStyle = .roundedBezel
baseURLField.translatesAutoresizingMaskIntoConstraints = false
apiKeyField.placeholderString = "sk-..."
apiKeyField.font = .systemFont(ofSize: 13)
apiKeyField.bezelStyle = .roundedBezel
apiKeyField.translatesAutoresizingMaskIntoConstraints = false
modelField.placeholderString = mode == .input ? "deepseek-v4-flash" : "gpt-4"
modelField.font = .systemFont(ofSize: 13)
modelField.bezelStyle = .roundedBezel
modelField.translatesAutoresizingMaskIntoConstraints = false
testButton.bezelStyle = .rounded
testButton.translatesAutoresizingMaskIntoConstraints = false
testButton.target = self
testButton.action = #selector(testConnection)
saveButton.bezelStyle = .rounded
saveButton.translatesAutoresizingMaskIntoConstraints = false
saveButton.keyEquivalent = "\r"
saveButton.target = self
saveButton.action = #selector(saveConfig)
let otherModeName = mode == .input ? "AI 助手" : "语音输入"
copyFromOtherButton.title = "从「\(otherModeName)」复制配置"
copyFromOtherButton.bezelStyle = .rounded
copyFromOtherButton.translatesAutoresizingMaskIntoConstraints = false
copyFromOtherButton.target = self
copyFromOtherButton.action = #selector(copyFromOther)
statusLabel.font = .systemFont(ofSize: 11)
statusLabel.textColor = .secondaryLabelColor
statusLabel.alignment = .center
statusLabel.maximumNumberOfLines = 2
statusLabel.translatesAutoresizingMaskIntoConstraints = false
let grid = NSGridView(views: [
[baseURLLabel, baseURLField],
[apiKeyLabel, apiKeyField],
[modelLabel, modelField]
])
grid.translatesAutoresizingMaskIntoConstraints = false
grid.column(at: 0).xPlacement = .trailing
grid.column(at: 1).width = 260
grid.rowSpacing = 12
grid.columnSpacing = 8
let buttonStack = NSStackView(views: [testButton, saveButton])
buttonStack.translatesAutoresizingMaskIntoConstraints = false
buttonStack.orientation = .horizontal
buttonStack.spacing = 12
buttonStack.distribution = .fillEqually
content.addSubview(grid)
content.addSubview(buttonStack)
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: buttonTopItem.bottomAnchor, constant: 20),
buttonStack.centerXAnchor.constraint(equalTo: content.centerXAnchor),
buttonStack.widthAnchor.constraint(equalToConstant: 220),
copyFromOtherButton.topAnchor.constraint(equalTo: buttonStack.bottomAnchor, constant: 10),
copyFromOtherButton.centerXAnchor.constraint(equalTo: content.centerXAnchor),
statusLabel.topAnchor.constraint(equalTo: copyFromOtherButton.bottomAnchor, constant: 10),
statusLabel.centerXAnchor.constraint(equalTo: content.centerXAnchor),
statusLabel.widthAnchor.constraint(equalToConstant: 380)
])
}
private func loadCurrentConfig() {
let config = Config.shared.getLLMConfig(for: mode)
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() {
let url = baseURLField.stringValue.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let key = apiKeyField.stringValue
let model = modelField.stringValue
guard !url.isEmpty, !key.isEmpty, !model.isEmpty else {
statusLabel.stringValue = "Please fill all fields"
statusLabel.textColor = .systemRed
return
}
statusLabel.stringValue = "Testing..."
statusLabel.textColor = .systemBlue
testButton.isEnabled = false
saveButton.isEnabled = false
Task { @MainActor in
let refiner = LLMRefiner()
let testConfig = LLMConfig(enabled: true, baseURL: url, apiKey: key, model: model)
do {
let result = try await refiner.testConnection(config: testConfig)
statusLabel.stringValue = "✅ Connected! Response: \(result)"
statusLabel.textColor = .systemGreen
} catch {
statusLabel.stringValue = "❌ Error: \(error.localizedDescription)"
statusLabel.textColor = .systemRed
}
testButton.isEnabled = true
saveButton.isEnabled = true
}
}
@objc private func copyFromOther() {
let otherMode: LLMMode = mode == .input ? .assist : .input
let config = Config.shared.getLLMConfig(for: otherMode)
baseURLField.stringValue = config.baseURL
apiKeyField.stringValue = config.apiKey
modelField.stringValue = config.model
statusLabel.stringValue = "✅ 已复制「\(otherMode == .input ? "语音输入" : "AI 助手")」的配置"
statusLabel.textColor = .systemGreen
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
self?.statusLabel.stringValue = ""
}
}
@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
let model = modelField.stringValue
let config = LLMConfig(enabled: true, baseURL: url, apiKey: key, model: model)
Config.shared.setLLMConfig(config, for: mode)
statusLabel.stringValue = "✅ Saved"
statusLabel.textColor = .systemGreen
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
self?.statusLabel.stringValue = ""
}
}
}