Files
keyboard/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.m

1417 lines
57 KiB
Mathematica
Raw Normal View History

2026-01-23 21:51:37 +08:00
//
// 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"
2026-01-26 20:36:51 +08:00
#import <MJRefresh/MJRefresh.h>
2026-01-23 21:51:37 +08:00
#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>
2026-01-28 17:21:19 +08:00
@property (nonatomic, strong) BaseTableView *tableView;
2026-01-23 21:51:37 +08:00
@property (nonatomic, strong) NSMutableArray<KBAiChatMessage *> *messages;
@property (nonatomic, strong) AVAudioPlayer *audioPlayer;
@property (nonatomic, strong) NSIndexPath *playingCellIndexPath;
@property (nonatomic, strong) AiVM *aiVM;
2026-01-26 20:36:51 +08:00
@property (nonatomic, assign) BOOL hasMoreData;
2026-01-27 16:28:17 +08:00
@property (nonatomic, assign) CGFloat contentBottomInset;
2026-01-23 21:51:37 +08:00
@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];
2026-01-26 20:36:51 +08:00
self.hasMoreData = YES;
2026-01-23 21:51:37 +08:00
// TableView
2026-01-28 17:21:19 +08:00
self.tableView = [[BaseTableView alloc] initWithFrame:self.bounds
2026-01-23 21:51:37 +08:00
style:UITableViewStylePlain];
2026-01-28 17:21:19 +08:00
self.tableView.useEmptyDataSet = false;
2026-01-23 21:51:37 +08:00
self.tableView.dataSource = self;
self.tableView.delegate = self;
self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
self.tableView.backgroundColor = [UIColor clearColor];
2026-01-27 17:03:16 +08:00
// 使0
self.tableView.estimatedRowHeight = 80;
self.tableView.estimatedSectionHeaderHeight = 0;
self.tableView.estimatedSectionFooterHeight = 0;
2026-01-23 21:51:37 +08:00
self.tableView.rowHeight = UITableViewAutomaticDimension;
self.tableView.showsVerticalScrollIndicator = YES;
2026-01-27 17:03:16 +08:00
// CollectionView
if (@available(iOS 11.0, *)) {
self.tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
}
2026-01-23 21:51:37 +08:00
[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) {
2026-01-27 16:28:17 +08:00
make.edges.equalTo(self);
2026-01-23 21:51:37 +08:00
}];
2026-01-26 20:36:51 +08:00
// contentInset
self.contentBottomInset = 0;
2026-01-27 16:28:17 +08:00
[self updateContentBottomInset:self.contentBottomInset];
// mj_footer
// TODO:
/*
2026-01-26 20:36:51 +08:00
__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];
}
}];
2026-01-28 15:32:56 +08:00
// "已经全部加载完毕"
// MJRefreshAutoNormalFooter *footer = (MJRefreshAutoNormalFooter *)self.tableView.mj_footer;
// footer.stateLabel.hidden = YES; //
// footer.refreshingBlock = footer.refreshingBlock; //
2026-01-28 15:32:56 +08:00
// self.tableView.mj_footer.hidden = YES;
*/
2026-01-23 21:51:37 +08:00
}
#pragma mark - Public Methods
2026-01-31 22:40:50 +08:00
- (void)setInverted:(BOOL)inverted {
if (_inverted == inverted) {
return;
}
_inverted = inverted;
self.tableView.transform = inverted ? CGAffineTransformMakeScale(1, -1) : CGAffineTransformIdentity;
[self updateContentBottomInset:self.contentBottomInset];
[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;
}
2026-01-23 21:51:37 +08:00
- (void)addUserMessage:(NSString *)text {
KBAiChatMessage *message = [KBAiChatMessage userMessageWithText:text];
2026-01-26 20:36:51 +08:00
[self addMessage:message autoScroll:YES];
2026-01-23 21:51:37 +08:00
}
2026-01-29 14:42:49 +08:00
- (void)addLoadingUserMessage {
KBAiChatMessage *message = [KBAiChatMessage loadingUserMessage];
[self addMessage:message autoScroll:YES];
}
- (void)updateLastUserMessage:(NSString *)text {
2026-01-31 22:40:50 +08:00
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;
}
2026-01-29 14:42:49 +08:00
}
}
}
2026-01-23 21:51:37 +08:00
- (void)addAssistantMessage:(NSString *)text
audioDuration:(NSTimeInterval)duration
audioData:(NSData *)audioData {
KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text
audioDuration:duration
audioData:audioData];
2026-01-26 20:36:51 +08:00
[self addMessage:message autoScroll:YES];
2026-01-23 21:51:37 +08:00
}
- (void)addAssistantMessage:(NSString *)text
audioId:(NSString *)audioId {
KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text
audioId:audioId];
message.needsTypewriterEffect = YES; //
2026-01-26 20:36:51 +08:00
[self addMessage:message autoScroll:YES];
2026-01-23 21:51:37 +08:00
}
- (void)updateLastAssistantMessage:(NSString *)text {
// AI
2026-01-31 22:40:50 +08:00
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;
2026-01-23 21:51:37 +08:00
}
}
}
//
NSLog(@"[KBChatTableView] 未找到未完成的 AI 消息,添加新消息");
[self addAssistantMessage:text audioDuration:0 audioData:nil];
}
- (void)markLastAssistantMessageComplete {
2026-01-31 22:40:50 +08:00
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;
}
2026-01-29 13:44:52 +08:00
}
}
}
- (void)markLastUserMessageLoadingComplete {
2026-01-31 22:40:50 +08:00
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;
}
2026-01-23 21:51:37 +08:00
}
}
}
- (void)clearMessages {
[self.messages removeAllObjects];
[self.tableView reloadData];
2026-01-26 20:36:51 +08:00
[self updateFooterVisibility];
2026-01-23 21:51:37 +08:00
}
2026-01-29 20:56:24 +08:00
- (void)removeLoadingAssistantMessage {
2026-01-31 22:40:50 +08:00
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;
}
2026-01-29 20:56:24 +08:00
}
}
}
2026-01-23 21:51:37 +08:00
- (void)scrollToBottom {
2026-01-27 17:03:16 +08:00
[self scrollToBottomAnimated:YES];
}
- (void)scrollToBottomAnimated:(BOOL)animated {
2026-01-23 21:51:37 +08:00
if (self.messages.count == 0) return;
2026-01-27 17:03:16 +08:00
// 使 layoutIfNeeded
[self.tableView layoutIfNeeded];
2026-01-31 22:40:50 +08:00
if (self.inverted) {
CGFloat minOffsetY = -self.tableView.contentInset.top;
[self.tableView setContentOffset:CGPointMake(0, minOffsetY) animated:animated];
return;
}
2026-01-27 17:03:16 +08:00
//
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;
2026-01-29 14:42:49 +08:00
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];
2026-01-23 21:51:37 +08:00
}
2026-01-26 20:36:51 +08:00
#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];
// }
2026-01-26 20:36:51 +08:00
[self updateFooterVisibility];
}
- (void)resetNoMoreData {
self.hasMoreData = YES;
// mj_footer
// [self.tableView.mj_footer resetNoMoreData];
2026-01-26 20:36:51 +08:00
[self updateFooterVisibility];
}
2026-01-27 16:28:17 +08:00
- (void)updateContentBottomInset:(CGFloat)bottomInset {
self.contentBottomInset = bottomInset;
// contentInset
UIEdgeInsets insets = UIEdgeInsetsZero;
2026-01-31 22:40:50 +08:00
if (self.inverted) {
insets.top = bottomInset;
} else {
insets.bottom = bottomInset;
}
2026-01-27 16:28:17 +08:00
self.tableView.contentInset = insets;
self.tableView.scrollIndicatorInsets = insets;
NSLog(@"[KBChatTableView] updateContentBottomInset: %.2f", bottomInset);
2026-01-27 16:28:17 +08:00
}
2026-01-26 20:36:51 +08:00
- (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;
2026-01-31 22:40:50 +08:00
NSArray<KBAiChatMessage *> *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;
}
2026-01-26 20:36:51 +08:00
NSInteger newCount = self.messages.count;
NSInteger insertedCount = newCount - oldCount;
if (insertedCount > 0) {
2026-01-31 22:40:50 +08:00
if (self.inverted && self.playingCellIndexPath) {
self.playingCellIndexPath = [NSIndexPath indexPathForRow:self.playingCellIndexPath.row + insertedCount inSection:0];
}
2026-01-26 20:36:51 +08:00
NSMutableArray *indexPaths = [NSMutableArray array];
2026-01-31 22:40:50 +08:00
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]];
}
2026-01-26 20:36:51 +08:00
}
2026-01-27 17:03:16 +08:00
[self.tableView layoutIfNeeded];
2026-01-31 22:40:50 +08:00
[self.tableView beginUpdates];
[self.tableView insertRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationNone];
[self.tableView endUpdates];
2026-01-26 20:36:51 +08:00
}
[self updateFooterVisibility];
if (autoScroll) {
2026-01-27 17:03:16 +08:00
// 使 dispatch_async
dispatch_async(dispatch_get_main_queue(), ^{
[self.tableView layoutIfNeeded]; //
[self scrollToBottomAnimated:YES];
2026-01-29 14:42:49 +08:00
//
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self.tableView layoutIfNeeded];
[self scrollToBottomAnimated:NO];
});
2026-01-26 20:36:51 +08:00
});
}
2026-01-29 20:56:24 +08:00
//
if (message.type == KBAiChatMessageTypeAssistant && message.audioId.length > 0) {
[self preloadAudioForMessage:message];
}
2026-01-26 20:36:51 +08:00
}
- (void)reloadWithMessages:(NSArray<KBAiChatMessage *> *)messages
keepOffset:(BOOL)keepOffset
scrollToBottom:(BOOL)scrollToBottom {
2026-01-31 22:40:50 +08:00
if (self.inverted) {
keepOffset = NO;
}
2026-01-26 20:36:51 +08:00
CGFloat oldContentHeight = self.tableView.contentSize.height;
CGFloat oldOffsetY = self.tableView.contentOffset.y;
2026-01-30 21:24:17 +08:00
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;
}
}
}
2026-01-26 20:36:51 +08:00
[self.messages removeAllObjects];
if (messages.count > 0) {
2026-01-31 22:40:50 +08:00
if (self.inverted) {
NSArray<KBAiChatMessage *> *rebuilt = [self invertedMessagesByInsertingTimestamps:messages
startingReference:nil];
[self.messages addObjectsFromArray:rebuilt];
} else {
for (KBAiChatMessage *message in messages) {
[self insertMessageWithTimestamp:message];
}
2026-01-26 20:36:51 +08:00
}
}
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));
2026-01-26 20:36:51 +08:00
[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 结束 ==========");
2026-01-26 20:36:51 +08:00
if (keepOffset) {
2026-01-31 22:40:50 +08:00
if (self.inverted) {
keepOffset = NO;
}
2026-01-30 21:24:17 +08:00
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;
}
2026-01-26 20:36:51 +08:00
[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));
});
2026-01-26 20:36:51 +08:00
}
}
2026-01-23 21:51:37 +08:00
#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];
}
2026-01-31 22:40:50 +08:00
- (NSArray<KBAiChatMessage *> *)invertedItemsForNewMessageAtBeginning:(KBAiChatMessage *)message {
NSMutableArray<KBAiChatMessage *> *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<KBAiChatMessage *> *)invertedMessagesByInsertingTimestamps:(NSArray<KBAiChatMessage *> *)messages
startingReference:(nullable KBAiChatMessage *)reference {
NSMutableArray<KBAiChatMessage *> *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;
}
2026-01-30 21:24:17 +08:00
- (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];
}];
}
2026-01-31 22:40:50 +08:00
- (void)appendHistoryMessages:(NSArray<KBAiChatMessage *> *)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<KBAiChatMessage *> *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<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];
}];
}
2026-01-31 23:17:58 +08:00
- (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];
}
2026-01-23 21:51:37 +08:00
///
- (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];
});
}
2026-01-26 20:36:51 +08:00
- (void)stopPreviousIncompleteAssistantMessageIfNeeded {
2026-01-31 22:40:50 +08:00
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;
2026-01-26 20:36:51 +08:00
}
}
}
}
- (void)updateFooterVisibility {
// mj_footer
// BOOL canLoadMore = (self.delegate &&
// [self.delegate respondsToSelector:@selector(chatTableViewDidTriggerLoadMore:)]);
// self.tableView.mj_footer.hidden = !canLoadMore || self.messages.count == 0;
2026-01-26 20:36:51 +08:00
}
2026-01-23 21:51:37 +08:00
#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];
2026-01-31 22:40:50 +08:00
cell.contentView.transform = self.inverted ? CGAffineTransformMakeScale(1, -1) : CGAffineTransformIdentity;
2026-01-23 21:51:37 +08:00
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];
2026-01-31 22:40:50 +08:00
cell.contentView.transform = self.inverted ? CGAffineTransformMakeScale(1, -1) : CGAffineTransformIdentity;
2026-01-23 21:51:37 +08:00
return cell;
}
case KBAiChatMessageTypeTime: {
KBChatTimeCell *cell = [tableView dequeueReusableCellWithIdentifier:kTimeCellIdentifier
forIndexPath:indexPath];
[cell configureWithMessage:message];
2026-01-31 22:40:50 +08:00
cell.contentView.transform = self.inverted ? CGAffineTransformMakeScale(1, -1) : CGAffineTransformIdentity;
2026-01-23 21:51:37 +08:00
return cell;
}
}
}
2026-01-26 20:36:51 +08:00
- (void)setDelegate:(id<KBChatTableViewDelegate>)delegate {
_delegate = delegate;
[self updateFooterVisibility];
}
#pragma mark - UIScrollViewDelegate
2026-01-31 22:40:50 +08:00
- (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];
}
2026-01-26 20:36:51 +08:00
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
if ([self.delegate respondsToSelector:@selector(chatTableViewDidScroll:scrollView:)]) {
[self.delegate chatTableViewDidScroll:self scrollView:scrollView];
}
}
2026-01-27 17:03:16 +08:00
///
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
withVelocity:(CGPoint)velocity
targetContentOffset:(inout CGPoint *)targetContentOffset {
CGFloat contentHeight = scrollView.contentSize.height;
CGFloat scrollViewHeight = scrollView.bounds.size.height;
2026-01-31 22:40:50 +08:00
CGFloat minOffset = -scrollView.contentInset.top;
CGFloat maxOffset = contentHeight - scrollViewHeight + scrollView.contentInset.bottom;
if (maxOffset < minOffset) {
maxOffset = minOffset;
}
2026-01-27 17:03:16 +08:00
2026-01-31 22:40:50 +08:00
if (targetContentOffset->y < minOffset) {
targetContentOffset->y = minOffset;
} else if (targetContentOffset->y > maxOffset) {
targetContentOffset->y = maxOffset;
2026-01-27 17:03:16 +08:00
}
}
2026-01-23 21:51:37 +08:00
#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];
}
2026-01-29 20:56:24 +08:00
// 10110
[self pollAudioForMessage:message atIndexPath:indexPath retryCount:0 maxRetries:10];
2026-01-23 21:51:37 +08:00
}
- (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) {
2026-01-29 20:56:24 +08:00
NSLog(@"[KBChatTableView] 音频 URL 获取成功(第 %ld 次)", (long)(retryCount + 1));
2026-01-23 21:51:37 +08:00
//
[strongSelf downloadAndPlayAudioFromURL:audioURL
forMessage:message
atIndexPath:indexPath];
return;
}
//
if (retryCount < maxRetries - 1) {
2026-01-29 20:56:24 +08:00
NSLog(@"[KBChatTableView] 音频未就绪1秒后重试 (%ld/%ld)",
2026-01-23 21:51:37 +08:00
(long)(retryCount + 1), (long)maxRetries);
2026-01-29 20:56:24 +08:00
// 1
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)),
2026-01-23 21:51:37 +08:00
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;
2026-01-26 13:51:38 +08:00
// Cell TableView
2026-01-23 21:51:37 +08:00
KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
2026-01-26 13:51:38 +08:00
[UIView performWithoutAnimation:^{
[cell updateVoicePlayingState:YES];
}];
2026-01-23 21:51:37 +08:00
}
}
- (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]]) {
2026-01-26 13:51:38 +08:00
// TableView
[UIView performWithoutAnimation:^{
[cell updateVoicePlayingState:NO];
}];
2026-01-23 21:51:37 +08:00
}
self.playingCellIndexPath = nil;
}
}
2026-01-29 20:56:24 +08:00
#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];
// 10110
[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];
}
2026-01-23 21:51:37 +08:00
#pragma mark - AVAudioPlayerDelegate
- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag {
[self stopPlayingAudio];
}
@end