diff --git a/keyBoard/Class/AiTalk/M/KBAIReplyModel.h b/keyBoard/Class/AiTalk/M/KBAIReplyModel.h index e28328b..84d56ea 100644 --- a/keyBoard/Class/AiTalk/M/KBAIReplyModel.h +++ b/keyBoard/Class/AiTalk/M/KBAIReplyModel.h @@ -34,7 +34,7 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, assign) NSInteger likeCount; /// 是否已点赞 -@property(nonatomic, assign) BOOL isLiked; +@property(nonatomic, assign) BOOL liked; /// 创建时间(时间戳) @property(nonatomic, assign) NSTimeInterval createTime; diff --git a/keyBoard/Class/AiTalk/M/KBAIReplyModel.m b/keyBoard/Class/AiTalk/M/KBAIReplyModel.m index be29c26..7796f6d 100644 --- a/keyBoard/Class/AiTalk/M/KBAIReplyModel.m +++ b/keyBoard/Class/AiTalk/M/KBAIReplyModel.m @@ -21,10 +21,6 @@ }; } -- (void)setLiked:(NSInteger)liked { - // 后端返回的是 NSInteger (0/1),转换为 BOOL - _isLiked = (liked == 1); -} - (void)setCreatedAt:(NSString *)createdAt { // 后端返回的是字符串时间,转换为时间戳 diff --git a/keyBoard/Class/AiTalk/M/KBAiChatMessage.h b/keyBoard/Class/AiTalk/M/KBAiChatMessage.h index 3312f16..c46e7f9 100644 --- a/keyBoard/Class/AiTalk/M/KBAiChatMessage.h +++ b/keyBoard/Class/AiTalk/M/KBAiChatMessage.h @@ -43,8 +43,14 @@ typedef NS_ENUM(NSInteger, KBAiChatMessageType) { /// 是否需要打字机效果(只有当前正在输入的消息才需要) @property (nonatomic, assign) BOOL needsTypewriterEffect; +/// 是否处于加载状态(用户消息) +@property (nonatomic, assign) BOOL isLoading; + #pragma mark - 便捷构造方法 +/// 创建加载中的用户消息 ++ (instancetype)loadingUserMessage; + /// 创建用户消息 + (instancetype)userMessageWithText:(NSString *)text; diff --git a/keyBoard/Class/AiTalk/M/KBAiChatMessage.m b/keyBoard/Class/AiTalk/M/KBAiChatMessage.m index bbcb4a5..fd1624e 100644 --- a/keyBoard/Class/AiTalk/M/KBAiChatMessage.m +++ b/keyBoard/Class/AiTalk/M/KBAiChatMessage.m @@ -9,12 +9,23 @@ @implementation KBAiChatMessage ++ (instancetype)loadingUserMessage { + KBAiChatMessage *message = [[KBAiChatMessage alloc] init]; + message.type = KBAiChatMessageTypeUser; + message.text = @""; + message.timestamp = [NSDate date]; + message.isComplete = NO; + message.isLoading = YES; + return message; +} + + (instancetype)userMessageWithText:(NSString *)text { KBAiChatMessage *message = [[KBAiChatMessage alloc] init]; message.type = KBAiChatMessageTypeUser; message.text = text; message.timestamp = [NSDate date]; message.isComplete = YES; + message.isLoading = NO; return message; } @@ -28,6 +39,7 @@ message.audioDuration = duration; message.audioData = audioData; message.isComplete = NO; + message.isLoading = NO; return message; } @@ -41,6 +53,7 @@ message.audioDuration = 0; message.audioData = nil; message.isComplete = NO; + message.isLoading = NO; return message; } @@ -53,6 +66,7 @@ message.audioDuration = 0; message.audioData = nil; message.isComplete = NO; + message.isLoading = NO; return message; } @@ -61,6 +75,7 @@ message.type = KBAiChatMessageTypeTime; message.timestamp = timestamp; message.isComplete = YES; + message.isLoading = NO; return message; } diff --git a/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.h b/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.h index 2e4f6d4..f3c70f0 100644 --- a/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.h +++ b/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.h @@ -27,6 +27,12 @@ NS_ASSUME_NONNULL_BEGIN /// 添加用户消息 - (void)addUserMessage:(NSString *)text; +/// 添加加载中的用户消息 +- (void)addLoadingUserMessage; + +/// 更新最后一条用户消息 +- (void)updateLastUserMessage:(NSString *)text; + /// 添加 AI 消息(带语音) - (void)addAssistantMessage:(NSString *)text audioDuration:(NSTimeInterval)duration @@ -42,6 +48,9 @@ NS_ASSUME_NONNULL_BEGIN /// 标记最后一条 AI 消息完成 - (void)markLastAssistantMessageComplete; +/// 标记最后一条用户消息结束加载 +- (void)markLastUserMessageLoadingComplete; + /// 清空所有消息 - (void)clearMessages; diff --git a/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.m b/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.m index 6a3a0e4..c70612a 100644 --- a/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.m +++ b/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.m @@ -132,6 +132,27 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟 [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 { @@ -198,9 +219,12 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟 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; } } @@ -227,14 +251,9 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟 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; + offsetY = MAX(0, offsetY); NSLog(@"[KBChatTableView] scrollToBottom - contentHeight: %.2f, tableViewHeight: %.2f, bottomInset: %.2f, offsetY: %.2f", contentHeight, tableViewHeight, bottomInset, offsetY); @@ -312,6 +331,12 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟 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]; + }); }); } } diff --git a/keyBoard/Class/AiTalk/V/Chat/KBChatUserMessageCell.m b/keyBoard/Class/AiTalk/V/Chat/KBChatUserMessageCell.m index 8b6b0ac..b41c099 100644 --- a/keyBoard/Class/AiTalk/V/Chat/KBChatUserMessageCell.m +++ b/keyBoard/Class/AiTalk/V/Chat/KBChatUserMessageCell.m @@ -13,6 +13,7 @@ @property (nonatomic, strong) UIView *bubbleView; @property (nonatomic, strong) UILabel *messageLabel; +@property (nonatomic, strong) UIActivityIndicatorView *loadingIndicator; @end @@ -44,12 +45,20 @@ self.messageLabel.textColor = [UIColor blackColor]; [self.bubbleView addSubview:self.messageLabel]; + // 加载指示器 + self.loadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhite]; + self.loadingIndicator.hidesWhenStopped = YES; + [self.contentView addSubview:self.loadingIndicator]; + // 布局约束 [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); + // 最小高度约束,确保 loading 时气泡不消失 + make.height.greaterThanOrEqualTo(@40); + make.width.greaterThanOrEqualTo(@50); }]; [self.messageLabel mas_makeConstraints:^(MASConstraintMaker *make) { @@ -58,10 +67,24 @@ make.left.equalTo(self.bubbleView).offset(12); make.right.equalTo(self.bubbleView).offset(-12); }]; + + [self.loadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) { + make.centerY.equalTo(self.contentView); + make.right.equalTo(self.contentView).offset(-16); + }]; } - (void)configureWithMessage:(KBAiChatMessage *)message { self.messageLabel.text = message.text; + + if (message.isLoading) { + self.bubbleView.hidden = YES; + [self.loadingIndicator startAnimating]; + } else { + self.bubbleView.hidden = NO; + self.messageLabel.hidden = NO; + [self.loadingIndicator stopAnimating]; + } } @end diff --git a/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.h b/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.h index fa9956a..7729eef 100644 --- a/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.h +++ b/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.h @@ -24,6 +24,15 @@ NS_ASSUME_NONNULL_BEGIN /// 添加用户消息 - (void)appendUserMessage:(NSString *)text; +/// 标记最后一条用户消息结束加载 +- (void)markLastUserMessageLoadingComplete; + +/// 添加加载中的用户消息 +- (void)appendLoadingUserMessage; + +/// 更新最后一条用户消息 +- (void)updateLastUserMessage:(NSString *)text; + /// 添加 AI 消息(支持打字机效果) - (void)appendAssistantMessage:(NSString *)text audioId:(nullable NSString *)audioId; diff --git a/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.m b/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.m index 3decfa7..da54809 100644 --- a/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.m +++ b/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.m @@ -453,8 +453,43 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe [self.chatView addMessage:message autoScroll:YES]; } +- (void)appendLoadingUserMessage { + if (!self.messages) { + self.messages = [NSMutableArray array]; + } + + [self ensureOpeningMessageAtTop]; + KBAiChatMessage *message = [KBAiChatMessage loadingUserMessage]; + [self.messages addObject:message]; + [self.chatView addMessage:message autoScroll:YES]; +} + +- (void)updateLastUserMessage:(NSString *)text { + [self.chatView updateLastUserMessage: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; + break; + } + } +} + - (void)markLastUserMessageLoadingComplete { -// [self.chatView markLastUserMessageLoadingComplete]; + [self.chatView markLastUserMessageLoadingComplete]; + + // 同步更新数据源 + for (NSInteger i = self.messages.count - 1; i >= 0; i--) { + KBAiChatMessage *message = self.messages[i]; + if (message.type == KBAiChatMessageTypeUser && message.isLoading) { + message.isLoading = NO; + break; + } + } } - (void)appendAssistantMessage:(NSString *)text diff --git a/keyBoard/Class/AiTalk/V/Comment/KBAICommentView.m b/keyBoard/Class/AiTalk/V/Comment/KBAICommentView.m index 91b8048..c918446 100644 --- a/keyBoard/Class/AiTalk/V/Comment/KBAICommentView.m +++ b/keyBoard/Class/AiTalk/V/Comment/KBAICommentView.m @@ -424,12 +424,12 @@ static NSString *const kCommentFooterIdentifier = @"CommentFooter"; // 更新模型状态 if (isNowLiked) { // 点赞成功:喜欢数+1 - reply.isLiked = YES; + reply.liked = YES; reply.likeCount = MAX(0, reply.likeCount + 1); NSLog(@"[KBAICommentView] 二级评论点赞成功,ID: %ld", (long)commentId); } else { // 取消点赞成功:喜欢数-1 - reply.isLiked = NO; + reply.liked = NO; reply.likeCount = MAX(0, reply.likeCount - 1); NSLog(@"[KBAICommentView] 二级评论取消点赞成功,ID: %ld", (long)commentId); } diff --git a/keyBoard/Class/AiTalk/V/Comment/KBAIReplyCell.m b/keyBoard/Class/AiTalk/V/Comment/KBAIReplyCell.m index 7e25982..14024ea 100644 --- a/keyBoard/Class/AiTalk/V/Comment/KBAIReplyCell.m +++ b/keyBoard/Class/AiTalk/V/Comment/KBAIReplyCell.m @@ -133,13 +133,14 @@ self.timeLabel.text = [reply formattedTime]; // 点赞 - NSString *likeText = reply.likeCount > 0 ? [self formatLikeCount:reply.likeCount] : @""; - self.likeButton.textLabel.text = likeText; + NSString *likeText = + reply.likeCount > 0 ? [self formatLikeCount:reply.likeCount] : @"赞"; + self.likeButton.textLabel.text = likeText; - UIImage *likeImage = reply.isLiked ? [UIImage systemImageNamed:@"heart.fill"] - : [UIImage systemImageNamed:@"heart"]; - self.likeButton.iconView.image = likeImage; - self.likeButton.iconView.tintColor = reply.isLiked ? [UIColor systemRedColor] : [UIColor grayColor]; + UIImage *likeImage = reply.liked + ? [UIImage imageNamed:@"comment_sel_icon"] + : [UIImage imageNamed:@"comment_nor_icon"]; + self.likeButton.iconView.image = likeImage; } - (NSString *)formatLikeCount:(NSInteger)count { @@ -225,10 +226,10 @@ _likeButton = [[KBTopImageButton alloc] init]; _likeButton.iconSize = CGSizeMake(16, 16); _likeButton.spacing = 2; - _likeButton.iconView.image = [UIImage systemImageNamed:@"heart"]; + _likeButton.iconView.image = [UIImage imageNamed:@"comment_nor_icon"]; _likeButton.iconView.tintColor = [UIColor grayColor]; - _likeButton.textLabel.font = [UIFont systemFontOfSize:10]; - _likeButton.textLabel.textColor = [UIColor grayColor]; + _likeButton.textLabel.font = [UIFont systemFontOfSize:10]; + _likeButton.textLabel.textColor = [UIColor colorWithHex:0xC5BEB4]; [_likeButton addTarget:self action:@selector(likeButtonTapped) forControlEvents:UIControlEventTouchUpInside]; diff --git a/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m b/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m index 9a0ed25..c4e9ee7 100644 --- a/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m +++ b/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m @@ -647,6 +647,12 @@ unsigned long long fileSize = [attributes[NSFileSize] unsignedLongLongValue]; NSLog(@"[KBAIHomeVC] 录音完成,时长: %.2fs,大小: %llu bytes", duration, fileSize); + // 显示 loading 气泡 + KBPersonaChatCell *currentCell = [self currentPersonaCell]; + if (currentCell) { + [currentCell appendLoadingUserMessage]; + } + __weak typeof(self) weakSelf = self; [self.aiVM transcribeAudioFileAtURL:fileURL completion:^(KBAiSpeechTranscribeResponse * _Nullable response, NSError * _Nullable error) { @@ -656,9 +662,14 @@ } dispatch_async(dispatch_get_main_queue(), ^{ + KBPersonaChatCell *cell = [strongSelf currentPersonaCell]; + if (error) { NSLog(@"[KBAIHomeVC] 语音转文字失败:%@", error.localizedDescription); [KBHUD showError:KBLocalized(@"语音转文字失败,请重试")]; + if (cell) { + [cell updateLastUserMessage:KBLocalized(@"语音识别失败")]; + } return; } @@ -666,10 +677,19 @@ if (transcript.length == 0) { NSLog(@"[KBAIHomeVC] 语音转文字结果为空"); [KBHUD showError:KBLocalized(@"未识别到语音内容")]; + if (cell) { + [cell updateLastUserMessage:KBLocalized(@"未识别到语音")]; + } return; } - [strongSelf handleTranscribedText:transcript]; + // 更新 loading 气泡为实际文本 + if (cell) { + [cell updateLastUserMessage:transcript]; + } + + // 发送聊天请求(不追加消息,因为已经更新了) + [strongSelf handleTranscribedText:transcript appendToUI:NO]; }); }]; } @@ -687,6 +707,10 @@ #pragma mark - Private - (void)handleTranscribedText:(NSString *)text { + [self handleTranscribedText:text appendToUI:YES]; +} + +- (void)handleTranscribedText:(NSString *)text appendToUI:(BOOL)appendToUI { if (text.length == 0) { return; } @@ -699,7 +723,7 @@ } KBPersonaChatCell *currentCell = [self currentPersonaCell]; - if (currentCell) { + if (currentCell && appendToUI) { [currentCell appendUserMessage:text]; } @@ -722,6 +746,12 @@ strongSelf.isWaitingForAIResponse = NO; strongSelf.collectionView.scrollEnabled = YES; NSLog(@"[KBAIHomeVC] AI 回复完成,恢复 CollectionView 滚动"); + + // 收到响应后,隐藏用户消息的 loading + KBPersonaChatCell *cell = [strongSelf currentPersonaCell]; + if (cell) { + [cell markLastUserMessageLoadingComplete]; + } if (response.code == 50030) { NSString *message = response.message ?: @""; @@ -745,7 +775,6 @@ return; } - KBPersonaChatCell *cell = [strongSelf currentPersonaCell]; if (cell) { [cell appendAssistantMessage:aiResponse audioId:audioId]; }