Compare commits

..

1 Commits

Author SHA1 Message Date
lollipopkit🏳️‍⚧️
28ac6145c4 proposal: enhance AI functionalities
Fixes [#1038]
2026-01-30 15:24:56 +08:00
38 changed files with 1418 additions and 1985 deletions

View File

@@ -19,7 +19,6 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: recursive
- uses: subosito/flutter-action@v2
with:
@@ -30,7 +29,7 @@ jobs:
# Consider passing '--fatal-infos' for slightly stricter analysis.
- name: Analyze project source
run: flutter analyze lib test
run: dart analyze
# Your project will need to have tests in test/ and a dependency on
# package:test for this step to succeed. Note that Flutter projects will

20
.gitmodules vendored
View File

@@ -1,20 +0,0 @@
[submodule "dartssh2"]
path = packages/dartssh2
url = https://github.com/lollipopkit/dartssh2
branch = master
[submodule "xterm"]
path = packages/xterm
url = https://github.com/lollipopkit/xterm.dart
branch = master
[submodule "fl_lib"]
path = packages/fl_lib
url = https://github.com/lollipopkit/fl_lib
branch = main
[submodule "fl_build"]
path = packages/fl_build
url = https://github.com/lppcg/fl_build.git
branch = main
[submodule "server_box_monitor"]
path = packages/server_box_monitor
url = https://github.com/lollipopkit/server_box_monitor
branch = main

View File

@@ -17,6 +17,9 @@ PODS:
- FlutterMacOS
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- plain_notification_token (0.0.1):
- Flutter
- share_plus (0.0.1):
@@ -41,6 +44,7 @@ DEPENDENCIES:
- icloud_storage (from `.symlinks/plugins/icloud_storage/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- plain_notification_token (from `.symlinks/plugins/plain_notification_token/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
@@ -67,6 +71,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/local_auth_darwin/darwin"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
plain_notification_token:
:path: ".symlinks/plugins/plain_notification_token/ios"
share_plus:
@@ -90,6 +96,7 @@ SPEC CHECKSUMS:
icloud_storage: e55639f0c0d7cb2b0ba9c0b3d5968ccca9cd9aa2
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
plain_notification_token: 047876b9d80a5b93565ddcc13a487a7e7b906f7d
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb

View File

@@ -748,7 +748,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1316;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
@@ -758,7 +758,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1316;
MARKETING_VERSION = 1.0.1297;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -884,7 +884,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1316;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
@@ -894,7 +894,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1316;
MARKETING_VERSION = 1.0.1297;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -912,7 +912,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = 1316;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist";
@@ -922,7 +922,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1316;
MARKETING_VERSION = 1.0.1297;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -943,7 +943,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1316;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -956,7 +956,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1316;
MARKETING_VERSION = 1.0.1297;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
@@ -982,7 +982,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1316;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -995,7 +995,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1316;
MARKETING_VERSION = 1.0.1297;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1018,7 +1018,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1316;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_TEAM = BA88US33G6;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
@@ -1031,7 +1031,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1316;
MARKETING_VERSION = 1.0.1297;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1054,7 +1054,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1316;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1066,7 +1066,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1316;
MARKETING_VERSION = 1.0.1297;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
@@ -1095,7 +1095,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1316;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1107,7 +1107,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1316;
MARKETING_VERSION = 1.0.1297;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
PRODUCT_NAME = ServerBox;
@@ -1133,7 +1133,7 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1316;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_PREVIEWS = YES;
@@ -1145,7 +1145,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1316;
MARKETING_VERSION = 1.0.1297;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.WatchEnd;
PRODUCT_NAME = ServerBox;

View File

@@ -9,6 +9,7 @@ import 'package:server_box/data/res/build_data.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/generated/l10n/l10n.dart';
import 'package:server_box/view/page/home.dart';
import 'package:server_box/view/widget/ai/ai_fab_overlay.dart';
part 'intro.dart';
@@ -108,7 +109,10 @@ class _MyAppState extends State<MyApp> {
return MaterialApp(
key: ValueKey(locale),
navigatorKey: AppNavigator.key,
builder: ResponsivePoints.builder,
builder: (context, child) {
final responsiveChild = ResponsivePoints.builder(context, child);
return AiFabOverlay(child: responsiveChild);
},
locale: locale,
localizationsDelegates: const [LibLocalizations.delegate, ...AppLocalizations.localizationsDelegates],
supportedLocales: AppLocalizations.supportedLocales,

View File

@@ -30,11 +30,27 @@ class AskAiCommand {
required this.command,
this.description = '',
this.toolName,
this.risk,
this.needsConfirmation,
this.why,
this.prechecks,
});
final String command;
final String description;
final String? toolName;
/// Optional risk hint returned by the model/tool, e.g. `low|medium|high`.
final String? risk;
/// Optional explicit confirmation requirement returned by the model/tool.
final bool? needsConfirmation;
/// Optional explanation for why this command is suggested.
final String? why;
/// Optional pre-check commands / steps.
final List<String>? prechecks;
}
@immutable

View File

@@ -8,7 +8,8 @@ enum ContainerMenu {
restart,
rm,
logs,
terminal
terminal,
askAi
//stats,
;
@@ -20,10 +21,11 @@ enum ContainerMenu {
rm,
logs,
terminal,
askAi,
//stats,
];
}
return [start, rm, logs];
return [start, rm, logs, askAi];
}
IconData get icon => switch (this) {
@@ -33,6 +35,7 @@ enum ContainerMenu {
ContainerMenu.rm => Icons.delete,
ContainerMenu.logs => Icons.logo_dev,
ContainerMenu.terminal => Icons.terminal,
ContainerMenu.askAi => Icons.smart_toy_outlined,
// DockerMenuType.stats => Icons.bar_chart,
};
@@ -43,6 +46,7 @@ enum ContainerMenu {
ContainerMenu.rm => libL10n.delete,
ContainerMenu.logs => libL10n.log,
ContainerMenu.terminal => l10n.terminal,
ContainerMenu.askAi => l10n.askAi,
// DockerMenuType.stats => s.stats,
};
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:meta/meta.dart';
@immutable
class AiContextSnapshot {
const AiContextSnapshot({
required this.title,
required this.scenario,
required this.blocks,
this.spiId,
this.updatedAtMs,
});
final String title;
final String scenario;
final List<String> blocks;
final String? spiId;
final int? updatedAtMs;
AiContextSnapshot copyWith({
String? title,
String? scenario,
List<String>? blocks,
String? spiId,
int? updatedAtMs,
}) {
return AiContextSnapshot(
title: title ?? this.title,
scenario: scenario ?? this.scenario,
blocks: blocks ?? this.blocks,
spiId: spiId ?? this.spiId,
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
);
}
}
final aiContextProvider = NotifierProvider<AiContextNotifier, AiContextSnapshot>(AiContextNotifier.new);
class AiContextNotifier extends Notifier<AiContextSnapshot> {
@override
AiContextSnapshot build() {
return const AiContextSnapshot(
title: 'Ask AI',
scenario: 'general',
blocks: [],
updatedAtMs: 0,
);
}
void setContext({
required String title,
required String scenario,
required List<String> blocks,
String? spiId,
}) {
state = AiContextSnapshot(
title: title,
scenario: scenario,
blocks: blocks,
spiId: spiId,
updatedAtMs: DateTime.now().millisecondsSinceEpoch,
);
}
}

View File

@@ -0,0 +1,186 @@
import 'package:meta/meta.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
@immutable
enum AiRedactionMode {
placeholder,
none,
}
@immutable
enum AiCommandRisk {
low,
medium,
high,
}
extension AiCommandRiskX on AiCommandRisk {
static AiCommandRisk? tryParse(Object? raw) {
if (raw is! String) return null;
final s = raw.trim().toLowerCase();
return switch (s) {
'low' => AiCommandRisk.low,
'medium' => AiCommandRisk.medium,
'high' => AiCommandRisk.high,
_ => null,
};
}
AiCommandRisk max(AiCommandRisk other) => index >= other.index ? this : other;
}
abstract final class AiSafety {
const AiSafety._();
static String redact(
String input, {
AiRedactionMode mode = AiRedactionMode.placeholder,
Spi? spi,
}) {
if (mode == AiRedactionMode.none) return input;
if (input.isEmpty) return input;
var out = input;
out = _redactPrivateKeyBlocks(out);
out = _redactBearerTokens(out);
out = _redactApiKeys(out);
if (spi != null) {
out = _redactSpiIdentity(out, spi);
}
return out;
}
static List<String> redactBlocks(
List<String> blocks, {
AiRedactionMode mode = AiRedactionMode.placeholder,
Spi? spi,
}) {
if (blocks.isEmpty) return const [];
return [
for (final b in blocks) redact(b, mode: mode, spi: spi),
];
}
static AiCommandRisk classifyRisk(String command) {
final raw = command.trim();
if (raw.isEmpty) return AiCommandRisk.low;
final s = raw.toLowerCase();
// High-risk destructive patterns.
if (_rxForkBomb.hasMatch(s)) return AiCommandRisk.high;
if (_rxMkfs.hasMatch(s)) return AiCommandRisk.high;
if (_rxDdToBlockDevice.hasMatch(s)) return AiCommandRisk.high;
if (_rxRmRf.hasMatch(s)) return AiCommandRisk.high;
if (_rxChmodChownRoot.hasMatch(s)) return AiCommandRisk.high;
if (_rxIptablesFlush.hasMatch(s) || _rxNftFlush.hasMatch(s)) return AiCommandRisk.high;
if (_rxDockerSystemPruneAll.hasMatch(s) || _rxPodmanSystemPruneAll.hasMatch(s)) return AiCommandRisk.high;
// Medium-risk operational patterns.
if (_rxRebootShutdown.hasMatch(s)) return AiCommandRisk.medium;
if (_rxSystemctlStopRestart.hasMatch(s)) return AiCommandRisk.medium;
if (_rxKill.hasMatch(s)) return AiCommandRisk.medium;
if (_rxDockerStopRm.hasMatch(s) || _rxPodmanStopRm.hasMatch(s)) return AiCommandRisk.medium;
return AiCommandRisk.low;
}
static String _redactPrivateKeyBlocks(String input) {
return input.replaceAllMapped(_rxPrivateKeyBlock, (_) => '<PRIVATE_KEY_BLOCK>');
}
static String _redactBearerTokens(String input) {
var out = input;
out = out.replaceAllMapped(
_rxAuthorizationBearer,
(m) => '${m.group(1)}Bearer <TOKEN>',
);
out = out.replaceAllMapped(
_rxBearerInline,
(m) => 'Bearer <TOKEN>',
);
return out;
}
static String _redactApiKeys(String input) {
// Keep it conservative; only match common patterns with clear prefixes.
var out = input;
out = out.replaceAllMapped(_rxOpenAiKey, (_) => '<API_KEY>');
out = out.replaceAllMapped(_rxAwsAccessKeyId, (_) => '<AWS_ACCESS_KEY_ID>');
return out;
}
static String _redactSpiIdentity(String input, Spi spi) {
var out = input;
final ip = spi.ip;
final user = spi.user;
final port = spi.port;
if (user.isNotEmpty && ip.isNotEmpty) {
out = out.replaceAll('$user@$ip:$port', '<USER_AT_HOST_PORT>');
out = out.replaceAll('$user@$ip', '<USER_AT_HOST>');
}
if (ip.isNotEmpty) {
out = out.replaceAll(ip, '<IP>');
}
if (user.isNotEmpty) {
out = out.replaceAll(user, '<USER>');
}
return out;
}
}
final _rxPrivateKeyBlock = RegExp(
r'-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----',
multiLine: true,
);
final _rxAuthorizationBearer = RegExp(
r'(authorization\s*:\s*)bearer\s+[^\s\n\r]+',
multiLine: true,
caseSensitive: false,
);
final _rxBearerInline = RegExp(
r'\bbearer\s+[^\s\n\r]+',
caseSensitive: false,
);
final _rxOpenAiKey = RegExp(r'\bsk-[A-Za-z0-9]{16,}\b');
final _rxAwsAccessKeyId = RegExp(r'\bAKIA[0-9A-Z]{16}\b');
final _rxForkBomb = RegExp(r':\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:');
final _rxMkfs = RegExp(r'\bmkfs(\.[a-z0-9_-]+)?\b');
final _rxDdToBlockDevice = RegExp(r'\bdd\b[^\n\r]*\bof\s*=\s*/dev/');
final _rxRmRf = RegExp(r'\brm\b[^\n\r]*\s-[a-z-]*r[a-z-]*f[a-z-]*\b');
final _rxChmodChownRoot = RegExp(r'\b(chmod|chown)\b[^\n\r]*\s-\w*r\w*\b[^\n\r]*\s/\b');
final _rxIptablesFlush = RegExp(r'\biptables\b[^\n\r]*(\s-(f|x)\b|\s--flush\b)');
final _rxNftFlush = RegExp(r'\bnft\b[^\n\r]*\bflush\s+ruleset\b');
final _rxDockerSystemPruneAll = RegExp(r'\bdocker\b[^\n\r]*\bsystem\s+prune\b[^\n\r]*\s-a\b');
final _rxPodmanSystemPruneAll = RegExp(r'\bpodman\b[^\n\r]*\bsystem\s+prune\b[^\n\r]*\s-a\b');
final _rxRebootShutdown = RegExp(r'\b(reboot|poweroff|halt|shutdown)\b');
final _rxSystemctlStopRestart = RegExp(r'\bsystemctl\b[^\n\r]*\b(stop|restart)\b');
final _rxKill = RegExp(r'\b(kill|killall|pkill)\b');
final _rxDockerStopRm = RegExp(r'\bdocker\b[^\n\r]*\b(stop|rm)\b');
final _rxPodmanStopRm = RegExp(r'\bpodman\b[^\n\r]*\b(stop|rm)\b');

View File

@@ -21,7 +21,8 @@ class AskAiRepository {
/// Streams the AI response using the configured endpoint.
Stream<AskAiEvent> ask({
required String selection,
required AskAiScenario scenario,
required List<String> contextBlocks,
String? localeHint,
List<AskAiMessage> conversation = const [],
}) async* {
@@ -54,7 +55,8 @@ class AskAiRepository {
final requestBody = _buildRequestBody(
model: model,
selection: selection,
scenario: scenario,
contextBlocks: contextBlocks,
localeHint: localeHint,
conversation: conversation,
);
@@ -202,21 +204,27 @@ class AskAiRepository {
Map<String, dynamic> _buildRequestBody({
required String model,
required String selection,
required AskAiScenario scenario,
required List<String> contextBlocks,
required List<AskAiMessage> conversation,
String? localeHint,
}) {
final promptBuffer = StringBuffer()
..writeln('你是一个 SSH 终端助手。')
..writeln('用户提供一段终端输出或命令,请结合上下文给出解释')
..writeln('你是 ServerBox 内嵌的服务器运维助手。')
..writeln('你会基于用户提供的上下文进行解释、诊断与建议')
..writeln('默认只建议,不自动执行任何命令。')
..writeln('优先给出安全、可回滚、只读的排查步骤。')
..writeln('当需要给出可执行命令时,调用 `recommend_shell` 工具,并提供简短描述。')
..writeln('仅在非常确定命令安全时才给出建议');
..writeln('不确定时先提出澄清问题');
if (localeHint != null && localeHint.isNotEmpty) {
promptBuffer
.writeln('请优先使用用户的语言输出:$localeHint');
promptBuffer.writeln('请优先使用用户的语言输出:$localeHint');
}
promptBuffer.writeln(_scenarioPrompt(scenario));
final ctx = contextBlocks.isEmpty ? '(empty)' : contextBlocks.join('\n\n---\n\n');
final messages = <Map<String, String>>[
{
'role': 'system',
@@ -228,7 +236,7 @@ class AskAiRepository {
}),
{
'role': 'user',
'content': '以下是终端选中的内容:\n$selection',
'content': '以下是当前页面/会话上下文Markdown blocks\n\n$ctx',
},
];
@@ -254,6 +262,24 @@ class AskAiRepository {
'type': 'string',
'description': '简述该命令的作用或注意事项。',
},
'risk': {
'type': 'string',
'description': '风险等级low/medium/high。',
'enum': ['low', 'medium', 'high'],
},
'needsConfirmation': {
'type': 'boolean',
'description': '是否需要更强确认(例如倒计时确认)。',
},
'why': {
'type': 'string',
'description': '为什么要执行该命令。',
},
'prechecks': {
'type': 'array',
'items': {'type': 'string'},
'description': '建议先执行的只读预检查命令。',
},
},
},
},
@@ -262,6 +288,18 @@ class AskAiRepository {
};
}
static String _scenarioPrompt(AskAiScenario scenario) {
return switch (scenario) {
AskAiScenario.general => '场景:通用。结合上下文回答,必要时给出命令建议。',
AskAiScenario.terminal => '场景SSH 终端。解释输出/错误,给出排查命令与下一步建议。',
AskAiScenario.systemd => '场景Systemd。围绕 unit 状态/日志/依赖给出诊断与建议。',
AskAiScenario.container => '场景:容器。围绕 docker/podman 的容器状态、镜像、日志给建议。',
AskAiScenario.process => '场景进程。围绕进程异常、资源占用、kill/renice 等给建议。',
AskAiScenario.snippet => '场景Snippet。生成或改写脚本强调幂等、安全与可回滚。',
AskAiScenario.sftp => '场景SFTP。围绕路径/权限/压缩包/传输错误等给操作与命令建议。',
};
}
Uri _composeUri(String base, String path) {
final sanitizedBase = base.replaceAll(RegExp(r'/+$'), '');
final sanitizedPath = path.replaceFirst(RegExp(r'^/+'), '');
@@ -269,6 +307,34 @@ class AskAiRepository {
}
}
@immutable
enum AskAiScenario {
general,
terminal,
systemd,
container,
process,
snippet,
sftp,
}
extension AskAiScenarioX on AskAiScenario {
static AskAiScenario? tryParse(Object? raw) {
if (raw is! String) return null;
final s = raw.trim().toLowerCase();
return switch (s) {
'general' => AskAiScenario.general,
'terminal' => AskAiScenario.terminal,
'systemd' => AskAiScenario.systemd,
'container' => AskAiScenario.container,
'process' => AskAiScenario.process,
'snippet' => AskAiScenario.snippet,
'sftp' => AskAiScenario.sftp,
_ => null,
};
}
}
class _ToolCallBuilder {
_ToolCallBuilder();
@@ -289,11 +355,25 @@ class _ToolCallBuilder {
return null;
}
final description = decoded['description'] as String? ?? decoded['explanation'] as String? ?? '';
final risk = decoded['risk'] as String?;
final needsConfirmation = decoded['needsConfirmation'] as bool?;
final why = decoded['why'] as String?;
List<String>? prechecks;
final preRaw = decoded['prechecks'];
if (preRaw is List) {
prechecks = preRaw.map((e) => e.toString()).where((e) => e.trim().isNotEmpty).toList();
}
_emitted = true;
return AskAiCommand(
command: command.trim(),
description: description.trim(),
toolName: name,
risk: risk,
needsConfirmation: needsConfirmation,
why: why,
prechecks: prechecks,
);
} on FormatException {
if (force) {

View File

@@ -3,6 +3,6 @@
abstract class BuildData {
static const String name = "ServerBox";
static const int build = 1316;
static const int build = 1297;
static const int script = 70;
}

View File

@@ -1,5 +1,6 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:get_it/get_it.dart';
import 'package:server_box/data/store/ai_history.dart';
import 'package:server_box/data/store/connection_stats.dart';
import 'package:server_box/data/store/container.dart';
import 'package:server_box/data/store/history.dart';
@@ -17,6 +18,7 @@ abstract final class Stores {
static PrivateKeyStore get key => getIt<PrivateKeyStore>();
static SnippetStore get snippet => getIt<SnippetStore>();
static HistoryStore get history => getIt<HistoryStore>();
static AiHistoryStore get aiHistory => getIt<AiHistoryStore>();
static ConnectionStatsStore get connectionStats => getIt<ConnectionStatsStore>();
/// All stores that need backup
@@ -27,6 +29,7 @@ abstract final class Stores {
key,
snippet,
history,
aiHistory,
connectionStats,
];
@@ -37,8 +40,9 @@ abstract final class Stores {
getIt.registerLazySingleton<PrivateKeyStore>(() => PrivateKeyStore.instance);
getIt.registerLazySingleton<SnippetStore>(() => SnippetStore.instance);
getIt.registerLazySingleton<HistoryStore>(() => HistoryStore.instance);
getIt.registerLazySingleton<AiHistoryStore>(() => AiHistoryStore.instance);
getIt.registerLazySingleton<ConnectionStatsStore>(() => ConnectionStatsStore.instance);
await Future.wait(_allBackup.map((store) => store.init()));
}

View File

@@ -0,0 +1,23 @@
import 'package:fl_lib/fl_lib.dart';
/// Global persistent Ask AI conversation history.
///
/// Kept separate from [HistoryStore] to avoid mixing with SSH/SFTP history.
class AiHistoryStore extends HiveStore {
AiHistoryStore._() : super('ai_history');
static final instance = AiHistoryStore._();
/// Stored as a list of maps to avoid needing Hive type adapters.
late final history = listProperty<Map<String, dynamic>>(
'history',
defaultValue: const [],
fromObj: (val) => List<Map<String, dynamic>>.from(
(val as List).map((e) => Map<String, dynamic>.from(e as Map)),
),
);
void clearHistory() {
history.put(const []);
}
}

View File

@@ -165,6 +165,10 @@ class SettingStore extends HiveStore {
late final askAiApiKey = propertyDefault('askAiApiKey', '');
late final askAiModel = propertyDefault('askAiModel', 'gpt-4o-mini');
/// Global AI floating action button position as normalized (0..1) ratios.
late final aiFabOffsetX = propertyDefault('aiFabOffsetX', 0.92);
late final aiFabOffsetY = propertyDefault('aiFabOffsetY', 0.55);
late final serverFuncBtns = listProperty('serverBtns', defaultValue: ServerFuncBtn.defaultIdxs);
/// Docker is more popular than podman, set to `false` to use docker

View File

@@ -10,7 +10,6 @@ import 'l10n_en.dart';
import 'l10n_es.dart';
import 'l10n_fr.dart';
import 'l10n_id.dart';
import 'l10n_it.dart';
import 'l10n_ja.dart';
import 'l10n_nl.dart';
import 'l10n_pt.dart';
@@ -110,7 +109,6 @@ abstract class AppLocalizations {
Locale('es'),
Locale('fr'),
Locale('id'),
Locale('it'),
Locale('ja'),
Locale('nl'),
Locale('pt'),
@@ -406,7 +404,7 @@ abstract class AppLocalizations {
/// No description provided for @compactDatabaseContent.
///
/// In en, this message translates to:
/// **'Database size: {size}\n\nThis will reorganize the database to reduce file size. No data will be deleted.'**
/// **'Database size: {size}\n\nThis will rebuild the whole database to reduce file size.'**
String compactDatabaseContent(Object size);
/// No description provided for @confirm.
@@ -1995,7 +1993,6 @@ class _AppLocalizationsDelegate
'es',
'fr',
'id',
'it',
'ja',
'nl',
'pt',
@@ -2034,8 +2031,6 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
return AppLocalizationsFr();
case 'id':
return AppLocalizationsId();
case 'it':
return AppLocalizationsIt();
case 'ja':
return AppLocalizationsJa();
case 'nl':

View File

@@ -166,7 +166,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String compactDatabaseContent(Object size) {
return 'Database size: $size\n\nThis will reorganize the database to reduce file size. No data will be deleted.';
return 'Database size: $size\n\nThis will rebuild the whole database to reduce file size.';
}
@override

File diff suppressed because it is too large Load Diff

View File

@@ -1,315 +0,0 @@
{
"@@locale": "it",
"aboutThanks": "Grazie alle seguenti persone che hanno partecipato.",
"acceptBeta": "Accetta aggiornamenti versione beta",
"addSystemPrivateKeyTip": "Attualmente non esistono chiavi private, vuoi aggiungere quella fornita dal sistema (~/.ssh/id_rsa)?",
"added2List": "Aggiunto alla lista delle attività",
"addr": "Indirizzo",
"alreadyLastDir": "Già nell'ultima directory.",
"askAi": "Chiedi all'IA",
"askAiApiKey": "Chiave API",
"askAiAwaitingResponse": "In attesa della risposta dell'IA...",
"askAiBaseUrl": "URL base",
"askAiCommandInserted": "Comando inserito nel terminale",
"askAiConfigMissing": "Configura {fields} in Impostazioni.",
"askAiConfirmExecute": "Conferma prima di eseguire",
"askAiConversation": "Conversazione IA",
"askAiDisclaimer": "L'IA potrebbe essere errata. Rivedi attentamente prima di applicare.",
"askAiFollowUpHint": "Fai una domanda di follow-up...",
"askAiInsertTerminal": "Inserisci nel terminale",
"askAiModel": "Modello",
"askAiNoResponse": "Nessuna risposta",
"askAiRecommendedCommand": "Comando suggerito dall'IA",
"askAiSelectedContent": "Contenuto selezionato",
"askAiUsageHint": "Utilizzato nel Terminale SSH",
"atLeastOneTab": "Deve essere selezionata almeno una scheda",
"authFailTip": "Autenticazione fallita, verifica se le credenziali sono corrette",
"autoBackupConflict": "Solo un backup automatico può essere attivato alla volta.",
"autoConnect": "Connessione automatica",
"autoRun": "Esecuzione automatica",
"autoUpdateHomeWidget": "Aggiornamento automatico widget home",
"availableTabs": "Schede disponibili",
"backupEncrypted": "Il backup è crittografato",
"backupNotEncrypted": "Il backup non è crittografato",
"backupPassword": "Password di backup",
"backupPasswordRemoved": "Password di backup rimossa",
"backupPasswordSet": "Password di backup impostata",
"backupPasswordTip": "Imposta una password per crittografare i file di backup. Lascia vuoto per disabilitare la crittografia.",
"backupPasswordWrong": "Password di backup errata",
"backupTip": "I dati esportati possono essere crittografati con password.\nConservali al sicuro.",
"backupVersionNotMatch": "La versione del backup non corrisponde.",
"battery": "Batteria",
"bgRun": "Esegui in background",
"bgRunTip": "Questa opzione significa solo che il programma cercherà di eseguire in background. Se può eseguire in background dipende dal fatto che il permesso sia abilitato o meno. Per le ROM Android basate su AOSP, disabilita \"Ottimizzazione batteria\" in questa app. Per MIUI/HyperOS, cambia la politica di risparmio energetico su \"Illimitato\".",
"clearAllStatsContent": "Sei sicuro di voler cancellare tutte le statistiche di connessione del server? Questa azione non può essere annullata.",
"clearAllStatsTitle": "Cancella tutte le statistiche",
"clearServerStatsContent": "Sei sicuro di voler cancellare le statistiche di connessione per il server \"{serverName}\"? Questa azione non può essere annullata.",
"clearServerStatsTitle": "Cancella statistiche {serverName}",
"clearThisServerStats": "Cancella statistiche di questo server",
"compactDatabase": "Compatta database",
"compactDatabaseContent": "Dimensione database: {size}\n\nQuesto riorganizzerà il database per ridurre la dimensione del file. Nessun dato verrà eliminato.",
"confirm": "Conferma",
"closeAfterSave": "Salva e chiudi",
"cmd": "Comando",
"collapseUITip": "Se comprimere le liste lunghe presenti nell'interfaccia utente per impostazione predefinita",
"conn": "Connessione",
"connectionDetails": "Dettagli connessione",
"connectionStats": "Statistiche connessione",
"connectionStatsDesc": "Visualizza il tasso di successo della connessione al server e la cronologia",
"container": "Container",
"containerTrySudoTip": "Ad esempio: nell'app, l'utente è impostato su aaa, ma Docker è installato sotto l'utente root. In questo caso, devi abilitare questa opzione.",
"containerSudoPasswordRequired": "È richiesta la password sudo per accedere a Docker. Inserisci la tua password.",
"containerSudoPasswordIncorrect": "La password sudo è errata o non consentita. Riprova.",
"convert": "Converti",
"copyPath": "Copia percorso",
"cpuViewAsProgressTip": "Visualizza l'utilizzo di ogni CPU in stile barra di avanzamento (stile vecchio)",
"cursorType": "Tipo di cursore",
"customCmd": "Comandi personalizzati",
"customCmdDocUrl": "https://github.com/lollipopkit/flutter_server_box/wiki#custom-commands",
"customCmdHint": "\"Nome comando\": \"Comando\"",
"decode": "Decodifica",
"decompress": "Decomprimi",
"deleteServers": "Elimina server in blocco",
"desktopTerminalTip": "Comando utilizzato per aprire l'emulatore di terminale quando si avviano sessioni SSH.",
"dirEmpty": "Assicurati che la cartella sia vuota.",
"disconnected": "Disconnesso",
"discoverSshServers": "Scopri server SSH",
"discoveryFailed": "Scoperta fallita",
"discoverySettings": "Impostazioni scoperta",
"discoverySummary": "Riepilogo scoperta",
"disk": "Disco",
"diskHealth": "Salute disco",
"diskIgnorePath": "Ignora percorso per disco",
"displayCpuIndex": "Mostra indice CPU",
"dl2Local": "Scaricare {fileName} in locale?",
"dockerEmptyRunningItems": "Non ci sono container in esecuzione.\nQuesto potrebbe essere perché:\n- L'utente di installazione di Docker non è lo stesso del nome utente configurato nell'App.\n- La variabile d'ambiente DOCKER_HOST non è stata letta correttamente. Puoi ottenerla eseguendo `echo $DOCKER_HOST` nel terminale.",
"dockerImagesFmt": "{count} immagini",
"dockerNotInstalled": "Docker non installato",
"dockerStatusRunningAndStoppedFmt": "{runningCount} in esecuzione, {stoppedCount} container fermati.",
"dockerStatusRunningFmt": "{count} container in esecuzione.",
"doubleColumnMode": "Modalità a doppia colonna",
"doubleColumnTip": "Questa opzione abilita solo la funzione, se può essere effettivamente abilitata dipende dalla larghezza del dispositivo",
"editVirtKeys": "Modifica tasti virtuali",
"editorHighlightTip": "Le attuali prestazioni di evidenziazione del codice non sono ideali e possono essere disabilitate opzionalmente per migliorare.",
"emulator": "Emulatore",
"enableMdns": "Abilita mDNS",
"enableMdnsDesc": "Usa mDNS/Bonjour per scoprire servizi SSH",
"encode": "Codifica",
"envVars": "Variabile d'ambiente",
"experimentalFeature": "Funzionalità sperimentale",
"extraArgs": "Argomenti extra",
"fallbackSshDest": "Destinazione SSH di fallback",
"fdroidReleaseTip": "Se hai scaricato questa app da F-Droid, si consiglia di disattivare questa opzione.",
"fgService": "Servizio in primo piano",
"fgServiceTip": "Dopo l'attivazione, alcuni modelli di dispositivo potrebbero arrestarsi in modo anomalo. Disabilitarlo potrebbe causare l'impossibilità per alcuni modelli di mantenere le connessioni SSH in background. Consenti le autorizzazioni di notifica ServerBox, l'esecuzione in background e l'auto-riattivazione nelle impostazioni di sistema.",
"fileTooLarge": "File '{file}' troppo grande {size}, max {sizeMax}",
"finishedAt": "Completato alle",
"followSystem": "Segui sistema",
"fontSize": "Dimensione carattere",
"force": "Forza",
"fullScreen": "Modalità schermo intero",
"fullScreenJitter": "Jitter schermo intero",
"fullScreenJitterHelp": "Per evitare il burn-in dello schermo",
"fullScreenTip": "La modalità a schermo intero deve essere abilitata quando il dispositivo viene ruotato in modalità orizzontale? Questa opzione si applica solo alla scheda server.",
"goBackQ": "Tornare indietro?",
"goto": "Vai a",
"hideTitleBar": "Nascondi barra del titolo",
"highlight": "Evidenziazione codice",
"homeTabs": "Schede home",
"homeTabsCustomizeDesc": "Personalizza quali schede appaiono nella home page e il loro ordine",
"homeWidgetUrlConfig": "Configura url widget home",
"host": "Host",
"httpFailedWithCode": "richiesta fallita, codice stato: {code}",
"ignoreCert": "Ignora certificato",
"image": "Immagine",
"imagesList": "Elenco immagini",
"inner": "Interno",
"install": "installa",
"installDockerWithUrl": "Installa prima docker da https://docs.docker.com/engine/install .",
"invalid": "Non valido",
"invalidHostFormat": "Formato host non valido. Sono consentiti solo caratteri IPv4, IPv6 e di dominio.",
"jumpServer": "Server di salto",
"keepForeground": "Mantieni l'app in primo piano!",
"keepStatusWhenErr": "Conserva l'ultimo stato del server",
"keepStatusWhenErrTip": "Solo in caso di errore durante l'esecuzione dello script",
"keyAuth": "Autenticazione chiave",
"lastFailure": "Ultimo fallimento",
"lastSuccess": "Ultimo successo",
"letterCache": "Cache lettere",
"letterCacheTip": "Si consiglia di disabilitare, ma dopo aver disabilitato, non sarà possibile inserire caratteri CJK.",
"location": "Posizione",
"loss": "perdita",
"madeWithLove": "Realizzato con ❤️ da {myGithub}",
"max": "max",
"maxConcurrency": "Massima concorrenza",
"maxRetryCount": "Numero di riconnessioni del server",
"maxRetryCountEqual0": "Proverà di nuovo e ancora.",
"min": "min",
"mission": "Missione",
"more": "Altro",
"moveOutServerFuncBtnsHelp": "Attivo: può essere visualizzato sotto ogni carta nella pagina Scheda Server. Disattivato: può essere visualizzato nella parte superiore della pagina Dettagli Server.",
"ms": "ms",
"needHomeDir": "Se sei un utente Synology, [vedi qui](https://kb.synology.com/DSM/tutorial/user_enable_home_service). Gli utenti di altri sistemi devono cercare come creare una directory home.",
"needRestart": "L'app deve essere riavviata",
"net": "Rete",
"netViewType": "Tipo di visualizzazione rete",
"newContainer": "Nuovo container",
"noConnectionStatsData": "Nessun dato di statistiche di connessione",
"noLineChart": "Non usare grafici a linee",
"noLineChartForCpu": "Non usare grafici a linee per la CPU",
"noPrivateKeyTip": "La chiave privata non esiste, potrebbe essere stata eliminata o c'è un errore di configurazione.",
"noPromptAgain": "Non chiedere di nuovo",
"node": "Nodo",
"notAvailable": "Non disponibile",
"onServerDetailPage": "Nella pagina dettagli server",
"onlyOneLine": "Visualizza solo come una riga (scorrevole)",
"onlyWhenCoreBiggerThan8": "Funziona solo quando il numero di core è maggiore di 8",
"openLastPath": "Apri l'ultimo percorso",
"openLastPathTip": "Server diversi avranno log diversi e il log è il percorso di uscita",
"parseContainerStatsTip": "L'analisi dello stato di occupazione di Docker è relativamente lenta.",
"percentOfSize": "{percent}% di {size}",
"permission": "Permessi",
"pingAvg": "Media:",
"pingInputIP": "Inserisci un IP / dominio di destinazione.",
"pingNoServer": "Nessun server da pingare.\nAggiungi un server nella scheda server.",
"pkg": "Pkg",
"plugInType": "Tipo di inserimento",
"port": "Porta",
"preferDiskAmount": "Priorità visualizzazione capacità disco",
"privateKey": "Chiave privata",
"privateKeyNotFoundFmt": "Chiave privata [{keyId}] non trovata.",
"process": "Processo",
"prune": "Potatura",
"pushToken": "Token push",
"pveIgnoreCertTip": "Non si consiglia di abilitare, attento ai rischi per la sicurezza! Se stai usando il certificato predefinito da PVE, devi abilitare questa opzione.",
"pveLoginFailed": "Accesso fallito. Impossibile autenticarsi con nome utente/password dalla configurazione del server per l'accesso Linux PAM.",
"pveVersionLow": "Questa funzionalità è attualmente nella fase di test ed è stata testata solo su PVE 8+. Usala con cautela.",
"read": "Leggi",
"reboot": "Riavvia",
"recentConnections": "Connessioni recenti",
"rememberPwdInMem": "Ricorda password in memoria",
"rememberPwdInMemTip": "Utilizzato per container, sospensione, ecc.",
"rememberWindowSize": "Ricorda dimensione finestra",
"remotePath": "Percorso remoto",
"restart": "Riavvia",
"result": "Risultato",
"rotateAngel": "Angolo di rotazione",
"route": "Routing",
"run": "Esegui",
"running": "In esecuzione",
"sameIdServerExist": "Esiste già un server con lo stesso ID",
"save": "Salva",
"saved": "Salvato",
"second": "s",
"sensors": "Sensori",
"sequence": "Sequenza",
"server": "Server",
"serverDetailOrder": "Ordine widget pagina dettagli",
"serverFuncBtns": "Pulsanti funzione server",
"serverOrder": "Ordine server",
"serverTabRequired": "La scheda server non può essere rimossa",
"servers": "server",
"sftpDlPrepare": "Preparazione alla connessione...",
"sftpEditorTip": "Se vuoto, usa l'editor di file integrato dell'app. Se è presente un valore, usa l'editor del server remoto, ad es. `vim` (si consiglia di rilevare automaticamente secondo `EDITOR`).",
"sftpRmrDirSummary": "Usa `rm -r` per eliminare una cartella in SFTP.",
"sftpSSHConnected": "SFTP connesso",
"sftpShowFoldersFirst": "Mostra prima le cartelle",
"showDistLogo": "Mostra logo distribuzione",
"shutdown": "Spegni",
"size": "Dimensione",
"snippet": "Snippet",
"softWrap": "A capo automatico",
"specifyDev": "Specifica dispositivo",
"specifyDevTip": "Ad esempio, le statistiche del traffico di rete sono per impostazione predefinita per tutti i dispositivi. Puoi specificare un dispositivo particolare qui.",
"speed": "Velocità",
"spentTime": "Tempo impiegato: {time}",
"sshConfigAllExist": "Tutti i server esistono già ({duplicateCount} duplicati trovati)",
"sshConfigDuplicatesSkipped": "{duplicateCount} duplicati verranno saltati",
"sshConfigFound": "Abbiamo trovato la configurazione SSH sul tuo sistema.",
"sshConfigFoundServers": "Trovati {totalCount} server",
"sshConfigImport": "Importa configurazione SSH",
"sshConfigImportHelp": "Solo le informazioni di base possono essere importate, ad esempio: IP/Porta.",
"sshConfigImportPermission": "Vuoi dare il permesso di leggere ~/.ssh/config e importare automaticamente le impostazioni del server?",
"sshConfigImportTip": "Chiedi di leggere ~/.ssh/config alla prima creazione del server",
"sshConfigImported": "Importati {count} server dalla configurazione SSH",
"sshHostKeyChangedDesc": "La chiave host SSH è cambiata per {serverName}. Continua solo se ti fidi di questo server.",
"sshHostKeyFingerprintMd5Base64": "Impronta digitale (MD5 base64): {fingerprint}",
"sshHostKeyFingerprintMd5Hex": "Impronta digitale (MD5 hex): {fingerprint}",
"sshHostKeyType": "Tipo chiave host SSH",
"@sshHostKeyType": {
"description": "Etichetta per il tipo di chiave host SSH visualizzata nella finestra di dialogo di verifica della chiave host."
},
"sshHostKeyNewDesc": "È stata ricevuta una nuova chiave host SSH da {serverName}. Rivedi l'impronta digitale prima di fidarti.",
"sshHostKeyStoredFingerprint": "Impronta digitale memorizzata: {fingerprint}",
"sshConfigManualSelect": "Vuoi selezionare manualmente il file di configurazione SSH?",
"sshConfigNoServers": "Nessun server trovato nella configurazione SSH",
"sshConfigPermissionDenied": "Impossibile accedere al file di configurazione SSH a causa dei permessi macOS.",
"sshConfigServersToImport": "{importCount} server verranno importati",
"sshTermHelp": "Quando il terminale è scorrevole, trascinare orizzontalmente può selezionare il testo. Cliccando il pulsante tastiera accende/spegne la tastiera. L'icona file apre il percorso corrente SFTP. Il pulsante appunti copia il contenuto quando il testo è selezionato e incolla il contenuto dagli appunti nel terminale quando nessun testo è selezionato e c'è contenuto negli appunti. L'icona codice incolla snippet di codice nel terminale ed esegue.",
"sshTip": "Questa funzione è ora nella fase sperimentale.\n\nSegnala i bug su {url} o unisciti al nostro sviluppo.",
"sshVirtualKeyAutoOff": "Commutazione automatica dei tasti virtuali",
"start": "Avvia",
"stat": "Statistiche",
"stats": "Statistiche",
"stop": "Ferma",
"stopped": "Fermato",
"storage": "Archiviazione",
"supportFmtArgs": "Sono supportati i seguenti parametri di formattazione:",
"suspend": "Sospendi",
"suspendTip": "La funzione di sospensione richiede il permesso root e il supporto systemd.",
"switchTo": "Passa a {val}",
"syncTip": "Potrebbe essere necessario un riavvio affinché alcune modifiche abbiano effetto.",
"system": "Sistema",
"tag": "Tag",
"tapToStartDiscovery": "Tocca il pulsante di ricerca per scoprire i server SSH sulla tua rete",
"temperature": "Temperatura",
"termFontSizeTip": "Questa impostazione influirà sulla dimensione del terminale (larghezza e altezza). Puoi ingrandire la pagina del terminale per regolare la dimensione del carattere della sessione corrente.",
"terminal": "Terminale",
"test": "Test",
"textScaler": "Scalatore testo",
"textScalerTip": "1.0 => 100% (dimensione originale), funziona solo su parte del carattere della pagina server, non si consiglia di cambiare.",
"theme": "Tema",
"time": "Tempo",
"times": "Volte",
"total": "Totale",
"totalAttempts": "Totale",
"traffic": "Traffico",
"trySudo": "Prova a usare sudo",
"ttl": "TTL",
"unknown": "Sconosciuto",
"unkownConvertMode": "Modalità di conversione sconosciuta",
"update": "Aggiorna",
"updateIntervalEqual0": "Hai impostato a 0, non aggiornerà automaticamente.\nNon può calcolare lo stato della CPU.",
"updateServerStatusInterval": "Intervallo di aggiornamento stato server",
"upsideDown": "Capovolto",
"uptime": "Tempo di attività",
"useCdn": "Utilizzo CDN",
"useCdnTip": "Si consiglia agli utenti non cinesi di usare CDN. Vuoi usarlo?",
"useNoPwd": "Non verrà usata nessuna password",
"usePodmanByDefault": "Usa Podman per impostazione predefinita",
"used": "Usato",
"view": "Visualizza",
"viewDetails": "Visualizza dettagli",
"viewErr": "Vedi errore",
"virtKeyHelpClipboard": "Copia negli appunti se il terminale selezionato non è vuoto, altrimenti incolla il contenuto degli appunti nel terminale.",
"virtKeyHelpIME": "Accendi/spegni la tastiera",
"virtKeyHelpSFTP": "Apri la directory corrente in SFTP.",
"waitConnection": "Attendi che la connessione venga stabilita.",
"wakeLock": "Mantieni sveglio",
"watchNotPaired": "Nessun Apple Watch associato",
"webdavSettingEmpty": "Impostazione WebDav vuota",
"whenOpenApp": "All'apertura dell'app",
"wolTip": "Dopo aver configurato WOL (Wake-on-LAN), viene inviata una richiesta WOL ogni volta che il server è connesso.",
"write": "Scrivi",
"writeScriptFailTip": "Scrittura dello script fallita, forse a causa di mancanza di permessi o la directory non esiste.",
"writeScriptTip": "Dopo essersi connessi al server, uno script verrà scritto in `~/.config/server_box` \n | `/tmp/server_box` per monitorare lo stato del sistema. Puoi rivedere il contenuto dello script.",
"menuSettings": "Impostazioni",
"menuQuit": "Esci",
"menuNavigate": "Naviga",
"menuInfo": "Info",
"menuGitHubRepository": "Repository GitHub",
"menuWiki": "Wiki",
"menuHelp": "Aiuto",
"logs": "Log",
"podmanDockerEmulationDetected": "Rilevata emulazione Docker Podman. Passa a Podman nelle impostazioni."
}

View File

@@ -223,6 +223,30 @@ extension on _ContainerPageState {
);
SSHPage.route.go(context, args);
break;
case ContainerMenu.askAi:
final runtime = switch (_containerState.type) {
ContainerType.podman => 'podman',
ContainerType.docker => 'docker',
};
final blocks = <String>[
'[Container]\nruntime: $runtime',
'[Container Item]\nid: ${dItem.id}\nname: ${dItem.name}\nimage: ${dItem.image}\nstatus: ${dItem.status.displayName}',
];
showAiAssistSheet(
context,
AiAssistArgs(
title: context.l10n.askAi,
contextBlocks: blocks,
scenario: AskAiScenario.container,
applyLabel: libL10n.ok,
applyBehavior: AiApplyBehavior.openSsh,
onOpenSsh: (cmd) {
final args = SshPageArgs(spi: widget.args.spi, initCmd: cmd);
SSHPage.route.go(context, args);
},
),
);
break;
}
}

View File

@@ -12,10 +12,12 @@ import 'package:server_box/data/model/app/menu/container.dart';
import 'package:server_box/data/model/container/image.dart';
import 'package:server_box/data/model/container/ps.dart';
import 'package:server_box/data/model/container/type.dart';
import 'package:server_box/data/provider/ai/ask_ai.dart';
import 'package:server_box/data/provider/container.dart';
import 'package:server_box/data/provider/server/single.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/page/ssh/page/page.dart';
import 'package:server_box/view/widget/ai/ai_assist_sheet.dart';
part 'actions.dart';
part 'types.dart';

View File

@@ -25,6 +25,7 @@ import 'package:server_box/data/provider/server/single.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/page/pve.dart';
import 'package:server_box/view/page/server/edit/edit.dart';
import 'package:server_box/view/page/ssh/page/page.dart';
import 'package:server_box/view/widget/server_func_btns.dart';
part 'misc.dart';
@@ -103,7 +104,7 @@ class _ServerDetailPageState extends ConsumerState<ServerDetailPage> with Single
Widget _buildMainPage(ServerState si) {
final buildFuncs = !_moveServerFuncs;
final logo = _buildLogo(si);
final children = <Widget>[?logo, if (buildFuncs) ServerFuncBtns(spi: si.spi)];
final children = <Widget>[if (logo != null) logo, if (buildFuncs) ServerFuncBtns(spi: si.spi)];
for (final card in _cardsOrder) {
final child = _cardBuildMap[card]?.call(si);
if (child != null) {
@@ -125,6 +126,14 @@ class _ServerDetailPageState extends ConsumerState<ServerDetailPage> with Single
),
actions: [
QrShareBtn(data: si.spi.toJsonString(), tip: si.spi.name, tip2: '${l10n.server} ~ ServerBox'),
IconButton(
icon: const Icon(Icons.smart_toy_outlined),
tooltip: context.l10n.askAi,
onPressed: () {
final args = SshPageArgs(spi: si.spi);
SSHPage.route.go(context, args);
},
),
IconButton(
icon: const Icon(Icons.edit),
onPressed: () async {

View File

@@ -162,7 +162,7 @@ class _SshDiscoveryPageState extends ConsumerState<SshDiscoveryPage> {
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.centerRight,
children: <Widget>[...previousChildren, ?currentChild],
children: <Widget>[...previousChildren, if (currentChild != null) currentChild],
);
},
child: selectedResults.isNotEmpty

View File

@@ -11,8 +11,8 @@ extension _App on _AppSettingsPageState {
_buildCheckUpdate(),
_buildHomeTabs(),
PlatformPublicSettings.buildBioAuth,
?androidSettings,
?specific,
if (androidSettings != null) androidSettings,
if (specific != null) specific,
_buildAppMore(),
];

View File

@@ -27,483 +27,44 @@ extension _AskAi on SSHPageState {
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();
}
}
final scrollback = _buildTerminalScrollbackTail(maxLines: 200);
class _AskAiSheet extends ConsumerStatefulWidget {
const _AskAiSheet({required this.selection, required this.localeHint, required this.onCommandApply});
final blocks = <String>[
'[Terminal Selection]\n$selection',
'[Terminal Scrollback Tail]\n$scrollback',
'[Session]\nserver: ${widget.args.spi.user}@${widget.args.spi.ip}:${widget.args.spi.port}\nsessionId: $_sessionId',
];
final String selection;
final String? localeHint;
final ValueChanged<String> onCommandApply;
final redactedBlocks = AiSafety.redactBlocks(blocks, spi: widget.args.spi);
@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;
bool _isMinimized = 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);
}
Future<void> _copyText(BuildContext context, String text) async {
if (text.trim().isEmpty) return;
await Clipboard.setData(ClipboardData(text: text));
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,
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SimpleMarkdown(data: content),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () => _copyText(context, content),
icon: const Icon(Icons.copy, size: 18),
label: Text(libL10n.copy),
),
],
),
],
);
return Align(
alignment: Alignment.centerLeft,
child: CardX(
child: Padding(padding: const EdgeInsets.all(12), child: child),
await showAiAssistSheet(
context,
AiAssistArgs(
title: context.l10n.askAi,
contextBlocks: redactedBlocks,
scenario: AskAiScenario.terminal,
localeHint: localeHint,
applyLabel: context.l10n.askAiInsertTerminal,
applyBehavior: AiApplyBehavior.insert,
redacted: false,
onInsert: (command) {
_terminal.textInput(command);
(widget.args.focusNode?.requestFocus ?? _termKey.currentState?.requestKeyboard)?.call();
},
),
);
}
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),
),
],
),
],
),
),
),
);
}
String _buildTerminalScrollbackTail({required int maxLines}) {
final lines = _terminal.buffer.lines.toList();
if (lines.isEmpty) return '';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bottomPadding = MediaQuery.viewInsetsOf(context).bottom;
final heightFactor = _isMinimized ? 0.18 : 0.85;
final start = (lines.length - maxLines).clamp(0, lines.length);
final tail = lines.sublist(start);
return TweenAnimationBuilder<double>(
tween: Tween<double>(end: heightFactor),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
builder: (context, animatedHeightFactor, child) {
return ClipRect(
child: FractionallySizedBox(
heightFactor: animatedHeightFactor,
child: child,
),
);
},
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: Icon(_isMinimized ? Icons.unfold_more : Icons.unfold_less),
tooltip: libL10n.fold,
onPressed: () {
FocusManager.instance.primaryFocus?.unfocus();
setState(() {
_isMinimized = !_isMinimized;
});
},
),
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop()),
],
),
),
if (!_isMinimized) ...[
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: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
context.l10n.askAiDisclaimer,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
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,
),
] else
const SizedBox(height: 8),
],
),
),
);
return tail.map((e) => e.toString()).join('\n');
}
}

