diff --git a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/openai.rs b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/openai.rs index a5a86ef8..113498e3 100644 --- a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/openai.rs +++ b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/openai.rs @@ -1,17 +1,356 @@ use super::stream_stats::StreamStats; use crate::types::openai::OpenAISSEData; -use crate::types::unified::UnifiedResponse; +use crate::types::unified::{UnifiedResponse, UnifiedTokenUsage}; use anyhow::{anyhow, Result}; use eventsource_stream::Eventsource; use futures::StreamExt; use log::{error, trace, warn}; use reqwest::Response; use serde_json::Value; +use std::collections::HashSet; +use std::mem; use std::time::Duration; use tokio::sync::mpsc; use tokio::time::timeout; const OPENAI_CHAT_COMPLETION_CHUNK_OBJECT: &str = "chat.completion.chunk"; +const INLINE_THINK_OPEN_TAG: &str = ""; +const INLINE_THINK_CLOSE_TAG: &str = ""; + +#[derive(Debug, Default)] +struct OpenAIToolCallFilter { + seen_tool_call_ids: HashSet, +} + +impl OpenAIToolCallFilter { + fn normalize_response(&mut self, mut response: UnifiedResponse) -> Option { + let Some(tool_call) = response.tool_call.as_ref() else { + return Some(response); + }; + + let tool_id = tool_call.id.as_ref().filter(|value| !value.is_empty()).cloned(); + let has_name = tool_call + .name + .as_ref() + .is_some_and(|value| !value.is_empty()); + let has_arguments = tool_call + .arguments + .as_ref() + .is_some_and(|value| !value.is_empty()); + + if let Some(tool_id) = tool_id { + let seen_before = self.seen_tool_call_ids.contains(&tool_id); + self.seen_tool_call_ids.insert(tool_id); + + // OpenAI-compatible providers may emit a trailing chunk that only repeats an + // already-seen tool id after the arguments have completed. It carries no new + // information and should not reopen a fresh tool-call buffer downstream. + if seen_before && !has_name && !has_arguments { + response.tool_call = None; + return Self::keep_if_non_empty(response); + } + } else if !has_name && !has_arguments { + response.tool_call = None; + return Self::keep_if_non_empty(response); + } + + Some(response) + } + + fn keep_if_non_empty(response: UnifiedResponse) -> Option { + if response.text.is_some() + || response.reasoning_content.is_some() + || response.thinking_signature.is_some() + || response.tool_call.is_some() + || response.usage.is_some() + || response.finish_reason.is_some() + || response.provider_metadata.is_some() + { + Some(response) + } else { + None + } + } +} + +#[derive(Debug, Default)] +struct DeferredResponseMeta { + usage: Option, + finish_reason: Option, + provider_metadata: Option, +} + +impl DeferredResponseMeta { + fn from_response(response: &mut UnifiedResponse) -> Self { + Self { + usage: response.usage.take(), + finish_reason: response.finish_reason.take(), + provider_metadata: response.provider_metadata.take(), + } + } + + fn merge(&mut self, other: Self) { + if other.usage.is_some() { + self.usage = other.usage; + } + if other.finish_reason.is_some() { + self.finish_reason = other.finish_reason; + } + if other.provider_metadata.is_some() { + self.provider_metadata = other.provider_metadata; + } + } + + fn apply_to(self, response: &mut UnifiedResponse) { + if response.usage.is_none() { + response.usage = self.usage; + } + if response.finish_reason.is_none() { + response.finish_reason = self.finish_reason; + } + if response.provider_metadata.is_none() { + response.provider_metadata = self.provider_metadata; + } + } + + fn is_empty(&self) -> bool { + self.usage.is_none() && self.finish_reason.is_none() && self.provider_metadata.is_none() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InlineThinkActivation { + Unknown, + Enabled, + Disabled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InlineThinkMode { + Text, + Thinking, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum InlineThinkSegment { + Text(String), + Thinking(String), +} + +#[derive(Debug)] +struct OpenAIInlineThinkParser { + enabled: bool, + activation: InlineThinkActivation, + mode: InlineThinkMode, + pending_tail: String, + initial_probe: String, + deferred_meta: DeferredResponseMeta, +} + +impl OpenAIInlineThinkParser { + fn new(enabled: bool) -> Self { + Self { + enabled, + activation: InlineThinkActivation::Unknown, + mode: InlineThinkMode::Text, + pending_tail: String::new(), + initial_probe: String::new(), + deferred_meta: DeferredResponseMeta::default(), + } + } + + fn normalize_response(&mut self, mut response: UnifiedResponse) -> Vec { + if !self.enabled { + return vec![response]; + } + + let Some(text) = response.text.take() else { + return vec![response]; + }; + + // Respect providers that already emit native reasoning chunks. + if response.reasoning_content.is_some() + || response.tool_call.is_some() + || response.thinking_signature.is_some() + { + response.text = Some(text); + return vec![response]; + } + + let current_meta = DeferredResponseMeta::from_response(&mut response); + let segments = match self.activation { + InlineThinkActivation::Unknown => self.consume_unknown_text(text), + InlineThinkActivation::Enabled => self.parse_enabled_text(text), + InlineThinkActivation::Disabled => vec![InlineThinkSegment::Text(text)], + }; + + self.attach_meta_to_segments(segments, current_meta) + } + + fn flush(&mut self) -> Vec { + if !self.enabled { + return Vec::new(); + } + + let segments = match self.activation { + InlineThinkActivation::Unknown => { + let pending = mem::take(&mut self.initial_probe); + if pending.is_empty() { + Vec::new() + } else { + vec![InlineThinkSegment::Text(pending)] + } + } + InlineThinkActivation::Enabled => { + let pending = mem::take(&mut self.pending_tail); + if pending.is_empty() { + Vec::new() + } else if self.mode == InlineThinkMode::Thinking { + vec![InlineThinkSegment::Thinking(pending)] + } else { + vec![InlineThinkSegment::Text(pending)] + } + } + InlineThinkActivation::Disabled => Vec::new(), + }; + + self.attach_meta_to_segments(segments, DeferredResponseMeta::default()) + } + + fn consume_unknown_text(&mut self, text: String) -> Vec { + self.initial_probe.push_str(&text); + + let trimmed = self.initial_probe.trim_start_matches(char::is_whitespace); + if trimmed.is_empty() { + return Vec::new(); + } + + if trimmed.starts_with(INLINE_THINK_OPEN_TAG) { + self.activation = InlineThinkActivation::Enabled; + let buffered = mem::take(&mut self.initial_probe); + return self.parse_enabled_text(buffered); + } + + if INLINE_THINK_OPEN_TAG.starts_with(trimmed) { + return Vec::new(); + } + + self.activation = InlineThinkActivation::Disabled; + vec![InlineThinkSegment::Text(mem::take(&mut self.initial_probe))] + } + + fn parse_enabled_text(&mut self, text: String) -> Vec { + let mut data = mem::take(&mut self.pending_tail); + data.push_str(&text); + + let mut segments = Vec::new(); + + loop { + let marker = match self.mode { + InlineThinkMode::Text => INLINE_THINK_OPEN_TAG, + InlineThinkMode::Thinking => INLINE_THINK_CLOSE_TAG, + }; + + if let Some(marker_idx) = data.find(marker) { + let before_marker = data[..marker_idx].to_string(); + self.push_segment(&mut segments, before_marker); + + data = data[marker_idx + marker.len()..].to_string(); + self.mode = match self.mode { + InlineThinkMode::Text => InlineThinkMode::Thinking, + InlineThinkMode::Thinking => InlineThinkMode::Text, + }; + continue; + } + + let tail_len = longest_suffix_prefix_len(&data, marker); + let flush_len = data.len() - tail_len; + let ready = data[..flush_len].to_string(); + self.push_segment(&mut segments, ready); + self.pending_tail = data[flush_len..].to_string(); + break; + } + + segments + } + + fn push_segment(&self, segments: &mut Vec, content: String) { + if content.is_empty() { + return; + } + + match self.mode { + InlineThinkMode::Text => segments.push(InlineThinkSegment::Text(content)), + InlineThinkMode::Thinking => segments.push(InlineThinkSegment::Thinking(content)), + } + } + + fn attach_meta_to_segments( + &mut self, + segments: Vec, + current_meta: DeferredResponseMeta, + ) -> Vec { + let mut merged_meta = mem::take(&mut self.deferred_meta); + merged_meta.merge(current_meta); + + let mut responses: Vec = segments + .into_iter() + .map(|segment| match segment { + InlineThinkSegment::Text(text) => UnifiedResponse { + text: Some(text), + ..Default::default() + }, + InlineThinkSegment::Thinking(reasoning_content) => UnifiedResponse { + reasoning_content: Some(reasoning_content), + ..Default::default() + }, + }) + .collect(); + + if let Some(last_response) = responses.last_mut() { + merged_meta.apply_to(last_response); + } else if !merged_meta.is_empty() { + self.deferred_meta = merged_meta; + } + + responses + } +} + +#[derive(Debug)] +struct OpenAIResponseNormalizer { + tool_call_filter: OpenAIToolCallFilter, + inline_think_parser: OpenAIInlineThinkParser, +} + +impl OpenAIResponseNormalizer { + fn new(inline_think_in_text: bool) -> Self { + Self { + tool_call_filter: OpenAIToolCallFilter::default(), + inline_think_parser: OpenAIInlineThinkParser::new(inline_think_in_text), + } + } + + fn normalize_response(&mut self, response: UnifiedResponse) -> Vec { + let Some(response) = self.tool_call_filter.normalize_response(response) else { + return Vec::new(); + }; + + self.inline_think_parser.normalize_response(response) + } + + fn flush(&mut self) -> Vec { + self.inline_think_parser.flush() + } +} + +fn longest_suffix_prefix_len(value: &str, marker: &str) -> usize { + let max_len = value.len().min(marker.len().saturating_sub(1)); + (1..=max_len) + .rev() + .find(|&len| value.ends_with(&marker[..len])) + .unwrap_or(0) +} fn is_valid_chat_completion_chunk_weak(event_json: &Value) -> bool { matches!( @@ -41,6 +380,7 @@ pub async fn handle_openai_stream( response: Response, tx_event: mpsc::UnboundedSender>, tx_raw_sse: Option>, + inline_think_in_text: bool, ) { let mut stream = response.bytes_stream().eventsource(); let idle_timeout = Duration::from_secs(600); @@ -50,6 +390,7 @@ pub async fn handle_openai_stream( // without sending `[DONE]`, so we treat `Ok(None)` as a normal termination // when a finish_reason has already been seen. let mut received_finish_reason = false; + let mut normalizer = OpenAIResponseNormalizer::new(inline_think_in_text); loop { let sse_event = timeout(idle_timeout, stream.next()).await; @@ -57,6 +398,10 @@ pub async fn handle_openai_stream( Ok(Some(Ok(sse))) => sse, Ok(None) => { if received_finish_reason { + for normalized_response in normalizer.flush() { + stats.record_unified_response(&normalized_response); + let _ = tx_event.send(Ok(normalized_response)); + } stats.log_summary("stream_closed_after_finish_reason"); return; } @@ -89,6 +434,10 @@ pub async fn handle_openai_stream( let _ = tx.send(raw.clone()); } if raw == "[DONE]" { + for normalized_response in normalizer.flush() { + stats.record_unified_response(&normalized_response); + let _ = tx_event.send(Ok(normalized_response)); + } stats.increment("marker:done"); stats.log_summary("done_marker_received"); return; @@ -173,18 +522,30 @@ pub async fn handle_openai_stream( } for unified_response in unified_responses { - if unified_response.finish_reason.is_some() { - received_finish_reason = true; + let normalized_responses = normalizer.normalize_response(unified_response); + if normalized_responses.is_empty() { + continue; + } + + for normalized_response in normalized_responses { + if normalized_response.finish_reason.is_some() { + received_finish_reason = true; + } + stats.record_unified_response(&normalized_response); + let _ = tx_event.send(Ok(normalized_response)); } - stats.record_unified_response(&unified_response); - let _ = tx_event.send(Ok(unified_response)); } } } #[cfg(test)] mod tests { - use super::{extract_sse_api_error_message, is_valid_chat_completion_chunk_weak}; + use super::{ + extract_sse_api_error_message, is_valid_chat_completion_chunk_weak, + longest_suffix_prefix_len, InlineThinkActivation, InlineThinkMode, OpenAIInlineThinkParser, + OpenAIToolCallFilter, + }; + use crate::types::unified::{UnifiedResponse, UnifiedToolCall}; #[test] fn weak_filter_accepts_chat_completion_chunk() { @@ -241,4 +602,169 @@ mod tests { }); assert!(extract_sse_api_error_message(&event).is_none()); } + + #[test] + fn drops_redundant_empty_tool_call_after_same_id_was_seen() { + let mut filter = OpenAIToolCallFilter::default(); + + let first = UnifiedResponse { + tool_call: Some(UnifiedToolCall { + id: Some("call_1".to_string()), + name: Some("read_file".to_string()), + arguments: Some("{\"path\":\"a.txt\"}".to_string()), + }), + ..Default::default() + }; + let trailing_empty = UnifiedResponse { + tool_call: Some(UnifiedToolCall { + id: Some("call_1".to_string()), + name: None, + arguments: Some(String::new()), + }), + ..Default::default() + }; + + assert!(filter.normalize_response(first).is_some()); + assert!(filter.normalize_response(trailing_empty).is_none()); + } + + #[test] + fn keeps_finish_reason_when_redundant_tool_call_is_stripped() { + let mut filter = OpenAIToolCallFilter::default(); + + let first = UnifiedResponse { + tool_call: Some(UnifiedToolCall { + id: Some("call_1".to_string()), + name: Some("read_file".to_string()), + arguments: Some("{\"path\":\"a.txt\"}".to_string()), + }), + ..Default::default() + }; + let trailing_empty = UnifiedResponse { + tool_call: Some(UnifiedToolCall { + id: Some("call_1".to_string()), + name: None, + arguments: None, + }), + finish_reason: Some("tool_calls".to_string()), + ..Default::default() + }; + + assert!(filter.normalize_response(first).is_some()); + let normalized = filter + .normalize_response(trailing_empty) + .expect("finish_reason should be preserved"); + assert!(normalized.tool_call.is_none()); + assert_eq!(normalized.finish_reason.as_deref(), Some("tool_calls")); + } + + #[test] + fn longest_suffix_prefix_len_detects_partial_tag_boundary() { + assert_eq!(longest_suffix_prefix_len(""), 4); + assert_eq!(longest_suffix_prefix_len("answer", ""), 0); + } + + #[test] + fn inline_think_parser_streams_thinking_and_text_per_chunk() { + let mut parser = OpenAIInlineThinkParser::new(true); + + let chunk1 = parser.normalize_response(UnifiedResponse { + text: Some("abc".to_string()), + ..Default::default() + }); + let chunk2 = parser.normalize_response(UnifiedResponse { + text: Some("defghi".to_string()), + ..Default::default() + }); + + assert_eq!(chunk1.len(), 1); + assert_eq!(chunk1[0].reasoning_content.as_deref(), Some("abc")); + assert_eq!(chunk2.len(), 2); + assert_eq!(chunk2[0].reasoning_content.as_deref(), Some("def")); + assert_eq!(chunk2[1].text.as_deref(), Some("ghi")); + } + + #[test] + fn inline_think_parser_handles_split_opening_tag() { + let mut parser = OpenAIInlineThinkParser::new(true); + + let first = parser.normalize_response(UnifiedResponse { + text: Some("hello".to_string()), + ..Default::default() + }); + + assert!(first.is_empty()); + assert_eq!(second.len(), 1); + assert_eq!(second[0].reasoning_content.as_deref(), Some("hello")); + } + + #[test] + fn inline_think_parser_disables_when_first_text_is_not_think_tag() { + let mut parser = OpenAIInlineThinkParser::new(true); + + let first = parser.normalize_response(UnifiedResponse { + text: Some("hello literal".to_string()), + ..Default::default() + }); + let second = parser.normalize_response(UnifiedResponse { + text: Some(" world".to_string()), + ..Default::default() + }); + + assert_eq!(first.len(), 1); + assert_eq!(first[0].text.as_deref(), Some("hello literal")); + assert_eq!(second.len(), 1); + assert_eq!(second[0].text.as_deref(), Some(" world")); + assert_eq!(parser.activation, InlineThinkActivation::Disabled); + assert_eq!(parser.mode, InlineThinkMode::Text); + } + + #[test] + fn inline_think_parser_preserves_finish_reason_on_last_segment() { + let mut parser = OpenAIInlineThinkParser::new(true); + + let responses = parser.normalize_response(UnifiedResponse { + text: Some("abcdone".to_string()), + finish_reason: Some("stop".to_string()), + ..Default::default() + }); + + assert_eq!(responses.len(), 2); + assert_eq!(responses[0].reasoning_content.as_deref(), Some("abc")); + assert_eq!(responses[1].text.as_deref(), Some("done")); + assert_eq!(responses[1].finish_reason.as_deref(), Some("stop")); + } + + #[test] + fn inline_think_parser_flushes_unclosed_thinking_at_stream_end() { + let mut parser = OpenAIInlineThinkParser::new(true); + + let first = parser.normalize_response(UnifiedResponse { + text: Some("abc".to_string()), + ..Default::default() + }); + let flushed = parser.flush(); + + assert_eq!(first.len(), 1); + assert_eq!(first[0].reasoning_content.as_deref(), Some("abc")); + assert!(flushed.is_empty()); + } + + #[test] + fn inline_think_parser_passthrough_when_feature_disabled() { + let mut parser = OpenAIInlineThinkParser::new(false); + + let responses = parser.normalize_response(UnifiedResponse { + text: Some("abcdone".to_string()), + ..Default::default() + }); + + assert_eq!(responses.len(), 1); + assert_eq!(responses[0].text.as_deref(), Some("abcdone")); + assert!(responses[0].reasoning_content.is_none()); + } } diff --git a/src/crates/core/src/infrastructure/ai/client.rs b/src/crates/core/src/infrastructure/ai/client.rs index b284fc01..3d025be2 100644 --- a/src/crates/core/src/infrastructure/ai/client.rs +++ b/src/crates/core/src/infrastructure/ai/client.rs @@ -1365,7 +1365,12 @@ impl AIClient { let (tx, rx) = mpsc::unbounded_channel(); let (tx_raw, rx_raw) = mpsc::unbounded_channel(); - tokio::spawn(handle_openai_stream(response, tx, Some(tx_raw))); + tokio::spawn(handle_openai_stream( + response, + tx, + Some(tx_raw), + self.config.inline_think_in_text, + )); return Ok(StreamResponse { stream: Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(rx)), @@ -2092,6 +2097,7 @@ mod tests { top_p: None, enable_thinking_process: false, support_preserved_thinking: false, + inline_think_in_text: false, custom_headers: None, custom_headers_mode: None, skip_ssl_verify: false, @@ -2115,6 +2121,7 @@ mod tests { top_p: None, enable_thinking_process: false, support_preserved_thinking: false, + inline_think_in_text: false, custom_headers: None, custom_headers_mode: None, skip_ssl_verify: false, @@ -2143,6 +2150,7 @@ mod tests { top_p: None, enable_thinking_process: false, support_preserved_thinking: false, + inline_think_in_text: false, custom_headers: None, custom_headers_mode: None, skip_ssl_verify: false, @@ -2172,6 +2180,7 @@ mod tests { top_p: Some(0.8), enable_thinking_process: true, support_preserved_thinking: true, + inline_think_in_text: false, custom_headers: None, custom_headers_mode: None, skip_ssl_verify: false, @@ -2250,6 +2259,7 @@ mod tests { top_p: None, enable_thinking_process: false, support_preserved_thinking: true, + inline_think_in_text: false, custom_headers: None, custom_headers_mode: None, skip_ssl_verify: false, diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index cd08164a..7520471e 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -785,6 +785,11 @@ pub struct AIModelConfig { #[serde(default)] pub support_preserved_thinking: bool, + /// Whether to parse OpenAI-compatible text chunks containing `...` into + /// streaming reasoning content. + #[serde(default)] + pub inline_think_in_text: bool, + /// Custom HTTP request headers. #[serde(default)] pub custom_headers: Option>, @@ -1196,6 +1201,7 @@ impl Default for AIModelConfig { metadata: None, enable_thinking_process: false, support_preserved_thinking: false, + inline_think_in_text: false, custom_headers: None, custom_headers_mode: None, skip_ssl_verify: false, diff --git a/src/crates/core/src/util/types/config.rs b/src/crates/core/src/util/types/config.rs index b90c5393..e8e1b079 100644 --- a/src/crates/core/src/util/types/config.rs +++ b/src/crates/core/src/util/types/config.rs @@ -82,6 +82,7 @@ pub struct AIConfig { pub top_p: Option, pub enable_thinking_process: bool, pub support_preserved_thinking: bool, + pub inline_think_in_text: bool, pub custom_headers: Option>, /// "replace" (default) or "merge" (defaults first, then custom) pub custom_headers_mode: Option, @@ -209,6 +210,7 @@ impl TryFrom for AIConfig { top_p: other.top_p, enable_thinking_process: other.enable_thinking_process, support_preserved_thinking: other.support_preserved_thinking, + inline_think_in_text: other.inline_think_in_text, custom_headers: other.custom_headers, custom_headers_mode: other.custom_headers_mode, skip_ssl_verify: other.skip_ssl_verify, diff --git a/src/web-ui/src/features/onboarding/components/steps/ModelConfigStep.tsx b/src/web-ui/src/features/onboarding/components/steps/ModelConfigStep.tsx index 46ba6d5b..b420affa 100644 --- a/src/web-ui/src/features/onboarding/components/steps/ModelConfigStep.tsx +++ b/src/web-ui/src/features/onboarding/components/steps/ModelConfigStep.tsx @@ -156,6 +156,7 @@ export const ModelConfigStep: React.FC = ({ onSkipForNow } max_tokens: 8192, enable_thinking_process: false, support_preserved_thinking: false, + inline_think_in_text: false, skip_ssl_verify: skipSslVerify, custom_headers: Object.keys(customHeaders).length > 0 ? customHeaders : undefined, custom_headers_mode: Object.keys(customHeaders).length > 0 ? customHeadersMode : undefined, diff --git a/src/web-ui/src/features/onboarding/services/OnboardingService.ts b/src/web-ui/src/features/onboarding/services/OnboardingService.ts index 31aa545f..06680806 100644 --- a/src/web-ui/src/features/onboarding/services/OnboardingService.ts +++ b/src/web-ui/src/features/onboarding/services/OnboardingService.ts @@ -206,6 +206,7 @@ class OnboardingServiceClass { max_tokens: 8192, category: 'general_chat', capabilities: ['text_chat', 'function_calling'], + inline_think_in_text: false, custom_request_body: modelConfig.customRequestBody || undefined, skip_ssl_verify: modelConfig.skipSslVerify || undefined, custom_headers: modelConfig.customHeaders || undefined, diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx index 4cc5192f..a9d683df 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx @@ -30,6 +30,7 @@ export const ExploreGroupRenderer: React.FC = ({ const { exploreGroupStates, onExploreGroupToggle, + onExpandGroup, onCollapseGroup } = useFlowChatContext(); @@ -40,6 +41,7 @@ export const ExploreGroupRenderer: React.FC = ({ isGroupStreaming, isLastGroupInTurn } = data; + const wasStreamingRef = useRef(isGroupStreaming); const { cardRootRef, applyExpandedState, @@ -53,8 +55,39 @@ export const ExploreGroupRenderer: React.FC = ({ ), }); - const isExpanded = exploreGroupStates?.get(groupId) ?? false; + const hasExplicitState = exploreGroupStates?.has(groupId) ?? false; + const explicitExpanded = exploreGroupStates?.get(groupId) ?? false; + const isExpanded = hasExplicitState ? explicitExpanded : isGroupStreaming; const isCollapsed = !isExpanded; + const allowManualToggle = !isGroupStreaming; + + useEffect(() => { + if (isGroupStreaming && !hasExplicitState) { + applyExpandedState(false, true, () => { + onExpandGroup?.(groupId); + }); + wasStreamingRef.current = true; + return; + } + + if (wasStreamingRef.current && !isGroupStreaming && isExpanded) { + applyExpandedState(true, false, () => { + onCollapseGroup?.(groupId); + }, { + reason: 'auto', + }); + } + + wasStreamingRef.current = isGroupStreaming; + }, [ + applyExpandedState, + groupId, + hasExplicitState, + isExpanded, + isGroupStreaming, + onCollapseGroup, + onExpandGroup, + ]); // Auto-scroll to bottom during streaming. useEffect(() => { @@ -105,7 +138,7 @@ export const ExploreGroupRenderer: React.FC = ({ // Build class list. const className = [ 'explore-region', - 'explore-region--collapsible', + allowManualToggle ? 'explore-region--collapsible' : null, isCollapsed ? 'explore-region--collapsed' : 'explore-region--expanded', isGroupStreaming ? 'explore-region--streaming' : null, ].filter(Boolean).join(' '); @@ -115,10 +148,12 @@ export const ExploreGroupRenderer: React.FC = ({ data-tool-card-id={groupId} className={className} > -
- - {displaySummary} -
+ {allowManualToggle && ( +
+ + {displaySummary} +
+ )}
diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx index ef1a5b9c..f6a4b081 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatContext.tsx @@ -37,6 +37,11 @@ export interface FlowChatContextValue { */ onExploreGroupToggle?: (groupId: string) => void; + /** + * Expand the specified explore group. + */ + onExpandGroup?: (groupId: string) => void; + /** * Expand all explore groups within a turn. */ diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 6d506a77..5e9f4cd0 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -51,6 +51,7 @@ export const ModernFlowChatContainer: React.FC = ( const { exploreGroupStates, onExploreGroupToggle: handleExploreGroupToggle, + onExpandGroup: handleExpandGroup, onExpandAllInTurn: handleExpandAllInTurn, onCollapseGroup: handleCollapseGroup, } = useExploreGroupState(virtualItems); @@ -89,6 +90,7 @@ export const ModernFlowChatContainer: React.FC = ( }, exploreGroupStates, onExploreGroupToggle: handleExploreGroupToggle, + onExpandGroup: handleExpandGroup, onExpandAllInTurn: handleExpandAllInTurn, onCollapseGroup: handleCollapseGroup, }), [ @@ -102,6 +104,7 @@ export const ModernFlowChatContainer: React.FC = ( config, exploreGroupStates, handleExploreGroupToggle, + handleExpandGroup, handleExpandAllInTurn, handleCollapseGroup, ]); diff --git a/src/web-ui/src/flow_chat/components/modern/useExploreGroupState.ts b/src/web-ui/src/flow_chat/components/modern/useExploreGroupState.ts index 9db53a8b..398a08b8 100644 --- a/src/web-ui/src/flow_chat/components/modern/useExploreGroupState.ts +++ b/src/web-ui/src/flow_chat/components/modern/useExploreGroupState.ts @@ -14,6 +14,7 @@ interface UseExploreGroupStateResult { */ exploreGroupStates: Map; onExploreGroupToggle: (groupId: string) => void; + onExpandGroup: (groupId: string) => void; onExpandAllInTurn: (turnId: string) => void; onCollapseGroup: (groupId: string) => void; } @@ -32,6 +33,17 @@ export function useExploreGroupState( }); }, []); + const onExpandGroup = useCallback((groupId: string) => { + setExploreGroupStates(prev => { + if (prev.get(groupId) === true) { + return prev; + } + const next = new Map(prev); + next.set(groupId, true); + return next; + }); + }, []); + const onExpandAllInTurn = useCallback((turnId: string) => { const groupIds = virtualItems .filter((item): item is ExploreGroupVirtualItem => ( @@ -57,6 +69,7 @@ export function useExploreGroupState( return { exploreGroupStates, onExploreGroupToggle, + onExpandGroup, onExpandAllInTurn, onCollapseGroup, }; diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx index 5f67bf94..01c73868 100644 --- a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx @@ -407,6 +407,7 @@ const AIModelConfig: React.FC = () => { metadata: config.metadata || {}, enable_thinking_process: config.enable_thinking_process ?? false, support_preserved_thinking: config.support_preserved_thinking ?? false, + inline_think_in_text: config.inline_think_in_text ?? false, reasoning_effort: config.reasoning_effort, custom_headers: config.custom_headers, custom_headers_mode: config.custom_headers_mode, @@ -420,6 +421,7 @@ const AIModelConfig: React.FC = () => { base_url: config.base_url, api_key: config.api_key, model_name: config.model_name, + inline_think_in_text: config.inline_think_in_text ?? false, skip_ssl_verify: config.skip_ssl_verify ?? false, custom_headers_mode: config.custom_headers_mode || null, custom_headers: config.custom_headers || null, @@ -527,7 +529,8 @@ const AIModelConfig: React.FC = () => { category: 'general_chat', capabilities: ['text_chat', 'function_calling'], recommended_for: [], - metadata: {} + metadata: {}, + inline_think_in_text: false, }); setSelectedModelDrafts( configuredProviderModels.length > 0 @@ -563,7 +566,8 @@ const AIModelConfig: React.FC = () => { category: 'general_chat', capabilities: ['text_chat'], recommended_for: [], - metadata: {} + metadata: {}, + inline_think_in_text: false, }); setSelectedModelDrafts([]); setShowAdvancedSettings(false); @@ -597,6 +601,7 @@ const AIModelConfig: React.FC = () => { metadata: config.metadata || {}, enable_thinking_process: config.enable_thinking_process ?? false, support_preserved_thinking: config.support_preserved_thinking ?? false, + inline_think_in_text: config.inline_think_in_text ?? false, reasoning_effort: config.reasoning_effort, custom_headers: config.custom_headers, custom_headers_mode: config.custom_headers_mode, @@ -605,6 +610,7 @@ const AIModelConfig: React.FC = () => { }); setSelectedModelDrafts(createDraftsFromConfigs(configuredProviderModels)); setShowAdvancedSettings( + !!config.inline_think_in_text || !!config.skip_ssl_verify || (!!config.custom_request_body && config.custom_request_body.trim() !== '') || (!!config.custom_headers && Object.keys(config.custom_headers).length > 0) @@ -628,7 +634,12 @@ const AIModelConfig: React.FC = () => { const hasCustomHeaders = !!config.custom_headers && Object.keys(config.custom_headers).length > 0; const hasCustomBody = !!config.custom_request_body && config.custom_request_body.trim() !== ''; - setShowAdvancedSettings(hasCustomHeaders || hasCustomBody || !!config.skip_ssl_verify); + setShowAdvancedSettings( + hasCustomHeaders || + hasCustomBody || + !!config.skip_ssl_verify || + !!config.inline_think_in_text + ); setIsEditing(true); }; @@ -680,6 +691,7 @@ const AIModelConfig: React.FC = () => { metadata: editingConfig.metadata, enable_thinking_process: draft.enableThinking, support_preserved_thinking: editingConfig.support_preserved_thinking ?? false, + inline_think_in_text: editingConfig.inline_think_in_text ?? false, reasoning_effort: editingConfig.reasoning_effort, custom_headers: editingConfig.custom_headers, custom_headers_mode: editingConfig.custom_headers_mode, @@ -1378,6 +1390,7 @@ const AIModelConfig: React.FC = () => { ...prev, provider, request_url: resolveRequestUrl(prev?.base_url || '', provider, prev?.model_name || ''), + inline_think_in_text: provider === 'openai' ? (prev?.inline_think_in_text ?? false) : false, reasoning_effort: isResponsesProvider(provider) ? (prev?.reasoning_effort || 'medium') : undefined, })); }} placeholder={t('form.providerPlaceholder')} options={requestFormatOptions} size="small" /> @@ -1458,9 +1471,13 @@ const AIModelConfig: React.FC = () => { {showAdvancedSettings && ( <> - {editingConfig.enable_thinking_process && ( - - setEditingConfig(prev => ({ ...prev, support_preserved_thinking: e.target.checked }))} size="small" /> + {editingConfig.provider === 'openai' && ( + + setEditingConfig(prev => ({ ...prev, inline_think_in_text: e.target.checked }))} + size="small" + /> )} diff --git a/src/web-ui/src/infrastructure/config/schemas/ai-models.json b/src/web-ui/src/infrastructure/config/schemas/ai-models.json index 87b216ab..b9c10026 100644 --- a/src/web-ui/src/infrastructure/config/schemas/ai-models.json +++ b/src/web-ui/src/infrastructure/config/schemas/ai-models.json @@ -391,6 +391,20 @@ "title": "高级设置", "icon": "cog", "fields": [ + { + "type": "switch", + "dataType": "boolean", + "key": "inline_think_in_text", + "title": "解析文本中的 标签", + "description": "将 OpenAI 兼容文本流中的 ... 实时拆分为 reasoning 内容,默认关闭", + "default": false, + "conditional": { + "when": "provider", + "operator": "equals", + "value": "openai", + "show": true + } + }, { "type": "number", "dataType": "number", diff --git a/src/web-ui/src/infrastructure/config/types/index.ts b/src/web-ui/src/infrastructure/config/types/index.ts index afde50ea..c78e57ec 100644 --- a/src/web-ui/src/infrastructure/config/types/index.ts +++ b/src/web-ui/src/infrastructure/config/types/index.ts @@ -130,6 +130,9 @@ export interface AIModelConfig { support_preserved_thinking?: boolean; + /** Parse `...` text chunks into streaming reasoning content. */ + inline_think_in_text?: boolean; + /** Reasoning effort for OpenAI Responses API ("low" | "medium" | "high" | "xhigh") */ reasoning_effort?: string; } diff --git a/src/web-ui/src/locales/en-US/settings/ai-model.json b/src/web-ui/src/locales/en-US/settings/ai-model.json index 441318db..cd7d5190 100644 --- a/src/web-ui/src/locales/en-US/settings/ai-model.json +++ b/src/web-ui/src/locales/en-US/settings/ai-model.json @@ -255,6 +255,10 @@ }, "advancedSettings": { "title": "Advanced Settings", + "inlineThinkInText": { + "label": "Parse Tags In Text", + "hint": "Split OpenAI-compatible text streams containing ... into live reasoning content. Disabled by default." + }, "skipSslVerify": { "label": "Skip SSL Certificate Verification", "warning": "Skipping SSL verification is a security risk, use only in test or internal network environments!" diff --git a/src/web-ui/src/locales/zh-CN/settings/ai-model.json b/src/web-ui/src/locales/zh-CN/settings/ai-model.json index bd1fb3d3..c98567e6 100644 --- a/src/web-ui/src/locales/zh-CN/settings/ai-model.json +++ b/src/web-ui/src/locales/zh-CN/settings/ai-model.json @@ -255,6 +255,10 @@ }, "advancedSettings": { "title": "高级设置", + "inlineThinkInText": { + "label": "解析文本中的 标签", + "hint": "将 OpenAI 兼容文本流中的 ... 实时拆分为思考内容,默认关闭" + }, "skipSslVerify": { "label": "跳过SSL证书验证", "warning": "跳过SSL证书验证存在安全风险,请仅在测试环境或内网环境使用!"