Files
keyboard/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m
2026-01-29 16:42:43 +08:00

832 lines
29 KiB
Objective-C
Raw 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.

//
// 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 <Masonry/Masonry.h>
@interface KBAIHomeVC () <UICollectionViewDelegate, UICollectionViewDataSource, KBVoiceToTextManagerDelegate, KBVoiceRecordManagerDelegate, UIGestureRecognizerDelegate, KBChatLimitPopViewDelegate>
/// 人设列表容器
@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 *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<KBPersonaModel *> *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<NSNumber *> *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;
}
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 setupUI];
[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);
// self.bottomBackgroundBottomConstraint = make.bottom.equalTo(self.view).offset(-self.baseInputBarBottomSpacing);
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); // 根据实际需要调整高度
}];
}
#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);
// TODO: 显示错误提示
return;
}
if (!pageModel || !pageModel.records) {
NSLog(@"人设列表数据为空");
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 ? @"" : @"");
}];
}
- (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<NSNumber *> *)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 {
// 关键修复:如果正在等待 AI 回复,不进行预加载等操作
if (self.isWaitingForAIResponse) {
return;
}
CGFloat pageHeight = scrollView.bounds.size.height;
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];
}
}
- (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];
}
/// 关键修复:禁止在等待 AI 回复时开始拖拽
- (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);
// 将键盘的 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;
}
self.currentKeyboardHeight = keyboardHeight;
NSLog(@"[KBAIHomeVC] 键盘高度: %.2f, 屏幕高度: %.2f, 键盘 Y: %.2f",
keyboardHeight, CGRectGetMaxY(self.view.bounds), CGRectGetMinY(convertedFrame));
// 问题1修复计算 VoiceInputBar 应该距离 view.bottom 多远
CGFloat bottomSpacing;
if (keyboardHeight > 0.0) {
// 键盘弹起时:让 VoiceInputBar 紧贴键盘上方(距离 5px
// bottomSpacing = 键盘高度 - 5px因为是负的 offset
bottomSpacing = keyboardHeight - 5.0;
} else {
// 键盘隐藏时:恢复到原始位置
bottomSpacing = self.baseInputBarBottomSpacing;
}
NSLog(@"[KBAIHomeVC] VoiceInputBar bottomSpacing: %.2f", bottomSpacing);
[self.voiceInputBarBottomConstraint setOffset:-bottomSpacing];
// 问题2修复键盘弹起时更新 ChatView 的 bottomInset
[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;
}
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 {
// 键盘弹起时,增加 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 已经避开的空间
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];
});
}
}
}
}
- (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 - 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;
}
#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];
}
#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);
// 显示 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) {
__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;
}
// 更新 loading 气泡为实际文本
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];
}
// 关键修复:发送消息前禁止 CollectionView 滚动
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(), ^{
// 关键修复:收到响应后(无论成功或失败)重新启用 CollectionView 滚动
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 ?: @"";
[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