新增聊天记录
This commit is contained in:
@@ -140,6 +140,9 @@
|
|||||||
048FFD102F27432D005D62AE /* KBPersonaModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD0D2F27432D005D62AE /* KBPersonaModel.m */; };
|
048FFD102F27432D005D62AE /* KBPersonaModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD0D2F27432D005D62AE /* KBPersonaModel.m */; };
|
||||||
048FFD112F27432D005D62AE /* KBPersonaPageModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD0F2F27432D005D62AE /* KBPersonaPageModel.m */; };
|
048FFD112F27432D005D62AE /* KBPersonaPageModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD0F2F27432D005D62AE /* KBPersonaPageModel.m */; };
|
||||||
048FFD142F274342005D62AE /* KBPersonaChatCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD132F274342005D62AE /* KBPersonaChatCell.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 */; };
|
0498BD622EDFFC12006CC1D5 /* KBMyVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD612EDFFC12006CC1D5 /* KBMyVM.m */; };
|
||||||
0498BD652EE0116D006CC1D5 /* KBEmailLoginVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD642EE0116D006CC1D5 /* KBEmailLoginVC.m */; };
|
0498BD652EE0116D006CC1D5 /* KBEmailLoginVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD642EE0116D006CC1D5 /* KBEmailLoginVC.m */; };
|
||||||
0498BD682EE01180006CC1D5 /* KBEmailRegistVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD672EE01180006CC1D5 /* KBEmailRegistVC.m */; };
|
0498BD682EE01180006CC1D5 /* KBEmailRegistVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD672EE01180006CC1D5 /* KBEmailRegistVC.m */; };
|
||||||
@@ -530,6 +533,12 @@
|
|||||||
048FFD0F2F27432D005D62AE /* KBPersonaPageModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBPersonaPageModel.m; sourceTree = "<group>"; };
|
048FFD0F2F27432D005D62AE /* KBPersonaPageModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBPersonaPageModel.m; sourceTree = "<group>"; };
|
||||||
048FFD122F274342005D62AE /* KBPersonaChatCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBPersonaChatCell.h; sourceTree = "<group>"; };
|
048FFD122F274342005D62AE /* KBPersonaChatCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBPersonaChatCell.h; sourceTree = "<group>"; };
|
||||||
048FFD132F274342005D62AE /* KBPersonaChatCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBPersonaChatCell.m; sourceTree = "<group>"; };
|
048FFD132F274342005D62AE /* KBPersonaChatCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBPersonaChatCell.m; sourceTree = "<group>"; };
|
||||||
|
048FFD162F2763A5005D62AE /* KBVoiceInputBar.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceInputBar.h; sourceTree = "<group>"; };
|
||||||
|
048FFD172F2763A5005D62AE /* KBVoiceInputBar.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceInputBar.m; sourceTree = "<group>"; };
|
||||||
|
048FFD192F277486005D62AE /* KBChatHistoryModel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatHistoryModel.h; sourceTree = "<group>"; };
|
||||||
|
048FFD1A2F277486005D62AE /* KBChatHistoryModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatHistoryModel.m; sourceTree = "<group>"; };
|
||||||
|
048FFD1B2F277486005D62AE /* KBChatHistoryPageModel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatHistoryPageModel.h; sourceTree = "<group>"; };
|
||||||
|
048FFD1C2F277486005D62AE /* KBChatHistoryPageModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatHistoryPageModel.m; sourceTree = "<group>"; };
|
||||||
0498BD5E2EDF2157006CC1D5 /* KBBizCode.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBBizCode.h; sourceTree = "<group>"; };
|
0498BD5E2EDF2157006CC1D5 /* KBBizCode.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBBizCode.h; sourceTree = "<group>"; };
|
||||||
0498BD602EDFFC12006CC1D5 /* KBMyVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBMyVM.h; sourceTree = "<group>"; };
|
0498BD602EDFFC12006CC1D5 /* KBMyVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBMyVM.h; sourceTree = "<group>"; };
|
||||||
0498BD612EDFFC12006CC1D5 /* KBMyVM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBMyVM.m; sourceTree = "<group>"; };
|
0498BD612EDFFC12006CC1D5 /* KBMyVM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBMyVM.m; sourceTree = "<group>"; };
|
||||||
@@ -981,6 +990,10 @@
|
|||||||
048FFD0D2F27432D005D62AE /* KBPersonaModel.m */,
|
048FFD0D2F27432D005D62AE /* KBPersonaModel.m */,
|
||||||
048FFD0E2F27432D005D62AE /* KBPersonaPageModel.h */,
|
048FFD0E2F27432D005D62AE /* KBPersonaPageModel.h */,
|
||||||
048FFD0F2F27432D005D62AE /* KBPersonaPageModel.m */,
|
048FFD0F2F27432D005D62AE /* KBPersonaPageModel.m */,
|
||||||
|
048FFD192F277486005D62AE /* KBChatHistoryModel.h */,
|
||||||
|
048FFD1A2F277486005D62AE /* KBChatHistoryModel.m */,
|
||||||
|
048FFD1B2F277486005D62AE /* KBChatHistoryPageModel.h */,
|
||||||
|
048FFD1C2F277486005D62AE /* KBChatHistoryPageModel.m */,
|
||||||
);
|
);
|
||||||
path = M;
|
path = M;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -1015,6 +1028,8 @@
|
|||||||
04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */,
|
04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */,
|
||||||
048FFD122F274342005D62AE /* KBPersonaChatCell.h */,
|
048FFD122F274342005D62AE /* KBPersonaChatCell.h */,
|
||||||
048FFD132F274342005D62AE /* KBPersonaChatCell.m */,
|
048FFD132F274342005D62AE /* KBPersonaChatCell.m */,
|
||||||
|
048FFD162F2763A5005D62AE /* KBVoiceInputBar.h */,
|
||||||
|
048FFD172F2763A5005D62AE /* KBVoiceInputBar.m */,
|
||||||
);
|
);
|
||||||
path = V;
|
path = V;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -2283,6 +2298,8 @@
|
|||||||
0450AC142EF11E4400B6AF06 /* ProductConverter.swift in Sources */,
|
0450AC142EF11E4400B6AF06 /* ProductConverter.swift in Sources */,
|
||||||
0450AC152EF11E4400B6AF06 /* TransactionHistory.swift in Sources */,
|
0450AC152EF11E4400B6AF06 /* TransactionHistory.swift in Sources */,
|
||||||
0450AC162EF11E4400B6AF06 /* StoreKitServiceDelegate.swift in Sources */,
|
0450AC162EF11E4400B6AF06 /* StoreKitServiceDelegate.swift in Sources */,
|
||||||
|
048FFD1D2F277486005D62AE /* KBChatHistoryPageModel.m in Sources */,
|
||||||
|
048FFD1E2F277486005D62AE /* KBChatHistoryModel.m in Sources */,
|
||||||
0450AC172EF11E4400B6AF06 /* StoreKitState.swift in Sources */,
|
0450AC172EF11E4400B6AF06 /* StoreKitState.swift in Sources */,
|
||||||
0450AC1B2EF11E4400B6AF06 /* KBStoreKitBridge.swift in Sources */,
|
0450AC1B2EF11E4400B6AF06 /* KBStoreKitBridge.swift in Sources */,
|
||||||
043FBCD22EAF97630036AFE1 /* KBPermissionViewController.m in Sources */,
|
043FBCD22EAF97630036AFE1 /* KBPermissionViewController.m in Sources */,
|
||||||
@@ -2355,6 +2372,7 @@
|
|||||||
048FFD102F27432D005D62AE /* KBPersonaModel.m in Sources */,
|
048FFD102F27432D005D62AE /* KBPersonaModel.m in Sources */,
|
||||||
048FFD112F27432D005D62AE /* KBPersonaPageModel.m in Sources */,
|
048FFD112F27432D005D62AE /* KBPersonaPageModel.m in Sources */,
|
||||||
0498BD6B2EE025FC006CC1D5 /* KBForgetPwdVC.m in Sources */,
|
0498BD6B2EE025FC006CC1D5 /* KBForgetPwdVC.m in Sources */,
|
||||||
|
048FFD182F2763A5005D62AE /* KBVoiceInputBar.m in Sources */,
|
||||||
046086B12F19239B00757C95 /* SubtitleSync.m in Sources */,
|
046086B12F19239B00757C95 /* SubtitleSync.m in Sources */,
|
||||||
046086B22F19239B00757C95 /* TTSServiceClient.m in Sources */,
|
046086B22F19239B00757C95 /* TTSServiceClient.m in Sources */,
|
||||||
046086B32F19239B00757C95 /* AudioSessionManager.m in Sources */,
|
046086B32F19239B00757C95 /* AudioSessionManager.m in Sources */,
|
||||||
|
|||||||
43
keyBoard/Class/AiTalk/M/KBChatHistoryModel.h
Normal file
43
keyBoard/Class/AiTalk/M/KBChatHistoryModel.h
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
//
|
||||||
|
// KBChatHistoryModel.h
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
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
|
||||||
43
keyBoard/Class/AiTalk/M/KBChatHistoryModel.m
Normal file
43
keyBoard/Class/AiTalk/M/KBChatHistoryModel.m
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
//
|
||||||
|
// KBChatHistoryModel.m
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBChatHistoryModel.h"
|
||||||
|
#import <MJExtension/MJExtension.h>
|
||||||
|
|
||||||
|
@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
|
||||||
45
keyBoard/Class/AiTalk/M/KBChatHistoryPageModel.h
Normal file
45
keyBoard/Class/AiTalk/M/KBChatHistoryPageModel.h
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
//
|
||||||
|
// KBChatHistoryPageModel.h
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#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<KBChatHistoryModel *> *records;
|
||||||
|
|
||||||
|
/// 总记录数
|
||||||
|
@property (nonatomic, assign) NSInteger total;
|
||||||
|
|
||||||
|
/// 每页大小
|
||||||
|
@property (nonatomic, assign) NSInteger size;
|
||||||
|
|
||||||
|
/// 当前页码
|
||||||
|
@property (nonatomic, assign) NSInteger current;
|
||||||
|
|
||||||
|
/// 排序规则
|
||||||
|
@property (nonatomic, strong, nullable) NSArray<KBChatOrderRule *> *orders;
|
||||||
|
|
||||||
|
/// 总页数
|
||||||
|
@property (nonatomic, assign) NSInteger pages;
|
||||||
|
|
||||||
|
/// 是否还有更多数据
|
||||||
|
@property (nonatomic, assign, readonly) BOOL hasMore;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
39
keyBoard/Class/AiTalk/M/KBChatHistoryPageModel.m
Normal file
39
keyBoard/Class/AiTalk/M/KBChatHistoryPageModel.m
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// KBChatHistoryPageModel.m
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBChatHistoryPageModel.h"
|
||||||
|
#import <MJExtension/MJExtension.h>
|
||||||
|
|
||||||
|
@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
|
||||||
408
keyBoard/Class/AiTalk/M/聊天记录Model说明.md
Normal file
408
keyBoard/Class/AiTalk/M/聊天记录Model说明.md
Normal file
@@ -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<KBChatHistoryModel *> *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<KBChatHistoryModel *> *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<KBAiChatMessage *> *)convertHistoryToMessages:(NSArray<KBChatHistoryModel *> *)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 (消息列表)
|
||||||
|
```
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
#import "KBPersonaChatCell.h"
|
#import "KBPersonaChatCell.h"
|
||||||
#import "KBChatTableView.h"
|
#import "KBChatTableView.h"
|
||||||
#import "KBAiChatMessage.h"
|
#import "KBAiChatMessage.h"
|
||||||
|
#import "KBChatHistoryPageModel.h"
|
||||||
|
#import "AiVM.h"
|
||||||
#import <Masonry/Masonry.h>
|
#import <Masonry/Masonry.h>
|
||||||
#import <SDWebImage/SDWebImage.h>
|
#import <SDWebImage/SDWebImage.h>
|
||||||
|
|
||||||
@@ -34,6 +36,18 @@
|
|||||||
/// 是否已加载数据
|
/// 是否已加载数据
|
||||||
@property (nonatomic, assign) BOOL hasLoadedData;
|
@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
|
@end
|
||||||
|
|
||||||
@implementation KBPersonaChatCell
|
@implementation KBPersonaChatCell
|
||||||
@@ -102,7 +116,11 @@
|
|||||||
|
|
||||||
// 重置状态
|
// 重置状态
|
||||||
self.hasLoadedData = NO;
|
self.hasLoadedData = NO;
|
||||||
|
self.isLoading = NO;
|
||||||
|
self.currentPage = 1;
|
||||||
|
self.hasMoreHistory = YES;
|
||||||
self.messages = [NSMutableArray array];
|
self.messages = [NSMutableArray array];
|
||||||
|
self.aiVM = [[AiVM alloc] init];
|
||||||
|
|
||||||
// 设置 UI
|
// 设置 UI
|
||||||
[self.backgroundImageView sd_setImageWithURL:[NSURL URLWithString:persona.coverImageUrl]
|
[self.backgroundImageView sd_setImageWithURL:[NSURL URLWithString:persona.coverImageUrl]
|
||||||
@@ -118,20 +136,113 @@
|
|||||||
#pragma mark - 2:数据加载
|
#pragma mark - 2:数据加载
|
||||||
|
|
||||||
- (void)preloadDataIfNeeded {
|
- (void)preloadDataIfNeeded {
|
||||||
if (self.hasLoadedData) {
|
if (self.hasLoadedData || self.isLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: 这里后续需要用 chatId 去请求聊天记录
|
[self loadChatHistory];
|
||||||
// 目前先添加开场白作为第一条消息
|
}
|
||||||
self.hasLoadedData = YES;
|
|
||||||
|
|
||||||
if (self.persona.introText.length > 0) {
|
- (void)loadChatHistory {
|
||||||
KBAiChatMessage *openingMsg = [KBAiChatMessage assistantMessageWithText:self.persona.introText];
|
if (self.isLoading || !self.hasMoreHistory) {
|
||||||
openingMsg.isComplete = YES;
|
return;
|
||||||
[self.messages addObject:openingMsg];
|
|
||||||
[self.tableView reloadData];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
#pragma mark - UITableViewDataSource
|
||||||
@@ -159,6 +270,17 @@
|
|||||||
return UITableViewAutomaticDimension;
|
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
|
#pragma mark - Lazy Load
|
||||||
|
|
||||||
- (UIImageView *)backgroundImageView {
|
- (UIImageView *)backgroundImageView {
|
||||||
|
|||||||
53
keyBoard/Class/AiTalk/V/KBVoiceInputBar.h
Normal file
53
keyBoard/Class/AiTalk/V/KBVoiceInputBar.h
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//
|
||||||
|
// KBVoiceInputBar.h
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@class KBVoiceInputBar;
|
||||||
|
|
||||||
|
/// 语音输入栏代理
|
||||||
|
@protocol KBVoiceInputBarDelegate <NSObject>
|
||||||
|
|
||||||
|
@optional
|
||||||
|
|
||||||
|
/// 开始录音
|
||||||
|
- (void)voiceInputBarDidBeginRecording:(KBVoiceInputBar *)inputBar;
|
||||||
|
|
||||||
|
/// 结束录音
|
||||||
|
- (void)voiceInputBarDidEndRecording:(KBVoiceInputBar *)inputBar;
|
||||||
|
|
||||||
|
/// 取消录音
|
||||||
|
- (void)voiceInputBarDidCancelRecording:(KBVoiceInputBar *)inputBar;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
/// 底部语音输入栏
|
||||||
|
/// 包含:毛玻璃背景 + 录音按钮
|
||||||
|
@interface KBVoiceInputBar : UIView
|
||||||
|
|
||||||
|
/// 代理
|
||||||
|
@property (nonatomic, weak) id<KBVoiceInputBarDelegate> 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
|
||||||
202
keyBoard/Class/AiTalk/V/KBVoiceInputBar.m
Normal file
202
keyBoard/Class/AiTalk/V/KBVoiceInputBar.m
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
//
|
||||||
|
// KBVoiceInputBar.m
|
||||||
|
// keyBoard
|
||||||
|
//
|
||||||
|
// Created by Kiro on 2026/1/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBVoiceInputBar.h"
|
||||||
|
#import "KBAiRecordButton.h"
|
||||||
|
#import <Masonry/Masonry.h>
|
||||||
|
|
||||||
|
@interface KBVoiceInputBar () <KBAiRecordButtonDelegate>
|
||||||
|
|
||||||
|
/// 毛玻璃背景容器
|
||||||
|
@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
|
||||||
320
keyBoard/Class/AiTalk/V/KBVoiceInputBar_使用说明.md
Normal file
320
keyBoard/Class/AiTalk/V/KBVoiceInputBar_使用说明.md
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
# KBVoiceInputBar 使用说明
|
||||||
|
|
||||||
|
## 📦 组件概述
|
||||||
|
|
||||||
|
`KBVoiceInputBar` 是一个封装好的底部语音输入栏组件,包含:
|
||||||
|
- ✅ 毛玻璃背景(带渐变 mask)
|
||||||
|
- ✅ 状态标签(显示当前状态)
|
||||||
|
- ✅ 录音按钮(支持长按、波形动画)
|
||||||
|
- ✅ 代理回调(开始/结束/取消录音)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 UI 结构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ 毛玻璃背景(渐变透明) │
|
||||||
|
│ ┌─────────────────────┐ │
|
||||||
|
│ │ 状态标签 │ │
|
||||||
|
│ │ "按住按钮开始对话" │ │
|
||||||
|
│ └─────────────────────┘ │
|
||||||
|
│ ┌─────────────────────┐ │
|
||||||
|
│ │ 录音按钮 │ │
|
||||||
|
│ │ [按住说话] │ │
|
||||||
|
│ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 使用方式
|
||||||
|
|
||||||
|
### 1. 导入头文件
|
||||||
|
|
||||||
|
```objc
|
||||||
|
#import "KBVoiceInputBar.h"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 在 VC 中声明属性
|
||||||
|
|
||||||
|
```objc
|
||||||
|
@interface YourViewController () <KBVoiceInputBarDelegate>
|
||||||
|
@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<KBVoiceInputBarDelegate>` | 代理对象 |
|
||||||
|
| `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 () <KBVoiceInputBarDelegate, DeepgramStreamingManagerDelegate>
|
||||||
|
@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`:语音聊天管理器
|
||||||
@@ -8,14 +8,18 @@
|
|||||||
#import "KBAIHomeVC.h"
|
#import "KBAIHomeVC.h"
|
||||||
#import "KBPersonaChatCell.h"
|
#import "KBPersonaChatCell.h"
|
||||||
#import "KBPersonaModel.h"
|
#import "KBPersonaModel.h"
|
||||||
|
#import "KBVoiceInputBar.h"
|
||||||
#import "AiVM.h"
|
#import "AiVM.h"
|
||||||
#import <Masonry/Masonry.h>
|
#import <Masonry/Masonry.h>
|
||||||
|
|
||||||
@interface KBAIHomeVC () <UICollectionViewDelegate, UICollectionViewDataSource>
|
@interface KBAIHomeVC () <UICollectionViewDelegate, UICollectionViewDataSource, KBVoiceInputBarDelegate>
|
||||||
|
|
||||||
/// 人设列表容器
|
/// 人设列表容器
|
||||||
@property (nonatomic, strong) UICollectionView *collectionView;
|
@property (nonatomic, strong) UICollectionView *collectionView;
|
||||||
|
|
||||||
|
/// 底部语音输入栏
|
||||||
|
@property (nonatomic, strong) KBVoiceInputBar *voiceInputBar;
|
||||||
|
|
||||||
/// 人设数据
|
/// 人设数据
|
||||||
@property (nonatomic, strong) NSMutableArray<KBPersonaModel *> *personas;
|
@property (nonatomic, strong) NSMutableArray<KBPersonaModel *> *personas;
|
||||||
|
|
||||||
@@ -67,6 +71,14 @@
|
|||||||
[self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) {
|
[self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
make.edges.equalTo(self.view);
|
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:数据加载
|
#pragma mark - 2:数据加载
|
||||||
@@ -183,6 +195,13 @@
|
|||||||
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
|
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||||
KBPersonaChatCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"KBPersonaChatCell" forIndexPath:indexPath];
|
KBPersonaChatCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"KBPersonaChatCell" forIndexPath:indexPath];
|
||||||
cell.persona = self.personas[indexPath.item];
|
cell.persona = self.personas[indexPath.item];
|
||||||
|
|
||||||
|
// 标记为已预加载
|
||||||
|
[self.preloadedIndexes addObject:@(indexPath.item)];
|
||||||
|
|
||||||
|
// 直接触发预加载
|
||||||
|
[cell preloadDataIfNeeded];
|
||||||
|
|
||||||
return cell;
|
return cell;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,4 +260,44 @@
|
|||||||
return _collectionView;
|
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
|
@end
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
#import <Foundation/Foundation.h>
|
||||||
#import "KBPersonaPageModel.h"
|
#import "KBPersonaPageModel.h"
|
||||||
|
#import "KBChatHistoryPageModel.h"
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
@@ -66,6 +67,16 @@ typedef void (^AiVMAudioURLCompletion)(NSString *_Nullable audioURL,
|
|||||||
pageSize:(NSInteger)pageSize
|
pageSize:(NSInteger)pageSize
|
||||||
completion:(void(^)(KBPersonaPageModel * _Nullable pageModel, NSError * _Nullable error))completion;
|
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
|
@end
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
NS_ASSUME_NONNULL_END
|
||||||
|
|||||||
@@ -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
|
@end
|
||||||
|
|||||||
@@ -4,13 +4,17 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
KBAIHomeVC (人设列表容器)
|
KBAIHomeVC (人设列表容器)
|
||||||
└─ UICollectionView (竖向分页滚动)
|
├─ UICollectionView (竖向分页滚动)
|
||||||
└─ KBPersonaChatCell (每个人设占满屏)
|
│ └─ KBPersonaChatCell (每个人设占满屏)
|
||||||
├─ 背景图(coverImageUrl)
|
│ ├─ 背景图(coverImageUrl)
|
||||||
├─ 头像(avatarUrl)
|
│ ├─ 头像(avatarUrl)
|
||||||
├─ 人设名称(name)
|
│ ├─ 人设名称(name)
|
||||||
├─ 简介(shortDesc)
|
│ ├─ 简介(shortDesc)
|
||||||
└─ UITableView (聊天记录 - 待实现)
|
│ └─ UITableView (聊天记录 - 待实现)
|
||||||
|
└─ KBVoiceInputBar (底部语音输入栏)
|
||||||
|
├─ 毛玻璃背景
|
||||||
|
├─ 状态标签
|
||||||
|
└─ 录音按钮
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user