909 lines
38 KiB
Objective-C
909 lines
38 KiB
Objective-C
//
|
||
// KBFunctionView.m
|
||
// CustomKeyboard
|
||
//
|
||
// Created by Mac on 2025/10/28.
|
||
//
|
||
|
||
#import "KBFunctionView.h"
|
||
#import "KBResponderUtils.h" // 统一查找 UIInputViewController 的工具
|
||
#import "KBFunctionBarView.h"
|
||
#import "KBFunctionPasteView.h"
|
||
#import "KBFunctionTagCell.h"
|
||
#import "Masonry.h"
|
||
#import <MBProgressHUD.h>
|
||
#import "KBFullAccessGuideView.h"
|
||
#import "KBFullAccessManager.h"
|
||
#import "KBSkinManager.h"
|
||
#import "KBAuthManager.h" // 登录态判断(共享钥匙串)
|
||
#import "KBULBridgeNotification.h" // Darwin 通知常量(UL 已处理)
|
||
#import "KBHostAppLauncher.h"
|
||
#import "KBStreamTextView.h" // 流式文本视图
|
||
#import "KBStreamOverlayView.h" // 带关闭按钮的流式层
|
||
#import "KBFunctionTagListView.h"
|
||
#import "WJXEventSource.h"
|
||
#import "KBTagItemModel.h"
|
||
#import <MJExtension/MJExtension.h>
|
||
#import "KBBizCode.h"
|
||
#import "KBBackspaceLongPressHandler.h"
|
||
|
||
@interface KBFunctionView () <KBFunctionBarViewDelegate, KBStreamOverlayViewDelegate, KBFunctionTagListViewDelegate>
|
||
// UI
|
||
@property (nonatomic, strong) KBFunctionBarView *barViewInternal;
|
||
@property (nonatomic, strong) KBFunctionPasteView *pasteViewInternal;
|
||
@property (nonatomic, strong) KBFunctionTagListView *tagListView;
|
||
@property (nonatomic, strong) UIView *rightButtonContainer; // 右侧竖排按钮容器
|
||
@property (nonatomic, strong) UIButton *pasteButtonInternal;
|
||
@property (nonatomic, strong) UIButton *deleteButtonInternal;
|
||
@property (nonatomic, strong) UIButton *clearButtonInternal;
|
||
@property (nonatomic, strong) UIButton *sendButtonInternal;
|
||
|
||
// 叠层:流式文本视图 + 关闭按钮
|
||
@property (nonatomic, strong, nullable) KBStreamOverlayView *streamOverlay;
|
||
|
||
// 网络流式(封装)
|
||
@property (nonatomic, strong, nullable) WJXEventSource *eventSource;
|
||
@property (nonatomic, assign) BOOL streamHasOutput; // 是否已输出过正文(首段去首个 \t 用)
|
||
@property (nonatomic, strong, nullable) NSNumber *loadingTagIndex; // 当前显示loading的标签index
|
||
@property (nonatomic, copy, nullable) NSString *loadingTagTitle;
|
||
@property (nonatomic, assign) BOOL eventSourceDidReceiveDone;
|
||
@property (nonatomic, copy, nullable) NSString *eventSourceSplitPrefix;
|
||
|
||
// Data
|
||
//@property (nonatomic, strong) NSArray<NSString *> *itemsInternal;
|
||
@property (nonatomic, strong) NSMutableArray<KBTagItemModel *> *modelArray;
|
||
|
||
// 剪贴板自动检测
|
||
@property (nonatomic, strong) NSTimer *pasteboardTimer; // 轮询定时器(轻量、主线程)
|
||
@property (nonatomic, assign) NSInteger lastHandledPBCount; // 上次处理过的 changeCount,避免重复弹窗
|
||
|
||
// UL 双路兜底
|
||
@property (nonatomic, assign) NSUInteger kb_ulSeq; // 当前 UL 发起序号
|
||
@property (nonatomic, assign) BOOL kb_ulHandledFlag; // 主 App 已确认处理 UL
|
||
@property (nonatomic, strong) KBBackspaceLongPressHandler *backspaceHandler;
|
||
@end
|
||
|
||
@implementation KBFunctionView
|
||
|
||
- (instancetype)initWithFrame:(CGRect)frame {
|
||
if (self = [super initWithFrame:frame]) {
|
||
// 背景使用当前主题强调色
|
||
[self kb_applyTheme];
|
||
self.backspaceHandler = [[KBBackspaceLongPressHandler alloc] initWithContainerView:self];
|
||
|
||
[self setupUI];
|
||
// [self reloadDemoData];
|
||
[self kb_reloadTagsFromSharedDefaults];
|
||
|
||
|
||
// 初始化剪贴板监控状态
|
||
_lastHandledPBCount = [UIPasteboard generalPasteboard].changeCount;
|
||
|
||
// 监听“完全访问”状态变化,动态启停剪贴板监控,避免在未开完全访问时触发 TCC/XPC 错误日志
|
||
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(kb_fullAccessChanged) name:KBFullAccessChangedNotification object:nil];
|
||
|
||
// 监听主 App 的 Darwin 确认(UL 已处理)
|
||
CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(),
|
||
(__bridge const void *)(self),
|
||
KBULDarwinCallback,
|
||
(__bridge CFStringRef)KBDarwinULHandled,
|
||
NULL,
|
||
CFNotificationSuspensionBehaviorDeliverImmediately);
|
||
}
|
||
return self;
|
||
}
|
||
|
||
#pragma mark - Data
|
||
|
||
/// 从 App Group 的 NSUserDefaults 中读取真实 JSON,解析为 model + 标签文案
|
||
- (void)kb_reloadTagsFromSharedDefaults {
|
||
NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||
NSDictionary *jsonDict = [sharedDefaults objectForKey:AppGroup_MyKbJson];
|
||
if (jsonDict != nil) {
|
||
id dataObj = jsonDict[@"data"];
|
||
NSArray<KBTagItemModel *> *modelList = [KBTagItemModel mj_objectArrayWithKeyValuesArray:(NSArray *)dataObj];
|
||
if (modelList.count > 0) {
|
||
self.modelArray = [NSMutableArray array];
|
||
[self.modelArray addObjectsFromArray:modelList];
|
||
// [self.collectionView reloadData];
|
||
[self.tagListView setItems:self.modelArray];
|
||
}
|
||
}else{
|
||
NSLog(@"json❎");
|
||
}
|
||
}
|
||
|
||
|
||
#pragma mark - Theme
|
||
|
||
- (void)kb_applyTheme {
|
||
// KBSkinManager *mgr = [KBSkinManager shared];
|
||
// UIColor *accent = mgr.current.accentColor ?: [UIColor colorWithRed:0.77 green:0.93 blue:0.82 alpha:1.0];
|
||
// BOOL hasImg = ([mgr currentBackgroundImage] != nil);
|
||
self.backgroundColor = [UIColor colorWithHex:0xD0D3DA];
|
||
}
|
||
|
||
- (void)dealloc {
|
||
[self stopPasteboardMonitor];
|
||
[self kb_stopNetworkStreaming];
|
||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||
CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(self), (__bridge CFStringRef)KBDarwinULHandled, NULL);
|
||
}
|
||
|
||
#pragma mark - UI
|
||
|
||
- (void)setupUI {
|
||
// 1. 顶部 Bar
|
||
[self addSubview:self.barViewInternal];
|
||
[self.barViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.left.right.equalTo(self);
|
||
make.top.equalTo(self.mas_top).offset(6);
|
||
make.height.mas_equalTo(52);
|
||
}];
|
||
|
||
// 右侧竖排按钮容器
|
||
[self addSubview:self.rightButtonContainer];
|
||
[self.rightButtonContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.right.equalTo(self.mas_right).offset(-4);
|
||
make.top.equalTo(self.barViewInternal.mas_bottom).offset(0);
|
||
make.bottom.equalTo(self.mas_bottom).offset(-10);
|
||
make.width.mas_equalTo(60);
|
||
}];
|
||
|
||
// 右侧四个按钮
|
||
[self.rightButtonContainer addSubview:self.pasteButtonInternal];
|
||
[self.rightButtonContainer addSubview:self.deleteButtonInternal];
|
||
[self.rightButtonContainer addSubview:self.clearButtonInternal];
|
||
[self.rightButtonContainer addSubview:self.sendButtonInternal];
|
||
|
||
// 竖向排布:粘贴、删除、清空为等高;发送优先更高,但允许在空间不足时压缩
|
||
CGFloat smallH = 41;
|
||
CGFloat bigH = 56;
|
||
// 原 10 在键盘总高度 276 下容易超出容器,改为 8 以避免 AutoLayout 冲突
|
||
CGFloat vSpace = 4;
|
||
[self.pasteButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.top.equalTo(self.rightButtonContainer.mas_top);
|
||
make.left.right.equalTo(self.rightButtonContainer);
|
||
make.height.mas_equalTo(smallH);
|
||
}];
|
||
[self.deleteButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.top.equalTo(self.pasteButtonInternal.mas_bottom).offset(vSpace);
|
||
make.left.right.equalTo(self.rightButtonContainer);
|
||
make.height.equalTo(self.pasteButtonInternal);
|
||
}];
|
||
[self.clearButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.top.equalTo(self.deleteButtonInternal.mas_bottom).offset(vSpace);
|
||
make.left.right.equalTo(self.rightButtonContainer);
|
||
make.height.equalTo(self.pasteButtonInternal);
|
||
}];
|
||
[self.sendButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.top.equalTo(self.clearButtonInternal.mas_bottom).offset(vSpace);
|
||
make.left.right.equalTo(self.rightButtonContainer);
|
||
// 允许在空间不足时缩短到 smallH,避免产生约束冲突
|
||
make.height.greaterThanOrEqualTo(@(smallH));
|
||
make.height.lessThanOrEqualTo(@(bigH));
|
||
make.bottom.lessThanOrEqualTo(self.rightButtonContainer.mas_bottom);
|
||
}];
|
||
|
||
// 2. 粘贴区(位于右侧按钮左侧)
|
||
[self addSubview:self.pasteViewInternal];
|
||
[self.pasteViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.left.equalTo(self.mas_left).offset(vSpace);
|
||
make.right.equalTo(self.rightButtonContainer.mas_left).offset(-vSpace);
|
||
make.top.equalTo(self.barViewInternal.mas_bottom).offset(0);
|
||
make.height.mas_equalTo(smallH);
|
||
}];
|
||
// 点击整个粘贴卡片按钮,行为与右侧「Paste」按钮保持一致
|
||
[self.pasteViewInternal.pasBtn addTarget:self action:@selector(onTapPaste) forControlEvents:UIControlEventTouchUpInside];
|
||
|
||
// 3. Tag List View
|
||
[self addSubview:self.tagListView];
|
||
[self.tagListView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.left.equalTo(self.pasteViewInternal);
|
||
make.right.equalTo(self.rightButtonContainer.mas_left).offset(-vSpace);
|
||
make.top.equalTo(self.pasteViewInternal.mas_bottom).offset(vSpace);
|
||
make.bottom.equalTo(self.mas_bottom).offset(-10);
|
||
}];
|
||
}
|
||
|
||
#pragma mark - Data
|
||
|
||
//- (void)reloadDemoData {
|
||
// // 演示数据(可由外部替换)
|
||
// self.itemsInternal = @[KBLocalized(@"Warm hearted man"),
|
||
// KBLocalized(@"Warm2 hearted man"),
|
||
// KBLocalized(@"Warm3 hearted man"),
|
||
// KBLocalized(@"撩女生啊u发顺丰大师傅"),
|
||
// KBLocalized(@"Warm = man"),
|
||
// KBLocalized(@"Warm hearted man"),
|
||
// KBLocalized(@"一枚暖男发放"),
|
||
// KBLocalized(@"聊天搭子"),
|
||
// KBLocalized(@"表达爱意"),
|
||
// KBLocalized(@"更多话术")];
|
||
// [self.tagListView setItems:self.itemsInternal];
|
||
//}
|
||
|
||
// UICollectionView 逻辑已下沉至 KBFunctionTagListView
|
||
|
||
- (void)kb_showStreamTextViewIfNeededWithTitle:(NSString *)title {
|
||
// 已有则不重复创建
|
||
if (self.streamOverlay.superview) { return; }
|
||
|
||
// 隐藏标签列表,使用同一区域展示流式文本
|
||
self.tagListView.hidden = YES;
|
||
|
||
KBStreamOverlayView *overlay = [[KBStreamOverlayView alloc] init];
|
||
overlay.delegate = (id)self;
|
||
[self addSubview:overlay];
|
||
[overlay mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
// 在原标签区域内展示流式文本,右侧继续保留竖排按钮栏
|
||
CGFloat vSpace = 4.0;
|
||
make.left.equalTo(self.pasteViewInternal);
|
||
make.right.equalTo(self).offset(-vSpace);
|
||
make.top.equalTo(self.pasteViewInternal.mas_bottom).offset(10);
|
||
make.bottom.equalTo(self.mas_bottom).offset(-10);
|
||
}];
|
||
// 仅隐藏删除/清空/发送按钮,保留“Paste”按钮可用
|
||
self.pasteButtonInternal.hidden = NO;
|
||
self.deleteButtonInternal.hidden = YES;
|
||
self.clearButtonInternal.hidden = YES;
|
||
self.sendButtonInternal.hidden = YES;
|
||
// 适当缩小内部左右留白,进一步提升可用宽度
|
||
overlay.textView.contentHorizontalPadding = 8.0;
|
||
self.streamOverlay = overlay;
|
||
|
||
// 只创建 UI;网络在点击 cell 时启动,避免重复 start 导致首包重复
|
||
}
|
||
|
||
- (void)kb_onTapStreamDelete {
|
||
// 关闭并销毁流式视图,恢复标签列表
|
||
[self kb_stopNetworkStreaming];
|
||
[self.streamOverlay removeFromSuperview];
|
||
self.streamOverlay = nil;
|
||
self.tagListView.hidden = NO;
|
||
// 恢复右侧按钮栏的全部按钮
|
||
self.pasteButtonInternal.hidden = NO;
|
||
self.deleteButtonInternal.hidden = NO;
|
||
self.clearButtonInternal.hidden = NO;
|
||
self.sendButtonInternal.hidden = NO;
|
||
}
|
||
|
||
// 叠层关闭回调
|
||
- (void)streamOverlayDidTapClose:(KBStreamOverlayView *)overlay {
|
||
[self kb_onTapStreamDelete];
|
||
}
|
||
|
||
|
||
#pragma mark - Network Streaming (WJXEventSource)
|
||
|
||
- (void)kb_startNetworkStreamingWithSeed:(NSString *)seedTitle {
|
||
[self kb_stopNetworkStreaming];
|
||
if (![[KBFullAccessManager shared] hasFullAccess]) { return; }
|
||
|
||
NSString *apiUrl = [NSString stringWithFormat:@"%@%@", KB_BASE_URL, API_AI_TALK];
|
||
NSURL *url = [NSURL URLWithString:apiUrl];
|
||
if (!url) { return; }
|
||
|
||
NSInteger characterId = 0;
|
||
if (self.loadingTagIndex != nil) {
|
||
NSInteger idx = self.loadingTagIndex.integerValue;
|
||
if (idx >= 0 && idx < self.modelArray.count) {
|
||
KBTagItemModel *model = self.modelArray[idx];
|
||
characterId = model.characterId;
|
||
}
|
||
}
|
||
NSInteger resolvedCharacterId = (characterId > 0) ? characterId : 75;
|
||
NSString *message = seedTitle.length > 0 ? seedTitle : @"aliqua non cupidatat";
|
||
// message = [NSString stringWithFormat:@"%@%d",message,arc4random() % 10000];
|
||
NSDictionary *payload = @{
|
||
@"characterId": @(resolvedCharacterId),
|
||
@"message": message
|
||
};
|
||
NSLog(@"[KBFunction] request payload: %@", payload);
|
||
NSError *bodyError = nil;
|
||
NSData *bodyData = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&bodyError];
|
||
if (bodyError || bodyData.length == 0) {
|
||
NSLog(@"[KBFunction] build body failed: %@", bodyError);
|
||
return;
|
||
}
|
||
|
||
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:60];
|
||
request.HTTPMethod = @"POST";
|
||
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
|
||
NSString *token = KBAuthManager.shared.current.accessToken ?: @"";
|
||
if (token.length > 0) {
|
||
[request setValue:token forHTTPHeaderField:@"auth-token"];
|
||
}
|
||
request.HTTPBody = bodyData;
|
||
|
||
self.streamHasOutput = NO;
|
||
self.eventSourceSplitPrefix = nil;
|
||
self.eventSourceDidReceiveDone = NO;
|
||
|
||
__weak typeof(self) weakSelf = self;
|
||
WJXEventSource *source = [[WJXEventSource alloc] initWithRquest:request];
|
||
source.ignoreRetryAction = YES;
|
||
[source addListener:^(WJXEvent * _Nonnull event) {
|
||
__strong typeof(weakSelf) self = weakSelf; if (!self) return;
|
||
[self kb_handleEventSourceMessage:event];
|
||
} forEvent:WJXEventNameMessage queue:NSOperationQueue.mainQueue];
|
||
[source addListener:^(WJXEvent * _Nonnull event) {
|
||
__strong typeof(weakSelf) self = weakSelf; if (!self) return;
|
||
[self kb_handleEventSourceError:event.error];
|
||
} forEvent:WJXEventNameError queue:NSOperationQueue.mainQueue];
|
||
self.eventSource = source;
|
||
[self.eventSource open];
|
||
}
|
||
|
||
- (void)kb_stopNetworkStreaming {
|
||
[self.eventSource close];
|
||
self.eventSource = nil;
|
||
self.eventSourceSplitPrefix = nil;
|
||
self.eventSourceDidReceiveDone = NO;
|
||
self.streamHasOutput = NO;
|
||
}
|
||
|
||
- (void)kb_handleEventSourceMessage:(WJXEvent *)event {
|
||
if (event.data.length == 0) { return; }
|
||
NSLog(@"[KBStream] SSE raw payload: %@", event.data);
|
||
NSData *jsonData = [event.data dataUsingEncoding:NSUTF8StringEncoding];
|
||
if (!jsonData) { return; }
|
||
NSError *error = nil;
|
||
NSDictionary *payload = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error];
|
||
if (error || ![payload isKindOfClass:[NSDictionary class]]) { return; }
|
||
if ([self kb_handleBizErrorIfNeeded:payload]) { return; }
|
||
NSString *type = payload[@"type"];
|
||
if (![type isKindOfClass:[NSString class]]) { return; }
|
||
|
||
if ([type isEqualToString:@"llm_chunk"]) {
|
||
NSString *chunk = [self kb_normalizedLLMChunkString:payload[@"data"]];
|
||
if (chunk.length > 0) {
|
||
[self kb_appendChunkToStreamView:chunk];
|
||
}
|
||
return;
|
||
}
|
||
if ([type isEqualToString:@"search_result"]) {
|
||
NSString *text = [self kb_formattedSearchResultString:payload[@"data"]];
|
||
if (text.length > 0) {
|
||
[self kb_appendChunkToStreamView:text];
|
||
}
|
||
return;
|
||
}
|
||
if ([type isEqualToString:@"done"]) {
|
||
self.eventSourceDidReceiveDone = YES;
|
||
[self kb_finishEventSourceWithError:nil];
|
||
return;
|
||
}
|
||
}
|
||
|
||
- (void)kb_handleEventSourceError:(NSError * _Nullable)error {
|
||
if (self.eventSourceDidReceiveDone) { return; }
|
||
[self kb_finishEventSourceWithError:error];
|
||
}
|
||
|
||
- (void)kb_finishEventSourceWithError:(NSError * _Nullable)error {
|
||
[self.eventSource close];
|
||
self.eventSource = nil;
|
||
if (!self.streamHasOutput && self.loadingTagIndex) {
|
||
[self.tagListView setLoading:NO atIndex:self.loadingTagIndex.integerValue];
|
||
self.loadingTagIndex = nil;
|
||
self.loadingTagTitle = nil;
|
||
}
|
||
BOOL shouldShowError = (error != nil);
|
||
if (shouldShowError) {
|
||
[KBHUD showInfo:error.localizedDescription ?: KBLocalized(@"拉取失败")];
|
||
}
|
||
if (self.streamOverlay) {
|
||
[self.streamOverlay finish];
|
||
}
|
||
self.eventSourceSplitPrefix = nil;
|
||
self.eventSourceDidReceiveDone = NO;
|
||
}
|
||
|
||
- (BOOL)kb_handleBizErrorIfNeeded:(NSDictionary *)payload {
|
||
NSInteger code = KBBizCodeFromJSONObject(payload);
|
||
if (code == NSNotFound || code == KBBizCodeSuccess) {
|
||
return NO;
|
||
}
|
||
BOOL needSubscriptionGuide = (code == KBBizCodeQuotaExhausted);
|
||
NSString *msg = KBBizMessageFromJSONObject(payload);
|
||
if (msg.length == 0) {
|
||
msg = KBLocalized(@"拉取失败");
|
||
}
|
||
NSError *bizError = [NSError errorWithDomain:@"KBStreamBizError"
|
||
code:code
|
||
userInfo:@{NSLocalizedDescriptionKey: msg}];
|
||
[self kb_finishEventSourceWithError:bizError];
|
||
if (needSubscriptionGuide) {
|
||
[self kb_requestSubscriptionGuide];
|
||
}
|
||
return YES;
|
||
}
|
||
|
||
- (void)kb_requestSubscriptionGuide {
|
||
if ([self.delegate respondsToSelector:@selector(functionViewDidRequestSubscription:)]) {
|
||
[self.delegate functionViewDidRequestSubscription:self];
|
||
}
|
||
}
|
||
|
||
#pragma mark - Event Parsing
|
||
|
||
- (NSString *)kb_normalizedLLMChunkString:(id)dataValue {
|
||
if (![dataValue isKindOfClass:[NSString class]]) { return @""; }
|
||
NSString *text = (NSString *)dataValue;
|
||
|
||
// 1. 处理上一个包遗留的 <SPLIT> 前缀(比如 "<SP" + "LIT>")
|
||
if (self.eventSourceSplitPrefix.length > 0) {
|
||
text = [self.eventSourceSplitPrefix stringByAppendingString:text ?: @""];
|
||
self.eventSourceSplitPrefix = nil;
|
||
}
|
||
if (text.length == 0) { return @""; }
|
||
|
||
// 2. 去掉开头多余换行(避免一开始就空一大块)
|
||
while (text.length > 0) {
|
||
unichar c0 = [text characterAtIndex:0];
|
||
if (c0 == '\n' || c0 == '\r') {
|
||
text = [text substringFromIndex:1];
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
if (text.length == 0) { return @""; }
|
||
|
||
// 3. 处理结尾可能是不完整的 "<SPLIT" 之类,先截掉,放到下一个包里拼
|
||
NSString *suffix = [self kb_pendingSplitSuffixForString:text];
|
||
if (suffix.length > 0) {
|
||
self.eventSourceSplitPrefix = suffix;
|
||
text = [text substringToIndex:text.length - suffix.length];
|
||
}
|
||
if (text.length == 0) { return @""; }
|
||
|
||
// 4. 处理完整的 <SPLIT>,变成段落分隔符 \t
|
||
text = [text stringByReplacingOccurrencesOfString:@"<SPLIT>" withString:@"\t"];
|
||
|
||
// 不再做其它替换,不合并 /t、不改行,只把真正内容原样丢给 UI
|
||
return text;
|
||
}
|
||
|
||
|
||
- (NSString *)kb_formattedSearchResultString:(id)dataValue {
|
||
// data 不是数组就直接返回空串
|
||
if (![dataValue isKindOfClass:[NSArray class]]) { return @""; }
|
||
NSArray *list = (NSArray *)dataValue;
|
||
|
||
NSMutableArray<NSString *> *segments = [NSMutableArray array];
|
||
|
||
[list enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
|
||
NSString *payload = nil;
|
||
|
||
if ([obj isKindOfClass:[NSDictionary class]]) {
|
||
id val = obj[@"payload"];
|
||
if ([val isKindOfClass:[NSString class]]) {
|
||
payload = (NSString *)val;
|
||
}
|
||
} else if ([obj isKindOfClass:[NSString class]]) {
|
||
// 兼容后端直接给字符串数组的情况
|
||
payload = (NSString *)obj;
|
||
}
|
||
|
||
payload = [payload stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||
if (payload.length > 0) {
|
||
// 每一个 payload 就是一段
|
||
[segments addObject:payload];
|
||
}
|
||
}];
|
||
|
||
if (segments.count == 0) { return @""; }
|
||
|
||
// 用 \t 拼起来,KBStreamTextView 会按 \t 拆成多个 label
|
||
NSMutableString *result = [NSMutableString string];
|
||
|
||
[segments enumerateObjectsUsingBlock:^(NSString * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
|
||
// 每段前面加一个 \t,保证是新的一段
|
||
[result appendFormat:@"\t%@", obj];
|
||
}];
|
||
|
||
return result;
|
||
}
|
||
|
||
|
||
- (NSString *)kb_pendingSplitSuffixForString:(NSString *)text {
|
||
static NSString * const token = @"<SPLIT>";
|
||
if (text.length == 0) { return @""; }
|
||
NSUInteger tokenLen = token.length;
|
||
if (tokenLen <= 1) { return @""; }
|
||
NSUInteger maxLen = MIN(tokenLen - 1, text.length);
|
||
for (NSUInteger len = maxLen; len > 0; len--) {
|
||
NSString *suffix = [text substringFromIndex:text.length - len];
|
||
NSString *prefix = [token substringToIndex:len];
|
||
if ([suffix isEqualToString:prefix]) {
|
||
return suffix;
|
||
}
|
||
}
|
||
return @"";
|
||
}
|
||
|
||
#pragma mark - Helpers
|
||
|
||
/// 统一处理需要输出到 KBStreamTextView 的分片:
|
||
/// - 已将 `<SPLIT>` 转换为 `\t` 并去掉多余换行
|
||
/// - 这里仅负责附加到视图与标记首段状态,避免 UI 抖动
|
||
- (void)kb_appendChunkToStreamView:(NSString *)chunk {
|
||
if (chunk.length == 0) return;
|
||
// 第一次有数据才创建 overlay,并取消 cell 上的小菊花
|
||
if (!self.streamOverlay) {
|
||
[self kb_showStreamTextViewIfNeededWithTitle:self.loadingTagTitle ?: @""];
|
||
if (self.loadingTagIndex) {
|
||
[self.tagListView setLoading:NO atIndex:self.loadingTagIndex.integerValue];
|
||
self.loadingTagIndex = nil; self.loadingTagTitle = nil;
|
||
}
|
||
}
|
||
if (!self.streamOverlay) return;
|
||
[self.streamOverlay appendChunk:chunk];
|
||
self.streamHasOutput = YES;
|
||
}
|
||
|
||
/// 统一更新左侧粘贴按钮的展示:
|
||
/// - 有粘贴文本:只显示文字,不再展示左侧图标;
|
||
/// - 无粘贴文本:恢复原始图标 + 占位文案。
|
||
- (void)kb_updatePasteButtonWithDisplayText:(NSString * _Nullable)text {
|
||
if (text.length > 0) {
|
||
NSString *displayText = text;
|
||
if (displayText.length > 30) {
|
||
displayText = [[displayText substringToIndex:30] stringByAppendingString:@"…"];
|
||
}
|
||
[self.pasteView.pasBtn setImage:nil forState:UIControlStateNormal];
|
||
[self.pasteView.pasBtn setTitle:displayText forState:UIControlStateNormal];
|
||
} else {
|
||
UIImage *img = [UIImage imageNamed:@"kb_zt_icon"];
|
||
[self.pasteView.pasBtn setImage:img forState:UIControlStateNormal];
|
||
[self.pasteView.pasBtn setTitle:KBLocalized(@" Paste Ta's Words") forState:UIControlStateNormal];
|
||
}
|
||
}
|
||
|
||
#pragma mark - KBFunctionTagListViewDelegate
|
||
|
||
- (void)tagListView:(KBFunctionTagListView *)view didSelectIndex:(NSInteger)index title:(NSString *)title {
|
||
// 1) 先判断权限:未开启“完全访问”则走引导逻辑
|
||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||
// 未开启完全访问:保持原有引导路径
|
||
[KBHUD showInfo:KBLocalized(@"处理中…")];
|
||
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self];
|
||
return;
|
||
}
|
||
|
||
// 2) 权限没问题,再判断是否登录:未登录 -> 直接拉起主 App,由主 App 负责完成登录
|
||
if (!KBAuthManager.shared.isLoggedIn) {
|
||
|
||
|
||
UIInputViewController *ivc = KBFindInputViewController(self);
|
||
|
||
NSString *schemeStr = [NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
|
||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:ivc.view];
|
||
return;
|
||
// if (!ivc) return;
|
||
// NSString *encodedTitle = [title stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]] ?: @"";
|
||
// NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=functionView&index=%ld&title=%@", KB_UL_LOGIN, (long)index, encodedTitle]];
|
||
// if (!ul) return;
|
||
// // 发起 UL,不依赖 ok 结果
|
||
// dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||
// [ivc.extensionContext openURL:ul completionHandler:^(__unused BOOL ok) {}];
|
||
// });
|
||
// // 双路兜底:500ms 内未收到主 App 确认,则回退到自定义 Scheme(通过宿主 UIApplication 打开)
|
||
// self.kb_ulHandledFlag = NO;
|
||
// NSUInteger token = ++self.kb_ulSeq;
|
||
// dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||
// if (token != self.kb_ulSeq) return; // 已有新请求覆盖
|
||
// if (self.kb_ulHandledFlag) return; // 主 App 已确认处理
|
||
// NSURL *scheme = [NSURL URLWithString:[NSString stringWithFormat:@"%@://login?src=functionView&index=%ld&title=%@", KB_APP_SCHEME, (long)index, encodedTitle]];
|
||
// if (!scheme) return;
|
||
// UIResponder *start = ivc.view ?: (UIResponder *)self;
|
||
// // 让键盘失去焦点
|
||
// [ivc dismissKeyboard];
|
||
// BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:start];
|
||
// if (!ok) {
|
||
// [KBHUD showInfo:KBLocalized(@"请切换到主App完成登录")];
|
||
// }else{
|
||
//
|
||
// }
|
||
// });
|
||
// return;
|
||
}
|
||
BOOL hasPasteText = ![self.pasteView.pasBtn.currentTitle isEqualToString:KBLocalized(@" Paste Ta's Words")];
|
||
// BOOL hasPasteText = (self.pasteView.pasBtn.imageView.image == nil);
|
||
if (!hasPasteText) {
|
||
[KBHUD showInfo:KBLocalized(@"Please copy the text first")];
|
||
return;
|
||
}
|
||
NSString *copyTitle = self.pasteView.pasBtn.currentTitle;
|
||
// 3) 已登录:开始业务逻辑(展示加载并拉取流式内容)
|
||
[self.tagListView setLoading:YES atIndex:index];
|
||
self.loadingTagIndex = @(index);
|
||
self.loadingTagTitle = title ?: @"";
|
||
[self kb_startNetworkStreamingWithSeed:copyTitle];
|
||
return;
|
||
}
|
||
|
||
// Darwin 回调:主 App 已处理 UL
|
||
static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo) {
|
||
KBFunctionView *self_ = (__bridge KBFunctionView *)observer;
|
||
if (!self_) return;
|
||
dispatch_async(dispatch_get_main_queue(), ^{ self_.kb_ulHandledFlag = YES; });
|
||
}
|
||
|
||
// 用户点击功能标签:优先 UL 拉起主App,失败再 Scheme;两次都失败则提示开启完全访问。
|
||
// 若已开启“完全访问”,则直接在键盘侧创建 KBStreamTextView,并在其右上角提供删除按钮关闭。
|
||
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
|
||
// 权限全部打开(键盘已启用 + 完全访问)。在扩展进程中仅需判断“完全访问”。
|
||
if ([[KBFullAccessManager shared] hasFullAccess]) {
|
||
KBTagItemModel *selModel = self.modelArray[indexPath.item];
|
||
[self kb_showStreamTextViewIfNeededWithTitle:selModel.characterName];
|
||
return;
|
||
}
|
||
|
||
[KBHUD showInfo:KBLocalized(@"处理中…")];
|
||
|
||
UIInputViewController *ivc = KBFindInputViewController(self);
|
||
if (!ivc) return;
|
||
|
||
NSString *title = self.modelArray[indexPath.item].characterName;
|
||
NSString *encodedTitle = [title stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]] ?: @"";
|
||
|
||
NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=functionView&index=%ld&title=%@", KB_UL_LOGIN, (long)indexPath.item, encodedTitle]];
|
||
if (!ul) return;
|
||
|
||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||
// 先尝试通过 extensionContext 打开 UL
|
||
[ivc.extensionContext openURL:ul completionHandler:^(BOOL ok) {
|
||
if (ok) {
|
||
return;
|
||
}
|
||
// UL 失败时,再通过宿主 UIApplication + 自定义 Scheme 兜底
|
||
NSURL *scheme = [NSURL URLWithString:[NSString stringWithFormat:@"%@@//login?src=functionView&index=%ld&title=%@", KB_APP_SCHEME, (long)indexPath.item, encodedTitle]];
|
||
UIResponder *start = ivc.view ?: (UIResponder *)self;
|
||
BOOL ok2 = [KBHostAppLauncher openHostAppURL:scheme fromResponder:start];
|
||
if (!ok2) {
|
||
// 两条路都失败:大概率未开完全访问或宿主拦截。统一交由 Manager 引导。
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self];
|
||
});
|
||
}
|
||
}];
|
||
});
|
||
}
|
||
|
||
|
||
#pragma mark - Button Actions
|
||
|
||
- (void)onTapPaste {
|
||
// 用户点击“粘贴”时才读取剪贴板:
|
||
// - iOS16+ 会在跨 App 首次读取时自动弹出系统权限弹窗;
|
||
// - iOS15 及以下不会弹窗,直接返回内容;
|
||
// 注意:不要在非用户触发的时机主动读取(如 viewDidLoad),否则会造成“立刻弹窗”的体验。
|
||
// 权限全部打开(键盘已启用 + 完全访问)。在扩展进程中仅需判断“完全访问”。
|
||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||
// 未开启完全访问:保持原有引导路径
|
||
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self];
|
||
return;
|
||
}
|
||
UIPasteboard *pb = [UIPasteboard generalPasteboard];
|
||
NSString *text = pb.string; // 读取纯文本(可能触发系统粘贴权限弹窗)
|
||
|
||
if (text.length <= 0) {
|
||
// 无可用文本或用户拒绝了粘贴权限;保持占位文案不变
|
||
NSLog(@"粘贴板无可用文本或未授权粘贴");
|
||
[KBHUD showInfo:KBLocalized(@"Clipboard is empty")];
|
||
return;
|
||
}
|
||
|
||
// 1)把内容真正「粘贴」到当前输入框
|
||
// UIInputViewController *ivc = KBFindInputViewController(self);
|
||
// if (ivc) {
|
||
// id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||
// [proxy insertText:text];
|
||
// }
|
||
|
||
// 2)顺便把最新的剪贴板内容展示在左侧粘贴区按钮上,便于用户确认
|
||
[self kb_updatePasteButtonWithDisplayText:text];
|
||
}
|
||
|
||
#pragma mark - 自动监控剪贴板(复制即弹窗)
|
||
// 说明:
|
||
// - 仅在视图可见时开启轮询,避免不必要的读取与打扰;
|
||
// - 当检测到 changeCount 变化,立即读 pasteboard.string:
|
||
// * iOS16+:此处会触发系统“是否允许粘贴”弹窗;
|
||
// * iOS15:不会弹窗,直接得到文本;
|
||
// - 无论允许/拒绝,都把本次 changeCount 记为已处理,避免一直重复询问。
|
||
|
||
- (void)startPasteboardMonitor {
|
||
// 未开启“完全访问”时不做自动读取,避免宿主/系统拒绝并刷错误日志
|
||
if (![[KBFullAccessManager shared] hasFullAccess]) return;
|
||
if (self.pasteboardTimer) return;
|
||
KBWeakSelf
|
||
self.pasteboardTimer = [NSTimer scheduledTimerWithTimeInterval:0.5 repeats:YES block:^(NSTimer * _Nonnull timer) {
|
||
__strong typeof(weakSelf) self = weakSelf; if (!self) return;
|
||
UIPasteboard *pb = [UIPasteboard generalPasteboard];
|
||
NSInteger cc = pb.changeCount;
|
||
if (cc <= self.lastHandledPBCount) return; // 没有新复制
|
||
self.lastHandledPBCount = cc; // 标记已处理,避免重复
|
||
|
||
// 实际读取触发系统弹窗(iOS16+)
|
||
NSString *text = pb.string;
|
||
// 有文字 -> 仅展示文字;无文字/非文本 -> 恢复图标 + 原占位文案
|
||
[self kb_updatePasteButtonWithDisplayText:text];
|
||
}];
|
||
}
|
||
|
||
- (void)stopPasteboardMonitor {
|
||
[self.pasteboardTimer invalidate];
|
||
self.pasteboardTimer = nil;
|
||
}
|
||
|
||
- (void)didMoveToWindow {
|
||
[super didMoveToWindow];
|
||
[self kb_refreshPasteboardMonitor];
|
||
}
|
||
|
||
- (void)setHidden:(BOOL)hidden {
|
||
BOOL wasHidden = self.isHidden;
|
||
[super setHidden:hidden];
|
||
if (wasHidden != hidden) {
|
||
[self kb_refreshPasteboardMonitor];
|
||
}
|
||
}
|
||
|
||
// 根据窗口可见性与完全访问状态,统一启停粘贴板监控
|
||
- (void)kb_refreshPasteboardMonitor {
|
||
BOOL visible = (self.window && !self.isHidden);
|
||
if (visible && [[KBFullAccessManager shared] hasFullAccess]) {
|
||
[self startPasteboardMonitor];
|
||
} else {
|
||
[self stopPasteboardMonitor];
|
||
}
|
||
}
|
||
|
||
- (void)kb_fullAccessChanged {
|
||
dispatch_async(dispatch_get_main_queue(), ^{ [self kb_refreshPasteboardMonitor]; });
|
||
}
|
||
- (void)onTapDelete {
|
||
NSLog(@"点击:删除");
|
||
UIInputViewController *ivc = KBFindInputViewController(self);
|
||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||
[proxy deleteBackward];
|
||
}
|
||
- (void)onTapClear {
|
||
NSLog(@"点击:清空");
|
||
[self.backspaceHandler performClearAction];
|
||
}
|
||
- (void)onTapSend {
|
||
NSLog(@"点击:发送");
|
||
// 发送:插入换行。大多数聊天类 App 会把回车视为“发送”
|
||
UIInputViewController *ivc = KBFindInputViewController(self);
|
||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||
[proxy insertText:@"\n"];
|
||
}
|
||
|
||
#pragma mark - Lazy
|
||
|
||
- (KBFunctionBarView *)barViewInternal {
|
||
if (!_barViewInternal) {
|
||
_barViewInternal = [[KBFunctionBarView alloc] init];
|
||
_barViewInternal.delegate = self; // 顶部功能Bar事件下发到本View
|
||
}
|
||
return _barViewInternal;
|
||
}
|
||
|
||
#pragma mark - KBFunctionBarViewDelegate
|
||
|
||
- (void)functionBarView:(KBFunctionBarView *)bar didTapLeftAtIndex:(NSInteger)index {
|
||
// 将事件继续透传给上层(如键盘控制器),用于切换界面或其它业务
|
||
if ([self.delegate respondsToSelector:@selector(functionView:didTapToolActionAtIndex:)]) {
|
||
[self.delegate functionView:self didTapToolActionAtIndex:index];
|
||
}
|
||
}
|
||
|
||
- (void)functionBarView:(KBFunctionBarView *)bar didTapRightAtIndex:(NSInteger)index {
|
||
// 右侧按钮点击,如收藏/宫格等,按需继续向外抛出(这里暂不定义单独协议方法,可在此内部处理或扩展)
|
||
if ([self.delegate respondsToSelector:@selector(functionView:didRightTapToolActionAtIndex:)]) {
|
||
[self.delegate functionView:self didRightTapToolActionAtIndex:index];
|
||
}
|
||
}
|
||
|
||
- (KBFunctionPasteView *)pasteViewInternal {
|
||
if (!_pasteViewInternal) {
|
||
_pasteViewInternal = [[KBFunctionPasteView alloc] init];
|
||
}
|
||
return _pasteViewInternal;
|
||
}
|
||
|
||
- (KBFunctionTagListView *)tagListView {
|
||
if (!_tagListView) {
|
||
_tagListView = [[KBFunctionTagListView alloc] init];
|
||
_tagListView.delegate = (id)self;
|
||
}
|
||
return _tagListView;
|
||
}
|
||
|
||
- (UIView *)rightButtonContainer {
|
||
if (!_rightButtonContainer) {
|
||
_rightButtonContainer = [[UIView alloc] init];
|
||
_rightButtonContainer.backgroundColor = [UIColor clearColor];
|
||
}
|
||
return _rightButtonContainer;
|
||
}
|
||
|
||
- (UIButton *)buildRightButtonWithTitle:(NSString *)title color:(UIColor *)color {
|
||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||
btn.backgroundColor = color;
|
||
btn.layer.cornerRadius = 8.0;
|
||
btn.layer.masksToBounds = YES;
|
||
btn.titleLabel.font = [KBFont medium:13];
|
||
[btn setTitle:title forState:UIControlStateNormal];
|
||
[btn setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
||
return btn;
|
||
}
|
||
|
||
- (UIButton *)pasteButtonInternal {
|
||
if (!_pasteButtonInternal) {
|
||
_pasteButtonInternal = [self buildRightButtonWithTitle:KBLocalized(@"Paste") color:[UIColor colorWithRed:0.13 green:0.73 blue:0.60 alpha:1.0]];
|
||
[_pasteButtonInternal addTarget:self action:@selector(onTapPaste) forControlEvents:UIControlEventTouchUpInside];
|
||
}
|
||
return _pasteButtonInternal;
|
||
}
|
||
|
||
- (UIButton *)deleteButtonInternal {
|
||
if (!_deleteButtonInternal) {
|
||
_deleteButtonInternal = [UIButton buttonWithType:UIButtonTypeCustom];
|
||
_deleteButtonInternal.backgroundColor = [UIColor colorWithHex:0xB9BDC8];
|
||
_deleteButtonInternal.layer.cornerRadius = 8.0;
|
||
_deleteButtonInternal.layer.masksToBounds = YES;
|
||
[_deleteButtonInternal setImage:[UIImage imageNamed:@"kb_del_icon"] forState:UIControlStateNormal];
|
||
|
||
[_deleteButtonInternal addTarget:self action:@selector(onTapDelete) forControlEvents:UIControlEventTouchUpInside];
|
||
[self.backspaceHandler bindDeleteButton:_deleteButtonInternal];
|
||
}
|
||
return _deleteButtonInternal;
|
||
}
|
||
|
||
- (UIButton *)clearButtonInternal {
|
||
if (!_clearButtonInternal) {
|
||
_clearButtonInternal = [UIButton buttonWithType:UIButtonTypeCustom];
|
||
_clearButtonInternal.backgroundColor = [UIColor colorWithHex:0xB9BDC8];
|
||
_clearButtonInternal.layer.cornerRadius = 8.0;
|
||
_clearButtonInternal.layer.masksToBounds = YES;
|
||
_clearButtonInternal.titleLabel.font = [KBFont medium:13];
|
||
[_clearButtonInternal setTitle:KBLocalized(@"Clear") forState:UIControlStateNormal];
|
||
[_clearButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
|
||
[_clearButtonInternal addTarget:self action:@selector(onTapClear) forControlEvents:UIControlEventTouchUpInside];
|
||
}
|
||
return _clearButtonInternal;
|
||
}
|
||
|
||
- (UIButton *)sendButtonInternal {
|
||
if (!_sendButtonInternal) {
|
||
_sendButtonInternal = [self buildRightButtonWithTitle:KBLocalized(@"Send") color:[UIColor colorWithHex:0x02BEAC]];
|
||
[_sendButtonInternal addTarget:self action:@selector(onTapSend) forControlEvents:UIControlEventTouchUpInside];
|
||
}
|
||
return _sendButtonInternal;
|
||
}
|
||
|
||
|
||
#pragma mark - Expose
|
||
|
||
- (UICollectionView *)collectionView { return self.tagListView.collectionView; }
|
||
//- (NSArray<NSString *> *)items { return self.itemsInternal; }
|
||
- (KBFunctionBarView *)barView { return self.barViewInternal; }
|
||
- (KBFunctionPasteView *)pasteView { return self.pasteViewInternal; }
|
||
- (UIButton *)pasteButton { return self.pasteButtonInternal; }
|
||
- (UIButton *)deleteButton { return self.deleteButtonInternal; }
|
||
- (UIButton *)clearButton { return self.clearButtonInternal; }
|
||
- (UIButton *)sendButton { return self.sendButtonInternal; }
|
||
|
||
#pragma mark - Find Owner Controller
|
||
|
||
// 工具方法已提取到 KBResponderUtils.h
|
||
|
||
@end
|