Files
keyboard/CustomKeyboard/View/Chat/KBChatPanelView.m
2026-01-30 13:46:08 +08:00

348 lines
13 KiB
Objective-C
Raw Permalink 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.

//
// KBChatPanelView.m
// CustomKeyboard
//
#import "KBChatPanelView.h"
#import "KBChatMessage.h"
#import "KBChatUserCell.h"
#import "KBChatAssistantCell.h"
#import "Masonry.h"
static NSString * const kUserCellIdentifier = @"KBChatUserCell";
static NSString * const kAssistantCellIdentifier = @"KBChatAssistantCell";
static const NSUInteger kKBChatMessageLimit = 10;
@interface KBChatPanelView () <UITableViewDataSource, UITableViewDelegate, KBChatAssistantCellDelegate>
@property (nonatomic, strong) UIView *headerView;
@property (nonatomic, strong) UILabel *titleLabel;
@property (nonatomic, strong) UIButton *closeButton;
@property (nonatomic, strong) UITableView *tableViewInternal;
@property (nonatomic, strong) NSMutableArray<KBChatMessage *> *messages;
@end
@implementation KBChatPanelView
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
self.backgroundColor = [UIColor clearColor];
self.messages = [NSMutableArray array];
[self addSubview:self.headerView];
[self addSubview:self.tableViewInternal];
[self.headerView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self);
make.top.equalTo(self.mas_top);
make.height.mas_equalTo(KBFit(36.0f));
}];
[self.tableViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self);
make.top.equalTo(self.headerView.mas_bottom).offset(4);
make.bottom.equalTo(self.mas_bottom).offset(-8);
}];
}
return self;
}
#pragma mark - Public
- (void)kb_reloadWithMessages:(NSArray<KBChatMessage *> *)messages {
NSLog(@"[Panel] ⚠️ kb_reloadWithMessages 被调用,传入 %lu 条消息", (unsigned long)messages.count);
[self.messages removeAllObjects];
if (messages.count > 0) {
[self.messages addObjectsFromArray:messages];
}
[self.tableViewInternal reloadData];
[self kb_scrollToBottom];
}
- (void)kb_addUserMessage:(NSString *)text {
if (text.length == 0) return;
NSLog(@"[Panel] 添加用户消息: %@,当前消息数: %lu", text, (unsigned long)self.messages.count);
KBChatMessage *msg = [KBChatMessage userMessageWithText:text];
[self kb_appendMessage:msg];
NSLog(@"[Panel] 添加后消息数: %lu", (unsigned long)self.messages.count);
}
- (void)kb_addLoadingAssistantMessage {
NSLog(@"[Panel] 添加 loading 消息,当前消息数: %lu", (unsigned long)self.messages.count);
KBChatMessage *msg = [KBChatMessage loadingAssistantMessage];
[self kb_appendMessage:msg];
NSLog(@"[Panel] 添加后消息数: %lu", (unsigned long)self.messages.count);
}
- (void)kb_removeLoadingAssistantMessage {
NSLog(@"[Panel] 移除 loading 消息,当前消息数: %lu", (unsigned long)self.messages.count);
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
KBChatMessage *msg = self.messages[i];
// 只移除 AI 消息outgoing == NO且是 loading 状态的
if (!msg.outgoing && msg.isLoading) {
NSLog(@"[Panel] ✅ 找到 loading 消息,移除索引: %ld", (long)i);
[self.messages removeObjectAtIndex:i];
// 使用 beginUpdates/endUpdates 包裹删除操作
[self.tableViewInternal beginUpdates];
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
[self.tableViewInternal deleteRowsAtIndexPaths:@[indexPath]
withRowAnimation:UITableViewRowAnimationNone];
[self.tableViewInternal endUpdates];
NSLog(@"[Panel] 移除后消息数: %lu", (unsigned long)self.messages.count);
break;
}
}
}
- (void)kb_addAssistantMessage:(NSString *)text audioId:(NSString *)audioId {
NSLog(@"[Panel] ========== kb_addAssistantMessage ==========");
NSLog(@"[Panel] 当前消息数: %lu", (unsigned long)self.messages.count);
// 查找 loading 消息的索引
NSInteger loadingIndex = -1;
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
KBChatMessage *msg = self.messages[i];
if (!msg.outgoing && msg.isLoading) {
loadingIndex = i;
break;
}
}
// 创建 AI 消息
KBChatMessage *msg = [KBChatMessage assistantMessageWithText:text audioId:audioId];
msg.displayName = KBLocalized(@"AI助手");
NSLog(@"[Panel] 创建 AI 消息needsTypewriter: %d", msg.needsTypewriterEffect);
// 使用批量更新,避免界面跳动
[self.tableViewInternal beginUpdates];
if (loadingIndex >= 0) {
// 移除 loading 消息
NSLog(@"[Panel] 移除 loading 索引: %ld", (long)loadingIndex);
[self.messages removeObjectAtIndex:loadingIndex];
NSIndexPath *deleteIndexPath = [NSIndexPath indexPathForRow:loadingIndex inSection:0];
[self.tableViewInternal deleteRowsAtIndexPaths:@[deleteIndexPath]
withRowAnimation:UITableViewRowAnimationNone];
}
// 添加 AI 消息
NSInteger insertIndex = self.messages.count;
[self.messages addObject:msg];
NSLog(@"[Panel] 插入 AI 消息索引: %ld", (long)insertIndex);
NSIndexPath *insertIndexPath = [NSIndexPath indexPathForRow:insertIndex inSection:0];
[self.tableViewInternal insertRowsAtIndexPaths:@[insertIndexPath]
withRowAnimation:UITableViewRowAnimationNone];
[self.tableViewInternal endUpdates];
// 滚动到底部
[self kb_scrollToBottom];
NSLog(@"[Panel] 添加后消息数: %lu", (unsigned long)self.messages.count);
}
- (void)kb_updateLastAssistantMessageWithAudioData:(NSData *)audioData duration:(NSTimeInterval)duration {
NSLog(@"[Panel] 更新音频数据duration: %.2f", duration);
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
KBChatMessage *msg = self.messages[i];
// 只更新 AI 消息outgoing == NO且非 loading 状态的
if (!msg.outgoing && !msg.isLoading) {
msg.audioData = audioData;
msg.audioDuration = duration;
// 不刷新 Cell避免打断打字机效果
if (duration > 0) {
msg.needsTypewriterEffect = NO;
msg.isComplete = YES;
}
NSLog(@"[Panel] ✅ 音频数据已更新");
break;
}
}
}
- (void)kb_scrollToBottom {
if (self.messages.count == 0) return;
NSLog(@"[Panel] 滚动到底部,消息数: %lu", (unsigned long)self.messages.count);
[self.tableViewInternal layoutIfNeeded];
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:self.messages.count - 1 inSection:0];
[self.tableViewInternal scrollToRowAtIndexPath:indexPath
atScrollPosition:UITableViewScrollPositionBottom
animated:NO]; // 改为 NO避免动画导致跳动
}
#pragma mark - Private
- (void)kb_appendMessage:(KBChatMessage *)message {
if (!message) return;
NSInteger oldCount = self.messages.count;
[self.messages addObject:message];
NSLog(@"[Panel] kb_appendMessage: oldCount=%ld, newCount=%lu", (long)oldCount, (unsigned long)self.messages.count);
// 限制消息数量
if (self.messages.count > kKBChatMessageLimit) {
NSUInteger overflow = self.messages.count - kKBChatMessageLimit;
[self.messages removeObjectsInRange:NSMakeRange(0, overflow)];
NSLog(@"[Panel] 消息超限reloadData");
[self.tableViewInternal reloadData];
} else {
NSLog(@"[Panel] 插入新行: %ld", (long)oldCount);
[self.tableViewInternal beginUpdates];
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:oldCount inSection:0];
[self.tableViewInternal insertRowsAtIndexPaths:@[indexPath]
withRowAnimation:UITableViewRowAnimationNone];
[self.tableViewInternal endUpdates];
}
// 直接滚动,不用 dispatch_async
[self kb_scrollToBottom];
}
#pragma mark - Actions
- (void)kb_onTapClose {
if ([self.delegate respondsToSelector:@selector(chatPanelViewDidTapClose:)]) {
[self.delegate chatPanelViewDidTapClose:self];
}
}
#pragma mark - UITableViewDataSource
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return self.messages.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
if (indexPath.row >= self.messages.count) {
NSLog(@"[Panel] ❌ cellForRow 索引越界: %ld >= %lu", (long)indexPath.row, (unsigned long)self.messages.count);
return [[UITableViewCell alloc] init];
}
KBChatMessage *msg = self.messages[indexPath.row];
NSLog(@"[Panel] cellForRow[%ld]: outgoing=%d, isLoading=%d", (long)indexPath.row, msg.outgoing, msg.isLoading);
if (msg.outgoing) {
// 用户消息(右侧)
KBChatUserCell *cell = [tableView dequeueReusableCellWithIdentifier:kUserCellIdentifier forIndexPath:indexPath];
[cell configureWithMessage:msg];
return cell;
} else {
// AI 消息(左侧)
KBChatAssistantCell *cell = [tableView dequeueReusableCellWithIdentifier:kAssistantCellIdentifier forIndexPath:indexPath];
cell.delegate = self;
[cell configureWithMessage:msg];
return cell;
}
}
#pragma mark - UITableViewDelegate
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
return UITableViewAutomaticDimension;
}
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
return 60.0;
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
if (indexPath.row >= self.messages.count) { return; }
KBChatMessage *msg = self.messages[indexPath.row];
if ([self.delegate respondsToSelector:@selector(chatPanelView:didTapMessage:)]) {
[self.delegate chatPanelView:self didTapMessage:msg];
}
}
#pragma mark - KBChatAssistantCellDelegate
- (void)assistantCell:(KBChatAssistantCell *)cell didTapVoiceButtonForMessage:(KBChatMessage *)message {
if ([self.delegate respondsToSelector:@selector(chatPanelView:didTapVoiceButtonForMessage:)]) {
[self.delegate chatPanelView:self didTapVoiceButtonForMessage:message];
}
}
#pragma mark - Lazy
- (UITableView *)tableViewInternal {
if (!_tableViewInternal) {
_tableViewInternal = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
_tableViewInternal.backgroundColor = [UIColor clearColor];
_tableViewInternal.backgroundView = nil;
_tableViewInternal.separatorStyle = UITableViewCellSeparatorStyleNone;
_tableViewInternal.dataSource = self;
_tableViewInternal.delegate = self;
_tableViewInternal.estimatedRowHeight = 60.0;
_tableViewInternal.rowHeight = UITableViewAutomaticDimension;
// 注册两种 Cell
[_tableViewInternal registerClass:KBChatUserCell.class forCellReuseIdentifier:kUserCellIdentifier];
[_tableViewInternal registerClass:KBChatAssistantCell.class forCellReuseIdentifier:kAssistantCellIdentifier];
if (@available(iOS 11.0, *)) {
_tableViewInternal.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
}
}
return _tableViewInternal;
}
- (UIView *)headerView {
if (!_headerView) {
_headerView = [[UIView alloc] init];
_headerView.backgroundColor = [UIColor clearColor];
[_headerView addSubview:self.titleLabel];
[_headerView addSubview:self.closeButton];
[self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(_headerView.mas_left).offset(12);
make.centerY.equalTo(_headerView);
}];
[self.closeButton mas_makeConstraints:^(MASConstraintMaker *make) {
make.right.equalTo(_headerView.mas_right).offset(-12);
make.centerY.equalTo(_headerView);
make.width.height.mas_equalTo(KBFit(24.0f));
}];
}
return _headerView;
}
- (UILabel *)titleLabel {
if (!_titleLabel) {
_titleLabel = [[UILabel alloc] init];
_titleLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightMedium];
_titleLabel.textColor =
[UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0x1B1F1A]
darkColor:[UIColor whiteColor]];
_titleLabel.text = KBLocalized(@"AI对话");
}
return _titleLabel;
}
- (UIButton *)closeButton {
if (!_closeButton) {
_closeButton = [UIButton buttonWithType:UIButtonTypeCustom];
UIImage *icon = [UIImage imageNamed:@"close_icon"];
[_closeButton setImage:icon forState:UIControlStateNormal];
_closeButton.backgroundColor = [UIColor clearColor];
[_closeButton addTarget:self
action:@selector(kb_onTapClose)
forControlEvents:UIControlEventTouchUpInside];
}
return _closeButton;
}
#pragma mark - Expose
- (UITableView *)tableView { return self.tableViewInternal; }
@end