From 32c4138ae0bdad8560928022653fd8f0014355c1 Mon Sep 17 00:00:00 2001 From: CodeST <694468528@qq.com> Date: Thu, 15 Jan 2026 18:16:56 +0800 Subject: [PATCH] 1 --- CustomKeyboard/Info.plist | 2 + CustomKeyboard/KeyboardViewController.m | 528 +++++++++++++++++- CustomKeyboard/Model/KBChatMessage.h | 26 + CustomKeyboard/Model/KBChatMessage.m | 20 + CustomKeyboard/Network/KBNetworkManager.h | 9 + CustomKeyboard/Network/KBNetworkManager.m | 78 +++ CustomKeyboard/Resource/ai_test.m4a | Bin 0 -> 45051 bytes CustomKeyboard/View/KBChatMessageCell.h | 17 + CustomKeyboard/View/KBChatMessageCell.m | 194 +++++++ CustomKeyboard/View/KBChatPanelView.h | 27 + CustomKeyboard/View/KBChatPanelView.m | 97 ++++ CustomKeyboard/View/KBToolBar.h | 2 +- CustomKeyboard/View/KBToolBar.m | 20 +- Shared/KBAPI.h | 1 + Shared/KBConfig.h | 3 + Shared/KBVoiceBridgeNotification.h | 26 + Shared/KBVoiceBridgeNotification.m | 14 + Shared/KBVoiceRecordManager.h | 21 + Shared/KBVoiceRecordManager.m | 312 +++++++++++ keyBoard.xcodeproj/project.pbxproj | 48 +- keyBoard/AppDelegate.m | 16 + keyBoard/Class/Base/VC/BaseTabBarController.m | 1 - keyBoard/Class/Home/VC/HomeMainVC.m | 14 + keyBoard/Class/Home/VC/HomeVC.h | 16 - keyBoard/Class/Home/VC/HomeVC.m | 61 -- keyBoard/Class/Me/VM/KBMyVM.m | 7 + keyBoard/Class/Network/KBNetworkManager.h | 8 + keyBoard/Class/Network/KBNetworkManager.m | 36 ++ keyBoard/Info.plist | 14 +- 29 files changed, 1523 insertions(+), 95 deletions(-) create mode 100644 CustomKeyboard/Model/KBChatMessage.h create mode 100644 CustomKeyboard/Model/KBChatMessage.m create mode 100644 CustomKeyboard/Resource/ai_test.m4a create mode 100644 CustomKeyboard/View/KBChatMessageCell.h create mode 100644 CustomKeyboard/View/KBChatMessageCell.m create mode 100644 CustomKeyboard/View/KBChatPanelView.h create mode 100644 CustomKeyboard/View/KBChatPanelView.m create mode 100644 Shared/KBVoiceBridgeNotification.h create mode 100644 Shared/KBVoiceBridgeNotification.m create mode 100644 Shared/KBVoiceRecordManager.h create mode 100644 Shared/KBVoiceRecordManager.m delete mode 100644 keyBoard/Class/Home/VC/HomeVC.h delete mode 100644 keyBoard/Class/Home/VC/HomeVC.m diff --git a/CustomKeyboard/Info.plist b/CustomKeyboard/Info.plist index 6b0b101..baa47a8 100644 --- a/CustomKeyboard/Info.plist +++ b/CustomKeyboard/Info.plist @@ -6,6 +6,8 @@ kbkeyboardAppExtension + NSMicrophoneUsageDescription + 需要使用麦克风进行语音输入 NSExtension NSExtensionAttributes diff --git a/CustomKeyboard/KeyboardViewController.m b/CustomKeyboard/KeyboardViewController.m index 564c13c..ad4d5d7 100644 --- a/CustomKeyboard/KeyboardViewController.m +++ b/CustomKeyboard/KeyboardViewController.m @@ -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 // #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 () + 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 *chatMessages; +@property(nonatomic, strong) AVAudioPlayer *chatAudioPlayer; +@property(nonatomic, strong) NSCache *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 *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 *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 *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 *)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 *)chatMessages { + if (!_chatMessages) { + _chatMessages = [NSMutableArray array]; + } + return _chatMessages; +} + +- (NSCache *)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]; } diff --git a/CustomKeyboard/Model/KBChatMessage.h b/CustomKeyboard/Model/KBChatMessage.h new file mode 100644 index 0000000..acf69f4 --- /dev/null +++ b/CustomKeyboard/Model/KBChatMessage.h @@ -0,0 +1,26 @@ +// +// KBChatMessage.h +// CustomKeyboard +// + +#import +#import + +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 diff --git a/CustomKeyboard/Model/KBChatMessage.m b/CustomKeyboard/Model/KBChatMessage.m new file mode 100644 index 0000000..ce85acf --- /dev/null +++ b/CustomKeyboard/Model/KBChatMessage.m @@ -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 diff --git a/CustomKeyboard/Network/KBNetworkManager.h b/CustomKeyboard/Network/KBNetworkManager.h index b319dac..da0a96f 100644 --- a/CustomKeyboard/Network/KBNetworkManager.h +++ b/CustomKeyboard/Network/KBNetworkManager.h @@ -64,6 +64,15 @@ typedef void(^KBNetworkDataCompletion)(NSData *_Nullable data, headers:(nullable NSDictionary *)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 *)headers + completion:(KBNetworkCompletion)completion; + @end NS_ASSUME_NONNULL_END diff --git a/CustomKeyboard/Network/KBNetworkManager.m b/CustomKeyboard/Network/KBNetworkManager.m index 0d9103e..5e1385a 100644 --- a/CustomKeyboard/Network/KBNetworkManager.m +++ b/CustomKeyboard/Network/KBNetworkManager.m @@ -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 *)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 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 *)headers diff --git a/CustomKeyboard/Resource/ai_test.m4a b/CustomKeyboard/Resource/ai_test.m4a new file mode 100644 index 0000000000000000000000000000000000000000..903927564611575138782c32f76dd3328978b244 GIT binary patch literal 45051 zcmX6^V|bhm)7{;e4K}uIG`4LuNn@+AZQHhO+h*f5b{e!%lYDvJZ~xra?ytG#K6B=r znOOh;fN$pP;ULd0Obqz^{P(nUvbVK$U}ydO3bZvbbpG#m0H7=rFb{wMg1|N5@$mQ( za2{(z_%*W&M&*cDv~+T)ztOOJ)%-=&v{Oa;2y^1m{wkt z0@8)ctAW8{yiT;Rffz=ZFj)FC<28E$+wG_`md3arS10!ZKH-pjhLOLni(4qp0m#?; zs54Oh7}Jurk}(uj0oXoOKl;tF-lJD!g}8ty;JW?7dMUXI+-+};mMAtH#zl`RbU`ItPr`9>?_Ln;+3mZ2 zdD!D-rXonOC`=9=8kxW3%yp=vs>MXxM%QoBQ&vu}y|)Oel<(H&8A*hzS<^yeupi1F z+Ti`8*a>FyTBkI-8OZ9N`3