VoiceInput/Sources/Utils/Config.swift
JOJO 76741877a8 feat: 朗读功能、弹窗优化、Node.js 打包、Prompt 更新
- Assist/Agent 模式新增朗读开关 + NSSpeechSynthesizer 语音合成
- 朗读完成后弹窗延迟5秒带动画关闭(不再写死15/20秒)
- 打字模式下不自动关闭弹窗
- 二次输入时取消旧朗读/旧延迟关闭,避免竞争问题
- Agent 进度计时器第二次输入时正确归零
- DMG 打包自动清空 models.json API Key
- Node.js 运行时打包进 Agent/bin/,用户无需安装
- Agent system prompt 更新为 VoiceInput 身份 + 语音容错说明
- AGENTS.md 全面更新,补全 Agent 模式文档
2026-04-29 18:22:14 +08:00

393 lines
13 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 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 {
// 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
}
}
}