Files

1 line
31 KiB
JSON
Raw Permalink 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.
{"repo": "blacksquarre/atomvoice", "files": {"Sources/AtomVoice/Windows/SettingsWindow.swift": {"44d95e52cec27eccac1bc042e20a0fb51dfb517c": {"t": 1785674646.8260791, "n": 29433, "h": "e05b02ae540e6ea1", "c": "import Cocoa\n\n// MARK: - 数据模型\n\nstruct LLMProvider: Codable {\n var name: String\n var baseURL: String\n var defaultModel: String\n}\n\nfinal class ProviderStore {\n static let key = AppSettings.Keys.llmProviders\n\n static var defaults: [LLMProvider] {\n [\n LLMProvider(name: loc(\"provider.preset.openai\"), baseURL: \"https://api.openai.com/v1\", defaultModel: AppSettings.defaultLLMModel),\n LLMProvider(name: loc(\"provider.preset.anthropic\"), baseURL: \"https://api.anthropic.com/v1\", defaultModel: \"claude-sonnet-4-6\"),\n LLMProvider(name: loc(\"provider.preset.deepseek\"), baseURL: \"https://api.deepseek.com/v1\", defaultModel: \"deepseek-v4-flash\"),\n LLMProvider(name: loc(\"provider.preset.moonshot\"), baseURL: \"https://api.moonshot.cn/v1\", defaultModel: \"kimi-latest\"),\n LLMProvider(name: loc(\"provider.preset.alibaba\"), baseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\", defaultModel: \"qwen-turbo-latest\"),\n LLMProvider(name: loc(\"provider.preset.zhipu\"), baseURL: \"https://open.bigmodel.cn/api/paas/v4\", defaultModel: \"glm-4-flash\"),\n LLMProvider(name: loc(\"provider.preset.lingyi\"), baseURL: \"https://api.lingyiwanwu.com/v1\", defaultModel: \"yi-lightning\"),\n LLMProvider(name: loc(\"provider.preset.groq\"), baseURL: \"https://api.groq.com/openai/v1\", defaultModel: \"llama-3.3-70b-versatile\"),\n LLMProvider(name: loc(\"provider.preset.ollama\"), baseURL: \"http://localhost:11434/v1\", defaultModel: \"qwen2.5:1.5b\"),\n LLMProvider(name: loc(\"provider.preset.custom\"), baseURL: \"\", defaultModel: \"\"),\n ]\n }\n\n static func load() -> [LLMProvider] {\n guard let data = AppSettings.backend.data(forKey: key),\n let list = try? JSONDecoder().decode([LLMProvider].self, from: data)\n else { return defaults }\n return list\n }\n\n static func save(_ list: [LLMProvider]) {\n if let data = try? JSONEncoder().encode(list) {\n AppSettings.backend.set(data, forKey: key)\n }\n }\n}\n\n// MARK: - 服务商编辑器\n\nfinal class ProviderEditorController: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate {\n private var sheet: NSWindow!\n private var tableView: NSTableView!\n private var nameField: NSTextField!\n private var urlField: NSTextField!\n private var modelField: NSTextField!\n private var providers: [LLMProvider] = []\n var onDone: (([LLMProvider]) -> Void)?\n\n func show(in parent: NSWindow, onDismiss: (() -> Void)? = nil) {\n providers = ProviderStore.load()\n buildSheet()\n parent.beginSheet(sheet) { _ in onDismiss?() }\n }\n\n private func buildSheet() {\n sheet = NSWindow(\n contentRect: NSRect(x: 0, y: 0, width: 540, height: 400),\n styleMask: [.titled],\n backing: .buffered,\n defer: false\n )\n sheet.title = loc(\"provider.title\")\n WindowConfiguration.configureSheet(sheet, identifier: \"AtomVoice.ProviderEditorSheet\")\n let cv = sheet.contentView!\n let p: CGFloat = 20\n\n // 表格(Table view)\n let scroll = NSScrollView()\n scroll.translatesAutoresizingMaskIntoConstraints = false\n scroll.hasVerticalScroller = true\n scroll.borderType = .bezelBorder\n cv.addSubview(scroll)\n\n tableView = NSTableView()\n tableView.dataSource = self\n tableView.delegate = self\n tableView.rowHeight = 22\n tableView.usesAlternatingRowBackgroundColors = true\n tableView.allowsMultipleSelection = false\n\n let cols: [(String, String, CGFloat)] = [\n (\"name\", loc(\"provider.col.name\"), 110),\n (\"url\", loc(\"provider.col.url\"), 250),\n (\"model\", loc(\"provider.col.model\"), 140)\n ]\n for (id, title, w) in cols {\n let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(id))\n col.title = title; col.width = w; col.isEditable = true\n tableView.addTableColumn(col)\n }\n scroll.documentView = tableView\n\n // +/- 按钮(+/- buttons)\n let addBtn = NSButton(title: \"+\", target: self, action: #selector(addRow))\n addBtn.toolTip = loc(\"tooltip.provider.add\")\n addBtn.translatesAutoresizingMaskIntoConstraints = false\n addBtn.bezelStyle = .rounded\n cv.addSubview(addBtn)\n\n let delBtn = NSButton(title: \"−\", target: self, action: #selector(deleteRow))\n delBtn.toolTip = loc(\"tooltip.provider.delete\")\n delBtn.translatesAutoresizingMaskIntoConstraints = false\n delBtn.bezelStyle = .rounded\n cv.addSubview(delBtn)\n\n // 编辑区(Edit area)\n let sep = NSBox()\n sep.translatesAutoresizingMaskIntoConstraints = false\n sep.boxType = .separator\n cv.addSubview(sep)\n\n let grid = NSGridView()\n grid.translatesAutoresizingMaskIntoConstraints = false\n grid.rowSpacing = 8\n grid.columnSpacing = 8\n cv.addSubview(grid)\n\n nameField = makeField(); urlField = makeField(); modelField = makeField()\n nameField.delegate = self; urlField.delegate = self; modelField.delegate = self\n\n for (label, field) in [(loc(\"provider.label.name\"), nameField!),\n (loc(\"provider.label.url\"), urlField!),\n (loc(\"provider.label.model\"), modelField!)] {\n let l = NSTextField(labelWithString: label)\n l.alignment = .right; l.font = .systemFont(ofSize: 12)\n l.textColor = .secondaryLabelColor\n grid.addRow(with: [l, field])\n }\n grid.column(at: 0).xPlacement = .trailing\n grid.column(at: 0).width = 44\n\n // 完成按钮(Done button)\n let doneBtn = NSButton(title: loc(\"provider.done\"), target: self, action: #selector(done))\n doneBtn.translatesAutoresizingMaskIntoConstraints = false\n doneBtn.bezelStyle = .rounded\n doneBtn.keyEquivalent = \"\\r\"\n cv.addSubview(doneBtn)\n\n NSLayoutConstraint.activate([\n scroll.topAnchor.constraint(equalTo: cv.topAnchor, constant: p),\n scroll.leadingAnchor.constraint(equalTo: cv.leadingAnchor, constant: p),\n scroll.trailingAnchor.constraint(equalTo: cv.trailingAnchor, constant: -p),\n scroll.heightAnchor.constraint(equalToConstant: 200),\n\n addBtn.topAnchor.constraint(equalTo: scroll.bottomAnchor, constant: 6),\n addBtn.leadingAnchor.constraint(equalTo: cv.leadingAnchor, constant: p),\n addBtn.widthAnchor.constraint(equalToConstant: 28),\n\n delBtn.topAnchor.constraint(equalTo: addBtn.topAnchor),\n delBtn.leadingAnchor.constraint(equalTo: addBtn.trailingAnchor, constant: 4),\n delBtn.widthAnchor.constraint(equalToConstant: 28),\n\n sep.topAnchor.constraint(equalTo: addBtn.bottomAnchor, constant: 10),\n sep.leadingAnchor.constraint(equalTo: cv.leadingAnchor, constant: p),\n sep.trailingAnchor.constraint(equalTo: cv.trailingAnchor, constant: -p),\n sep.heightAnchor.constraint(equalToConstant: 1),\n\n grid.topAnchor.constraint(equalTo: sep.bottomAnchor, constant: 12),\n grid.leadingAnchor.constraint(equalTo: cv.leadingAnchor, constant: p),\n grid.trailingAnchor.constraint(equalTo: cv.trailingAnchor, constant: -p),\n\n doneBtn.bottomAnchor.constraint(equalTo: cv.bottomAnchor, constant: -p),\n doneBtn.trailingAnchor.constraint(equalTo: cv.trailingAnchor, constant: -p),\n doneBtn.widthAnchor.constraint(equalToConstant: 72),\n ])\n }\n\n private func makeField() -> NSTextField {\n let f = NSTextField()\n f.bezelStyle = .roundedBezel\n f.font = .systemFont(ofSize: 12)\n f.translatesAutoresizingMaskIntoConstraints = false\n f.widthAnchor.constraint(greaterThanOrEqualToConstant: 300).isActive = true\n return f\n }\n\n // MARK: Table DataSource / Delegate\n\n func numberOfRows(in tableView: NSTableView) -> Int { providers.count }\n\n func tableView(_ tableView: NSTableView, objectValueFor col: NSTableColumn?, row: Int) -> Any? {\n let p = providers[row]\n switch col?.identifier.rawValue {\n case \"name\": return p.name\n case \"url\": return p.baseURL\n case \"model\": return p.defaultModel\n default: return nil\n }\n }\n\n func tableView(_ tableView: NSTableView, setObjectValue obj: Any?, for col: NSTableColumn?, row: Int) {\n guard let v = obj as? String else { return }\n switch col?.identifier.rawValue {\n case \"name\": providers[row].name = v\n case \"url\": providers[row].baseURL = v\n case \"model\": providers[row].defaultModel = v\n default: break\n }\n }\n\n func tableViewSelectionDidChange(_ notification: Notification) {\n let row = tableView.selectedRow\n guard row >= 0 else { return }\n nameField.stringValue = providers[row].name\n urlField.stringValue = providers[row].baseURL\n modelField.stringValue = providers[row].defaultModel\n }\n\n // MARK: NSTextFieldDelegate — 实时同步到 providers 数组\n\n func controlTextDidChange(_ obj: Notification) {\n let row = tableView.selectedRow\n guard row >= 0 else { return }\n providers[row].name = nameField.stringValue\n providers[row].baseURL = urlField.stringValue\n providers[row].defaultModel = modelField.stringValue\n tableView.reloadData(forRowIndexes: IndexSet(integer: row),\n columnIndexes: IndexSet(0..<3))\n }\n\n // MARK: Actions\n\n @objc private func addRow() {\n providers.append(LLMProvider(name: loc(\"provider.new\"), baseURL: \"\", defaultModel: \"\"))\n tableView.reloadData()\n let i = providers.count - 1\n tableView.selectRowIndexes(IndexSet(integer: i), byExtendingSelection: false)\n tableView.scrollRowToVisible(i)\n nameField.stringValue = providers[i].name\n urlField.stringValue = \"\"; modelField.stringValue = \"\"\n sheet.makeFirstResponder(nameField)\n }\n\n @objc private func deleteRow() {\n let row = tableView.selectedRow\n guard row >= 0 else { return }\n providers.remove(at: row)\n tableView.reloadData()\n if !providers.isEmpty {\n let next = min(row, providers.count - 1)\n tableView.selectRowIndexes(IndexSet(integer: next), byExtendingSelection: false)\n }\n }\n\n @objc private func done() {\n ProviderStore.save(providers)\n onDone?(providers)\n sheet.sheetParent?.endSheet(sheet)\n }\n}\n\n// MARK: - 提示词编辑器\n\nfinal class PromptEditorController: NSObject, NSTextViewDelegate {\n private var sheet: NSWindow!\n private var textView: NSTextView!\n private var placeholderLabel: NSTextField!\n var onDone: (() -> Void)?\n\n func show(in parent: NSWindow, onDismiss: (() -> Void)? = nil) {\n buildSheet()\n parent.beginSheet(sheet) { _ in onDismiss?() }\n }\n\n private func buildSheet() {\n sheet = NSWindow(\n contentRect: NSRect(x: 0, y: 0, width: 450, height: 300),\n styleMask: [.titled],\n backing: .buffered,\n defer: false\n )\n sheet.title = loc(\"settings.prompt.title\")\n WindowConfiguration.configureSheet(sheet, identifier: \"AtomVoice.PromptEditorSheet\")\n let cv = sheet.contentView!\n let p: CGFloat = 20\n\n let desc = NSTextField(labelWithString: loc(\"settings.prompt.desc\"))\n desc.font = .systemFont(ofSize: 12)\n desc.textColor = .secondaryLabelColor\n desc.lineBreakMode = .byWordWrapping\n desc.translatesAutoresizingMaskIntoConstraints = false\n cv.addSubview(desc)\n\n let scroll = NSScrollView()\n scroll.translatesAutoresizingMaskIntoConstraints = false\n scroll.hasVerticalScroller = true\n scroll.borderType = .bezelBorder\n \n textView = NSTextView()\n textView.isRichText = false\n textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular)\n textView.autoresizingMask = [.width]\n textView.delegate = self\n // 设置占位标签(Set up placeholder label)\n placeholderLabel = NSTextField(labelWithString: LLMRefiner.currentDefaultSystemPrompt)\n placeholderLabel.font = .monospacedSystemFont(ofSize: 12, weight: .regular)\n placeholderLabel.textColor = .placeholderTextColor\n placeholderLabel.lineBreakMode = .byWordWrapping\n placeholderLabel.translatesAutoresizingMaskIntoConstraints = false\n placeholderLabel.isBordered = false\n placeholderLabel.drawsBackground = false\n placeholderLabel.isEditable = false\n placeholderLabel.isSelectable = false\n // 将占位标签直接添加到文本视图(Add placeholder directly to text view)\n textView.addSubview(placeholderLabel)\n \n NSLayoutConstraint.activate([\n placeholderLabel.topAnchor.constraint(equalTo: textView.topAnchor),\n placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor, constant: 4),\n placeholderLabel.trailingAnchor.constraint(equalTo: textView.trailingAnchor, constant: -4)\n ])\n\n // 加载自定义提示词(Load custom prompt)\n if !AppSettings.llmSystemPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n textView.string = AppSettings.llmSystemPrompt\n }\n updatePlaceholderVisibility()\n scroll.documentView = textView\n cv.addSubview(scroll)\n\n let resetBtn = NSButton(title: loc(\"settings.prompt.reset\"), target: self, action: #selector(resetDefault))\n resetBtn.toolTip = loc(\"tooltip.prompt.reset\")\n resetBtn.translatesAutoresizingMaskIntoConstraints = false\n resetBtn.bezelStyle = .rounded\n cv.addSubview(resetBtn)\n\n let doneBtn = NSButton(title: loc(\"settings.prompt.done\"), target: self, action: #selector(done))\n doneBtn.toolTip = loc(\"tooltip.prompt.done\")\n doneBtn.translatesAutoresizingMaskIntoConstraints = false\n doneBtn.bezelStyle = .rounded\n doneBtn.keyEquivalent = \"\\r\"\n cv.addSubview(doneBtn)\n\n let cancelBtn = NSButton(title: loc(\"settings.prompt.cancel\"), target: self, action: #selector(cancel))\n cancelBtn.toolTip = loc(\"tooltip.prompt.cancel\")\n cancelBtn.translatesAutoresizingMaskIntoConstraints = false\n cancelBtn.bezelStyle = .rounded\n cancelBtn.keyEquivalent = \"\\u{1b}\"\n cv.addSubview(cancelBtn)\n\n NSLayoutConstraint.activate([\n desc.topAnchor.constraint(equalTo: cv.topAnchor, constant: p),\n desc.leadingAnchor.constraint(equalTo: cv.leadingAnchor, constant: p),\n desc.trailingAnchor.constraint(equalTo: cv.trailingAnchor, constant: -p),\n\n scroll.topAnchor.constraint(equalTo: desc.bottomAnchor, constant: 10),\n scroll.leadingAnchor.constraint(equalTo: cv.leadingAnchor, constant: p),\n scroll.trailingAnchor.constraint(equalTo: cv.trailingAnchor, constant: -p),\n scroll.bottomAnchor.constraint(equalTo: doneBtn.topAnchor, constant: -p),\n\n resetBtn.leadingAnchor.constraint(equalTo: cv.leadingAnchor, constant: p),\n resetBtn.bottomAnchor.constraint(equalTo: cv.bottomAnchor, constant: -p),\n\n doneBtn.trailingAnchor.constraint(equalTo: cv.trailingAnchor, constant: -p),\n doneBtn.bottomAnchor.constraint(equalTo: cv.bottomAnchor, constant: -p),\n\n cancelBtn.trailingAnchor.constraint(equalTo: doneBtn.leadingAnchor, constant: -8),\n cancelBtn.bottomAnchor.constraint(equalTo: cv.bottomAnchor, constant: -p),\n ])\n }\n\n @objc private func resetDefault() {\n textView.string = \"\"\n updatePlaceholderVisibility()\n }\n\n @objc private func cancel() {\n sheet.sheetParent?.endSheet(sheet)\n }\n\n @objc private func done() {\n AppSettings.llmSystemPrompt = textView.string\n onDone?()\n sheet.sheetParent?.endSheet(sheet)\n }\n\n // MARK: NSTextViewDelegate\n func textDidChange(_ notification: Notification) {\n updatePlaceholderVisibility()\n }\n\n private func updatePlaceholderVisibility() {\n placeholderLabel.isHidden = !textView.string.isEmpty\n }\n}\n\n// MARK: - 设置窗口\n\nfinal class SettingsWindowController: NSObject {\n var onClose: (() -> Void)?\n private var window: NSWindow?\n private var providerPopup: NSPopUpButton!\n private var apiBaseURLField: NSTextField!\n private var apiKeyField: NSSecureTextField!\n private var modelField: NSTextField!\n private var delayPopup: NSPopUpButton!\n private let delayOptions: [Double] = [0, 0.5, 1.0, 1.5, 2.0]\n private var statusLabel: NSTextField!\n private let llmRefiner: LLMRefiner\n private var providerEditor: ProviderEditorController?\n private var promptEditor: PromptEditorController?\n private var providers: [LLMProvider] = []\n\n init(llmRefiner: LLMRefiner) {\n self.llmRefiner = llmRefiner\n }\n\n func showWindow() {\n if let w = window {\n refreshFields()\n WindowPresenter.shared.bringToFront(w)\n return\n }\n buildWindow()\n }\n\n // MARK: - 构建窗口\n\n private func buildWindow() {\n let w = NSWindow(\n contentRect: NSRect(x: 0, y: 0, width: 560, height: 370),\n styleMask: [.titled, .closable],\n backing: .buffered,\n defer: false\n )\n w.title = loc(\"settings.title\")\n w.isReleasedWhenClosed = false\n\n guard let cv = w.contentView else { return }\n\n let pad: CGFloat = 24\n let gap: CGFloat = 8\n\n // ── 控件 ─────────────────────────────────────────────(Controls)\n providers = ProviderStore.load()\n providerPopup = NSPopUpButton()\n providerPopup.target = self\n providerPopup.action = #selector(providerChanged(_:))\n providerPopup.toolTip = loc(\"tooltip.settings.provider\")\n providers.forEach { providerPopup.addItem(withTitle: $0.name) }\n\n let manageBtn = SettingsUI.makeButton(loc(\"settings.manage\"), target: self, action: #selector(editProviders(_:)))\n manageBtn.toolTip = loc(\"tooltip.settings.manage\")\n\n apiBaseURLField = SettingsUI.makeField(placeholder: \"https://api.openai.com/v1\", delegate: self)\n apiBaseURLField.toolTip = loc(\"tooltip.settings.baseURL\")\n apiKeyField = SettingsUI.makeSecureField(placeholder: \"sk-...\", delegate: self)\n apiKeyField.toolTip = loc(\"tooltip.settings.apiKey\")\n modelField = SettingsUI.makeField(placeholder: AppSettings.defaultLLMModel, delegate: self)\n modelField.toolTip = loc(\"tooltip.settings.model\")\n\n delayPopup = NSPopUpButton()\n delayPopup.toolTip = loc(\"tooltip.settings.delay\")\n for v in delayOptions {\n delayPopup.addItem(withTitle: v == 0 ? loc(\"settings.delay.immediate\") : String(format: \"%.1fs\", v))\n }\n\n let promptBtn = SettingsUI.makeButton(loc(\"settings.prompt.edit\"), target: self, action: #selector(editPrompt(_:)))\n promptBtn.toolTip = loc(\"tooltip.settings.prompt\")\n\n statusLabel = SettingsUI.makeSecondaryLabel()\n\n let testBtn = SettingsUI.makeButton(loc(\"settings.test\"), target: self, action: #selector(testConnection(_:)))\n testBtn.toolTip = loc(\"tooltip.settings.test\")\n let cancelBtn = SettingsUI.makeButton(loc(\"settings.cancel\"), target: self, action: #selector(cancelSettings(_:)))\n let saveBtn = SettingsUI.makeButton(loc(\"settings.save\"), target: self, action: #selector(saveSettings(_:)))\n saveBtn.toolTip = loc(\"tooltip.settings.save\")\n saveBtn.keyEquivalent = \"\\r\"\n cancelBtn.keyEquivalent = \"\\u{1b}\"\n\n // ── 说明文字 ──────────────────────────────────────────────(Description text)\n let descLabel = NSTextField(labelWithString: loc(\"settings.llm.desc\"))\n descLabel.font = .systemFont(ofSize: 12)\n descLabel.textColor = .secondaryLabelColor\n descLabel.lineBreakMode = .byWordWrapping\n descLabel.maximumNumberOfLines = 0\n descLabel.translatesAutoresizingMaskIntoConstraints = false\n\n // ── 辅助:构造单行 HStack(左侧固定宽度标签 + 右侧控件) ─(Helper: build single-row HStack with fixed-width label + control)\n // 服务商行:popup 自动拉伸,管理按钮紧跟其后(Provider row: popup auto-stretches, manage button follows)\n let providerCtrl = NSStackView(views: [providerPopup, manageBtn])\n providerCtrl.orientation = .horizontal\n providerCtrl.spacing = gap\n providerCtrl.alignment = .centerY\n\n // 延迟行:popup 固定宽,不拉伸(Delay row: popup fixed width, no stretch)\n let delayWrap = NSStackView(views: [delayPopup])\n delayWrap.orientation = .horizontal\n delayWrap.alignment = .centerY\n delayPopup.widthAnchor.constraint(equalToConstant: 110).isActive = true\n\n // ── 表单垂直 StackView ────────────────────────────────(Form vertical StackView)\n let form = NSStackView()\n form.orientation = .vertical\n form.spacing = 10\n form.alignment = .leading\n form.translatesAutoresizingMaskIntoConstraints = false\n\n let formRows: [(String, NSView)] = [\n (loc(\"settings.provider\"), providerCtrl),\n (loc(\"settings.apiUrl\"), apiBaseURLField),\n (loc(\"settings.apiKey\"), apiKeyField),\n (loc(\"settings.model\"), modelField),\n (loc(\"settings.prompt.label\"), promptBtn),\n (loc(\"settings.delay\"), delayWrap),\n ]\n for (text, ctrl) in formRows {\n let row = SettingsUI.makeFormRow(labelText: text, control: ctrl, spacing: gap)\n form.addArrangedSubview(row)\n }\n\n // 分割线(Separator line)\n let sep = NSBox()\n sep.boxType = .separator\n sep.translatesAutoresizingMaskIntoConstraints = false\n\n // 底部:状态标签(左)+ 按钮组(右)(Bottom: status label (left) + button group (right))\n let bottomRow = SettingsUI.makeBottomRow(statusLabel: statusLabel, buttons: [testBtn, cancelBtn, saveBtn], spacing: gap)\n\n // ── 整体 ─────────────────────────────────────────────(Overall layout)\n let root = NSView()\n root.translatesAutoresizingMaskIntoConstraints = false\n cv.addSubview(root)\n\n [descLabel, form, sep, bottomRow].forEach {\n $0.translatesAutoresizingMaskIntoConstraints = false\n root.addSubview($0)\n }\n\n NSLayoutConstraint.activate([\n // root 贴满 contentView(带 padding)(Root fills contentView with padding)\n root.topAnchor.constraint(equalTo: cv.topAnchor, constant: pad),\n root.leadingAnchor.constraint(equalTo: cv.leadingAnchor, constant: pad),\n root.trailingAnchor.constraint(equalTo: cv.trailingAnchor, constant: -pad),\n root.bottomAnchor.constraint(equalTo: cv.bottomAnchor, constant: -pad),\n\n // 说明文字(Description text)\n descLabel.topAnchor.constraint(equalTo: root.topAnchor),\n descLabel.leadingAnchor.constraint(equalTo: root.leadingAnchor),\n descLabel.trailingAnchor.constraint(equalTo: root.trailingAnchor),\n\n // 表单在说明文字下方(Form below description text)\n form.topAnchor.constraint(equalTo: descLabel.bottomAnchor, constant: 12),\n form.leadingAnchor.constraint(equalTo: root.leadingAnchor),\n form.trailingAnchor.constraint(equalTo: root.trailingAnchor),\n\n // 各行控件拉满 form 宽度(label 固定 + 控件填充剩余)(Each row fills form width: label fixed + control fills remaining)\n // → 由 makeRow 内的 NSStackView 自动处理(Handled automatically by NSStackView in makeRow)\n\n // 分割线(Separator line)\n sep.topAnchor.constraint(equalTo: form.bottomAnchor, constant: 16),\n sep.leadingAnchor.constraint(equalTo: root.leadingAnchor),\n sep.trailingAnchor.constraint(equalTo: root.trailingAnchor),\n\n // 底部行(Bottom row)\n bottomRow.topAnchor.constraint(equalTo: sep.bottomAnchor, constant: 16),\n bottomRow.leadingAnchor.constraint(equalTo: root.leadingAnchor),\n bottomRow.trailingAnchor.constraint(equalTo: root.trailingAnchor),\n bottomRow.bottomAnchor.constraint(equalTo: root.bottomAnchor),\n ])\n\n // 让 form 内每行右侧控件自动拉满(Let right-side controls in each form row auto-fill width)\n SettingsUI.pinArrangedSubviewsTrailing(in: form)\n\n self.window = w\n w.delegate = self\n refreshFields()\n let restoredFrame = WindowConfiguration.configureRestorableSettingsWindow(w, identifier: \"AtomVoice.SettingsWindow\")\n if !restoredFrame {\n w.center()\n }\n WindowPresenter.shared.bringToFront(w)\n }\n\n // MARK: - Helpers\n\n // MARK: - State\n\n private func refreshFields() {\n providers = ProviderStore.load()\n providerPopup?.removeAllItems()\n providers.forEach { providerPopup?.addItem(withTitle: $0.name) }\n\n let connection = AppSettings.llmConnection\n let savedURL = connection.baseURL\n let matchIdx = providers.firstIndex { $0.baseURL == savedURL } ?? (providers.count - 1)\n providerPopup?.selectItem(at: matchIdx)\n\n apiBaseURLField?.stringValue = savedURL\n apiKeyField?.stringValue = connection.apiKey\n modelField?.stringValue = connection.model\n let delay = AppSettings.llmResultDelay\n // 选中最近的选项(Select closest option)\n let closestIdx = delayOptions.enumerated().min(by: { abs($0.element - delay) < abs($1.element - delay) })?.offset ?? 1\n delayPopup?.selectItem(at: closestIdx)\n statusLabel?.stringValue = \"\"\n }\n\n // MARK: - Actions\n\n @objc private func providerChanged(_ sender: NSPopUpButton) {\n let p = providers[sender.indexOfSelectedItem]\n if p.baseURL.isEmpty {\n apiBaseURLField.stringValue = \"\"\n modelField.stringValue = \"\"\n window?.makeFirstResponder(apiBaseURLField)\n } else {\n apiBaseURLField.stringValue = p.baseURL\n modelField.stringValue = p.defaultModel\n }\n }\n\n @objc private func editProviders(_ sender: NSButton) {\n guard let w = window else { return }\n let editor = ProviderEditorController()\n editor.onDone = { [weak self] _ in self?.refreshFields() }\n providerEditor = editor\n editor.show(in: w) { [weak self] in\n // sheet 关闭后立即释放 editor 实例,回收内存\n // (Release editor instance right after the sheet closes to reclaim memory.)\n self?.providerEditor = nil\n }\n }\n\n @objc private func editPrompt(_ sender: NSButton) {\n guard let w = window else { return }\n let editor = PromptEditorController()\n promptEditor = editor\n editor.show(in: w) { [weak self] in\n self?.promptEditor = nil\n }\n }\n\n @objc private func testConnection(_ sender: NSButton) {\n let originalConnection = AppSettings.llmConnection\n AppSettings.llmConnection = LLMConnectionSettings(\n baseURL: apiBaseURLField.stringValue,\n apiKey: apiKeyField.stringValue,\n model: modelField.stringValue\n )\n\n statusLabel.stringValue = loc(\"settings.testing\")\n statusLabel.textColor = .secondaryLabelColor\n\n llmRefiner.testConnection { [weak self] success, msg in\n DispatchQueue.main.async {\n self?.statusLabel.stringValue = success ? loc(\"settings.connected\") : loc(\"settings.connectFailed\", msg)\n self?.statusLabel.textColor = success ? .systemGreen : .systemRed\n AppSettings.llmConnection = originalConnection\n }\n }\n }\n\n @objc private func saveSettings(_ sender: NSButton) {\n AppSettings.llmConnection = LLMConnectionSettings(\n baseURL: apiBaseURLField.stringValue,\n apiKey: apiKeyField.stringValue,\n model: modelField.stringValue\n )\n let selectedDelay = delayOptions[delayPopup.indexOfSelectedItem]\n AppSettings.llmResultDelay = selectedDelay\n statusLabel.stringValue = loc(\"settings.saved\")\n statusLabel.textColor = .systemGreen\n window?.close()\n }\n\n @objc private func cancelSettings(_ sender: NSButton) {\n window?.close()\n }\n}\n\nextension SettingsWindowController: NSWindowDelegate {\n func windowWillClose(_ notification: Notification) {\n if let w = notification.object as? NSWindow {\n WindowPresenter.shared.resetActivationIfNeeded(closing: w)\n }\n onClose?()\n }\n}\n\nextension SettingsWindowController: NSTextFieldDelegate {\n // Enter 键跳到下一个输入框,而不是插入换行(Enter key jumps to next field instead of inserting newline)\n func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {\n if selector == #selector(NSResponder.insertNewline(_:)) {\n control.window?.selectNextKeyView(nil)\n return true\n }\n return false\n }\n}\n"}}}}