Files
keyboard/keyBoard/Class/AiTalk/V/KBChatTableView.m
2026-01-27 16:28:17 +08:00

647 lines
24 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) UITableView *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 = [[UITableView alloc] initWithFrame:self.bounds
style:UITableViewStylePlain];
self.tableView.dataSource = self;
self.tableView.delegate = self;
self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
self.tableView.backgroundColor = [UIColor clearColor];
self.tableView.estimatedRowHeight = 60;
self.tableView.rowHeight = UITableViewAutomaticDimension;
self.tableView.showsVerticalScrollIndicator = YES;
[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);
}];
self.contentBottomInset = KB_TABBAR_HEIGHT + 40 + 10;
[self updateContentBottomInset:self.contentBottomInset];
__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];
}
}];
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)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)clearMessages {
[self.messages removeAllObjects];
[self.tableView reloadData];
[self updateFooterVisibility];
}
- (void)scrollToBottom {
if (self.messages.count == 0) return;
NSIndexPath *lastIndexPath = [NSIndexPath indexPathForRow:self.messages.count - 1
inSection:0];
[self.tableView scrollToRowAtIndexPath:lastIndexPath
atScrollPosition:UITableViewScrollPositionBottom
animated:YES];
}
#pragma mark - Public Helpers
- (void)endLoadMoreWithHasMoreData:(BOOL)hasMoreData {
self.hasMoreData = hasMoreData;
if (hasMoreData) {
[self.tableView.mj_footer endRefreshing];
} else {
[self.tableView.mj_footer endRefreshingWithNoMoreData];
}
[self updateFooterVisibility];
}
- (void)resetNoMoreData {
self.hasMoreData = YES;
[self.tableView.mj_footer resetNoMoreData];
[self updateFooterVisibility];
}
- (void)updateContentBottomInset:(CGFloat)bottomInset {
self.contentBottomInset = bottomInset;
UIEdgeInsets insets = self.tableView.contentInset;
insets.bottom = bottomInset;
self.tableView.contentInset = insets;
self.tableView.scrollIndicatorInsets = insets;
}
- (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 insertRowsAtIndexPaths:indexPaths
withRowAnimation:UITableViewRowAnimationNone];
}
[self updateFooterVisibility];
if (autoScroll) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self scrollToBottom];
});
}
}
- (void)reloadWithMessages:(NSArray<KBAiChatMessage *> *)messages
keepOffset:(BOOL)keepOffset
scrollToBottom:(BOOL)scrollToBottom {
CGFloat oldContentHeight = self.tableView.contentSize.height;
CGFloat oldOffsetY = self.tableView.contentOffset.y;
[self.messages removeAllObjects];
if (messages.count > 0) {
for (KBAiChatMessage *message in messages) {
[self insertMessageWithTimestamp:message];
}
}
[self.tableView reloadData];
[self.tableView layoutIfNeeded];
[self updateFooterVisibility];
if (keepOffset) {
CGFloat newContentHeight = self.tableView.contentSize.height;
CGFloat delta = newContentHeight - oldContentHeight;
CGFloat offsetY = oldOffsetY + delta;
[self.tableView setContentOffset:CGPointMake(0, offsetY) animated:NO];
return;
}
if (scrollToBottom) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self scrollToBottom];
});
}
}
#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];
}
/// 判断是否需要插入时间戳
- (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 {
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];
}
}
#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];
}
// 开始轮询请求最多5次每次间隔0.5秒)
[self pollAudioForMessage:message atIndexPath:indexPath retryCount:0 maxRetries:5];
}
- (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) {
// 下载并播放音频
[strongSelf downloadAndPlayAudioFromURL:audioURL
forMessage:message
atIndexPath:indexPath];
return;
}
// 如果还没达到最大重试次数,继续轮询
if (retryCount < maxRetries - 1) {
NSLog(@"[KBChatTableView] 音频未就绪0.5秒后重试 (%ld/%ld)",
(long)(retryCount + 1), (long)maxRetries);
// 0.5秒后重试
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * 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 - AVAudioPlayerDelegate
- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag {
[self stopPlayingAudio];
}
@end