1
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
<array>
|
||||
<string>kbkeyboardAppExtension</string>
|
||||
</array>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>需要使用麦克风进行语音输入</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@@ -18,11 +18,15 @@
|
||||
#import "KBKeyboardSubscriptionProduct.h"
|
||||
#import "KBKeyboardSubscriptionView.h"
|
||||
#import "KBSettingView.h"
|
||||
#import "KBChatMessage.h"
|
||||
#import "KBChatPanelView.h"
|
||||
#import "KBSkinInstallBridge.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import "KBSuggestionEngine.h"
|
||||
#import "KBNetworkManager.h"
|
||||
#import "Masonry.h"
|
||||
#import "UIImage+KBColor.h"
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
|
||||
// #import "KBLog.h"
|
||||
|
||||
@@ -34,6 +38,8 @@
|
||||
|
||||
// 以 375 宽设计稿为基准的键盘总高度
|
||||
static const CGFloat kKBKeyboardBaseHeight = 250.0f;
|
||||
static const CGFloat kKBChatPanelHeight = 180;
|
||||
static const NSUInteger kKBChatMessageLimit = 6;
|
||||
static NSString *const kKBDefaultSkinIdLight = @"normal_them";
|
||||
static NSString *const kKBDefaultSkinZipNameLight = @"normal_them";
|
||||
static NSString *const kKBDefaultSkinIdDark = @"normal_hei_them";
|
||||
@@ -57,7 +63,8 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
|
||||
@interface KeyboardViewController () <KBKeyBoardMainViewDelegate,
|
||||
KBFunctionViewDelegate,
|
||||
KBKeyboardSubscriptionViewDelegate>
|
||||
KBKeyboardSubscriptionViewDelegate,
|
||||
KBChatPanelViewDelegate>
|
||||
@property(nonatomic, strong)
|
||||
UIButton *nextKeyboardButton; // 系统“下一个键盘”按钮(可选)
|
||||
@property(nonatomic, strong) UIView *contentView;
|
||||
@@ -67,16 +74,23 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
KBFunctionView *functionView; // 功能面板视图(点击工具栏第0个时显示)
|
||||
@property(nonatomic, strong) KBSettingView *settingView; // 设置页
|
||||
@property(nonatomic, strong) UIImageView *bgImageView; // 背景图(在底层)
|
||||
@property(nonatomic, strong) KBChatPanelView *chatPanelView;
|
||||
@property(nonatomic, strong) KBKeyboardSubscriptionView *subscriptionView;
|
||||
@property(nonatomic, strong) KBSuggestionEngine *suggestionEngine;
|
||||
@property(nonatomic, copy) NSString *currentWord;
|
||||
@property(nonatomic, assign) BOOL suppressSuggestions;
|
||||
@property(nonatomic, strong) MASConstraint *contentWidthConstraint;
|
||||
@property(nonatomic, strong) MASConstraint *contentHeightConstraint;
|
||||
@property(nonatomic, strong) MASConstraint *keyBoardMainHeightConstraint;
|
||||
@property(nonatomic, strong) MASConstraint *chatPanelHeightConstraint;
|
||||
@property(nonatomic, strong) NSLayoutConstraint *kb_heightConstraint;
|
||||
@property(nonatomic, strong) NSLayoutConstraint *kb_widthConstraint;
|
||||
@property(nonatomic, assign) CGFloat kb_lastPortraitWidth;
|
||||
@property(nonatomic, assign) CGFloat kb_lastKeyboardHeight;
|
||||
@property(nonatomic, strong) NSMutableArray<KBChatMessage *> *chatMessages;
|
||||
@property(nonatomic, strong) AVAudioPlayer *chatAudioPlayer;
|
||||
@property(nonatomic, strong) NSCache<NSString *, UIImage *> *chatAvatarCache;
|
||||
@property(nonatomic, assign) BOOL chatPanelVisible;
|
||||
@end
|
||||
|
||||
@implementation KeyboardViewController
|
||||
@@ -167,6 +181,8 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
// 按“短边”宽度等比缩放,横屏保持竖屏布局比例
|
||||
CGFloat portraitWidth = [self kb_portraitWidth];
|
||||
CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth];
|
||||
CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth];
|
||||
CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth];
|
||||
CGFloat screenWidth = CGRectGetWidth([UIScreen mainScreen].bounds);
|
||||
CGFloat outerVerticalInset = KBFit(4.0f);
|
||||
|
||||
@@ -210,8 +226,20 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
|
||||
[self.contentView addSubview:self.keyBoardMainView];
|
||||
[self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.contentView);
|
||||
make.left.right.equalTo(self.contentView);
|
||||
make.bottom.equalTo(self.contentView);
|
||||
self.keyBoardMainHeightConstraint =
|
||||
make.height.mas_equalTo(keyboardBaseHeight);
|
||||
}];
|
||||
|
||||
[self.contentView addSubview:self.chatPanelView];
|
||||
[self.chatPanelView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self.contentView);
|
||||
make.bottom.equalTo(self.keyBoardMainView.mas_top);
|
||||
self.chatPanelHeightConstraint =
|
||||
make.height.mas_equalTo(chatPanelHeight);
|
||||
}];
|
||||
self.chatPanelView.hidden = YES;
|
||||
}
|
||||
|
||||
#pragma mark - Private
|
||||
@@ -349,6 +377,9 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
/// 切换显示功能面板/键盘主视图
|
||||
- (void)showFunctionPanel:(BOOL)show {
|
||||
// 简单显隐切换,复用相同的布局区域
|
||||
if (show) {
|
||||
[self showChatPanel:NO];
|
||||
}
|
||||
self.functionView.hidden = !show;
|
||||
self.keyBoardMainView.hidden = show;
|
||||
|
||||
@@ -378,6 +409,7 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
/// 显示/隐藏设置页(高度与 keyBoardMainView 一致),右侧滑入/滑出
|
||||
- (void)showSettingView:(BOOL)show {
|
||||
if (show) {
|
||||
[self showChatPanel:NO];
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportPageExposureWithEventName:@"enter_keyboard_settings"
|
||||
pageId:@"keyboard_settings"
|
||||
@@ -436,6 +468,40 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示/隐藏聊天面板(覆盖整个键盘区域)
|
||||
- (void)showChatPanel:(BOOL)show {
|
||||
if (show == self.chatPanelVisible) {
|
||||
return;
|
||||
}
|
||||
self.chatPanelVisible = show;
|
||||
if (show) {
|
||||
self.chatPanelView.hidden = NO;
|
||||
self.chatPanelView.alpha = 0.0;
|
||||
[self.contentView bringSubviewToFront:self.chatPanelView];
|
||||
self.functionView.hidden = YES;
|
||||
[self hideSubscriptionPanel];
|
||||
[self showSettingView:NO];
|
||||
[UIView animateWithDuration:0.2
|
||||
delay:0
|
||||
options:UIViewAnimationOptionCurveEaseOut
|
||||
animations:^{
|
||||
self.chatPanelView.alpha = 1.0;
|
||||
}
|
||||
completion:nil];
|
||||
} else {
|
||||
[UIView animateWithDuration:0.18
|
||||
delay:0
|
||||
options:UIViewAnimationOptionCurveEaseIn
|
||||
animations:^{
|
||||
self.chatPanelView.alpha = 0.0;
|
||||
}
|
||||
completion:^(BOOL finished) {
|
||||
self.chatPanelView.hidden = YES;
|
||||
}];
|
||||
}
|
||||
[self kb_updateKeyboardLayoutIfNeeded];
|
||||
}
|
||||
|
||||
- (void)showSubscriptionPanel {
|
||||
// 1) 先判断权限:未开启“完全访问”则走引导逻辑
|
||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||
@@ -544,6 +610,10 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
[[KBInputBufferManager shared] appendText:@" "];
|
||||
break;
|
||||
case KBKeyTypeReturn:
|
||||
if (self.chatPanelVisible) {
|
||||
[self kb_handleChatSendAction];
|
||||
break;
|
||||
}
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
[self.textDocumentProxy insertText:@"\n"];
|
||||
[self kb_clearCurrentWord];
|
||||
@@ -575,11 +645,18 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
extra:extra
|
||||
completion:nil];
|
||||
if (index == 0) {
|
||||
[self showChatPanel:NO];
|
||||
[self showFunctionPanel:YES];
|
||||
[self kb_clearCurrentWord];
|
||||
return;
|
||||
}
|
||||
if (index == 1) {
|
||||
[self showFunctionPanel:NO];
|
||||
[self showChatPanel:YES];
|
||||
return;
|
||||
}
|
||||
[self showFunctionPanel:NO];
|
||||
[self showChatPanel:NO];
|
||||
}
|
||||
|
||||
- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView {
|
||||
@@ -697,6 +774,399 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
[self showSubscriptionPanel];
|
||||
}
|
||||
|
||||
#pragma mark - KBChatPanelViewDelegate
|
||||
|
||||
- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text {
|
||||
NSString *trim =
|
||||
[text stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
if (trim.length == 0) {
|
||||
return;
|
||||
}
|
||||
[self kb_sendChatText:trim];
|
||||
}
|
||||
|
||||
- (void)chatPanelView:(KBChatPanelView *)view
|
||||
didTapMessage:(KBChatMessage *)message {
|
||||
if (message.audioFilePath.length == 0) {
|
||||
return;
|
||||
}
|
||||
[self kb_playChatAudioAtPath:message.audioFilePath];
|
||||
}
|
||||
|
||||
#pragma mark - Chat Helpers
|
||||
|
||||
- (void)kb_handleChatSendAction {
|
||||
if (!self.chatPanelVisible) {
|
||||
return;
|
||||
}
|
||||
[[KBInputBufferManager shared] refreshFromProxyIfPossible:self.textDocumentProxy];
|
||||
NSString *rawText = [KBInputBufferManager shared].liveText ?: @"";
|
||||
NSString *trim =
|
||||
[rawText stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
if (trim.length == 0) {
|
||||
[KBHUD showInfo:KBLocalized(@"请输入内容")];
|
||||
return;
|
||||
}
|
||||
[self kb_sendChatText:trim];
|
||||
[self kb_clearHostInputForText:rawText];
|
||||
}
|
||||
|
||||
- (void)kb_sendChatText:(NSString *)text {
|
||||
if (text.length == 0) {
|
||||
return;
|
||||
}
|
||||
KBChatMessage *outgoing =
|
||||
[KBChatMessage messageWithText:text outgoing:YES audioFilePath:nil];
|
||||
outgoing.avatarURL = [self kb_sharedUserAvatarURL];
|
||||
[self kb_appendChatMessage:outgoing];
|
||||
[self kb_prefetchAvatarForMessage:outgoing];
|
||||
|
||||
if (![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) {
|
||||
[KBHUD showInfo:KBLocalized(@"请开启完全访问后使用")];
|
||||
return;
|
||||
}
|
||||
[self kb_requestChatAudioForText:text];
|
||||
}
|
||||
|
||||
- (void)kb_clearHostInputForText:(NSString *)text {
|
||||
if (text.length == 0) {
|
||||
return;
|
||||
}
|
||||
NSUInteger count = [self kb_composedCharacterCountForString:text];
|
||||
for (NSUInteger i = 0; i < count; i++) {
|
||||
[self.textDocumentProxy deleteBackward];
|
||||
}
|
||||
[[KBInputBufferManager shared] clearAllLiveText];
|
||||
[self kb_clearCurrentWord];
|
||||
}
|
||||
|
||||
- (NSUInteger)kb_composedCharacterCountForString:(NSString *)text {
|
||||
if (text.length == 0) {
|
||||
return 0;
|
||||
}
|
||||
__block NSUInteger count = 0;
|
||||
[text enumerateSubstringsInRange:NSMakeRange(0, text.length)
|
||||
options:NSStringEnumerationByComposedCharacterSequences
|
||||
usingBlock:^(__unused NSString *substring,
|
||||
__unused NSRange substringRange,
|
||||
__unused NSRange enclosingRange,
|
||||
__unused BOOL *stop) {
|
||||
count += 1;
|
||||
}];
|
||||
return count;
|
||||
}
|
||||
|
||||
- (NSString *)kb_sharedUserAvatarURL {
|
||||
NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||
NSString *url = [ud stringForKey:AppGroup_UserAvatarURL];
|
||||
return url ?: @"";
|
||||
}
|
||||
|
||||
- (void)kb_prefetchAvatarForMessage:(KBChatMessage *)message {
|
||||
if (!message || message.avatarImage) {
|
||||
return;
|
||||
}
|
||||
NSString *urlString = message.avatarURL ?: @"";
|
||||
if (urlString.length == 0) {
|
||||
return;
|
||||
}
|
||||
UIImage *cached = [self.chatAvatarCache objectForKey:urlString];
|
||||
if (cached) {
|
||||
message.avatarImage = cached;
|
||||
[self.chatPanelView kb_reloadWithMessages:self.chatMessages];
|
||||
return;
|
||||
}
|
||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||
return;
|
||||
}
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[[KBNetworkManager shared]
|
||||
GETData:urlString
|
||||
parameters:nil
|
||||
headers:nil
|
||||
completion:^(NSData *data, NSURLResponse *response, NSError *error) {
|
||||
if (error || data.length == 0) {
|
||||
return;
|
||||
}
|
||||
UIImage *image = [UIImage imageWithData:data];
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
[self.chatAvatarCache setObject:image forKey:urlString];
|
||||
message.avatarImage = image;
|
||||
[self.chatPanelView kb_reloadWithMessages:self.chatMessages];
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_requestChatAudioForText:(NSString *)text {
|
||||
NSString *mockPath = [self kb_mockChatAudioPath];
|
||||
if (mockPath.length > 0) {
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.35 * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
NSString *displayText = KBLocalized(@"语音回复");
|
||||
KBChatMessage *incoming =
|
||||
[KBChatMessage messageWithText:displayText
|
||||
outgoing:NO
|
||||
audioFilePath:mockPath];
|
||||
incoming.displayName = KBLocalized(@"AI助手");
|
||||
[self kb_appendChatMessage:incoming];
|
||||
[self kb_playChatAudioAtPath:mockPath];
|
||||
});
|
||||
return;
|
||||
}
|
||||
NSDictionary *payload = @{@"message" : text ?: @""};
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[[KBNetworkManager shared] POST:API_AI_TALK
|
||||
jsonBody:payload
|
||||
headers:nil
|
||||
completion:^(NSDictionary *json, NSURLResponse *response,
|
||||
NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
NSString *tip = error.localizedDescription
|
||||
?: KBLocalized(@"请求失败");
|
||||
[KBHUD showInfo:tip];
|
||||
return;
|
||||
}
|
||||
NSString *displayText =
|
||||
[self kb_chatTextFromJSON:json];
|
||||
NSString *audioURL =
|
||||
[self kb_chatAudioURLFromJSON:json];
|
||||
NSString *audioBase64 =
|
||||
[self kb_chatAudioBase64FromJSON:json];
|
||||
if (audioURL.length > 0) {
|
||||
[self kb_downloadChatAudioFromURL:audioURL
|
||||
displayText:displayText];
|
||||
return;
|
||||
}
|
||||
if (audioBase64.length > 0) {
|
||||
NSData *data = [[NSData alloc]
|
||||
initWithBase64EncodedString:audioBase64
|
||||
options:0];
|
||||
if (data.length == 0) {
|
||||
[KBHUD showInfo:KBLocalized(@"音频数据解析失败")];
|
||||
return;
|
||||
}
|
||||
[self kb_handleChatAudioData:data
|
||||
fileExtension:@"m4a"
|
||||
displayText:displayText];
|
||||
return;
|
||||
}
|
||||
[KBHUD showInfo:KBLocalized(@"未获取到音频文件")];
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_downloadChatAudioFromURL:(NSString *)audioURL
|
||||
displayText:(NSString *)displayText {
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[[KBNetworkManager shared]
|
||||
GETData:audioURL
|
||||
parameters:nil
|
||||
headers:nil
|
||||
completion:^(NSData *data, NSURLResponse *response, NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
NSString *tip = error.localizedDescription ?: KBLocalized(@"下载失败");
|
||||
[KBHUD showInfo:tip];
|
||||
return;
|
||||
}
|
||||
if (data.length == 0) {
|
||||
[KBHUD showInfo:KBLocalized(@"未获取到音频数据")];
|
||||
return;
|
||||
}
|
||||
NSString *ext = @"m4a";
|
||||
NSURL *url = [NSURL URLWithString:audioURL];
|
||||
if (url.pathExtension.length > 0) {
|
||||
ext = url.pathExtension;
|
||||
}
|
||||
[self kb_handleChatAudioData:data
|
||||
fileExtension:ext
|
||||
displayText:displayText];
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_handleChatAudioData:(NSData *)data
|
||||
fileExtension:(NSString *)extension
|
||||
displayText:(NSString *)displayText {
|
||||
if (data.length == 0) {
|
||||
[KBHUD showInfo:KBLocalized(@"音频数据为空")];
|
||||
return;
|
||||
}
|
||||
NSString *ext = extension.length > 0 ? extension : @"m4a";
|
||||
NSString *fileName = [NSString
|
||||
stringWithFormat:@"kb_chat_%@.%@",
|
||||
@((long long)([NSDate date].timeIntervalSince1970 *
|
||||
1000)),
|
||||
ext];
|
||||
NSString *filePath =
|
||||
[NSTemporaryDirectory() stringByAppendingPathComponent:fileName];
|
||||
if (![data writeToFile:filePath atomically:YES]) {
|
||||
[KBHUD showInfo:KBLocalized(@"音频保存失败")];
|
||||
return;
|
||||
}
|
||||
NSString *text = displayText.length > 0 ? displayText : KBLocalized(@"语音消息");
|
||||
KBChatMessage *incoming =
|
||||
[KBChatMessage messageWithText:text
|
||||
outgoing:NO
|
||||
audioFilePath:filePath];
|
||||
incoming.displayName = KBLocalized(@"AI助手");
|
||||
[self kb_appendChatMessage:incoming];
|
||||
}
|
||||
|
||||
- (void)kb_appendChatMessage:(KBChatMessage *)message {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
[self.chatMessages addObject:message];
|
||||
if (self.chatMessages.count > kKBChatMessageLimit) {
|
||||
NSUInteger overflow = self.chatMessages.count - kKBChatMessageLimit;
|
||||
NSArray<KBChatMessage *> *removed =
|
||||
[self.chatMessages subarrayWithRange:NSMakeRange(0, overflow)];
|
||||
[self.chatMessages removeObjectsInRange:NSMakeRange(0, overflow)];
|
||||
for (KBChatMessage *msg in removed) {
|
||||
if (msg.audioFilePath.length > 0) {
|
||||
NSString *tmpRoot = NSTemporaryDirectory();
|
||||
if (tmpRoot.length > 0 &&
|
||||
[msg.audioFilePath hasPrefix:tmpRoot]) {
|
||||
[[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath
|
||||
error:nil];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[self.chatPanelView kb_reloadWithMessages:self.chatMessages];
|
||||
}
|
||||
|
||||
- (NSString *)kb_mockChatAudioPath {
|
||||
NSString *path = [[NSBundle mainBundle] pathForResource:@"ai_test"
|
||||
ofType:@"m4a"];
|
||||
return path ?: @"";
|
||||
}
|
||||
|
||||
- (NSString *)kb_chatTextFromJSON:(NSDictionary *)json {
|
||||
NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
|
||||
NSString *text =
|
||||
[self kb_stringValueInDict:data
|
||||
keys:@[ @"text", @"message", @"content" ]];
|
||||
if (text.length == 0) {
|
||||
text = [self kb_stringValueInDict:json
|
||||
keys:@[ @"text", @"message", @"content" ]];
|
||||
}
|
||||
return text ?: @"";
|
||||
}
|
||||
|
||||
- (NSString *)kb_chatAudioURLFromJSON:(NSDictionary *)json {
|
||||
NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
|
||||
NSArray<NSString *> *keys =
|
||||
@[ @"audioUrl", @"audioURL", @"audio_url", @"url", @"fileUrl",
|
||||
@"file_url", @"audioFileUrl", @"audio_file_url" ];
|
||||
NSString *url = [self kb_stringValueInDict:data keys:keys];
|
||||
if (url.length == 0) {
|
||||
url = [self kb_stringValueInDict:json keys:keys];
|
||||
}
|
||||
return url ?: @"";
|
||||
}
|
||||
|
||||
- (NSString *)kb_chatAudioBase64FromJSON:(NSDictionary *)json {
|
||||
NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
|
||||
NSArray<NSString *> *keys =
|
||||
@[ @"audioBase64", @"audio_base64", @"audioData", @"audio_data",
|
||||
@"base64" ];
|
||||
NSString *b64 = [self kb_stringValueInDict:data keys:keys];
|
||||
if (b64.length == 0) {
|
||||
b64 = [self kb_stringValueInDict:json keys:keys];
|
||||
}
|
||||
return b64 ?: @"";
|
||||
}
|
||||
|
||||
- (NSDictionary *)kb_chatDataDictionaryFromJSON:(NSDictionary *)json {
|
||||
if (![json isKindOfClass:[NSDictionary class]]) {
|
||||
return @{};
|
||||
}
|
||||
id dataObj = json[@"data"] ?: json[@"result"] ?: json[@"response"];
|
||||
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||||
return (NSDictionary *)dataObj;
|
||||
}
|
||||
return @{};
|
||||
}
|
||||
|
||||
- (NSString *)kb_stringValueInDict:(NSDictionary *)dict
|
||||
keys:(NSArray<NSString *> *)keys {
|
||||
if (![dict isKindOfClass:[NSDictionary class]]) {
|
||||
return @"";
|
||||
}
|
||||
for (NSString *key in keys) {
|
||||
id value = dict[key];
|
||||
if ([value isKindOfClass:[NSString class]] &&
|
||||
((NSString *)value).length > 0) {
|
||||
return (NSString *)value;
|
||||
}
|
||||
}
|
||||
return @"";
|
||||
}
|
||||
|
||||
- (void)kb_playChatAudioAtPath:(NSString *)path {
|
||||
if (path.length == 0) {
|
||||
return;
|
||||
}
|
||||
NSURL *url = [NSURL fileURLWithPath:path];
|
||||
if (![NSFileManager.defaultManager fileExistsAtPath:path]) {
|
||||
[KBHUD showInfo:KBLocalized(@"音频文件不存在")];
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) {
|
||||
NSURL *currentURL = self.chatAudioPlayer.url;
|
||||
if ([currentURL isEqual:url]) {
|
||||
[self.chatAudioPlayer stop];
|
||||
self.chatAudioPlayer = nil;
|
||||
return;
|
||||
}
|
||||
[self.chatAudioPlayer stop];
|
||||
self.chatAudioPlayer = nil;
|
||||
}
|
||||
|
||||
NSError *sessionError = nil;
|
||||
AVAudioSession *session = [AVAudioSession sharedInstance];
|
||||
if ([session respondsToSelector:@selector(setCategory:options:error:)]) {
|
||||
[session setCategory:AVAudioSessionCategoryPlayback
|
||||
withOptions:AVAudioSessionCategoryOptionDuckOthers
|
||||
error:&sessionError];
|
||||
} else {
|
||||
[session setCategory:AVAudioSessionCategoryPlayback error:&sessionError];
|
||||
}
|
||||
[session setActive:YES error:nil];
|
||||
|
||||
NSError *playerError = nil;
|
||||
AVAudioPlayer *player =
|
||||
[[AVAudioPlayer alloc] initWithContentsOfURL:url error:&playerError];
|
||||
if (playerError || !player) {
|
||||
[KBHUD showInfo:KBLocalized(@"音频播放失败")];
|
||||
return;
|
||||
}
|
||||
self.chatAudioPlayer = player;
|
||||
[player prepareToPlay];
|
||||
[player play];
|
||||
}
|
||||
|
||||
#pragma mark - KBKeyboardSubscriptionViewDelegate
|
||||
|
||||
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view {
|
||||
@@ -750,6 +1220,28 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
return _settingView;
|
||||
}
|
||||
|
||||
- (KBChatPanelView *)chatPanelView {
|
||||
if (!_chatPanelView) {
|
||||
_chatPanelView = [[KBChatPanelView alloc] init];
|
||||
_chatPanelView.delegate = self;
|
||||
}
|
||||
return _chatPanelView;
|
||||
}
|
||||
|
||||
- (NSMutableArray<KBChatMessage *> *)chatMessages {
|
||||
if (!_chatMessages) {
|
||||
_chatMessages = [NSMutableArray array];
|
||||
}
|
||||
return _chatMessages;
|
||||
}
|
||||
|
||||
- (NSCache<NSString *, UIImage *> *)chatAvatarCache {
|
||||
if (!_chatAvatarCache) {
|
||||
_chatAvatarCache = [[NSCache alloc] init];
|
||||
}
|
||||
return _chatAvatarCache;
|
||||
}
|
||||
|
||||
- (KBKeyboardSubscriptionView *)subscriptionView {
|
||||
if (!_subscriptionView) {
|
||||
_subscriptionView = [[KBKeyboardSubscriptionView alloc] init];
|
||||
@@ -1150,12 +1642,36 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
if (width <= 0) {
|
||||
width = KB_DESIGN_WIDTH;
|
||||
}
|
||||
return kKBKeyboardBaseHeight * (width / KB_DESIGN_WIDTH);
|
||||
CGFloat scale = width / KB_DESIGN_WIDTH;
|
||||
CGFloat baseHeight = kKBKeyboardBaseHeight * scale;
|
||||
CGFloat chatHeight = kKBChatPanelHeight * scale;
|
||||
if (self.chatPanelVisible) {
|
||||
return baseHeight + chatHeight;
|
||||
}
|
||||
return baseHeight;
|
||||
}
|
||||
|
||||
- (CGFloat)kb_keyboardBaseHeightForWidth:(CGFloat)width {
|
||||
if (width <= 0) {
|
||||
width = KB_DESIGN_WIDTH;
|
||||
}
|
||||
CGFloat scale = width / KB_DESIGN_WIDTH;
|
||||
return kKBKeyboardBaseHeight * scale;
|
||||
}
|
||||
|
||||
- (CGFloat)kb_chatPanelHeightForWidth:(CGFloat)width {
|
||||
if (width <= 0) {
|
||||
width = KB_DESIGN_WIDTH;
|
||||
}
|
||||
CGFloat scale = width / KB_DESIGN_WIDTH;
|
||||
return kKBChatPanelHeight * scale;
|
||||
}
|
||||
|
||||
- (void)kb_updateKeyboardLayoutIfNeeded {
|
||||
CGFloat portraitWidth = [self kb_portraitWidth];
|
||||
CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth];
|
||||
CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth];
|
||||
CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth];
|
||||
CGFloat containerWidth = CGRectGetWidth(self.view.superview.bounds);
|
||||
if (containerWidth <= 0) {
|
||||
containerWidth = CGRectGetWidth(self.view.window.bounds);
|
||||
@@ -1186,6 +1702,12 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
if (self.contentHeightConstraint) {
|
||||
[self.contentHeightConstraint setOffset:keyboardHeight];
|
||||
}
|
||||
if (self.keyBoardMainHeightConstraint) {
|
||||
[self.keyBoardMainHeightConstraint setOffset:keyboardBaseHeight];
|
||||
}
|
||||
if (self.chatPanelHeightConstraint) {
|
||||
[self.chatPanelHeightConstraint setOffset:chatPanelHeight];
|
||||
}
|
||||
[self.view layoutIfNeeded];
|
||||
}
|
||||
|
||||
|
||||
26
CustomKeyboard/Model/KBChatMessage.h
Normal file
26
CustomKeyboard/Model/KBChatMessage.h
Normal file
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// KBChatMessage.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBChatMessage : NSObject
|
||||
|
||||
@property (nonatomic, copy) NSString *text;
|
||||
@property (nonatomic, assign) BOOL outgoing;
|
||||
@property (nonatomic, copy, nullable) NSString *audioFilePath;
|
||||
@property (nonatomic, copy, nullable) NSString *avatarURL;
|
||||
@property (nonatomic, copy, nullable) NSString *displayName;
|
||||
@property (nonatomic, strong, nullable) UIImage *avatarImage;
|
||||
|
||||
+ (instancetype)messageWithText:(NSString *)text
|
||||
outgoing:(BOOL)outgoing
|
||||
audioFilePath:(nullable NSString *)audioFilePath;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
20
CustomKeyboard/Model/KBChatMessage.m
Normal file
20
CustomKeyboard/Model/KBChatMessage.m
Normal file
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// KBChatMessage.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBChatMessage.h"
|
||||
|
||||
@implementation KBChatMessage
|
||||
|
||||
+ (instancetype)messageWithText:(NSString *)text
|
||||
outgoing:(BOOL)outgoing
|
||||
audioFilePath:(NSString *)audioFilePath {
|
||||
KBChatMessage *msg = [[KBChatMessage alloc] init];
|
||||
msg.text = text ?: @"";
|
||||
msg.outgoing = outgoing;
|
||||
msg.audioFilePath = audioFilePath;
|
||||
return msg;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -64,6 +64,15 @@ typedef void(^KBNetworkDataCompletion)(NSData *_Nullable data,
|
||||
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
|
||||
completion:(KBNetworkCompletion)completion;
|
||||
|
||||
/// POST multipart 上传文件(常用于语音/图片等文件)
|
||||
- (nullable NSURLSessionDataTask *)uploadFile:(NSString *)path
|
||||
fileURL:(NSURL *)fileURL
|
||||
name:(NSString *)name
|
||||
mimeType:(NSString *)mimeType
|
||||
parameters:(nullable NSDictionary *)parameters
|
||||
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
|
||||
completion:(KBNetworkCompletion)completion;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -124,6 +124,84 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
||||
return [self startAFJSONTaskWithRequest:req completion:completion];
|
||||
}
|
||||
|
||||
- (NSURLSessionDataTask *)uploadFile:(NSString *)path
|
||||
fileURL:(NSURL *)fileURL
|
||||
name:(NSString *)name
|
||||
mimeType:(NSString *)mimeType
|
||||
parameters:(NSDictionary *)parameters
|
||||
headers:(NSDictionary<NSString *, NSString *> *)headers
|
||||
completion:(KBNetworkCompletion)completion {
|
||||
[self getSignWithParare:parameters];
|
||||
if (![self ensureEnabled:completion]) return nil;
|
||||
NSString *urlString = [self buildURLStringWithPath:path];
|
||||
if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; }
|
||||
if (!fileURL) {
|
||||
if (completion) completion(nil, nil, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid file")}]);
|
||||
return nil;
|
||||
}
|
||||
AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer];
|
||||
serializer.timeoutInterval = self.timeout;
|
||||
NSError *error = nil;
|
||||
NSMutableURLRequest *req = [serializer multipartFormRequestWithMethod:@"POST"
|
||||
URLString:urlString
|
||||
parameters:parameters
|
||||
constructingBodyWithBlock:^(id<AFMultipartFormData> formData) {
|
||||
NSString *safeName = (name.length > 0) ? name : @"file";
|
||||
NSString *fileName = fileURL.lastPathComponent ?: @"upload.bin";
|
||||
NSString *type = (mimeType.length > 0) ? mimeType : @"application/octet-stream";
|
||||
[formData appendPartWithFileURL:fileURL name:safeName fileName:fileName mimeType:type error:nil];
|
||||
} error:&error];
|
||||
if (error || !req) {
|
||||
if (completion) completion(nil, nil, error ?: [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidURL userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}]);
|
||||
return nil;
|
||||
}
|
||||
[self applyHeaders:headers toMutableRequest:req contentType:nil];
|
||||
self.manager.responseSerializer = [AFHTTPResponseSerializer serializer];
|
||||
NSURLSessionUploadTask *task = [self.manager uploadTaskWithStreamedRequest:req progress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
|
||||
if (error) {
|
||||
if (completion) completion(nil, response, error);
|
||||
return;
|
||||
}
|
||||
NSData *data = (NSData *)responseObject;
|
||||
if (![data isKindOfClass:[NSData class]]) {
|
||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"No data")}]);
|
||||
return;
|
||||
}
|
||||
NSString *ct = nil;
|
||||
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
|
||||
ct = ((NSHTTPURLResponse *)response).allHeaderFields[@"Content-Type"];
|
||||
}
|
||||
BOOL looksJSON = (ct && [[ct lowercaseString] containsString:@"json"]);
|
||||
if (!looksJSON) {
|
||||
const unsigned char *bytes = data.bytes;
|
||||
NSUInteger len = data.length;
|
||||
for (NSUInteger i = 0; !looksJSON && i < len; i++) {
|
||||
unsigned char c = bytes[i];
|
||||
if (c == ' ' || c == '\n' || c == '\r' || c == '\t') continue;
|
||||
looksJSON = (c == '{' || c == '[');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (looksJSON) {
|
||||
NSError *jsonErr = nil;
|
||||
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonErr];
|
||||
if (jsonErr) {
|
||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDecodeFailed userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Failed to parse JSON")}]);
|
||||
return;
|
||||
}
|
||||
if (![json isKindOfClass:[NSDictionary class]]) {
|
||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
|
||||
return;
|
||||
}
|
||||
if (completion) completion((NSDictionary *)json, response, nil);
|
||||
} else {
|
||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
|
||||
}
|
||||
}];
|
||||
[task resume];
|
||||
return task;
|
||||
}
|
||||
|
||||
- (NSURLSessionDataTask *)GETData:(NSString *)path
|
||||
parameters:(NSDictionary *)parameters
|
||||
headers:(NSDictionary<NSString *,NSString *> *)headers
|
||||
|
||||
BIN
CustomKeyboard/Resource/ai_test.m4a
Normal file
BIN
CustomKeyboard/Resource/ai_test.m4a
Normal file
Binary file not shown.
17
CustomKeyboard/View/KBChatMessageCell.h
Normal file
17
CustomKeyboard/View/KBChatMessageCell.h
Normal file
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// KBChatMessageCell.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
@class KBChatMessage;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBChatMessageCell : UITableViewCell
|
||||
|
||||
- (void)kb_configureWithMessage:(KBChatMessage *)message;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
194
CustomKeyboard/View/KBChatMessageCell.m
Normal file
194
CustomKeyboard/View/KBChatMessageCell.m
Normal file
@@ -0,0 +1,194 @@
|
||||
//
|
||||
// KBChatMessageCell.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBChatMessageCell.h"
|
||||
#import "KBChatMessage.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
@interface KBChatMessageCell ()
|
||||
@property (nonatomic, strong) UIImageView *avatarView;
|
||||
@property (nonatomic, strong) UILabel *nameLabel;
|
||||
@property (nonatomic, strong) UIView *bubbleView;
|
||||
@property (nonatomic, strong) UILabel *messageLabel;
|
||||
@property (nonatomic, strong) UIImageView *audioIconView;
|
||||
@property (nonatomic, strong) UILabel *audioLabel;
|
||||
@end
|
||||
|
||||
@implementation KBChatMessageCell
|
||||
|
||||
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
|
||||
if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
self.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
|
||||
[self.contentView addSubview:self.avatarView];
|
||||
[self.contentView addSubview:self.nameLabel];
|
||||
[self.contentView addSubview:self.bubbleView];
|
||||
[self.bubbleView addSubview:self.messageLabel];
|
||||
[self.bubbleView addSubview:self.audioIconView];
|
||||
[self.bubbleView addSubview:self.audioLabel];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)kb_configureWithMessage:(KBChatMessage *)message {
|
||||
BOOL outgoing = message.outgoing;
|
||||
BOOL audioMessage = (!outgoing && message.audioFilePath.length > 0);
|
||||
UIColor *bubbleColor = outgoing ? [UIColor colorWithHex:0x02BEAC] : [UIColor colorWithWhite:1 alpha:0.95];
|
||||
UIColor *textColor = outgoing ? [UIColor whiteColor] : [UIColor colorWithHex:0x1B1F1A];
|
||||
|
||||
self.bubbleView.backgroundColor = bubbleColor;
|
||||
self.messageLabel.textColor = textColor;
|
||||
self.audioLabel.textColor = textColor;
|
||||
self.audioIconView.tintColor = textColor;
|
||||
self.messageLabel.text = message.text ?: @"";
|
||||
self.audioLabel.text =
|
||||
(message.text.length > 0) ? message.text : KBLocalized(@"语音回复");
|
||||
self.messageLabel.hidden = audioMessage;
|
||||
self.audioIconView.hidden = !audioMessage;
|
||||
self.audioLabel.hidden = !audioMessage;
|
||||
|
||||
UIImage *avatarImage = message.avatarImage;
|
||||
if (!avatarImage) {
|
||||
avatarImage = [self kb_defaultAvatarImage];
|
||||
}
|
||||
self.avatarView.image = avatarImage;
|
||||
self.avatarView.backgroundColor =
|
||||
avatarImage ? [UIColor clearColor] : [UIColor colorWithWhite:0.9 alpha:1.0];
|
||||
self.nameLabel.hidden = outgoing;
|
||||
self.nameLabel.text =
|
||||
(message.displayName.length > 0) ? message.displayName : KBLocalized(@"AI助手");
|
||||
|
||||
CGFloat avatarSize = 28.0;
|
||||
[self.avatarView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.height.mas_equalTo(avatarSize);
|
||||
make.top.equalTo(self.contentView.mas_top).offset(6);
|
||||
if (outgoing) {
|
||||
make.right.equalTo(self.contentView.mas_right).offset(-8);
|
||||
} else {
|
||||
make.left.equalTo(self.contentView.mas_left).offset(8);
|
||||
}
|
||||
}];
|
||||
|
||||
if (outgoing) {
|
||||
[self.nameLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.contentView.mas_top).offset(0);
|
||||
make.left.equalTo(self.contentView.mas_left);
|
||||
}];
|
||||
} else {
|
||||
[self.nameLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.avatarView.mas_right).offset(6);
|
||||
make.top.equalTo(self.contentView.mas_top).offset(2);
|
||||
make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12);
|
||||
}];
|
||||
}
|
||||
|
||||
[self.bubbleView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.lessThanOrEqualTo(self.contentView.mas_width).multipliedBy(0.65);
|
||||
if (outgoing) {
|
||||
make.top.equalTo(self.contentView.mas_top).offset(6);
|
||||
make.bottom.equalTo(self.contentView.mas_bottom).offset(-6);
|
||||
make.right.equalTo(self.avatarView.mas_left).offset(-6);
|
||||
} else {
|
||||
make.top.equalTo(self.nameLabel.mas_bottom).offset(2);
|
||||
make.bottom.equalTo(self.contentView.mas_bottom).offset(-6);
|
||||
make.left.equalTo(self.avatarView.mas_right).offset(6);
|
||||
make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12);
|
||||
}
|
||||
}];
|
||||
|
||||
if (audioMessage) {
|
||||
[self.audioIconView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.bubbleView.mas_left).offset(10);
|
||||
make.centerY.equalTo(self.bubbleView);
|
||||
make.width.height.mas_equalTo(16);
|
||||
}];
|
||||
[self.audioLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.audioIconView.mas_right).offset(6);
|
||||
make.centerY.equalTo(self.bubbleView);
|
||||
make.right.equalTo(self.bubbleView.mas_right).offset(-10);
|
||||
make.top.greaterThanOrEqualTo(self.bubbleView.mas_top).offset(8);
|
||||
make.bottom.lessThanOrEqualTo(self.bubbleView.mas_bottom).offset(-8);
|
||||
}];
|
||||
} else {
|
||||
[self.messageLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.bubbleView).insets(UIEdgeInsetsMake(8, 10, 8, 10));
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIImageView *)avatarView {
|
||||
if (!_avatarView) {
|
||||
_avatarView = [[UIImageView alloc] init];
|
||||
_avatarView.contentMode = UIViewContentModeScaleAspectFill;
|
||||
_avatarView.layer.cornerRadius = 14;
|
||||
_avatarView.layer.masksToBounds = YES;
|
||||
_avatarView.backgroundColor = [UIColor colorWithWhite:0.9 alpha:1.0];
|
||||
_avatarView.tintColor = [UIColor colorWithHex:0xB9BDC8];
|
||||
}
|
||||
return _avatarView;
|
||||
}
|
||||
|
||||
- (UILabel *)nameLabel {
|
||||
if (!_nameLabel) {
|
||||
_nameLabel = [[UILabel alloc] init];
|
||||
_nameLabel.font = [UIFont systemFontOfSize:11];
|
||||
_nameLabel.textColor = [UIColor colorWithHex:0x6B6F7A];
|
||||
_nameLabel.numberOfLines = 1;
|
||||
}
|
||||
return _nameLabel;
|
||||
}
|
||||
|
||||
- (UIView *)bubbleView {
|
||||
if (!_bubbleView) {
|
||||
_bubbleView = [[UIView alloc] init];
|
||||
_bubbleView.layer.cornerRadius = 12;
|
||||
_bubbleView.layer.masksToBounds = YES;
|
||||
}
|
||||
return _bubbleView;
|
||||
}
|
||||
|
||||
- (UILabel *)messageLabel {
|
||||
if (!_messageLabel) {
|
||||
_messageLabel = [[UILabel alloc] init];
|
||||
_messageLabel.font = [UIFont systemFontOfSize:14];
|
||||
_messageLabel.numberOfLines = 0;
|
||||
}
|
||||
return _messageLabel;
|
||||
}
|
||||
|
||||
- (UIImageView *)audioIconView {
|
||||
if (!_audioIconView) {
|
||||
_audioIconView = [[UIImageView alloc] init];
|
||||
_audioIconView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
_audioIconView.tintColor = [UIColor colorWithHex:0x1B1F1A];
|
||||
UIImage *icon = nil;
|
||||
if (@available(iOS 13.0, *)) {
|
||||
icon = [UIImage systemImageNamed:@"waveform"];
|
||||
}
|
||||
_audioIconView.image = icon;
|
||||
}
|
||||
return _audioIconView;
|
||||
}
|
||||
|
||||
- (UILabel *)audioLabel {
|
||||
if (!_audioLabel) {
|
||||
_audioLabel = [[UILabel alloc] init];
|
||||
_audioLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium];
|
||||
_audioLabel.numberOfLines = 1;
|
||||
}
|
||||
return _audioLabel;
|
||||
}
|
||||
|
||||
- (UIImage *)kb_defaultAvatarImage {
|
||||
if (@available(iOS 13.0, *)) {
|
||||
return [UIImage systemImageNamed:@"person.circle.fill"];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
@end
|
||||
27
CustomKeyboard/View/KBChatPanelView.h
Normal file
27
CustomKeyboard/View/KBChatPanelView.h
Normal file
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// KBChatPanelView.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
@class KBChatPanelView, KBChatMessage;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@protocol KBChatPanelViewDelegate <NSObject>
|
||||
@optional
|
||||
- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text;
|
||||
- (void)chatPanelView:(KBChatPanelView *)view didTapMessage:(KBChatMessage *)message;
|
||||
@end
|
||||
|
||||
@interface KBChatPanelView : UIView
|
||||
|
||||
@property (nonatomic, weak) id<KBChatPanelViewDelegate> delegate;
|
||||
|
||||
@property (nonatomic, strong, readonly) UITableView *tableView;
|
||||
|
||||
- (void)kb_reloadWithMessages:(NSArray<KBChatMessage *> *)messages;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
97
CustomKeyboard/View/KBChatPanelView.m
Normal file
97
CustomKeyboard/View/KBChatPanelView.m
Normal file
@@ -0,0 +1,97 @@
|
||||
//
|
||||
// KBChatPanelView.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBChatPanelView.h"
|
||||
#import "KBChatMessage.h"
|
||||
#import "KBChatMessageCell.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
@interface KBChatPanelView () <UITableViewDataSource, UITableViewDelegate>
|
||||
@property (nonatomic, strong) UITableView *tableViewInternal;
|
||||
@property (nonatomic, copy) NSArray<KBChatMessage *> *messages;
|
||||
@end
|
||||
|
||||
@implementation KBChatPanelView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.backgroundColor = [UIColor colorWithHex:0xD1D3DB];
|
||||
|
||||
[self addSubview:self.tableViewInternal];
|
||||
|
||||
[self.tableViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self);
|
||||
make.top.equalTo(self.mas_top).offset(8);
|
||||
make.bottom.equalTo(self.mas_bottom).offset(-8);
|
||||
}];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
#pragma mark - Public
|
||||
|
||||
- (void)kb_reloadWithMessages:(NSArray<KBChatMessage *> *)messages {
|
||||
self.messages = messages ?: @[];
|
||||
[self.tableViewInternal reloadData];
|
||||
if (self.messages.count > 0) {
|
||||
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:self.messages.count - 1 inSection:0];
|
||||
[self.tableViewInternal scrollToRowAtIndexPath:indexPath
|
||||
atScrollPosition:UITableViewScrollPositionBottom
|
||||
animated:YES];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - UITableViewDataSource
|
||||
|
||||
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
|
||||
return self.messages.count;
|
||||
}
|
||||
|
||||
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
KBChatMessageCell *cell = [tableView dequeueReusableCellWithIdentifier:NSStringFromClass(KBChatMessageCell.class)];
|
||||
KBChatMessage *msg = self.messages[indexPath.row];
|
||||
[cell kb_configureWithMessage:msg];
|
||||
return cell;
|
||||
}
|
||||
|
||||
#pragma mark - UITableViewDelegate
|
||||
|
||||
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
return UITableViewAutomaticDimension;
|
||||
}
|
||||
|
||||
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
return 44.0;
|
||||
}
|
||||
|
||||
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
if (indexPath.row >= self.messages.count) { return; }
|
||||
KBChatMessage *msg = self.messages[indexPath.row];
|
||||
if ([self.delegate respondsToSelector:@selector(chatPanelView:didTapMessage:)]) {
|
||||
[self.delegate chatPanelView:self didTapMessage:msg];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UITableView *)tableViewInternal {
|
||||
if (!_tableViewInternal) {
|
||||
_tableViewInternal = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
|
||||
_tableViewInternal.backgroundColor = [UIColor clearColor];
|
||||
_tableViewInternal.separatorStyle = UITableViewCellSeparatorStyleNone;
|
||||
_tableViewInternal.dataSource = self;
|
||||
_tableViewInternal.delegate = self;
|
||||
_tableViewInternal.estimatedRowHeight = 44.0;
|
||||
_tableViewInternal.rowHeight = UITableViewAutomaticDimension;
|
||||
[_tableViewInternal registerClass:KBChatMessageCell.class forCellReuseIdentifier:NSStringFromClass(KBChatMessageCell.class)];
|
||||
}
|
||||
return _tableViewInternal;
|
||||
}
|
||||
|
||||
#pragma mark - Expose
|
||||
|
||||
- (UITableView *)tableView { return self.tableViewInternal; }
|
||||
|
||||
@end
|
||||
@@ -26,7 +26,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@property (nonatomic, weak, nullable) id<KBToolBarDelegate> delegate;
|
||||
|
||||
/// 左侧按钮的标题(数量由数组决定)。默认值:@[@"AI"]。
|
||||
/// 左侧按钮的标题(数量由数组决定)。默认值:@[@"AI", @"语音"]。
|
||||
@property (nonatomic, copy) NSArray<NSString *> *leftButtonTitles;
|
||||
|
||||
/// 暴露按钮以便外部定制(只读;首次访问时懒加载创建)
|
||||
|
||||
@@ -26,11 +26,12 @@ static NSString * const kKBAIKeyIdentifier = @"ai";
|
||||
static NSString * const kKBUndoKeyIdentifier = @"key_revoke";
|
||||
static const CGFloat kKBAIButtonWidth = 40;
|
||||
static const CGFloat kKBAIButtonHeight = 40;
|
||||
static const NSInteger kKBVoiceButtonIndex = 1;
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame{
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
_leftButtonTitles = @[@"AI"]; // 默认标题
|
||||
_leftButtonTitles = @[@"AI", KBLocalized(@"语音")]; // 默认标题
|
||||
[self setupUI];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(kb_undoStateChanged)
|
||||
@@ -68,6 +69,7 @@ static const CGFloat kKBAIButtonHeight = 40;
|
||||
}
|
||||
}];
|
||||
[self kb_updateAIButtonAppearance];
|
||||
[self kb_updateVoiceButtonAppearance];
|
||||
}
|
||||
|
||||
#pragma mark - 视图搭建
|
||||
@@ -171,6 +173,7 @@ static const CGFloat kKBAIButtonHeight = 40;
|
||||
|
||||
- (void)kb_applyTheme {
|
||||
[self kb_updateAIButtonAppearance];
|
||||
[self kb_updateVoiceButtonAppearance];
|
||||
[self kb_updateUndoButtonAppearance];
|
||||
}
|
||||
|
||||
@@ -208,6 +211,16 @@ static const CGFloat kKBAIButtonHeight = 40;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_updateVoiceButtonAppearance {
|
||||
UIButton *voiceButton = [self kb_voiceButton];
|
||||
if (!voiceButton) { return; }
|
||||
|
||||
voiceButton.backgroundColor = [UIColor colorWithHex:0xE53935];
|
||||
[voiceButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
||||
voiceButton.layer.cornerRadius = 16;
|
||||
voiceButton.layer.masksToBounds = YES;
|
||||
}
|
||||
|
||||
- (void)kb_updateUndoButtonAppearance {
|
||||
if (!self.undoButtonInternal) { return; }
|
||||
|
||||
@@ -306,6 +319,11 @@ static const CGFloat kKBAIButtonHeight = 40;
|
||||
return self.leftButtonsInternal[0];
|
||||
}
|
||||
|
||||
- (UIButton *)kb_voiceButton {
|
||||
if (self.leftButtonsInternal.count <= kKBVoiceButtonIndex) { return nil; }
|
||||
return self.leftButtonsInternal[kKBVoiceButtonIndex];
|
||||
}
|
||||
|
||||
#pragma mark - Globe (Input Mode Switch)
|
||||
|
||||
// 根据宿主是否已提供系统切换键,决定是否显示地球按钮;并绑定系统事件。
|
||||
|
||||
Reference in New Issue
Block a user