1007 lines
34 KiB
Dart
1007 lines
34 KiB
Dart
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:image_picker/image_picker.dart';
|
||
import 'package:file_picker/file_picker.dart';
|
||
import '../../../../core/theme/app_colors.dart';
|
||
import '../../domain/entities/chat_message.dart';
|
||
import '../providers/chat_providers.dart';
|
||
import '../widgets/timeline_event_node.dart';
|
||
import '../widgets/stream_text_widget.dart';
|
||
import '../widgets/approval_action_card.dart';
|
||
import '../widgets/conversation_drawer.dart';
|
||
import '../../../agent_call/presentation/pages/agent_call_page.dart';
|
||
import '../widgets/voice_mic_button.dart';
|
||
import '../../../settings/presentation/providers/settings_providers.dart';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Chat page – Timeline workflow style (inspired by Claude Code VSCode)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class ChatPage extends ConsumerStatefulWidget {
|
||
const ChatPage({super.key});
|
||
|
||
@override
|
||
ConsumerState<ChatPage> createState() => _ChatPageState();
|
||
}
|
||
|
||
class _ChatPageState extends ConsumerState<ChatPage> {
|
||
final _messageController = TextEditingController();
|
||
final _scrollController = ScrollController();
|
||
final List<ChatAttachment> _pendingAttachments = [];
|
||
bool _sttLoading = false;
|
||
|
||
// -- Send ------------------------------------------------------------------
|
||
|
||
void _send() {
|
||
final text = _messageController.text.trim();
|
||
if (text.isEmpty && _pendingAttachments.isEmpty) return;
|
||
_messageController.clear();
|
||
final attachments = _pendingAttachments.isNotEmpty
|
||
? List<ChatAttachment>.from(_pendingAttachments)
|
||
: null;
|
||
if (_pendingAttachments.isNotEmpty) {
|
||
setState(() => _pendingAttachments.clear());
|
||
}
|
||
ref.read(chatProvider.notifier).sendMessage(text, attachments: attachments);
|
||
_scrollToBottom();
|
||
}
|
||
|
||
void _inject() {
|
||
final text = _messageController.text.trim();
|
||
if (text.isEmpty) return;
|
||
_messageController.clear();
|
||
ref.read(chatProvider.notifier).injectMessage(text);
|
||
_scrollToBottom();
|
||
}
|
||
|
||
Future<void> _transcribeToInput(String audioPath) async {
|
||
setState(() {
|
||
_sttLoading = true;
|
||
_messageController.text = '识别中…';
|
||
});
|
||
try {
|
||
final language = ref.read(settingsProvider).language;
|
||
final text = await ref.read(chatProvider.notifier).transcribeAudio(audioPath, language: language);
|
||
if (mounted) {
|
||
setState(() {
|
||
_messageController.text = text;
|
||
_messageController.selection = TextSelection.collapsed(
|
||
offset: text.length,
|
||
);
|
||
});
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() => _messageController.text = '');
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('语音识别失败,请重试')),
|
||
);
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _sttLoading = false);
|
||
}
|
||
}
|
||
|
||
void _scrollToBottom({bool jump = false}) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!_scrollController.hasClients) return;
|
||
final target = _scrollController.position.maxScrollExtent + 80;
|
||
if (jump) {
|
||
_scrollController.jumpTo(target);
|
||
// Second frame: layout may still be settling for large message lists
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (_scrollController.hasClients) {
|
||
_scrollController.jumpTo(
|
||
_scrollController.position.maxScrollExtent + 80,
|
||
);
|
||
}
|
||
});
|
||
} else {
|
||
_scrollController.animateTo(
|
||
target,
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeOut,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
void _openVoiceCall() {
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(builder: (_) => const AgentCallPage()),
|
||
);
|
||
}
|
||
|
||
// -- Attachments -----------------------------------------------------------
|
||
|
||
void _showAttachmentOptions() {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
builder: (ctx) => SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
ListTile(
|
||
leading: const Icon(Icons.photo_library),
|
||
title: const Text('从相册选择'),
|
||
subtitle: const Text('支持多选'),
|
||
onTap: () { Navigator.pop(ctx); _pickMultipleImages(); },
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.camera_alt),
|
||
title: const Text('拍照'),
|
||
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); },
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.attach_file),
|
||
title: const Text('选择文件'),
|
||
subtitle: const Text('图片、PDF'),
|
||
onTap: () { Navigator.pop(ctx); _pickFile(); },
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
static const _maxAttachments = 5;
|
||
|
||
Future<void> _pickImage(ImageSource source) async {
|
||
if (_pendingAttachments.length >= _maxAttachments) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('最多添加 $_maxAttachments 张图片')),
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
final picker = ImagePicker();
|
||
final picked = await picker.pickImage(
|
||
source: source,
|
||
maxWidth: 1568,
|
||
maxHeight: 1568,
|
||
imageQuality: 85,
|
||
);
|
||
if (picked == null) return;
|
||
|
||
final bytes = await picked.readAsBytes();
|
||
final ext = picked.path.split('.').last.toLowerCase();
|
||
final mediaType = switch (ext) {
|
||
'png' => 'image/png',
|
||
'webp' => 'image/webp',
|
||
'gif' => 'image/gif',
|
||
_ => 'image/jpeg',
|
||
};
|
||
|
||
setState(() {
|
||
_pendingAttachments.add(ChatAttachment(
|
||
base64Data: base64Encode(bytes),
|
||
mediaType: mediaType,
|
||
fileName: picked.name,
|
||
));
|
||
});
|
||
}
|
||
|
||
Future<void> _pickMultipleImages() async {
|
||
final remaining = _maxAttachments - _pendingAttachments.length;
|
||
if (remaining <= 0) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('最多添加 $_maxAttachments 张图片')),
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
final picker = ImagePicker();
|
||
final pickedList = await picker.pickMultiImage(
|
||
maxWidth: 1568,
|
||
maxHeight: 1568,
|
||
imageQuality: 85,
|
||
);
|
||
if (pickedList.isEmpty) return;
|
||
|
||
final toAdd = pickedList.take(remaining);
|
||
for (final picked in toAdd) {
|
||
final bytes = await picked.readAsBytes();
|
||
final ext = picked.path.split('.').last.toLowerCase();
|
||
final mediaType = switch (ext) {
|
||
'png' => 'image/png',
|
||
'webp' => 'image/webp',
|
||
'gif' => 'image/gif',
|
||
_ => 'image/jpeg',
|
||
};
|
||
_pendingAttachments.add(ChatAttachment(
|
||
base64Data: base64Encode(bytes),
|
||
mediaType: mediaType,
|
||
fileName: picked.name,
|
||
));
|
||
}
|
||
setState(() {});
|
||
|
||
if (pickedList.length > remaining && mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('已选择 $remaining 张,最多 $_maxAttachments 张')),
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<void> _pickFile() async {
|
||
final remaining = _maxAttachments - _pendingAttachments.length;
|
||
if (remaining <= 0) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('最多添加 $_maxAttachments 个附件')),
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Use FileType.any so the system picker shows subdirectories for navigation.
|
||
// We validate file extensions after selection.
|
||
final result = await FilePicker.platform.pickFiles(
|
||
type: FileType.any,
|
||
allowMultiple: true,
|
||
);
|
||
if (result == null || result.files.isEmpty) return;
|
||
|
||
const allowedExts = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'};
|
||
int skipped = 0;
|
||
|
||
final toAdd = result.files.take(remaining);
|
||
for (final file in toAdd) {
|
||
if (file.path == null) continue;
|
||
final ext = (file.extension ?? '').toLowerCase();
|
||
if (!allowedExts.contains(ext)) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
final bytes = await File(file.path!).readAsBytes();
|
||
final String mediaType;
|
||
if (ext == 'pdf') {
|
||
mediaType = 'application/pdf';
|
||
} else {
|
||
mediaType = switch (ext) {
|
||
'png' => 'image/png',
|
||
'webp' => 'image/webp',
|
||
'gif' => 'image/gif',
|
||
_ => 'image/jpeg',
|
||
};
|
||
}
|
||
|
||
_pendingAttachments.add(ChatAttachment(
|
||
base64Data: base64Encode(bytes),
|
||
mediaType: mediaType,
|
||
fileName: file.name,
|
||
));
|
||
}
|
||
setState(() {});
|
||
|
||
if (skipped > 0 && mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('仅支持图片(jpg/png/gif/webp)和PDF文件')),
|
||
);
|
||
} else if (result.files.length > remaining && mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('已选择 $remaining 个,最多 $_maxAttachments 个')),
|
||
);
|
||
}
|
||
}
|
||
|
||
Widget _buildAttachmentPreview() {
|
||
return SizedBox(
|
||
height: 80,
|
||
child: ListView.builder(
|
||
scrollDirection: Axis.horizontal,
|
||
itemCount: _pendingAttachments.length,
|
||
itemBuilder: (ctx, i) {
|
||
final att = _pendingAttachments[i];
|
||
final isImage = att.mediaType.startsWith('image/');
|
||
return Stack(
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.all(4),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: isImage
|
||
? Image.memory(
|
||
base64Decode(att.base64Data),
|
||
width: 72, height: 72,
|
||
fit: BoxFit.cover,
|
||
cacheWidth: 144, cacheHeight: 144,
|
||
)
|
||
: Container(
|
||
width: 72, height: 72,
|
||
color: AppColors.surfaceLight,
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(Icons.description, size: 28, color: AppColors.textSecondary),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
att.fileName?.split('.').last.toUpperCase() ?? 'FILE',
|
||
style: const TextStyle(fontSize: 10, color: AppColors.textMuted),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
top: 0,
|
||
right: 0,
|
||
child: GestureDetector(
|
||
onTap: () => setState(() => _pendingAttachments.removeAt(i)),
|
||
child: Container(
|
||
decoration: const BoxDecoration(
|
||
color: Colors.black54,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: const Icon(Icons.close, size: 16, color: Colors.white),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Whether to show a virtual "working" node at the bottom of the timeline.
|
||
/// True when the agent is streaming but no assistant message has appeared yet.
|
||
bool _needsWorkingNode(ChatState chatState) {
|
||
if (!chatState.isStreaming) return false;
|
||
if (chatState.messages.isEmpty) return false;
|
||
// Show working node if the last message is still the user's prompt
|
||
return chatState.messages.last.role == MessageRole.user;
|
||
}
|
||
|
||
// -- Timeline node builder ------------------------------------------------
|
||
|
||
Widget _buildTimelineNode(
|
||
ChatMessage message,
|
||
ChatState chatState, {
|
||
required bool isFirst,
|
||
required bool isLast,
|
||
}) {
|
||
final isLastMessage = chatState.messages.last.id == message.id;
|
||
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,
|
||
content: message.attachments != null && message.attachments!.isNotEmpty
|
||
? Wrap(
|
||
spacing: 4,
|
||
runSpacing: 4,
|
||
children: message.attachments!.map((att) {
|
||
final bytes = base64Decode(att.base64Data);
|
||
return ClipRRect(
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: Image.memory(bytes, width: 120, height: 120, fit: BoxFit.cover, cacheWidth: 240, cacheHeight: 240),
|
||
);
|
||
}).toList(),
|
||
)
|
||
: null,
|
||
);
|
||
}
|
||
|
||
switch (message.type) {
|
||
case MessageType.thinking:
|
||
return TimelineEventNode(
|
||
status: isStreamingNow ? NodeStatus.active : NodeStatus.completed,
|
||
label: '思考中...',
|
||
isFirst: isFirst,
|
||
isLast: isLast,
|
||
content: _CollapsibleThinking(
|
||
text: message.content,
|
||
isStreaming: isStreamingNow,
|
||
),
|
||
);
|
||
|
||
case MessageType.toolUse:
|
||
final tool = message.toolExecution;
|
||
final toolName = tool?.toolName ?? 'unknown';
|
||
final isExecuting = tool?.status == ToolStatus.executing;
|
||
return TimelineEventNode(
|
||
status: isExecuting ? NodeStatus.active : NodeStatus.completed,
|
||
label: toolName,
|
||
isFirst: isFirst,
|
||
isLast: isLast,
|
||
icon: isExecuting ? null : Icons.check_circle_outline,
|
||
content: tool != null && tool.input.isNotEmpty
|
||
? CodeBlock(text: tool.input, maxLines: 1)
|
||
: null,
|
||
);
|
||
|
||
case MessageType.toolResult:
|
||
final tool = message.toolExecution;
|
||
final isError = tool?.status == ToolStatus.error;
|
||
return TimelineEventNode(
|
||
status: isError ? NodeStatus.error : NodeStatus.completed,
|
||
label: isError ? '执行失败' : '执行结果',
|
||
isFirst: isFirst,
|
||
isLast: isLast,
|
||
icon: isError ? Icons.cancel_outlined : Icons.check_circle_outline,
|
||
content: tool?.output != null && tool!.output!.isNotEmpty
|
||
? _CollapsibleCodeBlock(
|
||
text: tool.output!,
|
||
textColor: isError ? AppColors.error : null,
|
||
)
|
||
: null,
|
||
);
|
||
|
||
case MessageType.approval:
|
||
return TimelineEventNode(
|
||
status: NodeStatus.warning,
|
||
label: '需要审批',
|
||
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.interrupted:
|
||
return TimelineEventNode(
|
||
status: NodeStatus.warning,
|
||
label: message.content,
|
||
isFirst: isFirst,
|
||
isLast: isLast,
|
||
icon: Icons.cancel_outlined,
|
||
);
|
||
|
||
case MessageType.text:
|
||
default:
|
||
return TimelineEventNode(
|
||
status: isStreamingNow ? NodeStatus.active : NodeStatus.completed,
|
||
label: isStreamingNow ? '回复中...' : '回复',
|
||
isFirst: isFirst,
|
||
isLast: isLast,
|
||
icon: isStreamingNow ? null : Icons.check_circle_outline,
|
||
content: StreamTextWidget(
|
||
text: message.content,
|
||
isStreaming: isStreamingNow,
|
||
style: const TextStyle(
|
||
color: AppColors.textPrimary,
|
||
fontSize: 14,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// -- Build -----------------------------------------------------------------
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final chatState = ref.watch(chatProvider);
|
||
|
||
// Auto-scroll when messages change
|
||
ref.listen(chatProvider, (prev, next) {
|
||
// Jump (no animation) when loading a conversation history
|
||
final wasEmpty = prev?.messages.isEmpty ?? true;
|
||
final nowHasMany = next.messages.length > 1;
|
||
_scrollToBottom(jump: wasEmpty && nowHasMany);
|
||
});
|
||
|
||
return Scaffold(
|
||
drawer: const ConversationDrawer(),
|
||
appBar: AppBar(
|
||
title: const Text('iAgent', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.edit_outlined, size: 20),
|
||
tooltip: '新对话',
|
||
visualDensity: VisualDensity.compact,
|
||
onPressed: () => ref.read(chatProvider.notifier).startNewChat(),
|
||
),
|
||
if (chatState.isStreaming)
|
||
IconButton(
|
||
icon: const Icon(Icons.stop_circle_outlined, size: 20),
|
||
tooltip: '停止',
|
||
visualDensity: VisualDensity.compact,
|
||
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.call, size: 20),
|
||
tooltip: '语音通话',
|
||
visualDensity: VisualDensity.compact,
|
||
onPressed: _openVoiceCall,
|
||
),
|
||
const SizedBox(width: 4),
|
||
],
|
||
),
|
||
body: SafeArea(
|
||
top: false,
|
||
child: Column(
|
||
children: [
|
||
// Error banner
|
||
if (chatState.error != null)
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||
color: AppColors.error.withOpacity(0.1),
|
||
child: Row(
|
||
children: [
|
||
const Icon(Icons.error_outline, size: 16, color: AppColors.error),
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// Timeline message list + floating input
|
||
Expanded(
|
||
child: Stack(
|
||
children: [
|
||
// Messages fill the entire area
|
||
chatState.messages.isEmpty
|
||
? _buildEmptyState()
|
||
: ListView.builder(
|
||
controller: _scrollController,
|
||
// Bottom padding leaves room for the floating input pill
|
||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 80),
|
||
itemCount: chatState.messages.length +
|
||
(_needsWorkingNode(chatState) ? 1 : 0),
|
||
itemBuilder: (context, index) {
|
||
if (index == chatState.messages.length &&
|
||
_needsWorkingNode(chatState)) {
|
||
return TimelineEventNode(
|
||
status: NodeStatus.active,
|
||
label: '处理中...',
|
||
isFirst: false,
|
||
isLast: false,
|
||
);
|
||
}
|
||
final isRealLast =
|
||
index == chatState.messages.length - 1;
|
||
return _buildTimelineNode(
|
||
chatState.messages[index],
|
||
chatState,
|
||
isFirst: index == 0,
|
||
isLast: isRealLast &&
|
||
!chatState.isStreaming &&
|
||
!_needsWorkingNode(chatState),
|
||
);
|
||
},
|
||
),
|
||
// Floating input pill at bottom
|
||
Positioned(
|
||
left: 12,
|
||
right: 12,
|
||
bottom: 8,
|
||
child: _buildInputArea(chatState),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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) {
|
||
final isAwaitingApproval = chatState.agentStatus == AgentStatus.awaitingApproval;
|
||
final isStreaming = chatState.isStreaming && !isAwaitingApproval;
|
||
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface.withOpacity(0.92),
|
||
borderRadius: BorderRadius.circular(28),
|
||
border: Border.all(color: AppColors.surfaceLight.withOpacity(0.6)),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.25),
|
||
blurRadius: 12,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (_pendingAttachments.isNotEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||
child: _buildAttachmentPreview(),
|
||
),
|
||
Row(
|
||
children: [
|
||
if (!isStreaming)
|
||
Padding(
|
||
padding: const EdgeInsets.only(left: 4),
|
||
child: IconButton(
|
||
icon: const Icon(Icons.add_circle_outline, size: 22),
|
||
tooltip: '添加图片',
|
||
onPressed: isAwaitingApproval ? null : _showAttachmentOptions,
|
||
),
|
||
),
|
||
Expanded(
|
||
child: TextField(
|
||
controller: _messageController,
|
||
decoration: InputDecoration(
|
||
hintText: isStreaming ? '追加指令...' : '输入指令...',
|
||
hintStyle: TextStyle(color: AppColors.textMuted),
|
||
border: InputBorder.none,
|
||
contentPadding: EdgeInsets.only(
|
||
left: isStreaming ? 16 : 4,
|
||
right: 4,
|
||
top: 12,
|
||
bottom: 12,
|
||
),
|
||
),
|
||
textInputAction: TextInputAction.send,
|
||
onSubmitted: (_) => isStreaming ? _inject() : _send(),
|
||
enabled: !isAwaitingApproval,
|
||
),
|
||
),
|
||
if (isStreaming)
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
IconButton(
|
||
icon: const Icon(Icons.send, color: AppColors.info, size: 20),
|
||
tooltip: '追加指令',
|
||
onPressed: _inject,
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: 4),
|
||
child: IconButton(
|
||
icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error, size: 20),
|
||
tooltip: '停止',
|
||
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
|
||
),
|
||
),
|
||
],
|
||
)
|
||
else
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
VoiceMicButton(
|
||
disabled: isAwaitingApproval || _sttLoading,
|
||
onAudioReady: _transcribeToInput,
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: 4),
|
||
child: IconButton(
|
||
icon: const Icon(Icons.send, size: 20),
|
||
onPressed: isAwaitingApproval ? null : _send,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_messageController.dispose();
|
||
_scrollController.dispose();
|
||
super.dispose();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Standing order content (embedded in timeline node)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Collapsible code block for tool results – collapsed by default, tap to expand
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class _CollapsibleCodeBlock extends StatefulWidget {
|
||
final String text;
|
||
final Color? textColor;
|
||
|
||
const _CollapsibleCodeBlock({
|
||
required this.text,
|
||
this.textColor,
|
||
});
|
||
|
||
@override
|
||
State<_CollapsibleCodeBlock> createState() => _CollapsibleCodeBlockState();
|
||
}
|
||
|
||
class _CollapsibleCodeBlockState extends State<_CollapsibleCodeBlock> {
|
||
bool _expanded = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final lineCount = '\n'.allMatches(widget.text).length + 1;
|
||
// Short results (≤3 lines): always show fully
|
||
if (lineCount <= 3) {
|
||
return CodeBlock(text: widget.text, textColor: widget.textColor);
|
||
}
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
ClipRect(
|
||
child: AnimatedCrossFade(
|
||
firstChild: CodeBlock(text: widget.text, textColor: widget.textColor),
|
||
secondChild: CodeBlock(
|
||
text: widget.text,
|
||
textColor: widget.textColor,
|
||
maxLines: 3,
|
||
),
|
||
crossFadeState: _expanded
|
||
? CrossFadeState.showFirst
|
||
: CrossFadeState.showSecond,
|
||
duration: const Duration(milliseconds: 200),
|
||
sizeCurve: Curves.easeInOut,
|
||
),
|
||
),
|
||
GestureDetector(
|
||
onTap: () => setState(() => _expanded = !_expanded),
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(top: 4),
|
||
child: Text(
|
||
_expanded ? '收起' : '展开 ($lineCount 行)',
|
||
style: TextStyle(
|
||
color: AppColors.info,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Collapsible thinking content – expanded while streaming, collapsed when done
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class _CollapsibleThinking extends StatefulWidget {
|
||
final String text;
|
||
final bool isStreaming;
|
||
|
||
const _CollapsibleThinking({
|
||
required this.text,
|
||
required this.isStreaming,
|
||
});
|
||
|
||
@override
|
||
State<_CollapsibleThinking> createState() => _CollapsibleThinkingState();
|
||
}
|
||
|
||
class _CollapsibleThinkingState extends State<_CollapsibleThinking> {
|
||
bool _expanded = true;
|
||
|
||
@override
|
||
void didUpdateWidget(covariant _CollapsibleThinking oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
// Auto-collapse when streaming ends
|
||
if (oldWidget.isStreaming && !widget.isStreaming) {
|
||
setState(() => _expanded = false);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
// While streaming, always show expanded content
|
||
if (widget.isStreaming) {
|
||
return StreamTextWidget(
|
||
text: widget.text,
|
||
isStreaming: true,
|
||
style: const TextStyle(
|
||
color: AppColors.textSecondary,
|
||
fontSize: 13,
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
);
|
||
}
|
||
|
||
// Completed – show collapsible toggle
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
GestureDetector(
|
||
onTap: () => setState(() => _expanded = !_expanded),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'Thinking',
|
||
style: TextStyle(
|
||
color: AppColors.textMuted,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
AnimatedRotation(
|
||
turns: _expanded ? 0.5 : 0.0,
|
||
duration: const Duration(milliseconds: 200),
|
||
child: Icon(
|
||
Icons.expand_more,
|
||
size: 16,
|
||
color: AppColors.textMuted,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
ClipRect(
|
||
child: AnimatedCrossFade(
|
||
firstChild: Padding(
|
||
padding: const EdgeInsets.only(top: 6),
|
||
child: StreamTextWidget(
|
||
text: widget.text,
|
||
isStreaming: false,
|
||
style: const TextStyle(
|
||
color: AppColors.textSecondary,
|
||
fontSize: 13,
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
),
|
||
),
|
||
secondChild: const SizedBox.shrink(),
|
||
crossFadeState: _expanded
|
||
? CrossFadeState.showFirst
|
||
: CrossFadeState.showSecond,
|
||
duration: const Duration(milliseconds: 200),
|
||
sizeCurve: Curves.easeInOut,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Standing order content (embedded in timeline node)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class _StandingOrderContent extends StatelessWidget {
|
||
final Map<String, dynamic> draft;
|
||
final VoidCallback onConfirm;
|
||
|
||
const _StandingOrderContent({
|
||
required this.draft,
|
||
required this.onConfirm,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final name = draft['name'] as String? ?? draft['orderName'] as String? ?? '未命名指令';
|
||
final cron = draft['cron'] as String? ?? draft['schedule'] as String? ?? '';
|
||
final command = draft['command'] as String? ?? draft['prompt'] as String? ?? '';
|
||
final targets = draft['targets'] as List? ?? draft['servers'] as List? ?? [];
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||
if (cron.isNotEmpty) ...[
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
cron,
|
||
style: const TextStyle(
|
||
color: AppColors.textSecondary,
|
||
fontSize: 12,
|
||
fontFamily: 'monospace',
|
||
),
|
||
),
|
||
],
|
||
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)),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|