VoiceInput/Sources/UI/SettingsWindow.swift
JOJO 9f3ea09b06 feat(agent): Better Siri 改造 — 个人助手 + 记忆系统 + 统一弹窗 + 多项优化
=== 记忆系统 ===
- 新增 remember(记录)和 recall(搜索)工具,持久化到 ~/Library/Application Support/VoiceInput/memory/memory.json
- 新增 set_identity 工具,独立管理助手名字/用户称呼/语气
- prompt 重写为个人助手身份,注入 macOS 能力 + 记忆规则
- prompt 中积极调用记忆工具,按需搜索、自然融入回答
- 禁止相对时间,待办必须绝对日期

=== 对话持久化 ===
- 集成 conversation_store,done 前自动写入对话文件
- token 记录:prompt/completion 累加,total 覆盖为最新上下文长度
- 启动时加载最新对话继续(使用 eagent /resume 逻辑)
- 去重:末尾连续 assistant 消息自动合并
- 修复消息加载顺序 bug(旧对话覆盖用户新消息)

=== 新建对话 ===
- 手动/自动两种模式(设置窗口可切换)
- 自动模式:total > 阈值时隐藏按钮,下次输入自动新建
- 自动阈值在设置窗口可配置(默认 40000)

=== 弹窗 UI 统一 ===
- 语音输入按 Fn 即显示刘海弹窗(和打字输入统一)
- 语音识别内容自动填入并发送(可关闭自动发送)
- 工作期间(thinking/running)隐藏输入框,idle/done 时显示
- 工具显示三种模式:全部显示/最后隐藏/一直不显示

=== 设置窗口增强 ===
- 助手名字 / 用户称呼 / 语气可配置
- 工具显示模式 / 新建对话模式 / 自动阈值
- 语音输入后自动发送开关(默认开)
- 记忆管理窗口可视化编辑(Dock 可访问)
- 窗口高度 840→860

=== 菜单增强 ===
- Agent LLM 二级菜单切换语音/打字输入
- 助手设置和记忆管理入口(Dock 可访问)

=== 修复 ===
- Fn 按下时停止正在进行的朗读
- 弹窗已存在时不再覆盖 isTextInputMode
- voiceInputText 加 @Published 以触发 UI 更新
2026-04-30 18:16:58 +08:00

922 lines
43 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
// 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
}
}