Compare commits
18 Commits
48c90fa0be
...
dev_st_重构A
| Author | SHA1 | Date | |
|---|---|---|---|
| 61095a379f | |||
| 822a814f85 | |||
| 0bd0392191 | |||
| b9663037f5 | |||
| a0923c8572 | |||
| d482cfcb7d | |||
| 9e6d2906f8 | |||
| 6f7bb4f960 | |||
| fa9af5ff1b | |||
| 08628bcd1d | |||
| 19cb29616f | |||
| 6e50cdcd2a | |||
| f1b52151be | |||
| 993ec623af | |||
| 0416a64235 | |||
| 2b75ad90fb | |||
| 0ac9030f80 | |||
| ea9c40f64f |
@@ -78,6 +78,7 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
@property(nonatomic, strong) UIImageView *bgImageView; // 背景图(在底层)
|
||||
@property(nonatomic, strong) UIImageView *personaAvatarImageView; // 语音模式下显示的 persona 小头像
|
||||
@property(nonatomic, strong) UIImageView *personaGrayImageView; // 语音模式下显示的 persona 小头像
|
||||
@property(nonatomic, strong) UIVisualEffectView *personaBlurView; // 语音模式下头像高斯模糊层
|
||||
|
||||
@property(nonatomic, strong) KBChatPanelView *chatPanelView;
|
||||
@property(nonatomic, strong) KBKeyboardSubscriptionView *subscriptionView;
|
||||
@@ -1407,6 +1408,10 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
_personaAvatarImageView.contentMode = UIViewContentModeScaleAspectFill;
|
||||
_personaAvatarImageView.clipsToBounds = YES;
|
||||
_personaAvatarImageView.hidden = YES;
|
||||
[_personaAvatarImageView addSubview:self.personaBlurView];
|
||||
[self.personaBlurView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(_personaAvatarImageView);
|
||||
}];
|
||||
}
|
||||
return _personaAvatarImageView;
|
||||
}
|
||||
@@ -1419,6 +1424,16 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
return _personaGrayImageView;
|
||||
}
|
||||
|
||||
- (UIVisualEffectView *)personaBlurView {
|
||||
if (!_personaBlurView) {
|
||||
UIBlurEffect *effect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleLight];
|
||||
_personaBlurView = [[UIVisualEffectView alloc] initWithEffect:effect];
|
||||
_personaBlurView.hidden = YES;
|
||||
_personaBlurView.userInteractionEnabled = NO;
|
||||
}
|
||||
return _personaBlurView;
|
||||
}
|
||||
|
||||
#pragma mark - Persona Avatar
|
||||
|
||||
/// 从 AppGroup 读取选中的 persona 信息
|
||||
@@ -1475,6 +1490,7 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
if (image) {
|
||||
self.personaAvatarImageView.image = image;
|
||||
self.personaAvatarImageView.hidden = NO;
|
||||
self.personaBlurView.hidden = NO;
|
||||
NSLog(@"[Keyboard] persona 封面图加载成功");
|
||||
} else {
|
||||
NSLog(@"[Keyboard] persona 封面图加载失败");
|
||||
@@ -1485,6 +1501,7 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
- (void)kb_hidePersonaAvatar {
|
||||
self.personaAvatarImageView.hidden = YES;
|
||||
self.personaAvatarImageView.image = nil;
|
||||
self.personaBlurView.hidden = YES;
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
@@ -20,6 +20,10 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
+ (NSString *)signWithParams:(NSDictionary<NSString *, NSString *> *)params
|
||||
secret:(NSString *)secret;
|
||||
|
||||
/// 获取签名原始拼接字符串(HMAC 前的明文)
|
||||
+ (NSString *)signSourceStringWithParams:(NSDictionary<NSString *, NSString *> *)params
|
||||
secret:(NSString *)secret;
|
||||
|
||||
/// 秒级时间戳(字符串)
|
||||
+ (NSString *)currentTimestamp;
|
||||
|
||||
@@ -29,4 +33,3 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
|
||||
@@ -12,10 +12,16 @@
|
||||
|
||||
+ (NSString *)urlEncode:(NSString *)value {
|
||||
if (!value) return @"";
|
||||
// 和 Swift 里的 .urlQueryAllowed 类似
|
||||
NSCharacterSet *allowed = [NSCharacterSet URLQueryAllowedCharacterSet];
|
||||
// 按 application/x-www-form-urlencoded 规则编码(更贴近后端常见实现)
|
||||
NSMutableCharacterSet *allowed = [NSMutableCharacterSet alphanumericCharacterSet];
|
||||
[allowed addCharactersInString:@"-._*"];
|
||||
NSString *encoded = [value stringByAddingPercentEncodingWithAllowedCharacters:allowed];
|
||||
return encoded ?: value;
|
||||
if (!encoded) {
|
||||
return value;
|
||||
}
|
||||
// 空格改为 +(URLEncoder 风格)
|
||||
encoded = [encoded stringByReplacingOccurrencesOfString:@"%20" withString:@"+"];
|
||||
return encoded;
|
||||
}
|
||||
|
||||
+ (NSString *)hmacSHA256:(NSString *)data secret:(NSString *)secret {
|
||||
@@ -41,6 +47,12 @@
|
||||
|
||||
+ (NSString *)signWithParams:(NSDictionary<NSString *, NSString *> *)params
|
||||
secret:(NSString *)secret {
|
||||
NSString *dataString = [self signSourceStringWithParams:params secret:secret];
|
||||
return [self hmacSHA256:dataString secret:secret];
|
||||
}
|
||||
|
||||
+ (NSString *)signSourceStringWithParams:(NSDictionary<NSString *, NSString *> *)params
|
||||
secret:(NSString *)secret {
|
||||
|
||||
// 1. 过滤空值 & sign 自身
|
||||
NSMutableDictionary<NSString *, NSString *> *filtered = [NSMutableDictionary dictionary];
|
||||
@@ -62,15 +74,11 @@
|
||||
[components addObject:part];
|
||||
}
|
||||
|
||||
NSString *encodedSecret = [self urlEncode:secret];
|
||||
NSString *encodedSecret = [self urlEncode:secret ?: @""];
|
||||
NSString *secretPart = [NSString stringWithFormat:@"secret=%@", encodedSecret];
|
||||
[components addObject:secretPart];
|
||||
|
||||
NSString *dataString = [components componentsJoinedByString:@"&"];
|
||||
|
||||
// 4. HMAC-SHA256
|
||||
NSString *sign = [self hmacSHA256:dataString secret:secret];
|
||||
return sign;
|
||||
return [components componentsJoinedByString:@"&"];
|
||||
}
|
||||
|
||||
+ (NSString *)currentTimestamp {
|
||||
@@ -89,4 +97,3 @@
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
@@ -229,6 +229,8 @@
|
||||
04E038E92F20E877002CA5A0 /* DeepgramStreamingManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */; };
|
||||
04E038EF2F21F0EC002CA5A0 /* AiVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038EE2F21F0EC002CA5A0 /* AiVM.m */; };
|
||||
04E0394B2F236E75002CA5A0 /* KBChatUserMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */; };
|
||||
0F2A10032F3C0001002CA5A0 /* KBChatMessageActionPopView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0F2A10022F3C0001002CA5A0 /* KBChatMessageActionPopView.m */; };
|
||||
0F2A10132F3C0002002CA5A0 /* KBAIPersonaSidebarView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0F2A10122F3C0002002CA5A0 /* KBAIPersonaSidebarView.m */; };
|
||||
04E0394C2F236E75002CA5A0 /* KBChatTimeCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039482F236E75002CA5A0 /* KBChatTimeCell.m */; };
|
||||
04E0394D2F236E75002CA5A0 /* KBChatAssistantMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039432F236E75002CA5A0 /* KBChatAssistantMessageCell.m */; };
|
||||
04E0394E2F236E75002CA5A0 /* KBChatTableView.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039452F236E75002CA5A0 /* KBChatTableView.m */; };
|
||||
@@ -562,6 +564,8 @@
|
||||
048FFD1C2F277486005D62AE /* KBChatHistoryPageModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatHistoryPageModel.m; sourceTree = "<group>"; };
|
||||
048FFD222F28A836005D62AE /* KBChatLimitPopView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatLimitPopView.h; sourceTree = "<group>"; };
|
||||
048FFD232F28A836005D62AE /* KBChatLimitPopView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatLimitPopView.m; sourceTree = "<group>"; };
|
||||
0F2A10112F3C0002002CA5A0 /* KBAIPersonaSidebarView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAIPersonaSidebarView.h; sourceTree = "<group>"; };
|
||||
0F2A10122F3C0002002CA5A0 /* KBAIPersonaSidebarView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAIPersonaSidebarView.m; sourceTree = "<group>"; };
|
||||
048FFD252F28C6CF005D62AE /* KBImagePositionButton.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBImagePositionButton.h; sourceTree = "<group>"; };
|
||||
048FFD262F28C6CF005D62AE /* KBImagePositionButton.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBImagePositionButton.m; sourceTree = "<group>"; };
|
||||
048FFD282F28E99A005D62AE /* KBCommentModel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBCommentModel.h; sourceTree = "<group>"; };
|
||||
@@ -720,6 +724,8 @@
|
||||
04E039482F236E75002CA5A0 /* KBChatTimeCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatTimeCell.m; sourceTree = "<group>"; };
|
||||
04E039492F236E75002CA5A0 /* KBChatUserMessageCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatUserMessageCell.h; sourceTree = "<group>"; };
|
||||
04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatUserMessageCell.m; sourceTree = "<group>"; };
|
||||
0F2A10012F3C0001002CA5A0 /* KBChatMessageActionPopView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatMessageActionPopView.h; sourceTree = "<group>"; };
|
||||
0F2A10022F3C0001002CA5A0 /* KBChatMessageActionPopView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatMessageActionPopView.m; sourceTree = "<group>"; };
|
||||
04E039502F2387D2002CA5A0 /* KBAiChatMessage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAiChatMessage.h; sourceTree = "<group>"; };
|
||||
04E039512F2387D2002CA5A0 /* KBAiChatMessage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAiChatMessage.m; sourceTree = "<group>"; };
|
||||
04E0B1002F300001002CA5A0 /* KBVoiceToTextManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceToTextManager.h; sourceTree = "<group>"; };
|
||||
@@ -1431,6 +1437,8 @@
|
||||
04E039452F236E75002CA5A0 /* KBChatTableView.m */,
|
||||
04E039492F236E75002CA5A0 /* KBChatUserMessageCell.h */,
|
||||
04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */,
|
||||
0F2A10012F3C0001002CA5A0 /* KBChatMessageActionPopView.h */,
|
||||
0F2A10022F3C0001002CA5A0 /* KBChatMessageActionPopView.m */,
|
||||
04E039422F236E75002CA5A0 /* KBChatAssistantMessageCell.h */,
|
||||
04E039432F236E75002CA5A0 /* KBChatAssistantMessageCell.m */,
|
||||
04E039472F236E75002CA5A0 /* KBChatTimeCell.h */,
|
||||
@@ -1444,6 +1452,8 @@
|
||||
children = (
|
||||
048FFD222F28A836005D62AE /* KBChatLimitPopView.h */,
|
||||
048FFD232F28A836005D62AE /* KBChatLimitPopView.m */,
|
||||
0F2A10112F3C0002002CA5A0 /* KBAIPersonaSidebarView.h */,
|
||||
0F2A10122F3C0002002CA5A0 /* KBAIPersonaSidebarView.m */,
|
||||
);
|
||||
path = PopView;
|
||||
sourceTree = "<group>";
|
||||
@@ -2413,6 +2423,8 @@
|
||||
046086D82F1A093400757C95 /* KBAIReplyCell.m in Sources */,
|
||||
046086D92F1A093400757C95 /* KBAICommentHeaderView.m in Sources */,
|
||||
04E0394B2F236E75002CA5A0 /* KBChatUserMessageCell.m in Sources */,
|
||||
0F2A10032F3C0001002CA5A0 /* KBChatMessageActionPopView.m in Sources */,
|
||||
0F2A10132F3C0002002CA5A0 /* KBAIPersonaSidebarView.m in Sources */,
|
||||
04E0394C2F236E75002CA5A0 /* KBChatTimeCell.m in Sources */,
|
||||
04E0394D2F236E75002CA5A0 /* KBChatAssistantMessageCell.m in Sources */,
|
||||
04E0394E2F236E75002CA5A0 /* KBChatTableView.m in Sources */,
|
||||
|
||||
22
keyBoard/Assets.xcassets/AI/ai_close_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_close_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_close_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
keyBoard/Assets.xcassets/AI/ai_close_icon.imageset/ai_close_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
keyBoard/Assets.xcassets/AI/ai_close_icon.imageset/ai_close_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
22
keyBoard/Assets.xcassets/AI/ai_more_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_more_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_more_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
keyBoard/Assets.xcassets/AI/ai_more_icon.imageset/ai_more_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
keyBoard/Assets.xcassets/AI/ai_more_icon.imageset/ai_more_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
22
keyBoard/Assets.xcassets/AI/ai_report_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_report_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_report_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
keyBoard/Assets.xcassets/AI/ai_report_icon.imageset/ai_report_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
keyBoard/Assets.xcassets/AI/ai_report_icon.imageset/ai_report_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
22
keyBoard/Assets.xcassets/AI/ai_role_sel.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_role_sel@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_role_sel@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
keyBoard/Assets.xcassets/AI/ai_role_sel.imageset/ai_role_sel@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
keyBoard/Assets.xcassets/AI/ai_role_sel.imageset/ai_role_sel@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
22
keyBoard/Assets.xcassets/AI/ai_search_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_search_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_search_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
keyBoard/Assets.xcassets/AI/ai_search_icon.imageset/ai_search_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 923 B |
BIN
keyBoard/Assets.xcassets/AI/ai_search_icon.imageset/ai_search_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
22
keyBoard/Assets.xcassets/AI/ai_sendmessage_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_sendmessage_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_sendmessage_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
keyBoard/Assets.xcassets/AI/ai_sendmessage_icon.imageset/ai_sendmessage_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
keyBoard/Assets.xcassets/AI/ai_sendmessage_icon.imageset/ai_sendmessage_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
22
keyBoard/Assets.xcassets/AI/right_arrow_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "right_arrow_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "right_arrow_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
keyBoard/Assets.xcassets/AI/right_arrow_icon.imageset/right_arrow_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 474 B |
BIN
keyBoard/Assets.xcassets/AI/right_arrow_icon.imageset/right_arrow_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 858 B |
33
keyBoard/Class/AiTalk/V/Chat/KBChatMessageActionPopView.h
Normal file
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// KBChatMessageActionPopView.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Codex on 2026/2/3.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef NS_ENUM(NSInteger, KBChatMessageActionType) {
|
||||
KBChatMessageActionTypeCopy = 0,
|
||||
KBChatMessageActionTypeDelete = 1,
|
||||
KBChatMessageActionTypeReport = 2,
|
||||
};
|
||||
|
||||
@class KBChatMessageActionPopView;
|
||||
|
||||
@protocol KBChatMessageActionPopViewDelegate <NSObject>
|
||||
@optional
|
||||
- (void)chatMessageActionPopView:(KBChatMessageActionPopView *)view
|
||||
didSelectAction:(KBChatMessageActionType)action;
|
||||
@end
|
||||
|
||||
/// 聊天消息长按操作弹窗(Copy / Delete / Report)
|
||||
@interface KBChatMessageActionPopView : UIView
|
||||
|
||||
@property (nonatomic, weak) id<KBChatMessageActionPopViewDelegate> delegate;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
164
keyBoard/Class/AiTalk/V/Chat/KBChatMessageActionPopView.m
Normal file
@@ -0,0 +1,164 @@
|
||||
//
|
||||
// KBChatMessageActionPopView.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Codex on 2026/2/3.
|
||||
//
|
||||
|
||||
#import "KBChatMessageActionPopView.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
|
||||
static CGFloat const kKBChatActionRowHeight = 52.0;
|
||||
|
||||
@interface KBChatMessageActionPopView ()
|
||||
|
||||
@property (nonatomic, strong) UIControl *copyRow;
|
||||
@property (nonatomic, strong) UIControl *deleteRow;
|
||||
@property (nonatomic, strong) UIControl *reportRow;
|
||||
@property (nonatomic, strong) UIView *line1;
|
||||
@property (nonatomic, strong) UIView *line2;
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBChatMessageActionPopView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
self = [super initWithFrame:frame];
|
||||
if (self) {
|
||||
[self setupUI];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
#pragma mark - UI
|
||||
|
||||
- (void)setupUI {
|
||||
self.backgroundColor = [UIColor colorWithWhite:0.1 alpha:0.92];
|
||||
self.layer.cornerRadius = 16.0;
|
||||
self.layer.masksToBounds = YES;
|
||||
|
||||
[self addSubview:self.copyRow];
|
||||
[self addSubview:self.line1];
|
||||
[self addSubview:self.deleteRow];
|
||||
[self addSubview:self.line2];
|
||||
[self addSubview:self.reportRow];
|
||||
|
||||
[self.copyRow mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.left.right.equalTo(self);
|
||||
make.height.mas_equalTo(kKBChatActionRowHeight);
|
||||
}];
|
||||
|
||||
[self.line1 mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.copyRow.mas_bottom);
|
||||
make.left.right.equalTo(self);
|
||||
make.height.mas_equalTo(0.5);
|
||||
}];
|
||||
|
||||
[self.deleteRow mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.line1.mas_bottom);
|
||||
make.left.right.equalTo(self);
|
||||
make.height.mas_equalTo(kKBChatActionRowHeight);
|
||||
}];
|
||||
|
||||
[self.line2 mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.deleteRow.mas_bottom);
|
||||
make.left.right.equalTo(self);
|
||||
make.height.mas_equalTo(0.5);
|
||||
}];
|
||||
|
||||
[self.reportRow mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.line2.mas_bottom);
|
||||
make.left.right.bottom.equalTo(self);
|
||||
make.height.mas_equalTo(kKBChatActionRowHeight);
|
||||
}];
|
||||
}
|
||||
|
||||
- (UIControl *)buildRowWithTitle:(NSString *)title
|
||||
iconName:(NSString *)iconName
|
||||
action:(KBChatMessageActionType)action {
|
||||
UIControl *row = [[UIControl alloc] init];
|
||||
row.tag = action;
|
||||
[row addTarget:self action:@selector(actionRowTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
|
||||
UILabel *label = [[UILabel alloc] init];
|
||||
label.text = title;
|
||||
label.textColor = [UIColor whiteColor];
|
||||
label.font = [UIFont systemFontOfSize:18 weight:UIFontWeightRegular];
|
||||
|
||||
UIImageView *iconView = [[UIImageView alloc] init];
|
||||
UIImage *icon = [UIImage systemImageNamed:iconName];
|
||||
iconView.image = icon;
|
||||
iconView.tintColor = [UIColor whiteColor];
|
||||
iconView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
|
||||
[row addSubview:label];
|
||||
[row addSubview:iconView];
|
||||
|
||||
[label mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(row).offset(16);
|
||||
make.centerY.equalTo(row);
|
||||
}];
|
||||
|
||||
[iconView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(row).offset(-16);
|
||||
make.centerY.equalTo(row);
|
||||
make.width.height.mas_equalTo(18);
|
||||
}];
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)actionRowTapped:(UIControl *)sender {
|
||||
if ([self.delegate respondsToSelector:@selector(chatMessageActionPopView:didSelectAction:)]) {
|
||||
[self.delegate chatMessageActionPopView:self didSelectAction:(KBChatMessageActionType)sender.tag];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIControl *)copyRow {
|
||||
if (!_copyRow) {
|
||||
_copyRow = [self buildRowWithTitle:KBLocalized(@"Copy")
|
||||
iconName:@"doc.on.doc"
|
||||
action:KBChatMessageActionTypeCopy];
|
||||
}
|
||||
return _copyRow;
|
||||
}
|
||||
|
||||
- (UIControl *)deleteRow {
|
||||
if (!_deleteRow) {
|
||||
_deleteRow = [self buildRowWithTitle:KBLocalized(@"Delete")
|
||||
iconName:@"trash"
|
||||
action:KBChatMessageActionTypeDelete];
|
||||
}
|
||||
return _deleteRow;
|
||||
}
|
||||
|
||||
- (UIControl *)reportRow {
|
||||
if (!_reportRow) {
|
||||
_reportRow = [self buildRowWithTitle:KBLocalized(@"Report")
|
||||
iconName:@"exclamationmark.circle"
|
||||
action:KBChatMessageActionTypeReport];
|
||||
}
|
||||
return _reportRow;
|
||||
}
|
||||
|
||||
- (UIView *)line1 {
|
||||
if (!_line1) {
|
||||
_line1 = [[UIView alloc] init];
|
||||
_line1.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.12];
|
||||
}
|
||||
return _line1;
|
||||
}
|
||||
|
||||
- (UIView *)line2 {
|
||||
if (!_line2) {
|
||||
_line2 = [[UIView alloc] init];
|
||||
_line2.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.12];
|
||||
}
|
||||
return _line2;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -17,6 +17,10 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
- (void)chatTableViewDidScroll:(KBChatTableView *)chatView
|
||||
scrollView:(UIScrollView *)scrollView;
|
||||
- (void)chatTableViewDidTriggerLoadMore:(KBChatTableView *)chatView;
|
||||
/// 长按消息(用户/AI)
|
||||
- (void)chatTableView:(KBChatTableView *)chatView
|
||||
didLongPressMessage:(KBAiChatMessage *)message
|
||||
sourceRect:(CGRect)sourceRect;
|
||||
@end
|
||||
|
||||
/// 聊天列表视图(支持用户消息、AI 消息、时间戳、语音播放)
|
||||
@@ -60,6 +64,17 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
/// 移除 loading AI 消息
|
||||
- (void)removeLoadingAssistantMessage;
|
||||
|
||||
/// 移除 loading 用户消息
|
||||
- (void)removeLoadingUserMessage;
|
||||
|
||||
/// 顶部加载中提示
|
||||
- (void)showTopLoading;
|
||||
- (void)hideTopLoading;
|
||||
|
||||
/// 顶部“无更多数据”提示
|
||||
- (void)showNoMoreData;
|
||||
- (void)hideNoMoreData;
|
||||
|
||||
/// 滚动到底部
|
||||
- (void)scrollToBottom;
|
||||
|
||||
|
||||
@@ -37,6 +37,12 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
|
||||
@property (nonatomic, assign) CGSize lastIntroFooterTableSize;
|
||||
@property (nonatomic, assign) BOOL applyingIntroFooter;
|
||||
@property (nonatomic, copy) NSString *remoteAudioToken;
|
||||
@property (nonatomic, strong) UIView *topStatusView;
|
||||
@property (nonatomic, strong) UIActivityIndicatorView *topLoadingIndicator;
|
||||
@property (nonatomic, strong) UILabel *topStatusLabel;
|
||||
@property (nonatomic, assign) BOOL isTopLoading;
|
||||
@property (nonatomic, assign) BOOL isTopNoMore;
|
||||
@property (nonatomic, strong) UILongPressGestureRecognizer *messageLongPressGesture;
|
||||
|
||||
@end
|
||||
|
||||
@@ -96,6 +102,13 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
|
||||
make.edges.equalTo(self);
|
||||
}];
|
||||
|
||||
// 长按消息操作
|
||||
self.messageLongPressGesture = [[UILongPressGestureRecognizer alloc] initWithTarget:self
|
||||
action:@selector(handleMessageLongPress:)];
|
||||
self.messageLongPressGesture.minimumPressDuration = 0.4;
|
||||
self.messageLongPressGesture.cancelsTouchesInView = YES;
|
||||
[self.tableView addGestureRecognizer:self.messageLongPressGesture];
|
||||
|
||||
// 初始化 contentInset
|
||||
self.contentBottomInset = 0;
|
||||
[self updateContentBottomInset:self.contentBottomInset];
|
||||
@@ -131,6 +144,32 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
|
||||
*/
|
||||
}
|
||||
|
||||
#pragma mark - Long Press
|
||||
|
||||
- (void)handleMessageLongPress:(UILongPressGestureRecognizer *)gesture {
|
||||
if (gesture.state != UIGestureRecognizerStateBegan) {
|
||||
return;
|
||||
}
|
||||
|
||||
CGPoint point = [gesture locationInView:self.tableView];
|
||||
NSIndexPath *indexPath = [self.tableView indexPathForRowAtPoint:point];
|
||||
if (!indexPath || indexPath.row >= self.messages.count) {
|
||||
return;
|
||||
}
|
||||
|
||||
KBAiChatMessage *message = self.messages[indexPath.row];
|
||||
if (!message || message.isLoading || message.type == KBAiChatMessageTypeTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
|
||||
CGRect cellRect = cell ? [cell convertRect:cell.bounds toView:nil] : CGRectZero;
|
||||
|
||||
if ([self.delegate respondsToSelector:@selector(chatTableView:didLongPressMessage:sourceRect:)]) {
|
||||
[self.delegate chatTableView:self didLongPressMessage:message sourceRect:cellRect];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (void)setInverted:(BOOL)inverted {
|
||||
@@ -401,6 +440,115 @@ static inline CGFloat KBChatAbsTimeInterval(NSTimeInterval interval) {
|
||||
}
|
||||
}
|
||||
|
||||
- (void)removeLoadingUserMessage {
|
||||
if (self.inverted) {
|
||||
for (NSInteger i = 0; i < self.messages.count; i++) {
|
||||
KBAiChatMessage *message = self.messages[i];
|
||||
if (message.type == KBAiChatMessageTypeUser && message.isLoading) {
|
||||
[self.messages removeObjectAtIndex:i];
|
||||
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
|
||||
[self.tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
|
||||
NSLog(@"[KBChatTableView] 移除 loading 用户消息,索引: %ld", (long)i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||
KBAiChatMessage *message = self.messages[i];
|
||||
if (message.type == KBAiChatMessageTypeUser && message.isLoading) {
|
||||
[self.messages removeObjectAtIndex:i];
|
||||
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
|
||||
[self.tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
|
||||
NSLog(@"[KBChatTableView] 移除 loading 用户消息,索引: %ld", (long)i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Top Status
|
||||
|
||||
- (void)showTopLoading {
|
||||
self.isTopLoading = YES;
|
||||
self.isTopNoMore = NO;
|
||||
[self updateTopStatusView];
|
||||
}
|
||||
|
||||
- (void)hideTopLoading {
|
||||
self.isTopLoading = NO;
|
||||
[self updateTopStatusView];
|
||||
}
|
||||
|
||||
- (void)showNoMoreData {
|
||||
self.isTopNoMore = YES;
|
||||
self.isTopLoading = NO;
|
||||
[self updateTopStatusView];
|
||||
}
|
||||
|
||||
- (void)hideNoMoreData {
|
||||
self.isTopNoMore = NO;
|
||||
[self updateTopStatusView];
|
||||
}
|
||||
|
||||
- (void)updateTopStatusView {
|
||||
BOOL shouldShow = self.isTopLoading || self.isTopNoMore;
|
||||
if (!shouldShow) {
|
||||
self.topStatusView.hidden = YES;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self.topStatusView) {
|
||||
self.topStatusView = [[UIView alloc] initWithFrame:CGRectZero];
|
||||
self.topStatusView.backgroundColor = [UIColor clearColor];
|
||||
self.topStatusView.userInteractionEnabled = NO;
|
||||
|
||||
self.topLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||
self.topLoadingIndicator.hidesWhenStopped = YES;
|
||||
[self.topStatusView addSubview:self.topLoadingIndicator];
|
||||
|
||||
self.topStatusLabel = [[UILabel alloc] initWithFrame:CGRectZero];
|
||||
self.topStatusLabel.font = [UIFont systemFontOfSize:12];
|
||||
self.topStatusLabel.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.8];
|
||||
[self.topStatusView addSubview:self.topStatusLabel];
|
||||
|
||||
[self addSubview:self.topStatusView];
|
||||
}
|
||||
|
||||
if (self.isTopLoading) {
|
||||
self.topStatusLabel.text = KBLocalized(@"Loading...");
|
||||
[self.topLoadingIndicator startAnimating];
|
||||
} else if (self.isTopNoMore) {
|
||||
self.topStatusLabel.text = KBLocalized(@"No more data");
|
||||
[self.topLoadingIndicator stopAnimating];
|
||||
}
|
||||
|
||||
CGFloat width = CGRectGetWidth(self.tableView.bounds);
|
||||
if (width <= 0) {
|
||||
width = CGRectGetWidth(self.bounds);
|
||||
}
|
||||
CGFloat height = 32;
|
||||
self.topStatusView.frame = CGRectMake(0, 0, width, height);
|
||||
self.topStatusView.hidden = NO;
|
||||
[self bringSubviewToFront:self.topStatusView];
|
||||
|
||||
CGSize labelSize = [self.topStatusLabel sizeThatFits:CGSizeMake(width - 40, height)];
|
||||
CGFloat totalWidth = labelSize.width + (self.isTopLoading ? 20 + 6 : 0);
|
||||
CGFloat startX = (width - totalWidth) / 2.0;
|
||||
if (self.isTopLoading) {
|
||||
self.topLoadingIndicator.frame = CGRectMake(startX, (height - 20) / 2.0, 20, 20);
|
||||
self.topStatusLabel.frame = CGRectMake(CGRectGetMaxX(self.topLoadingIndicator.frame) + 6,
|
||||
(height - labelSize.height) / 2.0,
|
||||
labelSize.width,
|
||||
labelSize.height);
|
||||
} else {
|
||||
self.topStatusLabel.frame = CGRectMake((width - labelSize.width) / 2.0,
|
||||
(height - labelSize.height) / 2.0,
|
||||
labelSize.width,
|
||||
labelSize.height);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
- (void)scrollToBottom {
|
||||
[self scrollToBottomAnimated:YES];
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
self.messageLabel.numberOfLines = 0;
|
||||
self.messageLabel.font = [UIFont systemFontOfSize:16];
|
||||
self.messageLabel.textColor = [UIColor blackColor];
|
||||
self.messageLabel.textAlignment = NSTextAlignmentLeft;
|
||||
[self.bubbleView addSubview:self.messageLabel];
|
||||
|
||||
// 加载指示器
|
||||
@@ -76,6 +77,7 @@
|
||||
|
||||
- (void)configureWithMessage:(KBAiChatMessage *)message {
|
||||
self.messageLabel.text = message.text;
|
||||
[self updateMessageAlignmentForText:message.text];
|
||||
|
||||
if (message.isLoading) {
|
||||
self.bubbleView.hidden = YES;
|
||||
@@ -87,4 +89,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
- (void)updateMessageAlignmentForText:(NSString *)text {
|
||||
if (self.messageLabel.hidden || text.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ([text rangeOfString:@"\n"].location != NSNotFound) {
|
||||
self.messageLabel.textAlignment = NSTextAlignmentLeft;
|
||||
return;
|
||||
}
|
||||
|
||||
CGSize singleLineSize = [text sizeWithAttributes:@{NSFontAttributeName: self.messageLabel.font}];
|
||||
CGFloat minBubbleWidth = 50.0;
|
||||
CGFloat padding = 24.0;
|
||||
if (singleLineSize.width + padding <= minBubbleWidth + 0.5) {
|
||||
self.messageLabel.textAlignment = NSTextAlignmentCenter;
|
||||
} else {
|
||||
self.messageLabel.textAlignment = NSTextAlignmentLeft;
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -36,6 +36,9 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
/// 更新最后一条用户消息
|
||||
- (void)updateLastUserMessage:(NSString *)text;
|
||||
|
||||
/// 移除 loading 用户消息
|
||||
- (void)removeLoadingUserMessage;
|
||||
|
||||
/// 添加 AI 消息(支持打字机效果)
|
||||
- (void)appendAssistantMessage:(NSString *)text
|
||||
audioId:(nullable NSString *)audioId;
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
#import "KBImagePositionButton.h"
|
||||
#import "KBAICommentView.h"
|
||||
#import "KBAIChatMessageCacheManager.h"
|
||||
#import "KBChatMessageActionPopView.h"
|
||||
#import "AIReportVC.h"
|
||||
#import "KBHUD.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
#import <SDWebImage/SDWebImage.h>
|
||||
#import <LSTPopView/LSTPopView.h>
|
||||
@@ -19,7 +22,7 @@
|
||||
/// 聊天会话被重置的通知
|
||||
static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidResetNotification";
|
||||
|
||||
@interface KBPersonaChatCell () <KBChatTableViewDelegate>
|
||||
@interface KBPersonaChatCell () <KBChatTableViewDelegate, KBChatMessageActionPopViewDelegate>
|
||||
|
||||
/// 背景图
|
||||
@property (nonatomic, strong) UIImageView *backgroundImageView;
|
||||
@@ -30,11 +33,6 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
/// 人设名称
|
||||
@property (nonatomic, strong) UILabel *nameLabel;
|
||||
|
||||
/// 开场白
|
||||
@property (nonatomic, strong) UILabel *openingLabel;
|
||||
|
||||
|
||||
|
||||
/// 聊天消息
|
||||
@property (nonatomic, strong) NSMutableArray<KBAiChatMessage *> *messages;
|
||||
|
||||
@@ -69,6 +67,10 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
@property (nonatomic, assign) BOOL isCurrentPersonaCell;
|
||||
@property (nonatomic, assign) BOOL shouldAutoPlayPrologueAudio;
|
||||
@property (nonatomic, assign) BOOL hasPlayedPrologueAudio;
|
||||
@property (nonatomic, assign) BOOL shouldShowOpeningMessage;
|
||||
@property (nonatomic, weak) LSTPopView *messageActionPopView;
|
||||
@property (nonatomic, strong) KBAiChatMessage *selectedActionMessage;
|
||||
@property (nonatomic, strong) UIControl *messageActionMaskView;
|
||||
|
||||
@end
|
||||
|
||||
@@ -107,6 +109,8 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
self.isCurrentPersonaCell = NO;
|
||||
self.shouldAutoPlayPrologueAudio = NO;
|
||||
self.hasPlayedPrologueAudio = NO;
|
||||
self.shouldShowOpeningMessage = NO;
|
||||
self.shouldShowOpeningMessage = NO;
|
||||
|
||||
// ✅ 移除了 self.hasLoadedData = NO;
|
||||
// 这样 Cell 复用时不会重复请求数据
|
||||
@@ -129,14 +133,6 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
make.edges.equalTo(self.contentView);
|
||||
}];
|
||||
|
||||
// 开场白
|
||||
[self.contentView addSubview:self.openingLabel];
|
||||
[self.openingLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.contentView).offset(KB_NAV_TOTAL_HEIGHT);
|
||||
make.left.equalTo(self.contentView).offset(40);
|
||||
make.right.equalTo(self.contentView).offset(-40);
|
||||
}];
|
||||
|
||||
// 头像
|
||||
[self.contentView addSubview:self.avatarImageView];
|
||||
[self.avatarImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
@@ -172,8 +168,9 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
|
||||
// 聊天列表
|
||||
[self.contentView addSubview:self.chatView];
|
||||
CGFloat topY = KB_STATUSBAR_HEIGHT + 15;
|
||||
[self.chatView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.contentView).offset(KB_NAV_TOTAL_HEIGHT);
|
||||
make.top.equalTo(self.contentView).offset(topY);
|
||||
make.left.right.equalTo(self.contentView);
|
||||
make.bottom.equalTo(self.avatarImageView.mas_top).offset(-10);
|
||||
}];
|
||||
@@ -220,8 +217,6 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
[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.prologue;
|
||||
|
||||
// 关键修复:清空消息时停止音频播放,避免状态混乱
|
||||
[self.chatView stopPlayingAudio];
|
||||
|
||||
@@ -232,6 +227,8 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
NSLog(@"[KBPersonaChatCell] contentView.frame: %@", NSStringFromCGRect(self.contentView.frame));
|
||||
|
||||
if (self.messages.count > 0) {
|
||||
self.shouldShowOpeningMessage = NO;
|
||||
[self removeOpeningMessageIfNeeded];
|
||||
[self.chatView updateIntroFooterText:nil];
|
||||
[self ensureOpeningMessageAtTop];
|
||||
// 同步缓存,避免下次从缓存缺少开场白
|
||||
@@ -241,8 +238,9 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
keepOffset:NO
|
||||
scrollToBottom:YES];
|
||||
} else {
|
||||
self.shouldShowOpeningMessage = NO;
|
||||
[self.chatView clearMessages];
|
||||
[self.chatView updateIntroFooterText:persona.prologue];
|
||||
[self.chatView updateIntroFooterText:nil];
|
||||
}
|
||||
|
||||
NSLog(@"[KBPersonaChatCell] ========== setPersona 结束 ==========");
|
||||
@@ -269,6 +267,10 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
}
|
||||
|
||||
self.isLoading = YES;
|
||||
BOOL isLoadMore = (self.currentPage > 1);
|
||||
if (isLoadMore) {
|
||||
[self.chatView showTopLoading];
|
||||
}
|
||||
|
||||
if (self.currentPage == 1) {
|
||||
[self.chatView resetNoMoreData];
|
||||
@@ -291,8 +293,12 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
NSLog(@"[KBPersonaChatCell] 加载聊天记录失败:%@", error.localizedDescription);
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
strongSelf.isLoading = NO;
|
||||
if (isLoadMore) {
|
||||
[strongSelf.chatView hideTopLoading];
|
||||
}
|
||||
[strongSelf.chatView endLoadMoreWithHasMoreData:strongSelf.hasMoreHistory];
|
||||
if (strongSelf.currentPage == 1 && strongSelf.persona.prologue.length > 0) {
|
||||
strongSelf.shouldShowOpeningMessage = YES;
|
||||
[strongSelf showOpeningMessage];
|
||||
}
|
||||
});
|
||||
@@ -306,6 +312,7 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
if (loadedPage == 1) {
|
||||
BOOL isEmpty = (pageModel.total == 0);
|
||||
strongSelf.shouldAutoPlayPrologueAudio = isEmpty && (strongSelf.persona.prologueAudio.length > 0);
|
||||
strongSelf.shouldShowOpeningMessage = isEmpty;
|
||||
if (!strongSelf.shouldAutoPlayPrologueAudio) {
|
||||
[strongSelf.chatView stopPlayingAudio];
|
||||
} else {
|
||||
@@ -317,6 +324,8 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
[strongSelf.chatView clearMessages];
|
||||
[strongSelf.chatView updateIntroFooterText:strongSelf.persona.prologue];
|
||||
[strongSelf.chatView endLoadMoreWithHasMoreData:strongSelf.hasMoreHistory];
|
||||
[strongSelf.chatView hideTopLoading];
|
||||
[strongSelf.chatView hideNoMoreData];
|
||||
strongSelf.isLoading = NO;
|
||||
});
|
||||
strongSelf.currentPage++;
|
||||
@@ -355,6 +364,9 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
if (loadedPage == 1) {
|
||||
// 第一页,直接赋值
|
||||
strongSelf.messages = newMessages;
|
||||
if (!strongSelf.shouldShowOpeningMessage) {
|
||||
[strongSelf removeOpeningMessageIfNeeded];
|
||||
}
|
||||
[strongSelf ensureOpeningMessageAtTop];
|
||||
} else {
|
||||
// 后续页,继续加载历史
|
||||
@@ -375,6 +387,9 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
|
||||
// 刷新 UI
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (isLoadMore) {
|
||||
[strongSelf.chatView hideTopLoading];
|
||||
}
|
||||
if (loadedPage == 1) {
|
||||
NSLog(@"[KBPersonaChatCell] 刷新 UI - loadedPage: %ld, keepOffset: 0, scrollToBottom: 1",
|
||||
(long)loadedPage);
|
||||
@@ -394,6 +409,11 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
}
|
||||
}
|
||||
[strongSelf.chatView endLoadMoreWithHasMoreData:strongSelf.hasMoreHistory];
|
||||
if (!strongSelf.hasMoreHistory && strongSelf.messages.count > 0) {
|
||||
[strongSelf.chatView showNoMoreData];
|
||||
} else {
|
||||
[strongSelf.chatView hideNoMoreData];
|
||||
}
|
||||
|
||||
// ✅ 保存到缓存(包含开场白)
|
||||
[[KBAIChatMessageCacheManager shared] saveMessages:strongSelf.messages
|
||||
@@ -449,6 +469,9 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
}
|
||||
|
||||
- (void)showOpeningMessage {
|
||||
if (!self.shouldShowOpeningMessage) {
|
||||
return;
|
||||
}
|
||||
if (self.messages.count == 0) {
|
||||
[self.chatView clearMessages];
|
||||
[self.chatView updateIntroFooterText:self.persona.prologue];
|
||||
@@ -487,6 +510,9 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
}
|
||||
|
||||
- (void)ensureOpeningMessageAtTop {
|
||||
if (!self.shouldShowOpeningMessage) {
|
||||
return;
|
||||
}
|
||||
NSString *prologue = [self currentPrologueText];
|
||||
if (prologue.length == 0) {
|
||||
return;
|
||||
@@ -507,6 +533,14 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
}
|
||||
}
|
||||
|
||||
- (void)removeOpeningMessageIfNeeded {
|
||||
NSInteger index = [self openingMessageIndexInMessages];
|
||||
if (index == NSNotFound) {
|
||||
return;
|
||||
}
|
||||
[self.messages removeObjectAtIndex:index];
|
||||
}
|
||||
|
||||
- (nullable KBAiChatMessage *)openingMessageInMessages {
|
||||
NSInteger index = [self openingMessageIndexInMessages];
|
||||
if (index == NSNotFound) {
|
||||
@@ -546,6 +580,11 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
if (self.persona && self.persona.personaId == companionId) {
|
||||
NSLog(@"[KBPersonaChatCell] 收到聊天重置通知:companionId=%ld, 清空聊天记录", (long)companionId);
|
||||
|
||||
self.shouldAutoPlayPrologueAudio = NO;
|
||||
self.hasPlayedPrologueAudio = NO;
|
||||
self.shouldShowOpeningMessage = YES;
|
||||
[self.chatView stopPlayingAudio];
|
||||
|
||||
// 清空消息数组
|
||||
self.messages = [NSMutableArray array];
|
||||
self.hasLoadedData = NO;
|
||||
@@ -633,6 +672,32 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
}
|
||||
}
|
||||
|
||||
- (void)removeLoadingUserMessage {
|
||||
if (!self.messages) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.chatView.inverted) {
|
||||
for (NSInteger i = 0; i < self.messages.count; i++) {
|
||||
KBAiChatMessage *message = self.messages[i];
|
||||
if (message.type == KBAiChatMessageTypeUser && message.isLoading) {
|
||||
[self.messages removeObjectAtIndex:i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||
KBAiChatMessage *message = self.messages[i];
|
||||
if (message.type == KBAiChatMessageTypeUser && message.isLoading) {
|
||||
[self.messages removeObjectAtIndex:i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[self.chatView removeLoadingUserMessage];
|
||||
}
|
||||
|
||||
- (void)markLastUserMessageLoadingComplete {
|
||||
[self.chatView markLastUserMessageLoadingComplete];
|
||||
|
||||
@@ -839,6 +904,126 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
[self loadMoreHistory];
|
||||
}
|
||||
|
||||
- (void)chatTableView:(KBChatTableView *)chatView
|
||||
didLongPressMessage:(KBAiChatMessage *)message
|
||||
sourceRect:(CGRect)sourceRect {
|
||||
[self showMessageActionPopForMessage:message sourceRect:sourceRect];
|
||||
}
|
||||
|
||||
#pragma mark - KBChatMessageActionPopViewDelegate
|
||||
|
||||
- (void)chatMessageActionPopView:(KBChatMessageActionPopView *)view
|
||||
didSelectAction:(KBChatMessageActionType)action {
|
||||
[self dismissMessageActionPop];
|
||||
|
||||
KBAiChatMessage *message = self.selectedActionMessage;
|
||||
self.selectedActionMessage = nil;
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case KBChatMessageActionTypeCopy: {
|
||||
if (message.text.length > 0) {
|
||||
[UIPasteboard generalPasteboard].string = message.text;
|
||||
[KBHUD showSuccess:KBLocalized(@"复制成功")];
|
||||
}
|
||||
} break;
|
||||
case KBChatMessageActionTypeDelete: {
|
||||
NSInteger idx = [self.messages indexOfObjectIdenticalTo:message];
|
||||
if (idx != NSNotFound) {
|
||||
[self.messages removeObjectAtIndex:idx];
|
||||
[self.chatView reloadWithMessages:self.messages
|
||||
keepOffset:YES
|
||||
scrollToBottom:NO];
|
||||
if (self.persona.personaId > 0) {
|
||||
if (self.messages.count > 0) {
|
||||
[[KBAIChatMessageCacheManager shared] saveMessages:self.messages
|
||||
forCompanionId:self.persona.personaId];
|
||||
} else {
|
||||
[[KBAIChatMessageCacheManager shared] clearMessagesForCompanionId:self.persona.personaId];
|
||||
}
|
||||
}
|
||||
}
|
||||
} break;
|
||||
case KBChatMessageActionTypeReport: {
|
||||
if (self.persona.personaId <= 0) {
|
||||
return;
|
||||
}
|
||||
AIReportVC *vc = [[AIReportVC alloc] init];
|
||||
vc.personaId = self.persona.personaId;
|
||||
[KB_CURRENT_NAV pushViewController:vc animated:YES];
|
||||
} break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Message Action Pop
|
||||
|
||||
- (void)showMessageActionPopForMessage:(KBAiChatMessage *)message
|
||||
sourceRect:(CGRect)sourceRect {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
[self dismissMessageActionPop];
|
||||
|
||||
self.selectedActionMessage = message;
|
||||
CGFloat width = 240;
|
||||
CGFloat height = 156;
|
||||
KBChatMessageActionPopView *content = [[KBChatMessageActionPopView alloc]
|
||||
initWithFrame:CGRectMake(0, 0, width, height)];
|
||||
content.delegate = self;
|
||||
|
||||
UIWindow *window = [UIApplication sharedApplication].keyWindow;
|
||||
if (!window) {
|
||||
window = [UIApplication sharedApplication].windows.firstObject;
|
||||
}
|
||||
if (!window) {
|
||||
return;
|
||||
}
|
||||
|
||||
UIControl *mask = [[UIControl alloc] initWithFrame:window.bounds];
|
||||
[mask addTarget:self action:@selector(dismissMessageActionPop) forControlEvents:UIControlEventTouchUpInside];
|
||||
[window addSubview:mask];
|
||||
self.messageActionMaskView = mask;
|
||||
|
||||
BOOL isUserMessage = (message.type == KBAiChatMessageTypeUser);
|
||||
CGFloat margin = 12.0;
|
||||
CGFloat spacing = 8.0;
|
||||
CGFloat topSafe = 0.0;
|
||||
CGFloat bottomSafe = 0.0;
|
||||
if (@available(iOS 11.0, *)) {
|
||||
topSafe = window.safeAreaInsets.top;
|
||||
bottomSafe = window.safeAreaInsets.bottom;
|
||||
}
|
||||
|
||||
CGFloat x = isUserMessage ? CGRectGetMaxX(sourceRect) - width : CGRectGetMinX(sourceRect);
|
||||
x = MAX(margin, MIN(x, CGRectGetWidth(window.bounds) - width - margin));
|
||||
|
||||
CGFloat y = CGRectGetMinY(sourceRect) - height - spacing;
|
||||
if (y < topSafe + margin) {
|
||||
y = CGRectGetMaxY(sourceRect) + spacing;
|
||||
}
|
||||
if (y + height > CGRectGetHeight(window.bounds) - bottomSafe - margin) {
|
||||
y = MAX(topSafe + margin, CGRectGetHeight(window.bounds) - bottomSafe - margin - height);
|
||||
}
|
||||
|
||||
content.frame = CGRectMake(x, y, width, height);
|
||||
[mask addSubview:content];
|
||||
}
|
||||
|
||||
- (void)dismissMessageActionPop {
|
||||
if (self.messageActionPopView) {
|
||||
[self.messageActionPopView dismiss];
|
||||
self.messageActionPopView = nil;
|
||||
}
|
||||
if (self.messageActionMaskView) {
|
||||
[self.messageActionMaskView removeFromSuperview];
|
||||
self.messageActionMaskView = nil;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Lazy Load
|
||||
|
||||
- (UIImageView *)backgroundImageView {
|
||||
@@ -877,17 +1062,6 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
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;
|
||||
}
|
||||
|
||||
- (KBChatTableView *)chatView {
|
||||
if (!_chatView) {
|
||||
_chatView = [[KBChatTableView alloc] init];
|
||||
@@ -1027,7 +1201,7 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
[self.popView dismiss];
|
||||
}
|
||||
|
||||
CGFloat customViewHeight = KB_SCREEN_HEIGHT * 0.7;
|
||||
CGFloat customViewHeight = KB_SCREEN_HEIGHT * 0.75;
|
||||
KBAICommentView *customView = [[KBAICommentView alloc]
|
||||
initWithFrame:CGRectMake(0, 0, KB_SCREEN_WIDTH, customViewHeight)];
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
if (!_actionButton) {
|
||||
_actionButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
_actionButton.titleLabel.font = [UIFont systemFontOfSize:13];
|
||||
[_actionButton setTitleColor:[UIColor secondaryLabelColor]
|
||||
[_actionButton setTitleColor:[UIColor whiteColor]
|
||||
forState:UIControlStateNormal];
|
||||
_actionButton.tintColor = [UIColor secondaryLabelColor];
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
@interface KBAICommentInputView () <UITextFieldDelegate>
|
||||
|
||||
@property(nonatomic, strong) UIView *containerView;
|
||||
@property(nonatomic, strong) UIImageView *avatarImageView;
|
||||
//@property(nonatomic, strong) UIImageView *avatarImageView;
|
||||
@property(nonatomic, strong) UITextField *textField;
|
||||
@property(nonatomic, strong) UILabel *placeholderLabel;
|
||||
@property(nonatomic, strong) UIButton *sendButton;
|
||||
@property(nonatomic, strong) UIView *topLine;
|
||||
|
||||
@@ -31,12 +32,13 @@
|
||||
#pragma mark - UI Setup
|
||||
|
||||
- (void)setupUI {
|
||||
self.backgroundColor = [UIColor whiteColor];
|
||||
self.backgroundColor = [UIColor colorWithHex:0x797979 alpha:0.49];
|
||||
|
||||
[self addSubview:self.topLine];
|
||||
[self addSubview:self.avatarImageView];
|
||||
// [self addSubview:self.avatarImageView];
|
||||
[self addSubview:self.containerView];
|
||||
[self.containerView addSubview:self.textField];
|
||||
[self addSubview:self.placeholderLabel];
|
||||
[self addSubview:self.sendButton];
|
||||
|
||||
[self.topLine mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
@@ -44,17 +46,17 @@
|
||||
make.height.mas_equalTo(0.5);
|
||||
}];
|
||||
|
||||
[self.avatarImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self).offset(12);
|
||||
make.centerY.equalTo(self);
|
||||
make.width.height.mas_equalTo(32);
|
||||
}];
|
||||
// [self.avatarImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
// make.left.equalTo(self).offset(12);
|
||||
// make.centerY.equalTo(self);
|
||||
// make.width.height.mas_equalTo(32);
|
||||
// }];
|
||||
|
||||
[self.containerView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.avatarImageView.mas_right).offset(10);
|
||||
make.right.equalTo(self.sendButton.mas_left).offset(-10);
|
||||
make.left.equalTo(self).offset(12);
|
||||
make.right.equalTo(self.sendButton.mas_left).offset(-12);
|
||||
make.centerY.equalTo(self);
|
||||
make.height.mas_equalTo(36);
|
||||
make.height.mas_equalTo(52);
|
||||
}];
|
||||
|
||||
[self.textField mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
@@ -69,17 +71,23 @@
|
||||
make.width.mas_equalTo(50);
|
||||
make.height.mas_equalTo(30);
|
||||
}];
|
||||
|
||||
[self.placeholderLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(self);
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (void)setPlaceholder:(NSString *)placeholder {
|
||||
_placeholder = placeholder;
|
||||
self.textField.placeholder = placeholder;
|
||||
self.placeholderLabel.text = placeholder;
|
||||
[self updatePlaceholderVisibility];
|
||||
}
|
||||
|
||||
- (void)clearText {
|
||||
self.textField.text = @"";
|
||||
[self updatePlaceholderVisibility];
|
||||
[self updateSendButtonState];
|
||||
}
|
||||
|
||||
@@ -97,13 +105,14 @@
|
||||
}
|
||||
|
||||
- (void)textFieldDidChange:(UITextField *)textField {
|
||||
[self updatePlaceholderVisibility];
|
||||
[self updateSendButtonState];
|
||||
}
|
||||
|
||||
- (void)updateSendButtonState {
|
||||
BOOL hasText = self.textField.text.length > 0;
|
||||
self.sendButton.enabled = hasText;
|
||||
self.sendButton.alpha = hasText ? 1.0 : 0.5;
|
||||
// self.sendButton.alpha = hasText ? 1.0 : 0.5;
|
||||
}
|
||||
|
||||
#pragma mark - UITextFieldDelegate
|
||||
@@ -113,6 +122,15 @@
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (BOOL)textFieldShouldBeginEditing:(UITextField *)textField {
|
||||
[self updatePlaceholderVisibility];
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)textFieldDidEndEditing:(UITextField *)textField {
|
||||
[self updatePlaceholderVisibility];
|
||||
}
|
||||
|
||||
#pragma mark - Lazy Loading
|
||||
|
||||
- (UIView *)topLine {
|
||||
@@ -123,25 +141,24 @@
|
||||
return _topLine;
|
||||
}
|
||||
|
||||
- (UIImageView *)avatarImageView {
|
||||
if (!_avatarImageView) {
|
||||
_avatarImageView = [[UIImageView alloc] init];
|
||||
_avatarImageView.contentMode = UIViewContentModeScaleAspectFill;
|
||||
_avatarImageView.layer.cornerRadius = 16;
|
||||
_avatarImageView.layer.masksToBounds = YES;
|
||||
_avatarImageView.backgroundColor = [UIColor systemGray5Color];
|
||||
_avatarImageView.image = [UIImage systemImageNamed:@"person.circle.fill"];
|
||||
_avatarImageView.tintColor = [UIColor systemGray3Color];
|
||||
}
|
||||
return _avatarImageView;
|
||||
}
|
||||
//- (UIImageView *)avatarImageView {
|
||||
// if (!_avatarImageView) {
|
||||
// _avatarImageView = [[UIImageView alloc] init];
|
||||
// _avatarImageView.contentMode = UIViewContentModeScaleAspectFill;
|
||||
// _avatarImageView.layer.cornerRadius = 16;
|
||||
// _avatarImageView.layer.masksToBounds = YES;
|
||||
// _avatarImageView.backgroundColor = [UIColor systemGray5Color];
|
||||
// _avatarImageView.image = [UIImage systemImageNamed:@"person.circle.fill"];
|
||||
// _avatarImageView.tintColor = [UIColor systemGray3Color];
|
||||
// }
|
||||
// return _avatarImageView;
|
||||
//}
|
||||
|
||||
- (UIView *)containerView {
|
||||
if (!_containerView) {
|
||||
_containerView = [[UIView alloc] init];
|
||||
_containerView.backgroundColor = [UIColor systemGray6Color];
|
||||
_containerView.layer.cornerRadius = 18;
|
||||
_containerView.layer.masksToBounds = YES;
|
||||
// _containerView.layer.cornerRadius = 26;
|
||||
// _containerView.layer.masksToBounds = YES;
|
||||
}
|
||||
return _containerView;
|
||||
}
|
||||
@@ -149,27 +166,42 @@
|
||||
- (UITextField *)textField {
|
||||
if (!_textField) {
|
||||
_textField = [[UITextField alloc] init];
|
||||
_textField.placeholder = @"说点什么...";
|
||||
_textField.textColor = [UIColor whiteColor];
|
||||
_textField.textAlignment = NSTextAlignmentLeft;
|
||||
_textField.font = [UIFont systemFontOfSize:14];
|
||||
_textField.delegate = self;
|
||||
_textField.returnKeyType = UIReturnKeySend;
|
||||
[_textField addTarget:self
|
||||
action:@selector(textFieldDidChange:)
|
||||
forControlEvents:UIControlEventEditingChanged];
|
||||
[self updatePlaceholderVisibility];
|
||||
}
|
||||
return _textField;
|
||||
}
|
||||
|
||||
- (UILabel *)placeholderLabel {
|
||||
if (!_placeholderLabel) {
|
||||
_placeholderLabel = [[UILabel alloc] init];
|
||||
_placeholderLabel.text = @"Send A Message";
|
||||
_placeholderLabel.textColor = [UIColor whiteColor];
|
||||
_placeholderLabel.font = [UIFont systemFontOfSize:14];
|
||||
_placeholderLabel.textAlignment = NSTextAlignmentCenter;
|
||||
_placeholderLabel.userInteractionEnabled = NO;
|
||||
}
|
||||
return _placeholderLabel;
|
||||
}
|
||||
|
||||
- (UIButton *)sendButton {
|
||||
if (!_sendButton) {
|
||||
_sendButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
[_sendButton setTitle:@"发送" forState:UIControlStateNormal];
|
||||
[_sendButton setTitleColor:[UIColor systemBlueColor]
|
||||
forState:UIControlStateNormal];
|
||||
_sendButton.titleLabel.font = [UIFont systemFontOfSize:15
|
||||
weight:UIFontWeightMedium];
|
||||
// [_sendButton setTitle:@"发送" forState:UIControlStateNormal];
|
||||
// [_sendButton setTitleColor:[UIColor systemBlueColor]
|
||||
// forState:UIControlStateNormal];
|
||||
// _sendButton.titleLabel.font = [UIFont systemFontOfSize:15
|
||||
// weight:UIFontWeightMedium];
|
||||
[_sendButton setImage:[UIImage imageNamed:@"ai_sendmessage_icon"] forState:UIControlStateNormal];
|
||||
_sendButton.enabled = NO;
|
||||
_sendButton.alpha = 0.5;
|
||||
// _sendButton.alpha = 0.5;
|
||||
[_sendButton addTarget:self
|
||||
action:@selector(sendButtonTapped)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
@@ -177,4 +209,11 @@
|
||||
return _sendButton;
|
||||
}
|
||||
|
||||
#pragma mark - Private
|
||||
|
||||
- (void)updatePlaceholderVisibility {
|
||||
BOOL hasText = self.textField.text.length > 0;
|
||||
self.placeholderLabel.hidden = hasText;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -189,8 +189,8 @@ static NSString *const kCommentFooterIdentifier = @"CommentFooter";
|
||||
}];
|
||||
|
||||
[self.inputView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self);
|
||||
make.height.mas_equalTo(50);
|
||||
make.left.right.equalTo(self).inset(12);
|
||||
make.height.mas_equalTo(52);
|
||||
self.inputBottomConstraint =
|
||||
make.bottom.equalTo(self).offset(-KB_SafeAreaBottom());
|
||||
}];
|
||||
@@ -846,8 +846,9 @@ static NSInteger const kRepliesLoadCount = 5;
|
||||
- (KBAICommentInputView *)inputView {
|
||||
if (!_inputView) {
|
||||
_inputView = [[KBAICommentInputView alloc] init];
|
||||
_inputView.placeholder = @"说点什么...";
|
||||
|
||||
_inputView.placeholder = @"Send A Message";
|
||||
_inputView.layer.cornerRadius = 26;
|
||||
_inputView.clipsToBounds = true;
|
||||
__weak typeof(self) weakSelf = self;
|
||||
_inputView.onSend = ^(NSString *text) {
|
||||
[weakSelf sendCommentWithText:text];
|
||||
|
||||
@@ -213,7 +213,7 @@
|
||||
_replyButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
_replyButton.titleLabel.font = [UIFont systemFontOfSize:11];
|
||||
[_replyButton setTitle:@"回复" forState:UIControlStateNormal];
|
||||
[_replyButton setTitleColor:[UIColor secondaryLabelColor] forState:UIControlStateNormal];
|
||||
[_replyButton setTitleColor:[UIColor colorWithHex:0x9F9F9F] forState:UIControlStateNormal];
|
||||
[_replyButton addTarget:self
|
||||
action:@selector(replyButtonTapped)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
|
||||
@@ -25,6 +25,9 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
/// 波形条间距
|
||||
@property(nonatomic, assign) CGFloat barSpacing;
|
||||
|
||||
/// 自定义柱状高度基准(0~1),长度需 >= barCount,未设置则使用默认算法
|
||||
@property(nonatomic, strong, nullable) NSArray<NSNumber *> *barHeightPattern;
|
||||
|
||||
/// 更新音量值
|
||||
/// @param rms 音量 RMS 值 (0.0 - 1.0)
|
||||
- (void)updateWithRMS:(float)rms;
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
@property(nonatomic, assign) float currentRMS;
|
||||
@property(nonatomic, assign) float targetRMS;
|
||||
@property(nonatomic, assign) BOOL isAnimating;
|
||||
@property(nonatomic, assign) NSInteger debugFrameCount;
|
||||
@property(nonatomic, assign) CGSize lastLayoutSize;
|
||||
@end
|
||||
|
||||
@implementation KBAiWaveformView
|
||||
@@ -49,6 +51,11 @@
|
||||
|
||||
- (void)layoutSubviews {
|
||||
[super layoutSubviews];
|
||||
if (CGSizeEqualToSize(self.lastLayoutSize, self.bounds.size) &&
|
||||
self.barLayers.count == self.barCount) {
|
||||
return;
|
||||
}
|
||||
self.lastLayoutSize = self.bounds.size;
|
||||
[self setupBars];
|
||||
}
|
||||
|
||||
@@ -73,10 +80,19 @@
|
||||
barLayer.cornerRadius = self.barWidth / 2;
|
||||
|
||||
CGFloat x = startX + i * (self.barWidth + self.barSpacing);
|
||||
CGFloat height = minHeight;
|
||||
CGFloat height = maxHeight; // 统一用满高,后续用 transform.scale.y 控制高度
|
||||
if (self.barHeightPattern.count > i) {
|
||||
CGFloat base = [self.barHeightPattern[i] floatValue];
|
||||
base = MIN(MAX(base, 0.15), 0.9);
|
||||
height = MAX(minHeight, maxHeight * base);
|
||||
}
|
||||
CGFloat y = (maxHeight - height) / 2;
|
||||
|
||||
barLayer.frame = CGRectMake(x, y, self.barWidth, height);
|
||||
barLayer.frame = CGRectMake(x, 0, self.barWidth, maxHeight);
|
||||
barLayer.anchorPoint = CGPointMake(0.5, 0.5);
|
||||
barLayer.position = CGPointMake(x + self.barWidth / 2, maxHeight / 2);
|
||||
CGFloat scale = height / maxHeight;
|
||||
barLayer.transform = CATransform3DMakeScale(1, scale, 1);
|
||||
barLayer.backgroundColor = self.waveColor.CGColor;
|
||||
|
||||
[self.layer addSublayer:barLayer];
|
||||
@@ -89,24 +105,42 @@
|
||||
|
||||
- (void)updateWithRMS:(float)rms {
|
||||
self.targetRMS = MIN(MAX(rms, 0), 1);
|
||||
NSLog(@"[KBAiWaveformView] updateWithRMS: %.3f, targetRMS=%.3f, barCount=%ld, size=%@",
|
||||
rms, self.targetRMS, (long)self.barLayers.count, NSStringFromCGRect(self.bounds));
|
||||
if (!self.displayLink) {
|
||||
self.currentRMS = self.targetRMS;
|
||||
[self updateBarsWithRMS:self.currentRMS];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)startIdleAnimation {
|
||||
if (self.isAnimating)
|
||||
NSLog(@"[KBAiWaveformView] startIdleAnimation (animating=%d, bars=%ld, size=%@)",
|
||||
self.isAnimating, (long)self.barLayers.count, NSStringFromCGRect(self.bounds));
|
||||
if (self.isAnimating && self.displayLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.isAnimating = YES;
|
||||
self.displayLink =
|
||||
[CADisplayLink displayLinkWithTarget:self
|
||||
self.debugFrameCount = 0;
|
||||
[self.displayLink invalidate];
|
||||
self.displayLink = [CADisplayLink displayLinkWithTarget:self
|
||||
selector:@selector(updateAnimation)];
|
||||
if (@available(iOS 10.0, *)) {
|
||||
self.displayLink.preferredFramesPerSecond = 60;
|
||||
}
|
||||
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop]
|
||||
forMode:NSRunLoopCommonModes];
|
||||
NSLog(@"[KBAiWaveformView] displayLink started");
|
||||
}
|
||||
|
||||
- (void)stopAnimation {
|
||||
NSLog(@"[KBAiWaveformView] stopAnimation (bars=%ld)", (long)self.barLayers.count);
|
||||
self.isAnimating = NO;
|
||||
[self.displayLink invalidate];
|
||||
self.displayLink = nil;
|
||||
for (CAShapeLayer *layer in self.barLayers) {
|
||||
[layer removeAnimationForKey:@"kb_idle_scale"];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)reset {
|
||||
@@ -118,8 +152,16 @@
|
||||
#pragma mark - Animation
|
||||
|
||||
- (void)updateAnimation {
|
||||
if (!self.isAnimating) {
|
||||
return;
|
||||
}
|
||||
self.debugFrameCount += 1;
|
||||
if (self.debugFrameCount % 30 == 0) {
|
||||
NSLog(@"[KBAiWaveformView] tick (target=%.3f, current=%.3f, bars=%ld)",
|
||||
self.targetRMS, self.currentRMS, (long)self.barLayers.count);
|
||||
}
|
||||
// 平滑过渡到目标 RMS
|
||||
CGFloat smoothing = 0.3;
|
||||
CGFloat smoothing = 0.65;
|
||||
self.currentRMS =
|
||||
self.currentRMS + (self.targetRMS - self.currentRMS) * smoothing;
|
||||
|
||||
@@ -139,19 +181,26 @@
|
||||
|
||||
// 添加基于时间的波动效果
|
||||
CGFloat phase = (CGFloat)i / self.barLayers.count * M_PI * 2;
|
||||
CGFloat wave = sin(time * 3 + phase) * 0.3 + 0.7; // 0.4 - 1.0
|
||||
CGFloat wave = sin(time * 8 + phase) * 0.3 + 0.7; // 0.4 - 1.0
|
||||
|
||||
// 计算高度
|
||||
CGFloat heightFactor = rms * wave;
|
||||
CGFloat height = minHeight + range * heightFactor;
|
||||
CGFloat baseFactor = 0.2;
|
||||
if (self.barHeightPattern.count > i) {
|
||||
baseFactor = [self.barHeightPattern[i] floatValue];
|
||||
baseFactor = MIN(MAX(baseFactor, 0.15), 0.9);
|
||||
}
|
||||
// 让基准高度也随时间轻微摆动(长按时更明显)
|
||||
CGFloat idleWave = sin(time * 10 + phase) * 0.25 + 0.85; // 0.6 - 1.1
|
||||
CGFloat baseWave = MIN(MAX(baseFactor * idleWave, 0.15), 0.95);
|
||||
// 计算高度:基准高度 + 随 RMS 波动
|
||||
CGFloat dynamicFactor = rms * (0.45 + 0.20 * wave); // 0.45~0.65
|
||||
CGFloat heightFactor = MIN(1.0, baseWave + dynamicFactor * (1.0 - baseWave));
|
||||
CGFloat height = maxHeight * heightFactor;
|
||||
height = MAX(minHeight, MIN(maxHeight, height));
|
||||
|
||||
// 更新位置
|
||||
CGFloat y = (maxHeight - height) / 2;
|
||||
CGFloat scale = height / maxHeight;
|
||||
|
||||
[CATransaction begin];
|
||||
[CATransaction setDisableActions:YES];
|
||||
layer.frame = CGRectMake(layer.frame.origin.x, y, self.barWidth, height);
|
||||
layer.transform = CATransform3DMakeScale(1, scale, 1);
|
||||
[CATransaction commit];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
#import "KBVoiceInputBar.h"
|
||||
#import "KBAiRecordButton.h"
|
||||
#import "KBAiWaveformView.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
|
||||
@interface KBVoiceInputBar () <KBAiRecordButtonDelegate>
|
||||
@@ -34,6 +35,8 @@
|
||||
/// 录音中视图
|
||||
@property (nonatomic, strong) UIView *recordingView;
|
||||
@property (nonatomic, strong) UIImageView *recordingCenterIconView;
|
||||
@property (nonatomic, strong) KBAiWaveformView *leftWaveformView;
|
||||
@property (nonatomic, strong) KBAiWaveformView *rightWaveformView;
|
||||
|
||||
/// 取消视图
|
||||
@property (nonatomic, strong) UIView *cancelView;
|
||||
@@ -146,6 +149,21 @@
|
||||
make.width.height.mas_equalTo(36);
|
||||
}];
|
||||
|
||||
[self.recordingView addSubview:self.leftWaveformView];
|
||||
[self.recordingView addSubview:self.rightWaveformView];
|
||||
[self.leftWaveformView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerY.equalTo(self.recordingCenterIconView);
|
||||
make.right.equalTo(self.recordingCenterIconView.mas_left).offset(-16);
|
||||
make.width.mas_equalTo(84);
|
||||
make.height.mas_equalTo(34);
|
||||
}];
|
||||
[self.rightWaveformView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerY.equalTo(self.recordingCenterIconView);
|
||||
make.left.equalTo(self.recordingCenterIconView.mas_right).offset(16);
|
||||
make.width.mas_equalTo(84);
|
||||
make.height.mas_equalTo(34);
|
||||
}];
|
||||
|
||||
// 取消视图
|
||||
[self.inputContainer addSubview:self.cancelView];
|
||||
[self.cancelView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
@@ -210,6 +228,7 @@
|
||||
|
||||
- (void)setInputState:(KBVoiceInputBarState)inputState {
|
||||
_inputState = inputState;
|
||||
NSLog(@"[KBVoiceInputBar] setInputState: %ld", (long)inputState);
|
||||
self.textInputView.hidden = (inputState != KBVoiceInputBarStateText);
|
||||
self.voiceInputView.hidden = (inputState != KBVoiceInputBarStateVoice);
|
||||
self.recordingView.hidden = (inputState != KBVoiceInputBarStateRecording);
|
||||
@@ -224,6 +243,11 @@
|
||||
if (!self.toggleIconButton.hidden) {
|
||||
[self.inputContainer bringSubviewToFront:self.toggleIconButton];
|
||||
}
|
||||
if (inputState == KBVoiceInputBarStateRecording) {
|
||||
[self startRecordingWaveAnimationIfNeeded];
|
||||
} else {
|
||||
[self stopRecordingWaveAnimation];
|
||||
}
|
||||
[self updateCenterTextIfNeeded];
|
||||
}
|
||||
|
||||
@@ -231,6 +255,12 @@
|
||||
|
||||
- (void)updateVolumeRMS:(float)rms {
|
||||
[self.recordButton updateVolumeRMS:rms];
|
||||
if (self.inputState == KBVoiceInputBarStateRecording) {
|
||||
CGFloat safeRMS = MAX(rms, 0.6f);
|
||||
NSLog(@"[KBVoiceInputBar] updateVolumeRMS: %.3f (safe=%.3f)", rms, safeRMS);
|
||||
[self.leftWaveformView updateWithRMS:safeRMS];
|
||||
[self.rightWaveformView updateWithRMS:safeRMS];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - KBAiRecordButtonDelegate
|
||||
@@ -370,6 +400,30 @@
|
||||
return _recordingCenterIconView;
|
||||
}
|
||||
|
||||
- (KBAiWaveformView *)leftWaveformView {
|
||||
if (!_leftWaveformView) {
|
||||
_leftWaveformView = [[KBAiWaveformView alloc] init];
|
||||
_leftWaveformView.waveColor = [UIColor whiteColor];
|
||||
_leftWaveformView.barCount = 7;
|
||||
_leftWaveformView.barWidth = 3;
|
||||
_leftWaveformView.barSpacing = 6;
|
||||
_leftWaveformView.barHeightPattern = @[@0.35, @0.85, @0.5, @0.35, @0.9, @0.55, @0.35];
|
||||
}
|
||||
return _leftWaveformView;
|
||||
}
|
||||
|
||||
- (KBAiWaveformView *)rightWaveformView {
|
||||
if (!_rightWaveformView) {
|
||||
_rightWaveformView = [[KBAiWaveformView alloc] init];
|
||||
_rightWaveformView.waveColor = [UIColor whiteColor];
|
||||
_rightWaveformView.barCount = 7;
|
||||
_rightWaveformView.barWidth = 3;
|
||||
_rightWaveformView.barSpacing = 6;
|
||||
_rightWaveformView.barHeightPattern = @[@0.35, @0.85, @0.5, @0.35, @0.9, @0.55, @0.35];
|
||||
}
|
||||
return _rightWaveformView;
|
||||
}
|
||||
|
||||
- (UIView *)cancelView {
|
||||
if (!_cancelView) {
|
||||
_cancelView = [[UIView alloc] init];
|
||||
@@ -478,4 +532,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Recording Wave
|
||||
|
||||
- (void)startRecordingWaveAnimationIfNeeded {
|
||||
NSLog(@"[KBVoiceInputBar] startRecordingWaveAnimationIfNeeded");
|
||||
self.leftWaveformView.hidden = NO;
|
||||
self.rightWaveformView.hidden = NO;
|
||||
[self.inputContainer setNeedsLayout];
|
||||
[self.recordingView setNeedsLayout];
|
||||
[self.inputContainer layoutIfNeeded];
|
||||
[self.recordingView layoutIfNeeded];
|
||||
[self.leftWaveformView setNeedsLayout];
|
||||
[self.rightWaveformView setNeedsLayout];
|
||||
[self.leftWaveformView layoutIfNeeded];
|
||||
[self.rightWaveformView layoutIfNeeded];
|
||||
NSLog(@"[KBVoiceInputBar] waveform frames L=%@ R=%@",
|
||||
NSStringFromCGRect(self.leftWaveformView.frame),
|
||||
NSStringFromCGRect(self.rightWaveformView.frame));
|
||||
[self.leftWaveformView startIdleAnimation];
|
||||
[self.rightWaveformView startIdleAnimation];
|
||||
[self.leftWaveformView updateWithRMS:0.7f];
|
||||
[self.rightWaveformView updateWithRMS:0.7f];
|
||||
}
|
||||
|
||||
- (void)stopRecordingWaveAnimation {
|
||||
NSLog(@"[KBVoiceInputBar] stopRecordingWaveAnimation");
|
||||
[self.leftWaveformView stopAnimation];
|
||||
[self.rightWaveformView stopAnimation];
|
||||
[self.leftWaveformView reset];
|
||||
[self.rightWaveformView reset];
|
||||
self.leftWaveformView.hidden = YES;
|
||||
self.rightWaveformView.hidden = YES;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
51
keyBoard/Class/AiTalk/V/PopView/KBAIPersonaSidebarView.h
Normal file
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// KBAIPersonaSidebarView.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Codex on 2026/2/3.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class KBPersonaModel;
|
||||
@class KBAIPersonaSidebarView;
|
||||
|
||||
@protocol KBAIPersonaSidebarViewDelegate <NSObject>
|
||||
@optional
|
||||
/// 侧边栏请求人设数据
|
||||
/// page 从 1 开始
|
||||
- (void)personaSidebarView:(KBAIPersonaSidebarView *)view
|
||||
requestPersonasAtPage:(NSInteger)page;
|
||||
/// 选择某个人设
|
||||
- (void)personaSidebarView:(KBAIPersonaSidebarView *)view
|
||||
didSelectPersona:(KBPersonaModel *)persona;
|
||||
@end
|
||||
|
||||
/// 人设侧边栏(LSTPopView 内容视图)
|
||||
@interface KBAIPersonaSidebarView : UIView
|
||||
|
||||
@property (nonatomic, weak) id<KBAIPersonaSidebarViewDelegate> delegate;
|
||||
@property (nonatomic, assign) NSInteger selectedPersonaId;
|
||||
@property (nonatomic, assign, readonly) NSInteger currentPage;
|
||||
|
||||
/// 更新人设列表
|
||||
/// reset=YES 表示重置并替换数据;reset=NO 表示追加
|
||||
/// currentPage 为当前已加载页数(从 1 开始)
|
||||
- (void)updatePersonas:(NSArray<KBPersonaModel *> *)personas
|
||||
reset:(BOOL)reset
|
||||
hasMore:(BOOL)hasMore
|
||||
currentPage:(NSInteger)currentPage;
|
||||
/// 请求数据(若为空)
|
||||
- (void)requestPersonasIfNeeded;
|
||||
/// 更新选中态
|
||||
- (void)updateSelectedPersonaId:(NSInteger)personaId;
|
||||
/// 结束加载更多
|
||||
- (void)endLoadingMore;
|
||||
/// 重置加载更多状态
|
||||
- (void)resetLoadMore;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
527
keyBoard/Class/AiTalk/V/PopView/KBAIPersonaSidebarView.m
Normal file
@@ -0,0 +1,527 @@
|
||||
//
|
||||
// KBAIPersonaSidebarView.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Codex on 2026/2/3.
|
||||
//
|
||||
|
||||
#import "KBAIPersonaSidebarView.h"
|
||||
#import "KBPersonaModel.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
#import <SDWebImage/SDWebImage.h>
|
||||
#import <MJRefresh/MJRefresh.h>
|
||||
|
||||
#pragma mark - Cell
|
||||
|
||||
@interface KBAIPersonaSidebarCell : UITableViewCell
|
||||
|
||||
@property (nonatomic, strong) UIImageView *avatarImageView;
|
||||
@property (nonatomic, strong) UILabel *nameLabel;
|
||||
@property (nonatomic, strong) UILabel *descLabel;
|
||||
@property (nonatomic, strong) UIImageView *checkImageView;
|
||||
@property (nonatomic, strong) UIImageView *arrowImageView;
|
||||
@property (nonatomic, strong) UIView *lineView;
|
||||
|
||||
- (void)configureWithPersona:(KBPersonaModel *)persona selected:(BOOL)selected;
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBAIPersonaSidebarCell
|
||||
|
||||
- (instancetype)initWithStyle:(UITableViewCellStyle)style
|
||||
reuseIdentifier:(NSString *)reuseIdentifier {
|
||||
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
|
||||
if (self) {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
self.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
[self setupUI];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setupUI {
|
||||
[self.contentView addSubview:self.avatarImageView];
|
||||
[self.contentView addSubview:self.nameLabel];
|
||||
[self.contentView addSubview:self.descLabel];
|
||||
[self.contentView addSubview:self.checkImageView];
|
||||
[self.contentView addSubview:self.arrowImageView];
|
||||
[self.contentView addSubview:self.lineView];
|
||||
|
||||
[self.avatarImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.contentView).offset(16);
|
||||
make.centerY.equalTo(self.contentView);
|
||||
make.width.height.mas_equalTo(44);
|
||||
}];
|
||||
|
||||
[self.nameLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.avatarImageView.mas_right).offset(12);
|
||||
make.top.equalTo(self.avatarImageView).offset(2);
|
||||
make.right.lessThanOrEqualTo(self.contentView).offset(-60);
|
||||
}];
|
||||
|
||||
[self.descLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.nameLabel);
|
||||
make.top.equalTo(self.nameLabel.mas_bottom).offset(4);
|
||||
make.right.lessThanOrEqualTo(self.contentView).offset(-60);
|
||||
}];
|
||||
|
||||
[self.checkImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerY.equalTo(self.contentView);
|
||||
make.right.equalTo(self.contentView).offset(-16);
|
||||
make.width.height.mas_equalTo(22);
|
||||
}];
|
||||
|
||||
[self.arrowImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerY.equalTo(self.contentView);
|
||||
make.right.equalTo(self.contentView).offset(-18);
|
||||
make.width.mas_equalTo(6);
|
||||
make.height.mas_equalTo(8);
|
||||
|
||||
}];
|
||||
|
||||
[self.lineView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.nameLabel);
|
||||
make.right.equalTo(self.contentView).offset(-16);
|
||||
make.bottom.equalTo(self.contentView);
|
||||
make.height.mas_equalTo(0.5);
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)configureWithPersona:(KBPersonaModel *)persona selected:(BOOL)selected {
|
||||
if (!persona) {
|
||||
return;
|
||||
}
|
||||
[self.avatarImageView sd_setImageWithURL:[NSURL URLWithString:persona.avatarUrl]
|
||||
placeholderImage:[UIImage imageNamed:@"placeholder_avatar"]];
|
||||
self.nameLabel.text = persona.name ?: @"";
|
||||
NSString *desc = persona.shortDesc.length > 0 ? persona.shortDesc : persona.introText;
|
||||
self.descLabel.text = desc ?: @"";
|
||||
self.checkImageView.hidden = !selected;
|
||||
self.arrowImageView.hidden = selected;
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIImageView *)avatarImageView {
|
||||
if (!_avatarImageView) {
|
||||
_avatarImageView = [[UIImageView alloc] init];
|
||||
_avatarImageView.contentMode = UIViewContentModeScaleAspectFill;
|
||||
_avatarImageView.layer.cornerRadius = 22;
|
||||
_avatarImageView.clipsToBounds = YES;
|
||||
_avatarImageView.layer.borderWidth = 1;
|
||||
_avatarImageView.layer.borderColor = [[UIColor whiteColor] colorWithAlphaComponent:0.6].CGColor;
|
||||
}
|
||||
return _avatarImageView;
|
||||
}
|
||||
|
||||
- (UILabel *)nameLabel {
|
||||
if (!_nameLabel) {
|
||||
_nameLabel = [[UILabel alloc] init];
|
||||
_nameLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightMedium];
|
||||
_nameLabel.textColor = [UIColor whiteColor];
|
||||
}
|
||||
return _nameLabel;
|
||||
}
|
||||
|
||||
- (UILabel *)descLabel {
|
||||
if (!_descLabel) {
|
||||
_descLabel = [[UILabel alloc] init];
|
||||
_descLabel.font = [UIFont systemFontOfSize:12];
|
||||
_descLabel.textColor = [[UIColor whiteColor] colorWithAlphaComponent:0.7];
|
||||
_descLabel.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||
}
|
||||
return _descLabel;
|
||||
}
|
||||
|
||||
- (UIImageView *)checkImageView {
|
||||
if (!_checkImageView) {
|
||||
_checkImageView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"ai_role_sel"]];
|
||||
}
|
||||
return _checkImageView;
|
||||
}
|
||||
|
||||
- (UIImageView *)arrowImageView {
|
||||
if (!_arrowImageView) {
|
||||
_arrowImageView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"right_arrow_icon"]];
|
||||
}
|
||||
return _arrowImageView;
|
||||
}
|
||||
|
||||
- (UIView *)lineView {
|
||||
if (!_lineView) {
|
||||
_lineView = [[UIView alloc] init];
|
||||
_lineView.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.12];
|
||||
}
|
||||
return _lineView;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - View
|
||||
|
||||
@interface KBAIPersonaSidebarView () <UITableViewDelegate, UITableViewDataSource, UITextFieldDelegate>
|
||||
|
||||
@property (nonatomic, strong) UIVisualEffectView *blurView;
|
||||
@property (nonatomic, strong) UIView *contentView;
|
||||
@property (nonatomic, strong) UIView *searchContainer;
|
||||
@property (nonatomic, strong) UIImageView *searchIconView;
|
||||
@property (nonatomic, strong) UITextField *searchField;
|
||||
@property (nonatomic, strong) BaseTableView *tableView;
|
||||
@property (nonatomic, strong) UIView *searchResultContainer;
|
||||
@property (nonatomic, strong) BaseTableView *searchResultTableView;
|
||||
|
||||
@property (nonatomic, strong) NSArray<KBPersonaModel *> *personas;
|
||||
@property (nonatomic, strong) NSArray<KBPersonaModel *> *displayPersonas;
|
||||
@property (nonatomic, strong) NSArray<KBPersonaModel *> *searchResults;
|
||||
@property (nonatomic, assign) BOOL isShowingSearchResults;
|
||||
@property (nonatomic, assign) NSInteger currentPage;
|
||||
@property (nonatomic, assign) BOOL hasMore;
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBAIPersonaSidebarView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
self = [super initWithFrame:frame];
|
||||
if (self) {
|
||||
[self setupUI];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setupUI {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
|
||||
[self addSubview:self.blurView];
|
||||
[self addSubview:self.contentView];
|
||||
|
||||
[self.contentView addSubview:self.searchContainer];
|
||||
[self.searchContainer addSubview:self.searchIconView];
|
||||
[self.searchContainer addSubview:self.searchField];
|
||||
[self.contentView addSubview:self.tableView];
|
||||
[self.contentView addSubview:self.searchResultContainer];
|
||||
[self.searchResultContainer addSubview:self.searchResultTableView];
|
||||
|
||||
[self.blurView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self);
|
||||
}];
|
||||
|
||||
[self.contentView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self);
|
||||
}];
|
||||
|
||||
[self.searchContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.contentView).offset(KB_STATUSBAR_HEIGHT + 20);
|
||||
make.left.equalTo(self.contentView).offset(16);
|
||||
make.right.equalTo(self.contentView).offset(-16);
|
||||
make.height.mas_equalTo(36);
|
||||
}];
|
||||
|
||||
[self.searchIconView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.searchContainer).offset(12);
|
||||
make.centerY.equalTo(self.searchContainer);
|
||||
make.width.height.mas_equalTo(16);
|
||||
}];
|
||||
|
||||
[self.searchField mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.searchIconView.mas_right).offset(8);
|
||||
make.right.equalTo(self.searchContainer).offset(-12);
|
||||
make.centerY.equalTo(self.searchContainer);
|
||||
make.height.mas_equalTo(28);
|
||||
}];
|
||||
|
||||
[self.tableView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.searchContainer.mas_bottom).offset(10);
|
||||
make.left.right.bottom.equalTo(self.contentView);
|
||||
}];
|
||||
|
||||
[self.searchResultContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.searchContainer.mas_bottom).offset(10);
|
||||
make.left.right.bottom.equalTo(self.contentView);
|
||||
}];
|
||||
|
||||
[self.searchResultTableView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.searchResultContainer);
|
||||
}];
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
MJRefreshAutoNormalFooter *footer = [MJRefreshAutoNormalFooter footerWithRefreshingBlock:^{
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf) {
|
||||
return;
|
||||
}
|
||||
if (!strongSelf.hasMore) {
|
||||
[strongSelf.tableView.mj_footer endRefreshingWithNoMoreData];
|
||||
return;
|
||||
}
|
||||
strongSelf.currentPage += 1;
|
||||
if ([strongSelf.delegate respondsToSelector:@selector(personaSidebarView:requestPersonasAtPage:)]) {
|
||||
[strongSelf.delegate personaSidebarView:strongSelf requestPersonasAtPage:strongSelf.currentPage];
|
||||
}
|
||||
}];
|
||||
footer.stateLabel.hidden = YES;
|
||||
footer.backgroundColor = [UIColor clearColor];
|
||||
footer.automaticallyHidden = YES;
|
||||
self.tableView.mj_footer = footer;
|
||||
}
|
||||
|
||||
#pragma mark - Public
|
||||
|
||||
- (void)requestPersonasIfNeeded {
|
||||
if (self.personas.count == 0) {
|
||||
self.currentPage = 1;
|
||||
self.hasMore = YES;
|
||||
NSLog(@"[SidebarSearch] 请求人设数据: page=%ld", (long)self.currentPage);
|
||||
if ([self.delegate respondsToSelector:@selector(personaSidebarView:requestPersonasAtPage:)]) {
|
||||
[self.delegate personaSidebarView:self requestPersonasAtPage:self.currentPage];
|
||||
}
|
||||
return;
|
||||
}
|
||||
[self applyFilterAndReload];
|
||||
}
|
||||
|
||||
- (void)updatePersonas:(NSArray<KBPersonaModel *> *)personas
|
||||
reset:(BOOL)reset
|
||||
hasMore:(BOOL)hasMore
|
||||
currentPage:(NSInteger)currentPage {
|
||||
self.hasMore = hasMore;
|
||||
NSInteger safePage = MAX(1, currentPage);
|
||||
// HomeVC 传入的是全量列表,这里直接替换,避免重复
|
||||
self.personas = personas ?: @[];
|
||||
self.currentPage = safePage;
|
||||
NSLog(@"[SidebarSearch] 更新人设: count=%ld, page=%ld, hasMore=%@",
|
||||
(long)self.personas.count, (long)self.currentPage, self.hasMore ? @"YES" : @"NO");
|
||||
[self applyFilterAndReload];
|
||||
if (self.isShowingSearchResults && self.searchField.text.length > 0) {
|
||||
[self performSearch];
|
||||
}
|
||||
[self endLoadingMore];
|
||||
}
|
||||
|
||||
- (void)updateSelectedPersonaId:(NSInteger)personaId {
|
||||
self.selectedPersonaId = personaId;
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
|
||||
- (void)endLoadingMore {
|
||||
if ([self.tableView.mj_footer isRefreshing]) {
|
||||
if (self.hasMore) {
|
||||
[self.tableView.mj_footer endRefreshing];
|
||||
} else {
|
||||
[self.tableView.mj_footer endRefreshingWithNoMoreData];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)resetLoadMore {
|
||||
[self.tableView.mj_footer resetNoMoreData];
|
||||
}
|
||||
|
||||
#pragma mark - Search
|
||||
|
||||
- (void)searchFieldChanged:(UITextField *)textField {
|
||||
NSString *keyword = [textField.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
NSLog(@"[SidebarSearch] 输入变化: \"%@\"", keyword);
|
||||
if (keyword.length == 0) {
|
||||
[self hideSearchResults];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)applyFilterAndReload {
|
||||
self.displayPersonas = self.personas;
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
|
||||
- (void)performSearch {
|
||||
NSString *keyword = [self.searchField.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
NSLog(@"[SidebarSearch] 执行搜索: \"%@\" (total=%ld)", keyword, (long)self.personas.count);
|
||||
if (keyword.length == 0) {
|
||||
[self hideSearchResults];
|
||||
return;
|
||||
}
|
||||
|
||||
NSMutableArray *result = [NSMutableArray array];
|
||||
for (KBPersonaModel *persona in self.personas) {
|
||||
NSString *name = persona.name ?: @"";
|
||||
NSString *desc = persona.shortDesc ?: persona.introText ?: @"";
|
||||
if ([name localizedCaseInsensitiveContainsString:keyword] ||
|
||||
[desc localizedCaseInsensitiveContainsString:keyword]) {
|
||||
[result addObject:persona];
|
||||
}
|
||||
}
|
||||
self.searchResults = result;
|
||||
NSLog(@"[SidebarSearch] 搜索结果: %ld 条", (long)self.searchResults.count);
|
||||
if (self.searchResults.count > 0) {
|
||||
[self showSearchResults];
|
||||
[self.searchResultTableView reloadData];
|
||||
} else {
|
||||
[self hideSearchResults];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)showSearchResults {
|
||||
self.isShowingSearchResults = YES;
|
||||
self.tableView.hidden = YES;
|
||||
self.searchResultContainer.hidden = NO;
|
||||
[self.contentView bringSubviewToFront:self.searchResultContainer];
|
||||
NSLog(@"[SidebarSearch] 显示搜索结果视图");
|
||||
}
|
||||
|
||||
- (void)hideSearchResults {
|
||||
self.isShowingSearchResults = NO;
|
||||
self.searchResults = @[];
|
||||
self.searchResultContainer.hidden = YES;
|
||||
self.tableView.hidden = NO;
|
||||
NSLog(@"[SidebarSearch] 隐藏搜索结果视图");
|
||||
}
|
||||
|
||||
#pragma mark - UITableViewDataSource
|
||||
|
||||
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
|
||||
if (tableView == self.searchResultTableView) {
|
||||
return self.searchResults.count;
|
||||
}
|
||||
return self.displayPersonas.count;
|
||||
}
|
||||
|
||||
- (UITableViewCell *)tableView:(UITableView *)tableView
|
||||
cellForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
KBAIPersonaSidebarCell *cell = [tableView dequeueReusableCellWithIdentifier:@"KBAIPersonaSidebarCell"
|
||||
forIndexPath:indexPath];
|
||||
KBPersonaModel *persona = (tableView == self.searchResultTableView)
|
||||
? self.searchResults[indexPath.row]
|
||||
: self.displayPersonas[indexPath.row];
|
||||
BOOL selected = (persona.personaId == self.selectedPersonaId);
|
||||
[cell configureWithPersona:persona selected:selected];
|
||||
return cell;
|
||||
}
|
||||
|
||||
#pragma mark - UITableViewDelegate
|
||||
|
||||
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
return 72.0;
|
||||
}
|
||||
|
||||
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
NSArray *source = (tableView == self.searchResultTableView) ? self.searchResults : self.displayPersonas;
|
||||
if (indexPath.row >= source.count) {
|
||||
return;
|
||||
}
|
||||
KBPersonaModel *persona = source[indexPath.row];
|
||||
self.selectedPersonaId = persona.personaId;
|
||||
[self.tableView reloadData];
|
||||
if (self.isShowingSearchResults) {
|
||||
[self.searchResultTableView reloadData];
|
||||
}
|
||||
if ([self.delegate respondsToSelector:@selector(personaSidebarView:didSelectPersona:)]) {
|
||||
[self.delegate personaSidebarView:self didSelectPersona:persona];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIVisualEffectView *)blurView {
|
||||
if (!_blurView) {
|
||||
UIBlurEffect *effect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleDark];
|
||||
_blurView = [[UIVisualEffectView alloc] initWithEffect:effect];
|
||||
}
|
||||
return _blurView;
|
||||
}
|
||||
|
||||
- (UIView *)contentView {
|
||||
if (!_contentView) {
|
||||
_contentView = [[UIView alloc] init];
|
||||
_contentView.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.25];
|
||||
}
|
||||
return _contentView;
|
||||
}
|
||||
|
||||
- (UIView *)searchContainer {
|
||||
if (!_searchContainer) {
|
||||
_searchContainer = [[UIView alloc] init];
|
||||
_searchContainer.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.12];
|
||||
_searchContainer.layer.cornerRadius = 18;
|
||||
_searchContainer.clipsToBounds = YES;
|
||||
}
|
||||
return _searchContainer;
|
||||
}
|
||||
|
||||
- (UIImageView *)searchIconView {
|
||||
if (!_searchIconView) {
|
||||
_searchIconView = [[UIImageView alloc] initWithImage:[UIImage systemImageNamed:@"magnifyingglass"]];
|
||||
_searchIconView.tintColor = [[UIColor whiteColor] colorWithAlphaComponent:0.8];
|
||||
}
|
||||
return _searchIconView;
|
||||
}
|
||||
|
||||
- (UITextField *)searchField {
|
||||
if (!_searchField) {
|
||||
_searchField = [[UITextField alloc] init];
|
||||
_searchField.placeholder = KBLocalized(@"Search Role");
|
||||
_searchField.textColor = [UIColor whiteColor];
|
||||
_searchField.font = [UIFont systemFontOfSize:14];
|
||||
_searchField.clearButtonMode = UITextFieldViewModeWhileEditing;
|
||||
_searchField.returnKeyType = UIReturnKeySearch;
|
||||
_searchField.delegate = self;
|
||||
[_searchField addTarget:self action:@selector(searchFieldChanged:) forControlEvents:UIControlEventEditingChanged];
|
||||
[_searchField addTarget:self action:@selector(searchFieldReturnTapped:) forControlEvents:UIControlEventEditingDidEndOnExit];
|
||||
}
|
||||
return _searchField;
|
||||
}
|
||||
|
||||
- (BaseTableView *)tableView {
|
||||
if (!_tableView) {
|
||||
_tableView = [[BaseTableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
|
||||
_tableView.backgroundColor = [UIColor clearColor];
|
||||
_tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
|
||||
_tableView.showsVerticalScrollIndicator = NO;
|
||||
_tableView.delegate = self;
|
||||
_tableView.dataSource = self;
|
||||
[_tableView registerClass:[KBAIPersonaSidebarCell class] forCellReuseIdentifier:@"KBAIPersonaSidebarCell"];
|
||||
}
|
||||
return _tableView;
|
||||
}
|
||||
|
||||
- (UIView *)searchResultContainer {
|
||||
if (!_searchResultContainer) {
|
||||
_searchResultContainer = [[UIView alloc] init];
|
||||
_searchResultContainer.backgroundColor = [UIColor clearColor];
|
||||
_searchResultContainer.hidden = YES;
|
||||
}
|
||||
return _searchResultContainer;
|
||||
}
|
||||
|
||||
- (BaseTableView *)searchResultTableView {
|
||||
if (!_searchResultTableView) {
|
||||
_searchResultTableView = [[BaseTableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
|
||||
_searchResultTableView.backgroundColor = [UIColor clearColor];
|
||||
_searchResultTableView.separatorStyle = UITableViewCellSeparatorStyleNone;
|
||||
_searchResultTableView.showsVerticalScrollIndicator = NO;
|
||||
_searchResultTableView.delegate = self;
|
||||
_searchResultTableView.dataSource = self;
|
||||
[_searchResultTableView registerClass:[KBAIPersonaSidebarCell class] forCellReuseIdentifier:@"KBAIPersonaSidebarCell"];
|
||||
}
|
||||
return _searchResultTableView;
|
||||
}
|
||||
|
||||
#pragma mark - UITextFieldDelegate
|
||||
|
||||
- (BOOL)textFieldShouldReturn:(UITextField *)textField {
|
||||
if (textField == self.searchField) {
|
||||
NSLog(@"[SidebarSearch] textFieldShouldReturn");
|
||||
[textField resignFirstResponder];
|
||||
[self performSearch];
|
||||
return YES;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (void)searchFieldReturnTapped:(UITextField *)textField {
|
||||
if (textField == self.searchField) {
|
||||
NSLog(@"[SidebarSearch] EditingDidEndOnExit");
|
||||
[textField resignFirstResponder];
|
||||
[self performSearch];
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -46,7 +46,7 @@
|
||||
[super viewDidLoad];
|
||||
|
||||
self.kb_navView.hidden = YES;
|
||||
self.view.backgroundColor = [UIColor blackColor];
|
||||
// self.view.backgroundColor = [UIColor blackColor];
|
||||
self.aiVM = [[AiVM alloc] init];
|
||||
|
||||
/// 1:控件初始化
|
||||
@@ -261,7 +261,7 @@
|
||||
- (UIButton *)closeButton {
|
||||
if (!_closeButton) {
|
||||
_closeButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
[_closeButton setImage:[UIImage imageNamed:@"comment_close_icon"] forState:UIControlStateNormal];
|
||||
[_closeButton setImage:[UIImage imageNamed:@"ai_close_icon"] forState:UIControlStateNormal];
|
||||
[_closeButton addTarget:self action:@selector(closeButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
return _closeButton;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
#import "AIReportVC.h"
|
||||
#import "AiVM.h"
|
||||
|
||||
#pragma mark - AIReportOptionCell
|
||||
|
||||
@@ -116,6 +117,8 @@
|
||||
/// 提交按钮
|
||||
@property (nonatomic, strong) UIButton *submitButton;
|
||||
|
||||
@property (nonatomic, strong) AiVM *viewModel;
|
||||
|
||||
@end
|
||||
|
||||
@implementation AIReportVC
|
||||
@@ -134,6 +137,8 @@
|
||||
[self setupUI];
|
||||
/// 3:绑定事件
|
||||
[self bindActions];
|
||||
/// 4:其他(通知/键盘等)
|
||||
[self bindKeyboardNotifications];
|
||||
}
|
||||
|
||||
#pragma mark - 1:初始化数据
|
||||
@@ -286,6 +291,59 @@
|
||||
[self.view endEditing:YES];
|
||||
}
|
||||
|
||||
#pragma mark - 4:键盘处理
|
||||
|
||||
- (void)bindKeyboardNotifications {
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(handleKeyboardWillChange:)
|
||||
name:UIKeyboardWillChangeFrameNotification
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(handleKeyboardWillHide:)
|
||||
name:UIKeyboardWillHideNotification
|
||||
object:nil];
|
||||
}
|
||||
|
||||
- (void)handleKeyboardWillChange:(NSNotification *)notification {
|
||||
NSDictionary *userInfo = notification.userInfo ?: @{};
|
||||
CGRect endFrame = [userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
|
||||
NSTimeInterval duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
|
||||
NSInteger curve = [userInfo[UIKeyboardAnimationCurveUserInfoKey] integerValue];
|
||||
|
||||
CGRect endFrameInView = [self.view convertRect:endFrame fromView:nil];
|
||||
CGFloat keyboardHeight = MAX(0, CGRectGetMaxY(self.view.bounds) - CGRectGetMinY(endFrameInView));
|
||||
|
||||
UIEdgeInsets inset = self.scrollView.contentInset;
|
||||
inset.bottom = keyboardHeight;
|
||||
|
||||
UIViewAnimationOptions options = (UIViewAnimationOptions)(curve << 16);
|
||||
[UIView animateWithDuration:duration delay:0 options:options animations:^{
|
||||
self.scrollView.contentInset = inset;
|
||||
self.scrollView.scrollIndicatorInsets = inset;
|
||||
if ([self.descriptionTextView isFirstResponder]) {
|
||||
CGRect rect = [self.descriptionTextView convertRect:self.descriptionTextView.bounds toView:self.scrollView];
|
||||
rect = CGRectInset(rect, 0, -12);
|
||||
[self.scrollView scrollRectToVisible:rect animated:NO];
|
||||
}
|
||||
} completion:nil];
|
||||
}
|
||||
|
||||
- (void)handleKeyboardWillHide:(NSNotification *)notification {
|
||||
NSDictionary *userInfo = notification.userInfo ?: @{};
|
||||
NSTimeInterval duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
|
||||
NSInteger curve = [userInfo[UIKeyboardAnimationCurveUserInfoKey] integerValue];
|
||||
UIViewAnimationOptions options = (UIViewAnimationOptions)(curve << 16);
|
||||
|
||||
[UIView animateWithDuration:duration delay:0 options:options animations:^{
|
||||
self.scrollView.contentInset = UIEdgeInsetsZero;
|
||||
self.scrollView.scrollIndicatorInsets = UIEdgeInsetsZero;
|
||||
} completion:nil];
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
#pragma mark - UITableViewDataSource
|
||||
|
||||
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
|
||||
@@ -351,6 +409,11 @@
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)submitButtonTapped {
|
||||
if (self.personaId <= 0) {
|
||||
[KBHUD showError:KBLocalized(@"Invalid parameter")];
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.selectedReasonIndexes.count == 0) {
|
||||
[KBHUD showError:KBLocalized(@"Please select at least one report reason")];
|
||||
return;
|
||||
@@ -373,19 +436,53 @@
|
||||
[selectedContents addObject:self.contentOptions[index.integerValue]];
|
||||
}
|
||||
|
||||
NSString *description = self.descriptionTextView.text ?: @"";
|
||||
NSString *reportDesc = [self.descriptionTextView.text ?: @"" stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
|
||||
NSLog(@"[AIReportVC] 举报人设 ID: %ld", (long)self.personaId);
|
||||
NSLog(@"[AIReportVC] 举报原因: %@", selectedReasons);
|
||||
NSLog(@"[AIReportVC] 内容类型: %@", selectedContents);
|
||||
NSLog(@"[AIReportVC] 描述: %@", description);
|
||||
NSLog(@"[AIReportVC] 描述: %@", reportDesc);
|
||||
|
||||
// TODO: 调用举报接口
|
||||
// 组装举报类型(从上到下 1-12:举报原因 1-9,内容类型 10-12)
|
||||
NSMutableArray<NSNumber *> *reportTypes = [NSMutableArray array];
|
||||
NSArray<NSNumber *> *sortedReasonIndexes = [[self.selectedReasonIndexes allObjects]
|
||||
sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"self" ascending:YES]]];
|
||||
for (NSNumber *index in sortedReasonIndexes) {
|
||||
NSInteger type = index.integerValue + 1;
|
||||
if (type > 0) {
|
||||
[reportTypes addObject:@(type)];
|
||||
}
|
||||
}
|
||||
NSArray<NSNumber *> *sortedContentIndexes = [[self.selectedContentIndexes allObjects]
|
||||
sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"self" ascending:YES]]];
|
||||
for (NSNumber *index in sortedContentIndexes) {
|
||||
NSInteger type = self.reportReasons.count + index.integerValue + 1;
|
||||
if (type > 0) {
|
||||
[reportTypes addObject:@(type)];
|
||||
}
|
||||
}
|
||||
|
||||
[KBHUD show];
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[self.viewModel reportCompanionWithCompanionId:self.personaId
|
||||
reportTypes:reportTypes
|
||||
reportDesc:reportDesc
|
||||
chatContext:nil
|
||||
evidenceImageUrl:nil
|
||||
completion:^(BOOL success, NSError * _Nullable error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[KBHUD dismiss];
|
||||
if (!success) {
|
||||
NSString *msg = error.localizedDescription ?: KBLocalized(@"Network error");
|
||||
[KBHUD showError:msg];
|
||||
return;
|
||||
}
|
||||
[KBHUD showSuccess:KBLocalized(@"Report submitted")];
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
[self.navigationController popViewControllerAnimated:YES];
|
||||
[weakSelf.navigationController popViewControllerAnimated:YES];
|
||||
});
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Lazy Load
|
||||
@@ -395,6 +492,7 @@
|
||||
_scrollView = [[UIScrollView alloc] init];
|
||||
_scrollView.showsVerticalScrollIndicator = NO;
|
||||
_scrollView.alwaysBounceVertical = YES;
|
||||
_scrollView.keyboardDismissMode = UIScrollViewKeyboardDismissModeOnDrag;
|
||||
}
|
||||
return _scrollView;
|
||||
}
|
||||
@@ -536,4 +634,11 @@
|
||||
return _submitButton;
|
||||
}
|
||||
|
||||
- (AiVM *)viewModel {
|
||||
if (!_viewModel) {
|
||||
_viewModel = [[AiVM alloc] init];
|
||||
}
|
||||
return _viewModel;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -18,10 +18,12 @@
|
||||
#import "KBUserSessionManager.h"
|
||||
#import "LSTPopView.h"
|
||||
#import "KBAIMessageVC.h"
|
||||
#import "KBAICommentInputView.h"
|
||||
#import "KBAIPersonaSidebarView.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
#import <SDWebImage/SDWebImage.h>
|
||||
|
||||
@interface KBAIHomeVC () <UICollectionViewDelegate, UICollectionViewDataSource, KBVoiceToTextManagerDelegate, KBVoiceRecordManagerDelegate, UIGestureRecognizerDelegate, KBChatLimitPopViewDelegate, UITextViewDelegate>
|
||||
@interface KBAIHomeVC () <UICollectionViewDelegate, UICollectionViewDataSource, KBVoiceToTextManagerDelegate, KBVoiceRecordManagerDelegate, UIGestureRecognizerDelegate, KBChatLimitPopViewDelegate, KBAIPersonaSidebarViewDelegate>
|
||||
|
||||
/// 人设列表容器
|
||||
@property (nonatomic, strong) UICollectionView *collectionView;
|
||||
@@ -37,16 +39,10 @@
|
||||
@property (nonatomic, strong) UITapGestureRecognizer *dismissKeyboardTap;
|
||||
@property (nonatomic, weak) LSTPopView *chatLimitPopView;
|
||||
|
||||
/// 文本输入容器视图(键盘弹起时显示)
|
||||
@property (nonatomic, strong) UIView *textInputContainerView;
|
||||
/// 文本输入框
|
||||
@property (nonatomic, strong) UITextView *textInputTextView;
|
||||
/// 发送按钮
|
||||
@property (nonatomic, strong) UIButton *sendButton;
|
||||
/// 占位符标签
|
||||
@property (nonatomic, strong) UILabel *placeholderLabel;
|
||||
/// 文本输入容器底部约束
|
||||
@property (nonatomic, strong) MASConstraint *textInputContainerBottomConstraint;
|
||||
/// 文本输入视图(键盘弹起时显示)
|
||||
@property (nonatomic, strong) KBAICommentInputView *commentInputView;
|
||||
/// 文本输入视图底部约束
|
||||
@property (nonatomic, strong) MASConstraint *commentInputBottomConstraint;
|
||||
/// 是否处于文本输入模式
|
||||
@property (nonatomic, assign) BOOL isTextInputMode;
|
||||
|
||||
@@ -87,13 +83,30 @@
|
||||
|
||||
@property (nonatomic, assign) NSInteger pendingAIRequestCount;
|
||||
|
||||
/// 是否处于语音流程中(录音/识别中,用于禁止滚动)
|
||||
@property (nonatomic, assign) BOOL isVoiceProcessing;
|
||||
|
||||
/// 是否正在语音录制中(用于禁止滚动)
|
||||
@property (nonatomic, assign) BOOL isVoiceRecording;
|
||||
|
||||
/// 右上角消息按钮
|
||||
@property (nonatomic, strong) UIButton *messageButton;
|
||||
/// 左上角人设列表按钮
|
||||
@property (nonatomic, strong) UIButton *sidebarButton;
|
||||
|
||||
/// 侧边栏 PopView
|
||||
@property (nonatomic, weak) LSTPopView *sidebarPopView;
|
||||
@property (nonatomic, strong) KBAIPersonaSidebarView *sidebarView;
|
||||
|
||||
/// 侧边栏选中的人设 ID
|
||||
@property (nonatomic, assign) NSInteger selectedPersonaId;
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBAIHomeVC
|
||||
|
||||
static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
|
||||
#pragma mark - Keyboard Gate
|
||||
|
||||
/// 查找当前 view 树里的 firstResponder
|
||||
@@ -116,8 +129,8 @@
|
||||
if (!firstResponder) {
|
||||
return NO;
|
||||
}
|
||||
// 文本输入模式下,textInputTextView 也算
|
||||
if (firstResponder == self.textInputTextView) {
|
||||
// 文本输入模式下,commentInputView 也算
|
||||
if ([firstResponder isDescendantOfView:self.commentInputView]) {
|
||||
return YES;
|
||||
}
|
||||
return [firstResponder isDescendantOfView:self.voiceInputBar];
|
||||
@@ -159,6 +172,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
[super viewWillDisappear:animated];
|
||||
for (NSIndexPath *indexPath in self.collectionView.indexPathsForVisibleItems) {
|
||||
KBPersonaChatCell *cell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:indexPath];
|
||||
if (cell) {
|
||||
[cell onResignedCurrentPersonaCell];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)viewDidLayoutSubviews {
|
||||
[super viewDidLayoutSubviews];
|
||||
if (self.bottomMaskLayer) {
|
||||
@@ -169,7 +192,7 @@
|
||||
#pragma mark - 1:控件初始化
|
||||
|
||||
- (void)setupUI {
|
||||
self.voiceInputBarHeight = 80.0;
|
||||
self.voiceInputBarHeight = 52;
|
||||
self.baseInputBarBottomSpacing = KB_TABBAR_HEIGHT;
|
||||
[self.view addSubview:self.collectionView];
|
||||
[self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
@@ -184,6 +207,14 @@
|
||||
make.width.height.mas_equalTo(32);
|
||||
}];
|
||||
|
||||
// 左上角人设列表按钮
|
||||
[self.view addSubview:self.sidebarButton];
|
||||
[self.sidebarButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.view).offset(KB_STATUSBAR_HEIGHT + 10);
|
||||
make.left.equalTo(self.view).offset(16);
|
||||
make.width.height.mas_equalTo(32);
|
||||
}];
|
||||
|
||||
// 底部毛玻璃背景
|
||||
[self.view addSubview:self.bottomBackgroundView];
|
||||
[self.bottomBackgroundView addSubview:self.bottomBlurEffectView];
|
||||
@@ -207,37 +238,13 @@
|
||||
|
||||
/// 设置文本输入视图
|
||||
- (void)setupTextInputView {
|
||||
// 文本输入容器(初始隐藏)
|
||||
[self.view addSubview:self.textInputContainerView];
|
||||
[self.textInputContainerView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self.view);
|
||||
self.textInputContainerBottomConstraint = make.bottom.equalTo(self.view).offset(100); // 初始在屏幕外
|
||||
make.height.mas_greaterThanOrEqualTo(50);
|
||||
}];
|
||||
|
||||
[self.textInputContainerView addSubview:self.textInputTextView];
|
||||
[self.textInputContainerView addSubview:self.sendButton];
|
||||
[self.textInputTextView addSubview:self.placeholderLabel];
|
||||
|
||||
[self.sendButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(self.textInputContainerView).offset(-16);
|
||||
make.bottom.equalTo(self.textInputContainerView).offset(-10);
|
||||
make.width.mas_equalTo(60);
|
||||
make.height.mas_equalTo(36);
|
||||
}];
|
||||
|
||||
[self.textInputTextView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.textInputContainerView).offset(16);
|
||||
make.right.equalTo(self.sendButton.mas_left).offset(-10);
|
||||
make.top.equalTo(self.textInputContainerView).offset(8);
|
||||
make.bottom.equalTo(self.textInputContainerView).offset(-8);
|
||||
make.height.mas_greaterThanOrEqualTo(36);
|
||||
make.height.mas_lessThanOrEqualTo(100);
|
||||
}];
|
||||
|
||||
[self.placeholderLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.textInputTextView).offset(15);
|
||||
make.top.equalTo(self.textInputTextView).offset(8);
|
||||
// 文本输入视图(初始隐藏)
|
||||
[self.view addSubview:self.commentInputView];
|
||||
[self.commentInputView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.view).offset(12);
|
||||
make.right.equalTo(self.view).offset(-12);
|
||||
self.commentInputBottomConstraint = make.bottom.equalTo(self.view).offset(100); // 初始在屏幕外
|
||||
make.height.mas_equalTo(self.voiceInputBarHeight);
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -254,18 +261,17 @@
|
||||
- (void)showTextInputView {
|
||||
self.isTextInputMode = YES;
|
||||
self.voiceInputBar.hidden = YES;
|
||||
self.textInputContainerView.hidden = NO;
|
||||
[self.textInputTextView becomeFirstResponder];
|
||||
self.commentInputView.hidden = NO;
|
||||
[self.commentInputView showKeyboard];
|
||||
}
|
||||
|
||||
/// 隐藏文本输入视图
|
||||
- (void)hideTextInputView {
|
||||
self.isTextInputMode = NO;
|
||||
[self.textInputTextView resignFirstResponder];
|
||||
self.textInputContainerView.hidden = YES;
|
||||
[self.view endEditing:YES];
|
||||
[self.commentInputView clearText];
|
||||
self.commentInputView.hidden = YES;
|
||||
self.voiceInputBar.hidden = NO;
|
||||
self.textInputTextView.text = @"";
|
||||
self.placeholderLabel.hidden = NO;
|
||||
}
|
||||
|
||||
#pragma mark - 2:数据加载
|
||||
@@ -276,6 +282,7 @@
|
||||
}
|
||||
|
||||
self.isLoading = YES;
|
||||
NSInteger oldCount = self.personas.count;
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[self.aiVM fetchPersonasWithPageNum:self.currentPage
|
||||
@@ -297,9 +304,31 @@
|
||||
weakSelf.hasMore = pageModel.hasMore;
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[weakSelf.collectionView reloadData];
|
||||
if (weakSelf.currentPage == 1) {
|
||||
[weakSelf.collectionView reloadData];
|
||||
[weakSelf preloadDataForIndexes:@[@0, @1, @2]];
|
||||
} else if (pageModel.records.count > 0) {
|
||||
NSInteger newCount = weakSelf.personas.count;
|
||||
NSMutableArray<NSIndexPath *> *indexPaths = [NSMutableArray array];
|
||||
for (NSInteger i = oldCount; i < newCount; i++) {
|
||||
[indexPaths addObject:[NSIndexPath indexPathForItem:i inSection:0]];
|
||||
}
|
||||
[UIView performWithoutAnimation:^{
|
||||
[weakSelf.collectionView performBatchUpdates:^{
|
||||
[weakSelf.collectionView insertItemsAtIndexPaths:indexPaths];
|
||||
} completion:nil];
|
||||
}];
|
||||
}
|
||||
if (weakSelf.selectedPersonaId <= 0 && weakSelf.personas.count > 0) {
|
||||
NSInteger index = MIN(MAX(weakSelf.currentIndex, 0), weakSelf.personas.count - 1);
|
||||
[weakSelf storeSelectedPersonaId:weakSelf.personas[index].personaId];
|
||||
}
|
||||
if (weakSelf.sidebarView) {
|
||||
[weakSelf.sidebarView updatePersonas:weakSelf.personas
|
||||
reset:(weakSelf.currentPage == 1)
|
||||
hasMore:weakSelf.hasMore
|
||||
currentPage:weakSelf.currentPage];
|
||||
[weakSelf.sidebarView updateSelectedPersonaId:[weakSelf storedSelectedPersonaId]];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -511,8 +540,7 @@
|
||||
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
|
||||
if (self.isWaitingForAIResponse) {
|
||||
NSLog(@"[KBAIHomeVC] 正在等待 AI 回复,禁止滚动");
|
||||
scrollView.scrollEnabled = NO;
|
||||
scrollView.scrollEnabled = YES;
|
||||
[self updateCollectionViewScrollState];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,11 +603,11 @@
|
||||
bottomSpacing = keyboardHeight - 5.0;
|
||||
// 文本输入模式:更新文本输入容器位置
|
||||
if (self.isTextInputMode) {
|
||||
[self.textInputContainerBottomConstraint setOffset:-keyboardHeight];
|
||||
[self.commentInputBottomConstraint setOffset:-keyboardHeight];
|
||||
}
|
||||
} else {
|
||||
bottomSpacing = self.baseInputBarBottomSpacing;
|
||||
[self.textInputContainerBottomConstraint setOffset:100]; // 移出屏幕
|
||||
[self.commentInputBottomConstraint setOffset:100]; // 移出屏幕
|
||||
}
|
||||
|
||||
[self.voiceInputBarBottomConstraint setOffset:-bottomSpacing];
|
||||
@@ -614,7 +642,7 @@
|
||||
if ([touch.view isDescendantOfView:self.voiceInputBar]) {
|
||||
return NO;
|
||||
}
|
||||
if ([touch.view isDescendantOfView:self.textInputContainerView]) {
|
||||
if ([touch.view isDescendantOfView:self.commentInputView]) {
|
||||
return NO;
|
||||
}
|
||||
return YES;
|
||||
@@ -659,8 +687,53 @@
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (NSInteger)indexOfPersonaId:(NSInteger)personaId {
|
||||
if (personaId <= 0) {
|
||||
return NSNotFound;
|
||||
}
|
||||
for (NSInteger i = 0; i < self.personas.count; i++) {
|
||||
KBPersonaModel *persona = self.personas[i];
|
||||
if (persona.personaId == personaId) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return NSNotFound;
|
||||
}
|
||||
|
||||
#pragma mark - Private
|
||||
|
||||
- (NSInteger)storedSelectedPersonaId {
|
||||
NSInteger savedId = [[NSUserDefaults standardUserDefaults] integerForKey:KBAISelectedPersonaIdKey];
|
||||
if (savedId > 0) {
|
||||
return savedId;
|
||||
}
|
||||
if (self.currentIndex >= 0 && self.currentIndex < self.personas.count) {
|
||||
return self.personas[self.currentIndex].personaId;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
- (void)storeSelectedPersonaId:(NSInteger)personaId {
|
||||
if (personaId <= 0) {
|
||||
return;
|
||||
}
|
||||
self.selectedPersonaId = personaId;
|
||||
[[NSUserDefaults standardUserDefaults] setInteger:personaId forKey:KBAISelectedPersonaIdKey];
|
||||
[[NSUserDefaults standardUserDefaults] synchronize];
|
||||
if (self.sidebarView) {
|
||||
[self.sidebarView updateSelectedPersonaId:personaId];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)updateCollectionViewScrollState {
|
||||
BOOL shouldEnable = !self.isWaitingForAIResponse
|
||||
&& !self.isVoiceRecording
|
||||
&& !self.isVoiceProcessing;
|
||||
self.collectionView.scrollEnabled = shouldEnable;
|
||||
self.collectionView.panGestureRecognizer.enabled = shouldEnable;
|
||||
self.collectionView.userInteractionEnabled = shouldEnable;
|
||||
}
|
||||
|
||||
- (void)updateChatViewBottomInset {
|
||||
CGFloat bottomInset;
|
||||
|
||||
@@ -708,18 +781,6 @@
|
||||
[pop pop];
|
||||
}
|
||||
|
||||
#pragma mark - UITextViewDelegate
|
||||
|
||||
- (void)textViewDidChange:(UITextView *)textView {
|
||||
self.placeholderLabel.hidden = textView.text.length > 0;
|
||||
// 动态调整高度
|
||||
CGSize size = [textView sizeThatFits:CGSizeMake(textView.frame.size.width, CGFLOAT_MAX)];
|
||||
CGFloat newHeight = MIN(MAX(size.height, 36), 100);
|
||||
[textView mas_updateConstraints:^(MASConstraintMaker *make) {
|
||||
make.height.mas_greaterThanOrEqualTo(newHeight);
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Lazy Load
|
||||
|
||||
- (UICollectionView *)collectionView {
|
||||
@@ -753,53 +814,19 @@
|
||||
return _voiceInputBar;
|
||||
}
|
||||
|
||||
- (UIView *)textInputContainerView {
|
||||
if (!_textInputContainerView) {
|
||||
_textInputContainerView = [[UIView alloc] init];
|
||||
_textInputContainerView.backgroundColor = [UIColor whiteColor];
|
||||
_textInputContainerView.hidden = YES;
|
||||
- (KBAICommentInputView *)commentInputView {
|
||||
if (!_commentInputView) {
|
||||
_commentInputView = [[KBAICommentInputView alloc] init];
|
||||
_commentInputView.layer.cornerRadius = 26;
|
||||
_commentInputView.layer.masksToBounds = true;
|
||||
_commentInputView.hidden = YES;
|
||||
_commentInputView.placeholder = KBLocalized(@"send a message");
|
||||
__weak typeof(self) weakSelf = self;
|
||||
_commentInputView.onSend = ^(NSString *text) {
|
||||
[weakSelf handleCommentInputSend:text];
|
||||
};
|
||||
}
|
||||
return _textInputContainerView;
|
||||
}
|
||||
|
||||
- (UITextView *)textInputTextView {
|
||||
if (!_textInputTextView) {
|
||||
_textInputTextView = [[UITextView alloc] init];
|
||||
_textInputTextView.font = [UIFont systemFontOfSize:16];
|
||||
_textInputTextView.textColor = [UIColor blackColor];
|
||||
_textInputTextView.backgroundColor = [UIColor colorWithRed:0.95 green:0.95 blue:0.95 alpha:1.0];
|
||||
_textInputTextView.layer.cornerRadius = 18;
|
||||
_textInputTextView.layer.masksToBounds = YES;
|
||||
_textInputTextView.textContainerInset = UIEdgeInsetsMake(8, 8, 8, 8);
|
||||
_textInputTextView.delegate = self;
|
||||
_textInputTextView.returnKeyType = UIReturnKeySend;
|
||||
_textInputTextView.enablesReturnKeyAutomatically = YES;
|
||||
}
|
||||
return _textInputTextView;
|
||||
}
|
||||
|
||||
- (UIButton *)sendButton {
|
||||
if (!_sendButton) {
|
||||
_sendButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
[_sendButton setTitle:KBLocalized(@"发送") forState:UIControlStateNormal];
|
||||
[_sendButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
||||
_sendButton.titleLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightMedium];
|
||||
_sendButton.backgroundColor = [UIColor colorWithRed:0.2 green:0.6 blue:1.0 alpha:1.0];
|
||||
_sendButton.layer.cornerRadius = 18;
|
||||
_sendButton.layer.masksToBounds = YES;
|
||||
[_sendButton addTarget:self action:@selector(sendButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
return _sendButton;
|
||||
}
|
||||
|
||||
- (UILabel *)placeholderLabel {
|
||||
if (!_placeholderLabel) {
|
||||
_placeholderLabel = [[UILabel alloc] init];
|
||||
_placeholderLabel.text = KBLocalized(@"输入消息...");
|
||||
_placeholderLabel.font = [UIFont systemFontOfSize:16];
|
||||
_placeholderLabel.textColor = [UIColor lightGrayColor];
|
||||
}
|
||||
return _placeholderLabel;
|
||||
return _commentInputView;
|
||||
}
|
||||
|
||||
#pragma mark - KBChatLimitPopViewDelegate
|
||||
@@ -818,6 +845,46 @@
|
||||
[KB_CURRENT_NAV pushViewController:vc animated:true];
|
||||
}
|
||||
|
||||
#pragma mark - KBAIPersonaSidebarViewDelegate
|
||||
|
||||
- (void)personaSidebarView:(KBAIPersonaSidebarView *)view
|
||||
requestPersonasAtPage:(NSInteger)page {
|
||||
if (self.isLoading) {
|
||||
[view endLoadingMore];
|
||||
return;
|
||||
}
|
||||
self.currentPage = MAX(1, page);
|
||||
if (self.currentPage == 1) {
|
||||
[self.personas removeAllObjects];
|
||||
[view resetLoadMore];
|
||||
}
|
||||
[self loadPersonas];
|
||||
}
|
||||
|
||||
- (void)personaSidebarView:(KBAIPersonaSidebarView *)view
|
||||
didSelectPersona:(KBPersonaModel *)persona {
|
||||
if (!persona) {
|
||||
return;
|
||||
}
|
||||
|
||||
[self storeSelectedPersonaId:persona.personaId];
|
||||
|
||||
NSInteger index = [self indexOfPersonaId:persona.personaId];
|
||||
if (index != NSNotFound) {
|
||||
self.currentIndex = index;
|
||||
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:index inSection:0];
|
||||
[self.collectionView scrollToItemAtIndexPath:indexPath
|
||||
atScrollPosition:UICollectionViewScrollPositionCenteredVertically
|
||||
animated:NO];
|
||||
[self preloadAdjacentCellsForIndex:index];
|
||||
[self saveSelectedPersonaToAppGroup:persona];
|
||||
}
|
||||
|
||||
if (self.sidebarPopView) {
|
||||
[self.sidebarPopView dismiss];
|
||||
}
|
||||
}
|
||||
|
||||
- (UIView *)bottomBackgroundView {
|
||||
if (!_bottomBackgroundView) {
|
||||
_bottomBackgroundView = [[UIView alloc] init];
|
||||
@@ -859,6 +926,16 @@
|
||||
return _messageButton;
|
||||
}
|
||||
|
||||
- (UIButton *)sidebarButton {
|
||||
if (!_sidebarButton) {
|
||||
_sidebarButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
UIImage *icon = [UIImage imageNamed:@"ai_more_icon"];
|
||||
[_sidebarButton setImage:icon forState:UIControlStateNormal];
|
||||
[_sidebarButton addTarget:self action:@selector(sidebarButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
return _sidebarButton;
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)messageButtonTapped {
|
||||
@@ -866,22 +943,53 @@
|
||||
[self.navigationController pushViewController:vc animated:YES];
|
||||
}
|
||||
|
||||
/// 发送按钮点击 - 直接调用 handleTranscribedText
|
||||
- (void)sendButtonTapped {
|
||||
NSString *text = [self.textInputTextView.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
if (text.length == 0) {
|
||||
return;
|
||||
- (void)sidebarButtonTapped {
|
||||
[self showPersonaSidebar];
|
||||
}
|
||||
|
||||
// 清空输入框
|
||||
self.textInputTextView.text = @"";
|
||||
self.placeholderLabel.hidden = NO;
|
||||
- (void)showPersonaSidebar {
|
||||
if (!self.sidebarView) {
|
||||
CGFloat width = KB_SCREEN_WIDTH * 0.7;
|
||||
CGFloat height = KB_SCREEN_HEIGHT;
|
||||
self.sidebarView = [[KBAIPersonaSidebarView alloc] initWithFrame:CGRectMake(0, 0, width, height)];
|
||||
self.sidebarView.delegate = self;
|
||||
}
|
||||
|
||||
self.sidebarView.selectedPersonaId = [self storedSelectedPersonaId];
|
||||
[self.sidebarView updatePersonas:self.personas
|
||||
reset:YES
|
||||
hasMore:self.hasMore
|
||||
currentPage:self.currentPage];
|
||||
[self.sidebarView requestPersonasIfNeeded];
|
||||
|
||||
if (self.sidebarPopView) {
|
||||
[self.sidebarPopView dismiss];
|
||||
}
|
||||
|
||||
LSTPopView *popView = [LSTPopView initWithCustomView:self.sidebarView
|
||||
parentView:nil
|
||||
popStyle:LSTPopStyleSmoothFromLeft
|
||||
dismissStyle:LSTDismissStyleSmoothToLeft];
|
||||
popView.bgColor = [[UIColor blackColor] colorWithAlphaComponent:0.35];
|
||||
popView.hemStyle = LSTHemStyleLeft;
|
||||
popView.isClickBgDismiss = YES;
|
||||
popView.isAvoidKeyboard = NO;
|
||||
self.sidebarPopView = popView;
|
||||
[popView pop];
|
||||
}
|
||||
|
||||
/// 文本输入发送 - 直接调用 handleTranscribedText
|
||||
- (void)handleCommentInputSend:(NSString *)text {
|
||||
NSString *trimmedText = [text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
if (trimmedText.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 隐藏键盘和文本输入框
|
||||
[self hideTextInputView];
|
||||
|
||||
// 直接调用 handleTranscribedText,不走语音录制流程
|
||||
[self handleTranscribedText:text];
|
||||
[self handleTranscribedText:trimmedText];
|
||||
}
|
||||
|
||||
#pragma mark - KBVoiceToTextManagerDelegate
|
||||
@@ -897,14 +1005,23 @@
|
||||
}
|
||||
|
||||
- (void)voiceToTextManagerDidBeginRecording:(KBVoiceToTextManager *)manager {
|
||||
self.isVoiceRecording = YES;
|
||||
self.isVoiceProcessing = YES;
|
||||
[self updateCollectionViewScrollState];
|
||||
[self.voiceRecordManager startRecording];
|
||||
}
|
||||
|
||||
- (void)voiceToTextManagerDidEndRecording:(KBVoiceToTextManager *)manager {
|
||||
self.isVoiceRecording = NO;
|
||||
self.isVoiceProcessing = YES;
|
||||
[self updateCollectionViewScrollState];
|
||||
[self.voiceRecordManager stopRecording];
|
||||
}
|
||||
|
||||
- (void)voiceToTextManagerDidCancelRecording:(KBVoiceToTextManager *)manager {
|
||||
self.isVoiceRecording = NO;
|
||||
self.isVoiceProcessing = NO;
|
||||
[self updateCollectionViewScrollState];
|
||||
[self.voiceRecordManager cancelRecording];
|
||||
}
|
||||
|
||||
@@ -941,6 +1058,8 @@
|
||||
if (cell) {
|
||||
[cell updateLastUserMessage:KBLocalized(@"语音识别失败")];
|
||||
}
|
||||
strongSelf.isVoiceProcessing = NO;
|
||||
[strongSelf updateCollectionViewScrollState];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -949,8 +1068,10 @@
|
||||
NSLog(@"[KBAIHomeVC] 语音转文字结果为空");
|
||||
[KBHUD showError:KBLocalized(@"未识别到语音内容")];
|
||||
if (cell) {
|
||||
[cell updateLastUserMessage:KBLocalized(@"未识别到语音")];
|
||||
[cell removeLoadingUserMessage];
|
||||
}
|
||||
strongSelf.isVoiceProcessing = NO;
|
||||
[strongSelf updateCollectionViewScrollState];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -958,6 +1079,7 @@
|
||||
[cell updateLastUserMessage:transcript];
|
||||
}
|
||||
|
||||
strongSelf.isVoiceProcessing = NO;
|
||||
[strongSelf handleTranscribedText:transcript appendToUI:NO];
|
||||
});
|
||||
}];
|
||||
@@ -971,6 +1093,9 @@
|
||||
- (void)voiceRecordManager:(KBVoiceRecordManager *)manager
|
||||
didFailWithError:(NSError *)error {
|
||||
NSLog(@"[KBAIHomeVC] 录音失败:%@", error.localizedDescription);
|
||||
self.isVoiceRecording = NO;
|
||||
self.isVoiceProcessing = NO;
|
||||
[self updateCollectionViewScrollState];
|
||||
}
|
||||
|
||||
#pragma mark - Private
|
||||
@@ -1001,7 +1126,7 @@
|
||||
self.pendingAIRequestCount += 1;
|
||||
self.isWaitingForAIResponse = (self.pendingAIRequestCount > 0);
|
||||
if (self.pendingAIRequestCount == 1) {
|
||||
self.collectionView.scrollEnabled = NO;
|
||||
[self updateCollectionViewScrollState];
|
||||
NSLog(@"[KBAIHomeVC] 开始等待 AI 回复,禁止 CollectionView 滚动");
|
||||
}
|
||||
|
||||
@@ -1020,7 +1145,7 @@
|
||||
}
|
||||
strongSelf.isWaitingForAIResponse = (strongSelf.pendingAIRequestCount > 0);
|
||||
if (strongSelf.pendingAIRequestCount == 0) {
|
||||
strongSelf.collectionView.scrollEnabled = YES;
|
||||
[strongSelf updateCollectionViewScrollState];
|
||||
NSLog(@"[KBAIHomeVC] AI 回复完成,恢复 CollectionView 滚动");
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
@property (nonatomic, strong) JXCategoryListContainerView *listContainerView;
|
||||
|
||||
/// 右侧搜索按钮
|
||||
@property (nonatomic, strong) UIButton *searchButton;
|
||||
//@property (nonatomic, strong) UIButton *searchButton;
|
||||
|
||||
/// 标题数组
|
||||
@property (nonatomic, strong) NSArray<NSString *> *titles;
|
||||
@@ -42,7 +42,7 @@
|
||||
[self setupUI];
|
||||
|
||||
/// 2:绑定事件
|
||||
[self bindActions];
|
||||
// [self bindActions];
|
||||
}
|
||||
|
||||
#pragma mark - 1:控件初始化
|
||||
@@ -69,12 +69,12 @@
|
||||
}];
|
||||
|
||||
// 添加搜索按钮
|
||||
[self.kb_navView addSubview:self.searchButton];
|
||||
[self.searchButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(self.kb_navView).offset(-16);
|
||||
make.centerY.equalTo(self.kb_backButton);
|
||||
make.width.height.mas_equalTo(24);
|
||||
}];
|
||||
// [self.kb_navView addSubview:self.searchButton];
|
||||
// [self.searchButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
// make.right.equalTo(self.kb_navView).offset(-16);
|
||||
// make.centerY.equalTo(self.kb_backButton);
|
||||
// make.width.height.mas_equalTo(24);
|
||||
// }];
|
||||
|
||||
// 添加列表容器
|
||||
[self.view addSubview:self.listContainerView];
|
||||
@@ -94,9 +94,9 @@
|
||||
|
||||
#pragma mark - 2:绑定事件
|
||||
|
||||
- (void)bindActions {
|
||||
[self.searchButton addTarget:self action:@selector(searchButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
//- (void)bindActions {
|
||||
// [self.searchButton addTarget:self action:@selector(searchButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
//}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
@@ -189,16 +189,16 @@
|
||||
return _listContainerView;
|
||||
}
|
||||
|
||||
- (UIButton *)searchButton {
|
||||
if (!_searchButton) {
|
||||
_searchButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
if (@available(iOS 13.0, *)) {
|
||||
UIImage *searchImage = [UIImage systemImageNamed:@"magnifyingglass"];
|
||||
[_searchButton setImage:searchImage forState:UIControlStateNormal];
|
||||
_searchButton.tintColor = [UIColor colorWithHex:0x1B1F1A];
|
||||
}
|
||||
}
|
||||
return _searchButton;
|
||||
}
|
||||
//- (UIButton *)searchButton {
|
||||
// if (!_searchButton) {
|
||||
// _searchButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
// if (@available(iOS 13.0, *)) {
|
||||
// UIImage *searchImage = [UIImage systemImageNamed:@"magnifyingglass"];
|
||||
// [_searchButton setImage:searchImage forState:UIControlStateNormal];
|
||||
// _searchButton.tintColor = [UIColor colorWithHex:0x1B1F1A];
|
||||
// }
|
||||
// }
|
||||
// return _searchButton;
|
||||
//}
|
||||
|
||||
@end
|
||||
|
||||
@@ -173,6 +173,22 @@ typedef void (^AiVMSpeechTranscribeCompletion)(KBAiSpeechTranscribeResponse *_Nu
|
||||
- (void)fetchCompanionDetailWithCompanionId:(NSInteger)companionId
|
||||
completion:(void(^)(KBAICompanionDetailModel * _Nullable detail, NSError * _Nullable error))completion;
|
||||
|
||||
#pragma mark - 举报接口
|
||||
|
||||
/// 举报 AI 角色
|
||||
/// @param companionId AI 角色 ID
|
||||
/// @param reportTypes 举报类型数组(按界面从上到下 1-12)
|
||||
/// @param reportDesc 详细描述
|
||||
/// @param chatContext 聊天上下文快照 JSON 字符串
|
||||
/// @param evidenceImageUrl 图片证据 URL
|
||||
/// @param completion 完成回调
|
||||
- (void)reportCompanionWithCompanionId:(NSInteger)companionId
|
||||
reportTypes:(NSArray<NSNumber *> *)reportTypes
|
||||
reportDesc:(nullable NSString *)reportDesc
|
||||
chatContext:(nullable NSString *)chatContext
|
||||
evidenceImageUrl:(nullable NSString *)evidenceImageUrl
|
||||
completion:(void(^)(BOOL success, NSError * _Nullable error))completion;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -119,12 +119,7 @@ autoShowBusinessError:NO
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *encodedContent =
|
||||
[content stringByAddingPercentEncodingWithAllowedCharacters:
|
||||
[NSCharacterSet URLQueryAllowedCharacterSet]];
|
||||
NSString *path = [NSString
|
||||
stringWithFormat:@"%@?content=%@&companionId=%ld", API_AI_CHAT_MESSAGE,
|
||||
encodedContent ?: @"", (long)companionId];
|
||||
NSString *path = API_AI_CHAT_MESSAGE;
|
||||
NSDictionary *params = @{
|
||||
@"content" : content ?: @"",
|
||||
@"companionId" : @(companionId)
|
||||
@@ -808,4 +803,100 @@ autoShowBusinessError:NO
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - 举报接口
|
||||
|
||||
- (void)reportCompanionWithCompanionId:(NSInteger)companionId
|
||||
reportTypes:(NSArray<NSNumber *> *)reportTypes
|
||||
reportDesc:(nullable NSString *)reportDesc
|
||||
chatContext:(nullable NSString *)chatContext
|
||||
evidenceImageUrl:(nullable NSString *)evidenceImageUrl
|
||||
completion:(void (^)(BOOL, NSError * _Nullable))completion {
|
||||
if (companionId <= 0) {
|
||||
NSError *error = [NSError errorWithDomain:@"AiVM"
|
||||
code:-1
|
||||
userInfo:@{NSLocalizedDescriptionKey : @"invalid companionId"}];
|
||||
if (completion) {
|
||||
completion(NO, error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
NSMutableArray<NSNumber *> *typeList = [NSMutableArray array];
|
||||
for (id item in reportTypes) {
|
||||
if ([item isKindOfClass:[NSNumber class]]) {
|
||||
[typeList addObject:(NSNumber *)item];
|
||||
} else if ([item isKindOfClass:[NSString class]]) {
|
||||
NSInteger value = [(NSString *)item integerValue];
|
||||
[typeList addObject:@(value)];
|
||||
}
|
||||
}
|
||||
if (typeList.count == 0) {
|
||||
NSError *error = [NSError errorWithDomain:@"AiVM"
|
||||
code:-1
|
||||
userInfo:@{NSLocalizedDescriptionKey : @"reportTypes is empty"}];
|
||||
if (completion) {
|
||||
completion(NO, error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
NSMutableDictionary *params = [NSMutableDictionary dictionary];
|
||||
params[@"companionId"] = @(companionId);
|
||||
params[@"reportTypes"] = [typeList copy];
|
||||
if (reportDesc.length > 0) {
|
||||
params[@"reportDesc"] = reportDesc;
|
||||
}
|
||||
if (chatContext.length > 0) {
|
||||
params[@"chatContext"] = chatContext;
|
||||
}
|
||||
if (evidenceImageUrl.length > 0) {
|
||||
params[@"evidenceImageUrl"] = evidenceImageUrl;
|
||||
}
|
||||
|
||||
NSLog(@"[AiVM] /ai-companion/report request: %@", params);
|
||||
[[KBNetworkManager shared]
|
||||
POST:@"/ai-companion/report"
|
||||
jsonBody:[params copy]
|
||||
headers:nil
|
||||
autoShowBusinessError:NO
|
||||
completion:^(NSDictionary *_Nullable json,
|
||||
NSURLResponse *_Nullable response,
|
||||
NSError *_Nullable error) {
|
||||
if (error) {
|
||||
NSLog(@"[AiVM] /ai-companion/report failed: %@", error.localizedDescription ?: @"");
|
||||
if (completion) {
|
||||
completion(NO, error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"[AiVM] /ai-companion/report response: %@", json);
|
||||
if (![json isKindOfClass:[NSDictionary class]]) {
|
||||
NSError *parseError = [NSError errorWithDomain:@"AiVM"
|
||||
code:-1
|
||||
userInfo:@{NSLocalizedDescriptionKey : @"数据格式错误"}];
|
||||
if (completion) {
|
||||
completion(NO, parseError);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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(NO, bizError);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (completion) {
|
||||
completion(YES, nil);
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -11,6 +11,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface UIColor (Extension)
|
||||
+ (UIColor *)colorWithHex:(int)hexValue;
|
||||
+ (UIColor *)colorWithHex:(int)hexValue alpha:(CGFloat)alpha;
|
||||
+ (nullable UIColor *)colorWithHexString:(NSString *)hexString;
|
||||
+ (UIColor *)kb_dynamicColorWithLightColor:(UIColor *)lightColor
|
||||
darkColor:(UIColor *)darkColor;
|
||||
|
||||
@@ -33,6 +33,29 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
||||
|
||||
@implementation KBNetworkManager
|
||||
|
||||
static NSString *KBSignStringFromObject(id obj) {
|
||||
if (!obj || obj == (id)kCFNull) {
|
||||
return nil;
|
||||
}
|
||||
if ([obj isKindOfClass:[NSString class]]) {
|
||||
return (NSString *)obj;
|
||||
}
|
||||
if ([obj isKindOfClass:[NSNumber class]]) {
|
||||
return [(NSNumber *)obj stringValue];
|
||||
}
|
||||
if ([obj isKindOfClass:[NSArray class]] || [obj isKindOfClass:[NSDictionary class]]) {
|
||||
NSJSONWritingOptions options = 0;
|
||||
if (@available(iOS 11.0, *)) {
|
||||
options = NSJSONWritingSortedKeys;
|
||||
}
|
||||
NSData *data = [NSJSONSerialization dataWithJSONObject:obj options:options error:nil];
|
||||
if (data.length > 0) {
|
||||
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
||||
}
|
||||
}
|
||||
return [obj description];
|
||||
}
|
||||
|
||||
+ (instancetype)shared {
|
||||
static KBNetworkManager *m; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ m = [KBNetworkManager new]; });
|
||||
return m;
|
||||
@@ -73,14 +96,24 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
||||
signParams[@"nonce"] = nonce;
|
||||
// 把 body 里的字段也加入签名参数
|
||||
[bodyParams enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
|
||||
if ([obj isKindOfClass:[NSString class]]) {
|
||||
signParams[key] = obj;
|
||||
} else {
|
||||
signParams[key] = [obj description];
|
||||
NSString *value = KBSignStringFromObject(obj);
|
||||
if (value.length == 0) {
|
||||
return;
|
||||
}
|
||||
signParams[key] = value;
|
||||
}];
|
||||
NSString *signSource = [KBSignUtils signSourceStringWithParams:signParams secret:secret];
|
||||
NSString *sign = [KBSignUtils signWithParams:signParams secret:secret];
|
||||
|
||||
#if DEBUG
|
||||
if (signSource.length > 0) {
|
||||
NSString *secretPart = [NSString stringWithFormat:@"secret=%@", [KBSignUtils urlEncode:secret ?: @""]];
|
||||
NSString *masked = [signSource stringByReplacingOccurrencesOfString:secretPart withString:@"secret=***"];
|
||||
KBLOG(@"[KBNetwork] sign source: %@", masked);
|
||||
KBLOG(@"[KBNetwork] sign value: %@", sign ?: @"");
|
||||
}
|
||||
#endif
|
||||
|
||||
// 将签名相关字段合并进默认请求头
|
||||
NSMutableDictionary<NSString *, NSString *> *headers =
|
||||
[self.defaultHeaders mutableCopy] ?: [NSMutableDictionary dictionary];
|
||||
@@ -202,6 +235,7 @@ autoShowBusinessError:YES
|
||||
parameters:(NSDictionary *)parameters
|
||||
headers:(NSDictionary<NSString *,NSString *> *)headers
|
||||
completion:(KBNetworkDataCompletion)completion {
|
||||
[self getSignWithParare:parameters];
|
||||
if (!self.isEnabled) {
|
||||
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain
|
||||
code:KBNetworkErrorDisabled
|
||||
@@ -243,6 +277,7 @@ autoShowBusinessError:YES
|
||||
completion:(KBNetworkCompletion)completion
|
||||
{
|
||||
NSLog(@"[KBNetworkManager] UPLOAD called, enabled=%d, path=%@", self.isEnabled, path);
|
||||
[self getSignWithParare:@{}];
|
||||
if (![self ensureEnabled:completion]) return nil;
|
||||
|
||||
NSString *urlString = [self buildURLStringWithPath:path];
|
||||
@@ -330,6 +365,7 @@ autoShowBusinessError:YES
|
||||
parameters:(NSDictionary *)parameters
|
||||
headers:(NSDictionary<NSString *, NSString *> *)headers
|
||||
completion:(KBNetworkCompletion)completion {
|
||||
[self getSignWithParare:parameters ?: @{}];
|
||||
if (!fileURL || !fileURL.isFileURL) {
|
||||
if (completion) {
|
||||
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain
|
||||
|
||||