分类移动文件

This commit is contained in:
2026-01-29 13:44:52 +08:00
parent 4392296616
commit 25fbe9b64e
25 changed files with 72 additions and 30 deletions

View File

@@ -0,0 +1,47 @@
//
// KBChatAssistantMessageCell.h
// keyBoard
//
// Created by Kiro on 2026/1/23.
//
#import <UIKit/UIKit.h>
@class KBAiChatMessage;
@class KBChatAssistantMessageCell;
NS_ASSUME_NONNULL_BEGIN
/// AI 消息 Cell 代理
@protocol KBChatAssistantMessageCellDelegate <NSObject>
/// 点击语音播放按钮
- (void)assistantMessageCell:(KBChatAssistantMessageCell *)cell
didTapVoiceButtonForMessage:(KBAiChatMessage *)message;
@end
/// AI 消息 Cell左侧气泡 + 语音按钮)
@interface KBChatAssistantMessageCell : UITableViewCell
@property (nonatomic, weak) id<KBChatAssistantMessageCellDelegate> delegate;
@property (nonatomic, strong, readonly) UILabel *messageLabel; // 暴露 messageLabel 供外部访问
/// 配置 Cell
- (void)configureWithMessage:(KBAiChatMessage *)message;
/// 更新语音播放状态
- (void)updateVoicePlayingState:(BOOL)isPlaying;
/// 显示加载动画
- (void)showLoadingAnimation;
/// 隐藏加载动画
- (void)hideLoadingAnimation;
/// 停止打字机效果
- (void)stopTypewriterEffect;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,354 @@
//
// KBChatAssistantMessageCell.m
// keyBoard
//
// Created by Kiro on 2026/1/23.
//
#import "KBChatAssistantMessageCell.h"
#import "KBAiChatMessage.h"
#import <Masonry/Masonry.h>
@interface KBChatAssistantMessageCell ()
@property (nonatomic, strong) UIButton *voiceButton;
@property (nonatomic, strong) UILabel *durationLabel;
@property (nonatomic, strong) UIView *bubbleView;
@property (nonatomic, strong, readwrite) UILabel *messageLabel; // readwrite
@property (nonatomic, strong) UIActivityIndicatorView *loadingIndicator;
@property (nonatomic, strong) KBAiChatMessage *currentMessage;
//
@property (nonatomic, strong) NSTimer *typewriterTimer;
@property (nonatomic, copy) NSString *fullText;
@property (nonatomic, assign) NSInteger currentCharIndex;
@end
@implementation KBChatAssistantMessageCell
- (instancetype)initWithStyle:(UITableViewCellStyle)style
reuseIdentifier:(NSString *)reuseIdentifier {
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
if (self) {
self.backgroundColor = [UIColor clearColor];
self.selectionStyle = UITableViewCellSelectionStyleNone;
[self setupUI];
}
return self;
}
- (void)setupUI {
//
self.voiceButton = [UIButton buttonWithType:UIButtonTypeCustom];
[self.voiceButton setImage:[UIImage systemImageNamed:@"play.circle.fill"]
forState:UIControlStateNormal];
self.voiceButton.tintColor = [UIColor whiteColor];
[self.voiceButton addTarget:self
action:@selector(voiceButtonTapped)
forControlEvents:UIControlEventTouchUpInside];
[self.contentView addSubview:self.voiceButton];
//
self.durationLabel = [[UILabel alloc] init];
self.durationLabel.font = [UIFont systemFontOfSize:14];
self.durationLabel.textColor = [UIColor whiteColor];
[self.contentView addSubview:self.durationLabel];
//
self.loadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
self.loadingIndicator.color = [UIColor whiteColor];
self.loadingIndicator.hidesWhenStopped = YES;
[self.contentView addSubview:self.loadingIndicator];
//
self.bubbleView = [[UIView alloc] init];
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.7];
self.bubbleView.layer.cornerRadius = 16;
self.bubbleView.layer.masksToBounds = YES;
[self.contentView addSubview:self.bubbleView];
//
self.messageLabel = [[UILabel alloc] init];
self.messageLabel.numberOfLines = 0;
self.messageLabel.font = [UIFont systemFontOfSize:16];
self.messageLabel.textColor = [UIColor whiteColor];
self.messageLabel.lineBreakMode = NSLineBreakByWordWrapping;
// preferredMaxLayoutWidth AutoLayout
CGFloat maxWidth = [UIScreen mainScreen].bounds.size.width * 0.75 - 16 - 24;
self.messageLabel.preferredMaxLayoutWidth = maxWidth;
[self.bubbleView addSubview:self.messageLabel];
//
[self.voiceButton mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.contentView).offset(16);
make.top.equalTo(self.contentView).offset(8);
// make.width.height.mas_equalTo(24);
make.width.height.mas_equalTo(0);
}];
[self.durationLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.voiceButton.mas_right).offset(4);
make.centerY.equalTo(self.voiceButton);
}];
[self.loadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) {
make.center.equalTo(self.voiceButton);
}];
// bubbleView
[self.bubbleView mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.voiceButton.mas_bottom).offset(4);
make.bottom.equalTo(self.contentView).offset(-4).priority(999); //
make.left.equalTo(self.contentView).offset(16);
make.width.lessThanOrEqualTo(self.contentView).multipliedBy(0.75);
}];
// messageLabel AutoLayout bubbleView
[self.messageLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.bubbleView).offset(5);
make.bottom.equalTo(self.bubbleView).offset(-5).priority(999); //
make.left.equalTo(self.bubbleView).offset(12);
make.right.equalTo(self.bubbleView).offset(-12);
// messageLabel 0
make.height.greaterThanOrEqualTo(@20);
}];
}
- (void)configureWithMessage:(KBAiChatMessage *)message {
NSLog(@"[KBChatAssistantMessageCell] 配置消息 - 文本长度: %lu, isComplete: %d, needsTypewriter: %d, 打字机运行中: %d",
(unsigned long)message.text.length, message.isComplete, message.needsTypewriterEffect,
(self.typewriterTimer && self.typewriterTimer.isValid));
//
[self stopTypewriterEffect];
self.currentMessage = message;
// 使
if (message.needsTypewriterEffect && !message.isComplete && message.text.length > 0) {
//
NSLog(@"[KBChatAssistantMessageCell] 启动新的打字机效果");
[self startTypewriterEffectWithText:message.text];
} else {
// attributedText使 text
NSLog(@"[KBChatAssistantMessageCell] 直接显示完整文本needsTypewriter: %d, isComplete: %d",
message.needsTypewriterEffect, message.isComplete);
self.messageLabel.attributedText = nil; // attributedText
self.messageLabel.text = message.text; //
//
[self.contentView setNeedsLayout];
[self.contentView layoutIfNeeded];
NSLog(@"[KBChatAssistantMessageCell] 直接显示后 Label frame: %@",
NSStringFromCGRect(self.messageLabel.frame));
}
// 0
if (message.audioDuration > 0) {
NSInteger seconds = (NSInteger)ceil(message.audioDuration);
self.durationLabel.text = [NSString stringWithFormat:@"%ld\"", (long)seconds];
} else {
self.durationLabel.text = @"";
}
// audioId audioData
BOOL hasAudio = (message.audioId.length > 0) ||
(message.audioData != nil && message.audioData.length > 0);
self.voiceButton.hidden = !hasAudio;
self.durationLabel.hidden = !hasAudio;
}
- (void)updateVoicePlayingState:(BOOL)isPlaying {
if (isPlaying) {
[self.voiceButton setImage:[UIImage systemImageNamed:@"pause.circle.fill"]
forState:UIControlStateNormal];
} else {
[self.voiceButton setImage:[UIImage systemImageNamed:@"play.circle.fill"]
forState:UIControlStateNormal];
}
}
- (void)showLoadingAnimation {
//
[self.voiceButton setImage:nil forState:UIControlStateNormal];
[self.loadingIndicator startAnimating];
}
- (void)hideLoadingAnimation {
//
[self.loadingIndicator stopAnimating];
[self.voiceButton setImage:[UIImage systemImageNamed:@"play.circle.fill"]
forState:UIControlStateNormal];
}
- (void)voiceButtonTapped {
if ([self.delegate respondsToSelector:@selector(assistantMessageCell:didTapVoiceButtonForMessage:)]) {
[self.delegate assistantMessageCell:self didTapVoiceButtonForMessage:self.currentMessage];
}
}
#pragma mark - Typewriter Effect
- (void)startTypewriterEffectWithText:(NSString *)text {
if (text.length == 0) {
NSLog(@"[KBChatAssistantMessageCell] 文本为空,跳过打字机效果");
return;
}
NSLog(@"[KBChatAssistantMessageCell] 开始打字机效果,文本长度: %lu, 文本: %@",
(unsigned long)text.length, text);
self.fullText = text;
self.currentCharIndex = 0;
//
self.messageLabel.text = text;
// Cell
[self.contentView setNeedsLayout];
[self.contentView layoutIfNeeded];
NSLog(@"[KBChatAssistantMessageCell] 布局后 Label frame: %@, bubbleView frame: %@",
NSStringFromCGRect(self.messageLabel.frame),
NSStringFromCGRect(self.bubbleView.frame));
// attributedText
dispatch_async(dispatch_get_main_queue(), ^{
// 使 NSAttributedString
//
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
[attributedText addAttribute:NSForegroundColorAttributeName
value:[UIColor clearColor]
range:NSMakeRange(0, text.length)];
[attributedText addAttribute:NSFontAttributeName
value:self.messageLabel.font
range:NSMakeRange(0, text.length)];
self.messageLabel.attributedText = attributedText;
// attributedText
[self.contentView layoutIfNeeded];
// 0.03
self.typewriterTimer = [NSTimer scheduledTimerWithTimeInterval:0.03
target:self
selector:@selector(typewriterTick)
userInfo:nil
repeats:YES];
// RunLoop common
[[NSRunLoop currentRunLoop] addTimer:self.typewriterTimer forMode:NSRunLoopCommonModes];
//
[self typewriterTick];
NSLog(@"[KBChatAssistantMessageCell] 定时器已创建: %@", self.typewriterTimer);
});
}
- (void)typewriterTick {
// 使 fullText线
NSString *text = self.fullText;
// fullText
if (!text || text.length == 0) {
NSLog(@"[KBChatAssistantMessageCell] fullText 无效,停止打字机");
[self stopTypewriterEffect];
return;
}
//
if (self.currentCharIndex < text.length) {
self.currentCharIndex++;
// 使 NSAttributedString N
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
//
if (self.currentCharIndex > 0) {
[attributedText addAttribute:NSForegroundColorAttributeName
value:[UIColor whiteColor]
range:NSMakeRange(0, self.currentCharIndex)];
}
//
if (self.currentCharIndex < text.length) {
[attributedText addAttribute:NSForegroundColorAttributeName
value:[UIColor clearColor]
range:NSMakeRange(self.currentCharIndex, text.length - self.currentCharIndex)];
}
//
[attributedText addAttribute:NSFontAttributeName
value:self.messageLabel.font
range:NSMakeRange(0, text.length)];
self.messageLabel.attributedText = attributedText;
// 10
if (self.currentCharIndex % 10 == 0) {
NSString *displayText = [text substringToIndex:self.currentCharIndex];
NSLog(@"[KBChatAssistantMessageCell] 打字机进度: %ld/%lu, 当前文本: %@",
(long)self.currentCharIndex, (unsigned long)text.length, displayText);
NSLog(@"[KBChatAssistantMessageCell] Label 状态 - frame: %@, hidden: %d, alpha: %.2f",
NSStringFromCGRect(self.messageLabel.frame),
self.messageLabel.hidden,
self.messageLabel.alpha);
}
} else {
//
NSLog(@"[KBChatAssistantMessageCell] 打字机效果完成,停止定时器");
// text
if (!text || text.length == 0) {
NSLog(@"[KBChatAssistantMessageCell] text 为空,跳过");
[self stopTypewriterEffect];
return;
}
[self stopTypewriterEffect];
// 使 attributedText
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
[attributedText addAttribute:NSForegroundColorAttributeName
value:[UIColor whiteColor]
range:NSMakeRange(0, text.length)];
[attributedText addAttribute:NSFontAttributeName
value:self.messageLabel.font
range:NSMakeRange(0, text.length)];
self.messageLabel.attributedText = attributedText;
// Cell
if (self.currentMessage) {
self.currentMessage.isComplete = YES;
self.currentMessage.needsTypewriterEffect = NO;
NSLog(@"[KBChatAssistantMessageCell] 标记消息完成 - 文本: %@", self.currentMessage.text);
}
}
}
- (void)stopTypewriterEffect {
if (self.typewriterTimer && self.typewriterTimer.isValid) {
NSLog(@"[KBChatAssistantMessageCell] 停止打字机定时器");
[self.typewriterTimer invalidate];
}
self.typewriterTimer = nil;
self.currentCharIndex = 0;
self.fullText = nil;
}
- (void)prepareForReuse {
[super prepareForReuse];
[self stopTypewriterEffect];
//
self.messageLabel.text = @"";
self.messageLabel.attributedText = nil;
}
- (void)dealloc {
[self stopTypewriterEffect];
}
@end

