Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions lib/features/chat/providers/chat_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5018,6 +5018,37 @@ Map<String, dynamic> _buildOpenWebUiBackgroundTasks({
};
}

bool _shouldGenerateQueuedTitle(
List<ChatMessage> messages, {
required String assistantMessageId,
required bool isTemporary,
}) {
if (isTemporary) return false;
final assistantIndex = messages.indexWhere(
(message) => message.id == assistantMessageId,
);
if (assistantIndex < 0) return false;
return messages
.take(assistantIndex)
.where((message) => message.role == 'user')
.length ==
1;
}

/// Exposes [_shouldGenerateQueuedTitle] for focused regression tests.
@visibleForTesting
bool shouldGenerateQueuedTitleForTest(
List<ChatMessage> messages, {
required String assistantMessageId,
required bool isTemporary,
}) {
return _shouldGenerateQueuedTitle(
messages,
assistantMessageId: assistantMessageId,
isTemporary: isTemporary,
);
}

/// Exposes [_buildOpenWebUiBackgroundTasks] for focused unit tests.
@visibleForTesting
Map<String, dynamic> buildOpenWebUiBackgroundTasksForTest({
Expand Down Expand Up @@ -8133,7 +8164,11 @@ Future<void> runQueuedCompletion(

final bgTasks = _buildOpenWebUiBackgroundTasks(
userSettings: userSettingsData,
shouldGenerateTitle: false,
shouldGenerateTitle: _shouldGenerateQueuedTitle(
messages,
assistantMessageId: assistantMessageId,
isTemporary: isTemporary,
),
webSearchEnabled: enableWebSearch,
imageGenerationEnabled: enableImageGeneration,
);
Expand Down Expand Up @@ -8377,7 +8412,11 @@ Future<void> runHeadlessCompletion(

final bgTasks = _buildOpenWebUiBackgroundTasks(
userSettings: userSettingsData,
shouldGenerateTitle: false,
shouldGenerateTitle: _shouldGenerateQueuedTitle(
messages,
assistantMessageId: assistantMessageId,
isTemporary: false,
),
webSearchEnabled: enableWebSearch,
imageGenerationEnabled: enableImageGeneration,
);
Expand Down
5 changes: 3 additions & 2 deletions lib/features/chat/views/chat_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2541,8 +2541,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
}

void _copyMessage(String content) {
// Strip reasoning blocks and annotations from copied content
final cleanedContent = ConduitMarkdownPreprocessor.sanitize(content);
final cleanedContent = ConduitMarkdownPreprocessor.sanitizeForClipboard(
content,
);
Clipboard.setData(ClipboardData(text: cleanedContent));
}

Expand Down
117 changes: 99 additions & 18 deletions lib/features/notes/views/note_editor_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,104 @@ import '../widgets/audio_player_dialog.dart';
import '../widgets/audio_recording_overlay.dart';
import '../widgets/note_file_attachment.dart';

/// Builds the rich-note editor theme from Conduit's semantic color tokens.
///
/// Fleather's fallback block styles derive their colors from a separate
/// [DefaultTextStyle]. Every block type must therefore be mapped explicitly so
/// headings and lists remain readable in both brightness modes.
FleatherThemeData buildNoteEditorFleatherTheme(BuildContext context) {
final theme = context.conduitTheme;
final fallback = FleatherThemeData.fallback(context);
final base = AppTypography.bodyLargeStyle.copyWith(
color: theme.textPrimary,
height: 1.8,
);

TextStyle blockStyle(TextStyle source, {Color? color}) {
return base
.merge(source)
.copyWith(
color: color ?? theme.textPrimary,
fontFamily: AppTypography.fontFamily,
);
}

TextBlockTheme themedBlock(
TextBlockTheme source, {
TextStyle? style,
BoxDecoration? decoration,
}) {
return TextBlockTheme(
style: style ?? blockStyle(source.style),
spacing: source.spacing,
lineSpacing: source.lineSpacing,
decoration: decoration ?? source.decoration,
);
}

TextStyle inlineCodeStyle(TextStyle? source) {
return (source ?? fallback.inlineCode.style).copyWith(
color: theme.codeText,
fontFamily: AppTypography.monospaceFontFamily,
);
}

return fallback.copyWith(
paragraph: TextBlockTheme(
style: base,
spacing: const VerticalSpacing(top: 0, bottom: 6),
),
heading1: themedBlock(fallback.heading1),
heading2: themedBlock(fallback.heading2),
heading3: themedBlock(fallback.heading3),
heading4: themedBlock(fallback.heading4),
heading5: themedBlock(fallback.heading5),
heading6: themedBlock(fallback.heading6),
lists: themedBlock(fallback.lists, style: base),
quote: themedBlock(
fallback.quote,
style: blockStyle(fallback.quote.style, color: theme.textSecondary),
decoration: BoxDecoration(
border: BorderDirectional(
start: BorderSide(width: 4, color: theme.dividerColor),
),
),
),
code: themedBlock(
fallback.code,
style: fallback.code.style.copyWith(
color: theme.codeText,
fontFamily: AppTypography.monospaceFontFamily,
),
decoration: BoxDecoration(
color: theme.codeBackground,
border: Border.all(color: theme.codeBorder),
borderRadius:
fallback.code.decoration?.borderRadius ?? BorderRadius.circular(2),
),
),
inlineCode: InlineCodeThemeData(
style: inlineCodeStyle(fallback.inlineCode.style),
heading1: inlineCodeStyle(fallback.inlineCode.heading1),
heading2: inlineCodeStyle(fallback.inlineCode.heading2),
heading3: inlineCodeStyle(fallback.inlineCode.heading3),
backgroundColor: theme.codeBackground,
radius: fallback.inlineCode.radius,
),
horizontalRuleThemeData: HorizontalRuleThemeData(
height: fallback.horizontalRule.height,
thickness: fallback.horizontalRule.thickness,
color: theme.dividerColor,
),
bold: const TextStyle(fontWeight: FontWeight.bold),
italic: const TextStyle(fontStyle: FontStyle.italic),
link: TextStyle(
color: theme.buttonPrimary,
decoration: TextDecoration.underline,
),
);
}

/// Page for editing a note with OpenWebUI-style layout.
class NoteEditorPage extends ConsumerStatefulWidget {
final String noteId;
Expand Down Expand Up @@ -2264,24 +2362,7 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
/// Builds a Fleather theme derived from the app's typography and colours so
/// the rich-text editor matches the rest of the note UI.
FleatherThemeData _buildFleatherTheme(BuildContext context) {
final theme = context.conduitTheme;
final base = AppTypography.bodyLargeStyle.copyWith(
color: theme.textPrimary,
height: 1.8,
);
final fallback = FleatherThemeData.fallback(context);
return fallback.copyWith(
paragraph: TextBlockTheme(
style: base,
spacing: const VerticalSpacing(top: 0, bottom: 6),
),
bold: const TextStyle(fontWeight: FontWeight.bold),
italic: const TextStyle(fontStyle: FontStyle.italic),
link: TextStyle(
color: theme.buttonPrimary,
decoration: TextDecoration.underline,
),
);
return buildNoteEditorFleatherTheme(context);
}

/// Play an audio file attachment.
Expand Down
59 changes: 41 additions & 18 deletions lib/shared/widgets/markdown/markdown_preprocessor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class ConduitMarkdownPreprocessor {
multiLine: true,
dotAll: true,
);
static final _codeSpanOrFence = RegExp(r'(`+)([\s\S]*?)\1');
static final _allDetailsBlocks = RegExp(
r'<details[^>]*>[\s\S]*?</details>',
multiLine: true,
Expand Down Expand Up @@ -179,11 +180,28 @@ class ConduitMarkdownPreprocessor {
return input
.replaceAll('\r\n', '\n')
.transform(_stripLinkReferenceDefinitions)
.replaceAll(_reasoningBlocks, '')
.transform(
(content) => _removeMatchesOutsideCode(content, _reasoningBlocks),
)
.replaceAll(_multipleNewlines, '\n\n')
.trim();
}

/// Sanitizes message content for copying while omitting internal tool calls.
///
/// Tool-call markup inside code spans is preserved so documentation and
/// examples are copied faithfully. Ordinary `<details>` blocks are also left
/// untouched.
static String sanitizeForClipboard(String input) {
if (input.isEmpty) return input;

final sanitized = sanitize(input);
return _removeMatchesOutsideCode(
sanitized,
_toolCallBlocks,
).replaceAll(_multipleNewlines, '\n\n').trim();
}

/// Converts markdown to plain text for text-to-speech.
static String toPlainText(String input) {
if (input.trim().isEmpty) return '';
Expand Down Expand Up @@ -228,10 +246,7 @@ class ConduitMarkdownPreprocessor {
static String removeAllDetails(String input) {
if (input.isEmpty) return input;

return _replaceOutsideCode(
input,
(segment) => segment.replaceAll(_allDetailsBlocks, ''),
);
return _removeMatchesOutsideCode(input, _allDetailsBlocks);
}

/// Breaks long inline code spans for better wrapping.
Expand Down Expand Up @@ -304,26 +319,34 @@ class ConduitMarkdownPreprocessor {
static String _stripLinkReferenceDefinitions(String input) {
if (!input.contains(']:')) return input;

final stripped = _replaceOutsideCode(
input,
(segment) => segment.replaceAll(_linkReferenceDefinition, ''),
);
final stripped = _removeMatchesOutsideCode(input, _linkReferenceDefinition);
return stripped.replaceAll(_multipleNewlines, '\n\n').trim();
}

static String _openWebUiCleanText(String input) {
return _openWebUiRemoveFormattings(_removeEmojis(input.trim()));
}

static String _replaceOutsideCode(
String input,
String Function(String segment) replacer,
) {
return input.splitMapJoin(
RegExp(r'```[\s\S]*?```|`[\s\S]*?`'),
onMatch: (match) => match[0] ?? '',
onNonMatch: replacer,
);
static String _removeMatchesOutsideCode(String input, RegExp pattern) {
final codeSpans = <String>[];
var marker = '\u0000conduit-code-span-';
while (input.contains(marker)) {
marker = '\u0000$marker';
}

final masked = input.replaceAllMapped(_codeSpanOrFence, (match) {
final index = codeSpans.length;
codeSpans.add(match[0] ?? '');
return '$marker$index\u0000';
});
var output = masked.replaceAll(pattern, '');

// Placeholders inside removed matches no longer exist, so only code from
// the retained content is restored.
for (var index = 0; index < codeSpans.length; index++) {
output = output.replaceAll('$marker$index\u0000', codeSpans[index]);
}
return output;
}

static String _removeEmojis(String input) {
Expand Down
Loading