This commit is contained in:
2025-11-12 14:18:56 +08:00
parent 39d8b3d547
commit afc44cb471
6 changed files with 684 additions and 186 deletions

View File

@@ -17,10 +17,11 @@
#import "KBSkinManager.h"
#import "KBURLOpenBridge.h" // openURL:
#import "KBStreamTextView.h" //
#import "KBStreamFetcher.h" //
static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
@interface KBFunctionView () <UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, KBFunctionBarViewDelegate, NSURLSessionDataDelegate>
@interface KBFunctionView () <UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, KBFunctionBarViewDelegate>
// UI
@property (nonatomic, strong) KBFunctionBarView *barViewInternal;
@property (nonatomic, strong) KBFunctionPasteView *pasteViewInternal;
@@ -41,15 +42,9 @@ 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
//
@property (nonatomic, strong, nullable) KBStreamFetcher *streamFetcher;
@property (nonatomic, assign) BOOL streamHasOutput; // \t
// Data
@property (nonatomic, strong) NSArray<NSString *> *itemsInternal;
@@ -322,190 +317,62 @@ static NSString * const kKBStreamDemoURL = @"http://192.168.1.144:7529/api/demo/
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];
self.streamHasOutput = NO; //
__weak typeof(self) weakSelf = self;
KBStreamFetcher *fetcher = [KBStreamFetcher fetcherWithURL:url];
// /t->\t \tfetcher
fetcher.disableCompression = YES;
fetcher.acceptEventStream = NO; // SSE
fetcher.treatSlashTAsTab = NO;
fetcher.trimLeadingTabOnce = NO;
fetcher.onChunk = ^(NSString *chunk) {
__strong typeof(weakSelf) self = weakSelf; if (!self) return;
[self kb_appendChunkToStreamView:chunk];
};
fetcher.onFinish = ^(NSError * _Nullable error) {
__strong typeof(weakSelf) self = weakSelf; if (!self) return;
if (error && fallback && !self.streamHasOutput) {
[KBHUD showInfo:@"拉取失败,使用本地演示"]; //
[self kb_startMockStreamingWithSeed:nil];
} else {
[self.streamTextView finishStreaming];
}
};
self.streamFetcher = fetcher;
[self.streamFetcher start];
}
- (void)kb_stopNetworkStreaming {
[self.streamTask cancel];
self.streamTask = nil;
[self.streamSession invalidateAndCancel];
self.streamSession = nil;
self.streamDataBuffer = nil;
self.streamDeliveredCharCount = 0;
[self.streamFetcher cancel];
self.streamFetcher = nil;
self.streamHasOutput = NO;
}
// 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 - Helpers
#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;
}
}
/// KBStreamTextView
/// - "/t" "\t"
/// - "\t"
/// -
- (void)kb_appendChunkToStreamView:(NSString *)chunk {
if (chunk.length == 0 || !self.streamTextView) return;
NSString *text = [chunk stringByReplacingOccurrencesOfString:@"/t" withString:@"\t"];
if (!self.streamHasOutput) {
NSUInteger i = 0; //
while (i < text.length) {
unichar c = [text characterAtIndex:i];
if (c == ' ' || c == '\r' || c == '\n') { i++; continue; }
break;
}
if (i < text.length && [text characterAtIndex:i] == '\t') {
NSMutableString *m = [text mutableCopy];
[m deleteCharactersInRange:NSMakeRange(i, 1)];
text = m;
}
}
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];
if (text.length == 0) return;
[self.streamTextView appendStreamText:text];
self.streamHasOutput = YES;
}
// UL App Scheme访