View File

@@ -13,10 +13,10 @@ 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/ai_safety.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';
@@ -25,6 +25,7 @@ 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:server_box/view/widget/ai/ai_assist_sheet.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:xterm/core.dart';
import 'package:xterm/ui.dart' hide TerminalThemes;

View File

@@ -1,11 +1,14 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/route.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/systemd.dart';
import 'package:server_box/data/provider/ai/ask_ai.dart';
import 'package:server_box/data/provider/systemd.dart';
import 'package:server_box/view/page/ssh/page/page.dart';
import 'package:server_box/view/widget/ai/ai_assist_sheet.dart';
final class SystemdPage extends ConsumerStatefulWidget {
final SpiRequiredArgs args;
@@ -28,7 +31,29 @@ final class _SystemdPageState extends ConsumerState<SystemdPage> {
return Scaffold(
appBar: CustomAppBar(
title: const Text('Systemd'),
actions: isDesktop ? [Btn.icon(icon: const Icon(Icons.refresh), onTap: _notifier.getUnits)] : null,
actions: [
if (isDesktop) Btn.icon(icon: const Icon(Icons.refresh), onTap: _notifier.getUnits),
IconButton(
icon: const Icon(Icons.smart_toy_outlined),
tooltip: context.l10n.askAi,
onPressed: () {
final blocks = <String>[
'[Systemd]\nscopeFilter: ${ref.read(_pro).scopeFilter.displayName}\nitems: ${_notifier.filteredUnits.length}',
];
showAiAssistSheet(
context,
AiAssistArgs(
title: context.l10n.askAi,
contextBlocks: blocks,
scenario: AskAiScenario.systemd,
applyLabel: libL10n.ok,
applyBehavior: AiApplyBehavior.openSsh,
onOpenSsh: _navigateToSsh,
),
);
},
),
],
),
body: RefreshIndicator(onRefresh: _notifier.getUnits, child: _buildBody()),
);

View File

@@ -0,0 +1,608 @@
import 'dart:async';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/ai/ask_ai_models.dart';
import 'package:server_box/data/provider/ai/ai_safety.dart';
import 'package:server_box/data/provider/ai/ask_ai.dart';
@immutable
enum AiApplyBehavior {
/// Apply means "insert" into an input (terminal/editor).
insert,
/// Apply means "open SSH and prefill initCmd".
openSsh,
/// Apply means "copy to clipboard".
copy,
}
@immutable
class AiAssistArgs {
const AiAssistArgs({
required this.title,
required this.contextBlocks,
required this.scenario,
required this.applyLabel,
required this.applyBehavior,
this.localeHint,
this.redacted = true,
this.onInsert,
this.onOpenSsh,
});
final String title;
final List<String> contextBlocks;
final AskAiScenario scenario;
final String applyLabel;
final AiApplyBehavior applyBehavior;
final String? localeHint;
/// If true, apply a conservative redaction before sending.
final bool redacted;
final ValueChanged<String>? onInsert;
final ValueChanged<String>? onOpenSsh;
}
Future<void> showAiAssistSheet(BuildContext context, AiAssistArgs args) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => AiAssistSheet(args: args),
);
}
class AiAssistSheet extends ConsumerStatefulWidget {
const AiAssistSheet({super.key, required this.args});
final AiAssistArgs args;
@override
ConsumerState<AiAssistSheet> createState() => _AiAssistSheetState();
}
enum _ChatEntryType { user, assistant, command }
class _ChatEntry {
const _ChatEntry._({required this.type, this.content, this.command, this.risk});
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, AiCommandRisk risk)
: this._(type: _ChatEntryType.command, command: command, risk: risk);
final _ChatEntryType type;
final String? content;
final AskAiCommand? command;
final AiCommandRisk? risk;
}
class _AiAssistSheetState extends ConsumerState<AiAssistSheet> {
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;
bool _isMinimized = 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(() {});
}
List<String> get _preparedBlocks {
final blocks = widget.args.contextBlocks;
if (!widget.args.redacted) return blocks;
// Best-effort: redact without Spi. Pages that have Spi should pass already-redacted
// blocks or avoid including secrets directly.
return AiSafety.redactBlocks(blocks);
}
void _startStream() {
_subscription?.cancel();
setState(() {
_isStreaming = true;
_error = null;
_streamingContent = '';
});
final messages = List<AskAiMessage>.from(_history);
_subscription = ref
.read(askAiRepositoryProvider)
.ask(
scenario: widget.args.scenario,
contextBlocks: _preparedBlocks,
localeHint: widget.args.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) {
final risk = event.command.risk != null
? (AiCommandRiskX.tryParse(event.command.risk) ?? AiSafety.classifyRisk(event.command.command))
: AiSafety.classifyRisk(event.command.command);
_chatEntries.add(_ChatEntry.command(event.command, risk));
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) {
final risk = command.risk != null
? (AiCommandRiskX.tryParse(command.risk) ?? AiSafety.classifyRisk(command.command))
: AiSafety.classifyRisk(command.command);
_chatEntries.add(_ChatEntry.command(command, risk));
}
}
_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> _confirmAndApplyCommand(AskAiCommand command, AiCommandRisk risk) async {
final l10n = context.l10n;
final needsCountdown = risk == AiCommandRisk.high || command.needsConfirmation == true;
final actions = <Widget>[Btn.cancel()];
if (needsCountdown) {
actions.add(
CountDownBtn(
seconds: 3,
onTap: () => context.pop(true),
text: libL10n.ok,
afterColor: Colors.red,
),
);
} else {
actions.add(TextButton(onPressed: () => context.pop(true), child: Text(libL10n.ok)));
}
final confirmed = await context.showRoundDialog<bool>(
title: needsCountdown ? libL10n.attention : l10n.askAiConfirmExecute,
child: SimpleMarkdown(data: '```shell\n${command.command}\n```'),
actions: actions,
);
if (confirmed != true) return;
if (!mounted) return;
await _applyCommand(command.command);
}
Future<void> _applyCommand(String cmd) async {
final text = cmd.trim();
if (text.isEmpty) return;
switch (widget.args.applyBehavior) {
case AiApplyBehavior.insert:
widget.args.onInsert?.call(text);
if (!mounted) return;
context.showSnackBar(context.l10n.askAiCommandInserted);
break;
case AiApplyBehavior.openSsh:
widget.args.onOpenSsh?.call(text);
break;
case AiApplyBehavior.copy:
await Clipboard.setData(ClipboardData(text: text));
if (!mounted) return;
context.showSnackBar(libL10n.success);
break;
}
}
Future<void> _copyCommand(AskAiCommand command) async {
await Clipboard.setData(ClipboardData(text: command.command));
if (!mounted) return;
context.showSnackBar(libL10n.success);
}
Future<void> _copyText(String text) async {
if (text.trim().isEmpty) return;
await Clipboard.setData(ClipboardData(text: text));
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!;
final risk = entry.risk ?? AiSafety.classifyRisk(command.command);
return _buildCommandBubble(context, theme, command, risk);
}
}
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,
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SimpleMarkdown(data: content),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () => _copyText(content),
icon: const Icon(Icons.copy, size: 18),
label: Text(libL10n.copy),
),
],
),
],
);
return Align(
alignment: Alignment.centerLeft,
child: CardX(
child: Padding(padding: const EdgeInsets.all(12), child: child),
),
);
}
Widget _buildRiskTag(ThemeData theme, AiCommandRisk risk) {
final (label, color) = switch (risk) {
AiCommandRisk.low => ('LOW', Colors.green),
AiCommandRisk.medium => ('MED', Colors.orange),
AiCommandRisk.high => ('HIGH', Colors.red),
};
return Container(
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(color: color),
).paddingSymmetric(horizontal: 6, vertical: 2),
);
}
Widget _buildCommandBubble(BuildContext context, ThemeData theme, AskAiCommand command, AiCommandRisk risk) {
final l10n = context.l10n;
return Align(
alignment: Alignment.centerLeft,
child: CardX(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(l10n.askAiRecommendedCommand, style: theme.textTheme.labelMedium),
const SizedBox(width: 8),
_buildRiskTag(theme, risk),
],
),
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(command),
icon: const Icon(Icons.copy, size: 18),
label: Text(libL10n.copy),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: () => _confirmAndApplyCommand(command, risk),
icon: const Icon(Icons.terminal, size: 18),
label: Text(widget.args.applyLabel),
),
],
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bottomPadding = MediaQuery.viewInsetsOf(context).bottom;
final heightFactor = _isMinimized ? 0.18 : 0.85;
return TweenAnimationBuilder<double>(
tween: Tween<double>(end: heightFactor),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
builder: (context, animatedHeightFactor, child) {
return ClipRect(
child: FractionallySizedBox(
heightFactor: animatedHeightFactor,
child: child,
),
);
},
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Row(
children: [
Text(widget.args.title, style: theme.textTheme.titleLarge),
const SizedBox(width: 8),
if (_isStreaming)
const SizedBox(height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2)),
const Spacer(),
IconButton(
icon: Icon(_isMinimized ? Icons.unfold_more : Icons.unfold_less),
tooltip: libL10n.fold,
onPressed: () {
FocusManager.instance.primaryFocus?.unfocus();
setState(() {
_isMinimized = !_isMinimized;
});
},
),
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop()),
],
),
),
if (!_isMinimized) ...[
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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final block in widget.args.contextBlocks) ...[
SelectableText(block, style: const TextStyle(fontFamily: 'monospace')),
const SizedBox(height: 8),
],
],
),
),
),
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: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
context.l10n.askAiDisclaimer,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
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,
),
] else
const SizedBox(height: 8),
],
),
),
);
}
}

