VoiceInput/Sources/UI/SettingsWindow.swift
JOJO 94b05c28b3 feat: Agent 模式重大更新 — 稳定多轮对话、流式刘海、模型管理、交互优化
## Agent 核心功能
- **流式输出刘海弹窗**:实时展示模型思考内容,支持自动滚动
- **工具调用进度**:最多可见3条 + 可滚动查看全部历史 + 防抖并行滚动
- **打字/语音双输入**:Agent 模式可选手动打字或语音输入
- **快速/思考模式切换**:支持 thinking mode 开关
- **多轮对话连续性**:同一会话内持续对话,新建对话按钮 ⊕ 开启新会话
- **工作时间显示**:工作完成状态栏显示耗时,如「工作完成 · 00:42」

## Bug 修复
- **SIGTERM 优雅退出**:bridge.js 处理 SIGTERM,避免 code 15 误报异常
- **弹窗复用不重建**:输入内容继续对话时弹窗内部刷新,不再闪出闪进
- **关闭按钮立刻生效**:ignoreMouse=true 绕过 DynamicNotchKit 延迟
- **设置窗口 Agent UI 构建时机**:从延迟构建改为 init 中直接构建
- **模型编辑器**:修复 grid column nil 崩溃 + 改为独立 KeyEquivalentPanel 窗口支持 Cmd+A/C/V/X
- **双击 Control 三向轮换**:语音输入 ↔ AI助手 ↔ 智能体
- **Fn 长按冲突处理**:非 Agent 模式下自动关闭 Agent 刘海弹窗
- **Save 后刷新下拉**:保存配置后默认模型下拉框即时同步

## UI 交互优化
- **工具列表无灰色背景**:工具执行期间不再有底色变化
- **工作时隐藏新建对话按钮**:thinking/running 期间 ⊕ 自动隐藏
- **工具调用去 timeout 显示**:运行指令行不再显示 (timeout=30s)

## 文件拆分(单文件≤500行)
- AgentNotchView → AgentProgressTracker + AppKitTextField + AgentNotchView
- SettingsWindow → AgentModelEditor + KeyEquivalentSecureTextField + KeyEquivalentPanel + SettingsWindow

## 底层改动
- DynamicNotchKit: 新增 contentVersion + notifyContentChanged 机制
- NotchView: 动画绑定 contentVersion 支持弹窗内容变化弹性动画
2026-04-29 16:42:46 +08:00

682 lines
30 KiB
Swift
Raw 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.

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
// 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)
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 ? 660 : (mode == .assist ? 360 : 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)
])
outputLabel = label
} else {
outputLabel = nil
}
let 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)
}
}
@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 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
}
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 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(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),
tavilyLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20),
tavilyLabel.topAnchor.constraint(equalTo: thinkLabel.bottomAnchor, constant: 16),
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 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
}
}