diff --git a/CustomKeyboard/View/KBFunctionView.m b/CustomKeyboard/View/KBFunctionView.m index 1996698..2bb0cb9 100644 --- a/CustomKeyboard/View/KBFunctionView.m +++ b/CustomKeyboard/View/KBFunctionView.m @@ -20,7 +20,7 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId"; -@interface KBFunctionView () +@interface KBFunctionView () // UI @property (nonatomic, strong) KBFunctionBarView *barViewInternal; @property (nonatomic, strong) KBFunctionPasteView *pasteViewInternal; @@ -41,6 +41,16 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId"; @property (nonatomic, copy, nullable) NSString *streamMockSource; // 连续文本源(包含 \t 作为分段) @property (nonatomic, assign) NSInteger streamMockCursor; // 当前光标位置 +// 网络流式:使用 NSURLSessionDataDelegate 逐块接收 +@property (nonatomic, strong, nullable) NSURLSession *streamSession; +@property (nonatomic, strong, nullable) NSURLSessionDataTask *streamTask; +@property (nonatomic, strong, nullable) NSMutableData *streamDataBuffer; +@property (nonatomic, assign) NSInteger streamDeliveredCharCount; // 已成功解码并输出的字符数 +@property (nonatomic, assign) NSStringEncoding streamTextEncoding; // 从响应头推断;默认 UTF-8 +@property (nonatomic, assign) BOOL streamIsSSE; // 是否为 SSE +@property (nonatomic, strong, nullable) NSMutableString *sseTextBuffer; // SSE 文本缓冲 +@property (nonatomic, assign) NSInteger streamDecodedByteCount; // 已从 buffer 解码到 sseTextBuffer 的字节数 + // Data @property (nonatomic, strong) NSArray *itemsInternal; @@ -79,6 +89,7 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId"; - (void)dealloc { [self stopPasteboardMonitor]; + [self kb_stopNetworkStreaming]; [self kb_stopMockStreaming]; [[NSNotificationCenter defaultCenter] removeObserver:self]; } @@ -234,12 +245,13 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId"; }]; self.streamDeleteButton = del; - // 启动模拟“后端流”数据,用于查看 KBStreamTextView 展示效果 - [self kb_startMockStreamingWithSeed:title]; + // 优先拉取后端测试数据(GET);失败则回落到本地演示 + [self kb_startNetworkStreamingWithSeed:title fallbackToMock:YES]; } - (void)kb_onTapStreamDelete { // 关闭并销毁流式视图,恢复标签列表 + [self kb_stopNetworkStreaming]; [self kb_stopMockStreaming]; [self.streamDeleteButton removeFromSuperview]; self.streamDeleteButton = nil; @@ -298,6 +310,204 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId"; self.streamMockCursor = 0; } +#pragma mark - Network Streaming (GET) + +// 后端测试地址(内网)。若被 ATS 拦截,请在扩展 target 的 Info.plist 中允许 HTTP 或添加例外域。 +static NSString * const kKBStreamDemoURL = @"http://192.168.1.144:7529/api/demo/talk"; + +- (void)kb_startNetworkStreamingWithSeed:(NSString *)seedTitle fallbackToMock:(BOOL)fallback { + [self kb_stopNetworkStreaming]; + if (![[KBFullAccessManager shared] hasFullAccess]) { if (fallback) [self kb_startMockStreamingWithSeed:seedTitle]; return; } + + NSURL *url = [NSURL URLWithString:kKBStreamDemoURL]; + if (!url) { if (fallback) [self kb_startMockStreamingWithSeed:seedTitle]; return; } + + NSURLSessionConfiguration *cfg = [NSURLSessionConfiguration defaultSessionConfiguration]; + cfg.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData; + cfg.timeoutIntervalForRequest = 30; + cfg.timeoutIntervalForResource = 60; + self.streamDataBuffer = [NSMutableData data]; + self.streamDeliveredCharCount = 0; + self.streamTextEncoding = NSUTF8StringEncoding; // 默认 UTF-8 + + // 主队列回调,便于直接驱动 UI + self.streamSession = [NSURLSession sessionWithConfiguration:cfg delegate:self delegateQueue:[NSOperationQueue mainQueue]]; + NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:url]; + req.HTTPMethod = @"GET"; + // 重要:禁用压缩,声明 SSE/流偏好,避免中间层缓存/缓冲 + [req setValue:@"identity" forHTTPHeaderField:@"Accept-Encoding"]; // 禁用 gzip,利于分块直达 + [req setValue:@"text/event-stream" forHTTPHeaderField:@"Accept"]; // 若服务端支持 SSE,可避免代理缓冲 + [req setValue:@"no-cache" forHTTPHeaderField:@"Cache-Control"]; + [req setValue:@"keep-alive" forHTTPHeaderField:@"Connection"]; + + [KBHUD showInfo:@"拉取中…"]; + self.streamTask = [self.streamSession dataTaskWithRequest:req]; + [self.streamTask resume]; +} + +- (void)kb_stopNetworkStreaming { + [self.streamTask cancel]; + self.streamTask = nil; + [self.streamSession invalidateAndCancel]; + self.streamSession = nil; + self.streamDataBuffer = nil; + self.streamDeliveredCharCount = 0; +} + +// 计算数据中以 UTF-8 编码可完整解码的前缀字节长度,避免切断多字节字符 +static NSUInteger kb_validUTF8PrefixLen(NSData *data) { + const unsigned char *bytes = (const unsigned char *)data.bytes; + NSUInteger n = data.length; + if (n == 0) return 0; + // 从末尾回溯,找到最后一个潜在的字符起始位置 + NSInteger i = (NSInteger)n - 1; + while (i >= 0 && (bytes[i] & 0xC0) == 0x80) { // 10xxxxxx 续字节 + i--; + } + if (i < 0) return 0; // 全是续字节,等下次 + // 根据起始字节计算应有的长度 + unsigned char b = bytes[i]; + NSUInteger expected = 1; + if ((b & 0x80) == 0x00) expected = 1; // 0xxxxxxx + else if ((b & 0xE0) == 0xC0) expected = 2; // 110xxxxx + else if ((b & 0xF0) == 0xE0) expected = 3; // 1110xxxx + else if ((b & 0xF8) == 0xF0) expected = 4; // 11110xxx + else return (NSUInteger)i; // 非法起始,截到 i 之前 + NSUInteger remain = n - (NSUInteger)i; + if (remain >= expected) return n; // 末尾完整 + return (NSUInteger)i; // 末尾不完整,截到起始位之前 +} + +#pragma mark - NSURLSessionDataDelegate + +- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler { + // 初始化解析状态:识别 SSE 与编码 + self.streamIsSSE = NO; + self.streamTextEncoding = NSUTF8StringEncoding; + if ([response isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse *r = (NSHTTPURLResponse *)response; + NSString *ct = r.allHeaderFields[@"Content-Type"] ?: r.allHeaderFields[@"content-type"]; + if ([ct isKindOfClass:[NSString class]]) { + NSString *lower = [ct lowercaseString]; + if ([lower containsString:@"text/event-stream"]) self.streamIsSSE = YES; + NSRange pos = [lower rangeOfString:@"charset="]; + if (pos.location != NSNotFound) { + NSString *charset = [[lower substringFromIndex:pos.location + pos.length] componentsSeparatedByString:@";"][0]; + if ([charset containsString:@"utf-8"] || [charset containsString:@"utf8"]) { + self.streamTextEncoding = NSUTF8StringEncoding; + } else if ([charset containsString:@"iso-8859-1"] || [charset containsString:@"latin1"]) { + self.streamTextEncoding = NSISOLatin1StringEncoding; + } + } + } + } + if (self.streamIsSSE) { + self.sseTextBuffer = [NSMutableString string]; + self.streamDecodedByteCount = 0; + } + if (completionHandler) completionHandler(NSURLSessionResponseAllow); +} + +- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data { + if (!self.streamTextView) return; + if (data.length == 0) return; + + if (!self.streamDataBuffer) { self.streamDataBuffer = [NSMutableData data]; } + [self.streamDataBuffer appendData:data]; + + // 可解码前缀长度:UTF-8 需处理半字符;其它编码直接尝试全部 + NSUInteger validLen = (self.streamTextEncoding == NSUTF8StringEncoding) + ? kb_validUTF8PrefixLen(self.streamDataBuffer) + : self.streamDataBuffer.length; + if (validLen == 0) return; + + if (self.streamIsSSE) { + // 仅解码新增字节到文本缓冲 + if (validLen > (NSUInteger)self.streamDecodedByteCount) { + NSRange rng = NSMakeRange((NSUInteger)self.streamDecodedByteCount, validLen - (NSUInteger)self.streamDecodedByteCount); + NSString *piece = [[NSString alloc] initWithBytes:(const char *)self.streamDataBuffer.bytes + rng.location length:rng.length encoding:self.streamTextEncoding]; + if (piece.length > 0) { + [self.sseTextBuffer appendString:piece]; + self.streamDecodedByteCount = (NSInteger)validLen; + } + } + // 规范换行,按 SSE 事件分隔符 \n\n 切分 + if (self.sseTextBuffer.length > 0) { + NSString *normalized = [self.sseTextBuffer stringByReplacingOccurrencesOfString:@"\r\n" withString:@"\n"]; + [self.sseTextBuffer setString:normalized]; + while (1) { + NSRange sep = [self.sseTextBuffer rangeOfString:@"\n\n"]; // 完整事件 + if (sep.location == NSNotFound) break; + NSString *event = [self.sseTextBuffer substringToIndex:sep.location]; + [self.sseTextBuffer deleteCharactersInRange:NSMakeRange(0, sep.location + sep.length)]; + // 提取 data: 行组成 payload + NSArray *lines = [event componentsSeparatedByString:@"\n"]; + NSMutableString *payload = [NSMutableString string]; + for (NSString *ln in lines) { + if ([ln hasPrefix:@"data:"]) { + NSString *v = [ln substringFromIndex:5]; + if (v.length > 0 && [v hasPrefix:@" "]) v = [v substringFromIndex:1]; + [payload appendString:v ?: @""]; + [payload appendString:@"\n"]; // 多 data 行合并 + } + } + if (payload.length > 0 && [payload hasSuffix:@"\n"]) { + [payload deleteCharactersInRange:NSMakeRange(payload.length - 1, 1)]; + } + if (payload.length > 0) { + NSString *clean = [[payload copy] stringByReplacingOccurrencesOfString:@"/t" withString:@"\t"]; + [self.streamTextView appendStreamText:clean]; + } + } + } + return; + } + + // 非 SSE:直接以字符串增量输出 + NSString *prefix = [[NSString alloc] initWithBytes:self.streamDataBuffer.bytes length:validLen encoding:self.streamTextEncoding]; + if (!prefix) return; + if (self.streamDeliveredCharCount < (NSInteger)prefix.length) { + NSString *delta = [prefix substringFromIndex:self.streamDeliveredCharCount]; + self.streamDeliveredCharCount = prefix.length; + delta = [delta stringByReplacingOccurrencesOfString:@"/t" withString:@"\t"]; // 支持后端 /t 作为分段 + [self.streamTextView appendStreamText:delta]; + } +} + +- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error { + if (error) { + NSLog(@"[Stream] 网络失败: %@", error); + if (self.streamTextView) { + [KBHUD showInfo:@"拉取失败,使用本地演示"]; // 降级提示 + [self kb_startMockStreamingWithSeed:nil]; + } + } else { + // 结束:若为 SSE,尝试处理最后一个不以 \n\n 结尾的事件 + if (self.streamIsSSE && self.sseTextBuffer.length > 0) { + NSString *normalized = [self.sseTextBuffer stringByReplacingOccurrencesOfString:@"\r\n" withString:@"\n"]; + NSArray *lines = [normalized componentsSeparatedByString:@"\n"]; + NSMutableString *payload = [NSMutableString string]; + for (NSString *ln in lines) { + if ([ln hasPrefix:@"data:"]) { + NSString *v = [ln substringFromIndex:5]; + if (v.length > 0 && [v hasPrefix:@" "]) v = [v substringFromIndex:1]; + [payload appendString:v ?: @""]; + [payload appendString:@"\n"]; + } + } + if (payload.length > 0 && [payload hasSuffix:@"\n"]) { + [payload deleteCharactersInRange:NSMakeRange(payload.length - 1, 1)]; + } + if (payload.length > 0) { + NSString *clean = [[payload copy] stringByReplacingOccurrencesOfString:@"/t" withString:@"\t"]; + [self.streamTextView appendStreamText:clean]; + } + } + [self.streamTextView finishStreaming]; + } + [self kb_stopNetworkStreaming]; +} + // 用户点击功能标签:优先 UL 拉起主App,失败再 Scheme;两次都失败则提示开启完全访问。 // 若已开启“完全访问”,则直接在键盘侧创建 KBStreamTextView,并在其右上角提供删除按钮关闭。 - (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {