Files
keyboard/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m
2026-03-09 17:34:08 +08:00

1411 lines
53 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 "KBPayMainVC.h"
#import "KBUserSessionManager.h"
#import "LSTPopView.h"
#import "KBAIMessageVC.h"
#import "KBAICommentInputView.h"
#import "KBAIPersonaSidebarView.h"
#import <Masonry/Masonry.h>
#import <SDWebImage/SDWebImage.h>
@interface KBAIHomeVC () <UICollectionViewDelegate, UICollectionViewDataSource, KBVoiceToTextManagerDelegate, KBVoiceRecordManagerDelegate, UIGestureRecognizerDelegate, KBChatLimitPopViewDelegate, KBAIPersonaSidebarViewDelegate>
/// 人设列表容器
@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) KBAICommentInputView *commentInputView;
/// 文本输入视图底部约束
@property (nonatomic, strong) MASConstraint *commentInputBottomConstraint;
/// 是否处于文本输入模式
@property (nonatomic, assign) BOOL isTextInputMode;
/// 底部毛玻璃背景
@property (nonatomic, strong) UIView *bottomBackgroundView;
@property (nonatomic, strong) UIVisualEffectView *bottomBlurEffectView;
@property (nonatomic, strong) CAGradientLayer *bottomMaskLayer;
@property (nonatomic, strong) CAGradientLayer *bottomGradientLayer;
/// 语音转写管理器
@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, assign) NSInteger pendingAIRequestCount;
/// 是否处于语音流程中(录音/识别中,用于禁止滚动)
@property (nonatomic, assign) BOOL isVoiceProcessing;
/// 是否正在语音录制中(用于禁止滚动)
@property (nonatomic, assign) BOOL isVoiceRecording;
/// 右上角消息按钮
@property (nonatomic, strong) UIButton *messageButton;
/// 左上角人设列表按钮
@property (nonatomic, strong) UIButton *sidebarButton;
/// 侧边栏 PopView
@property (nonatomic, weak) LSTPopView *sidebarPopView;
@property (nonatomic, strong) KBAIPersonaSidebarView *sidebarView;
/// 侧边栏选中的人设 ID
@property (nonatomic, assign) NSInteger selectedPersonaId;
@end
@implementation KBAIHomeVC
static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
#pragma mark - Darwin Notification Callback (键盘扩展聊天更新)
static void KBChatUpdatedDarwinCallback(CFNotificationCenterRef center,
void *observer,
CFStringRef name,
const void *object,
CFDictionaryRef userInfo) {
KBAIHomeVC *self = (__bridge KBAIHomeVC *)observer;
dispatch_async(dispatch_get_main_queue(), ^{
[self kb_handleChatUpdatedFromExtension];
});
}
#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;
}
// 文本输入模式下commentInputView 也算
if ([firstResponder isDescendantOfView:self.commentInputView]) {
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.pendingAIRequestCount = 0;
self.isTextInputMode = NO;
[self setupUI];
[self setupTextInputView];
[self setupVoiceInputBarCallback];
[self setupVoiceToTextManager];
[self setupVoiceRecordManager];
[self setupKeyboardNotifications];
[self setupKeyboardDismissGesture];
[self loadPersonas];
// 监听键盘扩展聊天更新的 Darwin 跨进程通知
CFNotificationCenterAddObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
(__bridge const void *)(self),
KBChatUpdatedDarwinCallback,
(__bridge CFStringRef)kKBDarwinChatUpdated,
NULL,
CFNotificationSuspensionBehaviorDeliverImmediately);
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
[self kb_syncTextInputStateIfNeeded];
[self kb_logInputLayoutWithTag:@"viewDidAppear"];
KBPersonaChatCell *cell = [self currentPersonaCell];
if (cell) {
[cell onBecameCurrentPersonaCell];
}
}
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
// 离开页面时结束编辑并重置底部输入状态,避免返回时出现 hidden/firstResponder 错位
[self.view endEditing:YES];
self.voiceInputKeyboardActive = NO;
self.currentKeyboardHeight = 0.0;
self.isTextInputMode = NO;
self.commentInputView.hidden = YES;
[self.commentInputBottomConstraint setOffset:100];
self.voiceInputBar.hidden = NO;
[self.voiceInputBarBottomConstraint setOffset:-self.baseInputBarBottomSpacing];
[self.view layoutIfNeeded];
[self kb_logInputLayoutWithTag:@"viewWillDisappear"];
for (NSIndexPath *indexPath in self.collectionView.indexPathsForVisibleItems) {
KBPersonaChatCell *cell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:indexPath];
if (cell) {
[cell onResignedCurrentPersonaCell];
}
}
}
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
if (self.bottomGradientLayer) {
self.bottomGradientLayer.frame = self.bottomBackgroundView.bounds;
}
}
#pragma mark - 1控件初始化
- (void)setupUI {
self.voiceInputBarHeight = 70;
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.sidebarButton];
[self.sidebarButton mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.view).offset(KB_STATUSBAR_HEIGHT + 10);
make.left.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.commentInputView];
[self.commentInputView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.view).offset(12);
make.right.equalTo(self.view).offset(-12);
self.commentInputBottomConstraint = make.bottom.equalTo(self.view).offset(100); // 初始在屏幕外
make.height.mas_equalTo(52);
}];
}
/// 设置 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.commentInputView.hidden = NO;
[self kb_logInputLayoutWithTag:@"showTextInputView-beforeAdjust"];
// 键盘未弹起时先停在底部栏位置,随后由键盘通知动画抬起到键盘上方
// 键盘已弹起时(如返回页面)直接使用当前键盘高度对齐
CGFloat targetOffset = self.currentKeyboardHeight > 0.0 ? -self.currentKeyboardHeight : -self.baseInputBarBottomSpacing;
[self.commentInputBottomConstraint setOffset:targetOffset];
[self.view layoutIfNeeded];
[self kb_logInputLayoutWithTag:@"showTextInputView-afterAdjust"];
[self.commentInputView showKeyboard];
}
/// 隐藏文本输入视图
- (void)hideTextInputView {
[self kb_logInputLayoutWithTag:@"hideTextInputView-before"];
self.isTextInputMode = NO;
[self.view endEditing:YES];
[self.commentInputView clearText];
self.commentInputView.hidden = YES;
[self.commentInputBottomConstraint setOffset:100];
self.voiceInputBar.hidden = NO;
[self kb_logInputLayoutWithTag:@"hideTextInputView-after"];
}
#pragma mark - 2数据加载
- (void)loadPersonas {
if (self.isLoading) {
return;
}
self.isLoading = YES;
NSInteger oldCount = self.personas.count;
__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(), ^{
if (weakSelf.currentPage == 1) {
[weakSelf.collectionView reloadData];
[weakSelf preloadDataForIndexes:@[@0, @1, @2]];
// 首次加载完成后,主动保存默认 persona 到 AppGroup
// 避免用户未滑动时键盘扩展拿不到数据
if (weakSelf.personas.count > 0) {
NSInteger index = MIN(MAX(weakSelf.currentIndex, 0), weakSelf.personas.count - 1);
[weakSelf saveSelectedPersonaToAppGroup:weakSelf.personas[index]];
}
} else if (pageModel.records.count > 0) {
NSInteger newCount = weakSelf.personas.count;
NSMutableArray<NSIndexPath *> *indexPaths = [NSMutableArray array];
for (NSInteger i = oldCount; i < newCount; i++) {
[indexPaths addObject:[NSIndexPath indexPathForItem:i inSection:0]];
}
[UIView performWithoutAnimation:^{
[weakSelf.collectionView performBatchUpdates:^{
[weakSelf.collectionView insertItemsAtIndexPaths:indexPaths];
} completion:nil];
}];
}
if (weakSelf.selectedPersonaId <= 0 && weakSelf.personas.count > 0) {
NSInteger index = MIN(MAX(weakSelf.currentIndex, 0), weakSelf.personas.count - 1);
[weakSelf storeSelectedPersonaId:weakSelf.personas[index].personaId];
}
if (weakSelf.sidebarView) {
[weakSelf.sidebarView updatePersonas:weakSelf.personas
reset:(weakSelf.currentPage == 1)
hasMore:weakSelf.hasMore
currentPage:weakSelf.currentPage];
[weakSelf.sidebarView updateSelectedPersonaId:[weakSelf storedSelectedPersonaId]];
}
});
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 {
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;
NSInteger previousIndex = self.currentIndex;
self.currentIndex = currentPage;
if (previousIndex != self.currentIndex) {
NSIndexPath *prevPath = [NSIndexPath indexPathForItem:previousIndex inSection:0];
KBPersonaChatCell *prevCell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:prevPath];
if (prevCell) {
[prevCell onResignedCurrentPersonaCell];
}
KBPersonaChatCell *currentCell = [self currentPersonaCell];
if (currentCell) {
[currentCell onBecameCurrentPersonaCell];
}
} else {
KBPersonaChatCell *currentCell = [self currentPersonaCell];
if (currentCell) {
[currentCell onBecameCurrentPersonaCell];
}
}
if (currentPage < self.personas.count) {
NSLog(@"当前在第 %ld 个人设:%@", (long)currentPage, self.personas[currentPage].name);
// 保存当前选中的 persona 到 AppGroup供键盘扩展使用
[self saveSelectedPersonaToAppGroup:self.personas[currentPage]];
}
[self updateChatViewBottomInset];
}
#pragma mark - AppGroup Persona 共享
/// 保存选中的 persona 到 AppGroup供键盘扩展读取
- (void)saveSelectedPersonaToAppGroup:(KBPersonaModel *)persona {
if (!persona) {
return;
}
NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
if (!ud) {
NSLog(@"[KBAIHomeVC] 无法访问 AppGroup");
return;
}
// 保存 persona 的关键信息
NSDictionary *personaDict = @{
@"personaId": @(persona.personaId),
@"name": persona.name ?: @"",
@"avatarUrl": persona.avatarUrl ?: @"",
@"coverImageUrl": persona.coverImageUrl ?: @"",
@"shortDesc": persona.shortDesc ?: @""
};
[ud setObject:personaDict forKey:@"AppGroup_SelectedPersona"];
[ud synchronize];
NSLog(@"[KBAIHomeVC] 已保存选中的 persona 到 AppGroup: %@, avatarUrl: %@", persona.name, persona.avatarUrl);
// 异步下载并缩小图片,保存到 AppGroup 共享目录
[self downloadAndSavePersonaCoverImage:persona.avatarUrl];
}
/// 下载并缩小 persona 封面图,保存到 AppGroup 共享目录
- (void)downloadAndSavePersonaCoverImage:(NSString *)imageUrl {
if (imageUrl.length == 0) {
return;
}
// 获取 AppGroup 共享目录
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup];
if (!containerURL) {
NSLog(@"[KBAIHomeVC] 无法获取 AppGroup 容器目录");
return;
}
NSString *imagePath = [[containerURL path] stringByAppendingPathComponent:@"persona_cover.jpg"];
// 使用 SDWebImage 下载图片
[[SDWebImageManager sharedManager] loadImageWithURL:[NSURL URLWithString:imageUrl]
options:SDWebImageHighPriority
progress:nil
completed:^(UIImage *image, NSData *data, NSError *error, SDImageCacheType cacheType, BOOL finished, NSURL *imageURL) {
if (error || !image) {
NSLog(@"[KBAIHomeVC] 下载 persona 封面图失败: %@", error.localizedDescription);
return;
}
// 缩小图片到 40x40仅用于工具栏头像显示
CGFloat targetSide = 40.0;
CGSize targetSize = CGSizeMake(targetSide, targetSide);
UIGraphicsBeginImageContextWithOptions(targetSize, NO, 1.0);
[image drawInRect:CGRectMake(0, 0, targetSize.width, targetSize.height)];
UIImage *scaledImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
// 压缩为 JPEG质量 0.8
NSData *jpegData = UIImageJPEGRepresentation(scaledImage, 0.8);
if (!jpegData) {
NSLog(@"[KBAIHomeVC] 压缩图片失败");
return;
}
// 保存到 AppGroup 共享目录
BOOL success = [jpegData writeToFile:imagePath atomically:YES];
if (success) {
NSLog(@"[KBAIHomeVC] persona 封面图已保存到: %@, 大小: %lu KB", imagePath, (unsigned long)jpegData.length / 1024);
} else {
NSLog(@"[KBAIHomeVC] 保存 persona 封面图失败");
}
}];
}
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
if (self.isWaitingForAIResponse) {
NSLog(@"[KBAIHomeVC] 正在等待 AI 回复,禁止滚动");
[self updateCollectionViewScrollState];
}
}
#pragma mark - 4语音转写
- (void)setupVoiceToTextManager {
self.voiceToTextManager = [[KBVoiceToTextManager alloc] initWithInputBar:self.voiceInputBar];
self.voiceToTextManager.delegate = self;
}
/// 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(handleKeyboardNotification:)
name:UIKeyboardWillChangeFrameNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleKeyboardNotification:)
name:UIKeyboardDidChangeFrameNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleKeyboardNotification:)
name:UIKeyboardWillShowNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleKeyboardNotification:)
name:UIKeyboardDidShowNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleKeyboardNotification:)
name:UIKeyboardWillHideNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleKeyboardNotification:)
name:UIKeyboardDidHideNotification
object:nil];
}
- (void)handleKeyboardNotification:(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));
BOOL shouldHandle = YES;
BOOL fromAllowedInput = [self kb_isKeyboardFromVoiceInputBar];
if (keyboardHeight > 0.0) {
// 文本输入模式优先跟随键盘,避免返回页面后 firstResponder 瞬时不稳定导致被误过滤
if (!self.isTextInputMode && !fromAllowedInput) {
shouldHandle = NO;
}
self.voiceInputKeyboardActive = YES;
} else {
if (!self.voiceInputKeyboardActive && !self.isTextInputMode) {
shouldHandle = NO;
}
self.voiceInputKeyboardActive = NO;
}
[self kb_logKeyboardNotification:notification
keyboardHeight:keyboardHeight
convertedFrame:convertedFrame
fromAllowedInput:fromAllowedInput
shouldHandle:shouldHandle];
if (!shouldHandle) {
return;
}
UIView *firstResponder = [self kb_findFirstResponderInView:self.view];
BOOL firstInComment = firstResponder ? [firstResponder isDescendantOfView:self.commentInputView] : NO;
if (keyboardHeight > 0.0 && firstInComment && !self.isTextInputMode) {
// 防止出现「firstResponder 在 commentInput但 textMode 仍为 NO」导致定位到底部
self.isTextInputMode = YES;
self.voiceInputBar.hidden = YES;
self.commentInputView.hidden = NO;
[self kb_logInputLayoutWithTag:@"keyboardSync-forceTextMode"];
}
if (keyboardHeight <= 0.0) {
// 键盘隐藏时,如果是文本输入模式,隐藏文本输入框并显示 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.commentInputBottomConstraint setOffset:-keyboardHeight];
}
} else {
bottomSpacing = self.baseInputBarBottomSpacing;
[self.commentInputBottomConstraint setOffset:100]; // 移出屏幕
}
[self.voiceInputBarBottomConstraint setOffset:-bottomSpacing];
[self updateChatViewBottomInset];
[UIView animateWithDuration:duration
delay:0
options:options
animations:^{
[self.view layoutIfNeeded];
}
completion:^(BOOL finished) {
[self kb_logInputLayoutWithTag:@"handleKeyboardNotification-animComplete"];
}];
}
#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.commentInputView]) {
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 - 键盘扩展聊天更新处理
/// 收到键盘扩展的聊天更新通知后,刷新对应 persona 的聊天记录
- (void)kb_handleChatUpdatedFromExtension {
NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
NSInteger companionId = [ud integerForKey:AppGroup_ChatUpdatedCompanionId];
if (companionId <= 0) {
return;
}
NSLog(@"[KBAIHomeVC] 收到键盘扩展聊天更新通知companionId=%ld", (long)companionId);
// 查找对应 persona 的索引
NSInteger index = [self indexOfPersonaId:companionId];
if (index == NSNotFound) {
NSLog(@"[KBAIHomeVC] 未找到 companionId=%ld 对应的 persona", (long)companionId);
return;
}
// 获取对应的 cell 并刷新聊天记录
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:index inSection:0];
KBPersonaChatCell *cell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:indexPath];
if (cell) {
[cell refreshChatHistory];
NSLog(@"[KBAIHomeVC] 已触发 companionId=%ld 的聊天记录刷新", (long)companionId);
} else {
NSLog(@"[KBAIHomeVC] companionId=%ld 的 cell 不可见,下次显示时会自动加载", (long)companionId);
}
}
- (NSInteger)indexOfPersonaId:(NSInteger)personaId {
if (personaId <= 0) {
return NSNotFound;
}
for (NSInteger i = 0; i < self.personas.count; i++) {
KBPersonaModel *persona = self.personas[i];
if (persona.personaId == personaId) {
return i;
}
}
return NSNotFound;
}
#pragma mark - Private
- (NSInteger)storedSelectedPersonaId {
NSInteger savedId = [[NSUserDefaults standardUserDefaults] integerForKey:KBAISelectedPersonaIdKey];
if (savedId > 0) {
return savedId;
}
if (self.currentIndex >= 0 && self.currentIndex < self.personas.count) {
return self.personas[self.currentIndex].personaId;
}
return 0;
}
- (void)storeSelectedPersonaId:(NSInteger)personaId {
if (personaId <= 0) {
return;
}
self.selectedPersonaId = personaId;
[[NSUserDefaults standardUserDefaults] setInteger:personaId forKey:KBAISelectedPersonaIdKey];
[[NSUserDefaults standardUserDefaults] synchronize];
if (self.sidebarView) {
[self.sidebarView updateSelectedPersonaId:personaId];
}
}
- (void)updateCollectionViewScrollState {
BOOL shouldEnable = !self.isWaitingForAIResponse
&& !self.isVoiceRecording
&& !self.isVoiceProcessing;
self.collectionView.scrollEnabled = shouldEnable;
self.collectionView.panGestureRecognizer.enabled = shouldEnable;
self.collectionView.userInteractionEnabled = shouldEnable;
}
- (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 = 252.0;
CGFloat height = 252.0 + 18.0 + 53.0 + 18.0 + 28.0;
KBChatLimitPopView *content = [[KBChatLimitPopView alloc] initWithFrame:CGRectMake(0, 0, width, height)];
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 = KBLocalized(@"Hold To Start Talking");
}
return _voiceInputBar;
}
- (KBAICommentInputView *)commentInputView {
if (!_commentInputView) {
_commentInputView = [[KBAICommentInputView alloc] init];
_commentInputView.layer.cornerRadius = 26;
_commentInputView.layer.masksToBounds = true;
_commentInputView.hidden = YES;
_commentInputView.placeholder = KBLocalized(@"send a message");
__weak typeof(self) weakSelf = self;
_commentInputView.onSend = ^(NSString *text) {
[weakSelf handleCommentInputSend:text];
};
}
return _commentInputView;
}
#pragma mark - KBChatLimitPopViewDelegate
- (void)chatLimitPopViewDidTapCancel:(KBChatLimitPopView *)view {
[self.chatLimitPopView dismiss];
}
- (void)chatLimitPopViewDidTapRecharge:(KBChatLimitPopView *)view {
NSLog(@"[KBAIHomeVC][Pay] chatLimitPopViewDidTapRecharge");
[self.chatLimitPopView dismiss];
if (![KBUserSessionManager shared].isLoggedIn) {
[[KBUserSessionManager shared] goLoginVC];
return;
}
KBPayMainVC *vc = [[KBPayMainVC alloc] init];
vc.initialSelectedIndex = 1; // SVIP
[KB_CURRENT_NAV pushViewController:vc animated:true];
}
#pragma mark - KBAIPersonaSidebarViewDelegate
- (void)personaSidebarView:(KBAIPersonaSidebarView *)view
requestPersonasAtPage:(NSInteger)page {
if (self.isLoading) {
[view endLoadingMore];
return;
}
self.currentPage = MAX(1, page);
if (self.currentPage == 1) {
[self.personas removeAllObjects];
[view resetLoadMore];
}
[self loadPersonas];
}
- (void)personaSidebarView:(KBAIPersonaSidebarView *)view
didSelectPersona:(KBPersonaModel *)persona {
if (!persona) {
return;
}
[self storeSelectedPersonaId:persona.personaId];
NSInteger index = [self indexOfPersonaId:persona.personaId];
if (index != NSNotFound) {
self.currentIndex = index;
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:index inSection:0];
[self.collectionView scrollToItemAtIndexPath:indexPath
atScrollPosition:UICollectionViewScrollPositionCenteredVertically
animated:NO];
[self preloadAdjacentCellsForIndex:index];
[self saveSelectedPersonaToAppGroup:persona];
}
if (self.sidebarPopView) {
[self.sidebarPopView dismiss];
}
}
- (UIView *)bottomBackgroundView {
if (!_bottomBackgroundView) {
_bottomBackgroundView = [[UIView alloc] init];
_bottomBackgroundView.clipsToBounds = YES;
// 添加渐变遮罩层,实现从底部到顶部的渐变显示效果
_bottomBackgroundView.layer.mask = self.bottomGradientLayer;
}
return _bottomBackgroundView;
}
- (UIVisualEffectView *)bottomBlurEffectView {
if (!_bottomBlurEffectView) {
// 使用深色毛玻璃效果
UIBlurEffect *blurEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleDark];
_bottomBlurEffectView = [[UIVisualEffectView alloc] initWithEffect:blurEffect];
_bottomBlurEffectView.alpha = 0.9; // 稍微降低整体透明度
}
return _bottomBlurEffectView;
}
- (CAGradientLayer *)bottomGradientLayer {
if (!_bottomGradientLayer) {
_bottomGradientLayer = [CAGradientLayer layer];
// 从底部到顶部
_bottomGradientLayer.startPoint = CGPointMake(0.5, 1);
_bottomGradientLayer.endPoint = CGPointMake(0.5, 0);
// 作为遮罩层:底部完全不透明(白色),顶部完全透明(透明色)
// 中间位置开始渐变,让底部区域保持完整的毛玻璃效果
_bottomGradientLayer.colors = @[
(__bridge id)[UIColor whiteColor].CGColor, // 底部:完全不透明
(__bridge id)[UIColor whiteColor].CGColor, // 中间偏下:完全不透明
(__bridge id)[[UIColor whiteColor] colorWithAlphaComponent:0.0].CGColor // 顶部:完全透明
];
_bottomGradientLayer.locations = @[@(0.0), @(0.4), @(1.0)];
}
return _bottomGradientLayer;
}
- (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;
}
- (UIButton *)sidebarButton {
if (!_sidebarButton) {
_sidebarButton = [UIButton buttonWithType:UIButtonTypeCustom];
UIImage *icon = [UIImage imageNamed:@"ai_more_icon"];
[_sidebarButton setImage:icon forState:UIControlStateNormal];
[_sidebarButton addTarget:self action:@selector(sidebarButtonTapped) forControlEvents:UIControlEventTouchUpInside];
}
return _sidebarButton;
}
#pragma mark - Actions
- (void)messageButtonTapped {
KBAIMessageVC *vc = [[KBAIMessageVC alloc] init];
[self.navigationController pushViewController:vc animated:YES];
}
- (void)sidebarButtonTapped {
[self showPersonaSidebar];
}
#pragma mark - Debug Log
- (void)kb_syncTextInputStateIfNeeded {
UIView *firstResponder = [self kb_findFirstResponderInView:self.view];
BOOL firstInComment = firstResponder ? [firstResponder isDescendantOfView:self.commentInputView] : NO;
if (!firstInComment) {
return;
}
self.isTextInputMode = YES;
self.voiceInputBar.hidden = YES;
self.commentInputView.hidden = NO;
if (self.currentKeyboardHeight > 0.0) {
[self.commentInputBottomConstraint setOffset:-self.currentKeyboardHeight];
[self.voiceInputBarBottomConstraint setOffset:-MAX(self.currentKeyboardHeight - 5.0, self.baseInputBarBottomSpacing)];
} else {
[self.commentInputBottomConstraint setOffset:-self.baseInputBarBottomSpacing];
}
[self.view layoutIfNeeded];
[self kb_logInputLayoutWithTag:@"kb_syncTextInputStateIfNeeded"];
}
- (void)kb_logInputLayoutWithTag:(NSString *)tag {
[self.view layoutIfNeeded];
UIView *firstResponder = [self kb_findFirstResponderInView:self.view];
NSString *firstResponderInfo = firstResponder ? NSStringFromClass(firstResponder.class) : @"nil";
NSLog(@"[KBAIHomeVC][Layout][%@] textMode=%d voiceHidden=%d commentHidden=%d currentKeyboardHeight=%.2f viewH=%.2f safeBottom=%.2f commentFrame=%@ voiceFrame=%@ firstResponder=%@",
tag ?: @"-",
self.isTextInputMode,
self.voiceInputBar.hidden,
self.commentInputView.hidden,
self.currentKeyboardHeight,
CGRectGetHeight(self.view.bounds),
self.view.safeAreaInsets.bottom,
NSStringFromCGRect(self.commentInputView.frame),
NSStringFromCGRect(self.voiceInputBar.frame),
firstResponderInfo);
}
- (void)kb_logKeyboardNotification:(NSNotification *)notification
keyboardHeight:(CGFloat)keyboardHeight
convertedFrame:(CGRect)convertedFrame
fromAllowedInput:(BOOL)fromAllowedInput
shouldHandle:(BOOL)shouldHandle {
NSDictionary *userInfo = notification.userInfo;
CGRect endFrame = [userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
NSTimeInterval duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
NSInteger curve = [userInfo[UIKeyboardAnimationCurveUserInfoKey] integerValue];
UIView *firstResponder = [self kb_findFirstResponderInView:self.view];
NSString *firstResponderInfo = firstResponder ? NSStringFromClass(firstResponder.class) : @"nil";
BOOL firstInComment = firstResponder ? [firstResponder isDescendantOfView:self.commentInputView] : NO;
BOOL firstInVoice = firstResponder ? [firstResponder isDescendantOfView:self.voiceInputBar] : NO;
NSLog(@"[KBAIHomeVC][Keyboard][%@] shouldHandle=%d textMode=%d voiceActive=%d fromAllowed=%d firstInComment=%d firstInVoice=%d firstResponder=%@ endFrame=%@ converted=%@ keyboardHeight=%.2f duration=%.2f curve=%ld viewWindow=%@",
notification.name,
shouldHandle,
self.isTextInputMode,
self.voiceInputKeyboardActive,
fromAllowedInput,
firstInComment,
firstInVoice,
firstResponderInfo,
NSStringFromCGRect(endFrame),
NSStringFromCGRect(convertedFrame),
keyboardHeight,
duration,
(long)curve,
self.view.window ? @"YES" : @"NO");
}
- (void)showPersonaSidebar {
if (!self.sidebarView) {
CGFloat width = KB_SCREEN_WIDTH * 0.7;
CGFloat height = KB_SCREEN_HEIGHT;
self.sidebarView = [[KBAIPersonaSidebarView alloc] initWithFrame:CGRectMake(0, 0, width, height)];
self.sidebarView.delegate = self;
}
self.sidebarView.selectedPersonaId = [self storedSelectedPersonaId];
[self.sidebarView updatePersonas:self.personas
reset:YES
hasMore:self.hasMore
currentPage:self.currentPage];
[self.sidebarView requestPersonasIfNeeded];
if (self.sidebarPopView) {
[self.sidebarPopView dismiss];
}
LSTPopView *popView = [LSTPopView initWithCustomView:self.sidebarView
parentView:nil
popStyle:LSTPopStyleSmoothFromLeft
dismissStyle:LSTDismissStyleSmoothToLeft];
popView.bgColor = [[UIColor blackColor] colorWithAlphaComponent:0.35];
popView.hemStyle = LSTHemStyleLeft;
popView.isClickBgDismiss = YES;
popView.isAvoidKeyboard = NO;
self.sidebarPopView = popView;
[popView pop];
}
/// 文本输入发送 - 直接调用 handleTranscribedText
- (void)handleCommentInputSend:(NSString *)text {
NSString *trimmedText = [text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if (trimmedText.length == 0) {
return;
}
// 隐藏键盘和文本输入框
[self hideTextInputView];
// 直接调用 handleTranscribedText不走语音录制流程
[self handleTranscribedText:trimmedText];
}
#pragma mark - KBVoiceToTextManagerDelegate
- (void)voiceToTextManagerDidBeginRecording:(KBVoiceToTextManager *)manager {
self.isVoiceRecording = YES;
self.isVoiceProcessing = YES;
[self updateCollectionViewScrollState];
[self.voiceRecordManager startRecording];
}
- (void)voiceToTextManagerDidEndRecording:(KBVoiceToTextManager *)manager {
self.isVoiceRecording = NO;
self.isVoiceProcessing = YES;
[self updateCollectionViewScrollState];
[self.voiceRecordManager stopRecording];
}
- (void)voiceToTextManagerDidCancelRecording:(KBVoiceToTextManager *)manager {
self.isVoiceRecording = NO;
self.isVoiceProcessing = NO;
[self updateCollectionViewScrollState];
[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) {
NSInteger bizCode = [error.userInfo[@"code"] integerValue];
NSString *messageError = error.localizedDescription;
if (bizCode == 50030) {
if (cell) {
[cell removeLoadingUserMessage];
}
NSString *message = messageError ?: @"";
strongSelf.isVoiceProcessing = NO;
[strongSelf updateCollectionViewScrollState];
[strongSelf showChatLimitPopWithMessage:message];
return;
}
NSLog(@"[KBAIHomeVC] 语音转文字失败:%@", error.localizedDescription);
[KBHUD showError:KBLocalized(@"Voice-to-text failed, please try again")];
if (cell) {
[cell updateLastUserMessage:KBLocalized(@"Voice recognition failed")];
}
strongSelf.isVoiceProcessing = NO;
[strongSelf updateCollectionViewScrollState];
return;
}
NSString *transcript = response.data.transcript ?: @"";
if (transcript.length == 0) {
NSLog(@"[KBAIHomeVC] 语音转文字结果为空");
[KBHUD showError:KBLocalized(@"No speech content recognized")];
if (cell) {
[cell removeLoadingUserMessage];
}
strongSelf.isVoiceProcessing = NO;
[strongSelf updateCollectionViewScrollState];
return;
}
if (cell) {
[cell updateLastUserMessage:transcript];
}
strongSelf.isVoiceProcessing = NO;
[strongSelf handleTranscribedText:transcript appendToUI:NO];
});
}];
}
- (void)voiceRecordManagerDidRecordTooShort:(KBVoiceRecordManager *)manager {
NSLog(@"[KBAIHomeVC] 录音过短,已忽略");
[KBHUD showError:KBLocalized(@"Recording too short, please try again")];
}
- (void)voiceRecordManager:(KBVoiceRecordManager *)manager
didFailWithError:(NSError *)error {
NSLog(@"[KBAIHomeVC] 录音失败:%@", error.localizedDescription);
self.isVoiceRecording = NO;
self.isVoiceProcessing = NO;
[self updateCollectionViewScrollState];
}
#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];
NSString *requestId = [NSUUID UUID].UUIDString;
if (currentCell && appendToUI) {
[currentCell appendUserMessage:text requestId:requestId];
[currentCell appendLoadingAssistantMessageWithRequestId:requestId];
}
self.pendingAIRequestCount += 1;
self.isWaitingForAIResponse = (self.pendingAIRequestCount > 0);
if (self.pendingAIRequestCount == 1) {
[self updateCollectionViewScrollState];
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(), ^{
if (strongSelf.pendingAIRequestCount > 0) {
strongSelf.pendingAIRequestCount -= 1;
}
strongSelf.isWaitingForAIResponse = (strongSelf.pendingAIRequestCount > 0);
if (strongSelf.pendingAIRequestCount == 0) {
[strongSelf updateCollectionViewScrollState];
NSLog(@"[KBAIHomeVC] AI 回复完成,恢复 CollectionView 滚动");
}
KBPersonaChatCell *cell = [strongSelf currentPersonaCell];
if (response.code == 50030) {
// 移除 loading 消息
if (cell) {
[cell removeLoadingAssistantMessageWithRequestId:requestId];
}
NSString *message = response.message ?: @"";
[strongSelf showChatLimitPopWithMessage:message];
return;
}
if (!response || !response.data) {
// 移除 loading 消息
if (cell) {
[cell removeLoadingAssistantMessageWithRequestId:requestId];
}
NSString *message = response.message ?: KBLocalized(@"Chat response is empty");
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) {
// 移除 loading 消息
if (cell) {
[cell removeLoadingAssistantMessageWithRequestId:requestId];
}
NSLog(@"[KBAIHomeVC] AI 回复为空");
return;
}
if (cell) {
[cell updateAssistantMessageWithRequestId:requestId text:aiResponse audioId:audioId];
}
});
}];
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
CFNotificationCenterRemoveObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
(__bridge const void *)(self),
(__bridge CFStringRef)kKBDarwinChatUpdated,
NULL);
}
@end