2
This commit is contained in:
@@ -9,10 +9,10 @@
|
|||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
/// 消息发送者类型
|
/// 消息发送者类型(与后端保持一致)
|
||||||
typedef NS_ENUM(NSInteger, KBChatSender) {
|
typedef NS_ENUM(NSInteger, KBChatSender) {
|
||||||
KBChatSenderUser = 0, // 用户
|
KBChatSenderUser = 1, // 用户(右侧显示)
|
||||||
KBChatSenderAssistant = 1 // AI 助手
|
KBChatSenderAssistant = 2 // AI 助手(左侧显示)
|
||||||
};
|
};
|
||||||
|
|
||||||
/// 聊天记录模型
|
/// 聊天记录模型
|
||||||
|
|||||||
@@ -86,13 +86,13 @@ POST /chat/history
|
|||||||
"records": [
|
"records": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"sender": 0, // 0-用户,1-AI
|
"sender": 1, // 1-用户(右侧),2-AI(左侧)
|
||||||
"content": "你好",
|
"content": "你好",
|
||||||
"createdAt": "2026-01-26 10:00:00"
|
"createdAt": "2026-01-26 10:00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"sender": 1,
|
"sender": 2,
|
||||||
"content": "你好!有什么可以帮助你的吗?",
|
"content": "你好!有什么可以帮助你的吗?",
|
||||||
"createdAt": "2026-01-26 10:00:05"
|
"createdAt": "2026-01-26 10:00:05"
|
||||||
}
|
}
|
||||||
@@ -104,6 +104,10 @@ POST /chat/history
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### sender 字段说明
|
||||||
|
- **sender = 1**:用户消息(显示在右侧)
|
||||||
|
- **sender = 2**:AI 消息(显示在左侧)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📝 使用示例
|
## 📝 使用示例
|
||||||
@@ -359,11 +363,24 @@ AiVM *aiVM = [[AiVM alloc] init];
|
|||||||
|
|
||||||
```objc
|
```objc
|
||||||
typedef NS_ENUM(NSInteger, KBChatSender) {
|
typedef NS_ENUM(NSInteger, KBChatSender) {
|
||||||
KBChatSenderUser = 0, // 用户
|
KBChatSenderUser = 1, // 用户(右侧显示)
|
||||||
KBChatSenderAssistant = 1 // AI 助手
|
KBChatSenderAssistant = 2 // AI 助手(左侧显示)
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 与 KBAiChatMessage 的映射关系
|
||||||
|
|
||||||
|
```objc
|
||||||
|
// KBChatHistoryModel → KBAiChatMessage
|
||||||
|
if (historyModel.sender == KBChatSenderUser) {
|
||||||
|
// sender = 1 → KBAiChatMessageTypeUser(右侧)
|
||||||
|
message = [KBAiChatMessage userMessageWithText:historyModel.content];
|
||||||
|
} else if (historyModel.sender == KBChatSenderAssistant) {
|
||||||
|
// sender = 2 → KBAiChatMessageTypeAssistant(左侧)
|
||||||
|
message = [KBAiChatMessage assistantMessageWithText:historyModel.content];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ 已完成功能
|
## ✅ 已完成功能
|
||||||
|
|||||||
@@ -6,14 +6,24 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
#import <UIKit/UIKit.h>
|
#import <UIKit/UIKit.h>
|
||||||
|
#import "KBAiChatMessage.h"
|
||||||
@class KBChatMessage;
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@class KBChatTableView;
|
||||||
|
|
||||||
|
@protocol KBChatTableViewDelegate <NSObject>
|
||||||
|
@optional
|
||||||
|
- (void)chatTableViewDidScroll:(KBChatTableView *)chatView
|
||||||
|
scrollView:(UIScrollView *)scrollView;
|
||||||
|
- (void)chatTableViewDidTriggerLoadMore:(KBChatTableView *)chatView;
|
||||||
|
@end
|
||||||
|
|
||||||
/// 聊天列表视图(支持用户消息、AI 消息、时间戳、语音播放)
|
/// 聊天列表视图(支持用户消息、AI 消息、时间戳、语音播放)
|
||||||
@interface KBChatTableView : UIView
|
@interface KBChatTableView : UIView
|
||||||
|
|
||||||
|
@property (nonatomic, weak) id<KBChatTableViewDelegate> delegate;
|
||||||
|
|
||||||
/// 添加用户消息
|
/// 添加用户消息
|
||||||
- (void)addUserMessage:(NSString *)text;
|
- (void)addUserMessage:(NSString *)text;
|
||||||
|
|
||||||
@@ -41,6 +51,21 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
/// 停止正在播放的音频
|
/// 停止正在播放的音频
|
||||||
- (void)stopPlayingAudio;
|
- (void)stopPlayingAudio;
|
||||||
|
|
||||||
|
/// 结束加载更多
|
||||||
|
- (void)endLoadMoreWithHasMoreData:(BOOL)hasMoreData;
|
||||||
|
|
||||||
|
/// 重置无更多数据状态
|
||||||
|
- (void)resetNoMoreData;
|
||||||
|
|
||||||
|
/// 添加自定义消息(可用于历史消息或打字机)
|
||||||
|
- (void)addMessage:(KBAiChatMessage *)message
|
||||||
|
autoScroll:(BOOL)autoScroll;
|
||||||
|
|
||||||
|
/// 用指定消息重载(用于历史消息分页)
|
||||||
|
- (void)reloadWithMessages:(NSArray<KBAiChatMessage *> *)messages
|
||||||
|
keepOffset:(BOOL)keepOffset
|
||||||
|
scrollToBottom:(BOOL)scrollToBottom;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
NS_ASSUME_NONNULL_END
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#import "KBChatAssistantMessageCell.h"
|
#import "KBChatAssistantMessageCell.h"
|
||||||
#import "KBChatTimeCell.h"
|
#import "KBChatTimeCell.h"
|
||||||
#import "AiVM.h"
|
#import "AiVM.h"
|
||||||
|
#import <MJRefresh/MJRefresh.h>
|
||||||
#import <Masonry/Masonry.h>
|
#import <Masonry/Masonry.h>
|
||||||
#import <AVFoundation/AVFoundation.h>
|
#import <AVFoundation/AVFoundation.h>
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
|
|||||||
@property (nonatomic, strong) AVAudioPlayer *audioPlayer;
|
@property (nonatomic, strong) AVAudioPlayer *audioPlayer;
|
||||||
@property (nonatomic, strong) NSIndexPath *playingCellIndexPath;
|
@property (nonatomic, strong) NSIndexPath *playingCellIndexPath;
|
||||||
@property (nonatomic, strong) AiVM *aiVM;
|
@property (nonatomic, strong) AiVM *aiVM;
|
||||||
|
@property (nonatomic, assign) BOOL hasMoreData;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
|
|||||||
- (void)setup {
|
- (void)setup {
|
||||||
self.messages = [[NSMutableArray alloc] init];
|
self.messages = [[NSMutableArray alloc] init];
|
||||||
self.aiVM = [[AiVM alloc] init];
|
self.aiVM = [[AiVM alloc] init];
|
||||||
|
self.hasMoreData = YES;
|
||||||
|
|
||||||
// 创建 TableView
|
// 创建 TableView
|
||||||
self.tableView = [[UITableView alloc] initWithFrame:self.bounds
|
self.tableView = [[UITableView alloc] initWithFrame:self.bounds
|
||||||
@@ -75,126 +78,54 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
|
|||||||
|
|
||||||
// 布局
|
// 布局
|
||||||
[self.tableView mas_makeConstraints:^(MASConstraintMaker *make) {
|
[self.tableView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
make.edges.equalTo(self);
|
// make.edges.equalTo(self);
|
||||||
|
make.top.left.right.equalTo(self);
|
||||||
|
make.bottom.equalTo(self).offset(-KB_TABBAR_HEIGHT - 40 - 10);
|
||||||
}];
|
}];
|
||||||
|
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
|
self.tableView.mj_footer = [MJRefreshAutoNormalFooter footerWithRefreshingBlock:^{
|
||||||
|
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||||
|
if (!strongSelf) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!strongSelf.hasMoreData) {
|
||||||
|
[strongSelf.tableView.mj_footer endRefreshingWithNoMoreData];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([strongSelf.delegate respondsToSelector:@selector(chatTableViewDidTriggerLoadMore:)]) {
|
||||||
|
[strongSelf.delegate chatTableViewDidTriggerLoadMore:strongSelf];
|
||||||
|
} else {
|
||||||
|
[strongSelf.tableView.mj_footer endRefreshing];
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
self.tableView.mj_footer.hidden = YES;
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - Public Methods
|
#pragma mark - Public Methods
|
||||||
|
|
||||||
- (void)addUserMessage:(NSString *)text {
|
- (void)addUserMessage:(NSString *)text {
|
||||||
// 记录插入前的消息数量
|
|
||||||
NSInteger oldCount = self.messages.count;
|
|
||||||
|
|
||||||
KBAiChatMessage *message = [KBAiChatMessage userMessageWithText:text];
|
KBAiChatMessage *message = [KBAiChatMessage userMessageWithText:text];
|
||||||
[self insertMessageWithTimestamp:message];
|
[self addMessage:message autoScroll:YES];
|
||||||
|
|
||||||
// 计算新增的行数
|
|
||||||
NSInteger newCount = self.messages.count;
|
|
||||||
NSInteger insertedCount = newCount - oldCount;
|
|
||||||
|
|
||||||
// 使用 insert 插入新行
|
|
||||||
if (insertedCount > 0) {
|
|
||||||
NSMutableArray *indexPaths = [NSMutableArray array];
|
|
||||||
for (NSInteger i = oldCount; i < newCount; i++) {
|
|
||||||
[indexPaths addObject:[NSIndexPath indexPathForRow:i inSection:0]];
|
|
||||||
}
|
|
||||||
[self.tableView insertRowsAtIndexPaths:indexPaths
|
|
||||||
withRowAnimation:UITableViewRowAnimationNone];
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
|
||||||
[self scrollToBottom];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)addAssistantMessage:(NSString *)text
|
- (void)addAssistantMessage:(NSString *)text
|
||||||
audioDuration:(NSTimeInterval)duration
|
audioDuration:(NSTimeInterval)duration
|
||||||
audioData:(NSData *)audioData {
|
audioData:(NSData *)audioData {
|
||||||
// 记录插入前的消息数量
|
|
||||||
NSInteger oldCount = self.messages.count;
|
|
||||||
|
|
||||||
KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text
|
KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text
|
||||||
audioDuration:duration
|
audioDuration:duration
|
||||||
audioData:audioData];
|
audioData:audioData];
|
||||||
[self insertMessageWithTimestamp:message];
|
[self addMessage:message autoScroll:YES];
|
||||||
|
|
||||||
// 计算新增的行数
|
|
||||||
NSInteger newCount = self.messages.count;
|
|
||||||
NSInteger insertedCount = newCount - oldCount;
|
|
||||||
|
|
||||||
// 使用 insert 插入新行
|
|
||||||
if (insertedCount > 0) {
|
|
||||||
NSMutableArray *indexPaths = [NSMutableArray array];
|
|
||||||
for (NSInteger i = oldCount; i < newCount; i++) {
|
|
||||||
[indexPaths addObject:[NSIndexPath indexPathForRow:i inSection:0]];
|
|
||||||
}
|
|
||||||
[self.tableView insertRowsAtIndexPaths:indexPaths
|
|
||||||
withRowAnimation:UITableViewRowAnimationNone];
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
|
||||||
[self scrollToBottom];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)addAssistantMessage:(NSString *)text
|
- (void)addAssistantMessage:(NSString *)text
|
||||||
audioId:(NSString *)audioId {
|
audioId:(NSString *)audioId {
|
||||||
NSLog(@"[KBChatTableView] ========== 添加新的 AI 消息 ==========");
|
|
||||||
NSLog(@"[KBChatTableView] 文本长度: %lu, audioId: %@", (unsigned long)text.length, audioId);
|
|
||||||
NSLog(@"[KBChatTableView] 当前消息数量: %ld", (long)self.messages.count);
|
|
||||||
|
|
||||||
// 在添加新消息之前,先标记上一条 AI 消息完成,并停止其打字机效果
|
|
||||||
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
|
||||||
KBAiChatMessage *msg = self.messages[i];
|
|
||||||
if (msg.type == KBAiChatMessageTypeAssistant && !msg.isComplete) {
|
|
||||||
NSLog(@"[KBChatTableView] 找到上一条未完成的消息 - 索引: %ld, 文本: %@", (long)i, msg.text);
|
|
||||||
msg.isComplete = YES;
|
|
||||||
msg.needsTypewriterEffect = NO;
|
|
||||||
|
|
||||||
// 停止该 Cell 的打字机效果
|
|
||||||
NSIndexPath *oldIndexPath = [NSIndexPath indexPathForRow:i inSection:0];
|
|
||||||
KBChatAssistantMessageCell *oldCell = [self.tableView cellForRowAtIndexPath:oldIndexPath];
|
|
||||||
if ([oldCell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
|
||||||
NSLog(@"[KBChatTableView] 停止上一条消息的打字机效果");
|
|
||||||
[oldCell stopTypewriterEffect];
|
|
||||||
// 显示完整文本
|
|
||||||
oldCell.messageLabel.text = msg.text;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 记录插入前的消息数量
|
|
||||||
NSInteger oldCount = self.messages.count;
|
|
||||||
|
|
||||||
KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text
|
KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text
|
||||||
audioId:audioId];
|
audioId:audioId];
|
||||||
message.needsTypewriterEffect = YES; // 新消息需要打字机效果
|
message.needsTypewriterEffect = YES; // 新消息需要打字机效果
|
||||||
NSLog(@"[KBChatTableView] 新消息属性 - needsTypewriter: %d, isComplete: %d",
|
[self addMessage:message autoScroll:YES];
|
||||||
message.needsTypewriterEffect, message.isComplete);
|
|
||||||
[self insertMessageWithTimestamp:message];
|
|
||||||
|
|
||||||
// 计算新增的行数
|
|
||||||
NSInteger newCount = self.messages.count;
|
|
||||||
NSInteger insertedCount = newCount - oldCount;
|
|
||||||
NSLog(@"[KBChatTableView] 插入后消息数量: %ld, 新增行数: %ld", (long)newCount, (long)insertedCount);
|
|
||||||
|
|
||||||
// 使用 insert 插入新行
|
|
||||||
if (insertedCount > 0) {
|
|
||||||
NSMutableArray *indexPaths = [NSMutableArray array];
|
|
||||||
for (NSInteger i = oldCount; i < newCount; i++) {
|
|
||||||
[indexPaths addObject:[NSIndexPath indexPathForRow:i inSection:0]];
|
|
||||||
NSLog(@"[KBChatTableView] 将插入行: %ld", (long)i);
|
|
||||||
}
|
|
||||||
[self.tableView insertRowsAtIndexPaths:indexPaths
|
|
||||||
withRowAnimation:UITableViewRowAnimationNone];
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
|
||||||
[self scrollToBottom];
|
|
||||||
});
|
|
||||||
|
|
||||||
NSLog(@"[KBChatTableView] ========== 添加完成 ==========");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)updateLastAssistantMessage:(NSString *)text {
|
- (void)updateLastAssistantMessage:(NSString *)text {
|
||||||
@@ -245,6 +176,7 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
|
|||||||
- (void)clearMessages {
|
- (void)clearMessages {
|
||||||
[self.messages removeAllObjects];
|
[self.messages removeAllObjects];
|
||||||
[self.tableView reloadData];
|
[self.tableView reloadData];
|
||||||
|
[self updateFooterVisibility];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)scrollToBottom {
|
- (void)scrollToBottom {
|
||||||
@@ -257,6 +189,92 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
|
|||||||
animated:YES];
|
animated:YES];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#pragma mark - Public Helpers
|
||||||
|
|
||||||
|
- (void)endLoadMoreWithHasMoreData:(BOOL)hasMoreData {
|
||||||
|
self.hasMoreData = hasMoreData;
|
||||||
|
if (hasMoreData) {
|
||||||
|
[self.tableView.mj_footer endRefreshing];
|
||||||
|
} else {
|
||||||
|
[self.tableView.mj_footer endRefreshingWithNoMoreData];
|
||||||
|
}
|
||||||
|
[self updateFooterVisibility];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)resetNoMoreData {
|
||||||
|
self.hasMoreData = YES;
|
||||||
|
[self.tableView.mj_footer resetNoMoreData];
|
||||||
|
[self updateFooterVisibility];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)addMessage:(KBAiChatMessage *)message
|
||||||
|
autoScroll:(BOOL)autoScroll {
|
||||||
|
if (!message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type == KBAiChatMessageTypeAssistant &&
|
||||||
|
message.needsTypewriterEffect &&
|
||||||
|
!message.isComplete) {
|
||||||
|
[self stopPreviousIncompleteAssistantMessageIfNeeded];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSInteger oldCount = self.messages.count;
|
||||||
|
[self insertMessageWithTimestamp:message];
|
||||||
|
|
||||||
|
NSInteger newCount = self.messages.count;
|
||||||
|
NSInteger insertedCount = newCount - oldCount;
|
||||||
|
|
||||||
|
if (insertedCount > 0) {
|
||||||
|
NSMutableArray *indexPaths = [NSMutableArray array];
|
||||||
|
for (NSInteger i = oldCount; i < newCount; i++) {
|
||||||
|
[indexPaths addObject:[NSIndexPath indexPathForRow:i inSection:0]];
|
||||||
|
}
|
||||||
|
[self.tableView insertRowsAtIndexPaths:indexPaths
|
||||||
|
withRowAnimation:UITableViewRowAnimationNone];
|
||||||
|
}
|
||||||
|
|
||||||
|
[self updateFooterVisibility];
|
||||||
|
|
||||||
|
if (autoScroll) {
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||||
|
[self scrollToBottom];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)reloadWithMessages:(NSArray<KBAiChatMessage *> *)messages
|
||||||
|
keepOffset:(BOOL)keepOffset
|
||||||
|
scrollToBottom:(BOOL)scrollToBottom {
|
||||||
|
CGFloat oldContentHeight = self.tableView.contentSize.height;
|
||||||
|
CGFloat oldOffsetY = self.tableView.contentOffset.y;
|
||||||
|
|
||||||
|
[self.messages removeAllObjects];
|
||||||
|
if (messages.count > 0) {
|
||||||
|
for (KBAiChatMessage *message in messages) {
|
||||||
|
[self insertMessageWithTimestamp:message];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[self.tableView reloadData];
|
||||||
|
[self.tableView layoutIfNeeded];
|
||||||
|
[self updateFooterVisibility];
|
||||||
|
|
||||||
|
if (keepOffset) {
|
||||||
|
CGFloat newContentHeight = self.tableView.contentSize.height;
|
||||||
|
CGFloat delta = newContentHeight - oldContentHeight;
|
||||||
|
CGFloat offsetY = oldOffsetY + delta;
|
||||||
|
[self.tableView setContentOffset:CGPointMake(0, offsetY) animated:NO];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollToBottom) {
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||||
|
[self scrollToBottom];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#pragma mark - Private Methods
|
#pragma mark - Private Methods
|
||||||
|
|
||||||
/// 插入消息并自动添加时间戳
|
/// 插入消息并自动添加时间戳
|
||||||
@@ -323,6 +341,30 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)stopPreviousIncompleteAssistantMessageIfNeeded {
|
||||||
|
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||||
|
KBAiChatMessage *msg = self.messages[i];
|
||||||
|
if (msg.type == KBAiChatMessageTypeAssistant && !msg.isComplete) {
|
||||||
|
msg.isComplete = YES;
|
||||||
|
msg.needsTypewriterEffect = NO;
|
||||||
|
|
||||||
|
NSIndexPath *oldIndexPath = [NSIndexPath indexPathForRow:i inSection:0];
|
||||||
|
KBChatAssistantMessageCell *oldCell = [self.tableView cellForRowAtIndexPath:oldIndexPath];
|
||||||
|
if ([oldCell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||||
|
[oldCell stopTypewriterEffect];
|
||||||
|
oldCell.messageLabel.text = msg.text;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)updateFooterVisibility {
|
||||||
|
BOOL canLoadMore = (self.delegate &&
|
||||||
|
[self.delegate respondsToSelector:@selector(chatTableViewDidTriggerLoadMore:)]);
|
||||||
|
self.tableView.mj_footer.hidden = !canLoadMore || self.messages.count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
#pragma mark - UITableViewDataSource
|
#pragma mark - UITableViewDataSource
|
||||||
|
|
||||||
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
|
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
|
||||||
@@ -365,6 +407,19 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)setDelegate:(id<KBChatTableViewDelegate>)delegate {
|
||||||
|
_delegate = delegate;
|
||||||
|
[self updateFooterVisibility];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - UIScrollViewDelegate
|
||||||
|
|
||||||
|
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
|
||||||
|
if ([self.delegate respondsToSelector:@selector(chatTableViewDidScroll:scrollView:)]) {
|
||||||
|
[self.delegate chatTableViewDidScroll:self scrollView:scrollView];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#pragma mark - KBChatAssistantMessageCellDelegate
|
#pragma mark - KBChatAssistantMessageCellDelegate
|
||||||
|
|
||||||
- (void)assistantMessageCell:(KBChatAssistantMessageCell *)cell
|
- (void)assistantMessageCell:(KBChatAssistantMessageCell *)cell
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
/// 预加载数据
|
/// 预加载数据
|
||||||
- (void)preloadDataIfNeeded;
|
- (void)preloadDataIfNeeded;
|
||||||
|
|
||||||
|
/// 添加用户消息
|
||||||
|
- (void)appendUserMessage:(NSString *)text;
|
||||||
|
|
||||||
|
/// 添加 AI 消息(支持打字机效果)
|
||||||
|
- (void)appendAssistantMessage:(NSString *)text
|
||||||
|
audioId:(nullable NSString *)audioId;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
NS_ASSUME_NONNULL_END
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
#import <Masonry/Masonry.h>
|
#import <Masonry/Masonry.h>
|
||||||
#import <SDWebImage/SDWebImage.h>
|
#import <SDWebImage/SDWebImage.h>
|
||||||
|
|
||||||
@interface KBPersonaChatCell () <UITableViewDelegate, UITableViewDataSource>
|
@interface KBPersonaChatCell () <KBChatTableViewDelegate>
|
||||||
|
|
||||||
/// 背景图
|
/// 背景图
|
||||||
@property (nonatomic, strong) UIImageView *backgroundImageView;
|
@property (nonatomic, strong) UIImageView *backgroundImageView;
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
@property (nonatomic, strong) UILabel *openingLabel;
|
@property (nonatomic, strong) UILabel *openingLabel;
|
||||||
|
|
||||||
/// 聊天列表
|
/// 聊天列表
|
||||||
@property (nonatomic, strong) UITableView *tableView;
|
@property (nonatomic, strong) KBChatTableView *chatView;
|
||||||
|
|
||||||
/// 聊天消息
|
/// 聊天消息
|
||||||
@property (nonatomic, strong) NSMutableArray<KBAiChatMessage *> *messages;
|
@property (nonatomic, strong) NSMutableArray<KBAiChatMessage *> *messages;
|
||||||
@@ -102,8 +102,8 @@
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
// 聊天列表
|
// 聊天列表
|
||||||
[self.contentView addSubview:self.tableView];
|
[self.contentView addSubview:self.chatView];
|
||||||
[self.tableView mas_makeConstraints:^(MASConstraintMaker *make) {
|
[self.chatView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
make.top.equalTo(self.openingLabel.mas_bottom).offset(30);
|
make.top.equalTo(self.openingLabel.mas_bottom).offset(30);
|
||||||
make.left.right.bottom.equalTo(self.contentView);
|
make.left.right.bottom.equalTo(self.contentView);
|
||||||
}];
|
}];
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
self.nameLabel.text = persona.name;
|
self.nameLabel.text = persona.name;
|
||||||
self.openingLabel.text = persona.shortDesc.length > 0 ? persona.shortDesc : persona.introText;
|
self.openingLabel.text = persona.shortDesc.length > 0 ? persona.shortDesc : persona.introText;
|
||||||
|
|
||||||
[self.tableView reloadData];
|
[self.chatView clearMessages];
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - 2:数据加载
|
#pragma mark - 2:数据加载
|
||||||
@@ -145,11 +145,16 @@
|
|||||||
|
|
||||||
- (void)loadChatHistory {
|
- (void)loadChatHistory {
|
||||||
if (self.isLoading || !self.hasMoreHistory) {
|
if (self.isLoading || !self.hasMoreHistory) {
|
||||||
|
[self.chatView endLoadMoreWithHasMoreData:self.hasMoreHistory];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.isLoading = YES;
|
self.isLoading = YES;
|
||||||
|
|
||||||
|
if (self.currentPage == 1) {
|
||||||
|
[self.chatView resetNoMoreData];
|
||||||
|
}
|
||||||
|
|
||||||
// 使用 persona.personaId 作为 companionId
|
// 使用 persona.personaId 作为 companionId
|
||||||
NSInteger companionId = self.persona.personaId;
|
NSInteger companionId = self.persona.personaId;
|
||||||
|
|
||||||
@@ -162,6 +167,7 @@
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
NSLog(@"[KBPersonaChatCell] 加载聊天记录失败:%@", error.localizedDescription);
|
NSLog(@"[KBPersonaChatCell] 加载聊天记录失败:%@", error.localizedDescription);
|
||||||
|
[weakSelf.chatView endLoadMoreWithHasMoreData:weakSelf.hasMoreHistory];
|
||||||
|
|
||||||
// 如果是第一次加载失败,显示开场白
|
// 如果是第一次加载失败,显示开场白
|
||||||
if (weakSelf.currentPage == 1 && weakSelf.persona.introText.length > 0) {
|
if (weakSelf.currentPage == 1 && weakSelf.persona.introText.length > 0) {
|
||||||
@@ -177,12 +183,24 @@
|
|||||||
NSMutableArray *newMessages = [NSMutableArray array];
|
NSMutableArray *newMessages = [NSMutableArray array];
|
||||||
for (KBChatHistoryModel *item in pageModel.records) {
|
for (KBChatHistoryModel *item in pageModel.records) {
|
||||||
KBAiChatMessage *message;
|
KBAiChatMessage *message;
|
||||||
if (item.isUserMessage) {
|
|
||||||
|
// 根据 sender 判断消息类型
|
||||||
|
// sender = 1: 用户消息(右侧)
|
||||||
|
// sender = 2: AI 消息(左侧)
|
||||||
|
if (item.sender == KBChatSenderUser) {
|
||||||
|
// 用户消息
|
||||||
message = [KBAiChatMessage userMessageWithText:item.content];
|
message = [KBAiChatMessage userMessageWithText:item.content];
|
||||||
|
} else if (item.sender == KBChatSenderAssistant) {
|
||||||
|
// AI 消息
|
||||||
|
message = [KBAiChatMessage assistantMessageWithText:item.content];
|
||||||
} else {
|
} else {
|
||||||
|
// 未知类型,默认为 AI 消息
|
||||||
|
NSLog(@"[KBPersonaChatCell] 未知的 sender 类型:%ld", (long)item.sender);
|
||||||
message = [KBAiChatMessage assistantMessageWithText:item.content];
|
message = [KBAiChatMessage assistantMessageWithText:item.content];
|
||||||
}
|
}
|
||||||
|
|
||||||
message.isComplete = YES;
|
message.isComplete = YES;
|
||||||
|
message.needsTypewriterEffect = NO;
|
||||||
[newMessages addObject:message];
|
[newMessages addObject:message];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,24 +216,12 @@
|
|||||||
|
|
||||||
// 刷新 UI
|
// 刷新 UI
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
if (weakSelf.currentPage == 1) {
|
BOOL keepOffset = (weakSelf.currentPage != 1);
|
||||||
[weakSelf.tableView reloadData];
|
BOOL scrollToBottom = (weakSelf.currentPage == 1);
|
||||||
|
[weakSelf.chatView reloadWithMessages:weakSelf.messages
|
||||||
// 滚动到底部(最新消息)
|
keepOffset:keepOffset
|
||||||
if (weakSelf.messages.count > 0) {
|
scrollToBottom:scrollToBottom];
|
||||||
NSIndexPath *lastIndexPath = [NSIndexPath indexPathForRow:weakSelf.messages.count - 1 inSection:0];
|
[weakSelf.chatView endLoadMoreWithHasMoreData:weakSelf.hasMoreHistory];
|
||||||
[weakSelf.tableView scrollToRowAtIndexPath:lastIndexPath
|
|
||||||
atScrollPosition:UITableViewScrollPositionBottom
|
|
||||||
animated:NO];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 保持滚动位置
|
|
||||||
CGFloat oldContentHeight = weakSelf.tableView.contentSize.height;
|
|
||||||
[weakSelf.tableView reloadData];
|
|
||||||
CGFloat newContentHeight = weakSelf.tableView.contentSize.height;
|
|
||||||
CGFloat offsetY = newContentHeight - oldContentHeight;
|
|
||||||
[weakSelf.tableView setContentOffset:CGPointMake(0, offsetY) animated:NO];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
NSLog(@"[KBPersonaChatCell] 加载成功:第 %ld 页,%ld 条消息,还有更多:%@",
|
NSLog(@"[KBPersonaChatCell] 加载成功:第 %ld 页,%ld 条消息,还有更多:%@",
|
||||||
@@ -227,6 +233,7 @@
|
|||||||
|
|
||||||
- (void)loadMoreHistory {
|
- (void)loadMoreHistory {
|
||||||
if (!self.hasMoreHistory || self.isLoading) {
|
if (!self.hasMoreHistory || self.isLoading) {
|
||||||
|
[self.chatView endLoadMoreWithHasMoreData:self.hasMoreHistory];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,41 +245,53 @@
|
|||||||
// 显示开场白作为第一条消息
|
// 显示开场白作为第一条消息
|
||||||
KBAiChatMessage *openingMsg = [KBAiChatMessage assistantMessageWithText:self.persona.introText];
|
KBAiChatMessage *openingMsg = [KBAiChatMessage assistantMessageWithText:self.persona.introText];
|
||||||
openingMsg.isComplete = YES;
|
openingMsg.isComplete = YES;
|
||||||
|
openingMsg.needsTypewriterEffect = NO;
|
||||||
[self.messages addObject:openingMsg];
|
[self.messages addObject:openingMsg];
|
||||||
|
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
[self.tableView reloadData];
|
[self.chatView reloadWithMessages:self.messages
|
||||||
|
keepOffset:NO
|
||||||
|
scrollToBottom:YES];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - UITableViewDataSource
|
#pragma mark - 3:消息追加
|
||||||
|
|
||||||
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
|
- (void)appendUserMessage:(NSString *)text {
|
||||||
return self.messages.count;
|
if (text.length == 0) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
|
if (!self.messages) {
|
||||||
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell"];
|
self.messages = [NSMutableArray array];
|
||||||
if (!cell) {
|
|
||||||
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"Cell"];
|
|
||||||
cell.backgroundColor = [UIColor clearColor];
|
|
||||||
cell.textLabel.textColor = [UIColor whiteColor];
|
|
||||||
cell.textLabel.numberOfLines = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
KBAiChatMessage *message = self.messages[indexPath.row];
|
KBAiChatMessage *message = [KBAiChatMessage userMessageWithText:text];
|
||||||
cell.textLabel.text = message.text;
|
[self.messages addObject:message];
|
||||||
|
[self.chatView addMessage:message autoScroll:YES];
|
||||||
return cell;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
|
- (void)appendAssistantMessage:(NSString *)text
|
||||||
return UITableViewAutomaticDimension;
|
audioId:(NSString *)audioId {
|
||||||
|
if (text.length == 0) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - UIScrollViewDelegate
|
if (!self.messages) {
|
||||||
|
self.messages = [NSMutableArray array];
|
||||||
|
}
|
||||||
|
|
||||||
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
|
KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text
|
||||||
|
audioId:audioId];
|
||||||
|
message.needsTypewriterEffect = YES;
|
||||||
|
[self.messages addObject:message];
|
||||||
|
[self.chatView addMessage:message autoScroll:YES];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - KBChatTableViewDelegate
|
||||||
|
|
||||||
|
- (void)chatTableViewDidScroll:(KBChatTableView *)chatView
|
||||||
|
scrollView:(UIScrollView *)scrollView {
|
||||||
CGFloat offsetY = scrollView.contentOffset.y;
|
CGFloat offsetY = scrollView.contentOffset.y;
|
||||||
|
|
||||||
// 下拉到顶部,加载历史消息
|
// 下拉到顶部,加载历史消息
|
||||||
@@ -281,6 +300,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)chatTableViewDidTriggerLoadMore:(KBChatTableView *)chatView {
|
||||||
|
[self loadMoreHistory];
|
||||||
|
}
|
||||||
|
|
||||||
#pragma mark - Lazy Load
|
#pragma mark - Lazy Load
|
||||||
|
|
||||||
- (UIImageView *)backgroundImageView {
|
- (UIImageView *)backgroundImageView {
|
||||||
@@ -325,22 +348,13 @@
|
|||||||
return _openingLabel;
|
return _openingLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (UITableView *)tableView {
|
- (KBChatTableView *)chatView {
|
||||||
if (!_tableView) {
|
if (!_chatView) {
|
||||||
_tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
|
_chatView = [[KBChatTableView alloc] init];
|
||||||
_tableView.delegate = self;
|
_chatView.backgroundColor = [UIColor clearColor];
|
||||||
_tableView.dataSource = self;
|
_chatView.delegate = self;
|
||||||
_tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
|
|
||||||
_tableView.backgroundColor = [UIColor clearColor];
|
|
||||||
_tableView.showsVerticalScrollIndicator = NO;
|
|
||||||
_tableView.estimatedRowHeight = 60;
|
|
||||||
_tableView.rowHeight = UITableViewAutomaticDimension;
|
|
||||||
|
|
||||||
if (@available(iOS 11.0, *)) {
|
|
||||||
_tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
|
|
||||||
}
|
}
|
||||||
}
|
return _chatView;
|
||||||
return _tableView;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
[self.view addSubview:self.voiceInputBar];
|
[self.view addSubview:self.voiceInputBar];
|
||||||
[self.voiceInputBar mas_makeConstraints:^(MASConstraintMaker *make) {
|
[self.voiceInputBar mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
make.left.right.equalTo(self.view);
|
make.left.right.equalTo(self.view);
|
||||||
make.bottom.equalTo(self.view);
|
make.bottom.equalTo(self.view).offset(-20);
|
||||||
make.height.mas_equalTo(150); // 根据实际需要调整高度
|
make.height.mas_equalTo(150); // 根据实际需要调整高度
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@@ -267,6 +267,27 @@
|
|||||||
return persona.personaId;
|
return persona.personaId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (KBPersonaChatCell *)currentPersonaCell {
|
||||||
|
if (self.personas.count == 0) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:self.currentIndex inSection:0];
|
||||||
|
KBPersonaChatCell *cell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:indexPath];
|
||||||
|
if (cell) {
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (NSIndexPath *visibleIndex in self.collectionView.indexPathsForVisibleItems) {
|
||||||
|
KBPersonaChatCell *visibleCell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:visibleIndex];
|
||||||
|
if (visibleCell) {
|
||||||
|
return visibleCell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
#pragma mark - Lazy Load
|
#pragma mark - Lazy Load
|
||||||
|
|
||||||
- (UICollectionView *)collectionView {
|
- (UICollectionView *)collectionView {
|
||||||
@@ -315,14 +336,43 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
KBPersonaChatCell *currentCell = [self currentPersonaCell];
|
||||||
|
if (currentCell) {
|
||||||
|
[currentCell appendUserMessage:text];
|
||||||
|
}
|
||||||
|
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
[self.aiVM requestChatMessageWithContent:text
|
[self.aiVM requestChatMessageWithContent:text
|
||||||
companionId:companionId
|
companionId:companionId
|
||||||
completion:^(KBAiMessageResponse * _Nullable response, NSError * _Nullable error) {
|
completion:^(KBAiMessageResponse * _Nullable response, NSError * _Nullable error) {
|
||||||
|
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||||
|
if (!strongSelf) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
if (error) {
|
if (error) {
|
||||||
NSLog(@"[KBAIHomeVC] 请求聊天失败:%@", error.localizedDescription);
|
NSLog(@"[KBAIHomeVC] 请求聊天失败:%@", error.localizedDescription);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
NSLog(@"[KBAIHomeVC] 聊天请求成功,code=%ld", (long)response.code);
|
|
||||||
|
if (!response || !response.data) {
|
||||||
|
NSLog(@"[KBAIHomeVC] 聊天响应为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *aiResponse = response.data.aiResponse ?: response.data.content ?: response.data.text ?: response.data.message ?: @"";
|
||||||
|
NSString *audioId = response.data.audioId;
|
||||||
|
if (aiResponse.length == 0) {
|
||||||
|
NSLog(@"[KBAIHomeVC] AI 回复为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
KBPersonaChatCell *cell = [strongSelf currentPersonaCell];
|
||||||
|
if (cell) {
|
||||||
|
[cell appendAssistantMessage:aiResponse audioId:audioId];
|
||||||
|
}
|
||||||
|
});
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user