处理语音

This commit is contained in:
2026-01-29 20:56:24 +08:00
parent 23c0d14128
commit 36135313d8
8 changed files with 236 additions and 9 deletions

View File

@@ -66,6 +66,9 @@ typedef NS_ENUM(NSInteger, KBAiChatMessageType) {
/// 创建 AI 消息(仅文本,无音频) /// 创建 AI 消息(仅文本,无音频)
+ (instancetype)assistantMessageWithText:(NSString *)text; + (instancetype)assistantMessageWithText:(NSString *)text;
/// 创建加载中的 AI 消息
+ (instancetype)loadingAssistantMessage;
/// 创建时间戳消息 /// 创建时间戳消息
+ (instancetype)timeMessageWithTimestamp:(NSDate *)timestamp; + (instancetype)timeMessageWithTimestamp:(NSDate *)timestamp;

View File

@@ -70,6 +70,20 @@
return message; return message;
} }
+ (instancetype)loadingAssistantMessage {
KBAiChatMessage *message = [[KBAiChatMessage alloc] init];
message.type = KBAiChatMessageTypeAssistant;
message.text = @"";
message.timestamp = [NSDate date];
message.audioId = nil;
message.audioDuration = 0;
message.audioData = nil;
message.isComplete = NO;
message.isLoading = YES;
message.needsTypewriterEffect = NO;
return message;
}
+ (instancetype)timeMessageWithTimestamp:(NSDate *)timestamp { + (instancetype)timeMessageWithTimestamp:(NSDate *)timestamp {
KBAiChatMessage *message = [[KBAiChatMessage alloc] init]; KBAiChatMessage *message = [[KBAiChatMessage alloc] init];
message.type = KBAiChatMessageTypeTime; message.type = KBAiChatMessageTypeTime;

View File

@@ -16,6 +16,7 @@
@property (nonatomic, strong) UIView *bubbleView; @property (nonatomic, strong) UIView *bubbleView;
@property (nonatomic, strong, readwrite) UILabel *messageLabel; // readwrite @property (nonatomic, strong, readwrite) UILabel *messageLabel; // readwrite
@property (nonatomic, strong) UIActivityIndicatorView *loadingIndicator; @property (nonatomic, strong) UIActivityIndicatorView *loadingIndicator;
@property (nonatomic, strong) UIActivityIndicatorView *messageLoadingIndicator; // AI
@property (nonatomic, strong) KBAiChatMessage *currentMessage; @property (nonatomic, strong) KBAiChatMessage *currentMessage;
// //
@@ -61,6 +62,12 @@
self.loadingIndicator.hidesWhenStopped = YES; self.loadingIndicator.hidesWhenStopped = YES;
[self.contentView addSubview:self.loadingIndicator]; [self.contentView addSubview:self.loadingIndicator];
// AI AI
self.messageLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
self.messageLoadingIndicator.color = [UIColor whiteColor];
self.messageLoadingIndicator.hidesWhenStopped = YES;
[self.contentView addSubview:self.messageLoadingIndicator];
// //
self.bubbleView = [[UIView alloc] init]; self.bubbleView = [[UIView alloc] init];
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.7]; self.bubbleView.backgroundColor = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.7];
@@ -83,8 +90,7 @@
[self.voiceButton mas_makeConstraints:^(MASConstraintMaker *make) { [self.voiceButton mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.contentView).offset(16); make.left.equalTo(self.contentView).offset(16);
make.top.equalTo(self.contentView).offset(8); make.top.equalTo(self.contentView).offset(8);
// make.width.height.mas_equalTo(24); make.width.height.mas_equalTo(24);
make.width.height.mas_equalTo(0);
}]; }];
[self.durationLabel mas_makeConstraints:^(MASConstraintMaker *make) { [self.durationLabel mas_makeConstraints:^(MASConstraintMaker *make) {
@@ -96,6 +102,12 @@
make.center.equalTo(self.voiceButton); make.center.equalTo(self.voiceButton);
}]; }];
// AI bubbleView
[self.messageLoadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.contentView).offset(16);
make.top.equalTo(self.voiceButton.mas_bottom).offset(12);
}];
// bubbleView // bubbleView
[self.bubbleView mas_makeConstraints:^(MASConstraintMaker *make) { [self.bubbleView mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.voiceButton.mas_bottom).offset(4); make.top.equalTo(self.voiceButton.mas_bottom).offset(4);
@@ -116,8 +128,8 @@
} }
- (void)configureWithMessage:(KBAiChatMessage *)message { - (void)configureWithMessage:(KBAiChatMessage *)message {
NSLog(@"[KBChatAssistantMessageCell] 配置消息 - 文本长度: %lu, isComplete: %d, needsTypewriter: %d, 打字机运行中: %d", NSLog(@"[KBChatAssistantMessageCell] 配置消息 - 文本长度: %lu, isComplete: %d, needsTypewriter: %d, isLoading: %d, 打字机运行中: %d",
(unsigned long)message.text.length, message.isComplete, message.needsTypewriterEffect, (unsigned long)message.text.length, message.isComplete, message.needsTypewriterEffect, message.isLoading,
(self.typewriterTimer && self.typewriterTimer.isValid)); (self.typewriterTimer && self.typewriterTimer.isValid));
// //
@@ -125,6 +137,21 @@
self.currentMessage = message; self.currentMessage = message;
// loading
if (message.isLoading) {
self.messageLabel.attributedText = nil;
self.messageLabel.text = @"";
self.bubbleView.hidden = YES;
self.voiceButton.hidden = YES;
self.durationLabel.hidden = YES;
[self.messageLoadingIndicator startAnimating];
return;
}
// loading loading
[self.messageLoadingIndicator stopAnimating];
self.bubbleView.hidden = NO;
// 使 // 使
if (message.needsTypewriterEffect && !message.isComplete && message.text.length > 0) { if (message.needsTypewriterEffect && !message.isComplete && message.text.length > 0) {
// //

View File

@@ -54,6 +54,9 @@ NS_ASSUME_NONNULL_BEGIN
/// 清空所有消息 /// 清空所有消息
- (void)clearMessages; - (void)clearMessages;
/// 移除 loading AI 消息
- (void)removeLoadingAssistantMessage;
/// 滚动到底部 /// 滚动到底部
- (void)scrollToBottom; - (void)scrollToBottom;

View File

@@ -236,6 +236,19 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
[self updateFooterVisibility]; [self updateFooterVisibility];
} }
- (void)removeLoadingAssistantMessage {
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
KBAiChatMessage *message = self.messages[i];
if (message.type == KBAiChatMessageTypeAssistant && message.isLoading) {
[self.messages removeObjectAtIndex:i];
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
[self.tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
NSLog(@"[KBChatTableView] 移除 loading AI 消息,索引: %ld", (long)i);
break;
}
}
}
- (void)scrollToBottom { - (void)scrollToBottom {
[self scrollToBottomAnimated:YES]; [self scrollToBottomAnimated:YES];
} }
@@ -339,6 +352,11 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
}); });
}); });
} }
//
if (message.type == KBAiChatMessageTypeAssistant && message.audioId.length > 0) {
[self preloadAudioForMessage:message];
}
} }
- (void)reloadWithMessages:(NSArray<KBAiChatMessage *> *)messages - (void)reloadWithMessages:(NSArray<KBAiChatMessage *> *)messages
@@ -620,8 +638,8 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
[cell showLoadingAnimation]; [cell showLoadingAnimation];
} }
// 50.5 // 10110
[self pollAudioForMessage:message atIndexPath:indexPath retryCount:0 maxRetries:5]; [self pollAudioForMessage:message atIndexPath:indexPath retryCount:0 maxRetries:10];
} }
- (void)pollAudioForMessage:(KBAiChatMessage *)message - (void)pollAudioForMessage:(KBAiChatMessage *)message
@@ -638,6 +656,7 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
dispatch_async(dispatch_get_main_queue(), ^{ dispatch_async(dispatch_get_main_queue(), ^{
// audioURL // audioURL
if (!error && audioURL.length > 0) { if (!error && audioURL.length > 0) {
NSLog(@"[KBChatTableView] 音频 URL 获取成功(第 %ld 次)", (long)(retryCount + 1));
// //
[strongSelf downloadAndPlayAudioFromURL:audioURL [strongSelf downloadAndPlayAudioFromURL:audioURL
forMessage:message forMessage:message
@@ -647,11 +666,11 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
// //
if (retryCount < maxRetries - 1) { if (retryCount < maxRetries - 1) {
NSLog(@"[KBChatTableView] 音频未就绪,0.5秒后重试 (%ld/%ld)", NSLog(@"[KBChatTableView] 音频未就绪,1秒后重试 (%ld/%ld)",
(long)(retryCount + 1), (long)maxRetries); (long)(retryCount + 1), (long)maxRetries);
// 0.5 // 1
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{ dispatch_get_main_queue(), ^{
[strongSelf pollAudioForMessage:message [strongSelf pollAudioForMessage:message
atIndexPath:indexPath atIndexPath:indexPath
@@ -786,6 +805,115 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
} }
} }
#pragma mark - Audio Preload ()
///
- (void)preloadAudioForMessage:(KBAiChatMessage *)message {
if (!message || message.audioId.length == 0) {
return;
}
//
if (message.audioData && message.audioData.length > 0) {
NSLog(@"[KBChatTableView] 音频已缓存,跳过预加载");
return;
}
NSLog(@"[KBChatTableView] 开始预加载音频audioId: %@", message.audioId);
//
NSDate *startTime = [NSDate date];
// 10110
[self pollPreloadAudioForMessage:message retryCount:0 maxRetries:10 startTime:startTime];
}
///
- (void)pollPreloadAudioForMessage:(KBAiChatMessage *)message
retryCount:(NSInteger)retryCount
maxRetries:(NSInteger)maxRetries
startTime:(NSDate *)startTime {
__weak typeof(self) weakSelf = self;
[self.aiVM requestAudioWithAudioId:message.audioId
completion:^(NSString *_Nullable audioURL, NSError *_Nullable error) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return;
dispatch_async(dispatch_get_main_queue(), ^{
// audioURL
if (!error && audioURL.length > 0) {
// audioURL
NSTimeInterval elapsed = [[NSDate date] timeIntervalSinceDate:startTime];
NSLog(@"[KBChatTableView] ✅ 预加载音频 URL 获取成功(第 %ld 次),耗时: %.2f 秒", (long)(retryCount + 1), elapsed);
//
[strongSelf downloadAudioFromURL:audioURL forMessage:message startTime:startTime];
return;
}
//
if (retryCount < maxRetries - 1) {
NSLog(@"[KBChatTableView] 预加载音频未就绪1秒后重试 (%ld/%ld)",
(long)(retryCount + 1), (long)maxRetries);
// 1
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
[strongSelf pollPreloadAudioForMessage:message
retryCount:retryCount + 1
maxRetries:maxRetries
startTime:startTime];
});
} else {
NSTimeInterval elapsed = [[NSDate date] timeIntervalSinceDate:startTime];
NSLog(@"[KBChatTableView] ❌ 预加载音频失败,已重试 %ld 次,总耗时: %.2f 秒", (long)maxRetries, elapsed);
}
});
}];
}
///
- (void)downloadAudioFromURL:(NSString *)urlString
forMessage:(KBAiChatMessage *)message
startTime:(NSDate *)startTime {
NSURL *url = [NSURL URLWithString:urlString];
if (!url) {
NSLog(@"[KBChatTableView] 预加载:无效的音频 URL: %@", urlString);
return;
}
NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
NSURLSessionDataTask *task = [session dataTaskWithURL:url
completionHandler:^(NSData *_Nullable data,
NSURLResponse *_Nullable response,
NSError *_Nullable error) {
if (error || !data || data.length == 0) {
NSLog(@"[KBChatTableView] 预加载:下载音频失败: %@", error.localizedDescription ?: @"");
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
//
message.audioData = data;
//
NSError *playerError = nil;
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:data error:&playerError];
if (!playerError && player) {
message.audioDuration = player.duration;
}
//
NSTimeInterval totalElapsed = [[NSDate date] timeIntervalSinceDate:startTime];
NSLog(@"[KBChatTableView] ✅ 预加载音频完成,音频时长: %.2f秒,总耗时: %.2f 秒", message.audioDuration, totalElapsed);
});
}];
[task resume];
}
#pragma mark - AVAudioPlayerDelegate #pragma mark - AVAudioPlayerDelegate
- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag { - (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag {

View File

@@ -37,6 +37,12 @@ NS_ASSUME_NONNULL_BEGIN
- (void)appendAssistantMessage:(NSString *)text - (void)appendAssistantMessage:(NSString *)text
audioId:(nullable NSString *)audioId; audioId:(nullable NSString *)audioId;
/// 添加 loading AI 消息
- (void)appendLoadingAssistantMessage;
/// 移除 loading AI 消息
- (void)removeLoadingAssistantMessage;
/// 更新聊天列表底部 inset /// 更新聊天列表底部 inset
- (void)updateChatViewBottomInset:(CGFloat)bottomInset; - (void)updateChatViewBottomInset:(CGFloat)bottomInset;

View File

@@ -503,6 +503,10 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
} }
[self ensureOpeningMessageAtTop]; [self ensureOpeningMessageAtTop];
// loading
[self removeLoadingAssistantMessage];
KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text
audioId:audioId]; audioId:audioId];
message.needsTypewriterEffect = YES; message.needsTypewriterEffect = YES;
@@ -510,6 +514,33 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
[self.chatView addMessage:message autoScroll:YES]; [self.chatView addMessage:message autoScroll:YES];
} }
/// loading AI
- (void)appendLoadingAssistantMessage {
if (!self.messages) {
self.messages = [NSMutableArray array];
}
[self ensureOpeningMessageAtTop];
KBAiChatMessage *message = [KBAiChatMessage loadingAssistantMessage];
[self.messages addObject:message];
[self.chatView addMessage:message autoScroll:YES];
}
/// loading AI
- (void)removeLoadingAssistantMessage {
//
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
KBAiChatMessage *message = self.messages[i];
if (message.type == KBAiChatMessageTypeAssistant && message.isLoading) {
[self.messages removeObjectAtIndex:i];
break;
}
}
// chatView
[self.chatView removeLoadingAssistantMessage];
}
- (void)updateChatViewBottomInset:(CGFloat)bottomInset { - (void)updateChatViewBottomInset:(CGFloat)bottomInset {
[self.chatView updateContentBottomInset:bottomInset]; [self.chatView updateContentBottomInset:bottomInset];
} }

View File

@@ -964,6 +964,8 @@
KBPersonaChatCell *currentCell = [self currentPersonaCell]; KBPersonaChatCell *currentCell = [self currentPersonaCell];
if (currentCell && appendToUI) { if (currentCell && appendToUI) {
[currentCell appendUserMessage:text]; [currentCell appendUserMessage:text];
// loading AI
[currentCell appendLoadingAssistantMessage];
} }
self.isWaitingForAIResponse = YES; self.isWaitingForAIResponse = YES;
@@ -990,12 +992,20 @@
} }
if (response.code == 50030) { if (response.code == 50030) {
// loading
if (cell) {
[cell removeLoadingAssistantMessage];
}
NSString *message = response.message ?: @""; NSString *message = response.message ?: @"";
[strongSelf showChatLimitPopWithMessage:message]; [strongSelf showChatLimitPopWithMessage:message];
return; return;
} }
if (!response || !response.data) { if (!response || !response.data) {
// loading
if (cell) {
[cell removeLoadingAssistantMessage];
}
NSString *message = response.message ?: @"聊天响应为空"; NSString *message = response.message ?: @"聊天响应为空";
NSLog(@"[KBAIHomeVC] 聊天响应为空:%@", message); NSLog(@"[KBAIHomeVC] 聊天响应为空:%@", message);
if (message.length > 0) { if (message.length > 0) {
@@ -1007,11 +1017,16 @@
NSString *aiResponse = response.data.aiResponse ?: response.data.content ?: response.data.text ?: response.data.message ?: @""; NSString *aiResponse = response.data.aiResponse ?: response.data.content ?: response.data.text ?: response.data.message ?: @"";
NSString *audioId = response.data.audioId; NSString *audioId = response.data.audioId;
if (aiResponse.length == 0) { if (aiResponse.length == 0) {
// loading
if (cell) {
[cell removeLoadingAssistantMessage];
}
NSLog(@"[KBAIHomeVC] AI 回复为空"); NSLog(@"[KBAIHomeVC] AI 回复为空");
return; return;
} }
if (cell) { if (cell) {
// appendAssistantMessage loading
[cell appendAssistantMessage:aiResponse audioId:audioId]; [cell appendAssistantMessage:aiResponse audioId:audioId];
} }
}); });