Compare commits
38 Commits
750b391100
...
dev_st_重构2
| Author | SHA1 | Date | |
|---|---|---|---|
| 47291934a2 | |||
| e619f48f93 | |||
| f55a70681c | |||
| cb86f7c32c | |||
| 40ef964b8c | |||
| 4269fde923 | |||
| c3e037e070 | |||
| a711be4c4d | |||
| 69bd2b2af9 | |||
| 82222afd76 | |||
| 92ca5c6180 | |||
| 851c0d9531 | |||
| 1c9013bede | |||
| 0a16a4f240 | |||
| 27d4b2b817 | |||
| bc623676ca | |||
| 5edf1751ff | |||
| 0ac47925fd | |||
| 635ad932c7 | |||
| cbe0a53cac | |||
| 5c273c3963 | |||
| c9743cb363 | |||
| f0cb69948e | |||
| 0144f9cc6d | |||
| ae4070ae88 | |||
| a83fd918a8 | |||
| 4168da618e | |||
| d2ffada83f | |||
| 76d387e08b | |||
| ea0df4fb19 | |||
| 02323fb5f1 | |||
| 3c71797b7b | |||
| 4c57f16058 | |||
| cb2e8467a7 | |||
| 4dfd6f5cbb | |||
| e4223b3a4c | |||
| 3d19403539 | |||
| 3cb02d5b76 |
13
.claude/settings.local.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebSearch",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(xcodebuild:*)",
|
||||
"Bash(plutil:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(wc:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
//
|
||||
// KeyboardViewController+Chat.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Codex on 2026/02/22.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController+Private.h"
|
||||
|
||||
#import "KBChatLimitPopView.h"
|
||||
#import "KBChatMessage.h"
|
||||
#import "KBChatPanelView.h"
|
||||
#import "KBFullAccessManager.h"
|
||||
#import "KBHostAppLauncher.h"
|
||||
#import "KBInputBufferManager.h"
|
||||
#import "KBNetworkManager.h"
|
||||
#import "KBVM.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
|
||||
static const NSUInteger kKBChatMessageLimit = 6;
|
||||
|
||||
@implementation KeyboardViewController (Chat)
|
||||
|
||||
#pragma mark - KBChatPanelViewDelegate
|
||||
|
||||
- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text {
|
||||
NSString *trim =
|
||||
[text stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
if (trim.length == 0) {
|
||||
return;
|
||||
}
|
||||
[self kb_sendChatText:trim];
|
||||
}
|
||||
|
||||
- (void)chatPanelView:(KBChatPanelView *)view
|
||||
didTapMessage:(KBChatMessage *)message {
|
||||
if (message.audioFilePath.length == 0) {
|
||||
return;
|
||||
}
|
||||
[self kb_playChatAudioAtPath:message.audioFilePath];
|
||||
}
|
||||
|
||||
- (void)chatPanelView:(KBChatPanelView *)view
|
||||
didTapVoiceButtonForMessage:(KBChatMessage *)message {
|
||||
if (!message)
|
||||
return;
|
||||
|
||||
// 如果有 audioData,直接播放
|
||||
if (message.audioData && message.audioData.length > 0) {
|
||||
[self kb_playChatAudioData:message.audioData];
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果有 audioFilePath,播放文件
|
||||
if (message.audioFilePath.length > 0) {
|
||||
[self kb_playChatAudioAtPath:message.audioFilePath];
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"[Keyboard] 没有音频数据可播放");
|
||||
}
|
||||
|
||||
- (void)chatPanelViewDidTapClose:(KBChatPanelView *)view {
|
||||
// 清空 chatPanelView 内部的消息
|
||||
[view kb_reloadWithMessages:@[]];
|
||||
if (self.chatAudioPlayer.isPlaying) {
|
||||
[self.chatAudioPlayer stop];
|
||||
}
|
||||
self.chatAudioPlayer = nil;
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
|
||||
}
|
||||
|
||||
#pragma mark - Chat Helpers
|
||||
|
||||
- (void)kb_handleChatSendAction {
|
||||
if (!self.chatPanelVisible) {
|
||||
return;
|
||||
}
|
||||
[[KBInputBufferManager shared]
|
||||
refreshFromProxyIfPossible:self.textDocumentProxy];
|
||||
NSString *fullText = [KBInputBufferManager shared].liveText ?: @"";
|
||||
|
||||
// 去掉打开聊天面板前宿主输入框里已有的基线文本,只取新增部分
|
||||
NSString *baseline = self.chatPanelBaselineText ?: @"";
|
||||
NSString *rawText = fullText;
|
||||
if (baseline.length > 0 && [fullText hasPrefix:baseline]) {
|
||||
rawText = [fullText substringFromIndex:baseline.length];
|
||||
}
|
||||
|
||||
NSString *trim =
|
||||
[rawText stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
NSString *textToClear = rawText;
|
||||
if (trim.length == 0) {
|
||||
// 兼容「先输入再打开聊天面板」场景:
|
||||
// 此时新增文本为空,但当前输入框已有可发送内容,应该允许直接发送。
|
||||
NSString *fullTrim =
|
||||
[fullText stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
if (fullTrim.length > 0) {
|
||||
trim = fullTrim;
|
||||
textToClear = fullText;
|
||||
}
|
||||
}
|
||||
if (trim.length == 0) {
|
||||
[KBHUD showInfo:KBLocalized(@"请输入内容")];
|
||||
return;
|
||||
}
|
||||
[self kb_sendChatText:trim];
|
||||
// 默认只清新增文本;若命中兜底则清当前全文,避免“已发送但输入框残留”。
|
||||
[self kb_clearHostInputForText:textToClear];
|
||||
}
|
||||
|
||||
- (void)kb_sendChatText:(NSString *)text {
|
||||
if (text.length == 0) {
|
||||
return;
|
||||
}
|
||||
NSLog(@"[KB] 发送消息: %@", text);
|
||||
|
||||
KBChatMessage *outgoing = [KBChatMessage userMessageWithText:text];
|
||||
outgoing.avatarURL = [self kb_sharedUserAvatarURL];
|
||||
[self.chatPanelView kb_addUserMessage:text];
|
||||
[self kb_prefetchAvatarForMessage:outgoing];
|
||||
|
||||
if (![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) {
|
||||
[KBHUD showInfo:KBLocalized(@"请开启完全访问后使用")];
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加 loading 消息
|
||||
[self.chatPanelView kb_addLoadingAssistantMessage];
|
||||
|
||||
// 调用新的聊天接口
|
||||
[self kb_requestChatMessageWithContent:text];
|
||||
}
|
||||
|
||||
#pragma mark - Chat Limit Pop
|
||||
|
||||
- (void)kb_showChatLimitPopWithMessage:(NSString *)message {
|
||||
[self kb_dismissChatLimitPop];
|
||||
|
||||
UIControl *mask = [[UIControl alloc] init];
|
||||
mask.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.4];
|
||||
mask.alpha = 0.0;
|
||||
[mask addTarget:self
|
||||
action:@selector(kb_dismissChatLimitPop)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.contentView addSubview:mask];
|
||||
[mask mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.contentView);
|
||||
}];
|
||||
|
||||
CGFloat width = 252.0;
|
||||
CGFloat height = 252.0 + 18.0 + 53.0 + 18.0 + 28.0;
|
||||
KBChatLimitPopView *content =
|
||||
[[KBChatLimitPopView alloc] initWithFrame:CGRectMake(0, 0, width, height)];
|
||||
content.message = message ?: @"";
|
||||
content.delegate = self;
|
||||
[mask addSubview:content];
|
||||
[content mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(mask);
|
||||
make.width.mas_equalTo(width);
|
||||
make.height.mas_equalTo(height);
|
||||
}];
|
||||
|
||||
self.chatLimitMaskView = mask;
|
||||
[self.contentView bringSubviewToFront:mask];
|
||||
[UIView animateWithDuration:0.18
|
||||
animations:^{
|
||||
mask.alpha = 1.0;
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_dismissChatLimitPop {
|
||||
if (!self.chatLimitMaskView) {
|
||||
return;
|
||||
}
|
||||
UIControl *mask = self.chatLimitMaskView;
|
||||
self.chatLimitMaskView = nil;
|
||||
[UIView animateWithDuration:0.15
|
||||
animations:^{
|
||||
mask.alpha = 0.0;
|
||||
}
|
||||
completion:^(__unused BOOL finished) {
|
||||
[mask removeFromSuperview];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_clearHostInputForText:(NSString *)text {
|
||||
if (text.length == 0) {
|
||||
return;
|
||||
}
|
||||
NSUInteger count = [self kb_composedCharacterCountForString:text];
|
||||
for (NSUInteger i = 0; i < count; i++) {
|
||||
[self.textDocumentProxy deleteBackward];
|
||||
}
|
||||
[[KBInputBufferManager shared] clearAllLiveText];
|
||||
[self kb_clearCurrentWord];
|
||||
}
|
||||
|
||||
- (NSUInteger)kb_composedCharacterCountForString:(NSString *)text {
|
||||
if (text.length == 0) {
|
||||
return 0;
|
||||
}
|
||||
__block NSUInteger count = 0;
|
||||
[text enumerateSubstringsInRange:NSMakeRange(0, text.length)
|
||||
options:NSStringEnumerationByComposedCharacterSequences
|
||||
usingBlock:^(__unused NSString *substring,
|
||||
__unused NSRange substringRange,
|
||||
__unused NSRange enclosingRange,
|
||||
__unused BOOL *stop) {
|
||||
count += 1;
|
||||
}];
|
||||
return count;
|
||||
}
|
||||
|
||||
- (NSString *)kb_sharedUserAvatarURL {
|
||||
NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||
NSString *url = [ud stringForKey:AppGroup_UserAvatarURL];
|
||||
return url ?: @"";
|
||||
}
|
||||
|
||||
- (void)kb_prefetchAvatarForMessage:(KBChatMessage *)message {
|
||||
if (!message || message.avatarImage) {
|
||||
return;
|
||||
}
|
||||
NSString *urlString = message.avatarURL ?: @"";
|
||||
if (urlString.length == 0) {
|
||||
return;
|
||||
}
|
||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||
return;
|
||||
}
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[[KBVM shared] downloadAvatarFromURL:urlString
|
||||
completion:^(UIImage *image, NSError *error) {
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self || !image)
|
||||
return;
|
||||
|
||||
message.avatarImage = image;
|
||||
[self kb_reloadChatRowForMessage:message];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_reloadChatRowForMessage:(KBChatMessage *)message {
|
||||
// 头像预加载完成后不需要刷新表格
|
||||
// 因为键盘扩展的聊天面板不显示头像,所以这里直接返回
|
||||
// 如果将来需要显示头像,可以只刷新特定行而不是整个表格
|
||||
}
|
||||
|
||||
- (void)kb_requestChatAudioForText:(NSString *)text {
|
||||
NSString *mockPath = [self kb_mockChatAudioPath];
|
||||
if (mockPath.length > 0) {
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.35 * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
NSString *displayText = KBLocalized(@"语音回复");
|
||||
KBChatMessage *incoming =
|
||||
[KBChatMessage messageWithText:displayText
|
||||
outgoing:NO
|
||||
audioFilePath:mockPath];
|
||||
incoming.displayName = KBLocalized(@"AI助手");
|
||||
[self kb_appendChatMessage:incoming];
|
||||
[self kb_playChatAudioAtPath:mockPath];
|
||||
});
|
||||
return;
|
||||
}
|
||||
NSDictionary *payload = @{@"message" : text ?: @""};
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[[KBNetworkManager shared] POST:API_AI_TALK
|
||||
jsonBody:payload
|
||||
headers:nil
|
||||
completion:^(NSDictionary *json, NSURLResponse *response,
|
||||
NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
NSString *tip = error.localizedDescription
|
||||
?: KBLocalized(@"请求失败");
|
||||
[KBHUD showInfo:tip];
|
||||
return;
|
||||
}
|
||||
NSString *displayText =
|
||||
[self kb_chatTextFromJSON:json];
|
||||
NSString *audioURL =
|
||||
[self kb_chatAudioURLFromJSON:json];
|
||||
NSString *audioBase64 =
|
||||
[self kb_chatAudioBase64FromJSON:json];
|
||||
if (audioURL.length > 0) {
|
||||
[self kb_downloadChatAudioFromURL:audioURL
|
||||
displayText:displayText];
|
||||
return;
|
||||
}
|
||||
if (audioBase64.length > 0) {
|
||||
NSData *data = [[NSData alloc]
|
||||
initWithBase64EncodedString:audioBase64
|
||||
options:0];
|
||||
if (data.length == 0) {
|
||||
[KBHUD showInfo:KBLocalized(@"音频数据解析失败")];
|
||||
return;
|
||||
}
|
||||
[self kb_handleChatAudioData:data
|
||||
fileExtension:@"m4a"
|
||||
displayText:displayText];
|
||||
return;
|
||||
}
|
||||
[KBHUD showInfo:KBLocalized(@"未获取到音频文件")];
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - New Chat API (with typewriter effect and audio preload)
|
||||
|
||||
/// 调用新的聊天接口(返回文本和 audioId)
|
||||
- (void)kb_requestChatMessageWithContent:(NSString *)content {
|
||||
if (content.length == 0) {
|
||||
[self.chatPanelView kb_removeLoadingAssistantMessage];
|
||||
return;
|
||||
}
|
||||
|
||||
NSInteger companionId = [[KBVM shared] selectedCompanionIdFromAppGroup];
|
||||
NSLog(@"[KB] 请求聊天: companionId=%ld", (long)companionId);
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[[KBVM shared] sendChatMessageWithContent:content
|
||||
companionId:companionId
|
||||
completion:^(KBChatResponse *response) {
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self)
|
||||
return;
|
||||
|
||||
if (response.code != 0) {
|
||||
if (response.code == 50030) {
|
||||
NSLog(@"[KB] ⚠️ 次数用尽: %@",
|
||||
response.message);
|
||||
[self.chatPanelView
|
||||
kb_removeLoadingAssistantMessage];
|
||||
[self kb_showChatLimitPopWithMessage:
|
||||
response.message];
|
||||
return;
|
||||
}
|
||||
NSLog(@"[KB] ❌ 请求失败: %@",
|
||||
response.message);
|
||||
[self.chatPanelView
|
||||
kb_removeLoadingAssistantMessage];
|
||||
[KBHUD showInfo:response.message
|
||||
?: KBLocalized(@"请求失败")];
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"[KB] ✅ 收到回复: %@",
|
||||
response.data.aiResponse);
|
||||
|
||||
if (response.data.aiResponse.length == 0) {
|
||||
[self.chatPanelView
|
||||
kb_removeLoadingAssistantMessage];
|
||||
[KBHUD showInfo:KBLocalized(@"未获取到回复内容")];
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加 AI 消息(带打字机效果)
|
||||
NSLog(@"[KB] 准备添加 AI 消息");
|
||||
[self.chatPanelView
|
||||
kb_addAssistantMessage:response.data.aiResponse
|
||||
audioId:response.data.audioId];
|
||||
NSLog(@"[KB] AI 消息添加完成");
|
||||
|
||||
// 通知主 App 刷新对应 persona 的聊天记录
|
||||
[self kb_notifyMainAppChatUpdatedWithCompanionId:companionId];
|
||||
|
||||
// 如果有 audioId,开始预加载音频
|
||||
if (response.data.audioId.length > 0) {
|
||||
[self kb_preloadAudioWithAudioId:
|
||||
response.data.audioId];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
/// 从 AppGroup 获取选中的 persona companionId
|
||||
- (NSInteger)kb_selectedCompanionId {
|
||||
return [[KBVM shared] selectedCompanionIdFromAppGroup];
|
||||
}
|
||||
|
||||
#pragma mark - Audio Preload
|
||||
|
||||
/// 预加载音频(轮询获取 audioURL)
|
||||
- (void)kb_preloadAudioWithAudioId:(NSString *)audioId {
|
||||
if (audioId.length == 0)
|
||||
return;
|
||||
|
||||
NSLog(@"[Keyboard] 开始预加载音频,audioId: %@", audioId);
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[[KBVM shared] pollAudioURLWithAudioId:audioId
|
||||
maxRetries:10
|
||||
interval:1.0
|
||||
completion:^(KBAudioResponse *response) {
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self)
|
||||
return;
|
||||
|
||||
if (!response.success ||
|
||||
response.audioURL.length == 0) {
|
||||
NSLog(@"[Keyboard] ❌ 预加载音频 URL 获取失败: %@",
|
||||
response.errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"[Keyboard] ✅ 预加载音频 URL 获取成功");
|
||||
|
||||
// 下载音频
|
||||
[[KBVM shared]
|
||||
downloadAudioFromURL:response.audioURL
|
||||
completion:^(
|
||||
KBAudioResponse *audioResponse) {
|
||||
if (!audioResponse.success) {
|
||||
NSLog(@"[Keyboard] ❌ 预加载音频下载失败: %@",
|
||||
audioResponse.errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新最后一条 AI 消息的音频数据
|
||||
[self.chatPanelView
|
||||
kb_updateLastAssistantMessageWithAudioData:
|
||||
audioResponse.audioData
|
||||
duration:
|
||||
audioResponse.duration];
|
||||
NSLog(@"[Keyboard] ✅ 预加载音频完成,音频时长: %.2f秒",
|
||||
audioResponse.duration);
|
||||
}];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_downloadChatAudioFromURL:(NSString *)audioURL
|
||||
displayText:(NSString *)displayText {
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[[KBVM shared] downloadAudioFromURL:audioURL
|
||||
completion:^(KBAudioResponse *response) {
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self)
|
||||
return;
|
||||
|
||||
if (!response.success) {
|
||||
[KBHUD showInfo:response.errorMessage
|
||||
?: KBLocalized(@"下载失败")];
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.audioData ||
|
||||
response.audioData.length == 0) {
|
||||
[KBHUD showInfo:KBLocalized(@"未获取到音频数据")];
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *ext = @"m4a";
|
||||
NSURL *url = [NSURL URLWithString:audioURL];
|
||||
if (url.pathExtension.length > 0) {
|
||||
ext = url.pathExtension;
|
||||
}
|
||||
[self kb_handleChatAudioData:response.audioData
|
||||
fileExtension:ext
|
||||
displayText:displayText];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_handleChatAudioData:(NSData *)data
|
||||
fileExtension:(NSString *)extension
|
||||
displayText:(NSString *)displayText {
|
||||
if (data.length == 0) {
|
||||
[KBHUD showInfo:KBLocalized(@"音频数据为空")];
|
||||
return;
|
||||
}
|
||||
NSString *ext = extension.length > 0 ? extension : @"m4a";
|
||||
NSString *fileName = [NSString
|
||||
stringWithFormat:@"kb_chat_%@.%@",
|
||||
@((long long)([NSDate date].timeIntervalSince1970 *
|
||||
1000)),
|
||||
ext];
|
||||
NSString *filePath =
|
||||
[NSTemporaryDirectory() stringByAppendingPathComponent:fileName];
|
||||
if (![data writeToFile:filePath atomically:YES]) {
|
||||
[KBHUD showInfo:KBLocalized(@"音频保存失败")];
|
||||
return;
|
||||
}
|
||||
NSString *text =
|
||||
displayText.length > 0 ? displayText : KBLocalized(@"语音消息");
|
||||
KBChatMessage *incoming =
|
||||
[KBChatMessage messageWithText:text outgoing:NO audioFilePath:filePath];
|
||||
incoming.displayName = KBLocalized(@"AI助手");
|
||||
[self kb_appendChatMessage:incoming];
|
||||
}
|
||||
|
||||
- (void)kb_appendChatMessage:(KBChatMessage *)message {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
[self.chatMessages addObject:message];
|
||||
if (self.chatMessages.count > kKBChatMessageLimit) {
|
||||
NSUInteger overflow = self.chatMessages.count - kKBChatMessageLimit;
|
||||
NSArray<KBChatMessage *> *removed =
|
||||
[self.chatMessages subarrayWithRange:NSMakeRange(0, overflow)];
|
||||
[self.chatMessages removeObjectsInRange:NSMakeRange(0, overflow)];
|
||||
for (KBChatMessage *msg in removed) {
|
||||
if (msg.audioFilePath.length > 0) {
|
||||
NSString *tmpRoot = NSTemporaryDirectory();
|
||||
if (tmpRoot.length > 0 && [msg.audioFilePath hasPrefix:tmpRoot]) {
|
||||
[[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath
|
||||
error:nil];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[self.chatPanelView kb_reloadWithMessages:self.chatMessages];
|
||||
}
|
||||
|
||||
- (NSString *)kb_mockChatAudioPath {
|
||||
NSString *path = [[NSBundle mainBundle] pathForResource:@"ai_test"
|
||||
ofType:@"m4a"];
|
||||
return path ?: @"";
|
||||
}
|
||||
|
||||
- (NSString *)kb_chatTextFromJSON:(NSDictionary *)json {
|
||||
NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
|
||||
NSString *text =
|
||||
[self kb_stringValueInDict:data keys:@[ @"text", @"message", @"content" ]];
|
||||
if (text.length == 0) {
|
||||
text = [self kb_stringValueInDict:json
|
||||
keys:@[ @"text", @"message", @"content" ]];
|
||||
}
|
||||
return text ?: @"";
|
||||
}
|
||||
|
||||
- (NSString *)kb_chatAudioURLFromJSON:(NSDictionary *)json {
|
||||
NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
|
||||
NSArray<NSString *> *keys =
|
||||
@[ @"audioUrl", @"audioURL", @"audio_url", @"url", @"fileUrl",
|
||||
@"file_url", @"audioFileUrl", @"audio_file_url" ];
|
||||
NSString *url = [self kb_stringValueInDict:data keys:keys];
|
||||
if (url.length == 0) {
|
||||
url = [self kb_stringValueInDict:json keys:keys];
|
||||
}
|
||||
return url ?: @"";
|
||||
}
|
||||
|
||||
- (NSString *)kb_chatAudioBase64FromJSON:(NSDictionary *)json {
|
||||
NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
|
||||
NSArray<NSString *> *keys =
|
||||
@[ @"audioBase64", @"audio_base64", @"audioData", @"audio_data",
|
||||
@"base64" ];
|
||||
NSString *b64 = [self kb_stringValueInDict:data keys:keys];
|
||||
if (b64.length == 0) {
|
||||
b64 = [self kb_stringValueInDict:json keys:keys];
|
||||
}
|
||||
return b64 ?: @"";
|
||||
}
|
||||
|
||||
- (NSDictionary *)kb_chatDataDictionaryFromJSON:(NSDictionary *)json {
|
||||
if (![json isKindOfClass:[NSDictionary class]]) {
|
||||
return @{};
|
||||
}
|
||||
id dataObj = json[@"data"] ?: json[@"result"] ?: json[@"response"];
|
||||
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||||
return (NSDictionary *)dataObj;
|
||||
}
|
||||
return @{};
|
||||
}
|
||||
|
||||
- (NSString *)kb_stringValueInDict:(NSDictionary *)dict
|
||||
keys:(NSArray<NSString *> *)keys {
|
||||
if (![dict isKindOfClass:[NSDictionary class]]) {
|
||||
return @"";
|
||||
}
|
||||
for (NSString *key in keys) {
|
||||
id value = dict[key];
|
||||
if ([value isKindOfClass:[NSString class]] &&
|
||||
((NSString *)value).length > 0) {
|
||||
return (NSString *)value;
|
||||
}
|
||||
}
|
||||
return @"";
|
||||
}
|
||||
|
||||
- (void)kb_playChatAudioAtPath:(NSString *)path {
|
||||
if (path.length == 0) {
|
||||
return;
|
||||
}
|
||||
NSURL *url = [NSURL fileURLWithPath:path];
|
||||
if (![NSFileManager.defaultManager fileExistsAtPath:path]) {
|
||||
[KBHUD showInfo:KBLocalized(@"音频文件不存在")];
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) {
|
||||
NSURL *currentURL = self.chatAudioPlayer.url;
|
||||
if ([currentURL isEqual:url]) {
|
||||
[self.chatAudioPlayer stop];
|
||||
self.chatAudioPlayer = nil;
|
||||
return;
|
||||
}
|
||||
[self.chatAudioPlayer stop];
|
||||
self.chatAudioPlayer = nil;
|
||||
}
|
||||
|
||||
NSError *sessionError = nil;
|
||||
AVAudioSession *session = [AVAudioSession sharedInstance];
|
||||
if ([session respondsToSelector:@selector(setCategory:options:error:)]) {
|
||||
[session setCategory:AVAudioSessionCategoryPlayback
|
||||
withOptions:AVAudioSessionCategoryOptionDuckOthers
|
||||
error:&sessionError];
|
||||
} else {
|
||||
[session setCategory:AVAudioSessionCategoryPlayback error:&sessionError];
|
||||
}
|
||||
[session setActive:YES error:nil];
|
||||
|
||||
NSError *playerError = nil;
|
||||
AVAudioPlayer *player =
|
||||
[[AVAudioPlayer alloc] initWithContentsOfURL:url error:&playerError];
|
||||
if (playerError || !player) {
|
||||
[KBHUD showInfo:KBLocalized(@"音频播放失败")];
|
||||
return;
|
||||
}
|
||||
self.chatAudioPlayer = player;
|
||||
[player prepareToPlay];
|
||||
[player play];
|
||||
}
|
||||
|
||||
/// 播放音频数据
|
||||
- (void)kb_playChatAudioData:(NSData *)audioData {
|
||||
if (!audioData || audioData.length == 0) {
|
||||
NSLog(@"[Keyboard] 音频数据为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果正在播放,先停止
|
||||
if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) {
|
||||
[self.chatAudioPlayer stop];
|
||||
self.chatAudioPlayer = nil;
|
||||
}
|
||||
|
||||
// 配置音频会话
|
||||
NSError *sessionError = nil;
|
||||
AVAudioSession *session = [AVAudioSession sharedInstance];
|
||||
if ([session respondsToSelector:@selector(setCategory:options:error:)]) {
|
||||
[session setCategory:AVAudioSessionCategoryPlayback
|
||||
withOptions:AVAudioSessionCategoryOptionDuckOthers
|
||||
error:&sessionError];
|
||||
} else {
|
||||
[session setCategory:AVAudioSessionCategoryPlayback error:&sessionError];
|
||||
}
|
||||
[session setActive:YES error:nil];
|
||||
|
||||
// 创建播放器
|
||||
NSError *playerError = nil;
|
||||
AVAudioPlayer *player =
|
||||
[[AVAudioPlayer alloc] initWithData:audioData error:&playerError];
|
||||
if (playerError || !player) {
|
||||
NSLog(@"[Keyboard] 音频播放器初始化失败: %@",
|
||||
playerError.localizedDescription);
|
||||
[KBHUD showInfo:KBLocalized(@"音频播放失败")];
|
||||
return;
|
||||
}
|
||||
|
||||
self.chatAudioPlayer = player;
|
||||
player.volume = 1.0;
|
||||
[player prepareToPlay];
|
||||
[player play];
|
||||
|
||||
NSLog(@"[Keyboard] 开始播放音频,时长: %.2f秒", player.duration);
|
||||
}
|
||||
|
||||
#pragma mark - Notify Main App
|
||||
|
||||
/// 通知主 App 刷新对应 persona 的聊天记录
|
||||
- (void)kb_notifyMainAppChatUpdatedWithCompanionId:(NSInteger)companionId {
|
||||
NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||
[ud setInteger:companionId forKey:AppGroup_ChatUpdatedCompanionId];
|
||||
[ud synchronize];
|
||||
|
||||
CFNotificationCenterPostNotification(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
(__bridge CFStringRef)kKBDarwinChatUpdated,
|
||||
NULL, NULL, true);
|
||||
|
||||
NSLog(@"[KB] 已通知主 App 刷新 companionId=%ld 的聊天记录", (long)companionId);
|
||||
}
|
||||
|
||||
#pragma mark - KBChatLimitPopViewDelegate
|
||||
|
||||
- (void)chatLimitPopViewDidTapCancel:(KBChatLimitPopView *)view {
|
||||
[self kb_dismissChatLimitPop];
|
||||
}
|
||||
|
||||
- (void)chatLimitPopViewDidTapRecharge:(KBChatLimitPopView *)view {
|
||||
[self kb_dismissChatLimitPop];
|
||||
NSString *urlString =
|
||||
[NSString stringWithFormat:@"%@://recharge?src=keyboard&vipType=svip",
|
||||
KB_APP_SCHEME];
|
||||
NSURL *scheme = [NSURL URLWithString:urlString];
|
||||
BOOL success = [KBHostAppLauncher openHostAppURL:scheme
|
||||
fromResponder:self.view];
|
||||
if (!success) {
|
||||
[KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")];
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// KeyboardViewController+Layout.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Codex on 2026/02/22.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController+Private.h"
|
||||
|
||||
// 以 375 宽设计稿为基准的键盘总高度
|
||||
static const CGFloat kKBKeyboardBaseHeight = 250.0f;
|
||||
static const CGFloat kKBChatPanelHeight = 180;
|
||||
|
||||
@implementation KeyboardViewController (Layout)
|
||||
|
||||
- (CGFloat)kb_portraitWidth {
|
||||
CGSize s = [UIScreen mainScreen].bounds.size;
|
||||
return MIN(s.width, s.height);
|
||||
}
|
||||
|
||||
- (CGFloat)kb_keyboardHeightForWidth:(CGFloat)width {
|
||||
if (width <= 0) {
|
||||
width = KB_DESIGN_WIDTH;
|
||||
}
|
||||
CGFloat scale = width / KB_DESIGN_WIDTH;
|
||||
CGFloat baseHeight = kKBKeyboardBaseHeight * scale;
|
||||
CGFloat chatHeight = kKBChatPanelHeight * scale;
|
||||
if (self.chatPanelVisible) {
|
||||
return baseHeight + chatHeight;
|
||||
}
|
||||
return baseHeight;
|
||||
}
|
||||
|
||||
- (CGFloat)kb_keyboardBaseHeightForWidth:(CGFloat)width {
|
||||
if (width <= 0) {
|
||||
width = KB_DESIGN_WIDTH;
|
||||
}
|
||||
CGFloat scale = width / KB_DESIGN_WIDTH;
|
||||
return kKBKeyboardBaseHeight * scale;
|
||||
}
|
||||
|
||||
- (CGFloat)kb_chatPanelHeightForWidth:(CGFloat)width {
|
||||
if (width <= 0) {
|
||||
width = KB_DESIGN_WIDTH;
|
||||
}
|
||||
CGFloat scale = width / KB_DESIGN_WIDTH;
|
||||
return kKBChatPanelHeight * scale;
|
||||
}
|
||||
|
||||
- (void)kb_updateKeyboardLayoutIfNeeded {
|
||||
CGFloat portraitWidth = [self kb_portraitWidth];
|
||||
CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth];
|
||||
CGFloat keyboardBaseHeight =
|
||||
[self kb_keyboardBaseHeightForWidth:portraitWidth];
|
||||
CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth];
|
||||
CGFloat containerWidth = CGRectGetWidth(self.view.superview.bounds);
|
||||
if (containerWidth <= 0) {
|
||||
containerWidth = CGRectGetWidth(self.view.window.bounds);
|
||||
}
|
||||
if (containerWidth <= 0) {
|
||||
containerWidth = CGRectGetWidth([UIScreen mainScreen].bounds);
|
||||
}
|
||||
|
||||
BOOL widthChanged = (fabs(self.kb_lastPortraitWidth - portraitWidth) >= 0.5);
|
||||
BOOL heightChanged =
|
||||
(fabs(self.kb_lastKeyboardHeight - keyboardHeight) >= 0.5);
|
||||
if (!widthChanged && !heightChanged && containerWidth > 0 &&
|
||||
self.kb_widthConstraint.constant == containerWidth) {
|
||||
return;
|
||||
}
|
||||
self.kb_lastPortraitWidth = portraitWidth;
|
||||
self.kb_lastKeyboardHeight = keyboardHeight;
|
||||
|
||||
if (self.kb_heightConstraint) {
|
||||
self.kb_heightConstraint.constant = keyboardHeight;
|
||||
}
|
||||
if (containerWidth > 0 && self.kb_widthConstraint) {
|
||||
self.kb_widthConstraint.constant = containerWidth;
|
||||
}
|
||||
if (self.contentWidthConstraint) {
|
||||
[self.contentWidthConstraint setOffset:portraitWidth];
|
||||
}
|
||||
if (self.contentHeightConstraint) {
|
||||
[self.contentHeightConstraint setOffset:keyboardHeight];
|
||||
}
|
||||
if (self.keyBoardMainHeightConstraint) {
|
||||
[self.keyBoardMainHeightConstraint setOffset:keyboardBaseHeight];
|
||||
}
|
||||
if (self.chatPanelHeightConstraint) {
|
||||
[self.chatPanelHeightConstraint setOffset:chatPanelHeight];
|
||||
}
|
||||
[self.view layoutIfNeeded];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
//
|
||||
// KeyboardViewController+Panels.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Codex on 2026/02/22.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController+Private.h"
|
||||
|
||||
#import "KBAuthManager.h"
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
#import "KBChatMessage.h"
|
||||
#import "KBChatPanelView.h"
|
||||
#import "KBFunctionView.h"
|
||||
#import "KBFullAccessManager.h"
|
||||
#import "KBHostAppLauncher.h"
|
||||
#import "KBInputBufferManager.h"
|
||||
#import "KBKey.h"
|
||||
#import "KBKeyBoardMainView.h"
|
||||
#import "KBKeyboardSubscriptionView.h"
|
||||
#import "KBSettingView.h"
|
||||
#import "Masonry.h"
|
||||
#import <SDWebImage/SDWebImage.h>
|
||||
#import <AVFoundation/AVAudioPlayer.h>
|
||||
|
||||
@implementation KeyboardViewController (Panels)
|
||||
|
||||
#pragma mark - Panel Mode
|
||||
|
||||
- (void)kb_setPanelMode:(KBKeyboardPanelMode)mode animated:(BOOL)animated {
|
||||
if (mode == self.kb_panelMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
KBKeyboardPanelMode fromMode = self.kb_panelMode;
|
||||
|
||||
// AI 入口先判完全访问:未开启时仅展示引导,不再继续登录态判断。
|
||||
if (mode == KBKeyboardPanelModeFunction &&
|
||||
![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 未登录时,不要提前写入面板状态,避免 mode 被错误卡在 Function 导致后续点击无响应。
|
||||
BOOL islogin = YES;
|
||||
if (mode == KBKeyboardPanelModeFunction) {
|
||||
[[KBAuthManager shared] reloadFromKeychain];
|
||||
islogin = KBAuthManager.shared.isLoggedIn;
|
||||
}
|
||||
#if DEBUG
|
||||
if (mode == KBKeyboardPanelModeFunction) {
|
||||
NSString *token = [KBAuthManager shared].current.accessToken ?: @"";
|
||||
NSLog(@"[AuthTrace][Ext] tapAI mode=%ld isLoggedIn=%d tokenLen=%lu",
|
||||
(long)mode, islogin, (unsigned long)token.length);
|
||||
}
|
||||
#endif
|
||||
if (mode == KBKeyboardPanelModeFunction && !islogin) {
|
||||
[KBHUD showInfo:KBLocalized(@"请先登录后使用AI功能")];
|
||||
NSString *schemeStr =
|
||||
[NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
|
||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||||
if (!ok) {
|
||||
[KBHUD showInfo:KBLocalized(@"请回到桌面手动打开App登录")];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
self.kb_panelMode = mode;
|
||||
|
||||
// 主键盘视图是基础承载:确保存在(键盘隐藏后会被释放)
|
||||
[self kb_ensureKeyBoardMainViewIfNeeded];
|
||||
|
||||
// 1) 先收起所有面板(再展开目标面板),避免互相调用导致漏关/层级错乱
|
||||
[self kb_setSubscriptionPanelVisible:NO animated:animated];
|
||||
[self kb_setSettingViewVisible:NO animated:animated];
|
||||
[self kb_setChatPanelVisible:NO animated:animated];
|
||||
[self kb_setFunctionPanelVisible:NO];
|
||||
|
||||
// 2) 再展开目标面板
|
||||
switch (mode) {
|
||||
case KBKeyboardPanelModeFunction:
|
||||
[self kb_setFunctionPanelVisible:YES];
|
||||
break;
|
||||
case KBKeyboardPanelModeChat:
|
||||
[self kb_setChatPanelVisible:YES animated:animated];
|
||||
break;
|
||||
case KBKeyboardPanelModeSettings:
|
||||
[self kb_setSettingViewVisible:YES animated:animated];
|
||||
break;
|
||||
case KBKeyboardPanelModeSubscription:
|
||||
[self kb_setSubscriptionPanelVisible:YES animated:animated];
|
||||
break;
|
||||
case KBKeyboardPanelModeMain:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// 3) 事件埋点:保持原逻辑(仅功能面板/主面板会互相曝光)
|
||||
if (mode == KBKeyboardPanelModeFunction) {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportPageExposureWithEventName:@"enter_keyboard_function_panel"
|
||||
pageId:@"keyboard_function_panel"
|
||||
extra:nil
|
||||
completion:nil];
|
||||
} else if (mode == KBKeyboardPanelModeMain &&
|
||||
fromMode == KBKeyboardPanelModeFunction) {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportPageExposureWithEventName:@"enter_keyboard_main_panel"
|
||||
pageId:@"keyboard_main_panel"
|
||||
extra:nil
|
||||
completion:nil];
|
||||
} else if (mode == KBKeyboardPanelModeSettings) {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportPageExposureWithEventName:@"enter_keyboard_settings"
|
||||
pageId:@"keyboard_settings"
|
||||
extra:nil
|
||||
completion:nil];
|
||||
} else if (mode == KBKeyboardPanelModeSubscription) {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportPageExposureWithEventName:@"enter_keyboard_subscription_panel"
|
||||
pageId:@"keyboard_subscription_panel"
|
||||
extra:nil
|
||||
completion:nil];
|
||||
}
|
||||
|
||||
// 4) 层级:保证当前面板在最上层
|
||||
if (mode == KBKeyboardPanelModeSubscription) {
|
||||
[self.contentView bringSubviewToFront:self.subscriptionView];
|
||||
} else if (mode == KBKeyboardPanelModeSettings) {
|
||||
[self.contentView bringSubviewToFront:self.settingView];
|
||||
} else if (mode == KBKeyboardPanelModeChat) {
|
||||
[self.contentView bringSubviewToFront:self.chatPanelView];
|
||||
} else if (mode == KBKeyboardPanelModeFunction) {
|
||||
[self.contentView bringSubviewToFront:self.functionView];
|
||||
} else {
|
||||
[self.contentView bringSubviewToFront:self.keyBoardMainView];
|
||||
}
|
||||
}
|
||||
|
||||
/// 对外兼容:切换显示功能面板/键盘主视图
|
||||
- (void)showFunctionPanel:(BOOL)show {
|
||||
if (show) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeFunction animated:NO];
|
||||
return;
|
||||
}
|
||||
if (self.kb_panelMode == KBKeyboardPanelModeFunction) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:NO];
|
||||
}
|
||||
}
|
||||
|
||||
/// 对外兼容:显示/隐藏设置页(高度与 keyBoardMainView 一致),右侧滑入/滑出
|
||||
- (void)showSettingView:(BOOL)show {
|
||||
if (show) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeSettings animated:YES];
|
||||
return;
|
||||
}
|
||||
if (self.kb_panelMode == KBKeyboardPanelModeSettings) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
|
||||
}
|
||||
}
|
||||
|
||||
/// 对外兼容:显示/隐藏聊天面板(覆盖整个键盘区域)
|
||||
- (void)showChatPanel:(BOOL)show {
|
||||
if (show) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeChat animated:YES];
|
||||
return;
|
||||
}
|
||||
if (self.kb_panelMode == KBKeyboardPanelModeChat) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_setFunctionPanelVisible:(BOOL)visible {
|
||||
if (visible) {
|
||||
[self kb_ensureFunctionViewIfNeeded];
|
||||
}
|
||||
if (_functionView) {
|
||||
_functionView.hidden = !visible;
|
||||
} else if (visible) {
|
||||
// ensure 后按理已存在;这里兜底一次,避免异常情况下状态不一致
|
||||
self.functionView.hidden = NO;
|
||||
}
|
||||
self.keyBoardMainView.hidden = visible;
|
||||
}
|
||||
|
||||
- (void)kb_setChatPanelVisible:(BOOL)visible animated:(BOOL)animated {
|
||||
if (visible == self.chatPanelVisible) {
|
||||
return;
|
||||
}
|
||||
self.chatPanelVisible = visible;
|
||||
|
||||
if (visible) {
|
||||
// 记录打开聊天面板时宿主输入框已有的文本,发送时只取新增部分
|
||||
[[KBInputBufferManager shared]
|
||||
refreshFromProxyIfPossible:self.textDocumentProxy];
|
||||
self.chatPanelBaselineText = [KBInputBufferManager shared].liveText ?: @"";
|
||||
[self kb_ensureChatPanelViewIfNeeded];
|
||||
self.chatPanelView.hidden = NO;
|
||||
self.chatPanelView.alpha = 0.0;
|
||||
if (animated) {
|
||||
[UIView animateWithDuration:0.2
|
||||
delay:0
|
||||
options:UIViewAnimationOptionCurveEaseOut
|
||||
animations:^{
|
||||
self.chatPanelView.alpha = 1.0;
|
||||
}
|
||||
completion:nil];
|
||||
} else {
|
||||
self.chatPanelView.alpha = 1.0;
|
||||
}
|
||||
} else {
|
||||
// 从未创建过聊天面板时,直接返回,避免 show/hide 触发额外内存分配
|
||||
if (!_chatPanelView) {
|
||||
[self kb_updateKeyboardLayoutIfNeeded];
|
||||
return;
|
||||
}
|
||||
if (animated) {
|
||||
[UIView animateWithDuration:0.18
|
||||
delay:0
|
||||
options:UIViewAnimationOptionCurveEaseIn
|
||||
animations:^{
|
||||
self.chatPanelView.alpha = 0.0;
|
||||
}
|
||||
completion:^(BOOL finished) {
|
||||
self.chatPanelView.hidden = YES;
|
||||
}];
|
||||
} else {
|
||||
self.chatPanelView.alpha = 0.0;
|
||||
self.chatPanelView.hidden = YES;
|
||||
}
|
||||
}
|
||||
[self kb_updateKeyboardLayoutIfNeeded];
|
||||
}
|
||||
|
||||
- (void)kb_setSettingViewVisible:(BOOL)visible animated:(BOOL)animated {
|
||||
if (visible) {
|
||||
KBSettingView *settingView = self.settingView;
|
||||
if (!settingView.superview) {
|
||||
settingView.hidden = YES;
|
||||
[self.contentView addSubview:settingView];
|
||||
[settingView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.contentView);
|
||||
}];
|
||||
[settingView.backButton addTarget:self
|
||||
action:@selector(onTapSettingsBack)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
[self.contentView bringSubviewToFront:settingView];
|
||||
// 以 keyBoardMainView 的实际宽度为准,避免首次添加时 self.view 宽度尚未计算
|
||||
[self.contentView layoutIfNeeded];
|
||||
CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds);
|
||||
if (w <= 0) {
|
||||
w = CGRectGetWidth(self.contentView.bounds);
|
||||
}
|
||||
if (w <= 0) {
|
||||
w = [self kb_portraitWidth];
|
||||
}
|
||||
settingView.transform = CGAffineTransformMakeTranslation(w, 0);
|
||||
settingView.hidden = NO;
|
||||
if (animated) {
|
||||
[UIView animateWithDuration:0.25
|
||||
delay:0
|
||||
options:UIViewAnimationOptionCurveEaseOut
|
||||
animations:^{
|
||||
settingView.transform = CGAffineTransformIdentity;
|
||||
}
|
||||
completion:nil];
|
||||
} else {
|
||||
settingView.transform = CGAffineTransformIdentity;
|
||||
}
|
||||
} else {
|
||||
KBSettingView *settingView = _settingView;
|
||||
if (!settingView) {
|
||||
return;
|
||||
}
|
||||
if (!settingView.superview || settingView.hidden) {
|
||||
return;
|
||||
}
|
||||
CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds);
|
||||
if (w <= 0) {
|
||||
w = CGRectGetWidth(self.contentView.bounds);
|
||||
}
|
||||
if (w <= 0) {
|
||||
w = [self kb_portraitWidth];
|
||||
}
|
||||
if (animated) {
|
||||
[UIView animateWithDuration:0.22
|
||||
delay:0
|
||||
options:UIViewAnimationOptionCurveEaseIn
|
||||
animations:^{
|
||||
settingView.transform = CGAffineTransformMakeTranslation(w, 0);
|
||||
}
|
||||
completion:^(BOOL finished) {
|
||||
settingView.hidden = YES;
|
||||
}];
|
||||
} else {
|
||||
settingView.transform = CGAffineTransformMakeTranslation(w, 0);
|
||||
settingView.hidden = YES;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_setSubscriptionPanelVisible:(BOOL)visible animated:(BOOL)animated {
|
||||
if (visible) {
|
||||
KBKeyboardSubscriptionView *panel = self.subscriptionView;
|
||||
if (!panel.superview) {
|
||||
panel.hidden = YES;
|
||||
[self.contentView addSubview:panel];
|
||||
[panel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.contentView);
|
||||
}];
|
||||
}
|
||||
[self.contentView bringSubviewToFront:panel];
|
||||
panel.hidden = NO;
|
||||
panel.alpha = 0.0;
|
||||
CGFloat height = CGRectGetHeight(self.contentView.bounds);
|
||||
if (height <= 0) {
|
||||
height = 260;
|
||||
}
|
||||
panel.transform = CGAffineTransformMakeTranslation(0, height);
|
||||
[panel refreshProductsIfNeeded];
|
||||
if (animated) {
|
||||
[UIView animateWithDuration:0.25
|
||||
delay:0
|
||||
options:UIViewAnimationOptionCurveEaseOut
|
||||
animations:^{
|
||||
panel.alpha = 1.0;
|
||||
panel.transform = CGAffineTransformIdentity;
|
||||
}
|
||||
completion:nil];
|
||||
} else {
|
||||
panel.alpha = 1.0;
|
||||
panel.transform = CGAffineTransformIdentity;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
KBKeyboardSubscriptionView *panel = _subscriptionView;
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
if (!panel.superview || panel.hidden) {
|
||||
return;
|
||||
}
|
||||
CGFloat height = CGRectGetHeight(panel.bounds);
|
||||
if (height <= 0) {
|
||||
height = CGRectGetHeight(self.contentView.bounds);
|
||||
}
|
||||
if (animated) {
|
||||
[UIView animateWithDuration:0.22
|
||||
delay:0
|
||||
options:UIViewAnimationOptionCurveEaseIn
|
||||
animations:^{
|
||||
panel.alpha = 0.0;
|
||||
panel.transform = CGAffineTransformMakeTranslation(0, height);
|
||||
}
|
||||
completion:^(BOOL finished) {
|
||||
panel.hidden = YES;
|
||||
panel.alpha = 1.0;
|
||||
panel.transform = CGAffineTransformIdentity;
|
||||
}];
|
||||
} else {
|
||||
panel.hidden = YES;
|
||||
panel.alpha = 1.0;
|
||||
panel.transform = CGAffineTransformIdentity;
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟创建:仅在用户真正打开功能面板时才创建/布局,降低默认内存占用。
|
||||
- (void)kb_ensureFunctionViewIfNeeded {
|
||||
if (_functionView && _functionView.superview) {
|
||||
return;
|
||||
}
|
||||
KBFunctionView *v = self.functionView;
|
||||
if (!v.superview) {
|
||||
v.hidden = YES;
|
||||
[self.contentView addSubview:v];
|
||||
[v mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.contentView);
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟创建:仅在用户打开聊天面板时才创建/布局。
|
||||
- (void)kb_ensureChatPanelViewIfNeeded {
|
||||
if (_chatPanelView && _chatPanelView.superview) {
|
||||
return;
|
||||
}
|
||||
CGFloat portraitWidth = [self kb_portraitWidth];
|
||||
CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth];
|
||||
KBChatPanelView *v = self.chatPanelView;
|
||||
if (!v.superview) {
|
||||
[self.contentView addSubview:v];
|
||||
[v mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self.contentView);
|
||||
make.bottom.equalTo(self.keyBoardMainView.mas_top);
|
||||
self.chatPanelHeightConstraint =
|
||||
make.height.mas_equalTo(chatPanelHeight);
|
||||
}];
|
||||
v.hidden = YES;
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟创建:键盘主面板(按键区)在隐藏时会被释放;再次显示时需要重建。
|
||||
- (void)kb_ensureKeyBoardMainViewIfNeeded {
|
||||
if (_keyBoardMainView && _keyBoardMainView.superview) {
|
||||
return;
|
||||
}
|
||||
CGFloat portraitWidth = [self kb_portraitWidth];
|
||||
CGFloat keyboardBaseHeight =
|
||||
[self kb_keyboardBaseHeightForWidth:portraitWidth];
|
||||
KBKeyBoardMainView *v = self.keyBoardMainView;
|
||||
if (!v.superview) {
|
||||
[self.contentView addSubview:v];
|
||||
[v mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self.contentView);
|
||||
make.bottom.equalTo(self.contentView);
|
||||
self.keyBoardMainHeightConstraint =
|
||||
make.height.mas_equalTo(keyboardBaseHeight);
|
||||
}];
|
||||
}
|
||||
[self.contentView bringSubviewToFront:v];
|
||||
}
|
||||
|
||||
// 键盘隐藏时释放可重建资源(背景图/缓存/非必需面板),降低扩展内存峰值。
|
||||
- (void)kb_releaseMemoryWhenKeyboardHidden {
|
||||
[KBHUD setContainerView:nil];
|
||||
self.bgImageView.image = nil;
|
||||
self.kb_cachedGradientImage = nil;
|
||||
[self.kb_defaultGradientLayer removeFromSuperlayer];
|
||||
self.kb_defaultGradientLayer = nil;
|
||||
[[SDImageCache sharedImageCache] clearMemory];
|
||||
|
||||
// 聊天相关可能持有音频数据/临时文件,键盘隐藏时直接清空,避免累计占用。
|
||||
if (self.chatAudioPlayer) {
|
||||
[self.chatAudioPlayer stop];
|
||||
self.chatAudioPlayer = nil;
|
||||
}
|
||||
if (_chatMessages.count > 0) {
|
||||
NSString *tmpRoot = NSTemporaryDirectory();
|
||||
for (KBChatMessage *msg in _chatMessages.copy) {
|
||||
if (tmpRoot.length > 0 && msg.audioFilePath.length > 0 &&
|
||||
[msg.audioFilePath hasPrefix:tmpRoot]) {
|
||||
[[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath
|
||||
error:nil];
|
||||
}
|
||||
}
|
||||
[_chatMessages removeAllObjects];
|
||||
}
|
||||
|
||||
if (_keyBoardMainView) {
|
||||
[_keyBoardMainView removeFromSuperview];
|
||||
_keyBoardMainView = nil;
|
||||
}
|
||||
self.keyBoardMainHeightConstraint = nil;
|
||||
|
||||
if (_functionView) {
|
||||
[_functionView removeFromSuperview];
|
||||
_functionView = nil;
|
||||
}
|
||||
if (_chatPanelView) {
|
||||
[_chatPanelView removeFromSuperview];
|
||||
_chatPanelView = nil;
|
||||
}
|
||||
self.chatPanelVisible = NO;
|
||||
self.kb_panelMode = KBKeyboardPanelModeMain;
|
||||
|
||||
if (_subscriptionView) {
|
||||
[_subscriptionView removeFromSuperview];
|
||||
_subscriptionView = nil;
|
||||
}
|
||||
if (_settingView) {
|
||||
[_settingView removeFromSuperview];
|
||||
_settingView = nil;
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - KBKeyBoardMainViewDelegate
|
||||
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
|
||||
didTapKey:(KBKey *)key {
|
||||
switch (key.type) {
|
||||
case KBKeyTypeCharacter: {
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
NSString *text = key.output ?: key.title ?: @"";
|
||||
[self.textDocumentProxy insertText:text];
|
||||
[self kb_updateCurrentWordWithInsertedText:text];
|
||||
[[KBInputBufferManager shared] appendText:text];
|
||||
} break;
|
||||
case KBKeyTypeBackspace:
|
||||
[[KBInputBufferManager shared]
|
||||
refreshFromProxyIfPossible:self.textDocumentProxy];
|
||||
[[KBInputBufferManager shared]
|
||||
prepareSnapshotForDeleteWithContextBefore:
|
||||
self.textDocumentProxy.documentContextBeforeInput
|
||||
after:
|
||||
self.textDocumentProxy
|
||||
.documentContextAfterInput];
|
||||
[[KBBackspaceUndoManager shared]
|
||||
captureAndDeleteBackwardFromProxy:self.textDocumentProxy
|
||||
count:1];
|
||||
[self kb_scheduleContextRefreshResetSuppression:NO];
|
||||
[[KBInputBufferManager shared] applyHoldDeleteCount:1];
|
||||
break;
|
||||
case KBKeyTypeSpace:
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
[self.textDocumentProxy insertText:@" "];
|
||||
[self kb_clearCurrentWord];
|
||||
[[KBInputBufferManager shared] appendText:@" "];
|
||||
break;
|
||||
case KBKeyTypeReturn:
|
||||
if (self.chatPanelVisible) {
|
||||
[self kb_handleChatSendAction];
|
||||
break;
|
||||
}
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
[self.textDocumentProxy insertText:@"\n"];
|
||||
[self kb_clearCurrentWord];
|
||||
[[KBInputBufferManager shared] appendText:@"\n"];
|
||||
break;
|
||||
case KBKeyTypeGlobe:
|
||||
[self advanceToNextInputMode];
|
||||
break;
|
||||
case KBKeyTypeCustom:
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
// 点击自定义键切换到功能面板
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeFunction animated:NO];
|
||||
[self kb_clearCurrentWord];
|
||||
break;
|
||||
case KBKeyTypeModeChange:
|
||||
case KBKeyTypeShift:
|
||||
// 这些已在 KBKeyBoardMainView/KBKeyboardView 内部处理
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
|
||||
didTapToolActionAtIndex:(NSInteger)index {
|
||||
NSDictionary *extra = @{@"index" : @(index)};
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportClickWithEventName:@"click_keyboard_toolbar_action"
|
||||
pageId:@"keyboard_main_panel"
|
||||
elementId:@"toolbar_action"
|
||||
extra:extra
|
||||
completion:nil];
|
||||
if (index == 0) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeFunction animated:YES];
|
||||
[self kb_clearCurrentWord];
|
||||
return;
|
||||
}
|
||||
if (index == 1) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeChat animated:YES];
|
||||
return;
|
||||
}
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
|
||||
}
|
||||
|
||||
- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportClickWithEventName:@"click_keyboard_settings_btn"
|
||||
pageId:@"keyboard_main_panel"
|
||||
elementId:@"settings_btn"
|
||||
extra:nil
|
||||
completion:nil];
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeSettings animated:YES];
|
||||
}
|
||||
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
|
||||
didSelectEmoji:(NSString *)emoji {
|
||||
if (emoji.length == 0) {
|
||||
return;
|
||||
}
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
[self.textDocumentProxy insertText:emoji];
|
||||
[self kb_clearCurrentWord];
|
||||
[[KBInputBufferManager shared] appendText:emoji];
|
||||
}
|
||||
|
||||
- (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportClickWithEventName:@"click_keyboard_undo_btn"
|
||||
pageId:@"keyboard_main_panel"
|
||||
elementId:@"undo_btn"
|
||||
extra:nil
|
||||
completion:nil];
|
||||
[[KBBackspaceUndoManager shared] performUndoFromResponder:self.view];
|
||||
[self kb_scheduleContextRefreshResetSuppression:YES];
|
||||
}
|
||||
|
||||
- (void)keyBoardMainViewDidTapEmojiSearch:
|
||||
(KBKeyBoardMainView *)keyBoardMainView {
|
||||
// [[KBMaiPointReporter sharedReporter]
|
||||
// reportClickWithEventName:@"click_keyboard_emoji_search_btn"
|
||||
// pageId:@"keyboard_main_panel"
|
||||
// elementId:@"emoji_search_btn"
|
||||
// extra:nil
|
||||
// completion:nil];
|
||||
[KBHUD showInfo:KBLocalized(@"Search coming soon")];
|
||||
}
|
||||
|
||||
// MARK: - KBFunctionViewDelegate
|
||||
|
||||
- (void)functionView:(KBFunctionView *)functionView
|
||||
didTapToolActionAtIndex:(NSInteger)index {
|
||||
// 需求:当 index == 0 时,切回键盘主视图
|
||||
if (index == 0) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:NO];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)functionView:(KBFunctionView *_Nullable)functionView
|
||||
didRightTapToolActionAtIndex:(NSInteger)index {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportClickWithEventName:@"click_keyboard_function_right_action"
|
||||
pageId:@"keyboard_function_panel"
|
||||
elementId:@"right_action"
|
||||
extra:@{@"action" : @"login_or_recharge"}
|
||||
completion:nil];
|
||||
if (!KBAuthManager.shared.isLoggedIn) {
|
||||
NSString *schemeStr =
|
||||
[NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
|
||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||||
return;
|
||||
}
|
||||
NSString *schemeStr =
|
||||
[NSString stringWithFormat:@"%@://recharge?src=keyboard", KB_APP_SCHEME];
|
||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||||
|
||||
if (!ok) {
|
||||
// 失败兜底:给个文案提示
|
||||
// 比如:请回到桌面手动打开 XXX App 进行设置/充值
|
||||
[KBHUD showInfo:@"请回到桌面手动打开App进行充值"];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)functionViewDidRequestSubscription:(KBFunctionView *)functionView {
|
||||
[self showSubscriptionPanel];
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)onTapSettingsBack {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportClickWithEventName:@"click_keyboard_settings_back_btn"
|
||||
pageId:@"keyboard_settings"
|
||||
elementId:@"back_btn"
|
||||
extra:nil
|
||||
completion:nil];
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// KeyboardViewController+Private.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Codex on 2026/02/22.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
@class AVAudioPlayer;
|
||||
@class CAGradientLayer;
|
||||
@class KBChatMessage;
|
||||
@class KBChatPanelView;
|
||||
@class KBFunctionView;
|
||||
@class KBKeyBoardMainView;
|
||||
@class KBKeyboardSubscriptionView;
|
||||
@class KBSettingView;
|
||||
@class KBSuggestionEngine;
|
||||
|
||||
@protocol KBChatLimitPopViewDelegate;
|
||||
@protocol KBChatPanelViewDelegate;
|
||||
@protocol KBFunctionViewDelegate;
|
||||
@protocol KBKeyBoardMainViewDelegate;
|
||||
@protocol KBKeyboardSubscriptionViewDelegate;
|
||||
|
||||
typedef NS_ENUM(NSInteger, KBKeyboardPanelMode) {
|
||||
KBKeyboardPanelModeMain = 0,
|
||||
KBKeyboardPanelModeFunction,
|
||||
KBKeyboardPanelModeChat,
|
||||
KBKeyboardPanelModeSettings,
|
||||
KBKeyboardPanelModeSubscription,
|
||||
};
|
||||
|
||||
@interface KeyboardViewController () <KBKeyBoardMainViewDelegate,
|
||||
KBFunctionViewDelegate,
|
||||
KBKeyboardSubscriptionViewDelegate,
|
||||
KBChatPanelViewDelegate,
|
||||
KBChatLimitPopViewDelegate>
|
||||
{
|
||||
UIButton *_nextKeyboardButton;
|
||||
UIView *_contentView;
|
||||
KBKeyBoardMainView *_keyBoardMainView;
|
||||
KBFunctionView *_functionView;
|
||||
KBSettingView *_settingView;
|
||||
UIImageView *_bgImageView;
|
||||
KBChatPanelView *_chatPanelView;
|
||||
KBKeyboardSubscriptionView *_subscriptionView;
|
||||
KBSuggestionEngine *_suggestionEngine;
|
||||
NSString *_currentWord;
|
||||
UIControl *_chatLimitMaskView;
|
||||
MASConstraint *_contentWidthConstraint;
|
||||
MASConstraint *_contentHeightConstraint;
|
||||
MASConstraint *_keyBoardMainHeightConstraint;
|
||||
MASConstraint *_chatPanelHeightConstraint;
|
||||
NSLayoutConstraint *_kb_heightConstraint;
|
||||
NSLayoutConstraint *_kb_widthConstraint;
|
||||
CGFloat _kb_lastPortraitWidth;
|
||||
CGFloat _kb_lastKeyboardHeight;
|
||||
UIImage *_kb_cachedGradientImage;
|
||||
CGSize _kb_cachedGradientSize;
|
||||
CAGradientLayer *_kb_defaultGradientLayer;
|
||||
NSString *_kb_lastAppliedThemeKey;
|
||||
NSMutableArray<KBChatMessage *> *_chatMessages;
|
||||
AVAudioPlayer *_chatAudioPlayer;
|
||||
BOOL _suppressSuggestions;
|
||||
BOOL _chatPanelVisible;
|
||||
NSString *_chatPanelBaselineText;
|
||||
id _kb_fullAccessObserverToken;
|
||||
id _kb_skinObserverToken;
|
||||
KBKeyboardPanelMode _kb_panelMode;
|
||||
}
|
||||
|
||||
@property(nonatomic, strong)
|
||||
UIButton *nextKeyboardButton; // 系统“下一个键盘”按钮(可选)
|
||||
@property(nonatomic, strong) UIView *contentView;
|
||||
@property(nonatomic, strong) KBKeyBoardMainView
|
||||
*keyBoardMainView; // 功能面板视图(点击工具栏第0个时显示)
|
||||
@property(nonatomic, strong)
|
||||
KBFunctionView *functionView; // 功能面板视图(点击工具栏第0个时显示)
|
||||
@property(nonatomic, strong) KBSettingView *settingView; // 设置页
|
||||
@property(nonatomic, strong) UIImageView *bgImageView; // 背景图(在底层)
|
||||
@property(nonatomic, strong) KBChatPanelView *chatPanelView;
|
||||
@property(nonatomic, strong) KBKeyboardSubscriptionView *subscriptionView;
|
||||
@property(nonatomic, strong) KBSuggestionEngine *suggestionEngine;
|
||||
@property(nonatomic, copy) NSString *currentWord;
|
||||
@property(nonatomic, assign) BOOL suppressSuggestions;
|
||||
@property(nonatomic, strong) UIControl *chatLimitMaskView;
|
||||
@property(nonatomic, strong) MASConstraint *contentWidthConstraint;
|
||||
@property(nonatomic, strong) MASConstraint *contentHeightConstraint;
|
||||
@property(nonatomic, strong) MASConstraint *keyBoardMainHeightConstraint;
|
||||
@property(nonatomic, strong) MASConstraint *chatPanelHeightConstraint;
|
||||
@property(nonatomic, strong) NSLayoutConstraint *kb_heightConstraint;
|
||||
@property(nonatomic, strong) NSLayoutConstraint *kb_widthConstraint;
|
||||
@property(nonatomic, assign) CGFloat kb_lastPortraitWidth;
|
||||
@property(nonatomic, assign) CGFloat kb_lastKeyboardHeight;
|
||||
@property(nonatomic, strong) UIImage *kb_cachedGradientImage;
|
||||
@property(nonatomic, assign) CGSize kb_cachedGradientSize;
|
||||
@property(nonatomic, strong, nullable) CAGradientLayer *kb_defaultGradientLayer;
|
||||
@property(nonatomic, copy, nullable) NSString *kb_lastAppliedThemeKey;
|
||||
@property(nonatomic, strong) NSMutableArray<KBChatMessage *> *chatMessages;
|
||||
@property(nonatomic, strong) AVAudioPlayer *chatAudioPlayer;
|
||||
@property(nonatomic, assign) BOOL chatPanelVisible;
|
||||
@property(nonatomic, copy) NSString *chatPanelBaselineText; // 打开聊天面板时宿主输入框已有的文本
|
||||
@property(nonatomic, strong, nullable) id kb_fullAccessObserverToken;
|
||||
@property(nonatomic, strong, nullable) id kb_skinObserverToken;
|
||||
@property(nonatomic, assign) KBKeyboardPanelMode kb_panelMode;
|
||||
|
||||
@end
|
||||
|
||||
@interface KeyboardViewController (KBPrivate)
|
||||
|
||||
// UI
|
||||
- (void)setupUI;
|
||||
- (nullable KBFunctionView *)kb_functionViewIfCreated;
|
||||
|
||||
// Panels
|
||||
- (void)showFunctionPanel:(BOOL)show;
|
||||
- (void)showSettingView:(BOOL)show;
|
||||
- (void)showChatPanel:(BOOL)show;
|
||||
- (void)showSubscriptionPanel;
|
||||
- (void)hideSubscriptionPanel;
|
||||
- (void)kb_setPanelMode:(KBKeyboardPanelMode)mode animated:(BOOL)animated;
|
||||
- (void)kb_ensureFunctionViewIfNeeded;
|
||||
- (void)kb_ensureChatPanelViewIfNeeded;
|
||||
- (void)kb_ensureKeyBoardMainViewIfNeeded;
|
||||
- (void)kb_releaseMemoryWhenKeyboardHidden;
|
||||
|
||||
// Suggestions
|
||||
- (void)kb_updateCurrentWordWithInsertedText:(NSString *)text;
|
||||
- (void)kb_clearCurrentWord;
|
||||
- (void)kb_scheduleContextRefreshResetSuppression:(BOOL)resetSuppression;
|
||||
- (void)kb_refreshCurrentWordFromDocumentContextResetSuppression:
|
||||
(BOOL)resetSuppression;
|
||||
- (void)kb_updateSuggestionsForCurrentWord;
|
||||
|
||||
// Chat
|
||||
- (void)kb_handleChatSendAction;
|
||||
|
||||
// Theme
|
||||
- (void)kb_applyTheme;
|
||||
- (void)kb_applyDefaultSkinIfNeeded;
|
||||
- (void)kb_consumePendingShopSkin;
|
||||
- (void)kb_registerDarwinSkinInstallObserver;
|
||||
- (void)kb_unregisterDarwinSkinInstallObserver;
|
||||
|
||||
// Layout
|
||||
- (CGFloat)kb_portraitWidth;
|
||||
- (CGFloat)kb_keyboardHeightForWidth:(CGFloat)width;
|
||||
- (CGFloat)kb_keyboardBaseHeightForWidth:(CGFloat)width;
|
||||
- (CGFloat)kb_chatPanelHeightForWidth:(CGFloat)width;
|
||||
- (void)kb_updateKeyboardLayoutIfNeeded;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// KeyboardViewController+Subscription.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Codex on 2026/02/22.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController+Private.h"
|
||||
|
||||
#import "KBAuthManager.h"
|
||||
#import "KBFullAccessManager.h"
|
||||
#import "KBHostAppLauncher.h"
|
||||
#import "KBKeyboardSubscriptionProduct.h"
|
||||
#import "KBKeyboardSubscriptionView.h"
|
||||
|
||||
@implementation KeyboardViewController (Subscription)
|
||||
|
||||
- (void)showSubscriptionPanel {
|
||||
// 1) 先判断权限:未开启“完全访问”则走引导逻辑
|
||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||
// 未开启完全访问:保持原有引导路径
|
||||
// [KBHUD showInfo:KBLocalized(@"处理中…")];
|
||||
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view];
|
||||
return;
|
||||
}
|
||||
// 点击充值要先判断是否登录
|
||||
// 2) 权限没问题,再判断是否登录:未登录 -> 直接拉起主 App,由主 App 负责完成登录
|
||||
if (!KBAuthManager.shared.isLoggedIn) {
|
||||
NSString *schemeStr =
|
||||
[NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
|
||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||||
return;
|
||||
}
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeSubscription animated:YES];
|
||||
}
|
||||
|
||||
- (void)hideSubscriptionPanel {
|
||||
if (self.kb_panelMode != KBKeyboardPanelModeSubscription) {
|
||||
return;
|
||||
}
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
|
||||
}
|
||||
|
||||
#pragma mark - KBKeyboardSubscriptionViewDelegate
|
||||
|
||||
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportClickWithEventName:@"click_keyboard_subscription_close_btn"
|
||||
pageId:@"keyboard_subscription_panel"
|
||||
elementId:@"close_btn"
|
||||
extra:nil
|
||||
completion:nil];
|
||||
[self hideSubscriptionPanel];
|
||||
}
|
||||
|
||||
- (void)subscriptionView:(KBKeyboardSubscriptionView *)view
|
||||
didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product {
|
||||
NSMutableDictionary *extra = [NSMutableDictionary dictionary];
|
||||
if ([product.productId isKindOfClass:NSString.class] &&
|
||||
product.productId.length > 0) {
|
||||
extra[@"product_id"] = product.productId;
|
||||
}
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportClickWithEventName:@"click_keyboard_subscription_product_btn"
|
||||
pageId:@"keyboard_subscription_panel"
|
||||
elementId:@"product_btn"
|
||||
extra:extra.copy
|
||||
completion:nil];
|
||||
[self hideSubscriptionPanel];
|
||||
[self kb_openRechargeForProduct:product];
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)kb_openRechargeForProduct:(KBKeyboardSubscriptionProduct *)product {
|
||||
if (![product isKindOfClass:KBKeyboardSubscriptionProduct.class] ||
|
||||
product.productId.length == 0) {
|
||||
[KBHUD showInfo:KBLocalized(@"Product unavailable")];
|
||||
return;
|
||||
}
|
||||
NSString *encodedId = [self.class kb_urlEncodedString:product.productId];
|
||||
NSString *title = [product displayTitle];
|
||||
NSString *encodedTitle = [self.class kb_urlEncodedString:title];
|
||||
NSMutableArray<NSString *> *params =
|
||||
[NSMutableArray arrayWithObjects:@"autoPay=1", @"prefill=1", nil];
|
||||
if (encodedId.length) {
|
||||
[params addObject:[NSString stringWithFormat:@"productId=%@", encodedId]];
|
||||
}
|
||||
if (encodedTitle.length) {
|
||||
[params
|
||||
addObject:[NSString stringWithFormat:@"productTitle=%@", encodedTitle]];
|
||||
}
|
||||
NSString *query = [params componentsJoinedByString:@"&"];
|
||||
NSString *urlString = [NSString
|
||||
stringWithFormat:@"%@://recharge?src=keyboard&%@", KB_APP_SCHEME, query];
|
||||
NSURL *scheme = [NSURL URLWithString:urlString];
|
||||
BOOL success = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||||
if (!success) {
|
||||
[KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")];
|
||||
}
|
||||
}
|
||||
|
||||
+ (NSString *)kb_urlEncodedString:(NSString *)value {
|
||||
if (value.length == 0) {
|
||||
return @"";
|
||||
}
|
||||
NSString *reserved = @"!*'();:@&=+$,/?%#[]";
|
||||
NSMutableCharacterSet *allowed =
|
||||
[[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy];
|
||||
[allowed removeCharactersInString:reserved];
|
||||
return [value stringByAddingPercentEncodingWithAllowedCharacters:allowed]
|
||||
?: @"";
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,178 @@
|
||||
//
|
||||
// KeyboardViewController+Suggestions.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Codex on 2026/02/22.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController+Private.h"
|
||||
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
#import "KBInputBufferManager.h"
|
||||
#import "KBKeyBoardMainView.h"
|
||||
#import "KBSuggestionEngine.h"
|
||||
|
||||
@implementation KeyboardViewController (Suggestions)
|
||||
|
||||
// MARK: - Suggestions
|
||||
|
||||
- (void)kb_updateCurrentWordWithInsertedText:(NSString *)text {
|
||||
if (text.length == 0) {
|
||||
return;
|
||||
}
|
||||
if ([self kb_isAlphabeticString:text]) {
|
||||
NSString *current = self.currentWord ?: @"";
|
||||
self.currentWord = [current stringByAppendingString:text];
|
||||
self.suppressSuggestions = NO;
|
||||
[self kb_updateSuggestionsForCurrentWord];
|
||||
} else {
|
||||
[self kb_clearCurrentWord];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_clearCurrentWord {
|
||||
self.currentWord = @"";
|
||||
[self.keyBoardMainView kb_setSuggestions:@[]];
|
||||
self.suppressSuggestions = NO;
|
||||
}
|
||||
|
||||
- (void)kb_scheduleContextRefreshResetSuppression:(BOOL)resetSuppression {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self kb_refreshCurrentWordFromDocumentContextResetSuppression:
|
||||
resetSuppression];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)kb_refreshCurrentWordFromDocumentContextResetSuppression:
|
||||
(BOOL)resetSuppression {
|
||||
NSString *context = self.textDocumentProxy.documentContextBeforeInput ?: @"";
|
||||
NSString *word = [self kb_extractTrailingWordFromContext:context];
|
||||
self.currentWord = word ?: @"";
|
||||
if (resetSuppression) {
|
||||
self.suppressSuggestions = NO;
|
||||
}
|
||||
[self kb_updateSuggestionsForCurrentWord];
|
||||
}
|
||||
|
||||
- (NSString *)kb_extractTrailingWordFromContext:(NSString *)context {
|
||||
if (context.length == 0) {
|
||||
return @"";
|
||||
}
|
||||
static NSCharacterSet *letters = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
letters = [NSCharacterSet
|
||||
characterSetWithCharactersInString:
|
||||
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"];
|
||||
});
|
||||
|
||||
NSInteger idx = (NSInteger)context.length - 1;
|
||||
while (idx >= 0) {
|
||||
unichar ch = [context characterAtIndex:(NSUInteger)idx];
|
||||
if (![letters characterIsMember:ch]) {
|
||||
break;
|
||||
}
|
||||
idx -= 1;
|
||||
}
|
||||
NSUInteger start = (NSUInteger)(idx + 1);
|
||||
if (start >= context.length) {
|
||||
return @"";
|
||||
}
|
||||
return [context substringFromIndex:start];
|
||||
}
|
||||
|
||||
- (BOOL)kb_isAlphabeticString:(NSString *)text {
|
||||
if (text.length == 0) {
|
||||
return NO;
|
||||
}
|
||||
static NSCharacterSet *letters = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
letters = [NSCharacterSet
|
||||
characterSetWithCharactersInString:
|
||||
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"];
|
||||
});
|
||||
for (NSUInteger i = 0; i < text.length; i++) {
|
||||
if (![letters characterIsMember:[text characterAtIndex:i]]) {
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)kb_updateSuggestionsForCurrentWord {
|
||||
NSString *prefix = self.currentWord ?: @"";
|
||||
if (prefix.length == 0) {
|
||||
[self.keyBoardMainView kb_setSuggestions:@[]];
|
||||
return;
|
||||
}
|
||||
if (self.suppressSuggestions) {
|
||||
[self.keyBoardMainView kb_setSuggestions:@[]];
|
||||
return;
|
||||
}
|
||||
NSArray<NSString *> *items =
|
||||
[self.suggestionEngine suggestionsForPrefix:prefix limit:5];
|
||||
NSArray<NSString *> *cased = [self kb_applyCaseToSuggestions:items
|
||||
prefix:prefix];
|
||||
[self.keyBoardMainView kb_setSuggestions:cased];
|
||||
}
|
||||
|
||||
- (NSArray<NSString *> *)kb_applyCaseToSuggestions:(NSArray<NSString *> *)items
|
||||
prefix:(NSString *)prefix {
|
||||
if (items.count == 0 || prefix.length == 0) {
|
||||
return items;
|
||||
}
|
||||
BOOL allUpper = [prefix isEqualToString:prefix.uppercaseString];
|
||||
BOOL firstUpper = [[prefix substringToIndex:1]
|
||||
isEqualToString:[[prefix substringToIndex:1] uppercaseString]];
|
||||
|
||||
if (!allUpper && !firstUpper) {
|
||||
return items;
|
||||
}
|
||||
|
||||
NSMutableArray<NSString *> *result =
|
||||
[NSMutableArray arrayWithCapacity:items.count];
|
||||
for (NSString *word in items) {
|
||||
if (allUpper) {
|
||||
[result addObject:word.uppercaseString];
|
||||
} else {
|
||||
NSString *first = [[word substringToIndex:1] uppercaseString];
|
||||
NSString *rest = (word.length > 1) ? [word substringFromIndex:1] : @"";
|
||||
[result addObject:[first stringByAppendingString:rest]];
|
||||
}
|
||||
}
|
||||
return result.copy;
|
||||
}
|
||||
|
||||
// MARK: - KBKeyBoardMainViewDelegate (Suggestion)
|
||||
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
|
||||
didSelectSuggestion:(NSString *)suggestion {
|
||||
if (suggestion.length == 0) {
|
||||
return;
|
||||
}
|
||||
NSDictionary *extra = @{@"suggestion_len" : @(suggestion.length)};
|
||||
// [[KBMaiPointReporter sharedReporter]
|
||||
// reportClickWithEventName:@"click_keyboard_suggestion_item"
|
||||
// pageId:@"keyboard_main_panel"
|
||||
// elementId:@"suggestion_item"
|
||||
// extra:extra
|
||||
// completion:nil];
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
NSString *current = self.currentWord ?: @"";
|
||||
if (current.length > 0) {
|
||||
for (NSUInteger i = 0; i < current.length; i++) {
|
||||
[self.textDocumentProxy deleteBackward];
|
||||
}
|
||||
}
|
||||
[self.textDocumentProxy insertText:suggestion];
|
||||
self.currentWord = suggestion;
|
||||
[self.suggestionEngine recordSelection:suggestion];
|
||||
self.suppressSuggestions = YES;
|
||||
[self.keyBoardMainView kb_setSuggestions:@[]];
|
||||
[[KBInputBufferManager shared] replaceTailWithText:suggestion
|
||||
deleteCount:current.length];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
//
|
||||
// KeyboardViewController+Theme.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Codex on 2026/02/22.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController+Private.h"
|
||||
|
||||
#import "KBFunctionView.h"
|
||||
#import "KBKeyBoardMainView.h"
|
||||
#import "KBSkinInstallBridge.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import "UIImage+KBColor.h"
|
||||
#import <QuartzCore/QuartzCore.h>
|
||||
|
||||
static NSString *const kKBDefaultSkinIdLight = @"normal_them";
|
||||
static NSString *const kKBDefaultSkinZipNameLight = @"normal_them";
|
||||
static NSString *const kKBDefaultSkinIdDark = @"normal_hei_them";
|
||||
static NSString *const kKBDefaultSkinZipNameDark = @"normal_hei_them";
|
||||
|
||||
// 提前声明一个类别,使编译器在 static 回调中识别 kb_consumePendingShopSkin 方法。
|
||||
@interface KeyboardViewController (KBSkinShopBridge)
|
||||
- (void)kb_consumePendingShopSkin;
|
||||
@end
|
||||
|
||||
static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
void *observer, CFStringRef name,
|
||||
const void *object,
|
||||
CFDictionaryRef userInfo) {
|
||||
KeyboardViewController *strongSelf =
|
||||
(__bridge KeyboardViewController *)observer;
|
||||
if (!strongSelf) {
|
||||
return;
|
||||
}
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([strongSelf respondsToSelector:@selector(kb_consumePendingShopSkin)]) {
|
||||
[strongSelf kb_consumePendingShopSkin];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@implementation KeyboardViewController (Theme)
|
||||
|
||||
- (void)kb_registerDarwinSkinInstallObserver {
|
||||
CFNotificationCenterAddObserver(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
(__bridge const void *)(self), KBSkinInstallNotificationCallback,
|
||||
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL,
|
||||
CFNotificationSuspensionBehaviorDeliverImmediately);
|
||||
}
|
||||
|
||||
- (void)kb_unregisterDarwinSkinInstallObserver {
|
||||
CFNotificationCenterRemoveObserver(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
(__bridge const void *)(self),
|
||||
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL);
|
||||
}
|
||||
|
||||
- (void)kb_applyTheme {
|
||||
@autoreleasepool {
|
||||
KBSkinTheme *t = [KBSkinManager shared].current;
|
||||
UIImage *img = nil;
|
||||
BOOL isDefaultTheme = [self kb_isDefaultKeyboardTheme:t];
|
||||
BOOL isDarkMode = [self kb_isDarkModeActive];
|
||||
|
||||
NSString *skinId = t.skinId ?: @"";
|
||||
NSString *themeKey =
|
||||
[NSString stringWithFormat:@"%@|default=%d|dark=%d", skinId,
|
||||
isDefaultTheme, isDarkMode];
|
||||
BOOL themeChanged =
|
||||
(self.kb_lastAppliedThemeKey.length == 0 ||
|
||||
![self.kb_lastAppliedThemeKey isEqualToString:themeKey]);
|
||||
if (themeChanged) {
|
||||
self.kb_lastAppliedThemeKey = themeKey;
|
||||
}
|
||||
|
||||
CGSize size = self.bgImageView.bounds.size;
|
||||
if (isDefaultTheme) {
|
||||
if (isDarkMode) {
|
||||
// 暗黑模式:直接使用背景色,不使用图片渲染
|
||||
// 这样可以避免图片渲染时的色彩空间转换导致颜色不一致
|
||||
img = nil;
|
||||
self.bgImageView.image = nil;
|
||||
[self.kb_defaultGradientLayer removeFromSuperlayer];
|
||||
self.kb_defaultGradientLayer = nil;
|
||||
// 使用与系统键盘底部完全相同的颜色
|
||||
if (@available(iOS 13.0, *)) {
|
||||
// iOS 系统键盘使用的实际颜色 (RGB: 44, 44, 46 in sRGB, 或 #2C2C2E)
|
||||
// 但为了完美匹配,我们使用动态颜色并直接设置为背景
|
||||
UIColor *kbBgColor =
|
||||
[UIColor colorWithDynamicProvider:^UIColor *_Nonnull(
|
||||
UITraitCollection *_Nonnull traitCollection) {
|
||||
if (traitCollection.userInterfaceStyle ==
|
||||
UIUserInterfaceStyleDark) {
|
||||
// 暗黑模式下系统键盘实际背景色
|
||||
return [UIColor colorWithRed:43.0 / 255.0
|
||||
green:43.0 / 255.0
|
||||
blue:43.0 / 255.0
|
||||
alpha:1.0];
|
||||
} else {
|
||||
return [UIColor colorWithRed:209.0 / 255.0
|
||||
green:211.0 / 255.0
|
||||
blue:219.0 / 255.0
|
||||
alpha:1.0];
|
||||
}
|
||||
}];
|
||||
self.contentView.backgroundColor = kbBgColor;
|
||||
self.bgImageView.backgroundColor = kbBgColor;
|
||||
} else {
|
||||
UIColor *darkColor = [UIColor colorWithRed:43.0 / 255.0
|
||||
green:43.0 / 255.0
|
||||
blue:43.0 / 255.0
|
||||
alpha:1.0];
|
||||
self.contentView.backgroundColor = darkColor;
|
||||
self.bgImageView.backgroundColor = darkColor;
|
||||
}
|
||||
} else {
|
||||
// 浅色模式:使用渐变层(避免生成大位图导致内存上涨)
|
||||
if (size.width <= 0 || size.height <= 0) {
|
||||
[self.view layoutIfNeeded];
|
||||
size = self.bgImageView.bounds.size;
|
||||
}
|
||||
if (size.width <= 0 || size.height <= 0) {
|
||||
size = self.view.bounds.size;
|
||||
}
|
||||
if (size.width <= 0 || size.height <= 0) {
|
||||
size = [UIScreen mainScreen].bounds.size;
|
||||
}
|
||||
UIColor *topColor = [UIColor colorWithHex:0xDEDFE4];
|
||||
UIColor *bottomColor = [UIColor colorWithHex:0xD1D3DB];
|
||||
UIColor *resolvedTopColor = topColor;
|
||||
UIColor *resolvedBottomColor = bottomColor;
|
||||
if (@available(iOS 13.0, *)) {
|
||||
resolvedTopColor =
|
||||
[topColor resolvedColorWithTraitCollection:self.traitCollection];
|
||||
resolvedBottomColor =
|
||||
[bottomColor resolvedColorWithTraitCollection:self.traitCollection];
|
||||
}
|
||||
CAGradientLayer *layer = self.kb_defaultGradientLayer;
|
||||
if (!layer) {
|
||||
layer = [CAGradientLayer layer];
|
||||
layer.startPoint = CGPointMake(0.5, 0.0);
|
||||
layer.endPoint = CGPointMake(0.5, 1.0);
|
||||
[self.bgImageView.layer insertSublayer:layer atIndex:0];
|
||||
self.kb_defaultGradientLayer = layer;
|
||||
}
|
||||
layer.colors =
|
||||
@[ (id)resolvedTopColor.CGColor, (id)resolvedBottomColor.CGColor ];
|
||||
layer.frame = (CGRect){CGPointZero, size};
|
||||
img = nil;
|
||||
self.bgImageView.image = nil;
|
||||
self.contentView.backgroundColor = [UIColor clearColor];
|
||||
self.bgImageView.backgroundColor = [UIColor clearColor];
|
||||
}
|
||||
NSLog(@"===");
|
||||
} else {
|
||||
// 自定义皮肤:清除背景色,使用皮肤图片
|
||||
self.contentView.backgroundColor = [UIColor clearColor];
|
||||
self.bgImageView.backgroundColor = [UIColor clearColor];
|
||||
[self.kb_defaultGradientLayer removeFromSuperlayer];
|
||||
self.kb_defaultGradientLayer = nil;
|
||||
img = [[KBSkinManager shared] currentBackgroundImage];
|
||||
}
|
||||
NSLog(@"⌨️[Keyboard] apply theme id=%@ hasBg=%d", t.skinId, (img != nil));
|
||||
[self kb_logSkinDiagnosticsWithTheme:t backgroundImage:img];
|
||||
self.bgImageView.image = img;
|
||||
|
||||
// 触发键区按主题重绘
|
||||
if (themeChanged &&
|
||||
[self.keyBoardMainView respondsToSelector:@selector(kb_applyTheme)]) {
|
||||
// method declared in KBKeyBoardMainView.h
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
[self.keyBoardMainView performSelector:@selector(kb_applyTheme)];
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
// 注意:这里不能直接访问 self.functionView,否则会导致功能面板提前创建,占用内存。
|
||||
KBFunctionView *functionView = [self kb_functionViewIfCreated];
|
||||
if (themeChanged && functionView &&
|
||||
[functionView respondsToSelector:@selector(kb_applyTheme)]) {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
[functionView performSelector:@selector(kb_applyTheme)];
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)kb_isDefaultKeyboardTheme:(KBSkinTheme *)theme {
|
||||
NSString *skinId = theme.skinId ?: @"";
|
||||
if (skinId.length == 0 || [skinId isEqualToString:@"default"]) {
|
||||
return YES;
|
||||
}
|
||||
if ([skinId isEqualToString:kKBDefaultSkinIdLight]) {
|
||||
return YES;
|
||||
}
|
||||
return [skinId isEqualToString:kKBDefaultSkinIdDark];
|
||||
}
|
||||
|
||||
- (BOOL)kb_isDarkModeActive {
|
||||
if (@available(iOS 13.0, *)) {
|
||||
return self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (NSString *)kb_defaultSkinIdForCurrentStyle {
|
||||
return [self kb_isDarkModeActive] ? kKBDefaultSkinIdDark
|
||||
: kKBDefaultSkinIdLight;
|
||||
}
|
||||
|
||||
- (NSString *)kb_defaultSkinZipNameForCurrentStyle {
|
||||
return [self kb_isDarkModeActive] ? kKBDefaultSkinZipNameDark
|
||||
: kKBDefaultSkinZipNameLight;
|
||||
}
|
||||
|
||||
- (UIImage *)kb_defaultGradientImageWithSize:(CGSize)size
|
||||
topColor:(UIColor *)topColor
|
||||
bottomColor:(UIColor *)bottomColor {
|
||||
if (size.width <= 0 || size.height <= 0) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
// 尺寸未变则复用缓存,避免反复创建图片撑爆键盘扩展内存
|
||||
if (self.kb_cachedGradientImage &&
|
||||
CGSizeEqualToSize(self.kb_cachedGradientSize, size)) {
|
||||
return self.kb_cachedGradientImage;
|
||||
}
|
||||
|
||||
UIColor *resolvedTopColor = topColor;
|
||||
UIColor *resolvedBottomColor = bottomColor;
|
||||
if (@available(iOS 13.0, *)) {
|
||||
resolvedTopColor =
|
||||
[topColor resolvedColorWithTraitCollection:self.traitCollection];
|
||||
resolvedBottomColor =
|
||||
[bottomColor resolvedColorWithTraitCollection:self.traitCollection];
|
||||
}
|
||||
|
||||
CAGradientLayer *layer = [CAGradientLayer layer];
|
||||
layer.frame = CGRectMake(0, 0, size.width, size.height);
|
||||
layer.startPoint = CGPointMake(0.5, 0.0);
|
||||
layer.endPoint = CGPointMake(0.5, 1.0);
|
||||
layer.colors =
|
||||
@[ (id)resolvedTopColor.CGColor, (id)resolvedBottomColor.CGColor ];
|
||||
|
||||
UIGraphicsBeginImageContextWithOptions(size, YES, 0);
|
||||
[layer renderInContext:UIGraphicsGetCurrentContext()];
|
||||
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
|
||||
UIGraphicsEndImageContext();
|
||||
|
||||
self.kb_cachedGradientImage = image;
|
||||
self.kb_cachedGradientSize = size;
|
||||
return image;
|
||||
}
|
||||
|
||||
- (void)kb_logSkinDiagnosticsWithTheme:(KBSkinTheme *)theme
|
||||
backgroundImage:(UIImage *)image {
|
||||
#if DEBUG
|
||||
NSString *skinId = theme.skinId ?: @"";
|
||||
NSString *name = theme.name ?: @"";
|
||||
NSMutableArray<NSString *> *roots = [NSMutableArray array];
|
||||
NSURL *containerURL = [[NSFileManager defaultManager]
|
||||
containerURLForSecurityApplicationGroupIdentifier:AppGroup];
|
||||
if (containerURL.path.length > 0) {
|
||||
[roots addObject:containerURL.path];
|
||||
}
|
||||
NSString *cacheRoot = NSSearchPathForDirectoriesInDomains(
|
||||
NSCachesDirectory, NSUserDomainMask, YES)
|
||||
.firstObject;
|
||||
if (cacheRoot.length > 0) {
|
||||
[roots addObject:cacheRoot];
|
||||
}
|
||||
|
||||
NSFileManager *fm = [NSFileManager defaultManager];
|
||||
NSMutableArray<NSString *> *lines = [NSMutableArray array];
|
||||
for (NSString *root in roots) {
|
||||
NSString *iconsDir = [[root stringByAppendingPathComponent:@"Skins"]
|
||||
stringByAppendingPathComponent:skinId];
|
||||
iconsDir = [iconsDir stringByAppendingPathComponent:@"icons"];
|
||||
BOOL isDir = NO;
|
||||
BOOL exists = [fm fileExistsAtPath:iconsDir isDirectory:&isDir] && isDir;
|
||||
NSArray *contents =
|
||||
exists ? [fm contentsOfDirectoryAtPath:iconsDir error:nil] : nil;
|
||||
NSUInteger count = contents.count;
|
||||
BOOL hasQ =
|
||||
exists &&
|
||||
[fm fileExistsAtPath:[iconsDir
|
||||
stringByAppendingPathComponent:@"key_q.png"]];
|
||||
BOOL hasQUp =
|
||||
exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent:
|
||||
@"key_q_up.png"]];
|
||||
BOOL hasDel =
|
||||
exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent:
|
||||
@"key_del.png"]];
|
||||
BOOL hasShift =
|
||||
exists &&
|
||||
[fm fileExistsAtPath:[iconsDir
|
||||
stringByAppendingPathComponent:@"key_up.png"]];
|
||||
BOOL hasShiftUpper =
|
||||
exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent:
|
||||
@"key_up_upper.png"]];
|
||||
NSString *line = [NSString
|
||||
stringWithFormat:@"root=%@ icons=%@ exist=%d count=%tu key_q=%d "
|
||||
@"key_q_up=%d key_del=%d key_up=%d key_up_upper=%d",
|
||||
root, iconsDir, exists, count, hasQ, hasQUp, hasDel,
|
||||
hasShift, hasShiftUpper];
|
||||
[lines addObject:line];
|
||||
}
|
||||
|
||||
NSLog(@"[Keyboard] theme id=%@ name=%@ hasBg=%d\n%@", skinId, name,
|
||||
(image != nil), [lines componentsJoinedByString:@"\n"]);
|
||||
#endif
|
||||
}
|
||||
|
||||
- (void)kb_consumePendingShopSkin {
|
||||
KBWeakSelf [KBSkinInstallBridge
|
||||
consumePendingRequestFromBundle:NSBundle.mainBundle
|
||||
completion:^(BOOL success,
|
||||
NSError *_Nullable error) {
|
||||
if (!success) {
|
||||
if (error) {
|
||||
NSLog(@"[Keyboard] skin request failed: %@",
|
||||
error);
|
||||
[KBHUD
|
||||
showInfo:
|
||||
KBLocalized(
|
||||
@"皮肤资源准备失败,请稍后再试")];
|
||||
}
|
||||
return;
|
||||
}
|
||||
[weakSelf kb_applyTheme];
|
||||
[KBHUD showInfo:KBLocalized(
|
||||
@"皮肤已更新,立即体验吧")];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_applyDefaultSkinIfNeeded {
|
||||
NSDictionary *pending = [KBSkinInstallBridge pendingRequestPayload];
|
||||
if (pending.count > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *currentId = [KBSkinManager shared].current.skinId ?: @"";
|
||||
BOOL isDefault =
|
||||
(currentId.length == 0 || [currentId isEqualToString:@"default"]);
|
||||
BOOL isLightDefault = [currentId isEqualToString:kKBDefaultSkinIdLight];
|
||||
BOOL isDarkDefault = [currentId isEqualToString:kKBDefaultSkinIdDark];
|
||||
if (!isDefault && !isLightDefault && !isDarkDefault) {
|
||||
// 用户已应用自定义皮肤:不随深色模式切换默认皮肤
|
||||
return;
|
||||
}
|
||||
NSString *targetId = [self kb_defaultSkinIdForCurrentStyle];
|
||||
NSString *targetZip = [self kb_defaultSkinZipNameForCurrentStyle];
|
||||
if (currentId.length > 0 && [currentId isEqualToString:targetId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSError *applyError = nil;
|
||||
if ([KBSkinInstallBridge applyInstalledSkinWithId:targetId error:&applyError]) {
|
||||
return;
|
||||
}
|
||||
|
||||
[KBSkinInstallBridge publishBundleSkinRequestWithId:targetId
|
||||
name:targetId
|
||||
zipName:targetZip
|
||||
iconShortNames:nil];
|
||||
[KBSkinInstallBridge
|
||||
consumePendingRequestFromBundle:NSBundle.mainBundle
|
||||
completion:^(__unused BOOL success,
|
||||
__unused NSError *_Nullable error) {
|
||||
// 已通过通知触发主题刷新,这里无需额外处理
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,151 @@
|
||||
//
|
||||
// KeyboardViewController+UI.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Codex on 2026/02/22.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController+Private.h"
|
||||
|
||||
#import "KBChatMessage.h"
|
||||
#import "KBChatPanelView.h"
|
||||
#import "KBFunctionView.h"
|
||||
#import "KBKeyBoardMainView.h"
|
||||
#import "KBKeyboardSubscriptionView.h"
|
||||
#import "KBSettingView.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
@implementation KeyboardViewController (UI)
|
||||
|
||||
- (void)setupUI {
|
||||
self.view.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
|
||||
// 按“短边”宽度等比缩放,横屏保持竖屏布局比例
|
||||
CGFloat portraitWidth = [self kb_portraitWidth];
|
||||
CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth];
|
||||
CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth];
|
||||
CGFloat screenWidth = CGRectGetWidth([UIScreen mainScreen].bounds);
|
||||
|
||||
// FIX: iOS 26 键盘闪烁问题
|
||||
// iOS 26 在键盘滑入动画开始前,会对 self.view 做一次离屏预渲染快照(非实时 view),
|
||||
// 该快照会短暂显示在屏幕中间。如果此时 view 已有完整高度和内容,用户就会看到
|
||||
// 键盘 UI 在屏幕中间闪现一帧,然后键盘才从底部正常滑入。
|
||||
// 解决方案:初始高度设为 0,让系统快照时无内容可渲染;
|
||||
// 在 viewWillAppear: 中恢复正确高度,此时系统已准备好滑入动画。
|
||||
// (iOS 18 及更早版本无此预渲染机制,不受影响)
|
||||
NSLayoutConstraint *h = [self.view.heightAnchor constraintEqualToConstant:0];
|
||||
NSLayoutConstraint *w =
|
||||
[self.view.widthAnchor constraintEqualToConstant:screenWidth];
|
||||
self.kb_heightConstraint = h;
|
||||
self.kb_widthConstraint = w;
|
||||
|
||||
h.priority = UILayoutPriorityRequired;
|
||||
w.priority = UILayoutPriorityRequired;
|
||||
[NSLayoutConstraint activateConstraints:@[ h, w ]];
|
||||
// 关闭 UIInputView 自适应(某些系统版本会尝试放大为全屏高度导致冲突)
|
||||
if ([self.view isKindOfClass:[UIInputView class]]) {
|
||||
UIInputView *iv = (UIInputView *)self.view;
|
||||
if ([iv respondsToSelector:@selector(setAllowsSelfSizing:)]) {
|
||||
iv.allowsSelfSizing = NO;
|
||||
}
|
||||
}
|
||||
// 内容容器:横屏时保持竖屏宽度,居中显示
|
||||
[self.view addSubview:self.contentView];
|
||||
[self.contentView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerX.equalTo(self.view);
|
||||
make.bottom.equalTo(self.view);
|
||||
self.contentWidthConstraint = make.width.mas_equalTo(portraitWidth);
|
||||
self.contentHeightConstraint = make.height.mas_equalTo(keyboardHeight);
|
||||
}];
|
||||
|
||||
// 背景图铺底(仅在内容容器内)
|
||||
[self.contentView addSubview:self.bgImageView];
|
||||
[self.bgImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.contentView);
|
||||
}];
|
||||
|
||||
[self.contentView addSubview:self.keyBoardMainView];
|
||||
[self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self.contentView);
|
||||
make.bottom.equalTo(self.contentView);
|
||||
self.keyBoardMainHeightConstraint =
|
||||
make.height.mas_equalTo(keyboardBaseHeight);
|
||||
}];
|
||||
|
||||
// 初始隐藏,避免布局完成前闪烁
|
||||
self.contentView.hidden = YES;
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (nullable KBFunctionView *)kb_functionViewIfCreated {
|
||||
return _functionView;
|
||||
}
|
||||
|
||||
- (UIView *)contentView {
|
||||
if (!_contentView) {
|
||||
_contentView = [[UIView alloc] init];
|
||||
_contentView.backgroundColor = [UIColor clearColor];
|
||||
}
|
||||
return _contentView;
|
||||
}
|
||||
|
||||
- (UIImageView *)bgImageView {
|
||||
if (!_bgImageView) {
|
||||
_bgImageView = [[UIImageView alloc] init];
|
||||
_bgImageView.contentMode = UIViewContentModeScaleAspectFill;
|
||||
_bgImageView.clipsToBounds = YES;
|
||||
}
|
||||
return _bgImageView;
|
||||
}
|
||||
|
||||
- (KBKeyBoardMainView *)keyBoardMainView {
|
||||
if (!_keyBoardMainView) {
|
||||
_keyBoardMainView = [[KBKeyBoardMainView alloc] init];
|
||||
_keyBoardMainView.delegate = self;
|
||||
}
|
||||
return _keyBoardMainView;
|
||||
}
|
||||
|
||||
- (KBFunctionView *)functionView {
|
||||
if (!_functionView) {
|
||||
_functionView = [[KBFunctionView alloc] init];
|
||||
_functionView.delegate = self; // 监听功能面板顶部Bar点击
|
||||
}
|
||||
return _functionView;
|
||||
}
|
||||
|
||||
- (KBSettingView *)settingView {
|
||||
if (!_settingView) {
|
||||
_settingView = [[KBSettingView alloc] init];
|
||||
}
|
||||
return _settingView;
|
||||
}
|
||||
|
||||
- (KBChatPanelView *)chatPanelView {
|
||||
if (!_chatPanelView) {
|
||||
NSLog(@"[Keyboard] ⚠️ chatPanelView 被创建!");
|
||||
_chatPanelView = [[KBChatPanelView alloc] init];
|
||||
_chatPanelView.delegate = self;
|
||||
}
|
||||
return _chatPanelView;
|
||||
}
|
||||
|
||||
- (NSMutableArray<KBChatMessage *> *)chatMessages {
|
||||
if (!_chatMessages) {
|
||||
_chatMessages = [NSMutableArray array];
|
||||
}
|
||||
return _chatMessages;
|
||||
}
|
||||
|
||||
- (KBKeyboardSubscriptionView *)subscriptionView {
|
||||
if (!_subscriptionView) {
|
||||
_subscriptionView = [[KBKeyboardSubscriptionView alloc] init];
|
||||
_subscriptionView.delegate = self;
|
||||
_subscriptionView.hidden = YES;
|
||||
_subscriptionView.alpha = 0.0;
|
||||
}
|
||||
return _subscriptionView;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -41,38 +41,10 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
||||
}
|
||||
|
||||
- (void)getSignWithParare:(NSDictionary *)bodyParams{
|
||||
|
||||
NSString *appId = @"loveKeyboard";
|
||||
NSString *secret = @"kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H"; // 和服务端保持一致
|
||||
NSString *timestamp = [KBSignUtils currentTimestamp];
|
||||
NSString *nonce = [KBSignUtils generateNonceWithLength:16];
|
||||
// 1. 组装参与签名的所有参数
|
||||
NSMutableDictionary<NSString *, NSString *> *signParams = [NSMutableDictionary dictionary];
|
||||
signParams[@"appId"] = appId;
|
||||
signParams[@"timestamp"] = timestamp;
|
||||
signParams[@"nonce"] = nonce;
|
||||
// 把 body 里的字段也加入签名参数
|
||||
[bodyParams enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
|
||||
if ([obj isKindOfClass:[NSString class]]) {
|
||||
signParams[key] = obj;
|
||||
} else {
|
||||
signParams[key] = [obj description];
|
||||
}
|
||||
}];
|
||||
NSString *sign = [KBSignUtils signWithParams:signParams secret:secret];
|
||||
|
||||
// 将签名相关字段合并进默认请求头
|
||||
NSDictionary<NSString *, NSString *> *signHeaders = [KBSignUtils signHeadersWithBodyParams:bodyParams];
|
||||
NSMutableDictionary<NSString *, NSString *> *headers =
|
||||
[self.defaultHeaders mutableCopy] ?: [NSMutableDictionary dictionary];
|
||||
|
||||
if (sign.length > 0) {
|
||||
headers[@"X-Sign"] = sign;
|
||||
}
|
||||
headers[@"X-App-Id"] = appId;
|
||||
headers[@"X-Timestamp"] = timestamp;
|
||||
headers[@"X-Nonce"] = nonce;
|
||||
|
||||
// 触发 copy 语义,确保对外仍是不可变字典
|
||||
[headers addEntriesFromDictionary:signHeaders ?: @{}];
|
||||
self.defaultHeaders = headers;
|
||||
}
|
||||
|
||||
|
||||
44
CustomKeyboard/PrivacyInfo.xcprivacy
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeOtherUserContent</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<true/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -7,17 +7,25 @@
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@class KBChatDataModel;
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 聊天响应模型
|
||||
@interface KBChatResponse : NSObject
|
||||
@property (nonatomic, copy, nullable) NSString *text;
|
||||
@property (nonatomic, copy, nullable) NSString *audioId;
|
||||
@property (nonatomic, strong, nullable) KBChatDataModel *data;
|
||||
//@property (nonatomic, copy, nullable) NSString *audioId;
|
||||
@property (nonatomic, copy, nullable) NSString *message;
|
||||
@property (nonatomic, assign) BOOL success;
|
||||
@property (nonatomic, assign) NSInteger code;
|
||||
|
||||
@end
|
||||
|
||||
@interface KBChatDataModel : NSObject
|
||||
@property (nonatomic, copy, nullable) NSString *aiResponse;
|
||||
@property (nonatomic, copy, nullable) NSString *audioId;
|
||||
@property (nonatomic, copy, nullable) NSString *llmDuration;
|
||||
|
||||
|
||||
@end
|
||||
|
||||
/// 音频响应模型
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
@implementation KBChatResponse
|
||||
@end
|
||||
|
||||
@implementation KBChatDataModel
|
||||
@end
|
||||
|
||||
@implementation KBAudioResponse
|
||||
@end
|
||||
|
||||
@@ -68,8 +71,7 @@
|
||||
completion:^(NSDictionary *json, NSURLResponse *response, NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
KBChatResponse *chatResponse = [KBChatResponse mj_objectWithKeyValues:json];
|
||||
|
||||
if (error) {
|
||||
if (chatResponse.code != 0) {
|
||||
chatResponse.success = NO;
|
||||
// chatResponse.errorMessage = error.localizedDescription ?: @"请求失败";
|
||||
if (completion) completion(chatResponse);
|
||||
|
||||
@@ -318,6 +318,7 @@ static const NSUInteger kKBChatMessageLimit = 10;
|
||||
- (UILabel *)titleLabel {
|
||||
if (!_titleLabel) {
|
||||
_titleLabel = [[UILabel alloc] init];
|
||||
_titleLabel.hidden = true;
|
||||
_titleLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightMedium];
|
||||
_titleLabel.textColor =
|
||||
[UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0x1B1F1A]
|
||||
|
||||
@@ -67,6 +67,8 @@ static CGFloat const kKBItemSpace = 4;
|
||||
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumLineSpacingForSectionAtIndex:(NSInteger)section { return kKBItemSpace; }
|
||||
|
||||
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
// 有cell正在loading时,不允许点击其他cell
|
||||
if (self.loadingIndexes.count > 0) { return; }
|
||||
KBTagItemModel *model = (indexPath.item < self.items.count) ? self.items[indexPath.item] : [KBTagItemModel new];
|
||||
NSInteger personaId = 0;
|
||||
if ([model isKindOfClass:KBTagItemModel.class]) {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#import "KBHostAppLauncher.h"
|
||||
#import "KBInputBufferManager.h"
|
||||
#import "KBResponderUtils.h" // 统一查找 UIInputViewController 的工具
|
||||
#import "KBSignUtils.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import "KBStreamOverlayView.h" // 带关闭按钮的流式层
|
||||
#import "KBStreamTextView.h" // 流式文本视图
|
||||
@@ -435,11 +436,32 @@
|
||||
cachePolicy:NSURLRequestReloadIgnoringCacheData
|
||||
timeoutInterval:60];
|
||||
request.HTTPMethod = @"POST";
|
||||
[request setValue:@"text/event-stream" forHTTPHeaderField:@"Accept"];
|
||||
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
|
||||
// 签名头(后端要求,否则会 401:Missing sign headers)
|
||||
NSDictionary<NSString *, NSString *> *signHeaders =
|
||||
[KBSignUtils signHeadersWithBodyParams:payload];
|
||||
[signHeaders enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj,
|
||||
BOOL *stop) {
|
||||
if (key.length == 0 || obj.length == 0) {
|
||||
return;
|
||||
}
|
||||
[request setValue:obj forHTTPHeaderField:key];
|
||||
}];
|
||||
NSString *token = KBAuthManager.shared.current.accessToken ?: @"";
|
||||
if (token.length > 0) {
|
||||
[request setValue:token forHTTPHeaderField:@"auth-token"];
|
||||
}
|
||||
// 与主 App 的请求头保持一致(Bearer)
|
||||
NSDictionary<NSString *, NSString *> *authHeader =
|
||||
[[KBAuthManager shared] authorizationHeader];
|
||||
[authHeader enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj,
|
||||
BOOL *stop) {
|
||||
if (key.length == 0 || obj.length == 0) {
|
||||
return;
|
||||
}
|
||||
[request setValue:obj forHTTPHeaderField:key];
|
||||
}];
|
||||
request.HTTPBody = bodyData;
|
||||
|
||||
self.streamHasOutput = NO;
|
||||
@@ -463,7 +485,7 @@
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self)
|
||||
return;
|
||||
[self kb_handleEventSourceError:event.error];
|
||||
// [self kb_handleEventSourceError:event.error];
|
||||
}
|
||||
forEvent:WJXEventNameError
|
||||
queue:NSOperationQueue.mainQueue];
|
||||
|
||||
@@ -95,6 +95,10 @@
|
||||
[self.keyboardView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.topBar.mas_bottom).offset(barSpacing);
|
||||
}];
|
||||
// 首次创建时立即生成按键,避免只出现 4 行容器但按键未渲染的情况。
|
||||
[self.keyboardView reloadKeys];
|
||||
// 同步应用一次主题,确保键盘背景与按键皮肤在首帧可见。
|
||||
[self kb_applyTheme];
|
||||
// 功能面板切换交由外部控制器处理;此处不直接创建/管理
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(kb_undoStateChanged)
|
||||
|
||||
@@ -49,7 +49,6 @@ static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
|
||||
self.layoutConfig = [KBKeyboardLayoutConfig sharedConfig];
|
||||
self.backspaceHandler = [[KBBackspaceLongPressHandler alloc] initWithContainerView:self];
|
||||
[self buildBase];
|
||||
[self reloadKeys];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
@@ -126,6 +125,39 @@ static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
|
||||
[self buildRow:self.row2 withRowConfig:rows[1]];
|
||||
[self buildRow:self.row3 withRowConfig:rows[2]];
|
||||
[self buildRow:self.row4 withRowConfig:rows[3]];
|
||||
|
||||
NSUInteger totalButtons = [self kb_totalKeyButtonCount];
|
||||
if (totalButtons == 0) {
|
||||
NSLog(@"[KBKeyboardView] config layout produced no keys, fallback to legacy.");
|
||||
for (UIView *row in @[self.row1, self.row2, self.row3, self.row4]) {
|
||||
[row.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
|
||||
}
|
||||
[self kb_buildLegacyLayout];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)didMoveToWindow {
|
||||
[super didMoveToWindow];
|
||||
if (!self.window) { return; }
|
||||
if ([self kb_totalKeyButtonCount] > 0) { return; }
|
||||
// 兜底:系统编辑菜单切出切回等场景下,若按键丢失则自动重建。
|
||||
[self reloadKeys];
|
||||
// 自动重建后再触发一次上层主题应用,避免“按键恢复了但皮肤背景没恢复”。
|
||||
UIView *container = self.superview;
|
||||
if ([container respondsToSelector:@selector(kb_applyTheme)]) {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
[container performSelector:@selector(kb_applyTheme)];
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
}
|
||||
|
||||
- (NSUInteger)kb_totalKeyButtonCount {
|
||||
NSUInteger total = 0;
|
||||
for (UIView *row in @[self.row1, self.row2, self.row3, self.row4]) {
|
||||
total += [self kb_collectKeyButtonsInView:row].count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
#pragma mark - Hit Test
|
||||
@@ -594,7 +626,7 @@ edgeSpacerMultiplier:(CGFloat)edgeSpacerMultiplier {
|
||||
[btn setTitle:key.title forState:UIControlStateNormal];
|
||||
// 在设置完标题后,按当前皮肤应用图标与文字显隐
|
||||
[btn applyThemeForCurrentKey];
|
||||
[btn addTarget:self action:@selector(onKeyTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[btn addTarget:self action:@selector(onKeyTapped:) forControlEvents:UIControlEventTouchDown];
|
||||
[row addSubview:btn];
|
||||
|
||||
if (key.type == KBKeyTypeBackspace) {
|
||||
@@ -920,7 +952,7 @@ edgeSpacerMultiplier:(CGFloat)edgeSpacerMultiplier {
|
||||
}
|
||||
|
||||
[btn applyThemeForCurrentKey];
|
||||
[btn addTarget:self action:@selector(onKeyTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[btn addTarget:self action:@selector(onKeyTapped:) forControlEvents:UIControlEventTouchDown];
|
||||
|
||||
if (key.type == KBKeyTypeBackspace) {
|
||||
[self.backspaceHandler bindDeleteButton:btn showClearLabel:YES];
|
||||
|
||||
@@ -26,7 +26,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@property (nonatomic, weak, nullable) id<KBToolBarDelegate> delegate;
|
||||
|
||||
/// 左侧按钮的标题(数量由数组决定)。默认值:@[@"AI", @"语音"]。
|
||||
/// 左侧按钮的标题(数量由数组决定)。默认值:@[@"AI"]。
|
||||
@property (nonatomic, copy) NSArray<NSString *> *leftButtonTitles;
|
||||
|
||||
/// 暴露按钮以便外部定制(只读;首次访问时懒加载创建)
|
||||
|
||||
@@ -9,29 +9,34 @@
|
||||
#import "KBResponderUtils.h" // 查找 UIInputViewController,用于系统切换输入法
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import <ImageIO/ImageIO.h>
|
||||
|
||||
@interface KBToolBar ()
|
||||
@property (nonatomic, strong) UIView *leftContainer;
|
||||
@property (nonatomic, strong) NSArray<UIButton *> *leftButtonsInternal;
|
||||
//@property (nonatomic, strong) UIButton *settingsButtonInternal;
|
||||
@property (nonatomic, strong) UIButton *globeButtonInternal; // 可选:系统“切换输入法”键
|
||||
@property (nonatomic, strong) UIImageView *avatarImageView; // 右侧头像(AppGroup persona_cover.jpg)
|
||||
@property (nonatomic, strong) UIButton *undoButtonInternal; // 右侧撤销删除
|
||||
@property (nonatomic, assign) BOOL kbNeedsInputModeSwitchKey;
|
||||
@property (nonatomic, assign) BOOL kbUndoVisible;
|
||||
@property (nonatomic, assign) BOOL kbAvatarVisible;
|
||||
@property (nonatomic, copy, nullable) NSString *kb_cachedPersonaCoverPath;
|
||||
@property (nonatomic, strong, nullable) UIImage *kb_cachedPersonaCoverImage;
|
||||
@end
|
||||
|
||||
@implementation KBToolBar
|
||||
|
||||
static NSString * const kKBAIKeyIdentifier = @"ai";
|
||||
static NSString * const kKBUndoKeyIdentifier = @"key_revoke";
|
||||
static const CGFloat kKBAIButtonWidth = 40;
|
||||
static const CGFloat kKBAIButtonHeight = 40;
|
||||
static const NSInteger kKBVoiceButtonIndex = 1;
|
||||
static NSString * const kKBAIKeyIdentifier = @"ai";
|
||||
static NSString * const kKBUndoKeyIdentifier = @"key_revoke";
|
||||
static const CGFloat kKBAIButtonWidth = 40;
|
||||
static const CGFloat kKBAIButtonHeight = 40;
|
||||
static const CGFloat kKBAvatarSize = 40;
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame{
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
_leftButtonTitles = @[@"AI", KBLocalized(@"语音")]; // 默认标题
|
||||
_leftButtonTitles = @[@"AI"]; // 默认标题
|
||||
[self setupUI];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(kb_undoStateChanged)
|
||||
@@ -69,7 +74,6 @@ static const NSInteger kKBVoiceButtonIndex = 1;
|
||||
}
|
||||
}];
|
||||
[self kb_updateAIButtonAppearance];
|
||||
[self kb_updateVoiceButtonAppearance];
|
||||
}
|
||||
|
||||
#pragma mark - 视图搭建
|
||||
@@ -79,6 +83,7 @@ static const NSInteger kKBVoiceButtonIndex = 1;
|
||||
// [self addSubview:self.settingsButtonInternal];
|
||||
[self addSubview:self.globeButtonInternal];
|
||||
[self addSubview:self.undoButtonInternal];
|
||||
[self addSubview:self.avatarImageView];
|
||||
|
||||
// 右侧设置按钮
|
||||
// [self.settingsButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
@@ -94,13 +99,7 @@ static const NSInteger kKBVoiceButtonIndex = 1;
|
||||
make.width.height.mas_equalTo(32);
|
||||
}];
|
||||
|
||||
// 右侧撤销按钮
|
||||
[self.undoButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(self.mas_right).offset(-12);
|
||||
make.centerY.equalTo(self.mas_centerY);
|
||||
make.height.mas_equalTo(32);
|
||||
make.width.mas_equalTo(84);
|
||||
}];
|
||||
[self kb_updateRightControlsConstraints];
|
||||
|
||||
[self kb_updateLeftContainerConstraints];
|
||||
|
||||
@@ -173,8 +172,8 @@ static const NSInteger kKBVoiceButtonIndex = 1;
|
||||
|
||||
- (void)kb_applyTheme {
|
||||
[self kb_updateAIButtonAppearance];
|
||||
[self kb_updateVoiceButtonAppearance];
|
||||
[self kb_updateUndoButtonAppearance];
|
||||
[self kb_updateAvatarAppearance];
|
||||
}
|
||||
|
||||
- (void)kb_updateAIButtonAppearance {
|
||||
@@ -211,16 +210,6 @@ static const NSInteger kKBVoiceButtonIndex = 1;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_updateVoiceButtonAppearance {
|
||||
UIButton *voiceButton = [self kb_voiceButton];
|
||||
if (!voiceButton) { return; }
|
||||
|
||||
voiceButton.backgroundColor = [UIColor colorWithHex:0xE53935];
|
||||
[voiceButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
||||
voiceButton.layer.cornerRadius = 16;
|
||||
voiceButton.layer.masksToBounds = YES;
|
||||
}
|
||||
|
||||
- (void)kb_updateUndoButtonAppearance {
|
||||
if (!self.undoButtonInternal) { return; }
|
||||
|
||||
@@ -241,6 +230,72 @@ static const NSInteger kKBVoiceButtonIndex = 1;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Avatar
|
||||
|
||||
- (void)kb_updateAvatarAppearance {
|
||||
UIImage *img = [self kb_personaCoverImageFromAppGroup];
|
||||
BOOL shouldShow = (img != nil);
|
||||
self.avatarImageView.image = img;
|
||||
if (self.kbAvatarVisible == shouldShow) {
|
||||
self.avatarImageView.hidden = !shouldShow;
|
||||
return;
|
||||
}
|
||||
self.kbAvatarVisible = shouldShow;
|
||||
self.avatarImageView.hidden = !shouldShow;
|
||||
[self kb_updateRightControlsConstraints];
|
||||
[self kb_updateLeftContainerConstraints];
|
||||
[self setNeedsLayout];
|
||||
[self layoutIfNeeded];
|
||||
}
|
||||
|
||||
- (nullable UIImage *)kb_personaCoverImageFromAppGroup {
|
||||
NSURL *containerURL = [[NSFileManager defaultManager]
|
||||
containerURLForSecurityApplicationGroupIdentifier:AppGroup];
|
||||
if (!containerURL) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSString *imagePath =
|
||||
[[containerURL path] stringByAppendingPathComponent:@"persona_cover.jpg"];
|
||||
if (imagePath.length == 0 ||
|
||||
![[NSFileManager defaultManager] fileExistsAtPath:imagePath]) {
|
||||
self.kb_cachedPersonaCoverPath = nil;
|
||||
self.kb_cachedPersonaCoverImage = nil;
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (self.kb_cachedPersonaCoverImage &&
|
||||
[self.kb_cachedPersonaCoverPath isEqualToString:imagePath]) {
|
||||
return self.kb_cachedPersonaCoverImage;
|
||||
}
|
||||
|
||||
// 头像仅 40pt,直接按像素上限缩略解码,避免每次显示键盘都 full decode 一张大 JPG 顶爆扩展内存。
|
||||
NSUInteger maxPixel = 256;
|
||||
NSURL *url = [NSURL fileURLWithPath:imagePath];
|
||||
CGImageSourceRef source = CGImageSourceCreateWithURL((__bridge CFURLRef)url, NULL);
|
||||
if (!source) {
|
||||
return nil;
|
||||
}
|
||||
NSDictionary *opts = @{
|
||||
(__bridge id)kCGImageSourceCreateThumbnailFromImageAlways : @YES,
|
||||
(__bridge id)kCGImageSourceCreateThumbnailWithTransform : @YES,
|
||||
(__bridge id)kCGImageSourceThumbnailMaxPixelSize : @(maxPixel),
|
||||
};
|
||||
CGImageRef cg = CGImageSourceCreateThumbnailAtIndex(source, 0, (__bridge CFDictionaryRef)opts);
|
||||
CFRelease(source);
|
||||
if (!cg) {
|
||||
return nil;
|
||||
}
|
||||
UIImage *img = [UIImage imageWithCGImage:cg
|
||||
scale:[UIScreen mainScreen].scale
|
||||
orientation:UIImageOrientationUp];
|
||||
CGImageRelease(cg);
|
||||
|
||||
self.kb_cachedPersonaCoverPath = imagePath;
|
||||
self.kb_cachedPersonaCoverImage = img;
|
||||
return img;
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)onLeftAction:(UIButton *)sender {
|
||||
@@ -261,6 +316,16 @@ static const NSInteger kKBVoiceButtonIndex = 1;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)onAvatarTap {
|
||||
if (!self.kbAvatarVisible || self.avatarImageView.hidden) {
|
||||
return;
|
||||
}
|
||||
// 复用原“语音”入口的 index=1 逻辑(外部会按 index 做面板切换)
|
||||
if ([self.delegate respondsToSelector:@selector(toolBar:didTapActionAtIndex:)]) {
|
||||
[self.delegate toolBar:self didTapActionAtIndex:1];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIView *)leftContainer {
|
||||
@@ -271,6 +336,23 @@ static const NSInteger kKBVoiceButtonIndex = 1;
|
||||
return _leftContainer;
|
||||
}
|
||||
|
||||
- (UIImageView *)avatarImageView {
|
||||
if (!_avatarImageView) {
|
||||
_avatarImageView = [[UIImageView alloc] init];
|
||||
_avatarImageView.hidden = YES;
|
||||
_avatarImageView.backgroundColor = [UIColor colorWithWhite:1 alpha:0.9];
|
||||
_avatarImageView.contentMode = UIViewContentModeScaleAspectFill;
|
||||
_avatarImageView.layer.cornerRadius = kKBAvatarSize * 0.5;
|
||||
_avatarImageView.layer.masksToBounds = YES;
|
||||
_avatarImageView.userInteractionEnabled = YES;
|
||||
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc]
|
||||
initWithTarget:self
|
||||
action:@selector(onAvatarTap)];
|
||||
[_avatarImageView addGestureRecognizer:tap];
|
||||
}
|
||||
return _avatarImageView;
|
||||
}
|
||||
|
||||
//- (UIButton *)settingsButtonInternal {
|
||||
// if (!_settingsButtonInternal) {
|
||||
// _settingsButtonInternal = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
@@ -319,11 +401,6 @@ static const NSInteger kKBVoiceButtonIndex = 1;
|
||||
return self.leftButtonsInternal[0];
|
||||
}
|
||||
|
||||
- (UIButton *)kb_voiceButton {
|
||||
if (self.leftButtonsInternal.count <= kKBVoiceButtonIndex) { return nil; }
|
||||
return self.leftButtonsInternal[kKBVoiceButtonIndex];
|
||||
}
|
||||
|
||||
#pragma mark - Globe (Input Mode Switch)
|
||||
|
||||
// 根据宿主是否已提供系统切换键,决定是否显示地球按钮;并绑定系统事件。
|
||||
@@ -362,6 +439,8 @@ static const NSInteger kKBVoiceButtonIndex = 1;
|
||||
}
|
||||
if (self.kbUndoVisible) {
|
||||
make.right.equalTo(self.undoButtonInternal.mas_left).offset(-8);
|
||||
} else if (self.kbAvatarVisible) {
|
||||
make.right.equalTo(self.avatarImageView.mas_left).offset(-8);
|
||||
} else {
|
||||
make.right.equalTo(self).offset(-12);
|
||||
}
|
||||
@@ -371,6 +450,24 @@ static const NSInteger kKBVoiceButtonIndex = 1;
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_updateRightControlsConstraints {
|
||||
[self.avatarImageView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(self).offset(-12);
|
||||
make.centerY.equalTo(self).offset(0);
|
||||
make.width.height.mas_equalTo(kKBAvatarSize);
|
||||
}];
|
||||
[self.undoButtonInternal mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
if (self.kbAvatarVisible) {
|
||||
make.right.equalTo(self.avatarImageView.mas_left).offset(-8);
|
||||
} else {
|
||||
make.right.equalTo(self).offset(-12);
|
||||
}
|
||||
make.centerY.equalTo(self.mas_centerY);
|
||||
make.height.mas_equalTo(32);
|
||||
make.width.mas_equalTo(84);
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_undoStateChanged {
|
||||
[self kb_updateUndoVisibilityAnimated:YES];
|
||||
}
|
||||
@@ -405,6 +502,7 @@ static const NSInteger kKBVoiceButtonIndex = 1;
|
||||
- (void)didMoveToWindow {
|
||||
[super didMoveToWindow];
|
||||
[self kb_refreshGlobeVisibility];
|
||||
[self kb_updateAvatarAppearance];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
Feb 5 20:30:09 macbookpro com.apple.dt.xcodebuild[56551] <Error>: Unable to deliver request ({
|
||||
"developer_dir" = "/Applications/Xcode.app/Contents/Developer";
|
||||
request = "set_developer_dir";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
Feb 5 20:30:22 macbookpro com.apple.dt.xcodebuild[56567] <Error>: Unable to deliver request ({
|
||||
"developer_dir" = "/Applications/Xcode.app/Contents/Developer";
|
||||
request = "set_developer_dir";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
Feb 5 20:31:06 macbookpro com.apple.dt.xcodebuild[56684] <Error>: Unable to deliver request ({
|
||||
"developer_dir" = "/Applications/Xcode.app/Contents/Developer";
|
||||
request = "set_developer_dir";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
Feb 5 20:31:06 macbookpro com.apple.dt.xcodebuild[56684] <Warning>: Unable to discover any Simulator runtimes. Developer Directory is /Applications/Xcode.app/Contents/Developer.
|
||||
Feb 5 20:31:06 macbookpro com.apple.dt.xcodebuild[56684] <Error>: Could not kickstart simdiskimaged; SimDiskImageManager services will not be available: Error Domain=NSPOSIXErrorDomain Code=53 "Software caused connection abort" UserInfo={NSLocalizedDescription=Error returned in reply from CoreSimulatorService: Connection invalid}
|
||||
Feb 5 20:31:06 macbookpro com.apple.dt.xcodebuild[56684] <Error>: simdiskimaged returned error (invalid), marking disconnected.
|
||||
Feb 5 20:31:06 macbookpro com.apple.dt.xcodebuild[56684] <Error>: Could not get list of trusted mount directories: Error Domain=com.apple.CoreSimulator.SimError Code=410 "The service used to manage runtime disk images (simdiskimaged) crashed or is not responding" UserInfo={NSLocalizedDescription=The service used to manage runtime disk images (simdiskimaged) crashed or is not responding}
|
||||
Feb 5 20:31:06 macbookpro com.apple.dt.xcodebuild[56684] <Error>: simdiskimaged returned error (invalid), marking disconnected.
|
||||
Feb 5 20:31:06 macbookpro com.apple.dt.xcodebuild[56684] <Error>: Unable to deliver request ({
|
||||
request = "notification_subscription";
|
||||
"set_path" = "/Users/mac/Library/Developer/CoreSimulator/Devices";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
Feb 5 20:31:06 macbookpro com.apple.dt.xcodebuild[56684] <Error>: Unable to deliver request ({
|
||||
request = "notification_subscription";
|
||||
"set_path" = "/Users/mac/Library/Developer/CoreSimulator/Devices";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
Feb 5 20:31:55 macbookpro com.apple.dt.xcodebuild[56806] <Error>: Unable to deliver request ({
|
||||
"developer_dir" = "/Applications/Xcode.app/Contents/Developer";
|
||||
request = "set_developer_dir";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
Feb 5 20:31:55 macbookpro com.apple.dt.xcodebuild[56806] <Warning>: Unable to discover any Simulator runtimes. Developer Directory is /Applications/Xcode.app/Contents/Developer.
|
||||
Feb 5 20:31:55 macbookpro com.apple.dt.xcodebuild[56806] <Error>: Could not kickstart simdiskimaged; SimDiskImageManager services will not be available: Error Domain=NSPOSIXErrorDomain Code=53 "Software caused connection abort" UserInfo={NSLocalizedDescription=Error returned in reply from CoreSimulatorService: Connection invalid}
|
||||
Feb 5 20:31:55 macbookpro com.apple.dt.xcodebuild[56806] <Error>: simdiskimaged returned error (invalid), marking disconnected.
|
||||
Feb 5 20:31:55 macbookpro com.apple.dt.xcodebuild[56806] <Error>: Could not get list of trusted mount directories: Error Domain=com.apple.CoreSimulator.SimError Code=410 "The service used to manage runtime disk images (simdiskimaged) crashed or is not responding" UserInfo={NSLocalizedDescription=The service used to manage runtime disk images (simdiskimaged) crashed or is not responding}
|
||||
Feb 5 20:31:55 macbookpro com.apple.dt.xcodebuild[56806] <Error>: simdiskimaged returned error (invalid), marking disconnected.
|
||||
Feb 5 20:31:55 macbookpro com.apple.dt.xcodebuild[56806] <Error>: Unable to deliver request ({
|
||||
request = "notification_subscription";
|
||||
"set_path" = "/Users/mac/Library/Developer/CoreSimulator/Devices";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
Feb 5 20:31:55 macbookpro com.apple.dt.xcodebuild[56806] <Error>: Unable to deliver request ({
|
||||
request = "notification_subscription";
|
||||
"set_path" = "/Users/mac/Library/Developer/CoreSimulator/Devices";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
@@ -28,11 +28,14 @@
|
||||
|
||||
|
||||
#define API_LOGOUT @"/user/logout" // 退出登录
|
||||
#define API_USER_CANCEL_ACCOUNT @"/user/cancelAccount" // 注销账户
|
||||
#define API_CANCEL_ACCOUNT_WARNING @"/keyboardWarningMessage/byLocale" // 按locale查询注销提示信息
|
||||
|
||||
#define API_UPDATA_INFO @"/user/updateInfo" // 更新用户
|
||||
|
||||
#define KB_API_USER_DETAIL @"/user/detail" // 用户详情
|
||||
#define API_USER_INVITE_CODE @"/user/inviteCode" // 查询邀请码
|
||||
#define API_USER_CUSTOMER_MAIL @"/user/customerMail" // 获取客服邮箱
|
||||
#define API_CHARACTER_LIST @"/character/list" // 排行榜角色列表(综合)
|
||||
#define API_NOT_LOGIN_CHARACTER_LIST @"/character/listWithNotLogin" //未登录用户人设列表
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
/// 用户头像 URL(主 App 写入,键盘扩展读取)
|
||||
#define AppGroup_UserAvatarURL @"AppGroup_UserAvatarURL"
|
||||
|
||||
/// 键盘扩展聊天更新的 companionId(键盘写入,主 App 读取后刷新对应聊天记录)
|
||||
#define AppGroup_ChatUpdatedCompanionId @"AppGroup_ChatUpdatedCompanionId"
|
||||
|
||||
/// Darwin 跨进程通知:键盘扩展发送聊天消息后通知主 App 刷新
|
||||
#define kKBDarwinChatUpdated @"com.loveKey.nyx.chat.updated"
|
||||
|
||||
/// 皮肤图标加载模式:
|
||||
/// 0 = 使用本地 Assets 图片名(key_icons 的 value 写成图片名,例如 "kb_q_melon")
|
||||
/// 1 = 使用远程 Zip 皮肤包(skinJSON 中提供 zip_url;key_icons 的 value 写成 Zip 内图标文件名,例如 "key_a")
|
||||
|
||||
@@ -296,7 +296,7 @@ static NSDictionary<NSString *, NSDictionary *> *KBMaiPoint_PageExposureMap(void
|
||||
@"HomeHotVC": @{@"event_name": @"enter_home_hot", @"page_id": @"home_hot"},
|
||||
@"HomeRankVC": @{@"event_name": @"enter_home_rank", @"page_id": @"home_rank"},
|
||||
@"HomeRankContentVC": @{@"event_name": @"enter_home_rank_content", @"page_id": @"home_rank_content"},
|
||||
@"HomeSheetVC": @{@"event_name": @"enter_home_sheet", @"page_id": @"home_sheet"},
|
||||
// @"HomeSheetVC": @{@"event_name": @"enter_home_sheet", @"page_id": @"home_sheet"},
|
||||
@"KBCommunityVC": @{@"event_name": @"enter_community", @"page_id": @"community"},
|
||||
@"KBSearchVC": @{@"event_name": @"enter_search", @"page_id": @"search"},
|
||||
@"KBSearchResultVC": @{@"event_name": @"enter_search_result", @"page_id": @"search_result"},
|
||||
|
||||
@@ -30,6 +30,10 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
/// 简单 nonce 生成(默认 16 位)
|
||||
+ (NSString *)generateNonceWithLength:(NSUInteger)length;
|
||||
|
||||
/// 生成本项目后端约定的签名请求头(X-Sign/X-App-Id/X-Timestamp/X-Nonce)。
|
||||
/// bodyParams:参与签名的业务参数(如 JSON body 字段)。内部会做类型容错与空值过滤。
|
||||
+ (NSDictionary<NSString *, NSString *> *)signHeadersWithBodyParams:(nullable NSDictionary *)bodyParams;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -10,6 +10,32 @@
|
||||
|
||||
@implementation KBSignUtils
|
||||
|
||||
static NSString *const KBSignAppId = @"loveKeyboard";
|
||||
static NSString *const KBSignSecret = @"kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H";
|
||||
|
||||
static NSString *KBSignStringFromObject(id obj) {
|
||||
if (!obj || obj == (id)kCFNull) {
|
||||
return nil;
|
||||
}
|
||||
if ([obj isKindOfClass:[NSString class]]) {
|
||||
return (NSString *)obj;
|
||||
}
|
||||
if ([obj isKindOfClass:[NSNumber class]]) {
|
||||
return [(NSNumber *)obj stringValue];
|
||||
}
|
||||
if ([obj isKindOfClass:[NSArray class]] || [obj isKindOfClass:[NSDictionary class]]) {
|
||||
NSJSONWritingOptions options = 0;
|
||||
if (@available(iOS 11.0, *)) {
|
||||
options = NSJSONWritingSortedKeys;
|
||||
}
|
||||
NSData *data = [NSJSONSerialization dataWithJSONObject:obj options:options error:nil];
|
||||
if (data.length > 0) {
|
||||
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
||||
}
|
||||
}
|
||||
return [obj description];
|
||||
}
|
||||
|
||||
+ (NSString *)urlEncode:(NSString *)value {
|
||||
if (!value) return @"";
|
||||
// 按 application/x-www-form-urlencoded 规则编码(更贴近后端常见实现)
|
||||
@@ -96,4 +122,50 @@
|
||||
return [uuid substringToIndex:length];
|
||||
}
|
||||
|
||||
+ (NSDictionary<NSString *, NSString *> *)signHeadersWithBodyParams:(NSDictionary *)bodyParams {
|
||||
NSString *timestamp = [self currentTimestamp];
|
||||
NSString *nonce = [self generateNonceWithLength:16];
|
||||
|
||||
NSMutableDictionary<NSString *, NSString *> *signParams = [NSMutableDictionary dictionary];
|
||||
if (KBSignAppId.length > 0) {
|
||||
signParams[@"appId"] = KBSignAppId;
|
||||
}
|
||||
if (timestamp.length > 0) {
|
||||
signParams[@"timestamp"] = timestamp;
|
||||
}
|
||||
if (nonce.length > 0) {
|
||||
signParams[@"nonce"] = nonce;
|
||||
}
|
||||
|
||||
if ([bodyParams isKindOfClass:[NSDictionary class]] && bodyParams.count > 0) {
|
||||
[bodyParams enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
|
||||
if (![key isKindOfClass:[NSString class]]) {
|
||||
return;
|
||||
}
|
||||
NSString *value = KBSignStringFromObject(obj);
|
||||
if (value.length == 0) {
|
||||
return;
|
||||
}
|
||||
signParams[(NSString *)key] = value;
|
||||
}];
|
||||
}
|
||||
|
||||
NSString *sign = [self signWithParams:signParams secret:KBSignSecret ?: @""];
|
||||
|
||||
NSMutableDictionary<NSString *, NSString *> *headers = [NSMutableDictionary dictionary];
|
||||
if (sign.length > 0) {
|
||||
headers[@"X-Sign"] = sign;
|
||||
}
|
||||
if (KBSignAppId.length > 0) {
|
||||
headers[@"X-App-Id"] = KBSignAppId;
|
||||
}
|
||||
if (timestamp.length > 0) {
|
||||
headers[@"X-Timestamp"] = timestamp;
|
||||
}
|
||||
if (nonce.length > 0) {
|
||||
headers[@"X-Nonce"] = nonce;
|
||||
}
|
||||
return [headers copy];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -443,7 +443,7 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json";
|
||||
#if __has_include("KBNetworkManager.h")
|
||||
// 远程下载(http/https)
|
||||
NSLog(@"🌐[SkinBridge] will GET zip: %@", zipURL);
|
||||
[KBHUD showWithStatus:@"正在下载..."];
|
||||
[KBHUD showWithStatus:KBLocalized(@"Downloading...")];
|
||||
[[KBNetworkManager shared] GETData:zipURL parameters:nil headers:nil completion:^(NSData *data, NSURLResponse *response, NSError *error) {
|
||||
NSLog(@"🌐[SkinBridge] GET finished id=%@ error=%@", skinId, error);
|
||||
if (error || data.length == 0) {
|
||||
|
||||
@@ -54,6 +54,9 @@ extern NSString * const KBDarwinSkinChanged; // cross-process
|
||||
/// 当前背景图片(若存在)
|
||||
- (nullable UIImage *)currentBackgroundImage;
|
||||
|
||||
/// 清理运行时图片缓存(内存缓存)。键盘扩展接近内存上限时可主动调用。
|
||||
- (void)clearRuntimeImageCaches;
|
||||
|
||||
/// 当前主题下,指定按键标识的文字是否应被隐藏(例如图标里已包含字母)
|
||||
- (BOOL)shouldHideKeyTextForIdentifier:(nullable NSString *)identifier;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#import "KBSkinManager.h"
|
||||
#import "KBConfig.h"
|
||||
#import <ImageIO/ImageIO.h>
|
||||
|
||||
NSString * const KBSkinDidChangeNotification = @"KBSkinDidChangeNotification";
|
||||
NSString * const KBDarwinSkinChanged = @"com.loveKey.nyx.skin.changed";
|
||||
@@ -59,10 +60,45 @@ static NSString * const kKBSkinThemeStoreKey = @"KBSkinThemeCurrent";
|
||||
|
||||
@interface KBSkinManager ()
|
||||
@property (atomic, strong, readwrite) KBSkinTheme *current;
|
||||
@property (nonatomic, strong) NSCache<NSString *, UIImage *> *kb_fileImageCache;
|
||||
@property (nonatomic, copy, nullable) NSString *kb_cachedBgSkinId;
|
||||
@property (nonatomic, assign) BOOL kb_cachedBgResolved;
|
||||
@property (nonatomic, strong, nullable) UIImage *kb_cachedBgImage;
|
||||
@end
|
||||
|
||||
@implementation KBSkinManager
|
||||
|
||||
/// 从文件路径解码图片,并按 maxPixel 限制最长边像素(避免加载超大背景图导致键盘扩展内存飙升)。
|
||||
+ (nullable UIImage *)kb_imageAtPath:(NSString *)path maxPixel:(NSUInteger)maxPixel {
|
||||
if (path.length == 0) return nil;
|
||||
NSURL *url = [NSURL fileURLWithPath:path];
|
||||
CGImageSourceRef source = CGImageSourceCreateWithURL((__bridge CFURLRef)url, NULL);
|
||||
if (!source) return nil;
|
||||
NSDictionary *opts = @{
|
||||
(__bridge id)kCGImageSourceCreateThumbnailFromImageAlways : @YES,
|
||||
(__bridge id)kCGImageSourceCreateThumbnailWithTransform : @YES,
|
||||
(__bridge id)kCGImageSourceThumbnailMaxPixelSize : @(MAX(1, (NSInteger)maxPixel)),
|
||||
};
|
||||
CGImageRef cg = CGImageSourceCreateThumbnailAtIndex(source, 0, (__bridge CFDictionaryRef)opts);
|
||||
CFRelease(source);
|
||||
if (!cg) return nil;
|
||||
UIImage *img = [UIImage imageWithCGImage:cg scale:[UIScreen mainScreen].scale orientation:UIImageOrientationUp];
|
||||
CGImageRelease(cg);
|
||||
return img;
|
||||
}
|
||||
|
||||
static inline NSUInteger KBApproxImageCostBytes(UIImage *img) {
|
||||
if (!img) return 0;
|
||||
CGFloat scale = img.scale > 0 ? img.scale : [UIScreen mainScreen].scale;
|
||||
CGSize s = img.size;
|
||||
double px = (double)s.width * scale * (double)s.height * scale;
|
||||
if (px <= 0) return 0;
|
||||
// RGBA 4 bytes/pixel
|
||||
double cost = px * 4.0;
|
||||
if (cost > (double)NSUIntegerMax) return NSUIntegerMax;
|
||||
return (NSUInteger)cost;
|
||||
}
|
||||
|
||||
/// 返回所有可能的皮肤根目录(优先 App Group,其次当前进程的 Caches)。
|
||||
+ (NSArray<NSString *> *)kb_candidateBaseRoots {
|
||||
NSMutableArray<NSString *> *roots = [NSMutableArray array];
|
||||
@@ -104,6 +140,14 @@ static NSString * const kKBSkinThemeStoreKey = @"KBSkinThemeCurrent";
|
||||
|
||||
- (instancetype)init {
|
||||
if (self = [super init]) {
|
||||
_kb_fileImageCache = [NSCache new];
|
||||
// 键盘扩展内存上限较小,缓存要保守一些;主 App 也共用该实现但不会出问题。
|
||||
// iPad 的键盘背景可能更大,适当放宽。
|
||||
if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) {
|
||||
_kb_fileImageCache.totalCostLimit = 24 * 1024 * 1024;
|
||||
} else {
|
||||
_kb_fileImageCache.totalCostLimit = 12 * 1024 * 1024;
|
||||
}
|
||||
KBSkinTheme *t = [self p_loadFromStore];
|
||||
// 若存储中的皮肤在 App Group 中找不到对应资源目录(如首次安装 / 已被清理),则回退到默认皮肤。
|
||||
if (!t || ![self.class kb_hasAssetsForSkinId:t.skinId]) {
|
||||
@@ -170,6 +214,7 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer,
|
||||
- (BOOL)applyTheme:(KBSkinTheme *)theme {
|
||||
if (!theme) return NO;
|
||||
NSLog(@"🎨[SkinManager] apply theme id=%@ name=%@", theme.skinId, theme.name);
|
||||
[self clearRuntimeImageCaches];
|
||||
// 将主题写入 App Group 存储(失败也不影响本次进程内的使用)
|
||||
[self p_saveToStore:theme];
|
||||
// 始终更新当前主题并广播通知,确保当前进程和扩展之间保持同步。
|
||||
@@ -187,6 +232,15 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer,
|
||||
[self applyTheme:[self.class defaultTheme]];
|
||||
}
|
||||
|
||||
- (void)clearRuntimeImageCaches {
|
||||
@synchronized (self) {
|
||||
[self.kb_fileImageCache removeAllObjects];
|
||||
self.kb_cachedBgSkinId = nil;
|
||||
self.kb_cachedBgResolved = NO;
|
||||
self.kb_cachedBgImage = nil;
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)applyImageSkinWithData:(NSData *)imageData skinId:(NSString *)skinId name:(NSString *)name {
|
||||
// 仅作为“存在背景图”的标记使用:图像文件本身存放在 App Group 容器
|
||||
// Skins/<skinId>/background.png 中,这里不再把二进制图片写入 Keychain,
|
||||
@@ -216,20 +270,52 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer,
|
||||
NSString *skinId = self.current.skinId;
|
||||
if (skinId.length == 0) return nil;
|
||||
|
||||
// 同一个 skinId 在键盘的生命周期内会被频繁读取;缓存一份避免反复解码导致内存上涨。
|
||||
@synchronized (self) {
|
||||
if (self.kb_cachedBgResolved && [self.kb_cachedBgSkinId isEqualToString:skinId]) {
|
||||
return self.kb_cachedBgImage;
|
||||
}
|
||||
}
|
||||
|
||||
NSArray<NSString *> *roots = [self.class kb_candidateBaseRoots];
|
||||
NSFileManager *fm = [NSFileManager defaultManager];
|
||||
NSString *relative = [NSString stringWithFormat:@"Skins/%@/background.png", skinId];
|
||||
|
||||
// 背景图通常远大于键盘实际显示区域,按像素上限做缩略解码,显著降低扩展内存占用。
|
||||
NSUInteger maxPixel = (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) ? 2048 : 1024;
|
||||
for (NSString *base in roots) {
|
||||
NSString *bgPath = [[base stringByAppendingPathComponent:relative] stringByStandardizingPath];
|
||||
BOOL isDir = NO;
|
||||
if (![fm fileExistsAtPath:bgPath isDirectory:&isDir] || isDir) {
|
||||
continue;
|
||||
}
|
||||
NSData *data = [NSData dataWithContentsOfFile:bgPath];
|
||||
if (data.length == 0) continue;
|
||||
UIImage *img = [UIImage imageWithData:data scale:[UIScreen mainScreen].scale];
|
||||
if (img) return img;
|
||||
NSString *cacheKey = [NSString stringWithFormat:@"bg|%@", bgPath];
|
||||
UIImage *cached = [self.kb_fileImageCache objectForKey:cacheKey];
|
||||
if (cached) {
|
||||
@synchronized (self) {
|
||||
self.kb_cachedBgSkinId = skinId;
|
||||
self.kb_cachedBgResolved = YES;
|
||||
self.kb_cachedBgImage = cached;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
UIImage *img = [self.class kb_imageAtPath:bgPath maxPixel:maxPixel];
|
||||
if (img) {
|
||||
NSUInteger cost = KBApproxImageCostBytes(img);
|
||||
[self.kb_fileImageCache setObject:img forKey:cacheKey cost:cost];
|
||||
@synchronized (self) {
|
||||
self.kb_cachedBgSkinId = skinId;
|
||||
self.kb_cachedBgResolved = YES;
|
||||
self.kb_cachedBgImage = img;
|
||||
}
|
||||
return img;
|
||||
}
|
||||
}
|
||||
@synchronized (self) {
|
||||
self.kb_cachedBgSkinId = skinId;
|
||||
self.kb_cachedBgResolved = YES;
|
||||
self.kb_cachedBgImage = nil;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
@@ -314,7 +400,13 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer,
|
||||
if (![fm fileExistsAtPath:fullPath isDirectory:&isDir] || isDir) {
|
||||
continue;
|
||||
}
|
||||
UIImage *img = [UIImage imageWithContentsOfFile:fullPath];
|
||||
NSString *cacheKey = [NSString stringWithFormat:@"icon|%@", fullPath];
|
||||
UIImage *img = [self.kb_fileImageCache objectForKey:cacheKey];
|
||||
if (img) return img;
|
||||
img = [UIImage imageWithContentsOfFile:fullPath];
|
||||
if (img) {
|
||||
[self.kb_fileImageCache setObject:img forKey:cacheKey cost:KBApproxImageCostBytes(img)];
|
||||
}
|
||||
if (img) return img;
|
||||
}
|
||||
#if DEBUG
|
||||
@@ -351,7 +443,13 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer,
|
||||
NSString *fullPath = [[base stringByAppendingPathComponent:relative] stringByStandardizingPath];
|
||||
BOOL isDir = NO;
|
||||
if ([fm fileExistsAtPath:fullPath isDirectory:&isDir] && !isDir) {
|
||||
UIImage *img = [UIImage imageWithContentsOfFile:fullPath];
|
||||
NSString *cacheKey = [NSString stringWithFormat:@"icon|%@", fullPath];
|
||||
UIImage *img = [self.kb_fileImageCache objectForKey:cacheKey];
|
||||
if (img) return img;
|
||||
img = [UIImage imageWithContentsOfFile:fullPath];
|
||||
if (img) {
|
||||
[self.kb_fileImageCache setObject:img forKey:cacheKey cost:KBApproxImageCostBytes(img)];
|
||||
}
|
||||
if (img) return img;
|
||||
}
|
||||
}
|
||||
@@ -363,7 +461,13 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer,
|
||||
NSString *fullPath = [[base stringByAppendingPathComponent:relative] stringByStandardizingPath];
|
||||
BOOL isDir = NO;
|
||||
if ([fm fileExistsAtPath:fullPath isDirectory:&isDir] && !isDir) {
|
||||
UIImage *img = [UIImage imageWithContentsOfFile:fullPath];
|
||||
NSString *cacheKey = [NSString stringWithFormat:@"icon|%@", fullPath];
|
||||
UIImage *img = [self.kb_fileImageCache objectForKey:cacheKey];
|
||||
if (img) return img;
|
||||
img = [UIImage imageWithContentsOfFile:fullPath];
|
||||
if (img) {
|
||||
[self.kb_fileImageCache setObject:img forKey:cacheKey cost:KBApproxImageCostBytes(img)];
|
||||
}
|
||||
if (img) return img;
|
||||
}
|
||||
}
|
||||
@@ -449,6 +553,7 @@ static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer,
|
||||
if (!t || ![self.class kb_hasAssetsForSkinId:t.skinId]) {
|
||||
t = [self.class defaultTheme];
|
||||
}
|
||||
[self clearRuntimeImageCaches];
|
||||
self.current = t;
|
||||
if (broadcast) {
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:KBSkinDidChangeNotification object:nil];
|
||||
|
||||
@@ -99,10 +99,22 @@ static NSString * const kKBSessionInstallFlagKey = @"KBSession.installInitialize
|
||||
// 先写 Keychain(统一走 KBAuthManager)
|
||||
NSString *token = user.token;
|
||||
if (token.length > 0) {
|
||||
[[KBAuthManager shared] saveAccessToken:token
|
||||
refreshToken:nil
|
||||
expiryDate:nil
|
||||
userIdentifier:nil];
|
||||
BOOL saveOK = [[KBAuthManager shared] saveAccessToken:token
|
||||
refreshToken:nil
|
||||
expiryDate:nil
|
||||
userIdentifier:nil];
|
||||
#if DEBUG
|
||||
NSLog(@"[AuthTrace][App] saveAccessToken tokenLen=%lu saveOK=%d",
|
||||
(unsigned long)token.length, saveOK);
|
||||
#endif
|
||||
// 主动 reload 一次,便于立即验证当前进程读到的登录态
|
||||
[[KBAuthManager shared] reloadFromKeychain];
|
||||
#if DEBUG
|
||||
NSString *savedToken = [KBAuthManager shared].current.accessToken ?: @"";
|
||||
NSLog(@"[AuthTrace][App] reloadAfterSave isLoggedIn=%d tokenLen=%lu",
|
||||
[KBAuthManager shared].isLoggedIn,
|
||||
(unsigned long)savedToken.length);
|
||||
#endif
|
||||
}
|
||||
|
||||
// 再缓存用户信息,供 App/键盘使用
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"Network error" = "Network error";
|
||||
"Saved" = "Saved";
|
||||
"Copy Success" = "Copy Success";
|
||||
"Email Copy Success" = "Email Copy Success";
|
||||
|
||||
// Network
|
||||
"Network unavailable" = "Network unavailable";
|
||||
@@ -140,6 +141,10 @@
|
||||
"Nickname" = "Nickname";
|
||||
"Gender" = "Gender";
|
||||
"User ID" = "User ID";
|
||||
"Modify Gender" = "Modify Gender";
|
||||
"Male" = "Male";
|
||||
"Female" = "Female";
|
||||
"The Third Gender" = "The Third Gender";
|
||||
|
||||
" Paste Ta's Words" = " Paste Ta's Words";
|
||||
|
||||
@@ -167,9 +172,18 @@
|
||||
"Delete" = "Delete";
|
||||
"Points\nMall" = "Points\nMall";
|
||||
"Log Out" = "Log Out";
|
||||
"Cancel Account" = "Cancel Account";
|
||||
"After cancellation, your account will be deactivated and local login data will be cleared. Continue?" = "After cancellation, your account will be deactivated and local login data will be cleared. Continue?";
|
||||
"Please enter your password" = "Please enter your password";
|
||||
"Cancel Account Notice" = "Cancel Account Notice";
|
||||
"Confirm Cancel Account" = "Confirm Cancel Account";
|
||||
"Ranking List" = "Ranking List";
|
||||
"Persona circle" = "Persona circle";
|
||||
"Clear" = "Clear";
|
||||
"Copy" = "Copy";
|
||||
"Report" = "Report报";
|
||||
"Thumbs Up" = "Thumbs Up";
|
||||
"Chatting" = "Chatting";
|
||||
|
||||
// Payment & IAP
|
||||
"Payment successful" = "Payment successful";
|
||||
@@ -188,10 +202,6 @@
|
||||
"测试" = "Test";
|
||||
"这里是设置内容占位" = "Settings content placeholder";
|
||||
"设置" = "Settings";
|
||||
"使用引导" = "Usage Guide";
|
||||
"螃蟹啊斯柯达积分卡" = "Crab points card";
|
||||
"❎不是自己的键盘" = "❎ Not our keyboard";
|
||||
"是自己的键盘" = "Our keyboard";
|
||||
|
||||
// English-only keys with Chinese equivalents
|
||||
"Change The Nickname" = "Change Nickname";
|
||||
@@ -202,3 +212,4 @@
|
||||
"Purchase pending approval." = "Purchase pending approval.";
|
||||
"Unable to obtain transaction payload." = "Unable to obtain transaction payload.";
|
||||
"Resume Purchase" = "Resume Purchase";
|
||||
"Downloading..." = "Downloading...";
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
"Network error" = "网络错误";
|
||||
"Saved" = "已保存";
|
||||
"Copy Success" = "复制成功";
|
||||
"Email Copy Success" = "Email Copy Success";
|
||||
|
||||
// 网络相关(英文 key)
|
||||
"Network unavailable" = "网络不可用";
|
||||
@@ -103,7 +104,6 @@
|
||||
"Please check this app's wireless-data permission or network connection in Settings." = "请在“设置”中检查本应用的无线数据权限或网络连接。";
|
||||
|
||||
// 权限与引导(英文 key)
|
||||
"Usage Guide" = "使用引导";
|
||||
"Turn on Allow Full Access to experience all features" = "开启【允许完全访问】,体验完整功能";
|
||||
"Allow Full Access" = "允许完全访问";
|
||||
"Follow: Settings → General → Keyboard → Keyboards → %@ → Allow Full Access" = "请按路径:设置→通用→键盘→键盘→%@→允许完全访问";
|
||||
@@ -142,6 +142,10 @@
|
||||
"Nickname" = "用户名";
|
||||
"Gender" = "性别";
|
||||
"User ID" = "用户ID";
|
||||
"Modify Gender" = "修改性别";
|
||||
"Male" = "男";
|
||||
"Female" = "女";
|
||||
"The Third Gender" = "第三性别";
|
||||
|
||||
" Paste Ta's Words" = " 粘贴TA的话";
|
||||
|
||||
@@ -168,9 +172,18 @@
|
||||
"Delete" = "删除";
|
||||
"Points\nMall" = "积分\n商城";
|
||||
"Log Out" = "退出";
|
||||
"Cancel Account" = "注销账户";
|
||||
"After cancellation, your account will be deactivated and local login data will be cleared. Continue?" = "注销后账号将被停用,并清除本地登录数据,是否继续?";
|
||||
"Please enter your password" = "请输入密码";
|
||||
"Cancel Account Notice" = "注销账户须知";
|
||||
"Confirm Cancel Account" = "确认注销";
|
||||
"Ranking List" = "排行榜";
|
||||
"Persona circle" = "圈子";
|
||||
"Clear" = "立刻清空";
|
||||
"Copy" = "复制";
|
||||
"Report" = "举报";
|
||||
"Thumbs Up" = "赞过";
|
||||
"Chatting" = "聊过";
|
||||
|
||||
// 皮肤示例名称
|
||||
|
||||
@@ -191,10 +204,6 @@
|
||||
"Test" = "测试";
|
||||
"暂无数据" = "暂无数据"; // 已有英文 key "No data"
|
||||
"Settings" = "设置";
|
||||
"Usage Guide" = "使用引导";
|
||||
"螃蟹啊斯柯达积分卡" = "螃蟹啊斯柯达积分卡";
|
||||
"❎不是自己的键盘" = "❎不是自己的键盘";
|
||||
"是自己的键盘" = "是自己的键盘";
|
||||
|
||||
|
||||
"Change The Nickname" = "修改名称";
|
||||
@@ -205,3 +214,4 @@
|
||||
"Purchase pending approval." = "购买等待确认";
|
||||
"Unable to obtain transaction payload." = "无法获取交易凭据";
|
||||
"Resume Purchase" = "恢复购买";
|
||||
"Downloading..." = "正在下载...";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<integer>11</integer>
|
||||
<key>logs</key>
|
||||
<dict>
|
||||
<key>800731DD-5595-43EC-B207-003BAB7870CE</key>
|
||||
<key>8E255990-30E2-4820-98AE-0559FF9CB504</key>
|
||||
<dict>
|
||||
<key>className</key>
|
||||
<string>IDECommandLineBuildLog</string>
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>domainType</key>
|
||||
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
|
||||
<key>fileName</key>
|
||||
<string>800731DD-5595-43EC-B207-003BAB7870CE.xcactivitylog</string>
|
||||
<string>8E255990-30E2-4820-98AE-0559FF9CB504.xcactivitylog</string>
|
||||
<key>hasPrimaryLog</key>
|
||||
<true/>
|
||||
<key>primaryObservable</key>
|
||||
@@ -34,13 +34,49 @@
|
||||
<key>signature</key>
|
||||
<string>Resolve Packages</string>
|
||||
<key>timeStartedRecording</key>
|
||||
<real>788359220.39837599</real>
|
||||
<real>791987515.57282996</real>
|
||||
<key>timeStoppedRecording</key>
|
||||
<real>788359220.51885402</real>
|
||||
<real>791987515.86043</real>
|
||||
<key>title</key>
|
||||
<string>Resolve Packages</string>
|
||||
<key>uniqueIdentifier</key>
|
||||
<string>800731DD-5595-43EC-B207-003BAB7870CE</string>
|
||||
<string>8E255990-30E2-4820-98AE-0559FF9CB504</string>
|
||||
</dict>
|
||||
<key>C9B2536F-0143-4FD7-90C7-C8F37E9F59D6</key>
|
||||
<dict>
|
||||
<key>className</key>
|
||||
<string>IDECommandLineBuildLog</string>
|
||||
<key>documentTypeString</key>
|
||||
<string><nil></string>
|
||||
<key>domainType</key>
|
||||
<string>Xcode.IDEActivityLogDomainType.BuildLog</string>
|
||||
<key>fileName</key>
|
||||
<string>C9B2536F-0143-4FD7-90C7-C8F37E9F59D6.xcactivitylog</string>
|
||||
<key>hasPrimaryLog</key>
|
||||
<true/>
|
||||
<key>primaryObservable</key>
|
||||
<dict>
|
||||
<key>highLevelStatus</key>
|
||||
<string>E</string>
|
||||
<key>totalNumberOfAnalyzerIssues</key>
|
||||
<integer>0</integer>
|
||||
<key>totalNumberOfErrors</key>
|
||||
<integer>1</integer>
|
||||
<key>totalNumberOfTestFailures</key>
|
||||
<integer>0</integer>
|
||||
<key>totalNumberOfWarnings</key>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
<key>signature</key>
|
||||
<string>Resolve Packages</string>
|
||||
<key>timeStartedRecording</key>
|
||||
<real>791987467.25816</real>
|
||||
<key>timeStoppedRecording</key>
|
||||
<real>791987467.36928797</real>
|
||||
<key>title</key>
|
||||
<string>Resolve Packages</string>
|
||||
<key>uniqueIdentifier</key>
|
||||
<string>C9B2536F-0143-4FD7-90C7-C8F37E9F59D6</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
1
_spm/checkouts/swift-collections
Submodule
@@ -8,8 +8,14 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
04050ECB2F10FB8F008051EB /* UIImage+KBColor.m in Sources */ = {isa = PBXBuildFile; fileRef = 047C655D2EBCD5B20035E841 /* UIImage+KBColor.m */; };
|
||||
040B620F2F4BF2560099DEAC /* KeyboardViewController+Theme.m in Sources */ = {isa = PBXBuildFile; fileRef = 040B620C2F4BF2560099DEAC /* KeyboardViewController+Theme.m */; };
|
||||
040B62102F4BF2560099DEAC /* KeyboardViewController+Chat.m in Sources */ = {isa = PBXBuildFile; fileRef = 040B62052F4BF2560099DEAC /* KeyboardViewController+Chat.m */; };
|
||||
040B62112F4BF2560099DEAC /* KeyboardViewController+Panels.m in Sources */ = {isa = PBXBuildFile; fileRef = 040B62082F4BF2560099DEAC /* KeyboardViewController+Panels.m */; };
|
||||
040B62122F4BF2560099DEAC /* KeyboardViewController+Layout.m in Sources */ = {isa = PBXBuildFile; fileRef = 040B62062F4BF2560099DEAC /* KeyboardViewController+Layout.m */; };
|
||||
040B62132F4BF2560099DEAC /* KeyboardViewController+UI.m in Sources */ = {isa = PBXBuildFile; fileRef = 040B620D2F4BF2560099DEAC /* KeyboardViewController+UI.m */; };
|
||||
040B62142F4BF2560099DEAC /* KeyboardViewController+Subscription.m in Sources */ = {isa = PBXBuildFile; fileRef = 040B620A2F4BF2560099DEAC /* KeyboardViewController+Subscription.m */; };
|
||||
040B62162F4BF2560099DEAC /* KeyboardViewController+Suggestions.m in Sources */ = {isa = PBXBuildFile; fileRef = 040B620B2F4BF2560099DEAC /* KeyboardViewController+Suggestions.m */; };
|
||||
041007D22ECE012000D203BB /* KBSkinIconMap.strings in Resources */ = {isa = PBXBuildFile; fileRef = 041007D12ECE012000D203BB /* KBSkinIconMap.strings */; };
|
||||
041007D42ECE012500D203BB /* 002.zip in Resources */ = {isa = PBXBuildFile; fileRef = 041007D32ECE012500D203BB /* 002.zip */; };
|
||||
04122F5D2EC5E5A900EF7AB3 /* KBLoginVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 04122F5B2EC5E5A900EF7AB3 /* KBLoginVM.m */; };
|
||||
04122F622EC5F41D00EF7AB3 /* KBUser.m in Sources */ = {isa = PBXBuildFile; fileRef = 04122F612EC5F41D00EF7AB3 /* KBUser.m */; };
|
||||
04122F7E2EC5FC5500EF7AB3 /* KBJfPayCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04122F7C2EC5FC5500EF7AB3 /* KBJfPayCell.m */; };
|
||||
@@ -28,7 +34,6 @@
|
||||
04286A032ECB0A1600CE730C /* KBSexSelVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 04286A022ECB0A1600CE730C /* KBSexSelVC.m */; };
|
||||
04286A062ECC81B200CE730C /* KBSkinService.m in Sources */ = {isa = PBXBuildFile; fileRef = 04286A052ECC81B200CE730C /* KBSkinService.m */; };
|
||||
04286A0B2ECD88B400CE730C /* KeyboardAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 04286A0A2ECD88B400CE730C /* KeyboardAssets.xcassets */; };
|
||||
04286A0F2ECDA71B00CE730C /* 001.zip in Resources */ = {isa = PBXBuildFile; fileRef = 04286A0E2ECDA71B00CE730C /* 001.zip */; };
|
||||
04286A132ECDEBF900CE730C /* KBSkinIconMap.strings in Resources */ = {isa = PBXBuildFile; fileRef = 041007D12ECE012000D203BB /* KBSkinIconMap.strings */; };
|
||||
043FBCD22EAF97630036AFE1 /* KBPermissionViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 04C6EAE12EAF940F0089C901 /* KBPermissionViewController.m */; };
|
||||
0450AA742EF013D000B6AF06 /* KBEmojiCollectionCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 0450AA732EF013D000B6AF06 /* KBEmojiCollectionCell.m */; };
|
||||
@@ -53,20 +58,12 @@
|
||||
0459D1B42EBA284C00F2D189 /* KBSkinCenterVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0459D1B32EBA284C00F2D189 /* KBSkinCenterVC.m */; };
|
||||
0459D1B72EBA287900F2D189 /* KBSkinManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 0459D1B62EBA287900F2D189 /* KBSkinManager.m */; };
|
||||
0459D1B82EBA287900F2D189 /* KBSkinManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 0459D1B62EBA287900F2D189 /* KBSkinManager.m */; };
|
||||
0460866B2F18D75500757C95 /* ai_test.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 0460866A2F18D75500757C95 /* ai_test.m4a */; };
|
||||
045ED5212F52AF9200131114 /* KBCancelAccountVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 045ED5202F52AF9200131114 /* KBCancelAccountVC.m */; };
|
||||
046086752F191CC700757C95 /* AI技术分析.txt in Resources */ = {isa = PBXBuildFile; fileRef = 046086742F191CC700757C95 /* AI技术分析.txt */; };
|
||||
0460869A2F19238500757C95 /* KBAiWaveformView.m in Sources */ = {isa = PBXBuildFile; fileRef = 046086992F19238500757C95 /* KBAiWaveformView.m */; };
|
||||
0460869C2F19238500757C95 /* KBAiRecordButton.m in Sources */ = {isa = PBXBuildFile; fileRef = 046086972F19238500757C95 /* KBAiRecordButton.m */; };
|
||||
046086B12F19239B00757C95 /* SubtitleSync.m in Sources */ = {isa = PBXBuildFile; fileRef = 046086AC2F19239B00757C95 /* SubtitleSync.m */; };
|
||||
046086B22F19239B00757C95 /* TTSServiceClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 046086B02F19239B00757C95 /* TTSServiceClient.m */; };
|
||||
046086B32F19239B00757C95 /* AudioSessionManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 046086A22F19239B00757C95 /* AudioSessionManager.m */; };
|
||||
046086B42F19239B00757C95 /* LLMStreamClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 046086A82F19239B00757C95 /* LLMStreamClient.m */; };
|
||||
046086B52F19239B00757C95 /* Segmenter.m in Sources */ = {isa = PBXBuildFile; fileRef = 046086AA2F19239B00757C95 /* Segmenter.m */; };
|
||||
046086B62F19239B00757C95 /* TTSPlaybackPipeline.m in Sources */ = {isa = PBXBuildFile; fileRef = 046086AE2F19239B00757C95 /* TTSPlaybackPipeline.m */; };
|
||||
046086B72F19239B00757C95 /* ConversationOrchestrator.m in Sources */ = {isa = PBXBuildFile; fileRef = 046086A62F19239B00757C95 /* ConversationOrchestrator.m */; };
|
||||
046086B82F19239B00757C95 /* ASRStreamClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 0460869E2F19239B00757C95 /* ASRStreamClient.m */; };
|
||||
046086B92F19239B00757C95 /* AudioCaptureManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 046086A02F19239B00757C95 /* AudioCaptureManager.m */; };
|
||||
046086BA2F19239B00757C95 /* AudioStreamPlayer.m in Sources */ = {isa = PBXBuildFile; fileRef = 046086A42F19239B00757C95 /* AudioStreamPlayer.m */; };
|
||||
046086BD2F1A039F00757C95 /* KBAICommentView.m in Sources */ = {isa = PBXBuildFile; fileRef = 046086BC2F1A039F00757C95 /* KBAICommentView.m */; };
|
||||
046086CB2F1A092500757C95 /* comments_mock.json in Resources */ = {isa = PBXBuildFile; fileRef = 046086C62F1A092500757C95 /* comments_mock.json */; };
|
||||
046086CC2F1A092500757C95 /* KBAIReplyModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 046086CA2F1A092500757C95 /* KBAIReplyModel.m */; };
|
||||
@@ -76,9 +73,7 @@
|
||||
046086D82F1A093400757C95 /* KBAIReplyCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 046086D52F1A093400757C95 /* KBAIReplyCell.m */; };
|
||||
046086D92F1A093400757C95 /* KBAICommentHeaderView.m in Sources */ = {isa = PBXBuildFile; fileRef = 046086D12F1A093400757C95 /* KBAICommentHeaderView.m */; };
|
||||
046131142ECF454500A6FADF /* KBKeyPreviewView.m in Sources */ = {isa = PBXBuildFile; fileRef = 046131132ECF454500A6FADF /* KBKeyPreviewView.m */; };
|
||||
0477BDF02EBB76E30055D639 /* HomeSheetVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0477BDEF2EBB76E30055D639 /* HomeSheetVC.m */; };
|
||||
0477BDF32EBB7B850055D639 /* KBDirectionIndicatorView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0477BDF22EBB7B850055D639 /* KBDirectionIndicatorView.m */; };
|
||||
0477BDF72EBC63A80055D639 /* KBTestVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0477BDF62EBC63A80055D639 /* KBTestVC.m */; };
|
||||
0477BDFA2EBC66340055D639 /* HomeHeadView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0477BDF92EBC66340055D639 /* HomeHeadView.m */; };
|
||||
0477BDFD2EBC6A170055D639 /* HomeHotVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0477BDFC2EBC6A170055D639 /* HomeHotVC.m */; };
|
||||
0477BE002EBC6A330055D639 /* HomeRankVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0477BDFF2EBC6A330055D639 /* HomeRankVC.m */; };
|
||||
@@ -90,7 +85,6 @@
|
||||
04791F952ED48028004E8522 /* KBFeedBackVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 04791F942ED48028004E8522 /* KBFeedBackVC.m */; };
|
||||
04791F982ED49CE7004E8522 /* KBFont.m in Sources */ = {isa = PBXBuildFile; fileRef = 04791F972ED49CE7004E8522 /* KBFont.m */; };
|
||||
04791F992ED49CE7004E8522 /* KBFont.m in Sources */ = {isa = PBXBuildFile; fileRef = 04791F972ED49CE7004E8522 /* KBFont.m */; };
|
||||
04791FF72ED5B985004E8522 /* Christmas.zip in Resources */ = {isa = PBXBuildFile; fileRef = 04791FF62ED5B985004E8522 /* Christmas.zip */; };
|
||||
04791FFC2ED71D17004E8522 /* UIColor+Extension.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC95E42EB220B5007BD342 /* UIColor+Extension.m */; };
|
||||
04791FFF2ED830FA004E8522 /* KBKeyboardMaskView.m in Sources */ = {isa = PBXBuildFile; fileRef = 04791FFE2ED830FA004E8522 /* KBKeyboardMaskView.m */; };
|
||||
047920072ED86ABC004E8522 /* kb_guide_keyboard.gif in Resources */ = {isa = PBXBuildFile; fileRef = 047920062ED86ABC004E8522 /* kb_guide_keyboard.gif */; };
|
||||
@@ -140,17 +134,15 @@
|
||||
048FFD112F27432D005D62AE /* KBPersonaPageModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD0F2F27432D005D62AE /* KBPersonaPageModel.m */; };
|
||||
048FFD142F274342005D62AE /* KBPersonaChatCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD132F274342005D62AE /* KBPersonaChatCell.m */; };
|
||||
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 */; };
|
||||
A1B2C9302FCA000100000001 /* KBChatLimitPopView.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD232F28A836005D62AE /* KBChatLimitPopView.m */; };
|
||||
048FFD272F28C6CF005D62AE /* KBImagePositionButton.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD262F28C6CF005D62AE /* KBImagePositionButton.m */; };
|
||||
048FFD2A2F28E99A005D62AE /* KBCommentModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD292F28E99A005D62AE /* KBCommentModel.m */; };
|
||||
048FFD2D2F29F356005D62AE /* KBAIMessageVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD2C2F29F356005D62AE /* KBAIMessageVC.m */; };
|
||||
048FFD302F29F3C3005D62AE /* KBAIMessageZanVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD2F2F29F3C3005D62AE /* KBAIMessageZanVC.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 */; };
|
||||
048FFD272F28C6CF005D62AE /* KBImagePositionButton.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD262F28C6CF005D62AE /* KBImagePositionButton.m */; };
|
||||
048FFD2A2F28E99A005D62AE /* KBCommentModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD292F28E99A005D62AE /* KBCommentModel.m */; };
|
||||
048FFD2D2F29F356005D62AE /* KBAIMessageVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD2C2F29F356005D62AE /* KBAIMessageVC.m */; };
|
||||
048FFD302F29F3C3005D62AE /* KBAIMessageZanVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD2F2F29F3C3005D62AE /* KBAIMessageZanVC.m */; };
|
||||
048FFD332F29F3D2005D62AE /* KBAIMessageChatingVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD322F29F3D2005D62AE /* KBAIMessageChatingVC.m */; };
|
||||
048FFD342F29F400005D62AE /* KBAIMessageListVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD362F29F400005D62AE /* KBAIMessageListVC.m */; };
|
||||
048FFD362F29F88E005D62AE /* AIMessageVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD352F29F88E005D62AE /* AIMessageVM.m */; };
|
||||
048FFD372F29F410005D62AE /* KBAIMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD392F29F410005D62AE /* KBAIMessageCell.m */; };
|
||||
048FFD392F2A24C5005D62AE /* KBAIChatMessageCacheManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD382F2A24C5005D62AE /* KBAIChatMessageCacheManager.m */; };
|
||||
048FFD3C2F29F500005D62AE /* KBLikedCompanionModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD3B2F29F500005D62AE /* KBLikedCompanionModel.m */; };
|
||||
@@ -209,6 +201,9 @@
|
||||
04A9FE202EB893F10020DB6D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 04A9FE1E2EB893F10020DB6D /* Localizable.strings */; };
|
||||
04A9FE212EB893F10020DB6D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 04A9FE1E2EB893F10020DB6D /* Localizable.strings */; };
|
||||
04B5A1A22EEFA12300AAAAAA /* KBPayProductModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 04B5A1A12EEFA12300AAAAAA /* KBPayProductModel.m */; };
|
||||
04BBF89D2F3ACD8800B1FBB2 /* KBKeyboardStressTestVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 04BBF89A2F3ACD8800B1FBB2 /* KBKeyboardStressTestVC.m */; };
|
||||
04BBF89E2F3ACD8800B1FBB2 /* KBTestVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 04BBF89C2F3ACD8800B1FBB2 /* KBTestVC.m */; };
|
||||
04BBF9002F3C97CB00B1FBB2 /* DeepgramWebSocketClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 04BBF8FF2F3C97CB00B1FBB2 /* DeepgramWebSocketClient.m */; };
|
||||
04C6EABA2EAF86530089C901 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 04C6EAAE2EAF86530089C901 /* Assets.xcassets */; };
|
||||
04C6EABC2EAF86530089C901 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 04C6EAB12EAF86530089C901 /* LaunchScreen.storyboard */; };
|
||||
04C6EABD2EAF86530089C901 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 04C6EAB42EAF86530089C901 /* Main.storyboard */; };
|
||||
@@ -222,10 +217,7 @@
|
||||
04D1F6B32EDFF10A00B12345 /* KBSkinInstallBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = 04D1F6B12EDFF10A00B12345 /* KBSkinInstallBridge.m */; };
|
||||
04E0383E2F1A7C30002CA5A0 /* KBCustomTabBar.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0383D2F1A7C30002CA5A0 /* KBCustomTabBar.m */; };
|
||||
04E038D82F20BFFB002CA5A0 /* websocket-api.md in Resources */ = {isa = PBXBuildFile; fileRef = 04E038D72F20BFFB002CA5A0 /* websocket-api.md */; };
|
||||
04E038DD2F20C420002CA5A0 /* VoiceChatStreamingManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038DA2F20C420002CA5A0 /* VoiceChatStreamingManager.m */; };
|
||||
04E038DE2F20C420002CA5A0 /* VoiceChatWebSocketClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038DC2F20C420002CA5A0 /* VoiceChatWebSocketClient.m */; };
|
||||
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 */; };
|
||||
04E038EF2F21F0EC002CA5A0 /* AiVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038EE2F21F0EC002CA5A0 /* AiVM.m */; };
|
||||
04E0394B2F236E75002CA5A0 /* KBChatUserMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */; };
|
||||
@@ -238,6 +230,8 @@
|
||||
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 */; };
|
||||
04E2277D2F516EBD001A8F14 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 04E2277C2F516EBD001A8F14 /* PrivacyInfo.xcprivacy */; };
|
||||
04E2277F2F516ED3001A8F14 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 04E2277E2F516ED3001A8F14 /* PrivacyInfo.xcprivacy */; };
|
||||
04F4C0AA2F32274000E8F08C /* KBPayMainVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 04F4C0A92F32274000E8F08C /* KBPayMainVC.m */; };
|
||||
04F4C0AD2F32288600E8F08C /* KBPaySvipVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 04F4C0AC2F32288600E8F08C /* KBPaySvipVC.m */; };
|
||||
04F4C0B02F322EF200E8F08C /* PagingViewTableHeaderView.m in Sources */ = {isa = PBXBuildFile; fileRef = 04F4C0AF2F322EF200E8F08C /* PagingViewTableHeaderView.m */; };
|
||||
@@ -302,6 +296,7 @@
|
||||
A1B2C9262FC9000100000001 /* KBChatMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C9212FC9000100000001 /* KBChatMessage.m */; };
|
||||
A1B2C9272FC9000100000001 /* KBChatMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C9232FC9000100000001 /* KBChatMessageCell.m */; };
|
||||
A1B2C9282FC9000100000001 /* KBChatPanelView.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C9252FC9000100000001 /* KBChatPanelView.m */; };
|
||||
A1B2C9302FCA000100000001 /* KBChatLimitPopView.m in Sources */ = {isa = PBXBuildFile; fileRef = 048FFD232F28A836005D62AE /* KBChatLimitPopView.m */; };
|
||||
A1B2D7022EB8C00100000001 /* KBLangTestVC.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2D7012EB8C00100000001 /* KBLangTestVC.m */; };
|
||||
A1B2E1012EBC7AAA00000001 /* KBTopThreeView.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2E0022EBC7AAA00000001 /* KBTopThreeView.m */; };
|
||||
A1B2E1022EBC7AAA00000001 /* HomeHotCell.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2E0042EBC7AAA00000001 /* HomeHotCell.m */; };
|
||||
@@ -341,8 +336,16 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
040B62052F4BF2560099DEAC /* KeyboardViewController+Chat.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "KeyboardViewController+Chat.m"; sourceTree = "<group>"; };
|
||||
040B62062F4BF2560099DEAC /* KeyboardViewController+Layout.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "KeyboardViewController+Layout.m"; sourceTree = "<group>"; };
|
||||
040B62072F4BF2560099DEAC /* KeyboardViewController+Legacy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "KeyboardViewController+Legacy.m"; sourceTree = "<group>"; };
|
||||
040B62082F4BF2560099DEAC /* KeyboardViewController+Panels.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "KeyboardViewController+Panels.m"; sourceTree = "<group>"; };
|
||||
040B62092F4BF2560099DEAC /* KeyboardViewController+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "KeyboardViewController+Private.h"; sourceTree = "<group>"; };
|
||||
040B620A2F4BF2560099DEAC /* KeyboardViewController+Subscription.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "KeyboardViewController+Subscription.m"; sourceTree = "<group>"; };
|
||||
040B620B2F4BF2560099DEAC /* KeyboardViewController+Suggestions.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "KeyboardViewController+Suggestions.m"; sourceTree = "<group>"; };
|
||||
040B620C2F4BF2560099DEAC /* KeyboardViewController+Theme.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "KeyboardViewController+Theme.m"; sourceTree = "<group>"; };
|
||||
040B620D2F4BF2560099DEAC /* KeyboardViewController+UI.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "KeyboardViewController+UI.m"; sourceTree = "<group>"; };
|
||||
041007D12ECE012000D203BB /* KBSkinIconMap.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = KBSkinIconMap.strings; sourceTree = "<group>"; };
|
||||
041007D32ECE012500D203BB /* 002.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = 002.zip; sourceTree = "<group>"; };
|
||||
04122F592EC5D40000EF7AB3 /* KBAPI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAPI.h; sourceTree = "<group>"; };
|
||||
04122F5A2EC5E5A900EF7AB3 /* KBLoginVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBLoginVM.h; sourceTree = "<group>"; };
|
||||
04122F5B2EC5E5A900EF7AB3 /* KBLoginVM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBLoginVM.m; sourceTree = "<group>"; };
|
||||
@@ -377,7 +380,6 @@
|
||||
04286A042ECC81B200CE730C /* KBSkinService.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBSkinService.h; sourceTree = "<group>"; };
|
||||
04286A052ECC81B200CE730C /* KBSkinService.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBSkinService.m; sourceTree = "<group>"; };
|
||||
04286A0A2ECD88B400CE730C /* KeyboardAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = KeyboardAssets.xcassets; sourceTree = "<group>"; };
|
||||
04286A0E2ECDA71B00CE730C /* 001.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = 001.zip; sourceTree = "<group>"; };
|
||||
0450AA722EF013D000B6AF06 /* KBEmojiCollectionCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBEmojiCollectionCell.h; sourceTree = "<group>"; };
|
||||
0450AA732EF013D000B6AF06 /* KBEmojiCollectionCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBEmojiCollectionCell.m; sourceTree = "<group>"; };
|
||||
0450AAE02EF03D5100B6AF06 /* keyBoard-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "keyBoard-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
@@ -403,32 +405,17 @@
|
||||
0459D1B32EBA284C00F2D189 /* KBSkinCenterVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBSkinCenterVC.m; sourceTree = "<group>"; };
|
||||
0459D1B52EBA287900F2D189 /* KBSkinManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBSkinManager.h; sourceTree = "<group>"; };
|
||||
0459D1B62EBA287900F2D189 /* KBSkinManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBSkinManager.m; sourceTree = "<group>"; };
|
||||
0460866A2F18D75500757C95 /* ai_test.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = ai_test.m4a; sourceTree = "<group>"; };
|
||||
045ED51F2F52AF9200131114 /* KBCancelAccountVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBCancelAccountVC.h; sourceTree = "<group>"; };
|
||||
045ED5202F52AF9200131114 /* KBCancelAccountVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBCancelAccountVC.m; sourceTree = "<group>"; };
|
||||
046086742F191CC700757C95 /* AI技术分析.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "AI技术分析.txt"; sourceTree = "<group>"; };
|
||||
046086962F19238500757C95 /* KBAiRecordButton.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAiRecordButton.h; sourceTree = "<group>"; };
|
||||
046086972F19238500757C95 /* KBAiRecordButton.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAiRecordButton.m; sourceTree = "<group>"; };
|
||||
046086982F19238500757C95 /* KBAiWaveformView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAiWaveformView.h; sourceTree = "<group>"; };
|
||||
046086992F19238500757C95 /* KBAiWaveformView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAiWaveformView.m; sourceTree = "<group>"; };
|
||||
0460869D2F19239B00757C95 /* ASRStreamClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ASRStreamClient.h; sourceTree = "<group>"; };
|
||||
0460869E2F19239B00757C95 /* ASRStreamClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ASRStreamClient.m; sourceTree = "<group>"; };
|
||||
0460869F2F19239B00757C95 /* AudioCaptureManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AudioCaptureManager.h; sourceTree = "<group>"; };
|
||||
046086A02F19239B00757C95 /* AudioCaptureManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AudioCaptureManager.m; sourceTree = "<group>"; };
|
||||
046086A12F19239B00757C95 /* AudioSessionManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AudioSessionManager.h; sourceTree = "<group>"; };
|
||||
046086A22F19239B00757C95 /* AudioSessionManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AudioSessionManager.m; sourceTree = "<group>"; };
|
||||
046086A32F19239B00757C95 /* AudioStreamPlayer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AudioStreamPlayer.h; sourceTree = "<group>"; };
|
||||
046086A42F19239B00757C95 /* AudioStreamPlayer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AudioStreamPlayer.m; sourceTree = "<group>"; };
|
||||
046086A52F19239B00757C95 /* ConversationOrchestrator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ConversationOrchestrator.h; sourceTree = "<group>"; };
|
||||
046086A62F19239B00757C95 /* ConversationOrchestrator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ConversationOrchestrator.m; sourceTree = "<group>"; };
|
||||
046086A72F19239B00757C95 /* LLMStreamClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LLMStreamClient.h; sourceTree = "<group>"; };
|
||||
046086A82F19239B00757C95 /* LLMStreamClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LLMStreamClient.m; sourceTree = "<group>"; };
|
||||
046086A92F19239B00757C95 /* Segmenter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Segmenter.h; sourceTree = "<group>"; };
|
||||
046086AA2F19239B00757C95 /* Segmenter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Segmenter.m; sourceTree = "<group>"; };
|
||||
046086AB2F19239B00757C95 /* SubtitleSync.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SubtitleSync.h; sourceTree = "<group>"; };
|
||||
046086AC2F19239B00757C95 /* SubtitleSync.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SubtitleSync.m; sourceTree = "<group>"; };
|
||||
046086AD2F19239B00757C95 /* TTSPlaybackPipeline.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TTSPlaybackPipeline.h; sourceTree = "<group>"; };
|
||||
046086AE2F19239B00757C95 /* TTSPlaybackPipeline.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TTSPlaybackPipeline.m; sourceTree = "<group>"; };
|
||||
046086AF2F19239B00757C95 /* TTSServiceClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TTSServiceClient.h; sourceTree = "<group>"; };
|
||||
046086B02F19239B00757C95 /* TTSServiceClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TTSServiceClient.m; sourceTree = "<group>"; };
|
||||
046086BB2F1A039F00757C95 /* KBAICommentView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAICommentView.h; sourceTree = "<group>"; };
|
||||
046086BC2F1A039F00757C95 /* KBAICommentView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAICommentView.m; sourceTree = "<group>"; };
|
||||
046086C62F1A092500757C95 /* comments_mock.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = comments_mock.json; sourceTree = "<group>"; };
|
||||
@@ -446,12 +433,8 @@
|
||||
046086D52F1A093400757C95 /* KBAIReplyCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAIReplyCell.m; sourceTree = "<group>"; };
|
||||
046131122ECF454500A6FADF /* KBKeyPreviewView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBKeyPreviewView.h; sourceTree = "<group>"; };
|
||||
046131132ECF454500A6FADF /* KBKeyPreviewView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBKeyPreviewView.m; sourceTree = "<group>"; };
|
||||
0477BDEE2EBB76E30055D639 /* HomeSheetVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HomeSheetVC.h; sourceTree = "<group>"; };
|
||||
0477BDEF2EBB76E30055D639 /* HomeSheetVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HomeSheetVC.m; sourceTree = "<group>"; };
|
||||
0477BDF12EBB7B850055D639 /* KBDirectionIndicatorView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBDirectionIndicatorView.h; sourceTree = "<group>"; };
|
||||
0477BDF22EBB7B850055D639 /* KBDirectionIndicatorView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBDirectionIndicatorView.m; sourceTree = "<group>"; };
|
||||
0477BDF52EBC63A80055D639 /* KBTestVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBTestVC.h; sourceTree = "<group>"; };
|
||||
0477BDF62EBC63A80055D639 /* KBTestVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBTestVC.m; sourceTree = "<group>"; };
|
||||
0477BDF82EBC66340055D639 /* HomeHeadView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HomeHeadView.h; sourceTree = "<group>"; };
|
||||
0477BDF92EBC66340055D639 /* HomeHeadView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HomeHeadView.m; sourceTree = "<group>"; };
|
||||
0477BDFB2EBC6A170055D639 /* HomeHotVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HomeHotVC.h; sourceTree = "<group>"; };
|
||||
@@ -470,7 +453,6 @@
|
||||
04791F942ED48028004E8522 /* KBFeedBackVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBFeedBackVC.m; sourceTree = "<group>"; };
|
||||
04791F962ED49CE7004E8522 /* KBFont.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBFont.h; sourceTree = "<group>"; };
|
||||
04791F972ED49CE7004E8522 /* KBFont.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBFont.m; sourceTree = "<group>"; };
|
||||
04791FF62ED5B985004E8522 /* Christmas.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = Christmas.zip; sourceTree = "<group>"; };
|
||||
04791FFD2ED830FA004E8522 /* KBKeyboardMaskView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBKeyboardMaskView.h; sourceTree = "<group>"; };
|
||||
04791FFE2ED830FA004E8522 /* KBKeyboardMaskView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBKeyboardMaskView.m; sourceTree = "<group>"; };
|
||||
047920062ED86ABC004E8522 /* kb_guide_keyboard.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = kb_guide_keyboard.gif; sourceTree = "<group>"; };
|
||||
@@ -580,9 +562,7 @@
|
||||
048FFD2F2F29F3C3005D62AE /* KBAIMessageZanVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAIMessageZanVC.m; sourceTree = "<group>"; };
|
||||
048FFD312F29F3D2005D62AE /* KBAIMessageChatingVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAIMessageChatingVC.h; sourceTree = "<group>"; };
|
||||
048FFD322F29F3D2005D62AE /* KBAIMessageChatingVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAIMessageChatingVC.m; sourceTree = "<group>"; };
|
||||
048FFD342F29F88E005D62AE /* AIMessageVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AIMessageVM.h; sourceTree = "<group>"; };
|
||||
048FFD352F29F400005D62AE /* KBAIMessageListVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAIMessageListVC.h; sourceTree = "<group>"; };
|
||||
048FFD352F29F88E005D62AE /* AIMessageVM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AIMessageVM.m; sourceTree = "<group>"; };
|
||||
048FFD362F29F400005D62AE /* KBAIMessageListVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAIMessageListVC.m; sourceTree = "<group>"; };
|
||||
048FFD372F2A24C5005D62AE /* KBAIChatMessageCacheManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAIChatMessageCacheManager.h; sourceTree = "<group>"; };
|
||||
048FFD382F29F410005D62AE /* KBAIMessageCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAIMessageCell.h; sourceTree = "<group>"; };
|
||||
@@ -685,6 +665,12 @@
|
||||
04A9FE1D2EB893F10020DB6D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
04B5A1A02EEFA12300AAAAAA /* KBPayProductModel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBPayProductModel.h; sourceTree = "<group>"; };
|
||||
04B5A1A12EEFA12300AAAAAA /* KBPayProductModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBPayProductModel.m; sourceTree = "<group>"; };
|
||||
04BBF8992F3ACD8800B1FBB2 /* KBKeyboardStressTestVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBKeyboardStressTestVC.h; sourceTree = "<group>"; };
|
||||
04BBF89A2F3ACD8800B1FBB2 /* KBKeyboardStressTestVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBKeyboardStressTestVC.m; sourceTree = "<group>"; };
|
||||
04BBF89B2F3ACD8800B1FBB2 /* KBTestVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBTestVC.h; sourceTree = "<group>"; };
|
||||
04BBF89C2F3ACD8800B1FBB2 /* KBTestVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBTestVC.m; sourceTree = "<group>"; };
|
||||
04BBF8FE2F3C97CB00B1FBB2 /* DeepgramWebSocketClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DeepgramWebSocketClient.h; sourceTree = "<group>"; };
|
||||
04BBF8FF2F3C97CB00B1FBB2 /* DeepgramWebSocketClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DeepgramWebSocketClient.m; sourceTree = "<group>"; };
|
||||
04C6EAAC2EAF86530089C901 /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = "<group>"; };
|
||||
04C6EAAD2EAF86530089C901 /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = "<group>"; };
|
||||
04C6EAAE2EAF86530089C901 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
@@ -708,15 +694,9 @@
|
||||
04E0383C2F1A7C30002CA5A0 /* KBCustomTabBar.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBCustomTabBar.h; sourceTree = "<group>"; };
|
||||
04E0383D2F1A7C30002CA5A0 /* KBCustomTabBar.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBCustomTabBar.m; sourceTree = "<group>"; };
|
||||
04E038D72F20BFFB002CA5A0 /* websocket-api.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "websocket-api.md"; sourceTree = "<group>"; };
|
||||
04E038D92F20C420002CA5A0 /* VoiceChatStreamingManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = VoiceChatStreamingManager.h; sourceTree = "<group>"; };
|
||||
04E038DA2F20C420002CA5A0 /* VoiceChatStreamingManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = VoiceChatStreamingManager.m; sourceTree = "<group>"; };
|
||||
04E038DB2F20C420002CA5A0 /* VoiceChatWebSocketClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = VoiceChatWebSocketClient.h; sourceTree = "<group>"; };
|
||||
04E038DC2F20C420002CA5A0 /* VoiceChatWebSocketClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = VoiceChatWebSocketClient.m; 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>"; };
|
||||
04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DeepgramStreamingManager.m; 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>"; };
|
||||
04E038ED2F21F0EC002CA5A0 /* AiVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AiVM.h; sourceTree = "<group>"; };
|
||||
04E038EE2F21F0EC002CA5A0 /* AiVM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AiVM.m; sourceTree = "<group>"; };
|
||||
04E039422F236E75002CA5A0 /* KBChatAssistantMessageCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatAssistantMessageCell.h; sourceTree = "<group>"; };
|
||||
@@ -736,6 +716,8 @@
|
||||
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>"; };
|
||||
04E161822F10E6470022C23B /* normal_them.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = normal_them.zip; sourceTree = "<group>"; };
|
||||
04E2277C2F516EBD001A8F14 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
04E2277E2F516ED3001A8F14 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
04F4C0A82F32274000E8F08C /* KBPayMainVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBPayMainVC.h; sourceTree = "<group>"; };
|
||||
04F4C0A92F32274000E8F08C /* KBPayMainVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBPayMainVC.m; sourceTree = "<group>"; };
|
||||
04F4C0AB2F32288600E8F08C /* KBPaySvipVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBPaySvipVC.h; sourceTree = "<group>"; };
|
||||
@@ -907,18 +889,31 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
040B620E2F4BF2560099DEAC /* KeyboardViewControllerHelp */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
040B62052F4BF2560099DEAC /* KeyboardViewController+Chat.m */,
|
||||
040B62062F4BF2560099DEAC /* KeyboardViewController+Layout.m */,
|
||||
040B62072F4BF2560099DEAC /* KeyboardViewController+Legacy.m */,
|
||||
040B62082F4BF2560099DEAC /* KeyboardViewController+Panels.m */,
|
||||
040B62092F4BF2560099DEAC /* KeyboardViewController+Private.h */,
|
||||
040B620A2F4BF2560099DEAC /* KeyboardViewController+Subscription.m */,
|
||||
040B620B2F4BF2560099DEAC /* KeyboardViewController+Suggestions.m */,
|
||||
040B620C2F4BF2560099DEAC /* KeyboardViewController+Theme.m */,
|
||||
040B620D2F4BF2560099DEAC /* KeyboardViewController+UI.m */,
|
||||
);
|
||||
path = KeyboardViewControllerHelp;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
041007D02ECE010100D203BB /* Resource */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0460866A2F18D75500757C95 /* ai_test.m4a */,
|
||||
04E161812F10E6470022C23B /* normal_hei_them.zip */,
|
||||
04E161822F10E6470022C23B /* normal_them.zip */,
|
||||
A1B2C3EC2F20000000000001 /* kb_words.txt */,
|
||||
A1B2C3F02F20000000000002 /* kb_keyboard_layout_config.json */,
|
||||
0498BDF42EEC50EE006CC1D5 /* emoji_categories.json */,
|
||||
041007D12ECE012000D203BB /* KBSkinIconMap.strings */,
|
||||
041007D32ECE012500D203BB /* 002.zip */,
|
||||
04791FF62ED5B985004E8522 /* Christmas.zip */,
|
||||
);
|
||||
path = Resource;
|
||||
sourceTree = "<group>";
|
||||
@@ -1177,42 +1172,20 @@
|
||||
0460866F2F191A5100757C95 /* VM */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0460869D2F19239B00757C95 /* ASRStreamClient.h */,
|
||||
0460869E2F19239B00757C95 /* ASRStreamClient.m */,
|
||||
0460869F2F19239B00757C95 /* AudioCaptureManager.h */,
|
||||
046086A02F19239B00757C95 /* AudioCaptureManager.m */,
|
||||
046086A12F19239B00757C95 /* AudioSessionManager.h */,
|
||||
046086A22F19239B00757C95 /* AudioSessionManager.m */,
|
||||
046086A32F19239B00757C95 /* AudioStreamPlayer.h */,
|
||||
046086A42F19239B00757C95 /* AudioStreamPlayer.m */,
|
||||
046086A52F19239B00757C95 /* ConversationOrchestrator.h */,
|
||||
046086A62F19239B00757C95 /* ConversationOrchestrator.m */,
|
||||
046086A72F19239B00757C95 /* LLMStreamClient.h */,
|
||||
046086A82F19239B00757C95 /* LLMStreamClient.m */,
|
||||
046086A92F19239B00757C95 /* Segmenter.h */,
|
||||
046086AA2F19239B00757C95 /* Segmenter.m */,
|
||||
046086AB2F19239B00757C95 /* SubtitleSync.h */,
|
||||
046086AC2F19239B00757C95 /* SubtitleSync.m */,
|
||||
046086AD2F19239B00757C95 /* TTSPlaybackPipeline.h */,
|
||||
046086AE2F19239B00757C95 /* TTSPlaybackPipeline.m */,
|
||||
046086AF2F19239B00757C95 /* TTSServiceClient.h */,
|
||||
046086B02F19239B00757C95 /* TTSServiceClient.m */,
|
||||
04E038D92F20C420002CA5A0 /* VoiceChatStreamingManager.h */,
|
||||
04E038DA2F20C420002CA5A0 /* VoiceChatStreamingManager.m */,
|
||||
04E038DB2F20C420002CA5A0 /* VoiceChatWebSocketClient.h */,
|
||||
04E038DC2F20C420002CA5A0 /* VoiceChatWebSocketClient.m */,
|
||||
04E038E42F20E877002CA5A0 /* DeepgramStreamingManager.h */,
|
||||
04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */,
|
||||
04BBF8FE2F3C97CB00B1FBB2 /* DeepgramWebSocketClient.h */,
|
||||
04BBF8FF2F3C97CB00B1FBB2 /* DeepgramWebSocketClient.m */,
|
||||
04E0B1002F300001002CA5A0 /* KBVoiceToTextManager.h */,
|
||||
04E0B1012F300001002CA5A0 /* KBVoiceToTextManager.m */,
|
||||
04E0B2002F300002002CA5A0 /* KBVoiceRecordManager.h */,
|
||||
04E0B2012F300002002CA5A0 /* KBVoiceRecordManager.m */,
|
||||
04E038E62F20E877002CA5A0 /* DeepgramWebSocketClient.h */,
|
||||
04E038E72F20E877002CA5A0 /* DeepgramWebSocketClient.m */,
|
||||
04E038ED2F21F0EC002CA5A0 /* AiVM.h */,
|
||||
04E038EE2F21F0EC002CA5A0 /* AiVM.m */,
|
||||
048FFD342F29F88E005D62AE /* AIMessageVM.h */,
|
||||
048FFD352F29F88E005D62AE /* AIMessageVM.m */,
|
||||
);
|
||||
path = VM;
|
||||
sourceTree = "<group>";
|
||||
@@ -1247,12 +1220,14 @@
|
||||
0477BE012EBC6D420055D639 /* FunctionTest */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0477BDF52EBC63A80055D639 /* KBTestVC.h */,
|
||||
0477BDF62EBC63A80055D639 /* KBTestVC.m */,
|
||||
A1B2D7002EB8C00100000001 /* KBLangTestVC.h */,
|
||||
A1B2D7012EB8C00100000001 /* KBLangTestVC.m */,
|
||||
0459D1B22EBA284C00F2D189 /* KBSkinCenterVC.h */,
|
||||
0459D1B32EBA284C00F2D189 /* KBSkinCenterVC.m */,
|
||||
04BBF8992F3ACD8800B1FBB2 /* KBKeyboardStressTestVC.h */,
|
||||
04BBF89A2F3ACD8800B1FBB2 /* KBKeyboardStressTestVC.m */,
|
||||
04BBF89B2F3ACD8800B1FBB2 /* KBTestVC.h */,
|
||||
04BBF89C2F3ACD8800B1FBB2 /* KBTestVC.m */,
|
||||
);
|
||||
path = FunctionTest;
|
||||
sourceTree = "<group>";
|
||||
@@ -1284,7 +1259,6 @@
|
||||
0479200A2ED87CEE004E8522 /* permiss_video.mp4 */,
|
||||
047920102ED98E7D004E8522 /* permiss_video_2.mp4 */,
|
||||
047920062ED86ABC004E8522 /* kb_guide_keyboard.gif */,
|
||||
04286A0E2ECDA71B00CE730C /* 001.zip */,
|
||||
);
|
||||
path = Resource;
|
||||
sourceTree = "<group>";
|
||||
@@ -1589,6 +1563,7 @@
|
||||
04C6EAB92EAF86530089C901 /* keyBoard */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
04E2277C2F516EBD001A8F14 /* PrivacyInfo.xcprivacy */,
|
||||
04FC95F52EB33B52007BD342 /* keyBoard.entitlements */,
|
||||
04FC95BF2EB1E3B1007BD342 /* Class */,
|
||||
04C6EAE32EAF942E0089C901 /* VC */,
|
||||
@@ -1609,6 +1584,7 @@
|
||||
04C6EAD72EAF870B0089C901 /* CustomKeyboard */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
04E2277E2F516ED3001A8F14 /* PrivacyInfo.xcprivacy */,
|
||||
0419C9632F2C7630002E86D3 /* VM */,
|
||||
041007D02ECE010100D203BB /* Resource */,
|
||||
0477BD942EBAFF4E0055D639 /* Utils */,
|
||||
@@ -1619,6 +1595,7 @@
|
||||
04C6EAD42EAF870B0089C901 /* Info.plist */,
|
||||
04C6EAD52EAF870B0089C901 /* KeyboardViewController.h */,
|
||||
04C6EAD62EAF870B0089C901 /* KeyboardViewController.m */,
|
||||
040B620E2F4BF2560099DEAC /* KeyboardViewControllerHelp */,
|
||||
04C6EADE2EAF8D680089C901 /* PrefixHeader.pch */,
|
||||
04286A0A2ECD88B400CE730C /* KeyboardAssets.xcassets */,
|
||||
);
|
||||
@@ -1752,8 +1729,6 @@
|
||||
children = (
|
||||
0477BE022EBC83130055D639 /* HomeMainVC.h */,
|
||||
0477BE032EBC83130055D639 /* HomeMainVC.m */,
|
||||
0477BDEE2EBB76E30055D639 /* HomeSheetVC.h */,
|
||||
0477BDEF2EBB76E30055D639 /* HomeSheetVC.m */,
|
||||
0477BDFB2EBC6A170055D639 /* HomeHotVC.h */,
|
||||
0477BDFC2EBC6A170055D639 /* HomeHotVC.m */,
|
||||
0477BDFE2EBC6A330055D639 /* HomeRankVC.h */,
|
||||
@@ -1870,6 +1845,8 @@
|
||||
A1F0C1A52F1234567890ABCD /* KBConsumptionRecordVC.m */,
|
||||
049FB2212EC311F900FAB05D /* KBPersonInfoVC.h */,
|
||||
049FB2222EC311F900FAB05D /* KBPersonInfoVC.m */,
|
||||
045ED51F2F52AF9200131114 /* KBCancelAccountVC.h */,
|
||||
045ED5202F52AF9200131114 /* KBCancelAccountVC.m */,
|
||||
04791F902ED48010004E8522 /* KBNoticeVC.h */,
|
||||
04791F912ED48010004E8522 /* KBNoticeVC.m */,
|
||||
04791F932ED48028004E8522 /* KBFeedBackVC.h */,
|
||||
@@ -2276,13 +2253,11 @@
|
||||
04E161832F10E6470022C23B /* normal_hei_them.zip in Resources */,
|
||||
04E161842F10E6470022C23B /* normal_them.zip in Resources */,
|
||||
04A9FE202EB893F10020DB6D /* Localizable.strings in Resources */,
|
||||
0460866B2F18D75500757C95 /* ai_test.m4a in Resources */,
|
||||
041007D42ECE012500D203BB /* 002.zip in Resources */,
|
||||
041007D22ECE012000D203BB /* KBSkinIconMap.strings in Resources */,
|
||||
04E2277F2F516ED3001A8F14 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
A1B2C3ED2F20000000000001 /* kb_words.txt in Resources */,
|
||||
A1B2C3F12F20000000000002 /* kb_keyboard_layout_config.json in Resources */,
|
||||
0498BDF52EEC50EE006CC1D5 /* emoji_categories.json in Resources */,
|
||||
04791FF72ED5B985004E8522 /* Christmas.zip in Resources */,
|
||||
04286A0B2ECD88B400CE730C /* KeyboardAssets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -2291,9 +2266,9 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
04286A0F2ECDA71B00CE730C /* 001.zip in Resources */,
|
||||
04E038D82F20BFFB002CA5A0 /* websocket-api.md in Resources */,
|
||||
0479200B2ED87CEE004E8522 /* permiss_video.mp4 in Resources */,
|
||||
04E2277D2F516EBD001A8F14 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
04C6EABA2EAF86530089C901 /* Assets.xcassets in Resources */,
|
||||
04A9FE212EB893F10020DB6D /* Localizable.strings in Resources */,
|
||||
047920072ED86ABC004E8522 /* kb_guide_keyboard.gif in Resources */,
|
||||
@@ -2386,15 +2361,22 @@
|
||||
0498BD862EE1BEC9006CC1D5 /* KBSignUtils.m in Sources */,
|
||||
04791FFC2ED71D17004E8522 /* UIColor+Extension.m in Sources */,
|
||||
0450AC4A2EF2C3ED00B6AF06 /* KBKeyboardSubscriptionOptionCell.m in Sources */,
|
||||
04A9FE0F2EB481100020DB6D /* KBHUD.m in Sources */,
|
||||
048FFD562F2B9C3D005D62AE /* KBChatAssistantCell.m in Sources */,
|
||||
048FFD572F2B9C3D005D62AE /* KBChatUserCell.m in Sources */,
|
||||
A1B2C9302FCA000100000001 /* KBChatLimitPopView.m in Sources */,
|
||||
04C6EADD2EAF8CEB0089C901 /* KBToolBar.m in Sources */,
|
||||
A1B2C9262FC9000100000001 /* KBChatMessage.m in Sources */,
|
||||
A1B2C9272FC9000100000001 /* KBChatMessageCell.m in Sources */,
|
||||
A1B2C9282FC9000100000001 /* KBChatPanelView.m in Sources */,
|
||||
A1B2C3EB2F20000000000001 /* KBSuggestionBarView.m in Sources */,
|
||||
040B620F2F4BF2560099DEAC /* KeyboardViewController+Theme.m in Sources */,
|
||||
040B62102F4BF2560099DEAC /* KeyboardViewController+Chat.m in Sources */,
|
||||
040B62112F4BF2560099DEAC /* KeyboardViewController+Panels.m in Sources */,
|
||||
040B62122F4BF2560099DEAC /* KeyboardViewController+Layout.m in Sources */,
|
||||
040B62132F4BF2560099DEAC /* KeyboardViewController+UI.m in Sources */,
|
||||
040B62142F4BF2560099DEAC /* KeyboardViewController+Subscription.m in Sources */,
|
||||
040B62162F4BF2560099DEAC /* KeyboardViewController+Suggestions.m in Sources */,
|
||||
04A9FE0F2EB481100020DB6D /* KBHUD.m in Sources */,
|
||||
048FFD562F2B9C3D005D62AE /* KBChatAssistantCell.m in Sources */,
|
||||
048FFD572F2B9C3D005D62AE /* KBChatUserCell.m in Sources */,
|
||||
A1B2C9302FCA000100000001 /* KBChatLimitPopView.m in Sources */,
|
||||
04C6EADD2EAF8CEB0089C901 /* KBToolBar.m in Sources */,
|
||||
A1B2C9262FC9000100000001 /* KBChatMessage.m in Sources */,
|
||||
A1B2C9272FC9000100000001 /* KBChatMessageCell.m in Sources */,
|
||||
A1B2C9282FC9000100000001 /* KBChatPanelView.m in Sources */,
|
||||
A1B2C3EB2F20000000000001 /* KBSuggestionBarView.m in Sources */,
|
||||
0419C9662F2C7693002E86D3 /* KBVM.m in Sources */,
|
||||
048FFD512F2B68F7005D62AE /* KBPersonaModel.m in Sources */,
|
||||
04FC95792EB09BC8007BD342 /* KBKeyBoardMainView.m in Sources */,
|
||||
@@ -2538,7 +2520,6 @@
|
||||
0498BD712EE02A41006CC1D5 /* KBForgetPwdNewPwdVC.m in Sources */,
|
||||
048908EF2EBF861800FABA60 /* KBSkinSectionTitleCell.m in Sources */,
|
||||
0450AAE22EF03D5100B6AF06 /* KBPerson.swift in Sources */,
|
||||
04E038E82F20E877002CA5A0 /* DeepgramWebSocketClient.m in Sources */,
|
||||
04E038E92F20E877002CA5A0 /* DeepgramStreamingManager.m in Sources */,
|
||||
04E0B1022F300001002CA5A0 /* KBVoiceToTextManager.m in Sources */,
|
||||
04E0B2022F300002002CA5A0 /* KBVoiceRecordManager.m in Sources */,
|
||||
@@ -2550,18 +2531,16 @@
|
||||
048908D22EBF611D00FABA60 /* KBHistoryMoreCell.m in Sources */,
|
||||
04FC95D82EB1EA16007BD342 /* BaseCell.m in Sources */,
|
||||
0498BD852EE1B255006CC1D5 /* KBSignUtils.m in Sources */,
|
||||
0477BDF72EBC63A80055D639 /* KBTestVC.m in Sources */,
|
||||
04122F7E2EC5FC5500EF7AB3 /* KBJfPayCell.m in Sources */,
|
||||
048FFD502F2B52E7005D62AE /* AIReportVC.m in Sources */,
|
||||
049FB2402EC4B6EF00FAB05D /* KBULBridgeNotification.m in Sources */,
|
||||
04BBF9002F3C97CB00B1FBB2 /* DeepgramWebSocketClient.m in Sources */,
|
||||
04FC95C92EB1E4C9007BD342 /* BaseNavigationController.m in Sources */,
|
||||
048908DD2EBF67EB00FABA60 /* KBSearchResultVC.m in Sources */,
|
||||
05A1B2D12F5B1A2B3C4D5E60 /* KBSearchVM.m in Sources */,
|
||||
05A1B2D22F5B1A2B3C4D5E60 /* KBSearchThemeModel.m in Sources */,
|
||||
047C65102EBCA8DD0035E841 /* HomeRankContentVC.m in Sources */,
|
||||
047C655C2EBCD0F80035E841 /* UIView+KBShadow.m in Sources */,
|
||||
04E038DD2F20C420002CA5A0 /* VoiceChatStreamingManager.m in Sources */,
|
||||
04E038DE2F20C420002CA5A0 /* VoiceChatWebSocketClient.m in Sources */,
|
||||
04F4C0B52F33053800E8F08C /* KBSvipBenefitCell.m in Sources */,
|
||||
04F4C0B62F33053800E8F08C /* KBSvipSubscribeCell.m in Sources */,
|
||||
049FB2262EC3136D00FAB05D /* KBPersonInfoItemCell.m in Sources */,
|
||||
@@ -2572,14 +2551,12 @@
|
||||
04FC95E52EB220B5007BD342 /* UIColor+Extension.m in Sources */,
|
||||
048908E02EBF73DC00FABA60 /* MySkinVC.m in Sources */,
|
||||
04F4C0AA2F32274000E8F08C /* KBPayMainVC.m in Sources */,
|
||||
048FFD362F29F88E005D62AE /* AIMessageVM.m in Sources */,
|
||||
048908F22EC047FD00FABA60 /* KBShopHeadView.m in Sources */,
|
||||
0498BD742EE02E3D006CC1D5 /* KBRegistVerEmailVC.m in Sources */,
|
||||
049FB2292EC31BB000FAB05D /* KBChangeNicknamePopView.m in Sources */,
|
||||
048908EC2EBF849300FABA60 /* KBSkinTagsContainerCell.m in Sources */,
|
||||
049FB2172EC20A6600FAB05D /* BMLongPressDragCellCollectionView.m in Sources */,
|
||||
04122F8E2EC6F83F00EF7AB3 /* PayVM.m in Sources */,
|
||||
0477BDF02EBB76E30055D639 /* HomeSheetVC.m in Sources */,
|
||||
048908E62EBF841B00FABA60 /* KBSkinDetailTagCell.m in Sources */,
|
||||
04FC97002EB30A00007BD342 /* KBGuideTopCell.m in Sources */,
|
||||
04791F982ED49CE7004E8522 /* KBFont.m in Sources */,
|
||||
@@ -2595,16 +2572,8 @@
|
||||
048FFD112F27432D005D62AE /* KBPersonaPageModel.m in Sources */,
|
||||
0498BD6B2EE025FC006CC1D5 /* KBForgetPwdVC.m in Sources */,
|
||||
048FFD182F2763A5005D62AE /* KBVoiceInputBar.m in Sources */,
|
||||
046086B12F19239B00757C95 /* SubtitleSync.m in Sources */,
|
||||
046086B22F19239B00757C95 /* TTSServiceClient.m in Sources */,
|
||||
046086B32F19239B00757C95 /* AudioSessionManager.m in Sources */,
|
||||
046086B42F19239B00757C95 /* LLMStreamClient.m in Sources */,
|
||||
046086B52F19239B00757C95 /* Segmenter.m in Sources */,
|
||||
046086B62F19239B00757C95 /* TTSPlaybackPipeline.m in Sources */,
|
||||
046086B72F19239B00757C95 /* ConversationOrchestrator.m in Sources */,
|
||||
046086B82F19239B00757C95 /* ASRStreamClient.m in Sources */,
|
||||
046086B92F19239B00757C95 /* AudioCaptureManager.m in Sources */,
|
||||
046086BA2F19239B00757C95 /* AudioStreamPlayer.m in Sources */,
|
||||
048908FE2EC0CC2400FABA60 /* UIScrollView+KBEmptyView.m in Sources */,
|
||||
0498BD7E2EE04F9C006CC1D5 /* KBTag.m in Sources */,
|
||||
04791F922ED48010004E8522 /* KBNoticeVC.m in Sources */,
|
||||
@@ -2646,12 +2615,15 @@
|
||||
0498BDE42EEA885D006CC1D5 /* KBShopThemeModel.m in Sources */,
|
||||
048908CD2EBE373500FABA60 /* KBSearchSectionHeader.m in Sources */,
|
||||
049FB2202EC30D2700FAB05D /* HomeRankDetailPopView.m in Sources */,
|
||||
045ED5212F52AF9200131114 /* KBCancelAccountVC.m in Sources */,
|
||||
048908CE2EBE373500FABA60 /* KBSkinCardCell.m in Sources */,
|
||||
048908CF2EBE373500FABA60 /* KBTagCell.m in Sources */,
|
||||
0477BEA22EBCF0000055D639 /* KBTopImageButton.m in Sources */,
|
||||
A1B2E1012EBC7AAA00000001 /* KBTopThreeView.m in Sources */,
|
||||
A1B2E1022EBC7AAA00000001 /* HomeHotCell.m in Sources */,
|
||||
048FFD272F28C6CF005D62AE /* KBImagePositionButton.m in Sources */,
|
||||
04BBF89D2F3ACD8800B1FBB2 /* KBKeyboardStressTestVC.m in Sources */,
|
||||
04BBF89E2F3ACD8800B1FBB2 /* KBTestVC.m in Sources */,
|
||||
0459D1B72EBA287900F2D189 /* KBSkinManager.m in Sources */,
|
||||
04286A002ECAEF2B00CE730C /* KBMoneyBtn.m in Sources */,
|
||||
048908F52EC0496400FABA60 /* KBShopItemVC.m in Sources */,
|
||||
|
||||
@@ -57,8 +57,12 @@
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<RemoteRunnable
|
||||
runnableDebuggingMode = "0"
|
||||
BundleIdentifier = "com.loveKey.nyx"
|
||||
RemotePath = "/var/containers/Bundle/Application/E51DCFA2-A182-4B31-8A45-BCCF663ADCAA/keyBoard.app">
|
||||
</RemoteRunnable>
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "727EC7522EAF848B00B36487"
|
||||
@@ -66,7 +70,14 @@
|
||||
BlueprintName = "keyBoard"
|
||||
ReferencedContainer = "container:keyBoard.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</MacroExpansion>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "OS_ACTIVITY_MODE"
|
||||
value = "disable"
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BuildLocationStyle</key>
|
||||
<string>UseAppPreferences</string>
|
||||
<key>CustomBuildLocationType</key>
|
||||
<string>RelativeToDerivedData</string>
|
||||
<key>DerivedDataLocationStyle</key>
|
||||
<string>Default</string>
|
||||
<key>ShowSharedSchemesAutomaticallyEnabled</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
22
keyBoard/Assets.xcassets/My/my_svip_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "my_svip_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "my_svip_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
keyBoard/Assets.xcassets/My/my_svip_icon.imageset/my_svip_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
keyBoard/Assets.xcassets/My/my_svip_icon.imageset/my_svip_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 12 KiB |
21
keyBoard/Assets.xcassets/Ohter/ai_placehode_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "微信图片_20260226192149_128_935 (1).png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
keyBoard/Assets.xcassets/Ohter/ai_placehode_icon.imageset/微信图片_20260226192149_128_935 (1).png
vendored
Normal file
|
After Width: | Height: | Size: 414 KiB |
@@ -5,12 +5,12 @@
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "tab_shequ_selected@2x.png",
|
||||
"filename" : "切图 145@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "tab_shequ_selected@3x.png",
|
||||
"filename" : "切图 145@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
BIN
keyBoard/Assets.xcassets/Tabbar/tab_shequ_selected.imageset/切图 145@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
keyBoard/Assets.xcassets/Tabbar/tab_shequ_selected.imageset/切图 145@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
@@ -69,6 +69,9 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
/// 当前 Cell 不再是屏幕主显示页
|
||||
- (void)onResignedCurrentPersonaCell;
|
||||
|
||||
/// 刷新聊天记录(重置分页状态,从第一页重新加载)
|
||||
- (void)refreshChatHistory;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -213,7 +213,7 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
|
||||
// 设置 UI
|
||||
[self.backgroundImageView sd_setImageWithURL:[NSURL URLWithString:persona.coverImageUrl]
|
||||
placeholderImage:[UIImage imageNamed:@"placeholder_bg"]];
|
||||
placeholderImage:[UIImage imageNamed:@"ai_placehode_icon"]];
|
||||
[self.avatarImageView sd_setImageWithURL:[NSURL URLWithString:persona.avatarUrl]
|
||||
placeholderImage:[UIImage imageNamed:@"placeholder_avatar"]];
|
||||
self.nameLabel.text = persona.name;
|
||||
@@ -256,7 +256,20 @@ static NSString * const KBChatSessionDidResetNotification = @"KBChatSessionDidRe
|
||||
if (self.hasLoadedData || self.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
[self loadChatHistory];
|
||||
}
|
||||
|
||||
- (void)refreshChatHistory {
|
||||
// 重置分页状态
|
||||
self.currentPage = 1;
|
||||
self.hasMoreHistory = YES;
|
||||
self.hasLoadedData = NO;
|
||||
self.isLoading = NO;
|
||||
|
||||
// 清空当前消息并重新加载
|
||||
[self.messages removeAllObjects];
|
||||
[self.chatView clearMessages];
|
||||
[self loadChatHistory];
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,9 @@
|
||||
}
|
||||
|
||||
- (void)showKeyboard {
|
||||
[self.textField becomeFirstResponder];
|
||||
BOOL before = self.textField.isFirstResponder;
|
||||
BOOL become = [self.textField becomeFirstResponder];
|
||||
NSLog(@"[KBAICommentInputView] showKeyboard before=%d become=%d after=%d", before, become, self.textField.isFirstResponder);
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
@@ -129,11 +131,17 @@
|
||||
}
|
||||
|
||||
- (BOOL)textFieldShouldBeginEditing:(UITextField *)textField {
|
||||
NSLog(@"[KBAICommentInputView] textFieldShouldBeginEditing");
|
||||
[self updatePlaceholderVisibility];
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)textFieldDidBeginEditing:(UITextField *)textField {
|
||||
NSLog(@"[KBAICommentInputView] textFieldDidBeginEditing firstResponder=%d", textField.isFirstResponder);
|
||||
}
|
||||
|
||||
- (void)textFieldDidEndEditing:(UITextField *)textField {
|
||||
NSLog(@"[KBAICommentInputView] textFieldDidEndEditing");
|
||||
[self updatePlaceholderVisibility];
|
||||
}
|
||||
|
||||
|
||||
@@ -317,6 +317,34 @@
|
||||
[self.tableView.mj_footer resetNoMoreData];
|
||||
}
|
||||
|
||||
#pragma mark - UIView
|
||||
|
||||
- (void)setHidden:(BOOL)hidden {
|
||||
[super setHidden:hidden];
|
||||
if (hidden) {
|
||||
[self kb_resetSearchState];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)didMoveToWindow {
|
||||
[super didMoveToWindow];
|
||||
if (self.window == nil) {
|
||||
[self kb_resetSearchState];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Private
|
||||
|
||||
- (void)kb_resetSearchState {
|
||||
if (self.searchField.text.length == 0 && !self.isShowingSearchResults) {
|
||||
[self endEditing:YES];
|
||||
return;
|
||||
}
|
||||
self.searchField.text = @"";
|
||||
[self endEditing:YES];
|
||||
[self hideSearchResults];
|
||||
}
|
||||
|
||||
#pragma mark - Search
|
||||
|
||||
- (void)searchFieldChanged:(UITextField *)textField {
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
@property (nonatomic, strong) UIView *bottomBackgroundView;
|
||||
@property (nonatomic, strong) UIVisualEffectView *bottomBlurEffectView;
|
||||
@property (nonatomic, strong) CAGradientLayer *bottomMaskLayer;
|
||||
@property (nonatomic, strong) CAGradientLayer *bottomGradientLayer;
|
||||
|
||||
/// 语音转写管理器
|
||||
@property (nonatomic, strong) KBVoiceToTextManager *voiceToTextManager;
|
||||
@@ -107,6 +108,19 @@
|
||||
|
||||
static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
|
||||
#pragma mark - Darwin Notification Callback (键盘扩展聊天更新)
|
||||
|
||||
static void KBChatUpdatedDarwinCallback(CFNotificationCenterRef center,
|
||||
void *observer,
|
||||
CFStringRef name,
|
||||
const void *object,
|
||||
CFDictionaryRef userInfo) {
|
||||
KBAIHomeVC *self = (__bridge KBAIHomeVC *)observer;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self kb_handleChatUpdatedFromExtension];
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - Keyboard Gate
|
||||
|
||||
/// 查找当前 view 树里的 firstResponder
|
||||
@@ -162,10 +176,21 @@ static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
[self setupKeyboardNotifications];
|
||||
[self setupKeyboardDismissGesture];
|
||||
[self loadPersonas];
|
||||
|
||||
// 监听键盘扩展聊天更新的 Darwin 跨进程通知
|
||||
CFNotificationCenterAddObserver(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
(__bridge const void *)(self),
|
||||
KBChatUpdatedDarwinCallback,
|
||||
(__bridge CFStringRef)kKBDarwinChatUpdated,
|
||||
NULL,
|
||||
CFNotificationSuspensionBehaviorDeliverImmediately);
|
||||
}
|
||||
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
[super viewDidAppear:animated];
|
||||
[self kb_syncTextInputStateIfNeeded];
|
||||
[self kb_logInputLayoutWithTag:@"viewDidAppear"];
|
||||
KBPersonaChatCell *cell = [self currentPersonaCell];
|
||||
if (cell) {
|
||||
[cell onBecameCurrentPersonaCell];
|
||||
@@ -174,6 +199,17 @@ static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
[super viewWillDisappear:animated];
|
||||
// 离开页面时结束编辑并重置底部输入状态,避免返回时出现 hidden/firstResponder 错位
|
||||
[self.view endEditing:YES];
|
||||
self.voiceInputKeyboardActive = NO;
|
||||
self.currentKeyboardHeight = 0.0;
|
||||
self.isTextInputMode = NO;
|
||||
self.commentInputView.hidden = YES;
|
||||
[self.commentInputBottomConstraint setOffset:100];
|
||||
self.voiceInputBar.hidden = NO;
|
||||
[self.voiceInputBarBottomConstraint setOffset:-self.baseInputBarBottomSpacing];
|
||||
[self.view layoutIfNeeded];
|
||||
[self kb_logInputLayoutWithTag:@"viewWillDisappear"];
|
||||
for (NSIndexPath *indexPath in self.collectionView.indexPathsForVisibleItems) {
|
||||
KBPersonaChatCell *cell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:indexPath];
|
||||
if (cell) {
|
||||
@@ -184,15 +220,15 @@ static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
|
||||
- (void)viewDidLayoutSubviews {
|
||||
[super viewDidLayoutSubviews];
|
||||
if (self.bottomMaskLayer) {
|
||||
self.bottomMaskLayer.frame = self.bottomBlurEffectView.bounds;
|
||||
if (self.bottomGradientLayer) {
|
||||
self.bottomGradientLayer.frame = self.bottomBackgroundView.bounds;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - 1:控件初始化
|
||||
|
||||
- (void)setupUI {
|
||||
self.voiceInputBarHeight = 52;
|
||||
self.voiceInputBarHeight = 70;
|
||||
self.baseInputBarBottomSpacing = KB_TABBAR_HEIGHT;
|
||||
[self.view addSubview:self.collectionView];
|
||||
[self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
@@ -244,7 +280,7 @@ static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
make.left.equalTo(self.view).offset(12);
|
||||
make.right.equalTo(self.view).offset(-12);
|
||||
self.commentInputBottomConstraint = make.bottom.equalTo(self.view).offset(100); // 初始在屏幕外
|
||||
make.height.mas_equalTo(self.voiceInputBarHeight);
|
||||
make.height.mas_equalTo(52);
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -262,16 +298,26 @@ static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
self.isTextInputMode = YES;
|
||||
self.voiceInputBar.hidden = YES;
|
||||
self.commentInputView.hidden = NO;
|
||||
[self kb_logInputLayoutWithTag:@"showTextInputView-beforeAdjust"];
|
||||
// 键盘未弹起时先停在底部栏位置,随后由键盘通知动画抬起到键盘上方
|
||||
// 键盘已弹起时(如返回页面)直接使用当前键盘高度对齐
|
||||
CGFloat targetOffset = self.currentKeyboardHeight > 0.0 ? -self.currentKeyboardHeight : -self.baseInputBarBottomSpacing;
|
||||
[self.commentInputBottomConstraint setOffset:targetOffset];
|
||||
[self.view layoutIfNeeded];
|
||||
[self kb_logInputLayoutWithTag:@"showTextInputView-afterAdjust"];
|
||||
[self.commentInputView showKeyboard];
|
||||
}
|
||||
|
||||
/// 隐藏文本输入视图
|
||||
- (void)hideTextInputView {
|
||||
[self kb_logInputLayoutWithTag:@"hideTextInputView-before"];
|
||||
self.isTextInputMode = NO;
|
||||
[self.view endEditing:YES];
|
||||
[self.commentInputView clearText];
|
||||
self.commentInputView.hidden = YES;
|
||||
[self.commentInputBottomConstraint setOffset:100];
|
||||
self.voiceInputBar.hidden = NO;
|
||||
[self kb_logInputLayoutWithTag:@"hideTextInputView-after"];
|
||||
}
|
||||
|
||||
#pragma mark - 2:数据加载
|
||||
@@ -307,6 +353,12 @@ static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
if (weakSelf.currentPage == 1) {
|
||||
[weakSelf.collectionView reloadData];
|
||||
[weakSelf preloadDataForIndexes:@[@0, @1, @2]];
|
||||
// 首次加载完成后,主动保存默认 persona 到 AppGroup,
|
||||
// 避免用户未滑动时键盘扩展拿不到数据
|
||||
if (weakSelf.personas.count > 0) {
|
||||
NSInteger index = MIN(MAX(weakSelf.currentIndex, 0), weakSelf.personas.count - 1);
|
||||
[weakSelf saveSelectedPersonaToAppGroup:weakSelf.personas[index]];
|
||||
}
|
||||
} else if (pageModel.records.count > 0) {
|
||||
NSInteger newCount = weakSelf.personas.count;
|
||||
NSMutableArray<NSIndexPath *> *indexPaths = [NSMutableArray array];
|
||||
@@ -479,10 +531,10 @@ static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
[ud setObject:personaDict forKey:@"AppGroup_SelectedPersona"];
|
||||
[ud synchronize];
|
||||
|
||||
NSLog(@"[KBAIHomeVC] 已保存选中的 persona 到 AppGroup: %@, coverImageUrl: %@", persona.name, persona.coverImageUrl);
|
||||
|
||||
NSLog(@"[KBAIHomeVC] 已保存选中的 persona 到 AppGroup: %@, avatarUrl: %@", persona.name, persona.avatarUrl);
|
||||
|
||||
// 异步下载并缩小图片,保存到 AppGroup 共享目录
|
||||
[self downloadAndSavePersonaCoverImage:persona.coverImageUrl];
|
||||
[self downloadAndSavePersonaCoverImage:persona.avatarUrl];
|
||||
}
|
||||
|
||||
/// 下载并缩小 persona 封面图,保存到 AppGroup 共享目录
|
||||
@@ -510,18 +562,17 @@ static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
return;
|
||||
}
|
||||
|
||||
// 缩小图片到适合键盘扩展的尺寸(宽度 390,高度按比例)
|
||||
CGFloat targetWidth = 390.0;
|
||||
CGFloat scale = targetWidth / image.size.width;
|
||||
CGSize targetSize = CGSizeMake(targetWidth, image.size.height * scale);
|
||||
|
||||
UIGraphicsBeginImageContextWithOptions(targetSize, YES, 1.0);
|
||||
// 缩小图片到 40x40(仅用于工具栏头像显示)
|
||||
CGFloat targetSide = 40.0;
|
||||
CGSize targetSize = CGSizeMake(targetSide, targetSide);
|
||||
|
||||
UIGraphicsBeginImageContextWithOptions(targetSize, NO, 1.0);
|
||||
[image drawInRect:CGRectMake(0, 0, targetSize.width, targetSize.height)];
|
||||
UIImage *scaledImage = UIGraphicsGetImageFromCurrentImageContext();
|
||||
UIGraphicsEndImageContext();
|
||||
|
||||
// 压缩为 JPEG,质量 0.6
|
||||
NSData *jpegData = UIImageJPEGRepresentation(scaledImage, 0.6);
|
||||
|
||||
// 压缩为 JPEG,质量 0.8
|
||||
NSData *jpegData = UIImageJPEGRepresentation(scaledImage, 0.8);
|
||||
if (!jpegData) {
|
||||
NSLog(@"[KBAIHomeVC] 压缩图片失败");
|
||||
return;
|
||||
@@ -564,12 +615,32 @@ static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
|
||||
- (void)setupKeyboardNotifications {
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(handleKeyboardWillChangeFrame:)
|
||||
selector:@selector(handleKeyboardNotification:)
|
||||
name:UIKeyboardWillChangeFrameNotification
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(handleKeyboardNotification:)
|
||||
name:UIKeyboardDidChangeFrameNotification
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(handleKeyboardNotification:)
|
||||
name:UIKeyboardWillShowNotification
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(handleKeyboardNotification:)
|
||||
name:UIKeyboardDidShowNotification
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(handleKeyboardNotification:)
|
||||
name:UIKeyboardWillHideNotification
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(handleKeyboardNotification:)
|
||||
name:UIKeyboardDidHideNotification
|
||||
object:nil];
|
||||
}
|
||||
|
||||
- (void)handleKeyboardWillChangeFrame:(NSNotification *)notification {
|
||||
- (void)handleKeyboardNotification:(NSNotification *)notification {
|
||||
NSDictionary *userInfo = notification.userInfo;
|
||||
CGRect endFrame = [userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
|
||||
NSTimeInterval duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
|
||||
@@ -577,17 +648,42 @@ static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
|
||||
CGRect convertedFrame = [self.view convertRect:endFrame fromView:nil];
|
||||
CGFloat keyboardHeight = MAX(0.0, CGRectGetMaxY(self.view.bounds) - CGRectGetMinY(convertedFrame));
|
||||
BOOL shouldHandle = YES;
|
||||
BOOL fromAllowedInput = [self kb_isKeyboardFromVoiceInputBar];
|
||||
|
||||
if (keyboardHeight > 0.0) {
|
||||
if (![self kb_isKeyboardFromVoiceInputBar]) {
|
||||
return;
|
||||
// 文本输入模式优先跟随键盘,避免返回页面后 firstResponder 瞬时不稳定导致被误过滤
|
||||
if (!self.isTextInputMode && !fromAllowedInput) {
|
||||
shouldHandle = NO;
|
||||
}
|
||||
self.voiceInputKeyboardActive = YES;
|
||||
} else {
|
||||
if (!self.voiceInputKeyboardActive) {
|
||||
return;
|
||||
if (!self.voiceInputKeyboardActive && !self.isTextInputMode) {
|
||||
shouldHandle = NO;
|
||||
}
|
||||
self.voiceInputKeyboardActive = NO;
|
||||
}
|
||||
|
||||
[self kb_logKeyboardNotification:notification
|
||||
keyboardHeight:keyboardHeight
|
||||
convertedFrame:convertedFrame
|
||||
fromAllowedInput:fromAllowedInput
|
||||
shouldHandle:shouldHandle];
|
||||
if (!shouldHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
UIView *firstResponder = [self kb_findFirstResponderInView:self.view];
|
||||
BOOL firstInComment = firstResponder ? [firstResponder isDescendantOfView:self.commentInputView] : NO;
|
||||
if (keyboardHeight > 0.0 && firstInComment && !self.isTextInputMode) {
|
||||
// 防止出现「firstResponder 在 commentInput,但 textMode 仍为 NO」导致定位到底部
|
||||
self.isTextInputMode = YES;
|
||||
self.voiceInputBar.hidden = YES;
|
||||
self.commentInputView.hidden = NO;
|
||||
[self kb_logInputLayoutWithTag:@"keyboardSync-forceTextMode"];
|
||||
}
|
||||
|
||||
if (keyboardHeight <= 0.0) {
|
||||
// 键盘隐藏时,如果是文本输入模式,隐藏文本输入框并显示 VoiceInputBar
|
||||
if (self.isTextInputMode) {
|
||||
[self hideTextInputView];
|
||||
@@ -619,7 +715,9 @@ static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
animations:^{
|
||||
[self.view layoutIfNeeded];
|
||||
}
|
||||
completion:nil];
|
||||
completion:^(BOOL finished) {
|
||||
[self kb_logInputLayoutWithTag:@"handleKeyboardNotification-animComplete"];
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - 7:键盘收起
|
||||
@@ -687,6 +785,36 @@ static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
return nil;
|
||||
}
|
||||
|
||||
#pragma mark - 键盘扩展聊天更新处理
|
||||
|
||||
/// 收到键盘扩展的聊天更新通知后,刷新对应 persona 的聊天记录
|
||||
- (void)kb_handleChatUpdatedFromExtension {
|
||||
NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||
NSInteger companionId = [ud integerForKey:AppGroup_ChatUpdatedCompanionId];
|
||||
if (companionId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"[KBAIHomeVC] 收到键盘扩展聊天更新通知,companionId=%ld", (long)companionId);
|
||||
|
||||
// 查找对应 persona 的索引
|
||||
NSInteger index = [self indexOfPersonaId:companionId];
|
||||
if (index == NSNotFound) {
|
||||
NSLog(@"[KBAIHomeVC] 未找到 companionId=%ld 对应的 persona", (long)companionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取对应的 cell 并刷新聊天记录
|
||||
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:index inSection:0];
|
||||
KBPersonaChatCell *cell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:indexPath];
|
||||
if (cell) {
|
||||
[cell refreshChatHistory];
|
||||
NSLog(@"[KBAIHomeVC] 已触发 companionId=%ld 的聊天记录刷新", (long)companionId);
|
||||
} else {
|
||||
NSLog(@"[KBAIHomeVC] companionId=%ld 的 cell 不可见,下次显示时会自动加载", (long)companionId);
|
||||
}
|
||||
}
|
||||
|
||||
- (NSInteger)indexOfPersonaId:(NSInteger)personaId {
|
||||
if (personaId <= 0) {
|
||||
return NSNotFound;
|
||||
@@ -837,6 +965,7 @@ static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
}
|
||||
|
||||
- (void)chatLimitPopViewDidTapRecharge:(KBChatLimitPopView *)view {
|
||||
NSLog(@"[KBAIHomeVC][Pay] chatLimitPopViewDidTapRecharge");
|
||||
[self.chatLimitPopView dismiss];
|
||||
if (![KBUserSessionManager shared].isLoggedIn) {
|
||||
[[KBUserSessionManager shared] goLoginVC];
|
||||
@@ -891,32 +1020,38 @@ static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
if (!_bottomBackgroundView) {
|
||||
_bottomBackgroundView = [[UIView alloc] init];
|
||||
_bottomBackgroundView.clipsToBounds = YES;
|
||||
// 添加渐变遮罩层,实现从底部到顶部的渐变显示效果
|
||||
_bottomBackgroundView.layer.mask = self.bottomGradientLayer;
|
||||
}
|
||||
return _bottomBackgroundView;
|
||||
}
|
||||
|
||||
- (UIVisualEffectView *)bottomBlurEffectView {
|
||||
if (!_bottomBlurEffectView) {
|
||||
UIBlurEffect *blurEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleLight];
|
||||
// 使用深色毛玻璃效果
|
||||
UIBlurEffect *blurEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleDark];
|
||||
_bottomBlurEffectView = [[UIVisualEffectView alloc] initWithEffect:blurEffect];
|
||||
_bottomBlurEffectView.layer.mask = self.bottomMaskLayer;
|
||||
_bottomBlurEffectView.alpha = 0.9; // 稍微降低整体透明度
|
||||
}
|
||||
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
|
||||
- (CAGradientLayer *)bottomGradientLayer {
|
||||
if (!_bottomGradientLayer) {
|
||||
_bottomGradientLayer = [CAGradientLayer layer];
|
||||
// 从底部到顶部
|
||||
_bottomGradientLayer.startPoint = CGPointMake(0.5, 1);
|
||||
_bottomGradientLayer.endPoint = CGPointMake(0.5, 0);
|
||||
// 作为遮罩层:底部完全不透明(白色),顶部完全透明(透明色)
|
||||
// 中间位置开始渐变,让底部区域保持完整的毛玻璃效果
|
||||
_bottomGradientLayer.colors = @[
|
||||
(__bridge id)[UIColor whiteColor].CGColor, // 底部:完全不透明
|
||||
(__bridge id)[UIColor whiteColor].CGColor, // 中间偏下:完全不透明
|
||||
(__bridge id)[[UIColor whiteColor] colorWithAlphaComponent:0.0].CGColor // 顶部:完全透明
|
||||
];
|
||||
_bottomMaskLayer.locations = @[@(0.0), @(0.5), @(1.0)];
|
||||
_bottomGradientLayer.locations = @[@(0.0), @(0.4), @(1.0)];
|
||||
}
|
||||
return _bottomMaskLayer;
|
||||
return _bottomGradientLayer;
|
||||
}
|
||||
|
||||
- (UIButton *)messageButton {
|
||||
@@ -949,6 +1084,74 @@ static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
[self showPersonaSidebar];
|
||||
}
|
||||
|
||||
#pragma mark - Debug Log
|
||||
|
||||
- (void)kb_syncTextInputStateIfNeeded {
|
||||
UIView *firstResponder = [self kb_findFirstResponderInView:self.view];
|
||||
BOOL firstInComment = firstResponder ? [firstResponder isDescendantOfView:self.commentInputView] : NO;
|
||||
if (!firstInComment) {
|
||||
return;
|
||||
}
|
||||
self.isTextInputMode = YES;
|
||||
self.voiceInputBar.hidden = YES;
|
||||
self.commentInputView.hidden = NO;
|
||||
if (self.currentKeyboardHeight > 0.0) {
|
||||
[self.commentInputBottomConstraint setOffset:-self.currentKeyboardHeight];
|
||||
[self.voiceInputBarBottomConstraint setOffset:-MAX(self.currentKeyboardHeight - 5.0, self.baseInputBarBottomSpacing)];
|
||||
} else {
|
||||
[self.commentInputBottomConstraint setOffset:-self.baseInputBarBottomSpacing];
|
||||
}
|
||||
[self.view layoutIfNeeded];
|
||||
[self kb_logInputLayoutWithTag:@"kb_syncTextInputStateIfNeeded"];
|
||||
}
|
||||
|
||||
- (void)kb_logInputLayoutWithTag:(NSString *)tag {
|
||||
[self.view layoutIfNeeded];
|
||||
UIView *firstResponder = [self kb_findFirstResponderInView:self.view];
|
||||
NSString *firstResponderInfo = firstResponder ? NSStringFromClass(firstResponder.class) : @"nil";
|
||||
NSLog(@"[KBAIHomeVC][Layout][%@] textMode=%d voiceHidden=%d commentHidden=%d currentKeyboardHeight=%.2f viewH=%.2f safeBottom=%.2f commentFrame=%@ voiceFrame=%@ firstResponder=%@",
|
||||
tag ?: @"-",
|
||||
self.isTextInputMode,
|
||||
self.voiceInputBar.hidden,
|
||||
self.commentInputView.hidden,
|
||||
self.currentKeyboardHeight,
|
||||
CGRectGetHeight(self.view.bounds),
|
||||
self.view.safeAreaInsets.bottom,
|
||||
NSStringFromCGRect(self.commentInputView.frame),
|
||||
NSStringFromCGRect(self.voiceInputBar.frame),
|
||||
firstResponderInfo);
|
||||
}
|
||||
|
||||
- (void)kb_logKeyboardNotification:(NSNotification *)notification
|
||||
keyboardHeight:(CGFloat)keyboardHeight
|
||||
convertedFrame:(CGRect)convertedFrame
|
||||
fromAllowedInput:(BOOL)fromAllowedInput
|
||||
shouldHandle:(BOOL)shouldHandle {
|
||||
NSDictionary *userInfo = notification.userInfo;
|
||||
CGRect endFrame = [userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
|
||||
NSTimeInterval duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
|
||||
NSInteger curve = [userInfo[UIKeyboardAnimationCurveUserInfoKey] integerValue];
|
||||
UIView *firstResponder = [self kb_findFirstResponderInView:self.view];
|
||||
NSString *firstResponderInfo = firstResponder ? NSStringFromClass(firstResponder.class) : @"nil";
|
||||
BOOL firstInComment = firstResponder ? [firstResponder isDescendantOfView:self.commentInputView] : NO;
|
||||
BOOL firstInVoice = firstResponder ? [firstResponder isDescendantOfView:self.voiceInputBar] : NO;
|
||||
NSLog(@"[KBAIHomeVC][Keyboard][%@] shouldHandle=%d textMode=%d voiceActive=%d fromAllowed=%d firstInComment=%d firstInVoice=%d firstResponder=%@ endFrame=%@ converted=%@ keyboardHeight=%.2f duration=%.2f curve=%ld viewWindow=%@",
|
||||
notification.name,
|
||||
shouldHandle,
|
||||
self.isTextInputMode,
|
||||
self.voiceInputKeyboardActive,
|
||||
fromAllowedInput,
|
||||
firstInComment,
|
||||
firstInVoice,
|
||||
firstResponderInfo,
|
||||
NSStringFromCGRect(endFrame),
|
||||
NSStringFromCGRect(convertedFrame),
|
||||
keyboardHeight,
|
||||
duration,
|
||||
(long)curve,
|
||||
self.view.window ? @"YES" : @"NO");
|
||||
}
|
||||
|
||||
- (void)showPersonaSidebar {
|
||||
if (!self.sidebarView) {
|
||||
CGFloat width = KB_SCREEN_WIDTH * 0.7;
|
||||
@@ -1209,6 +1412,11 @@ static NSString * const KBAISelectedPersonaIdKey = @"KBAISelectedPersonaId";
|
||||
|
||||
- (void)dealloc {
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
CFNotificationCenterRemoveObserver(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
(__bridge const void *)(self),
|
||||
(__bridge CFStringRef)kKBDarwinChatUpdated,
|
||||
NULL);
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
//
|
||||
// KBAiMainVC.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// AI 语音陪伴聊天主界面
|
||||
@interface KBAiMainVC : BaseViewController
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -1,809 +0,0 @@
|
||||
//
|
||||
// KBAiMainVC.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import "KBAiMainVC.h"
|
||||
#import "ConversationOrchestrator.h"
|
||||
#import "AiVM.h"
|
||||
#import "AudioSessionManager.h"
|
||||
#import "DeepgramStreamingManager.h"
|
||||
#import "KBAICommentView.h"
|
||||
#import "KBChatTableView.h"
|
||||
#import "KBAiRecordButton.h"
|
||||
#import "KBHUD.h"
|
||||
#import "KBChatLimitPopView.h"
|
||||
#import "KBPayMainVC.h"
|
||||
#import "LSTPopView.h"
|
||||
#import "VoiceChatStreamingManager.h"
|
||||
#import "KBUserSessionManager.h"
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
|
||||
@interface KBAiMainVC () <KBAiRecordButtonDelegate,
|
||||
VoiceChatStreamingManagerDelegate,
|
||||
DeepgramStreamingManagerDelegate,
|
||||
AVAudioPlayerDelegate,
|
||||
KBChatLimitPopViewDelegate>
|
||||
@property(nonatomic, weak) LSTPopView *popView;
|
||||
@property(nonatomic, weak) LSTPopView *limitPopView;
|
||||
|
||||
// UI
|
||||
@property(nonatomic, strong) KBChatTableView *chatView;
|
||||
@property(nonatomic, strong) KBAiRecordButton *recordButton;
|
||||
@property(nonatomic, strong) UILabel *statusLabel;
|
||||
@property(nonatomic, strong) UILabel *transcriptLabel;
|
||||
@property(nonatomic, strong) UIButton *commentButton;
|
||||
@property(nonatomic, strong) KBAICommentView *commentView;
|
||||
@property(nonatomic, strong) UIView *tabbarBackgroundView;
|
||||
@property(nonatomic, strong) UIVisualEffectView *blurEffectView;
|
||||
@property(nonatomic, strong) CAGradientLayer *gradientLayer;
|
||||
@property(nonatomic, strong) UIImageView *personImageView;
|
||||
|
||||
// 核心模块
|
||||
@property(nonatomic, strong) ConversationOrchestrator *orchestrator;
|
||||
@property(nonatomic, strong) VoiceChatStreamingManager *streamingManager;
|
||||
@property(nonatomic, strong) DeepgramStreamingManager *deepgramManager;
|
||||
@property(nonatomic, strong) AiVM *aiVM;
|
||||
@property(nonatomic, strong) AVAudioPlayer *aiAudioPlayer;
|
||||
@property(nonatomic, strong) NSMutableData *voiceChatAudioBuffer;
|
||||
|
||||
// 文本跟踪
|
||||
@property(nonatomic, strong) NSMutableString *assistantVisibleText;
|
||||
@property(nonatomic, strong) NSMutableString *deepgramFullText;
|
||||
|
||||
// 日志节流
|
||||
@property(nonatomic, assign) NSTimeInterval lastRMSLogTime;
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBAiMainVC
|
||||
|
||||
#pragma mark - Lifecycle
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
|
||||
// 让视图延伸到屏幕边缘(包括状态栏和导航栏下方)
|
||||
self.edgesForExtendedLayout = UIRectEdgeAll;
|
||||
self.extendedLayoutIncludesOpaqueBars = YES;
|
||||
|
||||
[self setupUI];
|
||||
[self setupOrchestrator];
|
||||
[self setupStreamingManager];
|
||||
[self setupDeepgramManager];
|
||||
}
|
||||
|
||||
- (void)viewWillAppear:(BOOL)animated {
|
||||
[super viewWillAppear:animated];
|
||||
// TabBar 背景色由 BaseTabBarController 统一管理,这里不需要设置
|
||||
}
|
||||
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
[super viewWillDisappear:animated];
|
||||
|
||||
// 页面消失时停止对话
|
||||
[self.orchestrator stop];
|
||||
[self.streamingManager disconnect];
|
||||
[self.deepgramManager disconnect];
|
||||
}
|
||||
|
||||
- (void)viewDidLayoutSubviews {
|
||||
[super viewDidLayoutSubviews];
|
||||
|
||||
// 只更新 mask 的 frame(mask 已在 setupUI 中创建)
|
||||
if (self.blurEffectView.layer.mask) {
|
||||
self.blurEffectView.layer.mask.frame = self.blurEffectView.bounds;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - UI Setup
|
||||
|
||||
- (void)setupUI {
|
||||
self.view.backgroundColor = [UIColor whiteColor];
|
||||
self.title = @"AI 助手";
|
||||
|
||||
// 安全区域
|
||||
UILayoutGuide *safeArea = self.view.safeAreaLayoutGuide;
|
||||
|
||||
// PersonImageView(背景图,最底层)
|
||||
self.personImageView =
|
||||
[[UIImageView alloc] initWithImage:[UIImage imageNamed:@"person_icon"]];
|
||||
[self.view addSubview:self.personImageView];
|
||||
[self.personImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.top.bottom.equalTo(self.view);
|
||||
}];
|
||||
|
||||
// TabBar 毛玻璃模糊背景(在 personImageView 之上)
|
||||
self.tabbarBackgroundView = [[UIView alloc] init];
|
||||
self.tabbarBackgroundView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
self.tabbarBackgroundView.clipsToBounds = YES;
|
||||
[self.view addSubview:self.tabbarBackgroundView];
|
||||
|
||||
// 模糊效果
|
||||
UIBlurEffect *blurEffect =
|
||||
[UIBlurEffect effectWithStyle:UIBlurEffectStyleLight];
|
||||
self.blurEffectView = [[UIVisualEffectView alloc] initWithEffect:blurEffect];
|
||||
self.blurEffectView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.tabbarBackgroundView addSubview:self.blurEffectView];
|
||||
|
||||
// 为 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.statusLabel = [[UILabel alloc] init];
|
||||
self.statusLabel.text = @"按住按钮开始对话";
|
||||
self.statusLabel.font = [UIFont systemFontOfSize:14];
|
||||
self.statusLabel.textColor = [UIColor secondaryLabelColor];
|
||||
self.statusLabel.textAlignment = NSTextAlignmentCenter;
|
||||
self.statusLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.view addSubview:self.statusLabel];
|
||||
|
||||
// 转写文本标签
|
||||
self.transcriptLabel = [[UILabel alloc] init];
|
||||
self.transcriptLabel.text = @"";
|
||||
self.transcriptLabel.font = [UIFont systemFontOfSize:16];
|
||||
self.transcriptLabel.textColor = [UIColor labelColor];
|
||||
self.transcriptLabel.numberOfLines = 0;
|
||||
self.transcriptLabel.textAlignment = NSTextAlignmentRight;
|
||||
self.transcriptLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.view addSubview:self.transcriptLabel];
|
||||
|
||||
// 聊天视图
|
||||
self.chatView = [[KBChatTableView alloc] init];
|
||||
self.chatView.backgroundColor = [UIColor clearColor];
|
||||
self.chatView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.view addSubview:self.chatView];
|
||||
|
||||
// 录音按钮
|
||||
self.recordButton = [[KBAiRecordButton alloc] init];
|
||||
self.recordButton.delegate = self;
|
||||
self.recordButton.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.view addSubview:self.recordButton];
|
||||
|
||||
// 评论按钮(聊天视图右侧居中)
|
||||
self.commentButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
[self.commentButton setImage:[UIImage systemImageNamed:@"bubble.right.fill"]
|
||||
forState:UIControlStateNormal];
|
||||
self.commentButton.tintColor = [UIColor whiteColor];
|
||||
self.commentButton.backgroundColor = [UIColor systemBlueColor];
|
||||
self.commentButton.layer.cornerRadius = 25;
|
||||
self.commentButton.layer.shadowColor = [UIColor blackColor].CGColor;
|
||||
self.commentButton.layer.shadowOffset = CGSizeMake(0, 2);
|
||||
self.commentButton.layer.shadowOpacity = 0.3;
|
||||
self.commentButton.layer.shadowRadius = 4;
|
||||
self.commentButton.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.commentButton addTarget:self
|
||||
action:@selector(showComment)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.view addSubview:self.commentButton];
|
||||
|
||||
// 布局约束 - 使用 Masonry
|
||||
[self.tabbarBackgroundView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.bottom.equalTo(self.view);
|
||||
make.height.mas_equalTo(KBFit(238));
|
||||
}];
|
||||
|
||||
[self.blurEffectView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.tabbarBackgroundView);
|
||||
}];
|
||||
|
||||
[self.statusLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.view.mas_safeAreaLayoutGuideTop).offset(8);
|
||||
make.left.equalTo(self.view).offset(16);
|
||||
make.right.equalTo(self.view).offset(-16);
|
||||
// 设置固定高度,避免内容变化导致布局跳动
|
||||
make.height.mas_equalTo(20); // 单行文本高度
|
||||
}];
|
||||
|
||||
[self.transcriptLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.statusLabel.mas_bottom).offset(8);
|
||||
make.left.equalTo(self.view).offset(16);
|
||||
make.right.equalTo(self.view).offset(-16);
|
||||
// 设置固定高度,避免内容变化导致布局跳动
|
||||
make.height.mas_equalTo(60); // 根据实际需要调整高度
|
||||
}];
|
||||
// 设置内容压缩阻力,避免被压缩
|
||||
[self.transcriptLabel setContentCompressionResistancePriority:UILayoutPriorityDefaultLow
|
||||
forAxis:UILayoutConstraintAxisVertical];
|
||||
|
||||
[self.chatView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self.view);
|
||||
make.bottom.equalTo(self.tabbarBackgroundView.mas_top).offset(-8);
|
||||
make.top.equalTo(self.transcriptLabel.mas_bottom).offset(8);
|
||||
// 设置最小高度,避免被压缩为 0
|
||||
make.height.greaterThanOrEqualTo(@100).priority(MASLayoutPriorityDefaultHigh);
|
||||
}];
|
||||
// chatView 应该尽可能占据空间
|
||||
[self.chatView setContentCompressionResistancePriority:UILayoutPriorityRequired
|
||||
forAxis:UILayoutConstraintAxisVertical];
|
||||
|
||||
[self.recordButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.view.mas_safeAreaLayoutGuideLeft).offset(20);
|
||||
make.right.equalTo(self.view.mas_safeAreaLayoutGuideRight).offset(-20);
|
||||
make.bottom.equalTo(self.view.mas_safeAreaLayoutGuideBottom).offset(-16);
|
||||
make.height.mas_equalTo(50);
|
||||
}];
|
||||
|
||||
[self.commentButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(self.view.mas_safeAreaLayoutGuideRight).offset(-16);
|
||||
make.centerY.equalTo(self.view);
|
||||
make.width.height.mas_equalTo(50);
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Orchestrator Setup
|
||||
|
||||
- (void)setupOrchestrator {
|
||||
self.orchestrator = [[ConversationOrchestrator alloc] init];
|
||||
|
||||
// 配置服务器地址
|
||||
// 1. ASR 语音识别服务(WebSocket)
|
||||
self.orchestrator.asrServerURL = @"ws://192.168.2.21:7529/ws/asr";
|
||||
|
||||
// 2. LLM 大语言模型服务(HTTP Stream)
|
||||
self.orchestrator.llmServerURL = @"http://192.168.2.21:7529/api/chat/stream";
|
||||
|
||||
// 3. TTS 语音合成服务(HTTP)
|
||||
self.orchestrator.ttsServerURL = @"http://192.168.2.21:7529/api/tts/stream";
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
|
||||
// 状态变化回调
|
||||
self.orchestrator.onStateChange = ^(ConversationState state) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf)
|
||||
return;
|
||||
|
||||
[strongSelf updateStatusForState:state];
|
||||
};
|
||||
|
||||
// 实时识别文本回调
|
||||
self.orchestrator.onPartialText = ^(NSString *text) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf)
|
||||
return;
|
||||
|
||||
strongSelf.statusLabel.text = text.length > 0 ? text : @"正在识别...";
|
||||
};
|
||||
|
||||
// 用户最终文本回调
|
||||
self.orchestrator.onUserFinalText = ^(NSString *text) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf)
|
||||
return;
|
||||
|
||||
if (text.length > 0) {
|
||||
[strongSelf.chatView addUserMessage:text];
|
||||
}
|
||||
};
|
||||
|
||||
// AI 可见文本回调(打字机效果)
|
||||
self.orchestrator.onAssistantVisibleText = ^(NSString *text) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf)
|
||||
return;
|
||||
|
||||
[strongSelf.chatView updateLastAssistantMessage:text];
|
||||
};
|
||||
|
||||
// AI 完整回复回调
|
||||
self.orchestrator.onAssistantFullText = ^(NSString *text) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf)
|
||||
return;
|
||||
|
||||
[strongSelf.chatView updateLastAssistantMessage:text];
|
||||
[strongSelf.chatView markLastAssistantMessageComplete];
|
||||
};
|
||||
|
||||
// 音量更新回调
|
||||
self.orchestrator.onVolumeUpdate = ^(float rms) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf)
|
||||
return;
|
||||
|
||||
[strongSelf.recordButton updateVolumeRMS:rms];
|
||||
};
|
||||
|
||||
// AI 开始说话
|
||||
self.orchestrator.onSpeakingStart = ^{
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf)
|
||||
return;
|
||||
|
||||
// 添加空的 AI 消息占位
|
||||
[strongSelf.chatView addAssistantMessage:@"" audioDuration:0 audioData:nil];
|
||||
};
|
||||
|
||||
// AI 说话结束
|
||||
self.orchestrator.onSpeakingEnd = ^{
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf)
|
||||
return;
|
||||
|
||||
[strongSelf.chatView markLastAssistantMessageComplete];
|
||||
};
|
||||
|
||||
// 错误回调
|
||||
self.orchestrator.onError = ^(NSError *error) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf)
|
||||
return;
|
||||
|
||||
[strongSelf showError:error];
|
||||
};
|
||||
}
|
||||
|
||||
#pragma mark - Streaming Manager
|
||||
|
||||
- (void)setupStreamingManager {
|
||||
self.streamingManager = [[VoiceChatStreamingManager alloc] init];
|
||||
self.streamingManager.delegate = self;
|
||||
self.streamingManager.serverURL = @"ws://192.168.2.21:7529/api/ws/chat";
|
||||
self.assistantVisibleText = [[NSMutableString alloc] init];
|
||||
self.voiceChatAudioBuffer = [[NSMutableData alloc] init];
|
||||
self.lastRMSLogTime = 0;
|
||||
}
|
||||
|
||||
#pragma mark - Deepgram Manager
|
||||
|
||||
- (void)setupDeepgramManager {
|
||||
self.deepgramManager = [[DeepgramStreamingManager alloc] init];
|
||||
self.deepgramManager.delegate = self;
|
||||
self.deepgramManager.serverURL = @"wss://api.deepgram.com/v1/listen";
|
||||
self.deepgramManager.apiKey = @"9c792eb63a65d644cbc95785155754cd1e84f8cf";
|
||||
self.deepgramManager.language = @"en";
|
||||
self.deepgramManager.model = @"nova-3";
|
||||
self.deepgramManager.punctuate = YES;
|
||||
self.deepgramManager.smartFormat = YES;
|
||||
self.deepgramManager.interimResults = YES;
|
||||
self.deepgramManager.encoding = @"linear16";
|
||||
self.deepgramManager.sampleRate = 16000.0;
|
||||
self.deepgramManager.channels = 1;
|
||||
[self.deepgramManager prepareConnection];
|
||||
|
||||
self.deepgramFullText = [[NSMutableString alloc] init];
|
||||
self.aiVM = [[AiVM alloc] init];
|
||||
}
|
||||
|
||||
#pragma mark - 事件
|
||||
- (void)showComment {
|
||||
CGFloat customViewHeight = KB_SCREEN_HEIGHT * (0.8);
|
||||
KBAICommentView *customView = [[KBAICommentView alloc]
|
||||
initWithFrame:CGRectMake(0, 0, KB_SCREEN_WIDTH, customViewHeight)];
|
||||
LSTPopView *popView =
|
||||
[LSTPopView initWithCustomView:customView
|
||||
parentView:nil
|
||||
popStyle:LSTPopStyleSmoothFromBottom
|
||||
dismissStyle:LSTDismissStyleSmoothToBottom];
|
||||
self.popView = popView;
|
||||
popView.priority = 1000;
|
||||
popView.isAvoidKeyboard = false;
|
||||
popView.hemStyle = LSTHemStyleBottom;
|
||||
popView.dragStyle = LSTDragStyleY_Positive;
|
||||
popView.dragDistance = customViewHeight * 0.5;
|
||||
popView.sweepStyle = LSTSweepStyleY_Positive;
|
||||
popView.swipeVelocity = 1600;
|
||||
popView.sweepDismissStyle = LSTSweepDismissStyleSmooth;
|
||||
|
||||
[popView pop];
|
||||
}
|
||||
|
||||
- (void)showCommentDirectly {
|
||||
if (self.commentView.superview) {
|
||||
[self.view bringSubviewToFront:self.commentView];
|
||||
return;
|
||||
}
|
||||
|
||||
CGFloat customViewHeight = KB_SCREEN_HEIGHT * (0.8);
|
||||
KBAICommentView *customView =
|
||||
[[KBAICommentView alloc] initWithFrame:CGRectZero];
|
||||
customView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.view addSubview:customView];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[customView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
|
||||
[customView.trailingAnchor
|
||||
constraintEqualToAnchor:self.view.trailingAnchor],
|
||||
[customView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
|
||||
[customView.heightAnchor constraintEqualToConstant:customViewHeight],
|
||||
]];
|
||||
self.commentView = customView;
|
||||
}
|
||||
|
||||
#pragma mark - 次数用尽弹窗
|
||||
|
||||
- (void)showChatLimitPopWithMessage:(NSString *)message {
|
||||
if (self.limitPopView) {
|
||||
[self.limitPopView dismiss];
|
||||
}
|
||||
|
||||
CGFloat width = 252.0;
|
||||
CGFloat height = 252.0 + 18.0 + 53.0 + 18.0 + 28.0;
|
||||
KBChatLimitPopView *content =
|
||||
[[KBChatLimitPopView alloc] initWithFrame:CGRectMake(0, 0, width, height)];
|
||||
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;
|
||||
}
|
||||
KBPayMainVC *vc = [[KBPayMainVC alloc] init];
|
||||
vc.initialSelectedIndex = 1; // SVIP
|
||||
[KB_CURRENT_NAV pushViewController:vc animated:true];
|
||||
}
|
||||
|
||||
#pragma mark - UI Updates
|
||||
|
||||
- (void)updateStatusForState:(ConversationState)state {
|
||||
switch (state) {
|
||||
case ConversationStateIdle:
|
||||
self.statusLabel.text = @"按住按钮开始对话";
|
||||
self.recordButton.state = KBAiRecordButtonStateNormal;
|
||||
break;
|
||||
|
||||
case ConversationStateListening:
|
||||
self.statusLabel.text = @"正在聆听...";
|
||||
self.recordButton.state = KBAiRecordButtonStateRecording;
|
||||
break;
|
||||
|
||||
case ConversationStateRecognizing:
|
||||
self.statusLabel.text = @"正在识别...";
|
||||
self.recordButton.state = KBAiRecordButtonStateNormal;
|
||||
break;
|
||||
|
||||
case ConversationStateThinking:
|
||||
self.statusLabel.text = @"AI 正在思考...";
|
||||
self.recordButton.state = KBAiRecordButtonStateNormal;
|
||||
break;
|
||||
|
||||
case ConversationStateSpeaking:
|
||||
self.statusLabel.text = @"AI 正在回复...";
|
||||
self.recordButton.state = KBAiRecordButtonStateNormal;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)showError:(NSError *)error {
|
||||
UIAlertController *alert =
|
||||
[UIAlertController alertControllerWithTitle:@"错误"
|
||||
message:error.localizedDescription
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:@"确定"
|
||||
style:UIAlertActionStyleDefault
|
||||
handler:nil]];
|
||||
[self presentViewController:alert animated:YES completion:nil];
|
||||
}
|
||||
|
||||
#pragma mark - KBAiRecordButtonDelegate
|
||||
|
||||
- (void)recordButtonDidBeginPress:(KBAiRecordButton *)button {
|
||||
NSLog(@"[KBAiMainVC] Record button began press");
|
||||
|
||||
// 停止正在播放的音频
|
||||
[self.chatView stopPlayingAudio];
|
||||
|
||||
NSString *token = [[KBUserSessionManager shared] accessToken] ?: @"";
|
||||
if (token.length == 0) {
|
||||
[[KBUserSessionManager shared] goLoginVC];
|
||||
return;
|
||||
}
|
||||
|
||||
self.statusLabel.text = @"正在连接...";
|
||||
self.recordButton.state = KBAiRecordButtonStateRecording;
|
||||
[self.deepgramFullText setString:@""];
|
||||
self.transcriptLabel.text = @"";
|
||||
[self.deepgramManager start];
|
||||
}
|
||||
|
||||
- (void)recordButtonDidEndPress:(KBAiRecordButton *)button {
|
||||
NSLog(@"[KBAiMainVC] Record button end press");
|
||||
[self.deepgramManager stopAndFinalize];
|
||||
}
|
||||
|
||||
- (void)recordButtonDidCancelPress:(KBAiRecordButton *)button {
|
||||
NSLog(@"[KBAiMainVC] Record button cancel press");
|
||||
[self.deepgramManager cancel];
|
||||
}
|
||||
|
||||
#pragma mark - VoiceChatStreamingManagerDelegate
|
||||
|
||||
- (void)voiceChatStreamingManagerDidConnect {
|
||||
self.statusLabel.text = @"已连接,准备中...";
|
||||
}
|
||||
|
||||
- (void)voiceChatStreamingManagerDidDisconnect:(NSError *_Nullable)error {
|
||||
self.recordButton.state = KBAiRecordButtonStateNormal;
|
||||
if (error) {
|
||||
[self showError:error];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)voiceChatStreamingManagerDidStartSession:(NSString *)sessionId {
|
||||
self.statusLabel.text = @"正在聆听...";
|
||||
self.recordButton.state = KBAiRecordButtonStateRecording;
|
||||
}
|
||||
|
||||
- (void)voiceChatStreamingManagerDidStartTurn:(NSInteger)turnIndex {
|
||||
self.statusLabel.text = @"正在聆听...";
|
||||
self.recordButton.state = KBAiRecordButtonStateRecording;
|
||||
}
|
||||
|
||||
- (void)voiceChatStreamingManagerDidReceiveEagerEndOfTurnWithTranscript:(NSString *)text
|
||||
confidence:(double)confidence {
|
||||
self.statusLabel.text = @"准备响应...";
|
||||
}
|
||||
|
||||
- (void)voiceChatStreamingManagerDidResumeTurn {
|
||||
self.statusLabel.text = @"正在聆听...";
|
||||
}
|
||||
|
||||
- (void)voiceChatStreamingManagerDidUpdateRMS:(float)rms {
|
||||
[self.recordButton updateVolumeRMS:rms];
|
||||
NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
|
||||
if (now - self.lastRMSLogTime >= 1.0) {
|
||||
self.lastRMSLogTime = now;
|
||||
NSLog(@"[KBAiMainVC] RMS: %.3f", rms);
|
||||
}
|
||||
}
|
||||
|
||||
- (void)voiceChatStreamingManagerDidReceiveInterimTranscript:(NSString *)text {
|
||||
self.statusLabel.text = @"正在识别...";
|
||||
if (text.length > 0) {
|
||||
self.transcriptLabel.text = text;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)voiceChatStreamingManagerDidReceiveFinalTranscript:(NSString *)text {
|
||||
if (text.length > 0) {
|
||||
self.transcriptLabel.text = @"";
|
||||
[self.chatView addUserMessage:text];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)voiceChatStreamingManagerDidReceiveLLMStart {
|
||||
self.statusLabel.text = @"AI 正在思考...";
|
||||
[self.assistantVisibleText setString:@""];
|
||||
[self.chatView addAssistantMessage:@"" audioDuration:0 audioData:nil];
|
||||
[self.voiceChatAudioBuffer setLength:0];
|
||||
}
|
||||
|
||||
- (void)voiceChatStreamingManagerDidReceiveLLMToken:(NSString *)token {
|
||||
if (token.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
[self.assistantVisibleText appendString:token];
|
||||
[self.chatView updateLastAssistantMessage:self.assistantVisibleText];
|
||||
}
|
||||
|
||||
- (void)voiceChatStreamingManagerDidReceiveAudioChunk:(NSData *)audioData {
|
||||
if (audioData.length == 0) {
|
||||
return;
|
||||
}
|
||||
[self.voiceChatAudioBuffer appendData:audioData];
|
||||
}
|
||||
|
||||
- (void)voiceChatStreamingManagerDidCompleteWithTranscript:(NSString *)transcript
|
||||
aiResponse:(NSString *)aiResponse {
|
||||
NSString *finalText = aiResponse.length > 0 ? aiResponse : self.assistantVisibleText;
|
||||
if (aiResponse.length > 0) {
|
||||
[self.assistantVisibleText setString:aiResponse];
|
||||
}
|
||||
|
||||
// 计算音频时长
|
||||
NSTimeInterval duration = 0;
|
||||
if (self.voiceChatAudioBuffer.length > 0) {
|
||||
NSError *error = nil;
|
||||
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:self.voiceChatAudioBuffer
|
||||
error:&error];
|
||||
if (!error && player) {
|
||||
duration = player.duration;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalText.length > 0) {
|
||||
[self.chatView updateLastAssistantMessage:finalText];
|
||||
[self.chatView markLastAssistantMessageComplete];
|
||||
} else if (transcript.length > 0) {
|
||||
[self.chatView addAssistantMessage:transcript
|
||||
audioDuration:duration
|
||||
audioData:self.voiceChatAudioBuffer.length > 0 ? self.voiceChatAudioBuffer : nil];
|
||||
}
|
||||
|
||||
if (self.voiceChatAudioBuffer.length > 0) {
|
||||
[self playAiAudioData:self.voiceChatAudioBuffer];
|
||||
[self.voiceChatAudioBuffer setLength:0];
|
||||
}
|
||||
|
||||
self.recordButton.state = KBAiRecordButtonStateNormal;
|
||||
self.statusLabel.text = @"完成";
|
||||
}
|
||||
|
||||
- (void)voiceChatStreamingManagerDidFail:(NSError *)error {
|
||||
self.recordButton.state = KBAiRecordButtonStateNormal;
|
||||
[self showError:error];
|
||||
}
|
||||
|
||||
#pragma mark - DeepgramStreamingManagerDelegate
|
||||
|
||||
- (void)deepgramStreamingManagerDidConnect {
|
||||
self.statusLabel.text = @"已连接,准备中...";
|
||||
}
|
||||
|
||||
- (void)deepgramStreamingManagerDidDisconnect:(NSError *_Nullable)error {
|
||||
self.recordButton.state = KBAiRecordButtonStateNormal;
|
||||
if (error) {
|
||||
[self showError:error];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)deepgramStreamingManagerDidUpdateRMS:(float)rms {
|
||||
[self.recordButton updateVolumeRMS:rms];
|
||||
NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
|
||||
if (now - self.lastRMSLogTime >= 1.0) {
|
||||
self.lastRMSLogTime = now;
|
||||
NSLog(@"[KBAiMainVC] RMS: %.3f", rms);
|
||||
}
|
||||
}
|
||||
|
||||
- (void)deepgramStreamingManagerDidReceiveInterimTranscript:(NSString *)text {
|
||||
self.statusLabel.text = @"正在识别...";
|
||||
NSString *displayText = text ?: @"";
|
||||
if (self.deepgramFullText.length > 0 && displayText.length > 0) {
|
||||
displayText =
|
||||
[NSString stringWithFormat:@"%@ %@", self.deepgramFullText, displayText];
|
||||
} else if (self.deepgramFullText.length > 0) {
|
||||
displayText = [self.deepgramFullText copy];
|
||||
}
|
||||
self.transcriptLabel.text = displayText;
|
||||
}
|
||||
|
||||
- (void)deepgramStreamingManagerDidReceiveFinalTranscript:(NSString *)text {
|
||||
if (text.length > 0) {
|
||||
if (self.deepgramFullText.length > 0) {
|
||||
[self.deepgramFullText appendString:@" "];
|
||||
}
|
||||
[self.deepgramFullText appendString:text];
|
||||
}
|
||||
self.transcriptLabel.text = self.deepgramFullText;
|
||||
self.statusLabel.text = @"识别完成";
|
||||
self.recordButton.state = KBAiRecordButtonStateNormal;
|
||||
|
||||
NSString *finalText = [self.deepgramFullText copy];
|
||||
if (finalText.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加用户消息
|
||||
[self.chatView addUserMessage:finalText];
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[KBHUD showWithStatus:@"AI 思考中..."];
|
||||
|
||||
// 请求 chat/message 接口
|
||||
[self.aiVM requestChatMessageWithContent:finalText
|
||||
companionId:0
|
||||
completion:^(KBAiMessageResponse *_Nullable response,
|
||||
NSError *_Nullable error) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[KBHUD dismiss];
|
||||
|
||||
if (error) {
|
||||
[KBHUD showError:error.localizedDescription ?: @"请求失败"];
|
||||
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 ?: @"";
|
||||
|
||||
if (aiResponse.length == 0) {
|
||||
[KBHUD showError:@"AI 回复为空"];
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取 audioId
|
||||
NSString *audioId = response.data.audioId;
|
||||
|
||||
// 添加 AI 消息(带 audioId)
|
||||
[strongSelf.chatView addAssistantMessage:aiResponse
|
||||
audioId:audioId];
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)deepgramStreamingManagerDidFail:(NSError *)error {
|
||||
self.recordButton.state = KBAiRecordButtonStateNormal;
|
||||
[self showError:error];
|
||||
}
|
||||
|
||||
#pragma mark - Audio Playback
|
||||
|
||||
- (void)playAiAudioData:(NSData *)audioData {
|
||||
if (audioData.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSError *sessionError = nil;
|
||||
AudioSessionManager *audioSession = [AudioSessionManager sharedManager];
|
||||
if (![audioSession configureForPlayback:&sessionError]) {
|
||||
NSLog(@"[KBAiMainVC] Configure playback failed: %@",
|
||||
sessionError.localizedDescription ?: @"");
|
||||
}
|
||||
if (![audioSession activateSession:&sessionError]) {
|
||||
NSLog(@"[KBAiMainVC] Activate playback session failed: %@",
|
||||
sessionError.localizedDescription ?: @"");
|
||||
}
|
||||
|
||||
NSError *error = nil;
|
||||
self.aiAudioPlayer = [[AVAudioPlayer alloc] initWithData:audioData
|
||||
error:&error];
|
||||
if (error || !self.aiAudioPlayer) {
|
||||
NSLog(@"[KBAiMainVC] Audio player init failed: %@",
|
||||
error.localizedDescription ?: @"");
|
||||
return;
|
||||
}
|
||||
self.aiAudioPlayer.delegate = self;
|
||||
[self.aiAudioPlayer prepareToPlay];
|
||||
[self.aiAudioPlayer play];
|
||||
}
|
||||
|
||||
#pragma mark - AVAudioPlayerDelegate
|
||||
|
||||
- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player
|
||||
successfully:(BOOL)flag {
|
||||
[[AudioSessionManager sharedManager] deactivateSession];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,16 +0,0 @@
|
||||
//
|
||||
// AIMessageVM.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/28.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface AIMessageVM : NSObject
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -1,12 +0,0 @@
|
||||
//
|
||||
// AIMessageVM.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/28.
|
||||
//
|
||||
|
||||
#import "AIMessageVM.h"
|
||||
|
||||
@implementation AIMessageVM
|
||||
|
||||
@end
|
||||
@@ -1,51 +0,0 @@
|
||||
//
|
||||
// ASRStreamClient.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// ASR 流式识别客户端代理
|
||||
@protocol ASRStreamClientDelegate <NSObject>
|
||||
@required
|
||||
/// 收到实时识别结果(部分文本)
|
||||
- (void)asrClientDidReceivePartialText:(NSString *)text;
|
||||
/// 收到最终识别结果
|
||||
- (void)asrClientDidReceiveFinalText:(NSString *)text;
|
||||
/// 识别失败
|
||||
- (void)asrClientDidFail:(NSError *)error;
|
||||
@end
|
||||
|
||||
/// ASR 流式识别客户端
|
||||
/// 使用 NSURLSessionWebSocketTask 实现流式语音识别
|
||||
@interface ASRStreamClient : NSObject
|
||||
|
||||
@property(nonatomic, weak) id<ASRStreamClientDelegate> delegate;
|
||||
|
||||
/// ASR 服务器 WebSocket URL
|
||||
@property(nonatomic, copy) NSString *serverURL;
|
||||
|
||||
/// 是否已连接
|
||||
@property(nonatomic, assign, readonly, getter=isConnected) BOOL connected;
|
||||
|
||||
/// 开始新的识别会话
|
||||
/// @param sessionId 会话 ID
|
||||
- (void)startWithSessionId:(NSString *)sessionId;
|
||||
|
||||
/// 发送 PCM 音频帧(20ms / 640 bytes)
|
||||
/// @param pcmFrame PCM 数据
|
||||
- (void)sendAudioPCMFrame:(NSData *)pcmFrame;
|
||||
|
||||
/// 结束当前会话,请求最终结果
|
||||
- (void)finalize;
|
||||
|
||||
/// 取消会话
|
||||
- (void)cancel;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -1,271 +0,0 @@
|
||||
//
|
||||
// ASRStreamClient.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import "ASRStreamClient.h"
|
||||
#import "AudioCaptureManager.h"
|
||||
|
||||
@interface ASRStreamClient () <NSURLSessionWebSocketDelegate>
|
||||
|
||||
@property(nonatomic, strong) NSURLSession *urlSession;
|
||||
@property(nonatomic, strong) NSURLSessionWebSocketTask *webSocketTask;
|
||||
@property(nonatomic, copy) NSString *currentSessionId;
|
||||
@property(nonatomic, strong) dispatch_queue_t networkQueue;
|
||||
@property(nonatomic, assign) BOOL connected;
|
||||
|
||||
@end
|
||||
|
||||
@implementation ASRStreamClient
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_networkQueue = dispatch_queue_create("com.keyboard.aitalk.asr.network",
|
||||
DISPATCH_QUEUE_SERIAL);
|
||||
// TODO: 替换为实际的 ASR 服务器地址
|
||||
_serverURL = @"wss://your-asr-server.com/ws/asr";
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self cancelInternal];
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (void)startWithSessionId:(NSString *)sessionId {
|
||||
dispatch_async(self.networkQueue, ^{
|
||||
[self cancelInternal];
|
||||
|
||||
self.currentSessionId = sessionId;
|
||||
|
||||
// 创建 WebSocket 连接
|
||||
NSURL *url = [NSURL URLWithString:self.serverURL];
|
||||
NSURLSessionConfiguration *config =
|
||||
[NSURLSessionConfiguration defaultSessionConfiguration];
|
||||
config.timeoutIntervalForRequest = 30;
|
||||
config.timeoutIntervalForResource = 300;
|
||||
|
||||
self.urlSession = [NSURLSession sessionWithConfiguration:config
|
||||
delegate:self
|
||||
delegateQueue:nil];
|
||||
|
||||
self.webSocketTask = [self.urlSession webSocketTaskWithURL:url];
|
||||
[self.webSocketTask resume];
|
||||
|
||||
// 发送 start 消息
|
||||
NSDictionary *startMessage = @{
|
||||
@"type" : @"start",
|
||||
@"sessionId" : sessionId,
|
||||
@"format" : @"pcm_s16le",
|
||||
@"sampleRate" : @(kAudioSampleRate),
|
||||
@"channels" : @(kAudioChannels)
|
||||
};
|
||||
|
||||
NSError *jsonError = nil;
|
||||
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:startMessage
|
||||
options:0
|
||||
error:&jsonError];
|
||||
if (jsonError) {
|
||||
[self reportError:jsonError];
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *jsonString = [[NSString alloc] initWithData:jsonData
|
||||
encoding:NSUTF8StringEncoding];
|
||||
NSURLSessionWebSocketMessage *message =
|
||||
[[NSURLSessionWebSocketMessage alloc] initWithString:jsonString];
|
||||
|
||||
[self.webSocketTask
|
||||
sendMessage:message
|
||||
completionHandler:^(NSError *_Nullable error) {
|
||||
if (error) {
|
||||
[self reportError:error];
|
||||
} else {
|
||||
self.connected = YES;
|
||||
[self receiveMessage];
|
||||
NSLog(@"[ASRStreamClient] Started session: %@", sessionId);
|
||||
}
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)sendAudioPCMFrame:(NSData *)pcmFrame {
|
||||
if (!self.connected || !self.webSocketTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch_async(self.networkQueue, ^{
|
||||
NSURLSessionWebSocketMessage *message =
|
||||
[[NSURLSessionWebSocketMessage alloc] initWithData:pcmFrame];
|
||||
[self.webSocketTask sendMessage:message
|
||||
completionHandler:^(NSError *_Nullable error) {
|
||||
if (error) {
|
||||
NSLog(@"[ASRStreamClient] Failed to send audio frame: %@",
|
||||
error.localizedDescription);
|
||||
}
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)finalize {
|
||||
if (!self.connected || !self.webSocketTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch_async(self.networkQueue, ^{
|
||||
NSDictionary *finalizeMessage =
|
||||
@{@"type" : @"finalize", @"sessionId" : self.currentSessionId ?: @""};
|
||||
|
||||
NSError *jsonError = nil;
|
||||
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:finalizeMessage
|
||||
options:0
|
||||
error:&jsonError];
|
||||
if (jsonError) {
|
||||
[self reportError:jsonError];
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *jsonString = [[NSString alloc] initWithData:jsonData
|
||||
encoding:NSUTF8StringEncoding];
|
||||
NSURLSessionWebSocketMessage *message =
|
||||
[[NSURLSessionWebSocketMessage alloc] initWithString:jsonString];
|
||||
|
||||
[self.webSocketTask sendMessage:message
|
||||
completionHandler:^(NSError *_Nullable error) {
|
||||
if (error) {
|
||||
[self reportError:error];
|
||||
} else {
|
||||
NSLog(@"[ASRStreamClient] Sent finalize for session: %@",
|
||||
self.currentSessionId);
|
||||
}
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)cancel {
|
||||
dispatch_async(self.networkQueue, ^{
|
||||
[self cancelInternal];
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - Private Methods
|
||||
|
||||
- (void)cancelInternal {
|
||||
self.connected = NO;
|
||||
|
||||
if (self.webSocketTask) {
|
||||
[self.webSocketTask cancel];
|
||||
self.webSocketTask = nil;
|
||||
}
|
||||
|
||||
if (self.urlSession) {
|
||||
[self.urlSession invalidateAndCancel];
|
||||
self.urlSession = nil;
|
||||
}
|
||||
|
||||
self.currentSessionId = nil;
|
||||
}
|
||||
|
||||
- (void)receiveMessage {
|
||||
if (!self.webSocketTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[self.webSocketTask receiveMessageWithCompletionHandler:^(
|
||||
NSURLSessionWebSocketMessage *_Nullable message,
|
||||
NSError *_Nullable error) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf)
|
||||
return;
|
||||
|
||||
if (error) {
|
||||
// 检查是否是正常关闭
|
||||
if (error.code != 57 && error.code != NSURLErrorCancelled) {
|
||||
[strongSelf reportError:error];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type == NSURLSessionWebSocketMessageTypeString) {
|
||||
[strongSelf handleTextMessage:message.string];
|
||||
}
|
||||
|
||||
// 继续接收下一条消息
|
||||
[strongSelf receiveMessage];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)handleTextMessage:(NSString *)text {
|
||||
NSData *data = [text dataUsingEncoding:NSUTF8StringEncoding];
|
||||
NSError *jsonError = nil;
|
||||
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data
|
||||
options:0
|
||||
error:&jsonError];
|
||||
|
||||
if (jsonError) {
|
||||
NSLog(@"[ASRStreamClient] Failed to parse message: %@", text);
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *type = json[@"type"];
|
||||
|
||||
if ([type isEqualToString:@"partial"]) {
|
||||
NSString *partialText = json[@"text"] ?: @"";
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate
|
||||
respondsToSelector:@selector(asrClientDidReceivePartialText:)]) {
|
||||
[self.delegate asrClientDidReceivePartialText:partialText];
|
||||
}
|
||||
});
|
||||
} else if ([type isEqualToString:@"final"]) {
|
||||
NSString *finalText = json[@"text"] ?: @"";
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate
|
||||
respondsToSelector:@selector(asrClientDidReceiveFinalText:)]) {
|
||||
[self.delegate asrClientDidReceiveFinalText:finalText];
|
||||
}
|
||||
});
|
||||
// 收到最终结果后关闭连接
|
||||
[self cancelInternal];
|
||||
} else if ([type isEqualToString:@"error"]) {
|
||||
NSInteger code = [json[@"code"] integerValue];
|
||||
NSString *message = json[@"message"] ?: @"Unknown error";
|
||||
NSError *error =
|
||||
[NSError errorWithDomain:@"ASRStreamClient"
|
||||
code:code
|
||||
userInfo:@{NSLocalizedDescriptionKey : message}];
|
||||
[self reportError:error];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)reportError:(NSError *)error {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector(asrClientDidFail:)]) {
|
||||
[self.delegate asrClientDidFail:error];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - NSURLSessionWebSocketDelegate
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session
|
||||
webSocketTask:(NSURLSessionWebSocketTask *)webSocketTask
|
||||
didOpenWithProtocol:(NSString *)protocol {
|
||||
NSLog(@"[ASRStreamClient] WebSocket connected with protocol: %@", protocol);
|
||||
}
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session
|
||||
webSocketTask:(NSURLSessionWebSocketTask *)webSocketTask
|
||||
didCloseWithCode:(NSURLSessionWebSocketCloseCode)closeCode
|
||||
reason:(NSData *)reason {
|
||||
NSLog(@"[ASRStreamClient] WebSocket closed with code: %ld", (long)closeCode);
|
||||
self.connected = NO;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,63 +0,0 @@
|
||||
//
|
||||
// AudioStreamPlayer.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 流式音频播放器代理
|
||||
@protocol AudioStreamPlayerDelegate <NSObject>
|
||||
@optional
|
||||
/// 开始播放片段
|
||||
- (void)audioStreamPlayerDidStartSegment:(NSString *)segmentId;
|
||||
/// 播放时间更新
|
||||
- (void)audioStreamPlayerDidUpdateTime:(NSTimeInterval)time
|
||||
segmentId:(NSString *)segmentId;
|
||||
/// 片段播放完成
|
||||
- (void)audioStreamPlayerDidFinishSegment:(NSString *)segmentId;
|
||||
@end
|
||||
|
||||
/// PCM 流式播放器
|
||||
/// 使用 AVAudioEngine + AVAudioPlayerNode 实现低延迟播放
|
||||
@interface AudioStreamPlayer : NSObject
|
||||
|
||||
@property(nonatomic, weak) id<AudioStreamPlayerDelegate> delegate;
|
||||
|
||||
/// 是否正在播放
|
||||
@property(nonatomic, assign, readonly, getter=isPlaying) BOOL playing;
|
||||
|
||||
/// 启动播放器
|
||||
/// @param error 错误信息
|
||||
/// @return 是否启动成功
|
||||
- (BOOL)start:(NSError **)error;
|
||||
|
||||
/// 停止播放器
|
||||
- (void)stop;
|
||||
|
||||
/// 入队 PCM 数据块
|
||||
/// @param pcmData PCM Int16 数据
|
||||
/// @param sampleRate 采样率
|
||||
/// @param channels 通道数
|
||||
/// @param segmentId 片段 ID
|
||||
- (void)enqueuePCMChunk:(NSData *)pcmData
|
||||
sampleRate:(double)sampleRate
|
||||
channels:(int)channels
|
||||
segmentId:(NSString *)segmentId;
|
||||
|
||||
/// 获取片段的当前播放时间
|
||||
/// @param segmentId 片段 ID
|
||||
/// @return 当前时间(秒)
|
||||
- (NSTimeInterval)playbackTimeForSegment:(NSString *)segmentId;
|
||||
|
||||
/// 获取片段的总时长
|
||||
/// @param segmentId 片段 ID
|
||||
/// @return 总时长(秒)
|
||||
- (NSTimeInterval)durationForSegment:(NSString *)segmentId;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -1,246 +0,0 @@
|
||||
//
|
||||
// AudioStreamPlayer.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import "AudioStreamPlayer.h"
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
|
||||
@interface AudioStreamPlayer ()
|
||||
|
||||
@property(nonatomic, strong) AVAudioEngine *audioEngine;
|
||||
@property(nonatomic, strong) AVAudioPlayerNode *playerNode;
|
||||
@property(nonatomic, strong) AVAudioFormat *playbackFormat;
|
||||
|
||||
// 片段跟踪
|
||||
@property(nonatomic, copy) NSString *currentSegmentId;
|
||||
@property(nonatomic, strong)
|
||||
NSMutableDictionary<NSString *, NSNumber *> *segmentDurations;
|
||||
@property(nonatomic, strong)
|
||||
NSMutableDictionary<NSString *, NSNumber *> *segmentStartTimes;
|
||||
@property(nonatomic, assign) NSUInteger scheduledSamples;
|
||||
@property(nonatomic, assign) NSUInteger playedSamples;
|
||||
|
||||
// 状态
|
||||
@property(nonatomic, assign) BOOL playing;
|
||||
@property(nonatomic, strong) dispatch_queue_t playerQueue;
|
||||
@property(nonatomic, strong) NSTimer *progressTimer;
|
||||
|
||||
@end
|
||||
|
||||
@implementation AudioStreamPlayer
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_audioEngine = [[AVAudioEngine alloc] init];
|
||||
_playerNode = [[AVAudioPlayerNode alloc] init];
|
||||
_segmentDurations = [[NSMutableDictionary alloc] init];
|
||||
_segmentStartTimes = [[NSMutableDictionary alloc] init];
|
||||
_playerQueue = dispatch_queue_create("com.keyboard.aitalk.streamplayer",
|
||||
DISPATCH_QUEUE_SERIAL);
|
||||
|
||||
// 默认播放格式:16kHz, Mono, Float32
|
||||
_playbackFormat =
|
||||
[[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32
|
||||
sampleRate:16000
|
||||
channels:1
|
||||
interleaved:NO];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self stop];
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (BOOL)start:(NSError **)error {
|
||||
if (self.playing) {
|
||||
return YES;
|
||||
}
|
||||
|
||||
// 连接节点
|
||||
[self.audioEngine attachNode:self.playerNode];
|
||||
[self.audioEngine connect:self.playerNode
|
||||
to:self.audioEngine.mainMixerNode
|
||||
format:self.playbackFormat];
|
||||
|
||||
// 启动引擎
|
||||
NSError *startError = nil;
|
||||
[self.audioEngine prepare];
|
||||
|
||||
if (![self.audioEngine startAndReturnError:&startError]) {
|
||||
if (error) {
|
||||
*error = startError;
|
||||
}
|
||||
NSLog(@"[AudioStreamPlayer] Failed to start engine: %@",
|
||||
startError.localizedDescription);
|
||||
return NO;
|
||||
}
|
||||
|
||||
[self.playerNode play];
|
||||
self.playing = YES;
|
||||
|
||||
// 启动进度更新定时器
|
||||
[self startProgressTimer];
|
||||
|
||||
NSLog(@"[AudioStreamPlayer] Started");
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)stop {
|
||||
dispatch_async(self.playerQueue, ^{
|
||||
[self stopProgressTimer];
|
||||
|
||||
[self.playerNode stop];
|
||||
[self.audioEngine stop];
|
||||
|
||||
self.playing = NO;
|
||||
self.currentSegmentId = nil;
|
||||
self.scheduledSamples = 0;
|
||||
self.playedSamples = 0;
|
||||
|
||||
[self.segmentDurations removeAllObjects];
|
||||
[self.segmentStartTimes removeAllObjects];
|
||||
|
||||
NSLog(@"[AudioStreamPlayer] Stopped");
|
||||
});
|
||||
}
|
||||
|
||||
- (void)enqueuePCMChunk:(NSData *)pcmData
|
||||
sampleRate:(double)sampleRate
|
||||
channels:(int)channels
|
||||
segmentId:(NSString *)segmentId {
|
||||
|
||||
if (!pcmData || pcmData.length == 0)
|
||||
return;
|
||||
|
||||
dispatch_async(self.playerQueue, ^{
|
||||
// 检查是否是新片段
|
||||
BOOL isNewSegment = ![segmentId isEqualToString:self.currentSegmentId];
|
||||
if (isNewSegment) {
|
||||
self.currentSegmentId = segmentId;
|
||||
self.scheduledSamples = 0;
|
||||
self.segmentStartTimes[segmentId] = @(CACurrentMediaTime());
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(audioStreamPlayerDidStartSegment:)]) {
|
||||
[self.delegate audioStreamPlayerDidStartSegment:segmentId];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 转换 Int16 -> Float32
|
||||
NSUInteger sampleCount = pcmData.length / sizeof(int16_t);
|
||||
const int16_t *int16Samples = (const int16_t *)pcmData.bytes;
|
||||
|
||||
// 创建播放格式的 buffer
|
||||
AVAudioFormat *format =
|
||||
[[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32
|
||||
sampleRate:sampleRate
|
||||
channels:channels
|
||||
interleaved:NO];
|
||||
|
||||
AVAudioPCMBuffer *buffer = [[AVAudioPCMBuffer alloc]
|
||||
initWithPCMFormat:format
|
||||
frameCapacity:(AVAudioFrameCount)sampleCount];
|
||||
buffer.frameLength = (AVAudioFrameCount)sampleCount;
|
||||
|
||||
float *floatChannel = buffer.floatChannelData[0];
|
||||
for (NSUInteger i = 0; i < sampleCount; i++) {
|
||||
floatChannel[i] = (float)int16Samples[i] / 32768.0f;
|
||||
}
|
||||
|
||||
// 调度播放
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[self.playerNode scheduleBuffer:buffer
|
||||
completionHandler:^{
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf)
|
||||
return;
|
||||
|
||||
dispatch_async(strongSelf.playerQueue, ^{
|
||||
strongSelf.playedSamples += sampleCount;
|
||||
});
|
||||
}];
|
||||
|
||||
self.scheduledSamples += sampleCount;
|
||||
|
||||
// 更新时长
|
||||
NSTimeInterval chunkDuration = (double)sampleCount / sampleRate;
|
||||
NSNumber *currentDuration = self.segmentDurations[segmentId];
|
||||
self.segmentDurations[segmentId] =
|
||||
@(currentDuration.doubleValue + chunkDuration);
|
||||
});
|
||||
}
|
||||
|
||||
- (NSTimeInterval)playbackTimeForSegment:(NSString *)segmentId {
|
||||
if (![segmentId isEqualToString:self.currentSegmentId]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 基于已播放的采样数估算时间
|
||||
return (double)self.playedSamples / self.playbackFormat.sampleRate;
|
||||
}
|
||||
|
||||
- (NSTimeInterval)durationForSegment:(NSString *)segmentId {
|
||||
NSNumber *duration = self.segmentDurations[segmentId];
|
||||
return duration ? duration.doubleValue : 0;
|
||||
}
|
||||
|
||||
#pragma mark - Progress Timer
|
||||
|
||||
- (void)startProgressTimer {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
self.progressTimer =
|
||||
[NSTimer scheduledTimerWithTimeInterval:1.0 / 30.0
|
||||
target:self
|
||||
selector:@selector(updateProgress)
|
||||
userInfo:nil
|
||||
repeats:YES];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)stopProgressTimer {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.progressTimer invalidate];
|
||||
self.progressTimer = nil;
|
||||
});
|
||||
}
|
||||
|
||||
- (void)updateProgress {
|
||||
if (!self.playing || !self.currentSegmentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSTimeInterval currentTime =
|
||||
[self playbackTimeForSegment:self.currentSegmentId];
|
||||
NSString *segmentId = self.currentSegmentId;
|
||||
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(audioStreamPlayerDidUpdateTime:segmentId:)]) {
|
||||
[self.delegate audioStreamPlayerDidUpdateTime:currentTime
|
||||
segmentId:segmentId];
|
||||
}
|
||||
|
||||
// 检查是否播放完成
|
||||
NSTimeInterval duration = [self durationForSegment:segmentId];
|
||||
if (duration > 0 && currentTime >= duration - 0.1) {
|
||||
// 播放完成
|
||||
dispatch_async(self.playerQueue, ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(audioStreamPlayerDidFinishSegment:)]) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.delegate audioStreamPlayerDidFinishSegment:segmentId];
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,88 +0,0 @@
|
||||
//
|
||||
// ConversationOrchestrator.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 对话状态
|
||||
typedef NS_ENUM(NSInteger, ConversationState) {
|
||||
ConversationStateIdle = 0, // 空闲
|
||||
ConversationStateListening, // 正在录音
|
||||
ConversationStateRecognizing, // 正在识别(等待 ASR 结果)
|
||||
ConversationStateThinking, // 正在思考(等待 LLM 回复)
|
||||
ConversationStateSpeaking // 正在播报 TTS
|
||||
};
|
||||
|
||||
/// 对话编排器
|
||||
/// 核心状态机,串联所有模块,处理打断逻辑
|
||||
@interface ConversationOrchestrator : NSObject
|
||||
|
||||
/// 当前状态
|
||||
@property(nonatomic, assign, readonly) ConversationState state;
|
||||
|
||||
/// 当前对话 ID
|
||||
@property(nonatomic, copy, readonly, nullable) NSString *conversationId;
|
||||
|
||||
#pragma mark - Callbacks
|
||||
|
||||
/// 用户最终识别文本回调
|
||||
@property(nonatomic, copy, nullable) void (^onUserFinalText)(NSString *text);
|
||||
|
||||
/// AI 可见文本回调(打字机效果)
|
||||
@property(nonatomic, copy, nullable) void (^onAssistantVisibleText)
|
||||
(NSString *text);
|
||||
|
||||
/// AI 完整回复文本回调
|
||||
@property(nonatomic, copy, nullable) void (^onAssistantFullText)(NSString *text)
|
||||
;
|
||||
|
||||
/// 实时识别文本回调(部分结果)
|
||||
@property(nonatomic, copy, nullable) void (^onPartialText)(NSString *text);
|
||||
|
||||
/// 音量更新回调(用于波形 UI)
|
||||
@property(nonatomic, copy, nullable) void (^onVolumeUpdate)(float rms);
|
||||
|
||||
/// 状态变化回调
|
||||
@property(nonatomic, copy, nullable) void (^onStateChange)
|
||||
(ConversationState state);
|
||||
|
||||
/// 错误回调
|
||||
@property(nonatomic, copy, nullable) void (^onError)(NSError *error);
|
||||
|
||||
/// AI 开始说话回调
|
||||
@property(nonatomic, copy, nullable) void (^onSpeakingStart)(void);
|
||||
|
||||
/// AI 说话结束回调
|
||||
@property(nonatomic, copy, nullable) void (^onSpeakingEnd)(void);
|
||||
|
||||
#pragma mark - Configuration
|
||||
|
||||
/// ASR 服务器 URL
|
||||
@property(nonatomic, copy) NSString *asrServerURL;
|
||||
|
||||
/// LLM 服务器 URL
|
||||
@property(nonatomic, copy) NSString *llmServerURL;
|
||||
|
||||
/// TTS 服务器 URL
|
||||
@property(nonatomic, copy) NSString *ttsServerURL;
|
||||
|
||||
#pragma mark - User Actions
|
||||
|
||||
/// 用户按下录音按钮
|
||||
/// 如果当前正在播放,会自动打断
|
||||
- (void)userDidPressRecord;
|
||||
|
||||
/// 用户松开录音按钮
|
||||
- (void)userDidReleaseRecord;
|
||||
|
||||
/// 手动停止(退出页面等)
|
||||
- (void)stop;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -1,532 +0,0 @@
|
||||
//
|
||||
// ConversationOrchestrator.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import "ConversationOrchestrator.h"
|
||||
#import "ASRStreamClient.h"
|
||||
#import "AudioCaptureManager.h"
|
||||
#import "AudioSessionManager.h"
|
||||
#import "LLMStreamClient.h"
|
||||
#import "Segmenter.h"
|
||||
#import "SubtitleSync.h"
|
||||
#import "TTSPlaybackPipeline.h"
|
||||
#import "TTSServiceClient.h"
|
||||
|
||||
@interface ConversationOrchestrator () <
|
||||
AudioSessionManagerDelegate, AudioCaptureManagerDelegate,
|
||||
ASRStreamClientDelegate, LLMStreamClientDelegate, TTSServiceClientDelegate,
|
||||
TTSPlaybackPipelineDelegate>
|
||||
|
||||
// 模块
|
||||
@property(nonatomic, strong) AudioSessionManager *audioSession;
|
||||
@property(nonatomic, strong) AudioCaptureManager *audioCapture;
|
||||
@property(nonatomic, strong) ASRStreamClient *asrClient;
|
||||
@property(nonatomic, strong) LLMStreamClient *llmClient;
|
||||
@property(nonatomic, strong) Segmenter *segmenter;
|
||||
@property(nonatomic, strong) TTSServiceClient *ttsClient;
|
||||
@property(nonatomic, strong) TTSPlaybackPipeline *playbackPipeline;
|
||||
@property(nonatomic, strong) SubtitleSync *subtitleSync;
|
||||
|
||||
// 状态
|
||||
@property(nonatomic, assign) ConversationState state;
|
||||
@property(nonatomic, copy) NSString *conversationId;
|
||||
@property(nonatomic, copy) NSString *currentSessionId;
|
||||
|
||||
// 文本跟踪
|
||||
@property(nonatomic, strong) NSMutableString *fullAssistantText;
|
||||
@property(nonatomic, strong)
|
||||
NSMutableDictionary<NSString *, NSString *> *segmentTextMap;
|
||||
@property(nonatomic, assign) NSInteger segmentCounter;
|
||||
|
||||
// 队列
|
||||
@property(nonatomic, strong) dispatch_queue_t orchestratorQueue;
|
||||
|
||||
@end
|
||||
|
||||
@implementation ConversationOrchestrator
|
||||
|
||||
#pragma mark - Initialization
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_orchestratorQueue = dispatch_queue_create(
|
||||
"com.keyboard.aitalk.orchestrator", DISPATCH_QUEUE_SERIAL);
|
||||
_state = ConversationStateIdle;
|
||||
_conversationId = [[NSUUID UUID] UUIDString];
|
||||
|
||||
_fullAssistantText = [[NSMutableString alloc] init];
|
||||
_segmentTextMap = [[NSMutableDictionary alloc] init];
|
||||
_segmentCounter = 0;
|
||||
|
||||
[self setupModules];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setupModules {
|
||||
// Audio Session
|
||||
self.audioSession = [AudioSessionManager sharedManager];
|
||||
self.audioSession.delegate = self;
|
||||
|
||||
// Audio Capture
|
||||
self.audioCapture = [[AudioCaptureManager alloc] init];
|
||||
self.audioCapture.delegate = self;
|
||||
|
||||
// ASR Client
|
||||
self.asrClient = [[ASRStreamClient alloc] init];
|
||||
self.asrClient.delegate = self;
|
||||
|
||||
// LLM Client
|
||||
self.llmClient = [[LLMStreamClient alloc] init];
|
||||
self.llmClient.delegate = self;
|
||||
|
||||
// Segmenter
|
||||
self.segmenter = [[Segmenter alloc] init];
|
||||
|
||||
// TTS Client
|
||||
self.ttsClient = [[TTSServiceClient alloc] init];
|
||||
self.ttsClient.delegate = self;
|
||||
// ElevenLabs 配置(通过后端代理)
|
||||
self.ttsClient.voiceId = @"JBFqnCBsd6RMkjVDRZzb"; // 默认语音 George
|
||||
self.ttsClient.languageCode = @"zh"; // 中文
|
||||
self.ttsClient.expectedPayloadType =
|
||||
TTSPayloadTypeURL; // 使用 URL 模式(简单)
|
||||
|
||||
// Playback Pipeline
|
||||
self.playbackPipeline = [[TTSPlaybackPipeline alloc] init];
|
||||
self.playbackPipeline.delegate = self;
|
||||
|
||||
// Subtitle Sync
|
||||
self.subtitleSync = [[SubtitleSync alloc] init];
|
||||
}
|
||||
|
||||
#pragma mark - Configuration Setters
|
||||
|
||||
- (void)setAsrServerURL:(NSString *)asrServerURL {
|
||||
_asrServerURL = [asrServerURL copy];
|
||||
self.asrClient.serverURL = asrServerURL;
|
||||
}
|
||||
|
||||
- (void)setLlmServerURL:(NSString *)llmServerURL {
|
||||
_llmServerURL = [llmServerURL copy];
|
||||
self.llmClient.serverURL = llmServerURL;
|
||||
}
|
||||
|
||||
- (void)setTtsServerURL:(NSString *)ttsServerURL {
|
||||
_ttsServerURL = [ttsServerURL copy];
|
||||
self.ttsClient.serverURL = ttsServerURL;
|
||||
}
|
||||
|
||||
#pragma mark - User Actions
|
||||
|
||||
- (void)userDidPressRecord {
|
||||
dispatch_async(self.orchestratorQueue, ^{
|
||||
NSLog(@"[Orchestrator] userDidPressRecord, current state: %ld",
|
||||
(long)self.state);
|
||||
|
||||
// 如果正在播放或思考,执行打断
|
||||
if (self.state == ConversationStateSpeaking ||
|
||||
self.state == ConversationStateThinking) {
|
||||
[self performBargein];
|
||||
}
|
||||
|
||||
// 检查麦克风权限
|
||||
if (![self.audioSession hasMicrophonePermission]) {
|
||||
[self.audioSession requestMicrophonePermission:^(BOOL granted) {
|
||||
if (granted) {
|
||||
dispatch_async(self.orchestratorQueue, ^{
|
||||
[self startRecording];
|
||||
});
|
||||
}
|
||||
}];
|
||||
return;
|
||||
}
|
||||
|
||||
[self startRecording];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)userDidReleaseRecord {
|
||||
dispatch_async(self.orchestratorQueue, ^{
|
||||
NSLog(@"[Orchestrator] userDidReleaseRecord, current state: %ld",
|
||||
(long)self.state);
|
||||
|
||||
if (self.state != ConversationStateListening) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 停止采集
|
||||
[self.audioCapture stopCapture];
|
||||
|
||||
// 请求 ASR 最终结果
|
||||
[self.asrClient finalize];
|
||||
|
||||
// 更新状态
|
||||
[self updateState:ConversationStateRecognizing];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)stop {
|
||||
dispatch_async(self.orchestratorQueue, ^{
|
||||
[self cancelAll];
|
||||
[self updateState:ConversationStateIdle];
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - Private: Recording
|
||||
|
||||
- (void)startRecording {
|
||||
// 配置音频会话
|
||||
NSError *error = nil;
|
||||
if (![self.audioSession configureForConversation:&error]) {
|
||||
[self reportError:error];
|
||||
return;
|
||||
}
|
||||
|
||||
if (![self.audioSession activateSession:&error]) {
|
||||
[self reportError:error];
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成新的会话 ID
|
||||
self.currentSessionId = [[NSUUID UUID] UUIDString];
|
||||
|
||||
// 启动 ASR
|
||||
[self.asrClient startWithSessionId:self.currentSessionId];
|
||||
|
||||
// 启动音频采集
|
||||
if (![self.audioCapture startCapture:&error]) {
|
||||
[self reportError:error];
|
||||
[self.asrClient cancel];
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
[self updateState:ConversationStateListening];
|
||||
}
|
||||
|
||||
#pragma mark - Private: Barge-in (打断)
|
||||
|
||||
- (void)performBargein {
|
||||
NSLog(@"[Orchestrator] Performing barge-in");
|
||||
|
||||
// 取消所有正在进行的请求
|
||||
[self.ttsClient cancel];
|
||||
[self.llmClient cancel];
|
||||
[self.asrClient cancel];
|
||||
|
||||
// 停止播放
|
||||
[self.playbackPipeline stop];
|
||||
|
||||
// 清空状态
|
||||
[self.segmenter reset];
|
||||
[self.segmentTextMap removeAllObjects];
|
||||
[self.fullAssistantText setString:@""];
|
||||
self.segmentCounter = 0;
|
||||
}
|
||||
|
||||
- (void)cancelAll {
|
||||
[self.audioCapture stopCapture];
|
||||
[self.asrClient cancel];
|
||||
[self.llmClient cancel];
|
||||
[self.ttsClient cancel];
|
||||
[self.playbackPipeline stop];
|
||||
[self.segmenter reset];
|
||||
[self.audioSession deactivateSession];
|
||||
}
|
||||
|
||||
#pragma mark - Private: State Management
|
||||
|
||||
- (void)updateState:(ConversationState)newState {
|
||||
if (self.state == newState)
|
||||
return;
|
||||
|
||||
ConversationState oldState = self.state;
|
||||
self.state = newState;
|
||||
|
||||
NSLog(@"[Orchestrator] State: %ld -> %ld", (long)oldState, (long)newState);
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self.onStateChange) {
|
||||
self.onStateChange(newState);
|
||||
}
|
||||
|
||||
// 特殊状态回调
|
||||
if (newState == ConversationStateSpeaking &&
|
||||
oldState != ConversationStateSpeaking) {
|
||||
if (self.onSpeakingStart) {
|
||||
self.onSpeakingStart();
|
||||
}
|
||||
}
|
||||
|
||||
if (oldState == ConversationStateSpeaking &&
|
||||
newState != ConversationStateSpeaking) {
|
||||
if (self.onSpeakingEnd) {
|
||||
self.onSpeakingEnd();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)reportError:(NSError *)error {
|
||||
NSLog(@"[Orchestrator] Error: %@", error.localizedDescription);
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self.onError) {
|
||||
self.onError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - AudioCaptureManagerDelegate
|
||||
|
||||
- (void)audioCaptureManagerDidOutputPCMFrame:(NSData *)pcmFrame {
|
||||
// 发送到 ASR
|
||||
[self.asrClient sendAudioPCMFrame:pcmFrame];
|
||||
}
|
||||
|
||||
- (void)audioCaptureManagerDidUpdateRMS:(float)rms {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self.onVolumeUpdate) {
|
||||
self.onVolumeUpdate(rms);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - AudioSessionManagerDelegate
|
||||
|
||||
- (void)audioSessionManagerDidInterrupt:(KBAudioSessionInterruptionType)type {
|
||||
dispatch_async(self.orchestratorQueue, ^{
|
||||
if (type == KBAudioSessionInterruptionTypeBegan) {
|
||||
// 中断开始:停止采集和播放
|
||||
[self cancelAll];
|
||||
[self updateState:ConversationStateIdle];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)audioSessionManagerMicrophonePermissionDenied {
|
||||
NSError *error =
|
||||
[NSError errorWithDomain:@"ConversationOrchestrator"
|
||||
code:-1
|
||||
userInfo:@{
|
||||
NSLocalizedDescriptionKey : @"请在设置中开启麦克风权限"
|
||||
}];
|
||||
[self reportError:error];
|
||||
}
|
||||
|
||||
#pragma mark - ASRStreamClientDelegate
|
||||
|
||||
- (void)asrClientDidReceivePartialText:(NSString *)text {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self.onPartialText) {
|
||||
self.onPartialText(text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)asrClientDidReceiveFinalText:(NSString *)text {
|
||||
dispatch_async(self.orchestratorQueue, ^{
|
||||
NSLog(@"[Orchestrator] ASR final text: %@", text);
|
||||
|
||||
// 回调用户文本
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self.onUserFinalText) {
|
||||
self.onUserFinalText(text);
|
||||
}
|
||||
});
|
||||
|
||||
// 如果文本为空,回到空闲
|
||||
if (text.length == 0) {
|
||||
[self updateState:ConversationStateIdle];
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新状态并开始 LLM 请求
|
||||
[self updateState:ConversationStateThinking];
|
||||
|
||||
// 重置文本跟踪
|
||||
[self.fullAssistantText setString:@""];
|
||||
[self.segmentTextMap removeAllObjects];
|
||||
self.segmentCounter = 0;
|
||||
[self.segmenter reset];
|
||||
|
||||
// 启动播放管线
|
||||
NSError *error = nil;
|
||||
if (![self.playbackPipeline start:&error]) {
|
||||
NSLog(@"[Orchestrator] Failed to start playback pipeline: %@",
|
||||
error.localizedDescription);
|
||||
}
|
||||
|
||||
// 发送 LLM 请求
|
||||
[self.llmClient sendUserText:text conversationId:self.conversationId];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)asrClientDidFail:(NSError *)error {
|
||||
dispatch_async(self.orchestratorQueue, ^{
|
||||
[self reportError:error];
|
||||
[self updateState:ConversationStateIdle];
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - LLMStreamClientDelegate
|
||||
|
||||
- (void)llmClientDidReceiveToken:(NSString *)token {
|
||||
dispatch_async(self.orchestratorQueue, ^{
|
||||
// 追加到完整文本
|
||||
[self.fullAssistantText appendString:token];
|
||||
|
||||
// 追加到分段器
|
||||
[self.segmenter appendToken:token];
|
||||
|
||||
// 检查是否有可触发 TTS 的片段
|
||||
NSArray<NSString *> *segments = [self.segmenter popReadySegments];
|
||||
for (NSString *segmentText in segments) {
|
||||
[self requestTTSForSegment:segmentText];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)llmClientDidComplete {
|
||||
dispatch_async(self.orchestratorQueue, ^{
|
||||
NSLog(@"[Orchestrator] LLM complete");
|
||||
|
||||
// 处理剩余片段
|
||||
NSString *remaining = [self.segmenter flushRemainingSegment];
|
||||
if (remaining && remaining.length > 0) {
|
||||
[self requestTTSForSegment:remaining];
|
||||
}
|
||||
|
||||
// 回调完整文本
|
||||
NSString *fullText = [self.fullAssistantText copy];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self.onAssistantFullText) {
|
||||
self.onAssistantFullText(fullText);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
- (void)llmClientDidFail:(NSError *)error {
|
||||
dispatch_async(self.orchestratorQueue, ^{
|
||||
[self reportError:error];
|
||||
[self updateState:ConversationStateIdle];
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - Private: TTS Request
|
||||
|
||||
- (void)requestTTSForSegment:(NSString *)segmentText {
|
||||
NSString *segmentId =
|
||||
[NSString stringWithFormat:@"seg_%ld", (long)self.segmentCounter++];
|
||||
|
||||
// 记录片段文本
|
||||
self.segmentTextMap[segmentId] = segmentText;
|
||||
|
||||
NSLog(@"[Orchestrator] Requesting TTS for segment %@: %@", segmentId,
|
||||
segmentText);
|
||||
|
||||
// 请求 TTS
|
||||
[self.ttsClient requestTTSForText:segmentText segmentId:segmentId];
|
||||
}
|
||||
|
||||
#pragma mark - TTSServiceClientDelegate
|
||||
|
||||
- (void)ttsClientDidReceiveURL:(NSURL *)url segmentId:(NSString *)segmentId {
|
||||
dispatch_async(self.orchestratorQueue, ^{
|
||||
[self.playbackPipeline enqueueURL:url segmentId:segmentId];
|
||||
|
||||
// 如果还在 Thinking,切换到 Speaking
|
||||
if (self.state == ConversationStateThinking) {
|
||||
[self updateState:ConversationStateSpeaking];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)ttsClientDidReceiveAudioChunk:(NSData *)chunk
|
||||
payloadType:(TTSPayloadType)type
|
||||
segmentId:(NSString *)segmentId {
|
||||
dispatch_async(self.orchestratorQueue, ^{
|
||||
[self.playbackPipeline enqueueChunk:chunk
|
||||
payloadType:type
|
||||
segmentId:segmentId];
|
||||
|
||||
// 如果还在 Thinking,切换到 Speaking
|
||||
if (self.state == ConversationStateThinking) {
|
||||
[self updateState:ConversationStateSpeaking];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)ttsClientDidFinishSegment:(NSString *)segmentId {
|
||||
dispatch_async(self.orchestratorQueue, ^{
|
||||
[self.playbackPipeline markSegmentComplete:segmentId];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)ttsClientDidFail:(NSError *)error {
|
||||
dispatch_async(self.orchestratorQueue, ^{
|
||||
[self reportError:error];
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - TTSPlaybackPipelineDelegate
|
||||
|
||||
- (void)pipelineDidStartSegment:(NSString *)segmentId
|
||||
duration:(NSTimeInterval)duration {
|
||||
NSLog(@"[Orchestrator] Started playing segment: %@", segmentId);
|
||||
}
|
||||
|
||||
- (void)pipelineDidUpdatePlaybackTime:(NSTimeInterval)time
|
||||
segmentId:(NSString *)segmentId {
|
||||
dispatch_async(self.orchestratorQueue, ^{
|
||||
// 获取片段文本
|
||||
NSString *segmentText = self.segmentTextMap[segmentId];
|
||||
if (!segmentText)
|
||||
return;
|
||||
|
||||
// 计算可见文本
|
||||
NSTimeInterval duration =
|
||||
[self.playbackPipeline durationForSegment:segmentId];
|
||||
NSString *visibleText =
|
||||
[self.subtitleSync visibleTextForFullText:segmentText
|
||||
currentTime:time
|
||||
duration:duration];
|
||||
|
||||
// TODO: 这里应该累加之前片段的文本,实现完整的打字机效果
|
||||
// 简化实现:只显示当前片段
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self.onAssistantVisibleText) {
|
||||
self.onAssistantVisibleText(visibleText);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
- (void)pipelineDidFinishSegment:(NSString *)segmentId {
|
||||
NSLog(@"[Orchestrator] Finished playing segment: %@", segmentId);
|
||||
}
|
||||
|
||||
- (void)pipelineDidFinishAllSegments {
|
||||
dispatch_async(self.orchestratorQueue, ^{
|
||||
NSLog(@"[Orchestrator] All segments finished");
|
||||
|
||||
// 回到空闲状态
|
||||
[self updateState:ConversationStateIdle];
|
||||
[self.audioSession deactivateSession];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)pipelineDidFail:(NSError *)error {
|
||||
dispatch_async(self.orchestratorQueue, ^{
|
||||
[self reportError:error];
|
||||
[self updateState:ConversationStateIdle];
|
||||
});
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,48 +0,0 @@
|
||||
//
|
||||
// LLMStreamClient.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// LLM 流式生成客户端代理
|
||||
@protocol LLMStreamClientDelegate <NSObject>
|
||||
@required
|
||||
/// 收到新的 token
|
||||
- (void)llmClientDidReceiveToken:(NSString *)token;
|
||||
/// 生成完成
|
||||
- (void)llmClientDidComplete;
|
||||
/// 生成失败
|
||||
- (void)llmClientDidFail:(NSError *)error;
|
||||
@end
|
||||
|
||||
/// LLM 流式生成客户端
|
||||
/// 支持 SSE(Server-Sent Events)或 WebSocket 接收 token 流
|
||||
@interface LLMStreamClient : NSObject
|
||||
|
||||
@property(nonatomic, weak) id<LLMStreamClientDelegate> delegate;
|
||||
|
||||
/// LLM 服务器 URL
|
||||
@property(nonatomic, copy) NSString *serverURL;
|
||||
|
||||
/// API Key(如需要)
|
||||
@property(nonatomic, copy, nullable) NSString *apiKey;
|
||||
|
||||
/// 是否正在生成
|
||||
@property(nonatomic, assign, readonly, getter=isGenerating) BOOL generating;
|
||||
|
||||
/// 发送用户文本请求 LLM 回复
|
||||
/// @param text 用户输入的文本
|
||||
/// @param conversationId 对话 ID
|
||||
- (void)sendUserText:(NSString *)text conversationId:(NSString *)conversationId;
|
||||
|
||||
/// 取消当前请求
|
||||
- (void)cancel;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -1,244 +0,0 @@
|
||||
//
|
||||
// LLMStreamClient.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import "LLMStreamClient.h"
|
||||
|
||||
@interface LLMStreamClient () <NSURLSessionDataDelegate>
|
||||
|
||||
@property(nonatomic, strong) NSURLSession *urlSession;
|
||||
@property(nonatomic, strong) NSURLSessionDataTask *dataTask;
|
||||
@property(nonatomic, strong) dispatch_queue_t networkQueue;
|
||||
@property(nonatomic, assign) BOOL generating;
|
||||
@property(nonatomic, strong) NSMutableString *buffer; // SSE 数据缓冲
|
||||
|
||||
@end
|
||||
|
||||
@implementation LLMStreamClient
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_networkQueue = dispatch_queue_create("com.keyboard.aitalk.llm.network",
|
||||
DISPATCH_QUEUE_SERIAL);
|
||||
_buffer = [[NSMutableString alloc] init];
|
||||
// TODO: 替换为实际的 LLM 服务器地址
|
||||
_serverURL = @"https://your-llm-server.com/api/chat/stream";
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self cancel];
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (void)sendUserText:(NSString *)text
|
||||
conversationId:(NSString *)conversationId {
|
||||
dispatch_async(self.networkQueue, ^{
|
||||
[self cancelInternal];
|
||||
|
||||
self.generating = YES;
|
||||
[self.buffer setString:@""];
|
||||
|
||||
// 创建请求
|
||||
NSURL *url = [NSURL URLWithString:self.serverURL];
|
||||
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
|
||||
request.HTTPMethod = @"POST";
|
||||
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
|
||||
[request setValue:@"text/event-stream" forHTTPHeaderField:@"Accept"];
|
||||
|
||||
if (self.apiKey) {
|
||||
[request setValue:[NSString stringWithFormat:@"Bearer %@", self.apiKey]
|
||||
forHTTPHeaderField:@"Authorization"];
|
||||
}
|
||||
|
||||
// 请求体
|
||||
NSDictionary *body = @{
|
||||
@"message" : text,
|
||||
@"conversationId" : conversationId,
|
||||
@"stream" : @YES
|
||||
};
|
||||
|
||||
NSError *jsonError = nil;
|
||||
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:body
|
||||
options:0
|
||||
error:&jsonError];
|
||||
if (jsonError) {
|
||||
[self reportError:jsonError];
|
||||
return;
|
||||
}
|
||||
request.HTTPBody = jsonData;
|
||||
|
||||
// 创建会话
|
||||
NSURLSessionConfiguration *config =
|
||||
[NSURLSessionConfiguration defaultSessionConfiguration];
|
||||
config.timeoutIntervalForRequest = 60;
|
||||
config.timeoutIntervalForResource = 300;
|
||||
|
||||
self.urlSession = [NSURLSession sessionWithConfiguration:config
|
||||
delegate:self
|
||||
delegateQueue:nil];
|
||||
|
||||
self.dataTask = [self.urlSession dataTaskWithRequest:request];
|
||||
[self.dataTask resume];
|
||||
|
||||
NSLog(@"[LLMStreamClient] Started request for conversation: %@",
|
||||
conversationId);
|
||||
});
|
||||
}
|
||||
|
||||
- (void)cancel {
|
||||
dispatch_async(self.networkQueue, ^{
|
||||
[self cancelInternal];
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - Private Methods
|
||||
|
||||
- (void)cancelInternal {
|
||||
self.generating = NO;
|
||||
|
||||
if (self.dataTask) {
|
||||
[self.dataTask cancel];
|
||||
self.dataTask = nil;
|
||||
}
|
||||
|
||||
if (self.urlSession) {
|
||||
[self.urlSession invalidateAndCancel];
|
||||
self.urlSession = nil;
|
||||
}
|
||||
|
||||
[self.buffer setString:@""];
|
||||
}
|
||||
|
||||
- (void)reportError:(NSError *)error {
|
||||
self.generating = NO;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector(llmClientDidFail:)]) {
|
||||
[self.delegate llmClientDidFail:error];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)reportComplete {
|
||||
self.generating = NO;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector(llmClientDidComplete)]) {
|
||||
[self.delegate llmClientDidComplete];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)reportToken:(NSString *)token {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate
|
||||
respondsToSelector:@selector(llmClientDidReceiveToken:)]) {
|
||||
[self.delegate llmClientDidReceiveToken:token];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - SSE Parsing
|
||||
|
||||
- (void)parseSSEData:(NSData *)data {
|
||||
NSString *string = [[NSString alloc] initWithData:data
|
||||
encoding:NSUTF8StringEncoding];
|
||||
if (!string)
|
||||
return;
|
||||
|
||||
[self.buffer appendString:string];
|
||||
|
||||
// SSE 格式:每个事件以 \n\n 分隔
|
||||
NSArray *events = [self.buffer componentsSeparatedByString:@"\n\n"];
|
||||
|
||||
// 保留最后一个可能不完整的事件
|
||||
if (events.count > 1) {
|
||||
[self.buffer setString:events.lastObject];
|
||||
|
||||
for (NSUInteger i = 0; i < events.count - 1; i++) {
|
||||
[self handleSSEEvent:events[i]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)handleSSEEvent:(NSString *)event {
|
||||
if (event.length == 0)
|
||||
return;
|
||||
|
||||
// 解析 SSE 事件
|
||||
// 格式: data: {...}
|
||||
NSArray *lines = [event componentsSeparatedByString:@"\n"];
|
||||
|
||||
for (NSString *line in lines) {
|
||||
if ([line hasPrefix:@"data: "]) {
|
||||
NSString *dataString = [line substringFromIndex:6];
|
||||
|
||||
// 检查是否是结束标志
|
||||
if ([dataString isEqualToString:@"[DONE]"]) {
|
||||
[self reportComplete];
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析 JSON
|
||||
NSData *jsonData = [dataString dataUsingEncoding:NSUTF8StringEncoding];
|
||||
NSError *jsonError = nil;
|
||||
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:jsonData
|
||||
options:0
|
||||
error:&jsonError];
|
||||
|
||||
if (jsonError) {
|
||||
NSLog(@"[LLMStreamClient] Failed to parse SSE data: %@", dataString);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 提取 token(根据实际 API 格式调整)
|
||||
// 常见格式: {"token": "..."} 或 {"choices": [{"delta": {"content":
|
||||
// "..."}}]}
|
||||
NSString *token = json[@"token"];
|
||||
if (!token) {
|
||||
// OpenAI 格式
|
||||
NSArray *choices = json[@"choices"];
|
||||
if (choices.count > 0) {
|
||||
NSDictionary *delta = choices[0][@"delta"];
|
||||
token = delta[@"content"];
|
||||
}
|
||||
}
|
||||
|
||||
if (token && token.length > 0) {
|
||||
[self reportToken:token];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - NSURLSessionDataDelegate
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session
|
||||
dataTask:(NSURLSessionDataTask *)dataTask
|
||||
didReceiveData:(NSData *)data {
|
||||
[self parseSSEData:data];
|
||||
}
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session
|
||||
task:(NSURLSessionTask *)task
|
||||
didCompleteWithError:(NSError *)error {
|
||||
if (error) {
|
||||
if (error.code != NSURLErrorCancelled) {
|
||||
[self reportError:error];
|
||||
}
|
||||
} else {
|
||||
// 处理缓冲区中剩余的数据
|
||||
if (self.buffer.length > 0) {
|
||||
[self handleSSEEvent:self.buffer];
|
||||
[self.buffer setString:@""];
|
||||
}
|
||||
[self reportComplete];
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,37 +0,0 @@
|
||||
//
|
||||
// Segmenter.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 句子切分器
|
||||
/// 将 LLM 输出的 token 流切分成可触发 TTS 的句子片段
|
||||
@interface Segmenter : NSObject
|
||||
|
||||
/// 累积字符数阈值(超过此值强制切分)
|
||||
/// 默认:30
|
||||
@property(nonatomic, assign) NSUInteger maxCharacterThreshold;
|
||||
|
||||
/// 追加 token
|
||||
/// @param token LLM 输出的 token
|
||||
- (void)appendToken:(NSString *)token;
|
||||
|
||||
/// 获取并移除已准备好的片段
|
||||
/// @return 可立即进行 TTS 的片段数组
|
||||
- (NSArray<NSString *> *)popReadySegments;
|
||||
|
||||
/// 获取剩余的未完成片段(用于最后 flush)
|
||||
/// @return 剩余片段,可能为空
|
||||
- (NSString *)flushRemainingSegment;
|
||||
|
||||
/// 重置状态
|
||||
- (void)reset;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -1,148 +0,0 @@
|
||||
//
|
||||
// Segmenter.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import "Segmenter.h"
|
||||
|
||||
@interface Segmenter ()
|
||||
|
||||
@property(nonatomic, strong) NSMutableString *buffer;
|
||||
@property(nonatomic, strong) NSMutableArray<NSString *> *readySegments;
|
||||
|
||||
@end
|
||||
|
||||
@implementation Segmenter
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_buffer = [[NSMutableString alloc] init];
|
||||
_readySegments = [[NSMutableArray alloc] init];
|
||||
_maxCharacterThreshold = 30;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (void)appendToken:(NSString *)token {
|
||||
if (!token || token.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
[self.buffer appendString:token];
|
||||
|
||||
// 检查是否需要切分
|
||||
[self checkAndSplit];
|
||||
}
|
||||
|
||||
- (NSArray<NSString *> *)popReadySegments {
|
||||
NSArray *segments = [self.readySegments copy];
|
||||
[self.readySegments removeAllObjects];
|
||||
return segments;
|
||||
}
|
||||
|
||||
- (NSString *)flushRemainingSegment {
|
||||
NSString *remaining = [self.buffer copy];
|
||||
[self.buffer setString:@""];
|
||||
|
||||
// 去除首尾空白
|
||||
remaining = [remaining
|
||||
stringByTrimmingCharactersInSet:[NSCharacterSet
|
||||
whitespaceAndNewlineCharacterSet]];
|
||||
|
||||
return remaining.length > 0 ? remaining : nil;
|
||||
}
|
||||
|
||||
- (void)reset {
|
||||
[self.buffer setString:@""];
|
||||
[self.readySegments removeAllObjects];
|
||||
}
|
||||
|
||||
#pragma mark - Private Methods
|
||||
|
||||
- (void)checkAndSplit {
|
||||
// 句子结束标点
|
||||
NSCharacterSet *sentenceEnders =
|
||||
[NSCharacterSet characterSetWithCharactersInString:@"。!?\n"];
|
||||
|
||||
while (YES) {
|
||||
NSString *currentBuffer = self.buffer;
|
||||
|
||||
// 查找第一个句子结束标点
|
||||
NSRange range = [currentBuffer rangeOfCharacterFromSet:sentenceEnders];
|
||||
|
||||
if (range.location != NSNotFound) {
|
||||
// 找到结束标点,切分
|
||||
NSUInteger endIndex = range.location + 1;
|
||||
NSString *segment = [currentBuffer substringToIndex:endIndex];
|
||||
segment = [segment stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
|
||||
if (segment.length > 0) {
|
||||
[self.readySegments addObject:segment];
|
||||
}
|
||||
|
||||
// 移除已切分的部分
|
||||
[self.buffer deleteCharactersInRange:NSMakeRange(0, endIndex)];
|
||||
} else if (currentBuffer.length >= self.maxCharacterThreshold) {
|
||||
// 未找到标点,但超过阈值,强制切分
|
||||
// 尝试在空格或逗号处切分
|
||||
NSRange breakRange = [self findBestBreakPoint:currentBuffer];
|
||||
|
||||
if (breakRange.location != NSNotFound) {
|
||||
NSString *segment =
|
||||
[currentBuffer substringToIndex:breakRange.location + 1];
|
||||
segment =
|
||||
[segment stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
|
||||
if (segment.length > 0) {
|
||||
[self.readySegments addObject:segment];
|
||||
}
|
||||
|
||||
[self.buffer
|
||||
deleteCharactersInRange:NSMakeRange(0, breakRange.location + 1)];
|
||||
} else {
|
||||
// 无法找到合适的断点,直接切分
|
||||
NSString *segment =
|
||||
[currentBuffer substringToIndex:self.maxCharacterThreshold];
|
||||
segment =
|
||||
[segment stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
|
||||
if (segment.length > 0) {
|
||||
[self.readySegments addObject:segment];
|
||||
}
|
||||
|
||||
[self.buffer
|
||||
deleteCharactersInRange:NSMakeRange(0, self.maxCharacterThreshold)];
|
||||
}
|
||||
} else {
|
||||
// 未达到切分条件
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (NSRange)findBestBreakPoint:(NSString *)text {
|
||||
// 优先在逗号、分号等处断开
|
||||
NSCharacterSet *breakChars =
|
||||
[NSCharacterSet characterSetWithCharactersInString:@",,、;;:: "];
|
||||
|
||||
// 从后往前查找,尽可能多包含内容
|
||||
for (NSInteger i = text.length - 1; i >= self.maxCharacterThreshold / 2;
|
||||
i--) {
|
||||
unichar c = [text characterAtIndex:i];
|
||||
if ([breakChars characterIsMember:c]) {
|
||||
return NSMakeRange(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return NSMakeRange(NSNotFound, 0);
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,36 +0,0 @@
|
||||
//
|
||||
// SubtitleSync.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 字幕同步器
|
||||
/// 根据播放进度映射文字显示,实现打字机效果
|
||||
@interface SubtitleSync : NSObject
|
||||
|
||||
/// 获取当前应显示的文本
|
||||
/// @param fullText 完整文本
|
||||
/// @param currentTime 当前播放时间(秒)
|
||||
/// @param duration 总时长(秒)
|
||||
/// @return 应显示的部分文本(打字机效果)
|
||||
- (NSString *)visibleTextForFullText:(NSString *)fullText
|
||||
currentTime:(NSTimeInterval)currentTime
|
||||
duration:(NSTimeInterval)duration;
|
||||
|
||||
/// 获取可见字符数
|
||||
/// @param fullText 完整文本
|
||||
/// @param currentTime 当前播放时间(秒)
|
||||
/// @param duration 总时长(秒)
|
||||
/// @return 应显示的字符数
|
||||
- (NSUInteger)visibleCountForFullText:(NSString *)fullText
|
||||
currentTime:(NSTimeInterval)currentTime
|
||||
duration:(NSTimeInterval)duration;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -1,66 +0,0 @@
|
||||
//
|
||||
// SubtitleSync.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import "SubtitleSync.h"
|
||||
|
||||
@implementation SubtitleSync
|
||||
|
||||
- (NSString *)visibleTextForFullText:(NSString *)fullText
|
||||
currentTime:(NSTimeInterval)currentTime
|
||||
duration:(NSTimeInterval)duration {
|
||||
|
||||
if (!fullText || fullText.length == 0) {
|
||||
return @"";
|
||||
}
|
||||
|
||||
NSUInteger visibleCount = [self visibleCountForFullText:fullText
|
||||
currentTime:currentTime
|
||||
duration:duration];
|
||||
|
||||
if (visibleCount >= fullText.length) {
|
||||
return fullText;
|
||||
}
|
||||
|
||||
return [fullText substringToIndex:visibleCount];
|
||||
}
|
||||
|
||||
- (NSUInteger)visibleCountForFullText:(NSString *)fullText
|
||||
currentTime:(NSTimeInterval)currentTime
|
||||
duration:(NSTimeInterval)duration {
|
||||
|
||||
if (!fullText || fullText.length == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 边界情况处理
|
||||
if (duration <= 0) {
|
||||
// 如果没有时长信息,直接返回全部
|
||||
return fullText.length;
|
||||
}
|
||||
|
||||
if (currentTime <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (currentTime >= duration) {
|
||||
return fullText.length;
|
||||
}
|
||||
|
||||
// 计算进度比例
|
||||
double progress = currentTime / duration;
|
||||
|
||||
// 计算可见字符数
|
||||
// 使用略微超前的策略,确保文字不会落后于语音
|
||||
double adjustedProgress = MIN(progress * 1.05, 1.0);
|
||||
|
||||
NSUInteger visibleCount =
|
||||
(NSUInteger)round(fullText.length * adjustedProgress);
|
||||
|
||||
return MIN(visibleCount, fullText.length);
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,79 +0,0 @@
|
||||
//
|
||||
// TTSPlaybackPipeline.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import "TTSServiceClient.h"
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 播放管线代理
|
||||
@protocol TTSPlaybackPipelineDelegate <NSObject>
|
||||
@optional
|
||||
/// 开始播放片段
|
||||
- (void)pipelineDidStartSegment:(NSString *)segmentId
|
||||
duration:(NSTimeInterval)duration;
|
||||
/// 播放时间更新
|
||||
- (void)pipelineDidUpdatePlaybackTime:(NSTimeInterval)time
|
||||
segmentId:(NSString *)segmentId;
|
||||
/// 片段播放完成
|
||||
- (void)pipelineDidFinishSegment:(NSString *)segmentId;
|
||||
/// 所有片段播放完成
|
||||
- (void)pipelineDidFinishAllSegments;
|
||||
/// 播放出错
|
||||
- (void)pipelineDidFail:(NSError *)error;
|
||||
@end
|
||||
|
||||
/// TTS 播放管线
|
||||
/// 根据 payloadType 路由到对应播放器
|
||||
@interface TTSPlaybackPipeline : NSObject
|
||||
|
||||
@property(nonatomic, weak) id<TTSPlaybackPipelineDelegate> delegate;
|
||||
|
||||
/// 是否正在播放
|
||||
@property(nonatomic, assign, readonly, getter=isPlaying) BOOL playing;
|
||||
|
||||
/// 当前播放的片段 ID
|
||||
@property(nonatomic, copy, readonly, nullable) NSString *currentSegmentId;
|
||||
|
||||
/// 启动管线
|
||||
/// @param error 错误信息
|
||||
/// @return 是否启动成功
|
||||
- (BOOL)start:(NSError **)error;
|
||||
|
||||
/// 停止管线(立即停止,用于打断)
|
||||
- (void)stop;
|
||||
|
||||
/// 入队 URL 播放
|
||||
/// @param url 音频 URL
|
||||
/// @param segmentId 片段 ID
|
||||
- (void)enqueueURL:(NSURL *)url segmentId:(NSString *)segmentId;
|
||||
|
||||
/// 入队音频数据块
|
||||
/// @param chunk 音频数据
|
||||
/// @param type 数据类型
|
||||
/// @param segmentId 片段 ID
|
||||
- (void)enqueueChunk:(NSData *)chunk
|
||||
payloadType:(TTSPayloadType)type
|
||||
segmentId:(NSString *)segmentId;
|
||||
|
||||
/// 标记片段数据完成(用于流式模式)
|
||||
/// @param segmentId 片段 ID
|
||||
- (void)markSegmentComplete:(NSString *)segmentId;
|
||||
|
||||
/// 获取片段的当前播放时间
|
||||
/// @param segmentId 片段 ID
|
||||
/// @return 当前时间(秒),如果未在播放则返回 0
|
||||
- (NSTimeInterval)currentTimeForSegment:(NSString *)segmentId;
|
||||
|
||||
/// 获取片段的总时长
|
||||
/// @param segmentId 片段 ID
|
||||
/// @return 总时长(秒)
|
||||
- (NSTimeInterval)durationForSegment:(NSString *)segmentId;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -1,343 +0,0 @@
|
||||
//
|
||||
// TTSPlaybackPipeline.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import "TTSPlaybackPipeline.h"
|
||||
#import "AudioStreamPlayer.h"
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
|
||||
@interface TTSPlaybackPipeline () <AudioStreamPlayerDelegate>
|
||||
|
||||
// 播放器
|
||||
@property(nonatomic, strong) AVPlayer *urlPlayer;
|
||||
@property(nonatomic, strong) AudioStreamPlayer *streamPlayer;
|
||||
|
||||
// 片段队列
|
||||
@property(nonatomic, strong) NSMutableArray<NSDictionary *> *segmentQueue;
|
||||
@property(nonatomic, strong)
|
||||
NSMutableDictionary<NSString *, NSNumber *> *segmentDurations;
|
||||
|
||||
// 状态
|
||||
@property(nonatomic, assign) BOOL playing;
|
||||
@property(nonatomic, copy) NSString *currentSegmentId;
|
||||
@property(nonatomic, strong) id playerTimeObserver;
|
||||
|
||||
// 队列
|
||||
@property(nonatomic, strong) dispatch_queue_t playbackQueue;
|
||||
|
||||
@end
|
||||
|
||||
@implementation TTSPlaybackPipeline
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_segmentQueue = [[NSMutableArray alloc] init];
|
||||
_segmentDurations = [[NSMutableDictionary alloc] init];
|
||||
_playbackQueue = dispatch_queue_create("com.keyboard.aitalk.playback",
|
||||
DISPATCH_QUEUE_SERIAL);
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self stop];
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (BOOL)start:(NSError **)error {
|
||||
// 初始化 stream player
|
||||
if (!self.streamPlayer) {
|
||||
self.streamPlayer = [[AudioStreamPlayer alloc] init];
|
||||
self.streamPlayer.delegate = self;
|
||||
}
|
||||
|
||||
return [self.streamPlayer start:error];
|
||||
}
|
||||
|
||||
- (void)stop {
|
||||
dispatch_async(self.playbackQueue, ^{
|
||||
// 停止 URL 播放
|
||||
if (self.urlPlayer) {
|
||||
[self.urlPlayer pause];
|
||||
if (self.playerTimeObserver) {
|
||||
[self.urlPlayer removeTimeObserver:self.playerTimeObserver];
|
||||
self.playerTimeObserver = nil;
|
||||
}
|
||||
self.urlPlayer = nil;
|
||||
}
|
||||
|
||||
// 停止流式播放
|
||||
[self.streamPlayer stop];
|
||||
|
||||
// 清空队列
|
||||
[self.segmentQueue removeAllObjects];
|
||||
[self.segmentDurations removeAllObjects];
|
||||
|
||||
self.playing = NO;
|
||||
self.currentSegmentId = nil;
|
||||
});
|
||||
}
|
||||
|
||||
- (void)enqueueURL:(NSURL *)url segmentId:(NSString *)segmentId {
|
||||
if (!url || !segmentId)
|
||||
return;
|
||||
|
||||
dispatch_async(self.playbackQueue, ^{
|
||||
NSDictionary *segment = @{
|
||||
@"type" : @(TTSPayloadTypeURL),
|
||||
@"url" : url,
|
||||
@"segmentId" : segmentId
|
||||
};
|
||||
[self.segmentQueue addObject:segment];
|
||||
|
||||
// 如果当前没有在播放,开始播放
|
||||
if (!self.playing) {
|
||||
[self playNextSegment];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)enqueueChunk:(NSData *)chunk
|
||||
payloadType:(TTSPayloadType)type
|
||||
segmentId:(NSString *)segmentId {
|
||||
if (!chunk || !segmentId)
|
||||
return;
|
||||
|
||||
dispatch_async(self.playbackQueue, ^{
|
||||
switch (type) {
|
||||
case TTSPayloadTypePCMChunk:
|
||||
// 直接喂给 stream player
|
||||
[self.streamPlayer enqueuePCMChunk:chunk
|
||||
sampleRate:16000
|
||||
channels:1
|
||||
segmentId:segmentId];
|
||||
|
||||
if (!self.playing) {
|
||||
self.playing = YES;
|
||||
self.currentSegmentId = segmentId;
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(pipelineDidStartSegment:duration:)]) {
|
||||
[self.delegate pipelineDidStartSegment:segmentId duration:0];
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case TTSPayloadTypeAACChunk:
|
||||
// TODO: AAC 解码 -> PCM -> streamPlayer
|
||||
NSLog(@"[TTSPlaybackPipeline] AAC chunk decoding not implemented yet");
|
||||
break;
|
||||
|
||||
case TTSPayloadTypeOpusChunk:
|
||||
// TODO: Opus 解码 -> PCM -> streamPlayer
|
||||
NSLog(@"[TTSPlaybackPipeline] Opus chunk decoding not implemented yet");
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)markSegmentComplete:(NSString *)segmentId {
|
||||
// Stream player 会自动处理播放完成
|
||||
}
|
||||
|
||||
- (NSTimeInterval)currentTimeForSegment:(NSString *)segmentId {
|
||||
if (![segmentId isEqualToString:self.currentSegmentId]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (self.urlPlayer) {
|
||||
return CMTimeGetSeconds(self.urlPlayer.currentTime);
|
||||
}
|
||||
|
||||
return [self.streamPlayer playbackTimeForSegment:segmentId];
|
||||
}
|
||||
|
||||
- (NSTimeInterval)durationForSegment:(NSString *)segmentId {
|
||||
NSNumber *duration = self.segmentDurations[segmentId];
|
||||
if (duration) {
|
||||
return duration.doubleValue;
|
||||
}
|
||||
|
||||
if (self.urlPlayer && [segmentId isEqualToString:self.currentSegmentId]) {
|
||||
CMTime duration = self.urlPlayer.currentItem.duration;
|
||||
if (CMTIME_IS_VALID(duration)) {
|
||||
return CMTimeGetSeconds(duration);
|
||||
}
|
||||
}
|
||||
|
||||
return [self.streamPlayer durationForSegment:segmentId];
|
||||
}
|
||||
|
||||
#pragma mark - Private Methods
|
||||
|
||||
- (void)playNextSegment {
|
||||
if (self.segmentQueue.count == 0) {
|
||||
self.playing = NO;
|
||||
self.currentSegmentId = nil;
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate
|
||||
respondsToSelector:@selector(pipelineDidFinishAllSegments)]) {
|
||||
[self.delegate pipelineDidFinishAllSegments];
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
NSDictionary *segment = self.segmentQueue.firstObject;
|
||||
[self.segmentQueue removeObjectAtIndex:0];
|
||||
|
||||
TTSPayloadType type = [segment[@"type"] integerValue];
|
||||
NSString *segmentId = segment[@"segmentId"];
|
||||
|
||||
self.playing = YES;
|
||||
self.currentSegmentId = segmentId;
|
||||
|
||||
if (type == TTSPayloadTypeURL) {
|
||||
NSURL *url = segment[@"url"];
|
||||
[self playURL:url segmentId:segmentId];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)playURL:(NSURL *)url segmentId:(NSString *)segmentId {
|
||||
AVPlayerItem *item = [AVPlayerItem playerItemWithURL:url];
|
||||
|
||||
if (!self.urlPlayer) {
|
||||
self.urlPlayer = [AVPlayer playerWithPlayerItem:item];
|
||||
} else {
|
||||
[self.urlPlayer replaceCurrentItemWithPlayerItem:item];
|
||||
}
|
||||
|
||||
// 监听播放完成
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
addObserver:self
|
||||
selector:@selector(playerItemDidFinish:)
|
||||
name:AVPlayerItemDidPlayToEndTimeNotification
|
||||
object:item];
|
||||
|
||||
// 添加时间观察器
|
||||
__weak typeof(self) weakSelf = self;
|
||||
self.playerTimeObserver = [self.urlPlayer
|
||||
addPeriodicTimeObserverForInterval:CMTimeMake(1, 30)
|
||||
queue:dispatch_get_main_queue()
|
||||
usingBlock:^(CMTime time) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf)
|
||||
return;
|
||||
|
||||
NSTimeInterval currentTime =
|
||||
CMTimeGetSeconds(time);
|
||||
if ([strongSelf.delegate
|
||||
respondsToSelector:@selector
|
||||
(pipelineDidUpdatePlaybackTime:
|
||||
segmentId:)]) {
|
||||
[strongSelf.delegate
|
||||
pipelineDidUpdatePlaybackTime:currentTime
|
||||
segmentId:segmentId];
|
||||
}
|
||||
}];
|
||||
|
||||
// 等待资源加载后获取时长并开始播放
|
||||
[item.asset
|
||||
loadValuesAsynchronouslyForKeys:@[ @"duration" ]
|
||||
completionHandler:^{
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NSTimeInterval duration =
|
||||
CMTimeGetSeconds(item.duration);
|
||||
if (!isnan(duration)) {
|
||||
self.segmentDurations[segmentId] = @(duration);
|
||||
}
|
||||
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(pipelineDidStartSegment:
|
||||
duration:)]) {
|
||||
[self.delegate pipelineDidStartSegment:segmentId
|
||||
duration:duration];
|
||||
}
|
||||
|
||||
[self.urlPlayer play];
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)playerItemDidFinish:(NSNotification *)notification {
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
removeObserver:self
|
||||
name:AVPlayerItemDidPlayToEndTimeNotification
|
||||
object:notification.object];
|
||||
|
||||
if (self.playerTimeObserver) {
|
||||
[self.urlPlayer removeTimeObserver:self.playerTimeObserver];
|
||||
self.playerTimeObserver = nil;
|
||||
}
|
||||
|
||||
NSString *finishedSegmentId = self.currentSegmentId;
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate
|
||||
respondsToSelector:@selector(pipelineDidFinishSegment:)]) {
|
||||
[self.delegate pipelineDidFinishSegment:finishedSegmentId];
|
||||
}
|
||||
});
|
||||
|
||||
dispatch_async(self.playbackQueue, ^{
|
||||
[self playNextSegment];
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - AudioStreamPlayerDelegate
|
||||
|
||||
- (void)audioStreamPlayerDidStartSegment:(NSString *)segmentId {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate
|
||||
respondsToSelector:@selector(pipelineDidStartSegment:duration:)]) {
|
||||
[self.delegate pipelineDidStartSegment:segmentId duration:0];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)audioStreamPlayerDidUpdateTime:(NSTimeInterval)time
|
||||
segmentId:(NSString *)segmentId {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(pipelineDidUpdatePlaybackTime:segmentId:)]) {
|
||||
[self.delegate pipelineDidUpdatePlaybackTime:time segmentId:segmentId];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)audioStreamPlayerDidFinishSegment:(NSString *)segmentId {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate
|
||||
respondsToSelector:@selector(pipelineDidFinishSegment:)]) {
|
||||
[self.delegate pipelineDidFinishSegment:segmentId];
|
||||
}
|
||||
});
|
||||
|
||||
dispatch_async(self.playbackQueue, ^{
|
||||
// 检查是否还有更多片段
|
||||
if (self.segmentQueue.count == 0) {
|
||||
self.playing = NO;
|
||||
self.currentSegmentId = nil;
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate
|
||||
respondsToSelector:@selector(pipelineDidFinishAllSegments)]) {
|
||||
[self.delegate pipelineDidFinishAllSegments];
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,66 +0,0 @@
|
||||
//
|
||||
// TTSServiceClient.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// TTS 返回数据类型
|
||||
typedef NS_ENUM(NSInteger, TTSPayloadType) {
|
||||
TTSPayloadTypeURL = 0, // 模式 A:返回 m4a/MP3 URL
|
||||
TTSPayloadTypePCMChunk, // 模式 D:返回 PCM chunk
|
||||
TTSPayloadTypeAACChunk, // 模式 B:返回 AAC chunk
|
||||
TTSPayloadTypeOpusChunk // 模式 C:返回 Opus chunk
|
||||
};
|
||||
|
||||
/// TTS 服务客户端代理
|
||||
@protocol TTSServiceClientDelegate <NSObject>
|
||||
@optional
|
||||
/// 收到音频 URL(模式 A)
|
||||
- (void)ttsClientDidReceiveURL:(NSURL *)url segmentId:(NSString *)segmentId;
|
||||
/// 收到音频数据块(模式 B/C/D)
|
||||
- (void)ttsClientDidReceiveAudioChunk:(NSData *)chunk
|
||||
payloadType:(TTSPayloadType)type
|
||||
segmentId:(NSString *)segmentId;
|
||||
/// 片段完成
|
||||
- (void)ttsClientDidFinishSegment:(NSString *)segmentId;
|
||||
/// 请求失败
|
||||
- (void)ttsClientDidFail:(NSError *)error;
|
||||
@end
|
||||
|
||||
/// TTS 服务客户端
|
||||
/// 统一网络层接口,支持多种 TTS 返回形态
|
||||
@interface TTSServiceClient : NSObject
|
||||
|
||||
@property(nonatomic, weak) id<TTSServiceClientDelegate> delegate;
|
||||
|
||||
/// TTS 服务器 URL
|
||||
@property(nonatomic, copy) NSString *serverURL;
|
||||
|
||||
/// 语音 ID(ElevenLabs voice ID)
|
||||
@property(nonatomic, copy) NSString *voiceId;
|
||||
|
||||
/// 语言代码(如 "zh", "en")
|
||||
@property(nonatomic, copy) NSString *languageCode;
|
||||
|
||||
/// 当前期望的返回类型(由服务端配置决定)
|
||||
@property(nonatomic, assign) TTSPayloadType expectedPayloadType;
|
||||
|
||||
/// 是否正在请求
|
||||
@property(nonatomic, assign, readonly, getter=isRequesting) BOOL requesting;
|
||||
|
||||
/// 请求 TTS 合成
|
||||
/// @param text 要合成的文本
|
||||
/// @param segmentId 片段 ID(用于标识和排序)
|
||||
- (void)requestTTSForText:(NSString *)text segmentId:(NSString *)segmentId;
|
||||
|
||||
/// 取消所有请求
|
||||
- (void)cancel;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -1,302 +0,0 @@
|
||||
//
|
||||
// TTSServiceClient.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/15.
|
||||
//
|
||||
|
||||
#import "TTSServiceClient.h"
|
||||
|
||||
@interface TTSServiceClient () <NSURLSessionDataDelegate,
|
||||
NSURLSessionWebSocketDelegate>
|
||||
|
||||
@property(nonatomic, strong) NSURLSession *urlSession;
|
||||
@property(nonatomic, strong)
|
||||
NSMutableDictionary<NSString *, NSURLSessionTask *> *activeTasks;
|
||||
@property(nonatomic, strong) dispatch_queue_t networkQueue;
|
||||
@property(nonatomic, assign) BOOL requesting;
|
||||
|
||||
@end
|
||||
|
||||
@implementation TTSServiceClient
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_networkQueue = dispatch_queue_create("com.keyboard.aitalk.tts.network",
|
||||
DISPATCH_QUEUE_SERIAL);
|
||||
_activeTasks = [[NSMutableDictionary alloc] init];
|
||||
_expectedPayloadType = TTSPayloadTypeURL; // 默认 URL 模式
|
||||
// TODO: 替换为实际的 TTS 服务器地址
|
||||
_serverURL = @"https://your-tts-server.com/api/tts";
|
||||
|
||||
[self setupSession];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setupSession {
|
||||
NSURLSessionConfiguration *config =
|
||||
[NSURLSessionConfiguration defaultSessionConfiguration];
|
||||
config.timeoutIntervalForRequest = 30;
|
||||
config.timeoutIntervalForResource = 120;
|
||||
|
||||
self.urlSession = [NSURLSession sessionWithConfiguration:config
|
||||
delegate:self
|
||||
delegateQueue:nil];
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self cancel];
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (void)requestTTSForText:(NSString *)text segmentId:(NSString *)segmentId {
|
||||
if (!text || text.length == 0 || !segmentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch_async(self.networkQueue, ^{
|
||||
self.requesting = YES;
|
||||
|
||||
switch (self.expectedPayloadType) {
|
||||
case TTSPayloadTypeURL:
|
||||
[self requestURLMode:text segmentId:segmentId];
|
||||
break;
|
||||
case TTSPayloadTypePCMChunk:
|
||||
case TTSPayloadTypeAACChunk:
|
||||
case TTSPayloadTypeOpusChunk:
|
||||
[self requestStreamMode:text segmentId:segmentId];
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)cancel {
|
||||
dispatch_async(self.networkQueue, ^{
|
||||
for (NSURLSessionTask *task in self.activeTasks.allValues) {
|
||||
[task cancel];
|
||||
}
|
||||
[self.activeTasks removeAllObjects];
|
||||
self.requesting = NO;
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - URL Mode (Mode A)
|
||||
|
||||
- (void)requestURLMode:(NSString *)text segmentId:(NSString *)segmentId {
|
||||
NSURL *url = [NSURL URLWithString:self.serverURL];
|
||||
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
|
||||
request.HTTPMethod = @"POST";
|
||||
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
|
||||
|
||||
NSDictionary *body = @{
|
||||
@"text" : text,
|
||||
@"segmentId" : segmentId,
|
||||
@"voiceId" : self.voiceId ?: @"JBFqnCBsd6RMkjVDRZzb",
|
||||
@"languageCode" : self.languageCode ?: @"zh",
|
||||
@"format" : @"mp3" // 或 m4a
|
||||
};
|
||||
|
||||
NSError *jsonError = nil;
|
||||
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:body
|
||||
options:0
|
||||
error:&jsonError];
|
||||
if (jsonError) {
|
||||
[self reportError:jsonError];
|
||||
return;
|
||||
}
|
||||
request.HTTPBody = jsonData;
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
NSURLSessionDataTask *task = [self.urlSession
|
||||
dataTaskWithRequest:request
|
||||
completionHandler:^(NSData *_Nullable data,
|
||||
NSURLResponse *_Nullable response,
|
||||
NSError *_Nullable error) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf)
|
||||
return;
|
||||
|
||||
dispatch_async(strongSelf.networkQueue, ^{
|
||||
[strongSelf.activeTasks removeObjectForKey:segmentId];
|
||||
|
||||
if (error) {
|
||||
if (error.code != NSURLErrorCancelled) {
|
||||
[strongSelf reportError:error];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
NSError *parseError = nil;
|
||||
NSDictionary *json =
|
||||
[NSJSONSerialization JSONObjectWithData:data
|
||||
options:0
|
||||
error:&parseError];
|
||||
if (parseError) {
|
||||
[strongSelf reportError:parseError];
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *audioURLString = json[@"audioUrl"];
|
||||
if (audioURLString) {
|
||||
NSURL *audioURL = [NSURL URLWithString:audioURLString];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([strongSelf.delegate respondsToSelector:@selector
|
||||
(ttsClientDidReceiveURL:segmentId:)]) {
|
||||
[strongSelf.delegate ttsClientDidReceiveURL:audioURL
|
||||
segmentId:segmentId];
|
||||
}
|
||||
if ([strongSelf.delegate respondsToSelector:@selector
|
||||
(ttsClientDidFinishSegment:)]) {
|
||||
[strongSelf.delegate ttsClientDidFinishSegment:segmentId];
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}];
|
||||
|
||||
self.activeTasks[segmentId] = task;
|
||||
[task resume];
|
||||
|
||||
NSLog(@"[TTSServiceClient] URL mode request for segment: %@", segmentId);
|
||||
}
|
||||
|
||||
#pragma mark - Stream Mode (Mode B/C/D)
|
||||
|
||||
- (void)requestStreamMode:(NSString *)text segmentId:(NSString *)segmentId {
|
||||
// WebSocket 连接用于流式接收
|
||||
NSString *wsURL =
|
||||
[self.serverURL stringByReplacingOccurrencesOfString:@"https://"
|
||||
withString:@"wss://"];
|
||||
wsURL = [wsURL stringByReplacingOccurrencesOfString:@"http://"
|
||||
withString:@"ws://"];
|
||||
wsURL = [wsURL stringByAppendingString:@"/stream"];
|
||||
|
||||
NSURL *url = [NSURL URLWithString:wsURL];
|
||||
NSURLSessionWebSocketTask *wsTask =
|
||||
[self.urlSession webSocketTaskWithURL:url];
|
||||
|
||||
self.activeTasks[segmentId] = wsTask;
|
||||
[wsTask resume];
|
||||
|
||||
// 发送请求
|
||||
NSDictionary *requestDict = @{
|
||||
@"text" : text,
|
||||
@"segmentId" : segmentId,
|
||||
@"voiceId" : self.voiceId ?: @"JBFqnCBsd6RMkjVDRZzb",
|
||||
@"languageCode" : self.languageCode ?: @"zh",
|
||||
@"format" : [self formatStringForPayloadType:self.expectedPayloadType]
|
||||
};
|
||||
|
||||
NSError *jsonError = nil;
|
||||
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:requestDict
|
||||
options:0
|
||||
error:&jsonError];
|
||||
if (jsonError) {
|
||||
[self reportError:jsonError];
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *jsonString = [[NSString alloc] initWithData:jsonData
|
||||
encoding:NSUTF8StringEncoding];
|
||||
NSURLSessionWebSocketMessage *message =
|
||||
[[NSURLSessionWebSocketMessage alloc] initWithString:jsonString];
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[wsTask sendMessage:message
|
||||
completionHandler:^(NSError *_Nullable error) {
|
||||
if (error) {
|
||||
[weakSelf reportError:error];
|
||||
} else {
|
||||
[weakSelf receiveStreamMessage:wsTask segmentId:segmentId];
|
||||
}
|
||||
}];
|
||||
|
||||
NSLog(@"[TTSServiceClient] Stream mode request for segment: %@", segmentId);
|
||||
}
|
||||
|
||||
- (void)receiveStreamMessage:(NSURLSessionWebSocketTask *)wsTask
|
||||
segmentId:(NSString *)segmentId {
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[wsTask receiveMessageWithCompletionHandler:^(
|
||||
NSURLSessionWebSocketMessage *_Nullable message,
|
||||
NSError *_Nullable error) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf)
|
||||
return;
|
||||
|
||||
if (error) {
|
||||
if (error.code != NSURLErrorCancelled && error.code != 57) {
|
||||
[strongSelf reportError:error];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type == NSURLSessionWebSocketMessageTypeData) {
|
||||
// 音频数据块
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([strongSelf.delegate respondsToSelector:@selector
|
||||
(ttsClientDidReceiveAudioChunk:
|
||||
payloadType:segmentId:)]) {
|
||||
[strongSelf.delegate
|
||||
ttsClientDidReceiveAudioChunk:message.data
|
||||
payloadType:strongSelf.expectedPayloadType
|
||||
segmentId:segmentId];
|
||||
}
|
||||
});
|
||||
|
||||
// 继续接收
|
||||
[strongSelf receiveStreamMessage:wsTask segmentId:segmentId];
|
||||
} else if (message.type == NSURLSessionWebSocketMessageTypeString) {
|
||||
// 控制消息
|
||||
NSData *data = [message.string dataUsingEncoding:NSUTF8StringEncoding];
|
||||
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data
|
||||
options:0
|
||||
error:nil];
|
||||
|
||||
if ([json[@"type"] isEqualToString:@"done"]) {
|
||||
dispatch_async(strongSelf.networkQueue, ^{
|
||||
[strongSelf.activeTasks removeObjectForKey:segmentId];
|
||||
});
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([strongSelf.delegate
|
||||
respondsToSelector:@selector(ttsClientDidFinishSegment:)]) {
|
||||
[strongSelf.delegate ttsClientDidFinishSegment:segmentId];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 继续接收
|
||||
[strongSelf receiveStreamMessage:wsTask segmentId:segmentId];
|
||||
}
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
- (NSString *)formatStringForPayloadType:(TTSPayloadType)type {
|
||||
switch (type) {
|
||||
case TTSPayloadTypePCMChunk:
|
||||
return @"pcm";
|
||||
case TTSPayloadTypeAACChunk:
|
||||
return @"aac";
|
||||
case TTSPayloadTypeOpusChunk:
|
||||
return @"opus";
|
||||
default:
|
||||
return @"mp3";
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Error Reporting
|
||||
|
||||
- (void)reportError:(NSError *)error {
|
||||
self.requesting = NO;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector(ttsClientDidFail:)]) {
|
||||
[self.delegate ttsClientDidFail:error];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,53 +0,0 @@
|
||||
//
|
||||
// VoiceChatStreamingManager.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/21.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@protocol VoiceChatStreamingManagerDelegate <NSObject>
|
||||
@optional
|
||||
- (void)voiceChatStreamingManagerDidConnect;
|
||||
- (void)voiceChatStreamingManagerDidDisconnect:(NSError *_Nullable)error;
|
||||
- (void)voiceChatStreamingManagerDidStartSession:(NSString *)sessionId;
|
||||
- (void)voiceChatStreamingManagerDidStartTurn:(NSInteger)turnIndex;
|
||||
- (void)voiceChatStreamingManagerDidReceiveEagerEndOfTurnWithTranscript:(NSString *)text
|
||||
confidence:(double)confidence;
|
||||
- (void)voiceChatStreamingManagerDidResumeTurn;
|
||||
- (void)voiceChatStreamingManagerDidUpdateRMS:(float)rms;
|
||||
- (void)voiceChatStreamingManagerDidReceiveInterimTranscript:(NSString *)text;
|
||||
- (void)voiceChatStreamingManagerDidReceiveFinalTranscript:(NSString *)text;
|
||||
- (void)voiceChatStreamingManagerDidReceiveLLMStart;
|
||||
- (void)voiceChatStreamingManagerDidReceiveLLMToken:(NSString *)token;
|
||||
- (void)voiceChatStreamingManagerDidReceiveAudioChunk:(NSData *)audioData;
|
||||
- (void)voiceChatStreamingManagerDidCompleteWithTranscript:(NSString *)transcript
|
||||
aiResponse:(NSString *)aiResponse;
|
||||
- (void)voiceChatStreamingManagerDidFail:(NSError *)error;
|
||||
@end
|
||||
|
||||
/// Manager for realtime recording and streaming.
|
||||
@interface VoiceChatStreamingManager : NSObject
|
||||
|
||||
@property(nonatomic, weak) id<VoiceChatStreamingManagerDelegate> delegate;
|
||||
|
||||
/// Base WebSocket URL, e.g. wss://api.yourdomain.com/api/ws/chat
|
||||
@property(nonatomic, copy) NSString *serverURL;
|
||||
|
||||
@property(nonatomic, assign, readonly, getter=isStreaming) BOOL streaming;
|
||||
@property(nonatomic, copy, readonly, nullable) NSString *sessionId;
|
||||
|
||||
- (void)startWithToken:(NSString *)token
|
||||
language:(nullable NSString *)language
|
||||
voiceId:(nullable NSString *)voiceId;
|
||||
|
||||
- (void)stopAndFinalize;
|
||||
- (void)cancel;
|
||||
- (void)disconnect;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -1,380 +0,0 @@
|
||||
//
|
||||
// VoiceChatStreamingManager.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/21.
|
||||
//
|
||||
|
||||
#import "VoiceChatStreamingManager.h"
|
||||
#import "AudioCaptureManager.h"
|
||||
#import "AudioSessionManager.h"
|
||||
#import "VoiceChatWebSocketClient.h"
|
||||
|
||||
static NSString *const kVoiceChatStreamingManagerErrorDomain =
|
||||
@"VoiceChatStreamingManager";
|
||||
|
||||
@interface VoiceChatStreamingManager () <AudioSessionManagerDelegate,
|
||||
AudioCaptureManagerDelegate,
|
||||
VoiceChatWebSocketClientDelegate>
|
||||
|
||||
@property(nonatomic, strong) AudioSessionManager *audioSession;
|
||||
@property(nonatomic, strong) AudioCaptureManager *audioCapture;
|
||||
@property(nonatomic, strong) VoiceChatWebSocketClient *webSocketClient;
|
||||
@property(nonatomic, strong) dispatch_queue_t stateQueue;
|
||||
|
||||
@property(nonatomic, assign) BOOL streaming;
|
||||
@property(nonatomic, copy) NSString *sessionId;
|
||||
|
||||
@property(nonatomic, copy) NSString *pendingToken;
|
||||
@property(nonatomic, copy) NSString *pendingLanguage;
|
||||
@property(nonatomic, copy) NSString *pendingVoiceId;
|
||||
|
||||
@end
|
||||
|
||||
@implementation VoiceChatStreamingManager
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_stateQueue = dispatch_queue_create("com.keyboard.aitalk.voicechat.manager",
|
||||
DISPATCH_QUEUE_SERIAL);
|
||||
|
||||
_audioSession = [AudioSessionManager sharedManager];
|
||||
_audioSession.delegate = self;
|
||||
|
||||
_audioCapture = [[AudioCaptureManager alloc] init];
|
||||
_audioCapture.delegate = self;
|
||||
|
||||
_webSocketClient = [[VoiceChatWebSocketClient alloc] init];
|
||||
_webSocketClient.delegate = self;
|
||||
|
||||
_serverURL = @"ws://192.168.2.21:7529/api/ws/chat?token=";
|
||||
_webSocketClient.serverURL = _serverURL;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self disconnectInternal];
|
||||
}
|
||||
|
||||
- (void)setServerURL:(NSString *)serverURL {
|
||||
_serverURL = [serverURL copy];
|
||||
self.webSocketClient.serverURL = _serverURL;
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (void)startWithToken:(NSString *)token
|
||||
language:(nullable NSString *)language
|
||||
voiceId:(nullable NSString *)voiceId {
|
||||
dispatch_async(self.stateQueue, ^{
|
||||
self.pendingToken = token ?: @"";
|
||||
self.pendingLanguage = language ?: @"";
|
||||
self.pendingVoiceId = voiceId ?: @"";
|
||||
[self.webSocketClient disableAudioSending];
|
||||
[self startInternal];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)stopAndFinalize {
|
||||
dispatch_async(self.stateQueue, ^{
|
||||
if (self.streaming) {
|
||||
[self.audioCapture stopCapture];
|
||||
self.streaming = NO;
|
||||
}
|
||||
[self.webSocketClient disableAudioSending];
|
||||
[self.webSocketClient endAudio];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)cancel {
|
||||
dispatch_async(self.stateQueue, ^{
|
||||
if (self.streaming) {
|
||||
[self.audioCapture stopCapture];
|
||||
self.streaming = NO;
|
||||
}
|
||||
[self.webSocketClient disableAudioSending];
|
||||
[self.webSocketClient cancel];
|
||||
self.sessionId = nil;
|
||||
});
|
||||
}
|
||||
|
||||
- (void)disconnect {
|
||||
dispatch_async(self.stateQueue, ^{
|
||||
[self disconnectInternal];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)disconnectInternal {
|
||||
if (self.streaming) {
|
||||
[self.audioCapture stopCapture];
|
||||
self.streaming = NO;
|
||||
}
|
||||
[self.webSocketClient disableAudioSending];
|
||||
[self.webSocketClient disconnect];
|
||||
[self.audioSession deactivateSession];
|
||||
self.sessionId = nil;
|
||||
}
|
||||
|
||||
#pragma mark - Private Methods
|
||||
|
||||
- (void)startInternal {
|
||||
if (self.pendingToken.length == 0) {
|
||||
NSLog(@"[VoiceChatStreamingManager] Start failed: token is empty");
|
||||
[self reportErrorWithMessage:@"Token is required"];
|
||||
return;
|
||||
}
|
||||
|
||||
if (![self.audioSession hasMicrophonePermission]) {
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[self.audioSession requestMicrophonePermission:^(BOOL granted) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf) {
|
||||
return;
|
||||
}
|
||||
if (!granted) {
|
||||
[strongSelf reportErrorWithMessage:@"Microphone permission denied"];
|
||||
return;
|
||||
}
|
||||
dispatch_async(strongSelf.stateQueue, ^{
|
||||
[strongSelf startInternal];
|
||||
});
|
||||
}];
|
||||
return;
|
||||
}
|
||||
|
||||
NSError *error = nil;
|
||||
if (![self.audioSession configureForConversation:&error]) {
|
||||
[self reportError:error];
|
||||
return;
|
||||
}
|
||||
|
||||
if (![self.audioSession activateSession:&error]) {
|
||||
[self reportError:error];
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.serverURL.length == 0) {
|
||||
NSLog(@"[VoiceChatStreamingManager] Start failed: server URL is empty");
|
||||
[self reportErrorWithMessage:@"Server URL is required"];
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"[VoiceChatStreamingManager] Start streaming, server: %@",
|
||||
self.serverURL);
|
||||
self.webSocketClient.serverURL = self.serverURL;
|
||||
[self.webSocketClient connectWithToken:self.pendingToken];
|
||||
}
|
||||
|
||||
- (void)reportError:(NSError *)error {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatStreamingManagerDidFail:)]) {
|
||||
[self.delegate voiceChatStreamingManagerDidFail:error];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)reportErrorWithMessage:(NSString *)message {
|
||||
NSError *error = [NSError errorWithDomain:kVoiceChatStreamingManagerErrorDomain
|
||||
code:-1
|
||||
userInfo:@{
|
||||
NSLocalizedDescriptionKey : message ?: @""
|
||||
}];
|
||||
[self reportError:error];
|
||||
}
|
||||
|
||||
#pragma mark - AudioCaptureManagerDelegate
|
||||
|
||||
- (void)audioCaptureManagerDidOutputPCMFrame:(NSData *)pcmFrame {
|
||||
if (!self.streaming) {
|
||||
return;
|
||||
}
|
||||
[self.webSocketClient sendAudioPCMFrame:pcmFrame];
|
||||
}
|
||||
|
||||
- (void)audioCaptureManagerDidUpdateRMS:(float)rms {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatStreamingManagerDidUpdateRMS:)]) {
|
||||
[self.delegate voiceChatStreamingManagerDidUpdateRMS:rms];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - AudioSessionManagerDelegate
|
||||
|
||||
- (void)audioSessionManagerDidInterrupt:(KBAudioSessionInterruptionType)type {
|
||||
if (type == KBAudioSessionInterruptionTypeBegan) {
|
||||
[self cancel];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)audioSessionManagerMicrophonePermissionDenied {
|
||||
[self reportErrorWithMessage:@"Microphone permission denied"];
|
||||
}
|
||||
|
||||
#pragma mark - VoiceChatWebSocketClientDelegate
|
||||
|
||||
- (void)voiceChatClientDidConnect {
|
||||
dispatch_async(self.stateQueue, ^{
|
||||
[self.webSocketClient startSessionWithLanguage:self.pendingLanguage
|
||||
voiceId:self.pendingVoiceId];
|
||||
});
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatStreamingManagerDidConnect)]) {
|
||||
[self.delegate voiceChatStreamingManagerDidConnect];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)voiceChatClientDidDisconnect:(NSError *_Nullable)error {
|
||||
dispatch_async(self.stateQueue, ^{
|
||||
if (self.streaming) {
|
||||
[self.audioCapture stopCapture];
|
||||
self.streaming = NO;
|
||||
}
|
||||
[self.audioSession deactivateSession];
|
||||
self.sessionId = nil;
|
||||
});
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatStreamingManagerDidDisconnect:)]) {
|
||||
[self.delegate voiceChatStreamingManagerDidDisconnect:error];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)voiceChatClientDidStartSession:(NSString *)sessionId {
|
||||
dispatch_async(self.stateQueue, ^{
|
||||
self.sessionId = sessionId;
|
||||
|
||||
NSError *error = nil;
|
||||
if (![self.audioCapture startCapture:&error]) {
|
||||
[self reportError:error];
|
||||
[self.webSocketClient cancel];
|
||||
return;
|
||||
}
|
||||
|
||||
self.streaming = YES;
|
||||
[self.webSocketClient enableAudioSending];
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatStreamingManagerDidStartSession:)]) {
|
||||
[self.delegate voiceChatStreamingManagerDidStartSession:sessionId];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
- (void)voiceChatClientDidStartTurn:(NSInteger)turnIndex {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatStreamingManagerDidStartTurn:)]) {
|
||||
[self.delegate voiceChatStreamingManagerDidStartTurn:turnIndex];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)voiceChatClientDidReceiveEagerEndOfTurnWithTranscript:(NSString *)text
|
||||
confidence:(double)confidence {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate
|
||||
respondsToSelector:@selector
|
||||
(voiceChatStreamingManagerDidReceiveEagerEndOfTurnWithTranscript:
|
||||
confidence:)]) {
|
||||
[self.delegate
|
||||
voiceChatStreamingManagerDidReceiveEagerEndOfTurnWithTranscript:text
|
||||
confidence:confidence];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)voiceChatClientDidResumeTurn {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatStreamingManagerDidResumeTurn)]) {
|
||||
[self.delegate voiceChatStreamingManagerDidResumeTurn];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)voiceChatClientDidReceiveInterimTranscript:(NSString *)text {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatStreamingManagerDidReceiveInterimTranscript:)]) {
|
||||
[self.delegate voiceChatStreamingManagerDidReceiveInterimTranscript:text];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)voiceChatClientDidReceiveFinalTranscript:(NSString *)text {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatStreamingManagerDidReceiveFinalTranscript:)]) {
|
||||
[self.delegate voiceChatStreamingManagerDidReceiveFinalTranscript:text];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)voiceChatClientDidReceiveLLMStart {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatStreamingManagerDidReceiveLLMStart)]) {
|
||||
[self.delegate voiceChatStreamingManagerDidReceiveLLMStart];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)voiceChatClientDidReceiveLLMToken:(NSString *)token {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatStreamingManagerDidReceiveLLMToken:)]) {
|
||||
[self.delegate voiceChatStreamingManagerDidReceiveLLMToken:token];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)voiceChatClientDidReceiveAudioChunk:(NSData *)audioData {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatStreamingManagerDidReceiveAudioChunk:)]) {
|
||||
[self.delegate voiceChatStreamingManagerDidReceiveAudioChunk:audioData];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)voiceChatClientDidCompleteWithTranscript:(NSString *)transcript
|
||||
aiResponse:(NSString *)aiResponse {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatStreamingManagerDidCompleteWithTranscript:
|
||||
aiResponse:)]) {
|
||||
[self.delegate voiceChatStreamingManagerDidCompleteWithTranscript:transcript
|
||||
aiResponse:aiResponse];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)voiceChatClientDidReceiveErrorCode:(NSString *)code
|
||||
message:(NSString *)message {
|
||||
NSString *desc = message.length > 0 ? message : @"Server error";
|
||||
NSError *error = [NSError errorWithDomain:kVoiceChatStreamingManagerErrorDomain
|
||||
code:-2
|
||||
userInfo:@{
|
||||
NSLocalizedDescriptionKey : desc,
|
||||
@"code" : code ?: @""
|
||||
}];
|
||||
[self reportError:error];
|
||||
}
|
||||
|
||||
- (void)voiceChatClientDidFail:(NSError *)error {
|
||||
[self reportError:error];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,57 +0,0 @@
|
||||
//
|
||||
// VoiceChatWebSocketClient.h
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/21.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@protocol VoiceChatWebSocketClientDelegate <NSObject>
|
||||
@optional
|
||||
- (void)voiceChatClientDidConnect;
|
||||
- (void)voiceChatClientDidDisconnect:(NSError *_Nullable)error;
|
||||
- (void)voiceChatClientDidStartSession:(NSString *)sessionId;
|
||||
- (void)voiceChatClientDidStartTurn:(NSInteger)turnIndex;
|
||||
- (void)voiceChatClientDidReceiveEagerEndOfTurnWithTranscript:(NSString *)text
|
||||
confidence:(double)confidence;
|
||||
- (void)voiceChatClientDidResumeTurn;
|
||||
- (void)voiceChatClientDidReceiveInterimTranscript:(NSString *)text;
|
||||
- (void)voiceChatClientDidReceiveFinalTranscript:(NSString *)text;
|
||||
- (void)voiceChatClientDidReceiveLLMStart;
|
||||
- (void)voiceChatClientDidReceiveLLMToken:(NSString *)token;
|
||||
- (void)voiceChatClientDidReceiveAudioChunk:(NSData *)audioData;
|
||||
- (void)voiceChatClientDidCompleteWithTranscript:(NSString *)transcript
|
||||
aiResponse:(NSString *)aiResponse;
|
||||
- (void)voiceChatClientDidReceiveErrorCode:(NSString *)code
|
||||
message:(NSString *)message;
|
||||
- (void)voiceChatClientDidFail:(NSError *)error;
|
||||
@end
|
||||
|
||||
/// WebSocket client for realtime voice chat.
|
||||
@interface VoiceChatWebSocketClient : NSObject
|
||||
|
||||
@property(nonatomic, weak) id<VoiceChatWebSocketClientDelegate> delegate;
|
||||
|
||||
/// Base WebSocket URL, e.g. wss://api.yourdomain.com/api/ws/chat
|
||||
@property(nonatomic, copy) NSString *serverURL;
|
||||
|
||||
@property(nonatomic, assign, readonly, getter=isConnected) BOOL connected;
|
||||
@property(nonatomic, copy, readonly, nullable) NSString *sessionId;
|
||||
|
||||
- (void)connectWithToken:(NSString *)token;
|
||||
- (void)disconnect;
|
||||
|
||||
- (void)startSessionWithLanguage:(nullable NSString *)language
|
||||
voiceId:(nullable NSString *)voiceId;
|
||||
- (void)enableAudioSending;
|
||||
- (void)disableAudioSending;
|
||||
- (void)sendAudioPCMFrame:(NSData *)pcmFrame;
|
||||
- (void)endAudio;
|
||||
- (void)cancel;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -1,459 +0,0 @@
|
||||
//
|
||||
// VoiceChatWebSocketClient.m
|
||||
// keyBoard
|
||||
//
|
||||
// Created by Mac on 2026/1/21.
|
||||
//
|
||||
|
||||
#import "VoiceChatWebSocketClient.h"
|
||||
|
||||
static NSString *const kVoiceChatWebSocketClientErrorDomain =
|
||||
@"VoiceChatWebSocketClient";
|
||||
|
||||
@interface VoiceChatWebSocketClient () <NSURLSessionWebSocketDelegate>
|
||||
|
||||
@property(nonatomic, strong) NSURLSession *urlSession;
|
||||
@property(nonatomic, strong) NSURLSessionWebSocketTask *webSocketTask;
|
||||
@property(nonatomic, strong) dispatch_queue_t networkQueue;
|
||||
@property(nonatomic, assign) BOOL connected;
|
||||
@property(nonatomic, copy) NSString *sessionId;
|
||||
@property(nonatomic, assign) BOOL audioSendingEnabled;
|
||||
|
||||
@end
|
||||
|
||||
@implementation VoiceChatWebSocketClient
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_networkQueue = dispatch_queue_create("com.keyboard.aitalk.voicechat.ws",
|
||||
DISPATCH_QUEUE_SERIAL);
|
||||
_serverURL = @"wss://api.yourdomain.com/api/ws/chat";
|
||||
_audioSendingEnabled = NO;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self disconnectInternal];
|
||||
}
|
||||
|
||||
#pragma mark - Public Methods
|
||||
|
||||
- (void)connectWithToken:(NSString *)token {
|
||||
dispatch_async(self.networkQueue, ^{
|
||||
[self disconnectInternal];
|
||||
|
||||
NSURL *url = [self buildURLWithToken:token];
|
||||
if (!url) {
|
||||
[self reportErrorWithMessage:@"Invalid server URL"];
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"[VoiceChatWebSocketClient] Connecting: %@", url.absoluteString);
|
||||
|
||||
NSURLSessionConfiguration *config =
|
||||
[NSURLSessionConfiguration defaultSessionConfiguration];
|
||||
config.timeoutIntervalForRequest = 30;
|
||||
config.timeoutIntervalForResource = 300;
|
||||
|
||||
self.urlSession = [NSURLSession sessionWithConfiguration:config
|
||||
delegate:self
|
||||
delegateQueue:nil];
|
||||
|
||||
self.webSocketTask = [self.urlSession webSocketTaskWithURL:url];
|
||||
[self.webSocketTask resume];
|
||||
[self receiveMessage];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)disconnect {
|
||||
dispatch_async(self.networkQueue, ^{
|
||||
BOOL shouldNotify = self.webSocketTask != nil;
|
||||
if (shouldNotify) {
|
||||
NSLog(@"[VoiceChatWebSocketClient] Disconnect requested");
|
||||
}
|
||||
[self disconnectInternal];
|
||||
if (shouldNotify) {
|
||||
[self notifyDisconnect:nil];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)startSessionWithLanguage:(nullable NSString *)language
|
||||
voiceId:(nullable NSString *)voiceId {
|
||||
NSMutableDictionary *message = [NSMutableDictionary dictionary];
|
||||
message[@"type"] = @"session_start";
|
||||
|
||||
NSMutableDictionary *config = [NSMutableDictionary dictionary];
|
||||
if (language.length > 0) {
|
||||
config[@"language"] = language;
|
||||
}
|
||||
if (voiceId.length > 0) {
|
||||
config[@"voice_id"] = voiceId;
|
||||
}
|
||||
if (config.count > 0) {
|
||||
message[@"config"] = config;
|
||||
}
|
||||
|
||||
NSLog(@"[VoiceChatWebSocketClient] Sending session_start: %@",
|
||||
message);
|
||||
[self sendJSON:message];
|
||||
}
|
||||
|
||||
- (void)enableAudioSending {
|
||||
dispatch_async(self.networkQueue, ^{
|
||||
self.audioSendingEnabled = YES;
|
||||
});
|
||||
}
|
||||
|
||||
- (void)disableAudioSending {
|
||||
dispatch_async(self.networkQueue, ^{
|
||||
self.audioSendingEnabled = NO;
|
||||
});
|
||||
}
|
||||
|
||||
- (void)sendAudioPCMFrame:(NSData *)pcmFrame {
|
||||
if (!self.connected || !self.webSocketTask || pcmFrame.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch_async(self.networkQueue, ^{
|
||||
if (!self.audioSendingEnabled) {
|
||||
return;
|
||||
}
|
||||
if (!self.connected || !self.webSocketTask) {
|
||||
return;
|
||||
}
|
||||
NSURLSessionWebSocketMessage *message =
|
||||
[[NSURLSessionWebSocketMessage alloc] initWithData:pcmFrame];
|
||||
[self.webSocketTask
|
||||
sendMessage:message
|
||||
completionHandler:^(NSError *_Nullable error) {
|
||||
if (error) {
|
||||
[self reportError:error];
|
||||
} else {
|
||||
NSLog(@"[VoiceChatWebSocketClient] Sent audio frame: %lu bytes",
|
||||
(unsigned long)pcmFrame.length);
|
||||
}
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)endAudio {
|
||||
NSLog(@"[VoiceChatWebSocketClient] Sending audio_end");
|
||||
[self sendJSON:@{ @"type" : @"audio_end" }];
|
||||
}
|
||||
|
||||
- (void)cancel {
|
||||
NSLog(@"[VoiceChatWebSocketClient] Sending cancel");
|
||||
[self sendJSON:@{ @"type" : @"cancel" }];
|
||||
}
|
||||
|
||||
#pragma mark - Private Methods
|
||||
|
||||
- (NSURL *)buildURLWithToken:(NSString *)token {
|
||||
if (self.serverURL.length == 0) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSURLComponents *components =
|
||||
[NSURLComponents componentsWithString:self.serverURL];
|
||||
if (!components) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (token.length > 0) {
|
||||
NSMutableArray<NSURLQueryItem *> *items =
|
||||
components.queryItems.mutableCopy ?: [NSMutableArray array];
|
||||
BOOL didReplace = NO;
|
||||
for (NSUInteger i = 0; i < items.count; i++) {
|
||||
NSURLQueryItem *item = items[i];
|
||||
if ([item.name isEqualToString:@"token"]) {
|
||||
items[i] = [NSURLQueryItem queryItemWithName:@"token" value:token];
|
||||
didReplace = YES;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!didReplace) {
|
||||
[items addObject:[NSURLQueryItem queryItemWithName:@"token"
|
||||
value:token]];
|
||||
}
|
||||
components.queryItems = items;
|
||||
}
|
||||
|
||||
return components.URL;
|
||||
}
|
||||
|
||||
- (void)sendJSON:(NSDictionary *)dict {
|
||||
if (!self.webSocketTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSError *jsonError = nil;
|
||||
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:dict
|
||||
options:0
|
||||
error:&jsonError];
|
||||
if (jsonError) {
|
||||
[self reportError:jsonError];
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *jsonString =
|
||||
[[NSString alloc] initWithData:jsonData
|
||||
encoding:NSUTF8StringEncoding];
|
||||
if (!jsonString) {
|
||||
[self reportErrorWithMessage:@"Failed to encode JSON message"];
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch_async(self.networkQueue, ^{
|
||||
NSURLSessionWebSocketMessage *message =
|
||||
[[NSURLSessionWebSocketMessage alloc] initWithString:jsonString];
|
||||
[self.webSocketTask
|
||||
sendMessage:message
|
||||
completionHandler:^(NSError *_Nullable error) {
|
||||
if (error) {
|
||||
[self reportError:error];
|
||||
}
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)receiveMessage {
|
||||
if (!self.webSocketTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[self.webSocketTask receiveMessageWithCompletionHandler:^(
|
||||
NSURLSessionWebSocketMessage *_Nullable message,
|
||||
NSError *_Nullable error) {
|
||||
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error.code != NSURLErrorCancelled && error.code != 57) {
|
||||
[strongSelf notifyDisconnect:error];
|
||||
[strongSelf disconnectInternal];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type == NSURLSessionWebSocketMessageTypeString) {
|
||||
NSLog(@"[VoiceChatWebSocketClient] Received text: %@", message.string);
|
||||
[strongSelf handleTextMessage:message.string];
|
||||
} else if (message.type == NSURLSessionWebSocketMessageTypeData) {
|
||||
NSLog(@"[VoiceChatWebSocketClient] Received binary: %lu bytes",
|
||||
(unsigned long)message.data.length);
|
||||
[strongSelf handleBinaryMessage:message.data];
|
||||
}
|
||||
|
||||
[strongSelf receiveMessage];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)handleTextMessage:(NSString *)text {
|
||||
if (text.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSData *data = [text dataUsingEncoding:NSUTF8StringEncoding];
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSError *jsonError = nil;
|
||||
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data
|
||||
options:0
|
||||
error:&jsonError];
|
||||
if (jsonError) {
|
||||
[self reportError:jsonError];
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *type = json[@"type"];
|
||||
if (type.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ([type isEqualToString:@"session_started"]) {
|
||||
NSString *sessionId = json[@"session_id"] ?: @"";
|
||||
self.sessionId = sessionId;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatClientDidStartSession:)]) {
|
||||
[self.delegate voiceChatClientDidStartSession:sessionId];
|
||||
}
|
||||
});
|
||||
} else if ([type isEqualToString:@"transcript_interim"]) {
|
||||
NSString *transcript = json[@"text"] ?: @"";
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatClientDidReceiveInterimTranscript:)]) {
|
||||
[self.delegate voiceChatClientDidReceiveInterimTranscript:transcript];
|
||||
}
|
||||
});
|
||||
} else if ([type isEqualToString:@"transcript_final"]) {
|
||||
NSString *transcript = json[@"text"] ?: @"";
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatClientDidReceiveFinalTranscript:)]) {
|
||||
[self.delegate voiceChatClientDidReceiveFinalTranscript:transcript];
|
||||
}
|
||||
});
|
||||
} else if ([type isEqualToString:@"turn_start"]) {
|
||||
NSInteger turnIndex = [json[@"turn_index"] integerValue];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatClientDidStartTurn:)]) {
|
||||
[self.delegate voiceChatClientDidStartTurn:turnIndex];
|
||||
}
|
||||
});
|
||||
} else if ([type isEqualToString:@"eager_eot"]) {
|
||||
NSString *transcript = json[@"transcript"] ?: @"";
|
||||
double confidence = [json[@"confidence"] doubleValue];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatClientDidReceiveEagerEndOfTurnWithTranscript:
|
||||
confidence:)]) {
|
||||
[self.delegate
|
||||
voiceChatClientDidReceiveEagerEndOfTurnWithTranscript:transcript
|
||||
confidence:confidence];
|
||||
}
|
||||
});
|
||||
} else if ([type isEqualToString:@"turn_resumed"]) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatClientDidResumeTurn)]) {
|
||||
[self.delegate voiceChatClientDidResumeTurn];
|
||||
}
|
||||
});
|
||||
} else if ([type isEqualToString:@"llm_start"]) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate
|
||||
respondsToSelector:@selector(voiceChatClientDidReceiveLLMStart)]) {
|
||||
[self.delegate voiceChatClientDidReceiveLLMStart];
|
||||
}
|
||||
});
|
||||
} else if ([type isEqualToString:@"llm_token"]) {
|
||||
NSString *token = json[@"token"] ?: @"";
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate
|
||||
respondsToSelector:@selector(voiceChatClientDidReceiveLLMToken:)]) {
|
||||
[self.delegate voiceChatClientDidReceiveLLMToken:token];
|
||||
}
|
||||
});
|
||||
} else if ([type isEqualToString:@"complete"]) {
|
||||
NSString *transcript = json[@"transcript"] ?: @"";
|
||||
NSString *aiResponse = json[@"ai_response"] ?: @"";
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatClientDidCompleteWithTranscript:
|
||||
aiResponse:)]) {
|
||||
[self.delegate voiceChatClientDidCompleteWithTranscript:transcript
|
||||
aiResponse:aiResponse];
|
||||
}
|
||||
});
|
||||
} else if ([type isEqualToString:@"error"]) {
|
||||
NSString *code = json[@"code"] ?: @"";
|
||||
NSString *message = json[@"message"] ?: @"";
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatClientDidReceiveErrorCode:message:)]) {
|
||||
[self.delegate voiceChatClientDidReceiveErrorCode:code
|
||||
message:message];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
- (void)handleBinaryMessage:(NSData *)data {
|
||||
if (data.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate
|
||||
respondsToSelector:@selector(voiceChatClientDidReceiveAudioChunk:)]) {
|
||||
[self.delegate voiceChatClientDidReceiveAudioChunk:data];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)disconnectInternal {
|
||||
self.connected = NO;
|
||||
self.sessionId = nil;
|
||||
self.audioSendingEnabled = NO;
|
||||
|
||||
if (self.webSocketTask) {
|
||||
[self.webSocketTask
|
||||
cancelWithCloseCode:NSURLSessionWebSocketCloseCodeNormalClosure
|
||||
reason:nil];
|
||||
self.webSocketTask = nil;
|
||||
}
|
||||
|
||||
if (self.urlSession) {
|
||||
[self.urlSession invalidateAndCancel];
|
||||
self.urlSession = nil;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)reportError:(NSError *)error {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector(voiceChatClientDidFail:)]) {
|
||||
[self.delegate voiceChatClientDidFail:error];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)reportErrorWithMessage:(NSString *)message {
|
||||
NSError *error = [NSError errorWithDomain:kVoiceChatWebSocketClientErrorDomain
|
||||
code:-1
|
||||
userInfo:@{
|
||||
NSLocalizedDescriptionKey : message ?: @""
|
||||
}];
|
||||
[self reportError:error];
|
||||
}
|
||||
|
||||
- (void)notifyDisconnect:(NSError *_Nullable)error {
|
||||
self.connected = NO;
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector
|
||||
(voiceChatClientDidDisconnect:)]) {
|
||||
[self.delegate voiceChatClientDidDisconnect:error];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - NSURLSessionWebSocketDelegate
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session
|
||||
webSocketTask:(NSURLSessionWebSocketTask *)webSocketTask
|
||||
didOpenWithProtocol:(NSString *)protocol {
|
||||
self.connected = YES;
|
||||
NSLog(@"[VoiceChatWebSocketClient] Connected");
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([self.delegate respondsToSelector:@selector(voiceChatClientDidConnect)]) {
|
||||
[self.delegate voiceChatClientDidConnect];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session
|
||||
webSocketTask:(NSURLSessionWebSocketTask *)webSocketTask
|
||||
didCloseWithCode:(NSURLSessionWebSocketCloseCode)closeCode
|
||||
reason:(NSData *)reason {
|
||||
if (!self.webSocketTask) {
|
||||
return;
|
||||
}
|
||||
NSLog(@"[VoiceChatWebSocketClient] Closed with code: %ld",
|
||||
(long)closeCode);
|
||||
[self notifyDisconnect:nil];
|
||||
[self disconnectInternal];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -99,9 +99,8 @@
|
||||
- (void)buttonTapped:(UIButton *)button {
|
||||
NSInteger index = button.tag;
|
||||
if (index != self.selectedIndex) {
|
||||
self.selectedIndex = index;
|
||||
[self updateButtonStates];
|
||||
|
||||
// 先通知 delegate,由 delegate 决定是否允许切换(如登录拦截)
|
||||
// delegate 允许时会主动调用 setSelectedIndex: 来更新状态
|
||||
if ([self.delegate respondsToSelector:@selector(customTabBar:
|
||||
didSelectItemAtIndex:)]) {
|
||||
[self.delegate customTabBar:self didSelectItemAtIndex:index];
|
||||
@@ -124,6 +123,13 @@
|
||||
for (NSInteger i = 0; i < self.buttons.count; i++) {
|
||||
UIButton *button = self.buttons[i];
|
||||
button.selected = (i == self.selectedIndex);
|
||||
|
||||
// 特殊处理:社区按钮(索引2)选中时文字颜色为白色
|
||||
if (i == 2 && button.selected) {
|
||||
[button setTitleColor:[UIColor whiteColor] forState:UIControlStateSelected];
|
||||
} else if (i == 2) {
|
||||
[button setTitleColor:[UIColor grayColor] forState:UIControlStateNormal];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,12 +154,15 @@
|
||||
|
||||
- (void)customTabBar:(KBCustomTabBar *)tabBar
|
||||
didSelectItemAtIndex:(NSInteger)index {
|
||||
// 登录检查
|
||||
if ((index == 1 || index == 2) && ![KBUserSessionManager shared].isLoggedIn) {
|
||||
// 登录检查:未登录时跳登录页,不切换 Tab,不更新选中状态
|
||||
if ((index == 1 || index == 2 || index == 3) && ![KBUserSessionManager shared].isLoggedIn) {
|
||||
[[KBUserSessionManager shared] goLoginVC];
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新自定义 TabBar 的选中状态
|
||||
[self.customTabBar setSelectedIndex:index];
|
||||
|
||||
// 切换 VC
|
||||
self.selectedIndex = index;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ static __weak MBProgressHUD *sHUD = nil;
|
||||
static KBHUDMaskType sMaskType = KBHUDMaskTypeClear; // 全局默认遮罩
|
||||
static BOOL sDefaultTapToDismiss = NO; // 全局默认:不允许点击关闭
|
||||
static NSTimeInterval sAutoDismiss = 1.2;
|
||||
static NSTimeInterval sLoadingMaxDuration = 20.0; // loading 最长展示时长,避免异常分支一直转圈
|
||||
static __weak UIView *sContainerView = nil; // 缺省承载视图(扩展里必须设置)
|
||||
|
||||
#pragma mark - Private Helpers
|
||||
@@ -129,8 +130,27 @@ static __weak UIView *sContainerView = nil; // 缺省承载视图(
|
||||
[self dismiss];
|
||||
}
|
||||
|
||||
+ (void)_kb_loadingTimeoutDismiss {
|
||||
[self dismiss];
|
||||
}
|
||||
|
||||
+ (void)cancelLoadingAutoDismiss {
|
||||
[NSObject cancelPreviousPerformRequestsWithTarget:self
|
||||
selector:@selector(_kb_loadingTimeoutDismiss)
|
||||
object:nil];
|
||||
}
|
||||
|
||||
+ (void)scheduleLoadingAutoDismiss {
|
||||
[self cancelLoadingAutoDismiss];
|
||||
if (sLoadingMaxDuration <= 0) { return; }
|
||||
[self performSelector:@selector(_kb_loadingTimeoutDismiss)
|
||||
withObject:nil
|
||||
afterDelay:sLoadingMaxDuration];
|
||||
}
|
||||
|
||||
+ (void)_showText:(NSString *)text icon:(nullable UIImage *)icon {
|
||||
[self onMain:^{
|
||||
[self cancelLoadingAutoDismiss];
|
||||
MBProgressHUD *loadingHUD = sHUD;
|
||||
if (loadingHUD) {
|
||||
[loadingHUD hideAnimated:YES];
|
||||
@@ -177,6 +197,7 @@ static __weak UIView *sContainerView = nil; // 缺省承载视图(
|
||||
if (!hud) { return; }
|
||||
hud.mode = MBProgressHUDModeIndeterminate;
|
||||
hud.label.text = status ?: @"";
|
||||
[self scheduleLoadingAutoDismiss];
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -191,6 +212,7 @@ static __weak UIView *sContainerView = nil; // 缺省承载视图(
|
||||
hud.mode = MBProgressHUDModeDeterminate;
|
||||
hud.progress = progress;
|
||||
hud.label.text = status ?: @"";
|
||||
[self scheduleLoadingAutoDismiss];
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -205,9 +227,9 @@ static __weak UIView *sContainerView = nil; // 缺省承载视图(
|
||||
|
||||
+ (void)showError:(NSString *)status { [self _showText:status ?: KBLocalized(@"Failed") icon:nil]; }
|
||||
|
||||
+ (void)dismiss { [self onMain:^{ [sHUD hideAnimated:YES]; }]; }
|
||||
+ (void)dismiss { [self onMain:^{ [self cancelLoadingAutoDismiss]; [sHUD hideAnimated:YES]; }]; }
|
||||
|
||||
+ (void)dismissWithDelay:(NSTimeInterval)delay { [self onMain:^{ [sHUD hideAnimated:YES afterDelay:delay]; }]; }
|
||||
+ (void)dismissWithDelay:(NSTimeInterval)delay { [self onMain:^{ [self cancelLoadingAutoDismiss]; [sHUD hideAnimated:YES afterDelay:delay]; }]; }
|
||||
|
||||
#pragma mark - Config
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBKeyboardStressTestVC : BaseViewController
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
142
keyBoard/Class/Home/VC/FunctionTest/KBKeyboardStressTestVC.m
Normal file
@@ -0,0 +1,142 @@
|
||||
#import "KBKeyboardStressTestVC.h"
|
||||
|
||||
@interface KBKeyboardStressTestVC ()
|
||||
@property(nonatomic, strong) UITextView *textView;
|
||||
@property(nonatomic, strong) UIButton *startButton;
|
||||
@property(nonatomic, strong) UIButton *stopButton;
|
||||
@property(nonatomic, strong) UILabel *statusLabel;
|
||||
@property(nonatomic, assign) NSInteger currentCycle;
|
||||
@property(nonatomic, assign) NSInteger totalCycles;
|
||||
@property(nonatomic, assign) BOOL running;
|
||||
@end
|
||||
|
||||
@implementation KBKeyboardStressTestVC
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = UIColor.whiteColor;
|
||||
self.title = @"键盘压力测试";
|
||||
self.totalCycles = 200;
|
||||
[self buildUI];
|
||||
[self updateStatus];
|
||||
}
|
||||
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
[super viewWillDisappear:animated];
|
||||
[self stop];
|
||||
}
|
||||
|
||||
- (void)buildUI {
|
||||
CGFloat w = UIScreen.mainScreen.bounds.size.width;
|
||||
|
||||
self.statusLabel = [[UILabel alloc] initWithFrame:CGRectMake(16, KB_NAV_TOTAL_HEIGHT + 16, w - 32, 22)];
|
||||
self.statusLabel.font = [UIFont systemFontOfSize:13];
|
||||
self.statusLabel.textColor = [UIColor colorWithWhite:0.2 alpha:1];
|
||||
[self.view addSubview:self.statusLabel];
|
||||
|
||||
self.textView = [[UITextView alloc] initWithFrame:CGRectMake(16, CGRectGetMaxY(self.statusLabel.frame) + 12, w - 32, 160)];
|
||||
self.textView.text = @"把系统输入法切到自定义键盘后,点击开始,会反复显示/隐藏键盘。";
|
||||
self.textView.layer.borderColor = [UIColor colorWithWhite:0 alpha:0.15].CGColor;
|
||||
self.textView.layer.borderWidth = 0.5;
|
||||
self.textView.layer.cornerRadius = 8;
|
||||
[self.view addSubview:self.textView];
|
||||
|
||||
CGFloat btnW = (w - 16 * 2 - 12) / 2.0;
|
||||
self.startButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
self.startButton.frame = CGRectMake(16, CGRectGetMaxY(self.textView.frame) + 16, btnW, 44);
|
||||
self.startButton.layer.cornerRadius = 10;
|
||||
self.startButton.backgroundColor = [UIColor colorWithRed:0.22 green:0.49 blue:0.96 alpha:1];
|
||||
[self.startButton setTitleColor:UIColor.whiteColor forState:UIControlStateNormal];
|
||||
[self.startButton setTitle:@"开始" forState:UIControlStateNormal];
|
||||
[self.startButton addTarget:self action:@selector(onStart) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.view addSubview:self.startButton];
|
||||
|
||||
self.stopButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
self.stopButton.frame = CGRectMake(CGRectGetMaxX(self.startButton.frame) + 12, CGRectGetMinY(self.startButton.frame), btnW, 44);
|
||||
self.stopButton.layer.cornerRadius = 10;
|
||||
self.stopButton.backgroundColor = [UIColor colorWithWhite:0.2 alpha:0.08];
|
||||
[self.stopButton setTitleColor:[UIColor colorWithWhite:0.15 alpha:1] forState:UIControlStateNormal];
|
||||
[self.stopButton setTitle:@"停止" forState:UIControlStateNormal];
|
||||
[self.stopButton addTarget:self action:@selector(onStop) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.view addSubview:self.stopButton];
|
||||
|
||||
UIButton *oneCycleBtn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
oneCycleBtn.frame = CGRectMake(16, CGRectGetMaxY(self.startButton.frame) + 12, w - 32, 44);
|
||||
oneCycleBtn.layer.cornerRadius = 10;
|
||||
oneCycleBtn.backgroundColor = [UIColor colorWithWhite:0.2 alpha:0.08];
|
||||
[oneCycleBtn setTitleColor:[UIColor colorWithWhite:0.15 alpha:1] forState:UIControlStateNormal];
|
||||
[oneCycleBtn setTitle:@"执行 10 次" forState:UIControlStateNormal];
|
||||
[oneCycleBtn addTarget:self action:@selector(onRunTen) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.view addSubview:oneCycleBtn];
|
||||
}
|
||||
|
||||
- (void)onStart {
|
||||
self.totalCycles = 200;
|
||||
[self start];
|
||||
}
|
||||
|
||||
- (void)onRunTen {
|
||||
self.totalCycles = 10;
|
||||
[self start];
|
||||
}
|
||||
|
||||
- (void)onStop {
|
||||
[self stop];
|
||||
}
|
||||
|
||||
- (void)start {
|
||||
if (self.running) {
|
||||
return;
|
||||
}
|
||||
self.running = YES;
|
||||
self.currentCycle = 0;
|
||||
[self updateStatus];
|
||||
[self runNextCycle];
|
||||
}
|
||||
|
||||
- (void)stop {
|
||||
self.running = NO;
|
||||
[self.textView resignFirstResponder];
|
||||
[self updateStatus];
|
||||
}
|
||||
|
||||
- (void)runNextCycle {
|
||||
if (!self.running) {
|
||||
return;
|
||||
}
|
||||
if (self.currentCycle >= self.totalCycles) {
|
||||
[self stop];
|
||||
return;
|
||||
}
|
||||
|
||||
self.currentCycle += 1;
|
||||
[self updateStatus];
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[self.textView becomeFirstResponder];
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.18 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self || !self.running) {
|
||||
return;
|
||||
}
|
||||
[self.textView resignFirstResponder];
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.12 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
[self runNextCycle];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
- (void)updateStatus {
|
||||
NSString *run = self.running ? @"运行中" : @"未运行";
|
||||
self.statusLabel.text = [NSString stringWithFormat:@"状态:%@ | 进度:%ld/%ld", run, (long)self.currentCycle, (long)self.totalCycles];
|
||||
self.startButton.enabled = !self.running;
|
||||
self.startButton.alpha = self.running ? 0.5 : 1.0;
|
||||
self.stopButton.enabled = self.running;
|
||||
self.stopButton.alpha = self.running ? 1.0 : 0.5;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -212,6 +212,7 @@
|
||||
// }
|
||||
// }];
|
||||
[[KBNetworkManager shared] POST:API_CHARACTER_ADD_USER_CHARACTER jsonBody:params headers:nil autoShowBusinessError:true completion:^(NSDictionary * _Nullable json, NSURLResponse * _Nullable response, NSError * _Nullable error) {
|
||||
[KBHUD dismiss];
|
||||
if (!error) {
|
||||
[self refreshUserCharacterCacheForKeyboard];
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ typedef NS_ENUM(NSInteger, UserSex) {
|
||||
/// 是否是 VIP
|
||||
@property (nonatomic, assign) BOOL isVip;
|
||||
|
||||
/// 1: vip 2:svip
|
||||
@property (nonatomic, assign) NSInteger vipLevel;
|
||||
|
||||
// token
|
||||
@property (nonatomic, copy, nullable) NSString *token; // token/access_token/accessToken
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
}
|
||||
NSString *message = nil;
|
||||
if (success) {
|
||||
message = KBLocalized(@"已应用,切到键盘查看");
|
||||
message = KBLocalized(@"Applied. Switch to the keyboard to view.");
|
||||
} else if ([error.domain isEqualToString:KBSkinBridgeErrorDomain] &&
|
||||
error.code == KBSkinBridgeErrorContainerUnavailable) {
|
||||
message = KBLocalized(@"无法访问共享容器,应用皮肤失败");
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
- (UILabel *)titleLabel {
|
||||
if (!_titleLabel) {
|
||||
_titleLabel = [UILabel new];
|
||||
_titleLabel.text = @"Modify Gender";
|
||||
_titleLabel.text = KBLocalized(@"Modify Gender");
|
||||
_titleLabel.textColor = [UIColor blackColor];
|
||||
_titleLabel.font = [KBFont medium:16];
|
||||
}
|
||||
@@ -226,7 +226,7 @@
|
||||
- (UIButton *)saveButton {
|
||||
if (!_saveButton) {
|
||||
_saveButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
[_saveButton setTitle:@"Save" forState:UIControlStateNormal];
|
||||
[_saveButton setTitle:KBLocalized(@"Save") forState:UIControlStateNormal];
|
||||
[_saveButton setTitleColor:UIColor.whiteColor forState:UIControlStateNormal];
|
||||
_saveButton.titleLabel.font = [KBFont medium:16];
|
||||
_saveButton.backgroundColor = [UIColor colorWithRed:0.02 green:0.75 blue:0.67 alpha:1.0];
|
||||
|
||||
@@ -136,9 +136,17 @@
|
||||
[self kb_applyVipLayout:isVip];
|
||||
self.vipIconView.hidden = !isVip;
|
||||
self.vipExpiryLabel.hidden = !isVip;
|
||||
if (isVip) {
|
||||
self.vipIconView.image = [UIImage imageNamed:[self kb_vipIconNameFromVipLevel:user.vipLevel]];
|
||||
}
|
||||
self.vipExpiryLabel.text = isVip ? [self vipExpiryDisplayTextFrom:user.vipExpiry] : nil;
|
||||
}
|
||||
|
||||
- (NSString *)kb_vipIconNameFromVipLevel:(NSInteger)vipLevel {
|
||||
if (vipLevel == 2) { return @"my_svip_icon"; }
|
||||
return @"my_vip_icon";
|
||||
}
|
||||
|
||||
- (void)kb_applyVipLayout:(BOOL)isVip {
|
||||
NSInteger state = isVip ? 1 : 0;
|
||||
if (self.kb_vipLayoutState == state) { return; }
|
||||
|
||||
19
keyBoard/Class/Me/VC/KBCancelAccountVC.h
Normal file
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// KBCancelAccountVC.h
|
||||
// keyBoard
|
||||
//
|
||||
// 注销账户页面
|
||||
//
|
||||
|
||||
#import "BaseViewController.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBCancelAccountVC : BaseViewController
|
||||
|
||||
/// 注销协议 HTML 内容(由外部传入或内部请求)
|
||||
@property (nonatomic, copy, nullable) NSString *htmlContent;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
142
keyBoard/Class/Me/VC/KBCancelAccountVC.m
Normal file
@@ -0,0 +1,142 @@
|
||||
//
|
||||
// KBCancelAccountVC.m
|
||||
// keyBoard
|
||||
//
|
||||
// 注销账户页面:顶部标题 + 中间 HTML 协议展示 + 底部注销按钮
|
||||
//
|
||||
|
||||
#import "KBCancelAccountVC.h"
|
||||
#import <WebKit/WebKit.h>
|
||||
#import <Masonry/Masonry.h>
|
||||
#import "KBMyVM.h"
|
||||
#import "KBAlert.h"
|
||||
|
||||
@interface KBCancelAccountVC ()
|
||||
|
||||
@property (nonatomic, strong) UILabel *titleLabel;
|
||||
@property (nonatomic, strong) WKWebView *webView;
|
||||
@property (nonatomic, strong) UIButton *cancelAccountBtn;
|
||||
@property (nonatomic, strong) KBMyVM *myVM;
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBCancelAccountVC
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.kb_titleLabel.text = KBLocalized(@"Cancel Account");
|
||||
self.view.backgroundColor = [UIColor colorWithHex:0xFFFFFF];
|
||||
|
||||
[self.view addSubview:self.titleLabel];
|
||||
[self.view addSubview:self.webView];
|
||||
[self.view addSubview:self.cancelAccountBtn];
|
||||
|
||||
[self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.view).offset(KB_NAV_TOTAL_HEIGHT + 20);
|
||||
make.left.equalTo(self.view).offset(16);
|
||||
make.right.equalTo(self.view).offset(-16);
|
||||
}];
|
||||
|
||||
[self.cancelAccountBtn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.view).offset(16);
|
||||
make.right.equalTo(self.view).offset(-16);
|
||||
make.bottom.equalTo(self.view.mas_safeAreaLayoutGuideBottom).offset(-12);
|
||||
make.height.mas_equalTo(56);
|
||||
}];
|
||||
|
||||
[self.webView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.titleLabel.mas_bottom).offset(16);
|
||||
make.left.equalTo(self.view).offset(16);
|
||||
make.right.equalTo(self.view).offset(-16);
|
||||
make.bottom.equalTo(self.cancelAccountBtn.mas_top).offset(-16);
|
||||
}];
|
||||
|
||||
[self fetchAgreement];
|
||||
}
|
||||
|
||||
- (void)fetchAgreement {
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[self.myVM fetchCancelAccountWarningWithCompletion:^(NSString * _Nullable html, NSError * _Nullable error) {
|
||||
if (html.length > 0) {
|
||||
weakSelf.htmlContent = html;
|
||||
}
|
||||
[weakSelf loadHTMLContent];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)loadHTMLContent {
|
||||
NSString *html = self.htmlContent ?: @"";
|
||||
// 包裹一层基本样式,适配移动端
|
||||
NSString *wrappedHTML = [NSString stringWithFormat:
|
||||
@"<html><head>"
|
||||
"<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'>"
|
||||
"<style>"
|
||||
"body { font-family: -apple-system, sans-serif; font-size: 15px; color: #333; "
|
||||
"line-height: 1.6; padding: 0; margin: 0; word-wrap: break-word; }"
|
||||
"</style></head><body>%@</body></html>", html];
|
||||
[self.webView loadHTMLString:wrappedHTML baseURL:nil];
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)onTapCancelAccount {
|
||||
[[KBMaiPointReporter sharedReporter] reportClickWithEventName:@"click_cancel_account_confirm_btn"
|
||||
pageId:@"cancel_account"
|
||||
elementId:@"cancel_account_confirm_btn"
|
||||
extra:nil
|
||||
completion:nil];
|
||||
KBWeakSelf;
|
||||
|
||||
[KBAlert confirmTitle:KBLocalized(@"Cancel Account") message:KBLocalized(@"After cancellation, your account will be deactivated and local login data will be cleared. Continue?") ok:KBLocalized(@"Confirm") cancel:KBLocalized(@"Cancel") completion:^(BOOL ok) {
|
||||
if (!ok) { return; }
|
||||
[weakSelf.myVM cancelAccountWithCompletion:nil];
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UILabel *)titleLabel {
|
||||
if (!_titleLabel) {
|
||||
_titleLabel = [UILabel new];
|
||||
_titleLabel.text = KBLocalized(@"Cancel Account Notice");
|
||||
_titleLabel.textColor = [UIColor colorWithHex:KBBlackValue];
|
||||
_titleLabel.font = [KBFont bold:20];
|
||||
_titleLabel.numberOfLines = 0;
|
||||
}
|
||||
return _titleLabel;
|
||||
}
|
||||
|
||||
- (WKWebView *)webView {
|
||||
if (!_webView) {
|
||||
WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
|
||||
_webView = [[WKWebView alloc] initWithFrame:CGRectZero configuration:config];
|
||||
_webView.backgroundColor = UIColor.whiteColor;
|
||||
_webView.scrollView.showsVerticalScrollIndicator = YES;
|
||||
_webView.layer.cornerRadius = 12;
|
||||
_webView.layer.masksToBounds = YES;
|
||||
}
|
||||
return _webView;
|
||||
}
|
||||
|
||||
- (UIButton *)cancelAccountBtn {
|
||||
if (!_cancelAccountBtn) {
|
||||
_cancelAccountBtn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
[_cancelAccountBtn setTitle:KBLocalized(@"Confirm Cancel Account") forState:UIControlStateNormal];
|
||||
[_cancelAccountBtn setTitleColor:UIColor.whiteColor forState:UIControlStateNormal];
|
||||
_cancelAccountBtn.titleLabel.font = [KBFont medium:16];
|
||||
_cancelAccountBtn.backgroundColor = [UIColor colorWithHex:0xFF0000];
|
||||
_cancelAccountBtn.layer.cornerRadius = 12;
|
||||
_cancelAccountBtn.layer.masksToBounds = YES;
|
||||
[_cancelAccountBtn addTarget:self action:@selector(onTapCancelAccount) forControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
return _cancelAccountBtn;
|
||||
}
|
||||
|
||||
- (KBMyVM *)myVM {
|
||||
if (!_myVM) {
|
||||
_myVM = [[KBMyVM alloc] init];
|
||||
}
|
||||
return _myVM;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -14,6 +14,8 @@
|
||||
#import "KBChangeNicknamePopView.h"
|
||||
#import "KBGenderPickerPopView.h"
|
||||
#import "KBMyVM.h"
|
||||
#import "KBAlert.h"
|
||||
#import "KBCancelAccountVC.h"
|
||||
@interface KBPersonInfoVC () <UITableViewDelegate, UITableViewDataSource, PHPickerViewControllerDelegate, UINavigationControllerDelegate, UIImagePickerControllerDelegate>
|
||||
|
||||
// 列表
|
||||
@@ -25,7 +27,7 @@
|
||||
@property (nonatomic, strong) UIButton *editBadge; // 头像右下角的小铅笔
|
||||
@property (nonatomic, strong) UILabel *modifyLabel; // “Modify” 文案
|
||||
|
||||
// 底部退出按钮(固定在屏幕底部)
|
||||
// 底部退出登录按钮
|
||||
@property (nonatomic, strong) UIButton *logoutBtn;
|
||||
|
||||
// 数据
|
||||
@@ -64,7 +66,7 @@
|
||||
// 表头
|
||||
self.tableView.tableHeaderView = self.headerView;
|
||||
|
||||
// 底部退出按钮固定在屏幕底部
|
||||
// 底部退出登录按钮
|
||||
[self.view addSubview:self.logoutBtn];
|
||||
[self.logoutBtn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.view).offset(16);
|
||||
@@ -116,14 +118,18 @@
|
||||
|
||||
#pragma mark - UITableView
|
||||
|
||||
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return 1; }
|
||||
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return self.items.count; }
|
||||
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return 2; }
|
||||
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
|
||||
return section == 0 ? self.items.count : 1;
|
||||
}
|
||||
|
||||
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
return 56.0;
|
||||
}
|
||||
|
||||
- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section { return 12.0; }
|
||||
- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section {
|
||||
return section == 0 ? 12.0 : 15.0;
|
||||
}
|
||||
- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { return [UIView new]; }
|
||||
- (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section { return 0.01; }
|
||||
|
||||
@@ -131,19 +137,34 @@
|
||||
static NSString *cid = @"KBPersonInfoItemCell";
|
||||
KBPersonInfoItemCell *cell = [tableView dequeueReusableCellWithIdentifier:cid];
|
||||
if (!cell) { cell = [[KBPersonInfoItemCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cid]; }
|
||||
NSDictionary *it = self.items[indexPath.row];
|
||||
BOOL isTop = (indexPath.row == 0);
|
||||
BOOL isBottom = (indexPath.row == self.items.count - 1);
|
||||
[cell configWithTitle:it[@"title"]
|
||||
value:it[@"value"]
|
||||
showArrow:[it[@"arrow"] boolValue]
|
||||
showCopy:[it[@"copy"] boolValue]
|
||||
isTop:isTop
|
||||
isBottom:isBottom];
|
||||
|
||||
if (indexPath.section == 0) {
|
||||
NSDictionary *it = self.items[indexPath.row];
|
||||
BOOL isTop = (indexPath.row == 0);
|
||||
BOOL isBottom = (indexPath.row == self.items.count - 1);
|
||||
[cell configWithTitle:it[@"title"]
|
||||
value:it[@"value"]
|
||||
showArrow:[it[@"arrow"] boolValue]
|
||||
showCopy:[it[@"copy"] boolValue]
|
||||
isTop:isTop
|
||||
isBottom:isBottom];
|
||||
} else {
|
||||
[cell configWithTitle:KBLocalized(@"Cancel Account")
|
||||
value:@""
|
||||
showArrow:YES
|
||||
showCopy:NO
|
||||
isTop:YES
|
||||
isBottom:YES];
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
if (indexPath.section == 1) {
|
||||
KBCancelAccountVC *vc = [[KBCancelAccountVC alloc] init];
|
||||
[self.navigationController pushViewController:vc animated:YES];
|
||||
return;
|
||||
}
|
||||
if (indexPath.row == 0) {
|
||||
// 昵称编辑 -> 弹窗
|
||||
CGFloat width = KB_SCREEN_WIDTH;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#import "KBMyVM.h"
|
||||
#import "KBMyTheme.h"
|
||||
#import "KBHUD.h"
|
||||
#import "KBSkinManager.h"
|
||||
|
||||
static NSString * const kMySkinCellId = @"kMySkinCellId";
|
||||
|
||||
@@ -254,6 +255,25 @@ static NSString * const kMySkinCellId = @"kMySkinCellId";
|
||||
return cell;
|
||||
}
|
||||
|
||||
- (BOOL)collectionView:(UICollectionView *)collectionView shouldSelectItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
if (self.isEditingMode) {
|
||||
KBMyTheme *theme = self.data[indexPath.item];
|
||||
NSString *currentSkinId = [KBSkinManager shared].current.skinId;
|
||||
// themeId 可能是 NSString 或 NSNumber,统一转字符串比较
|
||||
NSString *themeIdStr = nil;
|
||||
if ([theme.themeId isKindOfClass:[NSString class]]) {
|
||||
themeIdStr = theme.themeId;
|
||||
} else if ([theme.themeId respondsToSelector:@selector(stringValue)]) {
|
||||
themeIdStr = [(id)theme.themeId stringValue];
|
||||
}
|
||||
if (currentSkinId.length > 0 && themeIdStr.length > 0 && [themeIdStr isEqualToString:currentSkinId]) {
|
||||
[KBHUD showInfo:KBLocalized(@"The skin in use cannot be deleted")];
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
if (!self.isEditingMode) {
|
||||
// 非编辑态:可在此进入详情,当前示例不处理
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
@property (nonatomic, strong) NSArray<NSArray<NSDictionary *> *> *data; // 分组数据
|
||||
@property (nonatomic, strong) UIImageView *bgImageView; // 全屏背景图
|
||||
@property (nonatomic, strong) KBMyVM *viewModel; // 我的页面 VM
|
||||
@property (nonatomic, copy) NSString *customerMail; // 客服邮箱
|
||||
@end
|
||||
|
||||
@implementation MyVC
|
||||
@@ -43,7 +44,7 @@
|
||||
// 数据源(title + SF Symbols 名称 + 色值),分两组 my_record_icon
|
||||
self.data = @[
|
||||
@[@{ @"title": KBLocalized(@"Consumption Record"), @"icon": @"my_record_icon", @"color": @(0x60A3FF),@"id":@"8" }],
|
||||
@[@{ @"title": KBLocalized(@"Notice"), @"icon": @"my_notice_icon", @"color": @(0x60A3FF),@"id":@"1" }],
|
||||
// @[@{ @"title": KBLocalized(@"Notice"), @"icon": @"my_notice_icon", @"color": @(0x60A3FF),@"id":@"1" }],
|
||||
@[@{ @"title": KBLocalized(@"invite"), @"icon": @"my_share_icon", @"color": @(0xF5A623),@"id":@"2" }],
|
||||
@[@{ @"title": KBLocalized(@"Feedback"), @"icon": @"my_feedback_icon", @"color": @(0xB06AFD),@"id":@"3" },
|
||||
@{ @"title": KBLocalized(@"E-mail"), @"icon": @"my_email_icon", @"color": @(0xFF8A65),@"id":@"4" },
|
||||
@@ -63,6 +64,11 @@
|
||||
self.header = [[KBMyHeaderView alloc] initWithFrame:CGRectMake(0, 0, KB_SCREEN_WIDTH, 357)];
|
||||
self.header.backgroundColor = UIColor.clearColor; // 透明,显示背景图
|
||||
self.tableView.tableHeaderView = self.header;
|
||||
|
||||
if (!self.viewModel) {
|
||||
self.viewModel = [[KBMyVM alloc] init];
|
||||
}
|
||||
[self loadCustomerMailOnce];
|
||||
}
|
||||
|
||||
- (void)viewWillAppear:(BOOL)animated {
|
||||
@@ -81,6 +87,30 @@
|
||||
|
||||
// 系统导航栏的显隐由 BaseViewController 统一处理(始终隐藏),此处无需再设置。
|
||||
|
||||
/// 1:viewDidLoad 触发一次,预加载客服邮箱
|
||||
- (void)loadCustomerMailOnce {
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[self.viewModel fetchCustomerMailWithCompletion:^(NSString * _Nullable customerMail, NSError * _Nullable error) {
|
||||
if (error) { return; }
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self) { return; }
|
||||
self.customerMail = customerMail;
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
/// 2:点击 E-mail 复制邮箱并提示
|
||||
- (void)handleEmailCopy {
|
||||
NSString *mail = [self.customerMail stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
if (mail.length == 0) {
|
||||
[KBHUD showInfo:KBLocalized(@"Failed")];
|
||||
return;
|
||||
}
|
||||
UIPasteboard.generalPasteboard.string = mail;
|
||||
[KBHUD showInfo:KBLocalized(@"Email Copy Success")];
|
||||
}
|
||||
|
||||
#pragma mark - UITableView
|
||||
|
||||
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return self.data.count; }
|
||||
@@ -185,7 +215,7 @@
|
||||
[self.navigationController pushViewController:[KBFeedBackVC new] animated:true];
|
||||
|
||||
}else if ([itemID isEqualToString:@"4"]){
|
||||
|
||||
[self handleEmailCopy];
|
||||
}else if ([itemID isEqualToString:@"5"]){
|
||||
|
||||
}else if ([itemID isEqualToString:@"6"]){
|
||||
|
||||