From e39104c43174af0dc3b4a71ed338206924b82637 Mon Sep 17 00:00:00 2001 From: CodeST <694468528@qq.com> Date: Tue, 9 Dec 2025 16:12:54 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=84=E7=90=86=E6=B5=81=E9=80=9D=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=20=E5=A4=84=E7=90=86=E7=B2=98=E8=B4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/Function/KBStreamOverlayView.m | 77 ++++++++++++++++++- CustomKeyboard/View/KBFunctionView.m | 44 +++++++---- .../Localization/en.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + 4 files changed, 106 insertions(+), 17 deletions(-) diff --git a/CustomKeyboard/View/Function/KBStreamOverlayView.m b/CustomKeyboard/View/Function/KBStreamOverlayView.m index 03f7c7c..89ce6f8 100644 --- a/CustomKeyboard/View/Function/KBStreamOverlayView.m +++ b/CustomKeyboard/View/Function/KBStreamOverlayView.m @@ -9,6 +9,13 @@ @interface KBStreamOverlayView () @property (nonatomic, strong) KBStreamTextView *textViewInternal; @property (nonatomic, strong) UIButton *closeButton; + +// 新增:流式打字机用的缓冲 & 定时器 +@property (nonatomic, strong) NSMutableString *pendingText; +@property (nonatomic, strong) NSTimer *streamTimer; +@property (nonatomic, assign) NSInteger charsPerTick; // 每次“跳”几个字符 +// 新增:标记 SSE 已经收到 done +@property (nonatomic, assign) BOOL streamDidReceiveDone; @end @implementation KBStreamOverlayView @@ -34,6 +41,10 @@ make.height.mas_equalTo(28); make.width.mas_greaterThanOrEqualTo(56); }]; + _pendingText = [NSMutableString string]; + _charsPerTick = 2; // 每次输出 1~2 个字符,可以自己调 + _streamDidReceiveDone = NO; + } return self; } @@ -67,13 +78,73 @@ - (void)appendChunk:(NSString *)text { if (text.length == 0) return; - [self.textViewInternal appendStreamText:text]; + if (![NSThread isMainThread]) { + __weak typeof(self) weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf appendChunk:text]; + }); + return; + } + + [self.pendingText appendString:text]; + [self startStreamTimerIfNeeded]; } -- (void)finish { - [self.textViewInternal finishStreaming]; +- (void)startStreamTimerIfNeeded { + if (self.streamTimer) return; + self.streamTimer = [NSTimer scheduledTimerWithTimeInterval:0.02 + target:self + selector:@selector(handleStreamTick) + userInfo:nil + repeats:YES]; } +- (void)stopStreamTimer { + [self.streamTimer invalidate]; + self.streamTimer = nil; +} + +- (void)handleStreamTick { + if (self.pendingText.length == 0) { + // 如果已经收到 done 并且没有待播内容了,这里再真正 finish + if (self.streamDidReceiveDone) { + [self.textViewInternal finishStreaming]; + } + [self stopStreamTimer]; + return; + } + + NSInteger len = MIN(self.charsPerTick, self.pendingText.length); + NSString *slice = [self.pendingText substringToIndex:len]; + [self.pendingText deleteCharactersInRange:NSMakeRange(0, len)]; + + [self.textViewInternal appendStreamText:slice]; +} + + +- (void)finish { + if (![NSThread isMainThread]) { + __weak typeof(self) weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf finish]; + }); + return; + } + + // 只标记“流已结束” + self.streamDidReceiveDone = YES; + + // 如果此时已经没有待播内容了,可以立即结束 + if (self.pendingText.length == 0) { + [self stopStreamTimer]; + [self.textViewInternal finishStreaming]; + } + // 否则等 handleStreamTick 把 pendingText 慢慢播完, + // 它看到 pendingText == 0 且 streamDidReceiveDone == YES 时会自动调用 finishStreaming +} + + + - (KBStreamTextView *)textView { return self.textViewInternal; } @end diff --git a/CustomKeyboard/View/KBFunctionView.m b/CustomKeyboard/View/KBFunctionView.m index a386239..a1cf788 100644 --- a/CustomKeyboard/View/KBFunctionView.m +++ b/CustomKeyboard/View/KBFunctionView.m @@ -290,9 +290,10 @@ } 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": @"dolore ea cillum" + @"message": message }; NSLog(@"[KBFunction] request payload: %@", payload); NSError *bodyError = nil; @@ -399,13 +400,15 @@ - (NSString *)kb_normalizedLLMChunkString:(id)dataValue { if (![dataValue isKindOfClass:[NSString class]]) { return @""; } NSString *text = (NSString *)dataValue; + + // 1. 处理上一个包遗留的 前缀(比如 "") if (self.eventSourceSplitPrefix.length > 0) { text = [self.eventSourceSplitPrefix stringByAppendingString:text ?: @""]; self.eventSourceSplitPrefix = nil; } - text = [text stringByReplacingOccurrencesOfString:@"\r\n\t" withString:@"\t"]; - text = [text stringByReplacingOccurrencesOfString:@"\n\t" withString:@"\t"]; - text = [text stringByReplacingOccurrencesOfString:@"\r\t" withString:@"\t"]; + if (text.length == 0) { return @""; } + + // 2. 去掉开头多余换行(避免一开始就空一大块) while (text.length > 0) { unichar c0 = [text characterAtIndex:0]; if (c0 == '\n' || c0 == '\r') { @@ -414,17 +417,24 @@ } break; } - text = [text stringByReplacingOccurrencesOfString:@"/t" withString:@"\t"]; - text = [text stringByReplacingOccurrencesOfString:@"" withString:@"\t"]; + if (text.length == 0) { return @""; } + + // 3. 处理结尾可能是不完整的 " 0) { self.eventSourceSplitPrefix = suffix; text = [text substringToIndex:text.length - suffix.length]; } - text = [text stringByReplacingOccurrencesOfString:@"\t " withString:@"\t"]; + if (text.length == 0) { return @""; } + + // 4. 处理完整的 ,变成段落分隔符 \t + text = [text stringByReplacingOccurrencesOfString:@"" withString:@"\t"]; + + // 不再做其它替换,不合并 /t、不改行,只把真正内容原样丢给 UI return text; } + - (NSString *)kb_formattedSearchResultString:(id)dataValue { // data 不是数组就直接返回空串 if (![dataValue isKindOfClass:[NSArray class]]) { return @""; } @@ -570,12 +580,18 @@ // }); // 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:self.loadingTagTitle]; + [self kb_startNetworkStreamingWithSeed:copyTitle]; return; } @@ -652,11 +668,11 @@ static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer, C } // 1)把内容真正「粘贴」到当前输入框 - UIInputViewController *ivc = KBFindInputViewController(self); - if (ivc) { - id proxy = ivc.textDocumentProxy; - [proxy insertText:text]; - } +// UIInputViewController *ivc = KBFindInputViewController(self); +// if (ivc) { +// id proxy = ivc.textDocumentProxy; +// [proxy insertText:text]; +// } // 2)顺便把最新的剪贴板内容展示在左侧粘贴区按钮上,便于用户确认 [self kb_updatePasteButtonWithDisplayText:text]; diff --git a/Shared/Localization/en.lproj/Localizable.strings b/Shared/Localization/en.lproj/Localizable.strings index 43fe8c3..e7f156b 100644 --- a/Shared/Localization/en.lproj/Localizable.strings +++ b/Shared/Localization/en.lproj/Localizable.strings @@ -262,3 +262,4 @@ "Change The Nickname" = "Change Nickname"; "Please Enter The Modified Nickname" = "Please enter the new nickname"; "Save" = "Save"; +"Please copy the text first" = "Please copy the text first"; diff --git a/Shared/Localization/zh-Hans.lproj/Localizable.strings b/Shared/Localization/zh-Hans.lproj/Localizable.strings index 679c50a..a6affd1 100644 --- a/Shared/Localization/zh-Hans.lproj/Localizable.strings +++ b/Shared/Localization/zh-Hans.lproj/Localizable.strings @@ -259,3 +259,4 @@ "Change The Nickname" = "修改名称"; "Please Enter The Modified Nickname" = "请输入修改后的昵称"; "Save" = "保存"; +"Please copy the text first" = "请先复制文本";