View File

@@ -0,0 +1,74 @@
//
// KBChatTableView.h
// keyBoard
//
// Created by Kiro on 2026/1/23.
//
#import <UIKit/UIKit.h>
#import "KBAiChatMessage.h"
NS_ASSUME_NONNULL_BEGIN
@class KBChatTableView;
@protocol KBChatTableViewDelegate <NSObject>
@optional
- (void)chatTableViewDidScroll:(KBChatTableView *)chatView
scrollView:(UIScrollView *)scrollView;
- (void)chatTableViewDidTriggerLoadMore:(KBChatTableView *)chatView;
@end
/// 聊天列表视图支持用户消息、AI 消息、时间戳、语音播放)
@interface KBChatTableView : UIView
@property (nonatomic, weak) id<KBChatTableViewDelegate> delegate;
/// 添加用户消息
- (void)addUserMessage:(NSString *)text;
/// 添加 AI 消息(带语音)
- (void)addAssistantMessage:(NSString *)text
audioDuration:(NSTimeInterval)duration
audioData:(nullable NSData *)audioData;
/// 添加 AI 消息(带 audioId异步加载音频
- (void)addAssistantMessage:(NSString *)text
audioId:(nullable NSString *)audioId;
/// 更新最后一条 AI 消息(用于打字机效果)
- (void)updateLastAssistantMessage:(NSString *)text;
/// 标记最后一条 AI 消息完成
- (void)markLastAssistantMessageComplete;
/// 清空所有消息
- (void)clearMessages;
/// 滚动到底部
- (void)scrollToBottom;
/// 停止正在播放的音频
- (void)stopPlayingAudio;
/// 结束加载更多
- (void)endLoadMoreWithHasMoreData:(BOOL)hasMoreData;
/// 重置无更多数据状态
- (void)resetNoMoreData;
/// 更新底部内容 inset用于避开输入栏/键盘)
- (void)updateContentBottomInset:(CGFloat)bottomInset;
/// 添加自定义消息(可用于历史消息或打字机)
- (void)addMessage:(KBAiChatMessage *)message
autoScroll:(BOOL)autoScroll;
/// 用指定消息重载(用于历史消息分页)
- (void)reloadWithMessages:(NSArray<KBAiChatMessage *> *)messages
keepOffset:(BOOL)keepOffset
scrollToBottom:(BOOL)scrollToBottom;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,770 @@
//
// 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)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) {
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 {
[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;
// tableView
if (contentHeight <= tableViewHeight) {
NSLog(@"[KBChatTableView] 内容高度(%.2f) <= tableView高度(%.2f),不需要滚动", contentHeight, tableViewHeight);
return;
}
// offset
CGFloat offsetY = contentHeight - tableViewHeight + bottomInset;
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];
});
}
}
- (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];
}
}
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 newContentHeight = self.tableView.contentSize.height;
CGFloat delta = newContentHeight - oldContentHeight;
CGFloat offsetY = oldOffsetY + delta;
// 使 offset
[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];
}
///
- (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];
}
// 50.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

