VoiceInput/Sources/Utils/Config.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

375 lines
12 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 Foundation
// MARK: - Agent models.json
struct AgentModelConfig: Codable, Identifiable {
var id: String { name }
var url: String
var name: String
var apikey: String
var modes: String
var multimodal: String
var maxOutput: Int
var maxContext: Int
init(url: String = "", name: String = "", apikey: String = "",
modes: String = "快速,思考", multimodal: String = "", maxOutput: Int = 32000, maxContext: Int = 256000) {
self.url = url
self.name = name
self.apikey = apikey
self.modes = modes
self.multimodal = multimodal
self.maxOutput = maxOutput
self.maxContext = maxContext
}
///
var supportsThinking: Bool { modes.contains("思考") || modes.contains("thinking") }
///
var supportsFast: Bool { modes.contains("快速") || modes.contains("fast") }
/// API Key
var maskedKey: String {
apikey.count <= 8 ? apikey : "\(apikey.prefix(3))...\(apikey.suffix(3))"
}
}
struct AgentModelsFile: Codable {
var tavilyApiKey: String
var defaultModel: String
var models: [AgentModelConfig]
}
enum RecognitionLanguage: String, CaseIterable {
case chineseSimplified = "zh-CN"
case english = "en-US"
case chineseTraditional = "zh-TW"
case japanese = "ja-JP"
case korean = "ko-KR"
var displayName: String {
switch self {
case .chineseSimplified: return "简体中文"
case .english: return "English"
case .chineseTraditional: return "繁體中文"
case .japanese: return "日本語"
case .korean: return "한국어"
}
}
}
enum LLMMode {
case input //
case assist // AI
case agent //
}
enum TriggerMode: String {
case input = "input" //
case assist = "assist" // AI
case agent = "agent" //
}
enum AssistOutputMode: String, CaseIterable {
case inject = "inject" //
case notch = "notch" //
var displayName: String {
switch self {
case .inject: return "直接输入"
case .notch: return "刘海弹窗"
}
}
}
struct LLMConfig {
var enabled: Bool
var baseURL: String
var apiKey: String
var model: String
var isConfigured: Bool {
!baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty
}
}
final class Config {
static let shared = Config()
private init() {}
private let defaults = UserDefaults.standard
private enum Keys {
static let language = "recognition_language"
static let triggerMode = "trigger_mode"
// LLM
static let inputLLMEnabled = "input_llm_enabled"
static let inputLLMBaseURL = "input_llm_base_url"
static let inputLLMAPIKey = "input_llm_api_key"
static let inputLLMModel = "input_llm_model"
// LLMAI
static let assistLLMEnabled = "assist_llm_enabled"
static let assistLLMBaseURL = "assist_llm_base_url"
static let assistLLMAPIKey = "assist_llm_api_key"
static let assistLLMModel = "assist_llm_model"
//
static let assistOutputMode = "assist_output_mode"
//
static let agentLLMEnabled = "agent_llm_enabled"
static let agentLLMBaseURL = "agent_llm_base_url"
static let agentLLMAPIKey = "agent_llm_api_key"
static let agentLLMModel = "agent_llm_model"
static let agentWorkspace = "agent_workspace"
}
var language: RecognitionLanguage {
get {
guard let raw = defaults.string(forKey: Keys.language),
let lang = RecognitionLanguage(rawValue: raw) else {
return .chineseSimplified
}
return lang
}
set {
defaults.set(newValue.rawValue, forKey: Keys.language)
}
}
// MARK: -
var triggerMode: TriggerMode {
get {
guard let raw = defaults.string(forKey: Keys.triggerMode),
let mode = TriggerMode(rawValue: raw) else {
return .input
}
return mode
}
set {
defaults.set(newValue.rawValue, forKey: Keys.triggerMode)
}
}
// MARK: -
var inputLLM: LLMConfig {
get {
LLMConfig(
enabled: defaults.bool(forKey: Keys.inputLLMEnabled),
baseURL: defaults.string(forKey: Keys.inputLLMBaseURL) ?? "",
apiKey: defaults.string(forKey: Keys.inputLLMAPIKey) ?? "",
model: defaults.string(forKey: Keys.inputLLMModel) ?? ""
)
}
set {
defaults.set(newValue.enabled, forKey: Keys.inputLLMEnabled)
defaults.set(newValue.baseURL, forKey: Keys.inputLLMBaseURL)
defaults.set(newValue.apiKey, forKey: Keys.inputLLMAPIKey)
defaults.set(newValue.model, forKey: Keys.inputLLMModel)
}
}
//
var llmEnabled: Bool {
get { inputLLM.enabled }
set {
var cfg = inputLLM
cfg.enabled = newValue
inputLLM = cfg
}
}
var llmBaseURL: String {
get { inputLLM.baseURL }
set {
var cfg = inputLLM
cfg.baseURL = newValue
inputLLM = cfg
}
}
var llmAPIKey: String {
get { inputLLM.apiKey }
set {
var cfg = inputLLM
cfg.apiKey = newValue
inputLLM = cfg
}
}
var llmModel: String {
get { inputLLM.model }
set {
var cfg = inputLLM
cfg.model = newValue
inputLLM = cfg
}
}
var isLLMConfigured: Bool { inputLLM.isConfigured }
// MARK: - Control AI
var assistLLM: LLMConfig {
get {
LLMConfig(
enabled: defaults.bool(forKey: Keys.assistLLMEnabled),
baseURL: defaults.string(forKey: Keys.assistLLMBaseURL) ?? "",
apiKey: defaults.string(forKey: Keys.assistLLMAPIKey) ?? "",
model: defaults.string(forKey: Keys.assistLLMModel) ?? ""
)
}
set {
defaults.set(newValue.enabled, forKey: Keys.assistLLMEnabled)
defaults.set(newValue.baseURL, forKey: Keys.assistLLMBaseURL)
defaults.set(newValue.apiKey, forKey: Keys.assistLLMAPIKey)
defaults.set(newValue.model, forKey: Keys.assistLLMModel)
}
}
// MARK: -
var assistOutputMode: AssistOutputMode {
get {
guard let raw = defaults.string(forKey: Keys.assistOutputMode),
let mode = AssistOutputMode(rawValue: raw) else {
return .inject
}
return mode
}
set {
defaults.set(newValue.rawValue, forKey: Keys.assistOutputMode)
}
}
// MARK: -
var agentLLM: LLMConfig {
get {
LLMConfig(
enabled: defaults.bool(forKey: Keys.agentLLMEnabled),
baseURL: defaults.string(forKey: Keys.agentLLMBaseURL) ?? "",
apiKey: defaults.string(forKey: Keys.agentLLMAPIKey) ?? "",
model: defaults.string(forKey: Keys.agentLLMModel) ?? ""
)
}
set {
defaults.set(newValue.enabled, forKey: Keys.agentLLMEnabled)
defaults.set(newValue.baseURL, forKey: Keys.agentLLMBaseURL)
defaults.set(newValue.apiKey, forKey: Keys.agentLLMAPIKey)
defaults.set(newValue.model, forKey: Keys.agentLLMModel)
}
}
var agentWorkspace: String {
get { defaults.string(forKey: Keys.agentWorkspace) ?? NSHomeDirectory() }
set { defaults.set(newValue, forKey: Keys.agentWorkspace) }
}
// Agent
var agentAllowMode: String {
get { defaults.string(forKey: "agent_allow_mode") ?? "full_access" }
set { defaults.set(newValue, forKey: "agent_allow_mode") }
}
// Agent
var agentInputMode: String {
get { defaults.string(forKey: "agent_input_mode") ?? "voice" }
set { defaults.set(newValue, forKey: "agent_input_mode") }
}
// Agent
var agentThinkingMode: Bool {
get { defaults.bool(forKey: "agent_thinking_mode") }
set { defaults.set(newValue, forKey: "agent_thinking_mode") }
}
// MARK: - models.json
/// Agent bundle Resources models.json Agent/
var agentDir: String {
// 0bundle Resources/AgentRelease
if let resourcePath = Bundle.main.resourcePath {
let bundledAgent = resourcePath + "/Agent/models.json"
if FileManager.default.fileExists(atPath: bundledAgent) {
print("[Config] found Agent in bundle Resources: \(resourcePath)/Agent")
return resourcePath + "/Agent"
}
}
// 1 bundlePath Debug
var dir = Bundle.main.bundlePath
print("[Config] bundlePath: \(Bundle.main.bundlePath)")
for i in 0..<6 {
let candidate = dir + "/Agent/models.json"
let exists = FileManager.default.fileExists(atPath: candidate)
print("[Config] level \(i): \(candidate)\(exists ? "YES" : "NO")")
if exists {
return dir + "/Agent"
}
dir = (dir as NSString).deletingLastPathComponent
}
// 退
let cwd = FileManager.default.currentDirectoryPath + "/Agent/models.json"
print("[Config] cwd: \(cwd)\(FileManager.default.fileExists(atPath: cwd) ? "YES" : "NO")")
if FileManager.default.fileExists(atPath: cwd) {
return FileManager.default.currentDirectoryPath + "/Agent"
}
// 退
return Bundle.main.bundlePath + "/../../../Agent"
}
var agentModelsPath: String { agentDir + "/models.json" }
// MARK: - models.json
func loadAgentModels() -> AgentModelsFile {
let path = agentModelsPath
guard FileManager.default.fileExists(atPath: path),
let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
return AgentModelsFile(tavilyApiKey: "", defaultModel: "", models: [])
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
guard let models = try? decoder.decode(AgentModelsFile.self, from: data) else {
return AgentModelsFile(tavilyApiKey: "", defaultModel: "", models: [])
}
return models
}
func saveAgentModels(_ models: AgentModelsFile) {
let path = agentModelsPath
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
guard let data = try? encoder.encode(models) else { return }
try? data.write(to: URL(fileURLWithPath: path), options: .atomic)
}
///
var validAgentModels: [AgentModelConfig] {
loadAgentModels().models.filter { m in
!m.url.isEmpty && !m.name.isEmpty && !m.apikey.isEmpty && !m.modes.isEmpty
}
}
func getLLMConfig(for mode: LLMMode) -> LLMConfig {
switch mode {
case .input: return inputLLM
case .assist: return assistLLM
case .agent: return agentLLM
}
}
func setLLMConfig(_ config: LLMConfig, for mode: LLMMode) {
switch mode {
case .input: inputLLM = config
case .assist: assistLLM = config
case .agent: agentLLM = config
}
}
}