简单封装
This commit is contained in:
95
CustomKeyboard/VM/KBVM.h
Normal file
95
CustomKeyboard/VM/KBVM.h
Normal file
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// KBVM.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// 键盘扩展的 ViewModel,封装网络请求逻辑
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 聊天响应模型
|
||||
@interface KBChatResponse : NSObject
|
||||
@property (nonatomic, copy, nullable) NSString *text;
|
||||
@property (nonatomic, copy, nullable) NSString *audioId;
|
||||
@property (nonatomic, copy, nullable) NSString *errorMessage;
|
||||
@property (nonatomic, assign) BOOL success;
|
||||
@end
|
||||
|
||||
/// 音频响应模型
|
||||
@interface KBAudioResponse : NSObject
|
||||
@property (nonatomic, copy, nullable) NSString *audioURL;
|
||||
@property (nonatomic, strong, nullable) NSData *audioData;
|
||||
@property (nonatomic, assign) NSTimeInterval duration;
|
||||
@property (nonatomic, copy, nullable) NSString *errorMessage;
|
||||
@property (nonatomic, assign) BOOL success;
|
||||
@end
|
||||
|
||||
/// 聊天请求回调
|
||||
typedef void(^KBChatCompletion)(KBChatResponse *response);
|
||||
/// 音频 URL 回调
|
||||
typedef void(^KBAudioURLCompletion)(KBAudioResponse *response);
|
||||
/// 音频数据回调
|
||||
typedef void(^KBAudioDataCompletion)(KBAudioResponse *response);
|
||||
/// 头像回调
|
||||
typedef void(^KBAvatarCompletion)(UIImage * _Nullable image, NSError * _Nullable error);
|
||||
|
||||
@interface KBVM : NSObject
|
||||
|
||||
+ (instancetype)shared;
|
||||
|
||||
#pragma mark - Chat API
|
||||
|
||||
/// 发送聊天消息
|
||||
/// @param content 消息内容
|
||||
/// @param companionId 人设 ID
|
||||
/// @param completion 回调
|
||||
- (void)sendChatMessageWithContent:(NSString *)content
|
||||
companionId:(NSInteger)companionId
|
||||
completion:(KBChatCompletion)completion;
|
||||
|
||||
#pragma mark - Audio API
|
||||
|
||||
/// 获取音频 URL(单次请求)
|
||||
/// @param audioId 音频 ID
|
||||
/// @param completion 回调
|
||||
- (void)fetchAudioURLWithAudioId:(NSString *)audioId
|
||||
completion:(KBAudioURLCompletion)completion;
|
||||
|
||||
/// 轮询获取音频 URL(自动重试)
|
||||
/// @param audioId 音频 ID
|
||||
/// @param maxRetries 最大重试次数
|
||||
/// @param interval 重试间隔(秒)
|
||||
/// @param completion 回调
|
||||
- (void)pollAudioURLWithAudioId:(NSString *)audioId
|
||||
maxRetries:(NSInteger)maxRetries
|
||||
interval:(NSTimeInterval)interval
|
||||
completion:(KBAudioURLCompletion)completion;
|
||||
|
||||
/// 下载音频数据
|
||||
/// @param urlString 音频 URL
|
||||
/// @param completion 回调
|
||||
- (void)downloadAudioFromURL:(NSString *)urlString
|
||||
completion:(KBAudioDataCompletion)completion;
|
||||
|
||||
#pragma mark - Avatar API
|
||||
|
||||
/// 下载头像图片
|
||||
/// @param urlString 头像 URL
|
||||
/// @param completion 回调
|
||||
- (void)downloadAvatarFromURL:(NSString *)urlString
|
||||
completion:(KBAvatarCompletion)completion;
|
||||
|
||||
#pragma mark - Helper
|
||||
|
||||
/// 从 AppGroup 获取选中的 persona companionId
|
||||
- (NSInteger)selectedCompanionIdFromAppGroup;
|
||||
|
||||
/// 从 AppGroup 获取选中的 persona 信息
|
||||
- (nullable NSDictionary *)selectedPersonaFromAppGroup;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
334
CustomKeyboard/VM/KBVM.m
Normal file
334
CustomKeyboard/VM/KBVM.m
Normal file
@@ -0,0 +1,334 @@
|
||||
//
|
||||
// KBVM.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBVM.h"
|
||||
#import "KBNetworkManager.h"
|
||||
#import "KBConfig.h"
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
|
||||
@implementation KBChatResponse
|
||||
@end
|
||||
|
||||
@implementation KBAudioResponse
|
||||
@end
|
||||
|
||||
@interface KBVM ()
|
||||
@property (nonatomic, strong) NSCache<NSString *, UIImage *> *avatarCache;
|
||||
@end
|
||||
|
||||
@implementation KBVM
|
||||
|
||||
+ (instancetype)shared {
|
||||
static KBVM *instance = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
instance = [[KBVM alloc] init];
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
|
||||
- (instancetype)init {
|
||||
if (self = [super init]) {
|
||||
_avatarCache = [[NSCache alloc] init];
|
||||
_avatarCache.countLimit = 20;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
#pragma mark - Chat API
|
||||
|
||||
- (void)sendChatMessageWithContent:(NSString *)content
|
||||
companionId:(NSInteger)companionId
|
||||
completion:(KBChatCompletion)completion {
|
||||
if (content.length == 0) {
|
||||
if (completion) {
|
||||
KBChatResponse *response = [[KBChatResponse alloc] init];
|
||||
response.success = NO;
|
||||
response.errorMessage = @"内容为空";
|
||||
completion(response);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *encodedContent = [content stringByAddingPercentEncodingWithAllowedCharacters:
|
||||
[NSCharacterSet URLQueryAllowedCharacterSet]];
|
||||
NSString *path = [NSString stringWithFormat:@"%@?content=%@&companionId=%ld",
|
||||
API_AI_CHAT_MESSAGE, encodedContent ?: @"", (long)companionId];
|
||||
NSDictionary *params = @{
|
||||
@"content": content ?: @"",
|
||||
@"companionId": @(companionId)
|
||||
};
|
||||
|
||||
[[KBNetworkManager shared] POST:path
|
||||
jsonBody:params
|
||||
headers:nil
|
||||
completion:^(NSDictionary *json, NSURLResponse *response, NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
KBChatResponse *chatResponse = [[KBChatResponse alloc] init];
|
||||
|
||||
if (error) {
|
||||
chatResponse.success = NO;
|
||||
chatResponse.errorMessage = error.localizedDescription ?: @"请求失败";
|
||||
if (completion) completion(chatResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析文本
|
||||
chatResponse.text = [self p_parseTextFromJSON:json];
|
||||
// 解析 audioId
|
||||
chatResponse.audioId = [self p_parseAudioIdFromJSON:json];
|
||||
|
||||
chatResponse.success = (chatResponse.text.length > 0);
|
||||
if (!chatResponse.success) {
|
||||
chatResponse.errorMessage = @"未获取到回复内容";
|
||||
}
|
||||
|
||||
if (completion) completion(chatResponse);
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Audio API
|
||||
|
||||
- (void)fetchAudioURLWithAudioId:(NSString *)audioId
|
||||
completion:(KBAudioURLCompletion)completion {
|
||||
if (audioId.length == 0) {
|
||||
if (completion) {
|
||||
KBAudioResponse *response = [[KBAudioResponse alloc] init];
|
||||
response.success = NO;
|
||||
response.errorMessage = @"audioId 为空";
|
||||
completion(response);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *path = [NSString stringWithFormat:@"/chat/audio/%@", audioId];
|
||||
|
||||
[[KBNetworkManager shared] GET:path
|
||||
parameters:nil
|
||||
headers:nil
|
||||
completion:^(NSDictionary *json, NSURLResponse *response, NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
KBAudioResponse *audioResponse = [[KBAudioResponse alloc] init];
|
||||
|
||||
if (error) {
|
||||
audioResponse.success = NO;
|
||||
audioResponse.errorMessage = error.localizedDescription;
|
||||
if (completion) completion(audioResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析 audioURL
|
||||
NSString *audioURL = [self p_parseAudioURLFromJSON:json];
|
||||
audioResponse.audioURL = audioURL;
|
||||
audioResponse.success = (audioURL.length > 0);
|
||||
|
||||
if (completion) completion(audioResponse);
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)pollAudioURLWithAudioId:(NSString *)audioId
|
||||
maxRetries:(NSInteger)maxRetries
|
||||
interval:(NSTimeInterval)interval
|
||||
completion:(KBAudioURLCompletion)completion {
|
||||
[self p_pollAudioURLWithAudioId:audioId
|
||||
retryCount:0
|
||||
maxRetries:maxRetries
|
||||
interval:interval
|
||||
completion:completion];
|
||||
}
|
||||
|
||||
- (void)p_pollAudioURLWithAudioId:(NSString *)audioId
|
||||
retryCount:(NSInteger)retryCount
|
||||
maxRetries:(NSInteger)maxRetries
|
||||
interval:(NSTimeInterval)interval
|
||||
completion:(KBAudioURLCompletion)completion {
|
||||
|
||||
[self fetchAudioURLWithAudioId:audioId completion:^(KBAudioResponse *response) {
|
||||
if (response.success && response.audioURL.length > 0) {
|
||||
// 成功获取到 URL
|
||||
if (completion) completion(response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果还没达到最大重试次数,继续轮询
|
||||
if (retryCount < maxRetries - 1) {
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(interval * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
[self p_pollAudioURLWithAudioId:audioId
|
||||
retryCount:retryCount + 1
|
||||
maxRetries:maxRetries
|
||||
interval:interval
|
||||
completion:completion];
|
||||
});
|
||||
} else {
|
||||
// 达到最大重试次数
|
||||
KBAudioResponse *failResponse = [[KBAudioResponse alloc] init];
|
||||
failResponse.success = NO;
|
||||
failResponse.errorMessage = [NSString stringWithFormat:@"轮询失败,已重试 %ld 次", (long)maxRetries];
|
||||
if (completion) completion(failResponse);
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)downloadAudioFromURL:(NSString *)urlString
|
||||
completion:(KBAudioDataCompletion)completion {
|
||||
if (urlString.length == 0) {
|
||||
if (completion) {
|
||||
KBAudioResponse *response = [[KBAudioResponse alloc] init];
|
||||
response.success = NO;
|
||||
response.errorMessage = @"URL 为空";
|
||||
completion(response);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
[[KBNetworkManager shared] GETData:urlString
|
||||
parameters:nil
|
||||
headers:nil
|
||||
completion:^(NSData *data, NSURLResponse *response, NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
KBAudioResponse *audioResponse = [[KBAudioResponse alloc] init];
|
||||
|
||||
if (error || !data || data.length == 0) {
|
||||
audioResponse.success = NO;
|
||||
audioResponse.errorMessage = error.localizedDescription ?: @"下载失败";
|
||||
if (completion) completion(audioResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
audioResponse.audioData = data;
|
||||
|
||||
// 计算音频时长
|
||||
NSError *playerError = nil;
|
||||
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:data error:&playerError];
|
||||
if (!playerError && player) {
|
||||
audioResponse.duration = player.duration;
|
||||
}
|
||||
|
||||
audioResponse.success = YES;
|
||||
if (completion) completion(audioResponse);
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Avatar API
|
||||
|
||||
- (void)downloadAvatarFromURL:(NSString *)urlString
|
||||
completion:(KBAvatarCompletion)completion {
|
||||
if (urlString.length == 0) {
|
||||
if (completion) completion(nil, nil);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
UIImage *cached = [self.avatarCache objectForKey:urlString];
|
||||
if (cached) {
|
||||
if (completion) completion(cached, nil);
|
||||
return;
|
||||
}
|
||||
|
||||
[[KBNetworkManager shared] GETData:urlString
|
||||
parameters:nil
|
||||
headers:nil
|
||||
completion:^(NSData *data, NSURLResponse *response, NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (error || data.length == 0) {
|
||||
if (completion) completion(nil, error);
|
||||
return;
|
||||
}
|
||||
|
||||
UIImage *image = [UIImage imageWithData:data];
|
||||
if (image) {
|
||||
[self.avatarCache setObject:image forKey:urlString];
|
||||
}
|
||||
if (completion) completion(image, nil);
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Helper
|
||||
|
||||
- (NSInteger)selectedCompanionIdFromAppGroup {
|
||||
NSDictionary *persona = [self selectedPersonaFromAppGroup];
|
||||
if (persona) {
|
||||
id companionIdObj = persona[@"personaId"] ?: persona[@"companionId"] ?: persona[@"id"];
|
||||
if ([companionIdObj respondsToSelector:@selector(integerValue)]) {
|
||||
return [companionIdObj integerValue];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
- (nullable NSDictionary *)selectedPersonaFromAppGroup {
|
||||
NSUserDefaults *shared = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||
return [shared objectForKey:@"AppGroup_SelectedPersona"];
|
||||
}
|
||||
|
||||
#pragma mark - Private Parse Methods
|
||||
|
||||
/// 解析聊天文本
|
||||
- (NSString *)p_parseTextFromJSON:(NSDictionary *)json {
|
||||
if (![json isKindOfClass:[NSDictionary class]]) return @"";
|
||||
|
||||
id dataObj = json[@"data"];
|
||||
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||||
NSDictionary *data = (NSDictionary *)dataObj;
|
||||
// 优先读取 aiResponse 字段
|
||||
NSArray *keys = @[@"aiResponse", @"content", @"text", @"message"];
|
||||
for (NSString *key in keys) {
|
||||
id value = data[key];
|
||||
if ([value isKindOfClass:[NSString class]] && ((NSString *)value).length > 0) {
|
||||
return (NSString *)value;
|
||||
}
|
||||
}
|
||||
} else if ([dataObj isKindOfClass:[NSString class]]) {
|
||||
return (NSString *)dataObj;
|
||||
}
|
||||
|
||||
return @"";
|
||||
}
|
||||
|
||||
/// 解析 audioId
|
||||
- (NSString *)p_parseAudioIdFromJSON:(NSDictionary *)json {
|
||||
if (![json isKindOfClass:[NSDictionary class]]) return nil;
|
||||
|
||||
id dataObj = json[@"data"];
|
||||
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||||
NSDictionary *data = (NSDictionary *)dataObj;
|
||||
NSString *audioId = data[@"audioId"];
|
||||
if ([audioId isKindOfClass:[NSString class]] && audioId.length > 0) {
|
||||
return audioId;
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容其他字段名
|
||||
NSArray *keys = @[@"audioId", @"audio_id"];
|
||||
for (NSString *key in keys) {
|
||||
id value = json[key];
|
||||
if ([value isKindOfClass:[NSString class]] && ((NSString *)value).length > 0) {
|
||||
return (NSString *)value;
|
||||
}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
/// 解析 audioURL
|
||||
- (NSString *)p_parseAudioURLFromJSON:(NSDictionary *)json {
|
||||
if (![json isKindOfClass:[NSDictionary class]]) return nil;
|
||||
|
||||
id dataObj = json[@"data"];
|
||||
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||||
NSDictionary *data = (NSDictionary *)dataObj;
|
||||
id audioUrlObj = data[@"audioUrl"] ?: data[@"url"];
|
||||
if (audioUrlObj && ![audioUrlObj isKindOfClass:[NSNull class]] && [audioUrlObj isKindOfClass:[NSString class]]) {
|
||||
return (NSString *)audioUrlObj;
|
||||
}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
@end
|
||||
Reference in New Issue
Block a user