it0/it0_app/lib/features/chat/presentation/pages/chat_page.dart

1145 lines
39 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import 'dart:io';
import 'package:audio_session/audio_session.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:it0_app/l10n/app_localizations.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/robot_avatar.dart';
import '../../../../core/widgets/floating_robot_fab.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';
// ---------------------------------------------------------------------------
// 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 = AppLocalizations.of(context).chatRecognizingLabel;
});
try {
final text = await ref.read(chatProvider.notifier).transcribeAudio(audioPath);
if (mounted) {
setState(() {
_messageController.text = text;
_messageController.selection = TextSelection.collapsed(
offset: text.length,
);
});
}
} catch (e) {
if (mounted) {
setState(() => _messageController.text = '');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).chatSpeechRecognitionError)),
);
}
} 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: Text(AppLocalizations.of(context).chatSelectFromAlbum),
subtitle: const Text('支持多选'),
onTap: () { Navigator.pop(ctx); _pickMultipleImages(); },
),
ListTile(
leading: const Icon(Icons.camera_alt),
title: Text(AppLocalizations.of(context).chatTakePhoto),
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); },
),
ListTile(
leading: const Icon(Icons.attach_file),
title: Text(AppLocalizations.of(context).chatSelectFile),
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: AppLocalizations.of(context).chatThinkingLabel,
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 ? AppLocalizations.of(context).chatExecutionFailedLabel : AppLocalizations.of(context).chatExecutionResultLabel,
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: AppLocalizations.of(context).chatNeedsApprovalLabel,
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: AppLocalizations.of(context).chatStandingOrderDraftLabel,
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.oauthPrompt:
final url = message.metadata?['url'] as String? ?? '';
final instanceName = message.metadata?['instanceName'] as String? ?? '小龙虾';
return TimelineEventNode(
status: NodeStatus.warning,
label: '钉钉授权',
isFirst: isFirst,
isLast: isLast,
icon: Icons.link,
content: _OAuthPromptCard(url: url, instanceName: instanceName),
);
case MessageType.text:
default:
return TimelineEventNode(
status: isStreamingNow ? NodeStatus.active : NodeStatus.completed,
label: isStreamingNow ? AppLocalizations.of(context).chatReplyingLabel : AppLocalizations.of(context).chatReplyLabel,
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: Consumer(
builder: (context, ref, _) {
final robotState = ref.watch(robotStateProvider);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
RobotAvatar(state: robotState, size: 32),
const SizedBox(width: 8),
Text(AppLocalizations.of(context).appTitle,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600)),
],
);
},
),
actions: [
IconButton(
icon: const Icon(Icons.edit_outlined, size: 20),
tooltip: AppLocalizations.of(context).chatNewConversationTooltip,
visualDensity: VisualDensity.compact,
onPressed: () => ref.read(chatProvider.notifier).startNewChat(),
),
if (chatState.isStreaming)
IconButton(
icon: const Icon(Icons.stop_circle_outlined, size: 20),
tooltip: AppLocalizations.of(context).chatStopTooltip,
visualDensity: VisualDensity.compact,
onPressed: () => ref.read(chatProvider.notifier).cancelCurrentTask(),
),
IconButton(
icon: const Icon(Icons.call, size: 20),
tooltip: AppLocalizations.of(context).chatVoiceCallTooltip,
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: AppLocalizations.of(context).chatProcessingLabel,
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(
AppLocalizations.of(context).chatStartConversationPrompt,
style: const TextStyle(color: AppColors.textSecondary, fontSize: 16),
),
const SizedBox(height: 8),
Text(
AppLocalizations.of(context).chatInputInstructionHint,
style: const 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: AppLocalizations.of(context).chatAddImageTooltip,
onPressed: isAwaitingApproval ? null : _showAttachmentOptions,
),
),
Expanded(
child: TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: isStreaming ? AppLocalizations.of(context).chatAdditionalInstructionHint : AppLocalizations.of(context).chatInstructionHint,
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: AppLocalizations.of(context).chatInjectionTooltip,
onPressed: _inject,
),
Padding(
padding: const EdgeInsets.only(right: 4),
child: IconButton(
icon: const Icon(Icons.stop_circle_outlined, color: AppColors.error, size: 20),
tooltip: AppLocalizations.of(context).chatStopTooltip,
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)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// OAuth prompt card — shown in timeline when agent triggers DingTalk binding
// ---------------------------------------------------------------------------
// Stateful so it can activate an audio session before opening the browser,
// keeping the app's network connections alive on iOS while in the background.
class _OAuthPromptCard extends StatefulWidget {
final String url;
final String instanceName;
const _OAuthPromptCard({required this.url, required this.instanceName});
@override
State<_OAuthPromptCard> createState() => _OAuthPromptCardState();
}
class _OAuthPromptCardState extends State<_OAuthPromptCard>
with WidgetsBindingObserver {
bool _keepAliveActive = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_deactivateKeepAlive();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Deactivate when the user returns to the app after OAuth
if (state == AppLifecycleState.resumed && _keepAliveActive) {
_deactivateKeepAlive();
}
}
Future<void> _activateKeepAlive() async {
if (_keepAliveActive) return;
_keepAliveActive = true;
try {
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration(
avAudioSessionCategory: AVAudioSessionCategory.playback,
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.mixWithOthers,
avAudioSessionMode: AVAudioSessionMode.defaultMode,
androidAudioAttributes: AndroidAudioAttributes(
contentType: AndroidAudioContentType.music,
usage: AndroidAudioUsage.media,
),
androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransientMayDuck,
));
await session.setActive(true);
} catch (_) {
// Non-fatal — proceed even if audio session fails
_keepAliveActive = false;
}
}
Future<void> _deactivateKeepAlive() async {
if (!_keepAliveActive) return;
_keepAliveActive = false;
try {
final session = await AudioSession.instance;
await session.setActive(false);
} catch (_) {}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'点击下方按钮,在钉钉中为「${widget.instanceName}」完成一键授权绑定。',
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: const Text('🦞', style: TextStyle(fontSize: 16)),
label: const Text('立即授权'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1677FF),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
onPressed: () async {
final uri = Uri.tryParse(widget.url);
if (uri != null) {
// Keep app alive in background while browser is open
await _activateKeepAlive();
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
},
),
),
],
);
}
}
// ---------------------------------------------------------------------------
// 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 ? AppLocalizations.of(context).chatCollapseLabel : '展开 ($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: Text(AppLocalizations.of(context).cancelButton, style: const 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)),
),
],
),
],
);
}
}