This commit is contained in:
2026-01-27 16:28:17 +08:00
parent ce889e1ed0
commit 2b749cd2b0
26 changed files with 1092 additions and 128 deletions

View File

@@ -73,6 +73,7 @@
#define API_AI_CHAT_SYNC @"/chat/sync" // 同步对话 #define API_AI_CHAT_SYNC @"/chat/sync" // 同步对话
#define API_AI_CHAT_MESSAGE @"/chat/message" // 文本润色 #define API_AI_CHAT_MESSAGE @"/chat/message" // 文本润色
#define API_AI_AUDIO_UPLOAD @"/chat/audio/upload" // 语音上传(替换为后端真实路径) #define API_AI_AUDIO_UPLOAD @"/chat/audio/upload" // 语音上传(替换为后端真实路径)
#define API_AI_SPEECH_TRANSCRIBE @"/speech/transcribe" // 语音转文字

View File

@@ -143,6 +143,7 @@
048FFD182F2763A5005D62AE /* KBVoiceInputBar.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD172F2763A5005D62AE /* KBVoiceInputBar.m */; }; 048FFD182F2763A5005D62AE /* KBVoiceInputBar.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD172F2763A5005D62AE /* KBVoiceInputBar.m */; };
048FFD1D2F277486005D62AE /* KBChatHistoryPageModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD1C2F277486005D62AE /* KBChatHistoryPageModel.m */; }; 048FFD1D2F277486005D62AE /* KBChatHistoryPageModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD1C2F277486005D62AE /* KBChatHistoryPageModel.m */; };
048FFD1E2F277486005D62AE /* KBChatHistoryModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD1A2F277486005D62AE /* KBChatHistoryModel.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 */; }; 0498BD622EDFFC12006CC1D5 /* KBMyVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD612EDFFC12006CC1D5 /* KBMyVM.m */; };
0498BD652EE0116D006CC1D5 /* KBEmailLoginVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD642EE0116D006CC1D5 /* KBEmailLoginVC.m */; }; 0498BD652EE0116D006CC1D5 /* KBEmailLoginVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD642EE0116D006CC1D5 /* KBEmailLoginVC.m */; };
0498BD682EE01180006CC1D5 /* KBEmailRegistVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0498BD672EE01180006CC1D5 /* KBEmailRegistVC.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 */; }; 04E038E32F20E500002CA5A0 /* deepgramAPI.md in Resources */ = {isa = PBXBuildFile; fileRef = 04E038E22F20E500002CA5A0 /* deepgramAPI.md */; };
04E038E82F20E877002CA5A0 /* DeepgramWebSocketClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038E72F20E877002CA5A0 /* DeepgramWebSocketClient.m */; }; 04E038E82F20E877002CA5A0 /* DeepgramWebSocketClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038E72F20E877002CA5A0 /* DeepgramWebSocketClient.m */; };
04E038E92F20E877002CA5A0 /* DeepgramStreamingManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.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 */; }; 04E038EF2F21F0EC002CA5A0 /* AiVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038EE2F21F0EC002CA5A0 /* AiVM.m */; };
04E0394B2F236E75002CA5A0 /* KBChatUserMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */; }; 04E0394B2F236E75002CA5A0 /* KBChatUserMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */; };
04E0394C2F236E75002CA5A0 /* KBChatTimeCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039482F236E75002CA5A0 /* KBChatTimeCell.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 */; }; 04E0394E2F236E75002CA5A0 /* KBChatTableView.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039452F236E75002CA5A0 /* KBChatTableView.m */; };
04E0394F2F236E75002CA5A0 /* KBChatTableView_Usage.md in Resources */ = {isa = PBXBuildFile; fileRef = 04E039462F236E75002CA5A0 /* KBChatTableView_Usage.md */; }; 04E0394F2F236E75002CA5A0 /* KBChatTableView_Usage.md in Resources */ = {isa = PBXBuildFile; fileRef = 04E039462F236E75002CA5A0 /* KBChatTableView_Usage.md */; };
04E039522F2387D2002CA5A0 /* KBAiChatMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039512F2387D2002CA5A0 /* KBAiChatMessage.m */; }; 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 */; }; 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 */; }; 04E161842F10E6470022C23B /* normal_them.zip in Resources */ = {isa = PBXBuildFile; fileRef = 04E161822F10E6470022C23B /* normal_them.zip */; };
04FC95672EB0546C007BD342 /* KBKey.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95652EB0546C007BD342 /* KBKey.m */; }; 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 = "<group>"; }; 048FFD1A2F277486005D62AE /* KBChatHistoryModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatHistoryModel.m; sourceTree = "<group>"; };
048FFD1B2F277486005D62AE /* KBChatHistoryPageModel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatHistoryPageModel.h; sourceTree = "<group>"; }; 048FFD1B2F277486005D62AE /* KBChatHistoryPageModel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatHistoryPageModel.h; sourceTree = "<group>"; };
048FFD1C2F277486005D62AE /* KBChatHistoryPageModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatHistoryPageModel.m; sourceTree = "<group>"; }; 048FFD1C2F277486005D62AE /* KBChatHistoryPageModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatHistoryPageModel.m; sourceTree = "<group>"; };
048FFD222F28A836005D62AE /* KBChatLimitPopView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatLimitPopView.h; sourceTree = "<group>"; };
048FFD232F28A836005D62AE /* KBChatLimitPopView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatLimitPopView.m; sourceTree = "<group>"; };
0498BD5E2EDF2157006CC1D5 /* KBBizCode.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBBizCode.h; sourceTree = "<group>"; }; 0498BD5E2EDF2157006CC1D5 /* KBBizCode.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBBizCode.h; sourceTree = "<group>"; };
0498BD602EDFFC12006CC1D5 /* KBMyVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBMyVM.h; sourceTree = "<group>"; }; 0498BD602EDFFC12006CC1D5 /* KBMyVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBMyVM.h; sourceTree = "<group>"; };
0498BD612EDFFC12006CC1D5 /* KBMyVM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBMyVM.m; sourceTree = "<group>"; }; 0498BD612EDFFC12006CC1D5 /* KBMyVM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBMyVM.m; sourceTree = "<group>"; };
@@ -652,10 +655,6 @@
04E038E22F20E500002CA5A0 /* deepgramAPI.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = deepgramAPI.md; sourceTree = "<group>"; }; 04E038E22F20E500002CA5A0 /* deepgramAPI.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = deepgramAPI.md; sourceTree = "<group>"; };
04E038E42F20E877002CA5A0 /* DeepgramStreamingManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DeepgramStreamingManager.h; sourceTree = "<group>"; }; 04E038E42F20E877002CA5A0 /* DeepgramStreamingManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DeepgramStreamingManager.h; sourceTree = "<group>"; };
04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DeepgramStreamingManager.m; sourceTree = "<group>"; }; 04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DeepgramStreamingManager.m; sourceTree = "<group>"; };
04E0B1002F300001002CA5A0 /* KBVoiceToTextManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceToTextManager.h; sourceTree = "<group>"; };
04E0B1012F300001002CA5A0 /* KBVoiceToTextManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceToTextManager.m; sourceTree = "<group>"; };
04E0B2002F300002002CA5A0 /* KBVoiceRecordManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceRecordManager.h; sourceTree = "<group>"; };
04E0B2012F300002002CA5A0 /* KBVoiceRecordManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceRecordManager.m; sourceTree = "<group>"; };
04E038E62F20E877002CA5A0 /* DeepgramWebSocketClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DeepgramWebSocketClient.h; sourceTree = "<group>"; }; 04E038E62F20E877002CA5A0 /* DeepgramWebSocketClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DeepgramWebSocketClient.h; sourceTree = "<group>"; };
04E038E72F20E877002CA5A0 /* DeepgramWebSocketClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DeepgramWebSocketClient.m; sourceTree = "<group>"; }; 04E038E72F20E877002CA5A0 /* DeepgramWebSocketClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DeepgramWebSocketClient.m; sourceTree = "<group>"; };
04E038ED2F21F0EC002CA5A0 /* AiVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AiVM.h; sourceTree = "<group>"; }; 04E038ED2F21F0EC002CA5A0 /* AiVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AiVM.h; sourceTree = "<group>"; };
@@ -671,6 +670,10 @@
04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatUserMessageCell.m; sourceTree = "<group>"; }; 04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChatUserMessageCell.m; sourceTree = "<group>"; };
04E039502F2387D2002CA5A0 /* KBAiChatMessage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAiChatMessage.h; sourceTree = "<group>"; }; 04E039502F2387D2002CA5A0 /* KBAiChatMessage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAiChatMessage.h; sourceTree = "<group>"; };
04E039512F2387D2002CA5A0 /* KBAiChatMessage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAiChatMessage.m; sourceTree = "<group>"; }; 04E039512F2387D2002CA5A0 /* KBAiChatMessage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAiChatMessage.m; sourceTree = "<group>"; };
04E0B1002F300001002CA5A0 /* KBVoiceToTextManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceToTextManager.h; sourceTree = "<group>"; };
04E0B1012F300001002CA5A0 /* KBVoiceToTextManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceToTextManager.m; sourceTree = "<group>"; };
04E0B2002F300002002CA5A0 /* KBVoiceRecordManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBVoiceRecordManager.h; sourceTree = "<group>"; };
04E0B2012F300002002CA5A0 /* KBVoiceRecordManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBVoiceRecordManager.m; sourceTree = "<group>"; };
04E161812F10E6470022C23B /* normal_hei_them.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = normal_hei_them.zip; sourceTree = "<group>"; }; 04E161812F10E6470022C23B /* normal_hei_them.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = normal_hei_them.zip; sourceTree = "<group>"; };
04E161822F10E6470022C23B /* normal_them.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = normal_them.zip; sourceTree = "<group>"; }; 04E161822F10E6470022C23B /* normal_them.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = normal_them.zip; sourceTree = "<group>"; };
04FC953A2EAFAE56007BD342 /* KeyBoardPrefixHeader.pch */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KeyBoardPrefixHeader.pch; sourceTree = "<group>"; }; 04FC953A2EAFAE56007BD342 /* KeyBoardPrefixHeader.pch */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KeyBoardPrefixHeader.pch; sourceTree = "<group>"; };
@@ -1036,6 +1039,8 @@
048FFD132F274342005D62AE /* KBPersonaChatCell.m */, 048FFD132F274342005D62AE /* KBPersonaChatCell.m */,
048FFD162F2763A5005D62AE /* KBVoiceInputBar.h */, 048FFD162F2763A5005D62AE /* KBVoiceInputBar.h */,
048FFD172F2763A5005D62AE /* KBVoiceInputBar.m */, 048FFD172F2763A5005D62AE /* KBVoiceInputBar.m */,
048FFD222F28A836005D62AE /* KBChatLimitPopView.h */,
048FFD232F28A836005D62AE /* KBChatLimitPopView.m */,
); );
path = V; path = V;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -2287,6 +2292,7 @@
04FC95D22EB1E7AE007BD342 /* MyVC.m in Sources */, 04FC95D22EB1E7AE007BD342 /* MyVC.m in Sources */,
04286A032ECB0A1600CE730C /* KBSexSelVC.m in Sources */, 04286A032ECB0A1600CE730C /* KBSexSelVC.m in Sources */,
048FFD0B2F273BFC005D62AE /* KBAIHomeVC.m in Sources */, 048FFD0B2F273BFC005D62AE /* KBAIHomeVC.m in Sources */,
048FFD242F28A836005D62AE /* KBChatLimitPopView.m in Sources */,
046086CC2F1A092500757C95 /* KBAIReplyModel.m in Sources */, 046086CC2F1A092500757C95 /* KBAIReplyModel.m in Sources */,
046086CD2F1A092500757C95 /* KBAICommentModel.m in Sources */, 046086CD2F1A092500757C95 /* KBAICommentModel.m in Sources */,
04791F8F2ED469C0004E8522 /* KBHostAppLauncher.m in Sources */, 04791F8F2ED469C0004E8522 /* KBHostAppLauncher.m in Sources */,

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -47,6 +47,12 @@ typedef NS_ENUM(NSInteger, KBAiRecordButtonState) {
/// 主色调 /// 主色调
@property(nonatomic, strong) UIColor *tintColor; @property(nonatomic, strong) UIColor *tintColor;
/// 正常状态左侧图标
@property(nonatomic, strong, nullable) UIImage *normalIconImage;
/// 录音中状态图标(居中显示)
@property(nonatomic, strong, nullable) UIImage *recordingIconImage;
/// 更新音量(用于波形动画) /// 更新音量(用于波形动画)
/// @param rms 音量 RMS 值 (0.0 - 1.0) /// @param rms 音量 RMS 值 (0.0 - 1.0)
- (void)updateVolumeRMS:(float)rms; - (void)updateVolumeRMS:(float)rms;

View File

@@ -14,6 +14,7 @@
@property(nonatomic, strong) UILabel *titleLabel; @property(nonatomic, strong) UILabel *titleLabel;
@property(nonatomic, strong) KBAiWaveformView *waveformView; @property(nonatomic, strong) KBAiWaveformView *waveformView;
@property(nonatomic, strong) UIImageView *micIconView; @property(nonatomic, strong) UIImageView *micIconView;
@property(nonatomic, strong) UIImageView *recordingIconView;
@property(nonatomic, assign) BOOL isPressing; @property(nonatomic, assign) BOOL isPressing;
@end @end
@@ -50,7 +51,7 @@
self.backgroundView.translatesAutoresizingMaskIntoConstraints = NO; self.backgroundView.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:self.backgroundView]; [self addSubview:self.backgroundView];
// //
self.micIconView = [[UIImageView alloc] init]; self.micIconView = [[UIImageView alloc] init];
self.micIconView.image = [UIImage systemImageNamed:@"mic.fill"]; self.micIconView.image = [UIImage systemImageNamed:@"mic.fill"];
self.micIconView.tintColor = self.tintColor; self.micIconView.tintColor = self.tintColor;
@@ -58,6 +59,13 @@
self.micIconView.translatesAutoresizingMaskIntoConstraints = NO; self.micIconView.translatesAutoresizingMaskIntoConstraints = NO;
[self.backgroundView addSubview:self.micIconView]; [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 = [[UILabel alloc] init];
self.titleLabel.text = self.normalTitle; self.titleLabel.text = self.normalTitle;
@@ -104,6 +112,13 @@
constraintEqualToAnchor:self.backgroundView.centerYAnchor], constraintEqualToAnchor:self.backgroundView.centerYAnchor],
[self.waveformView.widthAnchor constraintEqualToConstant:60], [self.waveformView.widthAnchor constraintEqualToConstant:60],
[self.waveformView.heightAnchor constraintEqualToConstant:30], [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; 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 #pragma mark - Public Methods
- (void)updateVolumeRMS:(float)rms { - (void)updateVolumeRMS:(float)rms {
@@ -144,18 +171,21 @@
case KBAiRecordButtonStateNormal: case KBAiRecordButtonStateNormal:
self.titleLabel.text = self.normalTitle; self.titleLabel.text = self.normalTitle;
self.backgroundView.backgroundColor = [UIColor systemGray6Color]; 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.alpha = 0;
[self.waveformView stopAnimation]; [self.waveformView stopAnimation];
break; break;
case KBAiRecordButtonStateRecording: case KBAiRecordButtonStateRecording:
self.titleLabel.text = self.recordingTitle; self.titleLabel.text = self.recordingTitle;
self.backgroundView.backgroundColor = self.backgroundView.backgroundColor = [UIColor colorWithWhite:0 alpha:0.6];
[self.tintColor colorWithAlphaComponent:0.15]; self.micIconView.hidden = YES;
self.micIconView.alpha = 1; self.titleLabel.hidden = YES;
self.waveformView.alpha = 1; self.recordingIconView.hidden = NO;
[self.waveformView startIdleAnimation]; self.waveformView.alpha = 0;
[self.waveformView stopAnimation];
break; break;
case KBAiRecordButtonStateDisabled: case KBAiRecordButtonStateDisabled:

View File

@@ -0,0 +1,28 @@
//
// KBChatLimitPopView.h
// keyBoard
//
// Created by Codex on 2026/1/27.
//
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@class KBChatLimitPopView;
@protocol KBChatLimitPopViewDelegate <NSObject>
@optional
- (void)chatLimitPopViewDidTapCancel:(KBChatLimitPopView *)view;
- (void)chatLimitPopViewDidTapRecharge:(KBChatLimitPopView *)view;
@end
/// 聊天次数用尽提示弹窗内容视图(配合 LSTPopView 使用)
@interface KBChatLimitPopView : UIView
@property (nonatomic, weak) id<KBChatLimitPopViewDelegate> delegate;
@property (nonatomic, copy) NSString *message;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,157 @@
//
// KBChatLimitPopView.m
// keyBoard
//
// Created by Codex on 2026/1/27.
//
#import "KBChatLimitPopView.h"
#import <Masonry/Masonry.h>
@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

View File

@@ -57,6 +57,9 @@ NS_ASSUME_NONNULL_BEGIN
/// 重置无更多数据状态 /// 重置无更多数据状态
- (void)resetNoMoreData; - (void)resetNoMoreData;
/// 更新底部内容 inset用于避开输入栏/键盘)
- (void)updateContentBottomInset:(CGFloat)bottomInset;
/// 添加自定义消息(可用于历史消息或打字机) /// 添加自定义消息(可用于历史消息或打字机)
- (void)addMessage:(KBAiChatMessage *)message - (void)addMessage:(KBAiChatMessage *)message
autoScroll:(BOOL)autoScroll; autoScroll:(BOOL)autoScroll;

View File

@@ -30,6 +30,7 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
@property (nonatomic, strong) NSIndexPath *playingCellIndexPath; @property (nonatomic, strong) NSIndexPath *playingCellIndexPath;
@property (nonatomic, strong) AiVM *aiVM; @property (nonatomic, strong) AiVM *aiVM;
@property (nonatomic, assign) BOOL hasMoreData; @property (nonatomic, assign) BOOL hasMoreData;
@property (nonatomic, assign) CGFloat contentBottomInset;
@end @end
@@ -78,11 +79,12 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
// //
[self.tableView mas_makeConstraints:^(MASConstraintMaker *make) { [self.tableView mas_makeConstraints:^(MASConstraintMaker *make) {
// make.edges.equalTo(self); make.edges.equalTo(self);
make.top.left.right.equalTo(self);
make.bottom.equalTo(self).offset(-KB_TABBAR_HEIGHT - 40 - 10);
}]; }];
self.contentBottomInset = KB_TABBAR_HEIGHT + 40 + 10;
[self updateContentBottomInset:self.contentBottomInset];
__weak typeof(self) weakSelf = self; __weak typeof(self) weakSelf = self;
self.tableView.mj_footer = [MJRefreshAutoNormalFooter footerWithRefreshingBlock:^{ self.tableView.mj_footer = [MJRefreshAutoNormalFooter footerWithRefreshingBlock:^{
__strong typeof(weakSelf) strongSelf = weakSelf; __strong typeof(weakSelf) strongSelf = weakSelf;
@@ -207,6 +209,14 @@ static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟
[self updateFooterVisibility]; [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 - (void)addMessage:(KBAiChatMessage *)message
autoScroll:(BOOL)autoScroll { autoScroll:(BOOL)autoScroll {
if (!message) { if (!message) {

View File

@@ -26,6 +26,9 @@ NS_ASSUME_NONNULL_BEGIN
- (void)appendAssistantMessage:(NSString *)text - (void)appendAssistantMessage:(NSString *)text
audioId:(nullable NSString *)audioId; audioId:(nullable NSString *)audioId;
/// 更新聊天列表底部 inset
- (void)updateChatViewBottomInset:(CGFloat)bottomInset;
@end @end
NS_ASSUME_NONNULL_END NS_ASSUME_NONNULL_END

View File

@@ -288,6 +288,10 @@
[self.chatView addMessage:message autoScroll:YES]; [self.chatView addMessage:message autoScroll:YES];
} }
- (void)updateChatViewBottomInset:(CGFloat)bottomInset {
[self.chatView updateContentBottomInset:bottomInset];
}
#pragma mark - KBChatTableViewDelegate #pragma mark - KBChatTableViewDelegate
- (void)chatTableViewDidScroll:(KBChatTableView *)chatView - (void)chatTableViewDidScroll:(KBChatTableView *)chatView

View File

@@ -27,6 +27,18 @@ NS_ASSUME_NONNULL_BEGIN
@end @end
typedef NS_ENUM(NSInteger, KBVoiceInputBarMode) {
KBVoiceInputBarModeText,
KBVoiceInputBarModeVoice
};
typedef NS_ENUM(NSInteger, KBVoiceInputBarState) {
KBVoiceInputBarStateText,
KBVoiceInputBarStateVoice,
KBVoiceInputBarStateRecording,
KBVoiceInputBarStateCancel
};
/// 底部语音输入栏 /// 底部语音输入栏
/// 包含:毛玻璃背景 + 录音按钮 /// 包含:毛玻璃背景 + 录音按钮
@interface KBVoiceInputBar : UIView @interface KBVoiceInputBar : UIView
@@ -37,6 +49,12 @@ NS_ASSUME_NONNULL_BEGIN
/// 状态文本(显示在按钮上方) /// 状态文本(显示在按钮上方)
@property (nonatomic, copy) NSString *statusText; @property (nonatomic, copy) NSString *statusText;
/// 输入模式(文字/语音)
@property (nonatomic, assign) KBVoiceInputBarMode inputMode;
/// 输入状态(文字/语音/录音/取消)
@property (nonatomic, assign) KBVoiceInputBarState inputState;
/// 是否启用(禁用时按钮不可点击) /// 是否启用(禁用时按钮不可点击)
@property (nonatomic, assign) BOOL enabled; @property (nonatomic, assign) BOOL enabled;

View File

@@ -11,18 +11,37 @@
@interface KBVoiceInputBar () <KBAiRecordButtonDelegate> @interface KBVoiceInputBar () <KBAiRecordButtonDelegate>
///
@property (nonatomic, strong) UIView *backgroundView;
///
@property (nonatomic, strong) UIVisualEffectView *blurEffectView;
/// ///
@property (nonatomic, strong) UILabel *statusLabel; @property (nonatomic, strong) UILabel *statusLabel;
/// ///
@property (nonatomic, strong) KBAiRecordButton *recordButton; @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; @property (nonatomic, assign) BOOL isRecording;
@@ -52,30 +71,8 @@
self.backgroundColor = [UIColor clearColor]; self.backgroundColor = [UIColor clearColor];
self.enabled = YES; self.enabled = YES;
self.isRecording = NO; self.isRecording = NO;
self.inputMode = KBVoiceInputBarModeVoice;
// self.inputState = KBVoiceInputBarStateText;
[self addSubview:self.backgroundView];
[self.backgroundView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self);
}];
//
[self.backgroundView addSubview:self.blurEffectView];
[self.blurEffectView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self.backgroundView);
}];
// blurEffectView mask
CAGradientLayer *maskLayer = [CAGradientLayer layer];
maskLayer.startPoint = CGPointMake(0.5, 1); //
maskLayer.endPoint = CGPointMake(0.5, 0); //
maskLayer.colors = @[
(__bridge id)[UIColor whiteColor].CGColor, //
(__bridge id)[UIColor whiteColor].CGColor, //
(__bridge id)[UIColor clearColor].CGColor //
];
maskLayer.locations = @[@(0.0), @(0.5), @(1.0)];
self.blurEffectView.layer.mask = maskLayer;
// //
[self addSubview:self.statusLabel]; [self addSubview:self.statusLabel];
@@ -86,24 +83,92 @@
make.height.mas_equalTo(20); make.height.mas_equalTo(20);
}]; }];
// //
[self addSubview:self.recordButton]; [self addSubview:self.inputContainer];
[self.recordButton mas_makeConstraints:^(MASConstraintMaker *make) { [self.inputContainer mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.statusLabel.mas_bottom).offset(12); make.top.equalTo(self.statusLabel.mas_bottom).offset(12);
make.left.equalTo(self).offset(20); make.left.equalTo(self).offset(20);
make.right.equalTo(self).offset(-20); make.right.equalTo(self).offset(-20);
make.height.mas_equalTo(50); make.height.mas_equalTo(50);
make.bottom.lessThanOrEqualTo(self).offset(-16); make.bottom.lessThanOrEqualTo(self).offset(-16);
}]; }];
}
- (void)layoutSubviews { UILongPressGestureRecognizer *longPress =
[super layoutSubviews]; [[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.inputContainer addSubview:self.textInputView];
self.blurEffectView.layer.mask.frame = self.blurEffectView.bounds; [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 #pragma mark - Setter
@@ -111,17 +176,55 @@
- (void)setStatusText:(NSString *)statusText { - (void)setStatusText:(NSString *)statusText {
_statusText = [statusText copy]; _statusText = [statusText copy];
self.statusLabel.text = statusText; self.statusLabel.text = statusText;
[self updateCenterTextIfNeeded];
} }
- (void)setEnabled:(BOOL)enabled { - (void)setEnabled:(BOOL)enabled {
_enabled = enabled; _enabled = enabled;
self.recordButton.userInteractionEnabled = enabled; self.recordButton.userInteractionEnabled = enabled;
self.recordButton.alpha = enabled ? 1.0 : 0.5; self.recordButton.alpha = enabled ? 1.0 : 0.5;
self.inputContainer.userInteractionEnabled = enabled;
self.inputContainer.alpha = enabled ? 1.0 : 0.5;
} }
- (void)setRecording:(BOOL)recording { - (void)setRecording:(BOOL)recording {
_isRecording = recording; _isRecording = recording;
self.recordButton.state = recording ? KBAiRecordButtonStateRecording : KBAiRecordButtonStateNormal; 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 #pragma mark - Public Methods
@@ -162,22 +265,6 @@
#pragma mark - Lazy Load #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 { - (UILabel *)statusLabel {
if (!_statusLabel) { if (!_statusLabel) {
_statusLabel = [[UILabel alloc] init]; _statusLabel = [[UILabel alloc] init];
@@ -195,8 +282,198 @@
_recordButton.delegate = self; _recordButton.delegate = self;
_recordButton.normalTitle = @"按住说话"; _recordButton.normalTitle = @"按住说话";
_recordButton.recordingTitle = @"松开结束"; _recordButton.recordingTitle = @"松开结束";
_recordButton.normalIconImage = [UIImage imageNamed:@"ai_jianpan_icon"];
_recordButton.recordingIconImage = [UIImage imageNamed:@"ai_luyining_icon"];
_recordButton.hidden = YES;
} }
return _recordButton; 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 @end

View File

@@ -13,15 +13,30 @@
#import "KBVoiceToTextManager.h" #import "KBVoiceToTextManager.h"
#import "AiVM.h" #import "AiVM.h"
#import "KBHUD.h" #import "KBHUD.h"
#import "KBChatLimitPopView.h"
#import "KBVipPay.h"
#import "KBUserSessionManager.h"
#import "LSTPopView.h"
#import <Masonry/Masonry.h> #import <Masonry/Masonry.h>
@interface KBAIHomeVC () <UICollectionViewDelegate, UICollectionViewDataSource, KBVoiceToTextManagerDelegate, KBVoiceRecordManagerDelegate> @interface KBAIHomeVC () <UICollectionViewDelegate, UICollectionViewDataSource, KBVoiceToTextManagerDelegate, KBVoiceRecordManagerDelegate, UIGestureRecognizerDelegate, KBChatLimitPopViewDelegate>
/// ///
@property (nonatomic, strong) UICollectionView *collectionView; @property (nonatomic, strong) UICollectionView *collectionView;
/// ///
@property (nonatomic, strong) KBVoiceInputBar *voiceInputBar; @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; @property (nonatomic, strong) KBVoiceToTextManager *voiceToTextManager;
@@ -72,23 +87,47 @@
[self setupUI]; [self setupUI];
[self setupVoiceToTextManager]; [self setupVoiceToTextManager];
[self setupVoiceRecordManager]; [self setupVoiceRecordManager];
[self setupKeyboardNotifications];
[self setupKeyboardDismissGesture];
[self loadPersonas]; [self loadPersonas];
} }
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
if (self.bottomMaskLayer) {
self.bottomMaskLayer.frame = self.bottomBlurEffectView.bounds;
}
}
#pragma mark - 1 #pragma mark - 1
- (void)setupUI { - (void)setupUI {
self.voiceInputBarHeight = 150.0;
self.baseInputBarBottomSpacing = 20.0;
[self.view addSubview:self.collectionView]; [self.view addSubview:self.collectionView];
[self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) { [self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self.view); 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.view addSubview:self.voiceInputBar];
[self.voiceInputBar mas_makeConstraints:^(MASConstraintMaker *make) { [self.voiceInputBar mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self.view); make.left.right.equalTo(self.view);
make.bottom.equalTo(self.view).offset(-20); self.voiceInputBarBottomConstraint = make.bottom.equalTo(self.view).offset(-self.baseInputBarBottomSpacing);
make.height.mas_equalTo(150); // make.height.mas_equalTo(self.voiceInputBarHeight); //
}]; }];
} }
@@ -206,6 +245,7 @@
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
KBPersonaChatCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"KBPersonaChatCell" forIndexPath:indexPath]; KBPersonaChatCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"KBPersonaChatCell" forIndexPath:indexPath];
cell.persona = self.personas[indexPath.item]; cell.persona = self.personas[indexPath.item];
[self updateChatViewBottomInset];
// //
[self.preloadedIndexes addObject:@(indexPath.item)]; [self.preloadedIndexes addObject:@(indexPath.item)];
@@ -244,6 +284,8 @@
if (currentPage < self.personas.count) { if (currentPage < self.personas.count) {
NSLog(@"当前在第 %ld 个人设:%@", (long)currentPage, self.personas[currentPage].name); NSLog(@"当前在第 %ld 个人设:%@", (long)currentPage, self.personas[currentPage].name);
} }
[self updateChatViewBottomInset];
} }
#pragma mark - 4 #pragma mark - 4
@@ -262,6 +304,61 @@
self.voiceRecordManager.minRecordDuration = 1.0; 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 { - (NSInteger)currentCompanionId {
if (self.personas.count == 0) { if (self.personas.count == 0) {
return 0; return 0;
@@ -302,6 +399,42 @@
return nil; 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 #pragma mark - Lazy Load
- (UICollectionView *)collectionView { - (UICollectionView *)collectionView {
@@ -335,59 +468,59 @@
return _voiceInputBar; 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 #pragma mark - KBVoiceToTextManagerDelegate
- (void)voiceToTextManager:(KBVoiceToTextManager *)manager - (void)voiceToTextManager:(KBVoiceToTextManager *)manager
didReceiveFinalText:(NSString *)text { didReceiveFinalText:(NSString *)text {
if (text.length == 0) { [self handleTranscribedText:text];
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];
}
});
}];
} }
- (void)voiceToTextManager:(KBVoiceToTextManager *)manager - (void)voiceToTextManager:(KBVoiceToTextManager *)manager
@@ -417,6 +550,32 @@
error:nil]; error:nil];
unsigned long long fileSize = [attributes[NSFileSize] unsignedLongLongValue]; unsigned long long fileSize = [attributes[NSFileSize] unsignedLongLongValue];
NSLog(@"[KBAIHomeVC] 录音完成,时长: %.2fs,大小: %llu bytes", duration, fileSize); 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 { - (void)voiceRecordManagerDidRecordTooShort:(KBVoiceRecordManager *)manager {
@@ -429,4 +588,72 @@
NSLog(@"[KBAIHomeVC] 录音失败:%@", error.localizedDescription); 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 @end

View File

@@ -14,6 +14,8 @@
#import "KBChatTableView.h" #import "KBChatTableView.h"
#import "KBAiRecordButton.h" #import "KBAiRecordButton.h"
#import "KBHUD.h" #import "KBHUD.h"
#import "KBChatLimitPopView.h"
#import "KBVipPay.h"
#import "LSTPopView.h" #import "LSTPopView.h"
#import "VoiceChatStreamingManager.h" #import "VoiceChatStreamingManager.h"
#import "KBUserSessionManager.h" #import "KBUserSessionManager.h"
@@ -22,8 +24,10 @@
@interface KBAiMainVC () <KBAiRecordButtonDelegate, @interface KBAiMainVC () <KBAiRecordButtonDelegate,
VoiceChatStreamingManagerDelegate, VoiceChatStreamingManagerDelegate,
DeepgramStreamingManagerDelegate, DeepgramStreamingManagerDelegate,
AVAudioPlayerDelegate> AVAudioPlayerDelegate,
KBChatLimitPopViewDelegate>
@property(nonatomic, weak) LSTPopView *popView; @property(nonatomic, weak) LSTPopView *popView;
@property(nonatomic, weak) LSTPopView *limitPopView;
// UI // UI
@property(nonatomic, strong) KBChatTableView *chatView; @property(nonatomic, strong) KBChatTableView *chatView;
@@ -419,6 +423,48 @@
self.commentView = customView; 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 #pragma mark - UI Updates
- (void)updateStatusForState:(ConversationState)state { - (void)updateStatusForState:(ConversationState)state {
@@ -685,6 +731,18 @@
return; 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 // AI
NSString *aiResponse = response.data.aiResponse ?: response.data.content ?: response.data.text ?: response.data.message ?: @""; NSString *aiResponse = response.data.aiResponse ?: response.data.content ?: response.data.text ?: response.data.message ?: @"";

View File

@@ -37,6 +37,7 @@ typedef void (^AiVMSyncCompletion)(KBAiSyncResponse *_Nullable response,
@interface KBAiMessageResponse : NSObject @interface KBAiMessageResponse : NSObject
@property(nonatomic, assign) NSInteger code; @property(nonatomic, assign) NSInteger code;
@property(nonatomic, strong, nullable) KBAiMessageData *data; @property(nonatomic, strong, nullable) KBAiMessageData *data;
@property(nonatomic, copy, nullable) NSString *message;
@end @end
typedef void (^AiVMMessageCompletion)(KBAiMessageResponse *_Nullable response, typedef void (^AiVMMessageCompletion)(KBAiMessageResponse *_Nullable response,
@@ -47,6 +48,22 @@ typedef void (^AiVMAudioURLCompletion)(NSString *_Nullable audioURL,
typedef void (^AiVMUploadAudioCompletion)(NSString *_Nullable fileURL, typedef void (^AiVMUploadAudioCompletion)(NSString *_Nullable fileURL,
NSError *_Nullable error); 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 @interface AiVM : NSObject
- (void)syncChatWithTranscript:(NSString *)transcript - (void)syncChatWithTranscript:(NSString *)transcript
@@ -64,6 +81,10 @@ typedef void (^AiVMUploadAudioCompletion)(NSString *_Nullable fileURL,
- (void)uploadAudioFileAtURL:(NSURL *)fileURL - (void)uploadAudioFileAtURL:(NSURL *)fileURL
completion:(AiVMUploadAudioCompletion)completion; completion:(AiVMUploadAudioCompletion)completion;
/// 语音转文字multipart/form-data
- (void)transcribeAudioFileAtURL:(NSURL *)fileURL
completion:(AiVMSpeechTranscribeCompletion)completion;
#pragma mark - 人设相关接口 #pragma mark - 人设相关接口
/// 分页查询人设列表 /// 分页查询人设列表

View File

@@ -46,6 +46,12 @@
@implementation KBAiMessageResponse @implementation KBAiMessageResponse
@end @end
@implementation KBAiSpeechTranscribeData
@end
@implementation KBAiSpeechTranscribeResponse
@end
@implementation AiVM @implementation AiVM
- (void)syncChatWithTranscript:(NSString *)transcript - (void)syncChatWithTranscript:(NSString *)transcript
@@ -126,15 +132,16 @@ autoShowBusinessError:NO
completion:^(NSDictionary *_Nullable json, completion:^(NSDictionary *_Nullable json,
NSURLResponse *_Nullable response, NSURLResponse *_Nullable response,
NSError *_Nullable error) { NSError *_Nullable error) {
KBAiMessageResponse *model =
[KBAiMessageResponse mj_objectWithKeyValues:json];
if (error) { if (error) {
if (completion) { if (completion) {
completion(nil, error); completion(model, error);
} }
return; return;
} }
KBAiMessageResponse *model =
[KBAiMessageResponse mj_objectWithKeyValues:json];
id dataObj = json[@"data"]; id dataObj = json[@"data"];
if (!model.data && [dataObj isKindOfClass:[NSString class]]) { if (!model.data && [dataObj isKindOfClass:[NSString class]]) {
KBAiMessageData *data = [[KBAiMessageData alloc] init]; 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 - #pragma mark -
- (void)fetchPersonasWithPageNum:(NSInteger)pageNum - (void)fetchPersonasWithPageNum:(NSInteger)pageNum