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 voiceAutoSendCheckbox: NSButton // Agent 模式专属:助手名字/语气 private let assistantNameField: NSTextField private let assistantTonePopup: NSPopUpButton private let userNameField: NSTextField // Agent 模式专属:工具显示模式 private let toolsModePopup: NSPopUpButton // Agent 模式专属:新建对话模式 private let newConversationModePopup: NSPopUpButton // Agent 模式专属:自动新建阈值 private let autoNewTokenThresholdField: NSTextField // 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) voiceAutoSendCheckbox = NSButton(checkboxWithTitle: "语音输入后自动发送", target: nil, action: nil) assistantNameField = NSTextField(frame: .zero) assistantTonePopup = NSPopUpButton(frame: .zero, pullsDown: false) userNameField = NSTextField(frame: .zero) toolsModePopup = NSPopUpButton(frame: .zero, pullsDown: false) newConversationModePopup = NSPopUpButton(frame: .zero, pullsDown: false) autoNewTokenThresholdField = NSTextField(frame: .zero) 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 ? 860 : (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() { Config.shared.syncUserProfile() 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 voiceAutoSendCheckbox.state = Config.shared.agentVoiceAutoSend ? .on : .off assistantNameField.stringValue = Config.shared.assistantName userNameField.stringValue = Config.shared.userName let tones = ["友好", "专业", "幽默", "简洁", "温暖", "正式"] let toneIdx = tones.firstIndex(of: Config.shared.assistantTone) ?? 0 assistantTonePopup.selectItem(at: toneIdx) let modeMap = ["all", "hide_final", "never"] let currentMode = Config.shared.agentToolsMode toolsModePopup.selectItem(at: modeMap.firstIndex(of: currentMode) ?? 0) let convModeMap = ["manual", "auto"] let currentConvMode = Config.shared.agentNewConversationMode newConversationModePopup.selectItem(at: convModeMap.firstIndex(of: currentConvMode) ?? 0) autoNewTokenThresholdField.stringValue = String(Config.shared.agentAutoNewTokenThreshold) } 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) // 工具显示模式 let toolsModeLabel = makeLabel("工具显示:") toolsModePopup.translatesAutoresizingMaskIntoConstraints = false toolsModePopup.addItems(withTitles: ["全部显示", "最后隐藏", "一直不显示"]) let modeMap = ["all", "hide_final", "never"] let currentMode = Config.shared.agentToolsMode toolsModePopup.selectItem(at: modeMap.firstIndex(of: currentMode) ?? 0) // 新建对话模式 let newConvLabel = makeLabel("新建对话:") newConversationModePopup.translatesAutoresizingMaskIntoConstraints = false newConversationModePopup.addItems(withTitles: ["手动", "自动"]) let convModeMap = ["manual", "auto"] let currentConvMode = Config.shared.agentNewConversationMode newConversationModePopup.selectItem(at: convModeMap.firstIndex(of: currentConvMode) ?? 0) // 自动新建阈值 let thresholdLabel = makeLabel("自动新建阈值:") autoNewTokenThresholdField.placeholderString = "40000" autoNewTokenThresholdField.font = .systemFont(ofSize: 13) autoNewTokenThresholdField.bezelStyle = .roundedBezel autoNewTokenThresholdField.translatesAutoresizingMaskIntoConstraints = false autoNewTokenThresholdField.stringValue = String(Config.shared.agentAutoNewTokenThreshold) autoNewTokenThresholdField.target = self autoNewTokenThresholdField.action = #selector(agentAutoNewThresholdChanged) agentSpeakCheckbox.translatesAutoresizingMaskIntoConstraints = false agentSpeakCheckbox.state = Config.shared.agentSpeakEnabled ? .on : .off agentSpeakCheckbox.target = self agentSpeakCheckbox.action = #selector(agentSpeakToggled) voiceAutoSendCheckbox.translatesAutoresizingMaskIntoConstraints = false voiceAutoSendCheckbox.state = Config.shared.agentVoiceAutoSend ? .on : .off voiceAutoSendCheckbox.target = self voiceAutoSendCheckbox.action = #selector(agentVoiceAutoSendToggled) // 助手名字 let assistantNameLabel = makeLabel("助手名字:") assistantNameField.placeholderString = "小助手" assistantNameField.font = .systemFont(ofSize: 13) assistantNameField.bezelStyle = .roundedBezel assistantNameField.translatesAutoresizingMaskIntoConstraints = false assistantNameField.stringValue = Config.shared.assistantName // 用户称呼 let userNameLabel = makeLabel("我的称呼:") userNameField.placeholderString = "如 jojo、张总(留空则称「用户」)" userNameField.font = .systemFont(ofSize: 13) userNameField.bezelStyle = .roundedBezel userNameField.translatesAutoresizingMaskIntoConstraints = false userNameField.stringValue = Config.shared.userName // 助手语气 let assistantToneLabel = makeLabel("助手语气:") assistantTonePopup.translatesAutoresizingMaskIntoConstraints = false assistantTonePopup.addItems(withTitles: ["友好", "专业", "幽默", "简洁", "温暖", "正式"]) let toneIndex = ["友好", "专业", "幽默", "简洁", "温暖", "正式"].firstIndex(of: Config.shared.assistantTone) ?? 0 assistantTonePopup.selectItem(at: toneIndex) 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(toolsModeLabel) content.addSubview(toolsModePopup) content.addSubview(newConvLabel) content.addSubview(newConversationModePopup) content.addSubview(thresholdLabel) content.addSubview(autoNewTokenThresholdField) content.addSubview(agentSpeakCheckbox) content.addSubview(voiceAutoSendCheckbox) content.addSubview(assistantNameLabel) content.addSubview(assistantNameField) content.addSubview(userNameLabel) content.addSubview(userNameField) content.addSubview(assistantToneLabel) content.addSubview(assistantTonePopup) 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), toolsModeLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), toolsModeLabel.topAnchor.constraint(equalTo: thinkLabel.bottomAnchor, constant: 16), toolsModeLabel.widthAnchor.constraint(equalToConstant: 85), toolsModePopup.leadingAnchor.constraint(equalTo: toolsModeLabel.trailingAnchor, constant: 8), toolsModePopup.centerYAnchor.constraint(equalTo: toolsModeLabel.centerYAnchor), toolsModePopup.widthAnchor.constraint(equalToConstant: 200), newConvLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), newConvLabel.topAnchor.constraint(equalTo: toolsModeLabel.bottomAnchor, constant: 16), newConvLabel.widthAnchor.constraint(equalToConstant: 85), newConversationModePopup.leadingAnchor.constraint(equalTo: newConvLabel.trailingAnchor, constant: 8), newConversationModePopup.centerYAnchor.constraint(equalTo: newConvLabel.centerYAnchor), newConversationModePopup.widthAnchor.constraint(equalToConstant: 200), thresholdLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), thresholdLabel.topAnchor.constraint(equalTo: newConvLabel.bottomAnchor, constant: 16), thresholdLabel.widthAnchor.constraint(equalToConstant: 85), autoNewTokenThresholdField.leadingAnchor.constraint(equalTo: thresholdLabel.trailingAnchor, constant: 8), autoNewTokenThresholdField.centerYAnchor.constraint(equalTo: thresholdLabel.centerYAnchor), autoNewTokenThresholdField.widthAnchor.constraint(equalToConstant: 100), agentSpeakCheckbox.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), agentSpeakCheckbox.topAnchor.constraint(equalTo: thresholdLabel.bottomAnchor, constant: 10), voiceAutoSendCheckbox.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), voiceAutoSendCheckbox.topAnchor.constraint(equalTo: agentSpeakCheckbox.bottomAnchor, constant: 6), // 助手名字 assistantNameLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), assistantNameLabel.topAnchor.constraint(equalTo: voiceAutoSendCheckbox.bottomAnchor, constant: 14), assistantNameLabel.widthAnchor.constraint(equalToConstant: 85), assistantNameField.leadingAnchor.constraint(equalTo: assistantNameLabel.trailingAnchor, constant: 8), assistantNameField.centerYAnchor.constraint(equalTo: assistantNameLabel.centerYAnchor), assistantNameField.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), // 用户称呼 userNameLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), userNameLabel.topAnchor.constraint(equalTo: assistantNameLabel.bottomAnchor, constant: 14), userNameLabel.widthAnchor.constraint(equalToConstant: 85), userNameField.leadingAnchor.constraint(equalTo: userNameLabel.trailingAnchor, constant: 8), userNameField.centerYAnchor.constraint(equalTo: userNameLabel.centerYAnchor), userNameField.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), // 助手语气 assistantToneLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), assistantToneLabel.topAnchor.constraint(equalTo: userNameLabel.bottomAnchor, constant: 14), assistantToneLabel.widthAnchor.constraint(equalToConstant: 85), assistantTonePopup.leadingAnchor.constraint(equalTo: assistantToneLabel.trailingAnchor, constant: 8), assistantTonePopup.centerYAnchor.constraint(equalTo: assistantToneLabel.centerYAnchor), assistantTonePopup.widthAnchor.constraint(equalToConstant: 200), tavilyLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), tavilyLabel.topAnchor.constraint(equalTo: assistantToneLabel.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 agentVoiceAutoSendToggled() { Config.shared.agentVoiceAutoSend = voiceAutoSendCheckbox.state == .on } @objc private func agentAutoNewThresholdChanged() { let val = Int(autoNewTokenThresholdField.stringValue) ?? 40000 Config.shared.agentAutoNewTokenThreshold = max(1000, val) autoNewTokenThresholdField.stringValue = String(Config.shared.agentAutoNewTokenThreshold) } @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 // 保存助手个性化配置 Config.shared.assistantName = assistantNameField.stringValue.trimmingCharacters(in: .whitespaces) let tones = ["友好", "专业", "幽默", "简洁", "温暖", "正式"] let toneIdx = assistantTonePopup.indexOfSelectedItem if toneIdx >= 0, toneIdx < tones.count { Config.shared.assistantTone = tones[toneIdx] } Config.shared.userName = userNameField.stringValue.trimmingCharacters(in: .whitespaces) let modeMap = ["all", "hide_final", "never"] let modeIdx = toolsModePopup.indexOfSelectedItem if modeIdx >= 0, modeIdx < modeMap.count { Config.shared.agentToolsMode = modeMap[modeIdx] } let convModeMap = ["manual", "auto"] let convIdx = newConversationModePopup.indexOfSelectedItem if convIdx >= 0, convIdx < convModeMap.count { Config.shared.agentNewConversationMode = convModeMap[convIdx] } // 保存自动新建阈值 let thresholdVal = Int(autoNewTokenThresholdField.stringValue) ?? 40000 Config.shared.agentAutoNewTokenThreshold = max(1000, thresholdVal) 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 } }