diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index f59c7561..1d72e1b0 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -32,7 +32,6 @@ jobs: path: | ${{ env.PUB_CACHE }} ~/.pub-cache - .dart_tool/package_config.json key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}-${{ hashFiles('**/pubspec.yaml') }} restore-keys: | ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}- diff --git a/lib/app.dart b/lib/app.dart index 67bb4650..40ba5c6d 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -11,9 +11,16 @@ import 'package:server_box/view/page/home.dart'; part 'intro.dart'; -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { const MyApp({super.key}); + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + late final Future> _introFuture = _IntroPage.builders; + @override Widget build(BuildContext context) { _setup(context); @@ -91,7 +98,7 @@ class MyApp extends StatelessWidget { theme: light.fixWindowsFont, darkTheme: (tMode < 3 ? dark : dark.toAmoled).fixWindowsFont, home: FutureBuilder>( - future: _IntroPage.builders, + future: _introFuture, builder: (context, snapshot) { context.setLibL10n(); final appL10n = AppLocalizations.of(context); diff --git a/lib/data/model/ai/ask_ai_models.dart b/lib/data/model/ai/ask_ai_models.dart new file mode 100644 index 00000000..15c8cad1 --- /dev/null +++ b/lib/data/model/ai/ask_ai_models.dart @@ -0,0 +1,74 @@ +import 'package:meta/meta.dart'; + +/// Chat message exchanged with the Ask AI service. +enum AskAiMessageRole { user, assistant } + +@immutable +class AskAiMessage { + const AskAiMessage({ + required this.role, + required this.content, + }); + + final AskAiMessageRole role; + final String content; + + String get apiRole { + switch (role) { + case AskAiMessageRole.user: + return 'user'; + case AskAiMessageRole.assistant: + return 'assistant'; + } + } +} + +/// Recommended command returned by the AI tool call. +@immutable +class AskAiCommand { + const AskAiCommand({ + required this.command, + this.description = '', + this.toolName, + }); + + final String command; + final String description; + final String? toolName; +} + +@immutable +sealed class AskAiEvent { + const AskAiEvent(); +} + +/// Incremental text delta emitted while streaming the AI response. +class AskAiContentDelta extends AskAiEvent { + const AskAiContentDelta(this.delta); + final String delta; +} + +/// Emits when a tool call returns a runnable command suggestion. +class AskAiToolSuggestion extends AskAiEvent { + const AskAiToolSuggestion(this.command); + final AskAiCommand command; +} + +/// Signals that the stream finished successfully. +class AskAiCompleted extends AskAiEvent { + const AskAiCompleted({ + required this.fullText, + required this.commands, + }); + + final String fullText; + final List commands; +} + +/// Signals that the stream terminated with an error before completion. +class AskAiStreamError extends AskAiEvent { + const AskAiStreamError(this.error, this.stackTrace); + + final Object error; + final StackTrace? stackTrace; +} diff --git a/lib/data/provider/ai/ask_ai.dart b/lib/data/provider/ai/ask_ai.dart new file mode 100644 index 00000000..8c6b2334 --- /dev/null +++ b/lib/data/provider/ai/ask_ai.dart @@ -0,0 +1,346 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:meta/meta.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:server_box/data/model/ai/ask_ai_models.dart'; +import 'package:server_box/data/res/store.dart'; +import 'package:server_box/data/store/setting.dart'; + +final askAiRepositoryProvider = Provider((ref) { + return AskAiRepository(); +}); + +class AskAiRepository { + AskAiRepository({Dio? dio}) : _dio = dio ?? Dio(); + + final Dio _dio; + + SettingStore get _settings => Stores.setting; + + /// Streams the AI response using the configured endpoint. + Stream ask({ + required String selection, + String? localeHint, + List conversation = const [], + }) async* { + final baseUrl = _settings.askAiBaseUrl.fetch().trim(); + final apiKey = _settings.askAiApiKey.fetch().trim(); + final model = _settings.askAiModel.fetch().trim(); + + final missing = []; + if (baseUrl.isEmpty) missing.add(AskAiConfigField.baseUrl); + if (apiKey.isEmpty) missing.add(AskAiConfigField.apiKey); + if (model.isEmpty) missing.add(AskAiConfigField.model); + if (missing.isNotEmpty) { + throw AskAiConfigException(missingFields: missing); + } + + final parsedBaseUri = Uri.tryParse(baseUrl); + final hasScheme = parsedBaseUri?.hasScheme ?? false; + final hasHost = (parsedBaseUri?.host ?? '').isNotEmpty; + if (!hasScheme || !hasHost) { + throw AskAiConfigException(invalidBaseUrl: baseUrl); + } + + final uri = _composeUri(baseUrl, '/v1/chat/completions'); + final authHeader = apiKey.startsWith('Bearer ') ? apiKey : 'Bearer $apiKey'; + final headers = { + Headers.acceptHeader: 'text/event-stream', + Headers.contentTypeHeader: Headers.jsonContentType, + 'Authorization': authHeader, + }; + + final requestBody = _buildRequestBody( + model: model, + selection: selection, + localeHint: localeHint, + conversation: conversation, + ); + + Response response; + try { + response = await _dio.postUri( + uri, + data: jsonEncode(requestBody), + options: Options( + responseType: ResponseType.stream, + headers: headers, + sendTimeout: const Duration(seconds: 20), + receiveTimeout: const Duration(minutes: 2), + ), + ); + } on DioException catch (e) { + throw AskAiNetworkException(message: e.message ?? 'Request failed', cause: e); + } + + final body = response.data; + if (body == null) { + throw AskAiNetworkException(message: 'Empty response body'); + } + + final contentBuffer = StringBuffer(); + final commands = []; + final toolBuilders = {}; + final utf8Stream = body.stream.cast>().transform(utf8.decoder); + final carry = StringBuffer(); + + try { + await for (final chunk in utf8Stream) { + carry.write(chunk); + final segments = carry.toString().split('\n\n'); + carry + ..clear() + ..write(segments.removeLast()); + + for (final segment in segments) { + final lines = segment.split('\n'); + for (final rawLine in lines) { + final line = rawLine.trim(); + if (line.isEmpty || !line.startsWith('data:')) { + continue; + } + final payload = line.substring(5).trim(); + if (payload.isEmpty) { + continue; + } + if (payload == '[DONE]') { + yield AskAiCompleted( + fullText: contentBuffer.toString(), + commands: List.unmodifiable(commands), + ); + return; + } + + Map json; + try { + json = jsonDecode(payload) as Map; + } catch (e, s) { + yield AskAiStreamError(e, s); + continue; + } + + final choices = json['choices']; + if (choices is! List || choices.isEmpty) { + continue; + } + + for (final choice in choices) { + if (choice is! Map) { + continue; + } + final delta = choice['delta']; + if (delta is Map) { + final content = delta['content']; + if (content is String && content.isNotEmpty) { + contentBuffer.write(content); + yield AskAiContentDelta(content); + } else if (content is List) { + for (final item in content) { + if (item is Map) { + final text = item['text'] as String?; + if (text != null && text.isNotEmpty) { + contentBuffer.write(text); + yield AskAiContentDelta(text); + } + } + } + } + + final toolCalls = delta['tool_calls']; + if (toolCalls is List) { + for (final toolCall in toolCalls) { + if (toolCall is! Map) continue; + final index = toolCall['index'] as int? ?? 0; + final builder = toolBuilders.putIfAbsent(index, _ToolCallBuilder.new); + final function = toolCall['function']; + if (function is Map) { + builder.name ??= function['name'] as String?; + final args = function['arguments'] as String?; + if (args != null && args.isNotEmpty) { + builder.arguments.write(args); + final command = builder.tryBuild(); + if (command != null) { + commands.add(command); + yield AskAiToolSuggestion(command); + } + } + } + } + } + } + + final finishReason = choice['finish_reason']; + if (finishReason == 'tool_calls') { + for (final builder in toolBuilders.values) { + final command = builder.tryBuild(force: true); + if (command != null) { + commands.add(command); + yield AskAiToolSuggestion(command); + } + } + toolBuilders.clear(); + } + } + } + } + } + + // Flush remaining buffer if [DONE] not received. + if (contentBuffer.isNotEmpty || commands.isNotEmpty) { + yield AskAiCompleted( + fullText: contentBuffer.toString(), + commands: List.unmodifiable(commands), + ); + } + } catch (e, s) { + yield AskAiStreamError(e, s); + return; + } + } + + Map _buildRequestBody({ + required String model, + required String selection, + required List conversation, + String? localeHint, + }) { + final promptBuffer = StringBuffer() + ..writeln('你是一个 SSH 终端助手。') + ..writeln('用户会提供一段终端输出或命令,请结合上下文给出解释。') + ..writeln('当需要给出可执行命令时,调用 `recommend_shell` 工具,并提供简短描述。') + ..writeln('仅在非常确定命令安全时才给出建议。'); + + if (localeHint != null && localeHint.isNotEmpty) { + promptBuffer + ..writeln('请优先使用用户的语言输出:$localeHint。') + ..writeln('如果无法判断语言,请使用简体中文。'); + } else { + promptBuffer.writeln('如果无法判断语言,请使用简体中文。'); + } + + final messages = >[ + { + 'role': 'system', + 'content': promptBuffer.toString(), + }, + ...conversation.map((message) => { + 'role': message.apiRole, + 'content': message.content, + }), + { + 'role': 'user', + 'content': '以下是终端选中的内容:\n$selection', + }, + ]; + + return { + 'model': model, + 'stream': true, + 'messages': messages, + 'tools': [ + { + 'type': 'function', + 'function': { + 'name': 'recommend_shell', + 'description': '返回一个用户可以直接复制执行的终端命令。', + 'parameters': { + 'type': 'object', + 'required': ['command'], + 'properties': { + 'command': { + 'type': 'string', + 'description': '完整的终端命令,确保可以被粘贴后直接执行。', + }, + 'description': { + 'type': 'string', + 'description': '简述该命令的作用或注意事项。', + }, + }, + }, + }, + }, + ], + }; + } + + Uri _composeUri(String base, String path) { + final sanitizedBase = base.replaceAll(RegExp(r'/+$'), ''); + final sanitizedPath = path.replaceFirst(RegExp(r'^/+'), ''); + return Uri.parse('$sanitizedBase/$sanitizedPath'); + } +} + +class _ToolCallBuilder { + _ToolCallBuilder(); + + final StringBuffer arguments = StringBuffer(); + String? name; + bool _emitted = false; + + AskAiCommand? tryBuild({bool force = false}) { + if (_emitted && !force) return null; + final raw = arguments.toString(); + try { + final decoded = jsonDecode(raw) as Map; + final command = decoded['command'] as String?; + if (command == null || command.trim().isEmpty) { + if (force) { + _emitted = true; + } + return null; + } + final description = decoded['description'] as String? ?? decoded['explanation'] as String? ?? ''; + _emitted = true; + return AskAiCommand( + command: command.trim(), + description: description.trim(), + toolName: name, + ); + } on FormatException { + if (force) { + _emitted = true; + } + return null; + } + } +} + +@immutable +enum AskAiConfigField { baseUrl, apiKey, model } + +class AskAiConfigException implements Exception { + const AskAiConfigException({this.missingFields = const [], this.invalidBaseUrl}); + + final List missingFields; + final String? invalidBaseUrl; + + bool get hasInvalidBaseUrl => (invalidBaseUrl ?? '').isNotEmpty; + + @override + String toString() { + final parts = []; + if (missingFields.isNotEmpty) { + parts.add('missing: ${missingFields.map((e) => e.name).join(', ')}'); + } + if (hasInvalidBaseUrl) { + parts.add('invalidBaseUrl: $invalidBaseUrl'); + } + if (parts.isEmpty) { + return 'AskAiConfigException()'; + } + return 'AskAiConfigException(${parts.join('; ')})'; + } +} + +@immutable +class AskAiNetworkException implements Exception { + const AskAiNetworkException({required this.message, this.cause}); + + final String message; + final Object? cause; + + @override + String toString() => 'AskAiNetworkException(message: $message)'; +} diff --git a/lib/data/store/setting.dart b/lib/data/store/setting.dart index 16891e90..ec63d431 100644 --- a/lib/data/store/setting.dart +++ b/lib/data/store/setting.dart @@ -142,6 +142,11 @@ class SettingStore extends HiveStore { /// Whether collapse UI items by default late final collapseUIDefault = propertyDefault('collapseUIDefault', true); + /// Terminal AI helper configuration + late final askAiBaseUrl = propertyDefault('askAiBaseUrl', 'https://api.openai.com'); + late final askAiApiKey = propertyDefault('askAiApiKey', ''); + late final askAiModel = propertyDefault('askAiModel', 'gpt-4o-mini'); + late final serverFuncBtns = listProperty('serverBtns', defaultValue: ServerFuncBtn.defaultIdxs); /// Docker is more popular than podman, set to `false` to use docker diff --git a/lib/generated/l10n/l10n.dart b/lib/generated/l10n/l10n.dart index 781bd838..1c32bac6 100644 --- a/lib/generated/l10n/l10n.dart +++ b/lib/generated/l10n/l10n.dart @@ -1747,6 +1747,102 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'After connecting to the server, a script will be written to `~/.config/server_box` \n | `/tmp/server_box` to monitor the system status. You can review the script content.'** String get writeScriptTip; + + /// No description provided for @askAi. + /// + /// In en, this message translates to: + /// **'Ask AI'** + String get askAi; + + /// No description provided for @askAiUsageHint. + /// + /// In en, this message translates to: + /// **'Used in SSH Terminal'** + String get askAiUsageHint; + + /// No description provided for @askAiBaseUrl. + /// + /// In en, this message translates to: + /// **'Base URL'** + String get askAiBaseUrl; + + /// No description provided for @askAiModel. + /// + /// In en, this message translates to: + /// **'Model'** + String get askAiModel; + + /// No description provided for @askAiApiKey. + /// + /// In en, this message translates to: + /// **'API Key'** + String get askAiApiKey; + + /// No description provided for @askAiConfigMissing. + /// + /// In en, this message translates to: + /// **'Please configure {fields} in Settings.'** + String askAiConfigMissing(String fields); + + /// No description provided for @askAiConfirmExecute. + /// + /// In en, this message translates to: + /// **'Confirm before executing'** + String get askAiConfirmExecute; + + /// No description provided for @askAiCommandInserted. + /// + /// In en, this message translates to: + /// **'Command inserted into terminal'** + String get askAiCommandInserted; + + /// No description provided for @askAiAwaitingResponse. + /// + /// In en, this message translates to: + /// **'Waiting for AI response...'** + String get askAiAwaitingResponse; + + /// No description provided for @askAiNoResponse. + /// + /// In en, this message translates to: + /// **'No response'** + String get askAiNoResponse; + + /// No description provided for @askAiRecommendedCommand. + /// + /// In en, this message translates to: + /// **'AI suggested command'** + String get askAiRecommendedCommand; + + /// No description provided for @askAiInsertTerminal. + /// + /// In en, this message translates to: + /// **'Insert into terminal'** + String get askAiInsertTerminal; + + /// No description provided for @askAiSelectedContent. + /// + /// In en, this message translates to: + /// **'Selected content'** + String get askAiSelectedContent; + + /// No description provided for @askAiConversation. + /// + /// In en, this message translates to: + /// **'AI conversation'** + String get askAiConversation; + + /// No description provided for @askAiFollowUpHint. + /// + /// In en, this message translates to: + /// **'Ask a follow-up...'** + String get askAiFollowUpHint; + + /// No description provided for @askAiSend. + /// + /// In en, this message translates to: + /// **'Send'** + String get askAiSend; } class _AppLocalizationsDelegate diff --git a/lib/generated/l10n/l10n_de.dart b/lib/generated/l10n/l10n_de.dart index b30fa159..40c1bc8a 100644 --- a/lib/generated/l10n/l10n_de.dart +++ b/lib/generated/l10n/l10n_de.dart @@ -923,4 +923,54 @@ class AppLocalizationsDe extends AppLocalizations { @override String get writeScriptTip => 'Nach der Verbindung mit dem Server wird ein Skript in `~/.config/server_box` \n | `/tmp/server_box` geschrieben, um den Systemstatus zu überwachen. Sie können den Skriptinhalt überprüfen.'; + + @override + String get askAi => 'KI fragen'; + + @override + String get askAiUsageHint => 'Verwendet im SSH-Terminal'; + + @override + String get askAiBaseUrl => 'Basis-URL'; + + @override + String get askAiModel => 'Modell'; + + @override + String get askAiApiKey => 'API-Schlüssel'; + + @override + String askAiConfigMissing(String fields) { + return 'Bitte konfigurieren Sie $fields in den Einstellungen.'; + } + + @override + String get askAiConfirmExecute => 'Vor Ausführung bestätigen'; + + @override + String get askAiCommandInserted => 'Befehl ins Terminal eingefügt'; + + @override + String get askAiAwaitingResponse => 'Warte auf KI-Antwort...'; + + @override + String get askAiNoResponse => 'Keine Antwort'; + + @override + String get askAiRecommendedCommand => 'KI-empfohlener Befehl'; + + @override + String get askAiInsertTerminal => 'In Terminal einfügen'; + + @override + String get askAiSelectedContent => 'Ausgewählter Inhalt'; + + @override + String get askAiConversation => 'KI-Unterhaltung'; + + @override + String get askAiFollowUpHint => 'Weitere Frage stellen...'; + + @override + String get askAiSend => 'Senden'; } diff --git a/lib/generated/l10n/l10n_en.dart b/lib/generated/l10n/l10n_en.dart index 56586a93..47e3e55b 100644 --- a/lib/generated/l10n/l10n_en.dart +++ b/lib/generated/l10n/l10n_en.dart @@ -914,4 +914,54 @@ class AppLocalizationsEn extends AppLocalizations { @override String get writeScriptTip => 'After connecting to the server, a script will be written to `~/.config/server_box` \n | `/tmp/server_box` to monitor the system status. You can review the script content.'; + + @override + String get askAi => 'Ask AI'; + + @override + String get askAiUsageHint => 'Used in SSH Terminal'; + + @override + String get askAiBaseUrl => 'Base URL'; + + @override + String get askAiModel => 'Model'; + + @override + String get askAiApiKey => 'API Key'; + + @override + String askAiConfigMissing(String fields) { + return 'Please configure $fields in Settings.'; + } + + @override + String get askAiConfirmExecute => 'Confirm before executing'; + + @override + String get askAiCommandInserted => 'Command inserted into terminal'; + + @override + String get askAiAwaitingResponse => 'Waiting for AI response...'; + + @override + String get askAiNoResponse => 'No response'; + + @override + String get askAiRecommendedCommand => 'AI suggested command'; + + @override + String get askAiInsertTerminal => 'Insert into terminal'; + + @override + String get askAiSelectedContent => 'Selected content'; + + @override + String get askAiConversation => 'AI conversation'; + + @override + String get askAiFollowUpHint => 'Ask a follow-up...'; + + @override + String get askAiSend => 'Send'; } diff --git a/lib/generated/l10n/l10n_es.dart b/lib/generated/l10n/l10n_es.dart index 93e16444..f08762c6 100644 --- a/lib/generated/l10n/l10n_es.dart +++ b/lib/generated/l10n/l10n_es.dart @@ -925,4 +925,54 @@ class AppLocalizationsEs extends AppLocalizations { @override String get writeScriptTip => 'Después de conectarse al servidor, se escribirá un script en `~/.config/server_box` \n | `/tmp/server_box` para monitorear el estado del sistema. Puedes revisar el contenido del script.'; + + @override + String get askAi => 'Preguntar a la IA'; + + @override + String get askAiUsageHint => 'Usado en el terminal SSH'; + + @override + String get askAiBaseUrl => 'URL base'; + + @override + String get askAiModel => 'Modelo'; + + @override + String get askAiApiKey => 'Clave API'; + + @override + String askAiConfigMissing(String fields) { + return 'Configura $fields en Ajustes.'; + } + + @override + String get askAiConfirmExecute => 'Confirmar antes de ejecutar'; + + @override + String get askAiCommandInserted => 'Comando insertado en el terminal'; + + @override + String get askAiAwaitingResponse => 'Esperando la respuesta de la IA...'; + + @override + String get askAiNoResponse => 'Sin respuesta'; + + @override + String get askAiRecommendedCommand => 'Comando sugerido por la IA'; + + @override + String get askAiInsertTerminal => 'Insertar en el terminal'; + + @override + String get askAiSelectedContent => 'Contenido seleccionado'; + + @override + String get askAiConversation => 'Conversación con la IA'; + + @override + String get askAiFollowUpHint => 'Haz una pregunta adicional...'; + + @override + String get askAiSend => 'Enviar'; } diff --git a/lib/generated/l10n/l10n_fr.dart b/lib/generated/l10n/l10n_fr.dart index 164bbfb2..d3844528 100644 --- a/lib/generated/l10n/l10n_fr.dart +++ b/lib/generated/l10n/l10n_fr.dart @@ -928,4 +928,54 @@ class AppLocalizationsFr extends AppLocalizations { @override String get writeScriptTip => 'Après la connexion au serveur, un script sera écrit dans `~/.config/server_box` \n | `/tmp/server_box` pour surveiller l\'état du système. Vous pouvez examiner le contenu du script.'; + + @override + String get askAi => 'Demander à l\'IA'; + + @override + String get askAiUsageHint => 'Utilisé dans le terminal SSH'; + + @override + String get askAiBaseUrl => 'URL de base'; + + @override + String get askAiModel => 'Modèle'; + + @override + String get askAiApiKey => 'Clé API'; + + @override + String askAiConfigMissing(String fields) { + return 'Veuillez configurer $fields dans les paramètres.'; + } + + @override + String get askAiConfirmExecute => 'Confirmer avant d\'exécuter'; + + @override + String get askAiCommandInserted => 'Commande insérée dans le terminal'; + + @override + String get askAiAwaitingResponse => 'En attente de la réponse de l\'IA...'; + + @override + String get askAiNoResponse => 'Aucune réponse'; + + @override + String get askAiRecommendedCommand => 'Commande suggérée par l\'IA'; + + @override + String get askAiInsertTerminal => 'Insérer dans le terminal'; + + @override + String get askAiSelectedContent => 'Contenu sélectionné'; + + @override + String get askAiConversation => 'Conversation avec l\'IA'; + + @override + String get askAiFollowUpHint => 'Poser une question supplémentaire...'; + + @override + String get askAiSend => 'Envoyer'; } diff --git a/lib/generated/l10n/l10n_id.dart b/lib/generated/l10n/l10n_id.dart index ad683038..49de95e6 100644 --- a/lib/generated/l10n/l10n_id.dart +++ b/lib/generated/l10n/l10n_id.dart @@ -915,4 +915,54 @@ class AppLocalizationsId extends AppLocalizations { @override String get writeScriptTip => 'Setelah terhubung ke server, sebuah skrip akan ditulis ke `~/.config/server_box` \n | `/tmp/server_box` untuk memantau status sistem. Anda dapat meninjau konten skrip tersebut.'; + + @override + String get askAi => 'Tanya AI'; + + @override + String get askAiUsageHint => 'Digunakan di Terminal SSH'; + + @override + String get askAiBaseUrl => 'URL dasar'; + + @override + String get askAiModel => 'Model'; + + @override + String get askAiApiKey => 'Kunci API'; + + @override + String askAiConfigMissing(String fields) { + return 'Harap konfigurasikan $fields di Pengaturan.'; + } + + @override + String get askAiConfirmExecute => 'Konfirmasi sebelum menjalankan'; + + @override + String get askAiCommandInserted => 'Perintah dimasukkan ke terminal'; + + @override + String get askAiAwaitingResponse => 'Menunggu respons AI...'; + + @override + String get askAiNoResponse => 'Tidak ada respons'; + + @override + String get askAiRecommendedCommand => 'Perintah yang disarankan AI'; + + @override + String get askAiInsertTerminal => 'Masukkan ke terminal'; + + @override + String get askAiSelectedContent => 'Konten yang dipilih'; + + @override + String get askAiConversation => 'Percakapan AI'; + + @override + String get askAiFollowUpHint => 'Ajukan pertanyaan lanjutan...'; + + @override + String get askAiSend => 'Kirim'; } diff --git a/lib/generated/l10n/l10n_ja.dart b/lib/generated/l10n/l10n_ja.dart index 7b080d05..9056b963 100644 --- a/lib/generated/l10n/l10n_ja.dart +++ b/lib/generated/l10n/l10n_ja.dart @@ -885,4 +885,54 @@ class AppLocalizationsJa extends AppLocalizations { @override String get writeScriptTip => 'サーバーに接続すると、システムの状態を監視するためのスクリプトが `~/.config/server_box` \n | `/tmp/server_box` に書き込まれます。スクリプトの内容を確認できます。'; + + @override + String get askAi => 'AI に質問'; + + @override + String get askAiUsageHint => 'SSH ターミナルで使用'; + + @override + String get askAiBaseUrl => 'ベース URL'; + + @override + String get askAiModel => 'モデル'; + + @override + String get askAiApiKey => 'API キー'; + + @override + String askAiConfigMissing(String fields) { + return '設定で $fields を構成してください。'; + } + + @override + String get askAiConfirmExecute => '実行前に確認'; + + @override + String get askAiCommandInserted => 'コマンドをターミナルに挿入しました'; + + @override + String get askAiAwaitingResponse => 'AI の応答を待機中...'; + + @override + String get askAiNoResponse => '応答なし'; + + @override + String get askAiRecommendedCommand => 'AI 推奨コマンド'; + + @override + String get askAiInsertTerminal => 'ターミナルに挿入'; + + @override + String get askAiSelectedContent => '選択した内容'; + + @override + String get askAiConversation => 'AI 会話'; + + @override + String get askAiFollowUpHint => '追質問をする...'; + + @override + String get askAiSend => '送信'; } diff --git a/lib/generated/l10n/l10n_nl.dart b/lib/generated/l10n/l10n_nl.dart index 7ea75f2e..40b5af69 100644 --- a/lib/generated/l10n/l10n_nl.dart +++ b/lib/generated/l10n/l10n_nl.dart @@ -922,4 +922,54 @@ class AppLocalizationsNl extends AppLocalizations { @override String get writeScriptTip => 'Na het verbinden met de server wordt een script geschreven naar `~/.config/server_box` \n | `/tmp/server_box` om de systeemstatus te monitoren. U kunt de inhoud van het script controleren.'; + + @override + String get askAi => 'AI vragen'; + + @override + String get askAiUsageHint => 'Gebruikt in de SSH-terminal'; + + @override + String get askAiBaseUrl => 'Basis-URL'; + + @override + String get askAiModel => 'Model'; + + @override + String get askAiApiKey => 'API-sleutel'; + + @override + String askAiConfigMissing(String fields) { + return 'Configureer $fields in de instellingen.'; + } + + @override + String get askAiConfirmExecute => 'Bevestigen voor uitvoeren'; + + @override + String get askAiCommandInserted => 'Commando in terminal ingevoegd'; + + @override + String get askAiAwaitingResponse => 'Wachten op AI-reactie...'; + + @override + String get askAiNoResponse => 'Geen reactie'; + + @override + String get askAiRecommendedCommand => 'Door AI voorgestelde opdracht'; + + @override + String get askAiInsertTerminal => 'In terminal invoegen'; + + @override + String get askAiSelectedContent => 'Geselecteerde inhoud'; + + @override + String get askAiConversation => 'AI-gesprek'; + + @override + String get askAiFollowUpHint => 'Stel een vervolgvraag...'; + + @override + String get askAiSend => 'Verzenden'; } diff --git a/lib/generated/l10n/l10n_pt.dart b/lib/generated/l10n/l10n_pt.dart index cc5557b3..6c7ccf96 100644 --- a/lib/generated/l10n/l10n_pt.dart +++ b/lib/generated/l10n/l10n_pt.dart @@ -917,4 +917,54 @@ class AppLocalizationsPt extends AppLocalizations { @override String get writeScriptTip => 'Após conectar ao servidor, um script será escrito em `~/.config/server_box` \n | `/tmp/server_box` para monitorar o status do sistema. Você pode revisar o conteúdo do script.'; + + @override + String get askAi => 'Perguntar à IA'; + + @override + String get askAiUsageHint => 'Usado no terminal SSH'; + + @override + String get askAiBaseUrl => 'URL base'; + + @override + String get askAiModel => 'Modelo'; + + @override + String get askAiApiKey => 'Chave de API'; + + @override + String askAiConfigMissing(String fields) { + return 'Configure $fields nas configurações.'; + } + + @override + String get askAiConfirmExecute => 'Confirmar antes de executar'; + + @override + String get askAiCommandInserted => 'Comando inserido no terminal'; + + @override + String get askAiAwaitingResponse => 'Aguardando resposta da IA...'; + + @override + String get askAiNoResponse => 'Sem resposta'; + + @override + String get askAiRecommendedCommand => 'Comando sugerido pela IA'; + + @override + String get askAiInsertTerminal => 'Inserir no terminal'; + + @override + String get askAiSelectedContent => 'Conteúdo selecionado'; + + @override + String get askAiConversation => 'Conversa com a IA'; + + @override + String get askAiFollowUpHint => 'Faça uma pergunta adicional...'; + + @override + String get askAiSend => 'Enviar'; } diff --git a/lib/generated/l10n/l10n_ru.dart b/lib/generated/l10n/l10n_ru.dart index 10eaafa9..c6d82445 100644 --- a/lib/generated/l10n/l10n_ru.dart +++ b/lib/generated/l10n/l10n_ru.dart @@ -920,4 +920,54 @@ class AppLocalizationsRu extends AppLocalizations { @override String get writeScriptTip => 'После подключения к серверу скрипт будет записан в `~/.config/server_box` \n | `/tmp/server_box` для мониторинга состояния системы. Вы можете проверить содержимое скрипта.'; + + @override + String get askAi => 'Спросить ИИ'; + + @override + String get askAiUsageHint => 'Используется в SSH-терминале'; + + @override + String get askAiBaseUrl => 'Базовый URL'; + + @override + String get askAiModel => 'Модель'; + + @override + String get askAiApiKey => 'Ключ API'; + + @override + String askAiConfigMissing(String fields) { + return 'Настройте $fields в настройках.'; + } + + @override + String get askAiConfirmExecute => 'Подтвердите перед выполнением'; + + @override + String get askAiCommandInserted => 'Команда вставлена в терминал'; + + @override + String get askAiAwaitingResponse => 'Ожидание ответа ИИ...'; + + @override + String get askAiNoResponse => 'Нет ответа'; + + @override + String get askAiRecommendedCommand => 'Команда, предложенная ИИ'; + + @override + String get askAiInsertTerminal => 'Вставить в терминал'; + + @override + String get askAiSelectedContent => 'Выбранное содержимое'; + + @override + String get askAiConversation => 'Разговор с ИИ'; + + @override + String get askAiFollowUpHint => 'Задайте дополнительный вопрос...'; + + @override + String get askAiSend => 'Отправить'; } diff --git a/lib/generated/l10n/l10n_tr.dart b/lib/generated/l10n/l10n_tr.dart index 93faa902..98eddf31 100644 --- a/lib/generated/l10n/l10n_tr.dart +++ b/lib/generated/l10n/l10n_tr.dart @@ -915,4 +915,54 @@ class AppLocalizationsTr extends AppLocalizations { @override String get writeScriptTip => 'Sunucuya bağlandıktan sonra, sistem durumunu izlemek için `~/.config/server_box` \n | `/tmp/server_box` dizinine bir betik yazılacak. Betik içeriğini inceleyebilirsiniz.'; + + @override + String get askAi => 'Yapay zekaya sor'; + + @override + String get askAiUsageHint => 'SSH Terminalinde kullanılır'; + + @override + String get askAiBaseUrl => 'Temel URL'; + + @override + String get askAiModel => 'Model'; + + @override + String get askAiApiKey => 'API anahtarı'; + + @override + String askAiConfigMissing(String fields) { + return 'Lütfen Ayarlar\'da $fields öğesini yapılandırın.'; + } + + @override + String get askAiConfirmExecute => 'Çalıştırmadan önce onayla'; + + @override + String get askAiCommandInserted => 'Komut terminale eklendi'; + + @override + String get askAiAwaitingResponse => 'Yapay zekâ yanıtı bekleniyor...'; + + @override + String get askAiNoResponse => 'Yanıt yok'; + + @override + String get askAiRecommendedCommand => 'YZ önerilen komut'; + + @override + String get askAiInsertTerminal => 'Terminale ekle'; + + @override + String get askAiSelectedContent => 'Seçilen içerik'; + + @override + String get askAiConversation => 'YZ sohbeti'; + + @override + String get askAiFollowUpHint => 'Yeni bir soru sor...'; + + @override + String get askAiSend => 'Gönder'; } diff --git a/lib/generated/l10n/l10n_uk.dart b/lib/generated/l10n/l10n_uk.dart index 676f0472..268c11e5 100644 --- a/lib/generated/l10n/l10n_uk.dart +++ b/lib/generated/l10n/l10n_uk.dart @@ -921,4 +921,54 @@ class AppLocalizationsUk extends AppLocalizations { @override String get writeScriptTip => 'Після підключення до сервера скрипт буде записано у `~/.config/server_box` \n | `/tmp/server_box` для моніторингу стану системи. Ви можете переглянути вміст скрипта.'; + + @override + String get askAi => 'Запитати ШІ'; + + @override + String get askAiUsageHint => 'Використовується в SSH-терміналі'; + + @override + String get askAiBaseUrl => 'Базова URL'; + + @override + String get askAiModel => 'Модель'; + + @override + String get askAiApiKey => 'Ключ API'; + + @override + String askAiConfigMissing(String fields) { + return 'Налаштуйте $fields у налаштуваннях.'; + } + + @override + String get askAiConfirmExecute => 'Підтвердити перед виконанням'; + + @override + String get askAiCommandInserted => 'Команду вставлено в термінал'; + + @override + String get askAiAwaitingResponse => 'Очікування відповіді ШІ...'; + + @override + String get askAiNoResponse => 'Відповідь відсутня'; + + @override + String get askAiRecommendedCommand => 'Команда, запропонована ШІ'; + + @override + String get askAiInsertTerminal => 'Вставити в термінал'; + + @override + String get askAiSelectedContent => 'Вибраний вміст'; + + @override + String get askAiConversation => 'Розмова з ШІ'; + + @override + String get askAiFollowUpHint => 'Поставте додаткове запитання...'; + + @override + String get askAiSend => 'Надіслати'; } diff --git a/lib/generated/l10n/l10n_zh.dart b/lib/generated/l10n/l10n_zh.dart index 3030fa40..b8b30a35 100644 --- a/lib/generated/l10n/l10n_zh.dart +++ b/lib/generated/l10n/l10n_zh.dart @@ -870,6 +870,56 @@ class AppLocalizationsZh extends AppLocalizations { @override String get writeScriptTip => '在连接服务器后,会向 `~/.config/server_box` \n | `/tmp/server_box` 写入脚本来监测系统状态,你可以审查脚本内容。'; + + @override + String get askAi => '问 AI'; + + @override + String get askAiUsageHint => '用于 SSH 终端'; + + @override + String get askAiBaseUrl => '基础 URL'; + + @override + String get askAiModel => '模型'; + + @override + String get askAiApiKey => 'API 密钥'; + + @override + String askAiConfigMissing(String fields) { + return '请前往设置配置 $fields'; + } + + @override + String get askAiConfirmExecute => '执行前确认'; + + @override + String get askAiCommandInserted => '命令已插入终端'; + + @override + String get askAiAwaitingResponse => '等待 AI 响应...'; + + @override + String get askAiNoResponse => '无回复内容'; + + @override + String get askAiRecommendedCommand => 'AI 推荐命令'; + + @override + String get askAiInsertTerminal => '插入终端'; + + @override + String get askAiSelectedContent => '选中的内容'; + + @override + String get askAiConversation => 'AI 对话'; + + @override + String get askAiFollowUpHint => '继续提问...'; + + @override + String get askAiSend => '发送'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -1738,4 +1788,54 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get writeScriptTip => '連線到伺服器後,將會在 `~/.config/server_box` \n | `/tmp/server_box` 中寫入一個腳本來監測系統狀態。你可以審查腳本內容。'; + + @override + String get askAi => '詢問 AI'; + + @override + String get askAiUsageHint => '於 SSH 終端機中使用'; + + @override + String get askAiBaseUrl => '基礎 URL'; + + @override + String get askAiModel => '模型'; + + @override + String get askAiApiKey => 'API 金鑰'; + + @override + String askAiConfigMissing(String fields) { + return '請前往設定配置 $fields'; + } + + @override + String get askAiConfirmExecute => '執行前確認'; + + @override + String get askAiCommandInserted => '指令已插入終端機'; + + @override + String get askAiAwaitingResponse => '等待 AI 回應...'; + + @override + String get askAiNoResponse => '無回覆內容'; + + @override + String get askAiRecommendedCommand => 'AI 推薦指令'; + + @override + String get askAiInsertTerminal => '插入終端機'; + + @override + String get askAiSelectedContent => '選取的內容'; + + @override + String get askAiConversation => 'AI 對話'; + + @override + String get askAiFollowUpHint => '繼續提問...'; + + @override + String get askAiSend => '傳送'; } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 580d5b29..eefd9b81 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -284,5 +284,21 @@ "wolTip": "Nach der Konfiguration von WOL (Wake-on-LAN) wird jedes Mal, wenn der Server verbunden wird, eine WOL-Anfrage gesendet.", "write": "Schreiben", "writeScriptFailTip": "Das Schreiben des Skripts ist fehlgeschlagen, möglicherweise aufgrund fehlender Berechtigungen oder das Verzeichnis existiert nicht.", - "writeScriptTip": "Nach der Verbindung mit dem Server wird ein Skript in `~/.config/server_box` \n | `/tmp/server_box` geschrieben, um den Systemstatus zu überwachen. Sie können den Skriptinhalt überprüfen." -} \ No newline at end of file + "writeScriptTip": "Nach der Verbindung mit dem Server wird ein Skript in `~/.config/server_box` \n | `/tmp/server_box` geschrieben, um den Systemstatus zu überwachen. Sie können den Skriptinhalt überprüfen.", + "askAi": "KI fragen", + "askAiUsageHint": "Verwendet im SSH-Terminal", + "askAiBaseUrl": "Basis-URL", + "askAiModel": "Modell", + "askAiApiKey": "API-Schlüssel", + "askAiConfigMissing": "Bitte konfigurieren Sie {fields} in den Einstellungen.", + "askAiConfirmExecute": "Vor Ausführung bestätigen", + "askAiCommandInserted": "Befehl ins Terminal eingefügt", + "askAiAwaitingResponse": "Warte auf KI-Antwort...", + "askAiNoResponse": "Keine Antwort", + "askAiRecommendedCommand": "KI-empfohlener Befehl", + "askAiInsertTerminal": "In Terminal einfügen", + "askAiSelectedContent": "Ausgewählter Inhalt", + "askAiConversation": "KI-Unterhaltung", + "askAiFollowUpHint": "Weitere Frage stellen...", + "askAiSend": "Senden" +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 96c178dc..006dd57d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -284,5 +284,28 @@ "wolTip": "After configuring WOL (Wake-on-LAN), a WOL request is sent each time the server is connected.", "write": "Write", "writeScriptFailTip": "Writing to the script failed, possibly due to lack of permissions or the directory does not exist.", - "writeScriptTip": "After connecting to the server, a script will be written to `~/.config/server_box` \n | `/tmp/server_box` to monitor the system status. You can review the script content." -} \ No newline at end of file + "writeScriptTip": "After connecting to the server, a script will be written to `~/.config/server_box` \n | `/tmp/server_box` to monitor the system status. You can review the script content.", + "@askAiConfigMissing": { + "placeholders": { + "fields": { + "type": "String" + } + } + }, + "askAi": "Ask AI", + "askAiUsageHint": "Used in SSH Terminal", + "askAiBaseUrl": "Base URL", + "askAiModel": "Model", + "askAiApiKey": "API Key", + "askAiConfigMissing": "Please configure {fields} in Settings.", + "askAiConfirmExecute": "Confirm before executing", + "askAiCommandInserted": "Command inserted into terminal", + "askAiAwaitingResponse": "Waiting for AI response...", + "askAiNoResponse": "No response", + "askAiRecommendedCommand": "AI suggested command", + "askAiInsertTerminal": "Insert into terminal", + "askAiSelectedContent": "Selected content", + "askAiConversation": "AI conversation", + "askAiFollowUpHint": "Ask a follow-up...", + "askAiSend": "Send" +} diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 994323e8..49d7e307 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -284,5 +284,21 @@ "wolTip": "Después de configurar WOL (Wake-on-LAN), se envía una solicitud de WOL cada vez que se conecta el servidor.", "write": "Escribir", "writeScriptFailTip": "La escritura en el script falló, posiblemente por falta de permisos o porque el directorio no existe.", - "writeScriptTip": "Después de conectarse al servidor, se escribirá un script en `~/.config/server_box` \n | `/tmp/server_box` para monitorear el estado del sistema. Puedes revisar el contenido del script." -} \ No newline at end of file + "writeScriptTip": "Después de conectarse al servidor, se escribirá un script en `~/.config/server_box` \n | `/tmp/server_box` para monitorear el estado del sistema. Puedes revisar el contenido del script.", + "askAi": "Preguntar a la IA", + "askAiUsageHint": "Usado en el terminal SSH", + "askAiBaseUrl": "URL base", + "askAiModel": "Modelo", + "askAiApiKey": "Clave API", + "askAiConfigMissing": "Configura {fields} en Ajustes.", + "askAiConfirmExecute": "Confirmar antes de ejecutar", + "askAiCommandInserted": "Comando insertado en el terminal", + "askAiAwaitingResponse": "Esperando la respuesta de la IA...", + "askAiNoResponse": "Sin respuesta", + "askAiRecommendedCommand": "Comando sugerido por la IA", + "askAiInsertTerminal": "Insertar en el terminal", + "askAiSelectedContent": "Contenido seleccionado", + "askAiConversation": "Conversación con la IA", + "askAiFollowUpHint": "Haz una pregunta adicional...", + "askAiSend": "Enviar" +} diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6736c1f3..01bdcd27 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -284,5 +284,21 @@ "wolTip": "Après avoir configuré le WOL (Wake-on-LAN), une requête WOL est envoyée chaque fois que le serveur est connecté.", "write": "Écrire", "writeScriptFailTip": "Échec de l'écriture dans le script, probablement en raison d'un manque de permissions ou que le répertoire n'existe pas.", - "writeScriptTip": "Après la connexion au serveur, un script sera écrit dans `~/.config/server_box` \n | `/tmp/server_box` pour surveiller l'état du système. Vous pouvez examiner le contenu du script." -} \ No newline at end of file + "writeScriptTip": "Après la connexion au serveur, un script sera écrit dans `~/.config/server_box` \n | `/tmp/server_box` pour surveiller l'état du système. Vous pouvez examiner le contenu du script.", + "askAi": "Demander à l'IA", + "askAiUsageHint": "Utilisé dans le terminal SSH", + "askAiBaseUrl": "URL de base", + "askAiModel": "Modèle", + "askAiApiKey": "Clé API", + "askAiConfigMissing": "Veuillez configurer {fields} dans les paramètres.", + "askAiConfirmExecute": "Confirmer avant d'exécuter", + "askAiCommandInserted": "Commande insérée dans le terminal", + "askAiAwaitingResponse": "En attente de la réponse de l'IA...", + "askAiNoResponse": "Aucune réponse", + "askAiRecommendedCommand": "Commande suggérée par l'IA", + "askAiInsertTerminal": "Insérer dans le terminal", + "askAiSelectedContent": "Contenu sélectionné", + "askAiConversation": "Conversation avec l'IA", + "askAiFollowUpHint": "Poser une question supplémentaire...", + "askAiSend": "Envoyer" +} diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index ca370600..ee8e537b 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -284,5 +284,21 @@ "wolTip": "Setelah mengonfigurasi WOL (Wake-on-LAN), permintaan WOL dikirim setiap kali server terhubung.", "write": "Tulis", "writeScriptFailTip": "Penulisan ke skrip gagal, mungkin karena tidak ada izin atau direktori tidak ada.", - "writeScriptTip": "Setelah terhubung ke server, sebuah skrip akan ditulis ke `~/.config/server_box` \n | `/tmp/server_box` untuk memantau status sistem. Anda dapat meninjau konten skrip tersebut." -} \ No newline at end of file + "writeScriptTip": "Setelah terhubung ke server, sebuah skrip akan ditulis ke `~/.config/server_box` \n | `/tmp/server_box` untuk memantau status sistem. Anda dapat meninjau konten skrip tersebut.", + "askAi": "Tanya AI", + "askAiUsageHint": "Digunakan di Terminal SSH", + "askAiBaseUrl": "URL dasar", + "askAiModel": "Model", + "askAiApiKey": "Kunci API", + "askAiConfigMissing": "Harap konfigurasikan {fields} di Pengaturan.", + "askAiConfirmExecute": "Konfirmasi sebelum menjalankan", + "askAiCommandInserted": "Perintah dimasukkan ke terminal", + "askAiAwaitingResponse": "Menunggu respons AI...", + "askAiNoResponse": "Tidak ada respons", + "askAiRecommendedCommand": "Perintah yang disarankan AI", + "askAiInsertTerminal": "Masukkan ke terminal", + "askAiSelectedContent": "Konten yang dipilih", + "askAiConversation": "Percakapan AI", + "askAiFollowUpHint": "Ajukan pertanyaan lanjutan...", + "askAiSend": "Kirim" +} diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index e3715c66..70555942 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -284,5 +284,21 @@ "wolTip": "WOL(Wake-on-LAN)を設定した後、サーバーに接続するたびにWOLリクエストが送信されます。", "write": "書き込み", "writeScriptFailTip": "スクリプトの書き込みに失敗しました。権限がないかディレクトリが存在しない可能性があります。", - "writeScriptTip": "サーバーに接続すると、システムの状態を監視するためのスクリプトが `~/.config/server_box` \n | `/tmp/server_box` に書き込まれます。スクリプトの内容を確認できます。" -} \ No newline at end of file + "writeScriptTip": "サーバーに接続すると、システムの状態を監視するためのスクリプトが `~/.config/server_box` \n | `/tmp/server_box` に書き込まれます。スクリプトの内容を確認できます。", + "askAi": "AI に質問", + "askAiUsageHint": "SSH ターミナルで使用", + "askAiBaseUrl": "ベース URL", + "askAiModel": "モデル", + "askAiApiKey": "API キー", + "askAiConfigMissing": "設定で {fields} を構成してください。", + "askAiConfirmExecute": "実行前に確認", + "askAiCommandInserted": "コマンドをターミナルに挿入しました", + "askAiAwaitingResponse": "AI の応答を待機中...", + "askAiNoResponse": "応答なし", + "askAiRecommendedCommand": "AI 推奨コマンド", + "askAiInsertTerminal": "ターミナルに挿入", + "askAiSelectedContent": "選択した内容", + "askAiConversation": "AI 会話", + "askAiFollowUpHint": "追質問をする...", + "askAiSend": "送信" +} diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 1a8246c7..a511e5f5 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -284,5 +284,21 @@ "wolTip": "Na het configureren van WOL (Wake-on-LAN), wordt elke keer dat de server wordt verbonden een WOL-verzoek verzonden.", "write": "Schrijven", "writeScriptFailTip": "Het schrijven naar het script is mislukt, mogelijk door gebrek aan rechten of omdat de map niet bestaat.", - "writeScriptTip": "Na het verbinden met de server wordt een script geschreven naar `~/.config/server_box` \n | `/tmp/server_box` om de systeemstatus te monitoren. U kunt de inhoud van het script controleren." -} \ No newline at end of file + "writeScriptTip": "Na het verbinden met de server wordt een script geschreven naar `~/.config/server_box` \n | `/tmp/server_box` om de systeemstatus te monitoren. U kunt de inhoud van het script controleren.", + "askAi": "AI vragen", + "askAiUsageHint": "Gebruikt in de SSH-terminal", + "askAiBaseUrl": "Basis-URL", + "askAiModel": "Model", + "askAiApiKey": "API-sleutel", + "askAiConfigMissing": "Configureer {fields} in de instellingen.", + "askAiConfirmExecute": "Bevestigen voor uitvoeren", + "askAiCommandInserted": "Commando in terminal ingevoegd", + "askAiAwaitingResponse": "Wachten op AI-reactie...", + "askAiNoResponse": "Geen reactie", + "askAiRecommendedCommand": "Door AI voorgestelde opdracht", + "askAiInsertTerminal": "In terminal invoegen", + "askAiSelectedContent": "Geselecteerde inhoud", + "askAiConversation": "AI-gesprek", + "askAiFollowUpHint": "Stel een vervolgvraag...", + "askAiSend": "Verzenden" +} diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index cdb3340c..15dacb3e 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -284,5 +284,21 @@ "wolTip": "Após configurar o WOL (Wake-on-LAN), um pedido de WOL é enviado cada vez que o servidor é conectado.", "write": "Escrita", "writeScriptFailTip": "Falha ao escrever no script, possivelmente devido à falta de permissões ou o diretório não existe.", - "writeScriptTip": "Após conectar ao servidor, um script será escrito em `~/.config/server_box` \n | `/tmp/server_box` para monitorar o status do sistema. Você pode revisar o conteúdo do script." -} \ No newline at end of file + "writeScriptTip": "Após conectar ao servidor, um script será escrito em `~/.config/server_box` \n | `/tmp/server_box` para monitorar o status do sistema. Você pode revisar o conteúdo do script.", + "askAi": "Perguntar à IA", + "askAiUsageHint": "Usado no terminal SSH", + "askAiBaseUrl": "URL base", + "askAiModel": "Modelo", + "askAiApiKey": "Chave de API", + "askAiConfigMissing": "Configure {fields} nas configurações.", + "askAiConfirmExecute": "Confirmar antes de executar", + "askAiCommandInserted": "Comando inserido no terminal", + "askAiAwaitingResponse": "Aguardando resposta da IA...", + "askAiNoResponse": "Sem resposta", + "askAiRecommendedCommand": "Comando sugerido pela IA", + "askAiInsertTerminal": "Inserir no terminal", + "askAiSelectedContent": "Conteúdo selecionado", + "askAiConversation": "Conversa com a IA", + "askAiFollowUpHint": "Faça uma pergunta adicional...", + "askAiSend": "Enviar" +} diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index babe016c..38802428 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -284,5 +284,21 @@ "wolTip": "После настройки WOL (Wake-on-LAN) при каждом подключении к серверу отправляется запрос WOL.", "write": "Запись", "writeScriptFailTip": "Запись скрипта не удалась, возможно, из-за отсутствия прав или потому что, директории не существует.", - "writeScriptTip": "После подключения к серверу скрипт будет записан в `~/.config/server_box` \n | `/tmp/server_box` для мониторинга состояния системы. Вы можете проверить содержимое скрипта." -} \ No newline at end of file + "writeScriptTip": "После подключения к серверу скрипт будет записан в `~/.config/server_box` \n | `/tmp/server_box` для мониторинга состояния системы. Вы можете проверить содержимое скрипта.", + "askAi": "Спросить ИИ", + "askAiUsageHint": "Используется в SSH-терминале", + "askAiBaseUrl": "Базовый URL", + "askAiModel": "Модель", + "askAiApiKey": "Ключ API", + "askAiConfigMissing": "Настройте {fields} в настройках.", + "askAiConfirmExecute": "Подтвердите перед выполнением", + "askAiCommandInserted": "Команда вставлена в терминал", + "askAiAwaitingResponse": "Ожидание ответа ИИ...", + "askAiNoResponse": "Нет ответа", + "askAiRecommendedCommand": "Команда, предложенная ИИ", + "askAiInsertTerminal": "Вставить в терминал", + "askAiSelectedContent": "Выбранное содержимое", + "askAiConversation": "Разговор с ИИ", + "askAiFollowUpHint": "Задайте дополнительный вопрос...", + "askAiSend": "Отправить" +} diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 845fb7c6..6bcd29ba 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -284,5 +284,21 @@ "wolTip": "WOL (Wake-on-LAN) yapılandırıldıktan sonra, sunucuya her bağlanıldığında bir WOL isteği gönderilir.", "write": "Yaz", "writeScriptFailTip": "Betik yazma başarısız oldu, muhtemelen izin eksikliği veya dizin mevcut değil.", - "writeScriptTip": "Sunucuya bağlandıktan sonra, sistem durumunu izlemek için `~/.config/server_box` \n | `/tmp/server_box` dizinine bir betik yazılacak. Betik içeriğini inceleyebilirsiniz." -} \ No newline at end of file + "writeScriptTip": "Sunucuya bağlandıktan sonra, sistem durumunu izlemek için `~/.config/server_box` \n | `/tmp/server_box` dizinine bir betik yazılacak. Betik içeriğini inceleyebilirsiniz.", + "askAi": "Yapay zekaya sor", + "askAiUsageHint": "SSH Terminalinde kullanılır", + "askAiBaseUrl": "Temel URL", + "askAiModel": "Model", + "askAiApiKey": "API anahtarı", + "askAiConfigMissing": "Lütfen Ayarlar'da {fields} öğesini yapılandırın.", + "askAiConfirmExecute": "Çalıştırmadan önce onayla", + "askAiCommandInserted": "Komut terminale eklendi", + "askAiAwaitingResponse": "Yapay zekâ yanıtı bekleniyor...", + "askAiNoResponse": "Yanıt yok", + "askAiRecommendedCommand": "YZ önerilen komut", + "askAiInsertTerminal": "Terminale ekle", + "askAiSelectedContent": "Seçilen içerik", + "askAiConversation": "YZ sohbeti", + "askAiFollowUpHint": "Yeni bir soru sor...", + "askAiSend": "Gönder" +} diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index eca15447..8893f5f9 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -284,5 +284,21 @@ "wolTip": "Після налаштування WOL (Wake-on-LAN), при кожному підключенні до сервера відправляється запит WOL.", "write": "Записати", "writeScriptFailTip": "Запис у скрипт не вдався, можливо, через брак дозволів або каталог не існує.", - "writeScriptTip": "Після підключення до сервера скрипт буде записано у `~/.config/server_box` \n | `/tmp/server_box` для моніторингу стану системи. Ви можете переглянути вміст скрипта." -} \ No newline at end of file + "writeScriptTip": "Після підключення до сервера скрипт буде записано у `~/.config/server_box` \n | `/tmp/server_box` для моніторингу стану системи. Ви можете переглянути вміст скрипта.", + "askAi": "Запитати ШІ", + "askAiUsageHint": "Використовується в SSH-терміналі", + "askAiBaseUrl": "Базова URL", + "askAiModel": "Модель", + "askAiApiKey": "Ключ API", + "askAiConfigMissing": "Налаштуйте {fields} у налаштуваннях.", + "askAiConfirmExecute": "Підтвердити перед виконанням", + "askAiCommandInserted": "Команду вставлено в термінал", + "askAiAwaitingResponse": "Очікування відповіді ШІ...", + "askAiNoResponse": "Відповідь відсутня", + "askAiRecommendedCommand": "Команда, запропонована ШІ", + "askAiInsertTerminal": "Вставити в термінал", + "askAiSelectedContent": "Вибраний вміст", + "askAiConversation": "Розмова з ШІ", + "askAiFollowUpHint": "Поставте додаткове запитання...", + "askAiSend": "Надіслати" +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e5ebc781..dbdfdf4e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -284,5 +284,21 @@ "wolTip": "配置 WOL 后,每次连接服务器时将自动发送唤醒请求", "write": "写", "writeScriptFailTip": "写入脚本失败,可能是没有权限/目录不存在等", - "writeScriptTip": "在连接服务器后,会向 `~/.config/server_box` \n | `/tmp/server_box` 写入脚本来监测系统状态,你可以审查脚本内容。" -} \ No newline at end of file + "writeScriptTip": "在连接服务器后,会向 `~/.config/server_box` \n | `/tmp/server_box` 写入脚本来监测系统状态,你可以审查脚本内容。", + "askAi": "问 AI", + "askAiUsageHint": "用于 SSH 终端", + "askAiBaseUrl": "基础 URL", + "askAiModel": "模型", + "askAiApiKey": "API 密钥", + "askAiConfigMissing": "请前往设置配置 {fields}", + "askAiConfirmExecute": "执行前确认", + "askAiCommandInserted": "命令已插入终端", + "askAiAwaitingResponse": "等待 AI 响应...", + "askAiNoResponse": "无回复内容", + "askAiRecommendedCommand": "AI 推荐命令", + "askAiInsertTerminal": "插入终端", + "askAiSelectedContent": "选中的内容", + "askAiConversation": "AI 对话", + "askAiFollowUpHint": "继续提问...", + "askAiSend": "发送" +} diff --git a/lib/l10n/app_zh_tw.arb b/lib/l10n/app_zh_tw.arb index 764f7fd2..203bb51d 100644 --- a/lib/l10n/app_zh_tw.arb +++ b/lib/l10n/app_zh_tw.arb @@ -284,5 +284,21 @@ "wolTip": "設定 WOL 後,每次連線伺服器時將自動發送喚醒請求", "write": "寫入", "writeScriptFailTip": "寫入腳本失敗,可能是沒有權限/目錄不存在等。", - "writeScriptTip": "連線到伺服器後,將會在 `~/.config/server_box` \n | `/tmp/server_box` 中寫入一個腳本來監測系統狀態。你可以審查腳本內容。" -} \ No newline at end of file + "writeScriptTip": "連線到伺服器後,將會在 `~/.config/server_box` \n | `/tmp/server_box` 中寫入一個腳本來監測系統狀態。你可以審查腳本內容。", + "askAi": "詢問 AI", + "askAiUsageHint": "於 SSH 終端機中使用", + "askAiBaseUrl": "基礎 URL", + "askAiModel": "模型", + "askAiApiKey": "API 金鑰", + "askAiConfigMissing": "請前往設定配置 {fields}", + "askAiConfirmExecute": "執行前確認", + "askAiCommandInserted": "指令已插入終端機", + "askAiAwaitingResponse": "等待 AI 回應...", + "askAiNoResponse": "無回覆內容", + "askAiRecommendedCommand": "AI 推薦指令", + "askAiInsertTerminal": "插入終端機", + "askAiSelectedContent": "選取的內容", + "askAiConversation": "AI 對話", + "askAiFollowUpHint": "繼續提問...", + "askAiSend": "傳送" +} diff --git a/lib/view/page/setting/entries/ai.dart b/lib/view/page/setting/entries/ai.dart new file mode 100644 index 00000000..111a3274 --- /dev/null +++ b/lib/view/page/setting/entries/ai.dart @@ -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 _showAskAiFieldDialog({ + required HiveProp 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)), + ], + ); + }); + } +} diff --git a/lib/view/page/setting/entries/app.dart b/lib/view/page/setting/entries/app.dart index cc940483..7b0099b2 100644 --- a/lib/view/page/setting/entries/app.dart +++ b/lib/view/page/setting/entries/app.dart @@ -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 = [ - /// 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 = [ + /// 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, + ); + }); }, ); } diff --git a/lib/view/page/setting/entries/sftp.dart b/lib/view/page/setting/entries/sftp.dart index 16ce1523..8d01f4a0 100644 --- a/lib/view/page/setting/entries/sftp.dart +++ b/lib/view/page/setting/entries/sftp.dart @@ -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( - 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( + 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, + ); + }); }, ); }); diff --git a/lib/view/page/setting/entries/ssh.dart b/lib/view/page/setting/entries/ssh.dart index 086bb971..64235f13 100644 --- a/lib/view/page/setting/entries/ssh.dart +++ b/lib/view/page/setting/entries/ssh.dart @@ -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( - 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( + 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, + ); + }); }, ); }); diff --git a/lib/view/page/setting/entry.dart b/lib/view/page/setting/entry.dart index 0367fa44..a370f0b7 100644 --- a/lib/view/page/setting/entry.dart +++ b/lib/view/page/setting/entry.dart @@ -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 { 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()], diff --git a/lib/view/page/ssh/page/ask_ai.dart b/lib/view/page/ssh/page/ask_ai.dart new file mode 100644 index 00000000..142156db --- /dev/null +++ b/lib/view/page/ssh/page/ask_ai.dart @@ -0,0 +1,450 @@ +part of 'page.dart'; + +extension _AskAi on SSHPageState { + List _buildTerminalToolbar( + BuildContext context, + CustomTextEditState state, + List defaultItems, + ) { + final rawSelection = _termKey.currentState?.renderTerminal.selectedText; + final selection = rawSelection?.trim(); + if (selection == null || selection.isEmpty) { + return defaultItems; + } + + final items = List.from(defaultItems); + items.add( + ContextMenuButtonItem( + label: context.l10n.askAi, + onPressed: () { + state.hideToolbar(); + _showAskAiSheet(selection); + }, + ), + ); + return items; + } + + Future _showAskAiSheet(String selection) async { + if (!mounted) return; + final localeHint = Localizations.maybeLocaleOf(context)?.toLanguageTag(); + await showModalBottomSheet( + 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 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? _subscription; + final _chatEntries = <_ChatEntry>[]; + final _history = []; + final _scrollController = ScrollController(); + final _inputController = TextEditingController(); + final _seenCommands = {}; + 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.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 _handleApplyCommand(BuildContext context, AskAiCommand command) async { + final confirmed = await context.showRoundDialog( + 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 _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 _buildConversationWidgets(BuildContext context, ThemeData theme) { + final widgets = []; + 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, + ), + ], + ), + ), + ); + } +} diff --git a/lib/view/page/ssh/page/page.dart b/lib/view/page/ssh/page/page.dart index 2db743e4..3878e111 100644 --- a/lib/view/page/ssh/page/page.dart +++ b/lib/view/page/ssh/page/page.dart @@ -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 viewOffset: Offset(2 * _horizonPadding, CustomAppBar.sysStatusBarHeight), hideScrollBar: false, focusNode: widget.args.focusNode, + toolbarBuilder: _buildTerminalToolbar, ), ), ); diff --git a/pubspec.lock b/pubspec.lock index 70bb3dfe..bd59d0b5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -505,8 +505,8 @@ packages: dependency: "direct main" description: path: "." - ref: "v1.0.355" - resolved-ref: "73d5f2603859a9f70459d798ed2d267b1d9a86e5" + ref: "v1.0.358" + resolved-ref: c8e55d054875bb3ccdab9894a01fe82d173dc54e url: "https://github.com/lppcg/fl_lib" source: git version: "0.0.1" @@ -1862,8 +1862,8 @@ packages: dependency: "direct main" description: path: "." - ref: "v4.0.4" - resolved-ref: "5747837cdb7b113ef733ce0104e4f2bfa1eb4a36" + ref: "v4.0.11" + resolved-ref: "74546b4c4e81357448445c2b6f92411e72dbefec" url: "https://github.com/lollipopkit/xterm.dart" source: git version: "4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 94e6e6d6..77b42d51 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,7 +49,7 @@ dependencies: xterm: git: url: https://github.com/lollipopkit/xterm.dart - ref: v4.0.4 + ref: v4.0.11 computer: git: url: https://github.com/lollipopkit/dart_computer @@ -65,7 +65,7 @@ dependencies: fl_lib: git: url: https://github.com/lppcg/fl_lib - ref: v1.0.355 + ref: v1.0.358 dependency_overrides: # webdav_client_plus: