// // KBAIHomeVC.m // keyBoard // // Created by Mac on 2026/1/26. // #import "KBAIHomeVC.h" #import "KBPersonaChatCell.h" #import "KBPersonaModel.h" #import "KBVoiceInputBar.h" #import "KBVoiceRecordManager.h" #import "KBVoiceToTextManager.h" #import "AiVM.h" #import "KBHUD.h" #import "KBChatLimitPopView.h" #import "KBVipPay.h" #import "KBUserSessionManager.h" #import "LSTPopView.h" #import "KBAIMessageVC.h" #import @interface KBAIHomeVC () /// 人设列表容器 @property (nonatomic, strong) UICollectionView *collectionView; /// 底部语音输入栏 @property (nonatomic, strong) KBVoiceInputBar *voiceInputBar; @property (nonatomic, strong) MASConstraint *voiceInputBarBottomConstraint; @property (nonatomic, assign) CGFloat voiceInputBarHeight; @property (nonatomic, assign) CGFloat baseInputBarBottomSpacing; @property (nonatomic, assign) CGFloat currentKeyboardHeight; /// 仅用于标记"由 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; @property (nonatomic, strong) CAGradientLayer *bottomMaskLayer; /// 语音转写管理器 @property (nonatomic, strong) KBVoiceToTextManager *voiceToTextManager; /// 录音管理器 @property (nonatomic, strong) KBVoiceRecordManager *voiceRecordManager; /// 人设数据 @property (nonatomic, strong) NSMutableArray *personas; /// 当前页码 @property (nonatomic, assign) NSInteger currentPage; /// 是否还有更多数据 @property (nonatomic, assign) BOOL hasMore; /// 是否正在加载 @property (nonatomic, assign) BOOL isLoading; /// 当前显示的索引 @property (nonatomic, assign) NSInteger currentIndex; /// 已预加载的索引集合 @property (nonatomic, strong) NSMutableSet *preloadedIndexes; /// AiVM 实例 @property (nonatomic, strong) AiVM *aiVM; /// 是否正在等待 AI 回复(用于禁止滚动) @property (nonatomic, assign) BOOL isWaitingForAIResponse; /// 右上角消息按钮 @property (nonatomic, strong) UIButton *messageButton; @end @implementation KBAIHomeVC #pragma mark - Keyboard Gate /// 查找当前 view 树里的 firstResponder - (UIView *)kb_findFirstResponderInView:(UIView *)view { if ([view isFirstResponder]) { return view; } for (UIView *sub in view.subviews) { UIView *found = [self kb_findFirstResponderInView:sub]; if (found) { return found; } } return nil; } /// 仅允许 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]; } #pragma mark - Lifecycle - (void)viewDidLoad { [super viewDidLoad]; self.view.backgroundColor = [UIColor whiteColor]; self.kb_navView.hidden = true; // 初始化数据 self.personas = [NSMutableArray array]; self.currentPage = 1; self.hasMore = YES; self.isLoading = NO; self.currentIndex = 0; self.preloadedIndexes = [NSMutableSet set]; self.aiVM = [[AiVM alloc] init]; self.isWaitingForAIResponse = NO; self.isTextInputMode = NO; [self setupUI]; [self setupTextInputView]; [self setupVoiceInputBarCallback]; [self setupVoiceToTextManager]; [self setupVoiceRecordManager]; [self setupKeyboardNotifications]; [self setupKeyboardDismissGesture]; [self loadPersonas]; } - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; if (self.bottomMaskLayer) { self.bottomMaskLayer.frame = self.bottomBlurEffectView.bounds; } } #pragma mark - 1:控件初始化 - (void)setupUI { self.voiceInputBarHeight = 80.0; self.baseInputBarBottomSpacing = KB_TABBAR_HEIGHT; [self.view addSubview:self.collectionView]; [self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) { make.edges.equalTo(self.view); }]; // 右上角消息按钮 [self.view addSubview:self.messageButton]; [self.messageButton mas_makeConstraints:^(MASConstraintMaker *make) { make.top.equalTo(self.view).offset(KB_STATUSBAR_HEIGHT + 10); 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); make.bottom.equalTo(self.view); make.height.mas_equalTo(self.voiceInputBarHeight); }]; [self.bottomBlurEffectView mas_makeConstraints:^(MASConstraintMaker *make) { make.edges.equalTo(self.bottomBackgroundView); }]; // 底部语音输入栏 [self.view addSubview:self.voiceInputBar]; [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); }]; } /// 设置文本输入视图 - (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 { if (self.isLoading) { return; } self.isLoading = YES; __weak typeof(self) weakSelf = self; [self.aiVM fetchPersonasWithPageNum:self.currentPage pageSize:10 completion:^(KBPersonaPageModel * _Nullable pageModel, NSError * _Nullable error) { weakSelf.isLoading = NO; if (error) { NSLog(@"加载人设列表失败:%@", error.localizedDescription); return; } if (!pageModel || !pageModel.records) { NSLog(@"人设列表数据为空"); return; } [weakSelf.personas addObjectsFromArray:pageModel.records]; weakSelf.hasMore = pageModel.hasMore; dispatch_async(dispatch_get_main_queue(), ^{ [weakSelf.collectionView reloadData]; if (weakSelf.currentPage == 1) { [weakSelf preloadDataForIndexes:@[@0, @1, @2]]; } }); NSLog(@"加载成功:当前 %ld 条,总共 %ld 条,还有更多:%@", weakSelf.personas.count, pageModel.total, pageModel.hasMore ? @"是" : @"否"); }]; } - (void)loadMorePersonas { if (!self.hasMore || self.isLoading) { return; } self.currentPage++; [self loadPersonas]; } #pragma mark - 3:预加载逻辑 - (void)preloadAdjacentCellsForIndex:(NSInteger)index { if (index < 0 || index >= self.personas.count) { return; } 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]; } - (void)preloadDataForIndexes:(NSArray *)indexes { for (NSNumber *indexNum in indexes) { if ([self.preloadedIndexes containsObject:indexNum]) { continue; } [self.preloadedIndexes addObject:indexNum]; NSInteger index = [indexNum integerValue]; if (index >= self.personas.count) { continue; } NSIndexPath *indexPath = [NSIndexPath indexPathForItem:index inSection:0]; KBPersonaChatCell *cell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:indexPath]; if (cell) { [cell preloadDataIfNeeded]; } NSLog(@"预加载第 %ld 个人设", (long)index); } } #pragma mark - UICollectionViewDataSource - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { return self.personas.count; } - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { 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 { if (self.isWaitingForAIResponse) { return; } CGFloat pageHeight = scrollView.bounds.size.height; CGFloat offsetY = scrollView.contentOffset.y; NSInteger currentPage = offsetY / pageHeight; 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]; } } - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { CGFloat pageHeight = scrollView.bounds.size.height; NSInteger currentPage = scrollView.contentOffset.y / pageHeight; self.currentIndex = currentPage; if (currentPage < self.personas.count) { NSLog(@"当前在第 %ld 个人设:%@", (long)currentPage, self.personas[currentPage].name); } [self updateChatViewBottomInset]; } - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView { if (self.isWaitingForAIResponse) { NSLog(@"[KBAIHomeVC] 正在等待 AI 回复,禁止滚动"); scrollView.scrollEnabled = NO; scrollView.scrollEnabled = YES; } } #pragma mark - 4:语音转写 - (void)setupVoiceToTextManager { self.voiceToTextManager = [[KBVoiceToTextManager alloc] initWithInputBar:self.voiceInputBar]; self.voiceToTextManager.delegate = self; self.voiceToTextManager.deepgramEnabled = NO; [self.voiceToTextManager prepareConnection]; } /// 5:录音管理 - (void)setupVoiceRecordManager { self.voiceRecordManager = [[KBVoiceRecordManager alloc] init]; self.voiceRecordManager.delegate = self; self.voiceRecordManager.minRecordDuration = 1.0; } #pragma mark - 6:键盘监听 - (void)setupKeyboardNotifications { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleKeyboardWillChangeFrame:) name:UIKeyboardWillChangeFrameNotification object:nil]; } - (void)handleKeyboardWillChangeFrame:(NSNotification *)notification { NSDictionary *userInfo = notification.userInfo; CGRect endFrame = [userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]; NSTimeInterval duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue]; UIViewAnimationOptions options = ([userInfo[UIKeyboardAnimationCurveUserInfoKey] integerValue] << 16); CGRect convertedFrame = [self.view convertRect:endFrame fromView:nil]; CGFloat keyboardHeight = MAX(0.0, CGRectGetMaxY(self.view.bounds) - CGRectGetMinY(convertedFrame)); if (keyboardHeight > 0.0) { if (![self kb_isKeyboardFromVoiceInputBar]) { return; } self.voiceInputKeyboardActive = YES; } else { if (!self.voiceInputKeyboardActive) { return; } self.voiceInputKeyboardActive = NO; // 键盘隐藏时,如果是文本输入模式,隐藏文本输入框并显示 VoiceInputBar if (self.isTextInputMode) { [self hideTextInputView]; } } self.currentKeyboardHeight = keyboardHeight; NSLog(@"[KBAIHomeVC] 键盘高度: %.2f", keyboardHeight); CGFloat bottomSpacing; if (keyboardHeight > 0.0) { bottomSpacing = keyboardHeight - 5.0; // 文本输入模式:更新文本输入容器位置 if (self.isTextInputMode) { [self.textInputContainerBottomConstraint setOffset:-keyboardHeight]; } } else { bottomSpacing = self.baseInputBarBottomSpacing; [self.textInputContainerBottomConstraint setOffset:100]; // 移出屏幕 } [self.voiceInputBarBottomConstraint setOffset:-bottomSpacing]; [self updateChatViewBottomInset]; [UIView animateWithDuration:duration delay:0 options:options animations:^{ [self.view layoutIfNeeded]; } completion:nil]; } #pragma mark - 7:键盘收起 - (void)setupKeyboardDismissGesture { self.dismissKeyboardTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleBackgroundTap)]; self.dismissKeyboardTap.cancelsTouchesInView = NO; self.dismissKeyboardTap.delegate = self; [self.view addGestureRecognizer:self.dismissKeyboardTap]; } - (void)handleBackgroundTap { [self.view endEditing:YES]; } #pragma mark - UIGestureRecognizerDelegate - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch { if ([touch.view isDescendantOfView:self.voiceInputBar]) { return NO; } if ([touch.view isDescendantOfView:self.textInputContainerView]) { return NO; } return YES; } - (NSInteger)currentCompanionId { if (self.personas.count == 0) { return 0; } NSInteger index = self.currentIndex; if (index < 0 || index >= self.personas.count) { NSIndexPath *indexPath = self.collectionView.indexPathsForVisibleItems.firstObject; if (indexPath) { index = indexPath.item; } else { index = 0; } } KBPersonaModel *persona = self.personas[index]; return persona.personaId; } - (KBPersonaChatCell *)currentPersonaCell { if (self.personas.count == 0) { return nil; } NSIndexPath *indexPath = [NSIndexPath indexPathForItem:self.currentIndex inSection:0]; KBPersonaChatCell *cell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:indexPath]; if (cell) { return cell; } for (NSIndexPath *visibleIndex in self.collectionView.indexPathsForVisibleItems) { KBPersonaChatCell *visibleCell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:visibleIndex]; if (visibleCell) { return visibleCell; } } return nil; } #pragma mark - Private - (void)updateChatViewBottomInset { CGFloat bottomInset; if (self.currentKeyboardHeight > 0.0) { CGFloat avatarBottomSpace = KB_TABBAR_HEIGHT + 50 + 20; CGFloat chatViewPhysicalBottomSpace = avatarBottomSpace + 54 + 10; bottomInset = (self.currentKeyboardHeight + self.voiceInputBarHeight) - chatViewPhysicalBottomSpace; bottomInset = MAX(bottomInset, 0); } else { bottomInset = 0; } 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]; }); } } } } - (void)showChatLimitPopWithMessage:(NSString *)message { if (self.chatLimitPopView) { [self.chatLimitPopView dismiss]; } CGFloat width = KB_SCREEN_WIDTH - 60; KBChatLimitPopView *content = [[KBChatLimitPopView alloc] initWithFrame:CGRectMake(0, 0, width, 180)]; content.message = message; content.delegate = self; LSTPopView *pop = [LSTPopView initWithCustomView:content parentView:nil popStyle:LSTPopStyleFade dismissStyle:LSTDismissStyleFade]; pop.bgColor = [[UIColor blackColor] colorWithAlphaComponent:0.4]; pop.hemStyle = LSTHemStyleCenter; pop.isClickBgDismiss = YES; pop.isAvoidKeyboard = NO; self.chatLimitPopView = pop; [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 { if (!_collectionView) { UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init]; layout.scrollDirection = UICollectionViewScrollDirectionVertical; layout.minimumLineSpacing = 0; layout.minimumInteritemSpacing = 0; layout.itemSize = [UIScreen mainScreen].bounds.size; _collectionView = [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:layout]; _collectionView.pagingEnabled = YES; _collectionView.showsVerticalScrollIndicator = NO; _collectionView.backgroundColor = [UIColor whiteColor]; _collectionView.delegate = self; _collectionView.dataSource = self; [_collectionView registerClass:[KBPersonaChatCell class] forCellWithReuseIdentifier:@"KBPersonaChatCell"]; if (@available(iOS 11.0, *)) { _collectionView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; } } return _collectionView; } - (KBVoiceInputBar *)voiceInputBar { if (!_voiceInputBar) { _voiceInputBar = [[KBVoiceInputBar alloc] init]; _voiceInputBar.statusText = @"按住按钮开始对话"; } 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 { [self.chatLimitPopView dismiss]; } - (void)chatLimitPopViewDidTapRecharge:(KBChatLimitPopView *)view { [self.chatLimitPopView dismiss]; if (![KBUserSessionManager shared].isLoggedIn) { [[KBUserSessionManager shared] goLoginVC]; return; } KBVipPay *vc = [[KBVipPay alloc] init]; [KB_CURRENT_NAV pushViewController:vc animated:true]; } - (UIView *)bottomBackgroundView { if (!_bottomBackgroundView) { _bottomBackgroundView = [[UIView alloc] init]; _bottomBackgroundView.clipsToBounds = YES; } return _bottomBackgroundView; } - (UIVisualEffectView *)bottomBlurEffectView { if (!_bottomBlurEffectView) { UIBlurEffect *blurEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleLight]; _bottomBlurEffectView = [[UIVisualEffectView alloc] initWithEffect:blurEffect]; _bottomBlurEffectView.layer.mask = self.bottomMaskLayer; } return _bottomBlurEffectView; } - (CAGradientLayer *)bottomMaskLayer { if (!_bottomMaskLayer) { _bottomMaskLayer = [CAGradientLayer layer]; _bottomMaskLayer.startPoint = CGPointMake(0.5, 1); _bottomMaskLayer.endPoint = CGPointMake(0.5, 0); _bottomMaskLayer.colors = @[ (__bridge id)[UIColor whiteColor].CGColor, (__bridge id)[UIColor whiteColor].CGColor, (__bridge id)[UIColor clearColor].CGColor ]; _bottomMaskLayer.locations = @[@(0.0), @(0.5), @(1.0)]; } return _bottomMaskLayer; } - (UIButton *)messageButton { if (!_messageButton) { _messageButton = [UIButton buttonWithType:UIButtonTypeCustom]; [_messageButton setImage:[UIImage imageNamed:@"ai_message_icon"] forState:UIControlStateNormal]; [_messageButton addTarget:self action:@selector(messageButtonTapped) forControlEvents:UIControlEventTouchUpInside]; } return _messageButton; } #pragma mark - Actions - (void)messageButtonTapped { KBAIMessageVC *vc = [[KBAIMessageVC alloc] init]; [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 didReceiveFinalText:(NSString *)text { [self handleTranscribedText:text]; } - (void)voiceToTextManager:(KBVoiceToTextManager *)manager didFailWithError:(NSError *)error { NSLog(@"[KBAIHomeVC] 语音识别失败:%@", error.localizedDescription); } - (void)voiceToTextManagerDidBeginRecording:(KBVoiceToTextManager *)manager { [self.voiceRecordManager startRecording]; } - (void)voiceToTextManagerDidEndRecording:(KBVoiceToTextManager *)manager { [self.voiceRecordManager stopRecording]; } - (void)voiceToTextManagerDidCancelRecording:(KBVoiceToTextManager *)manager { [self.voiceRecordManager cancelRecording]; } #pragma mark - KBVoiceRecordManagerDelegate - (void)voiceRecordManager:(KBVoiceRecordManager *)manager didFinishRecordingAtURL:(NSURL *)fileURL duration:(NSTimeInterval)duration { NSDictionary *attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:fileURL.path error:nil]; unsigned long long fileSize = [attributes[NSFileSize] unsignedLongLongValue]; NSLog(@"[KBAIHomeVC] 录音完成,时长: %.2fs,大小: %llu bytes", duration, fileSize); KBPersonaChatCell *currentCell = [self currentPersonaCell]; if (currentCell) { [currentCell appendLoadingUserMessage]; } __weak typeof(self) weakSelf = self; [self.aiVM transcribeAudioFileAtURL:fileURL completion:^(KBAiSpeechTranscribeResponse * _Nullable response, NSError * _Nullable error) { __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) { return; } 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; } NSString *transcript = response.data.transcript ?: @""; if (transcript.length == 0) { NSLog(@"[KBAIHomeVC] 语音转文字结果为空"); [KBHUD showError:KBLocalized(@"未识别到语音内容")]; if (cell) { [cell updateLastUserMessage:KBLocalized(@"未识别到语音")]; } return; } if (cell) { [cell updateLastUserMessage:transcript]; } [strongSelf handleTranscribedText:transcript appendToUI:NO]; }); }]; } - (void)voiceRecordManagerDidRecordTooShort:(KBVoiceRecordManager *)manager { NSLog(@"[KBAIHomeVC] 录音过短,已忽略"); [KBHUD showError:KBLocalized(@"录音时间过短,请重新录音")]; } - (void)voiceRecordManager:(KBVoiceRecordManager *)manager didFailWithError:(NSError *)error { NSLog(@"[KBAIHomeVC] 录音失败:%@", error.localizedDescription); } #pragma mark - Private - (void)handleTranscribedText:(NSString *)text { [self handleTranscribedText:text appendToUI:YES]; } - (void)handleTranscribedText:(NSString *)text appendToUI:(BOOL)appendToUI { if (text.length == 0) { return; } NSLog(@"[KBAIHomeVC] 发送消息:%@", text); NSInteger companionId = [self currentCompanionId]; if (companionId <= 0) { NSLog(@"[KBAIHomeVC] companionId 无效,取消请求"); return; } KBPersonaChatCell *currentCell = [self currentPersonaCell]; if (currentCell && appendToUI) { [currentCell appendUserMessage:text]; } self.isWaitingForAIResponse = YES; self.collectionView.scrollEnabled = NO; NSLog(@"[KBAIHomeVC] 开始等待 AI 回复,禁止 CollectionView 滚动"); __weak typeof(self) weakSelf = self; [self.aiVM requestChatMessageWithContent:text companionId:companionId completion:^(KBAiMessageResponse * _Nullable response, NSError * _Nullable error) { __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) { return; } dispatch_async(dispatch_get_main_queue(), ^{ strongSelf.isWaitingForAIResponse = NO; strongSelf.collectionView.scrollEnabled = YES; NSLog(@"[KBAIHomeVC] AI 回复完成,恢复 CollectionView 滚动"); KBPersonaChatCell *cell = [strongSelf currentPersonaCell]; if (cell) { [cell markLastUserMessageLoadingComplete]; } if (response.code == 50030) { NSString *message = response.message ?: @""; [strongSelf showChatLimitPopWithMessage:message]; return; } if (!response || !response.data) { NSString *message = response.message ?: @"聊天响应为空"; NSLog(@"[KBAIHomeVC] 聊天响应为空:%@", message); if (message.length > 0) { [KBHUD showError:message]; } return; } NSString *aiResponse = response.data.aiResponse ?: response.data.content ?: response.data.text ?: response.data.message ?: @""; NSString *audioId = response.data.audioId; if (aiResponse.length == 0) { NSLog(@"[KBAIHomeVC] AI 回复为空"); return; } if (cell) { [cell appendAssistantMessage:aiResponse audioId:audioId]; } }); }]; } - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; } @end