1090 lines
44 KiB
Objective-C
1090 lines
44 KiB
Objective-C
//
|
||
// 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
|