View File

@@ -0,0 +1,22 @@
//
// KBChatTimeCell.h
// keyBoard
//
// Created by Kiro on 2026/1/23.
//
#import <UIKit/UIKit.h>
@class KBAiChatMessage;
NS_ASSUME_NONNULL_BEGIN
/// 时间戳 Cell居中显示
@interface KBChatTimeCell : UITableViewCell
/// 配置 Cell
- (void)configureWithMessage:(KBAiChatMessage *)message;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,70 @@
//
// KBChatTimeCell.m
// keyBoard
//
// Created by Kiro on 2026/1/23.
//
#import "KBChatTimeCell.h"
#import "KBAiChatMessage.h"
#import <Masonry/Masonry.h>
@interface KBChatTimeCell ()
@property (nonatomic, strong) UILabel *timeLabel;
@end
@implementation KBChatTimeCell
- (instancetype)initWithStyle:(UITableViewCellStyle)style
reuseIdentifier:(NSString *)reuseIdentifier {
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
if (self) {
self.backgroundColor = [UIColor clearColor];
self.selectionStyle = UITableViewCellSelectionStyleNone;
[self setupUI];
}
return self;
}
- (void)setupUI {
//
self.timeLabel = [[UILabel alloc] init];
self.timeLabel.font = [UIFont systemFontOfSize:12];
self.timeLabel.textColor = [UIColor secondaryLabelColor];
self.timeLabel.textAlignment = NSTextAlignmentCenter;
[self.contentView addSubview:self.timeLabel];
//
[self.timeLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.contentView).offset(1);
make.bottom.equalTo(self.contentView).offset(-1);
make.centerX.equalTo(self.contentView);
}];
}
- (void)configureWithMessage:(KBAiChatMessage *)message {
self.timeLabel.text = [self formatTimestamp:message.timestamp];
}
///
- (NSString *)formatTimestamp:(NSDate *)timestamp {
NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
NSCalendar *calendar = [NSCalendar currentCalendar];
if ([calendar isDateInToday:timestamp]) {
//
formatter.dateFormat = @"HH:mm";
} else if ([calendar isDateInYesterday:timestamp]) {
//
formatter.dateFormat = @"'昨天' HH:mm";
} else {
// +
formatter.dateFormat = @"MM月dd日 HH:mm";
}
return [formatter stringFromDate:timestamp];
}
@end

