This commit is contained in:
2026-01-22 22:03:56 +08:00
parent edc25c159d
commit 6ad9783bcb
6 changed files with 415 additions and 14 deletions

View File

@@ -9,8 +9,52 @@
NS_ASSUME_NONNULL_BEGIN
@interface KBAiSyncData : NSObject
@property(nonatomic, copy, nullable) NSString *aiResponse;
@property(nonatomic, copy, nullable) NSString *audioBase64;
@property(nonatomic, strong, nullable) NSData *audioData;
@end
@interface KBAiSyncResponse : NSObject
@property(nonatomic, assign) NSInteger code;
@property(nonatomic, strong, nullable) KBAiSyncData *data;
@end
typedef void (^AiVMSyncCompletion)(KBAiSyncResponse *_Nullable response,
NSError *_Nullable error);
@interface KBAiMessageData : NSObject
@property(nonatomic, copy, nullable) NSString *content;
@property(nonatomic, copy, nullable) NSString *text;
@property(nonatomic, copy, nullable) NSString *message;
@end
@interface KBAiMessageResponse : NSObject
@property(nonatomic, assign) NSInteger code;
@property(nonatomic, strong, nullable) KBAiMessageData *data;
@end
typedef void (^AiVMMessageCompletion)(KBAiMessageResponse *_Nullable response,
NSError *_Nullable error);
typedef void (^AiVMElevenLabsCompletion)(NSData *_Nullable audioData,
NSError *_Nullable error);
@interface AiVM : NSObject
- (void)syncChatWithTranscript:(NSString *)transcript
completion:(AiVMSyncCompletion)completion;
- (void)requestChatMessageWithContent:(NSString *)content
completion:(AiVMMessageCompletion)completion;
- (void)requestElevenLabsSpeechWithText:(NSString *)text
voiceId:(NSString *)voiceId
apiKey:(NSString *)apiKey
outputFormat:(nullable NSString *)outputFormat
modelId:(nullable NSString *)modelId
completion:(AiVMElevenLabsCompletion)completion;
@end
NS_ASSUME_NONNULL_END

View File

