proposal: enhance AI functionalities

Fixes [#1038]
This commit is contained in:
lollipopkit🏳️‍⚧️
2026-01-30 15:24:56 +08:00
parent 6338c6ce6b
commit 28ac6145c4
18 changed files with 1308 additions and 482 deletions

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

@@ -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