diff --git a/CustomKeyboard/Network/KBStreamFetcher.m b/CustomKeyboard/Network/KBStreamFetcher.m index 32517d0..2dd5605 100644 --- a/CustomKeyboard/Network/KBStreamFetcher.m +++ b/CustomKeyboard/Network/KBStreamFetcher.m @@ -14,6 +14,7 @@ @property (nonatomic, assign) NSInteger decodedPrefixBytes; // 已解码并写入 sseTextBuffer 的字节数(SSE) @property (nonatomic, assign) NSInteger deliveredCharCount; // 已回传的字符数(非 SSE,用于做增量) @property (nonatomic, assign) BOOL hasEmitted; // 是否已经输出过正文(用于“首段删 1 个 \t”) +@property (nonatomic, assign) BOOL lastChunkEndedWithTab; // 上一个已输出分片是否以 "\t" 结尾(用于跨分片去除“\t 后空格”) @property (nonatomic, strong) NSMutableArray *pendingQueue; // 待回调的分片(节流输出) @property (nonatomic, strong) NSTimer *flushTimer; // 定时从队列取出一条回调 @property (nonatomic, strong, nullable) NSError *finishError; // 结束时的错误(需要等队列清空再回调) @@ -57,7 +58,7 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { _buffer = [NSMutableData data]; _sseTextBuffer = [NSMutableString string]; _pendingQueue = [NSMutableArray array]; - _flushInterval = 0.10; + _flushInterval = 0.1; _splitLargeDeltasOnWhitespace = YES; } return self; @@ -90,6 +91,7 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { self.decodedPrefixBytes = 0; self.deliveredCharCount = 0; self.hasEmitted = NO; + self.lastChunkEndedWithTab = NO; [self.pendingQueue removeAllObjects]; [self.flushTimer invalidate]; self.flushTimer = nil; self.finishError = nil; @@ -108,6 +110,7 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { self.decodedPrefixBytes = 0; self.deliveredCharCount = 0; self.hasEmitted = NO; + self.lastChunkEndedWithTab = NO; [self.pendingQueue removeAllObjects]; [self.flushTimer invalidate]; self.flushTimer = nil; self.finishError = nil; @@ -251,26 +254,37 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) { - (void)emitChunk:(NSString *)rawText { if (rawText.length == 0) return; NSString *text = rawText; + // 1) 统一处理 “/t” -> “\t” if (self.treatSlashTAsTab) { text = [text stringByReplacingOccurrencesOfString:@"/t" withString:@"\t"]; } + // 2) 仅在整体首段:去掉一个起始的 "\t",以及其后紧邻的一个空格(若存在) if (!self.hasEmitted && self.trimLeadingTabOnce) { - // 跳过前导空白,只删除“一个”起始的 \t - 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 (text.length > 0 && [text characterAtIndex:0] == '\t') { + NSUInteger start = 1; + if (start < text.length && [text characterAtIndex:start] == ' ') start++; + text = [text substringFromIndex:start]; } } - if (text.length == 0) return; + // 3) 从第二段开始:去掉每个段首的一个空格(即 “\t ” -> “\t”),跨分片也处理 + if (text.length > 0) { + // 跨分片:若上个分片以 \t 结尾,本分片起始的一个或多个空格去掉一个 + if (self.lastChunkEndedWithTab) { + NSUInteger j = 0; + while (j < text.length && [text characterAtIndex:j] == ' ') { j++; } + if (j > 0) { + text = [text substringFromIndex:1]; // 仅去一个空格 + } + } + // 同一分片内:将 “\t ” 规范化为 “\t”(仅去一个空格) + text = [text stringByReplacingOccurrencesOfString:@"\t " withString:@"\t"]; + } + if (text.length == 0) { self.lastChunkEndedWithTab = NO; return; } if (self.onChunk) dispatch_async(dispatch_get_main_queue(), ^{ self.onChunk(text); }); self.hasEmitted = YES; + // 记录末尾是否为分段分隔符 \t(用于跨分片处理) + unichar lastc = [text characterAtIndex:text.length - 1]; + self.lastChunkEndedWithTab = (lastc == '\t'); } #pragma mark - Queue/Flush diff --git a/CustomKeyboard/View/KBFunctionView.m b/CustomKeyboard/View/KBFunctionView.m index 43dc252..98ee941 100644 --- a/CustomKeyboard/View/KBFunctionView.m +++ b/CustomKeyboard/View/KBFunctionView.m @@ -312,7 +312,7 @@ static NSString * const kKBStreamDemoURL = @"http://192.168.1.144:7529/api/demo/ - (void)kb_startNetworkStreamingWithSeed:(NSString *)seedTitle fallbackToMock:(BOOL)fallback { [self kb_stopNetworkStreaming]; - if (![[KBFullAccessManager shared] hasFullAccess]) { if (fallback) [self kb_startMockStreamingWithSeed:seedTitle]; return; } + 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; } @@ -323,8 +323,10 @@ static NSString * const kKBStreamDemoURL = @"http://192.168.1.144:7529/api/demo/ // 由本类统一做 /t->\t 与首段去 \t,fetcher 只负责增量与协议解析 fetcher.disableCompression = YES; fetcher.acceptEventStream = NO; // 响应头若是 SSE 仍会自动解析 - fetcher.treatSlashTAsTab = NO; - fetcher.trimLeadingTabOnce = NO; + // 将 \t 与首段去 \t 的处理下沉到 Fetcher,避免 UI 抖动 + fetcher.treatSlashTAsTab = YES; + fetcher.trimLeadingTabOnce = YES; + fetcher.flushInterval = 0.05; // 更接近“立刻显示”的节奏 fetcher.onChunk = ^(NSString *chunk) { __strong typeof(weakSelf) self = weakSelf; if (!self) return; [self kb_appendChunkToStreamView:chunk]; @@ -351,27 +353,11 @@ static NSString * const kKBStreamDemoURL = @"http://192.168.1.144:7529/api/demo/ #pragma mark - Helpers /// 统一处理需要输出到 KBStreamTextView 的分片: -/// - 将 "/t" 转为真正的制表符 "\t"; -/// - 若这是首段输出且文本起始(允许有前导空白)紧跟一个 "\t",只删除这一个; -/// - 非空才追加到视图,并标记已输出。 +/// - 目前网络层(KBStreamFetcher)已做 “/t->\t、首段去一个 \t、段间去一个空格” +/// - 这里仅负责附加到视图与标记首段状态,避免 UI 抖动 - (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 (text.length == 0) return; - [self.streamTextView appendStreamText:text]; + [self.streamTextView appendStreamText:chunk]; self.streamHasOutput = YES; } diff --git a/CustomKeyboard/View/KBStreamTextView.m b/CustomKeyboard/View/KBStreamTextView.m index 31df995..caf3f6c 100644 --- a/CustomKeyboard/View/KBStreamTextView.m +++ b/CustomKeyboard/View/KBStreamTextView.m @@ -62,6 +62,20 @@ [self addSubview:_scrollView]; } +#pragma mark - Helpers + +// 仅去掉末尾空白/换行(保留前导空白,避免“段落在完成时向左跳动”) +static inline NSString *KBTrimRight(NSString *s) { + if (s.length == 0) return s; + NSCharacterSet *set = [NSCharacterSet whitespaceAndNewlineCharacterSet]; + NSInteger end = (NSInteger)s.length - 1; + while (end >= 0 && [set characterIsMember:[s characterAtIndex:(NSUInteger)end]]) { + end--; + } + if (end < 0) return @""; + return [s substringToIndex:(NSUInteger)end + 1]; +} + #pragma mark - Public API // 边输边见:实时更新当前 label 文本,遇到分隔符就新建下一个 label @@ -98,14 +112,11 @@ self.labels.lastObject.text = self.buffer; // 不裁剪,保留实时输入 [self layoutLabelsForCurrentWidth]; - // 2) 处理每一个分隔符:完成当前段(可选裁剪)并新建空标签,然后填入下一段的内容 + // 2) 处理每一个分隔符:完成当前段(仅裁剪“末尾空白”)并新建空标签,然后填入下一段的内容 for (NSUInteger i = 1; i < parts.count; i++) { // a) 完成当前段:对外观进行最终裁剪(若开启) UILabel *current = self.labels.lastObject; - if (self.shouldTrimSegments) { - NSString *trimmed = [current.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; - current.text = trimmed; - } + if (self.shouldTrimSegments) { current.text = KBTrimRight(current.text ?: @""); } [self layoutLabelsForCurrentWidth]; // b) 新建一个空标签,代表下一个段(即刻创建,哪怕下一段当前为空) @@ -133,9 +144,7 @@ #pragma mark - Layout Helpers - (void)addLabelForText:(NSString *)text { - if (self.shouldTrimSegments) { - text = [text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; - } + if (self.shouldTrimSegments) { text = KBTrimRight(text ?: @""); } // 创建一个可换行、可点击的 UILabel UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero]; label.numberOfLines = 0; @@ -195,7 +204,7 @@ UILabel *current = self.labels.lastObject; if (!current) { return; } if (self.shouldTrimSegments) { - NSString *trimmed = [current.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + NSString *trimmed = KBTrimRight(current.text ?: @""); current.text = trimmed; self.buffer = trimmed; }