// // KBChatTableView.m // keyBoard // // Created by Kiro on 2026/1/23. // #import "KBChatTableView.h" #import "KBAiChatMessage.h" #import "KBChatUserMessageCell.h" #import "KBChatAssistantMessageCell.h" #import "KBChatTimeCell.h" #import "AiVM.h" #import #import #import static NSString * const kUserCellIdentifier = @"KBChatUserMessageCell"; static NSString * const kAssistantCellIdentifier = @"KBChatAssistantMessageCell"; static NSString * const kTimeCellIdentifier = @"KBChatTimeCell"; /// 时间戳显示间隔(秒) static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟 @interface KBChatTableView () @property (nonatomic, strong) BaseTableView *tableView; @property (nonatomic, strong) NSMutableArray *messages; @property (nonatomic, strong) AVAudioPlayer *audioPlayer; @property (nonatomic, strong) NSIndexPath *playingCellIndexPath; @property (nonatomic, strong) AiVM *aiVM; @property (nonatomic, assign) BOOL hasMoreData; @property (nonatomic, assign) CGFloat contentBottomInset; @property (nonatomic, copy) NSString *introFooterText; @property (nonatomic, strong) UIView *introFooterContainer; @property (nonatomic, strong) UILabel *introFooterLabel; @property (nonatomic, assign) CGSize lastIntroFooterTableSize; @property (nonatomic, assign) BOOL applyingIntroFooter; @property (nonatomic, copy) NSString *remoteAudioToken; @property (nonatomic, strong) UIView *topStatusView; @property (nonatomic, strong) UIActivityIndicatorView *topLoadingIndicator; @property (nonatomic, strong) UILabel *topStatusLabel; @property (nonatomic, assign) BOOL isTopLoading; @property (nonatomic, assign) BOOL isTopNoMore; @end @implementation KBChatTableView - (instancetype)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { [self setup]; } return self; } - (instancetype)initWithCoder:(NSCoder *)coder { self = [super initWithCoder:coder]; if (self) { [self setup]; } return self; } - (void)setup { self.messages = [[NSMutableArray alloc] init]; self.aiVM = [[AiVM alloc] init]; self.hasMoreData = YES; // 创建 TableView self.tableView = [[BaseTableView alloc] initWithFrame:self.bounds style:UITableViewStylePlain]; self.tableView.useEmptyDataSet = false; self.tableView.dataSource = self; self.tableView.delegate = self; self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone; self.tableView.backgroundColor = [UIColor clearColor]; // 关键修复:使用合理的估算高度(避免抖动,但不能为0) self.tableView.estimatedRowHeight = 80; self.tableView.estimatedSectionHeaderHeight = 0; self.tableView.estimatedSectionFooterHeight = 0; self.tableView.rowHeight = UITableViewAutomaticDimension; self.tableView.showsVerticalScrollIndicator = YES; // 关键修复:禁用内容自动调整,防止与外层 CollectionView 冲突 if (@available(iOS 11.0, *)) { self.tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; } [self addSubview:self.tableView]; // 注册 Cell [self.tableView registerClass:[KBChatUserMessageCell class] forCellReuseIdentifier:kUserCellIdentifier]; [self.tableView registerClass:[KBChatAssistantMessageCell class] forCellReuseIdentifier:kAssistantCellIdentifier]; [self.tableView registerClass:[KBChatTimeCell class] forCellReuseIdentifier:kTimeCellIdentifier]; // 布局 [self.tableView mas_makeConstraints:^(MASConstraintMaker *make) { make.edges.equalTo(self); }]; // 初始化 contentInset self.contentBottomInset = 0; [self updateContentBottomInset:self.contentBottomInset]; // 暂时禁用 mj_footer,排查问题 // TODO: 如果需要加载更多功能,重新启用 /* __weak typeof(self) weakSelf = self; self.tableView.mj_footer = [MJRefreshAutoNormalFooter footerWithRefreshingBlock:^{ __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) { return; } if (!strongSelf.hasMoreData) { [strongSelf.tableView.mj_footer endRefreshingWithNoMoreData]; return; } if ([strongSelf.delegate respondsToSelector:@selector(chatTableViewDidTriggerLoadMore:)]) { [strongSelf.delegate chatTableViewDidTriggerLoadMore:strongSelf]; } else { [strongSelf.tableView.mj_footer endRefreshing]; } }]; // 隐藏"已经全部加载完毕"的提示文字 // MJRefreshAutoNormalFooter *footer = (MJRefreshAutoNormalFooter *)self.tableView.mj_footer; // footer.stateLabel.hidden = YES; // 隐藏状态文字 // footer.refreshingBlock = footer.refreshingBlock; // 保持刷新逻辑 // self.tableView.mj_footer.hidden = YES; */ } #pragma mark - Public Methods - (void)setInverted:(BOOL)inverted { if (_inverted == inverted) { return; } _inverted = inverted; self.tableView.transform = inverted ? CGAffineTransformMakeScale(1, -1) : CGAffineTransformIdentity; [self updateContentBottomInset:self.contentBottomInset]; [self updateIntroFooterText:self.introFooterText]; [self.tableView reloadData]; [self.tableView layoutIfNeeded]; } static inline CGFloat KBChatAbsTimeInterval(NSTimeInterval interval) { return interval < 0 ? -interval : interval; } - (BOOL)shouldInsertTimestampBetweenMessage:(KBAiChatMessage *)message andReference:(KBAiChatMessage *)reference { if (!message.timestamp || !reference.timestamp) { return YES; } NSTimeInterval interval = KBChatAbsTimeInterval([message.timestamp timeIntervalSinceDate:reference.timestamp]); if (interval >= kTimestampInterval) { return YES; } NSCalendar *calendar = [NSCalendar currentCalendar]; NSDateComponents *a = [calendar components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:reference.timestamp]; NSDateComponents *b = [calendar components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:message.timestamp]; return ![a isEqual:b]; } - (NSInteger)firstNonTimeIndex { for (NSInteger i = 0; i < self.messages.count; i++) { if (self.messages[i].type != KBAiChatMessageTypeTime) { return i; } } return NSNotFound; } - (NSInteger)lastNonTimeIndexBeforeIndex:(NSInteger)index { NSInteger maxIndex = MIN(index, self.messages.count); for (NSInteger i = maxIndex - 1; i >= 0; i--) { if (self.messages[i].type != KBAiChatMessageTypeTime) { return i; } } return NSNotFound; } - (void)addUserMessage:(NSString *)text { KBAiChatMessage *message = [KBAiChatMessage userMessageWithText:text]; [self addMessage:message autoScroll:YES]; } - (void)addLoadingUserMessage { KBAiChatMessage *message = [KBAiChatMessage loadingUserMessage]; [self addMessage:message autoScroll:YES]; } - (void)updateLastUserMessage:(NSString *)text { if (self.inverted) { for (NSInteger i = 0; i < self.messages.count; i++) { KBAiChatMessage *message = self.messages[i]; if (message.type == KBAiChatMessageTypeUser && message.isLoading) { message.text = text; message.isLoading = NO; message.isComplete = YES; NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; [self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; return; } } } else { for (NSInteger i = self.messages.count - 1; i >= 0; i--) { KBAiChatMessage *message = self.messages[i]; if (message.type == KBAiChatMessageTypeUser && message.isLoading) { message.text = text; message.isLoading = NO; message.isComplete = YES; NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; [self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; return; } } } } - (void)addAssistantMessage:(NSString *)text audioDuration:(NSTimeInterval)duration audioData:(NSData *)audioData { KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text audioDuration:duration audioData:audioData]; [self addMessage:message autoScroll:YES]; } - (void)addAssistantMessage:(NSString *)text audioId:(NSString *)audioId { KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text audioId:audioId]; message.needsTypewriterEffect = YES; // 新消息需要打字机效果 [self addMessage:message autoScroll:YES]; } - (void)updateLastAssistantMessage:(NSString *)text { // 查找最后一条未完成的 AI 消息 if (self.inverted) { for (NSInteger i = 0; i < self.messages.count; i++) { KBAiChatMessage *message = self.messages[i]; if (message.type == KBAiChatMessageTypeAssistant && !message.isComplete) { NSLog(@"[KBChatTableView] 更新最后一条 AI 消息 - 索引: %ld, 文本长度: %lu, needsTypewriter: %d", (long)i, (unsigned long)text.length, message.needsTypewriterEffect); message.text = text; NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) { NSLog(@"[KBChatTableView] 找到 Cell,直接配置消息"); [cell configureWithMessage:message]; cell.contentView.transform = self.inverted ? CGAffineTransformMakeScale(1, -1) : CGAffineTransformIdentity; } else { NSLog(@"[KBChatTableView] 未找到 Cell 或类型不匹配"); } return; } } } else { for (NSInteger i = self.messages.count - 1; i >= 0; i--) { KBAiChatMessage *message = self.messages[i]; if (message.type == KBAiChatMessageTypeAssistant && !message.isComplete) { NSLog(@"[KBChatTableView] 更新最后一条 AI 消息 - 索引: %ld, 文本长度: %lu, needsTypewriter: %d", (long)i, (unsigned long)text.length, message.needsTypewriterEffect); message.text = text; NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) { NSLog(@"[KBChatTableView] 找到 Cell,直接配置消息"); [cell configureWithMessage:message]; } else { NSLog(@"[KBChatTableView] 未找到 Cell 或类型不匹配"); } return; } } } // 如果没找到,添加新消息 NSLog(@"[KBChatTableView] 未找到未完成的 AI 消息,添加新消息"); [self addAssistantMessage:text audioDuration:0 audioData:nil]; } - (void)markLastAssistantMessageComplete { if (self.inverted) { for (NSInteger i = 0; i < self.messages.count; i++) { KBAiChatMessage *message = self.messages[i]; if (message.type == KBAiChatMessageTypeAssistant) { NSLog(@"[KBChatTableView] 标记消息完成 - 索引: %ld, 文本: %@", (long)i, message.text); message.isComplete = YES; message.needsTypewriterEffect = NO; NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; [self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; return; } } } else { for (NSInteger i = self.messages.count - 1; i >= 0; i--) { KBAiChatMessage *message = self.messages[i]; if (message.type == KBAiChatMessageTypeAssistant) { NSLog(@"[KBChatTableView] 标记消息完成 - 索引: %ld, 文本: %@", (long)i, message.text); message.isComplete = YES; message.needsTypewriterEffect = NO; // 完成后不再需要打字机效果 NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; [self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; return; } } } } - (void)markLastUserMessageLoadingComplete { if (self.inverted) { for (NSInteger i = 0; i < self.messages.count; i++) { KBAiChatMessage *message = self.messages[i]; if (message.type == KBAiChatMessageTypeUser) { message.isLoading = NO; NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; [self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; [self.tableView layoutIfNeeded]; [self scrollToBottomAnimated:NO]; return; } } } else { for (NSInteger i = self.messages.count - 1; i >= 0; i--) { KBAiChatMessage *message = self.messages[i]; if (message.type == KBAiChatMessageTypeUser) { message.isLoading = NO; NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; [self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; [self.tableView layoutIfNeeded]; [self scrollToBottomAnimated:NO]; return; } } } } - (void)clearMessages { [self.messages removeAllObjects]; [self.tableView reloadData]; [self updateFooterVisibility]; } - (void)removeLoadingAssistantMessage { if (self.inverted) { for (NSInteger i = 0; i < self.messages.count; i++) { KBAiChatMessage *message = self.messages[i]; if (message.type == KBAiChatMessageTypeAssistant && message.isLoading) { if (self.playingCellIndexPath) { if (self.playingCellIndexPath.row == i) { [self stopPlayingAudio]; } else if (self.playingCellIndexPath.row > i) { self.playingCellIndexPath = [NSIndexPath indexPathForRow:self.playingCellIndexPath.row - 1 inSection:0]; } } [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; } } } else { for (NSInteger i = self.messages.count - 1; i >= 0; i--) { KBAiChatMessage *message = self.messages[i]; if (message.type == KBAiChatMessageTypeAssistant && message.isLoading) { if (self.playingCellIndexPath) { if (self.playingCellIndexPath.row == i) { [self stopPlayingAudio]; } else if (self.playingCellIndexPath.row > i) { self.playingCellIndexPath = [NSIndexPath indexPathForRow:self.playingCellIndexPath.row - 1 inSection:0]; } } [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)removeLoadingUserMessage { if (self.inverted) { for (NSInteger i = 0; i < self.messages.count; i++) { KBAiChatMessage *message = self.messages[i]; if (message.type == KBAiChatMessageTypeUser && message.isLoading) { [self.messages removeObjectAtIndex:i]; NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; [self.tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; NSLog(@"[KBChatTableView] 移除 loading 用户消息,索引: %ld", (long)i); break; } } } else { for (NSInteger i = self.messages.count - 1; i >= 0; i--) { KBAiChatMessage *message = self.messages[i]; if (message.type == KBAiChatMessageTypeUser && message.isLoading) { [self.messages removeObjectAtIndex:i]; NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; [self.tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; NSLog(@"[KBChatTableView] 移除 loading 用户消息,索引: %ld", (long)i); break; } } } } #pragma mark - Top Status - (void)showTopLoading { self.isTopLoading = YES; self.isTopNoMore = NO; [self updateTopStatusView]; } - (void)hideTopLoading { self.isTopLoading = NO; [self updateTopStatusView]; } - (void)showNoMoreData { self.isTopNoMore = YES; self.isTopLoading = NO; [self updateTopStatusView]; } - (void)hideNoMoreData { self.isTopNoMore = NO; [self updateTopStatusView]; } - (void)updateTopStatusView { BOOL shouldShow = self.isTopLoading || self.isTopNoMore; if (!shouldShow) { self.topStatusView.hidden = YES; return; } if (!self.topStatusView) { self.topStatusView = [[UIView alloc] initWithFrame:CGRectZero]; self.topStatusView.backgroundColor = [UIColor clearColor]; self.topStatusView.userInteractionEnabled = NO; self.topLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; self.topLoadingIndicator.hidesWhenStopped = YES; [self.topStatusView addSubview:self.topLoadingIndicator]; self.topStatusLabel = [[UILabel alloc] initWithFrame:CGRectZero]; self.topStatusLabel.font = [UIFont systemFontOfSize:12]; self.topStatusLabel.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.8]; [self.topStatusView addSubview:self.topStatusLabel]; [self addSubview:self.topStatusView]; } if (self.isTopLoading) { self.topStatusLabel.text = KBLocalized(@"Loading..."); [self.topLoadingIndicator startAnimating]; } else if (self.isTopNoMore) { self.topStatusLabel.text = KBLocalized(@"No more data"); [self.topLoadingIndicator stopAnimating]; } CGFloat width = CGRectGetWidth(self.tableView.bounds); if (width <= 0) { width = CGRectGetWidth(self.bounds); } CGFloat height = 32; self.topStatusView.frame = CGRectMake(0, 0, width, height); self.topStatusView.hidden = NO; [self bringSubviewToFront:self.topStatusView]; CGSize labelSize = [self.topStatusLabel sizeThatFits:CGSizeMake(width - 40, height)]; CGFloat totalWidth = labelSize.width + (self.isTopLoading ? 20 + 6 : 0); CGFloat startX = (width - totalWidth) / 2.0; if (self.isTopLoading) { self.topLoadingIndicator.frame = CGRectMake(startX, (height - 20) / 2.0, 20, 20); self.topStatusLabel.frame = CGRectMake(CGRectGetMaxX(self.topLoadingIndicator.frame) + 6, (height - labelSize.height) / 2.0, labelSize.width, labelSize.height); } else { self.topStatusLabel.frame = CGRectMake((width - labelSize.width) / 2.0, (height - labelSize.height) / 2.0, labelSize.width, labelSize.height); } } - (void)scrollToBottom { [self scrollToBottomAnimated:YES]; } - (void)scrollToBottomAnimated:(BOOL)animated { if (self.messages.count == 0) return; // 关键修复:使用 layoutIfNeeded 确保布局完成后再滚动 [self.tableView layoutIfNeeded]; if (self.inverted) { CGFloat minOffsetY = -self.tableView.contentInset.top; [self.tableView setContentOffset:CGPointMake(0, minOffsetY) animated:animated]; return; } // 计算需要滚动到的位置 CGFloat contentHeight = self.tableView.contentSize.height; CGFloat tableViewHeight = self.tableView.bounds.size.height; CGFloat bottomInset = self.tableView.contentInset.bottom; // 计算滚动到底部的 offset CGFloat offsetY = contentHeight - tableViewHeight + bottomInset; offsetY = MAX(0, offsetY); NSLog(@"[KBChatTableView] scrollToBottom - contentHeight: %.2f, tableViewHeight: %.2f, bottomInset: %.2f, offsetY: %.2f", contentHeight, tableViewHeight, bottomInset, offsetY); [self.tableView setContentOffset:CGPointMake(0, offsetY) animated:animated]; } #pragma mark - Public Helpers - (void)endLoadMoreWithHasMoreData:(BOOL)hasMoreData { self.hasMoreData = hasMoreData; // 暂时禁用 mj_footer // if (hasMoreData) { // [self.tableView.mj_footer endRefreshing]; // } else { // [self.tableView.mj_footer endRefreshingWithNoMoreData]; // } [self updateFooterVisibility]; } - (void)resetNoMoreData { self.hasMoreData = YES; // 暂时禁用 mj_footer // [self.tableView.mj_footer resetNoMoreData]; [self updateFooterVisibility]; } - (void)updateContentBottomInset:(CGFloat)bottomInset { self.contentBottomInset = bottomInset; // 直接设置 contentInset UIEdgeInsets insets = UIEdgeInsetsZero; if (self.inverted) { insets.top = bottomInset; } else { insets.bottom = bottomInset; } self.tableView.contentInset = insets; self.tableView.scrollIndicatorInsets = insets; NSLog(@"[KBChatTableView] updateContentBottomInset: %.2f", bottomInset); } - (void)addMessage:(KBAiChatMessage *)message autoScroll:(BOOL)autoScroll { if (!message) { return; } if (message.type == KBAiChatMessageTypeAssistant && message.needsTypewriterEffect && !message.isComplete) { [self stopPreviousIncompleteAssistantMessageIfNeeded]; } NSInteger oldCount = self.messages.count; NSArray *itemsToInsert = nil; NSInteger insertIndex = oldCount; if (self.inverted) { itemsToInsert = [self invertedItemsForNewMessageAtBeginning:message]; insertIndex = 0; if (itemsToInsert.count > 0) { NSIndexSet *set = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(insertIndex, itemsToInsert.count)]; [self.messages insertObjects:itemsToInsert atIndexes:set]; } } else { [self insertMessageWithTimestamp:message]; itemsToInsert = nil; insertIndex = oldCount; } NSInteger newCount = self.messages.count; NSInteger insertedCount = newCount - oldCount; if (insertedCount > 0) { if (self.inverted && self.playingCellIndexPath) { self.playingCellIndexPath = [NSIndexPath indexPathForRow:self.playingCellIndexPath.row + insertedCount inSection:0]; } NSMutableArray *indexPaths = [NSMutableArray array]; if (self.inverted) { for (NSInteger i = 0; i < insertedCount; i++) { [indexPaths addObject:[NSIndexPath indexPathForRow:i inSection:0]]; } } else { for (NSInteger i = insertIndex; i < newCount; i++) { [indexPaths addObject:[NSIndexPath indexPathForRow:i inSection:0]]; } } [self.tableView layoutIfNeeded]; [self.tableView beginUpdates]; [self.tableView insertRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationNone]; [self.tableView endUpdates]; } [self updateFooterVisibility]; if (autoScroll) { // 关键修复:插入完成后立即滚动,使用 dispatch_async 确保插入动画完成 dispatch_async(dispatch_get_main_queue(), ^{ [self.tableView layoutIfNeeded]; // 再次确保布局完成 [self scrollToBottomAnimated:YES]; // 二次滚动以确保底部完全可见(解决自动行高导致的布局偏差) dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [self.tableView layoutIfNeeded]; [self scrollToBottomAnimated:NO]; }); }); } // 自动预加载音频(不自动播放) if (message.type == KBAiChatMessageTypeAssistant && message.audioId.length > 0) { [self preloadAudioForMessage:message]; } } - (void)reloadWithMessages:(NSArray *)messages keepOffset:(BOOL)keepOffset scrollToBottom:(BOOL)scrollToBottom { if (self.inverted) { keepOffset = NO; } CGFloat oldContentHeight = self.tableView.contentSize.height; CGFloat oldOffsetY = self.tableView.contentOffset.y; KBAiChatMessage *anchorMessage = nil; CGFloat anchorOffset = 0; if (keepOffset) { NSArray *visibleRows = self.tableView.indexPathsForVisibleRows; if (visibleRows.count > 0) { NSArray *sortedRows = [visibleRows sortedArrayUsingComparator:^NSComparisonResult(NSIndexPath *obj1, NSIndexPath *obj2) { if (obj1.row < obj2.row) { return NSOrderedAscending; } else if (obj1.row > obj2.row) { return NSOrderedDescending; } return NSOrderedSame; }]; NSIndexPath *anchorIndexPath = nil; for (NSIndexPath *indexPath in sortedRows) { if (indexPath.row < self.messages.count) { KBAiChatMessage *message = self.messages[indexPath.row]; if (message.type != KBAiChatMessageTypeTime) { anchorIndexPath = indexPath; break; } } } if (!anchorIndexPath) { anchorIndexPath = sortedRows.firstObject; } if (anchorIndexPath && anchorIndexPath.row < self.messages.count) { anchorMessage = self.messages[anchorIndexPath.row]; CGRect anchorRect = [self.tableView rectForRowAtIndexPath:anchorIndexPath]; anchorOffset = oldOffsetY - anchorRect.origin.y; } } } [self.messages removeAllObjects]; if (messages.count > 0) { if (self.inverted) { NSArray *rebuilt = [self invertedMessagesByInsertingTimestamps:messages startingReference:nil]; [self.messages addObjectsFromArray:rebuilt]; } else { for (KBAiChatMessage *message in messages) { [self insertMessageWithTimestamp:message]; } } } NSLog(@"[KBChatTableView] ========== reloadWithMessages 开始 =========="); NSLog(@"[KBChatTableView] 消息数量: %ld", (long)self.messages.count); NSLog(@"[KBChatTableView] tableView.frame: %@", NSStringFromCGRect(self.tableView.frame)); NSLog(@"[KBChatTableView] tableView.bounds: %@", NSStringFromCGRect(self.tableView.bounds)); NSLog(@"[KBChatTableView] 刷新前 contentSize: %@", NSStringFromCGSize(self.tableView.contentSize)); NSLog(@"[KBChatTableView] 刷新前 contentInset: %@", NSStringFromUIEdgeInsets(self.tableView.contentInset)); [self.tableView reloadData]; [self.tableView layoutIfNeeded]; [self updateFooterVisibility]; NSLog(@"[KBChatTableView] 刷新后 contentSize: %@", NSStringFromCGSize(self.tableView.contentSize)); NSLog(@"[KBChatTableView] 刷新后 contentInset: %@", NSStringFromUIEdgeInsets(self.tableView.contentInset)); NSLog(@"[KBChatTableView] 刷新后 contentOffset: %@", NSStringFromCGPoint(self.tableView.contentOffset)); // 打印每个 Cell 的高度 for (NSInteger i = 0; i < self.messages.count; i++) { CGRect cellRect = [self.tableView rectForRowAtIndexPath:[NSIndexPath indexPathForRow:i inSection:0]]; KBAiChatMessage *msg = self.messages[i]; NSLog(@"[KBChatTableView] Cell[%ld] type=%ld, height=%.2f, text=%@", (long)i, (long)msg.type, cellRect.size.height, msg.text.length > 20 ? [msg.text substringToIndex:20] : msg.text); } NSLog(@"[KBChatTableView] ========== reloadWithMessages 结束 =========="); if (keepOffset) { if (self.inverted) { keepOffset = NO; } CGFloat offsetY = oldOffsetY; if (anchorMessage) { NSInteger newIndex = [self.messages indexOfObjectIdenticalTo:anchorMessage]; if (newIndex != NSNotFound) { CGRect newRect = [self.tableView rectForRowAtIndexPath:[NSIndexPath indexPathForRow:newIndex inSection:0]]; offsetY = newRect.origin.y + anchorOffset; } } else { CGFloat newContentHeight = self.tableView.contentSize.height; CGFloat delta = newContentHeight - oldContentHeight; offsetY = oldOffsetY + delta; } [self.tableView setContentOffset:CGPointMake(0, offsetY) animated:NO]; return; } if (scrollToBottom) { NSLog(@"[KBChatTableView] 准备滚动到底部..."); // 关键修复:使用 dispatch_after 延迟执行,确保 reloadData 和布局完全完成 __weak typeof(self) weakSelf = self; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) { NSLog(@"[KBChatTableView] ⚠️ strongSelf 为空,跳过滚动"); return; } NSLog(@"[KBChatTableView] dispatch_after 执行 scrollToBottom"); NSLog(@"[KBChatTableView] 滚动前 contentSize: %@", NSStringFromCGSize(strongSelf.tableView.contentSize)); NSLog(@"[KBChatTableView] 滚动前 tableView.frame: %@", NSStringFromCGRect(strongSelf.tableView.frame)); [strongSelf scrollToBottomAnimated:NO]; NSLog(@"[KBChatTableView] scrollToBottom 后 contentOffset: %@", NSStringFromCGPoint(strongSelf.tableView.contentOffset)); }); } } #pragma mark - Private Methods /// 插入消息并自动添加时间戳 - (void)insertMessageWithTimestamp:(KBAiChatMessage *)message { // 判断是否需要插入时间戳 if ([self shouldInsertTimestampForMessage:message]) { KBAiChatMessage *timeMessage = [KBAiChatMessage timeMessageWithTimestamp:message.timestamp]; [self.messages addObject:timeMessage]; } [self.messages addObject:message]; } - (NSArray *)invertedItemsForNewMessageAtBeginning:(KBAiChatMessage *)message { NSMutableArray *result = [NSMutableArray array]; NSInteger firstIndex = [self firstNonTimeIndex]; KBAiChatMessage *reference = (firstIndex != NSNotFound) ? self.messages[firstIndex] : nil; [result addObject:message]; if (!reference || [self shouldInsertTimestampBetweenMessage:message andReference:reference]) { KBAiChatMessage *timeMessage = [KBAiChatMessage timeMessageWithTimestamp:message.timestamp]; [result addObject:timeMessage]; } return result; } - (NSArray *)invertedMessagesByInsertingTimestamps:(NSArray *)messages startingReference:(nullable KBAiChatMessage *)reference { NSMutableArray *result = [NSMutableArray array]; KBAiChatMessage *ref = reference; for (KBAiChatMessage *msg in messages) { if (ref && [self shouldInsertTimestampBetweenMessage:msg andReference:ref]) { KBAiChatMessage *timeMessage = [KBAiChatMessage timeMessageWithTimestamp:msg.timestamp]; [result addObject:timeMessage]; } else if (!ref) { // 第一条消息不强制插时间,避免底部多一条时间戳 } [result addObject:msg]; ref = msg; } return result; } - (NSInteger)firstVisibleNonTimeRowExcludingMessage:(KBAiChatMessage *)excludedMessage { NSArray *visible = self.tableView.indexPathsForVisibleRows; if (visible.count == 0) { return NSNotFound; } NSArray *sorted = [visible sortedArrayUsingComparator:^NSComparisonResult(NSIndexPath * _Nonnull obj1, NSIndexPath * _Nonnull obj2) { if (obj1.row < obj2.row) return NSOrderedAscending; if (obj1.row > obj2.row) return NSOrderedDescending; return NSOrderedSame; }]; for (NSIndexPath *ip in sorted) { if (ip.row < self.messages.count) { KBAiChatMessage *msg = self.messages[ip.row]; if (msg.type != KBAiChatMessageTypeTime && msg != excludedMessage) { return ip.row; } } } return sorted.firstObject.row; } - (CGFloat)offsetForRow:(NSInteger)row { if (row == NSNotFound) { return self.tableView.contentOffset.y; } CGRect rect = [self.tableView rectForRowAtIndexPath:[NSIndexPath indexPathForRow:row inSection:0]]; return self.tableView.contentOffset.y - rect.origin.y; } - (void)restoreOffsetWithMessage:(KBAiChatMessage *)message anchorOffset:(CGFloat)anchorOffset fallbackDelta:(CGFloat)fallbackDelta { CGFloat offsetY = self.tableView.contentOffset.y + fallbackDelta; if (message) { NSInteger newIndex = [self.messages indexOfObjectIdenticalTo:message]; if (newIndex != NSNotFound) { CGRect newRect = [self.tableView rectForRowAtIndexPath:[NSIndexPath indexPathForRow:newIndex inSection:0]]; offsetY = newRect.origin.y + anchorOffset; } } [self.tableView setContentOffset:CGPointMake(0, offsetY) animated:NO]; } - (BOOL)shouldInsertTimestampForMessage:(KBAiChatMessage *)message inMessages:(NSArray *)messages { if (messages.count == 0) { return YES; } KBAiChatMessage *lastMessage = nil; for (NSInteger i = messages.count - 1; i >= 0; i--) { KBAiChatMessage *msg = messages[i]; if (msg.type != KBAiChatMessageTypeTime) { lastMessage = msg; break; } } if (!lastMessage) { return YES; } NSTimeInterval interval = [message.timestamp timeIntervalSinceDate:lastMessage.timestamp]; if (interval >= kTimestampInterval) { return YES; } NSCalendar *calendar = [NSCalendar currentCalendar]; NSDateComponents *lastComponents = [calendar components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:lastMessage.timestamp]; NSDateComponents *currentComponents = [calendar components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:message.timestamp]; return ![lastComponents isEqual:currentComponents]; } - (NSArray *)messagesByInsertingTimestamps:(NSArray *)messages { NSMutableArray *result = [NSMutableArray array]; for (KBAiChatMessage *msg in messages) { if ([self shouldInsertTimestampForMessage:msg inMessages:result]) { KBAiChatMessage *timeMessage = [KBAiChatMessage timeMessageWithTimestamp:msg.timestamp]; [result addObject:timeMessage]; } [result addObject:msg]; } return result; } - (void)prependHistoryMessages:(NSArray *)messages openingMessage:(nullable KBAiChatMessage *)openingMessage { CGFloat oldContentHeight = self.tableView.contentSize.height; NSInteger anchorRow = [self firstVisibleNonTimeRowExcludingMessage:openingMessage]; KBAiChatMessage *anchorMsg = nil; CGFloat anchorOffset = 0; if (anchorRow != NSNotFound && anchorRow < self.messages.count) { anchorMsg = self.messages[anchorRow]; anchorOffset = [self offsetForRow:anchorRow]; } NSArray *toInsert = [self messagesByInsertingTimestamps:messages]; [UIView performWithoutAnimation:^{ if (toInsert.count > 0) { NSInteger insertIndex = 0; if (openingMessage) { NSInteger openingIndex = [self.messages indexOfObjectIdenticalTo:openingMessage]; if (openingIndex != NSNotFound) { insertIndex = openingIndex + 1; } } NSRange range = NSMakeRange(insertIndex, toInsert.count); NSIndexSet *set = [NSIndexSet indexSetWithIndexesInRange:range]; [self.messages insertObjects:toInsert atIndexes:set]; NSMutableArray *indexPaths = [NSMutableArray array]; for (NSInteger i = 0; i < toInsert.count; i++) { [indexPaths addObject:[NSIndexPath indexPathForRow:insertIndex + i inSection:0]]; } [self.tableView beginUpdates]; [self.tableView insertRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationNone]; [self.tableView endUpdates]; } [self.tableView layoutIfNeeded]; CGFloat newContentHeight = self.tableView.contentSize.height; CGFloat delta = newContentHeight - oldContentHeight; [self restoreOffsetWithMessage:anchorMsg anchorOffset:anchorOffset fallbackDelta:delta]; }]; } - (void)appendHistoryMessages:(NSArray *)messages openingMessage:(nullable KBAiChatMessage *)openingMessage { if (messages.count == 0) { return; } NSInteger insertIndex = self.messages.count; if (openingMessage) { NSInteger openingIndex = [self.messages indexOfObjectIdenticalTo:openingMessage]; if (openingIndex != NSNotFound) { insertIndex = openingIndex; } } NSInteger referenceIndex = [self lastNonTimeIndexBeforeIndex:insertIndex]; KBAiChatMessage *reference = (referenceIndex != NSNotFound) ? self.messages[referenceIndex] : nil; NSArray *toInsert = nil; if (self.inverted) { toInsert = [self invertedMessagesByInsertingTimestamps:messages startingReference:reference]; } else { toInsert = [self messagesByInsertingTimestamps:messages]; } [UIView performWithoutAnimation:^{ NSRange range = NSMakeRange(insertIndex, toInsert.count); NSIndexSet *set = [NSIndexSet indexSetWithIndexesInRange:range]; [self.messages insertObjects:toInsert atIndexes:set]; NSMutableArray *indexPaths = [NSMutableArray array]; for (NSInteger i = 0; i < toInsert.count; i++) { [indexPaths addObject:[NSIndexPath indexPathForRow:insertIndex + i inSection:0]]; } [self.tableView beginUpdates]; [self.tableView insertRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationNone]; [self.tableView endUpdates]; [self.tableView layoutIfNeeded]; }]; } - (void)reloadMessage:(KBAiChatMessage *)message { if (!message) { return; } NSInteger index = [self.messages indexOfObjectIdenticalTo:message]; if (index == NSNotFound) { return; } NSIndexPath *indexPath = [NSIndexPath indexPathForRow:index inSection:0]; [self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; } - (void)updateIntroFooterText:(nullable NSString *)text { if (self.applyingIntroFooter) { return; } self.applyingIntroFooter = YES; self.introFooterText = text ?: @""; if (self.introFooterText.length == 0) { self.tableView.tableFooterView = nil; self.tableView.scrollEnabled = YES; self.lastIntroFooterTableSize = CGSizeZero; self.applyingIntroFooter = NO; return; } if (!self.introFooterContainer) { self.introFooterContainer = [[UIView alloc] initWithFrame:CGRectZero]; self.introFooterContainer.backgroundColor = [UIColor clearColor]; self.introFooterLabel = [[UILabel alloc] initWithFrame:CGRectZero]; self.introFooterLabel.numberOfLines = 0; self.introFooterLabel.font = [UIFont systemFontOfSize:14]; self.introFooterLabel.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.9]; self.introFooterLabel.textAlignment = NSTextAlignmentLeft; [self.introFooterContainer addSubview:self.introFooterLabel]; } self.introFooterLabel.text = self.introFooterText; self.introFooterContainer.transform = self.inverted ? CGAffineTransformMakeScale(1, -1) : CGAffineTransformIdentity; CGFloat width = CGRectGetWidth(self.tableView.bounds); if (width <= 0) { width = CGRectGetWidth(self.bounds); } CGFloat height = CGRectGetHeight(self.tableView.bounds); if (height <= 0) { height = CGRectGetHeight(self.bounds); } self.lastIntroFooterTableSize = CGSizeMake(width, height); CGFloat leftPadding = 16; CGFloat verticalPadding = 16; CGFloat labelWidth = MAX(0, width * 0.75); CGSize labelSize = [self.introFooterLabel sizeThatFits:CGSizeMake(labelWidth, CGFLOAT_MAX)]; CGFloat containerHeight = MAX(height, labelSize.height + verticalPadding * 2); self.introFooterContainer.frame = CGRectMake(0, 0, width, containerHeight); CGFloat labelY = containerHeight - verticalPadding - labelSize.height; labelY = MAX(verticalPadding, labelY); self.introFooterLabel.frame = CGRectMake(leftPadding, labelY, labelWidth, labelSize.height); self.tableView.tableFooterView = self.introFooterContainer; dispatch_async(dispatch_get_main_queue(), ^{ [self.tableView layoutIfNeeded]; CGFloat minOffset = -self.tableView.contentInset.top; [self.tableView setContentOffset:CGPointMake(0, minOffset) animated:NO]; }); self.tableView.scrollEnabled = (self.messages.count > 0); self.applyingIntroFooter = NO; } - (void)layoutSubviews { [super layoutSubviews]; if (self.introFooterText.length == 0 || self.applyingIntroFooter) { return; } CGFloat width = CGRectGetWidth(self.tableView.bounds); CGFloat height = CGRectGetHeight(self.tableView.bounds); if (width <= 0 || height <= 0) { return; } CGSize size = CGSizeMake(width, height); if (!CGSizeEqualToSize(size, self.lastIntroFooterTableSize)) { [self updateIntroFooterText:self.introFooterText]; } } /// 判断是否需要插入时间戳 - (BOOL)shouldInsertTimestampForMessage:(KBAiChatMessage *)message { // 第一条消息总是显示时间 if (self.messages.count == 0) { return YES; } // 查找最后一条非时间戳消息 KBAiChatMessage *lastMessage = nil; for (NSInteger i = self.messages.count - 1; i >= 0; i--) { KBAiChatMessage *msg = self.messages[i]; if (msg.type != KBAiChatMessageTypeTime) { lastMessage = msg; break; } } if (!lastMessage) { return YES; } // 计算时间间隔 NSTimeInterval interval = [message.timestamp timeIntervalSinceDate:lastMessage.timestamp]; // 超过 5 分钟或跨天则显示时间 if (interval >= kTimestampInterval) { return YES; } NSCalendar *calendar = [NSCalendar currentCalendar]; NSDateComponents *lastComponents = [calendar components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:lastMessage.timestamp]; NSDateComponents *currentComponents = [calendar components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:message.timestamp]; return ![lastComponents isEqual:currentComponents]; } /// 刷新并滚动到底部 - (void)reloadAndScroll { // 使用 insert 而不是 reload,避免刷新已有的 Cell NSInteger lastIndex = self.messages.count - 1; if (lastIndex >= 0) { NSIndexPath *indexPath = [NSIndexPath indexPathForRow:lastIndex inSection:0]; [self.tableView insertRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; } dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [self scrollToBottom]; }); } - (void)stopPreviousIncompleteAssistantMessageIfNeeded { if (self.inverted) { for (NSInteger i = 0; i < self.messages.count; i++) { KBAiChatMessage *msg = self.messages[i]; if (msg.type == KBAiChatMessageTypeAssistant && !msg.isComplete) { msg.isComplete = YES; msg.needsTypewriterEffect = NO; NSIndexPath *oldIndexPath = [NSIndexPath indexPathForRow:i inSection:0]; KBChatAssistantMessageCell *oldCell = [self.tableView cellForRowAtIndexPath:oldIndexPath]; if ([oldCell isKindOfClass:[KBChatAssistantMessageCell class]]) { [oldCell stopTypewriterEffect]; oldCell.messageLabel.text = msg.text; } break; } } } else { for (NSInteger i = self.messages.count - 1; i >= 0; i--) { KBAiChatMessage *msg = self.messages[i]; if (msg.type == KBAiChatMessageTypeAssistant && !msg.isComplete) { msg.isComplete = YES; msg.needsTypewriterEffect = NO; NSIndexPath *oldIndexPath = [NSIndexPath indexPathForRow:i inSection:0]; KBChatAssistantMessageCell *oldCell = [self.tableView cellForRowAtIndexPath:oldIndexPath]; if ([oldCell isKindOfClass:[KBChatAssistantMessageCell class]]) { [oldCell stopTypewriterEffect]; oldCell.messageLabel.text = msg.text; } break; } } } } - (void)updateFooterVisibility { // 暂时禁用 mj_footer // BOOL canLoadMore = (self.delegate && // [self.delegate respondsToSelector:@selector(chatTableViewDidTriggerLoadMore:)]); // self.tableView.mj_footer.hidden = !canLoadMore || self.messages.count == 0; } #pragma mark - UITableViewDataSource - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return self.messages.count; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { KBAiChatMessage *message = self.messages[indexPath.row]; NSLog(@"[KBChatTableView] cellForRow: %ld, 消息类型: %ld, needsTypewriter: %d, isComplete: %d", (long)indexPath.row, (long)message.type, message.needsTypewriterEffect, message.isComplete); switch (message.type) { case KBAiChatMessageTypeUser: { KBChatUserMessageCell *cell = [tableView dequeueReusableCellWithIdentifier:kUserCellIdentifier forIndexPath:indexPath]; [cell configureWithMessage:message]; cell.contentView.transform = self.inverted ? CGAffineTransformMakeScale(1, -1) : CGAffineTransformIdentity; return cell; } case KBAiChatMessageTypeAssistant: { KBChatAssistantMessageCell *cell = [tableView dequeueReusableCellWithIdentifier:kAssistantCellIdentifier forIndexPath:indexPath]; cell.delegate = self; [cell configureWithMessage:message]; // 更新播放状态 BOOL isPlaying = [indexPath isEqual:self.playingCellIndexPath]; [cell updateVoicePlayingState:isPlaying]; cell.contentView.transform = self.inverted ? CGAffineTransformMakeScale(1, -1) : CGAffineTransformIdentity; return cell; } case KBAiChatMessageTypeTime: { KBChatTimeCell *cell = [tableView dequeueReusableCellWithIdentifier:kTimeCellIdentifier forIndexPath:indexPath]; [cell configureWithMessage:message]; cell.contentView.transform = self.inverted ? CGAffineTransformMakeScale(1, -1) : CGAffineTransformIdentity; return cell; } } } - (void)setDelegate:(id)delegate { _delegate = delegate; [self updateFooterVisibility]; } #pragma mark - UIScrollViewDelegate - (UICollectionView *)nearestCollectionView { UIView *view = self; while (view) { if ([view isKindOfClass:[UICollectionView class]]) { return (UICollectionView *)view; } view = view.superview; } return nil; } - (void)setNearestCollectionViewScrollEnabled:(BOOL)enabled { UICollectionView *collectionView = [self nearestCollectionView]; if (!collectionView) { return; } if (collectionView.scrollEnabled == enabled) { return; } collectionView.scrollEnabled = enabled; } - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView { [self setNearestCollectionViewScrollEnabled:NO]; } - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate { if (!decelerate) { [self setNearestCollectionViewScrollEnabled:YES]; } } - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { [self setNearestCollectionViewScrollEnabled:YES]; } - (void)scrollViewDidScroll:(UIScrollView *)scrollView { if ([self.delegate respondsToSelector:@selector(chatTableViewDidScroll:scrollView:)]) { [self.delegate chatTableViewDidScroll:self scrollView:scrollView]; } } /// 关键修复:优化嵌套滚动体验,减少边界弹簧效果导致的抖动 - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset { CGFloat contentHeight = scrollView.contentSize.height; CGFloat scrollViewHeight = scrollView.bounds.size.height; CGFloat minOffset = -scrollView.contentInset.top; CGFloat maxOffset = contentHeight - scrollViewHeight + scrollView.contentInset.bottom; if (maxOffset < minOffset) { maxOffset = minOffset; } if (targetContentOffset->y < minOffset) { targetContentOffset->y = minOffset; } else if (targetContentOffset->y > maxOffset) { targetContentOffset->y = maxOffset; } } #pragma mark - KBChatAssistantMessageCellDelegate - (void)assistantMessageCell:(KBChatAssistantMessageCell *)cell didTapVoiceButtonForMessage:(KBAiChatMessage *)message { NSIndexPath *indexPath = [self.tableView indexPathForCell:cell]; if (!indexPath) return; // 如果正在播放同一条消息,则暂停 if ([indexPath isEqual:self.playingCellIndexPath]) { [self stopPlayingAudio]; return; } // 停止之前的播放 [self stopPlayingAudio]; // 如果有 audioData,直接播放 if (message.audioData && message.audioData.length > 0) { [self playAudioForMessage:message atIndexPath:indexPath]; return; } // 如果有 audioId,异步加载音频 if (message.audioId.length > 0) { [self loadAndPlayAudioForMessage:message atIndexPath:indexPath]; return; } NSLog(@"[KBChatTableView] 没有音频数据或 audioId"); } #pragma mark - Audio Playback - (void)loadAndPlayAudioForMessage:(KBAiChatMessage *)message atIndexPath:(NSIndexPath *)indexPath { // 显示加载动画 KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) { [cell showLoadingAnimation]; } // 开始轮询请求(最多10次,每次间隔1秒,共10秒) [self pollAudioForMessage:message atIndexPath:indexPath retryCount:0 maxRetries:10]; } - (void)pollAudioForMessage:(KBAiChatMessage *)message atIndexPath:(NSIndexPath *)indexPath retryCount:(NSInteger)retryCount maxRetries:(NSInteger)maxRetries { __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) { NSLog(@"[KBChatTableView] 音频 URL 获取成功(第 %ld 次)", (long)(retryCount + 1)); // 下载并播放音频 [strongSelf downloadAndPlayAudioFromURL:audioURL forMessage:message atIndexPath:indexPath]; 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 pollAudioForMessage:message atIndexPath:indexPath retryCount:retryCount + 1 maxRetries:maxRetries]; }); } else { // 达到最大重试次数,隐藏加载动画 KBChatAssistantMessageCell *cell = [strongSelf.tableView cellForRowAtIndexPath:indexPath]; if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) { [cell hideLoadingAnimation]; } NSLog(@"[KBChatTableView] 音频加载失败,已重试 %ld 次", (long)maxRetries); } }); }]; } - (void)downloadAndPlayAudioFromURL:(NSString *)urlString forMessage:(KBAiChatMessage *)message atIndexPath:(NSIndexPath *)indexPath { NSURL *url = [NSURL URLWithString:urlString]; if (!url) { NSLog(@"[KBChatTableView] 无效的音频 URL: %@", urlString); // 隐藏加载动画 KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) { [cell hideLoadingAnimation]; } 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 ?: @""); dispatch_async(dispatch_get_main_queue(), ^{ // 隐藏加载动画 KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) { [cell hideLoadingAnimation]; } }); return; } dispatch_async(dispatch_get_main_queue(), ^{ // 隐藏加载动画 KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) { [cell hideLoadingAnimation]; } // 缓存音频数据到消息对象 message.audioData = data; // 计算音频时长 NSError *playerError = nil; AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:data error:&playerError]; if (!playerError && player) { message.audioDuration = player.duration; } // 不刷新 Cell,避免触发打字机效果 // 直接播放音频 [self playAudioForMessage:message atIndexPath:indexPath]; }); }]; [task resume]; } - (void)playAudioForMessage:(KBAiChatMessage *)message atIndexPath:(NSIndexPath *)indexPath { if (!message.audioData || message.audioData.length == 0) { NSLog(@"[KBChatTableView] 没有音频数据"); return; } // 配置音频会话为播放模式 NSError *sessionError = nil; AVAudioSession *audioSession = [AVAudioSession sharedInstance]; [audioSession setCategory:AVAudioSessionCategoryPlayback error:&sessionError]; [audioSession setActive:YES error:&sessionError]; if (sessionError) { NSLog(@"[KBChatTableView] 音频会话配置失败: %@", sessionError.localizedDescription); } NSError *error = nil; self.audioPlayer = [[AVAudioPlayer alloc] initWithData:message.audioData error:&error]; if (error || !self.audioPlayer) { NSLog(@"[KBChatTableView] 音频播放器初始化失败: %@", error.localizedDescription); return; } self.audioPlayer.delegate = self; self.audioPlayer.volume = 1.0; // 设置音量为最大 [self.audioPlayer prepareToPlay]; [self.audioPlayer play]; self.playingCellIndexPath = indexPath; // 更新 Cell 状态,禁用动画避免 TableView 跳动 KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) { [UIView performWithoutAnimation:^{ [cell updateVoicePlayingState:YES]; }]; } } - (void)stopPlayingAudio { if (self.audioPlayer && self.audioPlayer.isPlaying) { [self.audioPlayer stop]; } if (self.playingCellIndexPath) { KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:self.playingCellIndexPath]; if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) { // 禁用动画,避免 TableView 跳动 [UIView performWithoutAnimation:^{ [cell updateVoicePlayingState:NO]; }]; } self.playingCellIndexPath = nil; } } - (void)playRemoteAudioWithURLString:(NSString *)urlString { if (urlString.length == 0) { return; } [self stopPlayingAudio]; self.remoteAudioToken = [NSUUID UUID].UUIDString; NSString *token = self.remoteAudioToken; NSURL *url = [NSURL URLWithString:urlString]; if (!url) { return; } NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration]; NSURLSession *session = [NSURLSession sessionWithConfiguration:config]; __weak typeof(self) weakSelf = self; NSURLSessionDataTask *task = [session dataTaskWithURL:url completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response, NSError *_Nullable error) { __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) { return; } if (![strongSelf.remoteAudioToken isEqualToString:token]) { return; } if (error || !data || data.length == 0) { return; } dispatch_async(dispatch_get_main_queue(), ^{ if (![strongSelf.remoteAudioToken isEqualToString:token]) { return; } NSError *sessionError = nil; AVAudioSession *audioSession = [AVAudioSession sharedInstance]; [audioSession setCategory:AVAudioSessionCategoryPlayback error:&sessionError]; [audioSession setActive:YES error:&sessionError]; NSError *playerError = nil; strongSelf.audioPlayer = [[AVAudioPlayer alloc] initWithData:data error:&playerError]; if (playerError || !strongSelf.audioPlayer) { return; } strongSelf.audioPlayer.delegate = strongSelf; strongSelf.audioPlayer.volume = 1.0; [strongSelf.audioPlayer prepareToPlay]; [strongSelf.audioPlayer play]; }); }]; [task resume]; } #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 { [self stopPlayingAudio]; } @end