1
This commit is contained in:
@@ -41,7 +41,7 @@
|
|||||||
// 基础baseUrl
|
// 基础baseUrl
|
||||||
#ifndef KB_BASE_URL
|
#ifndef KB_BASE_URL
|
||||||
//#define KB_BASE_URL @"https://m1.apifoxmock.com/m1/5438099-5113192-default/"
|
//#define KB_BASE_URL @"https://m1.apifoxmock.com/m1/5438099-5113192-default/"
|
||||||
#define KB_BASE_URL @"http://192.168.2.21:7529/api"
|
#define KB_BASE_URL @"http://192.168.2.22:7529/api"
|
||||||
//#define KB_BASE_URL @"https://devcallback.loveamorkey.com/api"
|
//#define KB_BASE_URL @"https://devcallback.loveamorkey.com/api"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,12 @@
|
|||||||
04E038E82F20E877002CA5A0 /* DeepgramWebSocketClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038E72F20E877002CA5A0 /* DeepgramWebSocketClient.m */; };
|
04E038E82F20E877002CA5A0 /* DeepgramWebSocketClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038E72F20E877002CA5A0 /* DeepgramWebSocketClient.m */; };
|
||||||
04E038E92F20E877002CA5A0 /* DeepgramStreamingManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */; };
|
04E038E92F20E877002CA5A0 /* DeepgramStreamingManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */; };
|
||||||
04E038EF2F21F0EC002CA5A0 /* AiVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038EE2F21F0EC002CA5A0 /* AiVM.m */; };
|
04E038EF2F21F0EC002CA5A0 /* AiVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038EE2F21F0EC002CA5A0 /* AiVM.m */; };
|
||||||
|
04E0394B2F236E75002CA5A0 /* KBChatUserMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */; };
|
||||||
|
04E0394C2F236E75002CA5A0 /* KBChatTimeCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039482F236E75002CA5A0 /* KBChatTimeCell.m */; };
|
||||||
|
04E0394D2F236E75002CA5A0 /* KBChatAssistantMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039432F236E75002CA5A0 /* KBChatAssistantMessageCell.m */; };
|
||||||
|
04E0394E2F236E75002CA5A0 /* KBChatTableView.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039452F236E75002CA5A0 /* KBChatTableView.m */; };
|
||||||
|
04E0394F2F236E75002CA5A0 /* KBChatTableView_Usage.md in Resources */ = {isa = PBXBuildFile; fileRef = 04E039462F236E75002CA5A0 /* KBChatTableView_Usage.md */; };
|
||||||
|
04E039522F2387D2002CA5A0 /* KBAiChatMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039512F2387D2002CA5A0 /* KBAiChatMessage.m */; };
|
||||||
04E161832F10E6470022C23B /* normal_hei_them.zip in Resources */ = {isa = PBXBuildFile; fileRef = 04E161812F10E6470022C23B /* normal_hei_them.zip */; };
|
04E161832F10E6470022C23B /* normal_hei_them.zip in Resources */ = {isa = PBXBuildFile; fileRef = 04E161812F10E6470022C23B /* normal_hei_them.zip */; };
|
||||||
04E161842F10E6470022C23B /* normal_them.zip in Resources */ = {isa = PBXBuildFile; fileRef = 04E161822F10E6470022C23B /* normal_them.zip */; };
|
04E161842F10E6470022C23B /* normal_them.zip in Resources */ = {isa = PBXBuildFile; fileRef = 04E161822F10E6470022C23B /* normal_them.zip */; };
|
||||||
04FC95672EB0546C007BD342 /* KBKey.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95652EB0546C007BD342 /* KBKey.m */; };
|
04FC95672EB0546C007BD342 /* KBKey.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95652EB0546C007BD342 /* KBKey.m */; };
|
||||||
@@ -627,6 +633,17 @@
|
|||||||
04E038E72F20E877002CA5A0 /* DeepgramWebSocketClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DeepgramWebSocketClient.m; sourceTree = "<group>"; };
|
04E038E72F20E877002CA5A0 /* DeepgramWebSocketClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DeepgramWebSocketClient.m; sourceTree = "<group>"; };
|
||||||
04E038ED2F21F0EC002CA5A0 /* AiVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AiVM.h; sourceTree = "<group>"; };
|
04E038ED2F21F0EC002CA5A0 /* AiVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AiVM.h; sourceTree = "<group>"; };
|
||||||
04E038EE2F21F0EC002CA5A0 /* AiVM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AiVM.m; sourceTree = "<group>"; };
|
04E038EE2F21F0EC002CA5A0 /* AiVM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AiVM.m; sourceTree = "<group>"; };
|
||||||
|
04E039422F236E75002CA5A0 /* KBChatAssistantMessageCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatAssistantMessageCell.h; sourceTree = "<group>"; };
|
||||||
|
04E039432F236E75002CA5A0 /* KBChatAssistantMessageCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatAssistantMessageCell.m; sourceTree = "<group>"; };
|
||||||
|
04E039442F236E75002CA5A0 /* KBChatTableView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatTableView.h; sourceTree = "<group>"; };
|
||||||
|
04E039452F236E75002CA5A0 /* KBChatTableView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatTableView.m; sourceTree = "<group>"; };
|
||||||
|
04E039462F236E75002CA5A0 /* KBChatTableView_Usage.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = KBChatTableView_Usage.md; sourceTree = "<group>"; };
|
||||||
|
04E039472F236E75002CA5A0 /* KBChatTimeCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatTimeCell.h; sourceTree = "<group>"; };
|
||||||
|
04E039482F236E75002CA5A0 /* KBChatTimeCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatTimeCell.m; sourceTree = "<group>"; };
|
||||||
|
04E039492F236E75002CA5A0 /* KBChatUserMessageCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatUserMessageCell.h; sourceTree = "<group>"; };
|
||||||
|
04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatUserMessageCell.m; sourceTree = "<group>"; };
|
||||||
|
04E039502F2387D2002CA5A0 /* KBAiChatMessage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAiChatMessage.h; sourceTree = "<group>"; };
|
||||||
|
04E039512F2387D2002CA5A0 /* KBAiChatMessage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAiChatMessage.m; sourceTree = "<group>"; };
|
||||||
04E161812F10E6470022C23B /* normal_hei_them.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = normal_hei_them.zip; sourceTree = "<group>"; };
|
04E161812F10E6470022C23B /* normal_hei_them.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = normal_hei_them.zip; sourceTree = "<group>"; };
|
||||||
04E161822F10E6470022C23B /* normal_them.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = normal_them.zip; sourceTree = "<group>"; };
|
04E161822F10E6470022C23B /* normal_them.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = normal_them.zip; sourceTree = "<group>"; };
|
||||||
04FC953A2EAFAE56007BD342 /* KeyBoardPrefixHeader.pch */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KeyBoardPrefixHeader.pch; sourceTree = "<group>"; };
|
04FC953A2EAFAE56007BD342 /* KeyBoardPrefixHeader.pch */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KeyBoardPrefixHeader.pch; sourceTree = "<group>"; };
|
||||||
@@ -946,6 +963,8 @@
|
|||||||
046086C82F1A092500757C95 /* KBAICommentModel.m */,
|
046086C82F1A092500757C95 /* KBAICommentModel.m */,
|
||||||
046086C92F1A092500757C95 /* KBAIReplyModel.h */,
|
046086C92F1A092500757C95 /* KBAIReplyModel.h */,
|
||||||
046086CA2F1A092500757C95 /* KBAIReplyModel.m */,
|
046086CA2F1A092500757C95 /* KBAIReplyModel.m */,
|
||||||
|
04E039502F2387D2002CA5A0 /* KBAiChatMessage.h */,
|
||||||
|
04E039512F2387D2002CA5A0 /* KBAiChatMessage.m */,
|
||||||
);
|
);
|
||||||
path = M;
|
path = M;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -969,6 +988,15 @@
|
|||||||
046086D32F1A093400757C95 /* KBAICommentInputView.m */,
|
046086D32F1A093400757C95 /* KBAICommentInputView.m */,
|
||||||
046086D42F1A093400757C95 /* KBAIReplyCell.h */,
|
046086D42F1A093400757C95 /* KBAIReplyCell.h */,
|
||||||
046086D52F1A093400757C95 /* KBAIReplyCell.m */,
|
046086D52F1A093400757C95 /* KBAIReplyCell.m */,
|
||||||
|
04E039422F236E75002CA5A0 /* KBChatAssistantMessageCell.h */,
|
||||||
|
04E039432F236E75002CA5A0 /* KBChatAssistantMessageCell.m */,
|
||||||
|
04E039442F236E75002CA5A0 /* KBChatTableView.h */,
|
||||||
|
04E039452F236E75002CA5A0 /* KBChatTableView.m */,
|
||||||
|
04E039462F236E75002CA5A0 /* KBChatTableView_Usage.md */,
|
||||||
|
04E039472F236E75002CA5A0 /* KBChatTimeCell.h */,
|
||||||
|
04E039482F236E75002CA5A0 /* KBChatTimeCell.m */,
|
||||||
|
04E039492F236E75002CA5A0 /* KBChatUserMessageCell.h */,
|
||||||
|
04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */,
|
||||||
);
|
);
|
||||||
path = V;
|
path = V;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -2042,6 +2070,7 @@
|
|||||||
046086752F191CC700757C95 /* AI技术分析.txt in Resources */,
|
046086752F191CC700757C95 /* AI技术分析.txt in Resources */,
|
||||||
047920112ED98E7D004E8522 /* permiss_video_2.mp4 in Resources */,
|
047920112ED98E7D004E8522 /* permiss_video_2.mp4 in Resources */,
|
||||||
04C6EABC2EAF86530089C901 /* LaunchScreen.storyboard in Resources */,
|
04C6EABC2EAF86530089C901 /* LaunchScreen.storyboard in Resources */,
|
||||||
|
04E0394F2F236E75002CA5A0 /* KBChatTableView_Usage.md in Resources */,
|
||||||
04286A132ECDEBF900CE730C /* KBSkinIconMap.strings in Resources */,
|
04286A132ECDEBF900CE730C /* KBSkinIconMap.strings in Resources */,
|
||||||
04C6EABD2EAF86530089C901 /* Main.storyboard in Resources */,
|
04C6EABD2EAF86530089C901 /* Main.storyboard in Resources */,
|
||||||
046086CB2F1A092500757C95 /* comments_mock.json in Resources */,
|
046086CB2F1A092500757C95 /* comments_mock.json in Resources */,
|
||||||
@@ -2195,6 +2224,10 @@
|
|||||||
046086D72F1A093400757C95 /* KBAICommentFooterView.m in Sources */,
|
046086D72F1A093400757C95 /* KBAICommentFooterView.m in Sources */,
|
||||||
046086D82F1A093400757C95 /* KBAIReplyCell.m in Sources */,
|
046086D82F1A093400757C95 /* KBAIReplyCell.m in Sources */,
|
||||||
046086D92F1A093400757C95 /* KBAICommentHeaderView.m in Sources */,
|
046086D92F1A093400757C95 /* KBAICommentHeaderView.m in Sources */,
|
||||||
|
04E0394B2F236E75002CA5A0 /* KBChatUserMessageCell.m in Sources */,
|
||||||
|
04E0394C2F236E75002CA5A0 /* KBChatTimeCell.m in Sources */,
|
||||||
|
04E0394D2F236E75002CA5A0 /* KBChatAssistantMessageCell.m in Sources */,
|
||||||
|
04E0394E2F236E75002CA5A0 /* KBChatTableView.m in Sources */,
|
||||||
04286A062ECC81B200CE730C /* KBSkinService.m in Sources */,
|
04286A062ECC81B200CE730C /* KBSkinService.m in Sources */,
|
||||||
0479204A2EDDCE25004E8522 /* KBUserSessionManager.m in Sources */,
|
0479204A2EDDCE25004E8522 /* KBUserSessionManager.m in Sources */,
|
||||||
04122FAD2EC73C0100EF7AB3 /* KBVipSubscribeCell.m in Sources */,
|
04122FAD2EC73C0100EF7AB3 /* KBVipSubscribeCell.m in Sources */,
|
||||||
@@ -2334,6 +2367,7 @@
|
|||||||
047C65502EBCBA9E0035E841 /* KBShopVC.m in Sources */,
|
047C65502EBCBA9E0035E841 /* KBShopVC.m in Sources */,
|
||||||
0477BE042EBC83130055D639 /* HomeMainVC.m in Sources */,
|
0477BE042EBC83130055D639 /* HomeMainVC.m in Sources */,
|
||||||
0477BDFD2EBC6A170055D639 /* HomeHotVC.m in Sources */,
|
0477BDFD2EBC6A170055D639 /* HomeHotVC.m in Sources */,
|
||||||
|
04E039522F2387D2002CA5A0 /* KBAiChatMessage.m in Sources */,
|
||||||
0460869A2F19238500757C95 /* KBAiWaveformView.m in Sources */,
|
0460869A2F19238500757C95 /* KBAiWaveformView.m in Sources */,
|
||||||
0460869B2F19238500757C95 /* KBAiChatView.m in Sources */,
|
0460869B2F19238500757C95 /* KBAiChatView.m in Sources */,
|
||||||
0460869C2F19238500757C95 /* KBAiRecordButton.m in Sources */,
|
0460869C2F19238500757C95 /* KBAiRecordButton.m in Sources */,
|
||||||
|
|||||||
68
keyBoard/Class/AiTalk/M/KBAiChatMessage.h
Normal file
68
keyBoard/Class/AiTalk/M/KBAiChatMessage.h
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
//
|
||||||
|
// KBAiChatMessage.h
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
/// 消息类型
|
||||||
|
typedef NS_ENUM(NSInteger, KBAiChatMessageType) {
|
||||||
|
KBAiChatMessageTypeUser, // 用户消息
|
||||||
|
KBAiChatMessageTypeAssistant, // AI 回复
|
||||||
|
KBAiChatMessageTypeTime // 时间戳
|
||||||
|
};
|
||||||
|
|
||||||
|
/// AI 聊天消息模型
|
||||||
|
@interface KBAiChatMessage : NSObject
|
||||||
|
|
||||||
|
/// 消息类型
|
||||||
|
@property (nonatomic, assign) KBAiChatMessageType type;
|
||||||
|
|
||||||
|
/// 文本内容
|
||||||
|
@property (nonatomic, copy) NSString *text;
|
||||||
|
|
||||||
|
/// 消息时间戳
|
||||||
|
@property (nonatomic, strong) NSDate *timestamp;
|
||||||
|
|
||||||
|
/// 语音时长(秒)- 仅 AI 消息使用
|
||||||
|
@property (nonatomic, assign) NSTimeInterval audioDuration;
|
||||||
|
|
||||||
|
/// 语音数据 - 仅 AI 消息使用
|
||||||
|
@property (nonatomic, strong, nullable) NSData *audioData;
|
||||||
|
|
||||||
|
/// 音频 ID - 用于异步加载音频
|
||||||
|
@property (nonatomic, copy, nullable) NSString *audioId;
|
||||||
|
|
||||||
|
/// 是否完成(用于打字机效果)
|
||||||
|
@property (nonatomic, assign) BOOL isComplete;
|
||||||
|
|
||||||
|
/// 是否需要打字机效果(只有当前正在输入的消息才需要)
|
||||||
|
@property (nonatomic, assign) BOOL needsTypewriterEffect;
|
||||||
|
|
||||||
|
#pragma mark - 便捷构造方法
|
||||||
|
|
||||||
|
/// 创建用户消息
|
||||||
|
+ (instancetype)userMessageWithText:(NSString *)text;
|
||||||
|
|
||||||
|
/// 创建 AI 消息(带语音)
|
||||||
|
+ (instancetype)assistantMessageWithText:(NSString *)text
|
||||||
|
audioDuration:(NSTimeInterval)duration
|
||||||
|
audioData:(nullable NSData *)audioData;
|
||||||
|
|
||||||
|
/// 创建 AI 消息(带 audioId,异步加载音频)
|
||||||
|
+ (instancetype)assistantMessageWithText:(NSString *)text
|
||||||
|
audioId:(nullable NSString *)audioId;
|
||||||
|
|
||||||
|
/// 创建 AI 消息(仅文本,无音频)
|
||||||
|
+ (instancetype)assistantMessageWithText:(NSString *)text;
|
||||||
|
|
||||||
|
/// 创建时间戳消息
|
||||||
|
+ (instancetype)timeMessageWithTimestamp:(NSDate *)timestamp;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
67
keyBoard/Class/AiTalk/M/KBAiChatMessage.m
Normal file
67
keyBoard/Class/AiTalk/M/KBAiChatMessage.m
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// KBAiChatMessage.m
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBAiChatMessage.h"
|
||||||
|
|
||||||
|
@implementation KBAiChatMessage
|
||||||
|
|
||||||
|
+ (instancetype)userMessageWithText:(NSString *)text {
|
||||||
|
KBAiChatMessage *message = [[KBAiChatMessage alloc] init];
|
||||||
|
message.type = KBAiChatMessageTypeUser;
|
||||||
|
message.text = text;
|
||||||
|
message.timestamp = [NSDate date];
|
||||||
|
message.isComplete = YES;
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (instancetype)assistantMessageWithText:(NSString *)text
|
||||||
|
audioDuration:(NSTimeInterval)duration
|
||||||
|
audioData:(NSData *)audioData {
|
||||||
|
KBAiChatMessage *message = [[KBAiChatMessage alloc] init];
|
||||||
|
message.type = KBAiChatMessageTypeAssistant;
|
||||||
|
message.text = text;
|
||||||
|
message.timestamp = [NSDate date];
|
||||||
|
message.audioDuration = duration;
|
||||||
|
message.audioData = audioData;
|
||||||
|
message.isComplete = NO;
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (instancetype)assistantMessageWithText:(NSString *)text
|
||||||
|
audioId:(NSString *)audioId {
|
||||||
|
KBAiChatMessage *message = [[KBAiChatMessage alloc] init];
|
||||||
|
message.type = KBAiChatMessageTypeAssistant;
|
||||||
|
message.text = text;
|
||||||
|
message.timestamp = [NSDate date];
|
||||||
|
message.audioId = audioId;
|
||||||
|
message.audioDuration = 0;
|
||||||
|
message.audioData = nil;
|
||||||
|
message.isComplete = NO;
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (instancetype)assistantMessageWithText:(NSString *)text {
|
||||||
|
KBAiChatMessage *message = [[KBAiChatMessage alloc] init];
|
||||||
|
message.type = KBAiChatMessageTypeAssistant;
|
||||||
|
message.text = text;
|
||||||
|
message.timestamp = [NSDate date];
|
||||||
|
message.audioId = nil;
|
||||||
|
message.audioDuration = 0;
|
||||||
|
message.audioData = nil;
|
||||||
|
message.isComplete = NO;
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (instancetype)timeMessageWithTimestamp:(NSDate *)timestamp {
|
||||||
|
KBAiChatMessage *message = [[KBAiChatMessage alloc] init];
|
||||||
|
message.type = KBAiChatMessageTypeTime;
|
||||||
|
message.timestamp = timestamp;
|
||||||
|
message.isComplete = YES;
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
251
keyBoard/Class/AiTalk/V/API_快速参考.md
Normal file
251
keyBoard/Class/AiTalk/V/API_快速参考.md
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
# KBChatTableView API 快速参考
|
||||||
|
|
||||||
|
## 📦 Import
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
#import "KBChatTableView.h"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 初始化
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
KBChatTableView *chatView = [[KBChatTableView alloc] init];
|
||||||
|
[self.view addSubview:chatView];
|
||||||
|
|
||||||
|
[chatView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.edges.equalTo(self.view);
|
||||||
|
}];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 API 方法
|
||||||
|
|
||||||
|
### 添加用户消息
|
||||||
|
```objective-c
|
||||||
|
- (void)addUserMessage:(NSString *)text;
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```objective-c
|
||||||
|
[self.chatView addUserMessage:@"你好"];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 添加 AI 消息(带语音)
|
||||||
|
```objective-c
|
||||||
|
- (void)addAssistantMessage:(NSString *)text
|
||||||
|
audioDuration:(NSTimeInterval)duration
|
||||||
|
audioData:(nullable NSData *)audioData;
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```objective-c
|
||||||
|
// 有语音
|
||||||
|
[self.chatView addAssistantMessage:@"你好!"
|
||||||
|
audioDuration:3.0
|
||||||
|
audioData:audioData];
|
||||||
|
|
||||||
|
// 无语音
|
||||||
|
[self.chatView addAssistantMessage:@"你好!"
|
||||||
|
audioDuration:0
|
||||||
|
audioData:nil];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 更新最后一条 AI 消息(打字机效果)
|
||||||
|
```objective-c
|
||||||
|
- (void)updateLastAssistantMessage:(NSString *)text;
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```objective-c
|
||||||
|
// 1. 先添加空消息
|
||||||
|
[self.chatView addAssistantMessage:@"" audioDuration:0 audioData:nil];
|
||||||
|
|
||||||
|
// 2. 逐步更新
|
||||||
|
[self.chatView updateLastAssistantMessage:@"你"];
|
||||||
|
[self.chatView updateLastAssistantMessage:@"你好"];
|
||||||
|
[self.chatView updateLastAssistantMessage:@"你好!"];
|
||||||
|
|
||||||
|
// 3. 标记完成
|
||||||
|
[self.chatView markLastAssistantMessageComplete];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 标记最后一条 AI 消息完成
|
||||||
|
```objective-c
|
||||||
|
- (void)markLastAssistantMessageComplete;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 清空所有消息
|
||||||
|
```objective-c
|
||||||
|
- (void)clearMessages;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 滚动到底部
|
||||||
|
```objective-c
|
||||||
|
- (void)scrollToBottom;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 消息类型
|
||||||
|
|
||||||
|
### KBChatMessage
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
@interface KBChatMessage : NSObject
|
||||||
|
|
||||||
|
@property (nonatomic, assign) KBChatMessageType type;
|
||||||
|
@property (nonatomic, copy) NSString *text;
|
||||||
|
@property (nonatomic, strong) NSDate *timestamp;
|
||||||
|
@property (nonatomic, assign) NSTimeInterval audioDuration;
|
||||||
|
@property (nonatomic, strong, nullable) NSData *audioData;
|
||||||
|
@property (nonatomic, assign) BOOL isComplete;
|
||||||
|
|
||||||
|
// 便捷构造方法
|
||||||
|
+ (instancetype)userMessageWithText:(NSString *)text;
|
||||||
|
+ (instancetype)assistantMessageWithText:(NSString *)text
|
||||||
|
audioDuration:(NSTimeInterval)duration
|
||||||
|
audioData:(nullable NSData *)audioData;
|
||||||
|
+ (instancetype)timeMessageWithTimestamp:(NSDate *)timestamp;
|
||||||
|
|
||||||
|
@end
|
||||||
|
```
|
||||||
|
|
||||||
|
### KBChatMessageType
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
typedef NS_ENUM(NSInteger, KBChatMessageType) {
|
||||||
|
KBChatMessageTypeUser, // 用户消息
|
||||||
|
KBChatMessageTypeAssistant, // AI 回复
|
||||||
|
KBChatMessageTypeTime // 时间戳
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔊 音频时长计算
|
||||||
|
|
||||||
|
### 方法 1:使用 AVAudioPlayer(推荐)
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
NSError *error = nil;
|
||||||
|
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:audioData error:&error];
|
||||||
|
NSTimeInterval duration = player ? player.duration : 0;
|
||||||
|
|
||||||
|
[self.chatView addAssistantMessage:text
|
||||||
|
audioDuration:duration
|
||||||
|
audioData:audioData];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法 2:估算(不准确)
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
// 假设:16kHz 采样率,单声道,16 位
|
||||||
|
NSTimeInterval duration = audioData.length / (16000.0 * 1 * 2);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⏰ 时间戳规则
|
||||||
|
|
||||||
|
时间戳自动插入,无需手动添加:
|
||||||
|
|
||||||
|
- ✅ 第一条消息
|
||||||
|
- ✅ 距离上一条消息 > 5 分钟
|
||||||
|
- ✅ 跨天的消息
|
||||||
|
|
||||||
|
**时间格式:**
|
||||||
|
- 今天:`16:36`
|
||||||
|
- 昨天:`昨天 16:36`
|
||||||
|
- 其他:`01月23日 16:36`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 完整示例
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
// 1. 用户发送消息
|
||||||
|
[self.chatView addUserMessage:@"你好"];
|
||||||
|
|
||||||
|
// 2. AI 回复(带语音)
|
||||||
|
NSString *aiText = @"你好!很高兴见到你。";
|
||||||
|
NSData *audioData = ...; // 从 TTS 获取
|
||||||
|
|
||||||
|
NSError *error = nil;
|
||||||
|
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:audioData error:&error];
|
||||||
|
NSTimeInterval duration = player ? player.duration : 0;
|
||||||
|
|
||||||
|
[self.chatView addAssistantMessage:aiText
|
||||||
|
audioDuration:duration
|
||||||
|
audioData:audioData];
|
||||||
|
|
||||||
|
// 3. 打字机效果
|
||||||
|
[self.chatView addAssistantMessage:@"" audioDuration:0 audioData:nil];
|
||||||
|
|
||||||
|
// 模拟流式返回
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
|
||||||
|
[self.chatView updateLastAssistantMessage:@"正"];
|
||||||
|
});
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.2 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
|
||||||
|
[self.chatView updateLastAssistantMessage:@"正在"];
|
||||||
|
});
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.3 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
|
||||||
|
[self.chatView updateLastAssistantMessage:@"正在思考"];
|
||||||
|
});
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.4 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
|
||||||
|
[self.chatView updateLastAssistantMessage:@"正在思考..."];
|
||||||
|
[self.chatView markLastAssistantMessageComplete];
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 自定义配置
|
||||||
|
|
||||||
|
### 修改时间戳间隔
|
||||||
|
|
||||||
|
在 `KBChatTableView.m` 中:
|
||||||
|
```objective-c
|
||||||
|
static const NSTimeInterval kTimestampInterval = 5 * 60; // 秒
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改气泡颜色
|
||||||
|
|
||||||
|
**用户消息**(`KBChatUserMessageCell.m`):
|
||||||
|
```objective-c
|
||||||
|
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.94 green:0.94 blue:0.94 alpha:1.0];
|
||||||
|
```
|
||||||
|
|
||||||
|
**AI 消息**(`KBChatAssistantMessageCell.m`):
|
||||||
|
```objective-c
|
||||||
|
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.7];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **音频格式**:MP3、AAC、M4A 等 AVAudioPlayer 支持的格式
|
||||||
|
2. **线程安全**:UI 更新必须在主线程
|
||||||
|
3. **内存管理**:大量消息时考虑限制数量
|
||||||
|
4. **音频会话**:确保配置了 AVAudioSession
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 相关文档
|
||||||
|
|
||||||
|
- 详细使用说明:`KBChatTableView_Usage.md`
|
||||||
|
- 集成指南:`集成指南.md`
|
||||||
|
- 测试页面:`KBChatTestVC.h/m`
|
||||||
@@ -6,24 +6,10 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
#import <UIKit/UIKit.h>
|
#import <UIKit/UIKit.h>
|
||||||
|
#import "KBAiChatMessage.h"
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
/// 消息类型
|
|
||||||
typedef NS_ENUM(NSInteger, KBAiChatMessageType) {
|
|
||||||
KBAiChatMessageTypeUser, // 用户消息
|
|
||||||
KBAiChatMessageTypeAssistant // AI 回复
|
|
||||||
};
|
|
||||||
|
|
||||||
/// 聊天消息模型
|
|
||||||
@interface KBAiChatMessage : NSObject
|
|
||||||
@property(nonatomic, assign) KBAiChatMessageType type;
|
|
||||||
@property(nonatomic, copy) NSString *text;
|
|
||||||
@property(nonatomic, assign) BOOL isComplete; // 是否完成(用于打字机效果)
|
|
||||||
+ (instancetype)userMessageWithText:(NSString *)text;
|
|
||||||
+ (instancetype)assistantMessageWithText:(NSString *)text;
|
|
||||||
@end
|
|
||||||
|
|
||||||
/// 聊天视图
|
/// 聊天视图
|
||||||
/// 显示用户消息和 AI 回复的气泡列表
|
/// 显示用户消息和 AI 回复的气泡列表
|
||||||
@interface KBAiChatView : UIView
|
@interface KBAiChatView : UIView
|
||||||
|
|||||||
@@ -7,28 +7,6 @@
|
|||||||
|
|
||||||
#import "KBAiChatView.h"
|
#import "KBAiChatView.h"
|
||||||
|
|
||||||
#pragma mark - KBAiChatMessage
|
|
||||||
|
|
||||||
@implementation KBAiChatMessage
|
|
||||||
|
|
||||||
+ (instancetype)userMessageWithText:(NSString *)text {
|
|
||||||
KBAiChatMessage *message = [[KBAiChatMessage alloc] init];
|
|
||||||
message.type = KBAiChatMessageTypeUser;
|
|
||||||
message.text = text;
|
|
||||||
message.isComplete = YES;
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (instancetype)assistantMessageWithText:(NSString *)text {
|
|
||||||
KBAiChatMessage *message = [[KBAiChatMessage alloc] init];
|
|
||||||
message.type = KBAiChatMessageTypeAssistant;
|
|
||||||
message.text = text;
|
|
||||||
message.isComplete = NO;
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
#pragma mark - KBAiChatBubbleCell
|
#pragma mark - KBAiChatBubbleCell
|
||||||
|
|
||||||
@interface KBAiChatBubbleCell : UITableViewCell
|
@interface KBAiChatBubbleCell : UITableViewCell
|
||||||
|
|||||||
47
keyBoard/Class/AiTalk/V/KBChatAssistantMessageCell.h
Normal file
47
keyBoard/Class/AiTalk/V/KBChatAssistantMessageCell.h
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
//
|
||||||
|
// KBChatAssistantMessageCell.h
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
|
||||||
|
@class KBAiChatMessage;
|
||||||
|
@class KBChatAssistantMessageCell;
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
/// AI 消息 Cell 代理
|
||||||
|
@protocol KBChatAssistantMessageCellDelegate <NSObject>
|
||||||
|
|
||||||
|
/// 点击语音播放按钮
|
||||||
|
- (void)assistantMessageCell:(KBChatAssistantMessageCell *)cell
|
||||||
|
didTapVoiceButtonForMessage:(KBAiChatMessage *)message;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
/// AI 消息 Cell(左侧气泡 + 语音按钮)
|
||||||
|
@interface KBChatAssistantMessageCell : UITableViewCell
|
||||||
|
|
||||||
|
@property (nonatomic, weak) id<KBChatAssistantMessageCellDelegate> delegate;
|
||||||
|
@property (nonatomic, strong, readonly) UILabel *messageLabel; // 暴露 messageLabel 供外部访问
|
||||||
|
|
||||||
|
/// 配置 Cell
|
||||||
|
- (void)configureWithMessage:(KBAiChatMessage *)message;
|
||||||
|
|
||||||
|
/// 更新语音播放状态
|
||||||
|
- (void)updateVoicePlayingState:(BOOL)isPlaying;
|
||||||
|
|
||||||
|
/// 显示加载动画
|
||||||
|
- (void)showLoadingAnimation;
|
||||||
|
|
||||||
|
/// 隐藏加载动画
|
||||||
|
- (void)hideLoadingAnimation;
|
||||||
|
|
||||||
|
/// 停止打字机效果
|
||||||
|
- (void)stopTypewriterEffect;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
339
keyBoard/Class/AiTalk/V/KBChatAssistantMessageCell.m
Normal file
339
keyBoard/Class/AiTalk/V/KBChatAssistantMessageCell.m
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
//
|
||||||
|
// KBChatAssistantMessageCell.m
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBChatAssistantMessageCell.h"
|
||||||
|
#import "KBAiChatMessage.h"
|
||||||
|
#import <Masonry/Masonry.h>
|
||||||
|
|
||||||
|
@interface KBChatAssistantMessageCell ()
|
||||||
|
|
||||||
|
@property (nonatomic, strong) UIButton *voiceButton;
|
||||||
|
@property (nonatomic, strong) UILabel *durationLabel;
|
||||||
|
@property (nonatomic, strong) UIView *bubbleView;
|
||||||
|
@property (nonatomic, strong, readwrite) UILabel *messageLabel; // readwrite 允许内部修改
|
||||||
|
@property (nonatomic, strong) UIActivityIndicatorView *loadingIndicator;
|
||||||
|
@property (nonatomic, strong) KBAiChatMessage *currentMessage;
|
||||||
|
|
||||||
|
// 打字机效果
|
||||||
|
@property (nonatomic, strong) NSTimer *typewriterTimer;
|
||||||
|
@property (nonatomic, copy) NSString *fullText;
|
||||||
|
@property (nonatomic, assign) NSInteger currentCharIndex;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBChatAssistantMessageCell
|
||||||
|
|
||||||
|
- (instancetype)initWithStyle:(UITableViewCellStyle)style
|
||||||
|
reuseIdentifier:(NSString *)reuseIdentifier {
|
||||||
|
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
|
||||||
|
if (self) {
|
||||||
|
self.backgroundColor = [UIColor clearColor];
|
||||||
|
self.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||||
|
[self setupUI];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setupUI {
|
||||||
|
// 语音按钮
|
||||||
|
self.voiceButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||||
|
[self.voiceButton setImage:[UIImage systemImageNamed:@"play.circle.fill"]
|
||||||
|
forState:UIControlStateNormal];
|
||||||
|
self.voiceButton.tintColor = [UIColor whiteColor];
|
||||||
|
[self.voiceButton addTarget:self
|
||||||
|
action:@selector(voiceButtonTapped)
|
||||||
|
forControlEvents:UIControlEventTouchUpInside];
|
||||||
|
[self.contentView addSubview:self.voiceButton];
|
||||||
|
|
||||||
|
// 语音时长标签
|
||||||
|
self.durationLabel = [[UILabel alloc] init];
|
||||||
|
self.durationLabel.font = [UIFont systemFontOfSize:14];
|
||||||
|
self.durationLabel.textColor = [UIColor whiteColor];
|
||||||
|
[self.contentView addSubview:self.durationLabel];
|
||||||
|
|
||||||
|
// 加载指示器
|
||||||
|
self.loadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||||
|
self.loadingIndicator.color = [UIColor whiteColor];
|
||||||
|
self.loadingIndicator.hidesWhenStopped = YES;
|
||||||
|
[self.contentView addSubview:self.loadingIndicator];
|
||||||
|
|
||||||
|
// 气泡视图
|
||||||
|
self.bubbleView = [[UIView alloc] init];
|
||||||
|
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.7];
|
||||||
|
self.bubbleView.layer.cornerRadius = 16;
|
||||||
|
self.bubbleView.layer.masksToBounds = YES;
|
||||||
|
[self.contentView addSubview:self.bubbleView];
|
||||||
|
|
||||||
|
// 消息标签
|
||||||
|
self.messageLabel = [[UILabel alloc] init];
|
||||||
|
self.messageLabel.numberOfLines = 0;
|
||||||
|
self.messageLabel.font = [UIFont systemFontOfSize:16];
|
||||||
|
self.messageLabel.textColor = [UIColor whiteColor];
|
||||||
|
// 设置 preferredMaxLayoutWidth 让 AutoLayout 能正确计算多行高度
|
||||||
|
CGFloat maxWidth = [UIScreen mainScreen].bounds.size.width * 0.75 - 16 - 24;
|
||||||
|
self.messageLabel.preferredMaxLayoutWidth = maxWidth;
|
||||||
|
[self.bubbleView addSubview:self.messageLabel];
|
||||||
|
|
||||||
|
// 布局约束
|
||||||
|
[self.voiceButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.contentView).offset(16);
|
||||||
|
make.top.equalTo(self.contentView).offset(8);
|
||||||
|
make.width.height.mas_equalTo(24);
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self.durationLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.voiceButton.mas_right).offset(4);
|
||||||
|
make.centerY.equalTo(self.voiceButton);
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self.loadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.center.equalTo(self.voiceButton);
|
||||||
|
}];
|
||||||
|
|
||||||
|
// bubbleView 约束
|
||||||
|
[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(16);
|
||||||
|
make.width.lessThanOrEqualTo(self.contentView).multipliedBy(0.75);
|
||||||
|
}];
|
||||||
|
|
||||||
|
// messageLabel 约束
|
||||||
|
[self.messageLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.bubbleView).offset(10);
|
||||||
|
make.bottom.equalTo(self.bubbleView).offset(-10);
|
||||||
|
make.left.equalTo(self.bubbleView).offset(12);
|
||||||
|
make.right.equalTo(self.bubbleView).offset(-12);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)configureWithMessage:(KBAiChatMessage *)message {
|
||||||
|
NSLog(@"[KBChatAssistantMessageCell] 配置消息 - 文本长度: %lu, isComplete: %d, needsTypewriter: %d, 打字机运行中: %d",
|
||||||
|
(unsigned long)message.text.length, message.isComplete, message.needsTypewriterEffect,
|
||||||
|
(self.typewriterTimer && self.typewriterTimer.isValid));
|
||||||
|
|
||||||
|
// 先停止之前的打字机效果(无论是否是同一条消息)
|
||||||
|
[self stopTypewriterEffect];
|
||||||
|
|
||||||
|
self.currentMessage = message;
|
||||||
|
|
||||||
|
// 只有明确需要打字机效果的消息才使用打字机
|
||||||
|
if (message.needsTypewriterEffect && !message.isComplete && message.text.length > 0) {
|
||||||
|
// 启动新的打字机效果
|
||||||
|
NSLog(@"[KBChatAssistantMessageCell] 启动新的打字机效果");
|
||||||
|
[self startTypewriterEffectWithText:message.text];
|
||||||
|
} else {
|
||||||
|
// 直接显示完整文本
|
||||||
|
NSLog(@"[KBChatAssistantMessageCell] 直接显示完整文本(needsTypewriter: %d, isComplete: %d)",
|
||||||
|
message.needsTypewriterEffect, message.isComplete);
|
||||||
|
self.messageLabel.text = message.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化语音时长(如果时长为 0,不显示)
|
||||||
|
if (message.audioDuration > 0) {
|
||||||
|
NSInteger seconds = (NSInteger)ceil(message.audioDuration);
|
||||||
|
self.durationLabel.text = [NSString stringWithFormat:@"%ld\"", (long)seconds];
|
||||||
|
} else {
|
||||||
|
self.durationLabel.text = @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有 audioId 或 audioData,显示语音按钮
|
||||||
|
BOOL hasAudio = (message.audioId.length > 0) ||
|
||||||
|
(message.audioData != nil && message.audioData.length > 0);
|
||||||
|
self.voiceButton.hidden = !hasAudio;
|
||||||
|
self.durationLabel.hidden = !hasAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)updateVoicePlayingState:(BOOL)isPlaying {
|
||||||
|
if (isPlaying) {
|
||||||
|
[self.voiceButton setImage:[UIImage systemImageNamed:@"pause.circle.fill"]
|
||||||
|
forState:UIControlStateNormal];
|
||||||
|
} else {
|
||||||
|
[self.voiceButton setImage:[UIImage systemImageNamed:@"play.circle.fill"]
|
||||||
|
forState:UIControlStateNormal];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)showLoadingAnimation {
|
||||||
|
// 隐藏按钮图标,显示加载动画
|
||||||
|
[self.voiceButton setImage:nil forState:UIControlStateNormal];
|
||||||
|
[self.loadingIndicator startAnimating];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)hideLoadingAnimation {
|
||||||
|
// 停止加载动画,恢复按钮图标
|
||||||
|
[self.loadingIndicator stopAnimating];
|
||||||
|
[self.voiceButton setImage:[UIImage systemImageNamed:@"play.circle.fill"]
|
||||||
|
forState:UIControlStateNormal];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)voiceButtonTapped {
|
||||||
|
if ([self.delegate respondsToSelector:@selector(assistantMessageCell:didTapVoiceButtonForMessage:)]) {
|
||||||
|
[self.delegate assistantMessageCell:self didTapVoiceButtonForMessage:self.currentMessage];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Typewriter Effect
|
||||||
|
|
||||||
|
- (void)startTypewriterEffectWithText:(NSString *)text {
|
||||||
|
if (text.length == 0) {
|
||||||
|
NSLog(@"[KBChatAssistantMessageCell] 文本为空,跳过打字机效果");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog(@"[KBChatAssistantMessageCell] 开始打字机效果,文本长度: %lu, 文本: %@",
|
||||||
|
(unsigned long)text.length, text);
|
||||||
|
|
||||||
|
self.fullText = text;
|
||||||
|
self.currentCharIndex = 0;
|
||||||
|
|
||||||
|
// 先设置完整文本,让布局系统计算出正确的高度
|
||||||
|
self.messageLabel.text = text;
|
||||||
|
|
||||||
|
// 强制布局更新
|
||||||
|
[self.messageLabel setNeedsLayout];
|
||||||
|
[self.bubbleView setNeedsLayout];
|
||||||
|
[self.contentView setNeedsLayout];
|
||||||
|
[self layoutIfNeeded];
|
||||||
|
|
||||||
|
NSLog(@"[KBChatAssistantMessageCell] 布局后 Label frame: %@", NSStringFromCGRect(self.messageLabel.frame));
|
||||||
|
|
||||||
|
// 使用 NSAttributedString 实现打字机效果
|
||||||
|
// 先把所有文字设置为透明,然后逐个显示
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 确保在主线程创建定时器
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
// 每 0.03 秒显示一个字符(更快的打字机效果)
|
||||||
|
self.typewriterTimer = [NSTimer scheduledTimerWithTimeInterval:0.03
|
||||||
|
target:self
|
||||||
|
selector:@selector(typewriterTick)
|
||||||
|
userInfo:nil
|
||||||
|
repeats:YES];
|
||||||
|
|
||||||
|
// 将定时器添加到 RunLoop 的 common 模式,确保滚动时也能触发
|
||||||
|
[[NSRunLoop currentRunLoop] addTimer:self.typewriterTimer forMode:NSRunLoopCommonModes];
|
||||||
|
|
||||||
|
// 立即触发一次,显示第一个字符
|
||||||
|
[self typewriterTick];
|
||||||
|
|
||||||
|
NSLog(@"[KBChatAssistantMessageCell] 定时器已创建: %@", self.typewriterTimer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)typewriterTick {
|
||||||
|
// 使用局部变量保存 fullText,避免多线程问题
|
||||||
|
NSString *text = self.fullText;
|
||||||
|
|
||||||
|
// 检查 fullText 是否有效
|
||||||
|
if (!text || text.length == 0) {
|
||||||
|
NSLog(@"[KBChatAssistantMessageCell] fullText 无效,停止打字机");
|
||||||
|
[self stopTypewriterEffect];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果当前索引小于完整文本长度,继续显示
|
||||||
|
if (self.currentCharIndex < text.length) {
|
||||||
|
self.currentCharIndex++;
|
||||||
|
|
||||||
|
// 使用 NSAttributedString 显示前 N 个字符,隐藏后面的字符
|
||||||
|
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||||
|
|
||||||
|
// 已显示的部分:白色
|
||||||
|
if (self.currentCharIndex > 0) {
|
||||||
|
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||||
|
value:[UIColor whiteColor]
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 每 10 个字符打印一次日志
|
||||||
|
if (self.currentCharIndex % 10 == 0) {
|
||||||
|
NSString *displayText = [text substringToIndex:self.currentCharIndex];
|
||||||
|
NSLog(@"[KBChatAssistantMessageCell] 打字机进度: %ld/%lu, 当前文本: %@",
|
||||||
|
(long)self.currentCharIndex, (unsigned long)text.length, displayText);
|
||||||
|
NSLog(@"[KBChatAssistantMessageCell] Label 状态 - frame: %@, hidden: %d, alpha: %.2f",
|
||||||
|
NSStringFromCGRect(self.messageLabel.frame),
|
||||||
|
self.messageLabel.hidden,
|
||||||
|
self.messageLabel.alpha);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 打字机效果已完成,停止定时器并标记消息完成
|
||||||
|
NSLog(@"[KBChatAssistantMessageCell] 打字机效果完成,停止定时器");
|
||||||
|
|
||||||
|
// 检查 text 是否为空
|
||||||
|
if (!text || text.length == 0) {
|
||||||
|
NSLog(@"[KBChatAssistantMessageCell] text 为空,跳过");
|
||||||
|
[self stopTypewriterEffect];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
[self stopTypewriterEffect];
|
||||||
|
|
||||||
|
// 显示完整文本(白色),使用 attributedText 保持布局
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 标记消息为完成状态,避免 Cell 复用时重新触发打字机
|
||||||
|
if (self.currentMessage) {
|
||||||
|
self.currentMessage.isComplete = YES;
|
||||||
|
self.currentMessage.needsTypewriterEffect = NO;
|
||||||
|
NSLog(@"[KBChatAssistantMessageCell] 标记消息完成 - 文本: %@", self.currentMessage.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
- (void)stopTypewriterEffect {
|
||||||
|
if (self.typewriterTimer && self.typewriterTimer.isValid) {
|
||||||
|
NSLog(@"[KBChatAssistantMessageCell] 停止打字机定时器");
|
||||||
|
[self.typewriterTimer invalidate];
|
||||||
|
}
|
||||||
|
self.typewriterTimer = nil;
|
||||||
|
self.currentCharIndex = 0;
|
||||||
|
self.fullText = nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)prepareForReuse {
|
||||||
|
[super prepareForReuse];
|
||||||
|
[self stopTypewriterEffect];
|
||||||
|
// 清空文本,避免复用时显示旧内容
|
||||||
|
self.messageLabel.text = @"";
|
||||||
|
self.messageLabel.attributedText = nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)dealloc {
|
||||||
|
[self stopTypewriterEffect];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
46
keyBoard/Class/AiTalk/V/KBChatTableView.h
Normal file
46
keyBoard/Class/AiTalk/V/KBChatTableView.h
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
//
|
||||||
|
// KBChatTableView.h
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
|
||||||
|
@class KBChatMessage;
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
/// 聊天列表视图(支持用户消息、AI 消息、时间戳、语音播放)
|
||||||
|
@interface KBChatTableView : UIView
|
||||||
|
|
||||||
|
/// 添加用户消息
|
||||||
|
- (void)addUserMessage:(NSString *)text;
|
||||||
|
|
||||||
|
/// 添加 AI 消息(带语音)
|
||||||
|
- (void)addAssistantMessage:(NSString *)text
|
||||||
|
audioDuration:(NSTimeInterval)duration
|
||||||
|
audioData:(nullable NSData *)audioData;
|
||||||
|
|
||||||
|
/// 添加 AI 消息(带 audioId,异步加载音频)
|
||||||
|
- (void)addAssistantMessage:(NSString *)text
|
||||||
|
audioId:(nullable NSString *)audioId;
|
||||||
|
|
||||||
|
/// 更新最后一条 AI 消息(用于打字机效果)
|
||||||
|
- (void)updateLastAssistantMessage:(NSString *)text;
|
||||||
|
|
||||||
|
/// 标记最后一条 AI 消息完成
|
||||||
|
- (void)markLastAssistantMessageComplete;
|
||||||
|
|
||||||
|
/// 清空所有消息
|
||||||
|
- (void)clearMessages;
|
||||||
|
|
||||||
|
/// 滚动到底部
|
||||||
|
- (void)scrollToBottom;
|
||||||
|
|
||||||
|
/// 停止正在播放的音频
|
||||||
|
- (void)stopPlayingAudio;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
576
keyBoard/Class/AiTalk/V/KBChatTableView.m
Normal file
576
keyBoard/Class/AiTalk/V/KBChatTableView.m
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
//
|
||||||
|
// KBChatTableView.m
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBChatTableView.h"
|
||||||
|
#import "KBAiChatMessage.h"
|
||||||
|
#import "KBChatUserMessageCell.h"
|
||||||
|
#import "KBChatAssistantMessageCell.h"
|
||||||
|
#import "KBChatTimeCell.h"
|
||||||
|
#import "AiVM.h"
|
||||||
|
#import <Masonry/Masonry.h>
|
||||||
|
#import <AVFoundation/AVFoundation.h>
|
||||||
|
|
||||||
|
static NSString * const kUserCellIdentifier = @"KBChatUserMessageCell";
|
||||||
|
static NSString * const kAssistantCellIdentifier = @"KBChatAssistantMessageCell";
|
||||||
|
static NSString * const kTimeCellIdentifier = @"KBChatTimeCell";
|
||||||
|
|
||||||
|
/// 时间戳显示间隔(秒)
|
||||||
|
static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
|
||||||
|
|
||||||
|
@interface KBChatTableView () <UITableViewDataSource, UITableViewDelegate, KBChatAssistantMessageCellDelegate, AVAudioPlayerDelegate>
|
||||||
|
|
||||||
|
@property (nonatomic, strong) UITableView *tableView;
|
||||||
|
@property (nonatomic, strong) NSMutableArray<KBAiChatMessage *> *messages;
|
||||||
|
@property (nonatomic, strong) AVAudioPlayer *audioPlayer;
|
||||||
|
@property (nonatomic, strong) NSIndexPath *playingCellIndexPath;
|
||||||
|
@property (nonatomic, strong) AiVM *aiVM;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBChatTableView
|
||||||
|
|
||||||
|
- (instancetype)initWithFrame:(CGRect)frame {
|
||||||
|
self = [super initWithFrame:frame];
|
||||||
|
if (self) {
|
||||||
|
[self setup];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (instancetype)initWithCoder:(NSCoder *)coder {
|
||||||
|
self = [super initWithCoder:coder];
|
||||||
|
if (self) {
|
||||||
|
[self setup];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setup {
|
||||||
|
self.messages = [[NSMutableArray alloc] init];
|
||||||
|
self.aiVM = [[AiVM alloc] init];
|
||||||
|
|
||||||
|
// 创建 TableView
|
||||||
|
self.tableView = [[UITableView alloc] initWithFrame:self.bounds
|
||||||
|
style:UITableViewStylePlain];
|
||||||
|
self.tableView.dataSource = self;
|
||||||
|
self.tableView.delegate = self;
|
||||||
|
self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
|
||||||
|
self.tableView.backgroundColor = [UIColor clearColor];
|
||||||
|
self.tableView.estimatedRowHeight = 60;
|
||||||
|
self.tableView.rowHeight = UITableViewAutomaticDimension;
|
||||||
|
self.tableView.showsVerticalScrollIndicator = YES;
|
||||||
|
[self addSubview:self.tableView];
|
||||||
|
|
||||||
|
// 注册 Cell
|
||||||
|
[self.tableView registerClass:[KBChatUserMessageCell class]
|
||||||
|
forCellReuseIdentifier:kUserCellIdentifier];
|
||||||
|
[self.tableView registerClass:[KBChatAssistantMessageCell class]
|
||||||
|
forCellReuseIdentifier:kAssistantCellIdentifier];
|
||||||
|
[self.tableView registerClass:[KBChatTimeCell class]
|
||||||
|
forCellReuseIdentifier:kTimeCellIdentifier];
|
||||||
|
|
||||||
|
// 布局
|
||||||
|
[self.tableView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.edges.equalTo(self);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Public Methods
|
||||||
|
|
||||||
|
- (void)addUserMessage:(NSString *)text {
|
||||||
|
// 记录插入前的消息数量
|
||||||
|
NSInteger oldCount = self.messages.count;
|
||||||
|
|
||||||
|
KBAiChatMessage *message = [KBAiChatMessage userMessageWithText:text];
|
||||||
|
[self insertMessageWithTimestamp:message];
|
||||||
|
|
||||||
|
// 计算新增的行数
|
||||||
|
NSInteger newCount = self.messages.count;
|
||||||
|
NSInteger insertedCount = newCount - oldCount;
|
||||||
|
|
||||||
|
// 使用 insert 插入新行
|
||||||
|
if (insertedCount > 0) {
|
||||||
|
NSMutableArray *indexPaths = [NSMutableArray array];
|
||||||
|
for (NSInteger i = oldCount; i < newCount; i++) {
|
||||||
|
[indexPaths addObject:[NSIndexPath indexPathForRow:i inSection:0]];
|
||||||
|
}
|
||||||
|
[self.tableView insertRowsAtIndexPaths:indexPaths
|
||||||
|
withRowAnimation:UITableViewRowAnimationNone];
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||||
|
[self scrollToBottom];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)addAssistantMessage:(NSString *)text
|
||||||
|
audioDuration:(NSTimeInterval)duration
|
||||||
|
audioData:(NSData *)audioData {
|
||||||
|
// 记录插入前的消息数量
|
||||||
|
NSInteger oldCount = self.messages.count;
|
||||||
|
|
||||||
|
KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text
|
||||||
|
audioDuration:duration
|
||||||
|
audioData:audioData];
|
||||||
|
[self insertMessageWithTimestamp:message];
|
||||||
|
|
||||||
|
// 计算新增的行数
|
||||||
|
NSInteger newCount = self.messages.count;
|
||||||
|
NSInteger insertedCount = newCount - oldCount;
|
||||||
|
|
||||||
|
// 使用 insert 插入新行
|
||||||
|
if (insertedCount > 0) {
|
||||||
|
NSMutableArray *indexPaths = [NSMutableArray array];
|
||||||
|
for (NSInteger i = oldCount; i < newCount; i++) {
|
||||||
|
[indexPaths addObject:[NSIndexPath indexPathForRow:i inSection:0]];
|
||||||
|
}
|
||||||
|
[self.tableView insertRowsAtIndexPaths:indexPaths
|
||||||
|
withRowAnimation:UITableViewRowAnimationNone];
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||||
|
[self scrollToBottom];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)addAssistantMessage:(NSString *)text
|
||||||
|
audioId:(NSString *)audioId {
|
||||||
|
NSLog(@"[KBChatTableView] ========== 添加新的 AI 消息 ==========");
|
||||||
|
NSLog(@"[KBChatTableView] 文本长度: %lu, audioId: %@", (unsigned long)text.length, audioId);
|
||||||
|
NSLog(@"[KBChatTableView] 当前消息数量: %ld", (long)self.messages.count);
|
||||||
|
|
||||||
|
// 在添加新消息之前,先标记上一条 AI 消息完成,并停止其打字机效果
|
||||||
|
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||||
|
KBAiChatMessage *msg = self.messages[i];
|
||||||
|
if (msg.type == KBAiChatMessageTypeAssistant && !msg.isComplete) {
|
||||||
|
NSLog(@"[KBChatTableView] 找到上一条未完成的消息 - 索引: %ld, 文本: %@", (long)i, msg.text);
|
||||||
|
msg.isComplete = YES;
|
||||||
|
msg.needsTypewriterEffect = NO;
|
||||||
|
|
||||||
|
// 停止该 Cell 的打字机效果
|
||||||
|
NSIndexPath *oldIndexPath = [NSIndexPath indexPathForRow:i inSection:0];
|
||||||
|
KBChatAssistantMessageCell *oldCell = [self.tableView cellForRowAtIndexPath:oldIndexPath];
|
||||||
|
if ([oldCell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||||
|
NSLog(@"[KBChatTableView] 停止上一条消息的打字机效果");
|
||||||
|
[oldCell stopTypewriterEffect];
|
||||||
|
// 显示完整文本
|
||||||
|
oldCell.messageLabel.text = msg.text;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录插入前的消息数量
|
||||||
|
NSInteger oldCount = self.messages.count;
|
||||||
|
|
||||||
|
KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text
|
||||||
|
audioId:audioId];
|
||||||
|
message.needsTypewriterEffect = YES; // 新消息需要打字机效果
|
||||||
|
NSLog(@"[KBChatTableView] 新消息属性 - needsTypewriter: %d, isComplete: %d",
|
||||||
|
message.needsTypewriterEffect, message.isComplete);
|
||||||
|
[self insertMessageWithTimestamp:message];
|
||||||
|
|
||||||
|
// 计算新增的行数
|
||||||
|
NSInteger newCount = self.messages.count;
|
||||||
|
NSInteger insertedCount = newCount - oldCount;
|
||||||
|
NSLog(@"[KBChatTableView] 插入后消息数量: %ld, 新增行数: %ld", (long)newCount, (long)insertedCount);
|
||||||
|
|
||||||
|
// 使用 insert 插入新行
|
||||||
|
if (insertedCount > 0) {
|
||||||
|
NSMutableArray *indexPaths = [NSMutableArray array];
|
||||||
|
for (NSInteger i = oldCount; i < newCount; i++) {
|
||||||
|
[indexPaths addObject:[NSIndexPath indexPathForRow:i inSection:0]];
|
||||||
|
NSLog(@"[KBChatTableView] 将插入行: %ld", (long)i);
|
||||||
|
}
|
||||||
|
[self.tableView insertRowsAtIndexPaths:indexPaths
|
||||||
|
withRowAnimation:UITableViewRowAnimationNone];
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||||
|
[self scrollToBottom];
|
||||||
|
});
|
||||||
|
|
||||||
|
NSLog(@"[KBChatTableView] ========== 添加完成 ==========");
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)updateLastAssistantMessage:(NSString *)text {
|
||||||
|
// 查找最后一条未完成的 AI 消息
|
||||||
|
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||||
|
KBAiChatMessage *message = self.messages[i];
|
||||||
|
if (message.type == KBAiChatMessageTypeAssistant && !message.isComplete) {
|
||||||
|
NSLog(@"[KBChatTableView] 更新最后一条 AI 消息 - 索引: %ld, 文本长度: %lu, needsTypewriter: %d",
|
||||||
|
(long)i, (unsigned long)text.length, message.needsTypewriterEffect);
|
||||||
|
message.text = text;
|
||||||
|
|
||||||
|
// 直接更新 Cell 的文本,不刷新整个 Cell(避免打断打字机效果)
|
||||||
|
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
|
||||||
|
KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
|
||||||
|
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||||
|
NSLog(@"[KBChatTableView] 找到 Cell,直接配置消息");
|
||||||
|
// 直接调用 configureWithMessage,让 Cell 自己决定是否使用打字机效果
|
||||||
|
[cell configureWithMessage:message];
|
||||||
|
} else {
|
||||||
|
NSLog(@"[KBChatTableView] 未找到 Cell 或类型不匹配");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没找到,添加新消息
|
||||||
|
NSLog(@"[KBChatTableView] 未找到未完成的 AI 消息,添加新消息");
|
||||||
|
[self addAssistantMessage:text audioDuration:0 audioData:nil];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)markLastAssistantMessageComplete {
|
||||||
|
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||||
|
KBAiChatMessage *message = self.messages[i];
|
||||||
|
if (message.type == KBAiChatMessageTypeAssistant) {
|
||||||
|
NSLog(@"[KBChatTableView] 标记消息完成 - 索引: %ld, 文本: %@", (long)i, message.text);
|
||||||
|
message.isComplete = YES;
|
||||||
|
message.needsTypewriterEffect = NO; // 完成后不再需要打字机效果
|
||||||
|
|
||||||
|
// 刷新 Cell 以显示完整文本
|
||||||
|
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
|
||||||
|
[self.tableView reloadRowsAtIndexPaths:@[indexPath]
|
||||||
|
withRowAnimation:UITableViewRowAnimationNone];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)clearMessages {
|
||||||
|
[self.messages removeAllObjects];
|
||||||
|
[self.tableView reloadData];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)scrollToBottom {
|
||||||
|
if (self.messages.count == 0) return;
|
||||||
|
|
||||||
|
NSIndexPath *lastIndexPath = [NSIndexPath indexPathForRow:self.messages.count - 1
|
||||||
|
inSection:0];
|
||||||
|
[self.tableView scrollToRowAtIndexPath:lastIndexPath
|
||||||
|
atScrollPosition:UITableViewScrollPositionBottom
|
||||||
|
animated:YES];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Private Methods
|
||||||
|
|
||||||
|
/// 插入消息并自动添加时间戳
|
||||||
|
- (void)insertMessageWithTimestamp:(KBAiChatMessage *)message {
|
||||||
|
// 判断是否需要插入时间戳
|
||||||
|
if ([self shouldInsertTimestampForMessage:message]) {
|
||||||
|
KBAiChatMessage *timeMessage = [KBAiChatMessage timeMessageWithTimestamp:message.timestamp];
|
||||||
|
[self.messages addObject:timeMessage];
|
||||||
|
}
|
||||||
|
|
||||||
|
[self.messages addObject:message];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断是否需要插入时间戳
|
||||||
|
- (BOOL)shouldInsertTimestampForMessage:(KBAiChatMessage *)message {
|
||||||
|
// 第一条消息总是显示时间
|
||||||
|
if (self.messages.count == 0) {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找最后一条非时间戳消息
|
||||||
|
KBAiChatMessage *lastMessage = nil;
|
||||||
|
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||||
|
KBAiChatMessage *msg = self.messages[i];
|
||||||
|
if (msg.type != KBAiChatMessageTypeTime) {
|
||||||
|
lastMessage = msg;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastMessage) {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算时间间隔
|
||||||
|
NSTimeInterval interval = [message.timestamp timeIntervalSinceDate:lastMessage.timestamp];
|
||||||
|
|
||||||
|
// 超过 5 分钟或跨天则显示时间
|
||||||
|
if (interval >= kTimestampInterval) {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSCalendar *calendar = [NSCalendar currentCalendar];
|
||||||
|
NSDateComponents *lastComponents = [calendar components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear
|
||||||
|
fromDate:lastMessage.timestamp];
|
||||||
|
NSDateComponents *currentComponents = [calendar components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear
|
||||||
|
fromDate:message.timestamp];
|
||||||
|
|
||||||
|
return ![lastComponents isEqual:currentComponents];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 刷新并滚动到底部
|
||||||
|
- (void)reloadAndScroll {
|
||||||
|
// 使用 insert 而不是 reload,避免刷新已有的 Cell
|
||||||
|
NSInteger lastIndex = self.messages.count - 1;
|
||||||
|
if (lastIndex >= 0) {
|
||||||
|
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:lastIndex inSection:0];
|
||||||
|
[self.tableView insertRowsAtIndexPaths:@[indexPath]
|
||||||
|
withRowAnimation:UITableViewRowAnimationNone];
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||||
|
[self scrollToBottom];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - UITableViewDataSource
|
||||||
|
|
||||||
|
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
|
||||||
|
return self.messages.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||||
|
KBAiChatMessage *message = self.messages[indexPath.row];
|
||||||
|
|
||||||
|
NSLog(@"[KBChatTableView] cellForRow: %ld, 消息类型: %ld, needsTypewriter: %d, isComplete: %d",
|
||||||
|
(long)indexPath.row, (long)message.type, message.needsTypewriterEffect, message.isComplete);
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case KBAiChatMessageTypeUser: {
|
||||||
|
KBChatUserMessageCell *cell = [tableView dequeueReusableCellWithIdentifier:kUserCellIdentifier
|
||||||
|
forIndexPath:indexPath];
|
||||||
|
[cell configureWithMessage:message];
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
case KBAiChatMessageTypeAssistant: {
|
||||||
|
KBChatAssistantMessageCell *cell = [tableView dequeueReusableCellWithIdentifier:kAssistantCellIdentifier
|
||||||
|
forIndexPath:indexPath];
|
||||||
|
cell.delegate = self;
|
||||||
|
[cell configureWithMessage:message];
|
||||||
|
|
||||||
|
// 更新播放状态
|
||||||
|
BOOL isPlaying = [indexPath isEqual:self.playingCellIndexPath];
|
||||||
|
[cell updateVoicePlayingState:isPlaying];
|
||||||
|
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
case KBAiChatMessageTypeTime: {
|
||||||
|
KBChatTimeCell *cell = [tableView dequeueReusableCellWithIdentifier:kTimeCellIdentifier
|
||||||
|
forIndexPath:indexPath];
|
||||||
|
[cell configureWithMessage:message];
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - KBChatAssistantMessageCellDelegate
|
||||||
|
|
||||||
|
- (void)assistantMessageCell:(KBChatAssistantMessageCell *)cell
|
||||||
|
didTapVoiceButtonForMessage:(KBAiChatMessage *)message {
|
||||||
|
|
||||||
|
NSIndexPath *indexPath = [self.tableView indexPathForCell:cell];
|
||||||
|
if (!indexPath) return;
|
||||||
|
|
||||||
|
// 如果正在播放同一条消息,则暂停
|
||||||
|
if ([indexPath isEqual:self.playingCellIndexPath]) {
|
||||||
|
[self stopPlayingAudio];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止之前的播放
|
||||||
|
[self stopPlayingAudio];
|
||||||
|
|
||||||
|
// 如果有 audioData,直接播放
|
||||||
|
if (message.audioData && message.audioData.length > 0) {
|
||||||
|
[self playAudioForMessage:message atIndexPath:indexPath];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有 audioId,异步加载音频
|
||||||
|
if (message.audioId.length > 0) {
|
||||||
|
[self loadAndPlayAudioForMessage:message atIndexPath:indexPath];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog(@"[KBChatTableView] 没有音频数据或 audioId");
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Audio Playback
|
||||||
|
|
||||||
|
- (void)loadAndPlayAudioForMessage:(KBAiChatMessage *)message atIndexPath:(NSIndexPath *)indexPath {
|
||||||
|
// 显示加载动画
|
||||||
|
KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
|
||||||
|
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||||
|
[cell showLoadingAnimation];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始轮询请求(最多5次,每次间隔0.5秒)
|
||||||
|
[self pollAudioForMessage:message atIndexPath:indexPath retryCount:0 maxRetries:5];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)pollAudioForMessage:(KBAiChatMessage *)message
|
||||||
|
atIndexPath:(NSIndexPath *)indexPath
|
||||||
|
retryCount:(NSInteger)retryCount
|
||||||
|
maxRetries:(NSInteger)maxRetries {
|
||||||
|
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
|
[self.aiVM requestAudioWithAudioId:message.audioId
|
||||||
|
completion:^(NSString *_Nullable audioURL, NSError *_Nullable error) {
|
||||||
|
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||||
|
if (!strongSelf) return;
|
||||||
|
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
// 如果成功获取到 audioURL
|
||||||
|
if (!error && audioURL.length > 0) {
|
||||||
|
// 下载并播放音频
|
||||||
|
[strongSelf downloadAndPlayAudioFromURL:audioURL
|
||||||
|
forMessage:message
|
||||||
|
atIndexPath:indexPath];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果还没达到最大重试次数,继续轮询
|
||||||
|
if (retryCount < maxRetries - 1) {
|
||||||
|
NSLog(@"[KBChatTableView] 音频未就绪,0.5秒后重试 (%ld/%ld)",
|
||||||
|
(long)(retryCount + 1), (long)maxRetries);
|
||||||
|
|
||||||
|
// 0.5秒后重试
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)),
|
||||||
|
dispatch_get_main_queue(), ^{
|
||||||
|
[strongSelf pollAudioForMessage:message
|
||||||
|
atIndexPath:indexPath
|
||||||
|
retryCount:retryCount + 1
|
||||||
|
maxRetries:maxRetries];
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 达到最大重试次数,隐藏加载动画
|
||||||
|
KBChatAssistantMessageCell *cell = [strongSelf.tableView cellForRowAtIndexPath:indexPath];
|
||||||
|
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||||
|
[cell hideLoadingAnimation];
|
||||||
|
}
|
||||||
|
NSLog(@"[KBChatTableView] 音频加载失败,已重试 %ld 次", (long)maxRetries);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)downloadAndPlayAudioFromURL:(NSString *)urlString
|
||||||
|
forMessage:(KBAiChatMessage *)message
|
||||||
|
atIndexPath:(NSIndexPath *)indexPath {
|
||||||
|
NSURL *url = [NSURL URLWithString:urlString];
|
||||||
|
if (!url) {
|
||||||
|
NSLog(@"[KBChatTableView] 无效的音频 URL: %@", urlString);
|
||||||
|
// 隐藏加载动画
|
||||||
|
KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
|
||||||
|
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||||
|
[cell hideLoadingAnimation];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
|
||||||
|
NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
|
||||||
|
|
||||||
|
NSURLSessionDataTask *task = [session dataTaskWithURL:url
|
||||||
|
completionHandler:^(NSData *_Nullable data,
|
||||||
|
NSURLResponse *_Nullable response,
|
||||||
|
NSError *_Nullable error) {
|
||||||
|
if (error || !data || data.length == 0) {
|
||||||
|
NSLog(@"[KBChatTableView] 下载音频失败: %@", error.localizedDescription ?: @"");
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
// 隐藏加载动画
|
||||||
|
KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
|
||||||
|
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||||
|
[cell hideLoadingAnimation];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
// 隐藏加载动画
|
||||||
|
KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
|
||||||
|
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||||
|
[cell hideLoadingAnimation];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存音频数据到消息对象
|
||||||
|
message.audioData = data;
|
||||||
|
|
||||||
|
// 计算音频时长
|
||||||
|
NSError *playerError = nil;
|
||||||
|
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:data error:&playerError];
|
||||||
|
if (!playerError && player) {
|
||||||
|
message.audioDuration = player.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不刷新 Cell,避免触发打字机效果
|
||||||
|
// 直接播放音频
|
||||||
|
[self playAudioForMessage:message atIndexPath:indexPath];
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
|
||||||
|
[task resume];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)playAudioForMessage:(KBAiChatMessage *)message atIndexPath:(NSIndexPath *)indexPath {
|
||||||
|
if (!message.audioData || message.audioData.length == 0) {
|
||||||
|
NSLog(@"[KBChatTableView] 没有音频数据");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置音频会话为播放模式
|
||||||
|
NSError *sessionError = nil;
|
||||||
|
AVAudioSession *audioSession = [AVAudioSession sharedInstance];
|
||||||
|
[audioSession setCategory:AVAudioSessionCategoryPlayback error:&sessionError];
|
||||||
|
[audioSession setActive:YES error:&sessionError];
|
||||||
|
|
||||||
|
if (sessionError) {
|
||||||
|
NSLog(@"[KBChatTableView] 音频会话配置失败: %@", sessionError.localizedDescription);
|
||||||
|
}
|
||||||
|
|
||||||
|
NSError *error = nil;
|
||||||
|
self.audioPlayer = [[AVAudioPlayer alloc] initWithData:message.audioData error:&error];
|
||||||
|
|
||||||
|
if (error || !self.audioPlayer) {
|
||||||
|
NSLog(@"[KBChatTableView] 音频播放器初始化失败: %@", error.localizedDescription);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.audioPlayer.delegate = self;
|
||||||
|
self.audioPlayer.volume = 1.0; // 设置音量为最大
|
||||||
|
[self.audioPlayer prepareToPlay];
|
||||||
|
[self.audioPlayer play];
|
||||||
|
|
||||||
|
self.playingCellIndexPath = indexPath;
|
||||||
|
|
||||||
|
// 更新 Cell 状态
|
||||||
|
KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
|
||||||
|
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||||
|
[cell updateVoicePlayingState:YES];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)stopPlayingAudio {
|
||||||
|
if (self.audioPlayer && self.audioPlayer.isPlaying) {
|
||||||
|
[self.audioPlayer stop];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.playingCellIndexPath) {
|
||||||
|
KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:self.playingCellIndexPath];
|
||||||
|
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||||
|
[cell updateVoicePlayingState:NO];
|
||||||
|
}
|
||||||
|
self.playingCellIndexPath = nil;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - AVAudioPlayerDelegate
|
||||||
|
|
||||||
|
- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag {
|
||||||
|
[self stopPlayingAudio];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
195
keyBoard/Class/AiTalk/V/KBChatTableView_Usage.md
Normal file
195
keyBoard/Class/AiTalk/V/KBChatTableView_Usage.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# KBChatTableView 使用说明
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
`KBChatTableView` 是一个完整的聊天 UI 组件,支持:
|
||||||
|
- ✅ 用户消息(右侧气泡)
|
||||||
|
- ✅ AI 消息(左侧气泡 + 语音播放按钮)
|
||||||
|
- ✅ 时间戳(自动插入,5 分钟间隔)
|
||||||
|
- ✅ 语音播放功能
|
||||||
|
- ✅ 打字机效果
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 在 ViewController 中引入
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
#import "KBChatTableView.h"
|
||||||
|
|
||||||
|
@interface YourViewController ()
|
||||||
|
@property (nonatomic, strong) KBChatTableView *chatView;
|
||||||
|
@end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 初始化并布局
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
- (void)viewDidLoad {
|
||||||
|
[super viewDidLoad];
|
||||||
|
|
||||||
|
self.chatView = [[KBChatTableView alloc] init];
|
||||||
|
self.chatView.backgroundColor = [UIColor clearColor];
|
||||||
|
[self.view addSubview:self.chatView];
|
||||||
|
|
||||||
|
[self.chatView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.view.mas_safeAreaLayoutGuideTop);
|
||||||
|
make.left.right.equalTo(self.view);
|
||||||
|
make.bottom.equalTo(self.recordButton.mas_top).offset(-16);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 添加消息
|
||||||
|
|
||||||
|
#### 添加用户消息
|
||||||
|
```objective-c
|
||||||
|
[self.chatView addUserMessage:@"你好"];
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 添加 AI 消息(带语音)
|
||||||
|
```objective-c
|
||||||
|
NSString *text = @"你好,很高兴见到你!";
|
||||||
|
NSTimeInterval duration = 6.0; // 6 秒
|
||||||
|
NSData *audioData = ...; // 从 TTS 获取的音频数据
|
||||||
|
|
||||||
|
[self.chatView addAssistantMessage:text
|
||||||
|
audioDuration:duration
|
||||||
|
audioData:audioData];
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 添加 AI 消息(无语音)
|
||||||
|
```objective-c
|
||||||
|
[self.chatView addAssistantMessage:@"你好"
|
||||||
|
audioDuration:0
|
||||||
|
audioData:nil];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 打字机效果
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
// 1. 添加空消息占位
|
||||||
|
[self.chatView addAssistantMessage:@""
|
||||||
|
audioDuration:0
|
||||||
|
audioData:nil];
|
||||||
|
|
||||||
|
// 2. 逐步更新文本
|
||||||
|
[self.chatView updateLastAssistantMessage:@"你"];
|
||||||
|
[self.chatView updateLastAssistantMessage:@"你好"];
|
||||||
|
[self.chatView updateLastAssistantMessage:@"你好,"];
|
||||||
|
[self.chatView updateLastAssistantMessage:@"你好,很高兴见到你!"];
|
||||||
|
|
||||||
|
// 3. 标记完成
|
||||||
|
[self.chatView markLastAssistantMessageComplete];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 其他操作
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
// 清空所有消息
|
||||||
|
[self.chatView clearMessages];
|
||||||
|
|
||||||
|
// 滚动到底部
|
||||||
|
[self.chatView scrollToBottom];
|
||||||
|
```
|
||||||
|
|
||||||
|
## 集成到 KBAiMainVC
|
||||||
|
|
||||||
|
### 替换现有的 KBAiChatView
|
||||||
|
|
||||||
|
在 `KBAiMainVC.m` 中:
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
// 1. 修改 import
|
||||||
|
#import "KBChatTableView.h"
|
||||||
|
|
||||||
|
// 2. 修改属性声明
|
||||||
|
@property (nonatomic, strong) KBChatTableView *chatView;
|
||||||
|
|
||||||
|
// 3. 在 setupUI 中初始化
|
||||||
|
self.chatView = [[KBChatTableView alloc] init];
|
||||||
|
self.chatView.backgroundColor = [UIColor clearColor];
|
||||||
|
[self.view addSubview:self.chatView];
|
||||||
|
|
||||||
|
[self.chatView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.transcriptLabel.mas_bottom).offset(8);
|
||||||
|
make.left.right.equalTo(self.view);
|
||||||
|
make.bottom.lessThanOrEqualTo(self.recordButton.mas_top).offset(-16);
|
||||||
|
}];
|
||||||
|
|
||||||
|
// 4. 在 Deepgram 回调中添加消息
|
||||||
|
- (void)deepgramStreamingManagerDidReceiveFinalTranscript:(NSString *)text {
|
||||||
|
// ... 现有代码 ...
|
||||||
|
|
||||||
|
// 添加用户消息
|
||||||
|
[self.chatView addUserMessage:finalText];
|
||||||
|
|
||||||
|
// AI 回复完成后
|
||||||
|
[self.chatView addAssistantMessage:polishedText
|
||||||
|
audioDuration:audioData.length / 16000.0 // 估算时长
|
||||||
|
audioData:audioData];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 时间戳显示规则
|
||||||
|
|
||||||
|
时间戳会在以下情况自动插入:
|
||||||
|
1. 第一条消息
|
||||||
|
2. 距离上一条消息超过 5 分钟
|
||||||
|
3. 跨天的消息
|
||||||
|
|
||||||
|
时间格式:
|
||||||
|
- 今天:`16:36`
|
||||||
|
- 昨天:`昨天 16:36`
|
||||||
|
- 其他:`01月23日 16:36`
|
||||||
|
|
||||||
|
## 语音播放
|
||||||
|
|
||||||
|
- 点击语音按钮播放音频
|
||||||
|
- 播放时按钮变为暂停图标
|
||||||
|
- 点击其他消息的语音按钮会停止当前播放
|
||||||
|
- 播放完成后自动恢复播放图标
|
||||||
|
|
||||||
|
## 自定义样式
|
||||||
|
|
||||||
|
### 修改气泡颜色
|
||||||
|
|
||||||
|
在对应的 Cell 文件中修改:
|
||||||
|
|
||||||
|
**用户消息** (`KBChatUserMessageCell.m`):
|
||||||
|
```objective-c
|
||||||
|
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.94 green:0.94 blue:0.94 alpha:1.0];
|
||||||
|
```
|
||||||
|
|
||||||
|
**AI 消息** (`KBChatAssistantMessageCell.m`):
|
||||||
|
```objective-c
|
||||||
|
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.7];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改时间戳间隔
|
||||||
|
|
||||||
|
在 `KBChatTableView.m` 中修改:
|
||||||
|
```objective-c
|
||||||
|
static const NSTimeInterval kTimestampInterval = 5 * 60; // 改为你想要的秒数
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **音频数据格式**:确保传入的 `audioData` 是 AVAudioPlayer 支持的格式(如 MP3、AAC)
|
||||||
|
2. **内存管理**:大量消息时考虑分页加载
|
||||||
|
3. **线程安全**:UI 更新需要在主线程执行
|
||||||
|
4. **音频会话**:播放音频前确保配置了 AVAudioSession
|
||||||
|
|
||||||
|
## 完整示例
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
// 模拟对话
|
||||||
|
[self.chatView addUserMessage:@"你好"];
|
||||||
|
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
|
||||||
|
NSString *aiText = @"你好!很高兴见到你,有什么可以帮助你的吗?";
|
||||||
|
NSData *audioData = [self generateMockAudioData]; // 模拟音频数据
|
||||||
|
[self.chatView addAssistantMessage:aiText
|
||||||
|
audioDuration:6.0
|
||||||
|
audioData:audioData];
|
||||||
|
});
|
||||||
|
```
|
||||||
22
keyBoard/Class/AiTalk/V/KBChatTimeCell.h
Normal file
22
keyBoard/Class/AiTalk/V/KBChatTimeCell.h
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// KBChatTimeCell.h
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
|
||||||
|
@class KBAiChatMessage;
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
/// 时间戳 Cell(居中显示)
|
||||||
|
@interface KBChatTimeCell : UITableViewCell
|
||||||
|
|
||||||
|
/// 配置 Cell
|
||||||
|
- (void)configureWithMessage:(KBAiChatMessage *)message;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
70
keyBoard/Class/AiTalk/V/KBChatTimeCell.m
Normal file
70
keyBoard/Class/AiTalk/V/KBChatTimeCell.m
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
//
|
||||||
|
// KBChatTimeCell.m
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBChatTimeCell.h"
|
||||||
|
#import "KBAiChatMessage.h"
|
||||||
|
#import <Masonry/Masonry.h>
|
||||||
|
|
||||||
|
@interface KBChatTimeCell ()
|
||||||
|
|
||||||
|
@property (nonatomic, strong) UILabel *timeLabel;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBChatTimeCell
|
||||||
|
|
||||||
|
- (instancetype)initWithStyle:(UITableViewCellStyle)style
|
||||||
|
reuseIdentifier:(NSString *)reuseIdentifier {
|
||||||
|
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
|
||||||
|
if (self) {
|
||||||
|
self.backgroundColor = [UIColor clearColor];
|
||||||
|
self.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||||
|
[self setupUI];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setupUI {
|
||||||
|
// 时间标签
|
||||||
|
self.timeLabel = [[UILabel alloc] init];
|
||||||
|
self.timeLabel.font = [UIFont systemFontOfSize:12];
|
||||||
|
self.timeLabel.textColor = [UIColor secondaryLabelColor];
|
||||||
|
self.timeLabel.textAlignment = NSTextAlignmentCenter;
|
||||||
|
[self.contentView addSubview:self.timeLabel];
|
||||||
|
|
||||||
|
// 布局约束
|
||||||
|
[self.timeLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.contentView).offset(8);
|
||||||
|
make.bottom.equalTo(self.contentView).offset(-8);
|
||||||
|
make.centerX.equalTo(self.contentView);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)configureWithMessage:(KBAiChatMessage *)message {
|
||||||
|
self.timeLabel.text = [self formatTimestamp:message.timestamp];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 格式化时间戳
|
||||||
|
- (NSString *)formatTimestamp:(NSDate *)timestamp {
|
||||||
|
NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
|
||||||
|
|
||||||
|
NSCalendar *calendar = [NSCalendar currentCalendar];
|
||||||
|
if ([calendar isDateInToday:timestamp]) {
|
||||||
|
// 今天:显示时间
|
||||||
|
formatter.dateFormat = @"HH:mm";
|
||||||
|
} else if ([calendar isDateInYesterday:timestamp]) {
|
||||||
|
// 昨天
|
||||||
|
formatter.dateFormat = @"'昨天' HH:mm";
|
||||||
|
} else {
|
||||||
|
// 其他:显示日期 + 时间
|
||||||
|
formatter.dateFormat = @"MM月dd日 HH:mm";
|
||||||
|
}
|
||||||
|
|
||||||
|
return [formatter stringFromDate:timestamp];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
22
keyBoard/Class/AiTalk/V/KBChatUserMessageCell.h
Normal file
22
keyBoard/Class/AiTalk/V/KBChatUserMessageCell.h
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// KBChatUserMessageCell.h
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
|
||||||
|
@class KBAiChatMessage;
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
/// 用户消息 Cell(右侧气泡)
|
||||||
|
@interface KBChatUserMessageCell : UITableViewCell
|
||||||
|
|
||||||
|
/// 配置 Cell
|
||||||
|
- (void)configureWithMessage:(KBAiChatMessage *)message;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
67
keyBoard/Class/AiTalk/V/KBChatUserMessageCell.m
Normal file
67
keyBoard/Class/AiTalk/V/KBChatUserMessageCell.m
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// KBChatUserMessageCell.m
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBChatUserMessageCell.h"
|
||||||
|
#import "KBAiChatMessage.h"
|
||||||
|
#import <Masonry/Masonry.h>
|
||||||
|
|
||||||
|
@interface KBChatUserMessageCell ()
|
||||||
|
|
||||||
|
@property (nonatomic, strong) UIView *bubbleView;
|
||||||
|
@property (nonatomic, strong) UILabel *messageLabel;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBChatUserMessageCell
|
||||||
|
|
||||||
|
- (instancetype)initWithStyle:(UITableViewCellStyle)style
|
||||||
|
reuseIdentifier:(NSString *)reuseIdentifier {
|
||||||
|
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
|
||||||
|
if (self) {
|
||||||
|
self.backgroundColor = [UIColor clearColor];
|
||||||
|
self.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||||
|
[self setupUI];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setupUI {
|
||||||
|
// 气泡视图
|
||||||
|
self.bubbleView = [[UIView alloc] init];
|
||||||
|
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.94 green:0.94 blue:0.94 alpha:1.0];
|
||||||
|
self.bubbleView.layer.cornerRadius = 16;
|
||||||
|
self.bubbleView.layer.masksToBounds = YES;
|
||||||
|
[self.contentView addSubview:self.bubbleView];
|
||||||
|
|
||||||
|
// 消息标签
|
||||||
|
self.messageLabel = [[UILabel alloc] init];
|
||||||
|
self.messageLabel.numberOfLines = 0;
|
||||||
|
self.messageLabel.font = [UIFont systemFontOfSize:16];
|
||||||
|
self.messageLabel.textColor = [UIColor blackColor];
|
||||||
|
[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(-16);
|
||||||
|
make.width.lessThanOrEqualTo(self.contentView).multipliedBy(0.75);
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self.messageLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.bubbleView).offset(10);
|
||||||
|
make.bottom.equalTo(self.bubbleView).offset(-10);
|
||||||
|
make.left.equalTo(self.bubbleView).offset(12);
|
||||||
|
make.right.equalTo(self.bubbleView).offset(-12);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)configureWithMessage:(KBAiChatMessage *)message {
|
||||||
|
self.messageLabel.text = message.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
#import "AudioSessionManager.h"
|
#import "AudioSessionManager.h"
|
||||||
#import "DeepgramStreamingManager.h"
|
#import "DeepgramStreamingManager.h"
|
||||||
#import "KBAICommentView.h"
|
#import "KBAICommentView.h"
|
||||||
#import "KBAiChatView.h"
|
#import "KBChatTableView.h"
|
||||||
#import "KBAiRecordButton.h"
|
#import "KBAiRecordButton.h"
|
||||||
#import "KBHUD.h"
|
#import "KBHUD.h"
|
||||||
#import "LSTPopView.h"
|
#import "LSTPopView.h"
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
@property(nonatomic, weak) LSTPopView *popView;
|
@property(nonatomic, weak) LSTPopView *popView;
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
@property(nonatomic, strong) KBAiChatView *chatView;
|
@property(nonatomic, strong) KBChatTableView *chatView;
|
||||||
@property(nonatomic, strong) KBAiRecordButton *recordButton;
|
@property(nonatomic, strong) KBAiRecordButton *recordButton;
|
||||||
@property(nonatomic, strong) UILabel *statusLabel;
|
@property(nonatomic, strong) UILabel *statusLabel;
|
||||||
@property(nonatomic, strong) UILabel *transcriptLabel;
|
@property(nonatomic, strong) UILabel *transcriptLabel;
|
||||||
@@ -68,8 +68,7 @@
|
|||||||
[self setupUI];
|
[self setupUI];
|
||||||
[self setupOrchestrator];
|
[self setupOrchestrator];
|
||||||
[self setupStreamingManager];
|
[self setupStreamingManager];
|
||||||
// 切换到 websocket-api 方案,Deepgram 暂不初始化
|
[self setupDeepgramManager];
|
||||||
// [self setupDeepgramManager];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)viewWillAppear:(BOOL)animated {
|
- (void)viewWillAppear:(BOOL)animated {
|
||||||
@@ -159,7 +158,7 @@
|
|||||||
[self.view addSubview:self.transcriptLabel];
|
[self.view addSubview:self.transcriptLabel];
|
||||||
|
|
||||||
// 聊天视图
|
// 聊天视图
|
||||||
self.chatView = [[KBAiChatView alloc] init];
|
self.chatView = [[KBChatTableView alloc] init];
|
||||||
self.chatView.backgroundColor = [UIColor clearColor];
|
self.chatView.backgroundColor = [UIColor clearColor];
|
||||||
self.chatView.translatesAutoresizingMaskIntoConstraints = NO;
|
self.chatView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||||
[self.view addSubview:self.chatView];
|
[self.view addSubview:self.chatView];
|
||||||
@@ -208,13 +207,20 @@
|
|||||||
make.left.equalTo(self.view).offset(16);
|
make.left.equalTo(self.view).offset(16);
|
||||||
make.right.equalTo(self.view).offset(-16);
|
make.right.equalTo(self.view).offset(-16);
|
||||||
}];
|
}];
|
||||||
|
// 设置内容压缩阻力,避免被压缩
|
||||||
|
[self.transcriptLabel setContentCompressionResistancePriority:UILayoutPriorityDefaultLow
|
||||||
|
forAxis:UILayoutConstraintAxisVertical];
|
||||||
|
|
||||||
[self.chatView mas_makeConstraints:^(MASConstraintMaker *make) {
|
[self.chatView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.right.equalTo(self.view);
|
||||||
|
make.bottom.equalTo(self.tabbarBackgroundView.mas_top).offset(-8);
|
||||||
make.top.equalTo(self.transcriptLabel.mas_bottom).offset(8);
|
make.top.equalTo(self.transcriptLabel.mas_bottom).offset(8);
|
||||||
make.left.equalTo(self.view).offset(16);
|
// 设置最小高度,避免被压缩为 0
|
||||||
make.right.equalTo(self.view).offset(-16);
|
make.height.greaterThanOrEqualTo(@100).priority(MASLayoutPriorityDefaultHigh);
|
||||||
make.bottom.lessThanOrEqualTo(self.recordButton.mas_top).offset(-16);
|
|
||||||
}];
|
}];
|
||||||
|
// chatView 应该尽可能占据空间
|
||||||
|
[self.chatView setContentCompressionResistancePriority:UILayoutPriorityRequired
|
||||||
|
forAxis:UILayoutConstraintAxisVertical];
|
||||||
|
|
||||||
[self.recordButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
[self.recordButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
make.left.equalTo(self.view.mas_safeAreaLayoutGuideLeft).offset(20);
|
make.left.equalTo(self.view.mas_safeAreaLayoutGuideLeft).offset(20);
|
||||||
@@ -311,7 +317,7 @@
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
// 添加空的 AI 消息占位
|
// 添加空的 AI 消息占位
|
||||||
[strongSelf.chatView addAssistantMessage:@""];
|
[strongSelf.chatView addAssistantMessage:@"" audioDuration:0 audioData:nil];
|
||||||
};
|
};
|
||||||
|
|
||||||
// AI 说话结束
|
// AI 说话结束
|
||||||
@@ -455,6 +461,10 @@
|
|||||||
|
|
||||||
- (void)recordButtonDidBeginPress:(KBAiRecordButton *)button {
|
- (void)recordButtonDidBeginPress:(KBAiRecordButton *)button {
|
||||||
NSLog(@"[KBAiMainVC] Record button began press");
|
NSLog(@"[KBAiMainVC] Record button began press");
|
||||||
|
|
||||||
|
// 停止正在播放的音频
|
||||||
|
[self.chatView stopPlayingAudio];
|
||||||
|
|
||||||
NSString *token = [[KBUserSessionManager shared] accessToken] ?: @"";
|
NSString *token = [[KBUserSessionManager shared] accessToken] ?: @"";
|
||||||
if (token.length == 0) {
|
if (token.length == 0) {
|
||||||
[[KBUserSessionManager shared] goLoginVC];
|
[[KBUserSessionManager shared] goLoginVC];
|
||||||
@@ -463,20 +473,19 @@
|
|||||||
|
|
||||||
self.statusLabel.text = @"正在连接...";
|
self.statusLabel.text = @"正在连接...";
|
||||||
self.recordButton.state = KBAiRecordButtonStateRecording;
|
self.recordButton.state = KBAiRecordButtonStateRecording;
|
||||||
|
[self.deepgramFullText setString:@""];
|
||||||
self.transcriptLabel.text = @"";
|
self.transcriptLabel.text = @"";
|
||||||
[self.voiceChatAudioBuffer setLength:0];
|
[self.deepgramManager start];
|
||||||
[self.streamingManager startWithToken:token language:@"en" voiceId:nil];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)recordButtonDidEndPress:(KBAiRecordButton *)button {
|
- (void)recordButtonDidEndPress:(KBAiRecordButton *)button {
|
||||||
NSLog(@"[KBAiMainVC] Record button end press");
|
NSLog(@"[KBAiMainVC] Record button end press");
|
||||||
[self.streamingManager stopAndFinalize];
|
[self.deepgramManager stopAndFinalize];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)recordButtonDidCancelPress:(KBAiRecordButton *)button {
|
- (void)recordButtonDidCancelPress:(KBAiRecordButton *)button {
|
||||||
NSLog(@"[KBAiMainVC] Record button cancel press");
|
NSLog(@"[KBAiMainVC] Record button cancel press");
|
||||||
[self.voiceChatAudioBuffer setLength:0];
|
[self.deepgramManager cancel];
|
||||||
[self.streamingManager cancel];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - VoiceChatStreamingManagerDelegate
|
#pragma mark - VoiceChatStreamingManagerDelegate
|
||||||
@@ -537,7 +546,7 @@
|
|||||||
- (void)voiceChatStreamingManagerDidReceiveLLMStart {
|
- (void)voiceChatStreamingManagerDidReceiveLLMStart {
|
||||||
self.statusLabel.text = @"AI 正在思考...";
|
self.statusLabel.text = @"AI 正在思考...";
|
||||||
[self.assistantVisibleText setString:@""];
|
[self.assistantVisibleText setString:@""];
|
||||||
[self.chatView addAssistantMessage:@""];
|
[self.chatView addAssistantMessage:@"" audioDuration:0 audioData:nil];
|
||||||
[self.voiceChatAudioBuffer setLength:0];
|
[self.voiceChatAudioBuffer setLength:0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,22 +568,36 @@
|
|||||||
|
|
||||||
- (void)voiceChatStreamingManagerDidCompleteWithTranscript:(NSString *)transcript
|
- (void)voiceChatStreamingManagerDidCompleteWithTranscript:(NSString *)transcript
|
||||||
aiResponse:(NSString *)aiResponse {
|
aiResponse:(NSString *)aiResponse {
|
||||||
NSString *finalText = aiResponse.length > 0 ? aiResponse
|
NSString *finalText = aiResponse.length > 0 ? aiResponse : self.assistantVisibleText;
|
||||||
: self.assistantVisibleText;
|
|
||||||
if (aiResponse.length > 0) {
|
if (aiResponse.length > 0) {
|
||||||
[self.assistantVisibleText setString:aiResponse];
|
[self.assistantVisibleText setString:aiResponse];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算音频时长
|
||||||
|
NSTimeInterval duration = 0;
|
||||||
|
if (self.voiceChatAudioBuffer.length > 0) {
|
||||||
|
NSError *error = nil;
|
||||||
|
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:self.voiceChatAudioBuffer
|
||||||
|
error:&error];
|
||||||
|
if (!error && player) {
|
||||||
|
duration = player.duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (finalText.length > 0) {
|
if (finalText.length > 0) {
|
||||||
[self.chatView updateLastAssistantMessage:finalText];
|
[self.chatView updateLastAssistantMessage:finalText];
|
||||||
[self.chatView markLastAssistantMessageComplete];
|
[self.chatView markLastAssistantMessageComplete];
|
||||||
} else if (transcript.length > 0) {
|
} else if (transcript.length > 0) {
|
||||||
[self.chatView addAssistantMessage:transcript];
|
[self.chatView addAssistantMessage:transcript
|
||||||
[self.chatView markLastAssistantMessageComplete];
|
audioDuration:duration
|
||||||
|
audioData:self.voiceChatAudioBuffer.length > 0 ? self.voiceChatAudioBuffer : nil];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self.voiceChatAudioBuffer.length > 0) {
|
if (self.voiceChatAudioBuffer.length > 0) {
|
||||||
[self playAiAudioData:self.voiceChatAudioBuffer];
|
[self playAiAudioData:self.voiceChatAudioBuffer];
|
||||||
[self.voiceChatAudioBuffer setLength:0];
|
[self.voiceChatAudioBuffer setLength:0];
|
||||||
}
|
}
|
||||||
|
|
||||||
self.recordButton.state = KBAiRecordButtonStateNormal;
|
self.recordButton.state = KBAiRecordButtonStateNormal;
|
||||||
self.statusLabel.text = @"完成";
|
self.statusLabel.text = @"完成";
|
||||||
}
|
}
|
||||||
@@ -629,39 +652,50 @@
|
|||||||
self.statusLabel.text = @"识别完成";
|
self.statusLabel.text = @"识别完成";
|
||||||
self.recordButton.state = KBAiRecordButtonStateNormal;
|
self.recordButton.state = KBAiRecordButtonStateNormal;
|
||||||
|
|
||||||
// NSString *finalText = [self.deepgramFullText copy];
|
NSString *finalText = [self.deepgramFullText copy];
|
||||||
// if (finalText.length > 0) {
|
if (finalText.length == 0) {
|
||||||
// __weak typeof(self) weakSelf = self;
|
return;
|
||||||
// [KBHUD show];
|
}
|
||||||
// [self.aiVM syncChatWithTranscript:finalText
|
|
||||||
// completion:^(KBAiSyncResponse *_Nullable response,
|
// 添加用户消息
|
||||||
// NSError *_Nullable error) {
|
[self.chatView addUserMessage:finalText];
|
||||||
// __strong typeof(weakSelf) strongSelf = weakSelf;
|
|
||||||
// if (!strongSelf) {
|
__weak typeof(self) weakSelf = self;
|
||||||
// return;
|
[KBHUD showWithStatus:@"AI 思考中..."];
|
||||||
// }
|
|
||||||
// dispatch_async(dispatch_get_main_queue(), ^{
|
// 请求 chat/message 接口
|
||||||
// [KBHUD dismiss];
|
[self.aiVM requestChatMessageWithContent:finalText
|
||||||
// if (error) {
|
completion:^(KBAiMessageResponse *_Nullable response,
|
||||||
// [KBHUD showError:error.localizedDescription ?: @"请求失败"];
|
NSError *_Nullable error) {
|
||||||
// return;
|
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||||
// }
|
if (!strongSelf) {
|
||||||
//
|
return;
|
||||||
// NSString *aiResponse = response.data.aiResponse ?: @"";
|
}
|
||||||
// if (aiResponse.length > 0) {
|
|
||||||
// NSLog(@"[KBAiMainVC] /chat/sync aiResponse: %@", aiResponse);
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
// }
|
[KBHUD dismiss];
|
||||||
//
|
|
||||||
// NSData *audioData = response.data.audioData;
|
if (error) {
|
||||||
// if (audioData.length > 0) {
|
[KBHUD showError:error.localizedDescription ?: @"请求失败"];
|
||||||
// NSLog(@"[KBAiMainVC] /chat/sync audio ready, start play");
|
return;
|
||||||
// [strongSelf playAiAudioData:audioData];
|
}
|
||||||
// } else {
|
|
||||||
// NSLog(@"[KBAiMainVC] /chat/sync audioData empty");
|
// 获取 AI 回复文本
|
||||||
// }
|
NSString *aiResponse = response.data.aiResponse ?: response.data.content ?: response.data.text ?: response.data.message ?: @"";
|
||||||
// });
|
|
||||||
// }];
|
if (aiResponse.length == 0) {
|
||||||
// }
|
[KBHUD showError:@"AI 回复为空"];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 audioId
|
||||||
|
NSString *audioId = response.data.audioId;
|
||||||
|
|
||||||
|
// 添加 AI 消息(带 audioId)
|
||||||
|
[strongSelf.chatView addAssistantMessage:aiResponse
|
||||||
|
audioId:audioId];
|
||||||
|
});
|
||||||
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)deepgramStreamingManagerDidFail:(NSError *)error {
|
- (void)deepgramStreamingManagerDidFail:(NSError *)error {
|
||||||
|
|||||||
269
keyBoard/Class/AiTalk/VC/KBAiMainVC_集成说明.md
Normal file
269
keyBoard/Class/AiTalk/VC/KBAiMainVC_集成说明.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# KBAiMainVC 集成 KBChatTableView 说明
|
||||||
|
|
||||||
|
## ✅ 已完成的修改
|
||||||
|
|
||||||
|
### 1. Import 修改
|
||||||
|
```objective-c
|
||||||
|
// 原来
|
||||||
|
#import "KBAiChatView.h"
|
||||||
|
|
||||||
|
// 修改为
|
||||||
|
#import "KBChatTableView.h"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 属性类型修改
|
||||||
|
```objective-c
|
||||||
|
// 原来
|
||||||
|
@property(nonatomic, strong) KBAiChatView *chatView;
|
||||||
|
|
||||||
|
// 修改为
|
||||||
|
@property(nonatomic, strong) KBChatTableView *chatView;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 初始化修改
|
||||||
|
```objective-c
|
||||||
|
// 原来
|
||||||
|
self.chatView = [[KBAiChatView alloc] init];
|
||||||
|
|
||||||
|
// 修改为
|
||||||
|
self.chatView = [[KBChatTableView alloc] init];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 布局约束修改(从底部向上显示)
|
||||||
|
|
||||||
|
**原来的布局:**
|
||||||
|
```objective-c
|
||||||
|
[self.chatView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.transcriptLabel.mas_bottom).offset(8);
|
||||||
|
make.left.equalTo(self.view).offset(16);
|
||||||
|
make.right.equalTo(self.view).offset(-16);
|
||||||
|
make.bottom.lessThanOrEqualTo(self.recordButton.mas_top).offset(-16);
|
||||||
|
}];
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改为(从底部向上):**
|
||||||
|
```objective-c
|
||||||
|
[self.chatView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.right.equalTo(self.view);
|
||||||
|
make.bottom.equalTo(self.tabbarBackgroundView.mas_top).offset(-8);
|
||||||
|
make.top.greaterThanOrEqualTo(self.transcriptLabel.mas_bottom).offset(8);
|
||||||
|
}];
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明:**
|
||||||
|
- `bottom` 约束到 `tabbarBackgroundView` 的顶部,确保从底部开始
|
||||||
|
- `top` 使用 `greaterThanOrEqualTo`,允许内容向上扩展
|
||||||
|
- 移除了左右的 16pt 边距,让聊天视图占满宽度(气泡内部已有边距)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 消息添加逻辑修改
|
||||||
|
|
||||||
|
#### 5.1 添加用户消息(保持不变)
|
||||||
|
```objective-c
|
||||||
|
[self.chatView addUserMessage:finalText];
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2 添加 AI 消息(带语音数据)
|
||||||
|
|
||||||
|
**在 `deepgramStreamingManagerDidReceiveFinalTranscript` 中:**
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
// 1. 添加用户消息
|
||||||
|
[self.chatView addUserMessage:finalText];
|
||||||
|
|
||||||
|
// 2. 生成 AI 回复后,添加带语音的消息
|
||||||
|
// 计算音频时长
|
||||||
|
NSTimeInterval duration = 0;
|
||||||
|
NSError *playerError = nil;
|
||||||
|
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:audioData
|
||||||
|
error:&playerError];
|
||||||
|
if (!playerError && player) {
|
||||||
|
duration = player.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 AI 消息(带语音)
|
||||||
|
[self.chatView addAssistantMessage:polishedText
|
||||||
|
audioDuration:duration
|
||||||
|
audioData:audioData];
|
||||||
|
|
||||||
|
// 播放音频
|
||||||
|
[self playAiAudioData:audioData];
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误处理:**
|
||||||
|
```objective-c
|
||||||
|
// 如果语音生成失败,仍然添加文本消息(无语音)
|
||||||
|
if (ttsError) {
|
||||||
|
[KBHUD showError:ttsError.localizedDescription ?: @"语音生成失败"];
|
||||||
|
[self.chatView addAssistantMessage:polishedText
|
||||||
|
audioDuration:0
|
||||||
|
audioData:nil];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5.3 打字机效果修改
|
||||||
|
|
||||||
|
**在 `voiceChatStreamingManagerDidReceiveLLMStart` 中:**
|
||||||
|
```objective-c
|
||||||
|
// 原来
|
||||||
|
[self.chatView addAssistantMessage:@""];
|
||||||
|
|
||||||
|
// 修改为
|
||||||
|
[self.chatView addAssistantMessage:@"" audioDuration:0 audioData:nil];
|
||||||
|
```
|
||||||
|
|
||||||
|
**在 `orchestrator.onSpeakingStart` 中:**
|
||||||
|
```objective-c
|
||||||
|
// 原来
|
||||||
|
[self.chatView addAssistantMessage:@""];
|
||||||
|
|
||||||
|
// 修改为
|
||||||
|
[self.chatView addAssistantMessage:@"" audioDuration:0 audioData:nil];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. VoiceChatStreamingManager 完成回调修改
|
||||||
|
|
||||||
|
**在 `voiceChatStreamingManagerDidCompleteWithTranscript` 中:**
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
// 计算音频时长
|
||||||
|
NSTimeInterval duration = 0;
|
||||||
|
if (self.voiceChatAudioBuffer.length > 0) {
|
||||||
|
NSError *error = nil;
|
||||||
|
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:self.voiceChatAudioBuffer
|
||||||
|
error:&error];
|
||||||
|
if (!error && player) {
|
||||||
|
duration = player.duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加消息时带上音频数据
|
||||||
|
if (transcript.length > 0) {
|
||||||
|
[self.chatView addAssistantMessage:transcript
|
||||||
|
audioDuration:duration
|
||||||
|
audioData:self.voiceChatAudioBuffer.length > 0 ? self.voiceChatAudioBuffer : nil];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 核心改进
|
||||||
|
|
||||||
|
### 1. 从底部向上显示
|
||||||
|
- 聊天视图的 `bottom` 约束到 `tabbarBackgroundView.top`
|
||||||
|
- 新消息会从底部向上堆叠
|
||||||
|
- 自动滚动到最新消息
|
||||||
|
|
||||||
|
### 2. 语音功能集成
|
||||||
|
- AI 消息带有语音数据和时长
|
||||||
|
- 点击语音按钮可播放
|
||||||
|
- 显示语音时长(如 "6"")
|
||||||
|
|
||||||
|
### 3. 时间戳自动显示
|
||||||
|
- 第一条消息显示时间
|
||||||
|
- 超过 5 分钟自动插入时间戳
|
||||||
|
- 跨天消息显示日期
|
||||||
|
|
||||||
|
### 4. 用户体验优化
|
||||||
|
- 消息气泡左右对齐清晰
|
||||||
|
- 语音播放状态可视化
|
||||||
|
- 自动滚动到最新消息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 布局效果
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 状态标签 │
|
||||||
|
│ 转写文本标签 │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 聊天消息区域 │ │
|
||||||
|
│ │ (从底部向上显示) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 16:36 │ │ ← 时间戳
|
||||||
|
│ │ ┌──────────┐ │ │
|
||||||
|
│ │ │ 你好呀。 │ │ │ ← 用户消息(右侧)
|
||||||
|
│ │ └──────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ▶️ 6" │ │ ← 语音按钮
|
||||||
|
│ │ ┌──────────────────────┐ │ │
|
||||||
|
│ │ │ 你好!很高兴见到你。 │ │ │ ← AI 消息(左侧)
|
||||||
|
│ │ └──────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────┐ │
|
||||||
|
│ │ 毛玻璃背景区域 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌──────────────────────┐ │ │
|
||||||
|
│ │ │ 录音按钮 │ │ │
|
||||||
|
│ │ └──────────────────────┘ │ │
|
||||||
|
│ └────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 测试清单
|
||||||
|
|
||||||
|
- [ ] 用户消息显示在右侧
|
||||||
|
- [ ] AI 消息显示在左侧
|
||||||
|
- [ ] 语音按钮显示在 AI 消息左上角
|
||||||
|
- [ ] 点击语音按钮可播放
|
||||||
|
- [ ] 语音时长正确显示
|
||||||
|
- [ ] 时间戳自动插入
|
||||||
|
- [ ] 消息从底部向上显示
|
||||||
|
- [ ] 新消息自动滚动到底部
|
||||||
|
- [ ] 打字机效果正常工作
|
||||||
|
- [ ] 语音播放状态图标切换
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 可能的问题
|
||||||
|
|
||||||
|
### 1. 语音按钮不显示
|
||||||
|
**原因:** `audioData` 为 nil 或长度为 0
|
||||||
|
**解决:** 检查 TTS 返回的音频数据
|
||||||
|
|
||||||
|
### 2. 消息不从底部显示
|
||||||
|
**原因:** 布局约束错误
|
||||||
|
**解决:** 确保 `bottom` 约束到 `tabbarBackgroundView.top`
|
||||||
|
|
||||||
|
### 3. 时间戳不显示
|
||||||
|
**原因:** 消息的 `timestamp` 未设置
|
||||||
|
**解决:** `KBChatMessage` 会自动设置当前时间
|
||||||
|
|
||||||
|
### 4. 点击语音按钮没反应
|
||||||
|
**原因:** 音频数据格式不正确
|
||||||
|
**解决:** 确保音频数据是 AVAudioPlayer 支持的格式(MP3、AAC、M4A)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 完成!
|
||||||
|
|
||||||
|
现在 `KBAiMainVC` 已经成功集成了新的 `KBChatTableView`,具备以下特性:
|
||||||
|
|
||||||
|
✅ 从底部向上显示消息
|
||||||
|
✅ 用户消息在右侧
|
||||||
|
✅ AI 消息在左侧(带语音按钮)
|
||||||
|
✅ 时间戳自动插入
|
||||||
|
✅ 语音播放功能
|
||||||
|
✅ 打字机效果
|
||||||
|
|
||||||
|
运行项目即可看到效果!
|
||||||
17
keyBoard/Class/AiTalk/VC/KBChatTestVC.h
Normal file
17
keyBoard/Class/AiTalk/VC/KBChatTestVC.h
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// KBChatTestVC.h
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "BaseViewController.h"
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
/// 聊天 UI 测试页面
|
||||||
|
@interface KBChatTestVC : BaseViewController
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
149
keyBoard/Class/AiTalk/VC/KBChatTestVC.m
Normal file
149
keyBoard/Class/AiTalk/VC/KBChatTestVC.m
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
//
|
||||||
|
// KBChatTestVC.m
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBChatTestVC.h"
|
||||||
|
#import "KBChatTableView.h"
|
||||||
|
#import <Masonry/Masonry.h>
|
||||||
|
|
||||||
|
@interface KBChatTestVC ()
|
||||||
|
|
||||||
|
@property (nonatomic, strong) KBChatTableView *chatView;
|
||||||
|
@property (nonatomic, strong) UIButton *addUserMessageButton;
|
||||||
|
@property (nonatomic, strong) UIButton *addAIMessageButton;
|
||||||
|
@property (nonatomic, strong) UIButton *clearButton;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBChatTestVC
|
||||||
|
|
||||||
|
- (void)viewDidLoad {
|
||||||
|
[super viewDidLoad];
|
||||||
|
|
||||||
|
self.title = @"聊天 UI 测试";
|
||||||
|
self.view.backgroundColor = [UIColor whiteColor];
|
||||||
|
|
||||||
|
[self setupUI];
|
||||||
|
[self loadMockData];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setupUI {
|
||||||
|
// 聊天视图
|
||||||
|
self.chatView = [[KBChatTableView alloc] init];
|
||||||
|
self.chatView.backgroundColor = [UIColor colorWithWhite:0.95 alpha:1.0];
|
||||||
|
[self.view addSubview:self.chatView];
|
||||||
|
|
||||||
|
// 按钮容器
|
||||||
|
UIView *buttonContainer = [[UIView alloc] init];
|
||||||
|
buttonContainer.backgroundColor = [UIColor whiteColor];
|
||||||
|
[self.view addSubview:buttonContainer];
|
||||||
|
|
||||||
|
// 添加用户消息按钮
|
||||||
|
self.addUserMessageButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||||
|
[self.addUserMessageButton setTitle:@"添加用户消息" forState:UIControlStateNormal];
|
||||||
|
[self.addUserMessageButton addTarget:self
|
||||||
|
action:@selector(addUserMessage)
|
||||||
|
forControlEvents:UIControlEventTouchUpInside];
|
||||||
|
[buttonContainer addSubview:self.addUserMessageButton];
|
||||||
|
|
||||||
|
// 添加 AI 消息按钮
|
||||||
|
self.addAIMessageButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||||
|
[self.addAIMessageButton setTitle:@"添加 AI 消息" forState:UIControlStateNormal];
|
||||||
|
[self.addAIMessageButton addTarget:self
|
||||||
|
action:@selector(addAIMessage)
|
||||||
|
forControlEvents:UIControlEventTouchUpInside];
|
||||||
|
[buttonContainer addSubview:self.addAIMessageButton];
|
||||||
|
|
||||||
|
// 清空按钮
|
||||||
|
self.clearButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||||
|
[self.clearButton setTitle:@"清空" forState:UIControlStateNormal];
|
||||||
|
[self.clearButton addTarget:self
|
||||||
|
action:@selector(clearMessages)
|
||||||
|
forControlEvents:UIControlEventTouchUpInside];
|
||||||
|
[buttonContainer addSubview:self.clearButton];
|
||||||
|
|
||||||
|
// 布局
|
||||||
|
[self.chatView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.view.mas_safeAreaLayoutGuideTop);
|
||||||
|
make.left.right.equalTo(self.view);
|
||||||
|
make.bottom.equalTo(buttonContainer.mas_top);
|
||||||
|
}];
|
||||||
|
|
||||||
|
[buttonContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.right.equalTo(self.view);
|
||||||
|
make.bottom.equalTo(self.view.mas_safeAreaLayoutGuideBottom);
|
||||||
|
make.height.mas_equalTo(60);
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self.addUserMessageButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(buttonContainer).offset(16);
|
||||||
|
make.centerY.equalTo(buttonContainer);
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self.addAIMessageButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.center.equalTo(buttonContainer);
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self.clearButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.right.equalTo(buttonContainer).offset(-16);
|
||||||
|
make.centerY.equalTo(buttonContainer);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)loadMockData {
|
||||||
|
// 模拟对话数据
|
||||||
|
[self.chatView addUserMessage:@"你好"];
|
||||||
|
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.5 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
|
||||||
|
[self.chatView addAssistantMessage:@"你好!很高兴见到你。"
|
||||||
|
audioDuration:3.0
|
||||||
|
audioData:[self generateMockAudioData:3.0]];
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1.0 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
|
||||||
|
[self.chatView addUserMessage:@"今天天气怎么样?"];
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1.5 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
|
||||||
|
[self.chatView addAssistantMessage:@"今天天气不错,阳光明媚,温度适宜,非常适合外出活动。"
|
||||||
|
audioDuration:8.0
|
||||||
|
audioData:[self generateMockAudioData:8.0]];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)addUserMessage {
|
||||||
|
static NSInteger userMessageCount = 0;
|
||||||
|
userMessageCount++;
|
||||||
|
NSString *text = [NSString stringWithFormat:@"这是用户消息 %ld", (long)userMessageCount];
|
||||||
|
[self.chatView addUserMessage:text];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)addAIMessage {
|
||||||
|
static NSInteger aiMessageCount = 0;
|
||||||
|
aiMessageCount++;
|
||||||
|
NSString *text = [NSString stringWithFormat:@"这是 AI 回复消息 %ld,包含一些较长的文本内容,用于测试气泡的自适应高度和换行效果。", (long)aiMessageCount];
|
||||||
|
NSTimeInterval duration = 5.0 + (aiMessageCount % 10);
|
||||||
|
[self.chatView addAssistantMessage:text
|
||||||
|
audioDuration:duration
|
||||||
|
audioData:[self generateMockAudioData:duration]];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)clearMessages {
|
||||||
|
[self.chatView clearMessages];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成模拟音频数据(实际项目中应该从 TTS 获取)
|
||||||
|
- (NSData *)generateMockAudioData:(NSTimeInterval)duration {
|
||||||
|
// 这里返回 nil,实际使用时应该传入真实的音频数据
|
||||||
|
// 为了测试,可以加载一个本地音频文件
|
||||||
|
NSString *audioPath = [[NSBundle mainBundle] pathForResource:@"ai_test" ofType:@"m4a"];
|
||||||
|
if (audioPath) {
|
||||||
|
return [NSData dataWithContentsOfFile:audioPath];
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -27,6 +27,9 @@ typedef void (^AiVMSyncCompletion)(KBAiSyncResponse *_Nullable response,
|
|||||||
@property(nonatomic, copy, nullable) NSString *content;
|
@property(nonatomic, copy, nullable) NSString *content;
|
||||||
@property(nonatomic, copy, nullable) NSString *text;
|
@property(nonatomic, copy, nullable) NSString *text;
|
||||||
@property(nonatomic, copy, nullable) NSString *message;
|
@property(nonatomic, copy, nullable) NSString *message;
|
||||||
|
@property(nonatomic, copy, nullable) NSString *aiResponse;
|
||||||
|
@property(nonatomic, copy, nullable) NSString *audioId;
|
||||||
|
@property(nonatomic, assign) NSInteger llmDuration;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@interface KBAiMessageResponse : NSObject
|
@interface KBAiMessageResponse : NSObject
|
||||||
@@ -37,7 +40,7 @@ typedef void (^AiVMSyncCompletion)(KBAiSyncResponse *_Nullable response,
|
|||||||
typedef void (^AiVMMessageCompletion)(KBAiMessageResponse *_Nullable response,
|
typedef void (^AiVMMessageCompletion)(KBAiMessageResponse *_Nullable response,
|
||||||
NSError *_Nullable error);
|
NSError *_Nullable error);
|
||||||
|
|
||||||
typedef void (^AiVMElevenLabsCompletion)(NSData *_Nullable audioData,
|
typedef void (^AiVMAudioURLCompletion)(NSString *_Nullable audioURL,
|
||||||
NSError *_Nullable error);
|
NSError *_Nullable error);
|
||||||
|
|
||||||
@interface AiVM : NSObject
|
@interface AiVM : NSObject
|
||||||
@@ -48,12 +51,9 @@ typedef void (^AiVMElevenLabsCompletion)(NSData *_Nullable audioData,
|
|||||||
- (void)requestChatMessageWithContent:(NSString *)content
|
- (void)requestChatMessageWithContent:(NSString *)content
|
||||||
completion:(AiVMMessageCompletion)completion;
|
completion:(AiVMMessageCompletion)completion;
|
||||||
|
|
||||||
- (void)requestElevenLabsSpeechWithText:(NSString *)text
|
/// 根据 audioId 获取音频 URL
|
||||||
voiceId:(NSString *)voiceId
|
- (void)requestAudioWithAudioId:(NSString *)audioId
|
||||||
apiKey:(NSString *)apiKey
|
completion:(AiVMAudioURLCompletion)completion;
|
||||||
outputFormat:(nullable NSString *)outputFormat
|
|
||||||
modelId:(nullable NSString *)modelId
|
|
||||||
completion:(AiVMElevenLabsCompletion)completion;
|
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|||||||
@@ -131,81 +131,38 @@ autoShowBusinessError:NO
|
|||||||
|
|
||||||
KBAiMessageResponse *model =
|
KBAiMessageResponse *model =
|
||||||
[KBAiMessageResponse mj_objectWithKeyValues:json];
|
[KBAiMessageResponse mj_objectWithKeyValues:json];
|
||||||
|
id dataObj = json[@"data"];
|
||||||
|
if (!model.data && [dataObj isKindOfClass:[NSString class]]) {
|
||||||
|
KBAiMessageData *data = [[KBAiMessageData alloc] init];
|
||||||
|
data.content = (NSString *)dataObj;
|
||||||
|
model.data = data;
|
||||||
|
}
|
||||||
if (completion) {
|
if (completion) {
|
||||||
completion(model, nil);
|
completion(model, nil);
|
||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)requestElevenLabsSpeechWithText:(NSString *)text
|
- (void)requestAudioWithAudioId:(NSString *)audioId
|
||||||
voiceId:(NSString *)voiceId
|
completion:(AiVMAudioURLCompletion)completion {
|
||||||
apiKey:(NSString *)apiKey
|
if (audioId.length == 0) {
|
||||||
outputFormat:(NSString *)outputFormat
|
|
||||||
modelId:(NSString *)modelId
|
|
||||||
completion:(AiVMElevenLabsCompletion)completion {
|
|
||||||
if (text.length == 0 || voiceId.length == 0 || apiKey.length == 0) {
|
|
||||||
NSError *error = [NSError
|
NSError *error = [NSError
|
||||||
errorWithDomain:@"AiVM"
|
errorWithDomain:@"AiVM"
|
||||||
code:-1
|
code:-1
|
||||||
userInfo:@{NSLocalizedDescriptionKey : @"invalid parameters"}];
|
userInfo:@{NSLocalizedDescriptionKey : @"audioId is empty"}];
|
||||||
if (completion) {
|
if (completion) {
|
||||||
completion(nil, error);
|
completion(nil, error);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
NSString *format = outputFormat.length > 0 ? outputFormat : @"mp3_44100_128";
|
NSString *path = [NSString stringWithFormat:@"/chat/audio/%@", audioId];
|
||||||
NSString *model = modelId.length > 0 ? modelId : @"eleven_multilingual_v2";
|
[[KBNetworkManager shared]
|
||||||
NSString *escapedVoiceId =
|
GET:path
|
||||||
[voiceId stringByAddingPercentEncodingWithAllowedCharacters:
|
parameters:nil
|
||||||
[NSCharacterSet URLPathAllowedCharacterSet]];
|
headers:nil
|
||||||
NSString *escapedFormat =
|
autoShowBusinessError:NO
|
||||||
[format stringByAddingPercentEncodingWithAllowedCharacters:
|
completion:^(NSDictionary *_Nullable json,
|
||||||
[NSCharacterSet URLQueryAllowedCharacterSet]];
|
|
||||||
NSString *urlString =
|
|
||||||
[NSString stringWithFormat:@"https://api.elevenlabs.io/v1/text-to-speech/%@/stream?output_format=%@",
|
|
||||||
escapedVoiceId ?: @"",
|
|
||||||
escapedFormat ?: @""];
|
|
||||||
NSURL *url = [NSURL URLWithString:urlString];
|
|
||||||
if (!url) {
|
|
||||||
NSError *error = [NSError
|
|
||||||
errorWithDomain:@"AiVM"
|
|
||||||
code:-1
|
|
||||||
userInfo:@{NSLocalizedDescriptionKey : @"invalid URL"}];
|
|
||||||
if (completion) {
|
|
||||||
completion(nil, error);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
|
|
||||||
request.HTTPMethod = @"POST";
|
|
||||||
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
|
|
||||||
[request setValue:@"audio/mpeg" forHTTPHeaderField:@"Accept"];
|
|
||||||
[request setValue:apiKey forHTTPHeaderField:@"xi-api-key"];
|
|
||||||
|
|
||||||
NSDictionary *body = @{
|
|
||||||
@"text" : text ?: @"",
|
|
||||||
@"model_id" : model ?: @""
|
|
||||||
};
|
|
||||||
NSError *jsonError = nil;
|
|
||||||
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:body
|
|
||||||
options:0
|
|
||||||
error:&jsonError];
|
|
||||||
if (jsonError) {
|
|
||||||
if (completion) {
|
|
||||||
completion(nil, jsonError);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
request.HTTPBody = jsonData;
|
|
||||||
|
|
||||||
NSURLSessionConfiguration *config =
|
|
||||||
[NSURLSessionConfiguration defaultSessionConfiguration];
|
|
||||||
NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
|
|
||||||
NSURLSessionDataTask *task =
|
|
||||||
[session dataTaskWithRequest:request
|
|
||||||
completionHandler:^(NSData *_Nullable data,
|
|
||||||
NSURLResponse *_Nullable response,
|
NSURLResponse *_Nullable response,
|
||||||
NSError *_Nullable error) {
|
NSError *_Nullable error) {
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -215,41 +172,44 @@ autoShowBusinessError:NO
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (![response isKindOfClass:[NSHTTPURLResponse class]]) {
|
// 解析返回的 URL
|
||||||
NSError *respError = [NSError
|
NSString *audioURL = nil;
|
||||||
errorWithDomain:@"AiVM"
|
if ([json isKindOfClass:[NSDictionary class]]) {
|
||||||
code:-1
|
// 返回格式:{"code": 0, "data": {"audioUrl": "http://...", "url": "http://..."}}
|
||||||
userInfo:@{
|
id dataObj = json[@"data"];
|
||||||
NSLocalizedDescriptionKey :
|
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||||||
@"invalid response"
|
NSDictionary *dataDict = (NSDictionary *)dataObj;
|
||||||
}];
|
// 优先使用 audioUrl,兼容 url
|
||||||
if (completion) {
|
id audioUrlObj = dataDict[@"audioUrl"] ?: dataDict[@"url"];
|
||||||
completion(nil, respError);
|
// 检查是否为 NSNull
|
||||||
|
if (audioUrlObj && ![audioUrlObj isKindOfClass:[NSNull class]] && [audioUrlObj isKindOfClass:[NSString class]]) {
|
||||||
|
audioURL = (NSString *)audioUrlObj;
|
||||||
}
|
}
|
||||||
return;
|
} else if ([dataObj isKindOfClass:[NSString class]]) {
|
||||||
|
audioURL = (NSString *)dataObj;
|
||||||
}
|
}
|
||||||
|
|
||||||
NSInteger status =
|
// 或者直接返回 URL 字符串
|
||||||
((NSHTTPURLResponse *)response).statusCode;
|
if (!audioURL) {
|
||||||
if (status < 200 || status >= 300 || data.length == 0) {
|
id audioUrlObj = json[@"audioUrl"] ?: json[@"url"];
|
||||||
NSError *respError = [NSError
|
if (audioUrlObj && ![audioUrlObj isKindOfClass:[NSNull class]] && [audioUrlObj isKindOfClass:[NSString class]]) {
|
||||||
errorWithDomain:@"AiVM"
|
audioURL = (NSString *)audioUrlObj;
|
||||||
code:status
|
}
|
||||||
userInfo:@{
|
}
|
||||||
NSLocalizedDescriptionKey :
|
}
|
||||||
@"request failed"
|
|
||||||
}];
|
// 如果 audioURL 为空或 nil,返回 nil(不是错误,表示音频还未生成)
|
||||||
|
if (!audioURL || audioURL.length == 0) {
|
||||||
if (completion) {
|
if (completion) {
|
||||||
completion(nil, respError);
|
completion(nil, nil); // 返回 nil 表示音频未就绪,需要重试
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completion) {
|
if (completion) {
|
||||||
completion(data, nil);
|
completion(audioURL, nil);
|
||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
[task resume];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
427
keyBoard/Class/AiTalk/实现总结.md
Normal file
427
keyBoard/Class/AiTalk/实现总结.md
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
# 聊天 UI 实现总结
|
||||||
|
|
||||||
|
## ✅ 已完成
|
||||||
|
|
||||||
|
根据你的需求,我已经完整实现了一个类似微信的聊天 UI 系统,包含以下功能:
|
||||||
|
|
||||||
|
### 核心功能
|
||||||
|
- ✅ 用户消息(右侧浅色气泡)
|
||||||
|
- ✅ AI 消息(左侧深色气泡)
|
||||||
|
- ✅ 语音播放按钮(AI 消息左上角)
|
||||||
|
- ✅ 语音时长显示(如 "6"")
|
||||||
|
- ✅ 时间戳(自动插入,5 分钟间隔)
|
||||||
|
- ✅ 打字机效果支持
|
||||||
|
- ✅ 自动滚动到底部
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 创建的文件清单
|
||||||
|
|
||||||
|
### 1. Model(数据模型)
|
||||||
|
```
|
||||||
|
keyBoard/Class/AiTalk/M/
|
||||||
|
├── KBChatMessage.h # 消息模型头文件
|
||||||
|
└── KBChatMessage.m # 消息模型实现
|
||||||
|
```
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 支持三种消息类型:用户、AI、时间戳
|
||||||
|
- 包含文本、时间戳、语音时长、语音数据等属性
|
||||||
|
- 提供便捷构造方法
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. View(视图组件)
|
||||||
|
```
|
||||||
|
keyBoard/Class/AiTalk/V/
|
||||||
|
├── KBChatUserMessageCell.h # 用户消息 Cell 头文件
|
||||||
|
├── KBChatUserMessageCell.m # 用户消息 Cell 实现
|
||||||
|
├── KBChatAssistantMessageCell.h # AI 消息 Cell 头文件
|
||||||
|
├── KBChatAssistantMessageCell.m # AI 消息 Cell 实现
|
||||||
|
├── KBChatTimeCell.h # 时间戳 Cell 头文件
|
||||||
|
├── KBChatTimeCell.m # 时间戳 Cell 实现
|
||||||
|
├── KBChatTableView.h # 聊天列表视图头文件
|
||||||
|
└── KBChatTableView.m # 聊天列表视图实现
|
||||||
|
```
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- **KBChatUserMessageCell**:右侧气泡,浅色背景
|
||||||
|
- **KBChatAssistantMessageCell**:左侧气泡,深色背景,带语音按钮
|
||||||
|
- **KBChatTimeCell**:居中显示时间
|
||||||
|
- **KBChatTableView**:主容器,管理所有消息和语音播放
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. ViewController(测试页面)
|
||||||
|
```
|
||||||
|
keyBoard/Class/AiTalk/VC/
|
||||||
|
├── KBChatTestVC.h # 测试页面头文件
|
||||||
|
└── KBChatTestVC.m # 测试页面实现
|
||||||
|
```
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 演示如何使用新的聊天 UI
|
||||||
|
- 提供添加消息、清空等测试按钮
|
||||||
|
- 加载模拟对话数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 文档
|
||||||
|
```
|
||||||
|
keyBoard/Class/AiTalk/
|
||||||
|
├── 实现总结.md # 本文档
|
||||||
|
├── 集成指南.md # 详细集成步骤
|
||||||
|
└── V/
|
||||||
|
├── KBChatTableView_Usage.md # 使用说明
|
||||||
|
└── API_快速参考.md # API 快速查询
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 架构设计
|
||||||
|
|
||||||
|
### 方案选择:UITableView + Plain 模式
|
||||||
|
|
||||||
|
**为什么选择 Plain 模式?**
|
||||||
|
|
||||||
|
| 对比项 | Plain 模式 ✅ | Grouped 模式 ❌ |
|
||||||
|
|--------|--------------|----------------|
|
||||||
|
| 视觉连贯性 | 消息紧密排列 | Section 间距打断视觉 |
|
||||||
|
| 时间戳处理 | 作为普通 Cell 灵活插入 | 需要用 Section Header |
|
||||||
|
| 数据管理 | 一维数组,简单 | 二维数组,复杂 |
|
||||||
|
| 性能 | Cell 复用简单 | Section 管理开销 |
|
||||||
|
| 符合习惯 | 微信、iMessage 都用此方案 | 不适合聊天场景 |
|
||||||
|
|
||||||
|
### 三种独立 Cell 设计
|
||||||
|
|
||||||
|
**为什么不用一个通用 Cell?**
|
||||||
|
|
||||||
|
✅ **三种独立 Cell 的优势:**
|
||||||
|
- 职责清晰,每个 Cell 只负责一种样式
|
||||||
|
- 布局简单,不需要复杂的条件判断
|
||||||
|
- 复用标识符明确,性能好
|
||||||
|
- 易于维护和扩展
|
||||||
|
|
||||||
|
❌ **通用 Cell 的劣势:**
|
||||||
|
- Cell 内部逻辑复杂,大量 if-else
|
||||||
|
- 布局约束需要动态调整,容易出错
|
||||||
|
- 复用时需要重置状态,性能略差
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 核心实现细节
|
||||||
|
|
||||||
|
### 1. 时间戳自动插入
|
||||||
|
|
||||||
|
**策略:**
|
||||||
|
- 第一条消息总是显示时间
|
||||||
|
- 距离上一条消息超过 5 分钟
|
||||||
|
- 跨天的消息
|
||||||
|
|
||||||
|
**实现:**
|
||||||
|
```objective-c
|
||||||
|
- (BOOL)shouldInsertTimestampForMessage:(KBChatMessage *)message {
|
||||||
|
if (self.messages.count == 0) return YES;
|
||||||
|
|
||||||
|
KBChatMessage *lastMessage = [self findLastNonTimeMessage];
|
||||||
|
NSTimeInterval interval = [message.timestamp timeIntervalSinceDate:lastMessage.timestamp];
|
||||||
|
|
||||||
|
if (interval >= kTimestampInterval) return YES;
|
||||||
|
|
||||||
|
// 检查是否跨天
|
||||||
|
return ![self isSameDay:message.timestamp with:lastMessage.timestamp];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 语音播放管理
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 点击播放/暂停
|
||||||
|
- 播放时图标切换
|
||||||
|
- 点击其他消息自动停止当前播放
|
||||||
|
- 播放完成自动恢复
|
||||||
|
|
||||||
|
**实现:**
|
||||||
|
```objective-c
|
||||||
|
- (void)assistantMessageCell:(KBChatAssistantMessageCell *)cell
|
||||||
|
didTapVoiceButtonForMessage:(KBChatMessage *)message {
|
||||||
|
|
||||||
|
NSIndexPath *indexPath = [self.tableView indexPathForCell:cell];
|
||||||
|
|
||||||
|
// 如果正在播放同一条,则暂停
|
||||||
|
if ([indexPath isEqual:self.playingCellIndexPath]) {
|
||||||
|
[self stopPlayingAudio];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止之前的播放
|
||||||
|
[self stopPlayingAudio];
|
||||||
|
|
||||||
|
// 播放新的音频
|
||||||
|
[self playAudioForMessage:message atIndexPath:indexPath];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 打字机效果
|
||||||
|
|
||||||
|
**流程:**
|
||||||
|
1. 添加空消息占位
|
||||||
|
2. 逐步更新文本
|
||||||
|
3. 标记完成
|
||||||
|
|
||||||
|
**实现:**
|
||||||
|
```objective-c
|
||||||
|
// 1. 添加占位
|
||||||
|
[self.chatView addAssistantMessage:@"" audioDuration:0 audioData:nil];
|
||||||
|
|
||||||
|
// 2. 更新文本
|
||||||
|
[self.chatView updateLastAssistantMessage:currentText];
|
||||||
|
|
||||||
|
// 3. 完成
|
||||||
|
[self.chatView markLastAssistantMessageComplete];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 自动滚动
|
||||||
|
|
||||||
|
**策略:**
|
||||||
|
- 新消息添加后自动滚动到底部
|
||||||
|
- 使用动画效果
|
||||||
|
- 延迟 0.1 秒确保布局完成
|
||||||
|
|
||||||
|
**实现:**
|
||||||
|
```objective-c
|
||||||
|
- (void)reloadAndScroll {
|
||||||
|
[self.tableView reloadData];
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
|
||||||
|
[self scrollToBottom];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
用户输入
|
||||||
|
↓
|
||||||
|
KBChatMessage (用户消息)
|
||||||
|
↓
|
||||||
|
判断是否需要时间戳
|
||||||
|
↓
|
||||||
|
插入时间戳 Cell(可选)
|
||||||
|
↓
|
||||||
|
插入用户消息 Cell
|
||||||
|
↓
|
||||||
|
刷新 TableView
|
||||||
|
↓
|
||||||
|
滚动到底部
|
||||||
|
↓
|
||||||
|
AI 处理
|
||||||
|
↓
|
||||||
|
KBChatMessage (AI 消息 + 音频)
|
||||||
|
↓
|
||||||
|
判断是否需要时间戳
|
||||||
|
↓
|
||||||
|
插入时间戳 Cell(可选)
|
||||||
|
↓
|
||||||
|
插入 AI 消息 Cell
|
||||||
|
↓
|
||||||
|
刷新 TableView
|
||||||
|
↓
|
||||||
|
滚动到底部
|
||||||
|
↓
|
||||||
|
用户点击语音按钮
|
||||||
|
↓
|
||||||
|
播放音频
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 UI 设计
|
||||||
|
|
||||||
|
### 用户消息(右侧)
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ 你好呀。 │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
- 背景色:`#F0F0F0`(浅灰)
|
||||||
|
- 文字颜色:黑色
|
||||||
|
- 对齐:右对齐
|
||||||
|
- 圆角:16pt
|
||||||
|
|
||||||
|
### AI 消息(左侧)
|
||||||
|
```
|
||||||
|
▶️ 6"
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ (嘻嘻笑出声,不再逗你) │
|
||||||
|
│ 公子今天怎么一直在问好 │
|
||||||
|
│ 呀?是有什么开心的事情 │
|
||||||
|
│ 和我分享吗? │
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
- 背景色:`rgba(0.2, 0.2, 0.2, 0.7)`(深色半透明)
|
||||||
|
- 文字颜色:白色
|
||||||
|
- 对齐:左对齐
|
||||||
|
- 圆角:16pt
|
||||||
|
- 语音按钮:左上角,24x24pt
|
||||||
|
|
||||||
|
### 时间戳(居中)
|
||||||
|
```
|
||||||
|
16:36
|
||||||
|
```
|
||||||
|
- 文字颜色:次要标签色
|
||||||
|
- 字体:12pt
|
||||||
|
- 对齐:居中
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 集成步骤(简化版)
|
||||||
|
|
||||||
|
### 1. 修改 import
|
||||||
|
```objective-c
|
||||||
|
#import "KBChatTableView.h"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 修改属性
|
||||||
|
```objective-c
|
||||||
|
@property (nonatomic, strong) KBChatTableView *chatView;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 初始化
|
||||||
|
```objective-c
|
||||||
|
self.chatView = [[KBChatTableView alloc] init];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 添加消息
|
||||||
|
```objective-c
|
||||||
|
// 用户消息
|
||||||
|
[self.chatView addUserMessage:text];
|
||||||
|
|
||||||
|
// AI 消息(带语音)
|
||||||
|
[self.chatView addAssistantMessage:text
|
||||||
|
audioDuration:duration
|
||||||
|
audioData:audioData];
|
||||||
|
```
|
||||||
|
|
||||||
|
**详细步骤请查看:`集成指南.md`**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 文档说明
|
||||||
|
|
||||||
|
### 1. 集成指南.md
|
||||||
|
- 完整的集成步骤
|
||||||
|
- 代码示例
|
||||||
|
- 常见问题解答
|
||||||
|
- 完成清单
|
||||||
|
|
||||||
|
### 2. KBChatTableView_Usage.md
|
||||||
|
- 详细的使用说明
|
||||||
|
- API 文档
|
||||||
|
- 自定义配置
|
||||||
|
- 注意事项
|
||||||
|
|
||||||
|
### 3. API_快速参考.md
|
||||||
|
- 快速查询 API
|
||||||
|
- 代码片段
|
||||||
|
- 常用配置
|
||||||
|
|
||||||
|
### 4. 实现总结.md(本文档)
|
||||||
|
- 架构设计说明
|
||||||
|
- 实现细节
|
||||||
|
- 文件清单
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 测试
|
||||||
|
|
||||||
|
### 运行测试页面
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
KBChatTestVC *testVC = [[KBChatTestVC alloc] init];
|
||||||
|
[self.navigationController pushViewController:testVC animated:YES];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试功能
|
||||||
|
- ✅ 添加用户消息
|
||||||
|
- ✅ 添加 AI 消息
|
||||||
|
- ✅ 语音播放
|
||||||
|
- ✅ 时间戳显示
|
||||||
|
- ✅ 自动滚动
|
||||||
|
- ✅ 打字机效果
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 优势总结
|
||||||
|
|
||||||
|
### 1. 架构清晰
|
||||||
|
- 三种独立 Cell,职责明确
|
||||||
|
- Plain 模式,数据管理简单
|
||||||
|
- 代码易读易维护
|
||||||
|
|
||||||
|
### 2. 功能完整
|
||||||
|
- 支持文本、语音、时间戳
|
||||||
|
- 自动时间戳插入
|
||||||
|
- 语音播放管理
|
||||||
|
- 打字机效果
|
||||||
|
|
||||||
|
### 3. 性能优良
|
||||||
|
- Cell 复用机制
|
||||||
|
- 自动布局
|
||||||
|
- 流畅滚动
|
||||||
|
|
||||||
|
### 4. 易于扩展
|
||||||
|
- 可轻松添加新的消息类型
|
||||||
|
- 可自定义样式
|
||||||
|
- 可调整时间戳规则
|
||||||
|
|
||||||
|
### 5. 文档完善
|
||||||
|
- 详细的集成指南
|
||||||
|
- 完整的 API 文档
|
||||||
|
- 快速参考卡片
|
||||||
|
- 测试示例
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 下一步
|
||||||
|
|
||||||
|
1. **测试新 UI**
|
||||||
|
- 运行 `KBChatTestVC` 查看效果
|
||||||
|
- 验证所有功能
|
||||||
|
|
||||||
|
2. **集成到 KBAiMainVC**
|
||||||
|
- 按照 `集成指南.md` 操作
|
||||||
|
- 替换现有的 `KBAiChatView`
|
||||||
|
|
||||||
|
3. **自定义样式**(可选)
|
||||||
|
- 修改气泡颜色
|
||||||
|
- 调整圆角大小
|
||||||
|
- 修改时间戳间隔
|
||||||
|
|
||||||
|
4. **优化性能**(可选)
|
||||||
|
- 限制消息数量
|
||||||
|
- 清除旧消息音频数据
|
||||||
|
- 实现分页加载
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 完成!
|
||||||
|
|
||||||
|
你现在拥有一个功能完整、设计精美的聊天 UI 系统,完全符合你的需求:
|
||||||
|
|
||||||
|
- ✅ 用户消息在右侧
|
||||||
|
- ✅ AI 回复在左侧
|
||||||
|
- ✅ AI 消息左上角有语音按钮
|
||||||
|
- ✅ 对话中间显示时间
|
||||||
|
- ✅ 使用 TableView Plain 模式
|
||||||
|
- ✅ 三种独立 Cell 设计
|
||||||
|
|
||||||
|
**祝你使用愉快!** 🎉
|
||||||
344
keyBoard/Class/AiTalk/新音频流程说明.md
Normal file
344
keyBoard/Class/AiTalk/新音频流程说明.md
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
# 新音频流程说明
|
||||||
|
|
||||||
|
## 📋 需求概述
|
||||||
|
|
||||||
|
1. **删除 ElevenLabs 接口**:不再使用 `requestElevenLabsSpeechWithText`
|
||||||
|
2. **新的流程**:
|
||||||
|
- 请求 `chat/message` 接口,后端返回 `audioId`
|
||||||
|
- 用户点击语音按钮时,用 `audioId` 请求 `/chat/audio/{audioId}` 获取 MP3 地址
|
||||||
|
- 如果音频还未生成,显示等待效果(3秒后自动停止)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 新的流程图
|
||||||
|
|
||||||
|
```
|
||||||
|
用户说话
|
||||||
|
↓
|
||||||
|
语音识别(Deepgram)
|
||||||
|
↓
|
||||||
|
添加用户消息到聊天列表
|
||||||
|
↓
|
||||||
|
请求 /chat/message 接口
|
||||||
|
↓
|
||||||
|
后端返回:
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"aiResponse": "AI 回复文本",
|
||||||
|
"audioId": "6e3e90575ce04658ab6c45d77a506100",
|
||||||
|
"llmDuration": 1572
|
||||||
|
}
|
||||||
|
}
|
||||||
|
↓
|
||||||
|
添加 AI 消息到聊天列表(带 audioId)
|
||||||
|
↓
|
||||||
|
用户点击语音按钮
|
||||||
|
↓
|
||||||
|
请求 /chat/audio/{audioId} 接口
|
||||||
|
↓
|
||||||
|
后端返回:
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"url": "http://example.com/audio.mp3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
↓
|
||||||
|
下载音频文件
|
||||||
|
↓
|
||||||
|
播放音频
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 修改清单
|
||||||
|
|
||||||
|
### 1. AiVM.h/m(网络层)
|
||||||
|
|
||||||
|
#### 新增字段
|
||||||
|
```objective-c
|
||||||
|
@interface KBAiMessageData : NSObject
|
||||||
|
@property(nonatomic, copy, nullable) NSString *aiResponse; // 新增
|
||||||
|
@property(nonatomic, copy, nullable) NSString *audioId; // 新增
|
||||||
|
@property(nonatomic, assign) NSInteger llmDuration; // 新增
|
||||||
|
@end
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 新增接口
|
||||||
|
```objective-c
|
||||||
|
/// 根据 audioId 获取音频 URL
|
||||||
|
- (void)requestAudioWithAudioId:(NSString *)audioId
|
||||||
|
completion:(AiVMAudioURLCompletion)completion;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 删除接口
|
||||||
|
```objective-c
|
||||||
|
// ❌ 已删除
|
||||||
|
- (void)requestElevenLabsSpeechWithText:...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. KBChatMessage.h/m(消息模型)
|
||||||
|
|
||||||
|
#### 新增字段
|
||||||
|
```objective-c
|
||||||
|
/// 音频 ID - 用于异步加载音频
|
||||||
|
@property (nonatomic, copy, nullable) NSString *audioId;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 新增构造方法
|
||||||
|
```objective-c
|
||||||
|
/// 创建 AI 消息(带 audioId,异步加载音频)
|
||||||
|
+ (instancetype)assistantMessageWithText:(NSString *)text
|
||||||
|
audioId:(nullable NSString *)audioId;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. KBChatTableView.h/m(聊天视图)
|
||||||
|
|
||||||
|
#### 新增 API
|
||||||
|
```objective-c
|
||||||
|
/// 添加 AI 消息(带 audioId,异步加载音频)
|
||||||
|
- (void)addAssistantMessage:(NSString *)text
|
||||||
|
audioId:(nullable NSString *)audioId;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 新增功能
|
||||||
|
- **异步加载音频**:点击语音按钮时,如果有 `audioId`,则请求音频 URL
|
||||||
|
- **等待效果**:加载音频时显示播放中状态,3秒后自动停止
|
||||||
|
- **音频缓存**:下载后的音频数据缓存到消息对象,下次点击直接播放
|
||||||
|
|
||||||
|
#### 新增方法
|
||||||
|
```objective-c
|
||||||
|
- (void)loadAndPlayAudioForMessage:(KBChatMessage *)message atIndexPath:(NSIndexPath *)indexPath;
|
||||||
|
- (void)downloadAndPlayAudioFromURL:(NSString *)urlString forMessage:(KBChatMessage *)message atIndexPath:(NSIndexPath *)indexPath;
|
||||||
|
- (void)startWaitingForCell:(NSIndexPath *)indexPath;
|
||||||
|
- (void)stopWaitingForCell:(NSIndexPath *)indexPath;
|
||||||
|
- (void)waitingTimeout;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. KBAiMainVC.m(主控制器)
|
||||||
|
|
||||||
|
#### 删除的代码
|
||||||
|
```objective-c
|
||||||
|
// ❌ 已删除
|
||||||
|
@property(nonatomic, copy) NSString *elevenLabsApiKey;
|
||||||
|
@property(nonatomic, copy) NSString *elevenLabsVoiceId;
|
||||||
|
|
||||||
|
// ❌ 已删除
|
||||||
|
self.elevenLabsVoiceId = @"...";
|
||||||
|
self.elevenLabsApiKey = @"...";
|
||||||
|
|
||||||
|
// ❌ 已删除 ElevenLabs 相关的所有调用
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 修改的流程
|
||||||
|
```objective-c
|
||||||
|
// 原来:
|
||||||
|
// 1. 请求 chat/message
|
||||||
|
// 2. 请求 ElevenLabs TTS
|
||||||
|
// 3. 添加消息(带音频数据)
|
||||||
|
// 4. 播放音频
|
||||||
|
|
||||||
|
// 现在:
|
||||||
|
// 1. 请求 chat/message(返回 audioId)
|
||||||
|
// 2. 添加消息(带 audioId)
|
||||||
|
// 3. 用户点击语音按钮时异步加载音频
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 核心实现
|
||||||
|
|
||||||
|
### 1. 点击语音按钮的处理逻辑
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
- (void)assistantMessageCell:(KBChatAssistantMessageCell *)cell
|
||||||
|
didTapVoiceButtonForMessage:(KBChatMessage *)message {
|
||||||
|
|
||||||
|
// 如果有 audioData,直接播放
|
||||||
|
if (message.audioData && message.audioData.length > 0) {
|
||||||
|
[self playAudioForMessage:message atIndexPath:indexPath];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有 audioId,异步加载音频
|
||||||
|
if (message.audioId.length > 0) {
|
||||||
|
[self loadAndPlayAudioForMessage:message atIndexPath:indexPath];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 异步加载音频
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
- (void)loadAndPlayAudioForMessage:(KBChatMessage *)message atIndexPath:(NSIndexPath *)indexPath {
|
||||||
|
// 1. 开始等待效果(显示播放中状态)
|
||||||
|
[self startWaitingForCell:indexPath];
|
||||||
|
|
||||||
|
// 2. 请求音频 URL
|
||||||
|
[self.aiVM requestAudioWithAudioId:message.audioId
|
||||||
|
completion:^(NSString *audioURL, NSError *error) {
|
||||||
|
// 3. 停止等待效果
|
||||||
|
[self stopWaitingForCell:indexPath];
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
NSLog(@"加载音频失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 下载音频数据
|
||||||
|
[self downloadAndPlayAudioFromURL:audioURL
|
||||||
|
forMessage:message
|
||||||
|
atIndexPath:indexPath];
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 等待效果(3秒超时)
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
- (void)startWaitingForCell:(NSIndexPath *)indexPath {
|
||||||
|
self.waitingCellIndexPath = indexPath;
|
||||||
|
|
||||||
|
// 更新 Cell 为等待状态(显示播放中图标)
|
||||||
|
KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
|
||||||
|
[cell updateVoicePlayingState:YES];
|
||||||
|
|
||||||
|
// 3 秒后自动停止
|
||||||
|
self.waitingTimer = [NSTimer scheduledTimerWithTimeInterval:3.0
|
||||||
|
target:self
|
||||||
|
selector:@selector(waitingTimeout)
|
||||||
|
userInfo:nil
|
||||||
|
repeats:NO];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)waitingTimeout {
|
||||||
|
NSLog(@"音频加载超时");
|
||||||
|
[self stopWaitingForCell:self.waitingCellIndexPath];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 音频缓存
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
- (void)downloadAndPlayAudioFromURL:(NSString *)urlString
|
||||||
|
forMessage:(KBChatMessage *)message
|
||||||
|
atIndexPath:(NSIndexPath *)indexPath {
|
||||||
|
// 下载音频
|
||||||
|
NSURLSessionDataTask *task = [session dataTaskWithURL:url
|
||||||
|
completionHandler:^(NSData *data, ...) {
|
||||||
|
// 缓存到消息对象
|
||||||
|
message.audioData = data;
|
||||||
|
|
||||||
|
// 计算时长
|
||||||
|
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:data error:nil];
|
||||||
|
message.audioDuration = player.duration;
|
||||||
|
|
||||||
|
// 刷新 Cell
|
||||||
|
[self.tableView reloadRowsAtIndexPaths:@[indexPath] ...];
|
||||||
|
|
||||||
|
// 播放音频
|
||||||
|
[self playAudioForMessage:message atIndexPath:indexPath];
|
||||||
|
}];
|
||||||
|
[task resume];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 数据结构
|
||||||
|
|
||||||
|
### chat/message 接口返回
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"aiResponse": "Ugh, seriously? It's Tiffany...",
|
||||||
|
"audioId": "6e3e90575ce04658ab6c45d77a506100",
|
||||||
|
"llmDuration": 1572
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### /chat/audio/{audioId} 接口返回
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"url": "http://127.0.0.1:4523/m1/7401033-7133645-default/chat/audio/1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 优势
|
||||||
|
|
||||||
|
1. **后端统一处理 TTS**:不需要前端配置 ElevenLabs API Key
|
||||||
|
2. **异步加载**:不阻塞 UI,用户体验更好
|
||||||
|
3. **音频缓存**:下载后缓存,再次点击直接播放
|
||||||
|
4. **等待效果**:3秒超时保护,避免无限等待
|
||||||
|
5. **降低耦合**:前端不需要关心 TTS 实现细节
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 测试清单
|
||||||
|
|
||||||
|
- [ ] 请求 chat/message 接口成功
|
||||||
|
- [ ] 返回的 audioId 正确保存
|
||||||
|
- [ ] 点击语音按钮触发异步加载
|
||||||
|
- [ ] 等待效果正常显示(播放中图标)
|
||||||
|
- [ ] 3秒后自动停止等待
|
||||||
|
- [ ] 音频 URL 请求成功
|
||||||
|
- [ ] 音频下载成功
|
||||||
|
- [ ] 音频播放正常
|
||||||
|
- [ ] 再次点击直接播放(缓存生效)
|
||||||
|
- [ ] 错误处理正常(网络失败、URL 无效等)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 调试建议
|
||||||
|
|
||||||
|
### 1. 查看日志
|
||||||
|
```objective-c
|
||||||
|
NSLog(@"[KBChatTableView] 加载音频失败: %@", error);
|
||||||
|
NSLog(@"[KBChatTableView] 音频 URL: %@", audioURL);
|
||||||
|
NSLog(@"[KBChatTableView] 音频加载超时");
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 检查返回数据
|
||||||
|
- 确认 `audioId` 不为空
|
||||||
|
- 确认 `/chat/audio/{audioId}` 返回的 URL 格式正确
|
||||||
|
- 确认 URL 可以正常访问
|
||||||
|
|
||||||
|
### 3. 测试超时
|
||||||
|
- 故意延迟后端响应,测试 3秒超时是否生效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 完成!
|
||||||
|
|
||||||
|
新的音频流程已经完全实现,具备以下特性:
|
||||||
|
|
||||||
|
✅ 后端统一处理 TTS
|
||||||
|
✅ 异步加载音频
|
||||||
|
✅ 等待效果(3秒超时)
|
||||||
|
✅ 音频缓存
|
||||||
|
✅ 错误处理
|
||||||
|
|
||||||
|
运行项目即可测试新流程!
|
||||||
151
keyBoard/Class/AiTalk/类名冲突解决说明.md
Normal file
151
keyBoard/Class/AiTalk/类名冲突解决说明.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# 类名冲突解决说明
|
||||||
|
|
||||||
|
## 🐛 问题
|
||||||
|
|
||||||
|
在实现新的聊天 UI 时,创建了 `KBChatMessage` 类,但与 `CustomKeyboard` 目录下已有的 `KBChatMessage` 类产生了命名冲突,导致 `KeyboardViewController` 编译报错:
|
||||||
|
|
||||||
|
```
|
||||||
|
Property 'audioFilePath' not found on object of type 'KBChatMessage *'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 原因分析
|
||||||
|
|
||||||
|
项目中存在两个同名的 `KBChatMessage` 类:
|
||||||
|
|
||||||
|
### 1. CustomKeyboard/Model/KBChatMessage(旧的)
|
||||||
|
```objective-c
|
||||||
|
@interface KBChatMessage : NSObject
|
||||||
|
@property (nonatomic, copy) NSString *text;
|
||||||
|
@property (nonatomic, assign) BOOL outgoing;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *audioFilePath; // ← 旧的属性
|
||||||
|
@property (nonatomic, copy, nullable) NSString *avatarURL;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *displayName;
|
||||||
|
@property (nonatomic, strong, nullable) UIImage *avatarImage;
|
||||||
|
@end
|
||||||
|
```
|
||||||
|
|
||||||
|
**用途:** 键盘扩展(CustomKeyboard)中的聊天消息模型
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. keyBoard/Class/AiTalk/M/KBChatMessage(新的)
|
||||||
|
```objective-c
|
||||||
|
@interface KBChatMessage : NSObject
|
||||||
|
@property (nonatomic, assign) KBChatMessageType type;
|
||||||
|
@property (nonatomic, copy) NSString *text;
|
||||||
|
@property (nonatomic, strong) NSDate *timestamp;
|
||||||
|
@property (nonatomic, assign) NSTimeInterval audioDuration;
|
||||||
|
@property (nonatomic, strong, nullable) NSData *audioData; // ← 新的属性
|
||||||
|
@property (nonatomic, copy, nullable) NSString *audioId; // ← 新的属性
|
||||||
|
@property (nonatomic, assign) BOOL isComplete;
|
||||||
|
@end
|
||||||
|
```
|
||||||
|
|
||||||
|
**用途:** AI 聊天主界面(KBAiMainVC)中的消息模型
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 解决方案
|
||||||
|
|
||||||
|
将新创建的类重命名为 `KBAiChatMessage`,避免与旧类冲突。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 修改清单
|
||||||
|
|
||||||
|
### 1. 重命名类文件
|
||||||
|
- `KBChatMessage.h` → `KBAiChatMessage.h`
|
||||||
|
- `KBChatMessage.m` → `KBAiChatMessage.m`
|
||||||
|
|
||||||
|
### 2. 重命名类名
|
||||||
|
```objective-c
|
||||||
|
// 原来
|
||||||
|
@interface KBChatMessage : NSObject
|
||||||
|
|
||||||
|
// 修改为
|
||||||
|
@interface KBAiChatMessage : NSObject
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 重命名枚举
|
||||||
|
```objective-c
|
||||||
|
// 原来
|
||||||
|
typedef NS_ENUM(NSInteger, KBChatMessageType) {
|
||||||
|
KBChatMessageTypeUser,
|
||||||
|
KBChatMessageTypeAssistant,
|
||||||
|
KBChatMessageTypeTime
|
||||||
|
};
|
||||||
|
|
||||||
|
// 修改为
|
||||||
|
typedef NS_ENUM(NSInteger, KBAiChatMessageType) {
|
||||||
|
KBAiChatMessageTypeUser,
|
||||||
|
KBAiChatMessageTypeAssistant,
|
||||||
|
KBAiChatMessageTypeTime
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 更新所有引用
|
||||||
|
|
||||||
|
#### Cell 文件
|
||||||
|
- `KBChatUserMessageCell.h/m`
|
||||||
|
- `KBChatAssistantMessageCell.h/m`
|
||||||
|
- `KBChatTimeCell.h/m`
|
||||||
|
|
||||||
|
#### 视图文件
|
||||||
|
- `KBChatTableView.h/m`
|
||||||
|
|
||||||
|
#### 所有方法参数和属性
|
||||||
|
```objective-c
|
||||||
|
// 原来
|
||||||
|
- (void)configureWithMessage:(KBChatMessage *)message;
|
||||||
|
@property (nonatomic, strong) NSMutableArray<KBChatMessage *> *messages;
|
||||||
|
|
||||||
|
// 修改为
|
||||||
|
- (void)configureWithMessage:(KBAiChatMessage *)message;
|
||||||
|
@property (nonatomic, strong) NSMutableArray<KBAiChatMessage *> *messages;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 最终结构
|
||||||
|
|
||||||
|
### CustomKeyboard(键盘扩展)
|
||||||
|
```
|
||||||
|
CustomKeyboard/Model/
|
||||||
|
├── KBChatMessage.h ← 旧的,保持不变
|
||||||
|
└── KBChatMessage.m
|
||||||
|
```
|
||||||
|
|
||||||
|
**用途:** 键盘扩展中的聊天功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AiTalk(AI 聊天主界面)
|
||||||
|
```
|
||||||
|
keyBoard/Class/AiTalk/M/
|
||||||
|
├── KBAiChatMessage.h ← 新的,已重命名
|
||||||
|
└── KBAiChatMessage.m
|
||||||
|
```
|
||||||
|
|
||||||
|
**用途:** AI 聊天主界面的消息模型
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 验证
|
||||||
|
|
||||||
|
编译项目,确认:
|
||||||
|
- ✅ `KeyboardViewController` 不再报错
|
||||||
|
- ✅ `KBAiMainVC` 正常工作
|
||||||
|
- ✅ 两个模块互不干扰
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 总结
|
||||||
|
|
||||||
|
通过将新类重命名为 `KBAiChatMessage`,成功解决了类名冲突问题。现在:
|
||||||
|
|
||||||
|
- **CustomKeyboard** 使用 `KBChatMessage`(旧的)
|
||||||
|
- **AiTalk** 使用 `KBAiChatMessage`(新的)
|
||||||
|
|
||||||
|
两者各司其职,互不干扰!✅
|
||||||
363
keyBoard/Class/AiTalk/集成指南.md
Normal file
363
keyBoard/Class/AiTalk/集成指南.md
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
# 新聊天 UI 集成指南
|
||||||
|
|
||||||
|
## 📦 已创建的文件
|
||||||
|
|
||||||
|
### Model(数据模型)
|
||||||
|
- `KBChatMessage.h/m` - 消息模型(支持用户/AI/时间戳三种类型)
|
||||||
|
|
||||||
|
### View(视图组件)
|
||||||
|
- `KBChatUserMessageCell.h/m` - 用户消息 Cell(右侧气泡)
|
||||||
|
- `KBChatAssistantMessageCell.h/m` - AI 消息 Cell(左侧气泡 + 语音按钮)
|
||||||
|
- `KBChatTimeCell.h/m` - 时间戳 Cell(居中显示)
|
||||||
|
- `KBChatTableView.h/m` - 聊天列表视图(主容器)
|
||||||
|
|
||||||
|
### ViewController(测试页面)
|
||||||
|
- `KBChatTestVC.h/m` - 测试页面(可选,用于演示)
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- `KBChatTableView_Usage.md` - 使用说明
|
||||||
|
- `集成指南.md` - 本文档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 核心特性
|
||||||
|
|
||||||
|
✅ **三种消息类型**
|
||||||
|
- 用户消息:右侧浅色气泡
|
||||||
|
- AI 消息:左侧深色气泡 + 语音播放按钮
|
||||||
|
- 时间戳:居中显示,自动插入(5 分钟间隔)
|
||||||
|
|
||||||
|
✅ **语音播放**
|
||||||
|
- 点击播放/暂停
|
||||||
|
- 显示语音时长(如 "6"")
|
||||||
|
- 播放状态图标切换
|
||||||
|
|
||||||
|
✅ **打字机效果**
|
||||||
|
- 支持实时更新 AI 消息文本
|
||||||
|
- 流式显示
|
||||||
|
|
||||||
|
✅ **自动滚动**
|
||||||
|
- 新消息自动滚动到底部
|
||||||
|
- 平滑动画
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 集成到 KBAiMainVC
|
||||||
|
|
||||||
|
### 步骤 1:修改 import
|
||||||
|
|
||||||
|
在 `KBAiMainVC.m` 顶部添加:
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
#import "KBChatTableView.h"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2:修改属性声明
|
||||||
|
|
||||||
|
将:
|
||||||
|
```objective-c
|
||||||
|
@property (nonatomic, strong) KBAiChatView *chatView;
|
||||||
|
```
|
||||||
|
|
||||||
|
改为:
|
||||||
|
```objective-c
|
||||||
|
@property (nonatomic, strong) KBChatTableView *chatView;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 3:修改 setupUI 方法
|
||||||
|
|
||||||
|
将:
|
||||||
|
```objective-c
|
||||||
|
self.chatView = [[KBAiChatView alloc] init];
|
||||||
|
```
|
||||||
|
|
||||||
|
改为:
|
||||||
|
```objective-c
|
||||||
|
self.chatView = [[KBChatTableView alloc] init];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 4:修改消息添加逻辑
|
||||||
|
|
||||||
|
#### 添加用户消息(保持不变)
|
||||||
|
```objective-c
|
||||||
|
[self.chatView addUserMessage:finalText];
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 添加 AI 消息(需要修改)
|
||||||
|
|
||||||
|
**原来的代码:**
|
||||||
|
```objective-c
|
||||||
|
[self.chatView addAssistantMessage:polishedText];
|
||||||
|
[self.chatView markLastAssistantMessageComplete];
|
||||||
|
```
|
||||||
|
|
||||||
|
**新的代码:**
|
||||||
|
```objective-c
|
||||||
|
// 计算音频时长(如果有音频数据)
|
||||||
|
NSTimeInterval duration = 0;
|
||||||
|
if (audioData && audioData.length > 0) {
|
||||||
|
// 方法 1:从 AVAudioPlayer 获取准确时长
|
||||||
|
NSError *error = nil;
|
||||||
|
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:audioData error:&error];
|
||||||
|
if (!error && player) {
|
||||||
|
duration = player.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法 2:估算时长(如果知道采样率)
|
||||||
|
// duration = audioData.length / (sampleRate * channels * bytesPerSample);
|
||||||
|
}
|
||||||
|
|
||||||
|
[self.chatView addAssistantMessage:polishedText
|
||||||
|
audioDuration:duration
|
||||||
|
audioData:audioData];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 5:修改打字机效果(如果使用)
|
||||||
|
|
||||||
|
**原来的代码:**
|
||||||
|
```objective-c
|
||||||
|
[self.chatView addAssistantMessage:@""];
|
||||||
|
[self.chatView updateLastAssistantMessage:token];
|
||||||
|
[self.chatView markLastAssistantMessageComplete];
|
||||||
|
```
|
||||||
|
|
||||||
|
**新的代码:**
|
||||||
|
```objective-c
|
||||||
|
// 1. 添加空消息占位
|
||||||
|
[self.chatView addAssistantMessage:@""
|
||||||
|
audioDuration:0
|
||||||
|
audioData:nil];
|
||||||
|
|
||||||
|
// 2. 逐步更新
|
||||||
|
[self.chatView updateLastAssistantMessage:token];
|
||||||
|
|
||||||
|
// 3. 完成后标记并添加音频
|
||||||
|
[self.chatView markLastAssistantMessageComplete];
|
||||||
|
|
||||||
|
// 如果有音频,需要更新最后一条消息的音频数据
|
||||||
|
// 注意:当前实现不支持后续添加音频,建议在完成时重新添加消息
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 完整示例:修改 deepgramStreamingManagerDidReceiveFinalTranscript
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
- (void)deepgramStreamingManagerDidReceiveFinalTranscript:(NSString *)text {
|
||||||
|
if (text.length > 0) {
|
||||||
|
if (self.deepgramFullText.length > 0) {
|
||||||
|
[self.deepgramFullText appendString:@" "];
|
||||||
|
}
|
||||||
|
[self.deepgramFullText appendString:text];
|
||||||
|
}
|
||||||
|
|
||||||
|
self.transcriptLabel.text = self.deepgramFullText;
|
||||||
|
self.statusLabel.text = @"识别完成";
|
||||||
|
self.recordButton.state = KBAiRecordButtonStateNormal;
|
||||||
|
|
||||||
|
NSString *finalText = [self.deepgramFullText copy];
|
||||||
|
if (finalText.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加用户消息
|
||||||
|
[self.chatView addUserMessage:finalText];
|
||||||
|
|
||||||
|
if (self.elevenLabsApiKey.length == 0 || self.elevenLabsVoiceId.length == 0) {
|
||||||
|
[KBHUD showError:@"请先配置 ElevenLabs API Key/VoiceId"];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
|
[KBHUD showWithStatus:@"润色中..."];
|
||||||
|
|
||||||
|
[self.aiVM requestChatMessageWithContent:finalText
|
||||||
|
completion:^(KBAiMessageResponse *_Nullable response,
|
||||||
|
NSError *_Nullable error) {
|
||||||
|
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||||
|
if (!strongSelf) return;
|
||||||
|
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
if (error) {
|
||||||
|
[KBHUD dismiss];
|
||||||
|
[KBHUD showError:error.localizedDescription ?: @"润色失败"];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *polishedText = response.data.content ?: response.data.text ?: response.data.message ?: @"";
|
||||||
|
|
||||||
|
if (polishedText.length == 0) {
|
||||||
|
[KBHUD dismiss];
|
||||||
|
[KBHUD showError:@"润色结果为空"];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
[KBHUD showWithStatus:@"生成语音..."];
|
||||||
|
|
||||||
|
[strongSelf.aiVM requestElevenLabsSpeechWithText:polishedText
|
||||||
|
voiceId:strongSelf.elevenLabsVoiceId
|
||||||
|
apiKey:strongSelf.elevenLabsApiKey
|
||||||
|
outputFormat:nil
|
||||||
|
modelId:nil
|
||||||
|
completion:^(NSData *_Nullable audioData,
|
||||||
|
NSError *_Nullable ttsError) {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
[KBHUD dismiss];
|
||||||
|
|
||||||
|
if (ttsError) {
|
||||||
|
[KBHUD showError:ttsError.localizedDescription ?: @"语音生成失败"];
|
||||||
|
// 即使语音失败,也添加文本消息
|
||||||
|
[strongSelf.chatView addAssistantMessage:polishedText
|
||||||
|
audioDuration:0
|
||||||
|
audioData:nil];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算音频时长
|
||||||
|
NSTimeInterval duration = 0;
|
||||||
|
if (audioData && audioData.length > 0) {
|
||||||
|
NSError *playerError = nil;
|
||||||
|
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:audioData
|
||||||
|
error:&playerError];
|
||||||
|
if (!playerError && player) {
|
||||||
|
duration = player.duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 AI 消息(带语音)
|
||||||
|
[strongSelf.chatView addAssistantMessage:polishedText
|
||||||
|
audioDuration:duration
|
||||||
|
audioData:audioData];
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 测试步骤
|
||||||
|
|
||||||
|
### 1. 测试新 UI(使用测试页面)
|
||||||
|
|
||||||
|
在任意地方跳转到测试页面:
|
||||||
|
|
||||||
|
```objective-c
|
||||||
|
KBChatTestVC *testVC = [[KBChatTestVC alloc] init];
|
||||||
|
[self.navigationController pushViewController:testVC animated:YES];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 集成到 KBAiMainVC
|
||||||
|
|
||||||
|
按照上面的步骤修改 `KBAiMainVC.m`
|
||||||
|
|
||||||
|
### 3. 验证功能
|
||||||
|
|
||||||
|
- ✅ 用户消息显示在右侧
|
||||||
|
- ✅ AI 消息显示在左侧
|
||||||
|
- ✅ 时间戳自动插入
|
||||||
|
- ✅ 语音按钮可点击播放
|
||||||
|
- ✅ 语音时长正确显示
|
||||||
|
- ✅ 消息自动滚动到底部
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 自定义样式
|
||||||
|
|
||||||
|
### 修改气泡颜色
|
||||||
|
|
||||||
|
**用户消息**(`KBChatUserMessageCell.m`):
|
||||||
|
```objective-c
|
||||||
|
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.94 green:0.94 blue:0.94 alpha:1.0];
|
||||||
|
self.messageLabel.textColor = [UIColor blackColor];
|
||||||
|
```
|
||||||
|
|
||||||
|
**AI 消息**(`KBChatAssistantMessageCell.m`):
|
||||||
|
```objective-c
|
||||||
|
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.7];
|
||||||
|
self.messageLabel.textColor = [UIColor whiteColor];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改时间戳间隔
|
||||||
|
|
||||||
|
在 `KBChatTableView.m` 中:
|
||||||
|
```objective-c
|
||||||
|
static const NSTimeInterval kTimestampInterval = 5 * 60; // 改为你想要的秒数
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改气泡圆角
|
||||||
|
|
||||||
|
在对应的 Cell 中:
|
||||||
|
```objective-c
|
||||||
|
self.bubbleView.layer.cornerRadius = 16; // 改为你想要的值
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **音频格式**:确保 `audioData` 是 AVAudioPlayer 支持的格式(MP3、AAC、M4A 等)
|
||||||
|
|
||||||
|
2. **音频会话**:播放音频前确保配置了 AVAudioSession(已在 `AudioSessionManager` 中处理)
|
||||||
|
|
||||||
|
3. **内存管理**:如果消息量很大,考虑:
|
||||||
|
- 限制消息数量(如只保留最近 100 条)
|
||||||
|
- 清除旧消息的音频数据
|
||||||
|
- 实现分页加载
|
||||||
|
|
||||||
|
4. **线程安全**:所有 UI 更新必须在主线程执行
|
||||||
|
|
||||||
|
5. **音频时长计算**:
|
||||||
|
- 方法 1:使用 AVAudioPlayer 获取准确时长(推荐)
|
||||||
|
- 方法 2:根据采样率估算(不够准确)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 常见问题
|
||||||
|
|
||||||
|
### Q1: 语音按钮不显示?
|
||||||
|
**A:** 检查 `audioData` 是否为 nil 或长度为 0
|
||||||
|
|
||||||
|
### Q2: 点击语音按钮没反应?
|
||||||
|
**A:** 检查音频数据格式是否正确,查看控制台日志
|
||||||
|
|
||||||
|
### Q3: 时间戳不显示?
|
||||||
|
**A:** 检查消息的 `timestamp` 是否正确设置
|
||||||
|
|
||||||
|
### Q4: 消息不自动滚动?
|
||||||
|
**A:** 确保在主线程调用 `scrollToBottom`
|
||||||
|
|
||||||
|
### Q5: 气泡宽度不对?
|
||||||
|
**A:** 检查 Masonry 约束,确保 `multipliedBy(0.75)` 生效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文件
|
||||||
|
|
||||||
|
- 使用说明:`KBChatTableView_Usage.md`
|
||||||
|
- 测试页面:`KBChatTestVC.h/m`
|
||||||
|
- 原有实现:`KBAiChatView.h/m`(可保留作为备份)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 完成清单
|
||||||
|
|
||||||
|
- [ ] 创建所有新文件
|
||||||
|
- [ ] 修改 `KBAiMainVC.m` 的 import
|
||||||
|
- [ ] 修改属性声明
|
||||||
|
- [ ] 修改 setupUI 方法
|
||||||
|
- [ ] 修改消息添加逻辑
|
||||||
|
- [ ] 测试用户消息显示
|
||||||
|
- [ ] 测试 AI 消息显示
|
||||||
|
- [ ] 测试语音播放功能
|
||||||
|
- [ ] 测试时间戳显示
|
||||||
|
- [ ] 测试打字机效果
|
||||||
|
- [ ] 自定义样式(可选)
|
||||||
|
- [ ] 删除旧的 `KBAiChatView`(可选)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 完成!
|
||||||
|
|
||||||
|
按照以上步骤完成集成后,你将拥有一个功能完整、美观的聊天 UI 界面!
|
||||||
Reference in New Issue
Block a user