feat: redesign chat UI from bubble style to timeline workflow
Replace traditional chat bubble layout with a Claude Code-inspired timeline/workflow design: - Vertical gray line connecting sequential event nodes - Colored dots for each event (green=done, red=error, yellow=warning) - Animated spinning asterisk (*) on active nodes - Streaming text with blinking cursor in timeline nodes - Tool execution shown as code blocks within timeline - User prompts as distinct nodes with person icon New file: timeline_event_node.dart (TimelineEventNode, CodeBlock) Rewritten: chat_page.dart (timeline layout, no more bubbles) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
74be945e4a
commit
20325a84bd
|
|
@ -3,15 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../../core/theme/app_colors.dart';
|
import '../../../../core/theme/app_colors.dart';
|
||||||
import '../../domain/entities/chat_message.dart';
|
import '../../domain/entities/chat_message.dart';
|
||||||
import '../providers/chat_providers.dart';
|
import '../providers/chat_providers.dart';
|
||||||
import '../widgets/message_bubble.dart';
|
import '../widgets/timeline_event_node.dart';
|
||||||
import '../widgets/tool_execution_card.dart';
|
|
||||||
import '../widgets/approval_action_card.dart';
|
|
||||||
import '../widgets/agent_thinking_indicator.dart';
|
|
||||||
import '../widgets/stream_text_widget.dart';
|
import '../widgets/stream_text_widget.dart';
|
||||||
|
import '../widgets/approval_action_card.dart';
|
||||||
import '../../../agent_call/presentation/pages/agent_call_page.dart';
|
import '../../../agent_call/presentation/pages/agent_call_page.dart';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Chat page – ConsumerStatefulWidget
|
// Chat page – Timeline workflow style (inspired by Claude Code VSCode)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class ChatPage extends ConsumerStatefulWidget {
|
class ChatPage extends ConsumerStatefulWidget {
|
||||||
|
|
@ -53,120 +51,127 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Message widget dispatch -----------------------------------------------
|
// -- Timeline node builder ------------------------------------------------
|
||||||
|
|
||||||
Widget _buildMessageWidget(ChatMessage message, ChatState chatState) {
|
Widget _buildTimelineNode(
|
||||||
|
ChatMessage message,
|
||||||
|
ChatState chatState, {
|
||||||
|
required bool isFirst,
|
||||||
|
required bool isLast,
|
||||||
|
}) {
|
||||||
final isLastMessage = chatState.messages.last.id == message.id;
|
final isLastMessage = chatState.messages.last.id == message.id;
|
||||||
final isStreamingNow = isLastMessage && chatState.isStreaming;
|
final isStreamingNow = isLastMessage && chatState.isStreaming;
|
||||||
|
|
||||||
|
// User message — special "prompt" node
|
||||||
|
if (message.role == MessageRole.user) {
|
||||||
|
return TimelineEventNode(
|
||||||
|
status: NodeStatus.completed,
|
||||||
|
label: message.content,
|
||||||
|
isFirst: isFirst,
|
||||||
|
isLast: isLast,
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case MessageType.toolUse:
|
case MessageType.thinking:
|
||||||
case MessageType.toolResult:
|
return TimelineEventNode(
|
||||||
if (message.toolExecution != null) {
|
status: isStreamingNow ? NodeStatus.active : NodeStatus.completed,
|
||||||
return ToolExecutionCard(toolExecution: message.toolExecution!);
|
label: '思考中...',
|
||||||
}
|
isFirst: isFirst,
|
||||||
return MessageBubble(message: message);
|
isLast: isLast,
|
||||||
|
content: StreamTextWidget(
|
||||||
case MessageType.approval:
|
text: message.content,
|
||||||
if (message.approvalRequest != null) {
|
isStreaming: isStreamingNow,
|
||||||
return ApprovalActionCard(
|
style: const TextStyle(
|
||||||
approvalRequest: message.approvalRequest!,
|
color: AppColors.textSecondary,
|
||||||
onApprove: () => ref.read(chatProvider.notifier)
|
fontSize: 13,
|
||||||
.approveCommand(message.approvalRequest!.taskId),
|
fontStyle: FontStyle.italic,
|
||||||
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:
|
case MessageType.toolUse:
|
||||||
// AI thinking content — italic label + streaming text
|
final tool = message.toolExecution;
|
||||||
return Align(
|
final toolName = tool?.toolName ?? 'unknown';
|
||||||
alignment: Alignment.centerLeft,
|
final isExecuting = tool?.status == ToolStatus.executing;
|
||||||
child: Container(
|
return TimelineEventNode(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
status: isExecuting ? NodeStatus.active : NodeStatus.completed,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
label: toolName,
|
||||||
constraints: BoxConstraints(
|
isFirst: isFirst,
|
||||||
maxWidth: MediaQuery.of(context).size.width * 0.78,
|
isLast: isLast,
|
||||||
),
|
icon: isExecuting ? null : Icons.check_circle_outline,
|
||||||
decoration: BoxDecoration(
|
content: tool != null && tool.input.isNotEmpty
|
||||||
color: AppColors.surface,
|
? CodeBlock(text: tool.input)
|
||||||
borderRadius: const BorderRadius.only(
|
: null,
|
||||||
topLeft: Radius.circular(16),
|
);
|
||||||
topRight: Radius.circular(16),
|
|
||||||
bottomLeft: Radius.circular(4),
|
case MessageType.toolResult:
|
||||||
bottomRight: Radius.circular(16),
|
final tool = message.toolExecution;
|
||||||
),
|
final isError = tool?.status == ToolStatus.error;
|
||||||
),
|
return TimelineEventNode(
|
||||||
child: Column(
|
status: isError ? NodeStatus.error : NodeStatus.completed,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
label: isError ? '执行失败' : '执行结果',
|
||||||
children: [
|
isFirst: isFirst,
|
||||||
Text(
|
isLast: isLast,
|
||||||
'思考中...',
|
icon: isError ? Icons.cancel_outlined : Icons.check_circle_outline,
|
||||||
style: TextStyle(
|
content: tool?.output != null && tool!.output!.isNotEmpty
|
||||||
color: AppColors.textMuted,
|
? CodeBlock(
|
||||||
fontSize: 11,
|
text: tool.output!,
|
||||||
fontStyle: FontStyle.italic,
|
textColor: isError ? AppColors.error : null,
|
||||||
),
|
)
|
||||||
),
|
: null,
|
||||||
const SizedBox(height: 4),
|
);
|
||||||
StreamTextWidget(
|
|
||||||
text: message.content,
|
case MessageType.approval:
|
||||||
isStreaming: isStreamingNow,
|
return TimelineEventNode(
|
||||||
style: TextStyle(
|
status: NodeStatus.warning,
|
||||||
color: AppColors.textSecondary,
|
label: '需要审批',
|
||||||
fontSize: 14,
|
isFirst: isFirst,
|
||||||
),
|
isLast: isLast,
|
||||||
),
|
icon: Icons.shield_outlined,
|
||||||
],
|
content: message.approvalRequest != null
|
||||||
),
|
? 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),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
case MessageType.standingOrderDraft:
|
||||||
|
return TimelineEventNode(
|
||||||
|
status: NodeStatus.warning,
|
||||||
|
label: '常驻指令草案',
|
||||||
|
isFirst: isFirst,
|
||||||
|
isLast: isLast,
|
||||||
|
icon: Icons.schedule,
|
||||||
|
content: _StandingOrderContent(
|
||||||
|
draft: message.metadata ?? {},
|
||||||
|
onConfirm: () => ref.read(chatProvider.notifier)
|
||||||
|
.confirmStandingOrder(message.metadata ?? {}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
case MessageType.text:
|
case MessageType.text:
|
||||||
default:
|
default:
|
||||||
// User messages → standard bubble
|
return TimelineEventNode(
|
||||||
if (message.role == MessageRole.user) {
|
status: isStreamingNow ? NodeStatus.active : NodeStatus.completed,
|
||||||
return MessageBubble(message: message);
|
label: isStreamingNow ? '回复中...' : '回复',
|
||||||
}
|
isFirst: isFirst,
|
||||||
// AI text messages — use StreamTextWidget when actively streaming
|
isLast: isLast,
|
||||||
if (isStreamingNow) {
|
icon: isStreamingNow ? null : Icons.check_circle_outline,
|
||||||
return Align(
|
content: StreamTextWidget(
|
||||||
alignment: Alignment.centerLeft,
|
text: message.content,
|
||||||
child: Container(
|
isStreaming: isStreamingNow,
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
style: const TextStyle(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
color: AppColors.textPrimary,
|
||||||
constraints: BoxConstraints(
|
fontSize: 14,
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,7 +186,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('AI 对话'),
|
title: const Text('iAgent'),
|
||||||
actions: [
|
actions: [
|
||||||
// Stop button during streaming
|
// Stop button during streaming
|
||||||
if (chatState.isStreaming)
|
if (chatState.isStreaming)
|
||||||
|
|
@ -208,67 +213,43 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||||
children: [
|
children: [
|
||||||
// Error banner
|
// Error banner
|
||||||
if (chatState.error != null)
|
if (chatState.error != null)
|
||||||
MaterialBanner(
|
Container(
|
||||||
content: Text(chatState.error!, style: const TextStyle(color: AppColors.error)),
|
width: double.infinity,
|
||||||
backgroundColor: AppColors.error.withOpacity(0.1),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
actions: [
|
color: AppColors.error.withOpacity(0.1),
|
||||||
TextButton(
|
child: Row(
|
||||||
onPressed: () =>
|
children: [
|
||||||
ref.read(chatProvider.notifier).clearChat(),
|
const Icon(Icons.error_outline, size: 16, color: AppColors.error),
|
||||||
child: const Text('关闭'),
|
const SizedBox(width: 8),
|
||||||
),
|
Expanded(
|
||||||
],
|
child: Text(
|
||||||
|
chatState.error!,
|
||||||
|
style: const TextStyle(color: AppColors.error, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => ref.read(chatProvider.notifier).clearChat(),
|
||||||
|
child: const Icon(Icons.close, size: 16, color: AppColors.error),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Agent status bar
|
// Timeline message list
|
||||||
if (chatState.agentStatus != AgentStatus.idle && chatState.agentStatus != AgentStatus.error)
|
|
||||||
_AgentStatusBar(status: chatState.agentStatus),
|
|
||||||
|
|
||||||
// Message list
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: chatState.messages.isEmpty
|
child: chatState.messages.isEmpty
|
||||||
? Center(
|
? _buildEmptyState()
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.smart_toy_outlined, size: 64, color: AppColors.textMuted),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'开始与 iAgent 对话',
|
|
||||||
style: TextStyle(color: AppColors.textSecondary, fontSize: 16),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'输入文字或拨打语音通话',
|
|
||||||
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _openVoiceCall,
|
|
||||||
icon: const Icon(Icons.call),
|
|
||||||
label: const Text('语音通话'),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: AppColors.success,
|
|
||||||
side: const BorderSide(color: AppColors.success),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: 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 +
|
itemCount: chatState.messages.length,
|
||||||
(chatState.agentStatus == AgentStatus.thinking ? 1 : 0),
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
// Trailing thinking indicator
|
return _buildTimelineNode(
|
||||||
if (index == chatState.messages.length) {
|
|
||||||
return const AgentThinkingIndicator();
|
|
||||||
}
|
|
||||||
return _buildMessageWidget(
|
|
||||||
chatState.messages[index],
|
chatState.messages[index],
|
||||||
chatState,
|
chatState,
|
||||||
|
isFirst: index == 0,
|
||||||
|
isLast: index == chatState.messages.length - 1 &&
|
||||||
|
!chatState.isStreaming,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -281,8 +262,41 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.smart_toy_outlined, size: 64, color: AppColors.textMuted),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'开始与 iAgent 对话',
|
||||||
|
style: TextStyle(color: AppColors.textSecondary, fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'输入指令或拨打语音通话',
|
||||||
|
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _openVoiceCall,
|
||||||
|
icon: const Icon(Icons.call),
|
||||||
|
label: const Text('语音通话'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.success,
|
||||||
|
side: const BorderSide(color: AppColors.success),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildInputArea(ChatState chatState) {
|
Widget _buildInputArea(ChatState chatState) {
|
||||||
final isDisabled = chatState.isStreaming || chatState.agentStatus == AgentStatus.awaitingApproval;
|
final isDisabled = chatState.isStreaming ||
|
||||||
|
chatState.agentStatus == AgentStatus.awaitingApproval;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
|
|
@ -291,48 +305,35 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||||
border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))),
|
border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))),
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Column(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
if (chatState.agentStatus == AgentStatus.awaitingApproval)
|
Expanded(
|
||||||
Padding(
|
child: TextField(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
controller: _messageController,
|
||||||
child: Text(
|
decoration: const InputDecoration(
|
||||||
'等待审批中...',
|
hintText: '输入指令...',
|
||||||
style: TextStyle(color: AppColors.warning, fontSize: 12),
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(24)),
|
||||||
|
),
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
),
|
),
|
||||||
|
textInputAction: TextInputAction.send,
|
||||||
|
onSubmitted: (_) => _send(),
|
||||||
|
enabled: !isDisabled,
|
||||||
),
|
),
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -348,57 +349,14 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Agent status bar
|
// Standing order content (embedded in timeline node)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class _AgentStatusBar extends StatelessWidget {
|
class _StandingOrderContent extends StatelessWidget {
|
||||||
final AgentStatus status;
|
|
||||||
const _AgentStatusBar({required this.status});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final (String label, Color color, IconData icon) = switch (status) {
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (label.isEmpty) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
color: color.withOpacity(0.1),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: status == AgentStatus.executing
|
|
||||||
? CircularProgressIndicator(strokeWidth: 2, color: color)
|
|
||||||
: Icon(icon, size: 16, color: color),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(color: color, fontSize: 13, fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Standing order draft card
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _StandingOrderDraftCard extends StatelessWidget {
|
|
||||||
final Map<String, dynamic> draft;
|
final Map<String, dynamic> draft;
|
||||||
final VoidCallback onConfirm;
|
final VoidCallback onConfirm;
|
||||||
|
|
||||||
const _StandingOrderDraftCard({
|
const _StandingOrderContent({
|
||||||
required this.draft,
|
required this.draft,
|
||||||
required this.onConfirm,
|
required this.onConfirm,
|
||||||
});
|
});
|
||||||
|
|
@ -410,112 +368,57 @@ class _StandingOrderDraftCard extends StatelessWidget {
|
||||||
final command = draft['command'] as String? ?? draft['prompt'] as String? ?? '';
|
final command = draft['command'] as String? ?? draft['prompt'] as String? ?? '';
|
||||||
final targets = draft['targets'] as List? ?? draft['servers'] as List? ?? [];
|
final targets = draft['targets'] as List? ?? draft['servers'] as List? ?? [];
|
||||||
|
|
||||||
return Container(
|
return Column(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
padding: const EdgeInsets.all(14),
|
children: [
|
||||||
decoration: BoxDecoration(
|
Text(name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||||
color: AppColors.surface,
|
if (cron.isNotEmpty) ...[
|
||||||
borderRadius: BorderRadius.circular(12),
|
const SizedBox(height: 4),
|
||||||
border: Border.all(color: AppColors.primary.withOpacity(0.4)),
|
Text(
|
||||||
),
|
cron,
|
||||||
child: Column(
|
style: const TextStyle(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
color: AppColors.textSecondary,
|
||||||
children: [
|
fontSize: 12,
|
||||||
// Header
|
fontFamily: 'monospace',
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.schedule, size: 18, color: AppColors.primary),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'常驻指令草案',
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 14,
|
|
||||||
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)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
if (command.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
CodeBlock(text: command),
|
||||||
|
],
|
||||||
|
if (targets.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'目标: ${targets.join(", ")}',
|
||||||
|
style: const TextStyle(color: AppColors.textSecondary, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () {},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.textSecondary,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
),
|
||||||
|
child: const Text('取消', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: onConfirm,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.primary,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
),
|
||||||
|
child: const Text('确认', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../core/theme/app_colors.dart';
|
||||||
|
|
||||||
|
/// Status of a timeline node, determines dot style and color.
|
||||||
|
enum NodeStatus { active, completed, error, warning, idle }
|
||||||
|
|
||||||
|
/// A single node in the agent workflow timeline.
|
||||||
|
///
|
||||||
|
/// Renders: vertical line | colored dot/spinner | content
|
||||||
|
/// When [status] is [NodeStatus.active], the dot becomes an animated spinner.
|
||||||
|
class TimelineEventNode extends StatelessWidget {
|
||||||
|
final NodeStatus status;
|
||||||
|
final String label;
|
||||||
|
final Widget? content;
|
||||||
|
final bool isFirst;
|
||||||
|
final bool isLast;
|
||||||
|
final bool isStreaming;
|
||||||
|
final IconData? icon;
|
||||||
|
|
||||||
|
const TimelineEventNode({
|
||||||
|
super.key,
|
||||||
|
required this.status,
|
||||||
|
required this.label,
|
||||||
|
this.content,
|
||||||
|
this.isFirst = false,
|
||||||
|
this.isLast = false,
|
||||||
|
this.isStreaming = false,
|
||||||
|
this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
Color get _dotColor => switch (status) {
|
||||||
|
NodeStatus.active => AppColors.info,
|
||||||
|
NodeStatus.completed => AppColors.success,
|
||||||
|
NodeStatus.error => AppColors.error,
|
||||||
|
NodeStatus.warning => AppColors.warning,
|
||||||
|
NodeStatus.idle => AppColors.textMuted,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Left column: vertical line + dot
|
||||||
|
SizedBox(
|
||||||
|
width: 28,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Line above dot
|
||||||
|
Container(
|
||||||
|
width: 1.5,
|
||||||
|
height: 8,
|
||||||
|
color: isFirst ? Colors.transparent : AppColors.surfaceLight,
|
||||||
|
),
|
||||||
|
// Dot or spinner
|
||||||
|
status == NodeStatus.active
|
||||||
|
? _SpinnerDot(color: _dotColor)
|
||||||
|
: _StaticDot(color: _dotColor, icon: icon),
|
||||||
|
// Line below dot
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
width: 1.5,
|
||||||
|
color: isLast ? Colors.transparent : AppColors.surfaceLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// Right column: label + content
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Label row
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: status == NodeStatus.active
|
||||||
|
? _dotColor
|
||||||
|
: AppColors.textSecondary,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Content
|
||||||
|
if (content != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
content!,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Static colored dot with optional icon for completed/error/idle states.
|
||||||
|
class _StaticDot extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
final IconData? icon;
|
||||||
|
|
||||||
|
const _StaticDot({required this.color, this.icon});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (icon != null) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: Icon(icon, size: 14, color: color),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Animated spinning asterisk for active nodes.
|
||||||
|
class _SpinnerDot extends StatefulWidget {
|
||||||
|
final Color color;
|
||||||
|
const _SpinnerDot({required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SpinnerDot> createState() => _SpinnerDotState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpinnerDotState extends State<_SpinnerDot>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1000),
|
||||||
|
)..repeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (_, __) {
|
||||||
|
return Transform.rotate(
|
||||||
|
angle: _controller.value * 2 * math.pi,
|
||||||
|
child: Text(
|
||||||
|
'*',
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.color,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Code block container used for tool input/output.
|
||||||
|
class CodeBlock extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final Color? textColor;
|
||||||
|
final int? maxLines;
|
||||||
|
|
||||||
|
const CodeBlock({
|
||||||
|
super.key,
|
||||||
|
required this.text,
|
||||||
|
this.textColor,
|
||||||
|
this.maxLines,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.background,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: SelectableText(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
color: textColor ?? AppColors.textSecondary,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
maxLines: maxLines,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue