diff --git a/keyBoard/Class/AiTalk/V/KBVoiceInputBar.h b/keyBoard/Class/AiTalk/V/KBVoiceInputBar.h index d5266f6..9619e8d 100644 --- a/keyBoard/Class/AiTalk/V/KBVoiceInputBar.h +++ b/keyBoard/Class/AiTalk/V/KBVoiceInputBar.h @@ -46,6 +46,9 @@ typedef NS_ENUM(NSInteger, KBVoiceInputBarState) { /// 代理 @property (nonatomic, weak) id delegate; +/// 文本发送回调(文本模式下发送消息时触发) +@property (nonatomic, copy) void (^onTextSend)(NSString *text); + /// 状态文本(显示在按钮上方) @property (nonatomic, copy) NSString *statusText; diff --git a/keyBoard/Class/AiTalk/V/KBVoiceInputBar.m b/keyBoard/Class/AiTalk/V/KBVoiceInputBar.m index c919509..ea40996 100644 --- a/keyBoard/Class/AiTalk/V/KBVoiceInputBar.m +++ b/keyBoard/Class/AiTalk/V/KBVoiceInputBar.m @@ -408,8 +408,10 @@ } - (void)handleTextCenterTap { - self.inputState = KBVoiceInputBarStateText; - [self.hiddenTextField becomeFirstResponder]; + // 文本模式点击时,触发回调让 VC 显示文本输入框 + if (self.onTextSend) { + self.onTextSend(nil); + } } - (void)handleVoiceLongPress:(UILongPressGestureRecognizer *)gesture { diff --git a/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m b/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m index 586248b..8d80e2a 100644 --- a/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m +++ b/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m @@ -20,7 +20,7 @@ #import "KBAIMessageVC.h" #import -@interface KBAIHomeVC () +@interface KBAIHomeVC () /// 人设列表容器 @property (nonatomic, strong) UICollectionView *collectionView; @@ -31,11 +31,24 @@ @property (nonatomic, assign) CGFloat voiceInputBarHeight; @property (nonatomic, assign) CGFloat baseInputBarBottomSpacing; @property (nonatomic, assign) CGFloat currentKeyboardHeight; -/// 仅用于标记“由 KBVoiceInputBar 触发的键盘”是否处于激活态 +/// 仅用于标记"由 KBVoiceInputBar 触发的键盘"是否处于激活态 @property (nonatomic, assign) BOOL voiceInputKeyboardActive; @property (nonatomic, strong) UITapGestureRecognizer *dismissKeyboardTap; @property (nonatomic, weak) LSTPopView *chatLimitPopView; +/// 文本输入容器视图(键盘弹起时显示) +@property (nonatomic, strong) UIView *textInputContainerView; +/// 文本输入框 +@property (nonatomic, strong) UITextView *textInputTextView; +/// 发送按钮 +@property (nonatomic, strong) UIButton *sendButton; +/// 占位符标签 +@property (nonatomic, strong) UILabel *placeholderLabel; +/// 文本输入容器底部约束 +@property (nonatomic, strong) MASConstraint *textInputContainerBottomConstraint; +/// 是否处于文本输入模式 +@property (nonatomic, assign) BOOL isTextInputMode; + /// 底部毛玻璃背景 @property (nonatomic, strong) UIView *bottomBackgroundView; @property (nonatomic, strong) UIVisualEffectView *bottomBlurEffectView; @@ -94,12 +107,16 @@ return nil; } -/// 仅允许 KBVoiceInputBar 触发键盘联动(避免评论输入等场景误触发) +/// 仅允许 KBVoiceInputBar 或文本输入框触发键盘联动 - (BOOL)kb_isKeyboardFromVoiceInputBar { UIView *firstResponder = [self kb_findFirstResponderInView:self.view]; if (!firstResponder) { return NO; } + // 文本输入模式下,textInputTextView 也算 + if (firstResponder == self.textInputTextView) { + return YES; + } return [firstResponder isDescendantOfView:self.voiceInputBar]; } @@ -117,9 +134,12 @@ self.currentIndex = 0; self.preloadedIndexes = [NSMutableSet set]; self.aiVM = [[AiVM alloc] init]; - self.isWaitingForAIResponse = NO; // 初始化状态 + self.isWaitingForAIResponse = NO; + self.isTextInputMode = NO; [self setupUI]; + [self setupTextInputView]; + [self setupVoiceInputBarCallback]; [self setupVoiceToTextManager]; [self setupVoiceRecordManager]; [self setupKeyboardNotifications]; @@ -151,13 +171,12 @@ make.right.equalTo(self.view).offset(-16); make.width.height.mas_equalTo(32); }]; - + // 底部毛玻璃背景 [self.view addSubview:self.bottomBackgroundView]; [self.bottomBackgroundView addSubview:self.bottomBlurEffectView]; [self.bottomBackgroundView mas_makeConstraints:^(MASConstraintMaker *make) { make.left.right.equalTo(self.view); -// self.bottomBackgroundBottomConstraint = make.bottom.equalTo(self.view).offset(-self.baseInputBarBottomSpacing); make.bottom.equalTo(self.view); make.height.mas_equalTo(self.voiceInputBarHeight); }]; @@ -170,10 +189,73 @@ [self.voiceInputBar mas_makeConstraints:^(MASConstraintMaker *make) { make.left.right.equalTo(self.view); self.voiceInputBarBottomConstraint = make.bottom.equalTo(self.view).offset(-self.baseInputBarBottomSpacing); - make.height.mas_equalTo(self.voiceInputBarHeight); // 根据实际需要调整高度 + make.height.mas_equalTo(self.voiceInputBarHeight); }]; } +/// 设置文本输入视图 +- (void)setupTextInputView { + // 文本输入容器(初始隐藏) + [self.view addSubview:self.textInputContainerView]; + [self.textInputContainerView mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.right.equalTo(self.view); + self.textInputContainerBottomConstraint = make.bottom.equalTo(self.view).offset(100); // 初始在屏幕外 + make.height.mas_greaterThanOrEqualTo(50); + }]; + + [self.textInputContainerView addSubview:self.textInputTextView]; + [self.textInputContainerView addSubview:self.sendButton]; + [self.textInputTextView addSubview:self.placeholderLabel]; + + [self.sendButton mas_makeConstraints:^(MASConstraintMaker *make) { + make.right.equalTo(self.textInputContainerView).offset(-16); + make.bottom.equalTo(self.textInputContainerView).offset(-10); + make.width.mas_equalTo(60); + make.height.mas_equalTo(36); + }]; + + [self.textInputTextView mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.textInputContainerView).offset(16); + make.right.equalTo(self.sendButton.mas_left).offset(-10); + make.top.equalTo(self.textInputContainerView).offset(8); + make.bottom.equalTo(self.textInputContainerView).offset(-8); + make.height.mas_greaterThanOrEqualTo(36); + make.height.mas_lessThanOrEqualTo(100); + }]; + + [self.placeholderLabel mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.textInputTextView).offset(5); + make.top.equalTo(self.textInputTextView).offset(8); + }]; +} + +/// 设置 VoiceInputBar 的文本发送回调 +- (void)setupVoiceInputBarCallback { + __weak typeof(self) weakSelf = self; + self.voiceInputBar.onTextSend = ^(NSString *text) { + // 文本模式下点击,显示文本输入框 + [weakSelf showTextInputView]; + }; +} + +/// 显示文本输入视图 +- (void)showTextInputView { + self.isTextInputMode = YES; + self.voiceInputBar.hidden = YES; + self.textInputContainerView.hidden = NO; + [self.textInputTextView becomeFirstResponder]; +} + +/// 隐藏文本输入视图 +- (void)hideTextInputView { + self.isTextInputMode = NO; + [self.textInputTextView resignFirstResponder]; + self.textInputContainerView.hidden = YES; + self.voiceInputBar.hidden = NO; + self.textInputTextView.text = @""; + self.placeholderLabel.hidden = NO; +} + #pragma mark - 2:数据加载 - (void)loadPersonas { @@ -191,7 +273,6 @@ if (error) { NSLog(@"加载人设列表失败:%@", error.localizedDescription); - // TODO: 显示错误提示 return; } @@ -200,24 +281,18 @@ return; } - // 追加数据 [weakSelf.personas addObjectsFromArray:pageModel.records]; weakSelf.hasMore = pageModel.hasMore; - // 刷新 UI dispatch_async(dispatch_get_main_queue(), ^{ [weakSelf.collectionView reloadData]; - - // 首次加载,预加载前 3 个 if (weakSelf.currentPage == 1) { [weakSelf preloadDataForIndexes:@[@0, @1, @2]]; } }); NSLog(@"加载成功:当前 %ld 条,总共 %ld 条,还有更多:%@", - weakSelf.personas.count, - pageModel.total, - pageModel.hasMore ? @"是" : @"否"); + weakSelf.personas.count, pageModel.total, pageModel.hasMore ? @"是" : @"否"); }]; } @@ -225,7 +300,6 @@ if (!self.hasMore || self.isLoading) { return; } - self.currentPage++; [self loadPersonas]; } @@ -238,20 +312,13 @@ } NSMutableArray *indexesToPreload = [NSMutableArray array]; - - // 上一个 if (index > 0) { [indexesToPreload addObject:@(index - 1)]; } - - // 当前 [indexesToPreload addObject:@(index)]; - - // 下一个 if (index < self.personas.count - 1) { [indexesToPreload addObject:@(index + 1)]; } - [self preloadDataForIndexes:indexesToPreload]; } @@ -260,7 +327,6 @@ if ([self.preloadedIndexes containsObject:indexNum]) { continue; } - [self.preloadedIndexes addObject:indexNum]; NSInteger index = [indexNum integerValue]; @@ -270,11 +336,9 @@ NSIndexPath *indexPath = [NSIndexPath indexPathForItem:index inSection:0]; KBPersonaChatCell *cell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:indexPath]; - if (cell) { [cell preloadDataIfNeeded]; } - NSLog(@"预加载第 %ld 个人设", (long)index); } } @@ -289,20 +353,14 @@ KBPersonaChatCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"KBPersonaChatCell" forIndexPath:indexPath]; cell.persona = self.personas[indexPath.item]; [self updateChatViewBottomInset]; - - // 标记为已预加载 [self.preloadedIndexes addObject:@(indexPath.item)]; - - // 直接触发预加载 [cell preloadDataIfNeeded]; - return cell; } #pragma mark - UIScrollViewDelegate - (void)scrollViewDidScroll:(UIScrollView *)scrollView { - // 关键修复:如果正在等待 AI 回复,不进行预加载等操作 if (self.isWaitingForAIResponse) { return; } @@ -311,14 +369,12 @@ CGFloat offsetY = scrollView.contentOffset.y; NSInteger currentPage = offsetY / pageHeight; - // 滑动超过 30% 就预加载 if (fmod(offsetY, pageHeight) > pageHeight * 0.3) { [self preloadAdjacentCellsForIndex:currentPage + 1]; } else { [self preloadAdjacentCellsForIndex:currentPage]; } - // 接近底部时加载更多 if (offsetY + scrollView.bounds.size.height >= scrollView.contentSize.height - pageHeight) { [self loadMorePersonas]; } @@ -332,15 +388,12 @@ if (currentPage < self.personas.count) { NSLog(@"当前在第 %ld 个人设:%@", (long)currentPage, self.personas[currentPage].name); } - [self updateChatViewBottomInset]; } -/// 关键修复:禁止在等待 AI 回复时开始拖拽 - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView { if (self.isWaitingForAIResponse) { NSLog(@"[KBAIHomeVC] 正在等待 AI 回复,禁止滚动"); - // 强制停止滚动 scrollView.scrollEnabled = NO; scrollView.scrollEnabled = YES; } @@ -377,45 +430,42 @@ NSTimeInterval duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue]; UIViewAnimationOptions options = ([userInfo[UIKeyboardAnimationCurveUserInfoKey] integerValue] << 16); - // 将键盘的 frame 转换到当前 view 的坐标系 CGRect convertedFrame = [self.view convertRect:endFrame fromView:nil]; CGFloat keyboardHeight = MAX(0.0, CGRectGetMaxY(self.view.bounds) - CGRectGetMinY(convertedFrame)); - // Gate:只让 KBVoiceInputBar 触发聊天区域的联动(评论输入等场景会误触发) if (keyboardHeight > 0.0) { if (![self kb_isKeyboardFromVoiceInputBar]) { return; } self.voiceInputKeyboardActive = YES; } else { - // 键盘收起时:只有之前是由 KBVoiceInputBar 触发的,才需要恢复 if (!self.voiceInputKeyboardActive) { return; } self.voiceInputKeyboardActive = NO; + // 键盘隐藏时,如果是文本输入模式,隐藏文本输入框并显示 VoiceInputBar + if (self.isTextInputMode) { + [self hideTextInputView]; + } } self.currentKeyboardHeight = keyboardHeight; - NSLog(@"[KBAIHomeVC] 键盘高度: %.2f, 屏幕高度: %.2f, 键盘 Y: %.2f", - keyboardHeight, CGRectGetMaxY(self.view.bounds), CGRectGetMinY(convertedFrame)); - - // 问题1修复:计算 VoiceInputBar 应该距离 view.bottom 多远 + NSLog(@"[KBAIHomeVC] 键盘高度: %.2f", keyboardHeight); + CGFloat bottomSpacing; if (keyboardHeight > 0.0) { - // 键盘弹起时:让 VoiceInputBar 紧贴键盘上方(距离 5px) - // bottomSpacing = 键盘高度 - 5px(因为是负的 offset) bottomSpacing = keyboardHeight - 5.0; + // 文本输入模式:更新文本输入容器位置 + if (self.isTextInputMode) { + [self.textInputContainerBottomConstraint setOffset:-keyboardHeight]; + } } else { - // 键盘隐藏时:恢复到原始位置 bottomSpacing = self.baseInputBarBottomSpacing; + [self.textInputContainerBottomConstraint setOffset:100]; // 移出屏幕 } - NSLog(@"[KBAIHomeVC] VoiceInputBar bottomSpacing: %.2f", bottomSpacing); - [self.voiceInputBarBottomConstraint setOffset:-bottomSpacing]; - - // 问题2修复:键盘弹起时更新 ChatView 的 bottomInset [self updateChatViewBottomInset]; [UIView animateWithDuration:duration @@ -447,6 +497,9 @@ if ([touch.view isDescendantOfView:self.voiceInputBar]) { return NO; } + if ([touch.view isDescendantOfView:self.textInputContainerView]) { + return NO; + } return YES; } @@ -486,46 +539,27 @@ return visibleCell; } } - return nil; } #pragma mark - Private - (void)updateChatViewBottomInset { - // 键盘弹起时,增加 bottomInset 让最后一条消息显示在 VoiceInputBar 上方 CGFloat bottomInset; if (self.currentKeyboardHeight > 0.0) { - // 键盘弹起时: - // avatarImageView 距离屏幕底部的距离 = KB_TABBAR_HEIGHT + 50 + 20 - // chatView 物理底部到屏幕底部 = KB_TABBAR_HEIGHT + 50 + 20 + 54(头像) + 10(间距) ≈ 153 - // 但是 VoiceInputBar 和键盘会遮挡 chatView - // 需要的 bottomInset = 键盘高度 + VoiceInputBar 高度 - chatView 已经避开的底部区域 - - CGFloat avatarBottomSpace = KB_TABBAR_HEIGHT + 50 + 20; // avatarImageView 距离底部的距离 - CGFloat chatViewPhysicalBottomSpace = avatarBottomSpace + 54 + 10; // chatView 物理底部距离屏幕底部 - - // 需要抬高的额外距离 = (键盘 + InputBar) - chatView 已经避开的空间 + CGFloat avatarBottomSpace = KB_TABBAR_HEIGHT + 50 + 20; + CGFloat chatViewPhysicalBottomSpace = avatarBottomSpace + 54 + 10; bottomInset = (self.currentKeyboardHeight + self.voiceInputBarHeight) - chatViewPhysicalBottomSpace; - - // 确保不会是负数 bottomInset = MAX(bottomInset, 0); - - NSLog(@"[KBAIHomeVC] 键盘弹起 - bottomInset: %.2f (键盘: %.2f + InputBar: %.2f - 已避开: %.2f)", - bottomInset, self.currentKeyboardHeight, self.voiceInputBarHeight, chatViewPhysicalBottomSpace); } else { - // 键盘隐藏时:不需要额外的 bottomInset bottomInset = 0; - NSLog(@"[KBAIHomeVC] 键盘隐藏 - bottomInset: %.2f", bottomInset); } for (NSIndexPath *indexPath in self.collectionView.indexPathsForVisibleItems) { KBPersonaChatCell *cell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:indexPath]; if (cell) { [cell updateChatViewBottomInset:bottomInset]; - - // 键盘弹起时,自动滚动到最后一条消息 if (self.currentKeyboardHeight > 0.0) { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [cell.chatView scrollToBottom]; @@ -557,6 +591,18 @@ [pop pop]; } +#pragma mark - UITextViewDelegate + +- (void)textViewDidChange:(UITextView *)textView { + self.placeholderLabel.hidden = textView.text.length > 0; + // 动态调整高度 + CGSize size = [textView sizeThatFits:CGSizeMake(textView.frame.size.width, CGFLOAT_MAX)]; + CGFloat newHeight = MIN(MAX(size.height, 36), 100); + [textView mas_updateConstraints:^(MASConstraintMaker *make) { + make.height.mas_greaterThanOrEqualTo(newHeight); + }]; +} + #pragma mark - Lazy Load - (UICollectionView *)collectionView { @@ -590,6 +636,55 @@ return _voiceInputBar; } +- (UIView *)textInputContainerView { + if (!_textInputContainerView) { + _textInputContainerView = [[UIView alloc] init]; + _textInputContainerView.backgroundColor = [UIColor whiteColor]; + _textInputContainerView.hidden = YES; + } + return _textInputContainerView; +} + +- (UITextView *)textInputTextView { + if (!_textInputTextView) { + _textInputTextView = [[UITextView alloc] init]; + _textInputTextView.font = [UIFont systemFontOfSize:16]; + _textInputTextView.textColor = [UIColor blackColor]; + _textInputTextView.backgroundColor = [UIColor colorWithRed:0.95 green:0.95 blue:0.95 alpha:1.0]; + _textInputTextView.layer.cornerRadius = 18; + _textInputTextView.layer.masksToBounds = YES; + _textInputTextView.textContainerInset = UIEdgeInsetsMake(8, 8, 8, 8); + _textInputTextView.delegate = self; + _textInputTextView.returnKeyType = UIReturnKeySend; + _textInputTextView.enablesReturnKeyAutomatically = YES; + } + return _textInputTextView; +} + +- (UIButton *)sendButton { + if (!_sendButton) { + _sendButton = [UIButton buttonWithType:UIButtonTypeCustom]; + [_sendButton setTitle:KBLocalized(@"发送") forState:UIControlStateNormal]; + [_sendButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; + _sendButton.titleLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightMedium]; + _sendButton.backgroundColor = [UIColor colorWithRed:0.2 green:0.6 blue:1.0 alpha:1.0]; + _sendButton.layer.cornerRadius = 18; + _sendButton.layer.masksToBounds = YES; + [_sendButton addTarget:self action:@selector(sendButtonTapped) forControlEvents:UIControlEventTouchUpInside]; + } + return _sendButton; +} + +- (UILabel *)placeholderLabel { + if (!_placeholderLabel) { + _placeholderLabel = [[UILabel alloc] init]; + _placeholderLabel.text = KBLocalized(@"输入消息..."); + _placeholderLabel.font = [UIFont systemFontOfSize:16]; + _placeholderLabel.textColor = [UIColor lightGrayColor]; + } + return _placeholderLabel; +} + #pragma mark - KBChatLimitPopViewDelegate - (void)chatLimitPopViewDidTapCancel:(KBChatLimitPopView *)view { @@ -654,6 +749,24 @@ [self.navigationController pushViewController:vc animated:YES]; } +/// 发送按钮点击 - 直接调用 handleTranscribedText +- (void)sendButtonTapped { + NSString *text = [self.textInputTextView.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (text.length == 0) { + return; + } + + // 清空输入框 + self.textInputTextView.text = @""; + self.placeholderLabel.hidden = NO; + + // 隐藏键盘和文本输入框 + [self hideTextInputView]; + + // 直接调用 handleTranscribedText,不走语音录制流程 + [self handleTranscribedText:text]; +} + #pragma mark - KBVoiceToTextManagerDelegate - (void)voiceToTextManager:(KBVoiceToTextManager *)manager @@ -689,7 +802,6 @@ unsigned long long fileSize = [attributes[NSFileSize] unsignedLongLongValue]; NSLog(@"[KBAIHomeVC] 录音完成,时长: %.2fs,大小: %llu bytes", duration, fileSize); - // 显示 loading 气泡 KBPersonaChatCell *currentCell = [self currentPersonaCell]; if (currentCell) { [currentCell appendLoadingUserMessage]; @@ -725,12 +837,10 @@ return; } - // 更新 loading 气泡为实际文本 if (cell) { [cell updateLastUserMessage:transcript]; } - // 发送聊天请求(不追加消息,因为已经更新了) [strongSelf handleTranscribedText:transcript appendToUI:NO]; }); }]; @@ -756,7 +866,7 @@ if (text.length == 0) { return; } - NSLog(@"[KBAIHomeVC] 语音识别结果:%@", text); + NSLog(@"[KBAIHomeVC] 发送消息:%@", text); NSInteger companionId = [self currentCompanionId]; if (companionId <= 0) { @@ -769,7 +879,6 @@ [currentCell appendUserMessage:text]; } - // 关键修复:发送消息前禁止 CollectionView 滚动 self.isWaitingForAIResponse = YES; self.collectionView.scrollEnabled = NO; NSLog(@"[KBAIHomeVC] 开始等待 AI 回复,禁止 CollectionView 滚动"); @@ -784,12 +893,10 @@ } dispatch_async(dispatch_get_main_queue(), ^{ - // 关键修复:收到响应后(无论成功或失败)重新启用 CollectionView 滚动 strongSelf.isWaitingForAIResponse = NO; strongSelf.collectionView.scrollEnabled = YES; NSLog(@"[KBAIHomeVC] AI 回复完成,恢复 CollectionView 滚动"); - // 收到响应后,隐藏用户消息的 loading KBPersonaChatCell *cell = [strongSelf currentPersonaCell]; if (cell) { [cell markLastUserMessageLoadingComplete];