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
74 changes: 33 additions & 41 deletions lib/core/screens/chat_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -288,10 +288,10 @@ class _ChatScreenState extends State<ChatScreen> {
void _extractToolMessages(List<Map<String, dynamic>> messages) {
_toolMessages.clear();
for (final msg in messages) {
final role = (msg['role'] as String?) ?? '';
if (role != 'tool') continue;
if (!isToolResultMessage(msg)) continue;

final name = (msg['name'] as String?) ??
final name =
(msg['name'] as String?) ??
(msg['tool_name'] as String?) ??
(msg['toolCallName'] as String?) ??
'';
Expand Down Expand Up @@ -412,23 +412,23 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
final saved = _savedPositions[widget.session.id];
if (saved != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(
saved.clamp(0.0, _scrollController.position.maxScrollExtent),
);
}
});
} else {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(
_scrollController.position.maxScrollExtent,
);
if (saved != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(
saved.clamp(0.0, _scrollController.position.maxScrollExtent),
);
}
});
} else {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(
_scrollController.position.maxScrollExtent,
);
}
});
}
});
}
} catch (e) {
setState(() {
_streaming = false;
Expand Down Expand Up @@ -491,9 +491,7 @@ class _ChatScreenState extends State<ChatScreen> {
setState(() {
final idx = toolCallId.isEmpty
? -1
: _toolMessages.indexWhere(
(m) => m['toolCallId'] == toolCallId,
);
: _toolMessages.indexWhere((m) => m['toolCallId'] == toolCallId);
final payload = {
'role': 'tool_progress',
'content': content,
Expand Down Expand Up @@ -681,21 +679,21 @@ class _ChatScreenState extends State<ChatScreen> {

for (final msg in _messages) {
final role = (msg['role'] as String?) ?? 'assistant';
if (role == 'tool') {
if (isToolResultMessage(msg)) {
if (toolQueue.isNotEmpty) {
currentGroup.add(toolQueue.removeAt(0));
}
continue;
}
if (role != 'user' && role != 'assistant') continue;
final content = messageContentToText(msg['content']);
final content = stripToolResultText(messageContentToText(msg['content']));
if (content.isEmpty) continue;

if (currentGroup.isNotEmpty) {
displayMessages.add(currentGroup.toList());
currentGroup.clear();
}
displayMessages.add(msg);
displayMessages.add({...msg, '_display_content': content});
}
if (currentGroup.isNotEmpty) {
displayMessages.add(currentGroup.toList());
Expand All @@ -720,7 +718,9 @@ class _ChatScreenState extends State<ChatScreen> {

final msg = item as Map<String, dynamic>;
final role = (msg['role'] as String?) ?? 'assistant';
final content = messageContentToText(msg['content']);
final content =
(msg['_display_content'] as String?) ??
stripToolResultText(messageContentToText(msg['content']));
final isUser = role == 'user';

return _MessageBubble(
Expand Down Expand Up @@ -892,15 +892,11 @@ class _MessageBubble extends StatelessWidget {
}
}


class _ToolProgressCard extends StatelessWidget {
final List<Map<String, dynamic>> items;
final bool verbose;

const _ToolProgressCard({
required this.items,
this.verbose = false,
});
const _ToolProgressCard({required this.items, this.verbose = false});

@override
Widget build(BuildContext context) {
Expand All @@ -916,7 +912,9 @@ class _ToolProgressCard extends StatelessWidget {

final emojis = items.map((item) {
final content = (item['content'] as String?) ?? '';
return content.isNotEmpty ? content.substring(0, content.length < 2 ? content.length : 2) : '\uD83D\uDD27';
return content.isNotEmpty
? content.substring(0, content.length < 2 ? content.length : 2)
: '\uD83D\uDD27';
}).toList();

return Container(
Expand All @@ -936,24 +934,18 @@ class _ToolProgressCard extends StatelessWidget {
style: const TextStyle(fontSize: 13),
),
const SizedBox(width: 6),
Text(
emojis.join(' '),
style: const TextStyle(fontSize: 13),
),
Text(emojis.join(' '), style: const TextStyle(fontSize: 13)),
if (active)
Padding(
padding: const EdgeInsets.only(left: 8),
child: SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: fg,
),
child: CircularProgressIndicator(strokeWidth: 1.5, color: fg),
),
),
],
),
);
}
}
}
48 changes: 48 additions & 0 deletions lib/core/services/connection_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,54 @@ class ConnectionManager {
_saveAll(current);
}

/// Updates all editable fields on an existing connection while preserving its
/// id and list position. Empty optional strings clear their saved values.
void updateConnection(
String connId,
String label,
String host,
int port,
String apiKey, {
String? gatewayPrefix,
String? dashboardPrefix,
bool dashboardProxied = false,
int? dashboardPort,
String? dashboardUsername,
String? dashboardPassword,
}) {
final current = getConnections();
final idx = current.indexWhere((c) => c.id == connId);
if (idx < 0) return;

final normalized = SavedConnection.normalizeHostAndPort(host, port);
final gateway = gatewayPrefix?.trim();
final dashboard = dashboardPrefix?.trim();
final dashUser = dashboardUsername?.trim();
final dashPass = dashboardPassword?.trim();

current[idx] = current[idx].copyWith(
label: label,
host: normalized.host,
port: normalized.port,
apiKey: apiKey,
useHttps: normalized.useHttps,
gatewayPrefix: gateway == null || gateway.isEmpty ? null : gateway,
clearGatewayPrefix: gateway != null && gateway.isEmpty,
dashboardPrefix: dashboard == null || dashboard.isEmpty
? null
: dashboard,
clearDashboardPrefix: dashboard != null && dashboard.isEmpty,
dashboardProxied: dashboardProxied,
dashboardPortOverride: dashboardPort,
clearDashboardPort: dashboardPort == null,
dashboardUsername: dashUser == null || dashUser.isEmpty ? null : dashUser,
clearDashboardUsername: dashUser != null && dashUser.isEmpty,
dashboardPassword: dashPass == null || dashPass.isEmpty ? null : dashPass,
clearDashboardPassword: dashPass != null && dashPass.isEmpty,
);
_saveAll(current);
}

/// Updates the dashboard port + basic-auth credentials on an existing
/// connection. Empty strings clear the corresponding field.
void updateDashboardAuth(
Expand Down
75 changes: 67 additions & 8 deletions lib/core/utils/message_content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,84 @@
/// Keep rendering resilient when the gateway adds new part types.
String messageContentToText(dynamic content) {
if (content == null) return '';
if (content is String) return content;
if (content is String) return normaliseDisplayText(content);

if (content is List) {
return content
.map(_contentPartToText)
.where((part) => part.isNotEmpty)
.join('\n\n');
return normaliseDisplayText(
content
.map(_contentPartToText)
.where((part) => part.isNotEmpty)
.join('\n\n'),
);
}

return _contentPartToText(content);
return normaliseDisplayText(_contentPartToText(content));
}

/// Normalise common JSON-escaped line endings that can survive one decode layer.
///
/// Hermes tool result payloads may contain nested JSON strings, so the outer
/// `jsonDecode` leaves literal two-character `\\n` sequences in the message
/// content. Markdown needs real newline characters to render paragraphs, lists,
/// and headings correctly.
String normaliseDisplayText(String text) {
if (!text.contains(r'\n') && !text.contains(r'\r')) return text;
return text
.replaceAll(r'\r\n', '\n')
.replaceAll(r'\n', '\n')
.replaceAll(r'\r', '\n');
}

/// True when a stored message is a tool result rather than user-visible chat.
bool isToolResultMessage(Map<String, dynamic> message) {
final role = (message['role']?.toString() ?? '').trim().toLowerCase();
if (role == 'tool' ||
role == 'tool_result' ||
role == 'tool-result' ||
role == 'function' ||
role.contains('tool')) {
return true;
}

return looksLikeToolResultText(messageContentToText(message['content']));
}

/// True when message text contains Hermes' raw tool-result wrapper.
bool looksLikeToolResultText(String text) {
final trimmed = text.trimLeft();
return trimmed.startsWith('<untrusted_tool_result') ||
trimmed.contains('<untrusted_tool_result') ||
trimmed.startsWith('{"tool_call_id"') ||
trimmed.startsWith('{"toolCallId"');
}

/// Remove embedded raw tool-result blocks from assistant text.
///
/// If the whole message is tool output, this returns an empty string so the chat
/// screen can suppress the bubble and show only the compact tool progress card.
String stripToolResultText(String text) {
final normalised = normaliseDisplayText(text);
final stripped = normalised
.replaceAll(
RegExp(
r'<untrusted_tool_result\b[^>]*>[\s\S]*?</untrusted_tool_result>',
multiLine: true,
),
'',
)
.trim();

if (looksLikeToolResultText(stripped)) return '';
return stripped;
}

String _contentPartToText(dynamic part) {
if (part == null) return '';
if (part is String) return part;
if (part is String) return normaliseDisplayText(part);
if (part is! Map) return part.toString();

final text = part['text'];
if (text is String && text.isNotEmpty) return text;
if (text is String && text.isNotEmpty) return normaliseDisplayText(text);

final type = part['type']?.toString() ?? 'unknown';
if (type.contains('image') || part.containsKey('image_url')) {
Expand Down
Loading
Loading