处理键盘
This commit is contained in:
@@ -802,18 +802,26 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
|||||||
[self kb_playChatAudioAtPath:message.audioFilePath];
|
[self kb_playChatAudioAtPath:message.audioFilePath];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)chatPanelViewDidTapClose:(KBChatPanelView *)view {
|
- (void)chatPanelView:(KBChatPanelView *)view didTapVoiceButtonForMessage:(KBChatMessage *)message {
|
||||||
for (KBChatMessage *msg in self.chatMessages) {
|
if (!message) return;
|
||||||
if (msg.audioFilePath.length > 0) {
|
|
||||||
NSString *tmpRoot = NSTemporaryDirectory();
|
// 如果有 audioData,直接播放
|
||||||
if (tmpRoot.length > 0 &&
|
if (message.audioData && message.audioData.length > 0) {
|
||||||
[msg.audioFilePath hasPrefix:tmpRoot]) {
|
[self kb_playChatAudioData:message.audioData];
|
||||||
[[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath
|
return;
|
||||||
error:nil];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
[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:@[]];
|
[self.chatPanelView kb_reloadWithMessages:@[]];
|
||||||
if (self.chatAudioPlayer.isPlaying) {
|
if (self.chatAudioPlayer.isPlaying) {
|
||||||
[self.chatAudioPlayer stop];
|
[self.chatAudioPlayer stop];
|
||||||
@@ -847,17 +855,25 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
|||||||
if (text.length == 0) {
|
if (text.length == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
KBChatMessage *outgoing =
|
NSLog(@"[Keyboard] ========== kb_sendChatText ==========");
|
||||||
[KBChatMessage messageWithText:text outgoing:YES audioFilePath:nil];
|
NSLog(@"[Keyboard] chatPanelView=%p", self.chatPanelView);
|
||||||
|
|
||||||
|
KBChatMessage *outgoing = [KBChatMessage userMessageWithText:text];
|
||||||
outgoing.avatarURL = [self kb_sharedUserAvatarURL];
|
outgoing.avatarURL = [self kb_sharedUserAvatarURL];
|
||||||
[self kb_appendChatMessage:outgoing];
|
[self.chatPanelView kb_addUserMessage:text];
|
||||||
[self kb_prefetchAvatarForMessage:outgoing];
|
[self kb_prefetchAvatarForMessage:outgoing];
|
||||||
|
|
||||||
if (![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) {
|
if (![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) {
|
||||||
[KBHUD showInfo:KBLocalized(@"请开启完全访问后使用")];
|
[KBHUD showInfo:KBLocalized(@"请开启完全访问后使用")];
|
||||||
return;
|
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 {
|
- (void)kb_clearHostInputForText:(NSString *)text {
|
||||||
@@ -937,23 +953,16 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (void)kb_reloadChatRowForMessage:(KBChatMessage *)message {
|
- (void)kb_reloadChatRowForMessage:(KBChatMessage *)message {
|
||||||
NSUInteger idx = [self.chatMessages indexOfObject:message];
|
NSLog(@"[Keyboard] ========== kb_reloadChatRowForMessage ==========");
|
||||||
if (idx == NSNotFound) {
|
// 不再使用 self.chatMessages,直接刷新 tableView
|
||||||
[self.chatPanelView kb_reloadWithMessages:self.chatMessages];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
UITableView *tableView = self.chatPanelView.tableView;
|
UITableView *tableView = self.chatPanelView.tableView;
|
||||||
if (!tableView) {
|
if (!tableView) {
|
||||||
[self.chatPanelView kb_reloadWithMessages:self.chatMessages];
|
NSLog(@"[Keyboard] tableView 为空,跳过");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (idx >= (NSUInteger)[tableView numberOfRowsInSection:0]) {
|
// 刷新整个 tableView
|
||||||
[self.chatPanelView kb_reloadWithMessages:self.chatMessages];
|
NSLog(@"[Keyboard] 调用 tableView reloadData");
|
||||||
return;
|
[tableView reloadData];
|
||||||
}
|
|
||||||
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:idx inSection:0];
|
|
||||||
[tableView reloadRowsAtIndexPaths:@[ indexPath ]
|
|
||||||
withRowAnimation:UITableViewRowAnimationNone];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)kb_requestChatAudioForText:(NSString *)text {
|
- (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
|
- (void)kb_downloadChatAudioFromURL:(NSString *)audioURL
|
||||||
displayText:(NSString *)displayText {
|
displayText:(NSString *)displayText {
|
||||||
__weak typeof(self) weakSelf = self;
|
__weak typeof(self) weakSelf = self;
|
||||||
@@ -1217,6 +1494,48 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
|||||||
[player play];
|
[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
|
#pragma mark - KBKeyboardSubscriptionViewDelegate
|
||||||
|
|
||||||
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view {
|
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view {
|
||||||
@@ -1272,6 +1591,7 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
|||||||
|
|
||||||
- (KBChatPanelView *)chatPanelView {
|
- (KBChatPanelView *)chatPanelView {
|
||||||
if (!_chatPanelView) {
|
if (!_chatPanelView) {
|
||||||
|
NSLog(@"[Keyboard] ⚠️ chatPanelView 被创建!");
|
||||||
_chatPanelView = [[KBChatPanelView alloc] init];
|
_chatPanelView = [[KBChatPanelView alloc] init];
|
||||||
_chatPanelView.delegate = self;
|
_chatPanelView.delegate = self;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,33 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
@property (nonatomic, copy, nullable) NSString *displayName;
|
@property (nonatomic, copy, nullable) NSString *displayName;
|
||||||
@property (nonatomic, strong, nullable) UIImage *avatarImage;
|
@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
|
+ (instancetype)messageWithText:(NSString *)text
|
||||||
outgoing:(BOOL)outgoing
|
outgoing:(BOOL)outgoing
|
||||||
audioFilePath:(nullable NSString *)audioFilePath;
|
audioFilePath:(nullable NSString *)audioFilePath;
|
||||||
|
|
||||||
|
/// 创建用户消息
|
||||||
|
+ (instancetype)userMessageWithText:(NSString *)text;
|
||||||
|
|
||||||
|
/// 创建 AI 消息(带 audioId)
|
||||||
|
+ (instancetype)assistantMessageWithText:(NSString *)text
|
||||||
|
audioId:(nullable NSString *)audioId;
|
||||||
|
|
||||||
|
/// 创建加载中的 AI 消息
|
||||||
|
+ (instancetype)loadingAssistantMessage;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
NS_ASSUME_NONNULL_END
|
||||||
|
|||||||
@@ -14,6 +14,41 @@
|
|||||||
msg.text = text ?: @"";
|
msg.text = text ?: @"";
|
||||||
msg.outgoing = outgoing;
|
msg.outgoing = outgoing;
|
||||||
msg.audioFilePath = audioFilePath;
|
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;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
CustomKeyboard/View/KBChatAssistantCell.h
Normal file
40
CustomKeyboard/View/KBChatAssistantCell.h
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//
|
||||||
|
// KBChatAssistantCell.h
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// AI 消息 Cell(左侧显示,带语音按钮和打字机效果)
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
@class KBChatMessage;
|
||||||
|
@class KBChatAssistantCell;
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@protocol KBChatAssistantCellDelegate <NSObject>
|
||||||
|
@optional
|
||||||
|
/// 点击语音播放按钮
|
||||||
|
- (void)assistantCell:(KBChatAssistantCell *)cell didTapVoiceButtonForMessage:(KBChatMessage *)message;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KBChatAssistantCell : UITableViewCell
|
||||||
|
|
||||||
|
@property (nonatomic, weak) id<KBChatAssistantCellDelegate> delegate;
|
||||||
|
|
||||||
|
- (void)configureWithMessage:(KBChatMessage *)message;
|
||||||
|
|
||||||
|
/// 更新语音播放状态
|
||||||
|
- (void)updateVoicePlayingState:(BOOL)isPlaying;
|
||||||
|
|
||||||
|
/// 显示语音加载动画
|
||||||
|
- (void)showVoiceLoadingAnimation;
|
||||||
|
|
||||||
|
/// 隐藏语音加载动画
|
||||||
|
- (void)hideVoiceLoadingAnimation;
|
||||||
|
|
||||||
|
/// 停止打字机效果
|
||||||
|
- (void)stopTypewriterEffect;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
346
CustomKeyboard/View/KBChatAssistantCell.m
Normal file
346
CustomKeyboard/View/KBChatAssistantCell.m
Normal file
@@ -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
|
||||||
@@ -5,13 +5,34 @@
|
|||||||
|
|
||||||
#import <UIKit/UIKit.h>
|
#import <UIKit/UIKit.h>
|
||||||
@class KBChatMessage;
|
@class KBChatMessage;
|
||||||
|
@class KBChatMessageCell;
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@protocol KBChatMessageCellDelegate <NSObject>
|
||||||
|
@optional
|
||||||
|
/// 点击语音播放按钮
|
||||||
|
- (void)chatMessageCell:(KBChatMessageCell *)cell didTapVoiceButtonForMessage:(KBChatMessage *)message;
|
||||||
|
@end
|
||||||
|
|
||||||
@interface KBChatMessageCell : UITableViewCell
|
@interface KBChatMessageCell : UITableViewCell
|
||||||
|
|
||||||
|
@property (nonatomic, weak) id<KBChatMessageCellDelegate> delegate;
|
||||||
|
|
||||||
- (void)kb_configureWithMessage:(KBChatMessage *)message;
|
- (void)kb_configureWithMessage:(KBChatMessage *)message;
|
||||||
|
|
||||||
|
/// 更新语音播放状态
|
||||||
|
- (void)kb_updateVoicePlayingState:(BOOL)isPlaying;
|
||||||
|
|
||||||
|
/// 显示语音加载动画
|
||||||
|
- (void)kb_showVoiceLoadingAnimation;
|
||||||
|
|
||||||
|
/// 隐藏语音加载动画
|
||||||
|
- (void)kb_hideVoiceLoadingAnimation;
|
||||||
|
|
||||||
|
/// 停止打字机效果
|
||||||
|
- (void)kb_stopTypewriterEffect;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
NS_ASSUME_NONNULL_END
|
||||||
|
|||||||
@@ -8,12 +8,31 @@
|
|||||||
#import "Masonry.h"
|
#import "Masonry.h"
|
||||||
|
|
||||||
@interface KBChatMessageCell ()
|
@interface KBChatMessageCell ()
|
||||||
|
|
||||||
@property (nonatomic, strong) UIImageView *avatarView;
|
@property (nonatomic, strong) UIImageView *avatarView;
|
||||||
@property (nonatomic, strong) UILabel *nameLabel;
|
@property (nonatomic, strong) UILabel *nameLabel;
|
||||||
@property (nonatomic, strong) UIView *bubbleView;
|
@property (nonatomic, strong) UIView *bubbleView;
|
||||||
@property (nonatomic, strong) UILabel *messageLabel;
|
@property (nonatomic, strong) UILabel *messageLabel;
|
||||||
@property (nonatomic, strong) UIImageView *audioIconView;
|
@property (nonatomic, strong) UIImageView *audioIconView;
|
||||||
@property (nonatomic, strong) UILabel *audioLabel;
|
@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
|
@end
|
||||||
|
|
||||||
@implementation KBChatMessageCell
|
@implementation KBChatMessageCell
|
||||||
@@ -26,6 +45,10 @@
|
|||||||
|
|
||||||
[self.contentView addSubview:self.avatarView];
|
[self.contentView addSubview:self.avatarView];
|
||||||
[self.contentView addSubview:self.nameLabel];
|
[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.contentView addSubview:self.bubbleView];
|
||||||
[self.bubbleView addSubview:self.messageLabel];
|
[self.bubbleView addSubview:self.messageLabel];
|
||||||
[self.bubbleView addSubview:self.audioIconView];
|
[self.bubbleView addSubview:self.audioIconView];
|
||||||
@@ -35,6 +58,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (void)kb_configureWithMessage:(KBChatMessage *)message {
|
- (void)kb_configureWithMessage:(KBChatMessage *)message {
|
||||||
|
// 先停止之前的打字机效果
|
||||||
|
[self kb_stopTypewriterEffect];
|
||||||
|
|
||||||
|
self.currentMessage = message;
|
||||||
|
|
||||||
BOOL outgoing = message.outgoing;
|
BOOL outgoing = message.outgoing;
|
||||||
BOOL audioMessage = (!outgoing && message.audioFilePath.length > 0);
|
BOOL audioMessage = (!outgoing && message.audioFilePath.length > 0);
|
||||||
UIColor *bubbleColor = outgoing ? [UIColor colorWithHex:0x02BEAC] : [UIColor colorWithWhite:1 alpha:0.95];
|
UIColor *bubbleColor = outgoing ? [UIColor colorWithHex:0x02BEAC] : [UIColor colorWithWhite:1 alpha:0.95];
|
||||||
@@ -50,7 +78,6 @@
|
|||||||
self.messageLabel.textColor = textColor;
|
self.messageLabel.textColor = textColor;
|
||||||
self.audioLabel.textColor = textColor;
|
self.audioLabel.textColor = textColor;
|
||||||
self.audioIconView.tintColor = textColor;
|
self.audioIconView.tintColor = textColor;
|
||||||
self.messageLabel.text = message.text ?: @"";
|
|
||||||
self.audioLabel.text =
|
self.audioLabel.text =
|
||||||
(message.text.length > 0) ? message.text : KBLocalized(@"语音回复");
|
(message.text.length > 0) ? message.text : KBLocalized(@"语音回复");
|
||||||
self.messageLabel.hidden = audioMessage;
|
self.messageLabel.hidden = audioMessage;
|
||||||
@@ -69,6 +96,43 @@
|
|||||||
self.nameLabel.text =
|
self.nameLabel.text =
|
||||||
(message.displayName.length > 0) ? message.displayName : KBLocalized(@"AI助手");
|
(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;
|
CGFloat avatarSize = 28.0;
|
||||||
[self.avatarView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
[self.avatarView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
make.width.height.mas_equalTo(avatarSize);
|
make.width.height.mas_equalTo(avatarSize);
|
||||||
@@ -85,13 +149,45 @@
|
|||||||
make.top.equalTo(self.contentView.mas_top).offset(0);
|
make.top.equalTo(self.contentView.mas_top).offset(0);
|
||||||
make.left.equalTo(self.contentView.mas_left);
|
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 {
|
} else {
|
||||||
[self.nameLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
[self.nameLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
make.left.equalTo(self.avatarView.mas_right).offset(6);
|
make.left.equalTo(self.avatarView.mas_right).offset(6);
|
||||||
make.top.equalTo(self.contentView.mas_top).offset(2);
|
make.top.equalTo(self.contentView.mas_top).offset(2);
|
||||||
make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12);
|
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) {
|
[self.bubbleView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
make.width.lessThanOrEqualTo(self.contentView.mas_width).multipliedBy(0.65);
|
make.width.lessThanOrEqualTo(self.contentView.mas_width).multipliedBy(0.65);
|
||||||
@@ -100,7 +196,8 @@
|
|||||||
make.bottom.equalTo(self.contentView.mas_bottom).offset(-6);
|
make.bottom.equalTo(self.contentView.mas_bottom).offset(-6);
|
||||||
make.right.equalTo(self.avatarView.mas_left).offset(-6);
|
make.right.equalTo(self.avatarView.mas_left).offset(-6);
|
||||||
} else {
|
} 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.bottom.equalTo(self.contentView.mas_bottom).offset(-6);
|
||||||
make.left.equalTo(self.avatarView.mas_right).offset(6);
|
make.left.equalTo(self.avatarView.mas_right).offset(6);
|
||||||
make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12);
|
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
|
#pragma mark - Lazy
|
||||||
|
|
||||||
- (UIImageView *)avatarView {
|
- (UIImageView *)avatarView {
|
||||||
@@ -209,6 +444,47 @@
|
|||||||
return _audioLabel;
|
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 {
|
- (UIImage *)kb_defaultAvatarImage {
|
||||||
if (@available(iOS 13.0, *)) {
|
if (@available(iOS 13.0, *)) {
|
||||||
return [UIImage systemImageNamed:@"person.circle.fill"];
|
return [UIImage systemImageNamed:@"person.circle.fill"];
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text;
|
- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text;
|
||||||
- (void)chatPanelView:(KBChatPanelView *)view didTapMessage:(KBChatMessage *)message;
|
- (void)chatPanelView:(KBChatPanelView *)view didTapMessage:(KBChatMessage *)message;
|
||||||
- (void)chatPanelViewDidTapClose:(KBChatPanelView *)view;
|
- (void)chatPanelViewDidTapClose:(KBChatPanelView *)view;
|
||||||
|
/// 点击语音播放按钮
|
||||||
|
- (void)chatPanelView:(KBChatPanelView *)view didTapVoiceButtonForMessage:(KBChatMessage *)message;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@interface KBChatPanelView : UIView
|
@interface KBChatPanelView : UIView
|
||||||
@@ -24,6 +26,24 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
//- (void)kb_setBackgroundImage:(nullable UIImage *)image;
|
//- (void)kb_setBackgroundImage:(nullable UIImage *)image;
|
||||||
- (void)kb_reloadWithMessages:(NSArray<KBChatMessage *> *)messages;
|
- (void)kb_reloadWithMessages:(NSArray<KBChatMessage *> *)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
|
@end
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
NS_ASSUME_NONNULL_END
|
||||||
|
|||||||
@@ -5,32 +5,33 @@
|
|||||||
|
|
||||||
#import "KBChatPanelView.h"
|
#import "KBChatPanelView.h"
|
||||||
#import "KBChatMessage.h"
|
#import "KBChatMessage.h"
|
||||||
#import "KBChatMessageCell.h"
|
#import "KBChatUserCell.h"
|
||||||
|
#import "KBChatAssistantCell.h"
|
||||||
#import "Masonry.h"
|
#import "Masonry.h"
|
||||||
|
|
||||||
@interface KBChatPanelView () <UITableViewDataSource, UITableViewDelegate>
|
static NSString * const kUserCellIdentifier = @"KBChatUserCell";
|
||||||
//@property (nonatomic, strong) UIImageView *backgroundImageView;
|
static NSString * const kAssistantCellIdentifier = @"KBChatAssistantCell";
|
||||||
|
static const NSUInteger kKBChatMessageLimit = 10;
|
||||||
|
|
||||||
|
@interface KBChatPanelView () <UITableViewDataSource, UITableViewDelegate, KBChatAssistantCellDelegate>
|
||||||
@property (nonatomic, strong) UIView *headerView;
|
@property (nonatomic, strong) UIView *headerView;
|
||||||
@property (nonatomic, strong) UILabel *titleLabel;
|
@property (nonatomic, strong) UILabel *titleLabel;
|
||||||
@property (nonatomic, strong) UIButton *closeButton;
|
@property (nonatomic, strong) UIButton *closeButton;
|
||||||
@property (nonatomic, strong) UITableView *tableViewInternal;
|
@property (nonatomic, strong) UITableView *tableViewInternal;
|
||||||
@property (nonatomic, copy) NSArray<KBChatMessage *> *messages;
|
@property (nonatomic, strong) NSMutableArray<KBChatMessage *> *messages;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation KBChatPanelView
|
@implementation KBChatPanelView
|
||||||
|
|
||||||
- (instancetype)initWithFrame:(CGRect)frame {
|
- (instancetype)initWithFrame:(CGRect)frame {
|
||||||
if (self = [super initWithFrame:frame]) {
|
if (self = [super initWithFrame:frame]) {
|
||||||
|
NSLog(@"[KBChatPanelView] ⚠️ initWithFrame 被调用,self=%p", self);
|
||||||
self.backgroundColor = [UIColor clearColor];
|
self.backgroundColor = [UIColor clearColor];
|
||||||
|
self.messages = [NSMutableArray array];
|
||||||
|
|
||||||
// [self addSubview:self.backgroundImageView];
|
|
||||||
[self addSubview:self.headerView];
|
[self addSubview:self.headerView];
|
||||||
[self addSubview:self.tableViewInternal];
|
[self addSubview:self.tableViewInternal];
|
||||||
|
|
||||||
// [self.backgroundImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
// make.edges.equalTo(self);
|
|
||||||
// }];
|
|
||||||
|
|
||||||
[self.headerView mas_makeConstraints:^(MASConstraintMaker *make) {
|
[self.headerView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
make.left.right.equalTo(self);
|
make.left.right.equalTo(self);
|
||||||
make.top.equalTo(self.mas_top);
|
make.top.equalTo(self.mas_top);
|
||||||
@@ -49,19 +50,167 @@
|
|||||||
#pragma mark - Public
|
#pragma mark - Public
|
||||||
|
|
||||||
- (void)kb_reloadWithMessages:(NSArray<KBChatMessage *> *)messages {
|
- (void)kb_reloadWithMessages:(NSArray<KBChatMessage *> *)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];
|
[self.tableViewInternal reloadData];
|
||||||
if (self.messages.count > 0) {
|
[self kb_scrollToBottom];
|
||||||
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:self.messages.count - 1 inSection:0];
|
}
|
||||||
[self.tableViewInternal scrollToRowAtIndexPath:indexPath
|
|
||||||
atScrollPosition:UITableViewScrollPositionBottom
|
- (void)kb_addUserMessage:(NSString *)text {
|
||||||
animated:YES];
|
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 {
|
- (void)kb_addLoadingAssistantMessage {
|
||||||
// self.backgroundImageView.image = image;
|
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
|
#pragma mark - Actions
|
||||||
|
|
||||||
@@ -78,10 +227,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
|
- (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];
|
KBChatMessage *msg = self.messages[indexPath.row];
|
||||||
[cell kb_configureWithMessage:msg];
|
NSLog(@"[KBChatPanelView] 消息: outgoing=%d, isLoading=%d, needsTypewriter=%d, text=%@",
|
||||||
return cell;
|
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
|
#pragma mark - UITableViewDelegate
|
||||||
@@ -91,7 +261,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
|
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||||
return 44.0;
|
return 60.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
|
- (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
|
#pragma mark - Lazy
|
||||||
|
|
||||||
- (UITableView *)tableViewInternal {
|
- (UITableView *)tableViewInternal {
|
||||||
@@ -112,9 +290,14 @@
|
|||||||
_tableViewInternal.separatorStyle = UITableViewCellSeparatorStyleNone;
|
_tableViewInternal.separatorStyle = UITableViewCellSeparatorStyleNone;
|
||||||
_tableViewInternal.dataSource = self;
|
_tableViewInternal.dataSource = self;
|
||||||
_tableViewInternal.delegate = self;
|
_tableViewInternal.delegate = self;
|
||||||
_tableViewInternal.estimatedRowHeight = 44.0;
|
_tableViewInternal.estimatedRowHeight = 60.0;
|
||||||
_tableViewInternal.rowHeight = UITableViewAutomaticDimension;
|
_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;
|
return _tableViewInternal;
|
||||||
}
|
}
|
||||||
@@ -165,17 +348,6 @@
|
|||||||
return _closeButton;
|
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
|
#pragma mark - Expose
|
||||||
|
|
||||||
- (UITableView *)tableView { return self.tableViewInternal; }
|
- (UITableView *)tableView { return self.tableViewInternal; }
|
||||||
|
|||||||
19
CustomKeyboard/View/KBChatUserCell.h
Normal file
19
CustomKeyboard/View/KBChatUserCell.h
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//
|
||||||
|
// KBChatUserCell.h
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// 用户消息 Cell(右侧显示)
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
@class KBChatMessage;
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@interface KBChatUserCell : UITableViewCell
|
||||||
|
|
||||||
|
- (void)configureWithMessage:(KBChatMessage *)message;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
85
CustomKeyboard/View/KBChatUserCell.m
Normal file
85
CustomKeyboard/View/KBChatUserCell.m
Normal file
@@ -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
|
||||||
@@ -159,6 +159,8 @@
|
|||||||
048FFD4A2F2B4AE4005D62AE /* KBAICompanionDetailModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD492F2B4AE4005D62AE /* KBAICompanionDetailModel.m */; };
|
048FFD4A2F2B4AE4005D62AE /* KBAICompanionDetailModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD492F2B4AE4005D62AE /* KBAICompanionDetailModel.m */; };
|
||||||
048FFD502F2B52E7005D62AE /* AIReportVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD4F2F2B52E7005D62AE /* AIReportVC.m */; };
|
048FFD502F2B52E7005D62AE /* AIReportVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD4F2F2B52E7005D62AE /* AIReportVC.m */; };
|
||||||
048FFD512F2B68F7005D62AE /* KBPersonaModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD0D2F27432D005D62AE /* KBPersonaModel.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 */; };
|
0498BD622EDFFC12006CC1D5 /* KBMyVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD612EDFFC12006CC1D5 /* KBMyVM.m */; };
|
||||||
0498BD652EE0116D006CC1D5 /* KBEmailLoginVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD642EE0116D006CC1D5 /* KBEmailLoginVC.m */; };
|
0498BD652EE0116D006CC1D5 /* KBEmailLoginVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD642EE0116D006CC1D5 /* KBEmailLoginVC.m */; };
|
||||||
0498BD682EE01180006CC1D5 /* KBEmailRegistVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD672EE01180006CC1D5 /* KBEmailRegistVC.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 = "<group>"; };
|
048FFD492F2B4AE4005D62AE /* KBAICompanionDetailModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAICompanionDetailModel.m; sourceTree = "<group>"; };
|
||||||
048FFD4E2F2B52E7005D62AE /* AIReportVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AIReportVC.h; sourceTree = "<group>"; };
|
048FFD4E2F2B52E7005D62AE /* AIReportVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AIReportVC.h; sourceTree = "<group>"; };
|
||||||
048FFD4F2F2B52E7005D62AE /* AIReportVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AIReportVC.m; sourceTree = "<group>"; };
|
048FFD4F2F2B52E7005D62AE /* AIReportVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AIReportVC.m; sourceTree = "<group>"; };
|
||||||
|
048FFD522F2B9C3D005D62AE /* KBChatAssistantCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatAssistantCell.h; sourceTree = "<group>"; };
|
||||||
|
048FFD532F2B9C3D005D62AE /* KBChatAssistantCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatAssistantCell.m; sourceTree = "<group>"; };
|
||||||
|
048FFD542F2B9C3D005D62AE /* KBChatUserCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatUserCell.h; sourceTree = "<group>"; };
|
||||||
|
048FFD552F2B9C3D005D62AE /* KBChatUserCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatUserCell.m; sourceTree = "<group>"; };
|
||||||
0498BD5E2EDF2157006CC1D5 /* KBBizCode.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBBizCode.h; sourceTree = "<group>"; };
|
0498BD5E2EDF2157006CC1D5 /* KBBizCode.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBBizCode.h; sourceTree = "<group>"; };
|
||||||
0498BD602EDFFC12006CC1D5 /* KBMyVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBMyVM.h; sourceTree = "<group>"; };
|
0498BD602EDFFC12006CC1D5 /* KBMyVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBMyVM.h; sourceTree = "<group>"; };
|
||||||
0498BD612EDFFC12006CC1D5 /* KBMyVM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBMyVM.m; sourceTree = "<group>"; };
|
0498BD612EDFFC12006CC1D5 /* KBMyVM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBMyVM.m; sourceTree = "<group>"; };
|
||||||
@@ -966,6 +972,13 @@
|
|||||||
path = WMDragView;
|
path = WMDragView;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
0419C9632F2C7630002E86D3 /* VM */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
);
|
||||||
|
path = VM;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
0450ABFB2EF11E4400B6AF06 /* Converts */ = {
|
0450ABFB2EF11E4400B6AF06 /* Converts */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -1532,6 +1545,7 @@
|
|||||||
04C6EAD72EAF870B0089C901 /* CustomKeyboard */ = {
|
04C6EAD72EAF870B0089C901 /* CustomKeyboard */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
0419C9632F2C7630002E86D3 /* VM */,
|
||||||
041007D02ECE010100D203BB /* Resource */,
|
041007D02ECE010100D203BB /* Resource */,
|
||||||
0477BD942EBAFF4E0055D639 /* Utils */,
|
0477BD942EBAFF4E0055D639 /* Utils */,
|
||||||
04A9FE122EB4D0D20020DB6D /* Manager */,
|
04A9FE122EB4D0D20020DB6D /* Manager */,
|
||||||
@@ -1583,6 +1597,10 @@
|
|||||||
A1B2C9232FC9000100000001 /* KBChatMessageCell.m */,
|
A1B2C9232FC9000100000001 /* KBChatMessageCell.m */,
|
||||||
049FB22D2EC34EB900FAB05D /* KBStreamTextView.h */,
|
049FB22D2EC34EB900FAB05D /* KBStreamTextView.h */,
|
||||||
049FB22E2EC34EB900FAB05D /* KBStreamTextView.m */,
|
049FB22E2EC34EB900FAB05D /* KBStreamTextView.m */,
|
||||||
|
048FFD522F2B9C3D005D62AE /* KBChatAssistantCell.h */,
|
||||||
|
048FFD532F2B9C3D005D62AE /* KBChatAssistantCell.m */,
|
||||||
|
048FFD542F2B9C3D005D62AE /* KBChatUserCell.h */,
|
||||||
|
048FFD552F2B9C3D005D62AE /* KBChatUserCell.m */,
|
||||||
049FB23A2EC4766700FAB05D /* Function */,
|
049FB23A2EC4766700FAB05D /* Function */,
|
||||||
);
|
);
|
||||||
path = View;
|
path = View;
|
||||||
@@ -2264,10 +2282,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-keyBoard/Pods-keyBoard-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-keyBoard/Pods-keyBoard-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-keyBoard/Pods-keyBoard-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-keyBoard/Pods-keyBoard-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-keyBoard/Pods-keyBoard-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-keyBoard/Pods-keyBoard-frameworks.sh\"\n";
|
||||||
@@ -2306,6 +2328,8 @@
|
|||||||
04791FFC2ED71D17004E8522 /* UIColor+Extension.m in Sources */,
|
04791FFC2ED71D17004E8522 /* UIColor+Extension.m in Sources */,
|
||||||
0450AC4A2EF2C3ED00B6AF06 /* KBKeyboardSubscriptionOptionCell.m in Sources */,
|
0450AC4A2EF2C3ED00B6AF06 /* KBKeyboardSubscriptionOptionCell.m in Sources */,
|
||||||
04A9FE0F2EB481100020DB6D /* KBHUD.m in Sources */,
|
04A9FE0F2EB481100020DB6D /* KBHUD.m in Sources */,
|
||||||
|
048FFD562F2B9C3D005D62AE /* KBChatAssistantCell.m in Sources */,
|
||||||
|
048FFD572F2B9C3D005D62AE /* KBChatUserCell.m in Sources */,
|
||||||
04C6EADD2EAF8CEB0089C901 /* KBToolBar.m in Sources */,
|
04C6EADD2EAF8CEB0089C901 /* KBToolBar.m in Sources */,
|
||||||
A1B2C9262FC9000100000001 /* KBChatMessage.m in Sources */,
|
A1B2C9262FC9000100000001 /* KBChatMessage.m in Sources */,
|
||||||
A1B2C9272FC9000100000001 /* KBChatMessageCell.m in Sources */,
|
A1B2C9272FC9000100000001 /* KBChatMessageCell.m in Sources */,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ typedef void (^AiVMSpeechTranscribeCompletion)(KBAiSpeechTranscribeResponse *_Nu
|
|||||||
- (void)syncChatWithTranscript:(NSString *)transcript
|
- (void)syncChatWithTranscript:(NSString *)transcript
|
||||||
completion:(AiVMSyncCompletion)completion;
|
completion:(AiVMSyncCompletion)completion;
|
||||||
|
|
||||||
|
/// ai文本润色,同时获取音频id
|
||||||
- (void)requestChatMessageWithContent:(NSString *)content
|
- (void)requestChatMessageWithContent:(NSString *)content
|
||||||
companionId:(NSInteger)companionId
|
companionId:(NSInteger)companionId
|
||||||
completion:(AiVMMessageCompletion)completion;
|
completion:(AiVMMessageCompletion)completion;
|
||||||
|
|||||||
Reference in New Issue
Block a user