refactor: 重构 chat_page 对接完整架构,集成全部 stream event widget
## 问题 chat_page.dart 包含内联的简化版 ChatMessage/ChatState/ChatNotifier(约180行), 绕过了已实现的完整 Clean Architecture 层: - domain/entities/chat_message.dart(含 ToolExecution、ApprovalRequest) - domain/entities/stream_event.dart(9种 sealed class 事件) - chat_providers.dart(完整 ChatNotifier 支持审批/工具/常驻指令) - 5 个独立 widget 全部闲置未使用 ## 变更 1. 删除内联重复代码(~180行):ChatRole、ChatContentType、内联 ChatMessage、 内联 ChatState、内联 ChatNotifier、chatMessagesProvider 2. 切换到正式 chatProvider(chat_providers.dart),支持全部 9 种 StreamEvent 3. 集成 5 个已有 widget: - MessageBubble — 用户/AI 消息气泡(带时间戳) - StreamTextWidget — AI 流式回复动画光标 - ToolExecutionCard — 工具执行详情(名称/输入/输出/状态/风险等级) - ApprovalActionCard — 审批卡片(倒计时/通过/拒绝/过期处理) - AgentThinkingIndicator — 思考动画指示器 4. 新增 _AgentStatusBar — 实时状态条(思考中/执行中/等待审批) 5. 新增 _StandingOrderDraftCard — 常驻指令草案渲染 6. AppBar + 输入区添加停止按钮,审批等待时显示提示 7. 消息渲染按 MessageType 分发:text/thinking/toolUse/toolResult/approval/standingOrderDraft Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
15e6fca6c0
commit
1075a6b265
|
|
@ -8,193 +8,14 @@ import '../../../../core/audio/noise_reducer.dart';
|
||||||
import '../../../../core/audio/speech_enhancer.dart';
|
import '../../../../core/audio/speech_enhancer.dart';
|
||||||
import '../../../../core/config/api_endpoints.dart';
|
import '../../../../core/config/api_endpoints.dart';
|
||||||
import '../../../../core/network/dio_client.dart';
|
import '../../../../core/network/dio_client.dart';
|
||||||
import '../../../../core/network/websocket_client.dart';
|
|
||||||
import '../../../../core/theme/app_colors.dart';
|
import '../../../../core/theme/app_colors.dart';
|
||||||
|
import '../../domain/entities/chat_message.dart';
|
||||||
// ---------------------------------------------------------------------------
|
import '../providers/chat_providers.dart';
|
||||||
// TODO 39 & 40 – Chat page: message send + voice input
|
import '../widgets/message_bubble.dart';
|
||||||
// ---------------------------------------------------------------------------
|
import '../widgets/tool_execution_card.dart';
|
||||||
|
import '../widgets/approval_action_card.dart';
|
||||||
// ---- Chat message model ----------------------------------------------------
|
import '../widgets/agent_thinking_indicator.dart';
|
||||||
|
import '../widgets/stream_text_widget.dart';
|
||||||
enum ChatRole { user, assistant, system }
|
|
||||||
enum ChatContentType { text, toolUse }
|
|
||||||
|
|
||||||
class ChatMessage {
|
|
||||||
final String id;
|
|
||||||
final ChatRole role;
|
|
||||||
final ChatContentType contentType;
|
|
||||||
final String content;
|
|
||||||
final DateTime timestamp;
|
|
||||||
|
|
||||||
const ChatMessage({
|
|
||||||
required this.id,
|
|
||||||
required this.role,
|
|
||||||
required this.contentType,
|
|
||||||
required this.content,
|
|
||||||
required this.timestamp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Chat state notifier ---------------------------------------------------
|
|
||||||
|
|
||||||
class ChatState {
|
|
||||||
final List<ChatMessage> messages;
|
|
||||||
final bool isStreaming;
|
|
||||||
final String? sessionId;
|
|
||||||
final String? error;
|
|
||||||
|
|
||||||
const ChatState({
|
|
||||||
this.messages = const [],
|
|
||||||
this.isStreaming = false,
|
|
||||||
this.sessionId,
|
|
||||||
this.error,
|
|
||||||
});
|
|
||||||
|
|
||||||
ChatState copyWith({
|
|
||||||
List<ChatMessage>? messages,
|
|
||||||
bool? isStreaming,
|
|
||||||
String? sessionId,
|
|
||||||
String? error,
|
|
||||||
}) {
|
|
||||||
return ChatState(
|
|
||||||
messages: messages ?? this.messages,
|
|
||||||
isStreaming: isStreaming ?? this.isStreaming,
|
|
||||||
sessionId: sessionId ?? this.sessionId,
|
|
||||||
error: error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ChatNotifier extends StateNotifier<ChatState> {
|
|
||||||
final Ref _ref;
|
|
||||||
StreamSubscription<Map<String, dynamic>>? _wsSubscription;
|
|
||||||
|
|
||||||
ChatNotifier(this._ref) : super(const ChatState());
|
|
||||||
|
|
||||||
/// Sends a user prompt, creates a task via the agent endpoint, subscribes to
|
|
||||||
/// the session WebSocket, and streams response messages into the chat.
|
|
||||||
Future<void> sendMessage(String prompt) async {
|
|
||||||
if (prompt.trim().isEmpty) return;
|
|
||||||
|
|
||||||
// Add the user message locally
|
|
||||||
final userMsg = ChatMessage(
|
|
||||||
id: DateTime.now().microsecondsSinceEpoch.toString(),
|
|
||||||
role: ChatRole.user,
|
|
||||||
contentType: ChatContentType.text,
|
|
||||||
content: prompt,
|
|
||||||
timestamp: DateTime.now(),
|
|
||||||
);
|
|
||||||
state = state.copyWith(
|
|
||||||
messages: [...state.messages, userMsg],
|
|
||||||
isStreaming: true,
|
|
||||||
error: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// POST to agent tasks endpoint
|
|
||||||
final dio = _ref.read(dioClientProvider);
|
|
||||||
final response = await dio.post(
|
|
||||||
ApiEndpoints.tasks,
|
|
||||||
data: {'prompt': prompt},
|
|
||||||
);
|
|
||||||
final data = response.data as Map<String, dynamic>;
|
|
||||||
final sessionId = data['sessionId'] as String? ?? data['session_id'] as String?;
|
|
||||||
final taskId = data['taskId'] as String? ?? data['task_id'] as String?;
|
|
||||||
|
|
||||||
if (sessionId == null) {
|
|
||||||
state = state.copyWith(isStreaming: false, error: '未返回 sessionId');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state = state.copyWith(sessionId: sessionId);
|
|
||||||
|
|
||||||
// Connect to agent WebSocket and subscribe
|
|
||||||
final wsClient = _ref.read(webSocketClientProvider);
|
|
||||||
await wsClient.connect('/ws/agent');
|
|
||||||
wsClient.send({
|
|
||||||
'event': 'subscribe_session',
|
|
||||||
'data': {'sessionId': sessionId, 'taskId': taskId},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for stream_event messages
|
|
||||||
_wsSubscription?.cancel();
|
|
||||||
_wsSubscription = wsClient.messages.listen((msg) {
|
|
||||||
final event = msg['event'] as String? ?? msg['type'] as String? ?? '';
|
|
||||||
if (event == 'stream_event' || event == 'message') {
|
|
||||||
_handleStreamEvent(msg['data'] as Map<String, dynamic>? ?? msg);
|
|
||||||
} else if (event == 'stream_end' || event == 'done' || event == 'complete') {
|
|
||||||
state = state.copyWith(isStreaming: false);
|
|
||||||
_wsSubscription?.cancel();
|
|
||||||
} else if (event == 'error') {
|
|
||||||
state = state.copyWith(
|
|
||||||
isStreaming: false,
|
|
||||||
error: msg['message'] as String? ?? '流式传输错误',
|
|
||||||
);
|
|
||||||
_wsSubscription?.cancel();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
state = state.copyWith(isStreaming: false, error: e.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleStreamEvent(Map<String, dynamic> data) {
|
|
||||||
final type = data['type'] as String? ?? 'text';
|
|
||||||
final content = data['content'] as String? ?? data['text'] as String? ?? '';
|
|
||||||
|
|
||||||
final ChatContentType contentType;
|
|
||||||
if (type == 'tool_use' || type == 'tool_call') {
|
|
||||||
contentType = ChatContentType.toolUse;
|
|
||||||
} else {
|
|
||||||
contentType = ChatContentType.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the last message is an assistant text message that is still streaming,
|
|
||||||
// append to it instead of creating a new bubble.
|
|
||||||
if (contentType == ChatContentType.text && state.messages.isNotEmpty) {
|
|
||||||
final last = state.messages.last;
|
|
||||||
if (last.role == ChatRole.assistant && last.contentType == ChatContentType.text) {
|
|
||||||
final updated = ChatMessage(
|
|
||||||
id: last.id,
|
|
||||||
role: ChatRole.assistant,
|
|
||||||
contentType: ChatContentType.text,
|
|
||||||
content: last.content + content,
|
|
||||||
timestamp: last.timestamp,
|
|
||||||
);
|
|
||||||
state = state.copyWith(
|
|
||||||
messages: [...state.messages.sublist(0, state.messages.length - 1), updated],
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final msg = ChatMessage(
|
|
||||||
id: DateTime.now().microsecondsSinceEpoch.toString(),
|
|
||||||
role: ChatRole.assistant,
|
|
||||||
contentType: contentType,
|
|
||||||
content: content,
|
|
||||||
timestamp: DateTime.now(),
|
|
||||||
);
|
|
||||||
state = state.copyWith(messages: [...state.messages, msg]);
|
|
||||||
}
|
|
||||||
|
|
||||||
void clearChat() {
|
|
||||||
_wsSubscription?.cancel();
|
|
||||||
state = const ChatState();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_wsSubscription?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final chatMessagesProvider =
|
|
||||||
StateNotifierProvider<ChatNotifier, ChatState>((ref) {
|
|
||||||
return ChatNotifier(ref);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Chat page – ConsumerStatefulWidget
|
// Chat page – ConsumerStatefulWidget
|
||||||
|
|
@ -315,7 +136,7 @@ class _ChatPageState extends ConsumerState<ChatPage> with SingleTickerProviderSt
|
||||||
final text = _messageController.text.trim();
|
final text = _messageController.text.trim();
|
||||||
if (text.isEmpty) return;
|
if (text.isEmpty) return;
|
||||||
_messageController.clear();
|
_messageController.clear();
|
||||||
ref.read(chatMessagesProvider.notifier).sendMessage(text);
|
ref.read(chatProvider.notifier).sendMessage(text);
|
||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -331,26 +152,150 @@ class _ChatPageState extends ConsumerState<ChatPage> with SingleTickerProviderSt
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Message widget dispatch -----------------------------------------------
|
||||||
|
|
||||||
|
Widget _buildMessageWidget(ChatMessage message, ChatState chatState) {
|
||||||
|
final isLastMessage = chatState.messages.last.id == message.id;
|
||||||
|
final isStreamingNow = isLastMessage && chatState.isStreaming;
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case MessageType.toolUse:
|
||||||
|
case MessageType.toolResult:
|
||||||
|
if (message.toolExecution != null) {
|
||||||
|
return ToolExecutionCard(toolExecution: message.toolExecution!);
|
||||||
|
}
|
||||||
|
return MessageBubble(message: message);
|
||||||
|
|
||||||
|
case MessageType.approval:
|
||||||
|
if (message.approvalRequest != null) {
|
||||||
|
return ApprovalActionCard(
|
||||||
|
approvalRequest: message.approvalRequest!,
|
||||||
|
onApprove: () => ref.read(chatProvider.notifier)
|
||||||
|
.approveCommand(message.approvalRequest!.taskId),
|
||||||
|
onReject: (reason) => ref.read(chatProvider.notifier)
|
||||||
|
.rejectCommand(message.approvalRequest!.taskId, reason: reason),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return MessageBubble(message: message);
|
||||||
|
|
||||||
|
case MessageType.standingOrderDraft:
|
||||||
|
return _StandingOrderDraftCard(
|
||||||
|
draft: message.metadata ?? {},
|
||||||
|
onConfirm: () => ref.read(chatProvider.notifier)
|
||||||
|
.confirmStandingOrder(message.metadata ?? {}),
|
||||||
|
);
|
||||||
|
|
||||||
|
case MessageType.thinking:
|
||||||
|
// AI thinking content — italic label + streaming text
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.of(context).size.width * 0.78,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(16),
|
||||||
|
topRight: Radius.circular(16),
|
||||||
|
bottomLeft: Radius.circular(4),
|
||||||
|
bottomRight: Radius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'思考中...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
fontSize: 11,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
StreamTextWidget(
|
||||||
|
text: message.content,
|
||||||
|
isStreaming: isStreamingNow,
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
case MessageType.text:
|
||||||
|
default:
|
||||||
|
// User messages → standard bubble
|
||||||
|
if (message.role == MessageRole.user) {
|
||||||
|
return MessageBubble(message: message);
|
||||||
|
}
|
||||||
|
// AI text messages — use StreamTextWidget when actively streaming
|
||||||
|
if (isStreamingNow) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.of(context).size.width * 0.78,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(16),
|
||||||
|
topRight: Radius.circular(16),
|
||||||
|
bottomLeft: Radius.circular(4),
|
||||||
|
bottomRight: Radius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: StreamTextWidget(
|
||||||
|
text: message.content,
|
||||||
|
isStreaming: true,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return MessageBubble(message: message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -- Build -----------------------------------------------------------------
|
// -- Build -----------------------------------------------------------------
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final chatState = ref.watch(chatMessagesProvider);
|
final chatState = ref.watch(chatProvider);
|
||||||
|
|
||||||
// Auto-scroll when messages change
|
// Auto-scroll when messages change
|
||||||
ref.listen(chatMessagesProvider, (_, __) => _scrollToBottom());
|
ref.listen(chatProvider, (_, __) => _scrollToBottom());
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('AI 对话'),
|
title: const Text('AI 对话'),
|
||||||
actions: [
|
actions: [
|
||||||
|
// Stop button during streaming
|
||||||
|
if (chatState.isStreaming)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.stop_circle_outlined),
|
||||||
|
tooltip: '停止',
|
||||||
|
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
|
||||||
|
),
|
||||||
if (chatState.messages.isNotEmpty)
|
if (chatState.messages.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline),
|
||||||
tooltip: '清空对话',
|
tooltip: '清空对话',
|
||||||
onPressed: () => ref.read(chatMessagesProvider.notifier).clearChat(),
|
onPressed: () => ref.read(chatProvider.notifier).clearChat(),
|
||||||
),
|
),
|
||||||
// Voice input button (TODO 40)
|
// Voice input button
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onLongPressStart: (_) => _startListening(),
|
onLongPressStart: (_) => _startListening(),
|
||||||
onLongPressEnd: (_) => _stopListening(autoSubmit: true),
|
onLongPressEnd: (_) => _stopListening(autoSubmit: true),
|
||||||
|
|
@ -388,12 +333,16 @@ class _ChatPageState extends ConsumerState<ChatPage> with SingleTickerProviderSt
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
ref.read(chatMessagesProvider.notifier).clearChat(),
|
ref.read(chatProvider.notifier).clearChat(),
|
||||||
child: const Text('关闭'),
|
child: const Text('关闭'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Agent status bar
|
||||||
|
if (chatState.agentStatus != AgentStatus.idle && chatState.agentStatus != AgentStatus.error)
|
||||||
|
_AgentStatusBar(status: chatState.agentStatus),
|
||||||
|
|
||||||
// Message list
|
// Message list
|
||||||
Expanded(
|
Expanded(
|
||||||
child: chatState.messages.isEmpty
|
child: chatState.messages.isEmpty
|
||||||
|
|
@ -418,13 +367,17 @@ class _ChatPageState extends ConsumerState<ChatPage> with SingleTickerProviderSt
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||||
itemCount: chatState.messages.length + (chatState.isStreaming ? 1 : 0),
|
itemCount: chatState.messages.length +
|
||||||
|
(chatState.agentStatus == AgentStatus.thinking ? 1 : 0),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
// Trailing thinking indicator
|
||||||
if (index == chatState.messages.length) {
|
if (index == chatState.messages.length) {
|
||||||
// Typing indicator
|
return const AgentThinkingIndicator();
|
||||||
return _TypingIndicator();
|
|
||||||
}
|
}
|
||||||
return _ChatBubble(message: chatState.messages[index]);
|
return _buildMessageWidget(
|
||||||
|
chatState.messages[index],
|
||||||
|
chatState,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -470,50 +423,70 @@ class _ChatPageState extends ConsumerState<ChatPage> with SingleTickerProviderSt
|
||||||
),
|
),
|
||||||
|
|
||||||
// Input row
|
// Input row
|
||||||
Container(
|
_buildInputArea(chatState),
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.surface,
|
|
||||||
border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))),
|
|
||||||
),
|
|
||||||
child: SafeArea(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: _messageController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: '向 iAgent 提问...',
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(24)),
|
|
||||||
),
|
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
||||||
),
|
|
||||||
textInputAction: TextInputAction.send,
|
|
||||||
onSubmitted: (_) => _send(),
|
|
||||||
enabled: !chatState.isStreaming,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
IconButton(
|
|
||||||
icon: chatState.isStreaming
|
|
||||||
? const SizedBox(
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.send),
|
|
||||||
onPressed: chatState.isStreaming ? null : _send,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildInputArea(ChatState chatState) {
|
||||||
|
final isDisabled = chatState.isStreaming || chatState.agentStatus == AgentStatus.awaitingApproval;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (chatState.agentStatus == AgentStatus.awaitingApproval)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text(
|
||||||
|
'等待审批中...',
|
||||||
|
style: TextStyle(color: AppColors.warning, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _messageController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '向 iAgent 提问...',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(24)),
|
||||||
|
),
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.send,
|
||||||
|
onSubmitted: (_) => _send(),
|
||||||
|
enabled: !isDisabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (chatState.isStreaming)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error),
|
||||||
|
tooltip: '停止',
|
||||||
|
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.send),
|
||||||
|
onPressed: isDisabled ? null : _send,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
|
|
@ -527,97 +500,41 @@ class _ChatPageState extends ConsumerState<ChatPage> with SingleTickerProviderSt
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Private helper widgets
|
// Agent status bar
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class _ChatBubble extends StatelessWidget {
|
class _AgentStatusBar extends StatelessWidget {
|
||||||
final ChatMessage message;
|
final AgentStatus status;
|
||||||
const _ChatBubble({required this.message});
|
const _AgentStatusBar({required this.status});
|
||||||
|
|
||||||
bool get _isUser => message.role == ChatRole.user;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (message.contentType == ChatContentType.toolUse) {
|
final (String label, Color color, IconData icon) = switch (status) {
|
||||||
return _ToolUseCard(content: message.content);
|
AgentStatus.thinking => ('AI 正在思考...', AppColors.info, Icons.psychology),
|
||||||
}
|
AgentStatus.executing => ('正在执行命令...', AppColors.primary, Icons.terminal),
|
||||||
|
AgentStatus.awaitingApproval => ('等待审批', AppColors.warning, Icons.shield_outlined),
|
||||||
|
_ => ('', AppColors.textMuted, Icons.info_outline),
|
||||||
|
};
|
||||||
|
|
||||||
return Align(
|
if (label.isEmpty) return const SizedBox.shrink();
|
||||||
alignment: _isUser ? Alignment.centerRight : Alignment.centerLeft,
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxWidth: MediaQuery.of(context).size.width * 0.78,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _isUser ? AppColors.primary : AppColors.surface,
|
|
||||||
borderRadius: BorderRadius.only(
|
|
||||||
topLeft: const Radius.circular(16),
|
|
||||||
topRight: const Radius.circular(16),
|
|
||||||
bottomLeft: Radius.circular(_isUser ? 16 : 4),
|
|
||||||
bottomRight: Radius.circular(_isUser ? 4 : 16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
message.content,
|
|
||||||
style: TextStyle(
|
|
||||||
color: _isUser ? Colors.white : AppColors.textPrimary,
|
|
||||||
fontSize: 15,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ToolUseCard extends StatelessWidget {
|
|
||||||
final String content;
|
|
||||||
const _ToolUseCard({required this.content});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
color: color.withOpacity(0.1),
|
||||||
color: AppColors.surfaceLight.withOpacity(0.5),
|
child: Row(
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: AppColors.primary.withOpacity(0.3)),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Row(
|
SizedBox(
|
||||||
children: [
|
width: 16,
|
||||||
Icon(Icons.code, size: 16, color: AppColors.primary),
|
height: 16,
|
||||||
const SizedBox(width: 6),
|
child: status == AgentStatus.executing
|
||||||
Text(
|
? CircularProgressIndicator(strokeWidth: 2, color: color)
|
||||||
'工具执行',
|
: Icon(icon, size: 16, color: color),
|
||||||
style: TextStyle(
|
|
||||||
color: AppColors.primary,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(width: 8),
|
||||||
Container(
|
Text(
|
||||||
width: double.infinity,
|
label,
|
||||||
padding: const EdgeInsets.all(10),
|
style: TextStyle(color: color, fontSize: 13, fontWeight: FontWeight.w500),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.background,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: SelectableText(
|
|
||||||
content,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 12,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -625,74 +542,132 @@ class _ToolUseCard extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TypingIndicator extends StatefulWidget {
|
// ---------------------------------------------------------------------------
|
||||||
@override
|
// Standing order draft card
|
||||||
State<_TypingIndicator> createState() => _TypingIndicatorState();
|
// ---------------------------------------------------------------------------
|
||||||
}
|
|
||||||
|
|
||||||
class _TypingIndicatorState extends State<_TypingIndicator>
|
class _StandingOrderDraftCard extends StatelessWidget {
|
||||||
with SingleTickerProviderStateMixin {
|
final Map<String, dynamic> draft;
|
||||||
late AnimationController _controller;
|
final VoidCallback onConfirm;
|
||||||
|
|
||||||
@override
|
const _StandingOrderDraftCard({
|
||||||
void initState() {
|
required this.draft,
|
||||||
super.initState();
|
required this.onConfirm,
|
||||||
_controller = AnimationController(
|
});
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(milliseconds: 1200),
|
|
||||||
)..repeat();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Align(
|
final name = draft['name'] as String? ?? draft['orderName'] as String? ?? '未命名指令';
|
||||||
alignment: Alignment.centerLeft,
|
final cron = draft['cron'] as String? ?? draft['schedule'] as String? ?? '';
|
||||||
child: Container(
|
final command = draft['command'] as String? ?? draft['prompt'] as String? ?? '';
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
final targets = draft['targets'] as List? ?? draft['servers'] as List? ?? [];
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
return Container(
|
||||||
color: AppColors.surface,
|
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||||
borderRadius: BorderRadius.circular(16),
|
padding: const EdgeInsets.all(14),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
child: AnimatedBuilder(
|
color: AppColors.surface,
|
||||||
animation: _controller,
|
borderRadius: BorderRadius.circular(12),
|
||||||
builder: (context, _) {
|
border: Border.all(color: AppColors.primary.withOpacity(0.4)),
|
||||||
return Row(
|
),
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
children: List.generate(3, (i) {
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
final delay = i * 0.33;
|
children: [
|
||||||
final t = ((_controller.value + delay) % 1.0);
|
// Header
|
||||||
final opacity = (t < 0.5) ? 0.3 + t * 1.0 : 1.3 - t * 1.0;
|
Row(
|
||||||
return Padding(
|
children: [
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
Icon(Icons.schedule, size: 18, color: AppColors.primary),
|
||||||
child: Opacity(
|
const SizedBox(width: 8),
|
||||||
opacity: opacity.clamp(0.3, 1.0),
|
Expanded(
|
||||||
child: Container(
|
child: Text(
|
||||||
width: 8,
|
'常驻指令草案',
|
||||||
height: 8,
|
style: TextStyle(
|
||||||
decoration: const BoxDecoration(
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textSecondary,
|
fontSize: 14,
|
||||||
shape: BoxShape.circle,
|
color: AppColors.primary,
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}),
|
),
|
||||||
);
|
],
|
||||||
},
|
),
|
||||||
),
|
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
|
// Name
|
||||||
|
Text(name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
|
||||||
|
|
||||||
|
if (cron.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.timer_outlined, size: 14, color: AppColors.textMuted),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(cron, style: const TextStyle(color: AppColors.textSecondary, fontSize: 12, fontFamily: 'monospace')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (command.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.background,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: SelectableText(
|
||||||
|
command,
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (targets.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.dns, size: 14, color: AppColors.textMuted),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'目标: ${targets.join(", ")}',
|
||||||
|
style: const TextStyle(color: AppColors.textSecondary, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () {
|
||||||
|
// Dismiss — no specific API call needed
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.textSecondary,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
),
|
||||||
|
child: const Text('取消', style: TextStyle(fontSize: 13)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: onConfirm,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.primary,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
),
|
||||||
|
child: const Text('确认', style: TextStyle(fontSize: 13)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A mini [AnimatedWidget] wrapper used in lieu of [AnimatedBuilder].
|
|
||||||
/// Flutter's actual class is [AnimatedBuilder]; we re-export via this alias
|
|
||||||
/// so that the code reads naturally and works with the stable API.
|
|
||||||
/// (AnimatedBuilder is the stable name since Flutter 3.x.)
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue