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 // Assist 模式专属:朗读开关 private let speakCheckbox: NSButton // Agent 模式专属:朗读开关 private let agentSpeakCheckbox: NSButton // Agent 模式专属 private let workspaceField: NSTextField private let browseButton: NSButton private let modelPopup: NSPopUpButton private let allowModePopup: NSPopUpButton private let inputModePopup: NSPopUpButton private let thinkingModePopup: NSPopUpButton private let tavilyKeyField: NSSecureTextField private let modelTableView: NSTableView private let addModelButton: NSButton private let editModelButton: NSButton private let deleteModelButton: NSButton private var agentModelList: [AgentModelConfig] = [] private var agentUIBuilt = false 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) speakCheckbox = NSButton(checkboxWithTitle: "朗读 AI 回复", target: nil, action: nil) agentSpeakCheckbox = NSButton(checkboxWithTitle: "任务完成时朗读 AI 最终输出", target: nil, action: nil) workspaceField = NSTextField(frame: .zero) browseButton = NSButton(title: "浏览...", target: nil, action: nil) modelPopup = NSPopUpButton(frame: .zero, pullsDown: false) allowModePopup = NSPopUpButton(frame: .zero, pullsDown: false) inputModePopup = NSPopUpButton(frame: .zero, pullsDown: false) thinkingModePopup = NSPopUpButton(frame: .zero, pullsDown: false) tavilyKeyField = NSSecureTextField(frame: .zero) modelTableView = NSTableView(frame: .zero) addModelButton = NSButton(title: "+", target: nil, action: nil) editModelButton = NSButton(title: "编辑", target: nil, action: nil) deleteModelButton = NSButton(title: "删除", target: nil, action: nil) let height: CGFloat = mode == .agent ? 700 : (mode == .assist ? 400 : 320) let width: CGFloat = mode == .agent ? 500 : 420 let windowRect = NSRect(x: 0, y: 0, width: width, height: height) super.init( contentRect: windowRect, styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false ) configureWindow() buildUI() loadCurrentConfig() } private func configureWindow() { switch mode { case .input: title = "Input LLM Settings (Fn)" case .assist: title = "Assist LLM Settings (Control)" case .agent: title = "Agent LLM Settings" } isReleasedWhenClosed = false center() } // MARK: - 快捷键支持 override func performKeyEquivalent(with event: NSEvent) -> Bool { 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 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 } private func buildUI() { if mode == .agent { buildAgentUI() return } guard let content = contentView else { return } 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) 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) ]) // 朗读开关 speakCheckbox.translatesAutoresizingMaskIntoConstraints = false speakCheckbox.state = Config.shared.assistSpeakEnabled ? .on : .off speakCheckbox.target = self speakCheckbox.action = #selector(speakToggled) content.addSubview(speakCheckbox) NSLayoutConstraint.activate([ speakCheckbox.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), speakCheckbox.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 10) ]) outputLabel = nil } else { outputLabel = nil } let buttonTopItem: NSView if mode == .assist { buttonTopItem = speakCheckbox } else { 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) speakCheckbox.state = Config.shared.assistSpeakEnabled ? .on : .off } } @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 speakToggled() { Config.shared.assistSpeakEnabled = speakCheckbox.state == .on statusLabel.stringValue = speakCheckbox.state == .on ? "✅ 朗读已开启" : "✅ 朗读已关闭" 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 = "" } } // MARK: - Agent UI override func makeKeyAndOrderFront(_ sender: Any?) { if mode == .agent && !agentUIBuilt { refreshAgentModelTable() } super.makeKeyAndOrderFront(sender) } func refreshAgentModelTable() { buildAgentUI() let modelsFile = Config.shared.loadAgentModels() print("[SettingsWindow] loaded models count: \(modelsFile.models.count)") agentModelList = modelsFile.models modelTableView.reloadData() modelPopup.removeAllItems() let validModels = modelsFile.models.filter { !$0.url.isEmpty && !$0.name.isEmpty && !$0.apikey.isEmpty && !$0.modes.isEmpty } for m in validModels { modelPopup.addItem(withTitle: m.name) } if let idx = validModels.firstIndex(where: { $0.name == modelsFile.defaultModel }) { modelPopup.selectItem(at: idx) } else if !validModels.isEmpty { modelPopup.selectItem(at: 0) } tavilyKeyField.stringValue = modelsFile.tavilyApiKey workspaceField.stringValue = Config.shared.agentWorkspace agentSpeakCheckbox.state = Config.shared.agentSpeakEnabled ? .on : .off } private func buildAgentUI() { guard !agentUIBuilt else { return } guard let content = contentView else { return } agentUIBuilt = true let modelLabel = makeLabel("默认模型:") modelPopup.translatesAutoresizingMaskIntoConstraints = false modelPopup.target = self modelPopup.action = #selector(agentModelChanged) let allowLabel = makeLabel("运行模式:") allowModePopup.translatesAutoresizingMaskIntoConstraints = false allowModePopup.addItems(withTitles: ["Read Only(只读)", "Full Access(无限制)"]) allowModePopup.selectItem(at: Config.shared.agentAllowMode == "read_only" ? 0 : 1) allowModePopup.target = self allowModePopup.action = #selector(agentAllowModeChanged) let inputLabel = makeLabel("输入方式:") inputModePopup.translatesAutoresizingMaskIntoConstraints = false inputModePopup.addItems(withTitles: ["🎤 语音输入", "⌨️ 打字输入"]) inputModePopup.selectItem(at: Config.shared.agentInputMode == "text" ? 1 : 0) inputModePopup.target = self inputModePopup.action = #selector(agentInputModeChanged) let thinkLabel = makeLabel("思考模式:") thinkingModePopup.translatesAutoresizingMaskIntoConstraints = false thinkingModePopup.addItems(withTitles: ["⚡ 快速", "🧠 思考"]) thinkingModePopup.selectItem(at: Config.shared.agentThinkingMode ? 1 : 0) thinkingModePopup.target = self thinkingModePopup.action = #selector(agentThinkingModeChanged) agentSpeakCheckbox.translatesAutoresizingMaskIntoConstraints = false agentSpeakCheckbox.state = Config.shared.agentSpeakEnabled ? .on : .off agentSpeakCheckbox.target = self agentSpeakCheckbox.action = #selector(agentSpeakToggled) let tavilyLabel = makeLabel("Tavily Key:") tavilyKeyField.placeholderString = "tvly-...(网络搜索用)" tavilyKeyField.font = .systemFont(ofSize: 13) tavilyKeyField.bezelStyle = .roundedBezel tavilyKeyField.translatesAutoresizingMaskIntoConstraints = false let wsLabel = makeLabel("工作目录:") workspaceField.placeholderString = NSHomeDirectory() workspaceField.font = .systemFont(ofSize: 12) workspaceField.bezelStyle = .roundedBezel workspaceField.translatesAutoresizingMaskIntoConstraints = false browseButton.bezelStyle = .rounded browseButton.translatesAutoresizingMaskIntoConstraints = false browseButton.target = self browseButton.action = #selector(browseWorkspace) let tableHeaderLabel = NSTextField(labelWithString: "模型列表") tableHeaderLabel.font = .systemFont(ofSize: 12, weight: .semibold) tableHeaderLabel.translatesAutoresizingMaskIntoConstraints = false addModelButton.bezelStyle = .rounded addModelButton.font = .systemFont(ofSize: 13, weight: .bold) addModelButton.translatesAutoresizingMaskIntoConstraints = false addModelButton.target = self addModelButton.action = #selector(addAgentModel) let scrollView = NSScrollView(frame: .zero) scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.hasVerticalScroller = true scrollView.borderType = .bezelBorder modelTableView.translatesAutoresizingMaskIntoConstraints = false modelTableView.headerView = nil modelTableView.selectionHighlightStyle = .regular modelTableView.dataSource = self modelTableView.delegate = self modelTableView.rowHeight = 24 modelTableView.intercellSpacing = NSSize(width: 4, height: 2) let colName = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("name")) colName.title = "模型名称"; colName.width = 120; colName.minWidth = 80 let colURL = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("url")) colURL.title = "API URL"; colURL.width = 150; colURL.minWidth = 100 let colKey = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("key")) colKey.title = "API Key"; colKey.width = 90; colKey.minWidth = 70 let colModes = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("modes")) colModes.title = "模式"; colModes.width = 70; colModes.minWidth = 50 modelTableView.addTableColumn(colName) modelTableView.addTableColumn(colURL) modelTableView.addTableColumn(colKey) modelTableView.addTableColumn(colModes) scrollView.documentView = modelTableView editModelButton.bezelStyle = .rounded editModelButton.translatesAutoresizingMaskIntoConstraints = false editModelButton.target = self editModelButton.action = #selector(editAgentModel) deleteModelButton.bezelStyle = .rounded deleteModelButton.translatesAutoresizingMaskIntoConstraints = false deleteModelButton.target = self deleteModelButton.action = #selector(deleteAgentModel) let editDeleteStack = NSStackView(views: [editModelButton, deleteModelButton]) editDeleteStack.translatesAutoresizingMaskIntoConstraints = false editDeleteStack.orientation = .horizontal editDeleteStack.spacing = 8 saveButton.bezelStyle = .rounded saveButton.translatesAutoresizingMaskIntoConstraints = false saveButton.keyEquivalent = "\r" saveButton.target = self saveButton.action = #selector(saveAgentConfig) statusLabel.font = .systemFont(ofSize: 11) statusLabel.textColor = .secondaryLabelColor statusLabel.alignment = .center statusLabel.maximumNumberOfLines = 2 statusLabel.translatesAutoresizingMaskIntoConstraints = false content.addSubview(modelLabel) content.addSubview(modelPopup) content.addSubview(allowLabel) content.addSubview(allowModePopup) content.addSubview(inputLabel) content.addSubview(inputModePopup) content.addSubview(thinkLabel) content.addSubview(thinkingModePopup) content.addSubview(agentSpeakCheckbox) content.addSubview(tavilyLabel) content.addSubview(tavilyKeyField) content.addSubview(wsLabel) content.addSubview(workspaceField) content.addSubview(browseButton) content.addSubview(tableHeaderLabel) content.addSubview(addModelButton) content.addSubview(scrollView) content.addSubview(editDeleteStack) content.addSubview(saveButton) content.addSubview(statusLabel) NSLayoutConstraint.activate([ modelLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), modelLabel.topAnchor.constraint(equalTo: content.topAnchor, constant: 24), modelLabel.widthAnchor.constraint(equalToConstant: 85), modelPopup.leadingAnchor.constraint(equalTo: modelLabel.trailingAnchor, constant: 8), modelPopup.centerYAnchor.constraint(equalTo: modelLabel.centerYAnchor), modelPopup.widthAnchor.constraint(equalToConstant: 200), allowLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), allowLabel.topAnchor.constraint(equalTo: modelLabel.bottomAnchor, constant: 16), allowLabel.widthAnchor.constraint(equalToConstant: 85), allowModePopup.leadingAnchor.constraint(equalTo: allowLabel.trailingAnchor, constant: 8), allowModePopup.centerYAnchor.constraint(equalTo: allowLabel.centerYAnchor), allowModePopup.widthAnchor.constraint(equalToConstant: 200), inputLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), inputLabel.topAnchor.constraint(equalTo: allowLabel.bottomAnchor, constant: 16), inputLabel.widthAnchor.constraint(equalToConstant: 85), inputModePopup.leadingAnchor.constraint(equalTo: inputLabel.trailingAnchor, constant: 8), inputModePopup.centerYAnchor.constraint(equalTo: inputLabel.centerYAnchor), inputModePopup.widthAnchor.constraint(equalToConstant: 200), thinkLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), thinkLabel.topAnchor.constraint(equalTo: inputLabel.bottomAnchor, constant: 16), thinkLabel.widthAnchor.constraint(equalToConstant: 85), thinkingModePopup.leadingAnchor.constraint(equalTo: thinkLabel.trailingAnchor, constant: 8), thinkingModePopup.centerYAnchor.constraint(equalTo: thinkLabel.centerYAnchor), thinkingModePopup.widthAnchor.constraint(equalToConstant: 200), agentSpeakCheckbox.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), agentSpeakCheckbox.topAnchor.constraint(equalTo: thinkLabel.bottomAnchor, constant: 10), tavilyLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), tavilyLabel.topAnchor.constraint(equalTo: agentSpeakCheckbox.bottomAnchor, constant: 14), tavilyLabel.widthAnchor.constraint(equalToConstant: 85), tavilyKeyField.leadingAnchor.constraint(equalTo: tavilyLabel.trailingAnchor, constant: 8), tavilyKeyField.centerYAnchor.constraint(equalTo: tavilyLabel.centerYAnchor), tavilyKeyField.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), wsLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), wsLabel.topAnchor.constraint(equalTo: tavilyLabel.bottomAnchor, constant: 16), wsLabel.widthAnchor.constraint(equalToConstant: 85), workspaceField.leadingAnchor.constraint(equalTo: wsLabel.trailingAnchor, constant: 8), workspaceField.centerYAnchor.constraint(equalTo: wsLabel.centerYAnchor), workspaceField.trailingAnchor.constraint(equalTo: browseButton.leadingAnchor, constant: -6), browseButton.centerYAnchor.constraint(equalTo: wsLabel.centerYAnchor), browseButton.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), browseButton.widthAnchor.constraint(equalToConstant: 60), tableHeaderLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 22), tableHeaderLabel.topAnchor.constraint(equalTo: wsLabel.bottomAnchor, constant: 22), addModelButton.centerYAnchor.constraint(equalTo: tableHeaderLabel.centerYAnchor), addModelButton.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), addModelButton.widthAnchor.constraint(equalToConstant: 28), scrollView.topAnchor.constraint(equalTo: tableHeaderLabel.bottomAnchor, constant: 6), scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), scrollView.heightAnchor.constraint(equalToConstant: 170), editDeleteStack.topAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: 8), editDeleteStack.centerXAnchor.constraint(equalTo: content.centerXAnchor), saveButton.topAnchor.constraint(equalTo: editDeleteStack.bottomAnchor, constant: 12), saveButton.centerXAnchor.constraint(equalTo: content.centerXAnchor), saveButton.widthAnchor.constraint(equalToConstant: 140), statusLabel.topAnchor.constraint(equalTo: saveButton.bottomAnchor, constant: 8), statusLabel.centerXAnchor.constraint(equalTo: content.centerXAnchor), statusLabel.widthAnchor.constraint(equalToConstant: 460) ]) refreshAgentModelTable() } @objc private func browseWorkspace() { let panel = NSOpenPanel() panel.canChooseDirectories = true panel.canChooseFiles = false panel.message = "选择智能体工作目录" panel.begin { [weak self] response in guard response == .OK, let url = panel.url else { return } self?.workspaceField.stringValue = url.path Config.shared.agentWorkspace = url.path } } // MARK: - Agent 事件 @objc private func agentModelChanged() {} @objc private func agentAllowModeChanged() { Config.shared.agentAllowMode = allowModePopup.indexOfSelectedItem == 0 ? "read_only" : "full_access" } @objc private func agentInputModeChanged() { Config.shared.agentInputMode = inputModePopup.indexOfSelectedItem == 0 ? "voice" : "text" } @objc private func agentThinkingModeChanged() { Config.shared.agentThinkingMode = thinkingModePopup.indexOfSelectedItem == 1 } @objc private func agentSpeakToggled() { Config.shared.agentSpeakEnabled = agentSpeakCheckbox.state == .on statusLabel.stringValue = agentSpeakCheckbox.state == .on ? "✅ 朗读已开启" : "✅ 朗读已关闭" statusLabel.textColor = .systemGreen DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.statusLabel.stringValue = "" } } @objc private func saveAgentConfig() { var modelsFile = Config.shared.loadAgentModels() modelsFile.tavilyApiKey = tavilyKeyField.stringValue.trimmingCharacters(in: .whitespaces) modelsFile.defaultModel = modelPopup.titleOfSelectedItem ?? "" modelsFile.models = agentModelList Config.shared.saveAgentModels(modelsFile) Config.shared.agentWorkspace = workspaceField.stringValue refreshAgentModelTable() statusLabel.stringValue = "✅ Agent 配置已保存" statusLabel.textColor = .systemGreen DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.statusLabel.stringValue = "" } } @objc private func addAgentModel() { AgentModelEditor.show(for: nil) { [weak self] newModel in guard let self = self else { return } self.agentModelList.append(newModel) self.modelTableView.reloadData() self.statusLabel.stringValue = "✅ 模型已添加(点击 Save 保存到文件)" self.statusLabel.textColor = .systemGreen } } @objc private func editAgentModel() { let selected = modelTableView.selectedRow guard selected >= 0, selected < agentModelList.count else { statusLabel.stringValue = "请先选中一个模型" statusLabel.textColor = .systemRed return } let original = agentModelList[selected] AgentModelEditor.show(for: original) { [weak self] newModel in guard let self = self else { return } if let idx = self.agentModelList.firstIndex(where: { $0.name == original.name }) { self.agentModelList[idx] = newModel } self.modelTableView.reloadData() self.statusLabel.stringValue = "✅ 模型已更新(点击 Save 保存到文件)" self.statusLabel.textColor = .systemGreen } } @objc private func deleteAgentModel() { let selected = modelTableView.selectedRow guard selected >= 0, selected < agentModelList.count else { statusLabel.stringValue = "请先选中一个模型" statusLabel.textColor = .systemRed return } let model = agentModelList[selected] let alert = NSAlert() alert.messageText = "删除模型" alert.informativeText = "确定要删除模型「\(model.name)」吗?" alert.alertStyle = .warning alert.addButton(withTitle: "删除") alert.addButton(withTitle: "取消") if alert.runModal() == .alertFirstButtonReturn { agentModelList.remove(at: selected) modelTableView.reloadData() statusLabel.stringValue = "✅ 已删除(点击 Save 保存到文件)" statusLabel.textColor = .systemGreen } } } // MARK: - NSTableView extension SettingsWindow: NSTableViewDataSource { func numberOfRows(in tableView: NSTableView) -> Int { agentModelList.count } } extension SettingsWindow: NSTableViewDelegate { func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { guard row < agentModelList.count, let column = tableColumn else { return nil } let model = agentModelList[row] let textField: NSTextField if let reused = tableView.makeView(withIdentifier: column.identifier, owner: nil) as? NSTextField { textField = reused } else { textField = NSTextField(labelWithString: "") textField.identifier = column.identifier textField.font = .systemFont(ofSize: 11) textField.lineBreakMode = .byTruncatingTail textField.maximumNumberOfLines = 1 } switch column.identifier.rawValue { case "name": textField.stringValue = model.name case "url": textField.stringValue = model.url case "key": textField.stringValue = model.maskedKey case "modes": textField.stringValue = model.modes default: textField.stringValue = "" } return textField } }