feat: ask ai (#936)
* feat: ask ai in ssh terminal Fixes #934 * new(ask_ai): settings * fix: app hot reload * new: l10n * chore: deps. * opt.
This commit is contained in:
95
lib/view/page/setting/entries/ai.dart
Normal file
95
lib/view/page/setting/entries/ai.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
part of '../entry.dart';
|
||||
|
||||
extension _AI on _AppSettingsPageState {
|
||||
Widget _buildAskAiConfig() {
|
||||
final l10n = context.l10n;
|
||||
return ExpandTile(
|
||||
leading: const Icon(LineAwesome.robot_solid, size: _kIconSize),
|
||||
title: TipText(l10n.askAi, l10n.askAiUsageHint),
|
||||
children: [
|
||||
_setting.askAiBaseUrl.listenable().listenVal((val) {
|
||||
final display = val.isEmpty ? libL10n.empty : val;
|
||||
return ListTile(
|
||||
leading: const Icon(MingCute.link_2_line),
|
||||
title: Text(l10n.askAiBaseUrl),
|
||||
subtitle: Text(display, style: UIs.textGrey, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
onTap: () => _showAskAiFieldDialog(
|
||||
prop: _setting.askAiBaseUrl,
|
||||
title: l10n.askAiBaseUrl,
|
||||
hint: 'https://api.openai.com',
|
||||
),
|
||||
);
|
||||
}),
|
||||
_setting.askAiModel.listenable().listenVal((val) {
|
||||
final display = val.isEmpty ? libL10n.empty : val;
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.view_module),
|
||||
title: Text(l10n.askAiModel),
|
||||
subtitle: Text(display, style: UIs.textGrey),
|
||||
onTap: () => _showAskAiFieldDialog(
|
||||
prop: _setting.askAiModel,
|
||||
title: l10n.askAiModel,
|
||||
hint: 'gpt-4o-mini',
|
||||
),
|
||||
);
|
||||
}),
|
||||
_setting.askAiApiKey.listenable().listenVal((val) {
|
||||
final hasKey = val.isNotEmpty;
|
||||
return ListTile(
|
||||
leading: const Icon(MingCute.key_2_line),
|
||||
title: Text(l10n.askAiApiKey),
|
||||
subtitle: Text(hasKey ? '••••••••' : libL10n.empty, style: UIs.textGrey),
|
||||
onTap: () => _showAskAiFieldDialog(
|
||||
prop: _setting.askAiApiKey,
|
||||
title: l10n.askAiApiKey,
|
||||
hint: 'sk-...',
|
||||
obscure: true,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
).cardx;
|
||||
}
|
||||
|
||||
|
||||
Future<void> _showAskAiFieldDialog({
|
||||
required HiveProp<String> prop,
|
||||
required String title,
|
||||
required String hint,
|
||||
bool obscure = false,
|
||||
}) async {
|
||||
return withTextFieldController((ctrl) async {
|
||||
final fetched = prop.fetch();
|
||||
if (fetched != null && fetched.isNotEmpty) ctrl.text = fetched;
|
||||
|
||||
void onSave() {
|
||||
prop.put(ctrl.text.trim());
|
||||
context.pop();
|
||||
}
|
||||
|
||||
await context.showRoundDialog(
|
||||
title: title,
|
||||
child: Input(
|
||||
controller: ctrl,
|
||||
autoFocus: true,
|
||||
label: title,
|
||||
hint: hint,
|
||||
icon: obscure ? MingCute.key_2_line : Icons.edit,
|
||||
obscureText: obscure,
|
||||
suggestion: !obscure,
|
||||
onSubmitted: (_) => onSave(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
prop.delete();
|
||||
context.pop();
|
||||
},
|
||||
child: Text(libL10n.clear),
|
||||
),
|
||||
TextButton(onPressed: onSave, child: Text(libL10n.ok)),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -92,37 +92,37 @@ extension _App on _AppSettingsPageState {
|
||||
trailing: _setting.colorSeed.listenable().listenVal((_) {
|
||||
return ClipOval(child: Container(color: UIs.primaryColor, height: 27, width: 27));
|
||||
}),
|
||||
onTap: () async {
|
||||
final ctrl = TextEditingController(text: UIs.primaryColor.toHex);
|
||||
await context.showRoundDialog(
|
||||
title: libL10n.primaryColorSeed,
|
||||
child: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
final children = <Widget>[
|
||||
/// Plugin [dynamic_color] is not supported on iOS
|
||||
if (!isIOS)
|
||||
ListTile(
|
||||
title: Text(l10n.followSystem),
|
||||
trailing: StoreSwitch(
|
||||
prop: _setting.useSystemPrimaryColor,
|
||||
callback: (_) => setState(() {}),
|
||||
onTap: () {
|
||||
withTextFieldController((ctrl) async {
|
||||
await context.showRoundDialog(
|
||||
title: libL10n.primaryColorSeed,
|
||||
child: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
final children = <Widget>[
|
||||
/// Plugin [dynamic_color] is not supported on iOS
|
||||
if (!isIOS)
|
||||
ListTile(
|
||||
title: Text(l10n.followSystem),
|
||||
trailing: StoreSwitch(
|
||||
prop: _setting.useSystemPrimaryColor,
|
||||
callback: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
if (!_setting.useSystemPrimaryColor.fetch()) {
|
||||
children.add(
|
||||
ColorPicker(
|
||||
color: Color(_setting.colorSeed.fetch()),
|
||||
onColorChanged: (c) => ctrl.text = c.toHex,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(mainAxisSize: MainAxisSize.min, children: children);
|
||||
},
|
||||
),
|
||||
actions: Btn.ok(onTap: () => _onSaveColor(ctrl.text)).toList,
|
||||
);
|
||||
ctrl.dispose();
|
||||
];
|
||||
if (!_setting.useSystemPrimaryColor.fetch()) {
|
||||
children.add(
|
||||
ColorPicker(
|
||||
color: Color(_setting.colorSeed.fetch()),
|
||||
onColorChanged: (c) => ctrl.text = c.toHex,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(mainAxisSize: MainAxisSize.min, children: children);
|
||||
},
|
||||
),
|
||||
actions: Btn.ok(onTap: () => _onSaveColor(ctrl.text)).toList,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,28 +44,28 @@ extension _SFTP on _AppSettingsPageState {
|
||||
leading: const Icon(MingCute.edit_fill),
|
||||
title: TipText(libL10n.editor, l10n.sftpEditorTip),
|
||||
trailing: Text(val.isEmpty ? l10n.inner : val, style: UIs.text15),
|
||||
onTap: () async {
|
||||
final ctrl = TextEditingController(text: val);
|
||||
void onSave() {
|
||||
final s = ctrl.text.trim();
|
||||
_setting.sftpEditor.put(s);
|
||||
context.pop();
|
||||
}
|
||||
onTap: () {
|
||||
withTextFieldController((ctrl) async {
|
||||
void onSave() {
|
||||
final s = ctrl.text.trim();
|
||||
_setting.sftpEditor.put(s);
|
||||
context.pop();
|
||||
}
|
||||
|
||||
await context.showRoundDialog<bool>(
|
||||
title: libL10n.select,
|
||||
child: Input(
|
||||
controller: ctrl,
|
||||
autoFocus: true,
|
||||
label: libL10n.editor,
|
||||
hint: '\$EDITOR / vim / nano ...',
|
||||
icon: Icons.edit,
|
||||
suggestion: false,
|
||||
onSubmitted: (_) => onSave(),
|
||||
),
|
||||
actions: Btn.ok(onTap: onSave).toList,
|
||||
);
|
||||
ctrl.dispose();
|
||||
await context.showRoundDialog<bool>(
|
||||
title: libL10n.select,
|
||||
child: Input(
|
||||
controller: ctrl,
|
||||
autoFocus: true,
|
||||
label: libL10n.editor,
|
||||
hint: '\$EDITOR / vim / nano ...',
|
||||
icon: Icons.edit,
|
||||
suggestion: false,
|
||||
onSubmitted: (_) => onSave(),
|
||||
),
|
||||
actions: Btn.ok(onTap: onSave).toList,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -116,27 +116,28 @@ extension _SSH on _AppSettingsPageState {
|
||||
leading: const Icon(Icons.terminal),
|
||||
title: TipText(l10n.terminal, l10n.desktopTerminalTip),
|
||||
trailing: Text(val, style: UIs.text15, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
onTap: () async {
|
||||
final ctrl = TextEditingController(text: val);
|
||||
void onSave() {
|
||||
_setting.desktopTerminal.put(ctrl.text.trim());
|
||||
context.pop();
|
||||
}
|
||||
onTap: () {
|
||||
withTextFieldController((ctrl) async {
|
||||
ctrl.text = val;
|
||||
void onSave() {
|
||||
_setting.desktopTerminal.put(ctrl.text.trim());
|
||||
context.pop();
|
||||
}
|
||||
|
||||
await context.showRoundDialog<bool>(
|
||||
title: libL10n.select,
|
||||
child: Input(
|
||||
controller: ctrl,
|
||||
autoFocus: true,
|
||||
label: l10n.terminal,
|
||||
hint: 'x-terminal-emulator / gnome-terminal',
|
||||
icon: Icons.edit,
|
||||
suggestion: false,
|
||||
onSubmitted: (_) => onSave(),
|
||||
),
|
||||
actions: Btn.ok(onTap: onSave).toList,
|
||||
);
|
||||
ctrl.dispose();
|
||||
await context.showRoundDialog<bool>(
|
||||
title: libL10n.select,
|
||||
child: Input(
|
||||
controller: ctrl,
|
||||
autoFocus: true,
|
||||
label: l10n.terminal,
|
||||
hint: 'x-terminal-emulator / gnome-terminal',
|
||||
icon: Icons.edit,
|
||||
suggestion: false,
|
||||
onSubmitted: (_) => onSave(),
|
||||
),
|
||||
actions: Btn.ok(onTap: onSave).toList,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ part 'entries/full_screen.dart';
|
||||
part 'entries/server.dart';
|
||||
part 'entries/sftp.dart';
|
||||
part 'entries/ssh.dart';
|
||||
part 'entries/ai.dart';
|
||||
|
||||
const _kIconSize = 23.0;
|
||||
|
||||
@@ -120,7 +121,7 @@ final class _AppSettingsPageState extends ConsumerState<AppSettingsPage> {
|
||||
Widget build(BuildContext context) {
|
||||
return MultiList(
|
||||
children: [
|
||||
[const CenterGreyTitle('App'), _buildApp()],
|
||||
[const CenterGreyTitle('App'), _buildApp(), const CenterGreyTitle('AI'), _buildAskAiConfig()],
|
||||
[CenterGreyTitle(l10n.server), _buildServer()],
|
||||
[const CenterGreyTitle('SSH'), _buildSSH(), const CenterGreyTitle('SFTP'), _buildSFTP()],
|
||||
[CenterGreyTitle(l10n.container), _buildContainer(), CenterGreyTitle(libL10n.editor), _buildEditor()],
|
||||
|
||||
450
lib/view/page/ssh/page/ask_ai.dart
Normal file
450
lib/view/page/ssh/page/ask_ai.dart
Normal file
@@ -0,0 +1,450 @@
|
||||
part of 'page.dart';
|
||||
|
||||
extension _AskAi on SSHPageState {
|
||||
List<ContextMenuButtonItem> _buildTerminalToolbar(
|
||||
BuildContext context,
|
||||
CustomTextEditState state,
|
||||
List<ContextMenuButtonItem> defaultItems,
|
||||
) {
|
||||
final rawSelection = _termKey.currentState?.renderTerminal.selectedText;
|
||||
final selection = rawSelection?.trim();
|
||||
if (selection == null || selection.isEmpty) {
|
||||
return defaultItems;
|
||||
}
|
||||
|
||||
final items = List<ContextMenuButtonItem>.from(defaultItems);
|
||||
items.add(
|
||||
ContextMenuButtonItem(
|
||||
label: context.l10n.askAi,
|
||||
onPressed: () {
|
||||
state.hideToolbar();
|
||||
_showAskAiSheet(selection);
|
||||
},
|
||||
),
|
||||
);
|
||||
return items;
|
||||
}
|
||||
|
||||
Future<void> _showAskAiSheet(String selection) async {
|
||||
if (!mounted) return;
|
||||
final localeHint = Localizations.maybeLocaleOf(context)?.toLanguageTag();
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (ctx) {
|
||||
return _AskAiSheet(selection: selection, localeHint: localeHint, onCommandApply: _applyAiCommand);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _applyAiCommand(String command) {
|
||||
if (command.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_terminal.textInput(command);
|
||||
(widget.args.focusNode?.requestFocus ?? _termKey.currentState?.requestKeyboard)?.call();
|
||||
}
|
||||
}
|
||||
|
||||
class _AskAiSheet extends ConsumerStatefulWidget {
|
||||
const _AskAiSheet({required this.selection, required this.localeHint, required this.onCommandApply});
|
||||
|
||||
final String selection;
|
||||
final String? localeHint;
|
||||
final ValueChanged<String> onCommandApply;
|
||||
|
||||
@override
|
||||
ConsumerState<_AskAiSheet> createState() => _AskAiSheetState();
|
||||
}
|
||||
|
||||
enum _ChatEntryType { user, assistant, command }
|
||||
|
||||
class _ChatEntry {
|
||||
const _ChatEntry._({required this.type, this.content, this.command});
|
||||
|
||||
const _ChatEntry.user(String content) : this._(type: _ChatEntryType.user, content: content);
|
||||
|
||||
const _ChatEntry.assistant(String content) : this._(type: _ChatEntryType.assistant, content: content);
|
||||
|
||||
const _ChatEntry.command(AskAiCommand command) : this._(type: _ChatEntryType.command, command: command);
|
||||
|
||||
final _ChatEntryType type;
|
||||
final String? content;
|
||||
final AskAiCommand? command;
|
||||
}
|
||||
|
||||
class _AskAiSheetState extends ConsumerState<_AskAiSheet> {
|
||||
StreamSubscription<AskAiEvent>? _subscription;
|
||||
final _chatEntries = <_ChatEntry>[];
|
||||
final _history = <AskAiMessage>[];
|
||||
final _scrollController = ScrollController();
|
||||
final _inputController = TextEditingController();
|
||||
final _seenCommands = <String>{};
|
||||
String? _streamingContent;
|
||||
String? _error;
|
||||
bool _isStreaming = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_inputController.addListener(_handleInputChanged);
|
||||
_startStream();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
_scrollController.dispose();
|
||||
_inputController
|
||||
..removeListener(_handleInputChanged)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleInputChanged() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _startStream() {
|
||||
_subscription?.cancel();
|
||||
setState(() {
|
||||
_isStreaming = true;
|
||||
_error = null;
|
||||
_streamingContent = '';
|
||||
});
|
||||
|
||||
final messages = List<AskAiMessage>.from(_history);
|
||||
|
||||
_subscription = ref
|
||||
.read(askAiRepositoryProvider)
|
||||
.ask(selection: widget.selection, localeHint: widget.localeHint, conversation: messages)
|
||||
.listen(
|
||||
_handleEvent,
|
||||
onError: (error, stack) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = _describeError(error);
|
||||
_isStreaming = false;
|
||||
_streamingContent = null;
|
||||
});
|
||||
},
|
||||
onDone: () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isStreaming = false;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleEvent(AskAiEvent event) {
|
||||
if (!mounted) return;
|
||||
var shouldScroll = false;
|
||||
setState(() {
|
||||
if (event is AskAiContentDelta) {
|
||||
_streamingContent = (_streamingContent ?? '') + event.delta;
|
||||
shouldScroll = true;
|
||||
} else if (event is AskAiToolSuggestion) {
|
||||
final inserted = _seenCommands.add(event.command.command);
|
||||
if (inserted) {
|
||||
_chatEntries.add(_ChatEntry.command(event.command));
|
||||
shouldScroll = true;
|
||||
}
|
||||
} else if (event is AskAiCompleted) {
|
||||
final fullText = event.fullText.isNotEmpty ? event.fullText : (_streamingContent ?? '');
|
||||
if (fullText.trim().isNotEmpty) {
|
||||
final message = AskAiMessage(role: AskAiMessageRole.assistant, content: fullText);
|
||||
_history.add(message);
|
||||
_chatEntries.add(_ChatEntry.assistant(fullText));
|
||||
}
|
||||
for (final command in event.commands) {
|
||||
final inserted = _seenCommands.add(command.command);
|
||||
if (inserted) {
|
||||
_chatEntries.add(_ChatEntry.command(command));
|
||||
}
|
||||
}
|
||||
_streamingContent = null;
|
||||
_isStreaming = false;
|
||||
shouldScroll = true;
|
||||
} else if (event is AskAiStreamError) {
|
||||
_error = _describeError(event.error);
|
||||
_streamingContent = null;
|
||||
_isStreaming = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldScroll) {
|
||||
_scheduleAutoScroll();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleAutoScroll() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!_scrollController.hasClients) return;
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
String _describeError(Object error) {
|
||||
final l10n = context.l10n;
|
||||
if (error is AskAiConfigException) {
|
||||
if (error.missingFields.isEmpty) {
|
||||
if (error.hasInvalidBaseUrl) {
|
||||
return 'Invalid Ask AI base URL: ${error.invalidBaseUrl}';
|
||||
}
|
||||
return error.toString();
|
||||
}
|
||||
final locale = Localizations.maybeLocaleOf(context);
|
||||
final separator = switch (locale?.languageCode) {
|
||||
'zh' => '、',
|
||||
'ja' => '、',
|
||||
_ => ', ',
|
||||
};
|
||||
final formattedFields = error.missingFields
|
||||
.map(
|
||||
(field) => switch (field) {
|
||||
AskAiConfigField.baseUrl => l10n.askAiBaseUrl,
|
||||
AskAiConfigField.apiKey => l10n.askAiApiKey,
|
||||
AskAiConfigField.model => l10n.askAiModel,
|
||||
},
|
||||
)
|
||||
.join(separator);
|
||||
final message = l10n.askAiConfigMissing(formattedFields);
|
||||
if (error.hasInvalidBaseUrl) {
|
||||
return '$message (invalid URL: ${error.invalidBaseUrl})';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
if (error is AskAiNetworkException) {
|
||||
return error.message;
|
||||
}
|
||||
return error.toString();
|
||||
}
|
||||
|
||||
Future<void> _handleApplyCommand(BuildContext context, AskAiCommand command) async {
|
||||
final confirmed = await context.showRoundDialog<bool>(
|
||||
title: context.l10n.askAiConfirmExecute,
|
||||
child: SelectableText(command.command, style: const TextStyle(fontFamily: 'monospace')),
|
||||
actions: [
|
||||
TextButton(onPressed: context.pop, child: Text(libL10n.cancel)),
|
||||
TextButton(onPressed: () => context.pop(true), child: Text(libL10n.ok)),
|
||||
],
|
||||
);
|
||||
if (confirmed == true) {
|
||||
widget.onCommandApply(command.command);
|
||||
if (!mounted) return;
|
||||
context.showSnackBar(context.l10n.askAiCommandInserted);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _copyCommand(BuildContext context, AskAiCommand command) async {
|
||||
await Clipboard.setData(ClipboardData(text: command.command));
|
||||
if (!mounted) return;
|
||||
context.showSnackBar(libL10n.success);
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
if (_isStreaming) return;
|
||||
final text = _inputController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
setState(() {
|
||||
final message = AskAiMessage(role: AskAiMessageRole.user, content: text);
|
||||
_history.add(message);
|
||||
_chatEntries.add(_ChatEntry.user(text));
|
||||
_inputController.clear();
|
||||
});
|
||||
_startStream();
|
||||
_scheduleAutoScroll();
|
||||
}
|
||||
|
||||
List<Widget> _buildConversationWidgets(BuildContext context, ThemeData theme) {
|
||||
final widgets = <Widget>[];
|
||||
for (final entry in _chatEntries) {
|
||||
widgets.add(_buildChatItem(context, theme, entry));
|
||||
widgets.add(const SizedBox(height: 12));
|
||||
}
|
||||
|
||||
if (_streamingContent != null) {
|
||||
widgets.add(_buildAssistantBubble(theme, content: _streamingContent!, streaming: true));
|
||||
widgets.add(const SizedBox(height: 12));
|
||||
} else if (_chatEntries.isEmpty && _error == null) {
|
||||
widgets.add(_buildAssistantBubble(theme, content: '', streaming: true));
|
||||
widgets.add(const SizedBox(height: 12));
|
||||
}
|
||||
|
||||
if (widgets.isNotEmpty) {
|
||||
widgets.removeLast();
|
||||
}
|
||||
return widgets;
|
||||
}
|
||||
|
||||
Widget _buildChatItem(BuildContext context, ThemeData theme, _ChatEntry entry) {
|
||||
switch (entry.type) {
|
||||
case _ChatEntryType.user:
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: CardX(
|
||||
child: Padding(padding: const EdgeInsets.all(12), child: SelectableText(entry.content ?? '')),
|
||||
),
|
||||
);
|
||||
case _ChatEntryType.assistant:
|
||||
return _buildAssistantBubble(theme, content: entry.content ?? '');
|
||||
case _ChatEntryType.command:
|
||||
final command = entry.command!;
|
||||
return _buildCommandBubble(context, theme, command);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildAssistantBubble(ThemeData theme, {required String content, bool streaming = false}) {
|
||||
final trimmed = content.trim();
|
||||
final l10n = context.l10n;
|
||||
final child = trimmed.isEmpty
|
||||
? Text(
|
||||
streaming ? l10n.askAiAwaitingResponse : l10n.askAiNoResponse,
|
||||
style: theme.textTheme.bodySmall,
|
||||
)
|
||||
: SimpleMarkdown(data: content);
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CardX(
|
||||
child: Padding(padding: const EdgeInsets.all(12), child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommandBubble(BuildContext context, ThemeData theme, AskAiCommand command) {
|
||||
final l10n = context.l10n;
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CardX(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.askAiRecommendedCommand, style: theme.textTheme.labelMedium),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(command.command, style: const TextStyle(fontFamily: 'monospace')),
|
||||
if (command.description.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(command.description, style: theme.textTheme.bodySmall),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => _copyCommand(context, command),
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
label: Text(libL10n.copy),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _handleApplyCommand(context, command),
|
||||
icon: const Icon(Icons.terminal, size: 18),
|
||||
label: Text(l10n.askAiInsertTerminal),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final bottomPadding = MediaQuery.viewInsetsOf(context).bottom;
|
||||
|
||||
return FractionallySizedBox(
|
||||
heightFactor: 0.85,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(context.l10n.askAi, style: theme.textTheme.titleLarge),
|
||||
const SizedBox(width: 8),
|
||||
if (_isStreaming)
|
||||
const SizedBox(height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
const Spacer(),
|
||||
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop()),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Scrollbar(
|
||||
controller: _scrollController,
|
||||
child: ListView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
children: [
|
||||
Text(context.l10n.askAiSelectedContent, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 6),
|
||||
CardX(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SelectableText(
|
||||
widget.selection,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(context.l10n.askAiConversation, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 6),
|
||||
..._buildConversationWidgets(context, theme),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
CardX(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_isStreaming) ...[const SizedBox(height: 16), const LinearProgressIndicator()],
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 8, 16, 16 + bottomPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Input(
|
||||
controller: _inputController,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
hint: context.l10n.askAiFollowUpHint,
|
||||
action: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Btn.icon(
|
||||
onTap: _isStreaming || _inputController.text.trim().isEmpty ? null : _sendMessage,
|
||||
icon: const Icon(Icons.send, size: 18),
|
||||
),
|
||||
],
|
||||
).cardx,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,11 @@ import 'package:server_box/core/chan.dart';
|
||||
import 'package:server_box/core/extension/context/locale.dart';
|
||||
import 'package:server_box/core/utils/server.dart';
|
||||
import 'package:server_box/core/utils/ssh_auth.dart';
|
||||
import 'package:server_box/data/model/ai/ask_ai_models.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
import 'package:server_box/data/model/server/snippet.dart';
|
||||
import 'package:server_box/data/model/ssh/virtual_key.dart';
|
||||
import 'package:server_box/data/provider/ai/ask_ai.dart';
|
||||
import 'package:server_box/data/provider/server/single.dart';
|
||||
import 'package:server_box/data/provider/snippet.dart';
|
||||
import 'package:server_box/data/provider/virtual_keyboard.dart';
|
||||
@@ -23,11 +25,11 @@ import 'package:server_box/data/res/store.dart';
|
||||
import 'package:server_box/data/res/terminal.dart';
|
||||
import 'package:server_box/data/ssh/session_manager.dart';
|
||||
import 'package:server_box/view/page/storage/sftp.dart';
|
||||
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:xterm/core.dart';
|
||||
import 'package:xterm/ui.dart' hide TerminalThemes;
|
||||
|
||||
part 'ask_ai.dart';
|
||||
part 'init.dart';
|
||||
part 'keyboard.dart';
|
||||
part 'virt_key.dart';
|
||||
@@ -247,6 +249,7 @@ class SSHPageState extends ConsumerState<SSHPage>
|
||||
viewOffset: Offset(2 * _horizonPadding, CustomAppBar.sysStatusBarHeight),
|
||||
hideScrollBar: false,
|
||||
focusNode: widget.args.focusNode,
|
||||
toolbarBuilder: _buildTerminalToolbar,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user