Files
keyboard/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m
2026-01-30 21:24:17 +08:00

1041 lines
36 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>
#import <SDWebImage/SDWebImage.h>
@interface KBAIHomeVC () <UICollectionViewDelegate, UICollectionViewDataSource, KBVoiceToTextManagerDelegate, KBVoiceRecordManagerDelegate, UIGestureRecognizerDelegate, KBChatLimitPopViewDelegate, UITextViewDelegate>
/// 人设列表容器
@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<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;
}
// 文本输入模式下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(15);
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<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;
self.currentIndex = currentPage;
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: %@, coverImageUrl: %@", persona.name, persona.coverImageUrl);
// 异步下载并缩小图片,保存到 AppGroup 共享目录
[self downloadAndSavePersonaCoverImage:persona.coverImageUrl];
}
/// 下载并缩小 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;
}
// 缩小图片到适合键盘扩展的尺寸(宽度 390高度按比例
CGFloat targetWidth = 390.0;
CGFloat scale = targetWidth / image.size.width;
CGSize targetSize = CGSizeMake(targetWidth, image.size.height * scale);
UIGraphicsBeginImageContextWithOptions(targetSize, YES, 1.0);
[image drawInRect:CGRectMake(0, 0, targetSize.width, targetSize.height)];
UIImage *scaledImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
// 压缩为 JPEG质量 0.6
NSData *jpegData = UIImageJPEGRepresentation(scaledImage, 0.6);
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 回复,禁止滚动");
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];
// 添加 loading AI 消息
[currentCell appendLoadingAssistantMessage];
}
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) {
// 移除 loading 消息
if (cell) {
[cell removeLoadingAssistantMessage];
}
NSString *message = response.message ?: @"";
[strongSelf showChatLimitPopWithMessage:message];
return;
}
if (!response || !response.data) {
// 移除 loading 消息
if (cell) {
[cell removeLoadingAssistantMessage];
}
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) {
// 移除 loading 消息
if (cell) {
[cell removeLoadingAssistantMessage];
}
NSLog(@"[KBAIHomeVC] AI 回复为空");
return;
}
if (cell) {
// appendAssistantMessage 内部会自动移除 loading 消息
[cell appendAssistantMessage:aiResponse audioId:audioId];
}
});
}];
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
@end