fix: tool calling
This commit is contained in:
@@ -69,10 +69,35 @@ class SocketService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscribe to general channel events (server-broadcasted channel updates)
|
||||||
|
void onChannelEvents(void Function(Map<String, dynamic> event) handler) {
|
||||||
|
_socket?.on('channel-events', (data) {
|
||||||
|
try {
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
handler(data);
|
||||||
|
} else if (data is Map) {
|
||||||
|
handler(Map<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void offChatEvents() {
|
void offChatEvents() {
|
||||||
_socket?.off('chat-events');
|
_socket?.off('chat-events');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void offChannelEvents() {
|
||||||
|
_socket?.off('channel-events');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to an arbitrary socket.io event (used for dynamic tool channels)
|
||||||
|
void onEvent(String eventName, void Function(dynamic data) handler) {
|
||||||
|
_socket?.on(eventName, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
void offEvent(String eventName) {
|
||||||
|
_socket?.off(eventName);
|
||||||
|
}
|
||||||
void dispose() {
|
void dispose() {
|
||||||
try {
|
try {
|
||||||
_socket?.dispose();
|
_socket?.dispose();
|
||||||
|
|||||||
@@ -34,6 +34,19 @@ class ToolCallsContent {
|
|||||||
|
|
||||||
/// Utility to parse <details type="tool_calls"> blocks from content
|
/// Utility to parse <details type="tool_calls"> blocks from content
|
||||||
class ToolCallsParser {
|
class ToolCallsParser {
|
||||||
|
static String _unescapeHtml(String s) {
|
||||||
|
return s
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('&', '&');
|
||||||
|
}
|
||||||
/// 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) {
|
||||||
@@ -59,12 +72,19 @@ class ToolCallsParser {
|
|||||||
// Find end of opening tag
|
// Find end of opening tag
|
||||||
final openEnd = content.indexOf('>', start);
|
final openEnd = content.indexOf('>', start);
|
||||||
if (openEnd == -1) {
|
if (openEnd == -1) {
|
||||||
// Malformed; append rest as text
|
// Malformed opening tag; append the rest as text and stop
|
||||||
segs.add(ToolCallsSegment.text(content.substring(start)));
|
segs.add(ToolCallsSegment.text(content.substring(start)));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
final openTag = content.substring(start, openEnd + 1);
|
final openTag = content.substring(start, openEnd + 1);
|
||||||
|
|
||||||
|
// Parse attributes from opening tag immediately (to support streaming)
|
||||||
|
final attrs = <String, String>{};
|
||||||
|
final attrRegex = RegExp(r'(\w+)="(.*?)"');
|
||||||
|
for (final m in attrRegex.allMatches(openTag)) {
|
||||||
|
attrs[m.group(1)!] = m.group(2) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
// Find matching closing tag with nesting support
|
// Find matching closing tag with nesting support
|
||||||
int depth = 1;
|
int depth = 1;
|
||||||
int i = openEnd + 1;
|
int i = openEnd + 1;
|
||||||
@@ -81,28 +101,22 @@ class ToolCallsParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (depth != 0) {
|
final isToolCalls = (attrs['type'] ?? '') == 'tool_calls';
|
||||||
// Unclosed details; append the rest as text
|
|
||||||
segs.add(ToolCallsSegment.text(content.substring(start)));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
final fullMatch = content.substring(start, i);
|
if (isToolCalls) {
|
||||||
|
// Decode attributes for tool call tile
|
||||||
// Parse attributes from opening tag
|
|
||||||
final attrs = <String, String>{};
|
|
||||||
final attrRegex = RegExp(r'(\w+)="(.*?)"');
|
|
||||||
for (final m in attrRegex.allMatches(openTag)) {
|
|
||||||
attrs[m.group(1)!] = m.group(2) ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((attrs['type'] ?? '') == 'tool_calls') {
|
|
||||||
dynamic _decode(String? s) {
|
dynamic _decode(String? s) {
|
||||||
if (s == null || s.isEmpty) return null;
|
if (s == null || s.isEmpty) return null;
|
||||||
try {
|
try {
|
||||||
return json.decode(s);
|
final unescaped = _unescapeHtml(s);
|
||||||
|
return json.decode(unescaped);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return s;
|
// If JSON decode fails, return unescaped string for display
|
||||||
|
try {
|
||||||
|
return _unescapeHtml(s);
|
||||||
|
} catch (_) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,11 +139,26 @@ class ToolCallsParser {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
segs.add(ToolCallsSegment.text(fullMatch));
|
// If details not closed yet, stop scanning (wait for more stream)
|
||||||
|
if (depth != 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If closed, advance index to the end of the block
|
||||||
|
index = i;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
index = i;
|
// Non-tool_calls: keep as text (full block) when closed; if not closed, append remainder and stop
|
||||||
|
if (depth != 0) {
|
||||||
|
segs.add(ToolCallsSegment.text(content.substring(start)));
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
final fullMatch = content.substring(start, i);
|
||||||
|
segs.add(ToolCallsSegment.text(fullMatch));
|
||||||
|
index = i;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return segs.isEmpty ? null : segs;
|
return segs.isEmpty ? null : segs;
|
||||||
@@ -139,6 +168,7 @@ class ToolCallsParser {
|
|||||||
static ToolCallsContent? parse(String content) {
|
static ToolCallsContent? parse(String content) {
|
||||||
if (content.isEmpty || !content.contains('<details')) return null;
|
if (content.isEmpty || !content.contains('<details')) return null;
|
||||||
|
|
||||||
|
// We need mainContent that excludes tool_calls blocks even if unclosed (streaming)
|
||||||
final segs = segments(content);
|
final segs = segments(content);
|
||||||
if (segs == null) return null;
|
if (segs == null) return null;
|
||||||
|
|
||||||
@@ -148,7 +178,18 @@ class ToolCallsParser {
|
|||||||
if (seg.isToolCall && seg.entry != null) {
|
if (seg.isToolCall && seg.entry != null) {
|
||||||
calls.add(seg.entry!);
|
calls.add(seg.entry!);
|
||||||
} else if (seg.text != null && seg.text!.isNotEmpty) {
|
} else if (seg.text != null && seg.text!.isNotEmpty) {
|
||||||
buf.write(seg.text);
|
// Remove any embedded tool_calls blocks that may have slipped into text
|
||||||
|
final cleaned = seg.text!
|
||||||
|
.replaceAll(
|
||||||
|
RegExp(
|
||||||
|
r'<details\s+type=\"tool_calls\"[^>]*>[\s\S]*?<\/details>',
|
||||||
|
multiLine: true,
|
||||||
|
dotAll: true,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
.trim();
|
||||||
|
if (cleaned.isNotEmpty) buf.write(cleaned);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,4 +250,3 @@ class ToolCallsSegment {
|
|||||||
|
|
||||||
bool get isToolCall => entry != null;
|
bool get isToolCall => entry != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../../../core/providers/app_providers.dart';
|
|||||||
import '../../../core/auth/auth_state_manager.dart';
|
import '../../../core/auth/auth_state_manager.dart';
|
||||||
import '../../../core/utils/stream_chunker.dart';
|
import '../../../core/utils/stream_chunker.dart';
|
||||||
import '../../../core/services/persistent_streaming_service.dart';
|
import '../../../core/services/persistent_streaming_service.dart';
|
||||||
|
import '../../../core/utils/debug_logger.dart';
|
||||||
import '../services/reviewer_mode_service.dart';
|
import '../services/reviewer_mode_service.dart';
|
||||||
|
|
||||||
// Chat messages for current conversation
|
// Chat messages for current conversation
|
||||||
@@ -408,6 +409,10 @@ Future<void> regenerateMessage(
|
|||||||
);
|
);
|
||||||
ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage);
|
ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage);
|
||||||
|
|
||||||
|
// Reviewer mode: no immediate tool preview (no tool context)
|
||||||
|
|
||||||
|
// Reviewer mode: no immediate tool preview (no tool context)
|
||||||
|
|
||||||
// Use canned response for regeneration
|
// Use canned response for regeneration
|
||||||
final responseText = ReviewerModeService.generateResponse(
|
final responseText = ReviewerModeService.generateResponse(
|
||||||
userMessage: userMessageContent,
|
userMessage: userMessageContent,
|
||||||
@@ -1064,7 +1069,9 @@ Future<void> _sendMessageInternal(
|
|||||||
// If socket is available, start listening for chat-events immediately
|
// If socket is available, start listening for chat-events immediately
|
||||||
// For background-tools flow (when socket session is present), socket is the primary stream.
|
// For background-tools flow (when socket session is present), socket is the primary stream.
|
||||||
// In that case, do NOT suppress socket content.
|
// In that case, do NOT suppress socket content.
|
||||||
bool suppressSocketContent = (socketSessionId == null); // only suppress when using SSE stream
|
// Suppress socket TEXT content when we already have a stream (SSE or polling)
|
||||||
|
// but DO allow tool_call status via socket to surface tiles immediately.
|
||||||
|
bool suppressSocketContent = (socketSessionId == null); // text-only suppression
|
||||||
if (socketService != null) {
|
if (socketService != null) {
|
||||||
void chatHandler(Map<String, dynamic> ev) {
|
void chatHandler(Map<String, dynamic> ev) {
|
||||||
try {
|
try {
|
||||||
@@ -1072,10 +1079,12 @@ Future<void> _sendMessageInternal(
|
|||||||
if (data == null) return;
|
if (data == null) return;
|
||||||
final type = data['type'];
|
final type = data['type'];
|
||||||
final payload = data['data'];
|
final payload = data['data'];
|
||||||
|
DebugLogger.stream('Socket chat-events: type=$type');
|
||||||
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
|
||||||
if (!suppressSocketContent && payload.containsKey('tool_calls')) {
|
// Always surface tool_calls status from socket for instant tiles
|
||||||
|
if (payload.containsKey('tool_calls')) {
|
||||||
final tc = payload['tool_calls'];
|
final tc = payload['tool_calls'];
|
||||||
if (tc is List) {
|
if (tc is List) {
|
||||||
for (final call in tc) {
|
for (final call in tc) {
|
||||||
@@ -1146,19 +1155,218 @@ Future<void> _sendMessageInternal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (payload['done'] == true) {
|
if (payload['done'] == true) {
|
||||||
// Do not force finish here to avoid cutting off active streams.
|
// Stop listening to further socket events for this session.
|
||||||
// Just stop listening to further socket events for this session.
|
|
||||||
try { socketService.offChatEvents(); } catch (_) {}
|
try { socketService.offChatEvents(); } catch (_) {}
|
||||||
|
|
||||||
|
// If no content was rendered yet, fetch final assistant message from server
|
||||||
|
final msgs = ref.read(chatMessagesProvider);
|
||||||
|
if (msgs.isNotEmpty && msgs.last.role == 'assistant') {
|
||||||
|
final lastContent = msgs.last.content.trim();
|
||||||
|
if (lastContent.isEmpty) {
|
||||||
|
final apiSvc = ref.read(apiServiceProvider);
|
||||||
|
final chatId = activeConversation?.id;
|
||||||
|
final msgId = assistantMessageId;
|
||||||
|
if (apiSvc != null && chatId != null && chatId.isNotEmpty) {
|
||||||
|
Future.microtask(() async {
|
||||||
|
try {
|
||||||
|
final resp = await apiSvc.dio.get('/api/v1/chats/' + chatId);
|
||||||
|
final data = resp.data as Map<String, dynamic>;
|
||||||
|
String content = '';
|
||||||
|
final chatObj = data['chat'] as Map<String, dynamic>?;
|
||||||
|
if (chatObj != null) {
|
||||||
|
// Prefer chat.messages list
|
||||||
|
final list = chatObj['messages'];
|
||||||
|
if (list is List) {
|
||||||
|
final target = list.firstWhere(
|
||||||
|
(m) => (m is Map && (m['id']?.toString() == msgId)),
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
if (target != null) {
|
||||||
|
final rawContent = (target as Map)['content'];
|
||||||
|
if (rawContent is String) {
|
||||||
|
content = rawContent;
|
||||||
|
} else if (rawContent is List) {
|
||||||
|
final textItem = rawContent.firstWhere(
|
||||||
|
(i) => i is Map && i['type'] == 'text',
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
if (textItem != null) {
|
||||||
|
content = textItem['text']?.toString() ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to history map
|
||||||
|
if (content.isEmpty) {
|
||||||
|
final history = chatObj['history'];
|
||||||
|
if (history is Map && history['messages'] is Map) {
|
||||||
|
final Map<String, dynamic> messagesMap =
|
||||||
|
(history['messages'] as Map).cast<String, dynamic>();
|
||||||
|
final msg = messagesMap[msgId];
|
||||||
|
if (msg is Map) {
|
||||||
|
final rawContent = msg['content'];
|
||||||
|
if (rawContent is String) {
|
||||||
|
content = rawContent;
|
||||||
|
} else if (rawContent is List) {
|
||||||
|
final textItem = rawContent.firstWhere(
|
||||||
|
(i) => i is Map && i['type'] == 'text',
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
if (textItem != null) {
|
||||||
|
content = textItem['text']?.toString() ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.isNotEmpty) {
|
||||||
|
ref
|
||||||
|
.read(chatMessagesProvider.notifier)
|
||||||
|
.replaceLastMessageContent(content);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Swallow; we'll still finish streaming
|
||||||
|
} finally {
|
||||||
|
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return; // Defer finish to microtask
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Normal path: finish now
|
||||||
|
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (type == 'request:chat:completion' && payload != null) {
|
||||||
|
// Mirror web client's execute path: listen on provided dynamic channel
|
||||||
|
final channel = payload['channel'];
|
||||||
|
if (channel is String && channel.isNotEmpty) {
|
||||||
|
DebugLogger.stream('Socket request:chat:completion channel=$channel');
|
||||||
|
void channelLineHandler(dynamic line) {
|
||||||
|
try {
|
||||||
|
if (line is String) {
|
||||||
|
final s = line.trim();
|
||||||
|
DebugLogger.stream('Socket [' + channel + '] line=' + (s.length > 160 ? s.substring(0, 160) + '…' : s));
|
||||||
|
if (s == '[DONE]' || s == 'DONE') {
|
||||||
|
socketService.offEvent(channel);
|
||||||
|
// Channel completed
|
||||||
|
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (s.startsWith('data:')) {
|
||||||
|
final dataStr = s.substring(5).trim();
|
||||||
|
if (dataStr == '[DONE]') {
|
||||||
|
socketService.offEvent(channel);
|
||||||
|
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Try to parse OpenAI-style delta JSON
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> j = jsonDecode(dataStr);
|
||||||
|
final choices = j['choices'];
|
||||||
|
if (choices is List && choices.isNotEmpty) {
|
||||||
|
final choice = choices.first;
|
||||||
|
final delta = choice is Map ? choice['delta'] : null;
|
||||||
|
if (delta is Map) {
|
||||||
|
if (delta.containsKey('content')) {
|
||||||
|
final c = delta['content']?.toString() ?? '';
|
||||||
|
if (c.isNotEmpty) {
|
||||||
|
DebugLogger.stream('Socket [' + channel + '] delta.content len=' + c.length.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Surface tool_calls status
|
||||||
|
if (delta.containsKey('tool_calls')) {
|
||||||
|
DebugLogger.stream('Socket [' + channel + '] delta.tool_calls detected');
|
||||||
|
final tc = delta['tool_calls'];
|
||||||
|
if (tc is List) {
|
||||||
|
for (final call in tc) {
|
||||||
|
if (call is Map<String, dynamic>) {
|
||||||
|
final fn = call['function'];
|
||||||
|
final name = (fn is Map && fn['name'] is String)
|
||||||
|
? fn['name'] as String
|
||||||
|
: null;
|
||||||
|
if (name is String && name.isNotEmpty) {
|
||||||
|
final status = '\n<details type="tool_calls" done="false" name="$name"><summary>Executing...</summary>\n</details>\n';
|
||||||
|
ref.read(chatMessagesProvider.notifier).appendToLastMessage(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Append streamed content
|
||||||
|
final content = delta['content']?.toString() ?? '';
|
||||||
|
if (content.isNotEmpty) {
|
||||||
|
ref.read(chatMessagesProvider.notifier).appendToLastMessage(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Non-JSON line: append as-is
|
||||||
|
if (s.isNotEmpty) {
|
||||||
|
ref.read(chatMessagesProvider.notifier).appendToLastMessage(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Plain text line
|
||||||
|
if (s.isNotEmpty) {
|
||||||
|
ref.read(chatMessagesProvider.notifier).appendToLastMessage(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (line is Map) {
|
||||||
|
// If server sends { done: true } via channel
|
||||||
|
final done = line['done'] == true;
|
||||||
|
if (done) {
|
||||||
|
socketService.offEvent(channel);
|
||||||
|
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register dynamic channel listener
|
||||||
|
try {
|
||||||
|
socketService.onEvent(channel, channelLineHandler);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
} else if (type == 'execute:tool' && payload != null) {
|
||||||
|
// Show an executing tile immediately using provided tool info
|
||||||
|
try {
|
||||||
|
final name = payload['name']?.toString() ?? 'tool';
|
||||||
|
DebugLogger.stream('Socket execute:tool name=' + name);
|
||||||
|
final status = '\n<details type="tool_calls" done="false" name="$name"><summary>Executing...</summary>\n</details>\n';
|
||||||
|
ref.read(chatMessagesProvider.notifier).appendToLastMessage(status);
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
socketService.onChatEvents(chatHandler);
|
socketService.onChatEvents(chatHandler);
|
||||||
|
// Also mirror channel-events like the web client
|
||||||
|
void channelEventsHandler(Map<String, dynamic> ev) {
|
||||||
|
try {
|
||||||
|
final data = ev['data'];
|
||||||
|
if (data == null) return;
|
||||||
|
final type = data['type'];
|
||||||
|
final payload = data['data'];
|
||||||
|
DebugLogger.stream('Socket channel-events: type=' + type.toString());
|
||||||
|
// Handle generic channel progress messages if needed
|
||||||
|
if (type == 'message' && payload is Map) {
|
||||||
|
final content = payload['content']?.toString() ?? '';
|
||||||
|
if (content.isNotEmpty) {
|
||||||
|
ref.read(chatMessagesProvider.notifier).appendToLastMessage(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
socketService.onChannelEvents(channelEventsHandler);
|
||||||
Future.delayed(const Duration(seconds: 90), () {
|
Future.delayed(const Duration(seconds: 90), () {
|
||||||
try {
|
try {
|
||||||
socketService.offChatEvents();
|
socketService.offChatEvents();
|
||||||
|
socketService.offChannelEvents();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1432,8 +1640,11 @@ Future<void> _sendMessageInternal(
|
|||||||
}
|
}
|
||||||
// Allow socket content again for future sessions (harmless if already false)
|
// Allow socket content again for future sessions (harmless if already false)
|
||||||
suppressSocketContent = false;
|
suppressSocketContent = false;
|
||||||
// Mark streaming as complete immediately for better UX
|
// If this path was SSE-driven (no background socket), finish now.
|
||||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
// Otherwise keep streaming state until socket/dynamic channel signals done.
|
||||||
|
if (socketService == null) {
|
||||||
|
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||||
|
}
|
||||||
|
|
||||||
// Send chat completed notification to OpenWebUI
|
// Send chat completed notification to OpenWebUI
|
||||||
final messages = ref.read(chatMessagesProvider);
|
final messages = ref.read(chatMessagesProvider);
|
||||||
|
|||||||
@@ -171,12 +171,11 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _updateTypingIndicatorGate() {
|
void _updateTypingIndicatorGate() {
|
||||||
// Only show typing indicator if streaming and nothing renderable yet,
|
// Show typing indicator while streaming until we have any renderable segments
|
||||||
// and only after a short delay to avoid flicker when content arrives quickly.
|
// (tool tiles or actual text). Use a short delay to avoid flicker.
|
||||||
_typingGateTimer?.cancel();
|
_typingGateTimer?.cancel();
|
||||||
final hasRenderable = _hasRenderableSegments;
|
final hasRenderable = _hasRenderableSegments;
|
||||||
final contentEmpty = (widget.message.content ?? '').trim().isEmpty;
|
if (widget.isStreaming && !hasRenderable) {
|
||||||
if (widget.isStreaming && !hasRenderable && contentEmpty) {
|
|
||||||
_allowTypingIndicator = false;
|
_allowTypingIndicator = false;
|
||||||
_typingGateTimer = Timer(const Duration(milliseconds: 150), () {
|
_typingGateTimer = Timer(const Duration(milliseconds: 150), () {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -367,11 +366,32 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool get _hasRenderableSegments {
|
bool get _hasRenderableSegments {
|
||||||
for (final seg in _toolSegments) {
|
bool _textRenderable(String t) {
|
||||||
if ((seg.isToolCall && seg.entry != null) ||
|
String cleaned = t;
|
||||||
((seg.text ?? '').trim().isNotEmpty)) {
|
// Hide tool_calls blocks entirely
|
||||||
return true;
|
cleaned = cleaned.replaceAll(
|
||||||
|
RegExp(
|
||||||
|
r'<details\s+type="tool_calls"[^>]*>[\s\S]*?<\/details>',
|
||||||
|
multiLine: true,
|
||||||
|
dotAll: true,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
// If last <details> is unclosed, drop tail to avoid rendering raw tag
|
||||||
|
final lastOpen = cleaned.lastIndexOf('<details');
|
||||||
|
if (lastOpen >= 0) {
|
||||||
|
final tail = cleaned.substring(lastOpen);
|
||||||
|
if (!tail.contains('</details>')) {
|
||||||
|
cleaned = cleaned.substring(0, lastOpen);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return cleaned.trim().isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final seg in _toolSegments) {
|
||||||
|
if (seg.isToolCall && seg.entry != null) return true;
|
||||||
|
final text = seg.text ?? '';
|
||||||
|
if (_textRenderable(text)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -623,24 +643,22 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
// For streaming, hide any tool_calls <details> blocks that may be incomplete
|
// Always hide tool_calls blocks; tiles render them separately.
|
||||||
// to avoid showing raw tag text; tiles will render once blocks complete.
|
String cleaned = content.replaceAll(
|
||||||
String cleaned = content;
|
RegExp(
|
||||||
if (widget.isStreaming) {
|
r'<details\s+type="tool_calls"[^>]*>[\s\S]*?<\/details>',
|
||||||
cleaned = cleaned.replaceAll(
|
multiLine: true,
|
||||||
RegExp(
|
dotAll: true,
|
||||||
r'<details\s+type="tool_calls"[^>]*>[\s\S]*?<\/details>',
|
),
|
||||||
multiLine: true,
|
'',
|
||||||
dotAll: true,
|
);
|
||||||
),
|
|
||||||
'',
|
// If there's an unclosed <details>, drop the tail to avoid raw tags.
|
||||||
);
|
final lastOpen = cleaned.lastIndexOf('<details');
|
||||||
final lastOpen = cleaned.lastIndexOf('<details');
|
if (lastOpen >= 0) {
|
||||||
if (lastOpen >= 0) {
|
final tail = cleaned.substring(lastOpen);
|
||||||
final tail = cleaned.substring(lastOpen);
|
if (!tail.contains('</details>')) {
|
||||||
if (!tail.contains('</details>')) {
|
cleaned = cleaned.substring(0, lastOpen);
|
||||||
cleaned = cleaned.substring(0, lastOpen);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,14 +823,22 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
children: [
|
children: [
|
||||||
// Increase spacing between assistant name and typing indicator
|
// Increase spacing between assistant name and typing indicator
|
||||||
const SizedBox(height: Spacing.md),
|
const SizedBox(height: Spacing.md),
|
||||||
Row(
|
// Give the dots breathing room to avoid any clip from transitions
|
||||||
children: [
|
Padding(
|
||||||
_buildTypingDot(0),
|
padding: const EdgeInsets.only(left: 4, bottom: 4),
|
||||||
const SizedBox(width: Spacing.xs),
|
child: SizedBox(
|
||||||
_buildTypingDot(200),
|
height: 14,
|
||||||
const SizedBox(width: Spacing.xs),
|
child: Row(
|
||||||
_buildTypingDot(400),
|
mainAxisSize: MainAxisSize.min,
|
||||||
],
|
children: [
|
||||||
|
_buildTypingDot(0),
|
||||||
|
const SizedBox(width: Spacing.xs),
|
||||||
|
_buildTypingDot(200),
|
||||||
|
const SizedBox(width: Spacing.xs),
|
||||||
|
_buildTypingDot(400),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -822,8 +848,8 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
|
|
||||||
Widget _buildTypingDot(int delay) {
|
Widget _buildTypingDot(int delay) {
|
||||||
return Container(
|
return Container(
|
||||||
width: 8,
|
width: 10,
|
||||||
height: 8,
|
height: 10,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: context.conduitTheme.textSecondary.withValues(alpha: 0.6),
|
color: context.conduitTheme.textSecondary.withValues(alpha: 0.6),
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||||
@@ -833,12 +859,12 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
.scale(
|
.scale(
|
||||||
duration: const Duration(milliseconds: 1000),
|
duration: const Duration(milliseconds: 1000),
|
||||||
begin: const Offset(1, 1),
|
begin: const Offset(1, 1),
|
||||||
end: const Offset(1.3, 1.3),
|
end: const Offset(1.25, 1.25),
|
||||||
)
|
)
|
||||||
.then(delay: Duration(milliseconds: delay))
|
.then(delay: Duration(milliseconds: delay))
|
||||||
.scale(
|
.scale(
|
||||||
duration: const Duration(milliseconds: 1000),
|
duration: const Duration(milliseconds: 1000),
|
||||||
begin: const Offset(1.3, 1.3),
|
begin: const Offset(1.25, 1.25),
|
||||||
end: const Offset(1, 1),
|
end: const Offset(1, 1),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user