921 lines
36 KiB
Dart
921 lines
36 KiB
Dart
// ignore_for_file: unused_import, unnecessary_import
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
import 'package:file_selector/file_selector.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
|
import 'package:markdown/markdown.dart' as md;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:super_clipboard/super_clipboard.dart';
|
|
import '../../app/app_controller.dart';
|
|
import '../../app/app_metadata.dart';
|
|
import '../../app/ui_feature_manifest.dart';
|
|
import '../../i18n/app_language.dart';
|
|
import '../../models/app_models.dart';
|
|
import '../../runtime/multi_agent_orchestrator.dart';
|
|
import '../../runtime/runtime_models.dart';
|
|
import '../../theme/app_palette.dart';
|
|
import '../../theme/app_theme.dart';
|
|
import '../../widgets/assistant_focus_panel.dart';
|
|
import '../../widgets/assistant_artifact_sidebar.dart';
|
|
import '../../widgets/desktop_workspace_scaffold.dart';
|
|
import '../../widgets/pane_resize_handle.dart';
|
|
import '../../widgets/surface_card.dart';
|
|
import 'assistant_page_components.dart';
|
|
import 'assistant_page_composer_bar.dart';
|
|
import 'assistant_page_composer_state_helpers.dart';
|
|
import 'assistant_page_composer_support.dart';
|
|
import 'assistant_page_tooltip_labels.dart';
|
|
import 'assistant_page_message_widgets.dart';
|
|
import 'assistant_page_task_models.dart';
|
|
import 'assistant_page_composer_skill_models.dart';
|
|
import 'assistant_page_composer_skill_picker.dart';
|
|
import 'assistant_page_composer_clipboard.dart';
|
|
import 'assistant_page_components_core.dart';
|
|
import 'assistant_page_state_closure.dart';
|
|
import 'assistant_page_state_actions.dart';
|
|
|
|
const double assistantComposerDefaultInputHeightInternal = 78;
|
|
const double assistantWorkspaceMinConversationHeightInternal = 180;
|
|
const double assistantWorkspaceMinLowerPaneHeightInternal = 160;
|
|
const double assistantHorizontalResizeHandleWidthInternal = 6;
|
|
const double assistantHorizontalPaneGapInternal = 2;
|
|
const double assistantVerticalResizeHandleHeightInternal = 10;
|
|
const double assistantArtifactPaneMinWidthInternal = 280;
|
|
const double assistantArtifactPaneDefaultWidthInternal = 360;
|
|
const double assistantCollapsedArtifactToggleClearanceInternal = 56;
|
|
const double assistantComposerSafeAreaGapInternal = 8;
|
|
const double assistantComposerBaseHeightCompactInternal = 168;
|
|
const double assistantComposerBaseHeightTallInternal = 188;
|
|
const int assistantTaskActionMaxRetryCountInternal = 5;
|
|
|
|
typedef AssistantClipboardImageReader = Future<XFile?> Function();
|
|
|
|
class AssistantPage extends StatefulWidget {
|
|
const AssistantPage({
|
|
super.key,
|
|
required this.controller,
|
|
required this.onOpenDetail,
|
|
this.navigationPanelBuilder,
|
|
this.showStandaloneTaskRail = true,
|
|
this.unifiedPaneStartsCollapsed = false,
|
|
this.clipboardImageReader,
|
|
});
|
|
|
|
final AppController controller;
|
|
final ValueChanged<DetailPanelData> onOpenDetail;
|
|
final Widget Function(double contentWidth)? navigationPanelBuilder;
|
|
final bool showStandaloneTaskRail;
|
|
final bool unifiedPaneStartsCollapsed;
|
|
final AssistantClipboardImageReader? clipboardImageReader;
|
|
|
|
@override
|
|
State<AssistantPage> createState() => AssistantPageStateInternal();
|
|
}
|
|
|
|
class AssistantPageStateInternal extends State<AssistantPage> {
|
|
static const double sidePaneMinWidthInternal = 184;
|
|
static const double sidePaneContentMinWidthInternal = 140;
|
|
static const double mainWorkspaceMinWidthInternal = 620;
|
|
static const double sidePaneViewportPaddingInternal = 72;
|
|
static const double sideTabRailWidthInternal = 46;
|
|
|
|
late final TextEditingController inputControllerInternal;
|
|
late final TextEditingController threadSearchControllerInternal;
|
|
late final ScrollController conversationControllerInternal;
|
|
late final FocusNode composerFocusNodeInternal;
|
|
final String modeInternal = 'ask';
|
|
String thinkingLabelInternal = 'high';
|
|
double threadRailWidthInternal = 248;
|
|
String threadQueryInternal = '';
|
|
bool sidePaneCollapsedInternal = false;
|
|
AssistantSidePaneInternal activeSidePaneInternal =
|
|
AssistantSidePaneInternal.tasks;
|
|
AssistantFocusEntry? activeFocusedDestinationInternal;
|
|
final Map<String, AssistantTaskSeedInternal> taskSeedsInternal =
|
|
<String, AssistantTaskSeedInternal>{};
|
|
final Set<String> archivedTaskKeysInternal = <String>{};
|
|
List<ComposerAttachmentInternal> attachmentsInternal =
|
|
const <ComposerAttachmentInternal>[];
|
|
String? lastAutoAgentLabelInternal;
|
|
String lastConversationScrollSignatureInternal = '';
|
|
double composerInputHeightInternal =
|
|
assistantComposerDefaultInputHeightInternal;
|
|
double composerMeasuredContentHeightInternal = 0;
|
|
double workspaceLowerPaneHeightAdjustmentInternal = 0;
|
|
bool artifactPaneCollapsedInternal = true;
|
|
double artifactPaneWidthInternal = assistantArtifactPaneDefaultWidthInternal;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
inputControllerInternal = TextEditingController();
|
|
threadSearchControllerInternal = TextEditingController();
|
|
conversationControllerInternal = ScrollController();
|
|
composerFocusNodeInternal = FocusNode();
|
|
sidePaneCollapsedInternal = widget.unifiedPaneStartsCollapsed;
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant AssistantPage oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.unifiedPaneStartsCollapsed !=
|
|
widget.unifiedPaneStartsCollapsed) {
|
|
sidePaneCollapsedInternal = widget.unifiedPaneStartsCollapsed;
|
|
}
|
|
}
|
|
|
|
void handleComposerContentHeightChangedInternal(double value) {
|
|
if (!mounted || !value.isFinite || value <= 0) {
|
|
return;
|
|
}
|
|
if ((composerMeasuredContentHeightInternal - value).abs() < 0.5) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
composerMeasuredContentHeightInternal = value;
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
inputControllerInternal.dispose();
|
|
threadSearchControllerInternal.dispose();
|
|
conversationControllerInternal.dispose();
|
|
composerFocusNodeInternal.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: widget.controller,
|
|
builder: (context, _) {
|
|
final controller = widget.controller;
|
|
final messages = List<GatewayChatMessage>.from(controller.chatMessages);
|
|
final timelineItems = buildTimelineItemsInternal(controller, messages);
|
|
final tasks = buildTaskEntriesInternal(controller);
|
|
final visibleTasks = filterTasksInternal(tasks);
|
|
final currentTask = resolveCurrentTaskInternal(
|
|
tasks,
|
|
controller.currentSessionKey,
|
|
);
|
|
final scrollSignature = messages.isEmpty
|
|
? controller.currentSessionKey
|
|
: '${controller.currentSessionKey}:${messages.length}:${messages.last.id}:${messages.last.pending}:${messages.last.error}';
|
|
|
|
if (scrollSignature != lastConversationScrollSignatureInternal) {
|
|
lastConversationScrollSignatureInternal = scrollSignature;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || !conversationControllerInternal.hasClients) {
|
|
return;
|
|
}
|
|
conversationControllerInternal.animateTo(
|
|
conversationControllerInternal.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 220),
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
});
|
|
}
|
|
|
|
return DesktopWorkspaceScaffold(
|
|
padding: EdgeInsets.zero,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final showUnifiedSidePane =
|
|
widget.navigationPanelBuilder != null &&
|
|
constraints.maxWidth >= 860;
|
|
final showThreadRail =
|
|
!showUnifiedSidePane &&
|
|
widget.showStandaloneTaskRail &&
|
|
constraints.maxWidth >= 860;
|
|
final mainWorkspace = buildMainWorkspaceInternal(
|
|
controller: controller,
|
|
timelineItems: timelineItems,
|
|
currentTask: currentTask,
|
|
);
|
|
final workspaceWithArtifacts =
|
|
buildWorkspaceWithArtifactsInternal(
|
|
controller: controller,
|
|
currentTask: currentTask,
|
|
child: mainWorkspace,
|
|
);
|
|
if (!showThreadRail && !showUnifiedSidePane) {
|
|
return workspaceWithArtifacts;
|
|
}
|
|
|
|
final maxThreadRailWidth = resolveMaxSidePaneWidthInternal(
|
|
constraints.maxWidth,
|
|
);
|
|
final threadRailWidth = threadRailWidthInternal
|
|
.clamp(sidePaneMinWidthInternal, maxThreadRailWidth)
|
|
.toDouble();
|
|
|
|
if (showUnifiedSidePane) {
|
|
final favoriteDestinations =
|
|
controller.assistantNavigationDestinations;
|
|
final activeFocusedDestination =
|
|
resolveFocusedDestinationInternal(favoriteDestinations);
|
|
final effectiveActiveSidePane =
|
|
activeSidePaneInternal ==
|
|
AssistantSidePaneInternal.focused &&
|
|
activeFocusedDestination == null
|
|
? AssistantSidePaneInternal.navigation
|
|
: activeSidePaneInternal;
|
|
final sidePanelContentWidth =
|
|
(threadRailWidth - sideTabRailWidthInternal - 2)
|
|
.clamp(sidePaneContentMinWidthInternal, threadRailWidth)
|
|
.toDouble();
|
|
return Row(
|
|
children: [
|
|
AnimatedContainer(
|
|
key: const Key('assistant-unified-side-pane-shell'),
|
|
duration: const Duration(milliseconds: 220),
|
|
curve: Curves.easeOutCubic,
|
|
width: sidePaneCollapsedInternal
|
|
? sideTabRailWidthInternal
|
|
: threadRailWidth,
|
|
child: AssistantUnifiedSidePaneInternal(
|
|
activePane: effectiveActiveSidePane,
|
|
activeFocusedDestination: activeFocusedDestination,
|
|
collapsed: sidePaneCollapsedInternal,
|
|
favoriteDestinations: favoriteDestinations,
|
|
taskPanel: AssistantTaskRailInternal(
|
|
key: const Key('assistant-task-rail'),
|
|
controller: controller,
|
|
tasks: visibleTasks,
|
|
query: threadQueryInternal,
|
|
searchController: threadSearchControllerInternal,
|
|
onQueryChanged: (value) {
|
|
setState(() {
|
|
threadQueryInternal = value.trim();
|
|
});
|
|
},
|
|
onClearQuery: () {
|
|
threadSearchControllerInternal.clear();
|
|
setState(() {
|
|
threadQueryInternal = '';
|
|
});
|
|
},
|
|
onRefreshTasks: refreshTasksWithRetryInternal,
|
|
onCreateTask: createNewThreadInternal,
|
|
onSelectTask: switchSessionWithRetryInternal,
|
|
onArchiveTask: archiveTaskInternal,
|
|
onRenameTask: renameTaskInternal,
|
|
),
|
|
navigationPanel: widget.navigationPanelBuilder!(
|
|
sidePanelContentWidth,
|
|
),
|
|
focusedPanel: activeFocusedDestination == null
|
|
? null
|
|
: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(6),
|
|
child: AssistantFocusDestinationCard(
|
|
controller: controller,
|
|
destination: activeFocusedDestination,
|
|
onOpenPage: () => controller.navigateTo(
|
|
activeFocusedDestination.destination ??
|
|
WorkspaceDestination.settings,
|
|
),
|
|
onRemoveFavorite: () async {
|
|
await controller
|
|
.toggleAssistantNavigationDestination(
|
|
activeFocusedDestination,
|
|
);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
activeFocusedDestinationInternal =
|
|
resolveFocusedDestinationInternal(
|
|
controller
|
|
.assistantNavigationDestinations,
|
|
);
|
|
activeSidePaneInternal =
|
|
activeFocusedDestinationInternal ==
|
|
null
|
|
? AssistantSidePaneInternal.navigation
|
|
: AssistantSidePaneInternal.focused;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
onSelectPane: (pane) {
|
|
setState(() {
|
|
final normalizedPane =
|
|
pane == AssistantSidePaneInternal.focused
|
|
? AssistantSidePaneInternal.navigation
|
|
: pane;
|
|
if (effectiveActiveSidePane == normalizedPane) {
|
|
sidePaneCollapsedInternal =
|
|
!sidePaneCollapsedInternal;
|
|
return;
|
|
}
|
|
activeSidePaneInternal = normalizedPane;
|
|
if (normalizedPane !=
|
|
AssistantSidePaneInternal.focused) {
|
|
activeFocusedDestinationInternal = null;
|
|
}
|
|
sidePaneCollapsedInternal = false;
|
|
});
|
|
},
|
|
onSelectFocusedDestination: (destination) {
|
|
setState(() {
|
|
final isSameSelection =
|
|
effectiveActiveSidePane ==
|
|
AssistantSidePaneInternal.focused &&
|
|
activeFocusedDestination == destination;
|
|
if (isSameSelection) {
|
|
sidePaneCollapsedInternal =
|
|
!sidePaneCollapsedInternal;
|
|
return;
|
|
}
|
|
activeFocusedDestinationInternal = destination;
|
|
activeSidePaneInternal =
|
|
AssistantSidePaneInternal.focused;
|
|
sidePaneCollapsedInternal = false;
|
|
});
|
|
},
|
|
onToggleCollapsed: () {
|
|
setState(() {
|
|
sidePaneCollapsedInternal =
|
|
!sidePaneCollapsedInternal;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
if (!sidePaneCollapsedInternal)
|
|
SizedBox(
|
|
width: assistantHorizontalResizeHandleWidthInternal,
|
|
child: PaneResizeHandle(
|
|
axis: Axis.horizontal,
|
|
onDelta: (delta) {
|
|
setState(() {
|
|
threadRailWidthInternal =
|
|
(threadRailWidthInternal + delta)
|
|
.clamp(
|
|
sidePaneMinWidthInternal,
|
|
maxThreadRailWidth,
|
|
)
|
|
.toDouble();
|
|
});
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: assistantHorizontalPaneGapInternal),
|
|
Expanded(child: workspaceWithArtifacts),
|
|
],
|
|
);
|
|
}
|
|
|
|
return Row(
|
|
children: [
|
|
SizedBox(
|
|
width: threadRailWidth,
|
|
child: AssistantTaskRailInternal(
|
|
key: const Key('assistant-task-rail'),
|
|
controller: controller,
|
|
tasks: visibleTasks,
|
|
query: threadQueryInternal,
|
|
searchController: threadSearchControllerInternal,
|
|
onQueryChanged: (value) {
|
|
setState(() {
|
|
threadQueryInternal = value.trim();
|
|
});
|
|
},
|
|
onClearQuery: () {
|
|
threadSearchControllerInternal.clear();
|
|
setState(() {
|
|
threadQueryInternal = '';
|
|
});
|
|
},
|
|
onRefreshTasks: refreshTasksWithRetryInternal,
|
|
onCreateTask: createNewThreadInternal,
|
|
onSelectTask: switchSessionWithRetryInternal,
|
|
onArchiveTask: archiveTaskInternal,
|
|
onRenameTask: renameTaskInternal,
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: assistantHorizontalResizeHandleWidthInternal,
|
|
child: PaneResizeHandle(
|
|
axis: Axis.horizontal,
|
|
onDelta: (delta) {
|
|
setState(() {
|
|
threadRailWidthInternal =
|
|
(threadRailWidthInternal + delta)
|
|
.clamp(
|
|
sidePaneMinWidthInternal,
|
|
maxThreadRailWidth,
|
|
)
|
|
.toDouble();
|
|
});
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: assistantHorizontalPaneGapInternal),
|
|
Expanded(child: workspaceWithArtifacts),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
enum AssistantSidePaneInternal { tasks, navigation, focused }
|
|
|
|
class AssistantUnifiedSidePaneInternal extends StatelessWidget {
|
|
const AssistantUnifiedSidePaneInternal({
|
|
super.key,
|
|
required this.activePane,
|
|
required this.activeFocusedDestination,
|
|
required this.collapsed,
|
|
required this.favoriteDestinations,
|
|
required this.taskPanel,
|
|
required this.navigationPanel,
|
|
required this.focusedPanel,
|
|
required this.onSelectPane,
|
|
required this.onSelectFocusedDestination,
|
|
required this.onToggleCollapsed,
|
|
});
|
|
|
|
final AssistantSidePaneInternal activePane;
|
|
final AssistantFocusEntry? activeFocusedDestination;
|
|
final bool collapsed;
|
|
final List<AssistantFocusEntry> favoriteDestinations;
|
|
final Widget taskPanel;
|
|
final Widget navigationPanel;
|
|
final Widget? focusedPanel;
|
|
final ValueChanged<AssistantSidePaneInternal> onSelectPane;
|
|
final ValueChanged<AssistantFocusEntry> onSelectFocusedDestination;
|
|
final VoidCallback onToggleCollapsed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final sidePaneContent = activePane == AssistantSidePaneInternal.tasks
|
|
? taskPanel
|
|
: activePane == AssistantSidePaneInternal.focused &&
|
|
focusedPanel != null
|
|
? focusedPanel!
|
|
: navigationPanel;
|
|
|
|
return Row(
|
|
children: [
|
|
AssistantSideTabRailInternal(
|
|
activePane: activePane,
|
|
activeFocusedDestination: activeFocusedDestination,
|
|
collapsed: collapsed,
|
|
favoriteDestinations: favoriteDestinations,
|
|
onSelectPane: onSelectPane,
|
|
onSelectFocusedDestination: onSelectFocusedDestination,
|
|
onToggleCollapsed: onToggleCollapsed,
|
|
),
|
|
if (!collapsed) ...[
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 180),
|
|
switchInCurve: Curves.easeOutCubic,
|
|
switchOutCurve: Curves.easeInCubic,
|
|
child: KeyedSubtree(
|
|
key: ValueKey<String>(switch (activePane) {
|
|
AssistantSidePaneInternal.tasks =>
|
|
'assistant-side-pane-tasks',
|
|
AssistantSidePaneInternal.navigation =>
|
|
'assistant-side-pane-navigation',
|
|
AssistantSidePaneInternal.focused =>
|
|
'assistant-side-pane-focused-${activeFocusedDestination?.name ?? 'none'}',
|
|
}),
|
|
child: sidePaneContent,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class AssistantSideTabRailInternal extends StatelessWidget {
|
|
const AssistantSideTabRailInternal({
|
|
super.key,
|
|
required this.activePane,
|
|
required this.activeFocusedDestination,
|
|
required this.collapsed,
|
|
required this.favoriteDestinations,
|
|
required this.onSelectPane,
|
|
required this.onSelectFocusedDestination,
|
|
required this.onToggleCollapsed,
|
|
});
|
|
|
|
final AssistantSidePaneInternal activePane;
|
|
final AssistantFocusEntry? activeFocusedDestination;
|
|
final bool collapsed;
|
|
final List<AssistantFocusEntry> favoriteDestinations;
|
|
final ValueChanged<AssistantSidePaneInternal> onSelectPane;
|
|
final ValueChanged<AssistantFocusEntry> onSelectFocusedDestination;
|
|
final VoidCallback onToggleCollapsed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = context.palette;
|
|
|
|
return Container(
|
|
key: const Key('assistant-side-pane'),
|
|
width: 46,
|
|
decoration: BoxDecoration(
|
|
color: palette.chromeSurface,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: palette.strokeSoft),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 4),
|
|
AssistantSideTabButtonInternal(
|
|
key: const Key('assistant-side-pane-tab-tasks'),
|
|
icon: Icons.checklist_rtl_rounded,
|
|
selected: activePane == AssistantSidePaneInternal.tasks,
|
|
tooltip: appText('任务', 'Tasks'),
|
|
onTap: () => onSelectPane(AssistantSidePaneInternal.tasks),
|
|
),
|
|
const SizedBox(height: 4),
|
|
AssistantSideTabButtonInternal(
|
|
key: const Key('assistant-side-pane-tab-navigation'),
|
|
icon: Icons.dashboard_customize_outlined,
|
|
selected: activePane == AssistantSidePaneInternal.navigation,
|
|
tooltip: appText('导航', 'Navigation'),
|
|
onTap: () => onSelectPane(AssistantSidePaneInternal.navigation),
|
|
),
|
|
if (favoriteDestinations.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
Container(width: 24, height: 1, color: palette.strokeSoft),
|
|
const SizedBox(height: 4),
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
padding: EdgeInsets.zero,
|
|
child: Column(
|
|
children: favoriteDestinations
|
|
.map(
|
|
(destination) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: AssistantSideTabButtonInternal(
|
|
key: ValueKey<String>(
|
|
'assistant-side-pane-tab-focus-${destination.name}',
|
|
),
|
|
icon: destination.icon,
|
|
selected:
|
|
activePane ==
|
|
AssistantSidePaneInternal.focused &&
|
|
activeFocusedDestination == destination,
|
|
tooltip: destination.label,
|
|
onTap: () =>
|
|
onSelectFocusedDestination(destination),
|
|
),
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
),
|
|
),
|
|
),
|
|
] else
|
|
const Spacer(),
|
|
IconButton(
|
|
key: const Key('assistant-side-pane-toggle'),
|
|
tooltip: collapsed
|
|
? appText('展开侧板', 'Expand side pane')
|
|
: appText('收起侧板', 'Collapse side pane'),
|
|
onPressed: onToggleCollapsed,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: palette.surfacePrimary,
|
|
foregroundColor: palette.textSecondary,
|
|
side: BorderSide(color: palette.strokeSoft),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
icon: Icon(
|
|
collapsed
|
|
? Icons.keyboard_double_arrow_right_rounded
|
|
: Icons.keyboard_double_arrow_left_rounded,
|
|
size: 18,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class AssistantSideTabButtonInternal extends StatefulWidget {
|
|
const AssistantSideTabButtonInternal({
|
|
super.key,
|
|
required this.icon,
|
|
required this.selected,
|
|
required this.tooltip,
|
|
required this.onTap,
|
|
});
|
|
|
|
final IconData icon;
|
|
final bool selected;
|
|
final String tooltip;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
State<AssistantSideTabButtonInternal> createState() =>
|
|
AssistantSideTabButtonStateInternal();
|
|
}
|
|
|
|
class AssistantSideTabButtonStateInternal
|
|
extends State<AssistantSideTabButtonInternal> {
|
|
bool hoveredInternal = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = context.palette;
|
|
|
|
return Tooltip(
|
|
message: widget.tooltip,
|
|
child: MouseRegion(
|
|
onEnter: (_) => setState(() => hoveredInternal = true),
|
|
onExit: (_) => setState(() => hoveredInternal = false),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
onTap: widget.onTap,
|
|
child: Container(
|
|
width: 34,
|
|
height: 34,
|
|
decoration: BoxDecoration(
|
|
color: widget.selected
|
|
? palette.surfacePrimary
|
|
: hoveredInternal
|
|
? palette.surfaceSecondary
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: widget.selected || hoveredInternal
|
|
? palette.strokeSoft
|
|
: Colors.transparent,
|
|
),
|
|
),
|
|
child: Icon(
|
|
widget.icon,
|
|
size: 18,
|
|
color: widget.selected
|
|
? palette.textPrimary
|
|
: palette.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class AssistantLowerPaneInternal extends StatelessWidget {
|
|
const AssistantLowerPaneInternal({
|
|
super.key,
|
|
required this.bottomContentInset,
|
|
required this.controller,
|
|
required this.inputController,
|
|
required this.focusNode,
|
|
required this.thinkingLabel,
|
|
required this.showModelControl,
|
|
required this.modelLabel,
|
|
required this.modelOptions,
|
|
required this.attachments,
|
|
required this.availableSkills,
|
|
required this.selectedSkillKeys,
|
|
required this.onRemoveAttachment,
|
|
required this.onToggleSkill,
|
|
required this.onThinkingChanged,
|
|
required this.onModelChanged,
|
|
required this.onOpenGateway,
|
|
required this.onOpenAiGatewaySettings,
|
|
required this.onReconnectGateway,
|
|
required this.onPickAttachments,
|
|
required this.onAddAttachment,
|
|
required this.onPasteImageAttachment,
|
|
required this.onComposerContentHeightChanged,
|
|
required this.onComposerInputHeightChanged,
|
|
required this.onSend,
|
|
});
|
|
|
|
final double bottomContentInset;
|
|
final AppController controller;
|
|
final TextEditingController inputController;
|
|
final FocusNode focusNode;
|
|
final String thinkingLabel;
|
|
final bool showModelControl;
|
|
final String modelLabel;
|
|
final List<String> modelOptions;
|
|
final List<ComposerAttachmentInternal> attachments;
|
|
final List<ComposerSkillOptionInternal> availableSkills;
|
|
final List<String> selectedSkillKeys;
|
|
final ValueChanged<ComposerAttachmentInternal> onRemoveAttachment;
|
|
final ValueChanged<String> onToggleSkill;
|
|
final ValueChanged<String> onThinkingChanged;
|
|
final Future<void> Function(String modelId) onModelChanged;
|
|
final VoidCallback onOpenGateway;
|
|
final VoidCallback onOpenAiGatewaySettings;
|
|
final Future<void> Function() onReconnectGateway;
|
|
final VoidCallback onPickAttachments;
|
|
final ValueChanged<ComposerAttachmentInternal> onAddAttachment;
|
|
final AssistantClipboardImageReader onPasteImageAttachment;
|
|
final ValueChanged<double> onComposerContentHeightChanged;
|
|
final ValueChanged<double> onComposerInputHeightChanged;
|
|
final Future<void> Function() onSend;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = context.palette;
|
|
|
|
return ColoredBox(
|
|
color: palette.canvas,
|
|
child: SingleChildScrollView(
|
|
physics: const ClampingScrollPhysics(),
|
|
padding: EdgeInsets.only(bottom: bottomContentInset),
|
|
child: ComposerBarInternal(
|
|
controller: controller,
|
|
inputController: inputController,
|
|
focusNode: focusNode,
|
|
thinkingLabel: thinkingLabel,
|
|
showModelControl: showModelControl,
|
|
modelLabel: modelLabel,
|
|
modelOptions: modelOptions,
|
|
attachments: attachments,
|
|
availableSkills: availableSkills,
|
|
selectedSkillKeys: selectedSkillKeys,
|
|
onRemoveAttachment: onRemoveAttachment,
|
|
onToggleSkill: onToggleSkill,
|
|
onThinkingChanged: onThinkingChanged,
|
|
onModelChanged: onModelChanged,
|
|
onOpenGateway: onOpenGateway,
|
|
onOpenAiGatewaySettings: onOpenAiGatewaySettings,
|
|
onReconnectGateway: onReconnectGateway,
|
|
onPickAttachments: onPickAttachments,
|
|
onAddAttachment: onAddAttachment,
|
|
onPasteImageAttachment: onPasteImageAttachment,
|
|
onContentHeightChanged: onComposerContentHeightChanged,
|
|
onInputHeightChanged: onComposerInputHeightChanged,
|
|
onSend: onSend,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ConversationAreaInternal extends StatelessWidget {
|
|
const ConversationAreaInternal({
|
|
super.key,
|
|
required this.controller,
|
|
required this.currentTask,
|
|
required this.items,
|
|
required this.messageViewMode,
|
|
required this.bottomContentInset,
|
|
required this.topTrailingInset,
|
|
required this.scrollController,
|
|
required this.onOpenDetail,
|
|
required this.onFocusComposer,
|
|
required this.onOpenGateway,
|
|
required this.onOpenAiGatewaySettings,
|
|
required this.onReconnectGateway,
|
|
required this.onMessageViewModeChanged,
|
|
});
|
|
|
|
final AppController controller;
|
|
final AssistantTaskEntryInternal currentTask;
|
|
final List<TimelineItemInternal> items;
|
|
final AssistantMessageViewMode messageViewMode;
|
|
final double bottomContentInset;
|
|
final double topTrailingInset;
|
|
final ScrollController scrollController;
|
|
final ValueChanged<DetailPanelData> onOpenDetail;
|
|
final VoidCallback onFocusComposer;
|
|
final VoidCallback onOpenGateway;
|
|
final VoidCallback onOpenAiGatewaySettings;
|
|
final Future<void> Function() onReconnectGateway;
|
|
final Future<void> Function(AssistantMessageViewMode mode)
|
|
onMessageViewModeChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final palette = context.palette;
|
|
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding: EdgeInsets.fromLTRB(10, 8, 10 + topTrailingInset, 8),
|
|
child: Align(
|
|
alignment: Alignment.centerRight,
|
|
child: Wrap(
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
alignment: WrapAlignment.end,
|
|
children: [
|
|
MessageViewModeChipInternal(
|
|
value: messageViewMode,
|
|
onSelected: onMessageViewModeChanged,
|
|
),
|
|
ConnectionChipInternal(controller: controller),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Divider(height: 1, color: palette.strokeSoft),
|
|
Expanded(
|
|
child: Container(
|
|
decoration: BoxDecoration(color: palette.canvas),
|
|
child: items.isEmpty
|
|
? AssistantEmptyStateInternal(
|
|
controller: controller,
|
|
onFocusComposer: onFocusComposer,
|
|
onOpenGateway: onOpenGateway,
|
|
onOpenAiGatewaySettings: onOpenAiGatewaySettings,
|
|
onReconnectGateway: onReconnectGateway,
|
|
)
|
|
: ListView.separated(
|
|
controller: scrollController,
|
|
padding: EdgeInsets.fromLTRB(
|
|
10,
|
|
8,
|
|
10,
|
|
8 + bottomContentInset,
|
|
),
|
|
physics: const BouncingScrollPhysics(),
|
|
itemCount: items.length,
|
|
separatorBuilder: (_, _) => const SizedBox(height: 6),
|
|
itemBuilder: (context, index) {
|
|
final item = items[index];
|
|
return switch (item.kind) {
|
|
TimelineItemKindInternal.user => MessageBubbleInternal(
|
|
label: item.label!,
|
|
text: item.text!,
|
|
alignRight: true,
|
|
tone: BubbleToneInternal.user,
|
|
messageViewMode: messageViewMode,
|
|
),
|
|
TimelineItemKindInternal.assistant =>
|
|
MessageBubbleInternal(
|
|
label: item.label!,
|
|
text: item.text!,
|
|
alignRight: false,
|
|
tone: BubbleToneInternal.assistant,
|
|
messageViewMode: messageViewMode,
|
|
),
|
|
TimelineItemKindInternal.agent => MessageBubbleInternal(
|
|
label: item.label!,
|
|
text: item.text!,
|
|
alignRight: false,
|
|
tone: BubbleToneInternal.agent,
|
|
messageViewMode: messageViewMode,
|
|
),
|
|
TimelineItemKindInternal.toolCall =>
|
|
ToolCallTileInternal(
|
|
toolName: item.title!,
|
|
summary: item.text!,
|
|
pending: item.pending,
|
|
error: item.error,
|
|
onOpenDetail: () => onOpenDetail(
|
|
DetailPanelData(
|
|
title: item.title!,
|
|
subtitle: appText('工具调用', 'Tool Call'),
|
|
icon: Icons.build_circle_outlined,
|
|
status: StatusInfo(
|
|
item.pending
|
|
? appText('运行中', 'Running')
|
|
: appText('已完成', 'Completed'),
|
|
item.error
|
|
? StatusTone.danger
|
|
: StatusTone.accent,
|
|
),
|
|
description: item.text ?? '',
|
|
meta: [
|
|
controller.currentSessionKey,
|
|
controller.activeAgentName,
|
|
],
|
|
actions: [appText('复制', 'Copy')],
|
|
sections: const [],
|
|
),
|
|
),
|
|
),
|
|
};
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|