1
This commit is contained in:
251
keyBoard/Class/AiTalk/V/API_快速参考.md
Normal file
251
keyBoard/Class/AiTalk/V/API_快速参考.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# KBChatTableView API 快速参考
|
||||
|
||||
## 📦 Import
|
||||
|
||||
```objective-c
|
||||
#import "KBChatTableView.h"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 初始化
|
||||
|
||||
```objective-c
|
||||
KBChatTableView *chatView = [[KBChatTableView alloc] init];
|
||||
[self.view addSubview:chatView];
|
||||
|
||||
[chatView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.view);
|
||||
}];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 API 方法
|
||||
|
||||
### 添加用户消息
|
||||
```objective-c
|
||||
- (void)addUserMessage:(NSString *)text;
|
||||
```
|
||||
|
||||
**示例:**
|
||||
```objective-c
|
||||
[self.chatView addUserMessage:@"你好"];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 添加 AI 消息(带语音)
|
||||
```objective-c
|
||||
- (void)addAssistantMessage:(NSString *)text
|
||||
audioDuration:(NSTimeInterval)duration
|
||||
audioData:(nullable NSData *)audioData;
|
||||
```
|
||||
|
||||
**示例:**
|
||||
```objective-c
|
||||
// 有语音
|
||||
[self.chatView addAssistantMessage:@"你好!"
|
||||
audioDuration:3.0
|
||||
audioData:audioData];
|
||||
|
||||
// 无语音
|
||||
[self.chatView addAssistantMessage:@"你好!"
|
||||
audioDuration:0
|
||||
audioData:nil];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 更新最后一条 AI 消息(打字机效果)
|
||||
```objective-c
|
||||
- (void)updateLastAssistantMessage:(NSString *)text;
|
||||
```
|
||||
|
||||
**示例:**
|
||||
```objective-c
|
||||
// 1. 先添加空消息
|
||||
[self.chatView addAssistantMessage:@"" audioDuration:0 audioData:nil];
|
||||
|
||||
// 2. 逐步更新
|
||||
[self.chatView updateLastAssistantMessage:@"你"];
|
||||
[self.chatView updateLastAssistantMessage:@"你好"];
|
||||
[self.chatView updateLastAssistantMessage:@"你好!"];
|
||||
|
||||
// 3. 标记完成
|
||||
[self.chatView markLastAssistantMessageComplete];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 标记最后一条 AI 消息完成
|
||||
```objective-c
|
||||
- (void)markLastAssistantMessageComplete;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 清空所有消息
|
||||
```objective-c
|
||||
- (void)clearMessages;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 滚动到底部
|
||||
```objective-c
|
||||
- (void)scrollToBottom;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 消息类型
|
||||
|
||||
### KBChatMessage
|
||||
|
||||
```objective-c
|
||||
@interface KBChatMessage : NSObject
|
||||
|
||||
@property (nonatomic, assign) KBChatMessageType type;
|
||||
@property (nonatomic, copy) NSString *text;
|
||||
@property (nonatomic, strong) NSDate *timestamp;
|
||||
@property (nonatomic, assign) NSTimeInterval audioDuration;
|
||||
@property (nonatomic, strong, nullable) NSData *audioData;
|
||||
@property (nonatomic, assign) BOOL isComplete;
|
||||
|
||||
// 便捷构造方法
|
||||
+ (instancetype)userMessageWithText:(NSString *)text;
|
||||
+ (instancetype)assistantMessageWithText:(NSString *)text
|
||||
audioDuration:(NSTimeInterval)duration
|
||||
audioData:(nullable NSData *)audioData;
|
||||
+ (instancetype)timeMessageWithTimestamp:(NSDate *)timestamp;
|
||||
|
||||
@end
|
||||
```
|
||||
|
||||
### KBChatMessageType
|
||||
|
||||
```objective-c
|
||||
typedef NS_ENUM(NSInteger, KBChatMessageType) {
|
||||
KBChatMessageTypeUser, // 用户消息
|
||||
KBChatMessageTypeAssistant, // AI 回复
|
||||
KBChatMessageTypeTime // 时间戳
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔊 音频时长计算
|
||||
|
||||
### 方法 1:使用 AVAudioPlayer(推荐)
|
||||
|
||||
```objective-c
|
||||
NSError *error = nil;
|
||||
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:audioData error:&error];
|
||||
NSTimeInterval duration = player ? player.duration : 0;
|
||||
|
||||
[self.chatView addAssistantMessage:text
|
||||
audioDuration:duration
|
||||
audioData:audioData];
|
||||
```
|
||||
|
||||
### 方法 2:估算(不准确)
|
||||
|
||||
```objective-c
|
||||
// 假设:16kHz 采样率,单声道,16 位
|
||||
NSTimeInterval duration = audioData.length / (16000.0 * 1 * 2);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⏰ 时间戳规则
|
||||
|
||||
时间戳自动插入,无需手动添加:
|
||||
|
||||
- ✅ 第一条消息
|
||||
- ✅ 距离上一条消息 > 5 分钟
|
||||
- ✅ 跨天的消息
|
||||
|
||||
**时间格式:**
|
||||
- 今天:`16:36`
|
||||
- 昨天:`昨天 16:36`
|
||||
- 其他:`01月23日 16:36`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 完整示例
|
||||
|
||||
```objective-c
|
||||
// 1. 用户发送消息
|
||||
[self.chatView addUserMessage:@"你好"];
|
||||
|
||||
// 2. AI 回复(带语音)
|
||||
NSString *aiText = @"你好!很高兴见到你。";
|
||||
NSData *audioData = ...; // 从 TTS 获取
|
||||
|
||||
NSError *error = nil;
|
||||
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:audioData error:&error];
|
||||
NSTimeInterval duration = player ? player.duration : 0;
|
||||
|
||||
[self.chatView addAssistantMessage:aiText
|
||||
audioDuration:duration
|
||||
audioData:audioData];
|
||||
|
||||
// 3. 打字机效果
|
||||
[self.chatView addAssistantMessage:@"" audioDuration:0 audioData:nil];
|
||||
|
||||
// 模拟流式返回
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
|
||||
[self.chatView updateLastAssistantMessage:@"正"];
|
||||
});
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.2 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
|
||||
[self.chatView updateLastAssistantMessage:@"正在"];
|
||||
});
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.3 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
|
||||
[self.chatView updateLastAssistantMessage:@"正在思考"];
|
||||
});
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.4 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
|
||||
[self.chatView updateLastAssistantMessage:@"正在思考..."];
|
||||
[self.chatView markLastAssistantMessageComplete];
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 自定义配置
|
||||
|
||||
### 修改时间戳间隔
|
||||
|
||||
在 `KBChatTableView.m` 中:
|
||||
```objective-c
|
||||
static const NSTimeInterval kTimestampInterval = 5 * 60; // 秒
|
||||
```
|
||||
|
||||
### 修改气泡颜色
|
||||
|
||||
**用户消息**(`KBChatUserMessageCell.m`):
|
||||
```objective-c
|
||||
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.94 green:0.94 blue:0.94 alpha:1.0];
|
||||
```
|
||||
|
||||
**AI 消息**(`KBChatAssistantMessageCell.m`):
|
||||
```objective-c
|
||||
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.7];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **音频格式**:MP3、AAC、M4A 等 AVAudioPlayer 支持的格式
|
||||
2. **线程安全**:UI 更新必须在主线程
|
||||
3. **内存管理**:大量消息时考虑限制数量
|
||||
4. **音频会话**:确保配置了 AVAudioSession
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- 详细使用说明:`KBChatTableView_Usage.md`
|
||||
- 集成指南:`集成指南.md`
|
||||
- 测试页面:`KBChatTestVC.h/m`
|
||||
@@ -6,24 +6,10 @@
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import "KBAiChatMessage.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 消息类型
|
||||
typedef NS_ENUM(NSInteger, KBAiChatMessageType) {
|
||||
KBAiChatMessageTypeUser, // 用户消息
|
||||
KBAiChatMessageTypeAssistant // AI 回复
|
||||
};
|
||||
|
||||
/// 聊天消息模型
|
||||
@interface KBAiChatMessage : NSObject
|
||||
@property(nonatomic, assign) KBAiChatMessageType type;
|
||||
@property(nonatomic, copy) NSString *text;
|
||||
@property(nonatomic, assign) BOOL isComplete; // 是否完成(用于打字机效果)
|
||||
+ (instancetype)userMessageWithText:(NSString *)text;
|
||||
+ (instancetype)assistantMessageWithText:(NSString *)text;
|
||||
@end
|
||||
|
||||
/// 聊天视图
|
||||
/// 显示用户消息和 AI 回复的气泡列表
|
||||
@interface KBAiChatView : UIView
|
||||
|
||||
@@ -7,28 +7,6 @@
|
||||
|
||||
#import "KBAiChatView.h"
|
||||
|
||||
#pragma mark - KBAiChatMessage
|
||||
|
||||
@implementation KBAiChatMessage
|
||||
|
||||
+ (instancetype)userMessageWithText:(NSString *)text {
|
||||
KBAiChatMessage *message = [[KBAiChatMessage alloc] init];
|
||||
message.type = KBAiChatMessageTypeUser;
|
||||
message.text = text;
|
||||
message.isComplete = YES;
|
||||
return message;
|
||||
}
|
||||
|
||||
+ (instancetype)assistantMessageWithText:(NSString *)text {
|
||||
KBAiChatMessage *message = [[KBAiChatMessage alloc] init];
|
||||
message.type = KBAiChatMessageTypeAssistant;
|
||||
message.text = text;
|
||||
message.isComplete = NO;
|
||||
return message;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - KBAiChatBubbleCell
|
||||
|
||||
@interface KBAiChatBubbleCell : UITableViewCell
|
||||
|
||||
47
keyBoard/Class/AiTalk/V/KBChatAssistantMessageCell.h
Normal file
47
keyBoard/Class/AiTalk/V/KBChatAssistantMessageCell.h
Normal file
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// KBChatAssistantMessageCell.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Kiro on 2026/1/23.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@class KBAiChatMessage;
|
||||
@class KBChatAssistantMessageCell;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// AI 消息 Cell 代理
|
||||
@protocol KBChatAssistantMessageCellDelegate <NSObject>
|
||||
|
||||
/// 点击语音播放按钮
|
||||
- (void)assistantMessageCell:(KBChatAssistantMessageCell *)cell
|
||||
didTapVoiceButtonForMessage:(KBAiChatMessage *)message;
|
||||
|
||||
@end
|
||||
|
||||
/// AI 消息 Cell(左侧气泡 + 语音按钮)
|
||||
@interface KBChatAssistantMessageCell : UITableViewCell
|
||||
|
||||
@property (nonatomic, weak) id<KBChatAssistantMessageCellDelegate> delegate;
|
||||
@property (nonatomic, strong, readonly) UILabel *messageLabel; // 暴露 messageLabel 供外部访问
|
||||
|
||||
/// 配置 Cell
|
||||
- (void)configureWithMessage:(KBAiChatMessage *)message;
|
||||
|
||||
/// 更新语音播放状态
|
||||
- (void)updateVoicePlayingState:(BOOL)isPlaying;
|
||||
|
||||
/// 显示加载动画
|
||||
- (void)showLoadingAnimation;
|
||||
|
||||
/// 隐藏加载动画
|
||||
- (void)hideLoadingAnimation;
|
||||
|
||||
/// 停止打字机效果
|
||||
- (void)stopTypewriterEffect;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
339
keyBoard/Class/AiTalk/V/KBChatAssistantMessageCell.m
Normal file
339
keyBoard/Class/AiTalk/V/KBChatAssistantMessageCell.m
Normal file
@@ -0,0 +1,339 @@
|
||||
//
|
||||
// KBChatAssistantMessageCell.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Kiro on 2026/1/23.
|
||||
//
|
||||
|
||||
#import "KBChatAssistantMessageCell.h"
|
||||
#import "KBAiChatMessage.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
|
||||
@interface KBChatAssistantMessageCell ()
|
||||
|
||||
@property (nonatomic, strong) UIButton *voiceButton;
|
||||
@property (nonatomic, strong) UILabel *durationLabel;
|
||||
@property (nonatomic, strong) UIView *bubbleView;
|
||||
@property (nonatomic, strong, readwrite) UILabel *messageLabel; // readwrite 允许内部修改
|
||||
@property (nonatomic, strong) UIActivityIndicatorView *loadingIndicator;
|
||||
@property (nonatomic, strong) KBAiChatMessage *currentMessage;
|
||||
|
||||
// 打字机效果
|
||||
@property (nonatomic, strong) NSTimer *typewriterTimer;
|
||||
@property (nonatomic, copy) NSString *fullText;
|
||||
@property (nonatomic, assign) NSInteger currentCharIndex;
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBChatAssistantMessageCell
|
||||
|
||||
- (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.voiceButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
[self.voiceButton setImage:[UIImage systemImageNamed:@"play.circle.fill"]
|
||||
forState:UIControlStateNormal];
|
||||
self.voiceButton.tintColor = [UIColor whiteColor];
|
||||
[self.voiceButton addTarget:self
|
||||
action:@selector(voiceButtonTapped)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.contentView addSubview:self.voiceButton];
|
||||
|
||||
// 语音时长标签
|
||||
self.durationLabel = [[UILabel alloc] init];
|
||||
self.durationLabel.font = [UIFont systemFontOfSize:14];
|
||||
self.durationLabel.textColor = [UIColor whiteColor];
|
||||
[self.contentView addSubview:self.durationLabel];
|
||||
|
||||
// 加载指示器
|
||||
self.loadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||
self.loadingIndicator.color = [UIColor whiteColor];
|
||||
self.loadingIndicator.hidesWhenStopped = YES;
|
||||
[self.contentView addSubview:self.loadingIndicator];
|
||||
|
||||
// 气泡视图
|
||||
self.bubbleView = [[UIView alloc] init];
|
||||
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.7];
|
||||
self.bubbleView.layer.cornerRadius = 16;
|
||||
self.bubbleView.layer.masksToBounds = YES;
|
||||
[self.contentView addSubview:self.bubbleView];
|
||||
|
||||
// 消息标签
|
||||
self.messageLabel = [[UILabel alloc] init];
|
||||
self.messageLabel.numberOfLines = 0;
|
||||
self.messageLabel.font = [UIFont systemFontOfSize:16];
|
||||
self.messageLabel.textColor = [UIColor whiteColor];
|
||||
// 设置 preferredMaxLayoutWidth 让 AutoLayout 能正确计算多行高度
|
||||
CGFloat maxWidth = [UIScreen mainScreen].bounds.size.width * 0.75 - 16 - 24;
|
||||
self.messageLabel.preferredMaxLayoutWidth = maxWidth;
|
||||
[self.bubbleView addSubview:self.messageLabel];
|
||||
|
||||
// 布局约束
|
||||
[self.voiceButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.contentView).offset(16);
|
||||
make.top.equalTo(self.contentView).offset(8);
|
||||
make.width.height.mas_equalTo(24);
|
||||
}];
|
||||
|
||||
[self.durationLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.voiceButton.mas_right).offset(4);
|
||||
make.centerY.equalTo(self.voiceButton);
|
||||
}];
|
||||
|
||||
[self.loadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(self.voiceButton);
|
||||
}];
|
||||
|
||||
// bubbleView 约束
|
||||
[self.bubbleView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.voiceButton.mas_bottom).offset(4);
|
||||
make.bottom.equalTo(self.contentView).offset(-4);
|
||||
make.left.equalTo(self.contentView).offset(16);
|
||||
make.width.lessThanOrEqualTo(self.contentView).multipliedBy(0.75);
|
||||
}];
|
||||
|
||||
// messageLabel 约束
|
||||
[self.messageLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.bubbleView).offset(10);
|
||||
make.bottom.equalTo(self.bubbleView).offset(-10);
|
||||
make.left.equalTo(self.bubbleView).offset(12);
|
||||
make.right.equalTo(self.bubbleView).offset(-12);
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)configureWithMessage:(KBAiChatMessage *)message {
|
||||
NSLog(@"[KBChatAssistantMessageCell] 配置消息 - 文本长度: %lu, isComplete: %d, needsTypewriter: %d, 打字机运行中: %d",
|
||||
(unsigned long)message.text.length, message.isComplete, message.needsTypewriterEffect,
|
||||
(self.typewriterTimer && self.typewriterTimer.isValid));
|
||||
|
||||
// 先停止之前的打字机效果(无论是否是同一条消息)
|
||||
[self stopTypewriterEffect];
|
||||
|
||||
self.currentMessage = message;
|
||||
|
||||
// 只有明确需要打字机效果的消息才使用打字机
|
||||
if (message.needsTypewriterEffect && !message.isComplete && message.text.length > 0) {
|
||||
// 启动新的打字机效果
|
||||
NSLog(@"[KBChatAssistantMessageCell] 启动新的打字机效果");
|
||||
[self startTypewriterEffectWithText:message.text];
|
||||
} else {
|
||||
// 直接显示完整文本
|
||||
NSLog(@"[KBChatAssistantMessageCell] 直接显示完整文本(needsTypewriter: %d, isComplete: %d)",
|
||||
message.needsTypewriterEffect, message.isComplete);
|
||||
self.messageLabel.text = message.text;
|
||||
}
|
||||
|
||||
// 格式化语音时长(如果时长为 0,不显示)
|
||||
if (message.audioDuration > 0) {
|
||||
NSInteger seconds = (NSInteger)ceil(message.audioDuration);
|
||||
self.durationLabel.text = [NSString stringWithFormat:@"%ld\"", (long)seconds];
|
||||
} else {
|
||||
self.durationLabel.text = @"";
|
||||
}
|
||||
|
||||
// 如果有 audioId 或 audioData,显示语音按钮
|
||||
BOOL hasAudio = (message.audioId.length > 0) ||
|
||||
(message.audioData != nil && message.audioData.length > 0);
|
||||
self.voiceButton.hidden = !hasAudio;
|
||||
self.durationLabel.hidden = !hasAudio;
|
||||
}
|
||||
|
||||
- (void)updateVoicePlayingState:(BOOL)isPlaying {
|
||||
if (isPlaying) {
|
||||
[self.voiceButton setImage:[UIImage systemImageNamed:@"pause.circle.fill"]
|
||||
forState:UIControlStateNormal];
|
||||
} else {
|
||||
[self.voiceButton setImage:[UIImage systemImageNamed:@"play.circle.fill"]
|
||||
forState:UIControlStateNormal];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)showLoadingAnimation {
|
||||
// 隐藏按钮图标,显示加载动画
|
||||
[self.voiceButton setImage:nil forState:UIControlStateNormal];
|
||||
[self.loadingIndicator startAnimating];
|
||||
}
|
||||
|
||||
- (void)hideLoadingAnimation {
|
||||
// 停止加载动画,恢复按钮图标
|
||||
[self.loadingIndicator stopAnimating];
|
||||
[self.voiceButton setImage:[UIImage systemImageNamed:@"play.circle.fill"]
|
||||
forState:UIControlStateNormal];
|
||||
}
|
||||
|
||||
- (void)voiceButtonTapped {
|
||||
if ([self.delegate respondsToSelector:@selector(assistantMessageCell:didTapVoiceButtonForMessage:)]) {
|
||||
[self.delegate assistantMessageCell:self didTapVoiceButtonForMessage:self.currentMessage];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Typewriter Effect
|
||||
|
||||
- (void)startTypewriterEffectWithText:(NSString *)text {
|
||||
if (text.length == 0) {
|
||||
NSLog(@"[KBChatAssistantMessageCell] 文本为空,跳过打字机效果");
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"[KBChatAssistantMessageCell] 开始打字机效果,文本长度: %lu, 文本: %@",
|
||||
(unsigned long)text.length, text);
|
||||
|
||||
self.fullText = text;
|
||||
self.currentCharIndex = 0;
|
||||
|
||||
// 先设置完整文本,让布局系统计算出正确的高度
|
||||
self.messageLabel.text = text;
|
||||
|
||||
// 强制布局更新
|
||||
[self.messageLabel setNeedsLayout];
|
||||
[self.bubbleView setNeedsLayout];
|
||||
[self.contentView setNeedsLayout];
|
||||
[self layoutIfNeeded];
|
||||
|
||||
NSLog(@"[KBChatAssistantMessageCell] 布局后 Label frame: %@", NSStringFromCGRect(self.messageLabel.frame));
|
||||
|
||||
// 使用 NSAttributedString 实现打字机效果
|
||||
// 先把所有文字设置为透明,然后逐个显示
|
||||
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||
value:[UIColor clearColor]
|
||||
range:NSMakeRange(0, text.length)];
|
||||
[attributedText addAttribute:NSFontAttributeName
|
||||
value:self.messageLabel.font
|
||||
range:NSMakeRange(0, text.length)];
|
||||
self.messageLabel.attributedText = attributedText;
|
||||
|
||||
// 确保在主线程创建定时器
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
// 每 0.03 秒显示一个字符(更快的打字机效果)
|
||||
self.typewriterTimer = [NSTimer scheduledTimerWithTimeInterval:0.03
|
||||
target:self
|
||||
selector:@selector(typewriterTick)
|
||||
userInfo:nil
|
||||
repeats:YES];
|
||||
|
||||
// 将定时器添加到 RunLoop 的 common 模式,确保滚动时也能触发
|
||||
[[NSRunLoop currentRunLoop] addTimer:self.typewriterTimer forMode:NSRunLoopCommonModes];
|
||||
|
||||
// 立即触发一次,显示第一个字符
|
||||
[self typewriterTick];
|
||||
|
||||
NSLog(@"[KBChatAssistantMessageCell] 定时器已创建: %@", self.typewriterTimer);
|
||||
});
|
||||
}
|
||||
|
||||
- (void)typewriterTick {
|
||||
// 使用局部变量保存 fullText,避免多线程问题
|
||||
NSString *text = self.fullText;
|
||||
|
||||
// 检查 fullText 是否有效
|
||||
if (!text || text.length == 0) {
|
||||
NSLog(@"[KBChatAssistantMessageCell] fullText 无效,停止打字机");
|
||||
[self stopTypewriterEffect];
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果当前索引小于完整文本长度,继续显示
|
||||
if (self.currentCharIndex < text.length) {
|
||||
self.currentCharIndex++;
|
||||
|
||||
// 使用 NSAttributedString 显示前 N 个字符,隐藏后面的字符
|
||||
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||
|
||||
// 已显示的部分:白色
|
||||
if (self.currentCharIndex > 0) {
|
||||
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||
value:[UIColor whiteColor]
|
||||
range:NSMakeRange(0, self.currentCharIndex)];
|
||||
}
|
||||
|
||||
// 未显示的部分:透明
|
||||
if (self.currentCharIndex < text.length) {
|
||||
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||
value:[UIColor clearColor]
|
||||
range:NSMakeRange(self.currentCharIndex, text.length - self.currentCharIndex)];
|
||||
}
|
||||
|
||||
// 设置字体
|
||||
[attributedText addAttribute:NSFontAttributeName
|
||||
value:self.messageLabel.font
|
||||
range:NSMakeRange(0, text.length)];
|
||||
|
||||
self.messageLabel.attributedText = attributedText;
|
||||
|
||||
// 每 10 个字符打印一次日志
|
||||
if (self.currentCharIndex % 10 == 0) {
|
||||
NSString *displayText = [text substringToIndex:self.currentCharIndex];
|
||||
NSLog(@"[KBChatAssistantMessageCell] 打字机进度: %ld/%lu, 当前文本: %@",
|
||||
(long)self.currentCharIndex, (unsigned long)text.length, displayText);
|
||||
NSLog(@"[KBChatAssistantMessageCell] Label 状态 - frame: %@, hidden: %d, alpha: %.2f",
|
||||
NSStringFromCGRect(self.messageLabel.frame),
|
||||
self.messageLabel.hidden,
|
||||
self.messageLabel.alpha);
|
||||
}
|
||||
} else {
|
||||
// 打字机效果已完成,停止定时器并标记消息完成
|
||||
NSLog(@"[KBChatAssistantMessageCell] 打字机效果完成,停止定时器");
|
||||
|
||||
// 检查 text 是否为空
|
||||
if (!text || text.length == 0) {
|
||||
NSLog(@"[KBChatAssistantMessageCell] text 为空,跳过");
|
||||
[self stopTypewriterEffect];
|
||||
return;
|
||||
}
|
||||
|
||||
[self stopTypewriterEffect];
|
||||
|
||||
// 显示完整文本(白色),使用 attributedText 保持布局
|
||||
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||
value:[UIColor whiteColor]
|
||||
range:NSMakeRange(0, text.length)];
|
||||
[attributedText addAttribute:NSFontAttributeName
|
||||
value:self.messageLabel.font
|
||||
range:NSMakeRange(0, text.length)];
|
||||
self.messageLabel.attributedText = attributedText;
|
||||
|
||||
// 标记消息为完成状态,避免 Cell 复用时重新触发打字机
|
||||
if (self.currentMessage) {
|
||||
self.currentMessage.isComplete = YES;
|
||||
self.currentMessage.needsTypewriterEffect = NO;
|
||||
NSLog(@"[KBChatAssistantMessageCell] 标记消息完成 - 文本: %@", self.currentMessage.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
- (void)stopTypewriterEffect {
|
||||
if (self.typewriterTimer && self.typewriterTimer.isValid) {
|
||||
NSLog(@"[KBChatAssistantMessageCell] 停止打字机定时器");
|
||||
[self.typewriterTimer invalidate];
|
||||
}
|
||||
self.typewriterTimer = nil;
|
||||
self.currentCharIndex = 0;
|
||||
self.fullText = nil;
|
||||
}
|
||||
|
||||
- (void)prepareForReuse {
|
||||
[super prepareForReuse];
|
||||
[self stopTypewriterEffect];
|
||||
// 清空文本,避免复用时显示旧内容
|
||||
self.messageLabel.text = @"";
|
||||
self.messageLabel.attributedText = nil;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self stopTypewriterEffect];
|
||||
}
|
||||
|
||||
@end
|
||||
46
keyBoard/Class/AiTalk/V/KBChatTableView.h
Normal file
46
keyBoard/Class/AiTalk/V/KBChatTableView.h
Normal file
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// KBChatTableView.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Kiro on 2026/1/23.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@class KBChatMessage;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 聊天列表视图(支持用户消息、AI 消息、时间戳、语音播放)
|
||||
@interface KBChatTableView : UIView
|
||||
|
||||
/// 添加用户消息
|
||||
- (void)addUserMessage:(NSString *)text;
|
||||
|
||||
/// 添加 AI 消息(带语音)
|
||||
- (void)addAssistantMessage:(NSString *)text
|
||||
audioDuration:(NSTimeInterval)duration
|
||||
audioData:(nullable NSData *)audioData;
|
||||
|
||||
/// 添加 AI 消息(带 audioId,异步加载音频)
|
||||
- (void)addAssistantMessage:(NSString *)text
|
||||
audioId:(nullable NSString *)audioId;
|
||||
|
||||
/// 更新最后一条 AI 消息(用于打字机效果)
|
||||
- (void)updateLastAssistantMessage:(NSString *)text;
|
||||
|
||||
/// 标记最后一条 AI 消息完成
|
||||
- (void)markLastAssistantMessageComplete;
|
||||
|
||||
/// 清空所有消息
|
||||
- (void)clearMessages;
|
||||
|
||||
/// 滚动到底部
|
||||
- (void)scrollToBottom;
|
||||
|
||||
/// 停止正在播放的音频
|
||||
- (void)stopPlayingAudio;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
576
keyBoard/Class/AiTalk/V/KBChatTableView.m
Normal file
576
keyBoard/Class/AiTalk/V/KBChatTableView.m
Normal file
@@ -0,0 +1,576 @@
|
||||
//
|
||||
// KBChatTableView.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Kiro on 2026/1/23.
|
||||
//
|
||||
|
||||
#import "KBChatTableView.h"
|
||||
#import "KBAiChatMessage.h"
|
||||
#import "KBChatUserMessageCell.h"
|
||||
#import "KBChatAssistantMessageCell.h"
|
||||
#import "KBChatTimeCell.h"
|
||||
#import "AiVM.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
|
||||
static NSString * const kUserCellIdentifier = @"KBChatUserMessageCell";
|
||||
static NSString * const kAssistantCellIdentifier = @"KBChatAssistantMessageCell";
|
||||
static NSString * const kTimeCellIdentifier = @"KBChatTimeCell";
|
||||
|
||||
/// 时间戳显示间隔(秒)
|
||||
static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
|
||||
|
||||
@interface KBChatTableView () <UITableViewDataSource, UITableViewDelegate, KBChatAssistantMessageCellDelegate, AVAudioPlayerDelegate>
|
||||
|
||||
@property (nonatomic, strong) UITableView *tableView;
|
||||
@property (nonatomic, strong) NSMutableArray<KBAiChatMessage *> *messages;
|
||||
@property (nonatomic, strong) AVAudioPlayer *audioPlayer;
|
||||
@property (nonatomic, strong) NSIndexPath *playingCellIndexPath;
|
||||
@property (nonatomic, strong) AiVM *aiVM;
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBChatTableView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
self = [super initWithFrame:frame];
|
||||
if (self) {
|
||||
[self setup];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)initWithCoder:(NSCoder *)coder {
|
||||
self = [super initWithCoder:coder];
|
||||
if (self) {
|
||||
[self setup];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setup {
|
||||
self.messages = [[NSMutableArray alloc] init];
|
||||
self.aiVM = [[AiVM alloc] init];
|
||||
|
||||
// 创建 TableView
|
||||
self.tableView = [[UITableView alloc] initWithFrame:self.bounds
|
||||
style:UITableViewStylePlain];
|
||||
self.tableView.dataSource = self;
|
||||
self.tableView.delegate = self;
|
||||
self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
|
||||
self.tableView.backgroundColor = [UIColor clearColor];
|
||||
self.tableView.estimatedRowHeight = 60;
|
||||
self.tableView.rowHeight = UITableViewAutomaticDimension;
|
||||
self.tableView.showsVerticalScrollIndicator = YES;
|
||||
[self addSubview:self.tableView];
|
||||
|
||||
// 注册 Cell
|
||||
[self.tableView registerClass:[KBChatUserMessageCell class]
|
||||
forCellReuseIdentifier:kUserCellIdentifier];
|
||||
[self.tableView registerClass:[KBChatAssistantMessageCell class]
|
||||
forCellReuseIdentifier:kAssistantCellIdentifier];
|
||||
[self.tableView registerClass:[KBChatTimeCell class]
|
||||
forCellReuseIdentifier:kTimeCellIdentifier];
|
||||
|
||||
// 布局
|
||||
[self.tableView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self);
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (void)addUserMessage:(NSString *)text {
|
||||
// 记录插入前的消息数量
|
||||
NSInteger oldCount = self.messages.count;
|
||||
|
||||
KBAiChatMessage *message = [KBAiChatMessage userMessageWithText:text];
|
||||
[self insertMessageWithTimestamp:message];
|
||||
|
||||
// 计算新增的行数
|
||||
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
|
||||
audioDuration:(NSTimeInterval)duration
|
||||
audioData:(NSData *)audioData {
|
||||
// 记录插入前的消息数量
|
||||
NSInteger oldCount = self.messages.count;
|
||||
|
||||
KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text
|
||||
audioDuration:duration
|
||||
audioData:audioData];
|
||||
[self insertMessageWithTimestamp:message];
|
||||
|
||||
// 计算新增的行数
|
||||
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
|
||||
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
|
||||
audioId:audioId];
|
||||
message.needsTypewriterEffect = YES; // 新消息需要打字机效果
|
||||
NSLog(@"[KBChatTableView] 新消息属性 - needsTypewriter: %d, isComplete: %d",
|
||||
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 {
|
||||
// 查找最后一条未完成的 AI 消息
|
||||
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||
KBAiChatMessage *message = self.messages[i];
|
||||
if (message.type == KBAiChatMessageTypeAssistant && !message.isComplete) {
|
||||
NSLog(@"[KBChatTableView] 更新最后一条 AI 消息 - 索引: %ld, 文本长度: %lu, needsTypewriter: %d",
|
||||
(long)i, (unsigned long)text.length, message.needsTypewriterEffect);
|
||||
message.text = text;
|
||||
|
||||
// 直接更新 Cell 的文本,不刷新整个 Cell(避免打断打字机效果)
|
||||
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
|
||||
KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
|
||||
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||
NSLog(@"[KBChatTableView] 找到 Cell,直接配置消息");
|
||||
// 直接调用 configureWithMessage,让 Cell 自己决定是否使用打字机效果
|
||||
[cell configureWithMessage:message];
|
||||
} else {
|
||||
NSLog(@"[KBChatTableView] 未找到 Cell 或类型不匹配");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没找到,添加新消息
|
||||
NSLog(@"[KBChatTableView] 未找到未完成的 AI 消息,添加新消息");
|
||||
[self addAssistantMessage:text audioDuration:0 audioData:nil];
|
||||
}
|
||||
|
||||
- (void)markLastAssistantMessageComplete {
|
||||
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||
KBAiChatMessage *message = self.messages[i];
|
||||
if (message.type == KBAiChatMessageTypeAssistant) {
|
||||
NSLog(@"[KBChatTableView] 标记消息完成 - 索引: %ld, 文本: %@", (long)i, message.text);
|
||||
message.isComplete = YES;
|
||||
message.needsTypewriterEffect = NO; // 完成后不再需要打字机效果
|
||||
|
||||
// 刷新 Cell 以显示完整文本
|
||||
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
|
||||
[self.tableView reloadRowsAtIndexPaths:@[indexPath]
|
||||
withRowAnimation:UITableViewRowAnimationNone];
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)clearMessages {
|
||||
[self.messages removeAllObjects];
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
|
||||
- (void)scrollToBottom {
|
||||
if (self.messages.count == 0) return;
|
||||
|
||||
NSIndexPath *lastIndexPath = [NSIndexPath indexPathForRow:self.messages.count - 1
|
||||
inSection:0];
|
||||
[self.tableView scrollToRowAtIndexPath:lastIndexPath
|
||||
atScrollPosition:UITableViewScrollPositionBottom
|
||||
animated:YES];
|
||||
}
|
||||
|
||||
#pragma mark - Private Methods
|
||||
|
||||
/// 插入消息并自动添加时间戳
|
||||
- (void)insertMessageWithTimestamp:(KBAiChatMessage *)message {
|
||||
// 判断是否需要插入时间戳
|
||||
if ([self shouldInsertTimestampForMessage:message]) {
|
||||
KBAiChatMessage *timeMessage = [KBAiChatMessage timeMessageWithTimestamp:message.timestamp];
|
||||
[self.messages addObject:timeMessage];
|
||||
}
|
||||
|
||||
[self.messages addObject:message];
|
||||
}
|
||||
|
||||
/// 判断是否需要插入时间戳
|
||||
- (BOOL)shouldInsertTimestampForMessage:(KBAiChatMessage *)message {
|
||||
// 第一条消息总是显示时间
|
||||
if (self.messages.count == 0) {
|
||||
return YES;
|
||||
}
|
||||
|
||||
// 查找最后一条非时间戳消息
|
||||
KBAiChatMessage *lastMessage = nil;
|
||||
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||
KBAiChatMessage *msg = self.messages[i];
|
||||
if (msg.type != KBAiChatMessageTypeTime) {
|
||||
lastMessage = msg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastMessage) {
|
||||
return YES;
|
||||
}
|
||||
|
||||
// 计算时间间隔
|
||||
NSTimeInterval interval = [message.timestamp timeIntervalSinceDate:lastMessage.timestamp];
|
||||
|
||||
// 超过 5 分钟或跨天则显示时间
|
||||
if (interval >= kTimestampInterval) {
|
||||
return YES;
|
||||
}
|
||||
|
||||
NSCalendar *calendar = [NSCalendar currentCalendar];
|
||||
NSDateComponents *lastComponents = [calendar components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear
|
||||
fromDate:lastMessage.timestamp];
|
||||
NSDateComponents *currentComponents = [calendar components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear
|
||||
fromDate:message.timestamp];
|
||||
|
||||
return ![lastComponents isEqual:currentComponents];
|
||||
}
|
||||
|
||||
/// 刷新并滚动到底部
|
||||
- (void)reloadAndScroll {
|
||||
// 使用 insert 而不是 reload,避免刷新已有的 Cell
|
||||
NSInteger lastIndex = self.messages.count - 1;
|
||||
if (lastIndex >= 0) {
|
||||
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:lastIndex inSection:0];
|
||||
[self.tableView insertRowsAtIndexPaths:@[indexPath]
|
||||
withRowAnimation:UITableViewRowAnimationNone];
|
||||
}
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
[self scrollToBottom];
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - UITableViewDataSource
|
||||
|
||||
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
|
||||
return self.messages.count;
|
||||
}
|
||||
|
||||
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
KBAiChatMessage *message = self.messages[indexPath.row];
|
||||
|
||||
NSLog(@"[KBChatTableView] cellForRow: %ld, 消息类型: %ld, needsTypewriter: %d, isComplete: %d",
|
||||
(long)indexPath.row, (long)message.type, message.needsTypewriterEffect, message.isComplete);
|
||||
|
||||
switch (message.type) {
|
||||
case KBAiChatMessageTypeUser: {
|
||||
KBChatUserMessageCell *cell = [tableView dequeueReusableCellWithIdentifier:kUserCellIdentifier
|
||||
forIndexPath:indexPath];
|
||||
[cell configureWithMessage:message];
|
||||
return cell;
|
||||
}
|
||||
|
||||
case KBAiChatMessageTypeAssistant: {
|
||||
KBChatAssistantMessageCell *cell = [tableView dequeueReusableCellWithIdentifier:kAssistantCellIdentifier
|
||||
forIndexPath:indexPath];
|
||||
cell.delegate = self;
|
||||
[cell configureWithMessage:message];
|
||||
|
||||
// 更新播放状态
|
||||
BOOL isPlaying = [indexPath isEqual:self.playingCellIndexPath];
|
||||
[cell updateVoicePlayingState:isPlaying];
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
case KBAiChatMessageTypeTime: {
|
||||
KBChatTimeCell *cell = [tableView dequeueReusableCellWithIdentifier:kTimeCellIdentifier
|
||||
forIndexPath:indexPath];
|
||||
[cell configureWithMessage:message];
|
||||
return cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - KBChatAssistantMessageCellDelegate
|
||||
|
||||
- (void)assistantMessageCell:(KBChatAssistantMessageCell *)cell
|
||||
didTapVoiceButtonForMessage:(KBAiChatMessage *)message {
|
||||
|
||||
NSIndexPath *indexPath = [self.tableView indexPathForCell:cell];
|
||||
if (!indexPath) return;
|
||||
|
||||
// 如果正在播放同一条消息,则暂停
|
||||
if ([indexPath isEqual:self.playingCellIndexPath]) {
|
||||
[self stopPlayingAudio];
|
||||
return;
|
||||
}
|
||||
|
||||
// 停止之前的播放
|
||||
[self stopPlayingAudio];
|
||||
|
||||
// 如果有 audioData,直接播放
|
||||
if (message.audioData && message.audioData.length > 0) {
|
||||
[self playAudioForMessage:message atIndexPath:indexPath];
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果有 audioId,异步加载音频
|
||||
if (message.audioId.length > 0) {
|
||||
[self loadAndPlayAudioForMessage:message atIndexPath:indexPath];
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"[KBChatTableView] 没有音频数据或 audioId");
|
||||
}
|
||||
|
||||
#pragma mark - Audio Playback
|
||||
|
||||
- (void)loadAndPlayAudioForMessage:(KBAiChatMessage *)message atIndexPath:(NSIndexPath *)indexPath {
|
||||
// 显示加载动画
|
||||
KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
|
||||
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||
[cell showLoadingAnimation];
|
||||
}
|
||||
|
||||
// 开始轮询请求(最多5次,每次间隔0.5秒)
|
||||
[self pollAudioForMessage:message atIndexPath:indexPath retryCount:0 maxRetries:5];
|
||||
}
|
||||
|
||||
- (void)pollAudioForMessage:(KBAiChatMessage *)message
|
||||
atIndexPath:(NSIndexPath *)indexPath
|
||||
retryCount:(NSInteger)retryCount
|
||||
maxRetries:(NSInteger)maxRetries {
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[self.aiVM requestAudioWithAudioId:message.audioId
|
||||
completion:^(NSString *_Nullable audioURL, NSError *_Nullable error) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf) return;
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
// 如果成功获取到 audioURL
|
||||
if (!error && audioURL.length > 0) {
|
||||
// 下载并播放音频
|
||||
[strongSelf downloadAndPlayAudioFromURL:audioURL
|
||||
forMessage:message
|
||||
atIndexPath:indexPath];
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果还没达到最大重试次数,继续轮询
|
||||
if (retryCount < maxRetries - 1) {
|
||||
NSLog(@"[KBChatTableView] 音频未就绪,0.5秒后重试 (%ld/%ld)",
|
||||
(long)(retryCount + 1), (long)maxRetries);
|
||||
|
||||
// 0.5秒后重试
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
[strongSelf pollAudioForMessage:message
|
||||
atIndexPath:indexPath
|
||||
retryCount:retryCount + 1
|
||||
maxRetries:maxRetries];
|
||||
});
|
||||
} else {
|
||||
// 达到最大重试次数,隐藏加载动画
|
||||
KBChatAssistantMessageCell *cell = [strongSelf.tableView cellForRowAtIndexPath:indexPath];
|
||||
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||
[cell hideLoadingAnimation];
|
||||
}
|
||||
NSLog(@"[KBChatTableView] 音频加载失败,已重试 %ld 次", (long)maxRetries);
|
||||
}
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)downloadAndPlayAudioFromURL:(NSString *)urlString
|
||||
forMessage:(KBAiChatMessage *)message
|
||||
atIndexPath:(NSIndexPath *)indexPath {
|
||||
NSURL *url = [NSURL URLWithString:urlString];
|
||||
if (!url) {
|
||||
NSLog(@"[KBChatTableView] 无效的音频 URL: %@", urlString);
|
||||
// 隐藏加载动画
|
||||
KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
|
||||
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||
[cell hideLoadingAnimation];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
|
||||
NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
|
||||
|
||||
NSURLSessionDataTask *task = [session dataTaskWithURL:url
|
||||
completionHandler:^(NSData *_Nullable data,
|
||||
NSURLResponse *_Nullable response,
|
||||
NSError *_Nullable error) {
|
||||
if (error || !data || data.length == 0) {
|
||||
NSLog(@"[KBChatTableView] 下载音频失败: %@", error.localizedDescription ?: @"");
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
// 隐藏加载动画
|
||||
KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
|
||||
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||
[cell hideLoadingAnimation];
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
// 隐藏加载动画
|
||||
KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
|
||||
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||
[cell hideLoadingAnimation];
|
||||
}
|
||||
|
||||
// 缓存音频数据到消息对象
|
||||
message.audioData = data;
|
||||
|
||||
// 计算音频时长
|
||||
NSError *playerError = nil;
|
||||
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:data error:&playerError];
|
||||
if (!playerError && player) {
|
||||
message.audioDuration = player.duration;
|
||||
}
|
||||
|
||||
// 不刷新 Cell,避免触发打字机效果
|
||||
// 直接播放音频
|
||||
[self playAudioForMessage:message atIndexPath:indexPath];
|
||||
});
|
||||
}];
|
||||
|
||||
[task resume];
|
||||
}
|
||||
|
||||
- (void)playAudioForMessage:(KBAiChatMessage *)message atIndexPath:(NSIndexPath *)indexPath {
|
||||
if (!message.audioData || message.audioData.length == 0) {
|
||||
NSLog(@"[KBChatTableView] 没有音频数据");
|
||||
return;
|
||||
}
|
||||
|
||||
// 配置音频会话为播放模式
|
||||
NSError *sessionError = nil;
|
||||
AVAudioSession *audioSession = [AVAudioSession sharedInstance];
|
||||
[audioSession setCategory:AVAudioSessionCategoryPlayback error:&sessionError];
|
||||
[audioSession setActive:YES error:&sessionError];
|
||||
|
||||
if (sessionError) {
|
||||
NSLog(@"[KBChatTableView] 音频会话配置失败: %@", sessionError.localizedDescription);
|
||||
}
|
||||
|
||||
NSError *error = nil;
|
||||
self.audioPlayer = [[AVAudioPlayer alloc] initWithData:message.audioData error:&error];
|
||||
|
||||
if (error || !self.audioPlayer) {
|
||||
NSLog(@"[KBChatTableView] 音频播放器初始化失败: %@", error.localizedDescription);
|
||||
return;
|
||||
}
|
||||
|
||||
self.audioPlayer.delegate = self;
|
||||
self.audioPlayer.volume = 1.0; // 设置音量为最大
|
||||
[self.audioPlayer prepareToPlay];
|
||||
[self.audioPlayer play];
|
||||
|
||||
self.playingCellIndexPath = indexPath;
|
||||
|
||||
// 更新 Cell 状态
|
||||
KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath];
|
||||
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||
[cell updateVoicePlayingState:YES];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)stopPlayingAudio {
|
||||
if (self.audioPlayer && self.audioPlayer.isPlaying) {
|
||||
[self.audioPlayer stop];
|
||||
}
|
||||
|
||||
if (self.playingCellIndexPath) {
|
||||
KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:self.playingCellIndexPath];
|
||||
if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) {
|
||||
[cell updateVoicePlayingState:NO];
|
||||
}
|
||||
self.playingCellIndexPath = nil;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - AVAudioPlayerDelegate
|
||||
|
||||
- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag {
|
||||
[self stopPlayingAudio];
|
||||
}
|
||||
|
||||
@end
|
||||
195
keyBoard/Class/AiTalk/V/KBChatTableView_Usage.md
Normal file
195
keyBoard/Class/AiTalk/V/KBChatTableView_Usage.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# KBChatTableView 使用说明
|
||||
|
||||
## 概述
|
||||
|
||||
`KBChatTableView` 是一个完整的聊天 UI 组件,支持:
|
||||
- ✅ 用户消息(右侧气泡)
|
||||
- ✅ AI 消息(左侧气泡 + 语音播放按钮)
|
||||
- ✅ 时间戳(自动插入,5 分钟间隔)
|
||||
- ✅ 语音播放功能
|
||||
- ✅ 打字机效果
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 在 ViewController 中引入
|
||||
|
||||
```objective-c
|
||||
#import "KBChatTableView.h"
|
||||
|
||||
@interface YourViewController ()
|
||||
@property (nonatomic, strong) KBChatTableView *chatView;
|
||||
@end
|
||||
```
|
||||
|
||||
### 2. 初始化并布局
|
||||
|
||||
```objective-c
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
|
||||
self.chatView = [[KBChatTableView alloc] init];
|
||||
self.chatView.backgroundColor = [UIColor clearColor];
|
||||
[self.view addSubview:self.chatView];
|
||||
|
||||
[self.chatView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.view.mas_safeAreaLayoutGuideTop);
|
||||
make.left.right.equalTo(self.view);
|
||||
make.bottom.equalTo(self.recordButton.mas_top).offset(-16);
|
||||
}];
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 添加消息
|
||||
|
||||
#### 添加用户消息
|
||||
```objective-c
|
||||
[self.chatView addUserMessage:@"你好"];
|
||||
```
|
||||
|
||||
#### 添加 AI 消息(带语音)
|
||||
```objective-c
|
||||
NSString *text = @"你好,很高兴见到你!";
|
||||
NSTimeInterval duration = 6.0; // 6 秒
|
||||
NSData *audioData = ...; // 从 TTS 获取的音频数据
|
||||
|
||||
[self.chatView addAssistantMessage:text
|
||||
audioDuration:duration
|
||||
audioData:audioData];
|
||||
```
|
||||
|
||||
#### 添加 AI 消息(无语音)
|
||||
```objective-c
|
||||
[self.chatView addAssistantMessage:@"你好"
|
||||
audioDuration:0
|
||||
audioData:nil];
|
||||
```
|
||||
|
||||
### 4. 打字机效果
|
||||
|
||||
```objective-c
|
||||
// 1. 添加空消息占位
|
||||
[self.chatView addAssistantMessage:@""
|
||||
audioDuration:0
|
||||
audioData:nil];
|
||||
|
||||
// 2. 逐步更新文本
|
||||
[self.chatView updateLastAssistantMessage:@"你"];
|
||||
[self.chatView updateLastAssistantMessage:@"你好"];
|
||||
[self.chatView updateLastAssistantMessage:@"你好,"];
|
||||
[self.chatView updateLastAssistantMessage:@"你好,很高兴见到你!"];
|
||||
|
||||
// 3. 标记完成
|
||||
[self.chatView markLastAssistantMessageComplete];
|
||||
```
|
||||
|
||||
### 5. 其他操作
|
||||
|
||||
```objective-c
|
||||
// 清空所有消息
|
||||
[self.chatView clearMessages];
|
||||
|
||||
// 滚动到底部
|
||||
[self.chatView scrollToBottom];
|
||||
```
|
||||
|
||||
## 集成到 KBAiMainVC
|
||||
|
||||
### 替换现有的 KBAiChatView
|
||||
|
||||
在 `KBAiMainVC.m` 中:
|
||||
|
||||
```objective-c
|
||||
// 1. 修改 import
|
||||
#import "KBChatTableView.h"
|
||||
|
||||
// 2. 修改属性声明
|
||||
@property (nonatomic, strong) KBChatTableView *chatView;
|
||||
|
||||
// 3. 在 setupUI 中初始化
|
||||
self.chatView = [[KBChatTableView alloc] init];
|
||||
self.chatView.backgroundColor = [UIColor clearColor];
|
||||
[self.view addSubview:self.chatView];
|
||||
|
||||
[self.chatView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.transcriptLabel.mas_bottom).offset(8);
|
||||
make.left.right.equalTo(self.view);
|
||||
make.bottom.lessThanOrEqualTo(self.recordButton.mas_top).offset(-16);
|
||||
}];
|
||||
|
||||
// 4. 在 Deepgram 回调中添加消息
|
||||
- (void)deepgramStreamingManagerDidReceiveFinalTranscript:(NSString *)text {
|
||||
// ... 现有代码 ...
|
||||
|
||||
// 添加用户消息
|
||||
[self.chatView addUserMessage:finalText];
|
||||
|
||||
// AI 回复完成后
|
||||
[self.chatView addAssistantMessage:polishedText
|
||||
audioDuration:audioData.length / 16000.0 // 估算时长
|
||||
audioData:audioData];
|
||||
}
|
||||
```
|
||||
|
||||
## 时间戳显示规则
|
||||
|
||||
时间戳会在以下情况自动插入:
|
||||
1. 第一条消息
|
||||
2. 距离上一条消息超过 5 分钟
|
||||
3. 跨天的消息
|
||||
|
||||
时间格式:
|
||||
- 今天:`16:36`
|
||||
- 昨天:`昨天 16:36`
|
||||
- 其他:`01月23日 16:36`
|
||||
|
||||
## 语音播放
|
||||
|
||||
- 点击语音按钮播放音频
|
||||
- 播放时按钮变为暂停图标
|
||||
- 点击其他消息的语音按钮会停止当前播放
|
||||
- 播放完成后自动恢复播放图标
|
||||
|
||||
## 自定义样式
|
||||
|
||||
### 修改气泡颜色
|
||||
|
||||
在对应的 Cell 文件中修改:
|
||||
|
||||
**用户消息** (`KBChatUserMessageCell.m`):
|
||||
```objective-c
|
||||
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.94 green:0.94 blue:0.94 alpha:1.0];
|
||||
```
|
||||
|
||||
**AI 消息** (`KBChatAssistantMessageCell.m`):
|
||||
```objective-c
|
||||
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.7];
|
||||
```
|
||||
|
||||
### 修改时间戳间隔
|
||||
|
||||
在 `KBChatTableView.m` 中修改:
|
||||
```objective-c
|
||||
static const NSTimeInterval kTimestampInterval = 5 * 60; // 改为你想要的秒数
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **音频数据格式**:确保传入的 `audioData` 是 AVAudioPlayer 支持的格式(如 MP3、AAC)
|
||||
2. **内存管理**:大量消息时考虑分页加载
|
||||
3. **线程安全**:UI 更新需要在主线程执行
|
||||
4. **音频会话**:播放音频前确保配置了 AVAudioSession
|
||||
|
||||
## 完整示例
|
||||
|
||||
```objective-c
|
||||
// 模拟对话
|
||||
[self.chatView addUserMessage:@"你好"];
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
|
||||
NSString *aiText = @"你好!很高兴见到你,有什么可以帮助你的吗?";
|
||||
NSData *audioData = [self generateMockAudioData]; // 模拟音频数据
|
||||
[self.chatView addAssistantMessage:aiText
|
||||
audioDuration:6.0
|
||||
audioData:audioData];
|
||||
});
|
||||
```
|
||||
22
keyBoard/Class/AiTalk/V/KBChatTimeCell.h
Normal file
22
keyBoard/Class/AiTalk/V/KBChatTimeCell.h
Normal file
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// KBChatTimeCell.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Kiro on 2026/1/23.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@class KBAiChatMessage;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 时间戳 Cell(居中显示)
|
||||
@interface KBChatTimeCell : UITableViewCell
|
||||
|
||||
/// 配置 Cell
|
||||
- (void)configureWithMessage:(KBAiChatMessage *)message;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
70
keyBoard/Class/AiTalk/V/KBChatTimeCell.m
Normal file
70
keyBoard/Class/AiTalk/V/KBChatTimeCell.m
Normal file
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// KBChatTimeCell.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Kiro on 2026/1/23.
|
||||
//
|
||||
|
||||
#import "KBChatTimeCell.h"
|
||||
#import "KBAiChatMessage.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
|
||||
@interface KBChatTimeCell ()
|
||||
|
||||
@property (nonatomic, strong) UILabel *timeLabel;
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBChatTimeCell
|
||||
|
||||
- (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.timeLabel = [[UILabel alloc] init];
|
||||
self.timeLabel.font = [UIFont systemFontOfSize:12];
|
||||
self.timeLabel.textColor = [UIColor secondaryLabelColor];
|
||||
self.timeLabel.textAlignment = NSTextAlignmentCenter;
|
||||
[self.contentView addSubview:self.timeLabel];
|
||||
|
||||
// 布局约束
|
||||
[self.timeLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.contentView).offset(8);
|
||||
make.bottom.equalTo(self.contentView).offset(-8);
|
||||
make.centerX.equalTo(self.contentView);
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)configureWithMessage:(KBAiChatMessage *)message {
|
||||
self.timeLabel.text = [self formatTimestamp:message.timestamp];
|
||||
}
|
||||
|
||||
/// 格式化时间戳
|
||||
- (NSString *)formatTimestamp:(NSDate *)timestamp {
|
||||
NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
|
||||
|
||||
NSCalendar *calendar = [NSCalendar currentCalendar];
|
||||
if ([calendar isDateInToday:timestamp]) {
|
||||
// 今天:显示时间
|
||||
formatter.dateFormat = @"HH:mm";
|
||||
} else if ([calendar isDateInYesterday:timestamp]) {
|
||||
// 昨天
|
||||
formatter.dateFormat = @"'昨天' HH:mm";
|
||||
} else {
|
||||
// 其他:显示日期 + 时间
|
||||
formatter.dateFormat = @"MM月dd日 HH:mm";
|
||||
}
|
||||
|
||||
return [formatter stringFromDate:timestamp];
|
||||
}
|
||||
|
||||
@end
|
||||
22
keyBoard/Class/AiTalk/V/KBChatUserMessageCell.h
Normal file
22
keyBoard/Class/AiTalk/V/KBChatUserMessageCell.h
Normal file
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// KBChatUserMessageCell.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Kiro on 2026/1/23.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@class KBAiChatMessage;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 用户消息 Cell(右侧气泡)
|
||||
@interface KBChatUserMessageCell : UITableViewCell
|
||||
|
||||
/// 配置 Cell
|
||||
- (void)configureWithMessage:(KBAiChatMessage *)message;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
67
keyBoard/Class/AiTalk/V/KBChatUserMessageCell.m
Normal file
67
keyBoard/Class/AiTalk/V/KBChatUserMessageCell.m
Normal file
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// KBChatUserMessageCell.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Kiro on 2026/1/23.
|
||||
//
|
||||
|
||||
#import "KBChatUserMessageCell.h"
|
||||
#import "KBAiChatMessage.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
|
||||
@interface KBChatUserMessageCell ()
|
||||
|
||||
@property (nonatomic, strong) UIView *bubbleView;
|
||||
@property (nonatomic, strong) UILabel *messageLabel;
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBChatUserMessageCell
|
||||
|
||||
- (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.bubbleView = [[UIView alloc] init];
|
||||
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.94 green:0.94 blue:0.94 alpha:1.0];
|
||||
self.bubbleView.layer.cornerRadius = 16;
|
||||
self.bubbleView.layer.masksToBounds = YES;
|
||||
[self.contentView addSubview:self.bubbleView];
|
||||
|
||||
// 消息标签
|
||||
self.messageLabel = [[UILabel alloc] init];
|
||||
self.messageLabel.numberOfLines = 0;
|
||||
self.messageLabel.font = [UIFont systemFontOfSize:16];
|
||||
self.messageLabel.textColor = [UIColor blackColor];
|
||||
[self.bubbleView addSubview:self.messageLabel];
|
||||
|
||||
// 布局约束
|
||||
[self.bubbleView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.contentView).offset(4);
|
||||
make.bottom.equalTo(self.contentView).offset(-4);
|
||||
make.right.equalTo(self.contentView).offset(-16);
|
||||
make.width.lessThanOrEqualTo(self.contentView).multipliedBy(0.75);
|
||||
}];
|
||||
|
||||
[self.messageLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.bubbleView).offset(10);
|
||||
make.bottom.equalTo(self.bubbleView).offset(-10);
|
||||
make.left.equalTo(self.bubbleView).offset(12);
|
||||
make.right.equalTo(self.bubbleView).offset(-12);
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)configureWithMessage:(KBAiChatMessage *)message {
|
||||
self.messageLabel.text = message.text;
|
||||
}
|
||||
|
||||
@end
|
||||
Reference in New Issue
Block a user