- Assist/Agent 模式新增朗读开关 + NSSpeechSynthesizer 语音合成 - 朗读完成后弹窗延迟5秒带动画关闭(不再写死15/20秒) - 打字模式下不自动关闭弹窗 - 二次输入时取消旧朗读/旧延迟关闭,避免竞争问题 - Agent 进度计时器第二次输入时正确归零 - DMG 打包自动清空 models.json API Key - Node.js 运行时打包进 Agent/bin/,用户无需安装 - Agent system prompt 更新为 VoiceInput 身份 + 语音容错说明 - AGENTS.md 全面更新,补全 Agent 模式文档
393 lines
13 KiB
Swift
393 lines
13 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 assistSpeakEnabled = "assist_speak_enabled"
|
||
|
||
// 智能体模式朗读开关
|
||
static let agentSpeakEnabled = "agent_speak_enabled"
|
||
|
||
// 智能体模式
|
||
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 assistSpeakEnabled: Bool {
|
||
get { defaults.bool(forKey: Keys.assistSpeakEnabled) }
|
||
set { defaults.set(newValue, forKey: Keys.assistSpeakEnabled) }
|
||
}
|
||
|
||
// 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) }
|
||
}
|
||
|
||
var agentSpeakEnabled: Bool {
|
||
get { defaults.bool(forKey: Keys.agentSpeakEnabled) }
|
||
set { defaults.set(newValue, forKey: Keys.agentSpeakEnabled) }
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
}
|