{"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"}}}}