From 36774a8a2ce92dba2a7911285a5c42d958e230f9 Mon Sep 17 00:00:00 2001 From: CodeST <694468528@qq.com> Date: Fri, 30 Jan 2026 13:17:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=84=E7=90=86=E9=94=AE=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CustomKeyboard/KeyboardViewController.m | 376 ++++++++++++++++++++-- CustomKeyboard/Model/KBChatMessage.h | 23 ++ CustomKeyboard/Model/KBChatMessage.m | 35 ++ CustomKeyboard/View/KBChatAssistantCell.h | 40 +++ CustomKeyboard/View/KBChatAssistantCell.m | 346 ++++++++++++++++++++ CustomKeyboard/View/KBChatMessageCell.h | 21 ++ CustomKeyboard/View/KBChatMessageCell.m | 280 +++++++++++++++- CustomKeyboard/View/KBChatPanelView.h | 20 ++ CustomKeyboard/View/KBChatPanelView.m | 242 ++++++++++++-- CustomKeyboard/View/KBChatUserCell.h | 19 ++ CustomKeyboard/View/KBChatUserCell.m | 85 +++++ keyBoard.xcodeproj/project.pbxproj | 24 ++ keyBoard/Class/AiTalk/VM/AiVM.h | 1 + 13 files changed, 1447 insertions(+), 65 deletions(-) create mode 100644 CustomKeyboard/View/KBChatAssistantCell.h create mode 100644 CustomKeyboard/View/KBChatAssistantCell.m create mode 100644 CustomKeyboard/View/KBChatUserCell.h create mode 100644 CustomKeyboard/View/KBChatUserCell.m diff --git a/CustomKeyboard/KeyboardViewController.m b/CustomKeyboard/KeyboardViewController.m index c63a344..ed5868d 100644 --- a/CustomKeyboard/KeyboardViewController.m +++ b/CustomKeyboard/KeyboardViewController.m @@ -802,18 +802,26 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, [self kb_playChatAudioAtPath:message.audioFilePath]; } -- (void)chatPanelViewDidTapClose:(KBChatPanelView *)view { - for (KBChatMessage *msg in self.chatMessages) { - if (msg.audioFilePath.length > 0) { - NSString *tmpRoot = NSTemporaryDirectory(); - if (tmpRoot.length > 0 && - [msg.audioFilePath hasPrefix:tmpRoot]) { - [[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath - error:nil]; - } - } +- (void)chatPanelView:(KBChatPanelView *)view didTapVoiceButtonForMessage:(KBChatMessage *)message { + if (!message) return; + + // 如果有 audioData,直接播放 + if (message.audioData && message.audioData.length > 0) { + [self kb_playChatAudioData:message.audioData]; + return; } - [self.chatMessages removeAllObjects]; + + // 如果有 audioFilePath,播放文件 + if (message.audioFilePath.length > 0) { + [self kb_playChatAudioAtPath:message.audioFilePath]; + return; + } + + NSLog(@"[Keyboard] 没有音频数据可播放"); +} + +- (void)chatPanelViewDidTapClose:(KBChatPanelView *)view { + // 清空 chatPanelView 内部的消息 [self.chatPanelView kb_reloadWithMessages:@[]]; if (self.chatAudioPlayer.isPlaying) { [self.chatAudioPlayer stop]; @@ -847,17 +855,25 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, if (text.length == 0) { return; } - KBChatMessage *outgoing = - [KBChatMessage messageWithText:text outgoing:YES audioFilePath:nil]; + NSLog(@"[Keyboard] ========== kb_sendChatText =========="); + NSLog(@"[Keyboard] chatPanelView=%p", self.chatPanelView); + + KBChatMessage *outgoing = [KBChatMessage userMessageWithText:text]; outgoing.avatarURL = [self kb_sharedUserAvatarURL]; - [self kb_appendChatMessage:outgoing]; + [self.chatPanelView kb_addUserMessage:text]; [self kb_prefetchAvatarForMessage:outgoing]; if (![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) { [KBHUD showInfo:KBLocalized(@"请开启完全访问后使用")]; return; } - [self kb_requestChatAudioForText:text]; + + // 添加 loading 消息 + NSLog(@"[Keyboard] 准备添加 loading 消息,chatPanelView=%p", self.chatPanelView); + [self.chatPanelView kb_addLoadingAssistantMessage]; + + // 调用新的聊天接口 + [self kb_requestChatMessageWithContent:text]; } - (void)kb_clearHostInputForText:(NSString *)text { @@ -937,23 +953,16 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, } - (void)kb_reloadChatRowForMessage:(KBChatMessage *)message { - NSUInteger idx = [self.chatMessages indexOfObject:message]; - if (idx == NSNotFound) { - [self.chatPanelView kb_reloadWithMessages:self.chatMessages]; - return; - } + NSLog(@"[Keyboard] ========== kb_reloadChatRowForMessage =========="); + // 不再使用 self.chatMessages,直接刷新 tableView UITableView *tableView = self.chatPanelView.tableView; if (!tableView) { - [self.chatPanelView kb_reloadWithMessages:self.chatMessages]; + NSLog(@"[Keyboard] tableView 为空,跳过"); return; } - if (idx >= (NSUInteger)[tableView numberOfRowsInSection:0]) { - [self.chatPanelView kb_reloadWithMessages:self.chatMessages]; - return; - } - NSIndexPath *indexPath = [NSIndexPath indexPathForRow:idx inSection:0]; - [tableView reloadRowsAtIndexPaths:@[ indexPath ] - withRowAnimation:UITableViewRowAnimationNone]; + // 刷新整个 tableView + NSLog(@"[Keyboard] 调用 tableView reloadData"); + [tableView reloadData]; } - (void)kb_requestChatAudioForText:(NSString *)text { @@ -1019,6 +1028,274 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, }]; } +#pragma mark - New Chat API (with typewriter effect and audio preload) + +/// 调用新的聊天接口(返回文本和 audioId) +- (void)kb_requestChatMessageWithContent:(NSString *)content { + NSLog(@"[Keyboard] ========== kb_requestChatMessageWithContent =========="); + NSLog(@"[Keyboard] 请求内容: %@", content); + + if (content.length == 0) { + NSLog(@"[Keyboard] ❌ 内容为空,移除 loading"); + [self.chatPanelView kb_removeLoadingAssistantMessage]; + return; + } + + // 从 AppGroup 获取选中的 persona companionId + NSInteger companionId = [self kb_selectedCompanionId]; + + NSString *encodedContent = + [content stringByAddingPercentEncodingWithAllowedCharacters: + [NSCharacterSet URLQueryAllowedCharacterSet]]; + NSString *path = [NSString + stringWithFormat:@"%@?content=%@&companionId=%ld", API_AI_CHAT_MESSAGE, + encodedContent ?: @"", (long)companionId]; + NSDictionary *params = @{ + @"content" : content ?: @"", + @"companionId" : @(companionId) + }; + + NSLog(@"[Keyboard] 发送聊天请求: path=%@, companionId=%ld", path, (long)companionId); + + __weak typeof(self) weakSelf = self; + [[KBNetworkManager shared] POST:path + jsonBody:params + headers:nil + completion:^(NSDictionary *json, NSURLResponse *response, + NSError *error) { + NSLog(@"[Keyboard] ========== 聊天响应回调 =========="); + NSLog(@"[Keyboard] error: %@", error); + NSLog(@"[Keyboard] json: %@", json); + + dispatch_async(dispatch_get_main_queue(), ^{ + __strong typeof(weakSelf) self = weakSelf; + if (!self) { + NSLog(@"[Keyboard] ❌ self 为空"); + return; + } + + NSLog(@"[Keyboard] 回调中 chatPanelView=%p", self.chatPanelView); + + if (error) { + NSLog(@"[Keyboard] ❌ 请求失败: %@", error.localizedDescription); + [self.chatPanelView kb_removeLoadingAssistantMessage]; + NSString *tip = error.localizedDescription ?: KBLocalized(@"请求失败"); + [KBHUD showInfo:tip]; + return; + } + + // 解析返回数据 + NSString *text = [self kb_chatMessageTextFromJSON:json]; + NSString *audioId = [self kb_chatMessageAudioIdFromJSON:json]; + + NSLog(@"[Keyboard] ✅ 解析结果: text=%@, audioId=%@", text, audioId); + + if (text.length == 0) { + NSLog(@"[Keyboard] ❌ 文本为空,移除 loading"); + [self.chatPanelView kb_removeLoadingAssistantMessage]; + [KBHUD showInfo:KBLocalized(@"未获取到回复内容")]; + return; + } + + NSLog(@"[Keyboard] 准备调用 kb_addAssistantMessage, chatPanelView=%p", self.chatPanelView); + // 添加 AI 消息(带打字机效果) + [self.chatPanelView kb_addAssistantMessage:text audioId:audioId]; + NSLog(@"[Keyboard] kb_addAssistantMessage 调用完成"); + + // 如果有 audioId,开始预加载音频 + if (audioId.length > 0) { + NSDate *startTime = [NSDate date]; + [self kb_preloadAudioWithAudioId:audioId startTime:startTime]; + } + }); + }]; +} + +/// 从 AppGroup 获取选中的 persona companionId +- (NSInteger)kb_selectedCompanionId { + NSDictionary *persona = [self kb_selectedPersonaFromAppGroup]; + if (persona) { + // 主 App 保存的字段名是 personaId + id companionIdObj = persona[@"personaId"] ?: persona[@"companionId"] ?: persona[@"id"]; + if ([companionIdObj respondsToSelector:@selector(integerValue)]) { + NSInteger companionId = [companionIdObj integerValue]; + NSLog(@"[Keyboard] 从 AppGroup 获取 companionId: %ld", (long)companionId); + return companionId; + } + } + NSLog(@"[Keyboard] 未找到 persona,使用默认 companionId: 0"); + return 0; // 默认值 +} + +/// 解析聊天消息文本 +- (NSString *)kb_chatMessageTextFromJSON:(NSDictionary *)json { + NSLog(@"[Keyboard] ========== kb_chatMessageTextFromJSON =========="); + NSLog(@"[Keyboard] 输入 json 类型: %@", NSStringFromClass([json class])); + + if (![json isKindOfClass:[NSDictionary class]]) { + NSLog(@"[Keyboard] ❌ json 不是字典类型"); + return @""; + } + + id dataObj = json[@"data"]; + NSLog(@"[Keyboard] data 字段类型: %@, 值: %@", NSStringFromClass([dataObj class]), dataObj); + + if ([dataObj isKindOfClass:[NSDictionary class]]) { + NSDictionary *data = (NSDictionary *)dataObj; + NSLog(@"[Keyboard] data 字典内容: %@", data); + + // 优先读取 aiResponse 字段(后端实际返回的字段名) + NSArray *dataKeys = @[@"aiResponse", @"content", @"text", @"message"]; + for (NSString *key in dataKeys) { + id value = data[key]; + NSLog(@"[Keyboard] 检查 data.%@ = %@ (类型: %@)", key, value, NSStringFromClass([value class])); + if ([value isKindOfClass:[NSString class]] && ((NSString *)value).length > 0) { + NSLog(@"[Keyboard] ✅ 从 data.%@ 解析到文本: %@", key, value); + return (NSString *)value; + } + } + NSLog(@"[Keyboard] ❌ data 字典中没有找到有效文本"); + } else if ([dataObj isKindOfClass:[NSString class]]) { + NSLog(@"[Keyboard] data 是字符串: %@", dataObj); + return (NSString *)dataObj; + } else { + NSLog(@"[Keyboard] ❌ data 字段类型不支持: %@", NSStringFromClass([dataObj class])); + } + + return @""; +} + +/// 解析聊天消息 audioId +- (NSString *)kb_chatMessageAudioIdFromJSON:(NSDictionary *)json { + if (![json isKindOfClass:[NSDictionary class]]) return nil; + + id dataObj = json[@"data"]; + if ([dataObj isKindOfClass:[NSDictionary class]]) { + NSDictionary *data = (NSDictionary *)dataObj; + NSString *audioId = data[@"audioId"]; + if ([audioId isKindOfClass:[NSString class]] && audioId.length > 0) { + return audioId; + } + } + + // 兼容其他字段名 + NSArray *keys = @[@"audioId", @"audio_id"]; + for (NSString *key in keys) { + id value = json[key]; + if ([value isKindOfClass:[NSString class]] && ((NSString *)value).length > 0) { + return (NSString *)value; + } + } + return nil; +} + +#pragma mark - Audio Preload + +/// 预加载音频(轮询获取 audioURL) +- (void)kb_preloadAudioWithAudioId:(NSString *)audioId startTime:(NSDate *)startTime { + if (audioId.length == 0) return; + + NSLog(@"[Keyboard] 开始预加载音频,audioId: %@", audioId); + + // 开始轮询(最多10次,每次间隔1秒,共10秒) + [self kb_pollAudioURLWithAudioId:audioId retryCount:0 maxRetries:10 startTime:startTime]; +} + +/// 轮询获取 audioURL +- (void)kb_pollAudioURLWithAudioId:(NSString *)audioId + retryCount:(NSInteger)retryCount + maxRetries:(NSInteger)maxRetries + startTime:(NSDate *)startTime { + + NSString *path = [NSString stringWithFormat:@"/chat/audio/%@", audioId]; + + __weak typeof(self) weakSelf = self; + [[KBNetworkManager shared] GET:path + parameters:nil + headers:nil + completion:^(NSDictionary *json, NSURLResponse *response, + NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + __strong typeof(weakSelf) self = weakSelf; + if (!self) return; + + // 解析 audioURL + NSString *audioURL = nil; + if ([json isKindOfClass:[NSDictionary class]]) { + id dataObj = json[@"data"]; + if ([dataObj isKindOfClass:[NSDictionary class]]) { + NSDictionary *dataDict = (NSDictionary *)dataObj; + id audioUrlObj = dataDict[@"audioUrl"] ?: dataDict[@"url"]; + if (audioUrlObj && ![audioUrlObj isKindOfClass:[NSNull class]] && [audioUrlObj isKindOfClass:[NSString class]]) { + audioURL = (NSString *)audioUrlObj; + } + } + } + + // 如果成功获取到 audioURL + if (audioURL.length > 0) { + NSTimeInterval elapsed = [[NSDate date] timeIntervalSinceDate:startTime]; + NSLog(@"[Keyboard] ✅ 预加载音频 URL 获取成功(第 %ld 次),耗时: %.2f 秒", (long)(retryCount + 1), elapsed); + // 下载音频 + [self kb_downloadPreloadAudioFromURL:audioURL startTime:startTime]; + return; + } + + // 如果还没达到最大重试次数,继续轮询 + if (retryCount < maxRetries - 1) { + NSLog(@"[Keyboard] 预加载音频未就绪,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(), ^{ + [self kb_pollAudioURLWithAudioId:audioId + retryCount:retryCount + 1 + maxRetries:maxRetries + startTime:startTime]; + }); + } else { + NSTimeInterval elapsed = [[NSDate date] timeIntervalSinceDate:startTime]; + NSLog(@"[Keyboard] ❌ 预加载音频失败,已重试 %ld 次,总耗时: %.2f 秒", (long)maxRetries, elapsed); + } + }); + }]; +} + +/// 下载预加载音频 +- (void)kb_downloadPreloadAudioFromURL:(NSString *)urlString startTime:(NSDate *)startTime { + if (urlString.length == 0) return; + + __weak typeof(self) weakSelf = self; + [[KBNetworkManager shared] GETData:urlString + parameters:nil + headers:nil + completion:^(NSData *data, NSURLResponse *response, NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + __strong typeof(weakSelf) self = weakSelf; + if (!self) return; + + if (error || !data || data.length == 0) { + NSLog(@"[Keyboard] 预加载:下载音频失败: %@", error.localizedDescription ?: @""); + return; + } + + // 计算音频时长 + NSError *playerError = nil; + AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:data error:&playerError]; + NSTimeInterval duration = 0; + if (!playerError && player) { + duration = player.duration; + } + + // 更新最后一条 AI 消息的音频数据 + [self.chatPanelView kb_updateLastAssistantMessageWithAudioData:data duration:duration]; + + NSTimeInterval totalElapsed = [[NSDate date] timeIntervalSinceDate:startTime]; + NSLog(@"[Keyboard] ✅ 预加载音频完成,音频时长: %.2f秒,总耗时: %.2f 秒", duration, totalElapsed); + }); + }]; +} + - (void)kb_downloadChatAudioFromURL:(NSString *)audioURL displayText:(NSString *)displayText { __weak typeof(self) weakSelf = self; @@ -1217,6 +1494,48 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, [player play]; } +/// 播放音频数据 +- (void)kb_playChatAudioData:(NSData *)audioData { + if (!audioData || audioData.length == 0) { + NSLog(@"[Keyboard] 音频数据为空"); + return; + } + + // 如果正在播放,先停止 + if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) { + [self.chatAudioPlayer stop]; + self.chatAudioPlayer = nil; + } + + // 配置音频会话 + NSError *sessionError = nil; + AVAudioSession *session = [AVAudioSession sharedInstance]; + if ([session respondsToSelector:@selector(setCategory:options:error:)]) { + [session setCategory:AVAudioSessionCategoryPlayback + withOptions:AVAudioSessionCategoryOptionDuckOthers + error:&sessionError]; + } else { + [session setCategory:AVAudioSessionCategoryPlayback error:&sessionError]; + } + [session setActive:YES error:nil]; + + // 创建播放器 + NSError *playerError = nil; + AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:audioData error:&playerError]; + if (playerError || !player) { + NSLog(@"[Keyboard] 音频播放器初始化失败: %@", playerError.localizedDescription); + [KBHUD showInfo:KBLocalized(@"音频播放失败")]; + return; + } + + self.chatAudioPlayer = player; + player.volume = 1.0; + [player prepareToPlay]; + [player play]; + + NSLog(@"[Keyboard] 开始播放音频,时长: %.2f秒", player.duration); +} + #pragma mark - KBKeyboardSubscriptionViewDelegate - (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view { @@ -1272,6 +1591,7 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, - (KBChatPanelView *)chatPanelView { if (!_chatPanelView) { + NSLog(@"[Keyboard] ⚠️ chatPanelView 被创建!"); _chatPanelView = [[KBChatPanelView alloc] init]; _chatPanelView.delegate = self; } diff --git a/CustomKeyboard/Model/KBChatMessage.h b/CustomKeyboard/Model/KBChatMessage.h index acf69f4..bf4a875 100644 --- a/CustomKeyboard/Model/KBChatMessage.h +++ b/CustomKeyboard/Model/KBChatMessage.h @@ -17,10 +17,33 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy, nullable) NSString *displayName; @property (nonatomic, strong, nullable) UIImage *avatarImage; +/// 是否处于加载状态 +@property (nonatomic, assign) BOOL isLoading; +/// 是否完成(用于打字机效果) +@property (nonatomic, assign) BOOL isComplete; +/// 是否需要打字机效果 +@property (nonatomic, assign) BOOL needsTypewriterEffect; +/// 音频 ID(用于异步加载音频) +@property (nonatomic, copy, nullable) NSString *audioId; +/// 音频数据(缓存) +@property (nonatomic, strong, nullable) NSData *audioData; +/// 音频时长(秒) +@property (nonatomic, assign) NSTimeInterval audioDuration; + + (instancetype)messageWithText:(NSString *)text outgoing:(BOOL)outgoing audioFilePath:(nullable NSString *)audioFilePath; +/// 创建用户消息 ++ (instancetype)userMessageWithText:(NSString *)text; + +/// 创建 AI 消息(带 audioId) ++ (instancetype)assistantMessageWithText:(NSString *)text + audioId:(nullable NSString *)audioId; + +/// 创建加载中的 AI 消息 ++ (instancetype)loadingAssistantMessage; + @end NS_ASSUME_NONNULL_END diff --git a/CustomKeyboard/Model/KBChatMessage.m b/CustomKeyboard/Model/KBChatMessage.m index ce85acf..8ad442f 100644 --- a/CustomKeyboard/Model/KBChatMessage.m +++ b/CustomKeyboard/Model/KBChatMessage.m @@ -14,6 +14,41 @@ msg.text = text ?: @""; msg.outgoing = outgoing; msg.audioFilePath = audioFilePath; + msg.isComplete = YES; + msg.isLoading = NO; + msg.needsTypewriterEffect = NO; + return msg; +} + ++ (instancetype)userMessageWithText:(NSString *)text { + KBChatMessage *msg = [[KBChatMessage alloc] init]; + msg.text = text ?: @""; + msg.outgoing = YES; + msg.isComplete = YES; + msg.isLoading = NO; + msg.needsTypewriterEffect = NO; + return msg; +} + ++ (instancetype)assistantMessageWithText:(NSString *)text + audioId:(NSString *)audioId { + KBChatMessage *msg = [[KBChatMessage alloc] init]; + msg.text = text ?: @""; + msg.outgoing = NO; + msg.audioId = audioId; + msg.isComplete = NO; + msg.isLoading = NO; + msg.needsTypewriterEffect = YES; + return msg; +} + ++ (instancetype)loadingAssistantMessage { + KBChatMessage *msg = [[KBChatMessage alloc] init]; + msg.text = @""; + msg.outgoing = NO; + msg.isComplete = NO; + msg.isLoading = YES; + msg.needsTypewriterEffect = NO; return msg; } diff --git a/CustomKeyboard/View/KBChatAssistantCell.h b/CustomKeyboard/View/KBChatAssistantCell.h new file mode 100644 index 0000000..d8bd88c --- /dev/null +++ b/CustomKeyboard/View/KBChatAssistantCell.h @@ -0,0 +1,40 @@ +// +// KBChatAssistantCell.h +// CustomKeyboard +// +// AI 消息 Cell(左侧显示,带语音按钮和打字机效果) +// + +#import +@class KBChatMessage; +@class KBChatAssistantCell; + +NS_ASSUME_NONNULL_BEGIN + +@protocol KBChatAssistantCellDelegate +@optional +/// 点击语音播放按钮 +- (void)assistantCell:(KBChatAssistantCell *)cell didTapVoiceButtonForMessage:(KBChatMessage *)message; +@end + +@interface KBChatAssistantCell : UITableViewCell + +@property (nonatomic, weak) id delegate; + +- (void)configureWithMessage:(KBChatMessage *)message; + +/// 更新语音播放状态 +- (void)updateVoicePlayingState:(BOOL)isPlaying; + +/// 显示语音加载动画 +- (void)showVoiceLoadingAnimation; + +/// 隐藏语音加载动画 +- (void)hideVoiceLoadingAnimation; + +/// 停止打字机效果 +- (void)stopTypewriterEffect; + +@end + +NS_ASSUME_NONNULL_END diff --git a/CustomKeyboard/View/KBChatAssistantCell.m b/CustomKeyboard/View/KBChatAssistantCell.m new file mode 100644 index 0000000..7e6f5f3 --- /dev/null +++ b/CustomKeyboard/View/KBChatAssistantCell.m @@ -0,0 +1,346 @@ +// +// KBChatAssistantCell.m +// CustomKeyboard +// +// AI 消息 Cell(左侧显示,带语音按钮和打字机效果) +// + +#import "KBChatAssistantCell.h" +#import "KBChatMessage.h" +#import "Masonry.h" + +@interface KBChatAssistantCell () + +@property (nonatomic, strong) UIButton *voiceButton; +@property (nonatomic, strong) UILabel *durationLabel; +@property (nonatomic, strong) UIView *bubbleView; +@property (nonatomic, strong) UILabel *messageLabel; +@property (nonatomic, strong) UIActivityIndicatorView *voiceLoadingIndicator; +@property (nonatomic, strong) UIActivityIndicatorView *messageLoadingIndicator; +@property (nonatomic, strong) KBChatMessage *currentMessage; + +/// 打字机效果 +@property (nonatomic, strong) NSTimer *typewriterTimer; +@property (nonatomic, copy) NSString *fullText; +@property (nonatomic, assign) NSInteger currentCharIndex; + +@end + +@implementation KBChatAssistantCell + +- (instancetype)initWithStyle:(UITableViewCellStyle)style + reuseIdentifier:(NSString *)reuseIdentifier { + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + if (self) { + self.backgroundColor = [UIColor clearColor]; + self.contentView.backgroundColor = [UIColor clearColor]; + self.selectionStyle = UITableViewCellSelectionStyleNone; + [self setupUI]; + } + return self; +} + +- (void)setupUI { + [self.contentView addSubview:self.voiceButton]; + [self.contentView addSubview:self.durationLabel]; + [self.contentView addSubview:self.voiceLoadingIndicator]; + [self.contentView addSubview:self.messageLoadingIndicator]; + [self.contentView addSubview:self.bubbleView]; + [self.bubbleView addSubview:self.messageLabel]; + + // 语音按钮 + [self.voiceButton mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.contentView).offset(12); + make.top.equalTo(self.contentView).offset(6); + make.width.height.mas_equalTo(20); + }]; + + // 语音时长 + [self.durationLabel mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.voiceButton.mas_right).offset(4); + make.centerY.equalTo(self.voiceButton); + }]; + + // 语音加载指示器 + [self.voiceLoadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) { + make.center.equalTo(self.voiceButton); + }]; + + // 消息加载指示器 + [self.messageLoadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.contentView).offset(12); + make.top.equalTo(self.voiceButton.mas_bottom).offset(8); + }]; + + // 气泡 + [self.bubbleView mas_makeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(self.voiceButton.mas_bottom).offset(4); + make.bottom.equalTo(self.contentView).offset(-4); + make.left.equalTo(self.contentView).offset(12); + make.width.lessThanOrEqualTo(self.contentView).multipliedBy(0.7); + }]; + + // 消息文本 + [self.messageLabel mas_makeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(self.bubbleView).offset(8); + make.bottom.equalTo(self.bubbleView).offset(-8); + make.left.equalTo(self.bubbleView).offset(12); + make.right.equalTo(self.bubbleView).offset(-12); + make.height.greaterThanOrEqualTo(@18); + }]; +} + +- (void)configureWithMessage:(KBChatMessage *)message { + NSLog(@"[KBChatAssistantCell] ========== configureWithMessage =========="); + NSLog(@"[KBChatAssistantCell] text: %@", message.text); + NSLog(@"[KBChatAssistantCell] outgoing: %d, isLoading: %d, isComplete: %d, needsTypewriter: %d", + message.outgoing, message.isLoading, message.isComplete, message.needsTypewriterEffect); + + // 先停止之前的打字机效果 + [self stopTypewriterEffect]; + + self.currentMessage = message; + + // 处理 loading 状态 + if (message.isLoading) { + NSLog(@"[KBChatAssistantCell] 显示 loading 状态"); + self.messageLabel.attributedText = nil; + self.messageLabel.text = @""; + self.bubbleView.hidden = YES; + self.voiceButton.hidden = YES; + self.durationLabel.hidden = YES; + [self.messageLoadingIndicator startAnimating]; + return; + } + + // 非 loading 状态 + [self.messageLoadingIndicator stopAnimating]; + self.bubbleView.hidden = NO; + + // 语音按钮显示逻辑 + BOOL hasAudio = (message.audioId.length > 0) || (message.audioData.length > 0); + self.voiceButton.hidden = !hasAudio; + self.durationLabel.hidden = !hasAudio; + NSLog(@"[KBChatAssistantCell] hasAudio: %d, audioId: %@", hasAudio, message.audioId); + + // 语音时长 + if (message.audioDuration > 0) { + NSInteger seconds = (NSInteger)ceil(message.audioDuration); + self.durationLabel.text = [NSString stringWithFormat:@"%ld\"", (long)seconds]; + } else { + self.durationLabel.text = @""; + } + + // 打字机效果 + if (message.needsTypewriterEffect && !message.isComplete && message.text.length > 0) { + NSLog(@"[KBChatAssistantCell] ✅ 启动打字机效果"); + [self startTypewriterEffectWithText:message.text]; + } else { + NSLog(@"[KBChatAssistantCell] 直接显示文本(不使用打字机)"); + self.messageLabel.attributedText = nil; + self.messageLabel.text = message.text ?: @""; + } +} + +#pragma mark - Typewriter Effect + +- (void)startTypewriterEffectWithText:(NSString *)text { + if (text.length == 0) return; + + self.fullText = text; + self.currentCharIndex = 0; + + // 先设置完整文本让布局计算正确高度 + self.messageLabel.text = text; + [self.contentView setNeedsLayout]; + [self.contentView layoutIfNeeded]; + + // 应用打字机效果 + dispatch_async(dispatch_get_main_queue(), ^{ + NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text]; + [attributedText addAttribute:NSForegroundColorAttributeName + value:[UIColor clearColor] + range:NSMakeRange(0, text.length)]; + [attributedText addAttribute:NSFontAttributeName + value:self.messageLabel.font + range:NSMakeRange(0, text.length)]; + self.messageLabel.attributedText = attributedText; + + self.typewriterTimer = [NSTimer scheduledTimerWithTimeInterval:0.03 + target:self + selector:@selector(typewriterTick) + userInfo:nil + repeats:YES]; + [[NSRunLoop currentRunLoop] addTimer:self.typewriterTimer forMode:NSRunLoopCommonModes]; + [self typewriterTick]; + }); +} + +- (void)typewriterTick { + NSString *text = self.fullText; + if (!text || text.length == 0) { + [self stopTypewriterEffect]; + return; + } + + if (self.currentCharIndex < text.length) { + self.currentCharIndex++; + + NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text]; + UIColor *textColor = [UIColor whiteColor]; + + if (self.currentCharIndex > 0) { + [attributedText addAttribute:NSForegroundColorAttributeName + value:textColor + range:NSMakeRange(0, self.currentCharIndex)]; + } + if (self.currentCharIndex < text.length) { + [attributedText addAttribute:NSForegroundColorAttributeName + value:[UIColor clearColor] + range:NSMakeRange(self.currentCharIndex, text.length - self.currentCharIndex)]; + } + [attributedText addAttribute:NSFontAttributeName + value:self.messageLabel.font + range:NSMakeRange(0, text.length)]; + + self.messageLabel.attributedText = attributedText; + } else { + [self stopTypewriterEffect]; + + // 显示完整文本 + NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text]; + [attributedText addAttribute:NSForegroundColorAttributeName + value:[UIColor whiteColor] + range:NSMakeRange(0, text.length)]; + [attributedText addAttribute:NSFontAttributeName + value:self.messageLabel.font + range:NSMakeRange(0, text.length)]; + self.messageLabel.attributedText = attributedText; + + // 标记完成 + if (self.currentMessage) { + self.currentMessage.isComplete = YES; + self.currentMessage.needsTypewriterEffect = NO; + } + } +} + +- (void)stopTypewriterEffect { + if (self.typewriterTimer && self.typewriterTimer.isValid) { + [self.typewriterTimer invalidate]; + } + self.typewriterTimer = nil; + self.currentCharIndex = 0; + self.fullText = nil; +} + +#pragma mark - Voice Button + +- (void)updateVoicePlayingState:(BOOL)isPlaying { + UIImage *icon = nil; + if (@available(iOS 13.0, *)) { + icon = isPlaying ? [UIImage systemImageNamed:@"pause.circle.fill"] : [UIImage systemImageNamed:@"play.circle.fill"]; + } + [self.voiceButton setImage:icon forState:UIControlStateNormal]; +} + +- (void)showVoiceLoadingAnimation { + [self.voiceButton setImage:nil forState:UIControlStateNormal]; + [self.voiceLoadingIndicator startAnimating]; +} + +- (void)hideVoiceLoadingAnimation { + [self.voiceLoadingIndicator stopAnimating]; + UIImage *icon = nil; + if (@available(iOS 13.0, *)) { + icon = [UIImage systemImageNamed:@"play.circle.fill"]; + } + [self.voiceButton setImage:icon forState:UIControlStateNormal]; +} + +- (void)voiceButtonTapped { + if ([self.delegate respondsToSelector:@selector(assistantCell:didTapVoiceButtonForMessage:)]) { + [self.delegate assistantCell:self didTapVoiceButtonForMessage:self.currentMessage]; + } +} + +#pragma mark - Reuse + +- (void)prepareForReuse { + [super prepareForReuse]; + [self stopTypewriterEffect]; + self.messageLabel.text = @""; + self.messageLabel.attributedText = nil; + [self.messageLoadingIndicator stopAnimating]; + [self.voiceLoadingIndicator stopAnimating]; +} + +- (void)dealloc { + [self stopTypewriterEffect]; +} + +#pragma mark - Lazy + +- (UIButton *)voiceButton { + if (!_voiceButton) { + _voiceButton = [UIButton buttonWithType:UIButtonTypeCustom]; + UIImage *icon = nil; + if (@available(iOS 13.0, *)) { + icon = [UIImage systemImageNamed:@"play.circle.fill"]; + } + [_voiceButton setImage:icon forState:UIControlStateNormal]; + _voiceButton.tintColor = [UIColor whiteColor]; + [_voiceButton addTarget:self action:@selector(voiceButtonTapped) forControlEvents:UIControlEventTouchUpInside]; + } + return _voiceButton; +} + +- (UILabel *)durationLabel { + if (!_durationLabel) { + _durationLabel = [[UILabel alloc] init]; + _durationLabel.font = [UIFont systemFontOfSize:11]; + _durationLabel.textColor = [UIColor whiteColor]; + } + return _durationLabel; +} + +- (UIActivityIndicatorView *)voiceLoadingIndicator { + if (!_voiceLoadingIndicator) { + _voiceLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; + _voiceLoadingIndicator.color = [UIColor whiteColor]; + _voiceLoadingIndicator.hidesWhenStopped = YES; + } + return _voiceLoadingIndicator; +} + +- (UIActivityIndicatorView *)messageLoadingIndicator { + if (!_messageLoadingIndicator) { + _messageLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; + _messageLoadingIndicator.color = [UIColor whiteColor]; + _messageLoadingIndicator.hidesWhenStopped = YES; + } + return _messageLoadingIndicator; +} + +- (UIView *)bubbleView { + if (!_bubbleView) { + _bubbleView = [[UIView alloc] init]; + _bubbleView.backgroundColor = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.7]; + _bubbleView.layer.cornerRadius = 12; + _bubbleView.layer.masksToBounds = YES; + } + return _bubbleView; +} + +- (UILabel *)messageLabel { + if (!_messageLabel) { + _messageLabel = [[UILabel alloc] init]; + _messageLabel.numberOfLines = 0; + _messageLabel.font = [UIFont systemFontOfSize:14]; + _messageLabel.textColor = [UIColor whiteColor]; + _messageLabel.lineBreakMode = NSLineBreakByWordWrapping; + } + return _messageLabel; +} + +@end diff --git a/CustomKeyboard/View/KBChatMessageCell.h b/CustomKeyboard/View/KBChatMessageCell.h index 1414a7c..382ec37 100644 --- a/CustomKeyboard/View/KBChatMessageCell.h +++ b/CustomKeyboard/View/KBChatMessageCell.h @@ -5,13 +5,34 @@ #import @class KBChatMessage; +@class KBChatMessageCell; NS_ASSUME_NONNULL_BEGIN +@protocol KBChatMessageCellDelegate +@optional +/// 点击语音播放按钮 +- (void)chatMessageCell:(KBChatMessageCell *)cell didTapVoiceButtonForMessage:(KBChatMessage *)message; +@end + @interface KBChatMessageCell : UITableViewCell +@property (nonatomic, weak) id delegate; + - (void)kb_configureWithMessage:(KBChatMessage *)message; +/// 更新语音播放状态 +- (void)kb_updateVoicePlayingState:(BOOL)isPlaying; + +/// 显示语音加载动画 +- (void)kb_showVoiceLoadingAnimation; + +/// 隐藏语音加载动画 +- (void)kb_hideVoiceLoadingAnimation; + +/// 停止打字机效果 +- (void)kb_stopTypewriterEffect; + @end NS_ASSUME_NONNULL_END diff --git a/CustomKeyboard/View/KBChatMessageCell.m b/CustomKeyboard/View/KBChatMessageCell.m index 6f9fc48..88d348f 100644 --- a/CustomKeyboard/View/KBChatMessageCell.m +++ b/CustomKeyboard/View/KBChatMessageCell.m @@ -8,12 +8,31 @@ #import "Masonry.h" @interface KBChatMessageCell () + @property (nonatomic, strong) UIImageView *avatarView; @property (nonatomic, strong) UILabel *nameLabel; @property (nonatomic, strong) UIView *bubbleView; @property (nonatomic, strong) UILabel *messageLabel; @property (nonatomic, strong) UIImageView *audioIconView; @property (nonatomic, strong) UILabel *audioLabel; + +/// 语音播放按钮 +@property (nonatomic, strong) UIButton *voiceButton; +/// 语音时长标签 +@property (nonatomic, strong) UILabel *durationLabel; +/// 语音加载指示器 +@property (nonatomic, strong) UIActivityIndicatorView *voiceLoadingIndicator; +/// 消息加载指示器(AI 回复 loading) +@property (nonatomic, strong) UIActivityIndicatorView *messageLoadingIndicator; + +/// 当前消息 +@property (nonatomic, strong) KBChatMessage *currentMessage; + +/// 打字机效果 +@property (nonatomic, strong) NSTimer *typewriterTimer; +@property (nonatomic, copy) NSString *fullText; +@property (nonatomic, assign) NSInteger currentCharIndex; + @end @implementation KBChatMessageCell @@ -26,6 +45,10 @@ [self.contentView addSubview:self.avatarView]; [self.contentView addSubview:self.nameLabel]; + [self.contentView addSubview:self.voiceButton]; + [self.contentView addSubview:self.durationLabel]; + [self.contentView addSubview:self.voiceLoadingIndicator]; + [self.contentView addSubview:self.messageLoadingIndicator]; [self.contentView addSubview:self.bubbleView]; [self.bubbleView addSubview:self.messageLabel]; [self.bubbleView addSubview:self.audioIconView]; @@ -35,6 +58,11 @@ } - (void)kb_configureWithMessage:(KBChatMessage *)message { + // 先停止之前的打字机效果 + [self kb_stopTypewriterEffect]; + + self.currentMessage = message; + BOOL outgoing = message.outgoing; BOOL audioMessage = (!outgoing && message.audioFilePath.length > 0); UIColor *bubbleColor = outgoing ? [UIColor colorWithHex:0x02BEAC] : [UIColor colorWithWhite:1 alpha:0.95]; @@ -50,7 +78,6 @@ self.messageLabel.textColor = textColor; self.audioLabel.textColor = textColor; self.audioIconView.tintColor = textColor; - self.messageLabel.text = message.text ?: @""; self.audioLabel.text = (message.text.length > 0) ? message.text : KBLocalized(@"语音回复"); self.messageLabel.hidden = audioMessage; @@ -69,6 +96,43 @@ self.nameLabel.text = (message.displayName.length > 0) ? message.displayName : KBLocalized(@"AI助手"); + // 处理 loading 状态 + if (message.isLoading && !outgoing) { + self.bubbleView.hidden = YES; + self.voiceButton.hidden = YES; + self.durationLabel.hidden = YES; + [self.messageLoadingIndicator startAnimating]; + [self kb_layoutForOutgoing:outgoing audioMessage:NO]; + return; + } + + // 非 loading 状态 + [self.messageLoadingIndicator stopAnimating]; + self.bubbleView.hidden = NO; + + // 语音按钮显示逻辑(仅 AI 消息且有 audioId 或 audioData) + BOOL hasAudio = (!outgoing) && (message.audioId.length > 0 || message.audioData.length > 0); + self.voiceButton.hidden = !hasAudio; + self.durationLabel.hidden = !hasAudio; + if (hasAudio && message.audioDuration > 0) { + NSInteger seconds = (NSInteger)ceil(message.audioDuration); + self.durationLabel.text = [NSString stringWithFormat:@"%ld\"", (long)seconds]; + } else { + self.durationLabel.text = @""; + } + + // 打字机效果 + if (!outgoing && message.needsTypewriterEffect && !message.isComplete && message.text.length > 0) { + [self kb_startTypewriterEffectWithText:message.text]; + } else { + self.messageLabel.attributedText = nil; + self.messageLabel.text = message.text ?: @""; + } + + [self kb_layoutForOutgoing:outgoing audioMessage:audioMessage]; +} + +- (void)kb_layoutForOutgoing:(BOOL)outgoing audioMessage:(BOOL)audioMessage { CGFloat avatarSize = 28.0; [self.avatarView mas_remakeConstraints:^(MASConstraintMaker *make) { make.width.height.mas_equalTo(avatarSize); @@ -85,13 +149,45 @@ make.top.equalTo(self.contentView.mas_top).offset(0); make.left.equalTo(self.contentView.mas_left); }]; + // 用户消息不显示语音按钮 + [self.voiceButton mas_remakeConstraints:^(MASConstraintMaker *make) { + make.width.height.mas_equalTo(0); + make.left.top.equalTo(self.contentView); + }]; + [self.durationLabel mas_remakeConstraints:^(MASConstraintMaker *make) { + make.width.height.mas_equalTo(0); + make.left.top.equalTo(self.contentView); + }]; } else { [self.nameLabel mas_remakeConstraints:^(MASConstraintMaker *make) { make.left.equalTo(self.avatarView.mas_right).offset(6); make.top.equalTo(self.contentView.mas_top).offset(2); make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12); }]; + // AI 消息语音按钮 + [self.voiceButton mas_remakeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.avatarView.mas_right).offset(6); + make.top.equalTo(self.nameLabel.mas_bottom).offset(4); + make.width.height.mas_equalTo(20); + }]; + [self.durationLabel mas_remakeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.voiceButton.mas_right).offset(4); + make.centerY.equalTo(self.voiceButton); + }]; + [self.voiceLoadingIndicator mas_remakeConstraints:^(MASConstraintMaker *make) { + make.center.equalTo(self.voiceButton); + }]; } + + // 消息加载指示器 + [self.messageLoadingIndicator mas_remakeConstraints:^(MASConstraintMaker *make) { + if (outgoing) { + make.right.equalTo(self.avatarView.mas_left).offset(-10); + } else { + make.left.equalTo(self.avatarView.mas_right).offset(10); + } + make.top.equalTo(self.nameLabel.mas_bottom).offset(8); + }]; [self.bubbleView mas_remakeConstraints:^(MASConstraintMaker *make) { make.width.lessThanOrEqualTo(self.contentView.mas_width).multipliedBy(0.65); @@ -100,7 +196,8 @@ make.bottom.equalTo(self.contentView.mas_bottom).offset(-6); make.right.equalTo(self.avatarView.mas_left).offset(-6); } else { - make.top.equalTo(self.nameLabel.mas_bottom).offset(2); + // AI 消息:气泡在语音按钮下方 + make.top.equalTo(self.voiceButton.mas_bottom).offset(4); make.bottom.equalTo(self.contentView.mas_bottom).offset(-6); make.left.equalTo(self.avatarView.mas_right).offset(6); make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12); @@ -142,6 +239,144 @@ } } +#pragma mark - Typewriter Effect + +- (void)kb_startTypewriterEffectWithText:(NSString *)text { + if (text.length == 0) return; + + self.fullText = text; + self.currentCharIndex = 0; + + // 先设置完整文本让布局计算正确高度 + self.messageLabel.text = text; + [self.contentView setNeedsLayout]; + [self.contentView layoutIfNeeded]; + + // 应用打字机效果 + dispatch_async(dispatch_get_main_queue(), ^{ + NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text]; + [attributedText addAttribute:NSForegroundColorAttributeName + value:[UIColor clearColor] + range:NSMakeRange(0, text.length)]; + [attributedText addAttribute:NSFontAttributeName + value:self.messageLabel.font + range:NSMakeRange(0, text.length)]; + self.messageLabel.attributedText = attributedText; + + self.typewriterTimer = [NSTimer scheduledTimerWithTimeInterval:0.03 + target:self + selector:@selector(kb_typewriterTick) + userInfo:nil + repeats:YES]; + [[NSRunLoop currentRunLoop] addTimer:self.typewriterTimer forMode:NSRunLoopCommonModes]; + [self kb_typewriterTick]; + }); +} + +- (void)kb_typewriterTick { + NSString *text = self.fullText; + if (!text || text.length == 0) { + [self kb_stopTypewriterEffect]; + return; + } + + if (self.currentCharIndex < text.length) { + self.currentCharIndex++; + + NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text]; + UIColor *textColor = self.messageLabel.textColor ?: [UIColor blackColor]; + + if (self.currentCharIndex > 0) { + [attributedText addAttribute:NSForegroundColorAttributeName + value:textColor + range:NSMakeRange(0, self.currentCharIndex)]; + } + if (self.currentCharIndex < text.length) { + [attributedText addAttribute:NSForegroundColorAttributeName + value:[UIColor clearColor] + range:NSMakeRange(self.currentCharIndex, text.length - self.currentCharIndex)]; + } + [attributedText addAttribute:NSFontAttributeName + value:self.messageLabel.font + range:NSMakeRange(0, text.length)]; + + self.messageLabel.attributedText = attributedText; + } else { + [self kb_stopTypewriterEffect]; + + // 显示完整文本 + NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text]; + UIColor *textColor = self.messageLabel.textColor ?: [UIColor blackColor]; + [attributedText addAttribute:NSForegroundColorAttributeName + value:textColor + range:NSMakeRange(0, text.length)]; + [attributedText addAttribute:NSFontAttributeName + value:self.messageLabel.font + range:NSMakeRange(0, text.length)]; + self.messageLabel.attributedText = attributedText; + + // 标记完成 + if (self.currentMessage) { + self.currentMessage.isComplete = YES; + self.currentMessage.needsTypewriterEffect = NO; + } + } +} + +- (void)kb_stopTypewriterEffect { + if (self.typewriterTimer && self.typewriterTimer.isValid) { + [self.typewriterTimer invalidate]; + } + self.typewriterTimer = nil; + self.currentCharIndex = 0; + self.fullText = nil; +} + +#pragma mark - Voice Button + +- (void)kb_updateVoicePlayingState:(BOOL)isPlaying { + UIImage *icon = nil; + if (@available(iOS 13.0, *)) { + icon = isPlaying ? [UIImage systemImageNamed:@"pause.circle.fill"] : [UIImage systemImageNamed:@"play.circle.fill"]; + } + [self.voiceButton setImage:icon forState:UIControlStateNormal]; +} + +- (void)kb_showVoiceLoadingAnimation { + [self.voiceButton setImage:nil forState:UIControlStateNormal]; + [self.voiceLoadingIndicator startAnimating]; +} + +- (void)kb_hideVoiceLoadingAnimation { + [self.voiceLoadingIndicator stopAnimating]; + UIImage *icon = nil; + if (@available(iOS 13.0, *)) { + icon = [UIImage systemImageNamed:@"play.circle.fill"]; + } + [self.voiceButton setImage:icon forState:UIControlStateNormal]; +} + +- (void)kb_onVoiceButtonTapped { + if ([self.delegate respondsToSelector:@selector(chatMessageCell:didTapVoiceButtonForMessage:)]) { + [self.delegate chatMessageCell:self didTapVoiceButtonForMessage:self.currentMessage]; + } +} + +#pragma mark - Reuse + +- (void)prepareForReuse { + [super prepareForReuse]; + [self kb_stopTypewriterEffect]; + self.messageLabel.text = @""; + self.messageLabel.attributedText = nil; + [self.messageLoadingIndicator stopAnimating]; + [self.voiceLoadingIndicator stopAnimating]; +} + +- (void)dealloc { + [self kb_stopTypewriterEffect]; +} + #pragma mark - Lazy - (UIImageView *)avatarView { @@ -209,6 +444,47 @@ return _audioLabel; } +- (UIButton *)voiceButton { + if (!_voiceButton) { + _voiceButton = [UIButton buttonWithType:UIButtonTypeCustom]; + UIImage *icon = nil; + if (@available(iOS 13.0, *)) { + icon = [UIImage systemImageNamed:@"play.circle.fill"]; + } + [_voiceButton setImage:icon forState:UIControlStateNormal]; + _voiceButton.tintColor = [UIColor whiteColor]; + [_voiceButton addTarget:self action:@selector(kb_onVoiceButtonTapped) forControlEvents:UIControlEventTouchUpInside]; + } + return _voiceButton; +} + +- (UILabel *)durationLabel { + if (!_durationLabel) { + _durationLabel = [[UILabel alloc] init]; + _durationLabel.font = [UIFont systemFontOfSize:11]; + _durationLabel.textColor = [UIColor whiteColor]; + } + return _durationLabel; +} + +- (UIActivityIndicatorView *)voiceLoadingIndicator { + if (!_voiceLoadingIndicator) { + _voiceLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; + _voiceLoadingIndicator.color = [UIColor whiteColor]; + _voiceLoadingIndicator.hidesWhenStopped = YES; + } + return _voiceLoadingIndicator; +} + +- (UIActivityIndicatorView *)messageLoadingIndicator { + if (!_messageLoadingIndicator) { + _messageLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; + _messageLoadingIndicator.color = [UIColor whiteColor]; + _messageLoadingIndicator.hidesWhenStopped = YES; + } + return _messageLoadingIndicator; +} + - (UIImage *)kb_defaultAvatarImage { if (@available(iOS 13.0, *)) { return [UIImage systemImageNamed:@"person.circle.fill"]; diff --git a/CustomKeyboard/View/KBChatPanelView.h b/CustomKeyboard/View/KBChatPanelView.h index 8c4c2ac..d11f2ae 100644 --- a/CustomKeyboard/View/KBChatPanelView.h +++ b/CustomKeyboard/View/KBChatPanelView.h @@ -13,6 +13,8 @@ NS_ASSUME_NONNULL_BEGIN - (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text; - (void)chatPanelView:(KBChatPanelView *)view didTapMessage:(KBChatMessage *)message; - (void)chatPanelViewDidTapClose:(KBChatPanelView *)view; +/// 点击语音播放按钮 +- (void)chatPanelView:(KBChatPanelView *)view didTapVoiceButtonForMessage:(KBChatMessage *)message; @end @interface KBChatPanelView : UIView @@ -24,6 +26,24 @@ NS_ASSUME_NONNULL_BEGIN //- (void)kb_setBackgroundImage:(nullable UIImage *)image; - (void)kb_reloadWithMessages:(NSArray *)messages; +/// 添加用户消息 +- (void)kb_addUserMessage:(NSString *)text; + +/// 添加 loading 状态的 AI 消息 +- (void)kb_addLoadingAssistantMessage; + +/// 移除 loading 状态的 AI 消息 +- (void)kb_removeLoadingAssistantMessage; + +/// 添加 AI 消息(带打字机效果) +- (void)kb_addAssistantMessage:(NSString *)text audioId:(nullable NSString *)audioId; + +/// 更新最后一条 AI 消息的音频数据 +- (void)kb_updateLastAssistantMessageWithAudioData:(NSData *)audioData duration:(NSTimeInterval)duration; + +/// 滚动到底部 +- (void)kb_scrollToBottom; + @end NS_ASSUME_NONNULL_END diff --git a/CustomKeyboard/View/KBChatPanelView.m b/CustomKeyboard/View/KBChatPanelView.m index 05e92e3..f8d04b4 100644 --- a/CustomKeyboard/View/KBChatPanelView.m +++ b/CustomKeyboard/View/KBChatPanelView.m @@ -5,32 +5,33 @@ #import "KBChatPanelView.h" #import "KBChatMessage.h" -#import "KBChatMessageCell.h" +#import "KBChatUserCell.h" +#import "KBChatAssistantCell.h" #import "Masonry.h" -@interface KBChatPanelView () -//@property (nonatomic, strong) UIImageView *backgroundImageView; +static NSString * const kUserCellIdentifier = @"KBChatUserCell"; +static NSString * const kAssistantCellIdentifier = @"KBChatAssistantCell"; +static const NSUInteger kKBChatMessageLimit = 10; + +@interface KBChatPanelView () @property (nonatomic, strong) UIView *headerView; @property (nonatomic, strong) UILabel *titleLabel; @property (nonatomic, strong) UIButton *closeButton; @property (nonatomic, strong) UITableView *tableViewInternal; -@property (nonatomic, copy) NSArray *messages; +@property (nonatomic, strong) NSMutableArray *messages; @end @implementation KBChatPanelView - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { + NSLog(@"[KBChatPanelView] ⚠️ initWithFrame 被调用,self=%p", self); self.backgroundColor = [UIColor clearColor]; + self.messages = [NSMutableArray array]; -// [self addSubview:self.backgroundImageView]; [self addSubview:self.headerView]; [self addSubview:self.tableViewInternal]; -// [self.backgroundImageView mas_makeConstraints:^(MASConstraintMaker *make) { -// make.edges.equalTo(self); -// }]; - [self.headerView mas_makeConstraints:^(MASConstraintMaker *make) { make.left.right.equalTo(self); make.top.equalTo(self.mas_top); @@ -49,19 +50,167 @@ #pragma mark - Public - (void)kb_reloadWithMessages:(NSArray *)messages { - self.messages = messages ?: @[]; + NSLog(@"[KBChatPanelView] ========== kb_reloadWithMessages =========="); + NSLog(@"[KBChatPanelView] self=%p, 传入消息数量: %lu", self, (unsigned long)messages.count); + NSLog(@"[KBChatPanelView] 调用堆栈: %@", [NSThread callStackSymbols]); + + [self.messages removeAllObjects]; + if (messages.count > 0) { + [self.messages addObjectsFromArray:messages]; + } [self.tableViewInternal reloadData]; - if (self.messages.count > 0) { - NSIndexPath *indexPath = [NSIndexPath indexPathForRow:self.messages.count - 1 inSection:0]; - [self.tableViewInternal scrollToRowAtIndexPath:indexPath - atScrollPosition:UITableViewScrollPositionBottom - animated:YES]; + [self kb_scrollToBottom]; +} + +- (void)kb_addUserMessage:(NSString *)text { + if (text.length == 0) return; + + NSLog(@"[KBChatPanelView] ========== kb_addUserMessage =========="); + NSLog(@"[KBChatPanelView] self=%p, messages=%p", self, self.messages); + NSLog(@"[KBChatPanelView] 添加用户消息: %@", text); + NSLog(@"[KBChatPanelView] 当前消息数量: %lu", (unsigned long)self.messages.count); + + KBChatMessage *msg = [KBChatMessage userMessageWithText:text]; + NSLog(@"[KBChatPanelView] 创建消息 - outgoing: %d, text: %@", msg.outgoing, msg.text); + [self kb_appendMessage:msg]; + + NSLog(@"[KBChatPanelView] 添加后消息数量: %lu", (unsigned long)self.messages.count); + for (NSInteger i = 0; i < self.messages.count; i++) { + KBChatMessage *m = self.messages[i]; + NSLog(@"[KBChatPanelView] 消息[%ld]: outgoing=%d, isLoading=%d, text=%@", (long)i, m.outgoing, m.isLoading, m.text); } } -//- (void)kb_setBackgroundImage:(UIImage *)image { -// self.backgroundImageView.image = image; -//} +- (void)kb_addLoadingAssistantMessage { + NSLog(@"[KBChatPanelView] ========== kb_addLoadingAssistantMessage =========="); + NSLog(@"[KBChatPanelView] self=%p, messages=%p", self, self.messages); + NSLog(@"[KBChatPanelView] 当前消息数量: %lu", (unsigned long)self.messages.count); + + KBChatMessage *msg = [KBChatMessage loadingAssistantMessage]; + NSLog(@"[KBChatPanelView] 创建 loading 消息 - outgoing: %d, isLoading: %d", msg.outgoing, msg.isLoading); + [self kb_appendMessage:msg]; + + NSLog(@"[KBChatPanelView] 添加后消息数量: %lu", (unsigned long)self.messages.count); + for (NSInteger i = 0; i < self.messages.count; i++) { + KBChatMessage *m = self.messages[i]; + NSLog(@"[KBChatPanelView] 消息[%ld]: outgoing=%d, isLoading=%d, text=%@", (long)i, m.outgoing, m.isLoading, m.text); + } +} + +- (void)kb_removeLoadingAssistantMessage { + NSLog(@"[KBChatPanelView] ========== kb_removeLoadingAssistantMessage =========="); + NSLog(@"[KBChatPanelView] 当前消息数量: %lu", (unsigned long)self.messages.count); + for (NSInteger i = 0; i < self.messages.count; i++) { + KBChatMessage *m = self.messages[i]; + NSLog(@"[KBChatPanelView] 消息[%ld]: outgoing=%d, isLoading=%d, text=%@", (long)i, m.outgoing, m.isLoading, m.text); + } + + for (NSInteger i = self.messages.count - 1; i >= 0; i--) { + KBChatMessage *msg = self.messages[i]; + NSLog(@"[KBChatPanelView] 检查消息[%ld]: outgoing=%d, isLoading=%d", (long)i, msg.outgoing, msg.isLoading); + // 只移除 AI 消息(outgoing == NO)且是 loading 状态的 + if (!msg.outgoing && msg.isLoading) { + NSLog(@"[KBChatPanelView] ✅ 找到 loading AI 消息,准备移除索引: %ld", (long)i); + [self.messages removeObjectAtIndex:i]; + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; + [self.tableViewInternal deleteRowsAtIndexPaths:@[indexPath] + withRowAnimation:UITableViewRowAnimationNone]; + NSLog(@"[KBChatPanelView] 移除后消息数量: %lu", (unsigned long)self.messages.count); + break; + } + } + + NSLog(@"[KBChatPanelView] 最终消息数量: %lu", (unsigned long)self.messages.count); + for (NSInteger i = 0; i < self.messages.count; i++) { + KBChatMessage *m = self.messages[i]; + NSLog(@"[KBChatPanelView] 最终消息[%ld]: outgoing=%d, isLoading=%d, text=%@", (long)i, m.outgoing, m.isLoading, m.text); + } +} + +- (void)kb_addAssistantMessage:(NSString *)text audioId:(NSString *)audioId { + NSLog(@"[KBChatPanelView] ========== kb_addAssistantMessage =========="); + NSLog(@"[KBChatPanelView] self=%p, messages=%p", self, self.messages); + NSLog(@"[KBChatPanelView] AI 回复文本: %@", text); + NSLog(@"[KBChatPanelView] audioId: %@", audioId); + NSLog(@"[KBChatPanelView] 当前消息数量: %lu", (unsigned long)self.messages.count); + + // 先移除 loading 消息 + [self kb_removeLoadingAssistantMessage]; + + NSLog(@"[KBChatPanelView] 移除 loading 后消息数量: %lu", (unsigned long)self.messages.count); + + KBChatMessage *msg = [KBChatMessage assistantMessageWithText:text audioId:audioId]; + msg.displayName = KBLocalized(@"AI助手"); + NSLog(@"[KBChatPanelView] 创建 AI 消息 - outgoing: %d, isLoading: %d, needsTypewriter: %d, text: %@", + msg.outgoing, msg.isLoading, msg.needsTypewriterEffect, msg.text); + [self kb_appendMessage:msg]; + + NSLog(@"[KBChatPanelView] 添加后消息数量: %lu", (unsigned long)self.messages.count); + for (NSInteger i = 0; i < self.messages.count; i++) { + KBChatMessage *m = self.messages[i]; + NSLog(@"[KBChatPanelView] 消息[%ld]: outgoing=%d, isLoading=%d, text=%@", (long)i, m.outgoing, m.isLoading, m.text); + } +} + +- (void)kb_updateLastAssistantMessageWithAudioData:(NSData *)audioData duration:(NSTimeInterval)duration { + for (NSInteger i = self.messages.count - 1; i >= 0; i--) { + KBChatMessage *msg = self.messages[i]; + // 只更新 AI 消息(outgoing == NO)且非 loading 状态的 + if (!msg.outgoing && !msg.isLoading) { + msg.audioData = audioData; + msg.audioDuration = duration; + + // 刷新该行以更新语音时长显示 + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; + KBChatAssistantCell *cell = [self.tableViewInternal cellForRowAtIndexPath:indexPath]; + if ([cell isKindOfClass:[KBChatAssistantCell class]]) { + // 直接更新 Cell,不刷新整行(避免打断打字机效果) + if (duration > 0) { + // 通过重新配置来更新时长显示 + // 但不要触发打字机效果 + msg.needsTypewriterEffect = NO; + msg.isComplete = YES; + } + } + NSLog(@"[KBChatPanelView] 更新 AI 消息音频数据,时长: %.2f秒", duration); + break; + } + } +} + +- (void)kb_scrollToBottom { + if (self.messages.count == 0) return; + + [self.tableViewInternal layoutIfNeeded]; + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:self.messages.count - 1 inSection:0]; + [self.tableViewInternal scrollToRowAtIndexPath:indexPath + atScrollPosition:UITableViewScrollPositionBottom + animated:YES]; +} + +#pragma mark - Private + +- (void)kb_appendMessage:(KBChatMessage *)message { + if (!message) return; + + NSInteger oldCount = self.messages.count; + [self.messages addObject:message]; + + // 限制消息数量 + if (self.messages.count > kKBChatMessageLimit) { + NSUInteger overflow = self.messages.count - kKBChatMessageLimit; + [self.messages removeObjectsInRange:NSMakeRange(0, overflow)]; + [self.tableViewInternal reloadData]; + } else { + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:oldCount inSection:0]; + [self.tableViewInternal insertRowsAtIndexPaths:@[indexPath] + withRowAnimation:UITableViewRowAnimationNone]; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + [self kb_scrollToBottom]; + }); +} #pragma mark - Actions @@ -78,10 +227,31 @@ } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { - KBChatMessageCell *cell = [tableView dequeueReusableCellWithIdentifier:NSStringFromClass(KBChatMessageCell.class)]; + NSLog(@"[KBChatPanelView] ========== cellForRowAtIndexPath: %ld ==========", (long)indexPath.row); + + if (indexPath.row >= self.messages.count) { + NSLog(@"[KBChatPanelView] ❌ 索引越界,返回空 Cell"); + return [[UITableViewCell alloc] init]; + } + KBChatMessage *msg = self.messages[indexPath.row]; - [cell kb_configureWithMessage:msg]; - return cell; + NSLog(@"[KBChatPanelView] 消息: outgoing=%d, isLoading=%d, needsTypewriter=%d, text=%@", + msg.outgoing, msg.isLoading, msg.needsTypewriterEffect, msg.text); + + if (msg.outgoing) { + // 用户消息(右侧) + NSLog(@"[KBChatPanelView] 使用 KBChatUserCell"); + KBChatUserCell *cell = [tableView dequeueReusableCellWithIdentifier:kUserCellIdentifier forIndexPath:indexPath]; + [cell configureWithMessage:msg]; + return cell; + } else { + // AI 消息(左侧) + NSLog(@"[KBChatPanelView] 使用 KBChatAssistantCell"); + KBChatAssistantCell *cell = [tableView dequeueReusableCellWithIdentifier:kAssistantCellIdentifier forIndexPath:indexPath]; + cell.delegate = self; + [cell configureWithMessage:msg]; + return cell; + } } #pragma mark - UITableViewDelegate @@ -91,7 +261,7 @@ } - (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath { - return 44.0; + return 60.0; } - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { @@ -102,6 +272,14 @@ } } +#pragma mark - KBChatAssistantCellDelegate + +- (void)assistantCell:(KBChatAssistantCell *)cell didTapVoiceButtonForMessage:(KBChatMessage *)message { + if ([self.delegate respondsToSelector:@selector(chatPanelView:didTapVoiceButtonForMessage:)]) { + [self.delegate chatPanelView:self didTapVoiceButtonForMessage:message]; + } +} + #pragma mark - Lazy - (UITableView *)tableViewInternal { @@ -112,9 +290,14 @@ _tableViewInternal.separatorStyle = UITableViewCellSeparatorStyleNone; _tableViewInternal.dataSource = self; _tableViewInternal.delegate = self; - _tableViewInternal.estimatedRowHeight = 44.0; + _tableViewInternal.estimatedRowHeight = 60.0; _tableViewInternal.rowHeight = UITableViewAutomaticDimension; - [_tableViewInternal registerClass:KBChatMessageCell.class forCellReuseIdentifier:NSStringFromClass(KBChatMessageCell.class)]; + // 注册两种 Cell + [_tableViewInternal registerClass:KBChatUserCell.class forCellReuseIdentifier:kUserCellIdentifier]; + [_tableViewInternal registerClass:KBChatAssistantCell.class forCellReuseIdentifier:kAssistantCellIdentifier]; + if (@available(iOS 11.0, *)) { + _tableViewInternal.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + } } return _tableViewInternal; } @@ -165,17 +348,6 @@ return _closeButton; } -//- (UIImageView *)backgroundImageView { -// if (!_backgroundImageView) { -// _backgroundImageView = [[UIImageView alloc] init]; -// _backgroundImageView.contentMode = UIViewContentModeScaleAspectFill; -// _backgroundImageView.clipsToBounds = YES; -// _backgroundImageView.backgroundColor = [UIColor clearColor]; -// _backgroundImageView.userInteractionEnabled = NO; -// } -// return _backgroundImageView; -//} - #pragma mark - Expose - (UITableView *)tableView { return self.tableViewInternal; } diff --git a/CustomKeyboard/View/KBChatUserCell.h b/CustomKeyboard/View/KBChatUserCell.h new file mode 100644 index 0000000..2f04ee2 --- /dev/null +++ b/CustomKeyboard/View/KBChatUserCell.h @@ -0,0 +1,19 @@ +// +// KBChatUserCell.h +// CustomKeyboard +// +// 用户消息 Cell(右侧显示) +// + +#import +@class KBChatMessage; + +NS_ASSUME_NONNULL_BEGIN + +@interface KBChatUserCell : UITableViewCell + +- (void)configureWithMessage:(KBChatMessage *)message; + +@end + +NS_ASSUME_NONNULL_END diff --git a/CustomKeyboard/View/KBChatUserCell.m b/CustomKeyboard/View/KBChatUserCell.m new file mode 100644 index 0000000..a177ffc --- /dev/null +++ b/CustomKeyboard/View/KBChatUserCell.m @@ -0,0 +1,85 @@ +// +// KBChatUserCell.m +// CustomKeyboard +// +// 用户消息 Cell(右侧显示) +// + +#import "KBChatUserCell.h" +#import "KBChatMessage.h" +#import "Masonry.h" + +@interface KBChatUserCell () + +@property (nonatomic, strong) UIView *bubbleView; +@property (nonatomic, strong) UILabel *messageLabel; + +@end + +@implementation KBChatUserCell + +- (instancetype)initWithStyle:(UITableViewCellStyle)style + reuseIdentifier:(NSString *)reuseIdentifier { + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + if (self) { + self.backgroundColor = [UIColor clearColor]; + self.contentView.backgroundColor = [UIColor clearColor]; + self.selectionStyle = UITableViewCellSelectionStyleNone; + [self setupUI]; + } + return self; +} + +- (void)setupUI { + [self.contentView addSubview:self.bubbleView]; + [self.bubbleView addSubview:self.messageLabel]; + + [self.bubbleView mas_makeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(self.contentView).offset(4); + make.bottom.equalTo(self.contentView).offset(-4); + make.right.equalTo(self.contentView).offset(-12); + make.width.lessThanOrEqualTo(self.contentView).multipliedBy(0.7); + make.height.greaterThanOrEqualTo(@36); + }]; + + [self.messageLabel mas_makeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(self.bubbleView).offset(8); + make.bottom.equalTo(self.bubbleView).offset(-8); + make.left.equalTo(self.bubbleView).offset(12); + make.right.equalTo(self.bubbleView).offset(-12); + }]; +} + +- (void)configureWithMessage:(KBChatMessage *)message { + self.messageLabel.text = message.text ?: @""; +} + +- (void)prepareForReuse { + [super prepareForReuse]; + self.messageLabel.text = @""; +} + +#pragma mark - Lazy + +- (UIView *)bubbleView { + if (!_bubbleView) { + _bubbleView = [[UIView alloc] init]; + _bubbleView.backgroundColor = [UIColor colorWithHex:0x02BEAC]; + _bubbleView.layer.cornerRadius = 12; + _bubbleView.layer.masksToBounds = YES; + } + return _bubbleView; +} + +- (UILabel *)messageLabel { + if (!_messageLabel) { + _messageLabel = [[UILabel alloc] init]; + _messageLabel.numberOfLines = 0; + _messageLabel.font = [UIFont systemFontOfSize:14]; + _messageLabel.textColor = [UIColor whiteColor]; + _messageLabel.lineBreakMode = NSLineBreakByWordWrapping; + } + return _messageLabel; +} + +@end diff --git a/keyBoard.xcodeproj/project.pbxproj b/keyBoard.xcodeproj/project.pbxproj index 675727c..a15644f 100644 --- a/keyBoard.xcodeproj/project.pbxproj +++ b/keyBoard.xcodeproj/project.pbxproj @@ -159,6 +159,8 @@ 048FFD4A2F2B4AE4005D62AE /* KBAICompanionDetailModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD492F2B4AE4005D62AE /* KBAICompanionDetailModel.m */; }; 048FFD502F2B52E7005D62AE /* AIReportVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD4F2F2B52E7005D62AE /* AIReportVC.m */; }; 048FFD512F2B68F7005D62AE /* KBPersonaModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD0D2F27432D005D62AE /* KBPersonaModel.m */; }; + 048FFD562F2B9C3D005D62AE /* KBChatAssistantCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD532F2B9C3D005D62AE /* KBChatAssistantCell.m */; }; + 048FFD572F2B9C3D005D62AE /* KBChatUserCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD552F2B9C3D005D62AE /* KBChatUserCell.m */; }; 0498BD622EDFFC12006CC1D5 /* KBMyVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD612EDFFC12006CC1D5 /* KBMyVM.m */; }; 0498BD652EE0116D006CC1D5 /* KBEmailLoginVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD642EE0116D006CC1D5 /* KBEmailLoginVC.m */; }; 0498BD682EE01180006CC1D5 /* KBEmailRegistVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD672EE01180006CC1D5 /* KBEmailRegistVC.m */; }; @@ -587,6 +589,10 @@ 048FFD492F2B4AE4005D62AE /* KBAICompanionDetailModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAICompanionDetailModel.m; sourceTree = ""; }; 048FFD4E2F2B52E7005D62AE /* AIReportVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AIReportVC.h; sourceTree = ""; }; 048FFD4F2F2B52E7005D62AE /* AIReportVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AIReportVC.m; sourceTree = ""; }; + 048FFD522F2B9C3D005D62AE /* KBChatAssistantCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatAssistantCell.h; sourceTree = ""; }; + 048FFD532F2B9C3D005D62AE /* KBChatAssistantCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatAssistantCell.m; sourceTree = ""; }; + 048FFD542F2B9C3D005D62AE /* KBChatUserCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatUserCell.h; sourceTree = ""; }; + 048FFD552F2B9C3D005D62AE /* KBChatUserCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatUserCell.m; sourceTree = ""; }; 0498BD5E2EDF2157006CC1D5 /* KBBizCode.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBBizCode.h; sourceTree = ""; }; 0498BD602EDFFC12006CC1D5 /* KBMyVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBMyVM.h; sourceTree = ""; }; 0498BD612EDFFC12006CC1D5 /* KBMyVM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBMyVM.m; sourceTree = ""; }; @@ -966,6 +972,13 @@ path = WMDragView; sourceTree = ""; }; + 0419C9632F2C7630002E86D3 /* VM */ = { + isa = PBXGroup; + children = ( + ); + path = VM; + sourceTree = ""; + }; 0450ABFB2EF11E4400B6AF06 /* Converts */ = { isa = PBXGroup; children = ( @@ -1532,6 +1545,7 @@ 04C6EAD72EAF870B0089C901 /* CustomKeyboard */ = { isa = PBXGroup; children = ( + 0419C9632F2C7630002E86D3 /* VM */, 041007D02ECE010100D203BB /* Resource */, 0477BD942EBAFF4E0055D639 /* Utils */, 04A9FE122EB4D0D20020DB6D /* Manager */, @@ -1583,6 +1597,10 @@ A1B2C9232FC9000100000001 /* KBChatMessageCell.m */, 049FB22D2EC34EB900FAB05D /* KBStreamTextView.h */, 049FB22E2EC34EB900FAB05D /* KBStreamTextView.m */, + 048FFD522F2B9C3D005D62AE /* KBChatAssistantCell.h */, + 048FFD532F2B9C3D005D62AE /* KBChatAssistantCell.m */, + 048FFD542F2B9C3D005D62AE /* KBChatUserCell.h */, + 048FFD552F2B9C3D005D62AE /* KBChatUserCell.m */, 049FB23A2EC4766700FAB05D /* Function */, ); path = View; @@ -2264,10 +2282,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-keyBoard/Pods-keyBoard-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-keyBoard/Pods-keyBoard-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-keyBoard/Pods-keyBoard-frameworks.sh\"\n"; @@ -2306,6 +2328,8 @@ 04791FFC2ED71D17004E8522 /* UIColor+Extension.m in Sources */, 0450AC4A2EF2C3ED00B6AF06 /* KBKeyboardSubscriptionOptionCell.m in Sources */, 04A9FE0F2EB481100020DB6D /* KBHUD.m in Sources */, + 048FFD562F2B9C3D005D62AE /* KBChatAssistantCell.m in Sources */, + 048FFD572F2B9C3D005D62AE /* KBChatUserCell.m in Sources */, 04C6EADD2EAF8CEB0089C901 /* KBToolBar.m in Sources */, A1B2C9262FC9000100000001 /* KBChatMessage.m in Sources */, A1B2C9272FC9000100000001 /* KBChatMessageCell.m in Sources */, diff --git a/keyBoard/Class/AiTalk/VM/AiVM.h b/keyBoard/Class/AiTalk/VM/AiVM.h index 95e7481..458ce85 100644 --- a/keyBoard/Class/AiTalk/VM/AiVM.h +++ b/keyBoard/Class/AiTalk/VM/AiVM.h @@ -74,6 +74,7 @@ typedef void (^AiVMSpeechTranscribeCompletion)(KBAiSpeechTranscribeResponse *_Nu - (void)syncChatWithTranscript:(NSString *)transcript completion:(AiVMSyncCompletion)completion; +/// ai文本润色,同时获取音频id - (void)requestChatMessageWithContent:(NSString *)content companionId:(NSInteger)companionId completion:(AiVMMessageCompletion)completion;