View File

@@ -0,0 +1,22 @@
//
// KBChatUserMessageCell.h
// keyBoard
//
// Created by Kiro on 2026/1/23.
//
#import <UIKit/UIKit.h>
@class KBAiChatMessage;
NS_ASSUME_NONNULL_BEGIN
/// 用户消息 Cell右侧气泡
@interface KBChatUserMessageCell : UITableViewCell
/// 配置 Cell
- (void)configureWithMessage:(KBAiChatMessage *)message;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,67 @@
//
// KBChatUserMessageCell.m
// keyBoard
//
// Created by Kiro on 2026/1/23.
//
#import "KBChatUserMessageCell.h"
#import "KBAiChatMessage.h"
#import <Masonry/Masonry.h>
@interface KBChatUserMessageCell ()
@property (nonatomic, strong) UIView *bubbleView;
@property (nonatomic, strong) UILabel *messageLabel;
@end
@implementation KBChatUserMessageCell
- (instancetype)initWithStyle:(UITableViewCellStyle)style
reuseIdentifier:(NSString *)reuseIdentifier {
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
if (self) {
self.backgroundColor = [UIColor clearColor];
self.selectionStyle = UITableViewCellSelectionStyleNone;
[self setupUI];
}
return self;
}
- (void)setupUI {
//
self.bubbleView = [[UIView alloc] init];
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.94 green:0.94 blue:0.94 alpha:1.0];
self.bubbleView.layer.cornerRadius = 16;
self.bubbleView.layer.masksToBounds = YES;
[self.contentView addSubview:self.bubbleView];
//
self.messageLabel = [[UILabel alloc] init];
self.messageLabel.numberOfLines = 0;
self.messageLabel.font = [UIFont systemFontOfSize:16];
self.messageLabel.textColor = [UIColor blackColor];
[self.bubbleView addSubview:self.messageLabel];
//
[self.bubbleView mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.contentView).offset(4);
make.bottom.equalTo(self.contentView).offset(-4);
make.right.equalTo(self.contentView).offset(-16);
make.width.lessThanOrEqualTo(self.contentView).multipliedBy(0.75);
}];
[self.messageLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.bubbleView).offset(10);
make.bottom.equalTo(self.bubbleView).offset(-10);
make.left.equalTo(self.bubbleView).offset(12);
make.right.equalTo(self.bubbleView).offset(-12);
}];
}
- (void)configureWithMessage:(KBAiChatMessage *)message {
self.messageLabel.text = message.text;
}
@end

View File

