From 3705db4aabfaef14ef0ae278a70647478bb17e21 Mon Sep 17 00:00:00 2001 From: CodeST <694468528@qq.com> Date: Fri, 30 Jan 2026 13:26:02 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AE=80=E5=8D=95=E5=B0=81=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CustomKeyboard/KeyboardViewController.m | 385 ++++++------------------ CustomKeyboard/VM/KBVM.h | 95 ++++++ CustomKeyboard/VM/KBVM.m | 334 ++++++++++++++++++++ keyBoard.xcodeproj/project.pbxproj | 6 + 4 files changed, 527 insertions(+), 293 deletions(-) create mode 100644 CustomKeyboard/VM/KBVM.h create mode 100644 CustomKeyboard/VM/KBVM.m diff --git a/CustomKeyboard/KeyboardViewController.m b/CustomKeyboard/KeyboardViewController.m index ed5868d..1e299b2 100644 --- a/CustomKeyboard/KeyboardViewController.m +++ b/CustomKeyboard/KeyboardViewController.m @@ -24,6 +24,7 @@ #import "KBSkinManager.h" #import "KBSuggestionEngine.h" #import "KBNetworkManager.h" +#import "KBVM.h" #import "Masonry.h" #import "UIImage+KBColor.h" #import @@ -93,7 +94,6 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, @property(nonatomic, assign) CGFloat kb_lastKeyboardHeight; @property(nonatomic, strong) NSMutableArray *chatMessages; @property(nonatomic, strong) AVAudioPlayer *chatAudioPlayer; -@property(nonatomic, strong) NSCache *chatAvatarCache; @property(nonatomic, assign) BOOL chatPanelVisible; @end @@ -918,37 +918,16 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, if (urlString.length == 0) { return; } - UIImage *cached = [self.chatAvatarCache objectForKey:urlString]; - if (cached) { - message.avatarImage = cached; - [self kb_reloadChatRowForMessage:message]; - return; - } if (![[KBFullAccessManager shared] hasFullAccess]) { return; } __weak typeof(self) weakSelf = self; - [[KBNetworkManager shared] - GETData:urlString - parameters:nil - headers:nil - completion:^(NSData *data, NSURLResponse *response, NSError *error) { - if (error || data.length == 0) { - return; - } - UIImage *image = [UIImage imageWithData:data]; - if (!image) { - return; - } - dispatch_async(dispatch_get_main_queue(), ^{ - __strong typeof(weakSelf) self = weakSelf; - if (!self) { - return; - } - [self.chatAvatarCache setObject:image forKey:urlString]; - message.avatarImage = image; - [self kb_reloadChatRowForMessage:message]; - }); + [[KBVM shared] downloadAvatarFromURL:urlString completion:^(UIImage *image, NSError *error) { + __strong typeof(weakSelf) self = weakSelf; + if (!self || !image) return; + + message.avatarImage = image; + [self kb_reloadChatRowForMessage:message]; }]; } @@ -1042,292 +1021,119 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, } // 从 AppGroup 获取选中的 persona companionId - NSInteger companionId = [self kb_selectedCompanionId]; - - 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) - }; - - NSLog(@"[Keyboard] 发送聊天请求: path=%@, companionId=%ld", path, (long)companionId); + NSInteger companionId = [[KBVM shared] selectedCompanionIdFromAppGroup]; + NSLog(@"[Keyboard] 发送聊天请求: companionId=%ld", (long)companionId); __weak typeof(self) weakSelf = self; - [[KBNetworkManager shared] POST:path - jsonBody:params - headers:nil - completion:^(NSDictionary *json, NSURLResponse *response, - NSError *error) { - NSLog(@"[Keyboard] ========== 聊天响应回调 =========="); - NSLog(@"[Keyboard] error: %@", error); - NSLog(@"[Keyboard] json: %@", json); + [[KBVM shared] sendChatMessageWithContent:content + companionId:companionId + completion:^(KBChatResponse *response) { + __strong typeof(weakSelf) self = weakSelf; + if (!self) { + NSLog(@"[Keyboard] ❌ self 为空"); + return; + } - dispatch_async(dispatch_get_main_queue(), ^{ - __strong typeof(weakSelf) self = weakSelf; - if (!self) { - NSLog(@"[Keyboard] ❌ self 为空"); - return; - } - - NSLog(@"[Keyboard] 回调中 chatPanelView=%p", self.chatPanelView); - - if (error) { - NSLog(@"[Keyboard] ❌ 请求失败: %@", error.localizedDescription); - [self.chatPanelView kb_removeLoadingAssistantMessage]; - NSString *tip = error.localizedDescription ?: KBLocalized(@"请求失败"); - [KBHUD showInfo:tip]; - return; - } - - // 解析返回数据 - NSString *text = [self kb_chatMessageTextFromJSON:json]; - NSString *audioId = [self kb_chatMessageAudioIdFromJSON:json]; - - NSLog(@"[Keyboard] ✅ 解析结果: text=%@, audioId=%@", text, audioId); - - if (text.length == 0) { - NSLog(@"[Keyboard] ❌ 文本为空,移除 loading"); - [self.chatPanelView kb_removeLoadingAssistantMessage]; - [KBHUD showInfo:KBLocalized(@"未获取到回复内容")]; - return; - } - - NSLog(@"[Keyboard] 准备调用 kb_addAssistantMessage, chatPanelView=%p", self.chatPanelView); - // 添加 AI 消息(带打字机效果) - [self.chatPanelView kb_addAssistantMessage:text audioId:audioId]; - NSLog(@"[Keyboard] kb_addAssistantMessage 调用完成"); - - // 如果有 audioId,开始预加载音频 - if (audioId.length > 0) { - NSDate *startTime = [NSDate date]; - [self kb_preloadAudioWithAudioId:audioId startTime:startTime]; - } - }); + NSLog(@"[Keyboard] 回调中 chatPanelView=%p", self.chatPanelView); + + if (!response.success) { + NSLog(@"[Keyboard] ❌ 请求失败: %@", response.errorMessage); + [self.chatPanelView kb_removeLoadingAssistantMessage]; + [KBHUD showInfo:response.errorMessage ?: KBLocalized(@"请求失败")]; + return; + } + + NSLog(@"[Keyboard] ✅ 解析结果: text=%@, audioId=%@", response.text, response.audioId); + + if (response.text.length == 0) { + NSLog(@"[Keyboard] ❌ 文本为空,移除 loading"); + [self.chatPanelView kb_removeLoadingAssistantMessage]; + [KBHUD showInfo:KBLocalized(@"未获取到回复内容")]; + return; + } + + NSLog(@"[Keyboard] 准备调用 kb_addAssistantMessage, chatPanelView=%p", self.chatPanelView); + // 添加 AI 消息(带打字机效果) + [self.chatPanelView kb_addAssistantMessage:response.text audioId:response.audioId]; + NSLog(@"[Keyboard] kb_addAssistantMessage 调用完成"); + + // 如果有 audioId,开始预加载音频 + if (response.audioId.length > 0) { + [self kb_preloadAudioWithAudioId:response.audioId]; + } }]; } /// 从 AppGroup 获取选中的 persona companionId - (NSInteger)kb_selectedCompanionId { - NSDictionary *persona = [self kb_selectedPersonaFromAppGroup]; - if (persona) { - // 主 App 保存的字段名是 personaId - id companionIdObj = persona[@"personaId"] ?: persona[@"companionId"] ?: persona[@"id"]; - if ([companionIdObj respondsToSelector:@selector(integerValue)]) { - NSInteger companionId = [companionIdObj integerValue]; - NSLog(@"[Keyboard] 从 AppGroup 获取 companionId: %ld", (long)companionId); - return companionId; - } - } - NSLog(@"[Keyboard] 未找到 persona,使用默认 companionId: 0"); - return 0; // 默认值 -} - -/// 解析聊天消息文本 -- (NSString *)kb_chatMessageTextFromJSON:(NSDictionary *)json { - NSLog(@"[Keyboard] ========== kb_chatMessageTextFromJSON =========="); - NSLog(@"[Keyboard] 输入 json 类型: %@", NSStringFromClass([json class])); - - if (![json isKindOfClass:[NSDictionary class]]) { - NSLog(@"[Keyboard] ❌ json 不是字典类型"); - return @""; - } - - id dataObj = json[@"data"]; - NSLog(@"[Keyboard] data 字段类型: %@, 值: %@", NSStringFromClass([dataObj class]), dataObj); - - if ([dataObj isKindOfClass:[NSDictionary class]]) { - NSDictionary *data = (NSDictionary *)dataObj; - NSLog(@"[Keyboard] data 字典内容: %@", data); - - // 优先读取 aiResponse 字段(后端实际返回的字段名) - NSArray *dataKeys = @[@"aiResponse", @"content", @"text", @"message"]; - for (NSString *key in dataKeys) { - id value = data[key]; - NSLog(@"[Keyboard] 检查 data.%@ = %@ (类型: %@)", key, value, NSStringFromClass([value class])); - if ([value isKindOfClass:[NSString class]] && ((NSString *)value).length > 0) { - NSLog(@"[Keyboard] ✅ 从 data.%@ 解析到文本: %@", key, value); - return (NSString *)value; - } - } - NSLog(@"[Keyboard] ❌ data 字典中没有找到有效文本"); - } else if ([dataObj isKindOfClass:[NSString class]]) { - NSLog(@"[Keyboard] data 是字符串: %@", dataObj); - return (NSString *)dataObj; - } else { - NSLog(@"[Keyboard] ❌ data 字段类型不支持: %@", NSStringFromClass([dataObj class])); - } - - return @""; -} - -/// 解析聊天消息 audioId -- (NSString *)kb_chatMessageAudioIdFromJSON:(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; + return [[KBVM shared] selectedCompanionIdFromAppGroup]; } #pragma mark - Audio Preload /// 预加载音频(轮询获取 audioURL) -- (void)kb_preloadAudioWithAudioId:(NSString *)audioId startTime:(NSDate *)startTime { +- (void)kb_preloadAudioWithAudioId:(NSString *)audioId { if (audioId.length == 0) return; NSLog(@"[Keyboard] 开始预加载音频,audioId: %@", audioId); - // 开始轮询(最多10次,每次间隔1秒,共10秒) - [self kb_pollAudioURLWithAudioId:audioId retryCount:0 maxRetries:10 startTime:startTime]; -} - -/// 轮询获取 audioURL -- (void)kb_pollAudioURLWithAudioId:(NSString *)audioId - retryCount:(NSInteger)retryCount - maxRetries:(NSInteger)maxRetries - startTime:(NSDate *)startTime { - - NSString *path = [NSString stringWithFormat:@"/chat/audio/%@", audioId]; - __weak typeof(self) weakSelf = self; - [[KBNetworkManager shared] GET:path - parameters:nil - headers:nil - completion:^(NSDictionary *json, NSURLResponse *response, - NSError *error) { - dispatch_async(dispatch_get_main_queue(), ^{ - __strong typeof(weakSelf) self = weakSelf; - if (!self) return; - - // 解析 audioURL - NSString *audioURL = nil; - if ([json isKindOfClass:[NSDictionary class]]) { - id dataObj = json[@"data"]; - if ([dataObj isKindOfClass:[NSDictionary class]]) { - NSDictionary *dataDict = (NSDictionary *)dataObj; - id audioUrlObj = dataDict[@"audioUrl"] ?: dataDict[@"url"]; - if (audioUrlObj && ![audioUrlObj isKindOfClass:[NSNull class]] && [audioUrlObj isKindOfClass:[NSString class]]) { - audioURL = (NSString *)audioUrlObj; - } - } - } - - // 如果成功获取到 audioURL - if (audioURL.length > 0) { - NSTimeInterval elapsed = [[NSDate date] timeIntervalSinceDate:startTime]; - NSLog(@"[Keyboard] ✅ 预加载音频 URL 获取成功(第 %ld 次),耗时: %.2f 秒", (long)(retryCount + 1), elapsed); - // 下载音频 - [self kb_downloadPreloadAudioFromURL:audioURL startTime:startTime]; + [[KBVM shared] pollAudioURLWithAudioId:audioId + maxRetries:10 + interval:1.0 + completion:^(KBAudioResponse *response) { + __strong typeof(weakSelf) self = weakSelf; + if (!self) return; + + if (!response.success || response.audioURL.length == 0) { + NSLog(@"[Keyboard] ❌ 预加载音频 URL 获取失败: %@", response.errorMessage); + return; + } + + NSLog(@"[Keyboard] ✅ 预加载音频 URL 获取成功"); + + // 下载音频 + [[KBVM shared] downloadAudioFromURL:response.audioURL + completion:^(KBAudioResponse *audioResponse) { + if (!audioResponse.success) { + NSLog(@"[Keyboard] ❌ 预加载音频下载失败: %@", audioResponse.errorMessage); return; } - // 如果还没达到最大重试次数,继续轮询 - if (retryCount < maxRetries - 1) { - NSLog(@"[Keyboard] 预加载音频未就绪,1秒后重试 (%ld/%ld)", (long)(retryCount + 1), (long)maxRetries); - - // 1秒后重试 - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), - dispatch_get_main_queue(), ^{ - [self kb_pollAudioURLWithAudioId:audioId - retryCount:retryCount + 1 - maxRetries:maxRetries - startTime:startTime]; - }); - } else { - NSTimeInterval elapsed = [[NSDate date] timeIntervalSinceDate:startTime]; - NSLog(@"[Keyboard] ❌ 预加载音频失败,已重试 %ld 次,总耗时: %.2f 秒", (long)maxRetries, elapsed); - } - }); - }]; -} - -/// 下载预加载音频 -- (void)kb_downloadPreloadAudioFromURL:(NSString *)urlString startTime:(NSDate *)startTime { - if (urlString.length == 0) return; - - __weak typeof(self) weakSelf = self; - [[KBNetworkManager shared] GETData:urlString - parameters:nil - headers:nil - completion:^(NSData *data, NSURLResponse *response, NSError *error) { - dispatch_async(dispatch_get_main_queue(), ^{ - __strong typeof(weakSelf) self = weakSelf; - if (!self) return; - - if (error || !data || data.length == 0) { - NSLog(@"[Keyboard] 预加载:下载音频失败: %@", error.localizedDescription ?: @""); - return; - } - - // 计算音频时长 - NSError *playerError = nil; - AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:data error:&playerError]; - NSTimeInterval duration = 0; - if (!playerError && player) { - duration = player.duration; - } - // 更新最后一条 AI 消息的音频数据 - [self.chatPanelView kb_updateLastAssistantMessageWithAudioData:data duration:duration]; - - NSTimeInterval totalElapsed = [[NSDate date] timeIntervalSinceDate:startTime]; - NSLog(@"[Keyboard] ✅ 预加载音频完成,音频时长: %.2f秒,总耗时: %.2f 秒", duration, totalElapsed); - }); + [self.chatPanelView kb_updateLastAssistantMessageWithAudioData:audioResponse.audioData + duration:audioResponse.duration]; + NSLog(@"[Keyboard] ✅ 预加载音频完成,音频时长: %.2f秒", audioResponse.duration); + }]; }]; } - (void)kb_downloadChatAudioFromURL:(NSString *)audioURL displayText:(NSString *)displayText { __weak typeof(self) weakSelf = self; - [[KBNetworkManager shared] - GETData:audioURL - parameters:nil - headers:nil - completion:^(NSData *data, NSURLResponse *response, NSError *error) { - dispatch_async(dispatch_get_main_queue(), ^{ - __strong typeof(weakSelf) self = weakSelf; - if (!self) { - return; - } - if (error) { - NSString *tip = error.localizedDescription ?: KBLocalized(@"下载失败"); - [KBHUD showInfo:tip]; - return; - } - if (data.length == 0) { - [KBHUD showInfo:KBLocalized(@"未获取到音频数据")]; - return; - } - NSString *ext = @"m4a"; - NSURL *url = [NSURL URLWithString:audioURL]; - if (url.pathExtension.length > 0) { - ext = url.pathExtension; - } - [self kb_handleChatAudioData:data - fileExtension:ext - displayText:displayText]; - }); - }]; + [[KBVM shared] downloadAudioFromURL:audioURL completion:^(KBAudioResponse *response) { + __strong typeof(weakSelf) self = weakSelf; + if (!self) return; + + if (!response.success) { + [KBHUD showInfo:response.errorMessage ?: KBLocalized(@"下载失败")]; + return; + } + + if (!response.audioData || response.audioData.length == 0) { + [KBHUD showInfo:KBLocalized(@"未获取到音频数据")]; + return; + } + + NSString *ext = @"m4a"; + NSURL *url = [NSURL URLWithString:audioURL]; + if (url.pathExtension.length > 0) { + ext = url.pathExtension; + } + [self kb_handleChatAudioData:response.audioData + fileExtension:ext + displayText:displayText]; + }]; } - (void)kb_handleChatAudioData:(NSData *)data @@ -1605,13 +1411,6 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, return _chatMessages; } -- (NSCache *)chatAvatarCache { - if (!_chatAvatarCache) { - _chatAvatarCache = [[NSCache alloc] init]; - } - return _chatAvatarCache; -} - - (KBKeyboardSubscriptionView *)subscriptionView { if (!_subscriptionView) { _subscriptionView = [[KBKeyboardSubscriptionView alloc] init]; diff --git a/CustomKeyboard/VM/KBVM.h b/CustomKeyboard/VM/KBVM.h new file mode 100644 index 0000000..2953847 --- /dev/null +++ b/CustomKeyboard/VM/KBVM.h @@ -0,0 +1,95 @@ +// +// KBVM.h +// CustomKeyboard +// +// 键盘扩展的 ViewModel,封装网络请求逻辑 +// + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/// 聊天响应模型 +@interface KBChatResponse : NSObject +@property (nonatomic, copy, nullable) NSString *text; +@property (nonatomic, copy, nullable) NSString *audioId; +@property (nonatomic, copy, nullable) NSString *errorMessage; +@property (nonatomic, assign) BOOL success; +@end + +/// 音频响应模型 +@interface KBAudioResponse : NSObject +@property (nonatomic, copy, nullable) NSString *audioURL; +@property (nonatomic, strong, nullable) NSData *audioData; +@property (nonatomic, assign) NSTimeInterval duration; +@property (nonatomic, copy, nullable) NSString *errorMessage; +@property (nonatomic, assign) BOOL success; +@end + +/// 聊天请求回调 +typedef void(^KBChatCompletion)(KBChatResponse *response); +/// 音频 URL 回调 +typedef void(^KBAudioURLCompletion)(KBAudioResponse *response); +/// 音频数据回调 +typedef void(^KBAudioDataCompletion)(KBAudioResponse *response); +/// 头像回调 +typedef void(^KBAvatarCompletion)(UIImage * _Nullable image, NSError * _Nullable error); + +@interface KBVM : NSObject + ++ (instancetype)shared; + +#pragma mark - Chat API + +/// 发送聊天消息 +/// @param content 消息内容 +/// @param companionId 人设 ID +/// @param completion 回调 +- (void)sendChatMessageWithContent:(NSString *)content + companionId:(NSInteger)companionId + completion:(KBChatCompletion)completion; + +#pragma mark - Audio API + +/// 获取音频 URL(单次请求) +/// @param audioId 音频 ID +/// @param completion 回调 +- (void)fetchAudioURLWithAudioId:(NSString *)audioId + completion:(KBAudioURLCompletion)completion; + +/// 轮询获取音频 URL(自动重试) +/// @param audioId 音频 ID +/// @param maxRetries 最大重试次数 +/// @param interval 重试间隔(秒) +/// @param completion 回调 +- (void)pollAudioURLWithAudioId:(NSString *)audioId + maxRetries:(NSInteger)maxRetries + interval:(NSTimeInterval)interval + completion:(KBAudioURLCompletion)completion; + +/// 下载音频数据 +/// @param urlString 音频 URL +/// @param completion 回调 +- (void)downloadAudioFromURL:(NSString *)urlString + completion:(KBAudioDataCompletion)completion; + +#pragma mark - Avatar API + +/// 下载头像图片 +/// @param urlString 头像 URL +/// @param completion 回调 +- (void)downloadAvatarFromURL:(NSString *)urlString + completion:(KBAvatarCompletion)completion; + +#pragma mark - Helper + +/// 从 AppGroup 获取选中的 persona companionId +- (NSInteger)selectedCompanionIdFromAppGroup; + +/// 从 AppGroup 获取选中的 persona 信息 +- (nullable NSDictionary *)selectedPersonaFromAppGroup; + +@end + +NS_ASSUME_NONNULL_END diff --git a/CustomKeyboard/VM/KBVM.m b/CustomKeyboard/VM/KBVM.m new file mode 100644 index 0000000..0918867 --- /dev/null +++ b/CustomKeyboard/VM/KBVM.m @@ -0,0 +1,334 @@ +// +// 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 diff --git a/keyBoard.xcodeproj/project.pbxproj b/keyBoard.xcodeproj/project.pbxproj index a15644f..c85c252 100644 --- a/keyBoard.xcodeproj/project.pbxproj +++ b/keyBoard.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 04122FAD2EC73C0100EF7AB3 /* KBVipSubscribeCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04122FAC2EC73C0100EF7AB3 /* KBVipSubscribeCell.m */; }; 04122FB02EC73C0100EF7AB3 /* KBVipReviewItemCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04122FAF2EC73C0100EF7AB3 /* KBVipReviewItemCell.m */; }; 04122FB32EC73C0100EF7AB3 /* KBVipReviewListCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04122FB22EC73C0100EF7AB3 /* KBVipReviewListCell.m */; }; + 0419C9662F2C7693002E86D3 /* KBVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 0419C9652F2C7693002E86D3 /* KBVM.m */; }; 04286A002ECAEF2B00CE730C /* KBMoneyBtn.m in Sources */ = {isa = PBXBuildFile; fileRef = 042869FE2ECAEF2B00CE730C /* KBMoneyBtn.m */; }; 04286A032ECB0A1600CE730C /* KBSexSelVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 04286A022ECB0A1600CE730C /* KBSexSelVC.m */; }; 04286A062ECC81B200CE730C /* KBSkinService.m in Sources */ = {isa = PBXBuildFile; fileRef = 04286A052ECC81B200CE730C /* KBSkinService.m */; }; @@ -357,6 +358,8 @@ 04122FAF2EC73C0100EF7AB3 /* KBVipReviewItemCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVipReviewItemCell.m; sourceTree = ""; }; 04122FB12EC73C0100EF7AB3 /* KBVipReviewListCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVipReviewListCell.h; sourceTree = ""; }; 04122FB22EC73C0100EF7AB3 /* KBVipReviewListCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVipReviewListCell.m; sourceTree = ""; }; + 0419C9642F2C7693002E86D3 /* KBVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVM.h; sourceTree = ""; }; + 0419C9652F2C7693002E86D3 /* KBVM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVM.m; sourceTree = ""; }; 042869FD2ECAEF2B00CE730C /* KBMoneyBtn.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBMoneyBtn.h; sourceTree = ""; }; 042869FE2ECAEF2B00CE730C /* KBMoneyBtn.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBMoneyBtn.m; sourceTree = ""; }; 04286A012ECB0A1600CE730C /* KBSexSelVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBSexSelVC.h; sourceTree = ""; }; @@ -975,6 +978,8 @@ 0419C9632F2C7630002E86D3 /* VM */ = { isa = PBXGroup; children = ( + 0419C9642F2C7693002E86D3 /* KBVM.h */, + 0419C9652F2C7693002E86D3 /* KBVM.m */, ); path = VM; sourceTree = ""; @@ -2335,6 +2340,7 @@ A1B2C9272FC9000100000001 /* KBChatMessageCell.m in Sources */, A1B2C9282FC9000100000001 /* KBChatPanelView.m in Sources */, A1B2C3EB2F20000000000001 /* KBSuggestionBarView.m in Sources */, + 0419C9662F2C7693002E86D3 /* KBVM.m in Sources */, 048FFD512F2B68F7005D62AE /* KBPersonaModel.m in Sources */, 04FC95792EB09BC8007BD342 /* KBKeyBoardMainView.m in Sources */, 04FEDAB32EEDB05000123456 /* KBEmojiPanelView.m in Sources */,