新增聊天记录

This commit is contained in:
2026-01-26 18:17:02 +08:00
parent f9d7579536
commit 6a177ceebc
14 changed files with 1447 additions and 18 deletions

View File

@@ -8,6 +8,8 @@
#import "KBPersonaChatCell.h"
#import "KBChatTableView.h"
#import "KBAiChatMessage.h"
#import "KBChatHistoryPageModel.h"
#import "AiVM.h"
#import <Masonry/Masonry.h>
#import <SDWebImage/SDWebImage.h>
@@ -34,6 +36,18 @@
///
@property (nonatomic, assign) BOOL hasLoadedData;
///
@property (nonatomic, assign) BOOL isLoading;
///
@property (nonatomic, assign) NSInteger currentPage;
///
@property (nonatomic, assign) BOOL hasMoreHistory;
/// AiVM
@property (nonatomic, strong) AiVM *aiVM;
@end
@implementation KBPersonaChatCell
@@ -102,7 +116,11 @@
//
self.hasLoadedData = NO;
self.isLoading = NO;
self.currentPage = 1;
self.hasMoreHistory = YES;
self.messages = [NSMutableArray array];
self.aiVM = [[AiVM alloc] init];
// UI
[self.backgroundImageView sd_setImageWithURL:[NSURL URLWithString:persona.coverImageUrl]
@@ -118,20 +136,113 @@
#pragma mark - 2
- (void)preloadDataIfNeeded {
if (self.hasLoadedData) {
if (self.hasLoadedData || self.isLoading) {
return;
}
// TODO: chatId
//
self.hasLoadedData = YES;
if (self.persona.introText.length > 0) {
KBAiChatMessage *openingMsg = [KBAiChatMessage assistantMessageWithText:self.persona.introText];
openingMsg.isComplete = YES;
[self.messages addObject:openingMsg];
[self.tableView reloadData];
[self loadChatHistory];
}
- (void)loadChatHistory {
if (self.isLoading || !self.hasMoreHistory) {
return;
}
self.isLoading = YES;
// 使 persona.personaId companionId
NSInteger companionId = self.persona.personaId;
__weak typeof(self) weakSelf = self;
[self.aiVM fetchChatHistoryWithCompanionId:companionId
pageNum:self.currentPage
pageSize:20
completion:^(KBChatHistoryPageModel *pageModel, NSError *error) {
weakSelf.isLoading = NO;
if (error) {
NSLog(@"[KBPersonaChatCell] 加载聊天记录失败:%@", error.localizedDescription);
//
if (weakSelf.currentPage == 1 && weakSelf.persona.introText.length > 0) {
[weakSelf showOpeningMessage];
}
return;
}
weakSelf.hasLoadedData = YES;
weakSelf.hasMoreHistory = pageModel.hasMore;
// KBAiChatMessage
NSMutableArray *newMessages = [NSMutableArray array];
for (KBChatHistoryModel *item in pageModel.records) {
KBAiChatMessage *message;
if (item.isUserMessage) {
message = [KBAiChatMessage userMessageWithText:item.content];
} else {
message = [KBAiChatMessage assistantMessageWithText:item.content];
}
message.isComplete = YES;
[newMessages addObject:message];
}
//
if (weakSelf.currentPage == 1) {
//
weakSelf.messages = newMessages;
} else {
//
NSIndexSet *indexSet = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, newMessages.count)];
[weakSelf.messages insertObjects:newMessages atIndexes:indexSet];
}
// UI
dispatch_async(dispatch_get_main_queue(), ^{
if (weakSelf.currentPage == 1) {
[weakSelf.tableView reloadData];
//
if (weakSelf.messages.count > 0) {
NSIndexPath *lastIndexPath = [NSIndexPath indexPathForRow:weakSelf.messages.count - 1 inSection:0];
[weakSelf.tableView scrollToRowAtIndexPath:lastIndexPath
atScrollPosition:UITableViewScrollPositionBottom
animated:NO];
}
} else {
//
CGFloat oldContentHeight = weakSelf.tableView.contentSize.height;
[weakSelf.tableView reloadData];
CGFloat newContentHeight = weakSelf.tableView.contentSize.height;
CGFloat offsetY = newContentHeight - oldContentHeight;
[weakSelf.tableView setContentOffset:CGPointMake(0, offsetY) animated:NO];
}
});
NSLog(@"[KBPersonaChatCell] 加载成功:第 %ld 页,%ld 条消息,还有更多:%@",
(long)weakSelf.currentPage,
(long)newMessages.count,
pageModel.hasMore ? @"是" : @"否");
}];
}
- (void)loadMoreHistory {
if (!self.hasMoreHistory || self.isLoading) {
return;
}
self.currentPage++;
[self loadChatHistory];
}
- (void)showOpeningMessage {
//
KBAiChatMessage *openingMsg = [KBAiChatMessage assistantMessageWithText:self.persona.introText];
openingMsg.isComplete = YES;
[self.messages addObject:openingMsg];
dispatch_async(dispatch_get_main_queue(), ^{
[self.tableView reloadData];
});
}
#pragma mark - UITableViewDataSource
@@ -159,6 +270,17 @@
return UITableViewAutomaticDimension;
}
#pragma mark - UIScrollViewDelegate
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
CGFloat offsetY = scrollView.contentOffset.y;
//
if (offsetY <= -50 && !self.isLoading) {
[self loadMoreHistory];
}
}
#pragma mark - Lazy Load
- (UIImageView *)backgroundImageView {

View File

@@ -0,0 +1,53 @@
//
// KBVoiceInputBar.h
// keyBoard
//
// Created by Kiro on 2026/1/26.
//
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@class KBVoiceInputBar;
/// 语音输入栏代理
@protocol KBVoiceInputBarDelegate <NSObject>
@optional
/// 开始录音
- (void)voiceInputBarDidBeginRecording:(KBVoiceInputBar *)inputBar;
/// 结束录音
- (void)voiceInputBarDidEndRecording:(KBVoiceInputBar *)inputBar;
/// 取消录音
- (void)voiceInputBarDidCancelRecording:(KBVoiceInputBar *)inputBar;
@end
/// 底部语音输入栏
/// 包含:毛玻璃背景 + 录音按钮
@interface KBVoiceInputBar : UIView
/// 代理
@property (nonatomic, weak) id<KBVoiceInputBarDelegate> delegate;
/// 状态文本(显示在按钮上方)
@property (nonatomic, copy) NSString *statusText;
/// 是否启用(禁用时按钮不可点击)
@property (nonatomic, assign) BOOL enabled;
/// 更新音量(用于波形动画)
/// @param rms 音量 RMS 值 (0.0 - 1.0)
- (void)updateVolumeRMS:(float)rms;
/// 设置录音状态
/// @param recording 是否正在录音
- (void)setRecording:(BOOL)recording;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,202 @@
//
// KBVoiceInputBar.m
// keyBoard
//
// Created by Kiro on 2026/1/26.
//
#import "KBVoiceInputBar.h"
#import "KBAiRecordButton.h"
#import <Masonry/Masonry.h>
@interface KBVoiceInputBar () <KBAiRecordButtonDelegate>
///
@property (nonatomic, strong) UIView *backgroundView;
///
@property (nonatomic, strong) UIVisualEffectView *blurEffectView;
///
@property (nonatomic, strong) UILabel *statusLabel;
///
@property (nonatomic, strong) KBAiRecordButton *recordButton;
///
@property (nonatomic, assign) BOOL isRecording;
@end
@implementation KBVoiceInputBar
#pragma mark - Lifecycle
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
[self setupUI];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)coder {
if (self = [super initWithCoder:coder]) {
[self setupUI];
}
return self;
}
#pragma mark - 1
- (void)setupUI {
self.backgroundColor = [UIColor clearColor];
self.enabled = YES;
self.isRecording = NO;
//
[self addSubview:self.backgroundView];
[self.backgroundView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self);
}];
//
[self.backgroundView addSubview:self.blurEffectView];
[self.blurEffectView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self.backgroundView);
}];
// blurEffectView mask
CAGradientLayer *maskLayer = [CAGradientLayer layer];
maskLayer.startPoint = CGPointMake(0.5, 1); //
maskLayer.endPoint = CGPointMake(0.5, 0); //
maskLayer.colors = @[
(__bridge id)[UIColor whiteColor].CGColor, //
(__bridge id)[UIColor whiteColor].CGColor, //
(__bridge id)[UIColor clearColor].CGColor //
];
maskLayer.locations = @[@(0.0), @(0.5), @(1.0)];
self.blurEffectView.layer.mask = maskLayer;
//
[self addSubview:self.statusLabel];
[self.statusLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self).offset(16);
make.left.equalTo(self).offset(20);
make.right.equalTo(self).offset(-20);
make.height.mas_equalTo(20);
}];
//
[self addSubview:self.recordButton];
[self.recordButton mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.statusLabel.mas_bottom).offset(12);
make.left.equalTo(self).offset(20);
make.right.equalTo(self).offset(-20);
make.height.mas_equalTo(50);
make.bottom.lessThanOrEqualTo(self).offset(-16);
}];
}
- (void)layoutSubviews {
[super layoutSubviews];
// mask frame
if (self.blurEffectView.layer.mask) {
self.blurEffectView.layer.mask.frame = self.blurEffectView.bounds;
}
}
#pragma mark - Setter
- (void)setStatusText:(NSString *)statusText {
_statusText = [statusText copy];
self.statusLabel.text = statusText;
}
- (void)setEnabled:(BOOL)enabled {
_enabled = enabled;
self.recordButton.userInteractionEnabled = enabled;
self.recordButton.alpha = enabled ? 1.0 : 0.5;
}
- (void)setRecording:(BOOL)recording {
_isRecording = recording;
self.recordButton.state = recording ? KBAiRecordButtonStateRecording : KBAiRecordButtonStateNormal;
}
#pragma mark - Public Methods
- (void)updateVolumeRMS:(float)rms {
[self.recordButton updateVolumeRMS:rms];
}
#pragma mark - KBAiRecordButtonDelegate
- (void)recordButtonDidBeginPress:(KBAiRecordButton *)button {
if (!self.enabled) {
return;
}
self.isRecording = YES;
if ([self.delegate respondsToSelector:@selector(voiceInputBarDidBeginRecording:)]) {
[self.delegate voiceInputBarDidBeginRecording:self];
}
}
- (void)recordButtonDidEndPress:(KBAiRecordButton *)button {
self.isRecording = NO;
if ([self.delegate respondsToSelector:@selector(voiceInputBarDidEndRecording:)]) {
[self.delegate voiceInputBarDidEndRecording:self];
}
}
- (void)recordButtonDidCancelPress:(KBAiRecordButton *)button {
self.isRecording = NO;
if ([self.delegate respondsToSelector:@selector(voiceInputBarDidCancelRecording:)]) {
[self.delegate voiceInputBarDidCancelRecording:self];
}
}
#pragma mark - Lazy Load
- (UIView *)backgroundView {
if (!_backgroundView) {
_backgroundView = [[UIView alloc] init];
_backgroundView.clipsToBounds = YES;
}
return _backgroundView;
}
- (UIVisualEffectView *)blurEffectView {
if (!_blurEffectView) {
UIBlurEffect *blurEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleLight];
_blurEffectView = [[UIVisualEffectView alloc] initWithEffect:blurEffect];
}
return _blurEffectView;
}
- (UILabel *)statusLabel {
if (!_statusLabel) {
_statusLabel = [[UILabel alloc] init];
_statusLabel.text = @"按住按钮开始对话";
_statusLabel.font = [UIFont systemFontOfSize:14];
_statusLabel.textColor = [UIColor secondaryLabelColor];
_statusLabel.textAlignment = NSTextAlignmentCenter;
}
return _statusLabel;
}
- (KBAiRecordButton *)recordButton {
if (!_recordButton) {
_recordButton = [[KBAiRecordButton alloc] init];
_recordButton.delegate = self;
_recordButton.normalTitle = @"按住说话";
_recordButton.recordingTitle = @"松开结束";
}
return _recordButton;
}
@end

