diff --git a/Shared/KBAPI.h b/Shared/KBAPI.h index 1173ce0..29ebc86 100644 --- a/Shared/KBAPI.h +++ b/Shared/KBAPI.h @@ -73,6 +73,7 @@ #define API_AI_CHAT_SYNC @"/chat/sync" // 同步对话 #define API_AI_CHAT_MESSAGE @"/chat/message" // 文本润色 #define API_AI_AUDIO_UPLOAD @"/chat/audio/upload" // 语音上传(替换为后端真实路径) +#define API_AI_SPEECH_TRANSCRIBE @"/speech/transcribe" // 语音转文字 diff --git a/keyBoard.xcodeproj/project.pbxproj b/keyBoard.xcodeproj/project.pbxproj index 1848db9..c87673b 100644 --- a/keyBoard.xcodeproj/project.pbxproj +++ b/keyBoard.xcodeproj/project.pbxproj @@ -143,6 +143,7 @@ 048FFD182F2763A5005D62AE /* KBVoiceInputBar.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD172F2763A5005D62AE /* KBVoiceInputBar.m */; }; 048FFD1D2F277486005D62AE /* KBChatHistoryPageModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD1C2F277486005D62AE /* KBChatHistoryPageModel.m */; }; 048FFD1E2F277486005D62AE /* KBChatHistoryModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD1A2F277486005D62AE /* KBChatHistoryModel.m */; }; + 048FFD242F28A836005D62AE /* KBChatLimitPopView.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD232F28A836005D62AE /* KBChatLimitPopView.m */; }; 0498BD622EDFFC12006CC1D5 /* KBMyVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD612EDFFC12006CC1D5 /* KBMyVM.m */; }; 0498BD652EE0116D006CC1D5 /* KBEmailLoginVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD642EE0116D006CC1D5 /* KBEmailLoginVC.m */; }; 0498BD682EE01180006CC1D5 /* KBEmailRegistVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD672EE01180006CC1D5 /* KBEmailRegistVC.m */; }; @@ -208,8 +209,6 @@ 04E038E32F20E500002CA5A0 /* deepgramAPI.md in Resources */ = {isa = PBXBuildFile; fileRef = 04E038E22F20E500002CA5A0 /* deepgramAPI.md */; }; 04E038E82F20E877002CA5A0 /* DeepgramWebSocketClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038E72F20E877002CA5A0 /* DeepgramWebSocketClient.m */; }; 04E038E92F20E877002CA5A0 /* DeepgramStreamingManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */; }; - 04E0B1022F300001002CA5A0 /* KBVoiceToTextManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0B1012F300001002CA5A0 /* KBVoiceToTextManager.m */; }; - 04E0B2022F300002002CA5A0 /* KBVoiceRecordManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0B2012F300002002CA5A0 /* KBVoiceRecordManager.m */; }; 04E038EF2F21F0EC002CA5A0 /* AiVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038EE2F21F0EC002CA5A0 /* AiVM.m */; }; 04E0394B2F236E75002CA5A0 /* KBChatUserMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */; }; 04E0394C2F236E75002CA5A0 /* KBChatTimeCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039482F236E75002CA5A0 /* KBChatTimeCell.m */; }; @@ -217,6 +216,8 @@ 04E0394E2F236E75002CA5A0 /* KBChatTableView.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039452F236E75002CA5A0 /* KBChatTableView.m */; }; 04E0394F2F236E75002CA5A0 /* KBChatTableView_Usage.md in Resources */ = {isa = PBXBuildFile; fileRef = 04E039462F236E75002CA5A0 /* KBChatTableView_Usage.md */; }; 04E039522F2387D2002CA5A0 /* KBAiChatMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039512F2387D2002CA5A0 /* KBAiChatMessage.m */; }; + 04E0B1022F300001002CA5A0 /* KBVoiceToTextManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0B1012F300001002CA5A0 /* KBVoiceToTextManager.m */; }; + 04E0B2022F300002002CA5A0 /* KBVoiceRecordManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0B2012F300002002CA5A0 /* KBVoiceRecordManager.m */; }; 04E161832F10E6470022C23B /* normal_hei_them.zip in Resources */ = {isa = PBXBuildFile; fileRef = 04E161812F10E6470022C23B /* normal_hei_them.zip */; }; 04E161842F10E6470022C23B /* normal_them.zip in Resources */ = {isa = PBXBuildFile; fileRef = 04E161822F10E6470022C23B /* normal_them.zip */; }; 04FC95672EB0546C007BD342 /* KBKey.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95652EB0546C007BD342 /* KBKey.m */; }; @@ -541,6 +542,8 @@ 048FFD1A2F277486005D62AE /* KBChatHistoryModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatHistoryModel.m; sourceTree = ""; }; 048FFD1B2F277486005D62AE /* KBChatHistoryPageModel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatHistoryPageModel.h; sourceTree = ""; }; 048FFD1C2F277486005D62AE /* KBChatHistoryPageModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatHistoryPageModel.m; sourceTree = ""; }; + 048FFD222F28A836005D62AE /* KBChatLimitPopView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatLimitPopView.h; sourceTree = ""; }; + 048FFD232F28A836005D62AE /* KBChatLimitPopView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatLimitPopView.m; sourceTree = ""; }; 0498BD5E2EDF2157006CC1D5 /* KBBizCode.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBBizCode.h; sourceTree = ""; }; 0498BD602EDFFC12006CC1D5 /* KBMyVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBMyVM.h; sourceTree = ""; }; 0498BD612EDFFC12006CC1D5 /* KBMyVM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBMyVM.m; sourceTree = ""; }; @@ -652,10 +655,6 @@ 04E038E22F20E500002CA5A0 /* deepgramAPI.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = deepgramAPI.md; sourceTree = ""; }; 04E038E42F20E877002CA5A0 /* DeepgramStreamingManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DeepgramStreamingManager.h; sourceTree = ""; }; 04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DeepgramStreamingManager.m; sourceTree = ""; }; - 04E0B1002F300001002CA5A0 /* KBVoiceToTextManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceToTextManager.h; sourceTree = ""; }; - 04E0B1012F300001002CA5A0 /* KBVoiceToTextManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceToTextManager.m; sourceTree = ""; }; - 04E0B2002F300002002CA5A0 /* KBVoiceRecordManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceRecordManager.h; sourceTree = ""; }; - 04E0B2012F300002002CA5A0 /* KBVoiceRecordManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceRecordManager.m; sourceTree = ""; }; 04E038E62F20E877002CA5A0 /* DeepgramWebSocketClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DeepgramWebSocketClient.h; sourceTree = ""; }; 04E038E72F20E877002CA5A0 /* DeepgramWebSocketClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DeepgramWebSocketClient.m; sourceTree = ""; }; 04E038ED2F21F0EC002CA5A0 /* AiVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AiVM.h; sourceTree = ""; }; @@ -671,6 +670,10 @@ 04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatUserMessageCell.m; sourceTree = ""; }; 04E039502F2387D2002CA5A0 /* KBAiChatMessage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAiChatMessage.h; sourceTree = ""; }; 04E039512F2387D2002CA5A0 /* KBAiChatMessage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAiChatMessage.m; sourceTree = ""; }; + 04E0B1002F300001002CA5A0 /* KBVoiceToTextManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceToTextManager.h; sourceTree = ""; }; + 04E0B1012F300001002CA5A0 /* KBVoiceToTextManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceToTextManager.m; sourceTree = ""; }; + 04E0B2002F300002002CA5A0 /* KBVoiceRecordManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceRecordManager.h; sourceTree = ""; }; + 04E0B2012F300002002CA5A0 /* KBVoiceRecordManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceRecordManager.m; sourceTree = ""; }; 04E161812F10E6470022C23B /* normal_hei_them.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = normal_hei_them.zip; sourceTree = ""; }; 04E161822F10E6470022C23B /* normal_them.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = normal_them.zip; sourceTree = ""; }; 04FC953A2EAFAE56007BD342 /* KeyBoardPrefixHeader.pch */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KeyBoardPrefixHeader.pch; sourceTree = ""; }; @@ -1036,6 +1039,8 @@ 048FFD132F274342005D62AE /* KBPersonaChatCell.m */, 048FFD162F2763A5005D62AE /* KBVoiceInputBar.h */, 048FFD172F2763A5005D62AE /* KBVoiceInputBar.m */, + 048FFD222F28A836005D62AE /* KBChatLimitPopView.h */, + 048FFD232F28A836005D62AE /* KBChatLimitPopView.m */, ); path = V; sourceTree = ""; @@ -2287,6 +2292,7 @@ 04FC95D22EB1E7AE007BD342 /* MyVC.m in Sources */, 04286A032ECB0A1600CE730C /* KBSexSelVC.m in Sources */, 048FFD0B2F273BFC005D62AE /* KBAIHomeVC.m in Sources */, + 048FFD242F28A836005D62AE /* KBChatLimitPopView.m in Sources */, 046086CC2F1A092500757C95 /* KBAIReplyModel.m in Sources */, 046086CD2F1A092500757C95 /* KBAICommentModel.m in Sources */, 04791F8F2ED469C0004E8522 /* KBHostAppLauncher.m in Sources */, diff --git a/keyBoard/Assets.xcassets/AI/Contents.json b/keyBoard/Assets.xcassets/AI/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/keyBoard/Assets.xcassets/AI/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/keyBoard/Assets.xcassets/AI/ai_jianpan_icon.imageset/Contents.json b/keyBoard/Assets.xcassets/AI/ai_jianpan_icon.imageset/Contents.json new file mode 100644 index 0000000..6366814 --- /dev/null +++ b/keyBoard/Assets.xcassets/AI/ai_jianpan_icon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ai_jianpan_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ai_jianpan_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/keyBoard/Assets.xcassets/AI/ai_jianpan_icon.imageset/ai_jianpan_icon@2x.png b/keyBoard/Assets.xcassets/AI/ai_jianpan_icon.imageset/ai_jianpan_icon@2x.png new file mode 100644 index 0000000..812be13 Binary files /dev/null and b/keyBoard/Assets.xcassets/AI/ai_jianpan_icon.imageset/ai_jianpan_icon@2x.png differ diff --git a/keyBoard/Assets.xcassets/AI/ai_jianpan_icon.imageset/ai_jianpan_icon@3x.png b/keyBoard/Assets.xcassets/AI/ai_jianpan_icon.imageset/ai_jianpan_icon@3x.png new file mode 100644 index 0000000..0333856 Binary files /dev/null and b/keyBoard/Assets.xcassets/AI/ai_jianpan_icon.imageset/ai_jianpan_icon@3x.png differ diff --git a/keyBoard/Assets.xcassets/AI/ai_luyining_icon.imageset/Contents.json b/keyBoard/Assets.xcassets/AI/ai_luyining_icon.imageset/Contents.json new file mode 100644 index 0000000..24ff511 --- /dev/null +++ b/keyBoard/Assets.xcassets/AI/ai_luyining_icon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ai_luyining_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ai_luyining_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/keyBoard/Assets.xcassets/AI/ai_luyining_icon.imageset/ai_luyining_icon@2x.png b/keyBoard/Assets.xcassets/AI/ai_luyining_icon.imageset/ai_luyining_icon@2x.png new file mode 100644 index 0000000..916acef Binary files /dev/null and b/keyBoard/Assets.xcassets/AI/ai_luyining_icon.imageset/ai_luyining_icon@2x.png differ diff --git a/keyBoard/Assets.xcassets/AI/ai_luyining_icon.imageset/ai_luyining_icon@3x.png b/keyBoard/Assets.xcassets/AI/ai_luyining_icon.imageset/ai_luyining_icon@3x.png new file mode 100644 index 0000000..06893e2 Binary files /dev/null and b/keyBoard/Assets.xcassets/AI/ai_luyining_icon.imageset/ai_luyining_icon@3x.png differ diff --git a/keyBoard/Assets.xcassets/AI/ai_maikefeng_icon.imageset/Contents.json b/keyBoard/Assets.xcassets/AI/ai_maikefeng_icon.imageset/Contents.json new file mode 100644 index 0000000..52e1ea2 --- /dev/null +++ b/keyBoard/Assets.xcassets/AI/ai_maikefeng_icon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ai_maikefeng_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ai_maikefeng_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/keyBoard/Assets.xcassets/AI/ai_maikefeng_icon.imageset/ai_maikefeng_icon@2x.png b/keyBoard/Assets.xcassets/AI/ai_maikefeng_icon.imageset/ai_maikefeng_icon@2x.png new file mode 100644 index 0000000..71a3a8d Binary files /dev/null and b/keyBoard/Assets.xcassets/AI/ai_maikefeng_icon.imageset/ai_maikefeng_icon@2x.png differ diff --git a/keyBoard/Assets.xcassets/AI/ai_maikefeng_icon.imageset/ai_maikefeng_icon@3x.png b/keyBoard/Assets.xcassets/AI/ai_maikefeng_icon.imageset/ai_maikefeng_icon@3x.png new file mode 100644 index 0000000..b470be9 Binary files /dev/null and b/keyBoard/Assets.xcassets/AI/ai_maikefeng_icon.imageset/ai_maikefeng_icon@3x.png differ diff --git a/keyBoard/Class/AiTalk/V/KBAiRecordButton.h b/keyBoard/Class/AiTalk/V/KBAiRecordButton.h index 35938d0..b6fb2dc 100644 --- a/keyBoard/Class/AiTalk/V/KBAiRecordButton.h +++ b/keyBoard/Class/AiTalk/V/KBAiRecordButton.h @@ -47,6 +47,12 @@ typedef NS_ENUM(NSInteger, KBAiRecordButtonState) { /// 主色调 @property(nonatomic, strong) UIColor *tintColor; +/// 正常状态左侧图标 +@property(nonatomic, strong, nullable) UIImage *normalIconImage; + +/// 录音中状态图标(居中显示) +@property(nonatomic, strong, nullable) UIImage *recordingIconImage; + /// 更新音量(用于波形动画) /// @param rms 音量 RMS 值 (0.0 - 1.0) - (void)updateVolumeRMS:(float)rms; diff --git a/keyBoard/Class/AiTalk/V/KBAiRecordButton.m b/keyBoard/Class/AiTalk/V/KBAiRecordButton.m index 3d24827..f608e3c 100644 --- a/keyBoard/Class/AiTalk/V/KBAiRecordButton.m +++ b/keyBoard/Class/AiTalk/V/KBAiRecordButton.m @@ -14,6 +14,7 @@ @property(nonatomic, strong) UILabel *titleLabel; @property(nonatomic, strong) KBAiWaveformView *waveformView; @property(nonatomic, strong) UIImageView *micIconView; +@property(nonatomic, strong) UIImageView *recordingIconView; @property(nonatomic, assign) BOOL isPressing; @end @@ -50,7 +51,7 @@ self.backgroundView.translatesAutoresizingMaskIntoConstraints = NO; [self addSubview:self.backgroundView]; - // 麦克风图标 + // 麦克风图标(正常态左侧) self.micIconView = [[UIImageView alloc] init]; self.micIconView.image = [UIImage systemImageNamed:@"mic.fill"]; self.micIconView.tintColor = self.tintColor; @@ -58,6 +59,13 @@ self.micIconView.translatesAutoresizingMaskIntoConstraints = NO; [self.backgroundView addSubview:self.micIconView]; + // 录音中图标(居中) + self.recordingIconView = [[UIImageView alloc] init]; + self.recordingIconView.contentMode = UIViewContentModeScaleAspectFit; + self.recordingIconView.translatesAutoresizingMaskIntoConstraints = NO; + self.recordingIconView.hidden = YES; + [self.backgroundView addSubview:self.recordingIconView]; + // 标题标签 self.titleLabel = [[UILabel alloc] init]; self.titleLabel.text = self.normalTitle; @@ -104,6 +112,13 @@ constraintEqualToAnchor:self.backgroundView.centerYAnchor], [self.waveformView.widthAnchor constraintEqualToConstant:60], [self.waveformView.heightAnchor constraintEqualToConstant:30], + + [self.recordingIconView.centerXAnchor + constraintEqualToAnchor:self.backgroundView.centerXAnchor], + [self.recordingIconView.centerYAnchor + constraintEqualToAnchor:self.backgroundView.centerYAnchor], + [self.recordingIconView.widthAnchor constraintEqualToConstant:36], + [self.recordingIconView.heightAnchor constraintEqualToConstant:36], ]]; // 添加手势 @@ -131,6 +146,18 @@ self.waveformView.waveColor = tintColor; } +- (void)setNormalIconImage:(UIImage *)normalIconImage { + _normalIconImage = normalIconImage; + if (normalIconImage) { + self.micIconView.image = normalIconImage; + } +} + +- (void)setRecordingIconImage:(UIImage *)recordingIconImage { + _recordingIconImage = recordingIconImage; + self.recordingIconView.image = recordingIconImage; +} + #pragma mark - Public Methods - (void)updateVolumeRMS:(float)rms { @@ -144,18 +171,21 @@ case KBAiRecordButtonStateNormal: self.titleLabel.text = self.normalTitle; self.backgroundView.backgroundColor = [UIColor systemGray6Color]; - self.micIconView.alpha = 1; + self.micIconView.hidden = NO; + self.titleLabel.hidden = NO; + self.recordingIconView.hidden = YES; self.waveformView.alpha = 0; [self.waveformView stopAnimation]; break; case KBAiRecordButtonStateRecording: self.titleLabel.text = self.recordingTitle; - self.backgroundView.backgroundColor = - [self.tintColor colorWithAlphaComponent:0.15]; - self.micIconView.alpha = 1; - self.waveformView.alpha = 1; - [self.waveformView startIdleAnimation]; + self.backgroundView.backgroundColor = [UIColor colorWithWhite:0 alpha:0.6]; + self.micIconView.hidden = YES; + self.titleLabel.hidden = YES; + self.recordingIconView.hidden = NO; + self.waveformView.alpha = 0; + [self.waveformView stopAnimation]; break; case KBAiRecordButtonStateDisabled: diff --git a/keyBoard/Class/AiTalk/V/KBChatLimitPopView.h b/keyBoard/Class/AiTalk/V/KBChatLimitPopView.h new file mode 100644 index 0000000..fffa3d1 --- /dev/null +++ b/keyBoard/Class/AiTalk/V/KBChatLimitPopView.h @@ -0,0 +1,28 @@ +// +// KBChatLimitPopView.h +// keyBoard +// +// Created by Codex on 2026/1/27. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class KBChatLimitPopView; + +@protocol KBChatLimitPopViewDelegate +@optional +- (void)chatLimitPopViewDidTapCancel:(KBChatLimitPopView *)view; +- (void)chatLimitPopViewDidTapRecharge:(KBChatLimitPopView *)view; +@end + +/// 聊天次数用尽提示弹窗内容视图(配合 LSTPopView 使用) +@interface KBChatLimitPopView : UIView + +@property (nonatomic, weak) id delegate; +@property (nonatomic, copy) NSString *message; + +@end + +NS_ASSUME_NONNULL_END diff --git a/keyBoard/Class/AiTalk/V/KBChatLimitPopView.m b/keyBoard/Class/AiTalk/V/KBChatLimitPopView.m new file mode 100644 index 0000000..1280283 --- /dev/null +++ b/keyBoard/Class/AiTalk/V/KBChatLimitPopView.m @@ -0,0 +1,157 @@ +// +// KBChatLimitPopView.m +// keyBoard +// +// Created by Codex on 2026/1/27. +// + +#import "KBChatLimitPopView.h" +#import + +@interface KBChatLimitPopView () +@property (nonatomic, strong) UILabel *titleLabel; +@property (nonatomic, strong) UILabel *messageLabel; +@property (nonatomic, strong) UIButton *cancelButton; +@property (nonatomic, strong) UIButton *rechargeButton; +@property (nonatomic, strong) UIView *buttonDivider; +@end + +@implementation KBChatLimitPopView + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + self.backgroundColor = [UIColor whiteColor]; + self.layer.cornerRadius = 16.0; + self.layer.masksToBounds = YES; + [self setupUI]; + } + return self; +} + +#pragma mark - UI + +- (void)setupUI { + [self addSubview:self.titleLabel]; + [self addSubview:self.messageLabel]; + [self addSubview:self.buttonDivider]; + [self addSubview:self.cancelButton]; + [self addSubview:self.rechargeButton]; + + [self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(self).offset(20); + make.left.equalTo(self).offset(20); + make.right.equalTo(self).offset(-20); + }]; + + [self.messageLabel mas_makeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(self.titleLabel.mas_bottom).offset(8); + make.left.equalTo(self).offset(20); + make.right.equalTo(self).offset(-20); + }]; + + [self.buttonDivider mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.right.equalTo(self); + make.height.mas_equalTo(1); + make.top.greaterThanOrEqualTo(self.messageLabel.mas_bottom).offset(16); + make.bottom.equalTo(self).offset(-48); + }]; + + [self.cancelButton mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.bottom.equalTo(self); + make.top.equalTo(self.buttonDivider.mas_bottom); + make.right.equalTo(self.mas_centerX); + }]; + + [self.rechargeButton mas_makeConstraints:^(MASConstraintMaker *make) { + make.right.bottom.equalTo(self); + make.top.equalTo(self.buttonDivider.mas_bottom); + make.left.equalTo(self.mas_centerX); + }]; + + UIView *verticalLine = [[UIView alloc] init]; + verticalLine.backgroundColor = [UIColor colorWithWhite:0.9 alpha:1.0]; + [self addSubview:verticalLine]; + [verticalLine mas_makeConstraints:^(MASConstraintMaker *make) { + make.centerX.equalTo(self); + make.top.equalTo(self.buttonDivider.mas_bottom); + make.bottom.equalTo(self); + make.width.mas_equalTo(1); + }]; +} + +#pragma mark - Actions + +- (void)onTapCancel { + if ([self.delegate respondsToSelector:@selector(chatLimitPopViewDidTapCancel:)]) { + [self.delegate chatLimitPopViewDidTapCancel:self]; + } +} + +- (void)onTapRecharge { + if ([self.delegate respondsToSelector:@selector(chatLimitPopViewDidTapRecharge:)]) { + [self.delegate chatLimitPopViewDidTapRecharge:self]; + } +} + +#pragma mark - Setter + +- (void)setMessage:(NSString *)message { + _message = [message copy]; + self.messageLabel.text = _message.length > 0 ? _message : @""; +} + +#pragma mark - Lazy + +- (UILabel *)titleLabel { + if (!_titleLabel) { + _titleLabel = [[UILabel alloc] init]; + _titleLabel.text = KBLocalized(@"提示"); + _titleLabel.font = [UIFont boldSystemFontOfSize:18]; + _titleLabel.textColor = [UIColor blackColor]; + _titleLabel.textAlignment = NSTextAlignmentCenter; + } + return _titleLabel; +} + +- (UILabel *)messageLabel { + if (!_messageLabel) { + _messageLabel = [[UILabel alloc] init]; + _messageLabel.font = [UIFont systemFontOfSize:14]; + _messageLabel.textColor = [UIColor colorWithWhite:0.2 alpha:1.0]; + _messageLabel.textAlignment = NSTextAlignmentCenter; + _messageLabel.numberOfLines = 0; + } + return _messageLabel; +} + +- (UIButton *)cancelButton { + if (!_cancelButton) { + _cancelButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [_cancelButton setTitle:KBLocalized(@"取消") forState:UIControlStateNormal]; + _cancelButton.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightMedium]; + [_cancelButton setTitleColor:[UIColor colorWithWhite:0.2 alpha:1.0] forState:UIControlStateNormal]; + [_cancelButton addTarget:self action:@selector(onTapCancel) forControlEvents:UIControlEventTouchUpInside]; + } + return _cancelButton; +} + +- (UIButton *)rechargeButton { + if (!_rechargeButton) { + _rechargeButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [_rechargeButton setTitle:KBLocalized(@"去充值") forState:UIControlStateNormal]; + _rechargeButton.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold]; + [_rechargeButton setTitleColor:[UIColor colorWithRed:0.28 green:0.45 blue:0.94 alpha:1.0] forState:UIControlStateNormal]; + [_rechargeButton addTarget:self action:@selector(onTapRecharge) forControlEvents:UIControlEventTouchUpInside]; + } + return _rechargeButton; +} + +- (UIView *)buttonDivider { + if (!_buttonDivider) { + _buttonDivider = [[UIView alloc] init]; + _buttonDivider.backgroundColor = [UIColor colorWithWhite:0.9 alpha:1.0]; + } + return _buttonDivider; +} + +@end diff --git a/keyBoard/Class/AiTalk/V/KBChatTableView.h b/keyBoard/Class/AiTalk/V/KBChatTableView.h index d47a58b..2e4f6d4 100644 --- a/keyBoard/Class/AiTalk/V/KBChatTableView.h +++ b/keyBoard/Class/AiTalk/V/KBChatTableView.h @@ -57,6 +57,9 @@ NS_ASSUME_NONNULL_BEGIN /// 重置无更多数据状态 - (void)resetNoMoreData; +/// 更新底部内容 inset(用于避开输入栏/键盘) +- (void)updateContentBottomInset:(CGFloat)bottomInset; + /// 添加自定义消息(可用于历史消息或打字机) - (void)addMessage:(KBAiChatMessage *)message autoScroll:(BOOL)autoScroll; diff --git a/keyBoard/Class/AiTalk/V/KBChatTableView.m b/keyBoard/Class/AiTalk/V/KBChatTableView.m index ce1178f..5dcaedd 100644 --- a/keyBoard/Class/AiTalk/V/KBChatTableView.m +++ b/keyBoard/Class/AiTalk/V/KBChatTableView.m @@ -30,6 +30,7 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟 @property (nonatomic, strong) NSIndexPath *playingCellIndexPath; @property (nonatomic, strong) AiVM *aiVM; @property (nonatomic, assign) BOOL hasMoreData; +@property (nonatomic, assign) CGFloat contentBottomInset; @end @@ -78,11 +79,12 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟 // 布局 [self.tableView mas_makeConstraints:^(MASConstraintMaker *make) { -// make.edges.equalTo(self); - make.top.left.right.equalTo(self); - make.bottom.equalTo(self).offset(-KB_TABBAR_HEIGHT - 40 - 10); + make.edges.equalTo(self); }]; + self.contentBottomInset = KB_TABBAR_HEIGHT + 40 + 10; + [self updateContentBottomInset:self.contentBottomInset]; + __weak typeof(self) weakSelf = self; self.tableView.mj_footer = [MJRefreshAutoNormalFooter footerWithRefreshingBlock:^{ __strong typeof(weakSelf) strongSelf = weakSelf; @@ -207,6 +209,14 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟 [self updateFooterVisibility]; } +- (void)updateContentBottomInset:(CGFloat)bottomInset { + self.contentBottomInset = bottomInset; + UIEdgeInsets insets = self.tableView.contentInset; + insets.bottom = bottomInset; + self.tableView.contentInset = insets; + self.tableView.scrollIndicatorInsets = insets; +} + - (void)addMessage:(KBAiChatMessage *)message autoScroll:(BOOL)autoScroll { if (!message) { diff --git a/keyBoard/Class/AiTalk/V/KBPersonaChatCell.h b/keyBoard/Class/AiTalk/V/KBPersonaChatCell.h index e9f3517..3016ad0 100644 --- a/keyBoard/Class/AiTalk/V/KBPersonaChatCell.h +++ b/keyBoard/Class/AiTalk/V/KBPersonaChatCell.h @@ -26,6 +26,9 @@ NS_ASSUME_NONNULL_BEGIN - (void)appendAssistantMessage:(NSString *)text audioId:(nullable NSString *)audioId; +/// 更新聊天列表底部 inset +- (void)updateChatViewBottomInset:(CGFloat)bottomInset; + @end NS_ASSUME_NONNULL_END diff --git a/keyBoard/Class/AiTalk/V/KBPersonaChatCell.m b/keyBoard/Class/AiTalk/V/KBPersonaChatCell.m index 4cc72a3..deb74c2 100644 --- a/keyBoard/Class/AiTalk/V/KBPersonaChatCell.m +++ b/keyBoard/Class/AiTalk/V/KBPersonaChatCell.m @@ -288,6 +288,10 @@ [self.chatView addMessage:message autoScroll:YES]; } +- (void)updateChatViewBottomInset:(CGFloat)bottomInset { + [self.chatView updateContentBottomInset:bottomInset]; +} + #pragma mark - KBChatTableViewDelegate - (void)chatTableViewDidScroll:(KBChatTableView *)chatView diff --git a/keyBoard/Class/AiTalk/V/KBVoiceInputBar.h b/keyBoard/Class/AiTalk/V/KBVoiceInputBar.h index d503a52..d5266f6 100644 --- a/keyBoard/Class/AiTalk/V/KBVoiceInputBar.h +++ b/keyBoard/Class/AiTalk/V/KBVoiceInputBar.h @@ -27,6 +27,18 @@ NS_ASSUME_NONNULL_BEGIN @end +typedef NS_ENUM(NSInteger, KBVoiceInputBarMode) { + KBVoiceInputBarModeText, + KBVoiceInputBarModeVoice +}; + +typedef NS_ENUM(NSInteger, KBVoiceInputBarState) { + KBVoiceInputBarStateText, + KBVoiceInputBarStateVoice, + KBVoiceInputBarStateRecording, + KBVoiceInputBarStateCancel +}; + /// 底部语音输入栏 /// 包含:毛玻璃背景 + 录音按钮 @interface KBVoiceInputBar : UIView @@ -37,6 +49,12 @@ NS_ASSUME_NONNULL_BEGIN /// 状态文本(显示在按钮上方) @property (nonatomic, copy) NSString *statusText; +/// 输入模式(文字/语音) +@property (nonatomic, assign) KBVoiceInputBarMode inputMode; + +/// 输入状态(文字/语音/录音/取消) +@property (nonatomic, assign) KBVoiceInputBarState inputState; + /// 是否启用(禁用时按钮不可点击) @property (nonatomic, assign) BOOL enabled; diff --git a/keyBoard/Class/AiTalk/V/KBVoiceInputBar.m b/keyBoard/Class/AiTalk/V/KBVoiceInputBar.m index 8da0d6d..3b96d3e 100644 --- a/keyBoard/Class/AiTalk/V/KBVoiceInputBar.m +++ b/keyBoard/Class/AiTalk/V/KBVoiceInputBar.m @@ -11,18 +11,37 @@ @interface KBVoiceInputBar () -/// 毛玻璃背景容器 -@property (nonatomic, strong) UIView *backgroundView; - -/// 毛玻璃效果 -@property (nonatomic, strong) UIVisualEffectView *blurEffectView; - /// 状态标签 @property (nonatomic, strong) UILabel *statusLabel; /// 录音按钮 @property (nonatomic, strong) KBAiRecordButton *recordButton; +/// 输入区域容器 +@property (nonatomic, strong) UIView *inputContainer; + +/// 文字输入视图 +@property (nonatomic, strong) UIView *textInputView; +@property (nonatomic, strong) UIButton *textCenterButton; + +/// 语音输入视图 +@property (nonatomic, strong) UIView *voiceInputView; +@property (nonatomic, strong) UILabel *voiceCenterLabel; + +/// 左侧切换按钮(麦克风/键盘共用) +@property (nonatomic, strong) UIButton *toggleIconButton; + +/// 录音中视图 +@property (nonatomic, strong) UIView *recordingView; +@property (nonatomic, strong) UIImageView *recordingCenterIconView; + +/// 取消视图 +@property (nonatomic, strong) UIView *cancelView; +@property (nonatomic, strong) UILabel *cancelLabel; + +/// 隐藏输入框(用于弹起键盘) +@property (nonatomic, strong) UITextField *hiddenTextField; + /// 是否正在录音 @property (nonatomic, assign) BOOL isRecording; @@ -52,30 +71,8 @@ self.backgroundColor = [UIColor clearColor]; self.enabled = YES; self.isRecording = NO; - - // 毛玻璃背景容器 - [self addSubview:self.backgroundView]; - [self.backgroundView mas_makeConstraints:^(MASConstraintMaker *make) { - make.edges.equalTo(self); - }]; - - // 毛玻璃效果 - [self.backgroundView addSubview:self.blurEffectView]; - [self.blurEffectView mas_makeConstraints:^(MASConstraintMaker *make) { - make.edges.equalTo(self.backgroundView); - }]; - - // 为 blurEffectView 创建透明度渐变 mask - CAGradientLayer *maskLayer = [CAGradientLayer layer]; - maskLayer.startPoint = CGPointMake(0.5, 1); // 底部 - maskLayer.endPoint = CGPointMake(0.5, 0); // 顶部 - maskLayer.colors = @[ - (__bridge id)[UIColor whiteColor].CGColor, // 底部:完全不透明 - (__bridge id)[UIColor whiteColor].CGColor, // 中间:完全不透明 - (__bridge id)[UIColor clearColor].CGColor // 顶部:完全透明 - ]; - maskLayer.locations = @[@(0.0), @(0.5), @(1.0)]; - self.blurEffectView.layer.mask = maskLayer; + self.inputMode = KBVoiceInputBarModeVoice; + self.inputState = KBVoiceInputBarStateText; // 状态标签 [self addSubview:self.statusLabel]; @@ -86,24 +83,92 @@ make.height.mas_equalTo(20); }]; - // 录音按钮 - [self addSubview:self.recordButton]; - [self.recordButton mas_makeConstraints:^(MASConstraintMaker *make) { + // 输入区域容器 + [self addSubview:self.inputContainer]; + [self.inputContainer mas_makeConstraints:^(MASConstraintMaker *make) { make.top.equalTo(self.statusLabel.mas_bottom).offset(12); make.left.equalTo(self).offset(20); make.right.equalTo(self).offset(-20); make.height.mas_equalTo(50); make.bottom.lessThanOrEqualTo(self).offset(-16); }]; -} -- (void)layoutSubviews { - [super layoutSubviews]; + UILongPressGestureRecognizer *longPress = + [[UILongPressGestureRecognizer alloc] initWithTarget:self + action:@selector(handleVoiceLongPress:)]; + longPress.minimumPressDuration = 0.05; + longPress.cancelsTouchesInView = NO; + [self.inputContainer addGestureRecognizer:longPress]; - // 更新 mask 的 frame - if (self.blurEffectView.layer.mask) { - self.blurEffectView.layer.mask.frame = self.blurEffectView.bounds; - } + // 文字输入视图 + [self.inputContainer addSubview:self.textInputView]; + [self.textInputView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.inputContainer); + }]; + + [self.inputContainer addSubview:self.toggleIconButton]; + [self.toggleIconButton mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.textInputView).offset(16); + make.centerY.equalTo(self.textInputView); + make.width.height.mas_equalTo(24); + }]; + + [self.textInputView addSubview:self.textCenterButton]; + [self.textCenterButton mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.toggleIconButton.mas_right).offset(12); + make.right.equalTo(self.textInputView).offset(-16); + make.centerY.equalTo(self.textInputView); + make.height.mas_equalTo(30); + }]; + + // 语音输入视图 + [self.inputContainer addSubview:self.voiceInputView]; + [self.voiceInputView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.inputContainer); + }]; + + [self.voiceInputView addSubview:self.voiceCenterLabel]; + [self.voiceCenterLabel mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.toggleIconButton.mas_right).offset(12); + make.right.equalTo(self.voiceInputView).offset(-16); + make.centerY.equalTo(self.voiceInputView); + }]; + + // 录音中视图 + [self.inputContainer addSubview:self.recordingView]; + [self.recordingView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.inputContainer); + }]; + + [self.recordingView addSubview:self.recordingCenterIconView]; + [self.recordingCenterIconView mas_makeConstraints:^(MASConstraintMaker *make) { + make.center.equalTo(self.recordingView); + make.width.height.mas_equalTo(36); + }]; + + // 取消视图 + [self.inputContainer addSubview:self.cancelView]; + [self.cancelView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.inputContainer); + }]; + + [self.cancelView addSubview:self.cancelLabel]; + [self.cancelLabel mas_makeConstraints:^(MASConstraintMaker *make) { + make.center.equalTo(self.cancelView); + }]; + + // 隐藏输入框(仅用于弹起键盘) + [self addSubview:self.hiddenTextField]; + [self.hiddenTextField mas_makeConstraints:^(MASConstraintMaker *make) { + make.width.height.mas_equalTo(1); + make.left.top.equalTo(self); + }]; + + // 隐藏旧的录音按钮和状态标签 + self.statusLabel.hidden = YES; + + // 统一设置初始状态 + self.inputState = KBVoiceInputBarStateText; } #pragma mark - Setter @@ -111,17 +176,55 @@ - (void)setStatusText:(NSString *)statusText { _statusText = [statusText copy]; self.statusLabel.text = statusText; + [self updateCenterTextIfNeeded]; } - (void)setEnabled:(BOOL)enabled { _enabled = enabled; self.recordButton.userInteractionEnabled = enabled; self.recordButton.alpha = enabled ? 1.0 : 0.5; + self.inputContainer.userInteractionEnabled = enabled; + self.inputContainer.alpha = enabled ? 1.0 : 0.5; } - (void)setRecording:(BOOL)recording { _isRecording = recording; self.recordButton.state = recording ? KBAiRecordButtonStateRecording : KBAiRecordButtonStateNormal; + if (recording) { + self.inputState = KBVoiceInputBarStateRecording; + } else if (self.inputState == KBVoiceInputBarStateRecording) { + self.inputState = KBVoiceInputBarStateVoice; + } +} + +- (void)setInputMode:(KBVoiceInputBarMode)inputMode { + _inputMode = inputMode; + if (inputMode == KBVoiceInputBarModeText) { + [self.toggleIconButton setImage:[UIImage imageNamed:@"ai_maikefeng_icon"] + forState:UIControlStateNormal]; + } else { + [self.toggleIconButton setImage:[UIImage imageNamed:@"ai_jianpan_icon"] + forState:UIControlStateNormal]; + } +} + +- (void)setInputState:(KBVoiceInputBarState)inputState { + _inputState = inputState; + self.textInputView.hidden = (inputState != KBVoiceInputBarStateText); + self.voiceInputView.hidden = (inputState != KBVoiceInputBarStateVoice); + self.recordingView.hidden = (inputState != KBVoiceInputBarStateRecording); + self.cancelView.hidden = (inputState != KBVoiceInputBarStateCancel); + self.toggleIconButton.hidden = (inputState == KBVoiceInputBarStateRecording || + inputState == KBVoiceInputBarStateCancel); + if (inputState == KBVoiceInputBarStateText) { + self.inputMode = KBVoiceInputBarModeText; + } else if (inputState == KBVoiceInputBarStateVoice) { + self.inputMode = KBVoiceInputBarModeVoice; + } + if (!self.toggleIconButton.hidden) { + [self.inputContainer bringSubviewToFront:self.toggleIconButton]; + } + [self updateCenterTextIfNeeded]; } #pragma mark - Public Methods @@ -162,22 +265,6 @@ #pragma mark - Lazy Load -- (UIView *)backgroundView { - if (!_backgroundView) { - _backgroundView = [[UIView alloc] init]; - _backgroundView.clipsToBounds = YES; - } - return _backgroundView; -} - -- (UIVisualEffectView *)blurEffectView { - if (!_blurEffectView) { - UIBlurEffect *blurEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleLight]; - _blurEffectView = [[UIVisualEffectView alloc] initWithEffect:blurEffect]; - } - return _blurEffectView; -} - - (UILabel *)statusLabel { if (!_statusLabel) { _statusLabel = [[UILabel alloc] init]; @@ -195,8 +282,198 @@ _recordButton.delegate = self; _recordButton.normalTitle = @"按住说话"; _recordButton.recordingTitle = @"松开结束"; + _recordButton.normalIconImage = [UIImage imageNamed:@"ai_jianpan_icon"]; + _recordButton.recordingIconImage = [UIImage imageNamed:@"ai_luyining_icon"]; + _recordButton.hidden = YES; } return _recordButton; } +- (UIView *)inputContainer { + if (!_inputContainer) { + _inputContainer = [[UIView alloc] init]; + _inputContainer.clipsToBounds = YES; + _inputContainer.layer.cornerRadius = 25; + _inputContainer.backgroundColor = [UIColor clearColor]; + } + return _inputContainer; +} + +- (UIView *)textInputView { + if (!_textInputView) { + _textInputView = [[UIView alloc] init]; + _textInputView.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.7]; + } + return _textInputView; +} + +- (UIButton *)textCenterButton { + if (!_textCenterButton) { + _textCenterButton = [UIButton buttonWithType:UIButtonTypeCustom]; + [_textCenterButton setTitle:@"发送一个消息给她" forState:UIControlStateNormal]; + _textCenterButton.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightMedium]; + [_textCenterButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; + _textCenterButton.contentHorizontalAlignment = UIControlContentHorizontalAlignmentCenter; + [_textCenterButton addTarget:self + action:@selector(handleTextCenterTap) + forControlEvents:UIControlEventTouchUpInside]; + } + return _textCenterButton; +} + +- (UIView *)voiceInputView { + if (!_voiceInputView) { + _voiceInputView = [[UIView alloc] init]; + _voiceInputView.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.7]; + } + return _voiceInputView; +} + +- (UILabel *)voiceCenterLabel { + if (!_voiceCenterLabel) { + _voiceCenterLabel = [[UILabel alloc] init]; + _voiceCenterLabel.text = @"按住说话"; + _voiceCenterLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightMedium]; + _voiceCenterLabel.textColor = [UIColor whiteColor]; + _voiceCenterLabel.textAlignment = NSTextAlignmentCenter; + } + return _voiceCenterLabel; +} + +- (UIButton *)toggleIconButton { + if (!_toggleIconButton) { + _toggleIconButton = [UIButton buttonWithType:UIButtonTypeCustom]; + [_toggleIconButton setImage:[UIImage imageNamed:@"ai_maikefeng_icon"] + forState:UIControlStateNormal]; + [_toggleIconButton addTarget:self + action:@selector(handleToggleIconTap) + forControlEvents:UIControlEventTouchUpInside]; + _toggleIconButton.exclusiveTouch = YES; + } + return _toggleIconButton; +} + +- (UIView *)recordingView { + if (!_recordingView) { + _recordingView = [[UIView alloc] init]; + _recordingView.backgroundColor = [UIColor colorWithWhite:0 alpha:0.6]; + } + return _recordingView; +} + +- (UIImageView *)recordingCenterIconView { + if (!_recordingCenterIconView) { + _recordingCenterIconView = [[UIImageView alloc] init]; + _recordingCenterIconView.image = [UIImage imageNamed:@"ai_luyining_icon"]; + _recordingCenterIconView.contentMode = UIViewContentModeScaleAspectFit; + } + return _recordingCenterIconView; +} + +- (UIView *)cancelView { + if (!_cancelView) { + _cancelView = [[UIView alloc] init]; + _cancelView.backgroundColor = [UIColor colorWithRed:0.75 green:0.3 blue:0.3 alpha:1.0]; + } + return _cancelView; +} + +- (UILabel *)cancelLabel { + if (!_cancelLabel) { + _cancelLabel = [[UILabel alloc] init]; + _cancelLabel.text = @"Release To Cancel"; + _cancelLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightMedium]; + _cancelLabel.textColor = [UIColor whiteColor]; + } + return _cancelLabel; +} + +- (UITextField *)hiddenTextField { + if (!_hiddenTextField) { + _hiddenTextField = [[UITextField alloc] init]; + _hiddenTextField.hidden = YES; + } + return _hiddenTextField; +} + +#pragma mark - Actions + +- (void)handleToggleIconTap { + if (self.inputState == KBVoiceInputBarStateText) { + self.inputState = KBVoiceInputBarStateVoice; + [self endEditing:YES]; + } else { + self.inputState = KBVoiceInputBarStateText; + } +} + +- (void)handleTextCenterTap { + self.inputState = KBVoiceInputBarStateText; + [self.hiddenTextField becomeFirstResponder]; +} + +- (void)handleVoiceLongPress:(UILongPressGestureRecognizer *)gesture { + if (self.inputState != KBVoiceInputBarStateVoice && + self.inputState != KBVoiceInputBarStateRecording && + self.inputState != KBVoiceInputBarStateCancel) { + return; + } + + CGPoint location = [gesture locationInView:self.inputContainer]; + BOOL isInside = CGRectContainsPoint(self.inputContainer.bounds, location); + CGPoint iconPoint = [gesture locationInView:self.toggleIconButton]; + BOOL isOnToggleIcon = CGRectContainsPoint(self.toggleIconButton.bounds, iconPoint); + if (isOnToggleIcon) { + return; + } + + switch (gesture.state) { + case UIGestureRecognizerStateBegan: { + if (self.inputState != KBVoiceInputBarStateVoice) { + return; + } + self.inputState = KBVoiceInputBarStateRecording; + if ([self.delegate respondsToSelector:@selector(voiceInputBarDidBeginRecording:)]) { + [self.delegate voiceInputBarDidBeginRecording:self]; + } + } break; + case UIGestureRecognizerStateChanged: { + if (isInside) { + self.inputState = KBVoiceInputBarStateRecording; + } else { + self.inputState = KBVoiceInputBarStateCancel; + } + } break; + case UIGestureRecognizerStateEnded: { + if (isInside) { + if ([self.delegate respondsToSelector:@selector(voiceInputBarDidEndRecording:)]) { + [self.delegate voiceInputBarDidEndRecording:self]; + } + } else { + if ([self.delegate respondsToSelector:@selector(voiceInputBarDidCancelRecording:)]) { + [self.delegate voiceInputBarDidCancelRecording:self]; + } + } + self.inputState = KBVoiceInputBarStateVoice; + } break; + case UIGestureRecognizerStateCancelled: + case UIGestureRecognizerStateFailed: { + if ([self.delegate respondsToSelector:@selector(voiceInputBarDidCancelRecording:)]) { + [self.delegate voiceInputBarDidCancelRecording:self]; + } + self.inputState = KBVoiceInputBarStateVoice; + } break; + default: + break; + } +} + +- (void)updateCenterTextIfNeeded { + if (self.inputState == KBVoiceInputBarStateText) { + [self.textCenterButton setTitle:@"发送一个消息给她" forState:UIControlStateNormal]; + } else if (self.inputState == KBVoiceInputBarStateVoice) { + self.voiceCenterLabel.text = @"按住说话"; + } +} + @end diff --git a/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m b/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m index a19132a..01dfc70 100644 --- a/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m +++ b/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m @@ -13,15 +13,30 @@ #import "KBVoiceToTextManager.h" #import "AiVM.h" #import "KBHUD.h" +#import "KBChatLimitPopView.h" +#import "KBVipPay.h" +#import "KBUserSessionManager.h" +#import "LSTPopView.h" #import -@interface KBAIHomeVC () +@interface KBAIHomeVC () /// 人设列表容器 @property (nonatomic, strong) UICollectionView *collectionView; /// 底部语音输入栏 @property (nonatomic, strong) KBVoiceInputBar *voiceInputBar; +@property (nonatomic, strong) MASConstraint *voiceInputBarBottomConstraint; +@property (nonatomic, assign) CGFloat voiceInputBarHeight; +@property (nonatomic, assign) CGFloat baseInputBarBottomSpacing; +@property (nonatomic, assign) CGFloat currentKeyboardHeight; +@property (nonatomic, strong) UITapGestureRecognizer *dismissKeyboardTap; +@property (nonatomic, weak) LSTPopView *chatLimitPopView; + +/// 底部毛玻璃背景 +@property (nonatomic, strong) UIView *bottomBackgroundView; +@property (nonatomic, strong) UIVisualEffectView *bottomBlurEffectView; +@property (nonatomic, strong) CAGradientLayer *bottomMaskLayer; /// 语音转写管理器 @property (nonatomic, strong) KBVoiceToTextManager *voiceToTextManager; @@ -72,23 +87,47 @@ [self setupUI]; [self setupVoiceToTextManager]; [self setupVoiceRecordManager]; + [self setupKeyboardNotifications]; + [self setupKeyboardDismissGesture]; [self loadPersonas]; } +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + if (self.bottomMaskLayer) { + self.bottomMaskLayer.frame = self.bottomBlurEffectView.bounds; + } +} + #pragma mark - 1:控件初始化 - (void)setupUI { + self.voiceInputBarHeight = 150.0; + self.baseInputBarBottomSpacing = 20.0; [self.view addSubview:self.collectionView]; [self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) { make.edges.equalTo(self.view); }]; + // 底部毛玻璃背景 + [self.view addSubview:self.bottomBackgroundView]; + [self.bottomBackgroundView addSubview:self.bottomBlurEffectView]; + [self.bottomBackgroundView mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.right.equalTo(self.view); +// self.bottomBackgroundBottomConstraint = make.bottom.equalTo(self.view).offset(-self.baseInputBarBottomSpacing); + make.bottom.equalTo(self.view); + make.height.mas_equalTo(self.voiceInputBarHeight); + }]; + [self.bottomBlurEffectView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.bottomBackgroundView); + }]; + // 底部语音输入栏 [self.view addSubview:self.voiceInputBar]; [self.voiceInputBar mas_makeConstraints:^(MASConstraintMaker *make) { make.left.right.equalTo(self.view); - make.bottom.equalTo(self.view).offset(-20); - make.height.mas_equalTo(150); // 根据实际需要调整高度 + self.voiceInputBarBottomConstraint = make.bottom.equalTo(self.view).offset(-self.baseInputBarBottomSpacing); + make.height.mas_equalTo(self.voiceInputBarHeight); // 根据实际需要调整高度 }]; } @@ -206,6 +245,7 @@ - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { KBPersonaChatCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"KBPersonaChatCell" forIndexPath:indexPath]; cell.persona = self.personas[indexPath.item]; + [self updateChatViewBottomInset]; // 标记为已预加载 [self.preloadedIndexes addObject:@(indexPath.item)]; @@ -244,6 +284,8 @@ if (currentPage < self.personas.count) { NSLog(@"当前在第 %ld 个人设:%@", (long)currentPage, self.personas[currentPage].name); } + + [self updateChatViewBottomInset]; } #pragma mark - 4:语音转写 @@ -262,6 +304,61 @@ self.voiceRecordManager.minRecordDuration = 1.0; } +#pragma mark - 6:键盘监听 + +- (void)setupKeyboardNotifications { + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleKeyboardWillChangeFrame:) + name:UIKeyboardWillChangeFrameNotification + object:nil]; +} + +- (void)handleKeyboardWillChangeFrame:(NSNotification *)notification { + NSDictionary *userInfo = notification.userInfo; + CGRect endFrame = [userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]; + NSTimeInterval duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + UIViewAnimationOptions options = ([userInfo[UIKeyboardAnimationCurveUserInfoKey] integerValue] << 16); + + CGRect convertedFrame = [self.view convertRect:endFrame fromView:nil]; + CGFloat keyboardHeight = MAX(0.0, CGRectGetMaxY(self.view.bounds) - CGRectGetMinY(convertedFrame)); + self.currentKeyboardHeight = keyboardHeight; + + CGFloat bottomSpacing = (keyboardHeight > 0.0) ? (keyboardHeight + 8.0) : self.baseInputBarBottomSpacing; + [self.voiceInputBarBottomConstraint setOffset:-bottomSpacing]; + [self updateChatViewBottomInset]; + + [UIView animateWithDuration:duration + delay:0 + options:options + animations:^{ + [self.view layoutIfNeeded]; + } + completion:nil]; +} + +#pragma mark - 7:键盘收起 + +- (void)setupKeyboardDismissGesture { + self.dismissKeyboardTap = [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(handleBackgroundTap)]; + self.dismissKeyboardTap.cancelsTouchesInView = NO; + self.dismissKeyboardTap.delegate = self; + [self.view addGestureRecognizer:self.dismissKeyboardTap]; +} + +- (void)handleBackgroundTap { + [self.view endEditing:YES]; +} + +#pragma mark - UIGestureRecognizerDelegate + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch { + if ([touch.view isDescendantOfView:self.voiceInputBar]) { + return NO; + } + return YES; +} + - (NSInteger)currentCompanionId { if (self.personas.count == 0) { return 0; @@ -302,6 +399,42 @@ return nil; } +#pragma mark - Private + +- (void)updateChatViewBottomInset { + CGFloat bottomSpacing = (self.currentKeyboardHeight > 0.0) ? (self.currentKeyboardHeight + 8.0) : self.baseInputBarBottomSpacing; + CGFloat bottomInset = self.voiceInputBarHeight + bottomSpacing; + + for (NSIndexPath *indexPath in self.collectionView.indexPathsForVisibleItems) { + KBPersonaChatCell *cell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:indexPath]; + if (cell) { + [cell updateChatViewBottomInset:bottomInset]; + } + } +} + +- (void)showChatLimitPopWithMessage:(NSString *)message { + if (self.chatLimitPopView) { + [self.chatLimitPopView dismiss]; + } + + CGFloat width = KB_SCREEN_WIDTH - 60; + KBChatLimitPopView *content = [[KBChatLimitPopView alloc] initWithFrame:CGRectMake(0, 0, width, 180)]; + content.message = message; + content.delegate = self; + + LSTPopView *pop = [LSTPopView initWithCustomView:content + parentView:nil + popStyle:LSTPopStyleFade + dismissStyle:LSTDismissStyleFade]; + pop.bgColor = [[UIColor blackColor] colorWithAlphaComponent:0.4]; + pop.hemStyle = LSTHemStyleCenter; + pop.isClickBgDismiss = YES; + pop.isAvoidKeyboard = NO; + self.chatLimitPopView = pop; + [pop pop]; +} + #pragma mark - Lazy Load - (UICollectionView *)collectionView { @@ -335,59 +468,59 @@ return _voiceInputBar; } +#pragma mark - KBChatLimitPopViewDelegate + +- (void)chatLimitPopViewDidTapCancel:(KBChatLimitPopView *)view { + [self.chatLimitPopView dismiss]; +} + +- (void)chatLimitPopViewDidTapRecharge:(KBChatLimitPopView *)view { + [self.chatLimitPopView dismiss]; + if (![KBUserSessionManager shared].isLoggedIn) { + [[KBUserSessionManager shared] goLoginVC]; + return; + } + KBVipPay *vc = [[KBVipPay alloc] init]; + [KB_CURRENT_NAV pushViewController:vc animated:true]; +} + +- (UIView *)bottomBackgroundView { + if (!_bottomBackgroundView) { + _bottomBackgroundView = [[UIView alloc] init]; + _bottomBackgroundView.clipsToBounds = YES; + } + return _bottomBackgroundView; +} + +- (UIVisualEffectView *)bottomBlurEffectView { + if (!_bottomBlurEffectView) { + UIBlurEffect *blurEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleLight]; + _bottomBlurEffectView = [[UIVisualEffectView alloc] initWithEffect:blurEffect]; + _bottomBlurEffectView.layer.mask = self.bottomMaskLayer; + } + return _bottomBlurEffectView; +} + +- (CAGradientLayer *)bottomMaskLayer { + if (!_bottomMaskLayer) { + _bottomMaskLayer = [CAGradientLayer layer]; + _bottomMaskLayer.startPoint = CGPointMake(0.5, 1); + _bottomMaskLayer.endPoint = CGPointMake(0.5, 0); + _bottomMaskLayer.colors = @[ + (__bridge id)[UIColor whiteColor].CGColor, + (__bridge id)[UIColor whiteColor].CGColor, + (__bridge id)[UIColor clearColor].CGColor + ]; + _bottomMaskLayer.locations = @[@(0.0), @(0.5), @(1.0)]; + } + return _bottomMaskLayer; +} + #pragma mark - KBVoiceToTextManagerDelegate - (void)voiceToTextManager:(KBVoiceToTextManager *)manager didReceiveFinalText:(NSString *)text { - if (text.length == 0) { - return; - } - NSLog(@"[KBAIHomeVC] 语音识别结果:%@", text); - - NSInteger companionId = [self currentCompanionId]; - if (companionId <= 0) { - NSLog(@"[KBAIHomeVC] companionId 无效,取消请求"); - return; - } - - KBPersonaChatCell *currentCell = [self currentPersonaCell]; - if (currentCell) { - [currentCell appendUserMessage:text]; - } - - __weak typeof(self) weakSelf = self; - [self.aiVM requestChatMessageWithContent:text - companionId:companionId - completion:^(KBAiMessageResponse * _Nullable response, NSError * _Nullable error) { - __strong typeof(weakSelf) strongSelf = weakSelf; - if (!strongSelf) { - return; - } - - dispatch_async(dispatch_get_main_queue(), ^{ - if (error) { - NSLog(@"[KBAIHomeVC] 请求聊天失败:%@", error.localizedDescription); - return; - } - - if (!response || !response.data) { - NSLog(@"[KBAIHomeVC] 聊天响应为空"); - return; - } - - NSString *aiResponse = response.data.aiResponse ?: response.data.content ?: response.data.text ?: response.data.message ?: @""; - NSString *audioId = response.data.audioId; - if (aiResponse.length == 0) { - NSLog(@"[KBAIHomeVC] AI 回复为空"); - return; - } - - KBPersonaChatCell *cell = [strongSelf currentPersonaCell]; - if (cell) { - [cell appendAssistantMessage:aiResponse audioId:audioId]; - } - }); - }]; + [self handleTranscribedText:text]; } - (void)voiceToTextManager:(KBVoiceToTextManager *)manager @@ -417,6 +550,32 @@ error:nil]; unsigned long long fileSize = [attributes[NSFileSize] unsignedLongLongValue]; NSLog(@"[KBAIHomeVC] 录音完成,时长: %.2fs,大小: %llu bytes", duration, fileSize); + + __weak typeof(self) weakSelf = self; + [self.aiVM transcribeAudioFileAtURL:fileURL + completion:^(KBAiSpeechTranscribeResponse * _Nullable response, NSError * _Nullable error) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) { + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + if (error) { + NSLog(@"[KBAIHomeVC] 语音转文字失败:%@", error.localizedDescription); + [KBHUD showError:KBLocalized(@"语音转文字失败,请重试")]; + return; + } + + NSString *transcript = response.data.transcript ?: @""; + if (transcript.length == 0) { + NSLog(@"[KBAIHomeVC] 语音转文字结果为空"); + [KBHUD showError:KBLocalized(@"未识别到语音内容")]; + return; + } + + [strongSelf handleTranscribedText:transcript]; + }); + }]; } - (void)voiceRecordManagerDidRecordTooShort:(KBVoiceRecordManager *)manager { @@ -429,4 +588,72 @@ NSLog(@"[KBAIHomeVC] 录音失败:%@", error.localizedDescription); } +#pragma mark - Private + +- (void)handleTranscribedText:(NSString *)text { + if (text.length == 0) { + return; + } + NSLog(@"[KBAIHomeVC] 语音识别结果:%@", text); + + NSInteger companionId = [self currentCompanionId]; + if (companionId <= 0) { + NSLog(@"[KBAIHomeVC] companionId 无效,取消请求"); + return; + } + + KBPersonaChatCell *currentCell = [self currentPersonaCell]; + if (currentCell) { + [currentCell appendUserMessage:text]; + } + + __weak typeof(self) weakSelf = self; + [self.aiVM requestChatMessageWithContent:text + companionId:companionId + completion:^(KBAiMessageResponse * _Nullable response, NSError * _Nullable error) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) { + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ +// if (error) { +// NSLog(@"[KBAIHomeVC] 请求聊天失败:%@", error.localizedDescription); +// return; +// } + + if (response.code == 50030) { + NSString *message = response.message ?: @""; + [strongSelf showChatLimitPopWithMessage:message]; + return; + } + + if (!response || !response.data) { + NSString *message = response.message ?: @"聊天响应为空"; + NSLog(@"[KBAIHomeVC] 聊天响应为空:%@", message); + if (message.length > 0) { + [KBHUD showError:message]; + } + return; + } + + NSString *aiResponse = response.data.aiResponse ?: response.data.content ?: response.data.text ?: response.data.message ?: @""; + NSString *audioId = response.data.audioId; + if (aiResponse.length == 0) { + NSLog(@"[KBAIHomeVC] AI 回复为空"); + return; + } + + KBPersonaChatCell *cell = [strongSelf currentPersonaCell]; + if (cell) { + [cell appendAssistantMessage:aiResponse audioId:audioId]; + } + }); + }]; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + @end diff --git a/keyBoard/Class/AiTalk/VC/KBAiMainVC.m b/keyBoard/Class/AiTalk/VC/KBAiMainVC.m index 67839ab..c126991 100644 --- a/keyBoard/Class/AiTalk/VC/KBAiMainVC.m +++ b/keyBoard/Class/AiTalk/VC/KBAiMainVC.m @@ -14,6 +14,8 @@ #import "KBChatTableView.h" #import "KBAiRecordButton.h" #import "KBHUD.h" +#import "KBChatLimitPopView.h" +#import "KBVipPay.h" #import "LSTPopView.h" #import "VoiceChatStreamingManager.h" #import "KBUserSessionManager.h" @@ -22,8 +24,10 @@ @interface KBAiMainVC () + AVAudioPlayerDelegate, + KBChatLimitPopViewDelegate> @property(nonatomic, weak) LSTPopView *popView; +@property(nonatomic, weak) LSTPopView *limitPopView; // UI @property(nonatomic, strong) KBChatTableView *chatView; @@ -419,6 +423,48 @@ self.commentView = customView; } +#pragma mark - 次数用尽弹窗 + +- (void)showChatLimitPopWithMessage:(NSString *)message { + if (self.limitPopView) { + [self.limitPopView dismiss]; + } + + CGFloat width = KB_SCREEN_WIDTH - 60; + KBChatLimitPopView *content = + [[KBChatLimitPopView alloc] initWithFrame:CGRectMake(0, 0, width, 180)]; + content.message = message; + content.delegate = self; + + LSTPopView *popView = + [LSTPopView initWithCustomView:content + parentView:nil + popStyle:LSTPopStyleFade + dismissStyle:LSTDismissStyleFade]; + popView.bgColor = [[UIColor blackColor] colorWithAlphaComponent:0.4]; + popView.hemStyle = LSTHemStyleCenter; + popView.isClickBgDismiss = YES; + popView.isAvoidKeyboard = NO; + self.limitPopView = popView; + [popView pop]; +} + +#pragma mark - KBChatLimitPopViewDelegate + +- (void)chatLimitPopViewDidTapCancel:(KBChatLimitPopView *)view { + [self.limitPopView dismiss]; +} + +- (void)chatLimitPopViewDidTapRecharge:(KBChatLimitPopView *)view { + [self.limitPopView dismiss]; + if (![KBUserSessionManager shared].isLoggedIn) { + [[KBUserSessionManager shared] goLoginVC]; + return; + } + KBVipPay *vc = [[KBVipPay alloc] init]; + [KB_CURRENT_NAV pushViewController:vc animated:true]; +} + #pragma mark - UI Updates - (void)updateStatusForState:(ConversationState)state { @@ -685,6 +731,18 @@ return; } + if (response.code == 50030) { + NSString *message = response.message ?: @""; + [strongSelf showChatLimitPopWithMessage:message]; + return; + } + + if (!response || !response.data) { + NSString *message = response.message ?: @"AI 回复为空"; + [KBHUD showError:message]; + return; + } + // 获取 AI 回复文本 NSString *aiResponse = response.data.aiResponse ?: response.data.content ?: response.data.text ?: response.data.message ?: @""; diff --git a/keyBoard/Class/AiTalk/VM/AiVM.h b/keyBoard/Class/AiTalk/VM/AiVM.h index af09f21..bb18cf7 100644 --- a/keyBoard/Class/AiTalk/VM/AiVM.h +++ b/keyBoard/Class/AiTalk/VM/AiVM.h @@ -37,6 +37,7 @@ typedef void (^AiVMSyncCompletion)(KBAiSyncResponse *_Nullable response, @interface KBAiMessageResponse : NSObject @property(nonatomic, assign) NSInteger code; @property(nonatomic, strong, nullable) KBAiMessageData *data; +@property(nonatomic, copy, nullable) NSString *message; @end typedef void (^AiVMMessageCompletion)(KBAiMessageResponse *_Nullable response, @@ -47,6 +48,22 @@ typedef void (^AiVMAudioURLCompletion)(NSString *_Nullable audioURL, typedef void (^AiVMUploadAudioCompletion)(NSString *_Nullable fileURL, NSError *_Nullable error); +@interface KBAiSpeechTranscribeData : NSObject +@property(nonatomic, copy, nullable) NSString *transcript; +@property(nonatomic, assign) double confidence; +@property(nonatomic, assign) double duration; +@property(nonatomic, copy, nullable) NSString *detectedLanguage; +@end + +@interface KBAiSpeechTranscribeResponse : NSObject +@property(nonatomic, assign) NSInteger code; +@property(nonatomic, strong, nullable) KBAiSpeechTranscribeData *data; +@property(nonatomic, copy, nullable) NSString *message; +@end + +typedef void (^AiVMSpeechTranscribeCompletion)(KBAiSpeechTranscribeResponse *_Nullable response, + NSError *_Nullable error); + @interface AiVM : NSObject - (void)syncChatWithTranscript:(NSString *)transcript @@ -64,6 +81,10 @@ typedef void (^AiVMUploadAudioCompletion)(NSString *_Nullable fileURL, - (void)uploadAudioFileAtURL:(NSURL *)fileURL completion:(AiVMUploadAudioCompletion)completion; +/// 语音转文字(multipart/form-data) +- (void)transcribeAudioFileAtURL:(NSURL *)fileURL + completion:(AiVMSpeechTranscribeCompletion)completion; + #pragma mark - 人设相关接口 /// 分页查询人设列表 diff --git a/keyBoard/Class/AiTalk/VM/AiVM.m b/keyBoard/Class/AiTalk/VM/AiVM.m index 510237f..ff42f71 100644 --- a/keyBoard/Class/AiTalk/VM/AiVM.m +++ b/keyBoard/Class/AiTalk/VM/AiVM.m @@ -46,6 +46,12 @@ @implementation KBAiMessageResponse @end +@implementation KBAiSpeechTranscribeData +@end + +@implementation KBAiSpeechTranscribeResponse +@end + @implementation AiVM - (void)syncChatWithTranscript:(NSString *)transcript @@ -126,15 +132,16 @@ autoShowBusinessError:NO completion:^(NSDictionary *_Nullable json, NSURLResponse *_Nullable response, NSError *_Nullable error) { + KBAiMessageResponse *model = + [KBAiMessageResponse mj_objectWithKeyValues:json]; if (error) { if (completion) { - completion(nil, error); + completion(model, error); } return; } - KBAiMessageResponse *model = - [KBAiMessageResponse mj_objectWithKeyValues:json]; + id dataObj = json[@"data"]; if (!model.data && [dataObj isKindOfClass:[NSString class]]) { KBAiMessageData *data = [[KBAiMessageData alloc] init]; @@ -261,6 +268,42 @@ autoShowBusinessError:NO }]; } +- (void)transcribeAudioFileAtURL:(NSURL *)fileURL + completion:(AiVMSpeechTranscribeCompletion)completion { + if (!fileURL || !fileURL.isFileURL) { + NSError *error = [NSError errorWithDomain:@"AiVM" + code:-1 + userInfo:@{NSLocalizedDescriptionKey : @"invalid fileURL"}]; + if (completion) { + completion(nil, error); + } + return; + } + + [[KBNetworkManager shared] uploadFile:API_AI_SPEECH_TRANSCRIBE + fileURL:fileURL + name:@"file" + mimeType:@"audio/m4a" + parameters:nil + headers:nil + completion:^(NSDictionary *_Nullable json, + NSURLResponse *_Nullable response, + NSError *_Nullable error) { + if (error) { + if (completion) { + completion(nil, error); + } + return; + } + + KBAiSpeechTranscribeResponse *model = + [KBAiSpeechTranscribeResponse mj_objectWithKeyValues:json]; + if (completion) { + completion(model, nil); + } + }]; +} + #pragma mark - 人设相关接口 - (void)fetchPersonasWithPageNum:(NSInteger)pageNum