Files
keyboard/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.m
2026-01-30 21:24:17 +08:00

1090 lines
44 KiB
Objective-C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 <MJRefresh/MJRefresh.h>
#import <Masonry/Masonry.h>
#import <AVFoundation/AVFoundation.h>
static NSString * const kUserCellIdentifier = @"KBChatUserMessageCell";
static NSString * const kAssistantCellIdentifier = @"KBChatAssistantMessageCell";
static NSString * const kTimeCellIdentifier = @"KBChatTimeCell";
/// 时间戳显示间隔(秒)
static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
@interface KBChatTableView () <UITableViewDataSource, UITableViewDelegate, KBChatAssistantMessageCellDelegate, AVAudioPlayerDelegate>
@property (nonatomic, strong) BaseTableView *tableView;
@property (nonatomic, strong) NSMutableArray<KBAiChatMessage *> *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;
@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)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 {
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 消息
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;
// 直接更新 Cell 的文本,不刷新整个 Cell避免打断打字机效果
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
NSLog(@"[KBChatTableView] 找到 Cell直接配置消息");
// 直接调用 configureWithMessage让 Cell 自己决定是否使用打字机效果
[cell configureWithMessage:message];
} else {
NSLog(@"[KBChatTableView] 未找到 Cell 或类型不匹配");
}
return;
}
}
// 如果没找到,添加新消息
NSLog(@"[KBChatTableView] 未找到未完成的 AI 消息,添加新消息");
[self addAssistantMessage:text audioDuration:0 audioData:nil];
}
- (void)markLastAssistantMessageComplete {
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; // 完成后不再需要打字机效果
// 刷新 Cell 以显示完整文本
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
[self.tableView reloadRowsAtIndexPaths:@[indexPath]
withRowAnimation:UITableViewRowAnimationNone];
return;
}
}
}
- (void)markLastUserMessageLoadingComplete {
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 {
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];
}
- (void)scrollToBottomAnimated:(BOOL)animated {
if (self.messages.count == 0) return;
// 关键修复:使用 layoutIfNeeded 确保布局完成后再滚动
[self.tableView layoutIfNeeded];
// 计算需要滚动到的位置
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;
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;
[self insertMessageWithTimestamp:message];
NSInteger newCount = self.messages.count;
NSInteger insertedCount = newCount - oldCount;
if (insertedCount > 0) {
NSMutableArray *indexPaths = [NSMutableArray array];
for (NSInteger i = oldCount; i < newCount; i++) {
[indexPaths addObject:[NSIndexPath indexPathForRow:i inSection:0]];
}
// 关键修复:批量插入前先布局,避免高度计算不准确
[self.tableView layoutIfNeeded];
[self.tableView insertRowsAtIndexPaths:indexPaths
withRowAnimation:UITableViewRowAnimationNone];
}
[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<KBAiChatMessage *> *)messages
keepOffset:(BOOL)keepOffset
scrollToBottom:(BOOL)scrollToBottom {
CGFloat oldContentHeight = self.tableView.contentSize.height;
CGFloat oldOffsetY = self.tableView.contentOffset.y;
KBAiChatMessage *anchorMessage = nil;
CGFloat anchorOffset = 0;
if (keepOffset) {
NSArray<NSIndexPath *> *visibleRows = self.tableView.indexPathsForVisibleRows;
if (visibleRows.count > 0) {
NSArray<NSIndexPath *> *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) {
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) {
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];
}
- (NSInteger)firstVisibleNonTimeRowExcludingMessage:(KBAiChatMessage *)excludedMessage {
NSArray<NSIndexPath *> *visible = self.tableView.indexPathsForVisibleRows;
if (visible.count == 0) { return NSNotFound; }
NSArray<NSIndexPath *> *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<KBAiChatMessage *> *)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<KBAiChatMessage *> *)messagesByInsertingTimestamps:(NSArray<KBAiChatMessage *> *)messages {
NSMutableArray<KBAiChatMessage *> *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<KBAiChatMessage *> *)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<KBAiChatMessage *> *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<NSIndexPath *> *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];
}];
}
/// 判断是否需要插入时间戳
- (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 {
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];
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];
return cell;
}
case KBAiChatMessageTypeTime: {
KBChatTimeCell *cell = [tableView dequeueReusableCellWithIdentifier:kTimeCellIdentifier
forIndexPath:indexPath];
[cell configureWithMessage:message];
return cell;
}
}
}
- (void)setDelegate:(id<KBChatTableViewDelegate>)delegate {
_delegate = delegate;
[self updateFooterVisibility];
}
#pragma mark - UIScrollViewDelegate
- (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 offsetY = scrollView.contentOffset.y;
CGFloat contentHeight = scrollView.contentSize.height;
CGFloat scrollViewHeight = scrollView.bounds.size.height;
// 如果内容不够长,禁用弹簧效果
if (contentHeight <= scrollViewHeight) {
scrollView.bounces = NO;
} else {
scrollView.bounces = YES;
// 在快速滑动到底部时,避免过度弹簧导致抖动
if (velocity.y < 0) { // 向上滑动(到底部)
CGFloat maxOffset = contentHeight - scrollViewHeight + scrollView.contentInset.bottom;
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;
}
}
#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