View File

@@ -0,0 +1,320 @@
# KBVoiceInputBar 使用说明
## 📦 组件概述
`KBVoiceInputBar` 是一个封装好的底部语音输入栏组件,包含:
- ✅ 毛玻璃背景(带渐变 mask
- ✅ 状态标签(显示当前状态)
- ✅ 录音按钮(支持长按、波形动画)
- ✅ 代理回调(开始/结束/取消录音)
---
## 🎨 UI 结构
```
┌─────────────────────────────┐
│ 毛玻璃背景(渐变透明) │
│ ┌─────────────────────┐ │
│ │ 状态标签 │ │
│ │ "按住按钮开始对话" │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ 录音按钮 │ │
│ │ [按住说话] │ │
│ └─────────────────────┘ │
└─────────────────────────────┘
```
---
## 📝 使用方式
### 1. 导入头文件
```objc
#import "KBVoiceInputBar.h"
```
### 2. 在 VC 中声明属性
```objc
@interface YourViewController () <KBVoiceInputBarDelegate>
@property (nonatomic, strong) KBVoiceInputBar *voiceInputBar;
@end
```
### 3. 初始化和布局
```objc
- (void)setupUI {
// 创建语音输入栏
self.voiceInputBar = [[KBVoiceInputBar alloc] init];
self.voiceInputBar.delegate = self;
self.voiceInputBar.statusText = @"按住按钮开始对话";
[self.view addSubview:self.voiceInputBar];
[self.voiceInputBar mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self.view);
make.bottom.equalTo(self.view);
make.height.mas_equalTo(150); // 根据实际需要调整
}];
}
```
### 4. 实现代理方法
```objc
#pragma mark - KBVoiceInputBarDelegate
- (void)voiceInputBarDidBeginRecording:(KBVoiceInputBar *)inputBar {
NSLog(@"开始录音");
inputBar.statusText = @"正在聆听...";
// TODO: 开始录音逻辑
// 1. 检查登录状态
// 2. 连接语音识别服务
// 3. 开始录音
}
- (void)voiceInputBarDidEndRecording:(KBVoiceInputBar *)inputBar {
NSLog(@"结束录音");
inputBar.statusText = @"正在识别...";
// TODO: 结束录音逻辑
// 1. 停止录音
// 2. 发送音频数据
// 3. 等待识别结果
}
- (void)voiceInputBarDidCancelRecording:(KBVoiceInputBar *)inputBar {
NSLog(@"取消录音");
inputBar.statusText = @"已取消";
// TODO: 取消录音逻辑
// 1. 停止录音
// 2. 清理资源
}
```
---
## 🔧 API 说明
### 属性
| 属性 | 类型 | 说明 |
|------|------|------|
| `delegate` | `id<KBVoiceInputBarDelegate>` | 代理对象 |
| `statusText` | `NSString *` | 状态文本(显示在按钮上方) |
| `enabled` | `BOOL` | 是否启用(禁用时按钮不可点击) |
### 方法
| 方法 | 说明 |
|------|------|
| `- (void)updateVolumeRMS:(float)rms` | 更新音量(用于波形动画) |
| `- (void)setRecording:(BOOL)recording` | 设置录音状态 |
### 代理方法
| 方法 | 说明 |
|------|------|
| `- (void)voiceInputBarDidBeginRecording:` | 开始录音 |
| `- (void)voiceInputBarDidEndRecording:` | 结束录音 |
| `- (void)voiceInputBarDidCancelRecording:` | 取消录音 |
---
## 💡 使用示例
### 示例 1更新状态文本
```objc
// 开始录音
self.voiceInputBar.statusText = @"正在聆听...";
// 识别中
self.voiceInputBar.statusText = @"正在识别...";
// AI 思考中
self.voiceInputBar.statusText = @"AI 正在思考...";
// 完成
self.voiceInputBar.statusText = @"完成";
```
### 示例 2更新音量波形
```objc
// 在录音过程中,定时更新音量
- (void)onVolumeUpdate:(float)rms {
[self.voiceInputBar updateVolumeRMS:rms];
}
```
### 示例 3禁用/启用按钮
```objc
// 禁用(比如未登录时)
self.voiceInputBar.enabled = NO;
// 启用
self.voiceInputBar.enabled = YES;
```
### 示例 4手动设置录音状态
```objc
// 开始录音
[self.voiceInputBar setRecording:YES];
// 结束录音
[self.voiceInputBar setRecording:NO];
```
---
## 🎯 完整示例(集成 Deepgram
```objc
#import "YourViewController.h"
#import "KBVoiceInputBar.h"
#import "DeepgramStreamingManager.h"
@interface YourViewController () <KBVoiceInputBarDelegate, DeepgramStreamingManagerDelegate>
@property (nonatomic, strong) KBVoiceInputBar *voiceInputBar;
@property (nonatomic, strong) DeepgramStreamingManager *deepgramManager;
@end
@implementation YourViewController
- (void)viewDidLoad {
[super viewDidLoad];
[self setupUI];
[self setupDeepgram];
}
- (void)setupUI {
self.voiceInputBar = [[KBVoiceInputBar alloc] init];
self.voiceInputBar.delegate = self;
[self.view addSubview:self.voiceInputBar];
[self.voiceInputBar mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.bottom.equalTo(self.view);
make.height.mas_equalTo(150);
}];
}
- (void)setupDeepgram {
self.deepgramManager = [[DeepgramStreamingManager alloc] init];
self.deepgramManager.delegate = self;
self.deepgramManager.serverURL = @"wss://api.deepgram.com/v1/listen";
self.deepgramManager.apiKey = @"your_api_key";
[self.deepgramManager prepareConnection];
}
#pragma mark - KBVoiceInputBarDelegate
- (void)voiceInputBarDidBeginRecording:(KBVoiceInputBar *)inputBar {
inputBar.statusText = @"正在连接...";
[self.deepgramManager start];
}
- (void)voiceInputBarDidEndRecording:(KBVoiceInputBar *)inputBar {
inputBar.statusText = @"正在识别...";
[self.deepgramManager stopAndFinalize];
}
- (void)voiceInputBarDidCancelRecording:(KBVoiceInputBar *)inputBar {
inputBar.statusText = @"已取消";
[self.deepgramManager cancel];
}
#pragma mark - DeepgramStreamingManagerDelegate
- (void)deepgramStreamingManagerDidConnect {
self.voiceInputBar.statusText = @"正在聆听...";
}
- (void)deepgramStreamingManagerDidUpdateRMS:(float)rms {
[self.voiceInputBar updateVolumeRMS:rms];
}
- (void)deepgramStreamingManagerDidReceiveInterimTranscript:(NSString *)text {
self.voiceInputBar.statusText = text.length > 0 ? text : @"正在识别...";
}
- (void)deepgramStreamingManagerDidReceiveFinalTranscript:(NSString *)text {
self.voiceInputBar.statusText = @"识别完成";
NSLog(@"最终识别结果:%@", text);
// TODO: 处理识别结果
}
@end
```
---
## 🎨 自定义样式
### 修改毛玻璃效果
```objc
// 在 KBVoiceInputBar.m 中修改
UIBlurEffect *blurEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleDark]; // 改为深色
```
### 修改按钮样式
```objc
// 在 KBVoiceInputBar.m 的 recordButton 懒加载中修改
_recordButton.normalTitle = @"点击说话";
_recordButton.recordingTitle = @"正在录音...";
_recordButton.tintColor = [UIColor systemBlueColor];
```
### 修改高度
```objc
// 在布局时调整
[self.voiceInputBar mas_makeConstraints:^(MASConstraintMaker *make) {
make.height.mas_equalTo(200); // 调整为 200
}];
```
---
## 📌 注意事项
1. **代理必须设置**:否则无法接收录音事件
2. **高度建议**:推荐高度 150-200根据实际需要调整
3. **状态文本**:及时更新 `statusText` 提升用户体验
4. **音量更新**:定时调用 `updateVolumeRMS:` 显示波形动画
5. **禁用状态**:未登录或其他情况下记得禁用按钮
---
## 🚀 优势
1.**开箱即用**:无需关心内部实现细节
2.**高度封装**:毛玻璃背景、按钮、状态标签一体化
3.**易于集成**:只需实现 3 个代理方法
4.**样式统一**:与 KBAiMainVC 保持一致
5.**易于扩展**:可以轻松添加更多功能
---
## 📂 文件位置
- **头文件**`keyBoard/Class/AiTalk/V/KBVoiceInputBar.h`
- **实现文件**`keyBoard/Class/AiTalk/V/KBVoiceInputBar.m`
- **依赖**`KBAiRecordButton`(已存在)
---
## 🔗 相关组件
- `KBAiRecordButton`:录音按钮(支持长按、波形动画)
- `DeepgramStreamingManager`:语音识别管理器
- `VoiceChatStreamingManager`:语音聊天管理器