diff --git a/keyBoard.xcodeproj/project.pbxproj b/keyBoard.xcodeproj/project.pbxproj index 9374666..176044d 100644 --- a/keyBoard.xcodeproj/project.pbxproj +++ b/keyBoard.xcodeproj/project.pbxproj @@ -140,6 +140,9 @@ 048FFD102F27432D005D62AE /* KBPersonaModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD0D2F27432D005D62AE /* KBPersonaModel.m */; }; 048FFD112F27432D005D62AE /* KBPersonaPageModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD0F2F27432D005D62AE /* KBPersonaPageModel.m */; }; 048FFD142F274342005D62AE /* KBPersonaChatCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD132F274342005D62AE /* KBPersonaChatCell.m */; }; + 048FFD182F2763A5005D62AE /* KBVoiceInputBar.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD172F2763A5005D62AE /* KBVoiceInputBar.m */; }; + 048FFD1D2F277486005D62AE /* KBChatHistoryPageModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD1C2F277486005D62AE /* KBChatHistoryPageModel.m */; }; + 048FFD1E2F277486005D62AE /* KBChatHistoryModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD1A2F277486005D62AE /* KBChatHistoryModel.m */; }; 0498BD622EDFFC12006CC1D5 /* KBMyVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD612EDFFC12006CC1D5 /* KBMyVM.m */; }; 0498BD652EE0116D006CC1D5 /* KBEmailLoginVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD642EE0116D006CC1D5 /* KBEmailLoginVC.m */; }; 0498BD682EE01180006CC1D5 /* KBEmailRegistVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD672EE01180006CC1D5 /* KBEmailRegistVC.m */; }; @@ -530,6 +533,12 @@ 048FFD0F2F27432D005D62AE /* KBPersonaPageModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBPersonaPageModel.m; sourceTree = ""; }; 048FFD122F274342005D62AE /* KBPersonaChatCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBPersonaChatCell.h; sourceTree = ""; }; 048FFD132F274342005D62AE /* KBPersonaChatCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBPersonaChatCell.m; sourceTree = ""; }; + 048FFD162F2763A5005D62AE /* KBVoiceInputBar.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceInputBar.h; sourceTree = ""; }; + 048FFD172F2763A5005D62AE /* KBVoiceInputBar.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceInputBar.m; sourceTree = ""; }; + 048FFD192F277486005D62AE /* KBChatHistoryModel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatHistoryModel.h; sourceTree = ""; }; + 048FFD1A2F277486005D62AE /* KBChatHistoryModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatHistoryModel.m; sourceTree = ""; }; + 048FFD1B2F277486005D62AE /* KBChatHistoryPageModel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatHistoryPageModel.h; sourceTree = ""; }; + 048FFD1C2F277486005D62AE /* KBChatHistoryPageModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatHistoryPageModel.m; sourceTree = ""; }; 0498BD5E2EDF2157006CC1D5 /* KBBizCode.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBBizCode.h; sourceTree = ""; }; 0498BD602EDFFC12006CC1D5 /* KBMyVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBMyVM.h; sourceTree = ""; }; 0498BD612EDFFC12006CC1D5 /* KBMyVM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBMyVM.m; sourceTree = ""; }; @@ -981,6 +990,10 @@ 048FFD0D2F27432D005D62AE /* KBPersonaModel.m */, 048FFD0E2F27432D005D62AE /* KBPersonaPageModel.h */, 048FFD0F2F27432D005D62AE /* KBPersonaPageModel.m */, + 048FFD192F277486005D62AE /* KBChatHistoryModel.h */, + 048FFD1A2F277486005D62AE /* KBChatHistoryModel.m */, + 048FFD1B2F277486005D62AE /* KBChatHistoryPageModel.h */, + 048FFD1C2F277486005D62AE /* KBChatHistoryPageModel.m */, ); path = M; sourceTree = ""; @@ -1015,6 +1028,8 @@ 04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */, 048FFD122F274342005D62AE /* KBPersonaChatCell.h */, 048FFD132F274342005D62AE /* KBPersonaChatCell.m */, + 048FFD162F2763A5005D62AE /* KBVoiceInputBar.h */, + 048FFD172F2763A5005D62AE /* KBVoiceInputBar.m */, ); path = V; sourceTree = ""; @@ -2283,6 +2298,8 @@ 0450AC142EF11E4400B6AF06 /* ProductConverter.swift in Sources */, 0450AC152EF11E4400B6AF06 /* TransactionHistory.swift in Sources */, 0450AC162EF11E4400B6AF06 /* StoreKitServiceDelegate.swift in Sources */, + 048FFD1D2F277486005D62AE /* KBChatHistoryPageModel.m in Sources */, + 048FFD1E2F277486005D62AE /* KBChatHistoryModel.m in Sources */, 0450AC172EF11E4400B6AF06 /* StoreKitState.swift in Sources */, 0450AC1B2EF11E4400B6AF06 /* KBStoreKitBridge.swift in Sources */, 043FBCD22EAF97630036AFE1 /* KBPermissionViewController.m in Sources */, @@ -2355,6 +2372,7 @@ 048FFD102F27432D005D62AE /* KBPersonaModel.m in Sources */, 048FFD112F27432D005D62AE /* KBPersonaPageModel.m in Sources */, 0498BD6B2EE025FC006CC1D5 /* KBForgetPwdVC.m in Sources */, + 048FFD182F2763A5005D62AE /* KBVoiceInputBar.m in Sources */, 046086B12F19239B00757C95 /* SubtitleSync.m in Sources */, 046086B22F19239B00757C95 /* TTSServiceClient.m in Sources */, 046086B32F19239B00757C95 /* AudioSessionManager.m in Sources */, diff --git a/keyBoard/Class/AiTalk/M/KBChatHistoryModel.h b/keyBoard/Class/AiTalk/M/KBChatHistoryModel.h new file mode 100644 index 0000000..84c3aaa --- /dev/null +++ b/keyBoard/Class/AiTalk/M/KBChatHistoryModel.h @@ -0,0 +1,43 @@ +// +// KBChatHistoryModel.h +// keyBoard +// +// Created by Kiro on 2026/1/26. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// 消息发送者类型 +typedef NS_ENUM(NSInteger, KBChatSender) { + KBChatSenderUser = 0, // 用户 + KBChatSenderAssistant = 1 // AI 助手 +}; + +/// 聊天记录模型 +@interface KBChatHistoryModel : NSObject + +/// 消息 ID +@property (nonatomic, assign) NSInteger messageId; + +/// 发送者(0-用户,1-AI) +@property (nonatomic, assign) KBChatSender sender; + +/// 消息内容 +@property (nonatomic, copy) NSString *content; + +/// 创建时间 +@property (nonatomic, copy) NSString *createdAt; + +#pragma mark - 扩展属性 + +/// 是否是用户消息 +@property (nonatomic, assign, readonly) BOOL isUserMessage; + +/// 是否是 AI 消息 +@property (nonatomic, assign, readonly) BOOL isAssistantMessage; + +@end + +NS_ASSUME_NONNULL_END diff --git a/keyBoard/Class/AiTalk/M/KBChatHistoryModel.m b/keyBoard/Class/AiTalk/M/KBChatHistoryModel.m new file mode 100644 index 0000000..809682f --- /dev/null +++ b/keyBoard/Class/AiTalk/M/KBChatHistoryModel.m @@ -0,0 +1,43 @@ +// +// KBChatHistoryModel.m +// keyBoard +// +// Created by Kiro on 2026/1/26. +// + +#import "KBChatHistoryModel.h" +#import + +@implementation KBChatHistoryModel + +#pragma mark - MJExtension 配置 + +// 字段映射 ++ (NSDictionary *)mj_replacedKeyFromPropertyName { + return @{ + @"messageId": @"id" + }; +} + +// 转换完成后的处理 +- (void)mj_keyValuesDidFinishConvertingToObject { + // 容错处理 + if (!self.content) { + self.content = @""; + } + if (!self.createdAt) { + self.createdAt = @""; + } +} + +#pragma mark - 扩展属性 + +- (BOOL)isUserMessage { + return self.sender == KBChatSenderUser; +} + +- (BOOL)isAssistantMessage { + return self.sender == KBChatSenderAssistant; +} + +@end diff --git a/keyBoard/Class/AiTalk/M/KBChatHistoryPageModel.h b/keyBoard/Class/AiTalk/M/KBChatHistoryPageModel.h new file mode 100644 index 0000000..e296838 --- /dev/null +++ b/keyBoard/Class/AiTalk/M/KBChatHistoryPageModel.h @@ -0,0 +1,45 @@ +// +// KBChatHistoryPageModel.h +// keyBoard +// +// Created by Kiro on 2026/1/26. +// + +#import +#import "KBChatHistoryModel.h" + +NS_ASSUME_NONNULL_BEGIN + +/// 排序规则 +@interface KBChatOrderRule : NSObject +@property (nonatomic, copy) NSString *column; // 排序字段 +@property (nonatomic, assign) BOOL asc; // 是否升序 +@end + +/// 聊天记录分页数据模型 +@interface KBChatHistoryPageModel : NSObject + +/// 聊天记录列表 +@property (nonatomic, strong) NSArray *records; + +/// 总记录数 +@property (nonatomic, assign) NSInteger total; + +/// 每页大小 +@property (nonatomic, assign) NSInteger size; + +/// 当前页码 +@property (nonatomic, assign) NSInteger current; + +/// 排序规则 +@property (nonatomic, strong, nullable) NSArray *orders; + +/// 总页数 +@property (nonatomic, assign) NSInteger pages; + +/// 是否还有更多数据 +@property (nonatomic, assign, readonly) BOOL hasMore; + +@end + +NS_ASSUME_NONNULL_END diff --git a/keyBoard/Class/AiTalk/M/KBChatHistoryPageModel.m b/keyBoard/Class/AiTalk/M/KBChatHistoryPageModel.m new file mode 100644 index 0000000..1098f9f --- /dev/null +++ b/keyBoard/Class/AiTalk/M/KBChatHistoryPageModel.m @@ -0,0 +1,39 @@ +// +// KBChatHistoryPageModel.m +// keyBoard +// +// Created by Kiro on 2026/1/26. +// + +#import "KBChatHistoryPageModel.h" +#import + +@implementation KBChatOrderRule +@end + +@implementation KBChatHistoryPageModel + +#pragma mark - MJExtension 配置 + +// 数组内元素类型 ++ (NSDictionary *)mj_objectClassInArray { + return @{ + @"records": [KBChatHistoryModel class], + @"orders": [KBChatOrderRule class] + }; +} + +// 转换完成后的处理 +- (void)mj_keyValuesDidFinishConvertingToObject { + if (!self.records) { + self.records = @[]; + } +} + +#pragma mark - 扩展属性 + +- (BOOL)hasMore { + return self.current < self.pages; +} + +@end diff --git a/keyBoard/Class/AiTalk/M/聊天记录Model说明.md b/keyBoard/Class/AiTalk/M/聊天记录Model说明.md new file mode 100644 index 0000000..7f42830 --- /dev/null +++ b/keyBoard/Class/AiTalk/M/聊天记录Model说明.md @@ -0,0 +1,408 @@ +# 聊天记录 Model 说明 + +## 📦 Model 结构 + +### 1. KBChatHistoryModel(聊天记录模型) + +```objc +@interface KBChatHistoryModel : NSObject + +/// 消息 ID +@property (nonatomic, assign) NSInteger messageId; + +/// 发送者(0-用户,1-AI) +@property (nonatomic, assign) KBChatSender sender; + +/// 消息内容 +@property (nonatomic, copy) NSString *content; + +/// 创建时间 +@property (nonatomic, copy) NSString *createdAt; + +/// 是否是用户消息 +@property (nonatomic, assign, readonly) BOOL isUserMessage; + +/// 是否是 AI 消息 +@property (nonatomic, assign, readonly) BOOL isAssistantMessage; + +@end +``` + +### 2. KBChatHistoryPageModel(分页数据模型) + +```objc +@interface KBChatHistoryPageModel : NSObject + +/// 聊天记录列表 +@property (nonatomic, strong) NSArray *records; + +/// 总记录数 +@property (nonatomic, assign) NSInteger total; + +/// 每页大小 +@property (nonatomic, assign) NSInteger size; + +/// 当前页码 +@property (nonatomic, assign) NSInteger current; + +/// 总页数 +@property (nonatomic, assign) NSInteger pages; + +/// 是否还有更多数据 +@property (nonatomic, assign, readonly) BOOL hasMore; + +@end +``` + +--- + +## 🌐 接口说明 + +### 接口地址 +``` +POST /chat/history +``` + +### 请求参数 +```json +{ + "companionId": 0, // AI 陪聊角色 ID(使用 KBPersonaModel.personaId) + "pageNum": 1, // 页码 + "pageSize": 20 // 每页大小 +} +``` + +### 参数说明 +- **companionId**:使用 `fetchPersonasWithPageNum` 接口返回的 `KBPersonaModel.personaId` +- **pageNum**:页码,从 1 开始 +- **pageSize**:每页大小,建议 20 + +### 响应格式 +```json +{ + "code": 0, + "message": "success", + "data": { + "records": [ + { + "id": 1, + "sender": 0, // 0-用户,1-AI + "content": "你好", + "createdAt": "2026-01-26 10:00:00" + }, + { + "id": 2, + "sender": 1, + "content": "你好!有什么可以帮助你的吗?", + "createdAt": "2026-01-26 10:00:05" + } + ], + "total": 100, + "current": 1, + "pages": 5 + } +} +``` + +--- + +## 📝 使用示例 + +### 1. 在 AiVM 中调用接口 + +```objc +#import "AiVM.h" + +AiVM *aiVM = [[AiVM alloc] init]; + +[aiVM fetchChatHistoryWithCompanionId:123 + pageNum:1 + pageSize:20 + completion:^(KBChatHistoryPageModel *pageModel, NSError *error) { + if (error) { + NSLog(@"加载失败:%@", error.localizedDescription); + return; + } + + NSLog(@"加载成功:%ld 条消息", pageModel.records.count); + + for (KBChatHistoryModel *message in pageModel.records) { + if (message.isUserMessage) { + NSLog(@"用户:%@", message.content); + } else { + NSLog(@"AI:%@", message.content); + } + } + + if (pageModel.hasMore) { + NSLog(@"还有更多数据"); + } +}]; +``` + +### 2. 在 VC 中使用 + +```objc +@interface YourViewController () +@property (nonatomic, strong) AiVM *aiVM; +@property (nonatomic, strong) NSMutableArray *messages; +@property (nonatomic, assign) NSInteger currentPage; +@property (nonatomic, assign) BOOL hasMore; +@end + +@implementation YourViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + self.aiVM = [[AiVM alloc] init]; + self.messages = [NSMutableArray array]; + self.currentPage = 1; + self.hasMore = YES; + + [self loadChatHistory]; +} + +- (void)loadChatHistory { + NSInteger companionId = 123; // 当前人设 ID + + __weak typeof(self) weakSelf = self; + [self.aiVM fetchChatHistoryWithCompanionId:companionId + pageNum:self.currentPage + pageSize:20 + completion:^(KBChatHistoryPageModel *pageModel, NSError *error) { + if (error) { + NSLog(@"加载失败:%@", error.localizedDescription); + return; + } + + // 追加数据 + [weakSelf.messages addObjectsFromArray:pageModel.records]; + weakSelf.hasMore = pageModel.hasMore; + + // 刷新 UI + [weakSelf.tableView reloadData]; + }]; +} + +- (void)loadMoreHistory { + if (!self.hasMore) { + return; + } + + self.currentPage++; + [self loadChatHistory]; +} + +@end +``` + +### 3. 转换为 KBAiChatMessage + +```objc +// 将 KBChatHistoryModel 转换为 KBAiChatMessage(用于聊天界面展示) +- (KBAiChatMessage *)convertToAiChatMessage:(KBChatHistoryModel *)historyModel { + if (historyModel.isUserMessage) { + return [KBAiChatMessage userMessageWithText:historyModel.content]; + } else { + return [KBAiChatMessage assistantMessageWithText:historyModel.content]; + } +} + +// 批量转换 +- (NSArray *)convertHistoryToMessages:(NSArray *)history { + NSMutableArray *messages = [NSMutableArray array]; + for (KBChatHistoryModel *item in history) { + [messages addObject:[self convertToAiChatMessage:item]]; + } + return messages; +} +``` + +--- + +## 🔄 与 KBPersonaChatCell 集成 + +### 在 KBPersonaChatCell 中加载聊天记录 + +```objc +// KBPersonaChatCell.m + +- (void)setPersona:(KBPersonaModel *)persona { + _persona = persona; + + // 重置状态 + self.hasLoadedData = NO; + self.isLoading = NO; + self.currentPage = 1; + self.hasMoreHistory = YES; + self.messages = [NSMutableArray array]; + self.aiVM = [[AiVM alloc] init]; + + // 设置 UI + [self updateUI]; +} + +- (void)preloadDataIfNeeded { + if (self.hasLoadedData || self.isLoading) { + return; + } + + [self loadChatHistory]; +} + +- (void)loadChatHistory { + if (self.isLoading || !self.hasMoreHistory) { + return; + } + + self.isLoading = YES; + + // 使用 persona.personaId 作为 companionId + NSInteger companionId = self.persona.personaId; + + __weak typeof(self) weakSelf = self; + [self.aiVM fetchChatHistoryWithCompanionId:companionId + pageNum:self.currentPage + pageSize:20 + completion:^(KBChatHistoryPageModel *pageModel, NSError *error) { + weakSelf.isLoading = NO; + + if (error) { + NSLog(@"加载聊天记录失败:%@", error.localizedDescription); + return; + } + + weakSelf.hasLoadedData = YES; + weakSelf.hasMoreHistory = pageModel.hasMore; + + // 转换为 KBAiChatMessage + NSMutableArray *newMessages = [NSMutableArray array]; + for (KBChatHistoryModel *item in pageModel.records) { + KBAiChatMessage *message; + if (item.isUserMessage) { + message = [KBAiChatMessage userMessageWithText:item.content]; + } else { + message = [KBAiChatMessage assistantMessageWithText:item.content]; + } + message.isComplete = YES; + [newMessages addObject:message]; + } + + // 插入到顶部(历史消息) + if (weakSelf.currentPage == 1) { + weakSelf.messages = newMessages; + } else { + NSIndexSet *indexSet = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, newMessages.count)]; + [weakSelf.messages insertObjects:newMessages atIndexes:indexSet]; + } + + // 刷新 UI + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf.tableView reloadData]; + }); + }]; +} + +- (void)loadMoreHistory { + if (!self.hasMoreHistory || self.isLoading) { + return; + } + + self.currentPage++; + [self loadChatHistory]; +} + +// 下拉加载更多 +- (void)scrollViewDidScroll:(UIScrollView *)scrollView { + CGFloat offsetY = scrollView.contentOffset.y; + + if (offsetY <= -50 && !self.isLoading) { + [self loadMoreHistory]; + } +} +``` + +--- + +## 📊 数据流程 + +``` +1. 用户滑动到某个人设 + ↓ +2. KBPersonaChatCell 触发预加载 + ↓ +3. 调用 AiVM.fetchChatHistoryWithCompanionId + ↓ +4. 请求 POST /chat/history + ↓ +5. 返回 KBChatHistoryPageModel + ↓ +6. 转换为 KBAiChatMessage + ↓ +7. 在 TableView 中展示 +``` + +--- + +## 🎯 字段映射 + +### MJExtension 自动映射 + +| JSON 字段 | Model 属性 | 说明 | +|-----------|-----------|------| +| `id` | `messageId` | 消息 ID | +| `sender` | `sender` | 发送者(0-用户,1-AI) | +| `content` | `content` | 消息内容 | +| `createdAt` | `createdAt` | 创建时间 | + +### 枚举值说明 + +```objc +typedef NS_ENUM(NSInteger, KBChatSender) { + KBChatSenderUser = 0, // 用户 + KBChatSenderAssistant = 1 // AI 助手 +}; +``` + +--- + +## ✅ 已完成功能 + +1. ✅ KBChatHistoryModel:聊天记录模型 +2. ✅ KBChatHistoryPageModel:分页数据模型 +3. ✅ AiVM 新增接口:fetchChatHistoryWithCompanionId +4. ✅ MJExtension 配置:JSON 自动转 Model +5. ✅ 扩展属性:isUserMessage、isAssistantMessage、hasMore +6. ✅ 容错处理:字段为 null 时设置默认值 + +--- + +## 🚧 待实现功能 + +1. 在 KBPersonaChatCell 中集成聊天记录加载 +2. 实现下拉加载更多历史消息 +3. 实现消息时间戳显示 +4. 实现消息发送功能 +5. 实现消息缓存(避免重复请求) + +--- + +## 📌 注意事项 + +1. **companionId**:直接使用 `KBPersonaModel.personaId`(人设列表接口返回的 ID) +2. **时间格式**:createdAt 是字符串,需要根据实际格式解析 +3. **分页方向**:默认从最新消息开始加载,下拉加载历史消息 +4. **消息顺序**:需要根据实际需求决定是正序还是倒序 +5. **容错处理**:已做 null 值容错,确保不会崩溃 + +### 数据关联关系 + +``` +KBPersonaModel (人设列表) + └─ personaId (人设 ID) + ↓ + 用作 companionId 参数 + ↓ +KBChatHistoryPageModel (聊天记录) + └─ records (消息列表) +``` diff --git a/keyBoard/Class/AiTalk/V/KBPersonaChatCell.m b/keyBoard/Class/AiTalk/V/KBPersonaChatCell.m index 160090a..8cf1ef0 100644 --- a/keyBoard/Class/AiTalk/V/KBPersonaChatCell.m +++ b/keyBoard/Class/AiTalk/V/KBPersonaChatCell.m @@ -8,6 +8,8 @@ #import "KBPersonaChatCell.h" #import "KBChatTableView.h" #import "KBAiChatMessage.h" +#import "KBChatHistoryPageModel.h" +#import "AiVM.h" #import #import @@ -34,6 +36,18 @@ /// 是否已加载数据 @property (nonatomic, assign) BOOL hasLoadedData; +/// 是否正在加载 +@property (nonatomic, assign) BOOL isLoading; + +/// 当前页码 +@property (nonatomic, assign) NSInteger currentPage; + +/// 是否还有更多历史消息 +@property (nonatomic, assign) BOOL hasMoreHistory; + +/// AiVM 实例 +@property (nonatomic, strong) AiVM *aiVM; + @end @implementation KBPersonaChatCell @@ -102,7 +116,11 @@ // 重置状态 self.hasLoadedData = NO; + self.isLoading = NO; + self.currentPage = 1; + self.hasMoreHistory = YES; self.messages = [NSMutableArray array]; + self.aiVM = [[AiVM alloc] init]; // 设置 UI [self.backgroundImageView sd_setImageWithURL:[NSURL URLWithString:persona.coverImageUrl] @@ -118,20 +136,113 @@ #pragma mark - 2:数据加载 - (void)preloadDataIfNeeded { - if (self.hasLoadedData) { + if (self.hasLoadedData || self.isLoading) { return; } - // TODO: 这里后续需要用 chatId 去请求聊天记录 - // 目前先添加开场白作为第一条消息 - self.hasLoadedData = YES; - - if (self.persona.introText.length > 0) { - KBAiChatMessage *openingMsg = [KBAiChatMessage assistantMessageWithText:self.persona.introText]; - openingMsg.isComplete = YES; - [self.messages addObject:openingMsg]; - [self.tableView reloadData]; + [self loadChatHistory]; +} + +- (void)loadChatHistory { + if (self.isLoading || !self.hasMoreHistory) { + return; } + + self.isLoading = YES; + + // 使用 persona.personaId 作为 companionId + NSInteger companionId = self.persona.personaId; + + __weak typeof(self) weakSelf = self; + [self.aiVM fetchChatHistoryWithCompanionId:companionId + pageNum:self.currentPage + pageSize:20 + completion:^(KBChatHistoryPageModel *pageModel, NSError *error) { + weakSelf.isLoading = NO; + + if (error) { + NSLog(@"[KBPersonaChatCell] 加载聊天记录失败:%@", error.localizedDescription); + + // 如果是第一次加载失败,显示开场白 + if (weakSelf.currentPage == 1 && weakSelf.persona.introText.length > 0) { + [weakSelf showOpeningMessage]; + } + return; + } + + weakSelf.hasLoadedData = YES; + weakSelf.hasMoreHistory = pageModel.hasMore; + + // 转换为 KBAiChatMessage + NSMutableArray *newMessages = [NSMutableArray array]; + for (KBChatHistoryModel *item in pageModel.records) { + KBAiChatMessage *message; + if (item.isUserMessage) { + message = [KBAiChatMessage userMessageWithText:item.content]; + } else { + message = [KBAiChatMessage assistantMessageWithText:item.content]; + } + message.isComplete = YES; + [newMessages addObject:message]; + } + + // 插入到顶部(历史消息) + if (weakSelf.currentPage == 1) { + // 第一页,直接赋值 + weakSelf.messages = newMessages; + } else { + // 后续页,插入到顶部 + NSIndexSet *indexSet = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, newMessages.count)]; + [weakSelf.messages insertObjects:newMessages atIndexes:indexSet]; + } + + // 刷新 UI + dispatch_async(dispatch_get_main_queue(), ^{ + if (weakSelf.currentPage == 1) { + [weakSelf.tableView reloadData]; + + // 滚动到底部(最新消息) + if (weakSelf.messages.count > 0) { + NSIndexPath *lastIndexPath = [NSIndexPath indexPathForRow:weakSelf.messages.count - 1 inSection:0]; + [weakSelf.tableView scrollToRowAtIndexPath:lastIndexPath + atScrollPosition:UITableViewScrollPositionBottom + animated:NO]; + } + } else { + // 保持滚动位置 + CGFloat oldContentHeight = weakSelf.tableView.contentSize.height; + [weakSelf.tableView reloadData]; + CGFloat newContentHeight = weakSelf.tableView.contentSize.height; + CGFloat offsetY = newContentHeight - oldContentHeight; + [weakSelf.tableView setContentOffset:CGPointMake(0, offsetY) animated:NO]; + } + }); + + NSLog(@"[KBPersonaChatCell] 加载成功:第 %ld 页,%ld 条消息,还有更多:%@", + (long)weakSelf.currentPage, + (long)newMessages.count, + pageModel.hasMore ? @"是" : @"否"); + }]; +} + +- (void)loadMoreHistory { + if (!self.hasMoreHistory || self.isLoading) { + return; + } + + self.currentPage++; + [self loadChatHistory]; +} + +- (void)showOpeningMessage { + // 显示开场白作为第一条消息 + KBAiChatMessage *openingMsg = [KBAiChatMessage assistantMessageWithText:self.persona.introText]; + openingMsg.isComplete = YES; + [self.messages addObject:openingMsg]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [self.tableView reloadData]; + }); } #pragma mark - UITableViewDataSource @@ -159,6 +270,17 @@ return UITableViewAutomaticDimension; } +#pragma mark - UIScrollViewDelegate + +- (void)scrollViewDidScroll:(UIScrollView *)scrollView { + CGFloat offsetY = scrollView.contentOffset.y; + + // 下拉到顶部,加载历史消息 + if (offsetY <= -50 && !self.isLoading) { + [self loadMoreHistory]; + } +} + #pragma mark - Lazy Load - (UIImageView *)backgroundImageView { diff --git a/keyBoard/Class/AiTalk/V/KBVoiceInputBar.h b/keyBoard/Class/AiTalk/V/KBVoiceInputBar.h new file mode 100644 index 0000000..d503a52 --- /dev/null +++ b/keyBoard/Class/AiTalk/V/KBVoiceInputBar.h @@ -0,0 +1,53 @@ +// +// KBVoiceInputBar.h +// keyBoard +// +// Created by Kiro on 2026/1/26. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class KBVoiceInputBar; + +/// 语音输入栏代理 +@protocol KBVoiceInputBarDelegate + +@optional + +/// 开始录音 +- (void)voiceInputBarDidBeginRecording:(KBVoiceInputBar *)inputBar; + +/// 结束录音 +- (void)voiceInputBarDidEndRecording:(KBVoiceInputBar *)inputBar; + +/// 取消录音 +- (void)voiceInputBarDidCancelRecording:(KBVoiceInputBar *)inputBar; + +@end + +/// 底部语音输入栏 +/// 包含:毛玻璃背景 + 录音按钮 +@interface KBVoiceInputBar : UIView + +/// 代理 +@property (nonatomic, weak) id delegate; + +/// 状态文本(显示在按钮上方) +@property (nonatomic, copy) NSString *statusText; + +/// 是否启用(禁用时按钮不可点击) +@property (nonatomic, assign) BOOL enabled; + +/// 更新音量(用于波形动画) +/// @param rms 音量 RMS 值 (0.0 - 1.0) +- (void)updateVolumeRMS:(float)rms; + +/// 设置录音状态 +/// @param recording 是否正在录音 +- (void)setRecording:(BOOL)recording; + +@end + +NS_ASSUME_NONNULL_END diff --git a/keyBoard/Class/AiTalk/V/KBVoiceInputBar.m b/keyBoard/Class/AiTalk/V/KBVoiceInputBar.m new file mode 100644 index 0000000..8da0d6d --- /dev/null +++ b/keyBoard/Class/AiTalk/V/KBVoiceInputBar.m @@ -0,0 +1,202 @@ +// +// KBVoiceInputBar.m +// keyBoard +// +// Created by Kiro on 2026/1/26. +// + +#import "KBVoiceInputBar.h" +#import "KBAiRecordButton.h" +#import + +@interface KBVoiceInputBar () + +/// 毛玻璃背景容器 +@property (nonatomic, strong) UIView *backgroundView; + +/// 毛玻璃效果 +@property (nonatomic, strong) UIVisualEffectView *blurEffectView; + +/// 状态标签 +@property (nonatomic, strong) UILabel *statusLabel; + +/// 录音按钮 +@property (nonatomic, strong) KBAiRecordButton *recordButton; + +/// 是否正在录音 +@property (nonatomic, assign) BOOL isRecording; + +@end + +@implementation KBVoiceInputBar + +#pragma mark - Lifecycle + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + [self setupUI]; + } + return self; +} + +- (instancetype)initWithCoder:(NSCoder *)coder { + if (self = [super initWithCoder:coder]) { + [self setupUI]; + } + return self; +} + +#pragma mark - 1:控件初始化 + +- (void)setupUI { + self.backgroundColor = [UIColor clearColor]; + self.enabled = YES; + self.isRecording = NO; + + // 毛玻璃背景容器 + [self addSubview:self.backgroundView]; + [self.backgroundView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self); + }]; + + // 毛玻璃效果 + [self.backgroundView addSubview:self.blurEffectView]; + [self.blurEffectView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.backgroundView); + }]; + + // 为 blurEffectView 创建透明度渐变 mask + CAGradientLayer *maskLayer = [CAGradientLayer layer]; + maskLayer.startPoint = CGPointMake(0.5, 1); // 底部 + maskLayer.endPoint = CGPointMake(0.5, 0); // 顶部 + maskLayer.colors = @[ + (__bridge id)[UIColor whiteColor].CGColor, // 底部:完全不透明 + (__bridge id)[UIColor whiteColor].CGColor, // 中间:完全不透明 + (__bridge id)[UIColor clearColor].CGColor // 顶部:完全透明 + ]; + maskLayer.locations = @[@(0.0), @(0.5), @(1.0)]; + self.blurEffectView.layer.mask = maskLayer; + + // 状态标签 + [self addSubview:self.statusLabel]; + [self.statusLabel mas_makeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(self).offset(16); + make.left.equalTo(self).offset(20); + make.right.equalTo(self).offset(-20); + make.height.mas_equalTo(20); + }]; + + // 录音按钮 + [self addSubview:self.recordButton]; + [self.recordButton mas_makeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(self.statusLabel.mas_bottom).offset(12); + make.left.equalTo(self).offset(20); + make.right.equalTo(self).offset(-20); + make.height.mas_equalTo(50); + make.bottom.lessThanOrEqualTo(self).offset(-16); + }]; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + + // 更新 mask 的 frame + if (self.blurEffectView.layer.mask) { + self.blurEffectView.layer.mask.frame = self.blurEffectView.bounds; + } +} + +#pragma mark - Setter + +- (void)setStatusText:(NSString *)statusText { + _statusText = [statusText copy]; + self.statusLabel.text = statusText; +} + +- (void)setEnabled:(BOOL)enabled { + _enabled = enabled; + self.recordButton.userInteractionEnabled = enabled; + self.recordButton.alpha = enabled ? 1.0 : 0.5; +} + +- (void)setRecording:(BOOL)recording { + _isRecording = recording; + self.recordButton.state = recording ? KBAiRecordButtonStateRecording : KBAiRecordButtonStateNormal; +} + +#pragma mark - Public Methods + +- (void)updateVolumeRMS:(float)rms { + [self.recordButton updateVolumeRMS:rms]; +} + +#pragma mark - KBAiRecordButtonDelegate + +- (void)recordButtonDidBeginPress:(KBAiRecordButton *)button { + if (!self.enabled) { + return; + } + + self.isRecording = YES; + + if ([self.delegate respondsToSelector:@selector(voiceInputBarDidBeginRecording:)]) { + [self.delegate voiceInputBarDidBeginRecording:self]; + } +} + +- (void)recordButtonDidEndPress:(KBAiRecordButton *)button { + self.isRecording = NO; + + if ([self.delegate respondsToSelector:@selector(voiceInputBarDidEndRecording:)]) { + [self.delegate voiceInputBarDidEndRecording:self]; + } +} + +- (void)recordButtonDidCancelPress:(KBAiRecordButton *)button { + self.isRecording = NO; + + if ([self.delegate respondsToSelector:@selector(voiceInputBarDidCancelRecording:)]) { + [self.delegate voiceInputBarDidCancelRecording:self]; + } +} + +#pragma mark - Lazy Load + +- (UIView *)backgroundView { + if (!_backgroundView) { + _backgroundView = [[UIView alloc] init]; + _backgroundView.clipsToBounds = YES; + } + return _backgroundView; +} + +- (UIVisualEffectView *)blurEffectView { + if (!_blurEffectView) { + UIBlurEffect *blurEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleLight]; + _blurEffectView = [[UIVisualEffectView alloc] initWithEffect:blurEffect]; + } + return _blurEffectView; +} + +- (UILabel *)statusLabel { + if (!_statusLabel) { + _statusLabel = [[UILabel alloc] init]; + _statusLabel.text = @"按住按钮开始对话"; + _statusLabel.font = [UIFont systemFontOfSize:14]; + _statusLabel.textColor = [UIColor secondaryLabelColor]; + _statusLabel.textAlignment = NSTextAlignmentCenter; + } + return _statusLabel; +} + +- (KBAiRecordButton *)recordButton { + if (!_recordButton) { + _recordButton = [[KBAiRecordButton alloc] init]; + _recordButton.delegate = self; + _recordButton.normalTitle = @"按住说话"; + _recordButton.recordingTitle = @"松开结束"; + } + return _recordButton; +} + +@end diff --git a/keyBoard/Class/AiTalk/V/KBVoiceInputBar_使用说明.md b/keyBoard/Class/AiTalk/V/KBVoiceInputBar_使用说明.md new file mode 100644 index 0000000..2fbe69b --- /dev/null +++ b/keyBoard/Class/AiTalk/V/KBVoiceInputBar_使用说明.md @@ -0,0 +1,320 @@ +# KBVoiceInputBar 使用说明 + +## 📦 组件概述 + +`KBVoiceInputBar` 是一个封装好的底部语音输入栏组件,包含: +- ✅ 毛玻璃背景(带渐变 mask) +- ✅ 状态标签(显示当前状态) +- ✅ 录音按钮(支持长按、波形动画) +- ✅ 代理回调(开始/结束/取消录音) + +--- + +## 🎨 UI 结构 + +``` +┌─────────────────────────────┐ +│ 毛玻璃背景(渐变透明) │ +│ ┌─────────────────────┐ │ +│ │ 状态标签 │ │ +│ │ "按住按钮开始对话" │ │ +│ └─────────────────────┘ │ +│ ┌─────────────────────┐ │ +│ │ 录音按钮 │ │ +│ │ [按住说话] │ │ +│ └─────────────────────┘ │ +└─────────────────────────────┘ +``` + +--- + +## 📝 使用方式 + +### 1. 导入头文件 + +```objc +#import "KBVoiceInputBar.h" +``` + +### 2. 在 VC 中声明属性 + +```objc +@interface YourViewController () +@property (nonatomic, strong) KBVoiceInputBar *voiceInputBar; +@end +``` + +### 3. 初始化和布局 + +```objc +- (void)setupUI { + // 创建语音输入栏 + self.voiceInputBar = [[KBVoiceInputBar alloc] init]; + self.voiceInputBar.delegate = self; + self.voiceInputBar.statusText = @"按住按钮开始对话"; + + [self.view addSubview:self.voiceInputBar]; + [self.voiceInputBar mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.right.equalTo(self.view); + make.bottom.equalTo(self.view); + make.height.mas_equalTo(150); // 根据实际需要调整 + }]; +} +``` + +### 4. 实现代理方法 + +```objc +#pragma mark - KBVoiceInputBarDelegate + +- (void)voiceInputBarDidBeginRecording:(KBVoiceInputBar *)inputBar { + NSLog(@"开始录音"); + inputBar.statusText = @"正在聆听..."; + + // TODO: 开始录音逻辑 + // 1. 检查登录状态 + // 2. 连接语音识别服务 + // 3. 开始录音 +} + +- (void)voiceInputBarDidEndRecording:(KBVoiceInputBar *)inputBar { + NSLog(@"结束录音"); + inputBar.statusText = @"正在识别..."; + + // TODO: 结束录音逻辑 + // 1. 停止录音 + // 2. 发送音频数据 + // 3. 等待识别结果 +} + +- (void)voiceInputBarDidCancelRecording:(KBVoiceInputBar *)inputBar { + NSLog(@"取消录音"); + inputBar.statusText = @"已取消"; + + // TODO: 取消录音逻辑 + // 1. 停止录音 + // 2. 清理资源 +} +``` + +--- + +## 🔧 API 说明 + +### 属性 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `delegate` | `id` | 代理对象 | +| `statusText` | `NSString *` | 状态文本(显示在按钮上方) | +| `enabled` | `BOOL` | 是否启用(禁用时按钮不可点击) | + +### 方法 + +| 方法 | 说明 | +|------|------| +| `- (void)updateVolumeRMS:(float)rms` | 更新音量(用于波形动画) | +| `- (void)setRecording:(BOOL)recording` | 设置录音状态 | + +### 代理方法 + +| 方法 | 说明 | +|------|------| +| `- (void)voiceInputBarDidBeginRecording:` | 开始录音 | +| `- (void)voiceInputBarDidEndRecording:` | 结束录音 | +| `- (void)voiceInputBarDidCancelRecording:` | 取消录音 | + +--- + +## 💡 使用示例 + +### 示例 1:更新状态文本 + +```objc +// 开始录音 +self.voiceInputBar.statusText = @"正在聆听..."; + +// 识别中 +self.voiceInputBar.statusText = @"正在识别..."; + +// AI 思考中 +self.voiceInputBar.statusText = @"AI 正在思考..."; + +// 完成 +self.voiceInputBar.statusText = @"完成"; +``` + +### 示例 2:更新音量波形 + +```objc +// 在录音过程中,定时更新音量 +- (void)onVolumeUpdate:(float)rms { + [self.voiceInputBar updateVolumeRMS:rms]; +} +``` + +### 示例 3:禁用/启用按钮 + +```objc +// 禁用(比如未登录时) +self.voiceInputBar.enabled = NO; + +// 启用 +self.voiceInputBar.enabled = YES; +``` + +### 示例 4:手动设置录音状态 + +```objc +// 开始录音 +[self.voiceInputBar setRecording:YES]; + +// 结束录音 +[self.voiceInputBar setRecording:NO]; +``` + +--- + +## 🎯 完整示例(集成 Deepgram) + +```objc +#import "YourViewController.h" +#import "KBVoiceInputBar.h" +#import "DeepgramStreamingManager.h" + +@interface YourViewController () +@property (nonatomic, strong) KBVoiceInputBar *voiceInputBar; +@property (nonatomic, strong) DeepgramStreamingManager *deepgramManager; +@end + +@implementation YourViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + [self setupUI]; + [self setupDeepgram]; +} + +- (void)setupUI { + self.voiceInputBar = [[KBVoiceInputBar alloc] init]; + self.voiceInputBar.delegate = self; + [self.view addSubview:self.voiceInputBar]; + [self.voiceInputBar mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.right.bottom.equalTo(self.view); + make.height.mas_equalTo(150); + }]; +} + +- (void)setupDeepgram { + self.deepgramManager = [[DeepgramStreamingManager alloc] init]; + self.deepgramManager.delegate = self; + self.deepgramManager.serverURL = @"wss://api.deepgram.com/v1/listen"; + self.deepgramManager.apiKey = @"your_api_key"; + [self.deepgramManager prepareConnection]; +} + +#pragma mark - KBVoiceInputBarDelegate + +- (void)voiceInputBarDidBeginRecording:(KBVoiceInputBar *)inputBar { + inputBar.statusText = @"正在连接..."; + [self.deepgramManager start]; +} + +- (void)voiceInputBarDidEndRecording:(KBVoiceInputBar *)inputBar { + inputBar.statusText = @"正在识别..."; + [self.deepgramManager stopAndFinalize]; +} + +- (void)voiceInputBarDidCancelRecording:(KBVoiceInputBar *)inputBar { + inputBar.statusText = @"已取消"; + [self.deepgramManager cancel]; +} + +#pragma mark - DeepgramStreamingManagerDelegate + +- (void)deepgramStreamingManagerDidConnect { + self.voiceInputBar.statusText = @"正在聆听..."; +} + +- (void)deepgramStreamingManagerDidUpdateRMS:(float)rms { + [self.voiceInputBar updateVolumeRMS:rms]; +} + +- (void)deepgramStreamingManagerDidReceiveInterimTranscript:(NSString *)text { + self.voiceInputBar.statusText = text.length > 0 ? text : @"正在识别..."; +} + +- (void)deepgramStreamingManagerDidReceiveFinalTranscript:(NSString *)text { + self.voiceInputBar.statusText = @"识别完成"; + NSLog(@"最终识别结果:%@", text); + + // TODO: 处理识别结果 +} + +@end +``` + +--- + +## 🎨 自定义样式 + +### 修改毛玻璃效果 + +```objc +// 在 KBVoiceInputBar.m 中修改 +UIBlurEffect *blurEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleDark]; // 改为深色 +``` + +### 修改按钮样式 + +```objc +// 在 KBVoiceInputBar.m 的 recordButton 懒加载中修改 +_recordButton.normalTitle = @"点击说话"; +_recordButton.recordingTitle = @"正在录音..."; +_recordButton.tintColor = [UIColor systemBlueColor]; +``` + +### 修改高度 + +```objc +// 在布局时调整 +[self.voiceInputBar mas_makeConstraints:^(MASConstraintMaker *make) { + make.height.mas_equalTo(200); // 调整为 200 +}]; +``` + +--- + +## 📌 注意事项 + +1. **代理必须设置**:否则无法接收录音事件 +2. **高度建议**:推荐高度 150-200,根据实际需要调整 +3. **状态文本**:及时更新 `statusText` 提升用户体验 +4. **音量更新**:定时调用 `updateVolumeRMS:` 显示波形动画 +5. **禁用状态**:未登录或其他情况下记得禁用按钮 + +--- + +## 🚀 优势 + +1. ✅ **开箱即用**:无需关心内部实现细节 +2. ✅ **高度封装**:毛玻璃背景、按钮、状态标签一体化 +3. ✅ **易于集成**:只需实现 3 个代理方法 +4. ✅ **样式统一**:与 KBAiMainVC 保持一致 +5. ✅ **易于扩展**:可以轻松添加更多功能 + +--- + +## 📂 文件位置 + +- **头文件**:`keyBoard/Class/AiTalk/V/KBVoiceInputBar.h` +- **实现文件**:`keyBoard/Class/AiTalk/V/KBVoiceInputBar.m` +- **依赖**:`KBAiRecordButton`(已存在) + +--- + +## 🔗 相关组件 + +- `KBAiRecordButton`:录音按钮(支持长按、波形动画) +- `DeepgramStreamingManager`:语音识别管理器 +- `VoiceChatStreamingManager`:语音聊天管理器 diff --git a/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m b/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m index 7363b27..98f0aed 100644 --- a/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m +++ b/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m @@ -8,14 +8,18 @@ #import "KBAIHomeVC.h" #import "KBPersonaChatCell.h" #import "KBPersonaModel.h" +#import "KBVoiceInputBar.h" #import "AiVM.h" #import -@interface KBAIHomeVC () +@interface KBAIHomeVC () /// 人设列表容器 @property (nonatomic, strong) UICollectionView *collectionView; +/// 底部语音输入栏 +@property (nonatomic, strong) KBVoiceInputBar *voiceInputBar; + /// 人设数据 @property (nonatomic, strong) NSMutableArray *personas; @@ -67,6 +71,14 @@ [self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) { make.edges.equalTo(self.view); }]; + + // 底部语音输入栏 + [self.view addSubview:self.voiceInputBar]; + [self.voiceInputBar mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.right.equalTo(self.view); + make.bottom.equalTo(self.view); + make.height.mas_equalTo(150); // 根据实际需要调整高度 + }]; } #pragma mark - 2:数据加载 @@ -183,6 +195,13 @@ - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { KBPersonaChatCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"KBPersonaChatCell" forIndexPath:indexPath]; cell.persona = self.personas[indexPath.item]; + + // 标记为已预加载 + [self.preloadedIndexes addObject:@(indexPath.item)]; + + // 直接触发预加载 + [cell preloadDataIfNeeded]; + return cell; } @@ -241,4 +260,44 @@ return _collectionView; } +- (KBVoiceInputBar *)voiceInputBar { + if (!_voiceInputBar) { + _voiceInputBar = [[KBVoiceInputBar alloc] init]; + _voiceInputBar.delegate = self; + _voiceInputBar.statusText = @"按住按钮开始对话"; + } + return _voiceInputBar; +} + +#pragma mark - KBVoiceInputBarDelegate + +- (void)voiceInputBarDidBeginRecording:(KBVoiceInputBar *)inputBar { + NSLog(@"[KBAIHomeVC] 开始录音"); + inputBar.statusText = @"正在聆听..."; + + // TODO: 开始录音逻辑 + // 1. 检查登录状态 + // 2. 连接语音识别服务 + // 3. 开始录音 +} + +- (void)voiceInputBarDidEndRecording:(KBVoiceInputBar *)inputBar { + NSLog(@"[KBAIHomeVC] 结束录音"); + inputBar.statusText = @"正在识别..."; + + // TODO: 结束录音逻辑 + // 1. 停止录音 + // 2. 发送音频数据 + // 3. 等待识别结果 +} + +- (void)voiceInputBarDidCancelRecording:(KBVoiceInputBar *)inputBar { + NSLog(@"[KBAIHomeVC] 取消录音"); + inputBar.statusText = @"已取消"; + + // TODO: 取消录音逻辑 + // 1. 停止录音 + // 2. 清理资源 +} + @end diff --git a/keyBoard/Class/AiTalk/VM/AiVM.h b/keyBoard/Class/AiTalk/VM/AiVM.h index fe98716..97d99eb 100644 --- a/keyBoard/Class/AiTalk/VM/AiVM.h +++ b/keyBoard/Class/AiTalk/VM/AiVM.h @@ -7,6 +7,7 @@ #import #import "KBPersonaPageModel.h" +#import "KBChatHistoryPageModel.h" NS_ASSUME_NONNULL_BEGIN @@ -66,6 +67,16 @@ typedef void (^AiVMAudioURLCompletion)(NSString *_Nullable audioURL, pageSize:(NSInteger)pageSize completion:(void(^)(KBPersonaPageModel * _Nullable pageModel, NSError * _Nullable error))completion; +/// 分页查询聊天记录 +/// @param companionId AI 陪聊角色 ID +/// @param pageNum 页码(从 1 开始) +/// @param pageSize 每页大小 +/// @param completion 完成回调 +- (void)fetchChatHistoryWithCompanionId:(NSInteger)companionId + pageNum:(NSInteger)pageNum + pageSize:(NSInteger)pageSize + completion:(void(^)(KBChatHistoryPageModel * _Nullable pageModel, NSError * _Nullable error))completion; + @end NS_ASSUME_NONNULL_END diff --git a/keyBoard/Class/AiTalk/VM/AiVM.m b/keyBoard/Class/AiTalk/VM/AiVM.m index 2e37e63..e9bb905 100644 --- a/keyBoard/Class/AiTalk/VM/AiVM.m +++ b/keyBoard/Class/AiTalk/VM/AiVM.m @@ -272,4 +272,66 @@ autoShowBusinessError:NO }]; } +#pragma mark - 聊天记录相关接口 + +- (void)fetchChatHistoryWithCompanionId:(NSInteger)companionId + pageNum:(NSInteger)pageNum + pageSize:(NSInteger)pageSize + completion:(void (^)(KBChatHistoryPageModel * _Nullable, NSError * _Nullable))completion { + NSDictionary *params = @{ + @"companionId": @(companionId), + @"pageNum": @(pageNum), + @"pageSize": @(pageSize) + }; + + NSLog(@"[AiVM] /chat/history request: %@", params); + [[KBNetworkManager shared] + POST:@"/chat/history" + jsonBody:params + headers:nil +autoShowBusinessError:NO + completion:^(NSDictionary *_Nullable json, + NSURLResponse *_Nullable response, + NSError *_Nullable error) { + if (error) { + NSLog(@"[AiVM] /chat/history failed: %@", error.localizedDescription ?: @""); + if (completion) { + completion(nil, error); + } + return; + } + + NSLog(@"[AiVM] /chat/history response: %@", json); + + // 解析响应 + NSInteger code = [json[@"code"] integerValue]; + if (code != 0) { + NSString *message = json[@"message"] ?: @"请求失败"; + NSError *bizError = [NSError errorWithDomain:@"AiVM" + code:code + userInfo:@{NSLocalizedDescriptionKey: message}]; + if (completion) { + completion(nil, bizError); + } + return; + } + + // 转换为模型 + id dataObj = json[@"data"]; + if ([dataObj isKindOfClass:[NSDictionary class]]) { + KBChatHistoryPageModel *pageModel = [KBChatHistoryPageModel mj_objectWithKeyValues:dataObj]; + if (completion) { + completion(pageModel, nil); + } + } else { + NSError *parseError = [NSError errorWithDomain:@"AiVM" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"数据格式错误"}]; + if (completion) { + completion(nil, parseError); + } + } + }]; +} + @end diff --git a/keyBoard/Class/AiTalk/人设列表实现说明.md b/keyBoard/Class/AiTalk/人设列表实现说明.md index 8e0e5e5..f98516a 100644 --- a/keyBoard/Class/AiTalk/人设列表实现说明.md +++ b/keyBoard/Class/AiTalk/人设列表实现说明.md @@ -4,13 +4,17 @@ ``` KBAIHomeVC (人设列表容器) - └─ UICollectionView (竖向分页滚动) - └─ KBPersonaChatCell (每个人设占满屏) - ├─ 背景图(coverImageUrl) - ├─ 头像(avatarUrl) - ├─ 人设名称(name) - ├─ 简介(shortDesc) - └─ UITableView (聊天记录 - 待实现) + ├─ UICollectionView (竖向分页滚动) + │ └─ KBPersonaChatCell (每个人设占满屏) + │ ├─ 背景图(coverImageUrl) + │ ├─ 头像(avatarUrl) + │ ├─ 人设名称(name) + │ ├─ 简介(shortDesc) + │ └─ UITableView (聊天记录 - 待实现) + └─ KBVoiceInputBar (底部语音输入栏) + ├─ 毛玻璃背景 + ├─ 状态标签 + └─ 录音按钮 ``` ---