From a0923c8572ec28bb9b52bad1abdef9afd2accc70 Mon Sep 17 00:00:00 2001 From: CodeST <694468528@qq.com> Date: Tue, 3 Feb 2026 15:53:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B6=88=E6=81=AF=E9=95=BF?= =?UTF-8?q?=E6=8C=89=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- keyBoard.xcodeproj/project.pbxproj | 6 + .../V/Chat/KBChatMessageActionPopView.h | 33 ++++ .../V/Chat/KBChatMessageActionPopView.m | 164 ++++++++++++++++++ .../Class/AiTalk/V/Chat/KBChatTableView.h | 4 + .../Class/AiTalk/V/Chat/KBChatTableView.m | 34 ++++ .../Class/AiTalk/V/Chat/KBPersonaChatCell.m | 128 +++++++++++++- 6 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 keyBoard/Class/AiTalk/V/Chat/KBChatMessageActionPopView.h create mode 100644 keyBoard/Class/AiTalk/V/Chat/KBChatMessageActionPopView.m diff --git a/keyBoard.xcodeproj/project.pbxproj b/keyBoard.xcodeproj/project.pbxproj index f25df55..6e1768c 100644 --- a/keyBoard.xcodeproj/project.pbxproj +++ b/keyBoard.xcodeproj/project.pbxproj @@ -229,6 +229,7 @@ 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 */; }; 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 */; }; @@ -720,6 +721,8 @@ 04E039482F236E75002CA5A0 /* KBChatTimeCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatTimeCell.m; sourceTree = ""; }; 04E039492F236E75002CA5A0 /* KBChatUserMessageCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatUserMessageCell.h; sourceTree = ""; }; 04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatUserMessageCell.m; sourceTree = ""; }; + 0F2A10012F3C0001002CA5A0 /* KBChatMessageActionPopView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatMessageActionPopView.h; sourceTree = ""; }; + 0F2A10022F3C0001002CA5A0 /* KBChatMessageActionPopView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatMessageActionPopView.m; sourceTree = ""; }; 04E039502F2387D2002CA5A0 /* KBAiChatMessage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAiChatMessage.h; sourceTree = ""; }; 04E039512F2387D2002CA5A0 /* KBAiChatMessage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAiChatMessage.m; sourceTree = ""; }; 04E0B1002F300001002CA5A0 /* KBVoiceToTextManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceToTextManager.h; sourceTree = ""; }; @@ -1431,6 +1434,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 */, @@ -2413,6 +2418,7 @@ 046086D82F1A093400757C95 /* KBAIReplyCell.m in Sources */, 046086D92F1A093400757C95 /* KBAICommentHeaderView.m in Sources */, 04E0394B2F236E75002CA5A0 /* KBChatUserMessageCell.m in Sources */, + 0F2A10032F3C0001002CA5A0 /* KBChatMessageActionPopView.m in Sources */, 04E0394C2F236E75002CA5A0 /* KBChatTimeCell.m in Sources */, 04E0394D2F236E75002CA5A0 /* KBChatAssistantMessageCell.m in Sources */, 04E0394E2F236E75002CA5A0 /* KBChatTableView.m in Sources */, diff --git a/keyBoard/Class/AiTalk/V/Chat/KBChatMessageActionPopView.h b/keyBoard/Class/AiTalk/V/Chat/KBChatMessageActionPopView.h new file mode 100644 index 0000000..df4967a --- /dev/null +++ b/keyBoard/Class/AiTalk/V/Chat/KBChatMessageActionPopView.h @@ -0,0 +1,33 @@ +// +// KBChatMessageActionPopView.h +// keyBoard +// +// Created by Codex on 2026/2/3. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, KBChatMessageActionType) { + KBChatMessageActionTypeCopy = 0, + KBChatMessageActionTypeDelete = 1, + KBChatMessageActionTypeReport = 2, +}; + +@class KBChatMessageActionPopView; + +@protocol KBChatMessageActionPopViewDelegate +@optional +- (void)chatMessageActionPopView:(KBChatMessageActionPopView *)view + didSelectAction:(KBChatMessageActionType)action; +@end + +/// 聊天消息长按操作弹窗(Copy / Delete / Report) +@interface KBChatMessageActionPopView : UIView + +@property (nonatomic, weak) id delegate; + +@end + +NS_ASSUME_NONNULL_END diff --git a/keyBoard/Class/AiTalk/V/Chat/KBChatMessageActionPopView.m b/keyBoard/Class/AiTalk/V/Chat/KBChatMessageActionPopView.m new file mode 100644 index 0000000..5532647 --- /dev/null +++ b/keyBoard/Class/AiTalk/V/Chat/KBChatMessageActionPopView.m @@ -0,0 +1,164 @@ +// +// KBChatMessageActionPopView.m +// keyBoard +// +// Created by Codex on 2026/2/3. +// + +#import "KBChatMessageActionPopView.h" +#import + +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 diff --git a/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.h b/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.h index 5aff434..825ac13 100644 --- a/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.h +++ b/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.h @@ -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 消息、时间戳、语音播放) diff --git a/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.m b/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.m index 2e83e07..8a8a918 100644 --- a/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.m +++ b/keyBoard/Class/AiTalk/V/Chat/KBChatTableView.m @@ -42,6 +42,7 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟 @property (nonatomic, strong) UILabel *topStatusLabel; @property (nonatomic, assign) BOOL isTopLoading; @property (nonatomic, assign) BOOL isTopNoMore; +@property (nonatomic, strong) UILongPressGestureRecognizer *messageLongPressGesture; @end @@ -101,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]; @@ -136,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 { diff --git a/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.m b/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.m index 48afdb5..99b8358 100644 --- a/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.m +++ b/keyBoard/Class/AiTalk/V/Chat/KBPersonaChatCell.m @@ -12,6 +12,9 @@ #import "KBImagePositionButton.h" #import "KBAICommentView.h" #import "KBAIChatMessageCacheManager.h" +#import "KBChatMessageActionPopView.h" +#import "AIReportVC.h" +#import "KBHUD.h" #import #import #import @@ -19,7 +22,7 @@ /// 聊天会话被重置的通知 static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidResetNotification"; -@interface KBPersonaChatCell () +@interface KBPersonaChatCell () /// 背景图 @property (nonatomic, strong) UIImageView *backgroundImageView; @@ -65,6 +68,9 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe @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 @@ -898,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 {