View File

@@ -0,0 +1,137 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/provider/ai/ai_context.dart';
import 'package:server_box/data/provider/ai/ask_ai.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/page/ssh/page/page.dart';
import 'package:server_box/view/widget/ai/ai_assist_sheet.dart';
class AiFabOverlay extends ConsumerStatefulWidget {
const AiFabOverlay({super.key, required this.child});
final Widget child;
@override
ConsumerState<AiFabOverlay> createState() => _AiFabOverlayState();
}
class _AiFabOverlayState extends ConsumerState<AiFabOverlay> {
Offset? _offsetPx;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_offsetPx != null) return;
final media = MediaQuery.of(context);
final size = media.size;
final x = Stores.setting.aiFabOffsetX.fetch().clamp(0.0, 1.0);
final y = Stores.setting.aiFabOffsetY.fetch().clamp(0.0, 1.0);
_offsetPx = Offset(size.width * x, size.height * y);
}
void _persistOffset(Offset px) {
final size = MediaQuery.of(context).size;
if (size.width <= 0 || size.height <= 0) return;
final nx = (px.dx / size.width).clamp(0.0, 1.0);
final ny = (px.dy / size.height).clamp(0.0, 1.0);
Stores.setting.aiFabOffsetX.put(nx);
Stores.setting.aiFabOffsetY.put(ny);
}
Offset _clampToBounds(Offset px) {
final media = MediaQuery.of(context);
final size = media.size;
final padding = media.padding;
const fabSize = 56.0;
const margin = 8.0;
final minX = margin;
final maxX = (size.width - fabSize - margin).clamp(minX, size.width);
final topInset = padding.top;
final bottomInset = padding.bottom;
final minY = topInset + margin;
final maxY = (size.height - fabSize - bottomInset - margin).clamp(minY, size.height);
return Offset(px.dx.clamp(minX, maxX), px.dy.clamp(minY, maxY));
}
Future<void> _onTapFab() async {
final snapshot = ref.read(aiContextProvider);
final localeHint = Localizations.maybeLocaleOf(context)?.toLanguageTag();
final scenario = AskAiScenarioX.tryParse(snapshot.scenario) ?? AskAiScenario.general;
final applyBehavior = snapshot.spiId != null ? AiApplyBehavior.openSsh : AiApplyBehavior.copy;
await showAiAssistSheet(
context,
AiAssistArgs(
title: snapshot.title,
contextBlocks: snapshot.blocks,
scenario: scenario,
localeHint: localeHint,
applyLabel: applyBehavior == AiApplyBehavior.openSsh ? libL10n.ok : libL10n.copy,
applyBehavior: applyBehavior,
onOpenSsh: (cmd) {
final spiId = snapshot.spiId;
if (spiId == null) return;
final spi = Stores.server.get<Spi>(spiId);
if (spi == null) return;
final args = SshPageArgs(spi: spi, initCmd: cmd);
SSHPage.route.go(context, args);
},
),
);
}
@override
Widget build(BuildContext context) {
final offset = _offsetPx;
if (offset == null) {
return widget.child;
}
return Stack(
children: [
widget.child,
Positioned(
left: offset.dx,
top: offset.dy,
child: Draggable(
feedback: _buildFab(context, dragging: true),
childWhenDragging: const SizedBox.shrink(),
onDragEnd: (details) {
if (!mounted) return;
final next = _clampToBounds(details.offset);
setState(() {
_offsetPx = next;
});
_persistOffset(next);
},
child: _buildFab(context),
),
),
],
);
}
Widget _buildFab(BuildContext context, {bool dragging = false}) {
return FloatingActionButton(
heroTag: dragging ? null : 'ai_fab',
onPressed: _onTapFab,
child: const Icon(LineAwesome.robot_solid),
);
}
}

