diff --git a/lib/core/models/assistant.dart b/lib/core/models/assistant.dart index f435fd93..abcb0750 100644 --- a/lib/core/models/assistant.dart +++ b/lib/core/models/assistant.dart @@ -29,6 +29,7 @@ class Assistant { final int? thinkingBudget; // null = use global/default; 0=off; >0 tokens budget final int? maxTokens; // null = unlimited + final String? verbosity; // null = default (medium); "low", "medium", "high" final String systemPrompt; final String messageTemplate; // e.g. "{{ message }}" final List mcpServerIds; // bound MCP server IDs @@ -63,6 +64,7 @@ class Assistant { this.streamOutput = true, this.thinkingBudget, this.maxTokens, + this.verbosity, this.systemPrompt = '', this.messageTemplate = '{{ message }}', this.mcpServerIds = const [], @@ -92,6 +94,7 @@ class Assistant { bool? streamOutput, int? thinkingBudget, int? maxTokens, + String? verbosity, String? systemPrompt, String? messageTemplate, List? mcpServerIds, @@ -110,6 +113,7 @@ class Assistant { bool clearTopP = false, bool clearThinkingBudget = false, bool clearMaxTokens = false, + bool clearVerbosity = false, bool clearBackground = false, }) { return Assistant( @@ -131,6 +135,7 @@ class Assistant { ? null : (thinkingBudget ?? this.thinkingBudget), maxTokens: clearMaxTokens ? null : (maxTokens ?? this.maxTokens), + verbosity: clearVerbosity ? null : (verbosity ?? this.verbosity), systemPrompt: systemPrompt ?? this.systemPrompt, messageTemplate: messageTemplate ?? this.messageTemplate, mcpServerIds: mcpServerIds ?? this.mcpServerIds, @@ -163,6 +168,7 @@ class Assistant { 'streamOutput': streamOutput, 'thinkingBudget': thinkingBudget, 'maxTokens': maxTokens, + 'verbosity': verbosity, 'systemPrompt': systemPrompt, 'messageTemplate': messageTemplate, 'mcpServerIds': mcpServerIds, @@ -192,6 +198,7 @@ class Assistant { streamOutput: json['streamOutput'] as bool? ?? true, thinkingBudget: (json['thinkingBudget'] as num?)?.toInt(), maxTokens: (json['maxTokens'] as num?)?.toInt(), + verbosity: json['verbosity'] as String?, systemPrompt: (json['systemPrompt'] as String?) ?? '', messageTemplate: (json['messageTemplate'] as String?) ?? '{{ message }}', mcpServerIds: diff --git a/lib/core/providers/settings_provider.dart b/lib/core/providers/settings_provider.dart index b500a991..6b3f0022 100644 --- a/lib/core/providers/settings_provider.dart +++ b/lib/core/providers/settings_provider.dart @@ -82,6 +82,7 @@ class SettingsProvider extends ChangeNotifier { static const String _themePaletteKey = 'theme_palette_v1'; static const String _useDynamicColorKey = 'use_dynamic_color_v1'; static const String _thinkingBudgetKey = 'thinking_budget_v1'; + static const String _verbosityKey = 'verbosity_v1'; static const String _displayShowUserAvatarKey = 'display_show_user_avatar_v1'; static const String _displayShowModelIconKey = 'display_show_model_icon_v1'; static const String _displayShowModelNameTimestampKey = @@ -714,6 +715,8 @@ class SettingsProvider extends ChangeNotifier { : lmp; // load thinking budget (reasoning strength) _thinkingBudget = prefs.getInt(_thinkingBudgetKey); + // load verbosity (GPT-5 family) + _verbosity = prefs.getString(_verbosityKey); // display settings _showUserAvatar = prefs.getBool(_displayShowUserAvatarKey) ?? true; @@ -2592,6 +2595,20 @@ DO NOT GIVE ANSWERS OR DO HOMEWORK FOR THE USER. If the user asks a math or logi } } + // Verbosity (GPT-5 family): null = default (medium); "low", "medium", "high" + String? _verbosity; + String? get verbosity => _verbosity; + Future setVerbosity(String? value) async { + _verbosity = value; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + if (value == null) { + await prefs.remove(_verbosityKey); + } else { + await prefs.setString(_verbosityKey, value); + } + } + // Display settings: user avatar and model icon visibility bool _showUserAvatar = true; bool get showUserAvatar => _showUserAvatar; @@ -3227,6 +3244,7 @@ DO NOT GIVE ANSWERS OR DO HOMEWORK FOR THE USER. If the user asks a math or logi copy._ocrPrompt = _ocrPrompt; copy._ocrEnabled = _ocrEnabled; copy._thinkingBudget = _thinkingBudget; + copy._verbosity = _verbosity; copy._showUserAvatar = _showUserAvatar; copy._showModelIcon = _showModelIcon; copy._showModelNameTimestamp = _showModelNameTimestamp; diff --git a/lib/core/services/api/chat_api_service.dart b/lib/core/services/api/chat_api_service.dart index d25bab34..c9af35d9 100644 --- a/lib/core/services/api/chat_api_service.dart +++ b/lib/core/services/api/chat_api_service.dart @@ -355,6 +355,7 @@ class ChatApiService { Map? extraBody, bool stream = true, String? requestId, + String? verbosity, }) async* { final kind = ProviderConfig.classify( config.id, @@ -390,6 +391,7 @@ class ChatApiService { extraHeaders: extraHeaders, extraBody: extraBody, stream: stream, + verbosity: verbosity, ); } else { yield* _sendOpenAIChatCompletionsStream( @@ -407,6 +409,7 @@ class ChatApiService { extraHeaders: extraHeaders, extraBody: extraBody, stream: stream, + verbosity: verbosity, ); } } else if (kind == ProviderKind.claude) { diff --git a/lib/core/services/api/providers/openai_chat_completions.dart b/lib/core/services/api/providers/openai_chat_completions.dart index e8fb97d7..644f021f 100644 --- a/lib/core/services/api/providers/openai_chat_completions.dart +++ b/lib/core/services/api/providers/openai_chat_completions.dart @@ -82,6 +82,7 @@ Stream _sendOpenAIChatCompletionsStream( Map? extraHeaders, Map? extraBody, bool stream = true, + String? verbosity, }) { final cfg = config.copyWith(useResponseApi: false); return _sendOpenAIStream( @@ -99,5 +100,6 @@ Stream _sendOpenAIChatCompletionsStream( extraHeaders: extraHeaders, extraBody: extraBody, stream: stream, + verbosity: verbosity, ); } diff --git a/lib/core/services/api/providers/openai_common.dart b/lib/core/services/api/providers/openai_common.dart index 5d4449a7..e47649a4 100644 --- a/lib/core/services/api/providers/openai_common.dart +++ b/lib/core/services/api/providers/openai_common.dart @@ -79,6 +79,7 @@ Stream _sendOpenAIStream( Map? extraHeaders, Map? extraBody, bool stream = true, + String? verbosity, }) async* { final upstreamModelId = _apiModelId(config, modelId); final base = config.baseUrl.endsWith('/') @@ -98,6 +99,19 @@ Stream _sendOpenAIStream( final host = Uri.tryParse(config.baseUrl)?.host.toLowerCase() ?? ''; final modelLower = upstreamModelId.toLowerCase(); final bool isAzureOpenAI = host.contains('openai.azure.com'); + // Direct OpenAI API supports previous_response_id for Responses API + final bool isOpenAIHost = + host.contains('openai.com') && !isAzureOpenAI; + // Keep `phase` narrowly scoped for now: + // - OpenAI host only (compat providers may reject/ignore this field) + // - gpt-5.4 family and gpt-5.3-codex (documented/validated targets) + final bool usePhase = + isOpenAIHost && + config.useResponseApi == true && + RegExp(r'gpt-5\.(?:4|3-codex)(?:$|[-.])', caseSensitive: false) + .hasMatch(upstreamModelId); + final bool hasValidVerbosity = + verbosity == 'low' || verbosity == 'medium' || verbosity == 'high'; final bool isMimoHost = host.contains('xiaomimimo'); final bool isMimoModel = modelLower.startsWith('mimo-') || modelLower.contains('/mimo-'); @@ -243,9 +257,11 @@ Stream _sendOpenAIStream( } final isAssistant = roleRaw == 'assistant'; + // Track whether this assistant message precedes tool calls (for phase) + final bool hasToolCalls = isAssistant && m['tool_calls'] is List; // Handle assistant messages with tool_calls - convert to function_call format - if (isAssistant && m['tool_calls'] is List) { + if (hasToolCalls) { final toolCalls = m['tool_calls'] as List; for (final tc in toolCalls) { if (tc is! Map) continue; @@ -350,12 +366,19 @@ Stream _sendOpenAIStream( } // Use proper message object format for assistant messages if (isAssistant) { - input.add({ + final msg = { 'type': 'message', 'role': 'assistant', 'status': 'completed', 'content': parts, - }); + }; + // GPT-5.4: annotate assistant messages with phase to prevent + // early stopping in long tool-calling chains. + if (usePhase) { + msg['phase'] = + hasToolCalls ? 'commentary' : 'final_answer'; + } + input.add(msg); } else { input.add({'role': roleRaw, 'content': parts}); } @@ -363,14 +386,19 @@ Stream _sendOpenAIStream( // No images if (isAssistant) { // Use proper message object format for assistant messages - input.add({ + final msg = { 'type': 'message', 'role': 'assistant', 'status': 'completed', 'content': [ {'type': 'output_text', 'text': raw}, ], - }); + }; + if (usePhase) { + msg['phase'] = + hasToolCalls ? 'commentary' : 'final_answer'; + } + input.add(msg); } else { input.add({'role': roleRaw, 'content': raw}); } @@ -391,6 +419,11 @@ Stream _sendOpenAIStream( 'summary': 'auto', if (effort != 'auto') 'effort': effort, }, + // GPT-5 family: verbosity (OpenAI Responses API nests under 'text') + if (hasValidVerbosity && + isOpenAIHost && + isOpenAIGpt5FamilyModel(upstreamModelId)) + 'text': {'verbosity': verbosity}, }; // Append include parameter if we opted into sources via overrides try { @@ -558,6 +591,11 @@ Stream _sendOpenAIStream( if (tools != null && tools.isNotEmpty) 'tools': _cleanToolsForCompatibility(tools), if (tools != null && tools.isNotEmpty) 'tool_choice': 'auto', + // GPT-5 family: verbosity (OpenAI Chat Completions uses top-level key) + if (hasValidVerbosity && + isOpenAIHost && + isOpenAIGpt5FamilyModel(upstreamModelId)) + 'verbosity': verbosity, }; _setMaxTokens(body); } @@ -1014,6 +1052,8 @@ Stream _sendOpenAIStream( >{}; // index -> {call_id,name,args} List> lastResponseOutputItems = const >[]; + // Track the latest Responses API response ID for previous_response_id chaining + String? lastResponseId; String? finishReason; await for (final chunk in sse) { @@ -1137,6 +1177,10 @@ Stream _sendOpenAIStream( if (tools != null && tools.isNotEmpty) 'tools': _cleanToolsForCompatibility(tools), if (tools != null && tools.isNotEmpty) 'tool_choice': 'auto', + if (hasValidVerbosity && + isOpenAIHost && + isOpenAIGpt5FamilyModel(upstreamModelId)) + 'verbosity': verbosity, }; _setMaxTokens(body2); @@ -1675,6 +1719,11 @@ Stream _sendOpenAIStream( entry['args'] = (entry['args'] ?? '') + argsDelta; } } else if (type == 'response.completed') { + // Capture response ID for previous_response_id chaining + try { + final rid = (json['response']?['id'] ?? '').toString(); + if (rid.isNotEmpty) lastResponseId = rid; + } catch (_) {} final u = json['response']?['usage']; if (u != null) { final inTok = (u['input_tokens'] ?? 0) as int; @@ -1865,19 +1914,33 @@ Stream _sendOpenAIStream( ); } - // Build follow-up Responses request input - List> currentInput = >[ - ...responsesInitialInput, - ]; - if (lastResponseOutputItems.isNotEmpty) - currentInput.addAll(lastResponseOutputItems); - currentInput.addAll(followUpOutputs); + // Build follow-up Responses request input. + // When talking to OpenAI directly, use previous_response_id to + // preserve reasoning items and avoid early stopping (GPT-5.4). + // Keep this OpenAI-host-only for now; some OpenAI-style providers + // do not reliably support this Responses API field. + // Only the new function_call_output items are needed as input. + final bool usePrevResponseId = + isOpenAIHost && lastResponseId != null; + List> currentInput; + if (usePrevResponseId) { + currentInput = >[...followUpOutputs]; + } else { + currentInput = >[ + ...responsesInitialInput, + ]; + if (lastResponseOutputItems.isNotEmpty) + currentInput.addAll(lastResponseOutputItems); + currentInput.addAll(followUpOutputs); + } // Iteratively request until no more tool calls for (int round = 0; round < 3; round++) { final body2 = { 'model': upstreamModelId, 'input': currentInput, + if (isOpenAIHost && lastResponseId != null) + 'previous_response_id': lastResponseId, 'stream': true, if (responsesToolsSpec.isNotEmpty) 'tools': responsesToolsSpec, @@ -1892,6 +1955,10 @@ Stream _sendOpenAIStream( 'summary': 'auto', if (effort != 'auto') 'effort': effort, }, + if (hasValidVerbosity && + isOpenAIHost && + isOpenAIGpt5FamilyModel(upstreamModelId)) + 'text': {'verbosity': verbosity}, if (responsesIncludeParam != null) 'include': responsesIncludeParam, }; @@ -2010,6 +2077,11 @@ Stream _sendOpenAIStream( } } else if (o is Map && (o['type'] ?? '') == 'response.completed') { + // Capture response ID for next round's previous_response_id + try { + final rid = (o['response']?['id'] ?? '').toString(); + if (rid.isNotEmpty) lastResponseId = rid; + } catch (_) {} // usage final u2 = o['response']?['usage']; if (u2 != null) { @@ -2121,9 +2193,14 @@ Stream _sendOpenAIStream( toolResults: resultsInfo2, ); } - // Extend current input with this round's model output and our outputs - if (outItems2.isNotEmpty) currentInput.addAll(outItems2); - currentInput.addAll(followUpOutputs2); + // Extend current input with this round's model output and our outputs. + // When using previous_response_id, only send new tool outputs. + if (isOpenAIHost && lastResponseId != null) { + currentInput = >[...followUpOutputs2]; + } else { + if (outItems2.isNotEmpty) currentInput.addAll(outItems2); + currentInput.addAll(followUpOutputs2); + } } // Safety @@ -2533,6 +2610,10 @@ Stream _sendOpenAIStream( if (tools != null && tools.isNotEmpty) 'tools': _cleanToolsForCompatibility(tools), if (tools != null && tools.isNotEmpty) 'tool_choice': 'auto', + if (hasValidVerbosity && + isOpenAIHost && + isOpenAIGpt5FamilyModel(upstreamModelId)) + 'verbosity': verbosity, }; _setMaxTokens(body2); final off = _isOff(thinkingBudget); @@ -3109,6 +3190,10 @@ Stream _sendOpenAIStream( if (tools != null && tools.isNotEmpty) 'tools': _cleanToolsForCompatibility(tools), if (tools != null && tools.isNotEmpty) 'tool_choice': 'auto', + if (hasValidVerbosity && + isOpenAIHost && + isOpenAIGpt5FamilyModel(upstreamModelId)) + 'verbosity': verbosity, }; _setMaxTokens(body2); final off = _isOff(thinkingBudget); diff --git a/lib/core/services/api/providers/openai_responses.dart b/lib/core/services/api/providers/openai_responses.dart index a01775e1..aca4b1e0 100644 --- a/lib/core/services/api/providers/openai_responses.dart +++ b/lib/core/services/api/providers/openai_responses.dart @@ -49,6 +49,7 @@ Stream _sendOpenAIResponsesStream( Map? extraHeaders, Map? extraBody, bool stream = true, + String? verbosity, }) { final cfg = config.copyWith(useResponseApi: true); return _sendOpenAIStream( @@ -66,5 +67,6 @@ Stream _sendOpenAIResponsesStream( extraHeaders: extraHeaders, extraBody: extraBody, stream: stream, + verbosity: verbosity, ); } diff --git a/lib/desktop/verbosity_popover.dart b/lib/desktop/verbosity_popover.dart new file mode 100644 index 00000000..e02a694d --- /dev/null +++ b/lib/desktop/verbosity_popover.dart @@ -0,0 +1,299 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +import '../icons/lucide_adapter.dart'; +import '../l10n/app_localizations.dart'; +import '../features/chat/widgets/verbosity_sheet.dart'; + +Future showDesktopVerbosityPopover( + BuildContext context, { + required GlobalKey anchorKey, + String? initialValue, +}) async { + final overlay = Overlay.of(context); + final keyContext = anchorKey.currentContext; + if (keyContext == null) return null; + + final box = keyContext.findRenderObject() as RenderBox?; + if (box == null) return null; + final offset = box.localToGlobal(Offset.zero); + final size = box.size; + final anchorRect = Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height); + + final completer = Completer(); + + late OverlayEntry entry; + entry = OverlayEntry( + builder: (ctx) => _VerbosityPopoverOverlay( + anchorRect: anchorRect, + anchorWidth: size.width, + initialValue: initialValue, + onClose: (value) { + try { entry.remove(); } catch (_) {} + if (!completer.isCompleted) completer.complete(value); + }, + ), + ); + overlay.insert(entry); + return completer.future; +} + +class _VerbosityPopoverOverlay extends StatefulWidget { + const _VerbosityPopoverOverlay({ + required this.anchorRect, + required this.anchorWidth, + required this.initialValue, + required this.onClose, + }); + + final Rect anchorRect; + final double anchorWidth; + final String? initialValue; + final ValueChanged onClose; + + @override + State<_VerbosityPopoverOverlay> createState() => _VerbosityPopoverOverlayState(); +} + +class _VerbosityPopoverOverlayState extends State<_VerbosityPopoverOverlay> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _fadeIn; + bool _closing = false; + Offset _offset = const Offset(0, 0.12); + late String _selected; + + @override + void initState() { + super.initState(); + _selected = widget.initialValue ?? kVerbosityDefaultSelection; + _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 260)); + _fadeIn = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic); + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + setState(() => _offset = Offset.zero); + try { await _controller.forward(); } catch (_) {} + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _close({String? value}) async { + if (_closing) return; + _closing = true; + setState(() => _offset = const Offset(0, 1.0)); + try { await _controller.reverse(); } catch (_) {} + if (mounted) widget.onClose(value); + } + + @override + Widget build(BuildContext context) { + final screen = MediaQuery.of(context).size; + final width = (widget.anchorWidth - 16).clamp(260.0, 720.0); + final left = (widget.anchorRect.left + (widget.anchorRect.width - width) / 2) + .clamp(8.0, screen.width - width - 8.0); + final clipHeight = widget.anchorRect.top.clamp(0.0, screen.height); + + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _close, + ), + ), + Positioned( + left: 0, + right: 0, + top: 0, + height: clipHeight, + child: ClipRect( + child: Stack( + children: [ + Positioned( + left: left, + width: width, + bottom: 0, + child: FadeTransition( + opacity: _fadeIn, + child: AnimatedSlide( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + offset: _offset, + child: _GlassPanel( + borderRadius: const BorderRadius.vertical(top: Radius.circular(14)), + child: _VerbosityContent( + selected: _selected, + onSelect: (value) => _close(value: value), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _GlassPanel extends StatelessWidget { + const _GlassPanel({required this.child, this.borderRadius}); + final Widget child; + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return ClipRRect( + borderRadius: borderRadius ?? BorderRadius.circular(14), + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: DecoratedBox( + decoration: BoxDecoration( + color: (isDark ? Colors.black : Colors.white).withOpacity(isDark ? 0.28 : 0.56), + border: Border( + top: BorderSide(color: Colors.white.withOpacity(isDark ? 0.06 : 0.18), width: 0.7), + left: BorderSide(color: Colors.white.withOpacity(isDark ? 0.04 : 0.12), width: 0.6), + right: BorderSide(color: Colors.white.withOpacity(isDark ? 0.04 : 0.12), width: 0.6), + ), + ), + child: Material( + type: MaterialType.transparency, + child: child, + ), + ), + ), + ); + } +} + +class _VerbosityContent extends StatelessWidget { + const _VerbosityContent({ + required this.selected, + required this.onSelect, + }); + + final String selected; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + Widget tile({ + required String label, + required String value, + }) { + final cs = Theme.of(context).colorScheme; + final active = selected == value; + final onColor = active ? cs.primary : cs.onSurface; + final iconColor = active ? cs.primary : cs.onSurface; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 1), + child: _HoverRow( + leading: Icon(Lucide.MessageCircleMore, size: 16, color: iconColor), + label: label, + selected: active, + onTap: () async { + onSelect(value); + }, + labelStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w400, decoration: TextDecoration.none) + .copyWith(color: onColor), + ), + ); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(0, 10, 0, 2), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + tile(label: l10n.verbosityDefault, value: kVerbosityDefaultSelection), + tile(label: l10n.verbosityLow, value: 'low'), + tile(label: l10n.verbosityMedium, value: 'medium'), + tile(label: l10n.verbosityHigh, value: 'high'), + ], + ), + ), + ); + } +} + +class _HoverRow extends StatefulWidget { + const _HoverRow({ + required this.leading, + required this.label, + required this.selected, + required this.onTap, + this.labelStyle, + }); + final Widget leading; + final String label; + final bool selected; + final VoidCallback onTap; + final TextStyle? labelStyle; + + @override + State<_HoverRow> createState() => _HoverRowState(); +} + +class _HoverRowState extends State<_HoverRow> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + final hoverBg = (isDark ? Colors.white : Colors.black).withOpacity(isDark ? 0.12 : 0.10); + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: _hovered ? hoverBg : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + SizedBox(width: 22, height: 22, child: Center(child: widget.leading)), + const SizedBox(width: 6), + Expanded( + child: Text( + widget.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: widget.labelStyle ?? const TextStyle(fontSize: 13, fontWeight: FontWeight.w400, decoration: TextDecoration.none), + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 160), + child: widget.selected + ? Icon(Lucide.Check, key: const ValueKey('check'), size: 16, color: cs.primary) + : const SizedBox(width: 16, key: ValueKey('space')), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/chat/widgets/verbosity_sheet.dart b/lib/features/chat/widgets/verbosity_sheet.dart new file mode 100644 index 00000000..1ec0a3ee --- /dev/null +++ b/lib/features/chat/widgets/verbosity_sheet.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import '../../../icons/lucide_adapter.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../shared/widgets/ios_tactile.dart'; +import '../../../core/services/haptics.dart'; + +const String kVerbosityDefaultSelection = '__default__'; + +Future showVerbositySheet( + BuildContext context, { + String? initialValue, +}) async { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => _VerbositySheet(initialValue: initialValue), + ); +} + +class _VerbositySheet extends StatefulWidget { + const _VerbositySheet({this.initialValue}); + + final String? initialValue; + + @override + State<_VerbositySheet> createState() => _VerbositySheetState(); +} + +class _VerbositySheetState extends State<_VerbositySheet> { + late String _selected; + + @override + void initState() { + super.initState(); + _selected = widget.initialValue ?? kVerbosityDefaultSelection; + } + + Widget _tile( + BuildContext context, { + required IconData icon, + required String title, + required String value, + }) { + final cs = Theme.of(context).colorScheme; + final active = _selected == value; + final Color iconColor = active ? cs.primary : cs.onSurface.withOpacity(0.7); + final Color onColor = active ? cs.primary : cs.onSurface; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: SizedBox( + height: 48, + child: IosCardPress( + borderRadius: BorderRadius.circular(14), + baseColor: cs.surface, + duration: const Duration(milliseconds: 260), + onTap: () { + Haptics.light(); + Navigator.of(context).maybePop( + value == kVerbosityDefaultSelection ? kVerbosityDefaultSelection : value, + ); + }, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Icon(icon, size: 20, color: iconColor), + const SizedBox(width: 10), + Expanded( + child: Text( + title, + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: onColor), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (active) Icon(Lucide.Check, size: 18, color: cs.primary) else const SizedBox(width: 18), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final cs = Theme.of(context).colorScheme; + final maxHeight = MediaQuery.sizeOf(context).height * 0.8; + return SafeArea( + top: false, + child: AnimatedPadding( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container(width: 40, height: 4, decoration: BoxDecoration(color: cs.onSurface.withOpacity(0.2), borderRadius: BorderRadius.circular(999))), + const SizedBox(height: 6), + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + children: [ + _tile( + context, + icon: Lucide.RotateCcw, + title: l10n.verbosityDefault, + value: kVerbosityDefaultSelection, + ), + _tile(context, icon: Lucide.MessageCircleMore, title: l10n.verbosityLow, value: 'low'), + _tile(context, icon: Lucide.MessageCircleMore, title: l10n.verbosityMedium, value: 'medium'), + _tile(context, icon: Lucide.MessageCircleMore, title: l10n.verbosityHigh, value: 'high'), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/home/controllers/chat_actions.dart b/lib/features/home/controllers/chat_actions.dart index 3390c6fd..47a34b34 100644 --- a/lib/features/home/controllers/chat_actions.dart +++ b/lib/features/home/controllers/chat_actions.dart @@ -485,6 +485,7 @@ class ChatActions { extraBody: ctx.extraBody, stream: ctx.streamOutput, requestId: conversationId, + verbosity: assistant?.verbosity ?? ctx.settings.verbosity, ); await _conversationStreams[conversationId]?.cancel(); diff --git a/lib/features/home/controllers/generation_controller.dart b/lib/features/home/controllers/generation_controller.dart index c0bc5c48..797caf02 100644 --- a/lib/features/home/controllers/generation_controller.dart +++ b/lib/features/home/controllers/generation_controller.dart @@ -8,6 +8,7 @@ import '../../../core/services/chat/chat_service.dart'; import '../../../utils/assistant_regex.dart'; import '../../../core/models/assistant_regex.dart'; import '../services/message_builder_service.dart'; +import '../../../core/utils/openai_model_compat.dart'; import '../services/tool_handler_service.dart'; import 'chat_controller.dart'; import 'stream_controller.dart' as stream_ctrl; @@ -103,6 +104,15 @@ class GenerationController { return budget >= 1024; } + bool supportsVerbosity(String providerKey, String modelId) { + final settings = contextProvider.read(); + final cfg = settings.getProviderConfig(providerKey); + final host = Uri.tryParse(cfg.baseUrl)?.host.toLowerCase() ?? ''; + final isAzureOpenAI = host.contains('openai.azure.com'); + final isOpenAIHost = host.contains('openai.com') && !isAzureOpenAI; + return isOpenAIHost && isOpenAIGpt5FamilyModel(modelId); + } + // ============================================================================ // Tool Definitions Builder (delegated to ToolHandlerService) // ============================================================================ diff --git a/lib/features/home/controllers/home_page_controller.dart b/lib/features/home/controllers/home_page_controller.dart index e2250103..db91a86b 100644 --- a/lib/features/home/controllers/home_page_controller.dart +++ b/lib/features/home/controllers/home_page_controller.dart @@ -1163,6 +1163,10 @@ class HomePageController extends ChangeNotifier { return budget >= 1024; } + bool supportsVerbosity(String providerKey, String modelId) { + return _generationController.supportsVerbosity(providerKey, modelId); + } + // ============================================================================ // Public Methods - Helpers // ============================================================================ diff --git a/lib/features/home/pages/home_page.dart b/lib/features/home/pages/home_page.dart index 57f6f843..d8cd0db8 100644 --- a/lib/features/home/pages/home_page.dart +++ b/lib/features/home/pages/home_page.dart @@ -23,6 +23,7 @@ import '../../../utils/sandbox_path_resolver.dart'; import '../../../utils/platform_utils.dart'; import '../../../desktop/search_provider_popover.dart'; import '../../../desktop/reasoning_budget_popover.dart'; +import '../../../desktop/verbosity_popover.dart'; import '../../../desktop/mcp_servers_popover.dart'; import '../../../desktop/mini_map_popover.dart'; import '../../../desktop/quick_phrase_popover.dart'; @@ -31,6 +32,7 @@ import '../../../desktop/world_book_popover.dart'; import '../../chat/widgets/bottom_tools_sheet.dart'; import '../../chat/widgets/context_management_sheet.dart'; import '../../chat/widgets/reasoning_budget_sheet.dart'; +import '../../chat/widgets/verbosity_sheet.dart'; import '../../search/widgets/search_settings_sheet.dart'; import '../../model/widgets/model_select_sheet.dart'; import '../../mcp/pages/mcp_page.dart'; @@ -685,6 +687,7 @@ class _HomePageState extends State with SingleTickerProviderStateMixin isToolModel: _controller.isToolModel, isReasoningModel: _controller.isReasoningModel, isReasoningEnabled: _controller.isReasoningEnabled, + isVerbosityModel: _controller.supportsVerbosity, onMore: _toggleTools, onSelectModel: () => showModelSelectSheet(context), onLongPressSelectModel: () { @@ -721,6 +724,22 @@ class _HomePageState extends State with SingleTickerProviderStateMixin ); } }, + onConfigureVerbosity: () async { + final assistant = context.read().currentAssistant; + if (assistant != null) { + final chosen = await _openVerbositySettings(assistant.verbosity); + if (chosen == null) return; + if (chosen == kVerbosityDefaultSelection) { + await context.read().updateAssistant( + assistant.copyWith(clearVerbosity: true), + ); + } else if (chosen != assistant.verbosity) { + await context.read().updateAssistant( + assistant.copyWith(verbosity: chosen), + ); + } + } + }, onSend: (text) { _controller.sendMessage(text); _inputController.clear(); @@ -845,6 +864,18 @@ class _HomePageState extends State with SingleTickerProviderStateMixin } } + Future _openVerbositySettings(String? initialValue) async { + if (PlatformUtils.isDesktop) { + return showDesktopVerbosityPopover( + context, + anchorKey: _inputBarKey, + initialValue: initialValue, + ); + } else { + return showVerbositySheet(context, initialValue: initialValue); + } + } + Future _openInstructionInjectionPopover() async { final isDesktop = PlatformUtils.isDesktop; final assistantId = context.read().currentAssistantId; diff --git a/lib/features/home/widgets/chat_input_bar.dart b/lib/features/home/widgets/chat_input_bar.dart index ea7927e8..1e57f212 100644 --- a/lib/features/home/widgets/chat_input_bar.dart +++ b/lib/features/home/widgets/chat_input_bar.dart @@ -54,6 +54,7 @@ class ChatInputBar extends StatefulWidget { this.onOpenSearch, this.onMore, this.onConfigureReasoning, + this.onConfigureVerbosity, this.moreOpen = false, this.focusNode, this.modelIcon, @@ -62,6 +63,8 @@ class ChatInputBar extends StatefulWidget { this.loading = false, this.reasoningActive = false, this.supportsReasoning = true, + this.supportsVerbosity = false, + this.verbosityActive = false, this.showMcpButton = false, this.mcpActive = false, this.searchEnabled = false, @@ -96,6 +99,7 @@ class ChatInputBar extends StatefulWidget { final VoidCallback? onOpenSearch; final VoidCallback? onMore; final VoidCallback? onConfigureReasoning; + final VoidCallback? onConfigureVerbosity; final bool moreOpen; final FocusNode? focusNode; final Widget? modelIcon; @@ -104,6 +108,8 @@ class ChatInputBar extends StatefulWidget { final bool loading; final bool reasoningActive; final bool supportsReasoning; + final bool supportsVerbosity; + final bool verbosityActive; final bool showMcpButton; final bool mcpActive; final bool searchEnabled; @@ -873,6 +879,24 @@ class _ChatInputBarState extends State with WidgetsBindingObserver )); } + // Verbosity button (GPT-5 family) + if (widget.supportsVerbosity) { + actions.add(_OverflowAction( + width: normalButtonW, + builder: () => _CompactIconButton( + tooltip: l10n.verbosityTooltip, + icon: Lucide.MessageCircleMore, + active: widget.verbosityActive, + onTap: widget.onConfigureVerbosity, + ), + menu: DesktopContextMenuItem( + icon: Lucide.MessageCircleMore, + label: l10n.verbosityTooltip, + onTap: widget.onConfigureVerbosity, + ), + )); + } + if (widget.showQuickPhraseButton && widget.onQuickPhrase != null) { actions.add(_OverflowAction( width: normalButtonW, diff --git a/lib/features/home/widgets/chat_input_section.dart b/lib/features/home/widgets/chat_input_section.dart index 112f2e37..0c827733 100644 --- a/lib/features/home/widgets/chat_input_section.dart +++ b/lib/features/home/widgets/chat_input_section.dart @@ -20,6 +20,9 @@ typedef IsToolModelCallback = bool Function(String providerKey, String modelId); /// Callback for checking if a model supports reasoning. typedef IsReasoningModelCallback = bool Function(String providerKey, String modelId); +/// Callback for checking if a model supports verbosity. +typedef IsVerbosityModelCallback = bool Function(String providerKey, String modelId); + /// Callback for checking if reasoning is enabled. typedef IsReasoningEnabledCallback = bool Function(int? budget); @@ -39,6 +42,7 @@ class ChatInputSection extends StatelessWidget { required this.isToolModel, required this.isReasoningModel, required this.isReasoningEnabled, + this.isVerbosityModel, this.onMore, this.onSelectModel, this.onLongPressSelectModel, @@ -46,6 +50,7 @@ class ChatInputSection extends StatelessWidget { this.onLongPressMcp, this.onOpenSearch, this.onConfigureReasoning, + this.onConfigureVerbosity, this.onSend, this.onStop, this.onQuickPhrase, @@ -73,6 +78,7 @@ class ChatInputSection extends StatelessWidget { final IsToolModelCallback isToolModel; final IsReasoningModelCallback isReasoningModel; final IsReasoningEnabledCallback isReasoningEnabled; + final IsVerbosityModelCallback? isVerbosityModel; // Callbacks final VoidCallback? onMore; @@ -82,6 +88,7 @@ class ChatInputSection extends StatelessWidget { final VoidCallback? onLongPressMcp; final VoidCallback? onOpenSearch; final VoidCallback? onConfigureReasoning; + final VoidCallback? onConfigureVerbosity; final ValueChanged? onSend; final VoidCallback? onStop; final VoidCallback? onQuickPhrase; @@ -143,6 +150,12 @@ class ChatInputSection extends StatelessWidget { controller: inputController, mediaController: mediaController, onConfigureReasoning: onConfigureReasoning, + onConfigureVerbosity: onConfigureVerbosity, + supportsVerbosity: (pk != null && mid != null && isVerbosityModel != null) ? isVerbosityModel!(pk, mid) : false, + verbosityActive: (() { + final v = (a?.verbosity) ?? settings.verbosity; + return v != null && v != 'medium'; + })(), reasoningActive: isReasoningEnabled( (context.watch().currentAssistant?.thinkingBudget) ?? settings.thinkingBudget, ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index db42a5a9..1e9379ec 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -873,6 +873,11 @@ "chatInputBarOnlineSearchTooltip": "Online Search", "chatInputBarReasoningStrengthTooltip": "Reasoning Strength", "chatInputBarMcpServersTooltip": "MCP Servers", + "verbosityTooltip": "Verbosity", + "verbosityDefault": "Default", + "verbosityLow": "Low", + "verbosityMedium": "Medium", + "verbosityHigh": "High", "chatInputBarMoreTooltip": "Add", "chatInputBarInsertNewline": "Newline", "chatInputBarExpand": "Expand", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 046e90ff..94714f79 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3761,6 +3761,36 @@ abstract class AppLocalizations { /// **'MCP Servers'** String get chatInputBarMcpServersTooltip; + /// No description provided for @verbosityTooltip. + /// + /// In en, this message translates to: + /// **'Verbosity'** + String get verbosityTooltip; + + /// No description provided for @verbosityDefault. + /// + /// In en, this message translates to: + /// **'Default'** + String get verbosityDefault; + + /// No description provided for @verbosityLow. + /// + /// In en, this message translates to: + /// **'Low'** + String get verbosityLow; + + /// No description provided for @verbosityMedium. + /// + /// In en, this message translates to: + /// **'Medium'** + String get verbosityMedium; + + /// No description provided for @verbosityHigh. + /// + /// In en, this message translates to: + /// **'High'** + String get verbosityHigh; + /// No description provided for @chatInputBarMoreTooltip. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 3b914c3e..a72f40dd 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1984,6 +1984,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chatInputBarMcpServersTooltip => 'MCP Servers'; + @override + String get verbosityTooltip => 'Verbosity'; + + @override + String get verbosityDefault => 'Default'; + + @override + String get verbosityLow => 'Low'; + + @override + String get verbosityMedium => 'Medium'; + + @override + String get verbosityHigh => 'High'; + @override String get chatInputBarMoreTooltip => 'Add'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 302375f9..51e24b62 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1921,6 +1921,21 @@ class AppLocalizationsZh extends AppLocalizations { @override String get chatInputBarMcpServersTooltip => 'MCP服务器'; + @override + String get verbosityTooltip => '回答详细程度'; + + @override + String get verbosityDefault => '默认'; + + @override + String get verbosityLow => '简洁'; + + @override + String get verbosityMedium => '适中'; + + @override + String get verbosityHigh => '详细'; + @override String get chatInputBarMoreTooltip => '更多'; @@ -6034,6 +6049,21 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get chatInputBarMcpServersTooltip => 'MCP服务器'; + @override + String get verbosityTooltip => '回答详细程度'; + + @override + String get verbosityDefault => '默认'; + + @override + String get verbosityLow => '简洁'; + + @override + String get verbosityMedium => '适中'; + + @override + String get verbosityHigh => '详细'; + @override String get chatInputBarMoreTooltip => '更多'; @@ -10094,6 +10124,21 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get chatInputBarMcpServersTooltip => 'MCP伺服器'; + @override + String get verbosityTooltip => '回答詳細程度'; + + @override + String get verbosityDefault => '預設'; + + @override + String get verbosityLow => '簡潔'; + + @override + String get verbosityMedium => '適中'; + + @override + String get verbosityHigh => '詳細'; + @override String get chatInputBarMoreTooltip => '更多'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e3004990..57b1385c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -631,6 +631,11 @@ "chatInputBarOnlineSearchTooltip": "联网搜索", "chatInputBarReasoningStrengthTooltip": "思维链强度", "chatInputBarMcpServersTooltip": "MCP服务器", + "verbosityTooltip": "回答详细程度", + "verbosityDefault": "默认", + "verbosityLow": "简洁", + "verbosityMedium": "适中", + "verbosityHigh": "详细", "chatInputBarMoreTooltip": "更多", "chatInputBarInsertNewline": "换行", "chatInputBarExpand": "展开", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 69a59afd..cbc1bed6 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -663,6 +663,11 @@ "chatInputBarOnlineSearchTooltip": "联网搜索", "chatInputBarReasoningStrengthTooltip": "思维链强度", "chatInputBarMcpServersTooltip": "MCP服务器", + "verbosityTooltip": "回答详细程度", + "verbosityDefault": "默认", + "verbosityLow": "简洁", + "verbosityMedium": "适中", + "verbosityHigh": "详细", "chatInputBarMoreTooltip": "更多", "chatInputBarInsertNewline": "换行", "chatInputBarExpand": "展开", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 6e5a9ef9..dccc1eff 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -617,6 +617,11 @@ "chatInputBarOnlineSearchTooltip": "聯網搜尋", "chatInputBarReasoningStrengthTooltip": "思維鏈強度", "chatInputBarMcpServersTooltip": "MCP伺服器", + "verbosityTooltip": "回答詳細程度", + "verbosityDefault": "預設", + "verbosityLow": "簡潔", + "verbosityMedium": "適中", + "verbosityHigh": "詳細", "chatInputBarMoreTooltip": "更多", "chatInputBarInsertNewline": "換行", "chatInputBarExpand": "展開",