@@ -0,0 +1,36 @@
//
// KBPersonaChatCell.h
// keyBoard
//
// Created by Kiro on 2026/1/26.
//
#import <UIKit/UIKit.h>
#import "KBPersonaModel.h"
#import "KBChatTableView.h"
NS_ASSUME_NONNULL_BEGIN
/// 人设聊天 Cell
@interface KBPersonaChatCell : UICollectionViewCell
/// 聊天列表
@property (nonatomic, strong) KBChatTableView *chatView;
/// 人设数据
@property (nonatomic, strong) KBPersonaModel *persona;
/// 预加载数据
- (void)preloadDataIfNeeded;
/// 添加用户消息
- (void)appendUserMessage:(NSString *)text;
/// 添加 AI 消息(支持打字机效果)
- (void)appendAssistantMessage:(NSString *)text
audioId:(nullable NSString *)audioId;
/// 更新聊天列表底部 inset
- (void)updateChatViewBottomInset:(CGFloat)bottomInset;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,704 @@
//
// KBPersonaChatCell.m
// keyBoard
//
// Created by Kiro on 2026/1/26.
//
#import "KBPersonaChatCell.h"
#import "KBAiChatMessage.h"
#import "KBChatHistoryPageModel.h"
#import "AiVM.h"
#import "KBImagePositionButton.h"
#import "KBAICommentView.h"
#import "KBAIChatMessageCacheManager.h"
#import <Masonry/Masonry.h>
#import <SDWebImage/SDWebImage.h>
#import <LSTPopView/LSTPopView.h>
///
static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidResetNotification";
@interface KBPersonaChatCell () <KBChatTableViewDelegate>
///
@property (nonatomic, strong) UIImageView *backgroundImageView;
///
@property (nonatomic, strong) UIImageView *avatarImageView;
///
@property (nonatomic, strong) UILabel *nameLabel;
///
@property (nonatomic, strong) UILabel *openingLabel;
///
@property (nonatomic, strong) NSMutableArray<KBAiChatMessage *> *messages;
///
@property (nonatomic, assign) BOOL hasLoadedData;
///
@property (nonatomic, assign) BOOL isLoading;
///
@property (nonatomic, assign) NSInteger currentPage;
///
@property (nonatomic, assign) BOOL hasMoreHistory;
/// AiVM
@property (nonatomic, strong) AiVM *aiVM;
///
@property (nonatomic, strong) KBImagePositionButton *commentButton;
///
@property (nonatomic, strong) KBImagePositionButton *likeButton;
///
@property (nonatomic, weak) LSTPopView *popView;
@end
@implementation KBPersonaChatCell
#pragma mark - Lifecycle
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
[self setupUI];
//
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleChatSessionReset:)
name:KBChatSessionDidResetNotification
object:nil];
}
return self;
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
/// Cell
- (void)prepareForReuse {
[super prepareForReuse];
//
[self.chatView stopPlayingAudio];
// hasLoadedData
self.isLoading = NO;
// self.hasLoadedData = NO;
// Cell
}
#pragma mark - 1
- (void)setupUI {
//
[self.contentView addSubview:self.backgroundImageView];
[self.backgroundImageView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self.contentView);
}];
//
UIView *maskView = [[UIView alloc] init];
maskView.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.3];
[self.contentView addSubview:maskView];
[maskView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self.contentView);
}];
//
[self.contentView addSubview:self.openingLabel];
[self.openingLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.contentView).offset(KB_NAV_TOTAL_HEIGHT);
make.left.equalTo(self.contentView).offset(40);
make.right.equalTo(self.contentView).offset(-40);
}];
//
[self.contentView addSubview:self.avatarImageView];
[self.avatarImageView mas_makeConstraints:^(MASConstraintMaker *make) {
make.bottom.equalTo(self.contentView).offset(-KB_TABBAR_HEIGHT - 50 - 20);
make.left.equalTo(self.contentView).offset(20);
make.size.mas_equalTo(CGSizeMake(54, 54));
}];
//
[self.contentView addSubview:self.nameLabel];
[self.nameLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.avatarImageView.mas_right).offset(5);
make.centerY.equalTo(self.avatarImageView);
}];
//
[self.contentView addSubview:self.commentButton];
[self.commentButton mas_makeConstraints:^(MASConstraintMaker *make) {
make.right.equalTo(self.contentView).offset(-20);
make.centerY.equalTo(self.avatarImageView);
make.width.mas_equalTo(40);
make.height.mas_equalTo(50);
}];
// 20px
[self.contentView addSubview:self.likeButton];
[self.likeButton mas_makeConstraints:^(MASConstraintMaker *make) {
make.right.equalTo(self.commentButton.mas_left).offset(-20);
make.centerY.equalTo(self.avatarImageView);
make.width.mas_equalTo(40);
make.height.mas_equalTo(50);
}];
//
[self.contentView addSubview:self.chatView];
[self.chatView mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.contentView).offset(KB_NAV_TOTAL_HEIGHT);
make.left.right.equalTo(self.contentView);
make.bottom.equalTo(self.avatarImageView.mas_top).offset(-10);
}];
}
#pragma mark - Setter
- (void)setPersona:(KBPersonaModel *)persona {
_persona = persona;
//
self.isLoading = NO;
self.currentPage = 1;
self.hasMoreHistory = YES;
//
// NSArray *cachedMessages = [[KBAIChatMessageCacheManager shared] messagesForCompanionId:persona.personaId];
// if (cachedMessages.count > 0) {
// self.messages = [cachedMessages mutableCopy];
// self.hasLoadedData = YES;
// NSLog(@"[Cell] ✅ 从缓存加载personaId=%ld, 消息数=%ld", (long)persona.personaId, (long)cachedMessages.count);
// } else {
self.messages = [NSMutableArray array];
self.hasLoadedData = NO;
NSLog(@"[Cell] ⚠️ 缓存已禁用personaId=%ld, 需要请求数据", (long)persona.personaId);
// }
// UI
[self.backgroundImageView sd_setImageWithURL:[NSURL URLWithString:persona.coverImageUrl]
placeholderImage:[UIImage imageNamed:@"placeholder_bg"]];
[self.avatarImageView sd_setImageWithURL:[NSURL URLWithString:persona.avatarUrl]
placeholderImage:[UIImage imageNamed:@"placeholder_avatar"]];
self.nameLabel.text = persona.name;
self.openingLabel.text = persona.shortDesc.length > 0 ? persona.shortDesc : persona.introText;
//
[self.chatView stopPlayingAudio];
//
[self ensureOpeningMessageAtTop];
NSLog(@"[KBPersonaChatCell] ========== setPersona 调试 ==========");
NSLog(@"[KBPersonaChatCell] personaId: %ld", (long)persona.personaId);
NSLog(@"[KBPersonaChatCell] messages.count: %ld", (long)self.messages.count);
NSLog(@"[KBPersonaChatCell] chatView.frame: %@", NSStringFromCGRect(self.chatView.frame));
NSLog(@"[KBPersonaChatCell] contentView.frame: %@", NSStringFromCGRect(self.contentView.frame));
//
if (self.messages.count > 0) {
//
[[KBAIChatMessageCacheManager shared] saveMessages:self.messages
forCompanionId:persona.personaId];
[self.chatView reloadWithMessages:self.messages
keepOffset:NO
scrollToBottom:YES];
} else {
[self.chatView clearMessages];
}
NSLog(@"[KBPersonaChatCell] ========== setPersona 结束 ==========");
[self.commentButton setTitle:persona.commentCount forState:UIControlStateNormal];
[self.likeButton setTitle:persona.likeCount forState:UIControlStateNormal];
self.likeButton.selected = persona.liked;
}
#pragma mark - 2
- (void)preloadDataIfNeeded {
if (self.hasLoadedData || self.isLoading) {
return;
}
[self loadChatHistory];
}
- (void)loadChatHistory {
if (self.isLoading || !self.hasMoreHistory) {
[self.chatView endLoadMoreWithHasMoreData:self.hasMoreHistory];
return;
}
self.isLoading = YES;
if (self.currentPage == 1) {
[self.chatView resetNoMoreData];
}
// 使 persona.personaId companionId
NSInteger companionId = self.persona.personaId;
__weak typeof(self) weakSelf = self;
[self.aiVM fetchChatHistoryWithCompanionId:companionId
pageNum:self.currentPage
pageSize:20
completion:^(KBChatHistoryPageModel *pageModel, NSError *error) {
weakSelf.isLoading = NO;
if (error) {
NSLog(@"[KBPersonaChatCell] 加载聊天记录失败:%@", error.localizedDescription);
[weakSelf.chatView endLoadMoreWithHasMoreData:weakSelf.hasMoreHistory];
//
if (weakSelf.currentPage == 1 && weakSelf.persona.introText.length > 0) {
[weakSelf showOpeningMessage];
}
return;
}
weakSelf.hasLoadedData = YES;
weakSelf.hasMoreHistory = pageModel.hasMore;
// KBAiChatMessage
NSMutableArray *newMessages = [NSMutableArray array];
for (KBChatHistoryModel *item in pageModel.records) {
KBAiChatMessage *message;
// sender
// sender = 1:
// sender = 2: AI
if (item.sender == KBChatSenderUser) {
//
message = [KBAiChatMessage userMessageWithText:item.content];
} else if (item.sender == KBChatSenderAssistant) {
// AI
message = [KBAiChatMessage assistantMessageWithText:item.content];
} else {
// AI
NSLog(@"[KBPersonaChatCell] 未知的 sender 类型:%ld", (long)item.sender);
message = [KBAiChatMessage assistantMessageWithText:item.content];
}
message.isComplete = YES;
message.needsTypewriterEffect = NO;
[newMessages addObject:message];
}
//
// dispatch_async currentPage
NSInteger loadedPage = weakSelf.currentPage;
if (loadedPage == 1) {
//
weakSelf.messages = newMessages;
[weakSelf ensureOpeningMessageAtTop];
} else {
//
[weakSelf ensureOpeningMessageAtTop];
if (newMessages.count > 0) {
NSUInteger insertIndex = [weakSelf hasOpeningMessageAtTop] ? 1 : 0;
NSIndexSet *indexSet = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(insertIndex, newMessages.count)];
[weakSelf.messages insertObjects:newMessages atIndexes:indexSet];
}
}
// UI
dispatch_async(dispatch_get_main_queue(), ^{
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) {
NSLog(@"[KBPersonaChatCell] ⚠️ strongSelf 为空,跳过刷新");
return;
}
// 使 currentPage
BOOL keepOffset = (loadedPage != 1);
BOOL scrollToBottom = (loadedPage == 1);
NSLog(@"[KBPersonaChatCell] 刷新 UI - loadedPage: %ld, keepOffset: %d, scrollToBottom: %d",
(long)loadedPage, keepOffset, scrollToBottom);
[strongSelf.chatView reloadWithMessages:strongSelf.messages
keepOffset:keepOffset
scrollToBottom:scrollToBottom];
[strongSelf.chatView endLoadMoreWithHasMoreData:strongSelf.hasMoreHistory];
//
[[KBAIChatMessageCacheManager shared] saveMessages:strongSelf.messages
forCompanionId:companionId];
});
weakSelf.currentPage++;
NSLog(@"[KBPersonaChatCell] 加载成功:第 %ld 页,%ld 条消息,还有更多:%@",
(long)weakSelf.currentPage - 1,
(long)newMessages.count,
pageModel.hasMore ? @"是" : @"否");
}];
}
- (void)loadMoreHistory {
if (!self.hasMoreHistory || self.isLoading) {
[self.chatView endLoadMoreWithHasMoreData:self.hasMoreHistory];
return;
}
self.currentPage++;
[self loadChatHistory];
}
- (void)showOpeningMessage {
//
[self ensureOpeningMessageAtTop];
dispatch_async(dispatch_get_main_queue(), ^{
[self.chatView reloadWithMessages:self.messages
keepOffset:NO
scrollToBottom:YES];
});
}
- (BOOL)hasOpeningMessageAtTop {
if (self.messages.count == 0) {
return NO;
}
return [self isOpeningMessage:self.messages.firstObject];
}
- (BOOL)isOpeningMessage:(KBAiChatMessage *)message {
if (!message) {
return NO;
}
NSString *introText = self.persona.introText ?: @"";
if (introText.length == 0) {
return NO;
}
return (message.type == KBAiChatMessageTypeAssistant) && [message.text isEqualToString:introText];
}
- (void)ensureOpeningMessageAtTop {
NSString *introText = self.persona.introText ?: @"";
if (introText.length == 0) {
return;
}
if (!self.messages) {
self.messages = [NSMutableArray array];
}
if ([self hasOpeningMessageAtTop]) {
return;
}
KBAiChatMessage *openingMsg = [KBAiChatMessage assistantMessageWithText:introText];
openingMsg.isComplete = YES;
openingMsg.needsTypewriterEffect = NO;
[self.messages insertObject:openingMsg atIndex:0];
}
#pragma mark -
///
- (void)handleChatSessionReset:(NSNotification *)notification {
NSNumber *companionIdObj = notification.userInfo[@"companionId"];
if (!companionIdObj) {
return;
}
NSInteger companionId = [companionIdObj integerValue];
//
if (self.persona && self.persona.personaId == companionId) {
NSLog(@"[KBPersonaChatCell] 收到聊天重置通知companionId=%ld, 清空聊天记录", (long)companionId);
//
self.messages = [NSMutableArray array];
self.hasLoadedData = NO;
self.currentPage = 1;
self.hasMoreHistory = YES;
//
[self.chatView clearMessages];
//
[self showOpeningMessage];
}
}
#pragma mark - 3
- (void)appendUserMessage:(NSString *)text {
if (text.length == 0) {
return;
}
if (!self.messages) {
self.messages = [NSMutableArray array];
}
[self ensureOpeningMessageAtTop];
KBAiChatMessage *message = [KBAiChatMessage userMessageWithText:text];
[self.messages addObject:message];
[self.chatView addMessage:message autoScroll:YES];
}
- (void)markLastUserMessageLoadingComplete {
// [self.chatView markLastUserMessageLoadingComplete];
}
- (void)appendAssistantMessage:(NSString *)text
audioId:(NSString *)audioId {
if (text.length == 0) {
return;
}
if (!self.messages) {
self.messages = [NSMutableArray array];
}
[self ensureOpeningMessageAtTop];
KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text
audioId:audioId];
message.needsTypewriterEffect = YES;
[self.messages addObject:message];
[self.chatView addMessage:message autoScroll:YES];
}
- (void)updateChatViewBottomInset:(CGFloat)bottomInset {
[self.chatView updateContentBottomInset:bottomInset];
}
#pragma mark - KBChatTableViewDelegate
- (void)chatTableViewDidScroll:(KBChatTableView *)chatView
scrollView:(UIScrollView *)scrollView {
CGFloat offsetY = scrollView.contentOffset.y;
//
if (offsetY <= -50 && !self.isLoading) {
[self loadMoreHistory];
}
}
- (void)chatTableViewDidTriggerLoadMore:(KBChatTableView *)chatView {
[self loadMoreHistory];
}
#pragma mark - Lazy Load
- (UIImageView *)backgroundImageView {
if (!_backgroundImageView) {
_backgroundImageView = [[UIImageView alloc] init];
_backgroundImageView.contentMode = UIViewContentModeScaleAspectFill;
_backgroundImageView.clipsToBounds = YES;
}
return _backgroundImageView;
}
- (UIImageView *)avatarImageView {
if (!_avatarImageView) {
_avatarImageView = [[UIImageView alloc] init];
_avatarImageView.contentMode = UIViewContentModeScaleAspectFill;
_avatarImageView.layer.cornerRadius = 27;
_avatarImageView.layer.borderWidth = 3;
_avatarImageView.layer.borderColor = [UIColor whiteColor].CGColor;
_avatarImageView.clipsToBounds = YES;
}
return _avatarImageView;
}
- (UILabel *)nameLabel {
if (!_nameLabel) {
_nameLabel = [[UILabel alloc] init];
_nameLabel.font = [UIFont boldSystemFontOfSize:12];
_nameLabel.textColor = [UIColor whiteColor];
_nameLabel.textAlignment = NSTextAlignmentCenter;
}
return _nameLabel;
}
- (UILabel *)openingLabel {
if (!_openingLabel) {
_openingLabel = [[UILabel alloc] init];
_openingLabel.font = [UIFont systemFontOfSize:14];
_openingLabel.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.9];
_openingLabel.textAlignment = NSTextAlignmentCenter;
_openingLabel.numberOfLines = 2;
}
return _openingLabel;
}
- (KBChatTableView *)chatView {
if (!_chatView) {
_chatView = [[KBChatTableView alloc] init];
_chatView.backgroundColor = [UIColor clearColor];
_chatView.delegate = self;
}
return _chatView;
}
- (KBImagePositionButton *)commentButton {
if (!_commentButton) {
//
_commentButton = [[KBImagePositionButton alloc] initWithImagePosition:KBImagePositionTop spacing:4];
//
_commentButton.titleLabel.font = [UIFont systemFontOfSize:10];
//
[_commentButton setImage:[UIImage imageNamed:@"ai_comment_icon"] forState:UIControlStateNormal];
//
[_commentButton setTitle:@"0" forState:UIControlStateNormal];
[_commentButton setTitleColor:[[UIColor whiteColor] colorWithAlphaComponent:0.8] forState:UIControlStateNormal];
//
[_commentButton addTarget:self action:@selector(commentButtonTapped:) forControlEvents:UIControlEventTouchUpInside];
}
return _commentButton;
}
- (KBImagePositionButton *)likeButton {
if (!_likeButton) {
//
_likeButton = [[KBImagePositionButton alloc] initWithImagePosition:KBImagePositionTop spacing:4];
//
_likeButton.titleLabel.font = [UIFont systemFontOfSize:10];
//
[_likeButton setImage:[UIImage imageNamed:@"ai_live_icon"] forState:UIControlStateNormal];
[_likeButton setImage:[UIImage imageNamed:@"ai_livesel_icon"] forState:UIControlStateSelected];
//
[_likeButton setTitle:@"0" forState:UIControlStateNormal];
[_likeButton setTitleColor:[[UIColor whiteColor] colorWithAlphaComponent:0.8] forState:UIControlStateNormal];
//
[_likeButton addTarget:self action:@selector(likeButtonTapped:) forControlEvents:UIControlEventTouchUpInside];
}
return _likeButton;
}
#pragma mark - Button Actions
- (void)commentButtonTapped:(KBImagePositionButton *)sender {
NSLog(@"[KBPersonaChatCell] 评论按钮点击");
//
[self showComment];
}
- (void)likeButtonTapped:(KBImagePositionButton *)sender {
NSLog(@"[KBPersonaChatCell] 喜欢按钮点击");
NSInteger personaId = self.persona.personaId;
//
sender.enabled = NO;
__weak typeof(self) weakSelf = self;
[self.aiVM likeCompanionWithCompanionId:personaId completion:^(KBCommentLikeResponse * _Nullable response, NSError * _Nullable error) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
//
sender.enabled = YES;
if (error) {
NSLog(@"[KBPersonaChatCell] 点赞失败:%@", error.localizedDescription);
// TODO:
return;
}
if (response && response.code == 0) {
//
NSInteger currentLikeCount = [strongSelf.persona.likeCount integerValue];
// response.data: true false
if (response.data) {
// 1
currentLikeCount += 1;
sender.selected = YES;
NSLog(@"[KBPersonaChatCell] 点赞成功,新喜欢数:%ld", (long)currentLikeCount);
} else {
// 10
currentLikeCount = MAX(0, currentLikeCount - 1);
sender.selected = NO;
NSLog(@"[KBPersonaChatCell] 取消点赞成功,新喜欢数:%ld", (long)currentLikeCount);
}
//
strongSelf.persona.likeCount = [NSString stringWithFormat:@"%ld", (long)currentLikeCount];
strongSelf.persona.liked = sender.selected;
//
[sender setTitle:strongSelf.persona.likeCount forState:UIControlStateNormal];
} else {
NSLog(@"[KBPersonaChatCell] 点赞失败:%@", response.message ?: @"未知错误");
// TODO:
}
});
}];
}
#pragma mark - Comment View
- (void)showComment {
//
if (self.popView) {
[self.popView dismiss];
}
CGFloat customViewHeight = KB_SCREEN_HEIGHT * 0.8;
KBAICommentView *customView = [[KBAICommentView alloc]
initWithFrame:CGRectMake(0, 0, KB_SCREEN_WIDTH, customViewHeight)];
// ID
customView.companionId = self.persona.personaId;
//
[customView loadComments];
LSTPopView *popView = [LSTPopView initWithCustomView:customView
parentView:nil
popStyle:LSTPopStyleSmoothFromBottom
dismissStyle:LSTDismissStyleSmoothToBottom];
customView.popView = popView;
popView.bgColor = [UIColor clearColor];
self.popView = popView;
popView.priority = 1000;
popView.isAvoidKeyboard = NO;
popView.hemStyle = LSTHemStyleBottom;
popView.dragStyle = LSTDragStyleY_Positive;
popView.dragDistance = customViewHeight * 0.5;
popView.sweepStyle = LSTSweepStyleY_Positive;
popView.swipeVelocity = 1600;
popView.sweepDismissStyle = LSTSweepDismissStyleSmooth;
[popView pop];
}
- (AiVM *)aiVM{
if (!_aiVM) {
_aiVM = [[AiVM alloc] init];
}
return _aiVM;
}
@end