diff --git a/keyBoard.xcodeproj/project.pbxproj b/keyBoard.xcodeproj/project.pbxproj index 2267584..9374666 100644 --- a/keyBoard.xcodeproj/project.pbxproj +++ b/keyBoard.xcodeproj/project.pbxproj @@ -136,6 +136,10 @@ 04890A042EC0BBBB00FABA60 /* KBCategoryTitleImageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04890A012EC0BBBB00FABA60 /* KBCategoryTitleImageCell.m */; }; 04890A052EC0BBBB00FABA60 /* KBCategoryTitleImageView.m in Sources */ = {isa = PBXBuildFile; fileRef = 04890A032EC0BBBB00FABA60 /* KBCategoryTitleImageView.m */; }; 04890B122EC2F00000FABA60 /* KBMyHeaderView.m in Sources */ = {isa = PBXBuildFile; fileRef = 04890B112EC2F00000FABA60 /* KBMyHeaderView.m */; }; + 048FFD0B2F273BFC005D62AE /* KBAIHomeVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD0A2F273BFC005D62AE /* KBAIHomeVC.m */; }; + 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 */; }; 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 */; }; @@ -518,6 +522,14 @@ 04890A032EC0BBBB00FABA60 /* KBCategoryTitleImageView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBCategoryTitleImageView.m; sourceTree = ""; }; 04890B102EC2F00000FABA60 /* KBMyHeaderView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBMyHeaderView.h; sourceTree = ""; }; 04890B112EC2F00000FABA60 /* KBMyHeaderView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBMyHeaderView.m; sourceTree = ""; }; + 048FFD092F273BFC005D62AE /* KBAIHomeVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAIHomeVC.h; sourceTree = ""; }; + 048FFD0A2F273BFC005D62AE /* KBAIHomeVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAIHomeVC.m; sourceTree = ""; }; + 048FFD0C2F27432D005D62AE /* KBPersonaModel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBPersonaModel.h; sourceTree = ""; }; + 048FFD0D2F27432D005D62AE /* KBPersonaModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBPersonaModel.m; sourceTree = ""; }; + 048FFD0E2F27432D005D62AE /* KBPersonaPageModel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBPersonaPageModel.h; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; @@ -965,6 +977,10 @@ 046086CA2F1A092500757C95 /* KBAIReplyModel.m */, 04E039502F2387D2002CA5A0 /* KBAiChatMessage.h */, 04E039512F2387D2002CA5A0 /* KBAiChatMessage.m */, + 048FFD0C2F27432D005D62AE /* KBPersonaModel.h */, + 048FFD0D2F27432D005D62AE /* KBPersonaModel.m */, + 048FFD0E2F27432D005D62AE /* KBPersonaPageModel.h */, + 048FFD0F2F27432D005D62AE /* KBPersonaPageModel.m */, ); path = M; sourceTree = ""; @@ -997,6 +1013,8 @@ 04E039482F236E75002CA5A0 /* KBChatTimeCell.m */, 04E039492F236E75002CA5A0 /* KBChatUserMessageCell.h */, 04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */, + 048FFD122F274342005D62AE /* KBPersonaChatCell.h */, + 048FFD132F274342005D62AE /* KBPersonaChatCell.m */, ); path = V; sourceTree = ""; @@ -1006,6 +1024,8 @@ children = ( 046086712F191B6900757C95 /* KBAiMainVC.h */, 046086722F191B6900757C95 /* KBAiMainVC.m */, + 048FFD092F273BFC005D62AE /* KBAIHomeVC.h */, + 048FFD0A2F273BFC005D62AE /* KBAIHomeVC.m */, ); path = VC; sourceTree = ""; @@ -2241,6 +2261,7 @@ 04FC95E92EB23B67007BD342 /* KBNetworkManager.m in Sources */, 04FC95D22EB1E7AE007BD342 /* MyVC.m in Sources */, 04286A032ECB0A1600CE730C /* KBSexSelVC.m in Sources */, + 048FFD0B2F273BFC005D62AE /* KBAIHomeVC.m in Sources */, 046086CC2F1A092500757C95 /* KBAIReplyModel.m in Sources */, 046086CD2F1A092500757C95 /* KBAICommentModel.m in Sources */, 04791F8F2ED469C0004E8522 /* KBHostAppLauncher.m in Sources */, @@ -2289,6 +2310,7 @@ 04E038E92F20E877002CA5A0 /* DeepgramStreamingManager.m in Sources */, 048908E32EBF821700FABA60 /* KBSkinDetailVC.m in Sources */, 0477BDF32EBB7B850055D639 /* KBDirectionIndicatorView.m in Sources */, + 048FFD142F274342005D62AE /* KBPersonaChatCell.m in Sources */, 049FB21A2EC20A9E00FAB05D /* KBMyKeyBoardVC.m in Sources */, 049FB22C2EC31F8800FAB05D /* KBGenderPickerPopView.m in Sources */, 048908D22EBF611D00FABA60 /* KBHistoryMoreCell.m in Sources */, @@ -2330,6 +2352,8 @@ 04FC970E2EB334F8007BD342 /* UIImageView+KBWebImage.m in Sources */, 049FB2232EC311F900FAB05D /* KBPersonInfoVC.m in Sources */, 04E038EF2F21F0EC002CA5A0 /* AiVM.m in Sources */, + 048FFD102F27432D005D62AE /* KBPersonaModel.m in Sources */, + 048FFD112F27432D005D62AE /* KBPersonaPageModel.m in Sources */, 0498BD6B2EE025FC006CC1D5 /* KBForgetPwdVC.m in Sources */, 046086B12F19239B00757C95 /* SubtitleSync.m in Sources */, 046086B22F19239B00757C95 /* TTSServiceClient.m in Sources */, diff --git a/keyBoard/Class/AiTalk/M/KBPersonaModel.h b/keyBoard/Class/AiTalk/M/KBPersonaModel.h new file mode 100644 index 0000000..85d6b0e --- /dev/null +++ b/keyBoard/Class/AiTalk/M/KBPersonaModel.h @@ -0,0 +1,96 @@ +// +// KBPersonaModel.h +// keyBoard +// +// Created by Kiro on 2026/1/26. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// 人设状态 +typedef NS_ENUM(NSInteger, KBPersonaStatus) { + KBPersonaStatusDisabled = 0, // 禁用 + KBPersonaStatusEnabled = 1 // 启用 +}; + +/// 人设可见性 +typedef NS_ENUM(NSInteger, KBPersonaVisibility) { + KBPersonaVisibilityPrivate = 0, // 私有 + KBPersonaVisibilityPublic = 1 // 公开 +}; + +/// 人设模型 +@interface KBPersonaModel : NSObject + +/// 人设 ID +@property (nonatomic, assign) NSInteger personaId; + +/// 人设名称 +@property (nonatomic, copy) NSString *name; + +/// 头像 URL +@property (nonatomic, copy) NSString *avatarUrl; + +/// 封面图 URL +@property (nonatomic, copy) NSString *coverImageUrl; + +/// 性别 +@property (nonatomic, copy) NSString *gender; + +/// 年龄范围 +@property (nonatomic, copy) NSString *ageRange; + +/// 简短描述 +@property (nonatomic, copy) NSString *shortDesc; + +/// 介绍文本 +@property (nonatomic, copy) NSString *introText; + +/// 性格标签(逗号分隔的字符串) +@property (nonatomic, copy) NSString *personalityTags; + +/// 说话风格 +@property (nonatomic, copy) NSString *speakingStyle; + +/// 系统提示词 +@property (nonatomic, copy) NSString *systemPrompt; + +/// 状态(0-禁用,1-启用) +@property (nonatomic, assign) KBPersonaStatus status; + +/// 可见性(0-私有,1-公开) +@property (nonatomic, assign) KBPersonaVisibility visibility; + +/// 排序顺序 +@property (nonatomic, assign) NSInteger sortOrder; + +/// 热度分数 +@property (nonatomic, assign) NSInteger popularityScore; + +/// 创建时间 +@property (nonatomic, copy) NSString *createdAt; + +/// 更新时间 +@property (nonatomic, copy) NSString *updatedAt; + +#pragma mark - 扩展属性 + +/// 性格标签数组(从 personalityTags 解析) +@property (nonatomic, strong, readonly) NSArray *tagsArray; + +/// 是否启用 +@property (nonatomic, assign, readonly) BOOL isEnabled; + +/// 是否公开 +@property (nonatomic, assign, readonly) BOOL isPublic; + +/// 开场白 +@property (nonatomic, copy) NSString *prologue; +/// 开场白URL +@property (nonatomic, copy) NSString *prologueAudio; + +@end + +NS_ASSUME_NONNULL_END diff --git a/keyBoard/Class/AiTalk/M/KBPersonaModel.m b/keyBoard/Class/AiTalk/M/KBPersonaModel.m new file mode 100644 index 0000000..af2b5db --- /dev/null +++ b/keyBoard/Class/AiTalk/M/KBPersonaModel.m @@ -0,0 +1,68 @@ +// +// KBPersonaModel.m +// keyBoard +// +// Created by Kiro on 2026/1/26. +// + +#import "KBPersonaModel.h" +#import + +@implementation KBPersonaModel + +#pragma mark - MJExtension 配置 + +// 字段映射 ++ (NSDictionary *)mj_replacedKeyFromPropertyName { + return @{ + @"personaId": @"id" + }; +} + +// 转换完成后的处理 +- (void)mj_keyValuesDidFinishConvertingToObject { + // 容错处理:如果字段为 null,设置默认值 + if (!self.name) self.name = @""; + if (!self.avatarUrl) self.avatarUrl = @""; + if (!self.coverImageUrl) self.coverImageUrl = @""; + if (!self.gender) self.gender = @""; + if (!self.ageRange) self.ageRange = @""; + if (!self.shortDesc) self.shortDesc = @""; + if (!self.introText) self.introText = @""; + if (!self.personalityTags) self.personalityTags = @""; + if (!self.speakingStyle) self.speakingStyle = @""; + if (!self.systemPrompt) self.systemPrompt = @""; + if (!self.createdAt) self.createdAt = @""; + if (!self.updatedAt) self.updatedAt = @""; +} + +#pragma mark - 扩展属性 + +- (NSArray *)tagsArray { + if (self.personalityTags.length == 0) { + return @[]; + } + // 去除空格并分割 + NSString *trimmed = [self.personalityTags stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + NSArray *tags = [trimmed componentsSeparatedByString:@","]; + + // 过滤空字符串 + NSMutableArray *result = [NSMutableArray array]; + for (NSString *tag in tags) { + NSString *trimmedTag = [tag stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (trimmedTag.length > 0) { + [result addObject:trimmedTag]; + } + } + return result; +} + +- (BOOL)isEnabled { + return self.status == KBPersonaStatusEnabled; +} + +- (BOOL)isPublic { + return self.visibility == KBPersonaVisibilityPublic; +} + +@end diff --git a/keyBoard/Class/AiTalk/M/KBPersonaPageModel.h b/keyBoard/Class/AiTalk/M/KBPersonaPageModel.h new file mode 100644 index 0000000..22084a9 --- /dev/null +++ b/keyBoard/Class/AiTalk/M/KBPersonaPageModel.h @@ -0,0 +1,45 @@ +// +// KBPersonaPageModel.h +// keyBoard +// +// Created by Kiro on 2026/1/26. +// + +#import +#import "KBPersonaModel.h" + +NS_ASSUME_NONNULL_BEGIN + +/// 排序规则 +@interface KBOrderRule : NSObject +@property (nonatomic, copy) NSString *column; // 排序字段 +@property (nonatomic, assign) BOOL asc; // 是否升序 +@end + +/// 分页数据模型 +@interface KBPersonaPageModel : 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/KBPersonaPageModel.m b/keyBoard/Class/AiTalk/M/KBPersonaPageModel.m new file mode 100644 index 0000000..6f7c467 --- /dev/null +++ b/keyBoard/Class/AiTalk/M/KBPersonaPageModel.m @@ -0,0 +1,39 @@ +// +// KBPersonaPageModel.m +// keyBoard +// +// Created by Kiro on 2026/1/26. +// + +#import "KBPersonaPageModel.h" +#import + +@implementation KBOrderRule +@end + +@implementation KBPersonaPageModel + +#pragma mark - MJExtension 配置 + +// 数组内元素类型 ++ (NSDictionary *)mj_objectClassInArray { + return @{ + @"records": [KBPersonaModel class], + @"orders": [KBOrderRule 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/V/KBPersonaChatCell.h b/keyBoard/Class/AiTalk/V/KBPersonaChatCell.h new file mode 100644 index 0000000..2e12e01 --- /dev/null +++ b/keyBoard/Class/AiTalk/V/KBPersonaChatCell.h @@ -0,0 +1,24 @@ +// +// KBPersonaChatCell.h +// keyBoard +// +// Created by Kiro on 2026/1/26. +// + +#import +#import "KBPersonaModel.h" + +NS_ASSUME_NONNULL_BEGIN + +/// 人设聊天 Cell +@interface KBPersonaChatCell : UICollectionViewCell + +/// 人设数据 +@property (nonatomic, strong) KBPersonaModel *persona; + +/// 预加载数据 +- (void)preloadDataIfNeeded; + +@end + +NS_ASSUME_NONNULL_END diff --git a/keyBoard/Class/AiTalk/V/KBPersonaChatCell.m b/keyBoard/Class/AiTalk/V/KBPersonaChatCell.m new file mode 100644 index 0000000..160090a --- /dev/null +++ b/keyBoard/Class/AiTalk/V/KBPersonaChatCell.m @@ -0,0 +1,224 @@ +// +// KBPersonaChatCell.m +// keyBoard +// +// Created by Kiro on 2026/1/26. +// + +#import "KBPersonaChatCell.h" +#import "KBChatTableView.h" +#import "KBAiChatMessage.h" +#import +#import + +@interface KBPersonaChatCell () + +/// 背景图 +@property (nonatomic, strong) UIImageView *backgroundImageView; + +/// 头像 +@property (nonatomic, strong) UIImageView *avatarImageView; + +/// 人设名称 +@property (nonatomic, strong) UILabel *nameLabel; + +/// 开场白 +@property (nonatomic, strong) UILabel *openingLabel; + +/// 聊天列表 +@property (nonatomic, strong) UITableView *tableView; + +/// 聊天消息 +@property (nonatomic, strong) NSMutableArray *messages; + +/// 是否已加载数据 +@property (nonatomic, assign) BOOL hasLoadedData; + +@end + +@implementation KBPersonaChatCell + +#pragma mark - Lifecycle + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + [self setupUI]; + } + return self; +} + +#pragma mark - 1:控件初始化 + +- (void)setupUI { + // 背景图 + [self.contentView addSubview:self.backgroundImageView]; + [self.backgroundImageView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.contentView); + }]; + + // 半透明遮罩 + UIView *maskView = [[UIView alloc] init]; + maskView.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.3]; + [self.contentView addSubview:maskView]; + [maskView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.contentView); + }]; + + // 头像 + [self.contentView addSubview:self.avatarImageView]; + [self.avatarImageView mas_makeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(self.contentView).offset(80); + make.centerX.equalTo(self.contentView); + make.size.mas_equalTo(CGSizeMake(80, 80)); + }]; + + // 人设名称 + [self.contentView addSubview:self.nameLabel]; + [self.nameLabel mas_makeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(self.avatarImageView.mas_bottom).offset(12); + make.centerX.equalTo(self.contentView); + }]; + + // 开场白 + [self.contentView addSubview:self.openingLabel]; + [self.openingLabel mas_makeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(self.nameLabel.mas_bottom).offset(8); + make.left.equalTo(self.contentView).offset(40); + make.right.equalTo(self.contentView).offset(-40); + }]; + + // 聊天列表 + [self.contentView addSubview:self.tableView]; + [self.tableView mas_makeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(self.openingLabel.mas_bottom).offset(30); + make.left.right.bottom.equalTo(self.contentView); + }]; +} + +#pragma mark - Setter + +- (void)setPersona:(KBPersonaModel *)persona { + _persona = persona; + + // 重置状态 + self.hasLoadedData = NO; + self.messages = [NSMutableArray array]; + + // 设置 UI + [self.backgroundImageView sd_setImageWithURL:[NSURL URLWithString:persona.coverImageUrl] + placeholderImage:[UIImage imageNamed:@"placeholder_bg"]]; + [self.avatarImageView sd_setImageWithURL:[NSURL URLWithString:persona.avatarUrl] + placeholderImage:[UIImage imageNamed:@"placeholder_avatar"]]; + self.nameLabel.text = persona.name; + self.openingLabel.text = persona.shortDesc.length > 0 ? persona.shortDesc : persona.introText; + + [self.tableView reloadData]; +} + +#pragma mark - 2:数据加载 + +- (void)preloadDataIfNeeded { + if (self.hasLoadedData) { + 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]; + } +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + return self.messages.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell"]; + if (!cell) { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"Cell"]; + cell.backgroundColor = [UIColor clearColor]; + cell.textLabel.textColor = [UIColor whiteColor]; + cell.textLabel.numberOfLines = 0; + } + + KBAiChatMessage *message = self.messages[indexPath.row]; + cell.textLabel.text = message.text; + + return cell; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { + return UITableViewAutomaticDimension; +} + +#pragma mark - Lazy Load + +- (UIImageView *)backgroundImageView { + if (!_backgroundImageView) { + _backgroundImageView = [[UIImageView alloc] init]; + _backgroundImageView.contentMode = UIViewContentModeScaleAspectFill; + _backgroundImageView.clipsToBounds = YES; + } + return _backgroundImageView; +} + +- (UIImageView *)avatarImageView { + if (!_avatarImageView) { + _avatarImageView = [[UIImageView alloc] init]; + _avatarImageView.contentMode = UIViewContentModeScaleAspectFill; + _avatarImageView.layer.cornerRadius = 40; + _avatarImageView.layer.borderWidth = 3; + _avatarImageView.layer.borderColor = [UIColor whiteColor].CGColor; + _avatarImageView.clipsToBounds = YES; + } + return _avatarImageView; +} + +- (UILabel *)nameLabel { + if (!_nameLabel) { + _nameLabel = [[UILabel alloc] init]; + _nameLabel.font = [UIFont boldSystemFontOfSize:20]; + _nameLabel.textColor = [UIColor whiteColor]; + _nameLabel.textAlignment = NSTextAlignmentCenter; + } + return _nameLabel; +} + +- (UILabel *)openingLabel { + if (!_openingLabel) { + _openingLabel = [[UILabel alloc] init]; + _openingLabel.font = [UIFont systemFontOfSize:14]; + _openingLabel.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.9]; + _openingLabel.textAlignment = NSTextAlignmentCenter; + _openingLabel.numberOfLines = 2; + } + return _openingLabel; +} + +- (UITableView *)tableView { + if (!_tableView) { + _tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain]; + _tableView.delegate = self; + _tableView.dataSource = self; + _tableView.separatorStyle = UITableViewCellSeparatorStyleNone; + _tableView.backgroundColor = [UIColor clearColor]; + _tableView.showsVerticalScrollIndicator = NO; + _tableView.estimatedRowHeight = 60; + _tableView.rowHeight = UITableViewAutomaticDimension; + + if (@available(iOS 11.0, *)) { + _tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + } + } + return _tableView; +} + +@end diff --git a/keyBoard/Class/AiTalk/VC/KBAIHomeVC.h b/keyBoard/Class/AiTalk/VC/KBAIHomeVC.h new file mode 100644 index 0000000..0fb3bed --- /dev/null +++ b/keyBoard/Class/AiTalk/VC/KBAIHomeVC.h @@ -0,0 +1,16 @@ +// +// KBAIHomeVC.h +// keyBoard +// +// Created by Mac on 2026/1/26. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface KBAIHomeVC : BaseViewController + +@end + +NS_ASSUME_NONNULL_END diff --git a/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m b/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m new file mode 100644 index 0000000..7363b27 --- /dev/null +++ b/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m @@ -0,0 +1,244 @@ +// +// KBAIHomeVC.m +// keyBoard +// +// Created by Mac on 2026/1/26. +// + +#import "KBAIHomeVC.h" +#import "KBPersonaChatCell.h" +#import "KBPersonaModel.h" +#import "AiVM.h" +#import + +@interface KBAIHomeVC () + +/// 人设列表容器 +@property (nonatomic, strong) UICollectionView *collectionView; + +/// 人设数据 +@property (nonatomic, strong) NSMutableArray *personas; + +/// 当前页码 +@property (nonatomic, assign) NSInteger currentPage; + +/// 是否还有更多数据 +@property (nonatomic, assign) BOOL hasMore; + +/// 是否正在加载 +@property (nonatomic, assign) BOOL isLoading; + +/// 当前显示的索引 +@property (nonatomic, assign) NSInteger currentIndex; + +/// 已预加载的索引集合 +@property (nonatomic, strong) NSMutableSet *preloadedIndexes; + +/// AiVM 实例 +@property (nonatomic, strong) AiVM *aiVM; + +@end + +@implementation KBAIHomeVC + +#pragma mark - Lifecycle + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = [UIColor whiteColor]; + self.kb_navView.hidden = true; + // 初始化数据 + self.personas = [NSMutableArray array]; + self.currentPage = 1; + self.hasMore = YES; + self.isLoading = NO; + self.currentIndex = 0; + self.preloadedIndexes = [NSMutableSet set]; + self.aiVM = [[AiVM alloc] init]; + + [self setupUI]; + [self loadPersonas]; +} + +#pragma mark - 1:控件初始化 + +- (void)setupUI { + [self.view addSubview:self.collectionView]; + [self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.view); + }]; +} + +#pragma mark - 2:数据加载 + +- (void)loadPersonas { + if (self.isLoading) { + return; + } + + self.isLoading = YES; + + __weak typeof(self) weakSelf = self; + [self.aiVM fetchPersonasWithPageNum:self.currentPage + pageSize:10 + completion:^(KBPersonaPageModel * _Nullable pageModel, NSError * _Nullable error) { + weakSelf.isLoading = NO; + + if (error) { + NSLog(@"加载人设列表失败:%@", error.localizedDescription); + // TODO: 显示错误提示 + return; + } + + if (!pageModel || !pageModel.records) { + NSLog(@"人设列表数据为空"); + return; + } + + // 追加数据 + [weakSelf.personas addObjectsFromArray:pageModel.records]; + weakSelf.hasMore = pageModel.hasMore; + + // 刷新 UI + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf.collectionView reloadData]; + + // 首次加载,预加载前 3 个 + if (weakSelf.currentPage == 1) { + [weakSelf preloadDataForIndexes:@[@0, @1, @2]]; + } + }); + + NSLog(@"加载成功:当前 %ld 条,总共 %ld 条,还有更多:%@", + weakSelf.personas.count, + pageModel.total, + pageModel.hasMore ? @"是" : @"否"); + }]; +} + +- (void)loadMorePersonas { + if (!self.hasMore || self.isLoading) { + return; + } + + self.currentPage++; + [self loadPersonas]; +} + +#pragma mark - 3:预加载逻辑 + +- (void)preloadAdjacentCellsForIndex:(NSInteger)index { + if (index < 0 || index >= self.personas.count) { + return; + } + + NSMutableArray *indexesToPreload = [NSMutableArray array]; + + // 上一个 + if (index > 0) { + [indexesToPreload addObject:@(index - 1)]; + } + + // 当前 + [indexesToPreload addObject:@(index)]; + + // 下一个 + if (index < self.personas.count - 1) { + [indexesToPreload addObject:@(index + 1)]; + } + + [self preloadDataForIndexes:indexesToPreload]; +} + +- (void)preloadDataForIndexes:(NSArray *)indexes { + for (NSNumber *indexNum in indexes) { + if ([self.preloadedIndexes containsObject:indexNum]) { + continue; + } + + [self.preloadedIndexes addObject:indexNum]; + + NSInteger index = [indexNum integerValue]; + if (index >= self.personas.count) { + continue; + } + + NSIndexPath *indexPath = [NSIndexPath indexPathForItem:index inSection:0]; + KBPersonaChatCell *cell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:indexPath]; + + if (cell) { + [cell preloadDataIfNeeded]; + } + + NSLog(@"预加载第 %ld 个人设", (long)index); + } +} + +#pragma mark - UICollectionViewDataSource + +- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { + return self.personas.count; +} + +- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { + KBPersonaChatCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"KBPersonaChatCell" forIndexPath:indexPath]; + cell.persona = self.personas[indexPath.item]; + return cell; +} + +#pragma mark - UIScrollViewDelegate + +- (void)scrollViewDidScroll:(UIScrollView *)scrollView { + CGFloat pageHeight = scrollView.bounds.size.height; + CGFloat offsetY = scrollView.contentOffset.y; + NSInteger currentPage = offsetY / pageHeight; + + // 滑动超过 30% 就预加载 + if (fmod(offsetY, pageHeight) > pageHeight * 0.3) { + [self preloadAdjacentCellsForIndex:currentPage + 1]; + } else { + [self preloadAdjacentCellsForIndex:currentPage]; + } + + // 接近底部时加载更多 + if (offsetY + scrollView.bounds.size.height >= scrollView.contentSize.height - pageHeight) { + [self loadMorePersonas]; + } +} + +- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { + CGFloat pageHeight = scrollView.bounds.size.height; + NSInteger currentPage = scrollView.contentOffset.y / pageHeight; + self.currentIndex = currentPage; + + if (currentPage < self.personas.count) { + NSLog(@"当前在第 %ld 个人设:%@", (long)currentPage, self.personas[currentPage].name); + } +} + +#pragma mark - Lazy Load + +- (UICollectionView *)collectionView { + if (!_collectionView) { + UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init]; + layout.scrollDirection = UICollectionViewScrollDirectionVertical; + layout.minimumLineSpacing = 0; + layout.minimumInteritemSpacing = 0; + layout.itemSize = [UIScreen mainScreen].bounds.size; + + _collectionView = [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:layout]; + _collectionView.pagingEnabled = YES; + _collectionView.showsVerticalScrollIndicator = NO; + _collectionView.backgroundColor = [UIColor whiteColor]; + _collectionView.delegate = self; + _collectionView.dataSource = self; + [_collectionView registerClass:[KBPersonaChatCell class] forCellWithReuseIdentifier:@"KBPersonaChatCell"]; + + if (@available(iOS 11.0, *)) { + _collectionView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + } + } + return _collectionView; +} + +@end diff --git a/keyBoard/Class/AiTalk/VM/AiVM.h b/keyBoard/Class/AiTalk/VM/AiVM.h index eba44b2..fe98716 100644 --- a/keyBoard/Class/AiTalk/VM/AiVM.h +++ b/keyBoard/Class/AiTalk/VM/AiVM.h @@ -6,6 +6,7 @@ // #import +#import "KBPersonaPageModel.h" NS_ASSUME_NONNULL_BEGIN @@ -55,6 +56,16 @@ typedef void (^AiVMAudioURLCompletion)(NSString *_Nullable audioURL, - (void)requestAudioWithAudioId:(NSString *)audioId completion:(AiVMAudioURLCompletion)completion; +#pragma mark - 人设相关接口 + +/// 分页查询人设列表 +/// @param pageNum 页码(从 1 开始) +/// @param pageSize 每页大小 +/// @param completion 完成回调 +- (void)fetchPersonasWithPageNum:(NSInteger)pageNum + pageSize:(NSInteger)pageSize + completion:(void(^)(KBPersonaPageModel * _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 a6f02d7..2e37e63 100644 --- a/keyBoard/Class/AiTalk/VM/AiVM.m +++ b/keyBoard/Class/AiTalk/VM/AiVM.m @@ -212,4 +212,64 @@ autoShowBusinessError:NO }]; } +#pragma mark - 人设相关接口 + +- (void)fetchPersonasWithPageNum:(NSInteger)pageNum + pageSize:(NSInteger)pageSize + completion:(void (^)(KBPersonaPageModel * _Nullable, NSError * _Nullable))completion { + NSDictionary *params = @{ + @"pageNum": @(pageNum), + @"pageSize": @(pageSize) + }; + + NSLog(@"[AiVM] /ai-companion/page request: %@", params); + [[KBNetworkManager shared] + POST:@"/ai-companion/page" + jsonBody:params + headers:nil +autoShowBusinessError:NO + completion:^(NSDictionary *_Nullable json, + NSURLResponse *_Nullable response, + NSError *_Nullable error) { + if (error) { + NSLog(@"[AiVM] /ai-companion/page failed: %@", error.localizedDescription ?: @""); + if (completion) { + completion(nil, error); + } + return; + } + + NSLog(@"[AiVM] /ai-companion/page 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]]) { + KBPersonaPageModel *pageModel = [KBPersonaPageModel 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 new file mode 100644 index 0000000..8e0e5e5 --- /dev/null +++ b/keyBoard/Class/AiTalk/人设列表实现说明.md @@ -0,0 +1,260 @@ +# 人设列表实现说明 + +## 📦 架构概览 + +``` +KBAIHomeVC (人设列表容器) + └─ UICollectionView (竖向分页滚动) + └─ KBPersonaChatCell (每个人设占满屏) + ├─ 背景图(coverImageUrl) + ├─ 头像(avatarUrl) + ├─ 人设名称(name) + ├─ 简介(shortDesc) + └─ UITableView (聊天记录 - 待实现) +``` + +--- + +## 📂 文件结构 + +### Model 层(keyBoard/Class/AiTalk/M/) +- **KBPersonaModel.h/m**:人设模型 + - 包含:personaId、name、avatarUrl、coverImageUrl、shortDesc、introText 等 + - 扩展属性:tagsArray、isEnabled、isPublic + +- **KBPersonaPageModel.h/m**:分页数据模型 + - 包含:records(人设数组)、total、current、pages、hasMore + +### View 层(keyBoard/Class/AiTalk/V/) +- **KBPersonaChatCell.h/m**:人设聊天 Cell + - 展示:背景图、头像、名称、简介 + - 支持:预加载数据 + +### VM 层(keyBoard/Class/AiTalk/VM/) +- **AiVM.h/m**:网络请求管理 + - 新增接口:`fetchPersonasWithPageNum:pageSize:completion:` + - 请求地址:`POST /ai-companion/page` + +### VC 层(keyBoard/Class/AiTalk/VC/) +- **KBAIHomeVC.h/m**:人设列表容器 + - 功能:分页加载、竖向翻页、预加载相邻 Cell + +--- + +## 🔧 核心功能 + +### 1. 竖向分页滚动 +```objc +// UICollectionView 配置 +layout.scrollDirection = UICollectionViewScrollDirectionVertical; +layout.itemSize = [UIScreen mainScreen].bounds.size; // 每个 cell 占满屏 +_collectionView.pagingEnabled = YES; // 开启分页 +``` + +### 2. 预加载机制 +- **预加载 3 个 Cell**:上一个、当前、下一个 +- **触发时机**:滑动超过 30% 时开始预加载 +- **避免重复**:用 `preloadedIndexes` 记录已加载的索引 + +```objc +- (void)scrollViewDidScroll:(UIScrollView *)scrollView { + CGFloat pageHeight = scrollView.bounds.size.height; + CGFloat offsetY = scrollView.contentOffset.y; + NSInteger currentPage = offsetY / pageHeight; + + // 滑动超过 30% 就预加载 + if (fmod(offsetY, pageHeight) > pageHeight * 0.3) { + [self preloadAdjacentCellsForIndex:currentPage + 1]; + } +} +``` + +### 3. 分页加载 +- **首次加载**:pageNum=1, pageSize=10 +- **加载更多**:接近底部时自动加载下一页 +- **防重复**:用 `isLoading` 标记防止重复请求 + +```objc +- (void)scrollViewDidScroll:(UIScrollView *)scrollView { + // 接近底部时加载更多 + if (offsetY + scrollView.bounds.size.height >= scrollView.contentSize.height - pageHeight) { + [self loadMorePersonas]; + } +} +``` + +--- + +## 🌐 网络请求 + +### 接口地址 +``` +POST /ai-companion/page +``` + +### 请求参数 +```json +{ + "pageNum": 1, + "pageSize": 10 +} +``` + +### 响应格式 +```json +{ + "code": 0, + "message": "success", + "data": { + "records": [ + { + "id": 1, + "name": "温柔小姐姐", + "avatarUrl": "https://...", + "coverImageUrl": "https://...", + "shortDesc": "温柔体贴的聊天伙伴", + "introText": "你好呀,今天过得怎么样?", + "personalityTags": "温柔,体贴,善解人意", + ... + } + ], + "total": 100, + "current": 1, + "pages": 10 + } +} +``` + +### 使用示例 +```objc +[self.aiVM fetchPersonasWithPageNum:1 + pageSize:10 + completion:^(KBPersonaPageModel *pageModel, NSError *error) { + if (error) { + NSLog(@"加载失败:%@", error.localizedDescription); + return; + } + + [self.personas addObjectsFromArray:pageModel.records]; + self.hasMore = pageModel.hasMore; + [self.collectionView reloadData]; +}]; +``` + +--- + +## 🎨 UI 布局 + +### KBPersonaChatCell 布局 +``` +┌─────────────────────────────┐ +│ 背景图(coverImageUrl) │ +│ ┌─────────────────────┐ │ +│ │ 半透明遮罩(黑色 0.3) │ │ +│ │ │ │ +│ │ ┌───┐ │ │ +│ │ │头像│ │ │ +│ │ └───┘ │ │ +│ │ 人设名称 │ │ +│ │ 简短描述 │ │ +│ │ │ │ +│ │ ┌───────────────┐ │ │ +│ │ │ 聊天记录列表 │ │ │ +│ │ │ (待实现) │ │ │ +│ │ └───────────────┘ │ │ +│ └─────────────────────┘ │ +└─────────────────────────────┘ +``` + +--- + +## ✅ 已完成功能 + +1. ✅ Model 层:KBPersonaModel、KBPersonaPageModel +2. ✅ VM 层:AiVM 新增人设列表接口 +3. ✅ View 层:KBPersonaChatCell(基础 UI) +4. ✅ VC 层:KBAIHomeVC(分页加载、竖向翻页、预加载) +5. ✅ MJExtension 配置:JSON 自动转 Model +6. ✅ 懒加载:所有控件使用懒加载 +7. ✅ Masonry 布局:纯代码布局 + +--- + +## 🚧 待实现功能 + +### 1. 聊天记录展示 +- 需要后端提供 `chatId` 字段 +- 用 `chatId` 请求聊天记录接口 +- 在 `KBPersonaChatCell` 的 `tableView` 中展示聊天记录 + +### 2. 聊天功能 +- 点击输入框发送消息 +- 接收 AI 回复 +- 支持语音消息 + +### 3. 错误处理 +- 网络请求失败提示 +- 空数据占位图 +- 加载中状态 + +### 4. 性能优化 +- Cell 高度缓存 +- 图片缓存策略 +- 内存管理(清理不可见 Cell 的数据) + +--- + +## 📝 使用方式 + +### 1. 在其他 VC 中跳转 +```objc +KBAIHomeVC *homeVC = [[KBAIHomeVC alloc] init]; +[self.navigationController pushViewController:homeVC animated:YES]; +``` + +### 2. 作为 TabBar 的一个页面 +```objc +KBAIHomeVC *homeVC = [[KBAIHomeVC alloc] init]; +homeVC.tabBarItem = [[UITabBarItem alloc] initWithTitle:@"AI聊天" + image:[UIImage imageNamed:@"tab_ai"] + selectedImage:[UIImage imageNamed:@"tab_ai_sel"]]; +``` + +--- + +## 🐛 调试日志 + +### 网络请求日志 +``` +[AiVM] /ai-companion/page request: {pageNum: 1, pageSize: 10} +[AiVM] /ai-companion/page response: {...} +加载成功:当前 10 条,总共 100 条,还有更多:是 +``` + +### 预加载日志 +``` +预加载第 0 个人设 +预加载第 1 个人设 +预加载第 2 个人设 +当前在第 1 个人设:温柔小姐姐 +``` + +--- + +## 📌 注意事项 + +1. **接口地址**:需要在 `KBAPI.h` 中定义 `/ai-companion/page` +2. **图片占位图**:需要添加 `placeholder_bg` 和 `placeholder_avatar` 图片资源 +3. **线程安全**:网络回调后需要回到主线程更新 UI +4. **内存管理**:注意使用 `weakSelf` 避免循环引用 +5. **字段兼容**:后端可能返回 `null`,已做容错处理 + +--- + +## 🎯 下一步计划 + +1. 接入真实的聊天记录接口 +2. 实现聊天输入和发送功能 +3. 优化 Cell 的聊天记录展示(使用现有的 KBChatTableView) +4. 添加下拉刷新和上拉加载更多 +5. 添加错误提示和空数据占位图 diff --git a/keyBoard/Class/Base/VC/BaseTabBarController.m b/keyBoard/Class/Base/VC/BaseTabBarController.m index 086f09f..67f56c0 100644 --- a/keyBoard/Class/Base/VC/BaseTabBarController.m +++ b/keyBoard/Class/Base/VC/BaseTabBarController.m @@ -10,6 +10,7 @@ #import "KBAiMainVC.h" #import "KBShopVC.h" #import "MyVC.h" +#import "KBAIHomeVC.h" // #import "KBCommunityVC.h" @@ -51,7 +52,7 @@ selectedImg:@"tab_shop_selected"]; // AI - KBAiMainVC *aiMainVC = [[KBAiMainVC alloc] init]; + KBAIHomeVC *aiMainVC = [[KBAIHomeVC alloc] init]; // community.title = KBLocalized(@"Circle"); BaseNavigationController *navCommunity = [[BaseNavigationController alloc] initWithRootViewController:aiMainVC];