239 lines
9.1 KiB
Swift
239 lines
9.1 KiB
Swift
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
|
||
|
||
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: "")
|
||
|
||
let windowRect = NSRect(x: 0, y: 0, width: 420, height: 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)
|
||
|
||
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.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
|
||
}
|
||
|
||
@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 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 = ""
|
||
}
|
||
}
|
||
}
|