View File

@@ -15,6 +15,9 @@ PODS:
- FlutterMacOS
- package_info_plus (0.0.1):
- FlutterMacOS
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- screen_retriever_macos (0.0.1):
- FlutterMacOS
- share_plus (0.0.1):
@@ -38,6 +41,7 @@ DEPENDENCIES:
- icloud_storage (from `Flutter/ephemeral/.symlinks/plugins/icloud_storage/macos`)
- local_auth_darwin (from `Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
@@ -62,6 +66,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
screen_retriever_macos:
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos
share_plus:
@@ -84,6 +90,7 @@ SPEC CHECKSUMS:
icloud_storage: eb5b0f20687cf5a4fabc0b541f3b079cd6df7dcb
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb

View File

@@ -214,7 +214,6 @@
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
3EF3AE3CC6FE5ADDF0613960 /* [CP] Embed Pods Frameworks */,
A1B2C3D4E5F60718293A4B5C /* Fix Objective-C Framework Resources */,
);
buildRules = (
);
@@ -289,28 +288,10 @@
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
A1B2C3D4E5F60718293A4B5C /* Fix Objective-C Framework Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Fix Objective-C Framework Resources";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "FRAMEWORK=\"$TARGET_BUILD_DIR/$FRAMEWORKS_FOLDER_PATH/objective_c.framework\"\nif [ -d \"$FRAMEWORK/Versions\" ]; then\n if [ ! -L \"$FRAMEWORK/Versions/Current\" ]; then\n (cd \"$FRAMEWORK/Versions\" && ln -sf A Current)\n fi\n rm -f \"$FRAMEWORK/Resources\"\n ln -sf Versions/Current/Resources \"$FRAMEWORK/Resources\"\nfi\n";
};
3399D490228B24CF009A79C7 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -490,7 +471,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1316;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_TEAM = BA88US33G6;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Server Box";
@@ -500,7 +481,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.15;
MARKETING_VERSION = 1.0.1316;
MARKETING_VERSION = 1.0.1297;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "Server Box";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -627,7 +608,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1316;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_TEAM = BA88US33G6;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Server Box";
@@ -637,7 +618,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.15;
MARKETING_VERSION = 1.0.1316;
MARKETING_VERSION = 1.0.1297;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "Server Box";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -657,7 +638,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1316;
CURRENT_PROJECT_VERSION = 1297;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=macosx*]" = BA88US33G6;
INFOPLIST_FILE = Runner/Info.plist;
@@ -668,7 +649,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.15;
MARKETING_VERSION = 1.0.1316;
MARKETING_VERSION = 1.0.1297;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "Server Box";
PROVISIONING_PROFILE_SPECIFIER = "";

Submodule packages/fl_lib deleted from f5968f8ab1

Submodule packages/xterm deleted from f307b2f253

View File

@@ -149,10 +149,10 @@ packages:
dependency: "direct dev"
description:
name: build_runner
sha256: "39ad4ca8a2876779737c60e4228b4bcd35d4352ef7e14e47514093edc012c734"
sha256: b4d854962a32fd9f8efc0b76f98214790b833af8b2e9b2df6bfc927c0415a072
url: "https://pub.dev"
source: hosted
version: "2.11.1"
version: "2.10.5"
built_collection:
dependency: transitive
description:
@@ -173,26 +173,26 @@ packages:
dependency: transitive
description:
name: camera
sha256: a005c6b9783d895a3a9808d65d06773d13587e22a186b6fe8ef3801b0d12f8cf
sha256: eefad89f262a873f38d21e5eec853461737ea074d7c9ede39f3ceb135d201cab
url: "https://pub.dev"
source: hosted
version: "0.11.3+1"
version: "0.11.3"
camera_android_camerax:
dependency: transitive
description:
name: camera_android_camerax
sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577"
sha256: bc7a96998258adddd0b653dd693b0874537707d58b0489708f2a646e4f124246
url: "https://pub.dev"
source: hosted
version: "0.6.30"
version: "0.6.27"
camera_avfoundation:
dependency: transitive
description:
name: camera_avfoundation
sha256: "4e47c2796dab3f21fdfe1d15151bf628519093b171307cb64a71ba8e451697b5"
sha256: a600b60a7752cc5fa9de476cd0055539d7a3b9d62662f4f446bae49eba2267df
url: "https://pub.dev"
source: hosted
version: "0.9.23"
version: "0.9.22+9"
camera_platform_interface:
dependency: transitive
description:
@@ -319,10 +319,10 @@ packages:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
version: "0.3.5+1"
crypto:
dependency: "direct main"
description:
@@ -366,18 +366,20 @@ packages:
dartssh2:
dependency: "direct main"
description:
path: "packages/dartssh2"
relative: true
source: path
path: "."
ref: "v1.0.293"
resolved-ref: "3eedfd55916eede70aeb28605469a43623a9791b"
url: "https://github.com/lollipopkit/dartssh2"
source: git
version: "2.12.0"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
version: "0.7.11"
dio:
dependency: "direct main"
description:
@@ -446,10 +448,10 @@ packages:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.1.5"
file:
dependency: transitive
description:
@@ -477,9 +479,11 @@ packages:
fl_build:
dependency: "direct dev"
description:
path: "packages/fl_build"
relative: true
source: path
path: "."
ref: "v1.0.53"
resolved-ref: "61ee37ea6f082592f5be56340b7746dce4ffbfda"
url: "https://github.com/lppcg/fl_build.git"
source: git
version: "1.0.0"
fl_chart:
dependency: "direct main"
@@ -492,9 +496,11 @@ packages:
fl_lib:
dependency: "direct main"
description:
path: "packages/fl_lib"
relative: true
source: path
path: "."
ref: "v1.0.363"
resolved-ref: "4b745be6f33b2e7f274d44f26175df440345cefb"
url: "https://github.com/lollipopkit/fl_lib"
source: git
version: "0.0.1"
flutter:
dependency: "direct main"
@@ -729,10 +735,10 @@ packages:
dependency: transitive
description:
name: hive_ce
sha256: "8e9980e68643afb1e765d3af32b47996552a64e190d03faf622cea07c1294418"
sha256: b844955c89f61f479170632b971dcf6fbb8e7233d2a5c2e3c7b89e1b2986bdb5
url: "https://pub.dev"
source: hosted
version: "2.19.3"
version: "2.19.1"
hive_ce_flutter:
dependency: "direct main"
description:
@@ -753,10 +759,10 @@ packages:
dependency: transitive
description:
name: hooks
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
version: "1.0.0"
html:
dependency: transitive
description:
@@ -841,10 +847,10 @@ packages:
dependency: transitive
description:
name: isolate_channel
sha256: a9d3d620695bc984244dafae00b95e4319d6974b2d77f4b9e1eb4f2efe099094
sha256: "000d617d021a608186b468584bbc6df2509ecba048f08510f832fdb9cf7aafbe"
url: "https://pub.dev"
source: hosted
version: "0.6.1"
version: "0.4.1"
isolate_contactor:
dependency: transitive
description:
@@ -913,10 +919,10 @@ packages:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0
url: "https://pub.dev"
source: hosted
version: "6.1.0"
version: "6.0.0"
local_auth:
dependency: transitive
description:
@@ -1057,10 +1063,10 @@ packages:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
sha256: "7fd0c4d8ac8980011753b9bdaed2bf15111365924cdeeeaeb596214ea2b03537"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
version: "9.2.4"
package_config:
dependency: transitive
description:
@@ -1218,10 +1224,10 @@ packages:
dependency: transitive
description:
name: pretty_qr_code
sha256: "474f8a4512113fba06f14a6ec9bbf42353b4e651d7a520e3096f2a9b6bbe7a8a"
sha256: "2291db3f68d70a3dcd46c6bd599f30991ae4c02f27f36215fbb3f4865a609259"
url: "https://pub.dev"
source: hosted
version: "3.6.0"
version: "3.5.0"
provider:
dependency: transitive
description:
@@ -1527,10 +1533,10 @@ packages:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
version: "1.10.1"
stack_trace:
dependency: transitive
description:
@@ -1647,10 +1653,10 @@ packages:
dependency: transitive
description:
name: url_launcher_ios
sha256: b1aca26728b7cc7a3af971bb6f601554a8ae9df2e0a006de8450ba06a17ad36a
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
url: "https://pub.dev"
source: hosted
version: "6.4.0"
version: "6.3.6"
url_launcher_linux:
dependency: transitive
description:
@@ -1855,9 +1861,11 @@ packages:
xterm:
dependency: "direct main"
description:
path: "packages/xterm"
relative: true
source: path
path: "."
ref: "v4.0.13"
resolved-ref: "6343b0e5f744d2c11090d34690ad5049ebbc599b"
url: "https://github.com/lollipopkit/xterm.dart"
source: git
version: "4.0.0"
yaml:
dependency: transitive

View File

@@ -1,7 +1,7 @@
name: server_box
description: server status & toolbox app.
publish_to: "none"
version: 1.0.1316+1316
version: 1.0.1297+1297
environment:
sdk: ">=3.9.0"
@@ -40,13 +40,17 @@ dependencies:
xml: ^6.4.2 # for parsing nvidia-smi
url_launcher: ^6.2.6
dartssh2:
path: packages/dartssh2
git:
url: https://github.com/lollipopkit/dartssh2
ref: v1.0.293
circle_chart:
git:
url: https://github.com/lollipopkit/circle_chart
ref: main
xterm:
path: packages/xterm
git:
url: https://github.com/lollipopkit/xterm.dart
ref: v4.0.13
computer:
git:
url: https://github.com/lollipopkit/dart_computer
@@ -60,7 +64,9 @@ dependencies:
url: https://github.com/lollipopkit/plain_notification_token
ref: v1.0.23
fl_lib:
path: packages/fl_lib
git:
url: https://github.com/lollipopkit/fl_lib
ref: v1.0.363
dependency_overrides:
# webdav_client_plus:
@@ -95,7 +101,9 @@ dev_dependencies:
# url: https://github.com/lollipopkit/riverpod_reg
# ref: v0.0.2
fl_build:
path: packages/fl_build
git:
url: https://github.com/lppcg/fl_build.git
ref: v1.0.53
flutter:
generate: true

74
test/ai_safety_test.dart Normal file
View File

@@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/provider/ai/ai_safety.dart';
void main() {
group('AiSafety.redact', () {
test('redacts private key blocks', () {
const input = '''before
-----BEGIN PRIVATE KEY-----
abc
-----END PRIVATE KEY-----
after''';
final out = AiSafety.redact(input);
expect(out, contains('<PRIVATE_KEY_BLOCK>'));
expect(out, isNot(contains('BEGIN PRIVATE KEY')));
});
test('redacts Bearer tokens', () {
const input = 'Authorization: Bearer abc.def.ghi\nnext';
final out = AiSafety.redact(input);
expect(out, contains('Authorization: Bearer <TOKEN>'));
expect(out, isNot(contains('abc.def.ghi')));
});
test('redacts OpenAI-style keys', () {
const input = 'sk-1234567890abcdef1234567890abcdef';
final out = AiSafety.redact(input);
expect(out, contains('<API_KEY>'));
expect(out, isNot(contains('sk-123456')));
});
test('replaces Spi identity with placeholders', () {
final spi = Spi(name: 'n', ip: '192.168.1.2', port: 22, user: 'root', id: 'id');
const input = 'ssh root@192.168.1.2 -p 22 && echo root && ping 192.168.1.2';
final out = AiSafety.redact(input, spi: spi);
expect(out, contains('<USER_AT_HOST>'));
expect(out, contains('<IP>'));
expect(out, isNot(contains('root@192.168.1.2')));
expect(out, isNot(contains('192.168.1.2')));
// Note: "root" may appear elsewhere and gets replaced.
expect(out, isNot(contains('echo root')));
});
test('none mode returns input unchanged', () {
const input = 'hello sk-1234567890abcdef';
final out = AiSafety.redact(input, mode: AiRedactionMode.none);
expect(out, input);
});
});
group('AiSafety.classifyRisk', () {
test('detects high risk rm -rf', () {
expect(AiSafety.classifyRisk('rm -rf /'), AiCommandRisk.high);
expect(AiSafety.classifyRisk('sudo rm -rf /var/lib/docker'), AiCommandRisk.high);
});
test('detects high risk mkfs', () {
expect(AiSafety.classifyRisk('mkfs.ext4 /dev/sda1'), AiCommandRisk.high);
});
test('detects medium risk reboot', () {
expect(AiSafety.classifyRisk('reboot'), AiCommandRisk.medium);
});
test('detects medium risk systemctl restart', () {
expect(AiSafety.classifyRisk('systemctl restart nginx'), AiCommandRisk.medium);
});
test('defaults to low risk', () {
expect(AiSafety.classifyRisk('ls -la'), AiCommandRisk.low);
expect(AiSafety.classifyRisk(''), AiCommandRisk.low);
});
});
}