@@ -6,7 +6,250 @@
//
#import "AiVM.h"
#import "KBAPI.h"
#import "KBNetworkManager.h"
#import <MJExtension/MJExtension.h>
@implementation KBAiSyncData
- (void)setAudioBase64:(NSString *)audioBase64 {
if (![audioBase64 isKindOfClass:[NSString class]]) {
_audioBase64 = nil;
self.audioData = nil;
return;
}
_audioBase64 = [audioBase64 copy];
if (_audioBase64.length == 0) {
self.audioData = nil;
return;
}
NSString *cleanBase64 = _audioBase64;
NSRange commaRange = [cleanBase64 rangeOfString:@","];
if ([cleanBase64 hasPrefix:@"data:"] && commaRange.location != NSNotFound) {
cleanBase64 = [cleanBase64 substringFromIndex:commaRange.location + 1];
}
self.audioData = [[NSData alloc]
initWithBase64EncodedString:cleanBase64
options:NSDataBase64DecodingIgnoreUnknownCharacters];
}
@end
@implementation KBAiSyncResponse
@end
@implementation KBAiMessageData
@end
@implementation KBAiMessageResponse
@end
@implementation AiVM
- (void)syncChatWithTranscript:(NSString *)transcript
completion:(AiVMSyncCompletion)completion {
if (transcript.length == 0) {
NSError *error = [NSError
errorWithDomain:@"AiVM"
code:-1
userInfo:@{NSLocalizedDescriptionKey : @"transcript is empty"}];
if (completion) {
completion(nil, error);
}
return;
}
NSDictionary *params = @{ @"transcript" : transcript ?: @"" };
CFAbsoluteTime startTime = CFAbsoluteTimeGetCurrent();
NSLog(@"[AiVM] /chat/sync request: %@", params);
[[KBNetworkManager shared]
POST:API_AI_CHAT_SYNC
jsonBody:params
headers:nil
autoShowBusinessError:NO
completion:^(NSDictionary *_Nullable json,
NSURLResponse *_Nullable response,
NSError *_Nullable error) {
CFAbsoluteTime elapsed =
(CFAbsoluteTimeGetCurrent() - startTime) * 1000.0;
if (error) {
NSLog(@"[AiVM] /chat/sync failed: %@",
error.localizedDescription ?: @"");
NSLog(@"[AiVM] /chat/sync duration: %.0f ms", elapsed);
if (completion) {
completion(nil, error);
}
return;
}
NSLog(@"[AiVM] /chat/sync response received");
NSLog(@"[AiVM] /chat/sync duration: %.0f ms", elapsed);
KBAiSyncResponse *model =
[KBAiSyncResponse mj_objectWithKeyValues:json];
if (completion) {
completion(model, nil);
}
}];
}
- (void)requestChatMessageWithContent:(NSString *)content
completion:(AiVMMessageCompletion)completion {
if (content.length == 0) {
NSError *error = [NSError
errorWithDomain:@"AiVM"
code:-1
userInfo:@{NSLocalizedDescriptionKey : @"content is empty"}];
if (completion) {
completion(nil, error);
}
return;
}
NSString *encodedContent =
[content stringByAddingPercentEncodingWithAllowedCharacters:
[NSCharacterSet URLQueryAllowedCharacterSet]];
NSString *path = [NSString
stringWithFormat:@"%@?content=%@", API_AI_CHAT_MESSAGE,
encodedContent ?: @""];
NSDictionary *params = @{ @"content" : content ?: @"" };
[[KBNetworkManager shared]
POST:path
jsonBody:params
headers:nil
autoShowBusinessError:NO
completion:^(NSDictionary *_Nullable json,
NSURLResponse *_Nullable response,
NSError *_Nullable error) {
if (error) {
if (completion) {
completion(nil, error);
}
return;
}
KBAiMessageResponse *model =
[KBAiMessageResponse mj_objectWithKeyValues:json];
if (completion) {
completion(model, nil);
}
}];
}
- (void)requestElevenLabsSpeechWithText:(NSString *)text
voiceId:(NSString *)voiceId
apiKey:(NSString *)apiKey
outputFormat:(NSString *)outputFormat
modelId:(NSString *)modelId
completion:(AiVMElevenLabsCompletion)completion {
if (text.length == 0 || voiceId.length == 0 || apiKey.length == 0) {
NSError *error = [NSError
errorWithDomain:@"AiVM"
code:-1
userInfo:@{NSLocalizedDescriptionKey : @"invalid parameters"}];
if (completion) {
completion(nil, error);
}
return;
}
NSString *format = outputFormat.length > 0 ? outputFormat : @"mp3_44100_128";
NSString *model = modelId.length > 0 ? modelId : @"eleven_multilingual_v2";
NSString *escapedVoiceId =
[voiceId stringByAddingPercentEncodingWithAllowedCharacters:
[NSCharacterSet URLPathAllowedCharacterSet]];
NSString *escapedFormat =
[format stringByAddingPercentEncodingWithAllowedCharacters:
[NSCharacterSet URLQueryAllowedCharacterSet]];
NSString *urlString =
[NSString stringWithFormat:@"https://api.elevenlabs.io/v1/text-to-speech/%@/stream?output_format=%@",
escapedVoiceId ?: @"",
escapedFormat ?: @""];
NSURL *url = [NSURL URLWithString:urlString];
if (!url) {
NSError *error = [NSError
errorWithDomain:@"AiVM"
code:-1
userInfo:@{NSLocalizedDescriptionKey : @"invalid URL"}];
if (completion) {
completion(nil, error);
}
return;
}
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
request.HTTPMethod = @"POST";
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
[request setValue:@"audio/mpeg" forHTTPHeaderField:@"Accept"];
[request setValue:apiKey forHTTPHeaderField:@"xi-api-key"];
NSDictionary *body = @{
@"text" : text ?: @"",
@"model_id" : model ?: @""
};
NSError *jsonError = nil;
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:body
options:0
error:&jsonError];
if (jsonError) {
if (completion) {
completion(nil, jsonError);
}
return;
}
request.HTTPBody = jsonData;
NSURLSessionConfiguration *config =
[NSURLSessionConfiguration defaultSessionConfiguration];
NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
NSURLSessionDataTask *task =
[session dataTaskWithRequest:request
completionHandler:^(NSData *_Nullable data,
NSURLResponse *_Nullable response,
NSError *_Nullable error) {
if (error) {
if (completion) {
completion(nil, error);
}
return;
}
if (![response isKindOfClass:[NSHTTPURLResponse class]]) {
NSError *respError = [NSError
errorWithDomain:@"AiVM"
code:-1
userInfo:@{
NSLocalizedDescriptionKey :
@"invalid response"
}];
if (completion) {
completion(nil, respError);
}
return;
}
NSInteger status =
((NSHTTPURLResponse *)response).statusCode;
if (status < 200 || status >= 300 || data.length == 0) {
NSError *respError = [NSError
errorWithDomain:@"AiVM"
code:status
userInfo:@{
NSLocalizedDescriptionKey :
@"request failed"
}];
if (completion) {
completion(nil, respError);
}
return;
}
if (completion) {
completion(data, nil);
}
}];
[task resume];
}
@end

View File

@@ -178,6 +178,9 @@ static NSString *const kDeepgramStreamingManagerErrorDomain =
}
[self.pendingFrames removeAllObjects];
self.pendingStart = NO;
if (self.client.isConnected) {
[self.client finish];
}
[self.client disableAudioSending];
[self startKeepAliveIfNeeded];
});