## 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 支持弹窗内容变化弹性动画
375 lines
12 KiB
Swift
375 lines
12 KiB
Swift
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"
|
||
|
||
// 助手模式 LLM(AI 助手对话)
|
||
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 {
|
||
// 优先级 0:bundle Resources/Agent(Release 打包后)
|
||
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
|
||
}
|
||
}
|
||
}
|