xworkmate-app/lib/web/web_assistant_page.dart

651 lines
22 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 'package:flutter/material.dart';
import '../app/app_controller_web.dart';
import '../app/ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/runtime_models.dart';
import '../theme/app_palette.dart';
import '../widgets/desktop_workspace_scaffold.dart';
import '../widgets/status_badge.dart';
import '../widgets/surface_card.dart';
import '../widgets/top_bar.dart';
class WebAssistantPage extends StatefulWidget {
const WebAssistantPage({super.key, required this.controller});
final AppController controller;
@override
State<WebAssistantPage> createState() => _WebAssistantPageState();
}
class _WebAssistantPageState extends State<WebAssistantPage> {
final TextEditingController _inputController = TextEditingController();
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
String _query = '';
@override
void dispose() {
_inputController.dispose();
_searchController.dispose();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final controller = widget.controller;
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
final uiFeatures = controller.featuresFor(UiFeaturePlatform.web);
final allDirect = controller.conversationsForTarget(
AssistantExecutionTarget.aiGatewayOnly,
);
final allRelay = controller.conversationsForTarget(
AssistantExecutionTarget.remote,
);
final direct = _filterConversations(allDirect);
final relay = _filterConversations(allRelay);
final currentTarget = controller.assistantExecutionTarget;
final availableTargets = uiFeatures.availableExecutionTargets
.where(
(target) =>
target == AssistantExecutionTarget.aiGatewayOnly ||
target == AssistantExecutionTarget.remote,
)
.toList(growable: false);
final connected =
currentTarget == AssistantExecutionTarget.aiGatewayOnly
? controller.canUseAiGatewayConversation
: controller.connection.status == RuntimeConnectionStatus.connected;
final currentMessages = controller.chatMessages;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(
_scrollController.position.maxScrollExtent,
);
}
});
return DesktopWorkspaceScaffold(
breadcrumbs: <AppBreadcrumbItem>[
AppBreadcrumbItem(
label: appText('主页', 'Home'),
icon: Icons.home_rounded,
onTap: controller.navigateHome,
),
AppBreadcrumbItem(label: WorkspaceDestination.assistant.label),
],
eyebrow: appText('Web Workspace', 'Web Workspace'),
title: appText('助手', 'Assistant'),
subtitle: appText(
'Direct AI 与 Relay Gateway 共用一个入口,左侧保留会话/任务历史。',
'Use one Assistant surface for Direct AI and Relay Gateway, with embedded conversation history on the left.',
),
toolbar: Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton.icon(
onPressed: () => controller.createConversation(
target: controller.assistantExecutionTarget,
),
icon: const Icon(Icons.edit_square),
label: Text(appText('新对话', 'New conversation')),
),
OutlinedButton.icon(
onPressed: () =>
controller.openSettings(tab: SettingsTab.gateway),
icon: const Icon(Icons.tune_rounded),
label: Text(appText('连接设置', 'Connection settings')),
),
_TargetChip(
targets: availableTargets,
value: currentTarget,
onChanged: (value) {
if (value != null) {
controller.setAssistantExecutionTarget(value);
}
},
),
],
),
child: LayoutBuilder(
builder: (context, constraints) {
final vertical = constraints.maxWidth < 980;
final rail = _ConversationRail(
controller: controller,
query: _query,
searchController: _searchController,
onQueryChanged: (value) {
setState(() => _query = value.trim().toLowerCase());
},
onClearQuery: () {
_searchController.clear();
setState(() => _query = '');
},
showDirect: uiFeatures.supportsDirectAi,
showRelay: uiFeatures.supportsRelayGateway,
direct: direct,
relay: relay,
);
final panel = _ConversationPanel(
controller: controller,
inputController: _inputController,
scrollController: _scrollController,
connected: connected,
currentMessages: currentMessages,
);
if (vertical) {
return Column(
children: [
SizedBox(height: 300, child: rail),
const SizedBox(height: 8),
Expanded(child: panel),
],
);
}
return Row(
children: [
SizedBox(width: 320, child: rail),
const SizedBox(width: 8),
Expanded(child: panel),
],
);
},
),
);
},
);
}
List<WebConversationSummary> _filterConversations(
List<WebConversationSummary> items,
) {
if (_query.isEmpty) {
return items;
}
return items
.where((item) {
final haystack = '${item.title}\n${item.preview}'.toLowerCase();
return haystack.contains(_query);
})
.toList(growable: false);
}
}
class _ConversationRail extends StatelessWidget {
const _ConversationRail({
required this.controller,
required this.query,
required this.searchController,
required this.onQueryChanged,
required this.onClearQuery,
required this.showDirect,
required this.showRelay,
required this.direct,
required this.relay,
});
final AppController controller;
final String query;
final TextEditingController searchController;
final ValueChanged<String> onQueryChanged;
final VoidCallback onClearQuery;
final bool showDirect;
final bool showRelay;
final List<WebConversationSummary> direct;
final List<WebConversationSummary> relay;
@override
Widget build(BuildContext context) {
return SurfaceCard(
borderRadius: 10,
tone: SurfaceCardTone.chrome,
child: Column(
key: const Key('assistant-task-rail'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: searchController,
onChanged: onQueryChanged,
decoration: InputDecoration(
hintText: appText('搜索会话', 'Search conversations'),
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: query.isEmpty
? null
: IconButton(
onPressed: onClearQuery,
icon: const Icon(Icons.close_rounded),
),
),
),
const SizedBox(height: 12),
Expanded(
child: ListView(
children: [
if (showDirect)
_ConversationGroup(
title: appText('Direct AI Gateway', 'Direct AI Gateway'),
icon: Icons.hub_rounded,
items: direct,
emptyLabel: appText(
'还没有 Direct AI 对话',
'No Direct AI conversations yet',
),
onSelect: controller.switchConversation,
),
if (showDirect && showRelay) const SizedBox(height: 12),
if (showRelay)
_ConversationGroup(
title: appText(
'Relay OpenClaw Gateway',
'Relay OpenClaw Gateway',
),
icon: Icons.cloud_outlined,
items: relay,
emptyLabel: appText(
'还没有 Relay 对话',
'No Relay conversations yet',
),
onSelect: controller.switchConversation,
),
],
),
),
],
),
);
}
}
class _ConversationGroup extends StatelessWidget {
const _ConversationGroup({
required this.title,
required this.icon,
required this.items,
required this.emptyLabel,
required this.onSelect,
});
final String title;
final IconData icon;
final List<WebConversationSummary> items;
final String emptyLabel;
final ValueChanged<String> onSelect;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: palette.accent),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
),
),
],
),
const SizedBox(height: 8),
if (items.isEmpty)
Text(
emptyLabel,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: palette.textSecondary),
),
...items.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SurfaceCard(
onTap: () => onSelect(item.sessionKey),
borderRadius: 10,
padding: const EdgeInsets.all(12),
color: item.current ? palette.accentMuted : null,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
Text(
item.preview,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: palette.textSecondary),
),
],
),
),
if (item.pending)
const Padding(
padding: EdgeInsets.only(left: 8, top: 2),
child: SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
),
),
),
),
],
);
}
}
class _ConversationPanel extends StatelessWidget {
const _ConversationPanel({
required this.controller,
required this.inputController,
required this.scrollController,
required this.connected,
required this.currentMessages,
});
final AppController controller;
final TextEditingController inputController;
final ScrollController scrollController;
final bool connected;
final List<GatewayChatMessage> currentMessages;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final currentTarget = controller.assistantExecutionTarget;
final targetReady = currentTarget == AssistantExecutionTarget.aiGatewayOnly
? controller.canUseAiGatewayConversation
: controller.connection.status == RuntimeConnectionStatus.connected;
return Column(
children: [
SurfaceCard(
borderRadius: 10,
tone: SurfaceCardTone.chrome,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
controller.currentConversationTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
controller.assistantConnectionTargetLabel,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: palette.textSecondary,
),
),
],
),
),
StatusBadge(
status: StatusInfo(
controller.assistantConnectionStatusLabel,
targetReady ? StatusTone.success : StatusTone.warning,
),
),
],
),
),
const SizedBox(height: 8),
if (!connected)
SurfaceCard(
borderRadius: 10,
child: Row(
children: [
const Icon(Icons.info_outline_rounded),
const SizedBox(width: 12),
Expanded(
child: Text(
currentTarget == AssistantExecutionTarget.aiGatewayOnly
? appText(
'当前 Direct AI 配置还不完整,请先在 Settings 中保存地址、API Key 和默认模型。',
'Direct AI is not ready yet. Save the endpoint, API key, and default model in Settings first.',
)
: appText(
'当前 Relay Gateway 尚未连接,请先在 Settings 中保存配置并连接。',
'Relay Gateway is offline. Save the relay config and connect from Settings first.',
),
),
),
const SizedBox(width: 12),
FilledButton.tonal(
onPressed: () =>
controller.openSettings(tab: SettingsTab.gateway),
child: Text(appText('打开设置', 'Open settings')),
),
],
),
),
if (!connected) const SizedBox(height: 8),
Expanded(
child: SurfaceCard(
borderRadius: 10,
padding: EdgeInsets.zero,
tone: SurfaceCardTone.chrome,
child: Column(
children: [
Expanded(
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.all(16),
itemCount: currentMessages.length,
itemBuilder: (context, index) {
final message = currentMessages[index];
return _MessageBubble(message: message);
},
),
),
Container(height: 1, color: palette.strokeSoft),
Padding(
padding: const EdgeInsets.all(14),
child: Column(
children: [
Row(
children: [
Expanded(
child: TextField(
controller: inputController,
minLines: 3,
maxLines: 6,
decoration: InputDecoration(
hintText: appText(
'输入需求、补充上下文、继续追问',
'Describe the task, add context, or continue the conversation',
),
),
onSubmitted: (_) {
if (!connected) {
return;
}
final value = inputController.text;
inputController.clear();
controller.sendMessage(value);
},
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: Text(
currentTarget ==
AssistantExecutionTarget.aiGatewayOnly
? appText(
'Web 端 Direct AI 只保留纯网络能力,不提供本地文件和 CLI。',
'Direct AI on web keeps network-only capabilities and does not expose local files or CLI.',
)
: appText(
'Web 端 Relay 模式使用远程 OpenClaw Gateway不区分 local / remote。',
'Relay mode on web uses the remote OpenClaw Gateway and does not expose local / remote splits.',
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: palette.textSecondary),
),
),
const SizedBox(width: 12),
FilledButton.icon(
onPressed: connected
? () {
final value = inputController.text;
inputController.clear();
controller.sendMessage(value);
}
: () => controller.openSettings(
tab: SettingsTab.gateway,
),
icon: Icon(
connected
? Icons.arrow_upward_rounded
: Icons.settings_rounded,
),
label: Text(
connected
? appText('提交', 'Submit')
: appText('配置', 'Configure'),
),
),
],
),
],
),
),
],
),
),
),
],
);
}
}
class _MessageBubble extends StatelessWidget {
const _MessageBubble({required this.message});
final GatewayChatMessage message;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final assistant = message.role.trim().toLowerCase() == 'assistant';
final color = message.error
? palette.danger.withValues(alpha: 0.14)
: assistant
? palette.surfacePrimary
: palette.accentMuted;
return Align(
alignment: assistant ? Alignment.centerLeft : Alignment.centerRight,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DecoratedBox(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: palette.strokeSoft),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
assistant ? 'Assistant' : 'You',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: palette.textSecondary,
),
),
const SizedBox(height: 6),
Text(message.text),
],
),
),
),
),
),
);
}
}
class _TargetChip extends StatelessWidget {
const _TargetChip({
required this.targets,
required this.value,
required this.onChanged,
});
final List<AssistantExecutionTarget> targets;
final AssistantExecutionTarget value;
final ValueChanged<AssistantExecutionTarget?> onChanged;
@override
Widget build(BuildContext context) {
return DropdownButtonHideUnderline(
child: DropdownButton<AssistantExecutionTarget>(
value: value,
onChanged: onChanged,
items: targets
.map((target) {
return DropdownMenuItem<AssistantExecutionTarget>(
value: target,
child: Text(_targetLabel(target)),
);
})
.toList(growable: false),
),
);
}
}
String _targetLabel(AssistantExecutionTarget target) {
return switch (target) {
AssistantExecutionTarget.aiGatewayOnly => appText(
'Direct AI Gateway',
'Direct AI Gateway',
),
AssistantExecutionTarget.remote => appText(
'Relay OpenClaw Gateway',
'Relay OpenClaw Gateway',
),
_ => '',
};
}