diff --git a/keyBoard/Class/AiTalk/M/KBAiChatMessage.h b/keyBoard/Class/AiTalk/M/KBAiChatMessage.h index c46e7f9..ca5efd1 100644 --- a/keyBoard/Class/AiTalk/M/KBAiChatMessage.h +++ b/keyBoard/Class/AiTalk/M/KBAiChatMessage.h @@ -66,6 +66,9 @@ typedef NS_ENUM(NSInteger, KBAiChatMessageType) { /// 创建 AI 消息(仅文本,无音频) + (instancetype)assistantMessageWithText:(NSString *)text; +/// 创建加载中的 AI 消息 ++ (instancetype)loadingAssistantMessage; + /// 创建时间戳消息 + (instancetype)timeMessageWithTimestamp:(NSDate *)timestamp; diff --git a/keyBoard/Class/AiTalk/M/KBAiChatMessage.m b/keyBoard/Class/AiTalk/M/KBAiChatMessage.m index fd1624e..771098d 100644 --- a/keyBoard/Class/AiTalk/M/KBAiChatMessage.m +++ b/keyBoard/Class/AiTalk/M/KBAiChatMessage.m @@ -70,6 +70,20 @@ return message; } ++ (instancetype)loadingAssistantMessage { + KBAiChatMessage *message = [[KBAiChatMessage alloc] init]; + message.type = KBAiChatMessageTypeAssistant; + message.text = @""; + message.timestamp = [NSDate date]; + message.audioId = nil; + message.audioDuration = 0; + message.audioData = nil; + message.isComplete = NO; + message.isLoading = YES; + message.needsTypewriterEffect = NO; + return message; +} + + (instancetype)timeMessageWithTimestamp:(NSDate *)timestamp { KBAiChatMessage *message = [[KBAiChatMessage alloc] init]; message.type = KBAiChatMessageTypeTime; diff --git a/keyBoard/Class/AiTalk/V/Chat/KBChatAssistantMessageCell.m b/keyBoard/Class/AiTalk/V/Chat/KBChatAssistantMessageCell.m index 0eea5cc..111fac8 100644 --- a/keyBoard/Class/AiTalk/V/Chat/KBChatAssistantMessageCell.m +++ b/keyBoard/Class/AiTalk/V/Chat/KBChatAssistantMessageCell.m @@ -16,6 +16,7 @@ @property (nonatomic, strong) UIView *bubbleView; @property (nonatomic, strong, readwrite) UILabel *messageLabel; // readwrite 允许内部修改 @property (nonatomic, strong) UIActivityIndicatorView *loadingIndicator; +@property (nonatomic, strong) UIActivityIndicatorView *messageLoadingIndicator; // AI 消息加载指示器 @property (nonatomic, strong) KBAiChatMessage *currentMessage; // 打字机效果 @@ -61,6 +62,12 @@ self.loadingIndicator.hidesWhenStopped = YES; [self.contentView addSubview:self.loadingIndicator]; + // AI 消息加载指示器(用于等待 AI 回复) + self.messageLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; + self.messageLoadingIndicator.color = [UIColor whiteColor]; + self.messageLoadingIndicator.hidesWhenStopped = YES; + [self.contentView addSubview:self.messageLoadingIndicator]; + // 气泡视图 self.bubbleView = [[UIView alloc] init]; self.bubbleView.backgroundColor = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.7]; @@ -83,8 +90,7 @@ [self.voiceButton mas_makeConstraints:^(MASConstraintMaker *make) { make.left.equalTo(self.contentView).offset(16); make.top.equalTo(self.contentView).offset(8); -// make.width.height.mas_equalTo(24); - make.width.height.mas_equalTo(0); + make.width.height.mas_equalTo(24); }]; [self.durationLabel mas_makeConstraints:^(MASConstraintMaker *make) { @@ -96,6 +102,12 @@ make.center.equalTo(self.voiceButton); }]; + // AI 消息加载指示器约束(与 bubbleView 位置一致) + [self.messageLoadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.contentView).offset(16); + make.top.equalTo(self.voiceButton.mas_bottom).offset(12); + }]; + // 关键修复:bubbleView 必须有明确的高度约束链 [self.bubbleView mas_makeConstraints:^(MASConstraintMaker *make) { make.top.equalTo(self.voiceButton.mas_bottom).offset(4); @@ -116,8 +128,8 @@ } - (void)configureWithMessage:(KBAiChatMessage *)message { - NSLog(@"[KBChatAssistantMessageCell] 配置消息 - 文本长度: %lu, isComplete: %d, needsTypewriter: %d, 打字机运行中: %d", - (unsigned long)message.text.length, message.isComplete, message.needsTypewriterEffect, + NSLog(@"[KBChatAssistantMessageCell] 配置消息 - 文本长度: %lu, isComplete: %d, needsTypewriter: %d, isLoading: %d, 打字机运行中: %d", + (unsigned long)message.text.length, message.isComplete, message.needsTypewriterEffect, message.isLoading, (self.typewriterTimer && self.typewriterTimer.isValid)); // 先停止之前的打字机效果(无论是否是同一条消息) @@ -125,6 +137,21 @@ self.currentMessage = message; + // 处理 loading 状态 + if (message.isLoading) { + self.messageLabel.attributedText = nil; + self.messageLabel.text = @""; + self.bubbleView.hidden = YES; + self.voiceButton.hidden = YES; + self.durationLabel.hidden = YES; + [self.messageLoadingIndicator startAnimating]; + return; + } + + // 非 loading 状态,隐藏 loading 指示器,显示气泡 + [self.messageLoadingIndicator stopAnimating]; + self.bubbleView.hidden = NO; + // 只有明确需要打字机效果的消息才使用打字机 if (message.needsTypewriterEffect && !message.isComplete && message.text.length > 0) { // 启动新的打字机效果 diff --git a/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.h b/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.h index f3c70f0..27b024e 100644 --- a/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.h +++ b/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.h @@ -54,6 +54,9 @@ NS_ASSUME_NONNULL_BEGIN /// 清空所有消息 - (void)clearMessages; +/// 移除 loading AI 消息 +- (void)removeLoadingAssistantMessage; + /// 滚动到底部 - (void)scrollToBottom; diff --git a/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.m b/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.m index c70612a..e721216 100644 --- a/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.m +++ b/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.m @@ -236,6 +236,19 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟 [self updateFooterVisibility]; } +- (void)removeLoadingAssistantMessage { + for (NSInteger i = self.messages.count - 1; i >= 0; i--) { + KBAiChatMessage *message = self.messages[i]; + if (message.type == KBAiChatMessageTypeAssistant && message.isLoading) { + [self.messages removeObjectAtIndex:i]; + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; + [self.tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; + NSLog(@"[KBChatTableView] 移除 loading AI 消息,索引: %ld", (long)i); + break; + } + } +} + - (void)scrollToBottom { [self scrollToBottomAnimated:YES]; } @@ -339,6 +352,11 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟 }); }); } + + // 自动预加载音频(不自动播放) + if (message.type == KBAiChatMessageTypeAssistant && message.audioId.length > 0) { + [self preloadAudioForMessage:message]; + } } - (void)reloadWithMessages:(NSArray *)messages @@ -620,8 +638,8 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟 [cell showLoadingAnimation]; } - // 开始轮询请求(最多5次,每次间隔0.5秒) - [self pollAudioForMessage:message atIndexPath:indexPath retryCount:0 maxRetries:5]; + // 开始轮询请求(最多10次,每次间隔1秒,共10秒) + [self pollAudioForMessage:message atIndexPath:indexPath retryCount:0 maxRetries:10]; } - (void)pollAudioForMessage:(KBAiChatMessage *)message @@ -638,6 +656,7 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟 dispatch_async(dispatch_get_main_queue(), ^{ // 如果成功获取到 audioURL if (!error && audioURL.length > 0) { + NSLog(@"[KBChatTableView] 音频 URL 获取成功(第 %ld 次)", (long)(retryCount + 1)); // 下载并播放音频 [strongSelf downloadAndPlayAudioFromURL:audioURL forMessage:message @@ -647,11 +666,11 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟 // 如果还没达到最大重试次数,继续轮询 if (retryCount < maxRetries - 1) { - NSLog(@"[KBChatTableView] 音频未就绪,0.5秒后重试 (%ld/%ld)", + NSLog(@"[KBChatTableView] 音频未就绪,1秒后重试 (%ld/%ld)", (long)(retryCount + 1), (long)maxRetries); - // 0.5秒后重试 - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), + // 1秒后重试 + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [strongSelf pollAudioForMessage:message atIndexPath:indexPath @@ -786,6 +805,115 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟 } } +#pragma mark - Audio Preload (自动预加载,不播放) + +/// 预加载音频(不自动播放) +- (void)preloadAudioForMessage:(KBAiChatMessage *)message { + if (!message || message.audioId.length == 0) { + return; + } + + // 如果已经有音频数据,不需要预加载 + if (message.audioData && message.audioData.length > 0) { + NSLog(@"[KBChatTableView] 音频已缓存,跳过预加载"); + return; + } + + NSLog(@"[KBChatTableView] 开始预加载音频,audioId: %@", message.audioId); + + // 记录开始时间 + NSDate *startTime = [NSDate date]; + + // 开始轮询请求(最多10次,每次间隔1秒,共10秒) + [self pollPreloadAudioForMessage:message retryCount:0 maxRetries:10 startTime:startTime]; +} + +/// 轮询预加载音频 +- (void)pollPreloadAudioForMessage:(KBAiChatMessage *)message + retryCount:(NSInteger)retryCount + maxRetries:(NSInteger)maxRetries + startTime:(NSDate *)startTime { + + __weak typeof(self) weakSelf = self; + [self.aiVM requestAudioWithAudioId:message.audioId + completion:^(NSString *_Nullable audioURL, NSError *_Nullable error) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) return; + + dispatch_async(dispatch_get_main_queue(), ^{ + // 如果成功获取到 audioURL + if (!error && audioURL.length > 0) { + // 计算获取 audioURL 的耗时 + NSTimeInterval elapsed = [[NSDate date] timeIntervalSinceDate:startTime]; + NSLog(@"[KBChatTableView] ✅ 预加载音频 URL 获取成功(第 %ld 次),耗时: %.2f 秒", (long)(retryCount + 1), elapsed); + // 下载音频(不播放) + [strongSelf downloadAudioFromURL:audioURL forMessage:message startTime:startTime]; + return; + } + + // 如果还没达到最大重试次数,继续轮询 + if (retryCount < maxRetries - 1) { + NSLog(@"[KBChatTableView] 预加载音频未就绪,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(), ^{ + [strongSelf pollPreloadAudioForMessage:message + retryCount:retryCount + 1 + maxRetries:maxRetries + startTime:startTime]; + }); + } else { + NSTimeInterval elapsed = [[NSDate date] timeIntervalSinceDate:startTime]; + NSLog(@"[KBChatTableView] ❌ 预加载音频失败,已重试 %ld 次,总耗时: %.2f 秒", (long)maxRetries, elapsed); + } + }); + }]; +} + +/// 下载音频(不播放) +- (void)downloadAudioFromURL:(NSString *)urlString + forMessage:(KBAiChatMessage *)message + startTime:(NSDate *)startTime { + NSURL *url = [NSURL URLWithString:urlString]; + if (!url) { + NSLog(@"[KBChatTableView] 预加载:无效的音频 URL: %@", urlString); + return; + } + + NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration]; + NSURLSession *session = [NSURLSession sessionWithConfiguration:config]; + + NSURLSessionDataTask *task = [session dataTaskWithURL:url + completionHandler:^(NSData *_Nullable data, + NSURLResponse *_Nullable response, + NSError *_Nullable error) { + if (error || !data || data.length == 0) { + NSLog(@"[KBChatTableView] 预加载:下载音频失败: %@", error.localizedDescription ?: @""); + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + // 缓存音频数据到消息对象 + message.audioData = data; + + // 计算音频时长 + NSError *playerError = nil; + AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:data error:&playerError]; + if (!playerError && player) { + message.audioDuration = player.duration; + } + + // 计算总耗时(从开始请求到下载完成) + NSTimeInterval totalElapsed = [[NSDate date] timeIntervalSinceDate:startTime]; + NSLog(@"[KBChatTableView] ✅ 预加载音频完成,音频时长: %.2f秒,总耗时: %.2f 秒", message.audioDuration, totalElapsed); + }); + }]; + + [task resume]; +} + #pragma mark - AVAudioPlayerDelegate - (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag { diff --git a/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.h b/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.h index 7729eef..eced63d 100644 --- a/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.h +++ b/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.h @@ -37,6 +37,12 @@ NS_ASSUME_NONNULL_BEGIN - (void)appendAssistantMessage:(NSString *)text audioId:(nullable NSString *)audioId; +/// 添加 loading AI 消息 +- (void)appendLoadingAssistantMessage; + +/// 移除 loading AI 消息 +- (void)removeLoadingAssistantMessage; + /// 更新聊天列表底部 inset - (void)updateChatViewBottomInset:(CGFloat)bottomInset; diff --git a/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.m b/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.m index c49aba7..eba1b58 100644 --- a/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.m +++ b/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.m @@ -503,6 +503,10 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe } [self ensureOpeningMessageAtTop]; + + // 查找并移除 loading 消息 + [self removeLoadingAssistantMessage]; + KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text audioId:audioId]; message.needsTypewriterEffect = YES; @@ -510,6 +514,33 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe [self.chatView addMessage:message autoScroll:YES]; } +/// 添加 loading AI 消息 +- (void)appendLoadingAssistantMessage { + if (!self.messages) { + self.messages = [NSMutableArray array]; + } + + [self ensureOpeningMessageAtTop]; + KBAiChatMessage *message = [KBAiChatMessage loadingAssistantMessage]; + [self.messages addObject:message]; + [self.chatView addMessage:message autoScroll:YES]; +} + +/// 移除 loading AI 消息 +- (void)removeLoadingAssistantMessage { + // 从数据源中移除 + for (NSInteger i = self.messages.count - 1; i >= 0; i--) { + KBAiChatMessage *message = self.messages[i]; + if (message.type == KBAiChatMessageTypeAssistant && message.isLoading) { + [self.messages removeObjectAtIndex:i]; + break; + } + } + + // 从 chatView 中移除 + [self.chatView removeLoadingAssistantMessage]; +} + - (void)updateChatViewBottomInset:(CGFloat)bottomInset { [self.chatView updateContentBottomInset:bottomInset]; } diff --git a/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m b/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m index 3a7ab09..bea3ee4 100644 --- a/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m +++ b/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m @@ -964,6 +964,8 @@ KBPersonaChatCell *currentCell = [self currentPersonaCell]; if (currentCell && appendToUI) { [currentCell appendUserMessage:text]; + // 添加 loading AI 消息 + [currentCell appendLoadingAssistantMessage]; } self.isWaitingForAIResponse = YES; @@ -990,12 +992,20 @@ } if (response.code == 50030) { + // 移除 loading 消息 + if (cell) { + [cell removeLoadingAssistantMessage]; + } NSString *message = response.message ?: @""; [strongSelf showChatLimitPopWithMessage:message]; return; } if (!response || !response.data) { + // 移除 loading 消息 + if (cell) { + [cell removeLoadingAssistantMessage]; + } NSString *message = response.message ?: @"聊天响应为空"; NSLog(@"[KBAIHomeVC] 聊天响应为空:%@", message); if (message.length > 0) { @@ -1007,11 +1017,16 @@ NSString *aiResponse = response.data.aiResponse ?: response.data.content ?: response.data.text ?: response.data.message ?: @""; NSString *audioId = response.data.audioId; if (aiResponse.length == 0) { + // 移除 loading 消息 + if (cell) { + [cell removeLoadingAssistantMessage]; + } NSLog(@"[KBAIHomeVC] AI 回复为空"); return; } if (cell) { + // appendAssistantMessage 内部会自动移除 loading 消息 [cell appendAssistantMessage:aiResponse audioId:audioId]; } });