// // KBVM.m // CustomKeyboard // #import "KBVM.h" #import "KBNetworkManager.h" #import "KBConfig.h" #import @implementation KBChatResponse @end @implementation KBAudioResponse @end @interface KBVM () @property (nonatomic, strong) NSCache *avatarCache; @end @implementation KBVM + (instancetype)shared { static KBVM *instance = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ instance = [[KBVM alloc] init]; }); return instance; } - (instancetype)init { if (self = [super init]) { _avatarCache = [[NSCache alloc] init]; _avatarCache.countLimit = 20; } return self; } #pragma mark - Chat API - (void)sendChatMessageWithContent:(NSString *)content companionId:(NSInteger)companionId completion:(KBChatCompletion)completion { if (content.length == 0) { if (completion) { KBChatResponse *response = [[KBChatResponse alloc] init]; response.success = NO; response.errorMessage = @"内容为空"; completion(response); } return; } NSString *encodedContent = [content stringByAddingPercentEncodingWithAllowedCharacters: [NSCharacterSet URLQueryAllowedCharacterSet]]; NSString *path = [NSString stringWithFormat:@"%@?content=%@&companionId=%ld", API_AI_CHAT_MESSAGE, encodedContent ?: @"", (long)companionId]; NSDictionary *params = @{ @"content": content ?: @"", @"companionId": @(companionId) }; [[KBNetworkManager shared] POST:path jsonBody:params headers:nil completion:^(NSDictionary *json, NSURLResponse *response, NSError *error) { dispatch_async(dispatch_get_main_queue(), ^{ KBChatResponse *chatResponse = [[KBChatResponse alloc] init]; if (error) { chatResponse.success = NO; chatResponse.errorMessage = error.localizedDescription ?: @"请求失败"; if (completion) completion(chatResponse); return; } // 解析文本 chatResponse.text = [self p_parseTextFromJSON:json]; // 解析 audioId chatResponse.audioId = [self p_parseAudioIdFromJSON:json]; chatResponse.success = (chatResponse.text.length > 0); if (!chatResponse.success) { chatResponse.errorMessage = @"未获取到回复内容"; } if (completion) completion(chatResponse); }); }]; } #pragma mark - Audio API - (void)fetchAudioURLWithAudioId:(NSString *)audioId completion:(KBAudioURLCompletion)completion { if (audioId.length == 0) { if (completion) { KBAudioResponse *response = [[KBAudioResponse alloc] init]; response.success = NO; response.errorMessage = @"audioId 为空"; completion(response); } return; } NSString *path = [NSString stringWithFormat:@"/chat/audio/%@", audioId]; [[KBNetworkManager shared] GET:path parameters:nil headers:nil completion:^(NSDictionary *json, NSURLResponse *response, NSError *error) { dispatch_async(dispatch_get_main_queue(), ^{ KBAudioResponse *audioResponse = [[KBAudioResponse alloc] init]; if (error) { audioResponse.success = NO; audioResponse.errorMessage = error.localizedDescription; if (completion) completion(audioResponse); return; } // 解析 audioURL NSString *audioURL = [self p_parseAudioURLFromJSON:json]; audioResponse.audioURL = audioURL; audioResponse.success = (audioURL.length > 0); if (completion) completion(audioResponse); }); }]; } - (void)pollAudioURLWithAudioId:(NSString *)audioId maxRetries:(NSInteger)maxRetries interval:(NSTimeInterval)interval completion:(KBAudioURLCompletion)completion { [self p_pollAudioURLWithAudioId:audioId retryCount:0 maxRetries:maxRetries interval:interval completion:completion]; } - (void)p_pollAudioURLWithAudioId:(NSString *)audioId retryCount:(NSInteger)retryCount maxRetries:(NSInteger)maxRetries interval:(NSTimeInterval)interval completion:(KBAudioURLCompletion)completion { [self fetchAudioURLWithAudioId:audioId completion:^(KBAudioResponse *response) { if (response.success && response.audioURL.length > 0) { // 成功获取到 URL if (completion) completion(response); return; } // 如果还没达到最大重试次数,继续轮询 if (retryCount < maxRetries - 1) { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(interval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [self p_pollAudioURLWithAudioId:audioId retryCount:retryCount + 1 maxRetries:maxRetries interval:interval completion:completion]; }); } else { // 达到最大重试次数 KBAudioResponse *failResponse = [[KBAudioResponse alloc] init]; failResponse.success = NO; failResponse.errorMessage = [NSString stringWithFormat:@"轮询失败,已重试 %ld 次", (long)maxRetries]; if (completion) completion(failResponse); } }]; } - (void)downloadAudioFromURL:(NSString *)urlString completion:(KBAudioDataCompletion)completion { if (urlString.length == 0) { if (completion) { KBAudioResponse *response = [[KBAudioResponse alloc] init]; response.success = NO; response.errorMessage = @"URL 为空"; completion(response); } return; } [[KBNetworkManager shared] GETData:urlString parameters:nil headers:nil completion:^(NSData *data, NSURLResponse *response, NSError *error) { dispatch_async(dispatch_get_main_queue(), ^{ KBAudioResponse *audioResponse = [[KBAudioResponse alloc] init]; if (error || !data || data.length == 0) { audioResponse.success = NO; audioResponse.errorMessage = error.localizedDescription ?: @"下载失败"; if (completion) completion(audioResponse); return; } audioResponse.audioData = data; // 计算音频时长 NSError *playerError = nil; AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:data error:&playerError]; if (!playerError && player) { audioResponse.duration = player.duration; } audioResponse.success = YES; if (completion) completion(audioResponse); }); }]; } #pragma mark - Avatar API - (void)downloadAvatarFromURL:(NSString *)urlString completion:(KBAvatarCompletion)completion { if (urlString.length == 0) { if (completion) completion(nil, nil); return; } // 检查缓存 UIImage *cached = [self.avatarCache objectForKey:urlString]; if (cached) { if (completion) completion(cached, nil); return; } [[KBNetworkManager shared] GETData:urlString parameters:nil headers:nil completion:^(NSData *data, NSURLResponse *response, NSError *error) { dispatch_async(dispatch_get_main_queue(), ^{ if (error || data.length == 0) { if (completion) completion(nil, error); return; } UIImage *image = [UIImage imageWithData:data]; if (image) { [self.avatarCache setObject:image forKey:urlString]; } if (completion) completion(image, nil); }); }]; } #pragma mark - Helper - (NSInteger)selectedCompanionIdFromAppGroup { NSDictionary *persona = [self selectedPersonaFromAppGroup]; if (persona) { id companionIdObj = persona[@"personaId"] ?: persona[@"companionId"] ?: persona[@"id"]; if ([companionIdObj respondsToSelector:@selector(integerValue)]) { return [companionIdObj integerValue]; } } return 0; } - (nullable NSDictionary *)selectedPersonaFromAppGroup { NSUserDefaults *shared = [[NSUserDefaults alloc] initWithSuiteName:AppGroup]; return [shared objectForKey:@"AppGroup_SelectedPersona"]; } #pragma mark - Private Parse Methods /// 解析聊天文本 - (NSString *)p_parseTextFromJSON:(NSDictionary *)json { if (![json isKindOfClass:[NSDictionary class]]) return @""; id dataObj = json[@"data"]; if ([dataObj isKindOfClass:[NSDictionary class]]) { NSDictionary *data = (NSDictionary *)dataObj; // 优先读取 aiResponse 字段 NSArray *keys = @[@"aiResponse", @"content", @"text", @"message"]; for (NSString *key in keys) { id value = data[key]; if ([value isKindOfClass:[NSString class]] && ((NSString *)value).length > 0) { return (NSString *)value; } } } else if ([dataObj isKindOfClass:[NSString class]]) { return (NSString *)dataObj; } return @""; } /// 解析 audioId - (NSString *)p_parseAudioIdFromJSON:(NSDictionary *)json { if (![json isKindOfClass:[NSDictionary class]]) return nil; id dataObj = json[@"data"]; if ([dataObj isKindOfClass:[NSDictionary class]]) { NSDictionary *data = (NSDictionary *)dataObj; NSString *audioId = data[@"audioId"]; if ([audioId isKindOfClass:[NSString class]] && audioId.length > 0) { return audioId; } } // 兼容其他字段名 NSArray *keys = @[@"audioId", @"audio_id"]; for (NSString *key in keys) { id value = json[key]; if ([value isKindOfClass:[NSString class]] && ((NSString *)value).length > 0) { return (NSString *)value; } } return nil; } /// 解析 audioURL - (NSString *)p_parseAudioURLFromJSON:(NSDictionary *)json { if (![json isKindOfClass:[NSDictionary class]]) return nil; id dataObj = json[@"data"]; if ([dataObj isKindOfClass:[NSDictionary class]]) { NSDictionary *data = (NSDictionary *)dataObj; id audioUrlObj = data[@"audioUrl"] ?: data[@"url"]; if (audioUrlObj && ![audioUrlObj isKindOfClass:[NSNull class]] && [audioUrlObj isKindOfClass:[NSString class]]) { return (NSString *)audioUrlObj; } } return nil; } @end