1
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
|
||||
static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
|
||||
@interface KBFunctionView () <UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, KBFunctionBarViewDelegate>
|
||||
@interface KBFunctionView () <UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, KBFunctionBarViewDelegate, NSURLSessionDataDelegate>
|
||||
// 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<NSString *> *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<NSString *> *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<NSString *> *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 {
|
||||
|
||||
Reference in New Issue
Block a user