refactor: fix lints

This commit is contained in:
cogwheel0
2025-09-16 18:15:44 +05:30
parent a5d5f60ed7
commit f80930685c
12 changed files with 277 additions and 266 deletions

View File

@@ -209,7 +209,9 @@ final socketServiceProvider = Provider<SocketService?>((ref) {
final activeServer = ref.watch(activeServerProvider); final activeServer = ref.watch(activeServerProvider);
final token = ref.watch(authTokenProvider3); final token = ref.watch(authTokenProvider3);
final transportMode = ref.watch(appSettingsProvider).socketTransportMode; // 'auto' or 'ws' final transportMode = ref
.watch(appSettingsProvider)
.socketTransportMode; // 'auto' or 'ws'
return activeServer.maybeWhen( return activeServer.maybeWhen(
data: (server) { data: (server) {
@@ -223,7 +225,9 @@ final socketServiceProvider = Provider<SocketService?>((ref) {
// ignore unawaited_futures // ignore unawaited_futures
s.connect(); s.connect();
ref.onDispose(() { ref.onDispose(() {
try { s.dispose(); } catch (_) {} try {
s.dispose();
} catch (_) {}
}); });
return s; return s;
}, },
@@ -373,7 +377,8 @@ final defaultModelAutoSelectionProvider = Provider<void>((ref) {
} }
// Fallback: keep current selection or pick first available // Fallback: keep current selection or pick first available
selected ??= ref.read(selectedModelProvider) ?? selected ??=
ref.read(selectedModelProvider) ??
(models.isNotEmpty ? models.first : null); (models.isNotEmpty ? models.first : null);
if (selected != null) { if (selected != null) {
@@ -481,11 +486,11 @@ final conversationsProvider = FutureProvider<List<Conversation>>((ref) async {
conversationMap[conversation.id] = conversation.copyWith( conversationMap[conversation.id] = conversation.copyWith(
folderId: folderIdToUse, folderId: folderIdToUse,
); );
final _idPreview = conversation.id.length > 8 final idPreview = conversation.id.length > 8
? conversation.id.substring(0, 8) ? conversation.id.substring(0, 8)
: conversation.id; : conversation.id;
foundation.debugPrint( foundation.debugPrint(
'DEBUG: Updated conversation $_idPreview with folderId: $folderIdToUse (explicit: ${explicitFolderId != null})', 'DEBUG: Updated conversation $idPreview with folderId: $folderIdToUse (explicit: ${explicitFolderId != null})',
); );
} else { } else {
conversationMap[conversation.id] = conversation; conversationMap[conversation.id] = conversation;
@@ -547,11 +552,11 @@ final conversationsProvider = FutureProvider<List<Conversation>>((ref) async {
// Use map to prevent duplicates - this will overwrite if ID already exists // Use map to prevent duplicates - this will overwrite if ID already exists
conversationMap[toAdd.id] = toAdd; conversationMap[toAdd.id] = toAdd;
existingIds.add(toAdd.id); existingIds.add(toAdd.id);
final _idPreview = toAdd.id.length > 8 final idPreview = toAdd.id.length > 8
? toAdd.id.substring(0, 8) ? toAdd.id.substring(0, 8)
: toAdd.id; : toAdd.id;
foundation.debugPrint( foundation.debugPrint(
'DEBUG: Added missing conversation from folder fetch: $_idPreview -> folder ${folder.id}', 'DEBUG: Added missing conversation from folder fetch: $idPreview -> folder ${folder.id}',
); );
} else { } else {
// Create a minimal placeholder if not returned by folder API // Create a minimal placeholder if not returned by folder API
@@ -566,11 +571,11 @@ final conversationsProvider = FutureProvider<List<Conversation>>((ref) async {
// Use map to prevent duplicates // Use map to prevent duplicates
conversationMap[convId] = placeholder; conversationMap[convId] = placeholder;
existingIds.add(convId); existingIds.add(convId);
final _idPreview = convId.length > 8 final idPreview = convId.length > 8
? convId.substring(0, 8) ? convId.substring(0, 8)
: convId; : convId;
foundation.debugPrint( foundation.debugPrint(
'DEBUG: Added placeholder conversation for missing ID: $_idPreview -> folder ${folder.id}', 'DEBUG: Added placeholder conversation for missing ID: $idPreview -> folder ${folder.id}',
); );
} }
} }
@@ -694,16 +699,18 @@ final defaultModelProvider = FutureProvider<Model?>((ref) async {
if (userDefaultModelId != null && userDefaultModelId.isNotEmpty) { if (userDefaultModelId != null && userDefaultModelId.isNotEmpty) {
try { try {
// Exact ID match only // Exact ID match only
selectedModel = selectedModel = models.firstWhere(
models.firstWhere((model) => model.id == userDefaultModelId); (model) => model.id == userDefaultModelId,
);
foundation.debugPrint( foundation.debugPrint(
'DEBUG: Found user default model by ID: ${selectedModel.name}', 'DEBUG: Found user default model by ID: ${selectedModel.name}',
); );
} catch (e) { } catch (e) {
// Attempt a one-time migration if the stored value was a model name // Attempt a one-time migration if the stored value was a model name
// from older versions. Only migrate on exact, unique name match. // from older versions. Only migrate on exact, unique name match.
final nameMatches = final nameMatches = models
models.where((m) => m.name == userDefaultModelId).toList(); .where((m) => m.name == userDefaultModelId)
.toList();
if (nameMatches.length == 1) { if (nameMatches.length == 1) {
selectedModel = nameMatches.first; selectedModel = nameMatches.first;
foundation.debugPrint( foundation.debugPrint(
@@ -719,7 +726,8 @@ final defaultModelProvider = FutureProvider<Model?>((ref) async {
'DEBUG: User default model "$userDefaultModelId" not found by ID and ' 'DEBUG: User default model "$userDefaultModelId" not found by ID and '
'no unique name match. Ignoring.', 'no unique name match. Ignoring.',
); );
selectedModel = null; // Will fall back to server default or first model selectedModel =
null; // Will fall back to server default or first model
} }
} }
} }
@@ -732,14 +740,17 @@ final defaultModelProvider = FutureProvider<Model?>((ref) async {
if (defaultModelId != null && defaultModelId.isNotEmpty) { if (defaultModelId != null && defaultModelId.isNotEmpty) {
// Find the model that matches the default model ID (ID only) // Find the model that matches the default model ID (ID only)
try { try {
selectedModel = selectedModel = models.firstWhere(
models.firstWhere((model) => model.id == defaultModelId); (model) => model.id == defaultModelId,
);
foundation.debugPrint( foundation.debugPrint(
'DEBUG: Found server default model by ID: ${selectedModel.name}', 'DEBUG: Found server default model by ID: ${selectedModel.name}',
); );
} catch (e) { } catch (e) {
// If server returned a name instead of ID, attempt exact name match. // If server returned a name instead of ID, attempt exact name match.
final byName = models.where((m) => m.name == defaultModelId).toList(); final byName = models
.where((m) => m.name == defaultModelId)
.toList();
if (byName.length == 1) { if (byName.length == 1) {
selectedModel = byName.first; selectedModel = byName.first;
foundation.debugPrint( foundation.debugPrint(

View File

@@ -392,11 +392,11 @@ class ApiService {
debugPrint( debugPrint(
'🔍 DEBUG: Sample chat data fields: ${chatData.keys.toList()}', '🔍 DEBUG: Sample chat data fields: ${chatData.keys.toList()}',
); );
final _sampleStr = chatData.toString(); final samplePreviewSource = chatData.toString();
final _preview = _sampleStr.length > 200 final preview = samplePreviewSource.length > 200
? _sampleStr.substring(0, 200) ? samplePreviewSource.substring(0, 200)
: _sampleStr; : samplePreviewSource;
debugPrint('🔍 DEBUG: Sample chat data: $_preview...'); debugPrint('🔍 DEBUG: Sample chat data: $preview...');
} }
final conversation = _parseOpenWebUIChat(chatData); final conversation = _parseOpenWebUIChat(chatData);
@@ -475,8 +475,8 @@ class ApiService {
// Debug logging for folder assignment // Debug logging for folder assignment
if (folderId != null) { if (folderId != null) {
final _idPreview = id.length > 8 ? id.substring(0, 8) : id; final idPreview = id.length > 8 ? id.substring(0, 8) : id;
debugPrint('🔍 DEBUG: Conversation $_idPreview has folderId: $folderId'); debugPrint('🔍 DEBUG: Conversation $idPreview has folderId: $folderId');
} }
debugPrint( debugPrint(
@@ -3357,11 +3357,11 @@ class ApiService {
} else if (response.data is Map) { } else if (response.data is Map) {
DebugLogger.log(' Object keys: ${(response.data as Map).keys}'); DebugLogger.log(' Object keys: ${(response.data as Map).keys}');
} }
final _dataStr = response.data.toString(); final dataSampleSource = response.data.toString();
final _dataPreview = _dataStr.length > 200 final dataPreview = dataSampleSource.length > 200
? _dataStr.substring(0, 200) ? dataSampleSource.substring(0, 200)
: _dataStr; : dataSampleSource;
DebugLogger.log(' Sample data: $_dataPreview...'); DebugLogger.log(' Sample data: $dataPreview...');
} catch (e) { } catch (e) {
debugPrint('$endpoint - Error: $e'); debugPrint('$endpoint - Error: $e');
} }

View File

@@ -33,7 +33,8 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
// Message update callbacks // Message update callbacks
required void Function(String) appendToLastMessage, required void Function(String) appendToLastMessage,
required void Function(String) replaceLastMessageContent, required void Function(String) replaceLastMessageContent,
required void Function(ChatMessage Function(ChatMessage)) updateLastMessageWith, required void Function(ChatMessage Function(ChatMessage))
updateLastMessageWith,
required void Function() finishStreaming, required void Function() finishStreaming,
required List<ChatMessage> Function() getMessages, required List<ChatMessage> Function() getMessages,
}) { }) {
@@ -71,7 +72,7 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
bool suppressSocketContent = suppressSocketContentInitially; bool suppressSocketContent = suppressSocketContentInitially;
bool usingDynamicChannel = usingDynamicChannelInitially; bool usingDynamicChannel = usingDynamicChannelInitially;
void _updateImagesFromCurrentContent() { void updateImagesFromCurrentContent() {
try { try {
final msgs = getMessages(); final msgs = getMessages();
if (msgs.isEmpty || msgs.last.role != 'assistant') return; if (msgs.isEmpty || msgs.last.role != 'assistant') return;
@@ -236,13 +237,13 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
for (final call in tc) { for (final call in tc) {
if (call is Map<String, dynamic>) { if (call is Map<String, dynamic>) {
final fn = call['function']; final fn = call['function'];
final name = final name = (fn is Map && fn['name'] is String)
(fn is Map && fn['name'] is String)
? fn['name'] as String ? fn['name'] as String
: null; : null;
if (name is String && name.isNotEmpty) { if (name is String && name.isNotEmpty) {
final msgs = getMessages(); final msgs = getMessages();
final exists = (msgs.isNotEmpty) && final exists =
(msgs.isNotEmpty) &&
RegExp( RegExp(
r'<details\s+type=\"tool_calls\"[^>]*\bname=\"' + r'<details\s+type=\"tool_calls\"[^>]*\bname=\"' +
RegExp.escape(name) + RegExp.escape(name) +
@@ -262,20 +263,20 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
final content = delta['content']?.toString() ?? ''; final content = delta['content']?.toString() ?? '';
if (content.isNotEmpty) { if (content.isNotEmpty) {
appendToLastMessage(content); appendToLastMessage(content);
_updateImagesFromCurrentContent(); updateImagesFromCurrentContent();
} }
} }
} }
} catch (_) { } catch (_) {
if (s.isNotEmpty) { if (s.isNotEmpty) {
appendToLastMessage(s); appendToLastMessage(s);
_updateImagesFromCurrentContent(); updateImagesFromCurrentContent();
} }
} }
} else { } else {
if (s.isNotEmpty) { if (s.isNotEmpty) {
appendToLastMessage(s); appendToLastMessage(s);
_updateImagesFromCurrentContent(); updateImagesFromCurrentContent();
} }
} }
} else if (line is Map) { } else if (line is Map) {
@@ -320,10 +321,12 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
: null; : null;
if (name is String && name.isNotEmpty) { if (name is String && name.isNotEmpty) {
final msgs = getMessages(); final msgs = getMessages();
final exists = (msgs.isNotEmpty) && final exists =
(msgs.isNotEmpty) &&
RegExp( RegExp(
r'<details\s+type=\"tool_calls\"[^>]*\bname=\"' + r'<details\s+type=\"tool_calls\"[^>]*\bname=\"' +
RegExp.escape(name) + r'\"', RegExp.escape(name) +
r'\"',
multiLine: true, multiLine: true,
).hasMatch(msgs.last.content); ).hasMatch(msgs.last.content);
if (!exists) { if (!exists) {
@@ -353,10 +356,12 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
: null; : null;
if (name is String && name.isNotEmpty) { if (name is String && name.isNotEmpty) {
final msgs = getMessages(); final msgs = getMessages();
final exists = (msgs.isNotEmpty) && final exists =
(msgs.isNotEmpty) &&
RegExp( RegExp(
r'<details\s+type=\"tool_calls\"[^>]*\bname=\"' + r'<details\s+type=\"tool_calls\"[^>]*\bname=\"' +
RegExp.escape(name) + r'\"', RegExp.escape(name) +
r'\"',
multiLine: true, multiLine: true,
).hasMatch(msgs.last.content); ).hasMatch(msgs.last.content);
if (!exists) { if (!exists) {
@@ -372,7 +377,7 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
final content = delta['content']?.toString() ?? ''; final content = delta['content']?.toString() ?? '';
if (content.isNotEmpty) { if (content.isNotEmpty) {
appendToLastMessage(content); appendToLastMessage(content);
_updateImagesFromCurrentContent(); updateImagesFromCurrentContent();
} }
} }
} }
@@ -409,7 +414,9 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
final list = chatObj['messages']; final list = chatObj['messages'];
if (list is List) { if (list is List) {
final target = list.firstWhere( final target = list.firstWhere(
(m) => (m is Map && (m['id']?.toString() == assistantMessageId)), (m) =>
(m is Map &&
(m['id']?.toString() == assistantMessageId)),
orElse: () => null, orElse: () => null,
); );
if (target != null) { if (target != null) {
@@ -431,7 +438,8 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
final history = chatObj['history']; final history = chatObj['history'];
if (history is Map && history['messages'] is Map) { if (history is Map && history['messages'] is Map) {
final Map<String, dynamic> messagesMap = final Map<String, dynamic> messagesMap =
(history['messages'] as Map).cast<String, dynamic>(); (history['messages'] as Map)
.cast<String, dynamic>();
final msg = messagesMap[assistantMessageId]; final msg = messagesMap[assistantMessageId];
if (msg is Map) { if (msg is Map) {
final rawContent = msg['content']; final rawContent = msg['content'];
@@ -454,7 +462,8 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
replaceLastMessageContent(content); replaceLastMessageContent(content);
} }
} }
} catch (_) {} finally { } catch (_) {
} finally {
finishStreaming(); finishStreaming();
} }
}); });
@@ -483,21 +492,23 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
} }
if (content.isNotEmpty) { if (content.isNotEmpty) {
// Replace current assistant message with a readable error // Replace current assistant message with a readable error
replaceLastMessageContent('⚠️ ' + content); replaceLastMessageContent('⚠️ $content');
} }
} catch (_) {} } catch (_) {}
// Ensure UI exits streaming state // Ensure UI exits streaming state
finishStreaming(); finishStreaming();
} else if ((type == 'chat:message:delta' || type == 'message') && payload != null) { } else if ((type == 'chat:message:delta' || type == 'message') &&
payload != null) {
// Incremental message content over socket; respect suppression on SSE-driven flows // Incremental message content over socket; respect suppression on SSE-driven flows
if (!suppressSocketContent) { if (!suppressSocketContent) {
final content = payload['content']?.toString() ?? ''; final content = payload['content']?.toString() ?? '';
if (content.isNotEmpty) { if (content.isNotEmpty) {
appendToLastMessage(content); appendToLastMessage(content);
_updateImagesFromCurrentContent(); updateImagesFromCurrentContent();
} }
} }
} else if ((type == 'chat:message' || type == 'replace') && payload != null) { } else if ((type == 'chat:message' || type == 'replace') &&
payload != null) {
// Full message replacement over socket; respect suppression on SSE-driven flows // Full message replacement over socket; respect suppression on SSE-driven flows
if (!suppressSocketContent) { if (!suppressSocketContent) {
final content = payload['content']?.toString() ?? ''; final content = payload['content']?.toString() ?? '';
@@ -600,10 +611,9 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
} else if (type == 'event:status' && payload != null) { } else if (type == 'event:status' && payload != null) {
final status = payload['status']?.toString() ?? ''; final status = payload['status']?.toString() ?? '';
if (status.isNotEmpty) { if (status.isNotEmpty) {
updateLastMessageWith((m) => m.copyWith(metadata: { updateLastMessageWith(
...?m.metadata, (m) => m.copyWith(metadata: {...?m.metadata, 'status': status}),
'status': status, );
}));
} }
} else if (type == 'event:tool' && payload != null) { } else if (type == 'event:tool' && payload != null) {
// Accept files from both 'result' and 'files' // Accept files from both 'result' and 'files'
@@ -624,7 +634,7 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
final content = payload['content']?.toString() ?? ''; final content = payload['content']?.toString() ?? '';
if (content.isNotEmpty) { if (content.isNotEmpty) {
appendToLastMessage(content); appendToLastMessage(content);
_updateImagesFromCurrentContent(); updateImagesFromCurrentContent();
} }
} }
} catch (_) {} } catch (_) {}
@@ -640,7 +650,7 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
final content = payload['content']?.toString() ?? ''; final content = payload['content']?.toString() ?? '';
if (content.isNotEmpty) { if (content.isNotEmpty) {
appendToLastMessage(content); appendToLastMessage(content);
_updateImagesFromCurrentContent(); updateImagesFromCurrentContent();
} }
} }
} catch (_) {} } catch (_) {}
@@ -656,7 +666,9 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
} catch (_) {} } catch (_) {}
try { try {
final msgs = getMessages(); final msgs = getMessages();
if (msgs.isNotEmpty && msgs.last.role == 'assistant' && msgs.last.isStreaming) { if (msgs.isNotEmpty &&
msgs.last.role == 'assistant' &&
msgs.last.isStreaming) {
finishStreaming(); finishStreaming();
} }
} catch (_) {} } catch (_) {}
@@ -681,17 +693,21 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
} }
} }
if (isSearching && (chunk.contains('[/SEARCHING]') || chunk.contains('Search complete'))) { if (isSearching &&
(chunk.contains('[/SEARCHING]') ||
chunk.contains('Search complete'))) {
isSearching = false; isSearching = false;
updateLastMessageWith( updateLastMessageWith(
(message) => message.copyWith(metadata: {'webSearchActive': false}), (message) => message.copyWith(metadata: {'webSearchActive': false}),
); );
effectiveChunk = effectiveChunk.replaceAll('[SEARCHING]', '').replaceAll('[/SEARCHING]', ''); effectiveChunk = effectiveChunk
.replaceAll('[SEARCHING]', '')
.replaceAll('[/SEARCHING]', '');
} }
if (effectiveChunk.trim().isNotEmpty) { if (effectiveChunk.trim().isNotEmpty) {
appendToLastMessage(effectiveChunk); appendToLastMessage(effectiveChunk);
_updateImagesFromCurrentContent(); updateImagesFromCurrentContent();
} }
}, },
onDone: () async { onDone: () async {

View File

@@ -49,7 +49,9 @@ class ReasoningParser {
if (openingIdx >= 0 && !content.contains('</details>')) { if (openingIdx >= 0 && !content.contains('</details>')) {
final after = content.substring(openingIdx); final after = content.substring(openingIdx);
// Try to extract optional summary // Try to extract optional summary
final summaryMatch = RegExp(r'<summary>([^<]*)<\/summary>').firstMatch(after); final summaryMatch = RegExp(
r'<summary>([^<]*)<\/summary>',
).firstMatch(after);
final summary = (summaryMatch?.group(1) ?? '').trim(); final summary = (summaryMatch?.group(1) ?? '').trim();
final reasoning = after final reasoning = after
.replaceAll(RegExp(r'^<details[^>]*>'), '') .replaceAll(RegExp(r'^<details[^>]*>'), '')
@@ -80,7 +82,11 @@ class ReasoningParser {
for (final pair in tagPairs) { for (final pair in tagPairs) {
final start = RegExp.escape(pair[0]); final start = RegExp.escape(pair[0]);
final end = RegExp.escape(pair[1]); final end = RegExp.escape(pair[1]);
final tagRegex = RegExp('($start)([\s\S]*?)($end)', multiLine: true, dotAll: true); final tagRegex = RegExp(
'($start)(.*?)($end)',
multiLine: true,
dotAll: true,
);
final match = tagRegex.firstMatch(content); final match = tagRegex.firstMatch(content);
if (match != null) { if (match != null) {
final reasoning = (match.group(2) ?? '').trim(); final reasoning = (match.group(2) ?? '').trim();
@@ -144,7 +150,8 @@ class ReasoningParser {
if (nextDetails == -1 && nextRawStart == -1) { if (nextDetails == -1 && nextRawStart == -1) {
nextIdx = -1; nextIdx = -1;
kind = 'none'; kind = 'none';
} else if (nextDetails != -1 && (nextRawStart == -1 || nextDetails < nextRawStart)) { } else if (nextDetails != -1 &&
(nextRawStart == -1 || nextDetails < nextRawStart)) {
nextIdx = nextDetails; nextIdx = nextDetails;
kind = 'details'; kind = 'details';
} else { } else {
@@ -219,7 +226,9 @@ class ReasoningParser {
if (depth != 0) { if (depth != 0) {
// Unclosed; treat as streaming partial // Unclosed; treat as streaming partial
final after = content.substring(openEnd + 1); final after = content.substring(openEnd + 1);
final summaryMatch = RegExp(r'<summary>([^<]*)<\/summary>').firstMatch(after); final summaryMatch = RegExp(
r'<summary>([^<]*)<\/summary>',
).firstMatch(after);
final summary = (summaryMatch?.group(1) ?? '').trim(); final summary = (summaryMatch?.group(1) ?? '').trim();
final reasoning = after final reasoning = after
.replaceAll(RegExp(r'^\s*<summary>[\s\S]*?<\/summary>'), '') .replaceAll(RegExp(r'^\s*<summary>[\s\S]*?<\/summary>'), '')
@@ -238,8 +247,13 @@ class ReasoningParser {
break; break;
} else { } else {
// Closed block: extract inner content // Closed block: extract inner content
final inner = content.substring(openEnd + 1, i - 10); // without </details> final inner = content.substring(
final sumMatch = RegExp(r'<summary>([^<]*)<\/summary>').firstMatch(inner); openEnd + 1,
i - 10,
); // without </details>
final sumMatch = RegExp(
r'<summary>([^<]*)<\/summary>',
).firstMatch(inner);
final summary = (sumMatch?.group(1) ?? '').trim(); final summary = (sumMatch?.group(1) ?? '').trim();
final reasoning = inner final reasoning = inner
.replaceAll(RegExp(r'<summary>[\s\S]*?<\/summary>'), '') .replaceAll(RegExp(r'<summary>[\s\S]*?<\/summary>'), '')

View File

@@ -47,6 +47,7 @@ class ToolCallsParser {
.replaceAll('&amp;', '&') .replaceAll('&amp;', '&')
.replaceAll('&#38;', '&'); .replaceAll('&#38;', '&');
} }
/// Represents a mixed stream of text and tool-call entries in original order /// Represents a mixed stream of text and tool-call entries in original order
/// as they appeared in the content. /// as they appeared in the content.
static List<ToolCallsSegment>? segments(String content) { static List<ToolCallsSegment>? segments(String content) {
@@ -97,7 +98,9 @@ class ToolCallsParser {
i = nextOpen + 8; // '<details' i = nextOpen + 8; // '<details'
} else { } else {
depth--; depth--;
i = (nextClose != -1) ? nextClose + 10 : content.length; // '</details>' i = (nextClose != -1)
? nextClose + 10
: content.length; // '</details>'
} }
} }
@@ -105,17 +108,16 @@ class ToolCallsParser {
if (isToolCalls) { if (isToolCalls) {
// Decode attributes for tool call tile // Decode attributes for tool call tile
dynamic _decode(String? s) { dynamic decodeAttribute(String? source) {
if (s == null || s.isEmpty) return null; if (source == null || source.isEmpty) return null;
try { try {
final unescaped = _unescapeHtml(s); final unescaped = _unescapeHtml(source);
return json.decode(unescaped); return json.decode(unescaped);
} catch (_) { } catch (_) {
// If JSON decode fails, return unescaped string for display
try { try {
return _unescapeHtml(s); return _unescapeHtml(source);
} catch (_) { } catch (_) {
return s; return source;
} }
} }
} }
@@ -123,9 +125,9 @@ class ToolCallsParser {
final id = (attrs['id'] ?? ''); final id = (attrs['id'] ?? '');
final name = (attrs['name'] ?? 'tool'); final name = (attrs['name'] ?? 'tool');
final done = (attrs['done'] == 'true'); final done = (attrs['done'] == 'true');
final args = _decode(attrs['arguments']); final args = decodeAttribute(attrs['arguments']);
final result = _decode(attrs['result']); final result = decodeAttribute(attrs['result']);
final files = _decode(attrs['files']); final files = decodeAttribute(attrs['files']);
segs.add( segs.add(
ToolCallsSegment.entry( ToolCallsSegment.entry(
@@ -207,7 +209,9 @@ class ToolCallsParser {
if (parsed == null) return content; if (parsed == null) return content;
final buf = StringBuffer(); final buf = StringBuffer();
for (final c in parsed.toolCalls) { for (final c in parsed.toolCalls) {
buf.writeln(c.done ? 'Tool Executed: ${c.name}' : 'Running tool: ${c.name}'); buf.writeln(
c.done ? 'Tool Executed: ${c.name}' : 'Running tool: ${c.name}',
);
final args = _prettyMaybe(c.arguments, max: 400); final args = _prettyMaybe(c.arguments, max: 400);
final res = _prettyMaybe(c.result, max: 800); final res = _prettyMaybe(c.result, max: 800);
if (args.isNotEmpty) { if (args.isNotEmpty) {
@@ -239,8 +243,8 @@ class ToolCallsParser {
/// Sanitize assistant/user content before sending to the API, mirroring /// Sanitize assistant/user content before sending to the API, mirroring
/// the web client's `processDetails` behavior: /// the web client's `processDetails` behavior:
/// - Remove <details type="reasoning"> and <details type="code_interpreter"> blocks /// - Remove &lt;details type="reasoning"&gt; and &lt;details type="code_interpreter"&gt; blocks
/// - Replace <details type="tool_calls" ...>...</details> blocks with the /// - Replace &lt;details type="tool_calls" ...&gt;...&lt;/details&gt; blocks with the
/// JSON-serialized `result` attribute (as a quoted string) when available; /// JSON-serialized `result` attribute (as a quoted string) when available;
/// otherwise replace with an empty string. /// otherwise replace with an empty string.
static String sanitizeForApi(String content) { static String sanitizeForApi(String content) {
@@ -251,7 +255,7 @@ class ToolCallsParser {
for (final t in removeTypes) { for (final t in removeTypes) {
content = content.replaceAll( content = content.replaceAll(
RegExp( RegExp(
'<details\\s+type=\"${t}\"[^>]*>[\\s\\S]*?<\\/details>', '<details\\s+type="$t"[^>]*>[\\s\\S]*?</details>',
multiLine: true, multiLine: true,
dotAll: true, dotAll: true,
), ),

View File

@@ -142,7 +142,7 @@ class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
final msgId = last.id; final msgId = last.id;
final chatId = activeConv?.id; final chatId = activeConv?.id;
if (apiSvc != null && chatId != null && chatId.isNotEmpty) { if (apiSvc != null && chatId != null && chatId.isNotEmpty) {
final resp = await apiSvc.dio.get('/api/v1/chats/' + chatId); final resp = await apiSvc.dio.get('/api/v1/chats/$chatId');
final data = resp.data as Map<String, dynamic>; final data = resp.data as Map<String, dynamic>;
String content = ''; String content = '';
final chatObj = data['chat'] as Map<String, dynamic>?; final chatObj = data['chat'] as Map<String, dynamic>?;
@@ -948,11 +948,11 @@ Future<void> regenerateMessage(
final bool isBackgroundWebSearchPre = webSearchEnabled; final bool isBackgroundWebSearchPre = webSearchEnabled;
// Dispatch using unified send pipeline (background tools flow) // Dispatch using unified send pipeline (background tools flow)
final bool _isBackgroundFlowPre = final bool isBackgroundFlowPre =
isBackgroundToolsFlowPre || isBackgroundToolsFlowPre ||
isBackgroundWebSearchPre || isBackgroundWebSearchPre ||
imageGenerationEnabled; imageGenerationEnabled;
final bool _passSocketSession = wantSessionBinding && _isBackgroundFlowPre; final bool passSocketSession = wantSessionBinding && isBackgroundFlowPre;
final response = api!.sendMessage( final response = api!.sendMessage(
messages: conversationMessages, messages: conversationMessages,
model: selectedModel.id, model: selectedModel.id,
@@ -961,7 +961,7 @@ Future<void> regenerateMessage(
enableWebSearch: webSearchEnabled, enableWebSearch: webSearchEnabled,
enableImageGeneration: imageGenerationEnabled, enableImageGeneration: imageGenerationEnabled,
modelItem: modelItem, modelItem: modelItem,
sessionIdOverride: _passSocketSession ? socketSessionId : null, sessionIdOverride: passSocketSession ? socketSessionId : null,
toolServers: toolServers, toolServers: toolServers,
backgroundTasks: bgTasks, backgroundTasks: bgTasks,
responseMessageId: assistantMessageId, responseMessageId: assistantMessageId,
@@ -971,7 +971,7 @@ Future<void> regenerateMessage(
final sessionId = response.sessionId; final sessionId = response.sessionId;
// New unified streaming path via helper; bypass old inline socket block // New unified streaming path via helper; bypass old inline socket block
final bool _isBackgroundFlow = final bool isBackgroundFlow =
isBackgroundToolsFlowPre || isBackgroundToolsFlowPre ||
isBackgroundWebSearchPre || isBackgroundWebSearchPre ||
imageGenerationEnabled || imageGenerationEnabled ||
@@ -982,7 +982,7 @@ Future<void> regenerateMessage(
) { ) {
final mergedMeta = { final mergedMeta = {
if (m.metadata != null) ...m.metadata!, if (m.metadata != null) ...m.metadata!,
'backgroundFlow': _isBackgroundFlow, 'backgroundFlow': isBackgroundFlow,
if (isBackgroundWebSearchPre) 'webSearchFlow': true, if (isBackgroundWebSearchPre) 'webSearchFlow': true,
if (imageGenerationEnabled) 'imageGenerationFlow': true, if (imageGenerationEnabled) 'imageGenerationFlow': true,
}; };
@@ -990,11 +990,11 @@ Future<void> regenerateMessage(
}); });
} catch (_) {} } catch (_) {}
final _sendStreamSub = attachUnifiedChunkedStreaming( final sendStreamSub = attachUnifiedChunkedStreaming(
stream: stream, stream: stream,
webSearchEnabled: webSearchEnabled, webSearchEnabled: webSearchEnabled,
isBackgroundFlow: _isBackgroundFlow, isBackgroundFlow: isBackgroundFlow,
suppressSocketContentInitially: !_isBackgroundFlow, suppressSocketContentInitially: !isBackgroundFlow,
usingDynamicChannelInitially: false, usingDynamicChannelInitially: false,
assistantMessageId: assistantMessageId, assistantMessageId: assistantMessageId,
modelId: selectedModel.id, modelId: selectedModel.id,
@@ -1014,7 +1014,7 @@ Future<void> regenerateMessage(
ref.read(chatMessagesProvider.notifier).finishStreaming(), ref.read(chatMessagesProvider.notifier).finishStreaming(),
getMessages: () => ref.read(chatMessagesProvider), getMessages: () => ref.read(chatMessagesProvider),
); );
ref.read(chatMessagesProvider.notifier).setMessageStream(_sendStreamSub); ref.read(chatMessagesProvider.notifier).setMessageStream(sendStreamSub);
return; return;
} catch (e) { } catch (e) {
rethrow; rethrow;
@@ -1482,7 +1482,7 @@ Future<void> _sendMessageInternal(
if (socketService != null) { if (socketService != null) {
// Activity-based watchdog for chat/channel events (resets on activity) // Activity-based watchdog for chat/channel events (resets on activity)
final _chatWatchdog = InactivityWatchdog( final chatWatchdog = InactivityWatchdog(
window: const Duration(minutes: 5), window: const Duration(minutes: 5),
onTimeout: () { onTimeout: () {
try { try {
@@ -1510,7 +1510,7 @@ Future<void> _sendMessageInternal(
DebugLogger.stream('Socket chat-events: type=$type'); DebugLogger.stream('Socket chat-events: type=$type');
// Any chat event indicates activity; reset inactivity watchdog // Any chat event indicates activity; reset inactivity watchdog
// (watchdog defined below, near handler registration) // (watchdog defined below, near handler registration)
_chatWatchdog.ping(); chatWatchdog.ping();
if (type == 'chat:completion' && payload != null) { if (type == 'chat:completion' && payload != null) {
if (payload is Map<String, dynamic>) { if (payload is Map<String, dynamic>) {
// Provider may emit tool_calls at the top level // Provider may emit tool_calls at the top level
@@ -1529,9 +1529,7 @@ Future<void> _sendMessageInternal(
final exists = final exists =
(msgs.isNotEmpty) && (msgs.isNotEmpty) &&
RegExp( RegExp(
r'<details\s+type=\"tool_calls\"[^>]*\bname=\"' + '<details\\s+type="tool_calls"[^>]*\\bname="${RegExp.escape(name)}"',
RegExp.escape(name) +
r'\"',
multiLine: true, multiLine: true,
).hasMatch(msgs.last.content); ).hasMatch(msgs.last.content);
if (!exists) { if (!exists) {
@@ -1567,9 +1565,7 @@ Future<void> _sendMessageInternal(
final exists = final exists =
(msgs.isNotEmpty) && (msgs.isNotEmpty) &&
RegExp( RegExp(
r'<details\s+type=\"tool_calls\"[^>]*\bname=\"' + '<details\\s+type="tool_calls"[^>]*\\bname="${RegExp.escape(name)}"',
RegExp.escape(name) +
r'\"',
multiLine: true, multiLine: true,
).hasMatch(msgs.last.content); ).hasMatch(msgs.last.content);
if (!exists) { if (!exists) {
@@ -1628,8 +1624,8 @@ Future<void> _sendMessageInternal(
socketService.offChatEvents(); socketService.offChatEvents();
} catch (_) {} } catch (_) {}
try { try {
_chatWatchdog.ping(); // ensure timer exists chatWatchdog.ping(); // ensure timer exists
_chatWatchdog.stop(); chatWatchdog.stop();
} catch (_) {} } catch (_) {}
// Notify server that chat is completed (mirrors web client) // Notify server that chat is completed (mirrors web client)
@@ -1744,7 +1740,7 @@ Future<void> _sendMessageInternal(
// Normal path: finish now // Normal path: finish now
ref.read(chatMessagesProvider.notifier).finishStreaming(); ref.read(chatMessagesProvider.notifier).finishStreaming();
try { try {
_chatWatchdog.stop(); chatWatchdog.stop();
} catch (_) {} } catch (_) {}
} }
} }
@@ -1767,7 +1763,7 @@ Future<void> _sendMessageInternal(
final s = line.trim(); final s = line.trim();
// Dynamic channel activity // Dynamic channel activity
try { try {
_chatWatchdog.ping(); chatWatchdog.ping();
} catch (_) {} } catch (_) {}
DebugLogger.stream( DebugLogger.stream(
'Socket [$channel] line=${s.length > 160 ? '${s.substring(0, 160)}' : s}', 'Socket [$channel] line=${s.length > 160 ? '${s.substring(0, 160)}' : s}',
@@ -1850,9 +1846,7 @@ Future<void> _sendMessageInternal(
final exists = final exists =
(msgs.isNotEmpty) && (msgs.isNotEmpty) &&
RegExp( RegExp(
r'<details\\s+type=\"tool_calls\"[^>]*\\bname=\"' + '<details\\s+type="tool_calls"[^>]*\\bname="${RegExp.escape(name)}"',
RegExp.escape(name) +
r'\"',
multiLine: true, multiLine: true,
).hasMatch(msgs.last.content); ).hasMatch(msgs.last.content);
if (!exists) { if (!exists) {
@@ -1944,7 +1938,7 @@ Future<void> _sendMessageInternal(
if (content.isNotEmpty) { if (content.isNotEmpty) {
ref ref
.read(chatMessagesProvider.notifier) .read(chatMessagesProvider.notifier)
.replaceLastMessageContent('⚠️ ' + content); .replaceLastMessageContent('⚠️ $content');
} }
} catch (_) {} } catch (_) {}
ref.read(chatMessagesProvider.notifier).finishStreaming(); ref.read(chatMessagesProvider.notifier).finishStreaming();
@@ -2060,7 +2054,7 @@ Future<void> _sendMessageInternal(
.read(chatMessagesProvider.notifier) .read(chatMessagesProvider.notifier)
.appendToLastMessage(content); .appendToLastMessage(content);
_updateImagesFromCurrentContent(ref); _updateImagesFromCurrentContent(ref);
_chatWatchdog.ping(); chatWatchdog.ping();
} }
} }
} catch (_) {} } catch (_) {}
@@ -2068,7 +2062,7 @@ Future<void> _sendMessageInternal(
socketService.onChannelEvents(channelEventsHandler); socketService.onChannelEvents(channelEventsHandler);
// Start activity watchdog // Start activity watchdog
_chatWatchdog.ping(); chatWatchdog.ping();
} }
// Prepare streaming and background handling // Prepare streaming and background handling
@@ -2123,14 +2117,14 @@ Future<void> _sendMessageInternal(
// Helpers were defined above // Helpers were defined above
int _chunkSeq = 0; int chunkSeq = 0;
final streamSubscription = persistentController.stream.listen( final streamSubscription = persistentController.stream.listen(
(chunk) { (chunk) {
_chunkSeq += 1; chunkSeq += 1;
try { try {
persistentService.updateStreamProgress( persistentService.updateStreamProgress(
streamId, streamId,
chunkSequence: _chunkSeq, chunkSequence: chunkSeq,
appendedContent: chunk, appendedContent: chunk,
); );
} catch (_) {} } catch (_) {}
@@ -3030,7 +3024,7 @@ void _attachSocketStreamingHandlers({
final api = ref.read(apiServiceProvider); final api = ref.read(apiServiceProvider);
// Activity-based watchdog for socket-driven streaming (resets on activity) // Activity-based watchdog for socket-driven streaming (resets on activity)
final _socketWatchdog = InactivityWatchdog( final socketWatchdog = InactivityWatchdog(
window: const Duration(minutes: 5), window: const Duration(minutes: 5),
onTimeout: () { onTimeout: () {
try { try {
@@ -3054,7 +3048,7 @@ void _attachSocketStreamingHandlers({
if (line is String) { if (line is String) {
final s = line.trim(); final s = line.trim();
// Any socket line is activity // Any socket line is activity
_socketWatchdog.ping(); socketWatchdog.ping();
if (s == '[DONE]' || s == 'DONE') { if (s == '[DONE]' || s == 'DONE') {
try { try {
socketService.offEvent(channel); socketService.offEvent(channel);
@@ -3072,7 +3066,7 @@ void _attachSocketStreamingHandlers({
); );
} catch (_) {} } catch (_) {}
ref.read(chatMessagesProvider.notifier).finishStreaming(); ref.read(chatMessagesProvider.notifier).finishStreaming();
_socketWatchdog.stop(); socketWatchdog.stop();
return; return;
} }
if (s.startsWith('data:')) { if (s.startsWith('data:')) {
@@ -3094,7 +3088,7 @@ void _attachSocketStreamingHandlers({
); );
} catch (_) {} } catch (_) {}
ref.read(chatMessagesProvider.notifier).finishStreaming(); ref.read(chatMessagesProvider.notifier).finishStreaming();
_socketWatchdog.stop(); socketWatchdog.stop();
return; return;
} }
try { try {
@@ -3118,9 +3112,7 @@ void _attachSocketStreamingHandlers({
final exists = final exists =
(msgs.isNotEmpty) && (msgs.isNotEmpty) &&
RegExp( RegExp(
r'<details\s+type=\"tool_calls\"[^>]*\bname=\"' + '<details\\s+type="tool_calls"[^>]*\\bname="${RegExp.escape(name)}"',
RegExp.escape(name) +
r'\"',
multiLine: true, multiLine: true,
).hasMatch(msgs.last.content); ).hasMatch(msgs.last.content);
if (!exists) { if (!exists) {
@@ -3157,13 +3149,13 @@ void _attachSocketStreamingHandlers({
} }
} }
} else if (line is Map) { } else if (line is Map) {
_socketWatchdog.ping(); socketWatchdog.ping();
if (line['done'] == true) { if (line['done'] == true) {
try { try {
socketService.offEvent(channel); socketService.offEvent(channel);
} catch (_) {} } catch (_) {}
ref.read(chatMessagesProvider.notifier).finishStreaming(); ref.read(chatMessagesProvider.notifier).finishStreaming();
_socketWatchdog.stop(); socketWatchdog.stop();
return; return;
} }
} }
@@ -3172,7 +3164,7 @@ void _attachSocketStreamingHandlers({
socketService.onEvent(channel, handler); socketService.onEvent(channel, handler);
// Start activity watchdog now that handler is attached // Start activity watchdog now that handler is attached
_socketWatchdog.ping(); socketWatchdog.ping();
} }
void chatHandler(Map<String, dynamic> ev) { void chatHandler(Map<String, dynamic> ev) {
@@ -3198,9 +3190,7 @@ void _attachSocketStreamingHandlers({
final exists = final exists =
(msgs.isNotEmpty) && (msgs.isNotEmpty) &&
RegExp( RegExp(
r'<details\s+type=\"tool_calls\"[^>]*\bname=\"' + '<details\\s+type="tool_calls"[^>]*\\bname="${RegExp.escape(name)}"',
RegExp.escape(name) +
r'\"',
multiLine: true, multiLine: true,
).hasMatch(msgs.last.content); ).hasMatch(msgs.last.content);
if (!exists) { if (!exists) {
@@ -3235,9 +3225,7 @@ void _attachSocketStreamingHandlers({
final exists = final exists =
(msgs.isNotEmpty) && (msgs.isNotEmpty) &&
RegExp( RegExp(
r'<details\s+type=\"tool_calls\"[^>]*\bname=\"' + '<details\\s+type="tool_calls"[^>]*\\bname="${RegExp.escape(name)}"',
RegExp.escape(name) +
r'\"',
multiLine: true, multiLine: true,
).hasMatch(msgs.last.content); ).hasMatch(msgs.last.content);
if (!exists) { if (!exists) {
@@ -3267,7 +3255,7 @@ void _attachSocketStreamingHandlers({
socketService.offChatEvents(); socketService.offChatEvents();
} catch (_) {} } catch (_) {}
try { try {
_socketWatchdog.stop(); socketWatchdog.stop();
} catch (_) {} } catch (_) {}
try { try {
unawaited( unawaited(
@@ -3421,7 +3409,7 @@ void _attachSocketStreamingHandlers({
socketService.onChatEvents(chatHandler); socketService.onChatEvents(chatHandler);
socketService.onChannelEvents(channelEventsHandler); socketService.onChannelEvents(channelEventsHandler);
// Start activity watchdog for chat/channel events // Start activity watchdog for chat/channel events
_socketWatchdog.ping(); socketWatchdog.ping();
} }
// ========== Tool Servers (OpenAPI) Helpers ========== // ========== Tool Servers (OpenAPI) Helpers ==========
@@ -3495,9 +3483,10 @@ Map<String, dynamic>? _resolveRef(
final section = components?[type]; final section = components?[type];
if (section is Map<String, dynamic>) { if (section is Map<String, dynamic>) {
final schema = section[name]; final schema = section[name];
if (schema is Map<String, dynamic>) if (schema is Map<String, dynamic>) {
return Map<String, dynamic>.from(schema); return Map<String, dynamic>.from(schema);
} }
}
return null; return null;
} }
@@ -3515,12 +3504,14 @@ Map<String, dynamic> _resolveSchemaSimple(
final out = <String, dynamic>{}; final out = <String, dynamic>{};
if (type is String) { if (type is String) {
out['type'] = type; out['type'] = type;
if (schema['description'] != null) if (schema['description'] != null) {
out['description'] = schema['description']; out['description'] = schema['description'];
}
if (type == 'object') { if (type == 'object') {
out['properties'] = <String, dynamic>{}; out['properties'] = <String, dynamic>{};
if (schema['required'] is List) if (schema['required'] is List) {
out['required'] = List.from(schema['required']); out['required'] = List.from(schema['required']);
}
final props = schema['properties']; final props = schema['properties'];
if (props is Map<String, dynamic>) { if (props is Map<String, dynamic>) {
props.forEach((k, v) { props.forEach((k, v) {

View File

@@ -557,15 +557,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
}); });
} }
// TODO: Implement select all functionality when needed
// void _selectAllMessages() {
// final messages = ref.read(chatMessagesProvider);
// setState(() {
// _selectedMessageIds.clear();
// _selectedMessageIds.addAll(messages.map((m) => m.id));
// });
// }
void _clearSelection() { void _clearSelection() {
setState(() { setState(() {
_selectedMessageIds.clear(); _selectedMessageIds.clear();
@@ -752,8 +743,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
modelName: displayModelName, modelName: displayModelName,
onCopy: () => _copyMessage(message.content), onCopy: () => _copyMessage(message.content),
onRegenerate: () => _regenerateMessage(message), onRegenerate: () => _regenerateMessage(message),
onLike: () => _likeMessage(message),
onDislike: () => _dislikeMessage(message),
); );
} else { } else {
messageWidget = assistant.AssistantMessageWidget( messageWidget = assistant.AssistantMessageWidget(
@@ -763,8 +752,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
modelName: displayModelName, modelName: displayModelName,
onCopy: () => _copyMessage(message.content), onCopy: () => _copyMessage(message.content),
onRegenerate: () => _regenerateMessage(message), onRegenerate: () => _regenerateMessage(message),
onLike: () => _likeMessage(message),
onDislike: () => _dislikeMessage(message),
); );
} }
@@ -840,14 +827,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// Inline editing handled by UserMessageBubble. Dialog flow removed. // Inline editing handled by UserMessageBubble. Dialog flow removed.
void _likeMessage(dynamic message) {
// TODO: Implement message liking
}
void _dislikeMessage(dynamic message) {
// TODO: Implement message disliking
}
Widget _buildEmptyState(ThemeData theme) { Widget _buildEmptyState(ThemeData theme) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final currentUserAsync = ref.watch(currentUserProvider); final currentUserAsync = ref.watch(currentUserProvider);
@@ -1575,13 +1554,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
}); });
} }
// TODO: Implement chat options when needed
// void _showChatOptions() {
// ScaffoldMessenger.of(
// context,
// ).showSnackBar(const SnackBar(content: Text('Chat options coming soon!')));
// }
void _deleteSelectedMessages() { void _deleteSelectedMessages() {
final selectedMessages = _getSelectedMessages(); final selectedMessages = _getSelectedMessages();
if (selectedMessages.isEmpty) return; if (selectedMessages.isEmpty) return;
@@ -1594,7 +1566,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
isDestructive: true, isDestructive: true,
).then((confirmed) async { ).then((confirmed) async {
if (confirmed == true) { if (confirmed == true) {
// TODO: Implement message removal
// for (final selectedMessage in selectedMessages) { // for (final selectedMessage in selectedMessages) {
// ref.read(chatMessagesProvider.notifier).removeMessage(selectedMessage.id); // ref.read(chatMessagesProvider.notifier).removeMessage(selectedMessage.id);
// } // }

View File

@@ -181,10 +181,12 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
final isExpanded = _expandedToolIds.contains(tc.id); final isExpanded = _expandedToolIds.contains(tc.id);
final theme = context.conduitTheme; final theme = context.conduitTheme;
String _pretty(dynamic v, {int max = 1200}) { String pretty(dynamic v, {int max = 1200}) {
try { try {
final pretty = const JsonEncoder.withIndent(' ').convert(v); final formatted = const JsonEncoder.withIndent(' ').convert(v);
return pretty.length > max ? '${pretty.substring(0, max)}\n' : pretty; return formatted.length > max
? '${formatted.substring(0, max)}\n'
: formatted;
} catch (_) { } catch (_) {
final s = v?.toString() ?? ''; final s = v?.toString() ?? '';
return s.length > max ? '${s.substring(0, max)}' : s; return s.length > max ? '${s.substring(0, max)}' : s;
@@ -233,7 +235,9 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
), ),
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.xs),
Icon( Icon(
tc.done ? Icons.build_circle_outlined : Icons.play_circle_outline, tc.done
? Icons.build_circle_outlined
: Icons.play_circle_outline,
size: 14, size: 14,
color: theme.buttonPrimary, color: theme.buttonPrimary,
), ),
@@ -281,7 +285,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
), ),
const SizedBox(height: Spacing.xxs), const SizedBox(height: Spacing.xxs),
SelectableText( SelectableText(
_pretty(tc.arguments), pretty(tc.arguments),
style: TextStyle( style: TextStyle(
fontSize: AppTypography.bodySmall, fontSize: AppTypography.bodySmall,
color: theme.textSecondary, color: theme.textSecondary,
@@ -303,7 +307,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
), ),
const SizedBox(height: Spacing.xxs), const SizedBox(height: Spacing.xxs),
SelectableText( SelectableText(
_pretty(tc.result), pretty(tc.result),
style: TextStyle( style: TextStyle(
fontSize: AppTypography.bodySmall, fontSize: AppTypography.bodySmall,
color: theme.textSecondary, color: theme.textSecondary,
@@ -315,8 +319,9 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
], ],
), ),
), ),
crossFadeState: crossFadeState: isExpanded
isExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, ? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
), ),
], ],
@@ -363,7 +368,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
} }
bool get _hasRenderableSegments { bool get _hasRenderableSegments {
bool _textRenderable(String t) { bool textRenderable(String t) {
String cleaned = t; String cleaned = t;
// Hide tool_calls blocks entirely // Hide tool_calls blocks entirely
cleaned = cleaned.replaceAll( cleaned = cleaned.replaceAll(
@@ -398,7 +403,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
if (seg.isTool && seg.toolCall != null) return true; if (seg.isTool && seg.toolCall != null) return true;
if (seg.isReasoning && seg.reasoning != null) return true; if (seg.isReasoning && seg.reasoning != null) return true;
final text = seg.text ?? ''; final text = seg.text ?? '';
if (_textRenderable(text)) return true; if (textRenderable(text)) return true;
} }
return false; return false;
} }
@@ -507,7 +512,8 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
), ),
); );
}, },
child: (widget.isStreaming && child:
(widget.isStreaming &&
!_hasRenderableSegments && !_hasRenderableSegments &&
_allowTypingIndicator) _allowTypingIndicator)
? KeyedSubtree( ? KeyedSubtree(
@@ -566,8 +572,18 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
); );
// Remove raw <think>...</think> or <reasoning>...</reasoning> tags in text // Remove raw <think>...</think> or <reasoning>...</reasoning> tags in text
cleaned = cleaned cleaned = cleaned
.replaceAll(RegExp(r'<think>[\s\S]*?<\/think>', multiLine: true, dotAll: true), '') .replaceAll(
.replaceAll(RegExp(r'<reasoning>[\s\S]*?<\/reasoning>', multiLine: true, dotAll: true), ''); RegExp(r'<think>[\s\S]*?<\/think>', multiLine: true, dotAll: true),
'',
)
.replaceAll(
RegExp(
r'<reasoning>[\s\S]*?<\/reasoning>',
multiLine: true,
dotAll: true,
),
'',
);
// If there's an unclosed <details>, drop the tail to avoid raw tags. // If there's an unclosed <details>, drop the tail to avoid raw tags.
final lastOpen = cleaned.lastIndexOf('<details'); final lastOpen = cleaned.lastIndexOf('<details');
@@ -699,7 +715,8 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
maxWidth: 500, maxWidth: 500,
maxHeight: 400, maxHeight: 400,
), ),
disableAnimation: false, // Keep animations enabled to prevent black display disableAnimation:
false, // Keep animations enabled to prevent black display
); );
}, },
), ),
@@ -722,7 +739,8 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
maxWidth: imageCount == 2 ? 245 : 160, maxWidth: imageCount == 2 ? 245 : 160,
maxHeight: imageCount == 2 ? 245 : 160, maxHeight: imageCount == 2 ? 245 : 160,
), ),
disableAnimation: false, // Keep animations enabled to prevent black display disableAnimation:
false, // Keep animations enabled to prevent black display
); );
}).toList(), }).toList(),
), ),
@@ -766,10 +784,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
return Container( return Container(
width: dotSize, width: dotSize,
height: dotSize, height: dotSize,
decoration: BoxDecoration( decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
color: dotColor,
shape: BoxShape.circle,
),
) )
.animate(onPlay: (controller) => controller.repeat()) .animate(onPlay: (controller) => controller.repeat())
.then(delay: delay) .then(delay: delay)
@@ -818,10 +833,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
return Container( return Container(
width: dotSize, width: dotSize,
height: dotSize, height: dotSize,
decoration: BoxDecoration( decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
color: dotColor,
shape: BoxShape.circle,
),
) )
.animate(onPlay: (controller) => controller.repeat()) .animate(onPlay: (controller) => controller.repeat())
.then(delay: delay) .then(delay: delay)
@@ -859,8 +871,6 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
); );
} }
Widget _buildActionButtons() { Widget _buildActionButtons() {
final isErrorMessage = final isErrorMessage =
widget.message.content.contains('⚠️') || widget.message.content.contains('⚠️') ||
@@ -914,7 +924,8 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
String headerText() { String headerText() {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final hasSummary = rc.summary.isNotEmpty; final hasSummary = rc.summary.isNotEmpty;
final isThinkingSummary = rc.summary.trim().toLowerCase() == 'thinking…' || final isThinkingSummary =
rc.summary.trim().toLowerCase() == 'thinking…' ||
rc.summary.trim().toLowerCase() == 'thinking...'; rc.summary.trim().toLowerCase() == 'thinking...';
if (widget.isStreaming) { if (widget.isStreaming) {
return hasSummary ? rc.summary : l10n.thinking; return hasSummary ? rc.summary : l10n.thinking;
@@ -1012,8 +1023,9 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
), ),
), ),
), ),
crossFadeState: crossFadeState: isExpanded
isExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, ? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
), ),
], ],

View File

@@ -131,7 +131,9 @@ class _EnhancedAttachmentState extends ConsumerState<EnhancedAttachment> {
if (path == null) return; if (path == null) return;
final filename = (_fileInfo?['filename'] ?? _fileInfo?['name'] ?? 'file') final filename = (_fileInfo?['filename'] ?? _fileInfo?['name'] ?? 'file')
.toString(); .toString();
await Share.shareXFiles([XFile(path, name: filename)]); await SharePlus.instance.share(
ShareParams(files: [XFile(path, name: filename)]),
);
} }
String _fileIconFor(String filename) { String _fileIconFor(String filename) {

View File

@@ -151,7 +151,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
bool _isRecording = false; bool _isRecording = false;
bool _isExpanded = true; // Start expanded for better UX bool _isExpanded = true; // Start expanded for better UX
// TODO: Implement voice input functionality
// final String _voiceInputText = ''; // final String _voiceInputText = '';
bool _hasText = false; // track locally without rebuilding on each keystroke bool _hasText = false; // track locally without rebuilding on each keystroke
StreamSubscription<String>? _voiceStreamSubscription; StreamSubscription<String>? _voiceStreamSubscription;
@@ -414,8 +413,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
}); });
} }
final bool showPlaceholder =
!_hasText && !_focusNode.hasFocus && !_isRecording;
final Brightness brightness = Theme.of(context).brightness; final Brightness brightness = Theme.of(context).brightness;
final Color outlineColor = (_focusNode.hasFocus || _hasText) final Color outlineColor = (_focusNode.hasFocus || _hasText)
? context.conduitTheme.inputBorderFocused.withValues(alpha: 0.6) ? context.conduitTheme.inputBorderFocused.withValues(alpha: 0.6)
@@ -425,16 +422,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
); );
final Color composerSurface = context.conduitTheme.inputBackground; final Color composerSurface = context.conduitTheme.inputBackground;
final Color placeholderColor = context.conduitTheme.inputPlaceholder; final Color placeholderColor = context.conduitTheme.inputPlaceholder;
final Color badgeBackground = showPlaceholder
? placeholderColor.withValues(alpha: 0.12)
: composerSurface.withValues(alpha: 0.3);
final Color badgeBorder = showPlaceholder
? Colors.transparent
: outlineColor.withValues(alpha: 0.35);
final Color badgeIconColor = showPlaceholder
? placeholderColor
: context.conduitTheme.textPrimary.withValues(alpha: 0.75);
return Container( return Container(
// Transparent wrapper so rounded corners are visible against page background // Transparent wrapper so rounded corners are visible against page background
color: Colors.transparent, color: Colors.transparent,
@@ -1017,7 +1004,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
// Append tools button at the end (always visible) // Append tools button at the end (always visible)
rowChildren..add( rowChildren.add(
_buildIconButton( _buildIconButton(
icon: Platform.isIOS icon: Platform.isIOS
? CupertinoIcons.wrench ? CupertinoIcons.wrench
@@ -1605,6 +1592,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
if (!widget.enabled) return; if (!widget.enabled) return;
try { try {
final ok = await _voiceService.initialize(); final ok = await _voiceService.initialize();
if (!mounted) return;
if (!ok) { if (!ok) {
_showVoiceUnavailable( _showVoiceUnavailable(
AppLocalizations.of(context)?.errorMessage ?? AppLocalizations.of(context)?.errorMessage ??
@@ -1614,6 +1602,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
} }
// Centralized permission + start // Centralized permission + start
final stream = await _voiceService.beginListening(); final stream = await _voiceService.beginListening();
if (!mounted) return;
setState(() { setState(() {
_isRecording = true; _isRecording = true;
_baseTextAtStart = _controller.text; _baseTextAtStart = _controller.text;

View File

@@ -819,15 +819,18 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
String folderId, String folderId,
String folderName, String folderName,
) async { ) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await ThemedDialogs.confirm( final confirmed = await ThemedDialogs.confirm(
context, context,
title: AppLocalizations.of(context)!.deleteFolderTitle, title: l10n.deleteFolderTitle,
message: AppLocalizations.of(context)!.deleteFolderMessage, message: l10n.deleteFolderMessage,
confirmText: AppLocalizations.of(context)!.delete, confirmText: l10n.delete,
isDestructive: true, isDestructive: true,
); );
if (!mounted) return;
if (!confirmed) return; if (!confirmed) return;
final deleteFolderError = l10n.failedToDeleteFolder;
try { try {
final api = ref.read(apiServiceProvider); final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service'); if (api == null) throw Exception('No API service');
@@ -837,16 +840,13 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
ref.invalidate(conversationsProvider); ref.invalidate(conversationsProvider);
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
UiUtils.showMessage( UiUtils.showMessage(this.context, deleteFolderError, isError: true);
this.context,
AppLocalizations.of(context)!.failedToDeleteFolder,
isError: true,
);
} }
} }
Widget _buildUnfileDropTarget() { Widget _buildUnfileDropTarget() {
final theme = context.conduitTheme; final theme = context.conduitTheme;
final l10n = AppLocalizations.of(context)!;
final isHover = _dragHoverFolderId == '__UNFILE__'; final isHover = _dragHoverFolderId == '__UNFILE__';
return DragTarget<_DragConversationData>( return DragTarget<_DragConversationData>(
onWillAcceptWithDetails: (details) { onWillAcceptWithDetails: (details) {
@@ -874,11 +874,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
} }
} catch (_) { } catch (_) {
if (mounted) { if (mounted) {
UiUtils.showMessage( UiUtils.showMessage(context, l10n.failedToMoveChat, isError: true);
context,
AppLocalizations.of(context)!.failedToMoveChat,
isError: true,
);
} }
} }
}, },
@@ -1149,14 +1145,14 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final dynamic authUser = ref.watch(authUserProvider); final dynamic authUser = ref.watch(authUserProvider);
final user = userFromProfile ?? authUser; final user = userFromProfile ?? authUser;
String _initial(String name) { String initialFor(String name) {
if (name.isEmpty) return 'U'; if (name.isEmpty) return 'U';
final ch = name.characters.first; final ch = name.characters.first;
return ch.toUpperCase(); return ch.toUpperCase();
} }
final displayName = deriveUserDisplayName(user); final displayName = deriveUserDisplayName(user);
final initial = _initial(displayName); final initial = initialFor(displayName);
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(Spacing.sm, 0, Spacing.sm, Spacing.sm), padding: const EdgeInsets.fromLTRB(Spacing.sm, 0, Spacing.sm, Spacing.sm),
child: Column( child: Column(
@@ -1273,13 +1269,16 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
onTap: () async { onTap: () async {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
final pinErrorMessage = AppLocalizations.of(
context,
)!.failedToUpdatePin;
try { try {
await chat.pinConversation(ref, conv.id, !isPinned); await chat.pinConversation(ref, conv.id, !isPinned);
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
UiUtils.showMessage( UiUtils.showMessage(
this.context, this.context,
AppLocalizations.of(context)!.failedToUpdatePin, pinErrorMessage,
isError: true, isError: true,
); );
} }
@@ -1305,13 +1304,16 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
onTap: () async { onTap: () async {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
final archiveErrorMessage = AppLocalizations.of(
context,
)!.failedToUpdateArchive;
try { try {
await chat.archiveConversation(ref, conv.id, !isArchived); await chat.archiveConversation(ref, conv.id, !isArchived);
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
UiUtils.showMessage( UiUtils.showMessage(
this.context, this.context,
AppLocalizations.of(context)!.failedToUpdateArchive, archiveErrorMessage,
isError: true, isError: true,
); );
} }
@@ -1360,18 +1362,20 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
String conversationId, String conversationId,
String currentTitle, String currentTitle,
) async { ) async {
final l10n = AppLocalizations.of(context)!;
final newName = await ThemedDialogs.promptTextInput( final newName = await ThemedDialogs.promptTextInput(
context, context,
title: AppLocalizations.of(context)!.renameChat, title: l10n.renameChat,
hintText: AppLocalizations.of(context)!.enterChatName, hintText: l10n.enterChatName,
initialValue: currentTitle, initialValue: currentTitle,
confirmText: AppLocalizations.of(context)!.save, confirmText: l10n.save,
cancelText: AppLocalizations.of(context)!.cancel, cancelText: l10n.cancel,
); );
if (!mounted) return;
if (newName == null) return; if (newName == null) return;
if (newName.isEmpty || newName == currentTitle) return; if (newName.isEmpty || newName == currentTitle) return;
final renameError = l10n.failedToRenameChat;
try { try {
final api = ref.read(apiServiceProvider); final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service'); if (api == null) throw Exception('No API service');
@@ -1387,11 +1391,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
} }
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
UiUtils.showMessage( UiUtils.showMessage(this.context, renameError, isError: true);
this.context,
AppLocalizations.of(context)!.failedToRenameChat,
isError: true,
);
} }
} }
@@ -1399,15 +1399,18 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
BuildContext context, BuildContext context,
String conversationId, String conversationId,
) async { ) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await ThemedDialogs.confirm( final confirmed = await ThemedDialogs.confirm(
context, context,
title: AppLocalizations.of(context)!.deleteChatTitle, title: l10n.deleteChatTitle,
message: AppLocalizations.of(context)!.deleteChatMessage, message: l10n.deleteChatMessage,
confirmText: AppLocalizations.of(context)!.delete, confirmText: l10n.delete,
isDestructive: true, isDestructive: true,
); );
if (!mounted) return;
if (!confirmed) return; if (!confirmed) return;
final deleteError = l10n.failedToDeleteChat;
try { try {
final api = ref.read(apiServiceProvider); final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service'); if (api == null) throw Exception('No API service');
@@ -1422,11 +1425,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
ref.invalidate(conversationsProvider); ref.invalidate(conversationsProvider);
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
UiUtils.showMessage( UiUtils.showMessage(this.context, deleteError, isError: true);
this.context,
AppLocalizations.of(context)!.failedToDeleteChat,
isError: true,
);
} }
} }
} }

View File

@@ -11,18 +11,20 @@ class MeasureSize extends SingleChildRenderObjectWidget {
@override @override
RenderObject createRenderObject(BuildContext context) { RenderObject createRenderObject(BuildContext context) {
return _MeasureSizeRenderObject(onChange); return MeasureSizeRenderObject(onChange);
} }
@override @override
void updateRenderObject( void updateRenderObject(
BuildContext context, covariant _MeasureSizeRenderObject renderObject) { BuildContext context,
covariant MeasureSizeRenderObject renderObject,
) {
renderObject.onChange = onChange; renderObject.onChange = onChange;
} }
} }
class _MeasureSizeRenderObject extends RenderProxyBox { class MeasureSizeRenderObject extends RenderProxyBox {
_MeasureSizeRenderObject(this.onChange); MeasureSizeRenderObject(this.onChange);
OnWidgetSizeChange onChange; OnWidgetSizeChange onChange;
Size? _oldSize; Size? _oldSize;