551 lines
18 KiB
Swift
551 lines
18 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 AgentReminderMode: String, CaseIterable {
|
||
case agent = "agent"
|
||
case system = "system"
|
||
case both = "both"
|
||
|
||
var displayName: String {
|
||
switch self {
|
||
case .agent: return "智能体通知"
|
||
case .system: return "系统通知"
|
||
case .both: return "同时通知"
|
||
}
|
||
}
|
||
}
|
||
|
||
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") }
|
||
}
|
||
|
||
// Agent 待办提醒通知方式
|
||
var agentReminderMode: AgentReminderMode {
|
||
get {
|
||
guard let raw = defaults.string(forKey: "agent_reminder_mode"),
|
||
let mode = AgentReminderMode(rawValue: raw) else {
|
||
return .agent
|
||
}
|
||
return mode
|
||
}
|
||
set { defaults.set(newValue.rawValue, forKey: "agent_reminder_mode") }
|
||
}
|
||
|
||
// MARK: - 助手个性化配置
|
||
|
||
var assistantName: String {
|
||
get { defaults.string(forKey: "assistant_name") ?? "小助手" }
|
||
set {
|
||
defaults.set(newValue, forKey: "assistant_name")
|
||
saveUserProfile()
|
||
}
|
||
}
|
||
|
||
var assistantTone: String {
|
||
get { defaults.string(forKey: "assistant_tone") ?? "友好" }
|
||
set {
|
||
defaults.set(newValue, forKey: "assistant_tone")
|
||
saveUserProfile()
|
||
}
|
||
}
|
||
|
||
var userName: String {
|
||
get { defaults.string(forKey: "user_name") ?? "" }
|
||
set {
|
||
defaults.set(newValue, forKey: "user_name")
|
||
saveUserProfile()
|
||
}
|
||
}
|
||
|
||
var agentShowTools: Bool {
|
||
get {
|
||
if defaults.object(forKey: "agent_show_tools") == nil { return true }
|
||
return defaults.bool(forKey: "agent_show_tools")
|
||
}
|
||
set { defaults.set(newValue, forKey: "agent_show_tools") }
|
||
}
|
||
|
||
var agentToolsMode: String {
|
||
get { defaults.string(forKey: "agent_tools_mode") ?? "all" }
|
||
set { defaults.set(newValue, forKey: "agent_tools_mode") }
|
||
}
|
||
|
||
var agentNewConversationMode: String {
|
||
get { defaults.string(forKey: "agent_new_conversation_mode") ?? "manual" }
|
||
set { defaults.set(newValue, forKey: "agent_new_conversation_mode") }
|
||
}
|
||
|
||
/// 自动新建对话的 token 阈值(仅 newConversationMode == "auto" 时生效)
|
||
var agentAutoNewTokenThreshold: Int {
|
||
get {
|
||
let val = defaults.integer(forKey: "agent_auto_new_token_threshold")
|
||
return val > 0 ? val : 40000
|
||
}
|
||
set { defaults.set(newValue, forKey: "agent_auto_new_token_threshold") }
|
||
}
|
||
|
||
/// 语音输入后是否自动发送(false 时只填入输入框)
|
||
var agentVoiceAutoSend: Bool {
|
||
get {
|
||
if defaults.object(forKey: "agent_voice_auto_send") == nil { return true }
|
||
return defaults.bool(forKey: "agent_voice_auto_send")
|
||
}
|
||
set { defaults.set(newValue, forKey: "agent_voice_auto_send") }
|
||
}
|
||
|
||
// MARK: - 用户配置文件(user_profile.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" }
|
||
|
||
/// 用户数据目录(配置持久化,覆盖安装不丢失)
|
||
private var userDataDir: String {
|
||
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||
let dir = appSupport.appendingPathComponent("VoiceInput").path
|
||
// 确保目录存在
|
||
if !FileManager.default.fileExists(atPath: dir) {
|
||
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||
}
|
||
return dir
|
||
}
|
||
|
||
/// 用户目录下的 models.json(覆盖安装不丢失)
|
||
var userAgentModelsPath: String {
|
||
userDataDir + "/models.json"
|
||
}
|
||
|
||
/// 用户配置文件路径(助手名字/语气)
|
||
var userProfilePath: String {
|
||
userDataDir + "/user_profile.json"
|
||
}
|
||
|
||
/// 记忆文件路径
|
||
var memoryPath: String {
|
||
userDataDir + "/memory/memory.json"
|
||
}
|
||
|
||
// MARK: - models.json 读写
|
||
|
||
func loadAgentModels() -> AgentModelsFile {
|
||
let userPath = userAgentModelsPath
|
||
|
||
// 用户目录已有配置 → 直接读取
|
||
if FileManager.default.fileExists(atPath: userPath),
|
||
let data = try? Data(contentsOf: URL(fileURLWithPath: userPath)) {
|
||
let decoder = JSONDecoder()
|
||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||
if let models = try? decoder.decode(AgentModelsFile.self, from: data) {
|
||
return models
|
||
}
|
||
}
|
||
|
||
// 首次启动:从 bundle 模板复制到用户目录
|
||
let bundlePath = agentModelsPath
|
||
if FileManager.default.fileExists(atPath: bundlePath),
|
||
let data = try? Data(contentsOf: URL(fileURLWithPath: bundlePath)) {
|
||
let decoder = JSONDecoder()
|
||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||
if let models = try? decoder.decode(AgentModelsFile.self, from: data) {
|
||
// 复制到用户目录
|
||
try? data.write(to: URL(fileURLWithPath: userPath), options: .atomic)
|
||
return models
|
||
}
|
||
}
|
||
|
||
return AgentModelsFile(tavilyApiKey: "", defaultModel: "", models: [])
|
||
}
|
||
|
||
func saveAgentModels(_ models: AgentModelsFile) {
|
||
let path = userAgentModelsPath
|
||
let encoder = JSONEncoder()
|
||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||
guard let data = try? encoder.encode(models) else { return }
|
||
try? data.write(to: URL(fileURLWithPath: path), options: .atomic)
|
||
}
|
||
|
||
/// 保存用户个性化配置到 user_profile.json
|
||
func saveUserProfile() {
|
||
let profile: [String: String] = [
|
||
"assistant_name": assistantName,
|
||
"assistant_tone": assistantTone,
|
||
"user_name": userName,
|
||
]
|
||
let encoder = JSONEncoder()
|
||
guard let data = try? encoder.encode(profile) else { return }
|
||
try? data.write(to: URL(fileURLWithPath: userProfilePath), options: .atomic)
|
||
}
|
||
|
||
/// 从 user_profile.json 同步到 UserDefaults(用于窗口打开时刷新)
|
||
func syncUserProfile() {
|
||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: userProfilePath)),
|
||
let profile = try? JSONDecoder().decode([String: String].self, from: data) else {
|
||
return
|
||
}
|
||
if let name = profile["assistant_name"], !name.isEmpty {
|
||
defaults.set(name, forKey: "assistant_name")
|
||
}
|
||
if let tone = profile["assistant_tone"], !tone.isEmpty {
|
||
defaults.set(tone, forKey: "assistant_tone")
|
||
}
|
||
if let userName = profile["user_name"], !userName.isEmpty {
|
||
defaults.set(userName, forKey: "user_name")
|
||
}
|
||
}
|
||
|
||
/// 有效的模型列表(过滤掉配置不完整的)
|
||
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
|
||
}
|
||
}
|
||
}
|