From 3a8f90fdd2b1673a60b50e0038e4b0cf3213b69d Mon Sep 17 00:00:00 2001 From: xiaowen <0928du@gmail.com> Date: Fri, 26 Jun 2026 15:59:38 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E7=AC=AC1=E6=89=B9=EF=BC=9A=E5=A4=9A=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=20+=20=E9=80=9A=E7=94=A8=E9=A1=B5(?= =?UTF-8?q?=E8=AF=AD=E8=A8=80/=E7=89=88=E6=9C=AC)=20+=20i18n=E6=A1=86?= =?UTF-8?q?=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建轻量i18n(core/i18n.dart 字典 + settings_provider 的 l10nProvider),中/英切换 - core/settings_store.dart 通用设置落盘(语言),独立 settings.json - config.dart LLM 升级为多供应商:LlmProvider模型 + 5家预设(GLM/DeepSeek/千问/GPT/Claude) - AppConfig 用 providers列表 + activeProviderId,保留 llm 派生getter零改调用方 - 向后兼容:旧单llm字段自动迁移成glm供应商;providers与预设合并保证完整 - 新建 ui/settings_center.dart:左栏索引+右栏内容大窗口(仿Termius),含AI模型页/通用页 - config_provider: updateProvider/setActiveProvider 替代 updateLlm - ai_pane 顶部加模型切换条(下拉切换已配置key的供应商) - top_bar 齿轮改打开设置中心;删除旧 showLlmSettingsDialog;app_shell 标题去掉写死型号 Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/app/lib/core/config.dart | 182 ++++++- clients/app/lib/core/i18n.dart | 60 +++ clients/app/lib/core/settings_store.dart | 50 ++ clients/app/lib/state/config_provider.dart | 19 +- clients/app/lib/state/settings_provider.dart | 25 + clients/app/lib/ui/ai_pane.dart | 88 ++++ clients/app/lib/ui/app_shell.dart | 2 +- clients/app/lib/ui/dialogs.dart | 34 +- clients/app/lib/ui/settings_center.dart | 501 +++++++++++++++++++ clients/app/lib/ui/top_bar.dart | 3 +- 10 files changed, 908 insertions(+), 56 deletions(-) create mode 100644 clients/app/lib/core/i18n.dart create mode 100644 clients/app/lib/core/settings_store.dart create mode 100644 clients/app/lib/state/settings_provider.dart create mode 100644 clients/app/lib/ui/settings_center.dart diff --git a/clients/app/lib/core/config.dart b/clients/app/lib/core/config.dart index 14f74c9..b7d4519 100644 --- a/clients/app/lib/core/config.dart +++ b/clients/app/lib/core/config.dart @@ -116,12 +116,119 @@ class LlmConfig { ); } +/// 一家大模型供应商的配置。id 固定(glm/deepseek/qwen/gpt/claude), +/// name/baseURL/model 有内置默认值,apiKey 由用户填。均走 OpenAI 兼容协议。 +class LlmProvider { + final String id; + final String name; + final String baseURL; + final String apiKey; + final String model; + + const LlmProvider({ + required this.id, + required this.name, + required this.baseURL, + this.apiKey = '', + required this.model, + }); + + /// 是否已配置(填了 apiKey) + bool get configured => apiKey.trim().isNotEmpty; + + /// 转成 GlmClient 用的 LlmConfig + LlmConfig toLlmConfig() => + LlmConfig(baseURL: baseURL, apiKey: apiKey, model: model); + + factory LlmProvider.fromJson(Map j) => LlmProvider( + id: j['id'] as String, + name: j['name'] as String? ?? j['id'] as String, + baseURL: j['baseURL'] as String? ?? '', + apiKey: j['apiKey'] as String? ?? '', + model: j['model'] as String? ?? '', + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'baseURL': baseURL, + 'apiKey': apiKey, + 'model': model, + }; + + LlmProvider copyWith({String? apiKey, String? model, String? baseURL}) => + LlmProvider( + id: id, + name: name, + baseURL: baseURL ?? this.baseURL, + apiKey: apiKey ?? this.apiKey, + model: model ?? this.model, + ); +} + +/// 五家内置供应商预设(apiKey 留空待填)。baseURL 均为各家 OpenAI 兼容端点。 +const List defaultProviders = [ + LlmProvider( + id: 'glm', + name: 'GLM(智谱)', + baseURL: 'https://open.bigmodel.cn/api/paas/v4', + model: 'glm-4.6'), + LlmProvider( + id: 'deepseek', + name: 'DeepSeek', + baseURL: 'https://api.deepseek.com', + model: 'deepseek-chat'), + LlmProvider( + id: 'qwen', + name: '通义千问', + baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + model: 'qwen-plus'), + LlmProvider( + id: 'gpt', + name: 'OpenAI GPT', + baseURL: 'https://api.openai.com/v1', + model: 'gpt-4o'), + LlmProvider( + id: 'claude', + name: 'Claude', + baseURL: 'https://api.anthropic.com/v1', + model: 'claude-sonnet-4-6'), +]; + class AppConfig { final List hosts; final List keys; - final LlmConfig llm; + final List providers; + final String activeProviderId; + + const AppConfig({ + required this.hosts, + this.keys = const [], + this.providers = defaultProviders, + this.activeProviderId = 'glm', + }); + + /// 当前激活的供应商(找不到回退第一个) + LlmProvider get activeProvider => providers.firstWhere( + (p) => p.id == activeProviderId, + orElse: () => providers.isNotEmpty ? providers.first : defaultProviders.first, + ); + + /// 当前激活供应商的 LlmConfig(给 GlmClient 用,调用方无需改) + LlmConfig get llm => activeProvider.toLlmConfig(); - const AppConfig({required this.hosts, this.keys = const [], required this.llm}); + AppConfig copyWith({ + List? hosts, + List? keys, + List? providers, + String? activeProviderId, + }) => + AppConfig( + hosts: hosts ?? this.hosts, + keys: keys ?? this.keys, + providers: providers ?? this.providers, + activeProviderId: activeProviderId ?? this.activeProviderId, + ); } /// 默认 LLM 设置:GLM。apiKey 留空,首次运行提示用户填或从环境变量读 @@ -137,9 +244,24 @@ String get configFile => '$_configDir/config.json'; const _uuid = Uuid(); -AppConfig _emptyConfig() => const AppConfig(hosts: [], llm: _defaultLlm); +AppConfig _emptyConfig() => const AppConfig(hosts: []); + +/// 合并供应商:以内置预设为基底,用文件里同 id 的配置覆盖(保留用户填的 key/model)。 +/// 保证即使老配置缺供应商,列表也始终是完整的五家。 +List _mergeProviders(List fromFile) { + return defaultProviders.map((preset) { + final saved = fromFile.where((p) => p.id == preset.id).firstOrNull; + if (saved == null) return preset; + // 用户填了什么就用什么,baseURL/model 为空时回退预设 + return preset.copyWith( + apiKey: saved.apiKey, + model: saved.model.isNotEmpty ? saved.model : preset.model, + baseURL: saved.baseURL.isNotEmpty ? saved.baseURL : preset.baseURL, + ); + }).toList(); +} -/// 读配置;不存在则返回空配置。环境变量 GLM_API_KEY 优先覆盖文件里的 apiKey。 +/// 读配置;不存在则返回空配置。环境变量 GLM_API_KEY 优先覆盖 GLM 供应商的 apiKey。 AppConfig loadConfig() { AppConfig cfg; final file = File(configFile); @@ -155,22 +277,45 @@ AppConfig loadConfig() { final keys = (parsed['keys'] as List? ?? []) .map((e) => SshKey.fromJson(e as Map)) .toList(); - final llm = parsed['llm'] != null - ? LlmConfig.fromJson(parsed['llm'] as Map) - : _defaultLlm; - cfg = AppConfig(hosts: hosts, keys: keys, llm: llm); + // 新版:providers 列表 + activeProviderId + List providers; + String activeId; + if (parsed['providers'] != null) { + final fromFile = (parsed['providers'] as List) + .map((e) => LlmProvider.fromJson(e as Map)) + .toList(); + providers = _mergeProviders(fromFile); + activeId = parsed['activeProviderId'] as String? ?? 'glm'; + } else if (parsed['llm'] != null) { + // 向后兼容:旧版单 llm 字段迁移成 glm 供应商 + final old = LlmConfig.fromJson(parsed['llm'] as Map); + providers = defaultProviders + .map((p) => p.id == 'glm' + ? p.copyWith(apiKey: old.apiKey, model: old.model, baseURL: old.baseURL) + : p) + .toList(); + activeId = 'glm'; + } else { + providers = defaultProviders; + activeId = 'glm'; + } + cfg = AppConfig( + hosts: hosts, + keys: keys, + providers: providers, + activeProviderId: activeId); } catch (_) { // 配置损坏不影响启动,退回空配置(用户可重新添加) cfg = _emptyConfig(); } } - // 环境变量优先:方便临时覆盖,且不把 key 写进文件 + // 环境变量优先:覆盖 GLM 供应商的 apiKey(不写回文件) final envKey = Platform.environment['GLM_API_KEY']; if (envKey != null && envKey.trim().isNotEmpty) { - cfg = AppConfig( - hosts: cfg.hosts, - keys: cfg.keys, - llm: cfg.llm.copyWith(apiKey: envKey)); + final providers = cfg.providers + .map((p) => p.id == 'glm' ? p.copyWith(apiKey: envKey) : p) + .toList(); + cfg = cfg.copyWith(providers: providers); } return cfg; } @@ -184,7 +329,8 @@ void saveConfig(AppConfig cfg) { final json = const JsonEncoder.withIndent(' ').convert({ 'hosts': cfg.hosts.map((h) => h.toJson()).toList(), 'keys': cfg.keys.map((k) => k.toJson()).toList(), - 'llm': cfg.llm.toJson(), + 'providers': cfg.providers.map((p) => p.toJson()).toList(), + 'activeProviderId': cfg.activeProviderId, }); final file = File(configFile); file.writeAsStringSync(json); @@ -217,7 +363,7 @@ Host addHost({ keyId: keyId, ); final hosts = [...cfg.hosts, newHost]; - saveConfig(AppConfig(hosts: hosts, keys: cfg.keys, llm: cfg.llm)); + saveConfig(cfg.copyWith(hosts: hosts)); return newHost; } @@ -225,7 +371,7 @@ Host addHost({ void removeHost(String id) { final cfg = loadConfig(); final hosts = cfg.hosts.where((h) => h.id != id).toList(); - saveConfig(AppConfig(hosts: hosts, keys: cfg.keys, llm: cfg.llm)); + saveConfig(cfg.copyWith(hosts: hosts)); } /// 取某主机的明文密码(解密);未存返回 null @@ -253,7 +399,7 @@ SshKey addKey({ : null, ); final keys = [...cfg.keys, newKey]; - saveConfig(AppConfig(hosts: cfg.hosts, keys: keys, llm: cfg.llm)); + saveConfig(cfg.copyWith(keys: keys)); return newKey; } @@ -275,7 +421,7 @@ void removeKey(String id) { ) : h) .toList(); - saveConfig(AppConfig(hosts: hosts, keys: keys, llm: cfg.llm)); + saveConfig(cfg.copyWith(hosts: hosts, keys: keys)); } /// 取某把密钥的明文 PEM + passphrase(解密)。找不到返回 null。 diff --git a/clients/app/lib/core/i18n.dart b/clients/app/lib/core/i18n.dart new file mode 100644 index 0000000..dae2adc --- /dev/null +++ b/clients/app/lib/core/i18n.dart @@ -0,0 +1,60 @@ +/// 轻量国际化 —— 不引第三方包,一个 key→译文 Map 搞定中/英切换。 +/// 设计:L10n 持有当前语言;t(key) 取译文,缺失回退中文、再回退 key 本身。 +/// 全应用文案逐步迁移到这里(分批),新代码一律用 context 无关的 L10n.of(ref)。 +library; + +/// 支持的语言 +enum AppLang { zh, en } + +/// 文案字典。外层 key 是文案标识,内层按语言取值。 +/// 约定:key 用点分命名空间(settings.title / common.save)。 +const Map> _dict = { + // 通用动作 + 'common.save': {AppLang.zh: '保存', AppLang.en: 'Save'}, + 'common.cancel': {AppLang.zh: '取消', AppLang.en: 'Cancel'}, + 'common.delete': {AppLang.zh: '删除', AppLang.en: 'Delete'}, + 'common.add': {AppLang.zh: '添加', AppLang.en: 'Add'}, + 'common.close': {AppLang.zh: '关闭', AppLang.en: 'Close'}, + 'common.version': {AppLang.zh: '版本', AppLang.en: 'Version'}, + + // 设置中心 - 导航 + 'settings.title': {AppLang.zh: '设置', AppLang.en: 'Settings'}, + 'settings.nav.aiModel': {AppLang.zh: 'AI 模型', AppLang.en: 'AI Model'}, + 'settings.nav.common': {AppLang.zh: '通用', AppLang.en: 'Common'}, + 'settings.nav.terminal': {AppLang.zh: '终端', AppLang.en: 'Terminal'}, + 'settings.nav.theme': {AppLang.zh: '终端主题', AppLang.en: 'Terminal Theme'}, + 'settings.nav.shortcuts': {AppLang.zh: '快捷键', AppLang.en: 'Shortcuts'}, + + // 通用页 + 'settings.common.title': {AppLang.zh: '通用设置', AppLang.en: 'Common Settings'}, + 'settings.common.language': {AppLang.zh: '语言', AppLang.en: 'Language'}, + 'settings.common.langZh': {AppLang.zh: '简体中文', AppLang.en: 'Simplified Chinese'}, + 'settings.common.langEn': {AppLang.zh: '英文', AppLang.en: 'English'}, + + // AI 模型页 + 'settings.ai.title': {AppLang.zh: 'AI 模型设置', AppLang.en: 'AI Model Settings'}, + 'settings.ai.provider': {AppLang.zh: '供应商', AppLang.en: 'Provider'}, + 'settings.ai.apiKey': {AppLang.zh: 'API Key', AppLang.en: 'API Key'}, + 'settings.ai.baseUrl': {AppLang.zh: 'Base URL', AppLang.en: 'Base URL'}, + 'settings.ai.model': {AppLang.zh: '模型', AppLang.en: 'Model'}, + 'settings.ai.active': {AppLang.zh: '当前使用', AppLang.en: 'Active'}, + 'settings.ai.setActive': {AppLang.zh: '设为当前', AppLang.en: 'Set as active'}, + 'settings.ai.configured': {AppLang.zh: '已配置', AppLang.en: 'Configured'}, + 'settings.ai.notConfigured': {AppLang.zh: '未配置 Key', AppLang.en: 'No API Key'}, + 'settings.ai.hint': { + AppLang.zh: '填入对应供应商的 API Key 即可启用。均走 OpenAI 兼容协议。', + AppLang.en: 'Enter the API Key to enable. All use the OpenAI-compatible protocol.' + }, +}; + +/// 当前语言下取文案 +class L10n { + final AppLang lang; + const L10n(this.lang); + + String t(String key) { + final entry = _dict[key]; + if (entry == null) return key; // 没收录就显示 key,便于发现遗漏 + return entry[lang] ?? entry[AppLang.zh] ?? key; + } +} diff --git a/clients/app/lib/core/settings_store.dart b/clients/app/lib/core/settings_store.dart new file mode 100644 index 0000000..f6dbac6 --- /dev/null +++ b/clients/app/lib/core/settings_store.dart @@ -0,0 +1,50 @@ +/// 通用设置持久化 —— 语言等应用级偏好,存 $HOME/.lowenssh/settings.json。 +/// 与 config.json/snippets.json 同目录。独立文件,避免污染主配置。 +/// 后续批次(Terminal 设置/主题/快捷键)的偏好也并到这里。 +library; + +import 'dart:convert'; +import 'dart:io'; +import 'i18n.dart'; + +/// 应用级通用设置 +class AppSettings { + final AppLang lang; + + const AppSettings({this.lang = AppLang.zh}); + + factory AppSettings.fromJson(Map j) => AppSettings( + lang: (j['lang'] as String?) == 'en' ? AppLang.en : AppLang.zh, + ); + + Map toJson() => { + 'lang': lang == AppLang.en ? 'en' : 'zh', + }; + + AppSettings copyWith({AppLang? lang}) => + AppSettings(lang: lang ?? this.lang); +} + +String get _settingsFile => + '${Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.'}/.lowenssh/settings.json'; + +/// 读设置;不存在或损坏返回默认(中文) +AppSettings loadSettings() { + final file = File(_settingsFile); + if (!file.existsSync()) return const AppSettings(); + try { + final parsed = jsonDecode(file.readAsStringSync()) as Map; + return AppSettings.fromJson(parsed); + } catch (_) { + return const AppSettings(); + } +} + +/// 写设置 +void saveSettings(AppSettings s) { + final dir = Directory( + '${Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.'}/.lowenssh'); + if (!dir.existsSync()) dir.createSync(recursive: true); + File(_settingsFile) + .writeAsStringSync(const JsonEncoder.withIndent(' ').convert(s.toJson())); +} diff --git a/clients/app/lib/state/config_provider.dart b/clients/app/lib/state/config_provider.dart index 3707bee..9c285ca 100644 --- a/clients/app/lib/state/config_provider.dart +++ b/clients/app/lib/state/config_provider.dart @@ -34,10 +34,23 @@ class ConfigNotifier extends Notifier { state = loadConfig(); } - /// 更新 LLM 配置 - void updateLlm(LlmConfig llm) { + /// 更新某供应商的配置(apiKey/model/baseURL) + void updateProvider(String id, + {String? apiKey, String? model, String? baseURL}) { final cfg = loadConfig(); - saveConfig(AppConfig(hosts: cfg.hosts, keys: cfg.keys, llm: llm)); + final providers = cfg.providers + .map((p) => p.id == id + ? p.copyWith(apiKey: apiKey, model: model, baseURL: baseURL) + : p) + .toList(); + saveConfig(cfg.copyWith(providers: providers)); + state = loadConfig(); + } + + /// 切换当前激活的供应商 + void setActiveProvider(String id) { + final cfg = loadConfig(); + saveConfig(cfg.copyWith(activeProviderId: id)); state = loadConfig(); } diff --git a/clients/app/lib/state/settings_provider.dart b/clients/app/lib/state/settings_provider.dart new file mode 100644 index 0000000..618703e --- /dev/null +++ b/clients/app/lib/state/settings_provider.dart @@ -0,0 +1,25 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/i18n.dart'; +import '../core/settings_store.dart'; + +/// 通用设置 Notifier —— 语言等偏好,落盘持久化。 +class SettingsNotifier extends Notifier { + @override + AppSettings build() => loadSettings(); + + /// 切换语言(落盘 + 刷新,全应用即时重建) + void setLang(AppLang lang) { + final next = state.copyWith(lang: lang); + saveSettings(next); + state = next; + } +} + +final settingsProvider = + NotifierProvider(SettingsNotifier.new); + +/// 当前语言下的 L10n(派生)。UI 用 ref.watch(l10nProvider).t('key')。 +final l10nProvider = Provider((ref) { + final lang = ref.watch(settingsProvider).lang; + return L10n(lang); +}); diff --git a/clients/app/lib/ui/ai_pane.dart b/clients/app/lib/ui/ai_pane.dart index 5933b74..bae27f1 100644 --- a/clients/app/lib/ui/ai_pane.dart +++ b/clients/app/lib/ui/ai_pane.dart @@ -5,6 +5,7 @@ import 'package:gpt_markdown/gpt_markdown.dart'; import '../theme.dart'; import '../state/agent_provider.dart'; import '../state/snippet_provider.dart'; +import '../state/config_provider.dart'; /// AI 对话面板 —— 对话流 + 工具卡片 + 门禁卡片 + 输入框 /// 对应设计稿 .pane.ai。三种卡片(tool/ask/blocked)是门禁可视化核心。 @@ -82,6 +83,8 @@ class _AiPaneState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // 顶部模型切换条 + _modelBar(), // 对话流 Expanded( child: st.items.isEmpty && st.pendingAsk == null @@ -160,6 +163,91 @@ class _AiPaneState extends ConsumerState { ), ); + // 顶部模型切换条:显示当前模型,下拉切换已配置 key 的供应商 + Widget _modelBar() { + final cfg = ref.watch(configProvider); + final active = cfg.activeProvider; + // 只列已配置 key 的供应商;当前激活的即使没 key 也显示 + final selectable = cfg.providers + .where((p) => p.configured || p.id == active.id) + .toList(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: const BoxDecoration( + color: AppColors.mantle, + border: Border(bottom: BorderSide(color: AppColors.surface0)), + ), + child: Row( + children: [ + const Icon(Icons.smart_toy_outlined, + size: 14, color: AppColors.subtext), + const SizedBox(width: 7), + const Text('智能体', + style: TextStyle(fontSize: 12, color: AppColors.subtext)), + const SizedBox(width: 8), + // 模型下拉 + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: PopupMenuButton( + initialValue: active.id, + tooltip: '切换模型', + color: AppColors.mantle, + onSelected: (id) => + ref.read(configProvider.notifier).setActiveProvider(id), + itemBuilder: (_) => [ + for (final p in selectable) + PopupMenuItem( + value: p.id, + height: 38, + child: Row( + children: [ + Icon( + p.id == active.id + ? Icons.check + : Icons.circle_outlined, + size: 13, + color: p.id == active.id + ? AppColors.green + : AppColors.overlay), + const SizedBox(width: 8), + Text('${p.name} · ${p.model}', + style: const TextStyle( + fontSize: 12, color: AppColors.text)), + ], + ), + ), + ], + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.surface0, + borderRadius: BorderRadius.circular(5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(active.model, + style: const TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: AppColors.text)), + const SizedBox(width: 4), + const Icon(Icons.expand_more, + size: 14, color: AppColors.subtext), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } + // 智能体消息正文(Markdown 渲染:加粗/列表/表格/代码块) Widget _assistantMsg({required String text}) => Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/clients/app/lib/ui/app_shell.dart b/clients/app/lib/ui/app_shell.dart index 2eed811..c363444 100644 --- a/clients/app/lib/ui/app_shell.dart +++ b/clients/app/lib/ui/app_shell.dart @@ -52,7 +52,7 @@ class _AppShellState extends State { ], weight: 0.32), // 中右:智能体(独立面板,可拖动) DockingItem( - name: '智能体 · GLM-4.6', + name: '智能体', widget: const AiPane(), weight: 0.30, keepAlive: true, diff --git a/clients/app/lib/ui/dialogs.dart b/clients/app/lib/ui/dialogs.dart index 4020784..5ec00a3 100644 --- a/clients/app/lib/ui/dialogs.dart +++ b/clients/app/lib/ui/dialogs.dart @@ -275,36 +275,4 @@ Widget _keyDropdown( ); } -/// LLM 设置对话框 -Future showLlmSettingsDialog(BuildContext context, WidgetRef ref) { - final cfg = ref.read(configProvider).llm; - final baseURL = TextEditingController(text: cfg.baseURL); - final apiKey = TextEditingController(text: cfg.apiKey); - final model = TextEditingController(text: cfg.model); - - return _showDark( - context, - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _title(Icons.settings_outlined, 'LLM 设置'), - _field(baseURL, 'Base URL', - hint: 'https://open.bigmodel.cn/api/paas/v4'), - _field(apiKey, 'API Key', obscure: true), - _field(model, '模型', hint: 'glm-4.6'), - const SizedBox(height: 4), - Builder( - builder: (ctx) => _actions(ctx, onOk: () { - ref.read(configProvider.notifier).updateLlm(LlmConfig( - baseURL: baseURL.text.trim(), - apiKey: apiKey.text.trim(), - model: model.text.trim(), - )); - Navigator.pop(ctx); - }), - ), - ], - ), - ); -} +/// LLM 设置已迁移到设置中心(ui/settings_center.dart)。 diff --git a/clients/app/lib/ui/settings_center.dart b/clients/app/lib/ui/settings_center.dart new file mode 100644 index 0000000..c0c5a9a --- /dev/null +++ b/clients/app/lib/ui/settings_center.dart @@ -0,0 +1,501 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../theme.dart'; +import '../core/i18n.dart'; +import '../core/config.dart'; +import '../state/config_provider.dart'; +import '../state/settings_provider.dart'; + +/// 应用版本号(与 pubspec version 对齐,手工维护) +const String kAppVersion = '1.0.0'; + +/// 设置中心 —— 左栏索引 + 右栏内容的大窗口(仿 Termius)。 +/// 各页:AI 模型 / 通用 / 终端 / 终端主题 / 快捷键(后三者后续批次填充)。 +Future showSettingsCenter(BuildContext context) { + return showDialog( + context: context, + builder: (_) => Dialog( + backgroundColor: AppColors.base, + insetPadding: const EdgeInsets.all(40), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: AppColors.surface0), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 860, maxHeight: 620), + child: const _SettingsCenter(), + ), + ), + ); +} + +// 导航项标识 +enum _Nav { aiModel, common, terminal, theme, shortcuts } + +class _SettingsCenter extends ConsumerStatefulWidget { + const _SettingsCenter(); + + @override + ConsumerState<_SettingsCenter> createState() => _SettingsCenterState(); +} + +class _SettingsCenterState extends ConsumerState<_SettingsCenter> { + _Nav _active = _Nav.aiModel; + + @override + Widget build(BuildContext context) { + final l = ref.watch(l10nProvider); + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 左栏导航 + SizedBox(width: 220, child: _navBar(l)), + const VerticalDivider(width: 1, color: AppColors.surface0), + // 右栏内容 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 顶部标题条 + Container( + padding: const EdgeInsets.fromLTRB(20, 16, 16, 16), + decoration: const BoxDecoration( + border: + Border(bottom: BorderSide(color: AppColors.surface0)), + ), + child: Row( + children: [ + Text(l.t('settings.title'), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.text)), + const Spacer(), + InkWell( + onTap: () => Navigator.pop(context), + child: const Icon(Icons.close, + size: 18, color: AppColors.subtext), + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: switch (_active) { + _Nav.aiModel => const _AiModelPage(), + _Nav.common => const _CommonPage(), + _ => _placeholder(l), + }, + ), + ), + ], + ), + ), + ], + ); + } + + // 左侧导航栏 + Widget _navBar(L10n l) { + Widget item(_Nav nav, IconData icon, String label) { + final active = _active == nav; + return InkWell( + onTap: () => setState(() => _active = nav), + child: Container( + margin: const EdgeInsets.fromLTRB(8, 2, 8, 2), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: active ? AppColors.surface0 : null, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(icon, + size: 16, + color: active ? AppColors.text : AppColors.subtext), + const SizedBox(width: 10), + Text(label, + style: TextStyle( + fontSize: 13, + color: active ? AppColors.text : AppColors.subtext)), + ], + ), + ), + ); + } + + return Container( + color: AppColors.mantle, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 14), + item(_Nav.aiModel, Icons.smart_toy_outlined, + l.t('settings.nav.aiModel')), + item(_Nav.common, Icons.tune, l.t('settings.nav.common')), + item(_Nav.terminal, Icons.terminal_outlined, + l.t('settings.nav.terminal')), + item(_Nav.theme, Icons.palette_outlined, + l.t('settings.nav.theme')), + item(_Nav.shortcuts, Icons.keyboard_outlined, + l.t('settings.nav.shortcuts')), + const Spacer(), + // 版本号 + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(l.t('common.version'), + style: const TextStyle( + fontSize: 11, color: AppColors.overlay)), + Text(kAppVersion, + style: const TextStyle( + fontSize: 11, color: AppColors.overlay)), + ], + ), + ), + ], + ), + ); + } + + Widget _placeholder(L10n l) => const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Text('即将推出…', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: AppColors.overlay)), + ); +} + +// ============ AI 模型页 ============ + +class _AiModelPage extends ConsumerStatefulWidget { + const _AiModelPage(); + + @override + ConsumerState<_AiModelPage> createState() => _AiModelPageState(); +} + +class _AiModelPageState extends ConsumerState<_AiModelPage> { + // 当前编辑中的供应商 id(默认选激活的) + String? _editingId; + + @override + Widget build(BuildContext context) { + final l = ref.watch(l10nProvider); + final cfg = ref.watch(configProvider); + final editingId = _editingId ?? cfg.activeProviderId; + final editing = + cfg.providers.firstWhere((p) => p.id == editingId); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l.t('settings.ai.title'), + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.text)), + const SizedBox(height: 4), + Text(l.t('settings.ai.hint'), + style: const TextStyle(fontSize: 11.5, color: AppColors.overlay)), + const SizedBox(height: 16), + // 供应商选择行(横向卡片) + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final p in cfg.providers) + _providerChip(p, p.id == editingId, + p.id == cfg.activeProviderId, () { + setState(() => _editingId = p.id); + }), + ], + ), + const SizedBox(height: 20), + // 编辑区 + _editor(l, editing, cfg.activeProviderId == editing.id), + ], + ); + } + + // 供应商卡片:名称 + 配置状态 + 激活标记 + Widget _providerChip( + LlmProvider p, bool editing, bool active, VoidCallback onTap) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + width: 150, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: editing ? AppColors.surface0 : AppColors.mantle, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: editing ? AppColors.blue : AppColors.surface0), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(p.name, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: AppColors.text)), + ), + if (active) + const Icon(Icons.check_circle, + size: 14, color: AppColors.green), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: p.configured ? AppColors.green : AppColors.overlay, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + Flexible( + child: Text(p.model, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 10, color: AppColors.overlay)), + ), + ], + ), + ], + ), + ), + ); + } + + // 配置编辑表单 + Widget _editor(L10n l, LlmProvider p, bool isActive) { + // 用 key 让切换供应商时重建表单(刷新 controller 初值) + return _ProviderForm( + key: ValueKey(p.id), + provider: p, + isActive: isActive, + l: l, + ); + } +} + +// 供应商编辑表单(独立 StatefulWidget,持有 controller) +class _ProviderForm extends ConsumerStatefulWidget { + final LlmProvider provider; + final bool isActive; + final L10n l; + const _ProviderForm( + {super.key, + required this.provider, + required this.isActive, + required this.l}); + + @override + ConsumerState<_ProviderForm> createState() => _ProviderFormState(); +} + +class _ProviderFormState extends ConsumerState<_ProviderForm> { + late final TextEditingController _apiKey = + TextEditingController(text: widget.provider.apiKey); + late final TextEditingController _model = + TextEditingController(text: widget.provider.model); + late final TextEditingController _baseURL = + TextEditingController(text: widget.provider.baseURL); + + @override + void dispose() { + _apiKey.dispose(); + _model.dispose(); + _baseURL.dispose(); + super.dispose(); + } + + void _save() { + ref.read(configProvider.notifier).updateProvider( + widget.provider.id, + apiKey: _apiKey.text.trim(), + model: _model.text.trim(), + baseURL: _baseURL.text.trim(), + ); + } + + @override + Widget build(BuildContext context) { + final l = widget.l; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.mantle, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.surface0), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text(widget.provider.name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.text)), + const SizedBox(width: 8), + if (widget.isActive) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: AppColors.green.withValues(alpha: .18), + borderRadius: BorderRadius.circular(4), + ), + child: Text(l.t('settings.ai.active'), + style: const TextStyle( + fontSize: 9.5, + fontWeight: FontWeight.w700, + color: AppColors.green)), + ), + ], + ), + const SizedBox(height: 14), + _label(l.t('settings.ai.apiKey')), + _input(_apiKey, obscure: true, onChanged: (_) => _save()), + const SizedBox(height: 12), + _label(l.t('settings.ai.model')), + _input(_model, onChanged: (_) => _save()), + const SizedBox(height: 12), + _label(l.t('settings.ai.baseUrl')), + _input(_baseURL, onChanged: (_) => _save()), + const SizedBox(height: 16), + // 设为当前 / 已是当前 + Align( + alignment: Alignment.centerLeft, + child: widget.isActive + ? Text(l.t('settings.ai.active'), + style: const TextStyle( + fontSize: 12, color: AppColors.green)) + : FilledButton( + onPressed: widget.provider.configured + ? () => ref + .read(configProvider.notifier) + .setActiveProvider(widget.provider.id) + : null, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.crust, + disabledBackgroundColor: AppColors.surface0), + child: Text(l.t('settings.ai.setActive')), + ), + ), + ], + ), + ); + } +} + +// ============ 通用页 ============ + +class _CommonPage extends ConsumerWidget { + const _CommonPage(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l = ref.watch(l10nProvider); + final lang = ref.watch(settingsProvider).lang; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l.t('settings.common.title'), + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.text)), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.mantle, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.surface0), + ), + child: Row( + children: [ + Text(l.t('settings.common.language'), + style: const TextStyle(fontSize: 13, color: AppColors.text)), + const Spacer(), + // 语言切换 + _langTab(l.t('settings.common.langZh'), lang == AppLang.zh, + () => ref.read(settingsProvider.notifier).setLang(AppLang.zh)), + const SizedBox(width: 8), + _langTab(l.t('settings.common.langEn'), lang == AppLang.en, + () => ref.read(settingsProvider.notifier).setLang(AppLang.en)), + ], + ), + ), + ], + ); + } + + Widget _langTab(String label, bool active, VoidCallback onTap) => InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: active ? AppColors.surface1 : AppColors.base, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: active ? AppColors.blue : AppColors.surface0), + ), + child: Text(label, + style: TextStyle( + fontSize: 12.5, + color: active ? AppColors.text : AppColors.subtext)), + ), + ); +} + +// ============ 共用小部件 ============ + +Widget _label(String text) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text(text, + style: const TextStyle(fontSize: 11, color: AppColors.subtext)), + ); + +Widget _input(TextEditingController c, + {bool obscure = false, ValueChanged? onChanged}) => + TextField( + controller: c, + obscureText: obscure, + onChanged: onChanged, + style: const TextStyle(fontSize: 13, color: AppColors.text), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: AppColors.base, + contentPadding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: AppColors.surface0), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: AppColors.blue), + ), + ), + ); diff --git a/clients/app/lib/ui/top_bar.dart b/clients/app/lib/ui/top_bar.dart index 59bef63..f06104f 100644 --- a/clients/app/lib/ui/top_bar.dart +++ b/clients/app/lib/ui/top_bar.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../theme.dart'; import '../state/search_provider.dart'; import 'dialogs.dart'; +import 'settings_center.dart'; /// 顶栏 —— Logo + 搜索框 + 操作按钮(高 38px) /// 对应设计稿 .topbar @@ -92,7 +93,7 @@ class TopBar extends ConsumerWidget { const SizedBox(width: 6), _btn( icon: Icons.settings_outlined, - onTap: () => showLlmSettingsDialog(context, ref)), + onTap: () => showSettingsCenter(context)), ], ); } From 9d93242bc30da30f7db443034a5280578834e812 Mon Sep 17 00:00:00 2001 From: xiaowen <0928du@gmail.com> Date: Fri, 26 Jun 2026 16:46:12 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E7=AC=AC2=E6=89=B9=EF=BC=9ATerminal=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=EF=BC=88=E7=9C=9F=E5=AE=9E=E7=94=9F=E6=95=88=E9=A1=B9?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings_store 扩展终端设置:字号/选中即复制/右键粘贴/光标样式/光标闪烁 - settings_provider 加 updateTerminal - settings_center 新增 Terminal 页(开关+分段选择+字号步进),i18n 覆盖 - terminal_pane 接真:setAutoCopy 可控、onSecondaryTapDown 按开关、cursorType/blink/fontSize 跟随设置 - 只做 xterm 4.0.0 真实支持项,无装饰假开关 - import xterm hide CursorStyle 避免与 settings_store 命名冲突 Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/app/lib/core/i18n.dart | 17 ++ clients/app/lib/core/settings_store.dart | 51 +++++- clients/app/lib/state/settings_provider.dart | 19 +++ clients/app/lib/ui/settings_center.dart | 164 +++++++++++++++++++ clients/app/lib/ui/terminal_pane.dart | 55 +++++-- 5 files changed, 286 insertions(+), 20 deletions(-) diff --git a/clients/app/lib/core/i18n.dart b/clients/app/lib/core/i18n.dart index dae2adc..0e4c9e7 100644 --- a/clients/app/lib/core/i18n.dart +++ b/clients/app/lib/core/i18n.dart @@ -45,6 +45,23 @@ const Map> _dict = { AppLang.zh: '填入对应供应商的 API Key 即可启用。均走 OpenAI 兼容协议。', AppLang.en: 'Enter the API Key to enable. All use the OpenAI-compatible protocol.' }, + + // 终端设置页 + 'settings.term.title': {AppLang.zh: '终端设置', AppLang.en: 'Terminal Settings'}, + 'settings.term.fontSize': {AppLang.zh: '字号', AppLang.en: 'Font Size'}, + 'settings.term.selectToCopy': { + AppLang.zh: '选中即复制 / 右键粘贴', + AppLang.en: 'Select to copy & Right click to paste' + }, + 'settings.term.rightClickPaste': { + AppLang.zh: '右键粘贴', + AppLang.en: 'Right click to paste' + }, + 'settings.term.cursorStyle': {AppLang.zh: '光标样式', AppLang.en: 'Cursor Style'}, + 'settings.term.cursorBlink': {AppLang.zh: '光标闪烁', AppLang.en: 'Cursor Blink'}, + 'settings.term.cursorBlock': {AppLang.zh: '方块', AppLang.en: 'Block'}, + 'settings.term.cursorUnderline': {AppLang.zh: '下划线', AppLang.en: 'Underline'}, + 'settings.term.cursorBar': {AppLang.zh: '竖线', AppLang.en: 'Bar'}, }; /// 当前语言下取文案 diff --git a/clients/app/lib/core/settings_store.dart b/clients/app/lib/core/settings_store.dart index f6dbac6..945779f 100644 --- a/clients/app/lib/core/settings_store.dart +++ b/clients/app/lib/core/settings_store.dart @@ -7,22 +7,67 @@ import 'dart:convert'; import 'dart:io'; import 'i18n.dart'; +/// 终端光标样式 +enum CursorStyle { block, underline, bar } + /// 应用级通用设置 class AppSettings { final AppLang lang; - const AppSettings({this.lang = AppLang.zh}); + // 终端设置(xterm 真实支持项) + final double termFontSize; // 字号 + final bool selectToCopy; // 选中即复制 + final bool rightClickPaste; // 右键粘贴 + final CursorStyle cursorStyle; //光标样式 + final bool cursorBlink; // 光标闪烁 + + const AppSettings({ + this.lang = AppLang.zh, + this.termFontSize = 12.5, + this.selectToCopy = true, + this.rightClickPaste = true, + this.cursorStyle = CursorStyle.block, + this.cursorBlink = true, + }); factory AppSettings.fromJson(Map j) => AppSettings( lang: (j['lang'] as String?) == 'en' ? AppLang.en : AppLang.zh, + termFontSize: (j['termFontSize'] as num?)?.toDouble() ?? 12.5, + selectToCopy: j['selectToCopy'] as bool? ?? true, + rightClickPaste: j['rightClickPaste'] as bool? ?? true, + cursorStyle: switch (j['cursorStyle'] as String?) { + 'underline' => CursorStyle.underline, + 'bar' => CursorStyle.bar, + _ => CursorStyle.block, + }, + cursorBlink: j['cursorBlink'] as bool? ?? true, ); Map toJson() => { 'lang': lang == AppLang.en ? 'en' : 'zh', + 'termFontSize': termFontSize, + 'selectToCopy': selectToCopy, + 'rightClickPaste': rightClickPaste, + 'cursorStyle': cursorStyle.name, + 'cursorBlink': cursorBlink, }; - AppSettings copyWith({AppLang? lang}) => - AppSettings(lang: lang ?? this.lang); + AppSettings copyWith({ + AppLang? lang, + double? termFontSize, + bool? selectToCopy, + bool? rightClickPaste, + CursorStyle? cursorStyle, + bool? cursorBlink, + }) => + AppSettings( + lang: lang ?? this.lang, + termFontSize: termFontSize ?? this.termFontSize, + selectToCopy: selectToCopy ?? this.selectToCopy, + rightClickPaste: rightClickPaste ?? this.rightClickPaste, + cursorStyle: cursorStyle ?? this.cursorStyle, + cursorBlink: cursorBlink ?? this.cursorBlink, + ); } String get _settingsFile => diff --git a/clients/app/lib/state/settings_provider.dart b/clients/app/lib/state/settings_provider.dart index 618703e..93855ec 100644 --- a/clients/app/lib/state/settings_provider.dart +++ b/clients/app/lib/state/settings_provider.dart @@ -13,6 +13,25 @@ class SettingsNotifier extends Notifier { saveSettings(next); state = next; } + + /// 更新终端设置(任一字段,落盘 + 刷新) + void updateTerminal({ + double? termFontSize, + bool? selectToCopy, + bool? rightClickPaste, + CursorStyle? cursorStyle, + bool? cursorBlink, + }) { + final next = state.copyWith( + termFontSize: termFontSize, + selectToCopy: selectToCopy, + rightClickPaste: rightClickPaste, + cursorStyle: cursorStyle, + cursorBlink: cursorBlink, + ); + saveSettings(next); + state = next; + } } final settingsProvider = diff --git a/clients/app/lib/ui/settings_center.dart b/clients/app/lib/ui/settings_center.dart index c0c5a9a..20aee4c 100644 --- a/clients/app/lib/ui/settings_center.dart +++ b/clients/app/lib/ui/settings_center.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../theme.dart'; import '../core/i18n.dart'; import '../core/config.dart'; +import '../core/settings_store.dart'; import '../state/config_provider.dart'; import '../state/settings_provider.dart'; @@ -85,6 +86,7 @@ class _SettingsCenterState extends ConsumerState<_SettingsCenter> { child: switch (_active) { _Nav.aiModel => const _AiModelPage(), _Nav.common => const _CommonPage(), + _Nav.terminal => const _TerminalPage(), _ => _placeholder(l), }, ), @@ -468,6 +470,168 @@ class _CommonPage extends ConsumerWidget { ); } +// ============ 终端设置页 ============ + +class _TerminalPage extends ConsumerWidget { + const _TerminalPage(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l = ref.watch(l10nProvider); + final s = ref.watch(settingsProvider); + final notifier = ref.read(settingsProvider.notifier); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l.t('settings.term.title'), + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.text)), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: AppColors.mantle, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.surface0), + ), + child: Column( + children: [ + // 选中即复制 / 右键粘贴 + _switchRow(l.t('settings.term.selectToCopy'), s.selectToCopy, + (v) { + notifier.updateTerminal(selectToCopy: v, rightClickPaste: v); + }), + const Divider(height: 1, color: AppColors.surface0), + // 光标闪烁 + _switchRow(l.t('settings.term.cursorBlink'), s.cursorBlink, + (v) => notifier.updateTerminal(cursorBlink: v)), + const Divider(height: 1, color: AppColors.surface0), + // 光标样式(三选) + _rowWrap( + l.t('settings.term.cursorStyle'), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _seg(l.t('settings.term.cursorBlock'), + s.cursorStyle == CursorStyle.block, + () => notifier.updateTerminal( + cursorStyle: CursorStyle.block)), + const SizedBox(width: 6), + _seg(l.t('settings.term.cursorUnderline'), + s.cursorStyle == CursorStyle.underline, + () => notifier.updateTerminal( + cursorStyle: CursorStyle.underline)), + const SizedBox(width: 6), + _seg(l.t('settings.term.cursorBar'), + s.cursorStyle == CursorStyle.bar, + () => notifier.updateTerminal( + cursorStyle: CursorStyle.bar)), + ], + ), + ), + const Divider(height: 1, color: AppColors.surface0), + // 字号(± 步进) + _rowWrap( + l.t('settings.term.fontSize'), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _stepBtn(Icons.remove, () { + final n = (s.termFontSize - 1).clamp(8.0, 28.0); + notifier.updateTerminal(termFontSize: n); + }), + Container( + width: 44, + alignment: Alignment.center, + child: Text(s.termFontSize.toStringAsFixed(0), + style: const TextStyle( + fontSize: 13, color: AppColors.text)), + ), + _stepBtn(Icons.add, () { + final n = (s.termFontSize + 1).clamp(8.0, 28.0); + notifier.updateTerminal(termFontSize: n); + }), + ], + ), + ), + ], + ), + ), + ], + ); + } + + // 开关行 + Widget _switchRow(String label, bool value, ValueChanged onChanged) => + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Expanded( + child: Text(label, + style: const TextStyle(fontSize: 13, color: AppColors.text)), + ), + Switch( + value: value, + onChanged: onChanged, + activeThumbColor: AppColors.crust, + activeTrackColor: AppColors.blue, + ), + ], + ), + ); + + // 标签 + 右侧自定义控件行 + Widget _rowWrap(String label, Widget trailing) => Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + Expanded( + child: Text(label, + style: const TextStyle(fontSize: 13, color: AppColors.text)), + ), + trailing, + ], + ), + ); + + // 分段选择按钮 + Widget _seg(String label, bool active, VoidCallback onTap) => InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 6), + decoration: BoxDecoration( + color: active ? AppColors.surface1 : AppColors.base, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: active ? AppColors.blue : AppColors.surface0), + ), + child: Text(label, + style: TextStyle( + fontSize: 12, + color: active ? AppColors.text : AppColors.subtext)), + ), + ); + + // 步进按钮 + Widget _stepBtn(IconData icon, VoidCallback onTap) => InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.surface0, + borderRadius: BorderRadius.circular(6), + ), + child: Icon(icon, size: 16, color: AppColors.text), + ), + ); +} + // ============ 共用小部件 ============ Widget _label(String text) => Padding( diff --git a/clients/app/lib/ui/terminal_pane.dart b/clients/app/lib/ui/terminal_pane.dart index 0fe6046..108fd3d 100644 --- a/clients/app/lib/ui/terminal_pane.dart +++ b/clients/app/lib/ui/terminal_pane.dart @@ -2,11 +2,13 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:xterm/xterm.dart'; +import 'package:xterm/xterm.dart' hide CursorStyle; import 'package:dartssh2/dartssh2.dart'; import '../theme.dart'; import '../core/ssh.dart'; +import '../core/settings_store.dart'; import '../state/connection_provider.dart'; +import '../state/settings_provider.dart'; /// 一台主机的终端会话:独立的 xterm 缓冲 + PTY shell + 选区控制器。 /// 按主机保活,切主机切显示对应 Terminal,旧的 PTY 留在后台不断。 @@ -17,17 +19,23 @@ class _TermSession { bool starting = false; VoidCallback? _selListener; - /// 选中自动复制(Linux 终端风格):选区变化且非空 → 写入系统剪贴板 - void enableAutoCopy() { - _selListener = () { - final sel = controller.selection; - if (sel == null) return; - final text = terminal.buffer.getText(sel); - if (text.trim().isNotEmpty) { - Clipboard.setData(ClipboardData(text: text)); - } - }; - controller.addListener(_selListener!); + /// 选中自动复制(Linux 终端风格):选区变化且非空 → 写入系统剪贴板。 + /// 受设置控制,可动态开关。 + void setAutoCopy(bool on) { + if (on && _selListener == null) { + _selListener = () { + final sel = controller.selection; + if (sel == null) return; + final text = terminal.buffer.getText(sel); + if (text.trim().isNotEmpty) { + Clipboard.setData(ClipboardData(text: text)); + } + }; + controller.addListener(_selListener!); + } else if (!on && _selListener != null) { + controller.removeListener(_selListener!); + _selListener = null; + } } void dispose() { @@ -59,7 +67,6 @@ class _TerminalPaneState extends ConsumerState { s.terminal.onOutput = (data) => s.shell?.write(utf8.encode(data)); s.terminal.onResize = (w, h, pw, ph) => s.shell?.resizeTerminal(w, h, pw, ph); - s.enableAutoCopy(); // 选中即复制 return s; }); @@ -109,6 +116,7 @@ class _TerminalPaneState extends ConsumerState { @override Widget build(BuildContext context) { final conn = ref.watch(connectionProvider); + final cfg = ref.watch(settingsProvider); // 终端设置 final hostId = conn.host?.id; // 池里已不存在的主机(被 LRU 踢掉/断开),清理其终端会话 @@ -134,16 +142,29 @@ class _TerminalPaneState extends ConsumerState { WidgetsBinding.instance .addPostFrameCallback((_) => _startShell(hostId, conn.client!)); } + // 按设置同步「选中即复制」开关 + s.setAutoCopy(cfg.selectToCopy); + + // 光标样式映射 + final cursorType = switch (cfg.cursorStyle) { + CursorStyle.underline => TerminalCursorType.underline, + CursorStyle.bar => TerminalCursorType.verticalBar, + _ => TerminalCursorType.block, + }; return Container( color: AppColors.crust, child: TerminalView( s.terminal, controller: s.controller, - // 右键粘贴:剪贴板内容写入 shell - onSecondaryTapDown: (details, offset) => _paste(s), - textStyle: const TerminalStyle( - fontSize: 12.5, + // 右键粘贴:按设置开关 + onSecondaryTapDown: + cfg.rightClickPaste ? (details, offset) => _paste(s) : null, + cursorType: cursorType, + // alwaysShowCursor=true 即不闪烁;闪烁则 false + alwaysShowCursor: !cfg.cursorBlink, + textStyle: TerminalStyle( + fontSize: cfg.termFontSize, fontFamily: kMonoFont, ), theme: _termTheme, From af7576e7436c1a696d85840d0de594c445ec7d27 Mon Sep 17 00:00:00 2001 From: xiaowen <0928du@gmail.com> Date: Fri, 26 Jun 2026 20:03:07 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E5=9B=BD=E9=99=85=E5=8C=96=EF=BC=9AUI?= =?UTF-8?q?=E6=96=87=E6=A1=88=E4=B8=AD=E8=8B=B1=E5=8F=8C=E8=AF=AD=E5=88=87?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - i18n.dart 字典补齐全部UI文案key(顶栏/左栏/智能体/各对话框/右栏/状态栏/终端) - L10n.t 支持 {占位符} 参数替换 - 全UI文件硬编码中文抽成 l.t('key'):top_bar/left_bar/status_bar/ai_pane/dialogs/ keys_dialog/forward_dialog/right_bar/security_dialog/audit_dialog/snippets_dialog/ sftp_view/terminal_pane/settings_center - 拿不到ref的辅助方法统一加 L10n 参数(_connSeg/_inputBox/_ReasoningTile/_logToolbar等) - 通用页语言切换即时生效(settingsProvider 重建全应用) - 有意保留:core层发给LLM的prompt/上下文/规则说明(不随UI语言变)、 app_shell docking标题(架构限制)、sftp演示标签 Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/app/lib/core/i18n.dart | 228 +++++++++++++++++++++++- clients/app/lib/ui/ai_pane.dart | 107 ++++++----- clients/app/lib/ui/audit_dialog.dart | 37 ++-- clients/app/lib/ui/dialogs.dart | 59 +++--- clients/app/lib/ui/forward_dialog.dart | 74 ++++---- clients/app/lib/ui/keys_dialog.dart | 76 ++++---- clients/app/lib/ui/left_bar.dart | 46 +++-- clients/app/lib/ui/right_bar.dart | 93 +++++----- clients/app/lib/ui/security_dialog.dart | 41 +++-- clients/app/lib/ui/settings_center.dart | 8 +- clients/app/lib/ui/sftp_view.dart | 30 ++-- clients/app/lib/ui/snippets_dialog.dart | 40 +++-- clients/app/lib/ui/status_bar.dart | 33 ++-- clients/app/lib/ui/terminal_pane.dart | 6 +- clients/app/lib/ui/top_bar.dart | 19 +- 15 files changed, 598 insertions(+), 299 deletions(-) diff --git a/clients/app/lib/core/i18n.dart b/clients/app/lib/core/i18n.dart index 0e4c9e7..89095b7 100644 --- a/clients/app/lib/core/i18n.dart +++ b/clients/app/lib/core/i18n.dart @@ -62,6 +62,224 @@ const Map> _dict = { 'settings.term.cursorBlock': {AppLang.zh: '方块', AppLang.en: 'Block'}, 'settings.term.cursorUnderline': {AppLang.zh: '下划线', AppLang.en: 'Underline'}, 'settings.term.cursorBar': {AppLang.zh: '竖线', AppLang.en: 'Bar'}, + + // ========== 通用复用 ========== + 'common.confirm': {AppLang.zh: '确认', AppLang.en: 'Confirm'}, + 'common.clear': {AppLang.zh: '清空', AppLang.en: 'Clear'}, + 'common.all': {AppLang.zh: '全部', AppLang.en: 'All'}, + 'state.denied': {AppLang.zh: '已阻止', AppLang.en: 'Blocked'}, + 'state.ask': {AppLang.zh: '待确认', AppLang.en: 'Pending'}, + 'state.allowed': {AppLang.zh: '已放行', AppLang.en: 'Allowed'}, + + // ========== 顶栏 ========== + 'top.search': {AppLang.zh: '搜索主机…', AppLang.en: 'Search hosts…'}, + 'top.connect': {AppLang.zh: '连接', AppLang.en: 'Connect'}, + 'top.newHost': {AppLang.zh: '新建主机', AppLang.en: 'New Host'}, + 'top.split': {AppLang.zh: '分屏', AppLang.en: 'Split'}, + + // ========== 面板标题 ========== + 'panel.hosts': {AppLang.zh: '主机', AppLang.en: 'Hosts'}, + 'panel.terminal': {AppLang.zh: '终端', AppLang.en: 'Terminal'}, + 'panel.agent': {AppLang.zh: '智能体', AppLang.en: 'Agent'}, + 'panel.side': {AppLang.zh: '面板', AppLang.en: 'Panel'}, + + // ========== 左栏 ========== + 'left.noHosts': {AppLang.zh: '暂无主机,点 + 添加', AppLang.en: 'No hosts, click + to add'}, + 'left.noMatch': { + AppLang.zh: '未找到匹配「{q}」的主机', + AppLang.en: 'No host matching "{q}"' + }, + 'left.connectFail': {AppLang.zh: '连接失败:{err}', AppLang.en: 'Connect failed: {err}'}, + 'left.snippets': {AppLang.zh: '命令片段', AppLang.en: 'Snippets'}, + 'left.keys': {AppLang.zh: '密钥库', AppLang.en: 'Keys'}, + 'left.forward': {AppLang.zh: '端口转发', AppLang.en: 'Port Forwarding'}, + 'left.security': {AppLang.zh: '安全策略', AppLang.en: 'Security'}, + 'left.audit': {AppLang.zh: '审计日志', AppLang.en: 'Audit Log'}, + 'left.deleteHost': {AppLang.zh: '删除主机', AppLang.en: 'Delete Host'}, + 'left.deleteHostConfirm': { + AppLang.zh: '确定删除「{name}」({addr})?此操作不可撤销。', + AppLang.en: 'Delete "{name}" ({addr})? This cannot be undone.' + }, + + // ========== 智能体面板 ========== + 'ai.empty': { + AppLang.zh: '连接主机后,输入运维任务开始对话', + AppLang.en: 'Connect a host, then enter an ops task to start' + }, + 'ai.error': {AppLang.zh: '错误:{err}', AppLang.en: 'Error: {err}'}, + 'ai.agent': {AppLang.zh: '智能体', AppLang.en: 'Agent'}, + 'ai.switchModel': {AppLang.zh: '切换模型', AppLang.en: 'Switch model'}, + 'ai.askTitle': {AppLang.zh: '需要确认 · 该命令需人工放行', AppLang.en: 'Confirmation needed · manual approval required'}, + 'ai.gateVerdict': {AppLang.zh: '门禁判定:ASK — {reason}', AppLang.en: 'Guard: ASK — {reason}'}, + 'ai.allow': {AppLang.zh: '允许执行', AppLang.en: 'Allow'}, + 'ai.deny': {AppLang.zh: '拒绝', AppLang.en: 'Deny'}, + 'ai.blockedTitle': {AppLang.zh: '已阻止 · 高危命令', AppLang.en: 'Blocked · dangerous command'}, + 'ai.inputHint': {AppLang.zh: '输入运维任务,或 @ 引用主机…', AppLang.en: 'Enter an ops task, or @ to reference a host…'}, + 'ai.interrupt': {AppLang.zh: '中断', AppLang.en: 'Stop'}, + 'ai.send': {AppLang.zh: '↵ 发送', AppLang.en: '↵ Send'}, + 'ai.sendKey': {AppLang.zh: '⏎ 发送', AppLang.en: '⏎ Send'}, + 'ai.newlineKey': {AppLang.zh: '⇧⏎ 换行', AppLang.en: '⇧⏎ Newline'}, + 'ai.escKey': {AppLang.zh: 'Esc 中断', AppLang.en: 'Esc Stop'}, + 'ai.cmdK': {AppLang.zh: '⌘K 命令面板', AppLang.en: '⌘K Commands'}, + 'ai.thinkingDone': {AppLang.zh: '思考 {sec}s', AppLang.en: 'Thought {sec}s'}, + 'ai.thinking': {AppLang.zh: '思考中…', AppLang.en: 'Thinking…'}, + 'ai.thinkProcess': {AppLang.zh: '思考过程', AppLang.en: 'Reasoning'}, + + // ========== 审计对话框 ========== + 'audit.title': {AppLang.zh: '审计日志', AppLang.en: 'Audit Log'}, + 'audit.count': {AppLang.zh: '共 {n} 条', AppLang.en: '{n} entries'}, + 'audit.empty': {AppLang.zh: '暂无审计记录', AppLang.en: 'No audit records'}, + 'audit.notExecuted': {AppLang.zh: ' · 未执行', AppLang.en: ' · not executed'}, + + // ========== 新建主机对话框 ========== + 'host.new': {AppLang.zh: '新建主机', AppLang.en: 'New Host'}, + 'host.alias': {AppLang.zh: '别名(可选)', AppLang.en: 'Alias (optional)'}, + 'host.address': {AppLang.zh: '主机地址', AppLang.en: 'Host Address'}, + 'host.addressHint': {AppLang.zh: '10.0.1.21 或 example.com', AppLang.en: '10.0.1.21 or example.com'}, + 'host.port': {AppLang.zh: '端口', AppLang.en: 'Port'}, + 'host.user': {AppLang.zh: '用户名', AppLang.en: 'Username'}, + 'host.authMode': {AppLang.zh: '认证方式', AppLang.en: 'Auth Method'}, + 'host.password': {AppLang.zh: '密码', AppLang.en: 'Password'}, + 'host.key': {AppLang.zh: '密钥', AppLang.en: 'Key'}, + 'host.keyEmpty': { + AppLang.zh: '密钥库为空,请先到「密钥库」添加密钥', + AppLang.en: 'No keys. Add one in Keys first.' + }, + 'host.selectKey': {AppLang.zh: '选择密钥', AppLang.en: 'Select Key'}, + 'host.pleaseSelect': {AppLang.zh: '请选择…', AppLang.en: 'Please select…'}, + + // ========== 端口转发对话框 ========== + 'fwd.title': {AppLang.zh: '端口转发', AppLang.en: 'Port Forwarding'}, + 'fwd.count': {AppLang.zh: '共 {n} 条', AppLang.en: '{n} tunnels'}, + 'fwd.addTunnel': {AppLang.zh: '添加隧道', AppLang.en: 'Add Tunnel'}, + 'fwd.boundHint': { + AppLang.zh: '隧道依附当前连接:{host}。等价 ssh -L 本地→远程,仅本机可访问。', + AppLang.en: 'Tunnel bound to current connection: {host}. Equivalent to ssh -L, localhost only.' + }, + 'fwd.notConnected': { + AppLang.zh: '未连接主机。连接后才能启动隧道。', + AppLang.en: 'Not connected. Connect a host to start tunnels.' + }, + 'fwd.empty': {AppLang.zh: '暂无隧道,点「添加隧道」新建', AppLang.en: 'No tunnels, click "Add Tunnel"'}, + 'fwd.running': {AppLang.zh: '运行中', AppLang.en: 'Running'}, + 'fwd.stopped': {AppLang.zh: '已停止', AppLang.en: 'Stopped'}, + 'fwd.start': {AppLang.zh: '启动', AppLang.en: 'Start'}, + 'fwd.stop': {AppLang.zh: '停止', AppLang.en: 'Stop'}, + 'fwd.addDesc': { + AppLang.zh: '本地端口的连接经 SSH 转发到远程地址。常用于访问远端内网服务(如数据库)。', + AppLang.en: 'Forward a local port through SSH to a remote address. Useful for remote internal services (e.g. databases).' + }, + 'fwd.localPort': {AppLang.zh: '本地端口', AppLang.en: 'Local Port'}, + 'fwd.localPortHint': {AppLang.zh: '如 13306', AppLang.en: 'e.g. 13306'}, + 'fwd.remoteHost': {AppLang.zh: '远程地址(从远端主机视角)', AppLang.en: 'Remote Host (from remote view)'}, + 'fwd.remoteHostHint': {AppLang.zh: '127.0.0.1 或内网 IP', AppLang.en: '127.0.0.1 or internal IP'}, + 'fwd.remotePort': {AppLang.zh: '远程端口', AppLang.en: 'Remote Port'}, + 'fwd.remotePortHint': {AppLang.zh: '如 3306', AppLang.en: 'e.g. 3306'}, + 'fwd.errLocalPort': {AppLang.zh: '本地端口不合法(1-65535)', AppLang.en: 'Invalid local port (1-65535)'}, + 'fwd.errRemotePort': {AppLang.zh: '远程端口不合法(1-65535)', AppLang.en: 'Invalid remote port (1-65535)'}, + 'fwd.errRemoteHost': {AppLang.zh: '远程地址不能为空', AppLang.en: 'Remote host required'}, + 'fwd.addStart': {AppLang.zh: '添加并启动', AppLang.en: 'Add & Start'}, + + // ========== 密钥库对话框 ========== + 'keys.title': {AppLang.zh: '密钥库', AppLang.en: 'Keys'}, + 'keys.count': {AppLang.zh: '共 {n} 把', AppLang.en: '{n} keys'}, + 'keys.add': {AppLang.zh: '添加密钥', AppLang.en: 'Add Key'}, + 'keys.empty': {AppLang.zh: '暂无密钥,点「添加密钥」粘贴私钥 PEM', AppLang.en: 'No keys. Click "Add Key" to paste a PEM.'}, + 'keys.withPassphrase': {AppLang.zh: '🔒 带 passphrase · ', AppLang.en: '🔒 with passphrase · '}, + 'keys.usedBy': {AppLang.zh: '{n} 台主机使用', AppLang.en: 'used by {n} hosts'}, + 'keys.unused': {AppLang.zh: '未被使用', AppLang.en: 'unused'}, + 'keys.deleteKey': {AppLang.zh: '删除密钥', AppLang.en: 'Delete Key'}, + 'keys.deleteUsed': { + AppLang.zh: '密钥「{name}」正被 {n} 台主机使用,删除后这些主机将解除密钥绑定(需重新配置认证)。确定删除?', + AppLang.en: 'Key "{name}" is used by {n} hosts. Deleting unbinds them (re-config needed). Delete?' + }, + 'keys.deleteConfirm': { + AppLang.zh: '确定删除密钥「{name}」?此操作不可撤销。', + AppLang.en: 'Delete key "{name}"? This cannot be undone.' + }, + 'keys.name': {AppLang.zh: '名称', AppLang.en: 'Name'}, + 'keys.pem': {AppLang.zh: '私钥(PEM,粘贴 -----BEGIN ... 全文)', AppLang.en: 'Private Key (PEM, paste full -----BEGIN ...)'}, + 'keys.passphrase': {AppLang.zh: 'passphrase(私钥无加密则留空)', AppLang.en: 'passphrase (leave empty if none)'}, + 'keys.errPem': {AppLang.zh: '私钥格式不对,应以 -----BEGIN 开头', AppLang.en: 'Invalid key, must start with -----BEGIN'}, + 'keys.unnamed': {AppLang.zh: '未命名密钥', AppLang.en: 'Unnamed Key'}, + + // ========== 右栏(安全/文件/监控) ========== + 'right.tabSec': {AppLang.zh: '安全', AppLang.en: 'Security'}, + 'right.tabFiles': {AppLang.zh: '文件', AppLang.en: 'Files'}, + 'right.tabMon': {AppLang.zh: '监控', AppLang.en: 'Monitor'}, + 'right.hits': {AppLang.zh: '{n} 次', AppLang.en: '{n}×'}, + 'right.rulesTitle': {AppLang.zh: '门禁规则(按严格度)', AppLang.en: 'Guard Rules (by severity)'}, + 'right.blockHistory': {AppLang.zh: '阻止历史', AppLang.en: 'Block History'}, + 'right.noBlock': {AppLang.zh: '暂无阻止记录', AppLang.en: 'No block records'}, + 'right.tempAllow': {AppLang.zh: '临时放行', AppLang.en: 'Temp allow'}, + 'right.filesEmpty': {AppLang.zh: '连接主机后浏览远程文件', AppLang.en: 'Connect a host to browse remote files'}, + 'right.loadFail': {AppLang.zh: '加载失败:{err}', AppLang.en: 'Load failed: {err}'}, + 'right.emptyDir': {AppLang.zh: '(空目录)', AppLang.en: '(empty)'}, + 'right.monEmpty': {AppLang.zh: '连接主机后查看实时监控', AppLang.en: 'Connect a host to view live monitoring'}, + 'right.resUsage': {AppLang.zh: '资源占用', AppLang.en: 'Resource Usage'}, + 'right.mem': {AppLang.zh: '内存', AppLang.en: 'Memory'}, + 'right.disk': {AppLang.zh: '磁盘 /', AppLang.en: 'Disk /'}, + 'right.load': {AppLang.zh: '负载', AppLang.en: 'Load'}, + 'right.network': {AppLang.zh: '网络', AppLang.en: 'Network'}, + 'right.netIn': {AppLang.zh: '↓ 入站', AppLang.en: '↓ In'}, + 'right.netOut': {AppLang.zh: '↑ 出站', AppLang.en: '↑ Out'}, + 'right.sampleFail': {AppLang.zh: '采样失败:{err}', AppLang.en: 'Sampling failed: {err}'}, + + // ========== 安全策略对话框 ========== + 'sec.title': {AppLang.zh: '安全策略', AppLang.en: 'Security Policy'}, + 'sec.subtitle': {AppLang.zh: '命令门禁规则', AppLang.en: 'Command guard rules'}, + 'sec.principle1': { + AppLang.zh: '判定顺序:先查 DENY(命中即拒)→ 再看 ASK(执行前确认)→ 默认 ALLOW。', + AppLang.en: 'Order: DENY (reject on match) → ASK (confirm first) → default ALLOW.' + }, + 'sec.principle2': { + AppLang.zh: '复合命令拆段逐查,取最严结果。安全检查是独立代码路径,模型越狱也绕不过。', + AppLang.en: 'Compound commands split & checked per segment, strictest wins. Guard is an independent code path — jailbreaks cannot bypass it.' + }, + 'sec.denySection': {AppLang.zh: 'DENY · 直接拒绝({n} 条)', AppLang.en: 'DENY · reject ({n})'}, + 'sec.askSection': {AppLang.zh: 'ASK · 执行前确认({n} 条)', AppLang.en: 'ASK · confirm first ({n})'}, + 'sec.allowSection': {AppLang.zh: 'ALLOW · 默认放行', AppLang.en: 'ALLOW · default'}, + 'sec.allowDesc': { + AppLang.zh: '未命中以上规则的只读/安全命令(ls · cat · df · tail 等)', + AppLang.en: 'Read-only/safe commands not matching above (ls · cat · df · tail …)' + }, + + // ========== 命令片段对话框 ========== + 'snip.title': {AppLang.zh: '命令片段', AppLang.en: 'Snippets'}, + 'snip.clickToFill': {AppLang.zh: '点击填入输入框', AppLang.en: 'Click to fill input'}, + 'snip.empty': {AppLang.zh: '暂无片段,点下方新增', AppLang.en: 'No snippets, add one below'}, + 'snip.addNew': {AppLang.zh: '新增片段', AppLang.en: 'New Snippet'}, + 'snip.nameOpt': {AppLang.zh: '名称(可选)', AppLang.en: 'Name (optional)'}, + 'snip.cmdHint': {AppLang.zh: '命令,如 df -h', AppLang.en: 'Command, e.g. df -h'}, + + // ========== SFTP ========== + 'sftp.local': {AppLang.zh: '本地', AppLang.en: 'Local'}, + 'sftp.upload': {AppLang.zh: '上传', AppLang.en: 'Upload'}, + 'sftp.download': {AppLang.zh: '下载', AppLang.en: 'Download'}, + 'sftp.hint': { + AppLang.zh: '双击文件用内置编辑器打开 · 拖拽可跨栏传输', + AppLang.en: 'Double-click to open in editor · drag to transfer across panes' + }, + + // ========== 状态栏 ========== + 'status.guard': {AppLang.zh: '门禁 ', AppLang.en: 'Guard '}, + 'status.blocked': {AppLang.zh: '阻止{n}', AppLang.en: 'blocked {n}'}, + 'status.pending': {AppLang.zh: '待确认{n}', AppLang.en: 'pending {n}'}, + 'status.model': {AppLang.zh: '模型 ', AppLang.en: 'Model '}, + 'status.notConfigured': {AppLang.zh: '未配置', AppLang.en: 'Not set'}, + 'status.context': {AppLang.zh: '上下文 ', AppLang.en: 'Context '}, + 'status.rounds': {AppLang.zh: '{n} 轮', AppLang.en: '{n} rounds'}, + 'status.connected': {AppLang.zh: '已连接', AppLang.en: 'Connected'}, + 'status.connecting': {AppLang.zh: '连接中…', AppLang.en: 'Connecting…'}, + 'status.connFail': {AppLang.zh: '连接失败', AppLang.en: 'Connect failed'}, + 'status.disconnected': {AppLang.zh: '未连接', AppLang.en: 'Disconnected'}, + + // ========== 终端面板 ========== + 'term.startFail': {AppLang.zh: '[终端启动失败: {err}]', AppLang.en: '[Terminal start failed: {err}]'}, + 'term.connectFirst': {AppLang.zh: '连接主机后可在此使用交互式终端', AppLang.en: 'Connect a host to use the interactive terminal'}, + + // ========== 设置中心 ========== + 'settings.comingSoon': {AppLang.zh: '即将推出…', AppLang.en: 'Coming soon…'}, }; /// 当前语言下取文案 @@ -69,9 +287,13 @@ class L10n { final AppLang lang; const L10n(this.lang); - String t(String key) { + /// 取文案。可选 params 替换占位符:译文里写 {name},传 {'name': 'x'} 即替换。 + String t(String key, [Map? params]) { final entry = _dict[key]; - if (entry == null) return key; // 没收录就显示 key,便于发现遗漏 - return entry[lang] ?? entry[AppLang.zh] ?? key; + var s = entry == null ? key : (entry[lang] ?? entry[AppLang.zh] ?? key); + if (params != null) { + params.forEach((k, v) => s = s.replaceAll('{$k}', v)); + } + return s; } } diff --git a/clients/app/lib/ui/ai_pane.dart b/clients/app/lib/ui/ai_pane.dart index bae27f1..9d12840 100644 --- a/clients/app/lib/ui/ai_pane.dart +++ b/clients/app/lib/ui/ai_pane.dart @@ -6,6 +6,8 @@ import '../theme.dart'; import '../state/agent_provider.dart'; import '../state/snippet_provider.dart'; import '../state/config_provider.dart'; +import '../state/settings_provider.dart'; +import '../core/i18n.dart'; /// AI 对话面板 —— 对话流 + 工具卡片 + 门禁卡片 + 输入框 /// 对应设计稿 .pane.ai。三种卡片(tool/ask/blocked)是门禁可视化核心。 @@ -49,6 +51,7 @@ class _AiPaneState extends ConsumerState { @override Widget build(BuildContext context) { final st = ref.watch(agentProvider); + final l = ref.watch(l10nProvider); // 新消息进来自动滚到底 ref.listen(agentProvider, (prev, next) => _scrollToBottom()); // 命令片段等外部请求:把文本填进输入框并聚焦末尾 @@ -73,7 +76,7 @@ class _AiPaneState extends ConsumerState { if (st.pendingAsk != null) { children.add(_askCard( cmd: st.pendingAsk!.command, - why: '门禁判定:ASK — ${st.pendingAsk!.reason}', + why: l.t('ai.gateVerdict', {'reason': st.pendingAsk!.reason}), )); children.add(const SizedBox(height: 12)); } @@ -88,10 +91,10 @@ class _AiPaneState extends ConsumerState { // 对话流 Expanded( child: st.items.isEmpty && st.pendingAsk == null - ? const Center( - child: Text('连接主机后,输入运维任务开始对话', - style: - TextStyle(fontSize: 12, color: AppColors.overlay)), + ? Center( + child: Text(l.t('ai.empty'), + style: const TextStyle( + fontSize: 12, color: AppColors.overlay)), ) : SingleChildScrollView( controller: _scrollCtrl, @@ -108,10 +111,10 @@ class _AiPaneState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), color: AppColors.red.withValues(alpha: .12), - child: Text('错误:${st.error}', + child: Text(l.t('ai.error', {'err': '${st.error}'}), style: const TextStyle(fontSize: 11, color: AppColors.red)), ), - _inputBox(st.running), + _inputBox(st.running, l), ], ), ); @@ -124,7 +127,10 @@ class _AiPaneState extends ConsumerState { return _userBubble(it.text); case ChatItemKind.reasoning: return _ReasoningTile( - text: it.text, seconds: it.reasoningSec ?? 0, live: live); + text: it.text, + seconds: it.reasoningSec ?? 0, + live: live, + l: ref.read(l10nProvider)); case ChatItemKind.assistant: return _assistantMsg(text: it.text); case ChatItemKind.tool: @@ -166,6 +172,7 @@ class _AiPaneState extends ConsumerState { // 顶部模型切换条:显示当前模型,下拉切换已配置 key 的供应商 Widget _modelBar() { final cfg = ref.watch(configProvider); + final l = ref.watch(l10nProvider); final active = cfg.activeProvider; // 只列已配置 key 的供应商;当前激活的即使没 key 也显示 final selectable = cfg.providers @@ -183,8 +190,8 @@ class _AiPaneState extends ConsumerState { const Icon(Icons.smart_toy_outlined, size: 14, color: AppColors.subtext), const SizedBox(width: 7), - const Text('智能体', - style: TextStyle(fontSize: 12, color: AppColors.subtext)), + Text(l.t('ai.agent'), + style: const TextStyle(fontSize: 12, color: AppColors.subtext)), const SizedBox(width: 8), // 模型下拉 Expanded( @@ -192,7 +199,7 @@ class _AiPaneState extends ConsumerState { alignment: Alignment.centerLeft, child: PopupMenuButton( initialValue: active.id, - tooltip: '切换模型', + tooltip: l.t('ai.switchModel'), color: AppColors.mantle, onSelected: (id) => ref.read(configProvider.notifier).setActiveProvider(id), @@ -253,12 +260,13 @@ class _AiPaneState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - children: const [ - Icon(Icons.smart_toy_outlined, + children: [ + const Icon(Icons.smart_toy_outlined, size: 13, color: AppColors.overlay), - SizedBox(width: 5), - Text('智能体', - style: TextStyle(fontSize: 10.5, color: AppColors.overlay)), + const SizedBox(width: 5), + Text(ref.read(l10nProvider).t('ai.agent'), + style: const TextStyle( + fontSize: 10.5, color: AppColors.overlay)), ], ), const SizedBox(height: 4), @@ -289,12 +297,12 @@ class _AiPaneState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - children: const [ - Icon(Icons.warning_amber_rounded, + children: [ + const Icon(Icons.warning_amber_rounded, size: 14, color: AppColors.yellow), - SizedBox(width: 6), - Text('需要确认 · 该命令需人工放行', - style: TextStyle( + const SizedBox(width: 6), + Text(ref.read(l10nProvider).t('ai.askTitle'), + style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.yellow)), @@ -313,11 +321,11 @@ class _AiPaneState extends ConsumerState { const SizedBox(height: 8), Row( children: [ - _cardBtn('允许执行', danger: true, + _cardBtn(ref.read(l10nProvider).t('ai.allow'), danger: true, onTap: () => ref.read(agentProvider.notifier).resolveAsk(true)), const SizedBox(width: 8), - _cardBtn('拒绝', ghost: true, + _cardBtn(ref.read(l10nProvider).t('ai.deny'), ghost: true, onTap: () => ref.read(agentProvider.notifier).resolveAsk(false)), ], @@ -338,11 +346,11 @@ class _AiPaneState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - children: const [ - Icon(Icons.block, size: 14, color: AppColors.red), - SizedBox(width: 6), - Text('已阻止 · 高危命令', - style: TextStyle( + children: [ + const Icon(Icons.block, size: 14, color: AppColors.red), + const SizedBox(width: 6), + Text(ref.read(l10nProvider).t('ai.blockedTitle'), + style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.red)), @@ -384,7 +392,7 @@ class _AiPaneState extends ConsumerState { ); // AI 输入框 + 快捷键提示。running 时禁用并显示中断。 - Widget _inputBox(bool running) => Container( + Widget _inputBox(bool running, L10n l) => Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: const BoxDecoration( color: AppColors.mantle, @@ -410,11 +418,11 @@ class _AiPaneState extends ConsumerState { onSubmitted: (_) => _send(), style: const TextStyle( fontSize: 13, color: AppColors.text), - decoration: const InputDecoration( + decoration: InputDecoration( isDense: true, border: InputBorder.none, - hintText: '输入运维任务,或 @ 引用主机…', - hintStyle: TextStyle( + hintText: l.t('ai.inputHint'), + hintStyle: const TextStyle( fontSize: 13, color: AppColors.overlay), ), ), @@ -425,27 +433,27 @@ class _AiPaneState extends ConsumerState { ? InkWell( onTap: () => ref.read(agentProvider.notifier).abort(), - child: const Row( + child: Row( mainAxisSize: MainAxisSize.min, children: [ - SizedBox( + const SizedBox( width: 12, height: 12, child: CircularProgressIndicator( strokeWidth: 1.5, color: AppColors.yellow), ), - SizedBox(width: 6), - Text('中断', - style: TextStyle( + const SizedBox(width: 6), + Text(l.t('ai.interrupt'), + style: const TextStyle( fontSize: 12, color: AppColors.yellow)), ], ), ) : InkWell( onTap: _send, - child: const Text('↵ 发送', - style: TextStyle( + child: Text(l.t('ai.send'), + style: const TextStyle( fontFamily: kMonoFont, fontSize: 12, color: AppColors.blue)), @@ -460,11 +468,11 @@ class _AiPaneState extends ConsumerState { child: Wrap( spacing: 14, runSpacing: 4, - children: const [ - Text('⏎ 发送'), - Text('⇧⏎ 换行'), - Text('Esc 中断'), - Text('⌘K 命令面板'), + children: [ + Text(l.t('ai.sendKey')), + Text(l.t('ai.newlineKey')), + Text(l.t('ai.escKey')), + Text(l.t('ai.cmdK')), ], ), ), @@ -479,8 +487,12 @@ class _ReasoningTile extends StatefulWidget { final String text; final int seconds; final bool live; // 是否「真正在思考」(运行中且为最后一项),否则是已结束/历史块 + final L10n l; const _ReasoningTile( - {required this.text, required this.seconds, this.live = false}); + {required this.text, + required this.seconds, + required this.l, + this.live = false}); @override State<_ReasoningTile> createState() => _ReasoningTileState(); @@ -491,10 +503,11 @@ class _ReasoningTileState extends State<_ReasoningTile> { @override Widget build(BuildContext context) { + final l = widget.l; // 有秒数 → 「思考 Xs」;无秒数:运行中显示「思考中…」,历史/已结束显示「思考过程」 final title = widget.seconds > 0 - ? '思考 ${widget.seconds}s' - : (widget.live ? '思考中…' : '思考过程'); + ? l.t('ai.thinkingDone', {'sec': '${widget.seconds}'}) + : (widget.live ? l.t('ai.thinking') : l.t('ai.thinkProcess')); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/clients/app/lib/ui/audit_dialog.dart b/clients/app/lib/ui/audit_dialog.dart index 0255ee2..1d89abf 100644 --- a/clients/app/lib/ui/audit_dialog.dart +++ b/clients/app/lib/ui/audit_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../theme.dart'; import '../core/audit_store.dart'; import '../state/guard_provider.dart'; +import '../state/settings_provider.dart'; /// 审计日志对话框 —— 全局命令审计列表,支持按决策筛选 + 清空。 Future showAuditDialog(BuildContext context) { @@ -38,6 +39,7 @@ class _AuditBodyState extends ConsumerState<_AuditBody> { @override Widget build(BuildContext context) { final all = ref.watch(auditProvider); + final l = ref.watch(l10nProvider); final list = _filter == 'all' ? all : all.where((e) => e.decision == _filter).toList(); @@ -52,20 +54,20 @@ class _AuditBodyState extends ConsumerState<_AuditBody> { const Icon(Icons.receipt_long_outlined, size: 16, color: AppColors.text), const SizedBox(width: 8), - const Text('审计日志', - style: TextStyle( + Text(l.t('audit.title'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), const SizedBox(width: 8), - Text('共 ${all.length} 条', + Text(l.t('audit.count', {'n': '${all.length}'}), style: const TextStyle(fontSize: 11, color: AppColors.overlay)), const Spacer(), if (all.isNotEmpty) TextButton( onPressed: () => ref.read(auditProvider.notifier).clear(), - child: const Text('清空', - style: TextStyle(fontSize: 12, color: AppColors.red)), + child: Text(l.t('common.clear'), + style: const TextStyle(fontSize: 12, color: AppColors.red)), ), ], ), @@ -73,22 +75,22 @@ class _AuditBodyState extends ConsumerState<_AuditBody> { // 筛选标签 Row( children: [ - _chip('all', '全部'), - _chip('deny', '已阻止'), - _chip('ask', '待确认'), - _chip('allow', '已放行'), + _chip('all', l.t('common.all')), + _chip('deny', l.t('state.denied')), + _chip('ask', l.t('state.ask')), + _chip('allow', l.t('state.allowed')), ], ), const SizedBox(height: 12), // 列表 Flexible( child: list.isEmpty - ? const Padding( - padding: EdgeInsets.symmetric(vertical: 30), - child: Text('暂无审计记录', + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 30), + child: Text(l.t('audit.empty'), textAlign: TextAlign.center, - style: - TextStyle(fontSize: 12, color: AppColors.overlay)), + style: const TextStyle( + fontSize: 12, color: AppColors.overlay)), ) : ListView.separated( shrinkWrap: true, @@ -137,7 +139,9 @@ class _AuditBodyState extends ConsumerState<_AuditBody> { _ => 'ALLOW', }; - Widget _row(AuditEntry e) => Container( + Widget _row(AuditEntry e) { + final l = ref.watch(l10nProvider); + return Container( padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 8), decoration: BoxDecoration( color: AppColors.base, @@ -171,7 +175,7 @@ class _AuditBodyState extends ConsumerState<_AuditBody> { fontSize: 11.5, color: AppColors.text)), const SizedBox(height: 2), - Text('${e.host} · ${_fmtTime(e.time)}${e.executed ? '' : ' · 未执行'}', + Text('${e.host} · ${_fmtTime(e.time)}${e.executed ? '' : l.t('audit.notExecuted')}', style: const TextStyle( fontSize: 10, color: AppColors.overlay)), ], @@ -180,6 +184,7 @@ class _AuditBodyState extends ConsumerState<_AuditBody> { ], ), ); + } static String _fmtTime(DateTime t) { String two(int n) => n.toString().padLeft(2, '0'); diff --git a/clients/app/lib/ui/dialogs.dart b/clients/app/lib/ui/dialogs.dart index 5ec00a3..2e56e54 100644 --- a/clients/app/lib/ui/dialogs.dart +++ b/clients/app/lib/ui/dialogs.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../theme.dart'; import '../core/config.dart'; +import '../core/i18n.dart'; import '../state/config_provider.dart'; import '../state/key_provider.dart'; +import '../state/settings_provider.dart'; /// 暗色对话框统一外壳 Future _showDark(BuildContext context, Widget child) { @@ -112,15 +114,15 @@ Widget _title(IconData icon, String text) => Padding( ), ); -Widget _actions(BuildContext context, - {required VoidCallback onOk, String okLabel = '保存'}) => +Widget _actions(BuildContext context, L10n l, + {required VoidCallback onOk, String? okLabel}) => Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('取消', - style: TextStyle(color: AppColors.subtext)), + child: Text(l.t('common.cancel'), + style: const TextStyle(color: AppColors.subtext)), ), const SizedBox(width: 8), FilledButton( @@ -128,7 +130,7 @@ Widget _actions(BuildContext context, style: FilledButton.styleFrom( backgroundColor: AppColors.blue, foregroundColor: AppColors.crust), - child: Text(okLabel), + child: Text(okLabel ?? l.t('common.save')), ), ], ); @@ -144,6 +146,7 @@ Future showAddHostDialog(BuildContext context, WidgetRef ref) { String authMode = 'password'; String? selectedKeyId; final keys = ref.read(keyProvider); + final l = ref.watch(l10nProvider); return _showDark( context, @@ -152,35 +155,35 @@ Future showAddHostDialog(BuildContext context, WidgetRef ref) { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _title(Icons.dns_outlined, '新建主机'), - _field(alias, '别名(可选)', hint: 'web01'), - _field(host, '主机地址', hint: '10.0.1.21 或 example.com'), - _field(port, '端口', hint: '22'), - _field(user, '用户名', hint: 'root'), + _title(Icons.dns_outlined, l.t('host.new')), + _field(alias, l.t('host.alias'), hint: 'web01'), + _field(host, l.t('host.address'), hint: l.t('host.addressHint')), + _field(port, l.t('host.port'), hint: '22'), + _field(user, l.t('host.user'), hint: 'root'), // 认证方式切换 - const Padding( - padding: EdgeInsets.only(bottom: 4), - child: Text('认证方式', - style: TextStyle(fontSize: 11, color: AppColors.subtext)), + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text(l.t('host.authMode'), + style: const TextStyle(fontSize: 11, color: AppColors.subtext)), ), Row( children: [ - _authTab('password', '密码', authMode, + _authTab('password', l.t('host.password'), authMode, () => setState(() => authMode = 'password')), const SizedBox(width: 8), - _authTab('key', '密钥', authMode, + _authTab('key', l.t('host.key'), authMode, () => setState(() => authMode = 'key')), ], ), const SizedBox(height: 12), // 密码模式:密码框;密钥模式:密钥下拉 if (authMode == 'password') - _field(pwd, '密码', obscure: true) + _field(pwd, l.t('host.password'), obscure: true) else - _keyDropdown(keys, selectedKeyId, + _keyDropdown(l, keys, selectedKeyId, (id) => setState(() => selectedKeyId = id)), const SizedBox(height: 4), - _actions(ctx, okLabel: '添加', onOk: () { + _actions(ctx, l, okLabel: l.t('common.add'), onOk: () { if (host.text.trim().isEmpty) return; // 密钥模式必须选中一把密钥 if (authMode == 'key' && selectedKeyId == null) return; @@ -226,7 +229,7 @@ Widget _authTab(String id, String label, String current, VoidCallback onTap) => ); // 密钥下拉选择 -Widget _keyDropdown( +Widget _keyDropdown(L10n l, List keys, String? selectedId, ValueChanged onChanged) { if (keys.isEmpty) { return Container( @@ -236,17 +239,17 @@ Widget _keyDropdown( borderRadius: BorderRadius.circular(6), border: Border.all(color: AppColors.surface0), ), - child: const Text('密钥库为空,请先到「密钥库」添加密钥', - style: TextStyle(fontSize: 11.5, color: AppColors.overlay)), + child: Text(l.t('host.keyEmpty'), + style: const TextStyle(fontSize: 11.5, color: AppColors.overlay)), ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Padding( - padding: EdgeInsets.only(bottom: 4), - child: Text('选择密钥', - style: TextStyle(fontSize: 11, color: AppColors.subtext)), + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text(l.t('host.selectKey'), + style: const TextStyle(fontSize: 11, color: AppColors.subtext)), ), Container( padding: const EdgeInsets.symmetric(horizontal: 10), @@ -260,8 +263,8 @@ Widget _keyDropdown( value: selectedId, isExpanded: true, dropdownColor: AppColors.mantle, - hint: const Text('请选择…', - style: TextStyle(fontSize: 12, color: AppColors.overlay)), + hint: Text(l.t('host.pleaseSelect'), + style: const TextStyle(fontSize: 12, color: AppColors.overlay)), style: const TextStyle(fontSize: 13, color: AppColors.text), items: [ for (final k in keys) diff --git a/clients/app/lib/ui/forward_dialog.dart b/clients/app/lib/ui/forward_dialog.dart index a58d3b9..b70e5ab 100644 --- a/clients/app/lib/ui/forward_dialog.dart +++ b/clients/app/lib/ui/forward_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../theme.dart'; import '../state/forward_provider.dart'; import '../state/connection_provider.dart'; +import '../state/settings_provider.dart'; /// 端口转发对话框 —— 管理本地端口转发隧道(增删 + 启停)。 /// 隧道依附当前 SSH 连接(等价 ssh -L),绑定 127.0.0.1 仅本机可访问。 @@ -33,6 +34,7 @@ class _ForwardBody extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final list = ref.watch(forwardProvider); final conn = ref.watch(connectionProvider); + final l = ref.watch(l10nProvider); final hostName = conn.host?.alias?.isNotEmpty == true ? conn.host!.alias! : (conn.host?.host ?? '-'); @@ -47,13 +49,13 @@ class _ForwardBody extends ConsumerWidget { const Icon(Icons.swap_horiz_outlined, size: 16, color: AppColors.text), const SizedBox(width: 8), - const Text('端口转发', - style: TextStyle( + Text(l.t('fwd.title'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), const SizedBox(width: 8), - Text('共 ${list.length} 条', + Text(l.t('fwd.count', {'n': '${list.length}'}), style: const TextStyle(fontSize: 11, color: AppColors.overlay)), const Spacer(), TextButton.icon( @@ -65,7 +67,7 @@ class _ForwardBody extends ConsumerWidget { color: conn.isConnected ? AppColors.blue : AppColors.overlay), - label: Text('添加隧道', + label: Text(l.t('fwd.addTunnel'), style: TextStyle( fontSize: 12, color: conn.isConnected @@ -92,8 +94,8 @@ class _ForwardBody extends ConsumerWidget { Expanded( child: Text( conn.isConnected - ? '隧道依附当前连接:$hostName。等价 ssh -L 本地→远程,仅本机可访问。' - : '未连接主机。连接后才能启动隧道。', + ? l.t('fwd.boundHint', {'host': hostName}) + : l.t('fwd.notConnected'), style: const TextStyle( fontSize: 10.5, height: 1.4, color: AppColors.subtext)), ), @@ -103,12 +105,12 @@ class _ForwardBody extends ConsumerWidget { const SizedBox(height: 12), Flexible( child: list.isEmpty - ? const Padding( - padding: EdgeInsets.symmetric(vertical: 40), - child: Text('暂无隧道,点「添加隧道」新建', + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: Text(l.t('fwd.empty'), textAlign: TextAlign.center, - style: - TextStyle(fontSize: 12, color: AppColors.overlay)), + style: const TextStyle( + fontSize: 12, color: AppColors.overlay)), ) : ListView.separated( shrinkWrap: true, @@ -121,7 +123,9 @@ class _ForwardBody extends ConsumerWidget { ); } - Widget _row(WidgetRef ref, ForwardEntry e) => Container( + Widget _row(WidgetRef ref, ForwardEntry e) { + final l = ref.watch(l10nProvider); + return Container( padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 9), decoration: BoxDecoration( color: AppColors.base, @@ -161,7 +165,7 @@ class _ForwardBody extends ConsumerWidget { Text( e.error != null ? e.error! - : (e.running ? '运行中' : '已停止'), + : (e.running ? l.t('fwd.running') : l.t('fwd.stopped')), style: TextStyle( fontSize: 10, color: e.error != null @@ -179,7 +183,7 @@ class _ForwardBody extends ConsumerWidget { size: 18, color: e.running ? AppColors.yellow : AppColors.green), splashRadius: 16, - tooltip: e.running ? '停止' : '启动', + tooltip: e.running ? l.t('fwd.stop') : l.t('fwd.start'), onPressed: () => ref.read(forwardProvider.notifier).toggle(e.id), ), // 删除 @@ -187,12 +191,13 @@ class _ForwardBody extends ConsumerWidget { icon: const Icon(Icons.delete_outline, size: 16, color: AppColors.red), splashRadius: 16, - tooltip: '删除', + tooltip: l.t('common.delete'), onPressed: () => ref.read(forwardProvider.notifier).remove(e.id), ), ], ), ); + } } // 添加隧道子对话框:本地端口 + 远程地址 + 远程端口 @@ -201,6 +206,7 @@ void _showAddDialog(BuildContext context, WidgetRef ref) { final remoteHost = TextEditingController(text: '127.0.0.1'); final remotePort = TextEditingController(); String? errorText; + final l = ref.watch(l10nProvider); showDialog( context: context, @@ -220,30 +226,30 @@ void _showAddDialog(BuildContext context, WidgetRef ref) { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( - children: const [ - Icon(Icons.swap_horiz_outlined, + children: [ + const Icon(Icons.swap_horiz_outlined, size: 18, color: AppColors.blue), - SizedBox(width: 8), - Text('添加隧道', - style: TextStyle( + const SizedBox(width: 8), + Text(l.t('fwd.addTunnel'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), ], ), const SizedBox(height: 6), - const Text('本地端口的连接经 SSH 转发到远程地址。常用于访问远端内网服务(如数据库)。', - style: TextStyle( + Text(l.t('fwd.addDesc'), + style: const TextStyle( fontSize: 10.5, height: 1.4, color: AppColors.overlay)), const SizedBox(height: 14), - _label('本地端口'), - _input(localPort, hint: '如 13306'), + _label(l.t('fwd.localPort')), + _input(localPort, hint: l.t('fwd.localPortHint')), const SizedBox(height: 12), - _label('远程地址(从远端主机视角)'), - _input(remoteHost, hint: '127.0.0.1 或内网 IP'), + _label(l.t('fwd.remoteHost')), + _input(remoteHost, hint: l.t('fwd.remoteHostHint')), const SizedBox(height: 12), - _label('远程端口'), - _input(remotePort, hint: '如 3306'), + _label(l.t('fwd.remotePort')), + _input(remotePort, hint: l.t('fwd.remotePortHint')), if (errorText != null) ...[ const SizedBox(height: 8), Text(errorText!, @@ -256,8 +262,8 @@ void _showAddDialog(BuildContext context, WidgetRef ref) { children: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('取消', - style: TextStyle(color: AppColors.subtext)), + child: Text(l.t('common.cancel'), + style: const TextStyle(color: AppColors.subtext)), ), const SizedBox(width: 8), FilledButton( @@ -269,15 +275,15 @@ void _showAddDialog(BuildContext context, WidgetRef ref) { final rp = int.tryParse(remotePort.text.trim()); final rh = remoteHost.text.trim(); if (lp == null || lp < 1 || lp > 65535) { - setState(() => errorText = '本地端口不合法(1-65535)'); + setState(() => errorText = l.t('fwd.errLocalPort')); return; } if (rp == null || rp < 1 || rp > 65535) { - setState(() => errorText = '远程端口不合法(1-65535)'); + setState(() => errorText = l.t('fwd.errRemotePort')); return; } if (rh.isEmpty) { - setState(() => errorText = '远程地址不能为空'); + setState(() => errorText = l.t('fwd.errRemoteHost')); return; } ref.read(forwardProvider.notifier).add( @@ -287,7 +293,7 @@ void _showAddDialog(BuildContext context, WidgetRef ref) { ); Navigator.pop(ctx); }, - child: const Text('添加并启动'), + child: Text(l.t('fwd.addStart')), ), ], ), diff --git a/clients/app/lib/ui/keys_dialog.dart b/clients/app/lib/ui/keys_dialog.dart index 725ba60..0190201 100644 --- a/clients/app/lib/ui/keys_dialog.dart +++ b/clients/app/lib/ui/keys_dialog.dart @@ -4,6 +4,7 @@ import '../theme.dart'; import '../core/config.dart'; import '../state/key_provider.dart'; import '../state/config_provider.dart'; +import '../state/settings_provider.dart'; /// 密钥库对话框 —— 管理 SSH 私钥(列表 + 粘贴 PEM 添加 + 删除)。 /// 私钥与 passphrase 加密落盘(复用 crypto.dart),绝不存明文。 @@ -34,6 +35,7 @@ class _KeysBody extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final keys = ref.watch(keyProvider); final hosts = ref.watch(configProvider).hosts; + final l = ref.watch(l10nProvider); return Column( mainAxisSize: MainAxisSize.min, @@ -44,32 +46,32 @@ class _KeysBody extends ConsumerWidget { children: [ const Icon(Icons.vpn_key_outlined, size: 16, color: AppColors.text), const SizedBox(width: 8), - const Text('密钥库', - style: TextStyle( + Text(l.t('keys.title'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), const SizedBox(width: 8), - Text('共 ${keys.length} 把', + Text(l.t('keys.count', {'n': '${keys.length}'}), style: const TextStyle(fontSize: 11, color: AppColors.overlay)), const Spacer(), TextButton.icon( onPressed: () => _showAddKeyDialog(context, ref), icon: const Icon(Icons.add, size: 15, color: AppColors.blue), - label: const Text('添加密钥', - style: TextStyle(fontSize: 12, color: AppColors.blue)), + label: Text(l.t('keys.add'), + style: const TextStyle(fontSize: 12, color: AppColors.blue)), ), ], ), const SizedBox(height: 8), Flexible( child: keys.isEmpty - ? const Padding( - padding: EdgeInsets.symmetric(vertical: 40), - child: Text('暂无密钥,点「添加密钥」粘贴私钥 PEM', + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: Text(l.t('keys.empty'), textAlign: TextAlign.center, - style: - TextStyle(fontSize: 12, color: AppColors.overlay)), + style: const TextStyle( + fontSize: 12, color: AppColors.overlay)), ) : ListView.separated( shrinkWrap: true, @@ -89,8 +91,9 @@ class _KeysBody extends ConsumerWidget { } Widget _keyRow( - BuildContext context, WidgetRef ref, SshKey k, int usedBy) => - Container( + BuildContext context, WidgetRef ref, SshKey k, int usedBy) { + final l = ref.watch(l10nProvider); + return Container( padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 9), decoration: BoxDecoration( color: AppColors.base, @@ -110,8 +113,8 @@ class _KeysBody extends ConsumerWidget { fontSize: 13, color: AppColors.text)), const SizedBox(height: 2), Text( - '${k.passphraseEnc != null ? '🔒 带 passphrase · ' : ''}' - '${usedBy > 0 ? '$usedBy 台主机使用' : '未被使用'}', + '${k.passphraseEnc != null ? l.t('keys.withPassphrase') : ''}' + '${usedBy > 0 ? l.t('keys.usedBy', {'n': '$usedBy'}) : l.t('keys.unused')}', style: const TextStyle( fontSize: 10, color: AppColors.overlay)), ], @@ -121,15 +124,17 @@ class _KeysBody extends ConsumerWidget { icon: const Icon(Icons.delete_outline, size: 16, color: AppColors.red), splashRadius: 16, - tooltip: '删除', + tooltip: l.t('common.delete'), onPressed: () => _confirmDelete(context, ref, k, usedBy), ), ], ), ); + } void _confirmDelete( BuildContext context, WidgetRef ref, SshKey k, int usedBy) { + final l = ref.watch(l10nProvider); showDialog( context: context, builder: (ctx) => AlertDialog( @@ -138,18 +143,18 @@ class _KeysBody extends ConsumerWidget { borderRadius: BorderRadius.circular(10), side: const BorderSide(color: AppColors.surface0), ), - title: const Text('删除密钥', - style: TextStyle(fontSize: 15, color: AppColors.text)), + title: Text(l.t('keys.deleteKey'), + style: const TextStyle(fontSize: 15, color: AppColors.text)), content: Text( usedBy > 0 - ? '密钥「${k.name}」正被 $usedBy 台主机使用,删除后这些主机将解除密钥绑定(需重新配置认证)。确定删除?' - : '确定删除密钥「${k.name}」?此操作不可撤销。', + ? l.t('keys.deleteUsed', {'name': k.name, 'n': '$usedBy'}) + : l.t('keys.deleteConfirm', {'name': k.name}), style: const TextStyle(fontSize: 13, color: AppColors.subtext)), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('取消', - style: TextStyle(color: AppColors.subtext)), + child: Text(l.t('common.cancel'), + style: const TextStyle(color: AppColors.subtext)), ), FilledButton( style: FilledButton.styleFrom( @@ -159,7 +164,7 @@ class _KeysBody extends ConsumerWidget { ref.read(keyProvider.notifier).remove(k.id); Navigator.pop(ctx); }, - child: const Text('删除'), + child: Text(l.t('common.delete')), ), ], ), @@ -173,6 +178,7 @@ void _showAddKeyDialog(BuildContext context, WidgetRef ref) { final pem = TextEditingController(); final passphrase = TextEditingController(); String? errorText; + final l = ref.watch(l10nProvider); showDialog( context: context, @@ -192,27 +198,27 @@ void _showAddKeyDialog(BuildContext context, WidgetRef ref) { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( - children: const [ - Icon(Icons.vpn_key_outlined, + children: [ + const Icon(Icons.vpn_key_outlined, size: 18, color: AppColors.blue), - SizedBox(width: 8), - Text('添加密钥', - style: TextStyle( + const SizedBox(width: 8), + Text(l.t('keys.add'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), ], ), const SizedBox(height: 16), - _label('名称'), + _label(l.t('keys.name')), _input(name, hint: 'id_ed25519'), const SizedBox(height: 12), - _label('私钥(PEM,粘贴 -----BEGIN ... 全文)'), + _label(l.t('keys.pem')), _input(pem, hint: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', maxLines: 6, mono: true), const SizedBox(height: 12), - _label('passphrase(私钥无加密则留空)'), + _label(l.t('keys.passphrase')), _input(passphrase, obscure: true), if (errorText != null) ...[ const SizedBox(height: 8), @@ -226,8 +232,8 @@ void _showAddKeyDialog(BuildContext context, WidgetRef ref) { children: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('取消', - style: TextStyle(color: AppColors.subtext)), + child: Text(l.t('common.cancel'), + style: const TextStyle(color: AppColors.subtext)), ), const SizedBox(width: 8), FilledButton( @@ -239,11 +245,11 @@ void _showAddKeyDialog(BuildContext context, WidgetRef ref) { // 基本校验:必须像 PEM if (!pemText.contains('-----BEGIN')) { setState(() => - errorText = '私钥格式不对,应以 -----BEGIN 开头'); + errorText = l.t('keys.errPem')); return; } final nm = name.text.trim().isEmpty - ? '未命名密钥' + ? l.t('keys.unnamed') : name.text.trim(); ref.read(keyProvider.notifier).add( name: nm, @@ -254,7 +260,7 @@ void _showAddKeyDialog(BuildContext context, WidgetRef ref) { ); Navigator.pop(ctx); }, - child: const Text('保存'), + child: Text(l.t('common.save')), ), ], ), diff --git a/clients/app/lib/ui/left_bar.dart b/clients/app/lib/ui/left_bar.dart index 8ff4dc0..ce3e7ec 100644 --- a/clients/app/lib/ui/left_bar.dart +++ b/clients/app/lib/ui/left_bar.dart @@ -7,6 +7,7 @@ import '../state/connection_provider.dart'; import '../state/guard_provider.dart'; import '../state/search_provider.dart'; import '../state/snippet_provider.dart'; +import '../state/settings_provider.dart'; import 'dialogs.dart'; import 'snippets_dialog.dart'; import 'audit_dialog.dart'; @@ -27,6 +28,7 @@ class LeftBar extends ConsumerWidget { final allHosts = ref.watch(hostsProvider); final conn = ref.watch(connectionProvider); final query = ref.watch(hostSearchProvider).trim().toLowerCase(); + final l = ref.watch(l10nProvider); // 按搜索词过滤:匹配别名或主机地址 final hosts = query.isEmpty ? allHosts @@ -43,12 +45,14 @@ class LeftBar extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: 10), - _navTitle(context, ref, '主机'), + _navTitle(context, ref, l.t('panel.hosts')), if (hosts.isEmpty) Padding( padding: const EdgeInsets.fromLTRB(14, 8, 14, 8), child: Text( - query.isEmpty ? '暂无主机,点 + 添加' : '未找到匹配「$query」的主机', + query.isEmpty + ? l.t('left.noHosts') + : l.t('left.noMatch', {'q': query}), style: const TextStyle( fontSize: 11, color: AppColors.overlay)), ) @@ -63,29 +67,29 @@ class LeftBar extends ConsumerWidget { color: AppColors.red.withValues(alpha: .12), borderRadius: BorderRadius.circular(6), ), - child: Text('连接失败:${conn.error}', + child: Text(l.t('left.connectFail', {'err': '${conn.error}'}), style: const TextStyle( fontSize: 10.5, color: AppColors.red)), ), _divider(), // 命令片段:真实功能,点击弹片段面板,badge 显示真实数量 - _navLink(Icons.content_paste_outlined, '命令片段', + _navLink(Icons.content_paste_outlined, l.t('left.snippets'), '${ref.watch(snippetProvider).length}', onTap: () => showSnippetsDialog(context)), // 密钥库:真实功能,点击弹密钥管理,badge 显示真实数量 - _navLink(Icons.vpn_key_outlined, '密钥库', + _navLink(Icons.vpn_key_outlined, l.t('left.keys'), _keyBadge(ref), onTap: () => showKeysDialog(context)), // 端口转发:真实功能,点击弹隧道管理,badge 显示运行中隧道数 - _navLink(Icons.swap_horiz_outlined, '端口转发', + _navLink(Icons.swap_horiz_outlined, l.t('left.forward'), _forwardBadge(ref), onTap: () => showForwardDialog(context)), // 安全策略:真实功能,点击弹策略面板,badge 显示累计拦截数(deny+ask) - _navLink(Icons.shield_outlined, '安全策略', + _navLink(Icons.shield_outlined, l.t('left.security'), _guardBadge(ref), onTap: () => showSecurityDialog(context)), // 审计日志:真实功能,点击弹审计面板,badge 显示总条数 - _navLink(Icons.receipt_long_outlined, '审计日志', + _navLink(Icons.receipt_long_outlined, l.t('left.audit'), '${ref.watch(auditProvider).length}', onTap: () => showAuditDialog(context)), ], @@ -196,20 +200,21 @@ class LeftBar extends ConsumerWidget { // 主机右键菜单:目前仅删除 void _showHostMenu( BuildContext context, WidgetRef ref, Host h, Offset pos) { + final l = ref.read(l10nProvider); showMenu( context: context, color: AppColors.mantle, position: RelativeRect.fromLTRB(pos.dx, pos.dy, pos.dx, pos.dy), - items: const [ + items: [ PopupMenuItem( value: 'delete', height: 36, child: Row( children: [ - Icon(Icons.delete_outline, size: 15, color: AppColors.red), - SizedBox(width: 8), - Text('删除主机', - style: TextStyle(fontSize: 13, color: AppColors.text)), + const Icon(Icons.delete_outline, size: 15, color: AppColors.red), + const SizedBox(width: 8), + Text(l.t('left.deleteHost'), + style: const TextStyle(fontSize: 13, color: AppColors.text)), ], ), ), @@ -223,6 +228,7 @@ class LeftBar extends ConsumerWidget { // 删除确认。若正连着这台,先断开再删。 void _confirmDelete(BuildContext context, WidgetRef ref, Host h) { + final l = ref.read(l10nProvider); final name = h.alias?.isNotEmpty == true ? h.alias! : h.host; showDialog( context: context, @@ -232,15 +238,17 @@ class LeftBar extends ConsumerWidget { borderRadius: BorderRadius.circular(10), side: const BorderSide(color: AppColors.surface0), ), - title: const Text('删除主机', - style: TextStyle(fontSize: 15, color: AppColors.text)), - content: Text('确定删除「$name」(${h.host}:${h.port})?此操作不可撤销。', + title: Text(l.t('left.deleteHost'), + style: const TextStyle(fontSize: 15, color: AppColors.text)), + content: Text( + l.t('left.deleteHostConfirm', + {'name': name, 'addr': '${h.host}:${h.port}'}), style: const TextStyle(fontSize: 13, color: AppColors.subtext)), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('取消', - style: TextStyle(color: AppColors.subtext)), + child: Text(l.t('common.cancel'), + style: const TextStyle(color: AppColors.subtext)), ), FilledButton( style: FilledButton.styleFrom( @@ -255,7 +263,7 @@ class LeftBar extends ConsumerWidget { ref.read(configProvider.notifier).deleteHost(h.id); Navigator.pop(ctx); }, - child: const Text('删除'), + child: Text(l.t('common.delete')), ), ], ), diff --git a/clients/app/lib/ui/right_bar.dart b/clients/app/lib/ui/right_bar.dart index 30aae2c..205728e 100644 --- a/clients/app/lib/ui/right_bar.dart +++ b/clients/app/lib/ui/right_bar.dart @@ -1,21 +1,23 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../theme.dart'; +import '../core/i18n.dart'; import '../state/guard_provider.dart'; import '../state/connection_provider.dart'; import '../state/monitor_provider.dart'; import '../state/sftp_provider.dart'; +import '../state/settings_provider.dart'; /// 右栏 —— 安全 / 文件 / 监控 三 Tab(宽 300px) /// 对应设计稿 .rightbar。安全面板是差异化核心,重点还原。 -class RightBar extends StatefulWidget { +class RightBar extends ConsumerStatefulWidget { const RightBar({super.key}); @override - State createState() => _RightBarState(); + ConsumerState createState() => _RightBarState(); } -class _RightBarState extends State { +class _RightBarState extends ConsumerState { // 当前 tab:sec / files / mon String _tab = 'sec'; @@ -43,6 +45,7 @@ class _RightBarState extends State { // 顶部三 tab,底部蓝条标记 active Widget _tabs() { + final l = ref.watch(l10nProvider); Widget tab(String id, IconData icon, String label) { final active = _tab == id; return Expanded( @@ -83,9 +86,9 @@ class _RightBarState extends State { ), child: Row( children: [ - tab('sec', Icons.shield_outlined, '安全'), - tab('files', Icons.folder_outlined, '文件'), - tab('mon', Icons.monitor_heart_outlined, '监控'), + tab('sec', Icons.shield_outlined, l.t('right.tabSec')), + tab('files', Icons.folder_outlined, l.t('right.tabFiles')), + tab('mon', Icons.monitor_heart_outlined, l.t('right.tabMon')), ], ), ); @@ -109,11 +112,12 @@ class _SecurityPanel extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final stats = ref.watch(guardProvider); + final l = ref.watch(l10nProvider); // 每条规则末尾显示对应三态的累计命中次数 String hitsFor(String level) => switch (level) { - 'deny' => '${stats.denyCount} 次', - 'ask' => '${stats.askCount} 次', - _ => '${stats.allowCount} 次', + 'deny' => l.t('right.hits', {'n': '${stats.denyCount}'}), + 'ask' => l.t('right.hits', {'n': '${stats.askCount}'}), + _ => l.t('right.hits', {'n': '${stats.allowCount}'}), }; return Column( @@ -122,28 +126,28 @@ class _SecurityPanel extends ConsumerWidget { // 统计三卡:已阻止/待确认/已放行(真实数据) Row( children: [ - _stat('${stats.denyCount}', '已阻止', AppColors.red), + _stat('${stats.denyCount}', l.t('state.denied'), AppColors.red), const SizedBox(width: 8), - _stat('${stats.askCount}', '待确认', AppColors.yellow), + _stat('${stats.askCount}', l.t('state.ask'), AppColors.yellow), const SizedBox(width: 8), - _stat('${stats.allowCount}', '已放行', AppColors.green), + _stat('${stats.allowCount}', l.t('state.allowed'), AppColors.green), ], ), const SizedBox(height: 14), - _panelTitle('门禁规则(按严格度)'), + _panelTitle(l.t('right.rulesTitle')), for (final r in _rules) _ruleRow(r.$1, r.$2, hitsFor(r.$1)), const SizedBox(height: 14), - _panelTitle('阻止历史'), + _panelTitle(l.t('right.blockHistory')), if (stats.blocked.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: 4), - child: Text('暂无阻止记录', - style: TextStyle(fontSize: 11, color: AppColors.overlay)), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text(l.t('right.noBlock'), + style: const TextStyle(fontSize: 11, color: AppColors.overlay)), ) else for (final b in stats.blocked) _logItem(b.command, - '${_fmtTime(b.time)} · ${b.level.toUpperCase()}'), + '${_fmtTime(b.time)} · ${b.level.toUpperCase()}', l), ], ); } @@ -228,7 +232,7 @@ class _SecurityPanel extends ConsumerWidget { } // 拦截历史项(左边红条 + 命令 + 时间/可临时放行) - Widget _logItem(String cmd, String meta) => Container( + Widget _logItem(String cmd, String meta, L10n l) => Container( margin: const EdgeInsets.only(bottom: 5), padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 7), decoration: const BoxDecoration( @@ -251,8 +255,8 @@ class _SecurityPanel extends ConsumerWidget { Text(meta, style: const TextStyle( fontSize: 10, color: AppColors.overlay)), - const Text('临时放行', - style: TextStyle(fontSize: 10, color: AppColors.blue)), + Text(l.t('right.tempAllow'), + style: const TextStyle(fontSize: 10, color: AppColors.blue)), ], ), ], @@ -287,6 +291,7 @@ class _FilesPanelState extends ConsumerState<_FilesPanel> { Widget build(BuildContext context) { final conn = ref.watch(connectionProvider); final sftp = ref.watch(sftpProvider); + final l = ref.watch(l10nProvider); // 切到已连接的新主机时,若其 SFTP 还没加载过则自动列根目录 ref.listen(connectionProvider.select((s) => s.host?.id), (prev, next) { @@ -301,11 +306,11 @@ class _FilesPanelState extends ConsumerState<_FilesPanel> { }); if (!conn.isConnected) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: Text('连接主机后浏览远程文件', + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Text(l.t('right.filesEmpty'), textAlign: TextAlign.center, - style: TextStyle(fontSize: 11, color: AppColors.overlay)), + style: const TextStyle(fontSize: 11, color: AppColors.overlay)), ); } @@ -353,7 +358,7 @@ class _FilesPanelState extends ConsumerState<_FilesPanel> { else if (sftp.error != null) Padding( padding: const EdgeInsets.symmetric(vertical: 8), - child: Text('加载失败:${sftp.error}', + child: Text(l.t('right.loadFail', {'err': '${sftp.error}'}), style: const TextStyle(fontSize: 11, color: AppColors.red)), ) else ...[ @@ -378,10 +383,10 @@ class _FilesPanelState extends ConsumerState<_FilesPanel> { ), ), if (sftp.files.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: Text('(空目录)', - style: TextStyle(fontSize: 11, color: AppColors.overlay)), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text(l.t('right.emptyDir'), + style: const TextStyle(fontSize: 11, color: AppColors.overlay)), ), ], ], @@ -451,13 +456,14 @@ class _MonitorPanelState extends ConsumerState<_MonitorPanel> { Widget build(BuildContext context) { final conn = ref.watch(connectionProvider); final m = ref.watch(monitorProvider); + final l = ref.watch(l10nProvider); if (!conn.isConnected) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: Text('连接主机后查看实时监控', + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Text(l.t('right.monEmpty'), textAlign: TextAlign.center, - style: TextStyle(fontSize: 11, color: AppColors.overlay)), + style: const TextStyle(fontSize: 11, color: AppColors.overlay)), ); } @@ -467,20 +473,21 @@ class _MonitorPanelState extends ConsumerState<_MonitorPanel> { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _panelTitle('资源占用'), + _panelTitle(l.t('right.resUsage')), _metric('CPU', m.cpuPct, '${(m.cpuPct * 100).toStringAsFixed(0)}%', m.cpuPct > 0.9), - _metric('内存', m.memPct, m.memText, m.memPct > 0.9), - _metric('磁盘 /', m.diskPct, + _metric(l.t('right.mem'), m.memPct, m.memText, m.memPct > 0.9), + _metric(l.t('right.disk'), m.diskPct, '${(m.diskPct * 100).toStringAsFixed(0)}%', m.diskPct > 0.9), - _metric('负载', loadPct, m.load1.toStringAsFixed(2), loadPct > 0.9), + _metric(l.t('right.load'), loadPct, m.load1.toStringAsFixed(2), + loadPct > 0.9), const SizedBox(height: 6), - _panelTitle('网络'), - _netRow('↓ 入站', humanBps(m.netRxBps)), - _netRow('↑ 出站', humanBps(m.netTxBps)), + _panelTitle(l.t('right.network')), + _netRow(l.t('right.netIn'), humanBps(m.netRxBps)), + _netRow(l.t('right.netOut'), humanBps(m.netTxBps)), if (m.error != null) ...[ const SizedBox(height: 8), - Text('采样失败:${m.error}', + Text(l.t('right.sampleFail', {'err': '${m.error}'}), style: const TextStyle(fontSize: 10, color: AppColors.red)), ], ], diff --git a/clients/app/lib/ui/security_dialog.dart b/clients/app/lib/ui/security_dialog.dart index c3ebb01..342aded 100644 --- a/clients/app/lib/ui/security_dialog.dart +++ b/clients/app/lib/ui/security_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../theme.dart'; import '../core/guard.dart'; import '../state/guard_provider.dart'; +import '../state/settings_provider.dart'; /// 安全策略对话框 —— 完整列出门禁规则(deny/ask)+ 实时三态统计。 /// 纯展示,规则来自 core/guard.dart,与实际判定同源。 @@ -32,6 +33,7 @@ class _SecurityBody extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final stats = ref.watch(guardProvider); + final l = ref.watch(l10nProvider); return Column( mainAxisSize: MainAxisSize.min, @@ -42,25 +44,25 @@ class _SecurityBody extends ConsumerWidget { children: [ const Icon(Icons.shield_outlined, size: 16, color: AppColors.text), const SizedBox(width: 8), - const Text('安全策略', - style: TextStyle( + Text(l.t('sec.title'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), const SizedBox(width: 8), - const Text('命令门禁规则', - style: TextStyle(fontSize: 11, color: AppColors.overlay)), + Text(l.t('sec.subtitle'), + style: const TextStyle(fontSize: 11, color: AppColors.overlay)), ], ), const SizedBox(height: 12), // 三态统计卡(真实数据) Row( children: [ - _stat('${stats.denyCount}', '已阻止', AppColors.red), + _stat('${stats.denyCount}', l.t('state.denied'), AppColors.red), const SizedBox(width: 8), - _stat('${stats.askCount}', '待确认', AppColors.yellow), + _stat('${stats.askCount}', l.t('state.ask'), AppColors.yellow), const SizedBox(width: 8), - _stat('${stats.allowCount}', '已放行', AppColors.green), + _stat('${stats.allowCount}', l.t('state.allowed'), AppColors.green), ], ), const SizedBox(height: 8), @@ -71,10 +73,9 @@ class _SecurityBody extends ConsumerWidget { color: AppColors.base, borderRadius: BorderRadius.circular(6), ), - child: const Text( - '判定顺序:先查 DENY(命中即拒)→ 再看 ASK(执行前确认)→ 默认 ALLOW。' - '复合命令拆段逐查,取最严结果。安全检查是独立代码路径,模型越狱也绕不过。', - style: TextStyle( + child: Text( + '${l.t('sec.principle1')}${l.t('sec.principle2')}', + style: const TextStyle( fontSize: 10.5, height: 1.5, color: AppColors.subtext)), ), const SizedBox(height: 12), @@ -83,19 +84,23 @@ class _SecurityBody extends ConsumerWidget { child: ListView( shrinkWrap: true, children: [ - _sectionTitle('DENY · 直接拒绝(${denyRules.length} 条)', + _sectionTitle( + l.t('sec.denySection', {'n': '${denyRules.length}'}), AppColors.red), for (final r in denyRules) _ruleRow(r), const SizedBox(height: 10), - _sectionTitle('ASK · 执行前确认(${askRules.length} 条)', + _sectionTitle( + l.t('sec.askSection', {'n': '${askRules.length}'}), AppColors.yellow), for (final r in askRules) _ruleRow(r), const SizedBox(height: 10), - _sectionTitle('ALLOW · 默认放行', AppColors.green), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 2, vertical: 4), - child: Text('未命中以上规则的只读/安全命令(ls · cat · df · tail 等)', - style: TextStyle(fontSize: 11, color: AppColors.overlay)), + _sectionTitle(l.t('sec.allowSection'), AppColors.green), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 2, vertical: 4), + child: Text(l.t('sec.allowDesc'), + style: const TextStyle( + fontSize: 11, color: AppColors.overlay)), ), ], ), diff --git a/clients/app/lib/ui/settings_center.dart b/clients/app/lib/ui/settings_center.dart index 20aee4c..8ef6627 100644 --- a/clients/app/lib/ui/settings_center.dart +++ b/clients/app/lib/ui/settings_center.dart @@ -163,11 +163,11 @@ class _SettingsCenterState extends ConsumerState<_SettingsCenter> { ); } - Widget _placeholder(L10n l) => const Padding( - padding: EdgeInsets.symmetric(vertical: 40), - child: Text('即将推出…', + Widget _placeholder(L10n l) => Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: Text(l.t('settings.comingSoon'), textAlign: TextAlign.center, - style: TextStyle(fontSize: 13, color: AppColors.overlay)), + style: const TextStyle(fontSize: 13, color: AppColors.overlay)), ); } diff --git a/clients/app/lib/ui/sftp_view.dart b/clients/app/lib/ui/sftp_view.dart index 18a6542..3ee8d8b 100644 --- a/clients/app/lib/ui/sftp_view.dart +++ b/clients/app/lib/ui/sftp_view.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../theme.dart'; +import '../core/i18n.dart'; +import '../state/settings_provider.dart'; /// 文件项占位 model(Step 4 接 core/ssh.dart 的 SFTP listdir) class _FileItem { @@ -14,7 +17,7 @@ class _FileItem { /// SFTP 双栏文件管理器 —— 本地 | 传输控制 | 远程 /// 对应设计稿 .sftp-view。图标统一 Material 线性。 -class SftpView extends StatelessWidget { +class SftpView extends ConsumerWidget { const SftpView({super.key}); static const _folder = Icons.folder_outlined; @@ -40,13 +43,14 @@ class SftpView extends StatelessWidget { ]; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final l = ref.watch(l10nProvider); return Container( color: AppColors.base, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _toolbar(), + _toolbar(l), Expanded( child: Row( children: [ @@ -59,14 +63,14 @@ class SftpView extends StatelessWidget { ], ), ), - _status(), + _status(l), ], ), ); } // 工具条:本地路径 | 上传/下载 | 远程路径 - Widget _toolbar() => Container( + Widget _toolbar(L10n l) => Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: const BoxDecoration( color: AppColors.mantle, @@ -74,14 +78,14 @@ class SftpView extends StatelessWidget { ), child: Row( children: [ - Expanded(child: _pathBar('本地', '~/Downloads', remote: false)), + Expanded(child: _pathBar(l.t('sftp.local'), '~/Downloads', remote: false)), const SizedBox(width: 12), Column( mainAxisSize: MainAxisSize.min, children: [ - _arrow(Icons.arrow_upward, '上传'), + _arrow(Icons.arrow_upward, l.t('sftp.upload')), const SizedBox(height: 5), - _arrow(Icons.arrow_downward, '下载'), + _arrow(Icons.arrow_downward, l.t('sftp.download')), ], ), const SizedBox(width: 12), @@ -220,7 +224,7 @@ class SftpView extends StatelessWidget { ); // 底部传输状态条 - Widget _status() => Container( + Widget _status(L10n l) => Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: const BoxDecoration( color: AppColors.crust, @@ -228,8 +232,8 @@ class SftpView extends StatelessWidget { ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: const [ - Row( + children: [ + const Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.upload, size: 12, color: AppColors.blue), @@ -241,8 +245,8 @@ class SftpView extends StatelessWidget { color: AppColors.blue)), ], ), - Text('双击文件用内置编辑器打开 · 拖拽可跨栏传输', - style: TextStyle( + Text(l.t('sftp.hint'), + style: const TextStyle( fontFamily: kMonoFont, fontSize: 10.5, color: AppColors.overlay)), diff --git a/clients/app/lib/ui/snippets_dialog.dart b/clients/app/lib/ui/snippets_dialog.dart index 4458977..057c795 100644 --- a/clients/app/lib/ui/snippets_dialog.dart +++ b/clients/app/lib/ui/snippets_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../theme.dart'; import '../core/snippet_store.dart'; import '../state/snippet_provider.dart'; +import '../state/settings_provider.dart'; /// 命令片段对话框 —— 列出预置/自定义片段,点击填进 AI 输入框,支持增删。 Future showSnippetsDialog(BuildContext context) { @@ -57,6 +58,7 @@ class _SnippetsBodyState extends ConsumerState<_SnippetsBody> { @override Widget build(BuildContext context) { final snippets = ref.watch(snippetProvider); + final l = ref.watch(l10nProvider); return Column( mainAxisSize: MainAxisSize.min, @@ -68,25 +70,26 @@ class _SnippetsBodyState extends ConsumerState<_SnippetsBody> { const Icon(Icons.content_paste_outlined, size: 16, color: AppColors.text), const SizedBox(width: 8), - const Text('命令片段', - style: TextStyle( + Text(l.t('snip.title'), + style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.text)), const Spacer(), - const Text('点击填入输入框', - style: TextStyle(fontSize: 11, color: AppColors.overlay)), + Text(l.t('snip.clickToFill'), + style: const TextStyle(fontSize: 11, color: AppColors.overlay)), ], ), const SizedBox(height: 12), // 片段列表 Flexible( child: snippets.isEmpty - ? const Padding( - padding: EdgeInsets.symmetric(vertical: 24), - child: Text('暂无片段,点下方新增', + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text(l.t('snip.empty'), textAlign: TextAlign.center, - style: TextStyle(fontSize: 12, color: AppColors.overlay)), + style: const TextStyle( + fontSize: 12, color: AppColors.overlay)), ) : ListView.separated( shrinkWrap: true, @@ -106,8 +109,8 @@ class _SnippetsBodyState extends ConsumerState<_SnippetsBody> { child: TextButton.icon( onPressed: () => setState(() => _adding = true), icon: const Icon(Icons.add, size: 16, color: AppColors.blue), - label: const Text('新增片段', - style: TextStyle(fontSize: 12, color: AppColors.blue)), + label: Text(l.t('snip.addNew'), + style: const TextStyle(fontSize: 12, color: AppColors.blue)), ), ), ], @@ -163,7 +166,9 @@ class _SnippetsBodyState extends ConsumerState<_SnippetsBody> { ); // 新增表单:名称 + 命令 - Widget _addForm() => Container( + Widget _addForm() { + final l = ref.watch(l10nProvider); + return Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: AppColors.base, @@ -172,17 +177,18 @@ class _SnippetsBodyState extends ConsumerState<_SnippetsBody> { ), child: Column( children: [ - _miniField(_labelCtrl, '名称(可选)'), + _miniField(_labelCtrl, l.t('snip.nameOpt')), const SizedBox(height: 6), - _miniField(_cmdCtrl, '命令,如 df -h', mono: true), + _miniField(_cmdCtrl, l.t('snip.cmdHint'), mono: true), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () => setState(() => _adding = false), - child: const Text('取消', - style: TextStyle(fontSize: 12, color: AppColors.subtext)), + child: Text(l.t('common.cancel'), + style: const TextStyle( + fontSize: 12, color: AppColors.subtext)), ), const SizedBox(width: 4), FilledButton( @@ -192,13 +198,15 @@ class _SnippetsBodyState extends ConsumerState<_SnippetsBody> { padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8)), onPressed: _submitNew, - child: const Text('添加', style: TextStyle(fontSize: 12)), + child: Text(l.t('common.add'), + style: const TextStyle(fontSize: 12)), ), ], ), ], ), ); + } Widget _miniField(TextEditingController c, String hint, {bool mono = false}) => TextField( diff --git a/clients/app/lib/ui/status_bar.dart b/clients/app/lib/ui/status_bar.dart index 9b66756..1b0ff37 100644 --- a/clients/app/lib/ui/status_bar.dart +++ b/clients/app/lib/ui/status_bar.dart @@ -5,6 +5,8 @@ import '../state/connection_provider.dart'; import '../state/config_provider.dart'; import '../state/guard_provider.dart'; import '../state/agent_provider.dart'; +import '../state/settings_provider.dart'; +import '../core/i18n.dart'; /// 底部状态栏 —— 连接状态 / 门禁统计 / 模型 / 上下文(高 26px,等宽字体) /// 数据全部来自真实 provider,无可靠来源的指标不展示(不放假数据)。 @@ -17,6 +19,7 @@ class StatusBar extends ConsumerWidget { final guard = ref.watch(guardProvider); final llm = ref.watch(configProvider).llm; final agent = ref.watch(agentProvider); + final l = ref.watch(l10nProvider); // 上下文轮数:对话流里用户消息条数 final rounds = agent.items.where((i) => i.kind == ChatItemKind.user).length; @@ -34,31 +37,35 @@ class StatusBar extends ConsumerWidget { child: Row( children: [ // 连接状态 - _connSeg(conn), + _connSeg(conn, l), const SizedBox(width: 16), // 门禁:ON + 阻止/待确认实时计数 _seg([ - const Text('门禁 ', style: TextStyle(color: AppColors.overlay)), + Text(l.t('status.guard'), + style: const TextStyle(color: AppColors.overlay)), const Text('ON', style: TextStyle(color: AppColors.green)), const Text(' · ', style: TextStyle(color: AppColors.overlay)), - Text('阻止${guard.denyCount}', + Text(l.t('status.blocked', {'n': '${guard.denyCount}'}), style: const TextStyle(color: AppColors.red)), const Text(' · ', style: TextStyle(color: AppColors.overlay)), - Text('待确认${guard.askCount}', + Text(l.t('status.pending', {'n': '${guard.askCount}'}), style: const TextStyle(color: AppColors.yellow)), ]), const Spacer(), // 模型 _seg([ - const Text('模型 ', style: TextStyle(color: AppColors.overlay)), - Text(llm.model.isEmpty ? '未配置' : llm.model, + Text(l.t('status.model'), + style: const TextStyle(color: AppColors.overlay)), + Text(llm.model.isEmpty ? l.t('status.notConfigured') : llm.model, style: const TextStyle(color: AppColors.text)), ]), const SizedBox(width: 16), // 上下文轮数 _seg([ - const Text('上下文 ', style: TextStyle(color: AppColors.overlay)), - Text('$rounds 轮', style: const TextStyle(color: AppColors.text)), + Text(l.t('status.context'), + style: const TextStyle(color: AppColors.overlay)), + Text(l.t('status.rounds', {'n': '$rounds'}), + style: const TextStyle(color: AppColors.text)), ]), ], ), @@ -67,15 +74,15 @@ class StatusBar extends ConsumerWidget { } // 连接状态段:已连绿点+主机名 / 未连灰点 - Widget _connSeg(ConnState conn) { + Widget _connSeg(ConnState conn, L10n l) { final (Color c, String label) = switch (conn.phase) { ConnPhase.connected => ( AppColors.green, - '${conn.host?.alias?.isNotEmpty == true ? conn.host!.alias! : conn.host?.host ?? ''} 已连接' + '${conn.host?.alias?.isNotEmpty == true ? conn.host!.alias! : conn.host?.host ?? ''} ${l.t('status.connected')}' ), - ConnPhase.connecting => (AppColors.yellow, '连接中…'), - ConnPhase.error => (AppColors.red, '连接失败'), - _ => (AppColors.overlay, '未连接'), + ConnPhase.connecting => (AppColors.yellow, l.t('status.connecting')), + ConnPhase.error => (AppColors.red, l.t('status.connFail')), + _ => (AppColors.overlay, l.t('status.disconnected')), }; return _seg([ Container( diff --git a/clients/app/lib/ui/terminal_pane.dart b/clients/app/lib/ui/terminal_pane.dart index 108fd3d..d24e774 100644 --- a/clients/app/lib/ui/terminal_pane.dart +++ b/clients/app/lib/ui/terminal_pane.dart @@ -99,7 +99,8 @@ class _TerminalPaneState extends ConsumerState { .transform(const Utf8Decoder(allowMalformed: true)) .listen(s.terminal.write); } catch (e) { - s.terminal.write('\r\n[终端启动失败: $e]\r\n'); + final l = ref.read(l10nProvider); + s.terminal.write('\r\n${l.t('term.startFail', {'err': '$e'})}\r\n'); } finally { s.starting = false; } @@ -117,6 +118,7 @@ class _TerminalPaneState extends ConsumerState { Widget build(BuildContext context) { final conn = ref.watch(connectionProvider); final cfg = ref.watch(settingsProvider); // 终端设置 + final l = ref.watch(l10nProvider); final hostId = conn.host?.id; // 池里已不存在的主机(被 LRU 踢掉/断开),清理其终端会话 @@ -131,7 +133,7 @@ class _TerminalPaneState extends ConsumerState { return Container( color: AppColors.crust, alignment: Alignment.center, - child: const Text('连接主机后可在此使用交互式终端', + child: Text(l.t('term.connectFirst'), style: TextStyle(fontSize: 12, color: AppColors.overlay)), ); } diff --git a/clients/app/lib/ui/top_bar.dart b/clients/app/lib/ui/top_bar.dart index f06104f..8b8322e 100644 --- a/clients/app/lib/ui/top_bar.dart +++ b/clients/app/lib/ui/top_bar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../theme.dart'; import '../state/search_provider.dart'; +import '../state/settings_provider.dart'; import 'dialogs.dart'; import 'settings_center.dart'; @@ -45,6 +46,7 @@ class TopBar extends ConsumerWidget { } Widget _searchBox(WidgetRef ref) { + final l = ref.watch(l10nProvider); return Container( width: 280, decoration: BoxDecoration( @@ -62,13 +64,13 @@ class TopBar extends ConsumerWidget { onChanged: (v) => ref.read(hostSearchProvider.notifier).update(v), style: const TextStyle(color: AppColors.text, fontSize: 13), - decoration: const InputDecoration( + decoration: InputDecoration( isDense: true, border: InputBorder.none, - contentPadding: EdgeInsets.symmetric(vertical: 6), - hintText: '搜索主机…', - hintStyle: - TextStyle(color: AppColors.overlay, fontSize: 13), + contentPadding: const EdgeInsets.symmetric(vertical: 6), + hintText: l.t('top.search'), + hintStyle: const TextStyle( + color: AppColors.overlay, fontSize: 13), ), ), ), @@ -78,18 +80,19 @@ class TopBar extends ConsumerWidget { } Widget _actions(BuildContext context, WidgetRef ref) { + final l = ref.watch(l10nProvider); return Row( children: [ - _btn(icon: Icons.bolt, label: '连接', primary: true), + _btn(icon: Icons.bolt, label: l.t('top.connect'), primary: true), const SizedBox(width: 6), _btn( icon: Icons.add, - label: '新建主机', + label: l.t('top.newHost'), onTap: () => showAddHostDialog(context, ref)), const SizedBox(width: 6), _btn(icon: Icons.folder_outlined, label: 'SFTP'), const SizedBox(width: 6), - _btn(icon: Icons.splitscreen_outlined, label: '分屏'), + _btn(icon: Icons.splitscreen_outlined, label: l.t('top.split')), const SizedBox(width: 6), _btn( icon: Icons.settings_outlined, From 2fd103eaf28c4a431466827a15ebf5922020e165 Mon Sep 17 00:00:00 2001 From: xiaowen <0928du@gmail.com> Date: Fri, 26 Jun 2026 20:53:32 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E8=BF=87=E5=B0=8F=E6=97=B6=E7=9A=84=E5=B8=83=E5=B1=80=E6=BA=A2?= =?UTF-8?q?=E5=87=BA=EF=BC=9A=E8=AE=BE=E6=9C=80=E5=B0=8F=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=B0=BA=E5=AF=B8=20960x600?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 窗口被拖到极窄时多处 Row 触发 RenderFlex overflow(英文模式文案更长更明显)。 在 MainFlutterWindow 设 minSize 下限根治,用户无法拖到触发溢出的尺寸。 Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/app/macos/Runner/MainFlutterWindow.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/clients/app/macos/Runner/MainFlutterWindow.swift b/clients/app/macos/Runner/MainFlutterWindow.swift index 3cc05eb..4529f3f 100644 --- a/clients/app/macos/Runner/MainFlutterWindow.swift +++ b/clients/app/macos/Runner/MainFlutterWindow.swift @@ -7,6 +7,8 @@ class MainFlutterWindow: NSWindow { let windowFrame = self.frame self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) + // 窗口最小尺寸:再小会触发多处布局溢出,设下限根治 + self.minSize = NSSize(width: 960, height: 600) RegisterGeneratedPlugins(registry: flutterViewController) From 6f4f02f661286c1db2baa2671fec06639fa45e96 Mon Sep 17 00:00:00 2001 From: xiaowen <0928du@gmail.com> Date: Sun, 28 Jun 2026 09:31:37 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E9=80=89=E5=8C=BA=E5=A4=8D=E5=88=B6=E8=B6=8A=E7=95=8C=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=20+=20=E9=A1=B6=E6=A0=8F=E5=BE=AE=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buffer 未布局完成时 getText 会越界抛异常,包一层防御忽略本次复制。 Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/app/lib/ui/terminal_pane.dart | 11 ++++++++--- clients/app/lib/ui/top_bar.dart | 7 +++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/clients/app/lib/ui/terminal_pane.dart b/clients/app/lib/ui/terminal_pane.dart index d24e774..8034d51 100644 --- a/clients/app/lib/ui/terminal_pane.dart +++ b/clients/app/lib/ui/terminal_pane.dart @@ -26,9 +26,14 @@ class _TermSession { _selListener = () { final sel = controller.selection; if (sel == null) return; - final text = terminal.buffer.getText(sel); - if (text.trim().isNotEmpty) { - Clipboard.setData(ClipboardData(text: text)); + // buffer 可能尚未布局完成,getText 越界会抛异常,包一层防御 + try { + final text = terminal.buffer.getText(sel); + if (text.trim().isNotEmpty) { + Clipboard.setData(ClipboardData(text: text)); + } + } catch (_) { + // 选区超出当前 buffer 范围,忽略本次复制 } }; controller.addListener(_selListener!); diff --git a/clients/app/lib/ui/top_bar.dart b/clients/app/lib/ui/top_bar.dart index 8b8322e..f40809c 100644 --- a/clients/app/lib/ui/top_bar.dart +++ b/clients/app/lib/ui/top_bar.dart @@ -35,9 +35,9 @@ class TopBar extends ConsumerWidget { ], ), const SizedBox(width: 12), - // 搜索框 - _searchBox(ref), - const Spacer(), + // 搜索框(弹性占据中间剩余空间,窄窗口下自动收缩,避免溢出) + Expanded(child: _searchBox(ref)), + const SizedBox(width: 12), // 操作按钮组 _actions(context, ref), ], @@ -48,7 +48,6 @@ class TopBar extends ConsumerWidget { Widget _searchBox(WidgetRef ref) { final l = ref.watch(l10nProvider); return Container( - width: 280, decoration: BoxDecoration( color: AppColors.base, border: Border.all(color: AppColors.surface0), From 4f9846e42fa1c1a29dad301b4acd8da0a3b39713 Mon Sep 17 00:00:00 2001 From: xiaowen <0928du@gmail.com> Date: Sun, 28 Jun 2026 09:31:57 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E5=BC=80=E6=BA=90=E5=8C=96=EF=BC=9A?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=20web=20=E7=AB=AF=EF=BC=8C=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=B7=A5=E7=A8=8B=E5=8C=96=E9=85=8D=E5=A5=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 frontend/ web 端(Vue),桌面端与 CLI 为平行独立实现,不再需要 web - Dockerfile 改为纯后端 API 镜像(去掉前端构建阶段) - 新增 LICENSE(MIT)、Maven Wrapper(mvnw)、GitHub Actions CI - README 重写:三种形态架构说明,去掉面试定位,统一版本号 - 新增 clients/cli/README,改写 clients/app/README - PROJECT_HIGHLIGHTS(简历素材) → docs/ARCHITECTURE,移除内部 PLAN 文档 - DESIGN.md 去掉 web 技术绑定 Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 65 + .gitignore | 8 +- .mvn/wrapper/maven-wrapper.properties | 19 + DESIGN.md | 6 +- Dockerfile | 21 +- LICENSE | 21 + README.md | 66 +- clients/app/README.md | 50 +- clients/cli/README.md | 58 + PROJECT_HIGHLIGHTS.md => docs/ARCHITECTURE.md | 61 +- docs/PLAN-clients.md | 66 - docs/PLAN-sftp-monitor.md | 124 -- frontend/README.md | 35 - frontend/index.html | 19 - frontend/package-lock.json | 1415 ----------------- frontend/package.json | 22 - frontend/src/App.vue | 307 ---- frontend/src/components/ChatComposer.vue | 180 --- frontend/src/components/ConnectionPanel.vue | 156 -- frontend/src/components/HistorySidebar.vue | 117 -- frontend/src/composables/useAgentStream.js | 473 ------ frontend/src/main.js | 25 - frontend/src/styles/global.css | 103 -- frontend/src/views/ChatView.vue | 422 ----- frontend/src/views/FilesView.vue | 373 ----- frontend/src/views/HostsView.vue | 374 ----- frontend/src/views/MonitorView.vue | 417 ----- frontend/src/views/TerminalView.vue | 134 -- frontend/vite.config.js | 29 - mvnw | 332 ++++ mvnw.cmd | 206 +++ 31 files changed, 815 insertions(+), 4889 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100644 LICENSE create mode 100644 clients/cli/README.md rename PROJECT_HIGHLIGHTS.md => docs/ARCHITECTURE.md (55%) delete mode 100644 docs/PLAN-clients.md delete mode 100644 docs/PLAN-sftp-monitor.md delete mode 100644 frontend/README.md delete mode 100644 frontend/index.html delete mode 100644 frontend/package-lock.json delete mode 100644 frontend/package.json delete mode 100644 frontend/src/App.vue delete mode 100644 frontend/src/components/ChatComposer.vue delete mode 100644 frontend/src/components/ConnectionPanel.vue delete mode 100644 frontend/src/components/HistorySidebar.vue delete mode 100644 frontend/src/composables/useAgentStream.js delete mode 100644 frontend/src/main.js delete mode 100644 frontend/src/styles/global.css delete mode 100644 frontend/src/views/ChatView.vue delete mode 100644 frontend/src/views/FilesView.vue delete mode 100644 frontend/src/views/HostsView.vue delete mode 100644 frontend/src/views/MonitorView.vue delete mode 100644 frontend/src/views/TerminalView.vue delete mode 100644 frontend/vite.config.js create mode 100755 mvnw create mode 100644 mvnw.cmd diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..82faa06 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + backend: + name: 后端(Java) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: 安装 JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: temurin + cache: maven + # 单测均为纯单元测试,不依赖 MySQL,可直接跑 + - name: 编译 + 单测 + run: ./mvnw -B clean test + + app: + name: 桌面端(Flutter) + runs-on: ubuntu-latest + defaults: + run: + working-directory: clients/app + steps: + - uses: actions/checkout@v4 + - name: 安装 Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + - name: 拉依赖 + run: flutter pub get + - name: 静态分析 + run: flutter analyze + - name: 单测 + run: flutter test + + cli: + name: CLI(Node) + runs-on: ubuntu-latest + defaults: + run: + working-directory: clients/cli + steps: + - uses: actions/checkout@v4 + - name: 安装 Node 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: clients/cli/package-lock.json + - name: 装依赖 + run: npm ci + - name: 类型检查 + run: npm run typecheck + - name: 单测 + run: npm test + - name: 构建 + run: npm run build diff --git a/.gitignore b/.gitignore index 9a735d1..f1f81ee 100644 --- a/.gitignore +++ b/.gitignore @@ -20,8 +20,6 @@ target/ # 远端拉取的服务器日志,不进仓库 logs/ -# 前端 -frontend/node_modules/ -frontend/dist/ -# 构建产物:前端 build 会输出到此,不提交(由 CI/本地构建生成) -src/main/resources/static/ +# CLI 客户端(Node)构建产物与依赖 +clients/cli/node_modules/ +clients/cli/dist/ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..654af46 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/DESIGN.md b/DESIGN.md index b865856..30ad54c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,6 +1,6 @@ # LowenSSH 设计系统 -> AI SSH 智能运维 Agent 的前端设计源(source of truth)。两种界面形态共用这一套设计 token 与事件语义规则。 +> AI SSH 智能运维 Agent 的界面设计源(source of truth)。桌面端与 CLI 共用这一套设计 token 与事件语义规则。 ## 1. 设计 thesis(一句话) @@ -91,8 +91,8 @@ ## 8. 反 AI-slop 约束(自我要求) - 不用紫色渐变、不用居中堆叠、不用三列图标网格、不用装饰性色块。 -- 不引入 UI 组件库(Element/Antd)——它们会盖掉这套精心设计的事件语义色,且对一个 6 类事件的对话界面是杀鸡用牛刀。手写组件,保持设计控制权。 -- 密码字段用 `type=password`;连接信息只在前端内存,不落 localStorage(避免明文密钥留在浏览器)。 +- 不引入重型 UI 组件库——它们会盖掉这套精心设计的事件语义色,且对一个 6 类事件的对话界面是杀鸡用牛刀。手写组件,保持设计控制权。 +- 密码字段做遮挡输入;明文密码只在内存中短暂存在,落盘一律 AES-GCM 加密,不留明文。 ## 9. 安全相关的前端约束 diff --git a/Dockerfile b/Dockerfile index 213b66a..0355bd0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,20 @@ -# ---- 阶段 1:构建前端 ---- -# Vite 产物输出到 ../src/main/resources/static,被后端打进 jar 一起托管 -FROM node:22-alpine AS frontend -WORKDIR /build/frontend -# 先拷依赖清单,利用 Docker 层缓存:源码变了不必重装依赖 -COPY frontend/package*.json ./ -RUN npm ci -COPY frontend/ ./ -# 产物写到 /build/src/main/resources/static(相对 outDir ../src/...) -RUN npm run build - -# ---- 阶段 2:打后端 jar ---- +# ---- 阶段 1:打后端 jar ---- FROM maven:3.9-eclipse-temurin-17 AS backend WORKDIR /build -# 先拷 pom 预热依赖缓存 +# 先拷 pom 预热依赖缓存:源码变了不必重新下依赖 COPY pom.xml ./ RUN mvn -q dependency:go-offline -# 拷后端源码 + 上一阶段构建好的前端产物 +# 拷后端源码 COPY src/ ./src/ -COPY --from=frontend /build/src/main/resources/static ./src/main/resources/static # 跳过测试打包(测试需要 MySQL,构建环境没有) RUN mvn -q clean package -DskipTests -# ---- 阶段 3:运行 ---- +# ---- 阶段 2:运行 ---- # 只带 JRE,镜像更小 FROM eclipse-temurin:17-jre WORKDIR /app COPY --from=backend /build/target/lowenssh-*.jar app.jar EXPOSE 8081 +# 纯后端 API 服务,供 Flutter 桌面端 / CLI 客户端连接 # 密钥全走环境变量,镜像里不含任何凭据 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..84a49b7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lowen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d47add2..fdff558 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,41 @@ AI 驱动的 SSH 智能运维 Agent。给它一个运维目标和一台服务器,它会像工程师一样一步步排查:自己决定跑什么命令、读结果、调整思路,直到给出结论。危险命令会被安全门禁实时拦截。 -> 这是一个面试项目,演示如何从零手写一个 agentic loop,而不是套用现成框架。核心看点是「看得见 AI 在干什么,也看得见安全护栏起作用」。 +核心看点是「看得见 AI 在干什么,也看得见安全护栏起作用」——整个 agentic loop 是从零手写的,不套用任何编排框架。 + +## 三种形态 + +同一套「Agent loop + 安全门禁 + 上下文管理」理念,落地为三个独立实现,按需选用: + +| 形态 | 目录 | 技术栈 | 说明 | +|------|------|--------|------| +| **后端服务** | `src/` | Java 17 · Spring Boot 3.4 · Spring AI | REST + SSE API,参考实现,逻辑最完整 | +| **桌面客户端** | `clients/app/` | Flutter(macOS / Windows) | 独立桌面应用,内置全套逻辑,直连大模型 | +| **CLI 客户端** | `clients/cli/` | Node 20 · Ink(TUI) | 终端里跑,类 Claude Code 的交互,内置全套逻辑 | + +三者**互不依赖**:桌面端和 CLI 各自内置 SSH + Agent loop + 门禁 + 大模型调用,不需要先起后端。门禁规则与事件语义在三端手动对齐。 + +> 想了解手写 agentic loop、安全门禁、上下文管理的设计取舍,见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。 ## 能力 - **手写 Agentic Loop**:不依赖 LangChain 之类的编排框架,自己实现「模型决策 → 调工具 → 喂回结果 → 再决策」的循环,逻辑完全可控、可读。 - **安全门禁三态**:每条命令在执行前经过 `deny / ask / allow` 判定。`rm -rf`、`find -delete` 等高危操作直接拦截,模型被拦后会自主改用安全方式。 -- **流式可视化**:SSE 实时推送 6 类事件(模型 token、要跑的命令、命令结果、被拦截、最终结论、错误),前端逐字渲染整个排查过程。 +- **流式可视化**:实时推送多类事件(模型 token、要跑的命令、命令结果、被拦截、最终结论、错误),逐字渲染整个排查过程。 - **上下文管理**:多轮对话爆 context 时,自动做大工具结果截断 + 全量 LLM 摘要,复用消息表持久化。 -- **双前端**:图形版(产品形态)和终端版(工具形态),同一套会话状态,两种界面随时切换。 - **全程审计**:每次连接、每条命令、每个拦截决策都落库,可追溯。 ## 技术栈 -**后端**:Java 17 · Spring Boot 3.4 · Spring AI 1.1 · JSch(SSH)· MyBatis-Plus · MySQL · GLM-4.6 +**后端**:Java 17 · Spring Boot 3.4 · Spring AI 1.1 · JSch(SSH)· MyBatis-Plus · MySQL · GLM-4.6(OpenAI 兼容协议,可换任意兼容模型) + +**桌面端**:Flutter · Dart(macOS / Windows 桌面) + +**CLI**:Node 20 · TypeScript · Ink · ssh2 · openai SDK -**前端**:Vite 6 · Vue 3.5(Composition API)· vue-router 4 +## 快速开始(后端服务) -## 快速开始 +后端提供 REST + SSE API。客户端的运行方式见各自目录的 README([桌面端](clients/app/README.md) · [CLI](clients/cli/README.md))。 ### 方式一:Docker 一键启动(推荐) @@ -31,7 +48,7 @@ export GLM_API_KEY='你的智谱AI key' # https://open.bigmodel.cn 申请 docker compose up --build ``` -compose 会自动起 MySQL(建库 + 执行 schema.sql 建表)、构建前后端、等 DB 就绪后启动应用。访问 http://localhost:8081 即可。 +compose 会自动起 MySQL(建库 + 执行 schema.sql 建表)、构建后端、等 DB 就绪后启动应用。API 监听 http://localhost:8081。 ### 方式二:本地手动启动 @@ -48,34 +65,22 @@ export GLM_API_KEY='你的智谱AI key' # https://open.bigmodel.cn 申请 #### 2. 初始化数据库 -先建库,再执行建表脚本: +先建库,再执行建表脚本: ```bash mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS lowenssh DEFAULT CHARSET utf8mb4;" mysql -u root -p lowenssh < src/main/resources/schema.sql ``` -#### 3. 构建前端 +#### 3. 启动后端 -```bash -cd frontend -npm install -npm run build # 产物输出到 ../src/main/resources/static/,由后端直接托管 -``` - -#### 4. 启动后端 +项目自带 Maven Wrapper,无需预装 Maven: ```bash -mvn spring-boot:run +./mvnw spring-boot:run # Windows 用 mvnw.cmd ``` -访问 http://localhost:8081 即可使用(前端和 API 同端口)。 - -### 开发模式(前后端分离调试) - -```bash -cd frontend && npm run dev # dev server 在 5173,/api 自动代理到后端 8081 -``` +API 监听 http://localhost:8081。 ## 项目结构 @@ -87,19 +92,20 @@ LowenSSH/ │ └── ... ├── src/main/resources/ │ ├── application.yml # 配置(密钥走环境变量) -│ ├── schema.sql # 建表脚本 -│ └── static/ # 前端构建产物(npm run build 生成) -├── frontend/ # Vite + Vue3 双界面前端(见 frontend/README.md) -└── DESIGN.md # 前端设计规范 +│ └── schema.sql # 建表脚本 +├── clients/ +│ ├── app/ # Flutter 桌面客户端(见 clients/app/README.md) +│ └── cli/ # Node CLI 客户端(见 clients/cli/README.md) +└── DESIGN.md # 设计规范 ``` ## 安全说明 - 所有密钥走环境变量,源码无任何明文凭据。 -- 前端密码字段不写入 localStorage/sessionStorage,不打印到控制台。 +- 客户端密码字段不写入明文持久化(AES-GCM 加密落库),不打印到控制台。 - 安全门禁的高危命令规则(含 `rm -rf`、`find -delete` 等变体)是真实防护,请勿在生产前移除。 - 这是一个运维 Agent,会真实在目标服务器执行命令。请只连接你有权操作的服务器。 ## License -MIT +[MIT](LICENSE) diff --git a/clients/app/README.md b/clients/app/README.md index 8261375..e233b33 100644 --- a/clients/app/README.md +++ b/clients/app/README.md @@ -1,17 +1,45 @@ -# lowenssh +# LowenSSH 桌面客户端 -A new Flutter project. +LowenSSH 的桌面应用形态,基于 Flutter,支持 macOS 和 Windows。 -## Getting Started +内置全套逻辑——SSH 连接、手写 Agent loop、安全门禁、上下文管理、直连大模型——**不依赖项目的 Java 后端**,独立运行。 -This project is a starting point for a Flutter application. +## 环境要求 -A few resources to get you started if this is your first Flutter project: +- [Flutter SDK](https://docs.flutter.dev/get-started/install) 3.12+(Dart 3.12+) +- macOS 构建需 Xcode;Windows 构建需 Visual Studio(含「使用 C++ 的桌面开发」工作负载) -- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) -- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) +确认环境就绪: -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +```bash +flutter doctor +``` + +## 运行 + +```bash +cd clients/app +flutter pub get + +flutter run -d macos # macOS +flutter run -d windows # Windows +``` + +## 打包 + +```bash +flutter build macos # 产物在 build/macos/Build/Products/Release/ +flutter build windows # 产物在 build/windows/x64/runner/Release/ +``` + +## 大模型配置 + +首次启动后,在应用内「设置」里填入大模型 API Key(默认接入 GLM,走 OpenAI 兼容协议,可改 baseURL / model 换成任意兼容模型)。 + +配置保存在 `~/.lowenssh/config.json`。也可通过环境变量 `GLM_API_KEY` 注入,优先级高于配置文件,且不会被写回文件。 + +## 安全说明 + +- 主机密码 AES-GCM 加密后落盘,不存明文,不打印到控制台。 +- 端口转发隧道默认绑定 `127.0.0.1`,仅本机可访问,不暴露到局域网。 +- 这是一个运维 Agent,会真实在目标服务器执行命令。请只连接你有权操作的服务器。 diff --git a/clients/cli/README.md b/clients/cli/README.md new file mode 100644 index 0000000..9e81585 --- /dev/null +++ b/clients/cli/README.md @@ -0,0 +1,58 @@ +# LowenSSH CLI + +LowenSSH 的命令行形态,在终端里跑的 AI SSH 运维 Agent,交互体验类似 Claude Code。基于 Node + Ink(TUI)。 + +内置全套逻辑——SSH 连接、手写 Agent loop、安全门禁、上下文管理、直连大模型——**不依赖项目的 Java 后端**,独立运行。 + +## 环境要求 + +- Node.js 20+ + +## 安装依赖 + +```bash +cd clients/cli +npm install +``` + +## 大模型配置 + +CLI 需要大模型 API Key(默认接入 GLM,走 OpenAI 兼容协议)。两种方式,环境变量优先: + +```bash +export GLM_API_KEY='你的智谱AI key' # https://open.bigmodel.cn 申请 +``` + +或写进配置文件 `~/.lowenssh/config.json`(文件权限 600)。缺 Key 时启动会给出明确提示,不会静默失败。 + +## 运行 + +开发模式(直接跑 TS 源码): + +```bash +npm run dev # 启动交互式 TUI +npm run dev add-host # 添加主机(无需 API Key) +``` + +构建后作为命令安装: + +```bash +npm run build # 产物输出到 dist/ +npm link # 注册全局命令 lowenssh +lowenssh # 启动 +lowenssh add-host # 添加主机 +``` + +## 开发 + +```bash +npm test # vitest 跑单测(门禁、加密) +npm run typecheck # tsc 类型检查 +``` + +## 安全说明 + +- 主机密码 AES-GCM 加密后落盘,配置文件权限 600,不存明文。 +- 环境变量注入的 API Key 不会被写回配置文件。 +- 安全门禁的高危命令规则与后端、桌面端对齐,是真实防护。 +- 这是一个运维 Agent,会真实在目标服务器执行命令。请只连接你有权操作的服务器。 diff --git a/PROJECT_HIGHLIGHTS.md b/docs/ARCHITECTURE.md similarity index 55% rename from PROJECT_HIGHLIGHTS.md rename to docs/ARCHITECTURE.md index 363794b..acdccc6 100644 --- a/PROJECT_HIGHLIGHTS.md +++ b/docs/ARCHITECTURE.md @@ -1,19 +1,14 @@ -# LowenSSH 项目亮点(简历素材) +# 架构与设计 -> 一个 AI 驱动的 SSH 智能体:用自然语言下达任务,Agent 自主连上目标 Linux 服务器,分步执行命令、读文件、看日志,过程带安全门禁,最后给出中文结论。 -> 技术栈:Spring Boot 3.4.3 + Spring AI 1.1.5 + GLM-4.7 + JSch + MySQL + MyBatis-Plus + Vue 3。后端约 2500 行 Java,35 个单测。 +LowenSSH 是一个 AI 驱动的 SSH 智能体:用自然语言下达任务,Agent 自主连上目标 Linux 服务器,分步执行命令、读文件、看日志,过程带安全门禁,最后给出中文结论。 ---- - -## 一句话简历版(可直接用) +本文记录核心设计决策与取舍。技术栈:Spring Boot 3.4 + Spring AI 1.1 + GLM-4.6 + JSch + MySQL + MyBatis-Plus。后端约 2500 行 Java,35 个单测。 -> 独立设计并实现「LowenSSH」AI SSH 智能体:**手写 agentic loop** 接管工具执行,植入 **deny/ask/allow 三态安全门禁**作为不可绕过的硬边界;针对 LLM 多轮对话 O(N²) 的 token 膨胀,借鉴 Claude Code 设计**四层上下文管理 + 上下文缓存友好策略**,并接入**缓存命中率实时测量**;全链路 SSE 流式输出,密码 AES-GCM 加密落库。 +> 桌面端(Flutter)与 CLI(Node)是平行的独立实现,各自内置同样的 loop / 门禁 / 上下文管理理念,本文以后端实现为主线讲解。 --- -## 核心亮点(按面试价值排序) - -### 1. 手写 Agentic Loop —— 不用框架自动循环,为的是把安全卡进执行链 +## 1. 手写 Agentic Loop —— 不用框架自动循环,为的是把安全卡进执行链 **问题**:Spring AI 自带工具自动循环,会在框架内部直接把模型要调用的命令执行掉。这意味着「执行前人工确认 / 危险命令拦截」根本插不进去。 @@ -23,11 +18,11 @@ - 加最大轮数上限防死循环。 - 任一命令被拒:不执行,手动回灌「拒绝」作为工具结果,loop 继续让模型换方案——而不是直接报错中断。 -**可讲的点**:理解了 Agent 的本质是「模型 + 工具 + 循环」的状态机;知道为什么有时候要放弃框架的便利去换控制权。 +**要点**:Agent 的本质是「模型 + 工具 + 循环」的状态机;有时候要放弃框架的便利去换控制权。 --- -### 2. Deny/Ask/Allow 三态安全门禁 —— Agent 安全的硬边界 +## 2. Deny/Ask/Allow 三态安全门禁 —— Agent 安全的硬边界 **设计原则(落地,非口号)**: 1. **安全检查是独立代码路径**,不写进工具方法、不靠模型自觉——模型越狱也绕不过这层。 @@ -35,49 +30,49 @@ 3. **只看实际要执行的命令**,不看模型话术,防花言巧语骗过门禁。 4. **复合命令拆段逐查**:`ls && rm -rf /` 会被 `&& || | ;` 拆开,任一段命中 deny 则整条拒绝,防整条当一段漏过。 -**真机联调发现的绕过并修复**:模型被拦 `rm -rf` 后会**改用 `find ... -delete` / `find ... -exec rm` 达成同样的删除**——补进 deny 名单。这是一条很好的「攻防对抗」叙事。 +**真机联调发现的绕过并修复**:模型被拦 `rm -rf` 后会**改用 `find ... -delete` / `find ... -exec rm` 达成同样的删除**——补进 deny 名单。这是一条典型的攻防对抗迭代。 **正则细节**:用 `\b` 词边界保证匹配独立命令词而非子串(`dd` 不误伤 `add`)。 -**可讲的点**:纵深防御思想;把「不信任模型输出」做成代码约束;从真实对抗中迭代规则。 +**要点**:纵深防御;把「不信任模型输出」做成代码约束;从真实对抗中迭代规则。 --- -### 3. 四层上下文管理 + 上下文缓存友好 —— 直面 LLM 多轮 O(N²) token 膨胀 +## 3. 四层上下文管理 + 上下文缓存友好 —— 直面 LLM 多轮 O(N²) token 膨胀 **问题**:agentic loop 每轮都把完整历史重发给模型,轮数越多、历史越长,token 烧得越凶(O(N²))。一次 200w token 配额很快打满,触发 429。 **借鉴 Claude Code 的分层压缩思路,落地为**: - **Layer 0 工具结果截断**:单条大输出(cat 大文件、tail 海量日志)超阈值截掉中段,留头 60% 尾 40% + 截断提示。 -- **分级收紧(本轮优化)**:最近 K 条工具结果保大阈值(3000 字符)保细节;**更早的收紧到小阈值(800 字符)**——旧命令模型已读过、结论已在历史里,没必要每轮全量重发。让 token 不随轮数线性膨胀。 +- **分级收紧**:最近 K 条工具结果保大阈值(3000 字符)保细节;**更早的收紧到小阈值(800 字符)**——旧命令模型已读过、结论已在历史里,没必要每轮全量重发。让 token 不随轮数线性膨胀。 - **Layer 4 历史压缩**:整段超阈值时把较早对话丢给 LLM 摘要成一条,保留 system + 最近 K 条原文。带**成对约束**(assistant 的 tool_call 与其 tool_result 必须成对,切割点不能落中间,否则 GLM 直接报错)+ **熔断**(摘要 LLM 连续失败到阈值就停止压缩裸跑兜底)。 **上下文缓存友好(第一性原理)**: -- 学习发现:省 token 的核心杠杆不是「压缩」,而是**保住缓存命中**——GLM 支持隐式上下文缓存,命中的 token 按更低价计费(约 10x 差距)。 +- 省 token 的核心杠杆不只是「压缩」,更是**保住缓存命中**——GLM 支持隐式上下文缓存,命中的 token 按更低价计费(约 10x 差距)。 - 缓存命中的前提是**前缀字节稳定**(system prompt + 工具 schema + 早期历史不变)。 - 据此做了三件事:① 系统提示词保持常量;② `options`(含工具 schema)在循环外只构建一次,避免每轮重建导致序列化抖动毁缓存;③ 截断逻辑保持**幂等**,同一条结果跨轮字节一致。 -**缓存命中率测量**:每轮模型调用后打印 `prompt/completion/total/cached/命中率`,从 GLM 返回的 `prompt_tokens_details.cached_tokens` 防御式读取——**先测量再优化,用数据说话**。 +**缓存命中率测量**:每轮模型调用后打印 `prompt/completion/total/cached/命中率`,从 GLM 返回的 `prompt_tokens_details.cached_tokens` 防御式读取——先测量再优化,用数据说话。 -**可讲的点**:理解 LLM 推理的成本结构(缓存经济学);能把「向大厂学到的设计」拆解成可落地的小改动;懂得「先量化现状再动手」的工程方法论。 +**要点**:理解 LLM 推理的成本结构(缓存经济学);把「学到的设计」拆成可落地的小改动;先量化现状再动手。 --- -### 4. 全链路 SSE 流式输出 —— 边推理边展示 +## 4. 全链路 SSE 流式输出 —— 边推理边展示 - 对外用 `Sinks.Many` 造一条 `Flux`,方法立刻返回不阻塞。 - agentic loop 是命令式 while(内部有 block 调用),放独立线程跑,不污染 reactor 调度线程。 - 每轮 `chatModel.stream` 拿 token 流,用 `MessageAggregator` **边推 Token 事件、边把碎片聚合成完整 ChatResponse**(含 tool_call),聚合完再走门禁/执行。 - 事件类型化:`Token / ToolCall / ToolResult / Blocked / Done / Error`,前端按类型渲染。 -**可讲的点**:响应式与命令式的边界处理(哪些该异步、哪些必须同步阻塞);混合线程模型的取舍。 +**要点**:响应式与命令式的边界处理(哪些该异步、哪些必须同步阻塞);混合线程模型的取舍。 --- -### 5. 工程细节与安全 +## 5. 工程细节与安全 - **密码 AES-GCM 加密落库**:主机簿密码绝不存明文。选 GCM 因其自带完整性校验(认证标签),密文被篡改解密会失败,比 CBC 安全。密文格式 `Base64(iv[12] + cipherText + tag[16])`,IV 每次随机。密钥走环境变量。 -- **密钥全部走环境变量**:`SSH_PASSWORD / MYSQL_PASSWORD / GLM_API_KEY / XWSSH_CRYPTO_KEY` 从 `.env.local` 注入,代码不硬编码、日志不回显 value。 +- **密钥全部走环境变量**:`MYSQL_PASSWORD / GLM_API_KEY / XWSSH_CRYPTO_KEY` 从环境变量注入,代码不硬编码、日志不回显 value。 - **Lazy-create 会话**:连接时只建 SSH 预连接(不落库),首个任务到来才真正创建会话行——避免「点开历史就生成一堆空会话」的脏数据。 - **JSch 的坑**:stdout 走 channel 的 InputStream,stderr 要单独 `setErrStream` 接,否则丢错误输出。 - **完整审计**:危险命令拦截、用户拒绝都落审计表,可还原「模型想跑什么、被拦在哪」。 @@ -85,33 +80,33 @@ --- -## 技术决策问答(面试可能追问) +## 技术决策问答 **Q:为什么手写 loop 不用框架自动循环?** -A:框架自动循环把工具执行藏在内部,没法在「执行前」插入安全门禁。安全是这个项目的核心卖点,必须拿回执行控制权,所以用 `internalToolExecutionEnabled(false)` 关掉自动执行。 +框架自动循环把工具执行藏在内部,没法在「执行前」插入安全门禁。安全是这个项目的核心,必须拿回执行控制权,所以用 `internalToolExecutionEnabled(false)` 关掉自动执行。 **Q:安全门禁为什么用规则而不是让模型自己判断?** -A:模型可被越狱、被话术绕过,不能把安全托付给被防护对象本身。门禁是独立代码路径、纯函数、只看真实命令,模型怎么都绕不过。这是纵深防御的基本原则。 +模型可被越狱、被话术绕过,不能把安全托付给被防护对象本身。门禁是独立代码路径、纯函数、只看真实命令,模型怎么都绕不过。这是纵深防御的基本原则。 **Q:token 优化的核心是什么?** -A:不是粗暴删历史,而是两层——① 让历史「别再变」以命中上下文缓存(缓存 token 便宜约 10x);② 旧工具结果分级收紧,让 token 不随轮数线性膨胀。而且先接了命中率测量,用数据驱动优化方向。 +不是粗暴删历史,而是两层——① 让历史「别再变」以命中上下文缓存(缓存 token 便宜约 10x);② 旧工具结果分级收紧,让 token 不随轮数线性膨胀。而且先接了命中率测量,用数据驱动优化方向。 **Q:为什么选 GLM?** -A:OpenAI 兼容协议,Spring AI 直接接;国内访问稳定、有免费/低价配额适合自费做项目;支持隐式上下文缓存,正好验证缓存优化。 +OpenAI 兼容协议,Spring AI 直接接;国内访问稳定、有低价配额;支持隐式上下文缓存,正好验证缓存优化。换任意 OpenAI 兼容模型只需改 baseURL / model。 --- -## 路线图(体现产品思维) +## 路线图 当前是「连上去查/操作并给结论」的智能体,规划演进方向: -1. 帮忙传输文件(上传/下载) -2. 帮忙部署项目(拉代码、装依赖、起服务) -3. 监控项目运行状态 +1. 文件传输(上传/下载) +2. 项目部署(拉代码、装依赖、起服务) +3. 项目运行状态监控 4. 类 Prometheus 的指标监控 --- -## 量化数据(简历可填) +## 量化数据 - 后端约 **2500 行 Java**,**35 个单元测试** - 安全门禁覆盖 **11 条 deny 规则 + 8 条 ask 规则**,支持复合命令拆段 diff --git a/docs/PLAN-clients.md b/docs/PLAN-clients.md deleted file mode 100644 index 1b4c0e6..0000000 --- a/docs/PLAN-clients.md +++ /dev/null @@ -1,66 +0,0 @@ -# LowenSSH 多端客户端实施计划(App 端 + 终端端) - -## 目标 -在现有 Web 版(Vue + Spring Boot,单端口 8081)之外,新增两个**独立客户端**,各自**内置逻辑**(不依赖 Spring Boot 后端,自己实现 SSH + Agent loop + 门禁 + 调 GLM): - -- **App 端** = 独立桌面应用程序(非网页),适配 macOS + Windows,体验优先 → **Flutter Desktop** -- **终端端** = 在 terminal 里跑、类似 Claude Code 的 CLI/TUI,适配 macOS + Windows → **Node.js + Ink** - -代码复用策略:**各自独立实现**。终端端用 TS 一套核心,App 端用 Dart 一套核心。门禁规则与 6 类事件语义两端手动对齐。 - -## 必须从现有 Java 后端移植的核心逻辑(已读透) -1. **Agent loop**(AgentService):手写 agentic 循环。关闭框架自动执行工具 → 拿到 tool_call → 门禁预检 → 全放行才执行 → 结果回灌 → 带新历史再请求;任一被拒则回灌"拒绝"结果让模型换方案。最大轮数上限防死循环(默认 40)。 -2. **门禁三态**(CommandGuard):DENY/ASK/ALLOW,纯函数。先 deny 再 ask 后 allow;复合命令按 `&& || | ; 换行` 拆段取最严。DENY/ASK 正则名单需 1:1 移植(rm -rf、mkfs、dd、shutdown、find -delete 等)。 -3. **6 类 SSE 事件语义**(AgentEvent):token / reasoning / tool_call / tool_result / blocked / done / error / session_ready / session_expired。语义色是记忆点核心,两端对齐。 -4. **SSH 执行**(SshClient):长连接复用,exec 收集 stdout/stderr/exitCode;SFTP 列目录/删/建/移。 -5. **上下文管理**(ContextManager):Layer 0 工具结果分级截断 + Layer 4 历史超阈值 LLM 摘要压缩(带熔断)。 -6. **GLM 接入**:OpenAI 兼容协议,base-url `https://open.bigmodel.cn/api/paas/v4`,completions-path `/chat/completions`,model `glm-4.6/4.7`,需支持 function calling + streaming + reasoning_content。 -7. **密码加密**(CryptoUtil):AES-256-GCM,密文 = Base64(iv[12]+ct+tag[16]),密钥 SHA-256 派生。 - -## 安全要点(内置带来的) -- API key 与主机密码改为**本地存储**:App 端用系统钥匙串(macOS Keychain / Windows Credential Manager),终端端用本地配置文件(`~/.lowenssh/config`,权限 600)+ 复用 AES-GCM 加密。 -- 绝不把密码/key 回显或上报。门禁是独立代码路径,不写进工具、不靠模型自觉。 - ---- - -## 阶段划分 - -### 阶段 1:终端端(Node.js + Ink)—— 先做,验证核心逻辑 -工程独立放在 `clients/cli/`。 - -1.1 脚手架:TS + Ink + ink 相关库;`bin` 入口;`tsup`/`esbuild` 打包;目标 `npx lowenssh` 或全局安装。 -1.2 核心库 `src/core/`(纯逻辑,无 UI,可单测): - - `guard.ts` — 门禁三态(1:1 移植正则) - - `ssh.ts` — SSH 执行(用 `ssh2` 库) - - `glm.ts` — OpenAI 兼容 client(用官方 openai sdk 指 base-url,或 fetch 手写) - - `agent.ts` — 手写 loop,对外吐 6 类事件(EventEmitter / async generator) - - `context.ts` — 上下文截断 + 压缩 - - `crypto.ts` — AES-GCM - - `config.ts` — 本地配置读写(host 簿、API key) -1.3 TUI 层 `src/ui/`:主机选择 → 对话流(token 流式渲染、tool_call 折叠、blocked 红色高亮、reasoning 灰显)→ ASK 态命令交互式确认(y/n)。 -1.4 验证:单测门禁;真机连一台测试机跑通"查磁盘/查进程"。 - -### 阶段 2:App 端(Flutter Desktop)—— 体验优先 -工程独立放在 `clients/app/`。 - -2.1 脚手架:Flutter desktop(macos + windows enable);Riverpod 状态管理(你熟);分层 data/domain/ui。 -2.2 核心库 `lib/core/`(Dart 重写同一套逻辑): - - 门禁(移植正则)、SSH(用 `dartssh2`)、GLM(dio + SSE 解析)、loop、context、crypto(pointycastle)、安全存储(flutter_secure_storage) -2.3 UI:复刻并提升现有 HUD 指挥中心视觉(深空底 + 青色辉光 #2dd4bf),五个区:主机簿 / 对话 / 终端流 / 文件(SFTP) / 监控。 -2.4 验证:mac 跑通;门禁单测;真机连测试机。 - -### 阶段 3:打磨与分发 -- 终端端:发 npm(或单文件可执行)。 -- App 端:mac `.dmg` + win 安装包;签名按需。 -- 两端门禁规则集中成一份"规则清单"文档,保证一致。 - ---- - -## 建议先做哪一个 -**建议先做终端端(阶段 1)**:工作量小、最快跑通核心 loop,验证门禁/事件/GLM 对接无误后,再把同一套逻辑用 Dart 重写进 App 端,风险最低。 - -## 待确认 -- 先做哪个?(建议终端端先行) -- App 端 UI 是完全复刻现有 HUD 风格,还是借机做新设计? -- 是否需要两端都连同一个本地主机簿(共享配置文件),还是各存各的? - diff --git a/docs/PLAN-sftp-monitor.md b/docs/PLAN-sftp-monitor.md deleted file mode 100644 index c78f076..0000000 --- a/docs/PLAN-sftp-monitor.md +++ /dev/null @@ -1,124 +0,0 @@ -# 开发计划:SFTP 文件管理 + 监控 - -> 目标:在现有 AI 运维 Agent 基础上,增加 ①SFTP 文件管理(人 + AI 双形态)②监控(远端主机指标 + 应用自身)。 -> 原则:复用现有常驻 JSch 连接,零重连;分阶段可独立交付;改动范围最小。 - -## 一、现状复用点(已确认) - -- `SshClient` 持有 JSch `Session`,可在同一 Session 上 `openChannel("sftp")`,**SFTP 不用重连**。 -- `SessionManager.LiveSession` 按 hostId/sessionId 管理常驻连接,FTP/监控都从这里取连接。 -- `SshTools` 已是 `@Tool` 模式,新增 SFTP 工具直接挂上去给 Agent 用。 -- 监控指标采集走现有 `SshClient.exec()`,无需新通道。 - ---- - -## 阶段 1:SFTP 底层能力(SshClient 扩展) - -**目标**:在 SshClient 上加 SFTP 原子操作,作为人/AI 两条路径的共同底座。 - -- `SshClient` 新增方法(复用同一 Session,懒开 ChannelSftp): - - `List listDir(String path)` — ls,返回名称/大小/权限/是否目录/修改时间 - - `void upload(InputStream in, String remotePath)` — 上传 - - `void download(String remotePath, OutputStream out)` — 下载 - - `void deleteFile(String path)` / `void mkdir(String path)` / `void rename(String from, String to)` -- 新增 `ssh/RemoteFile.java`(record:name/path/size/isDir/perms/mtime) -- ChannelSftp 生命周期:随 Session 关闭一并释放,加进 `SshClient.close()` -- **安全**:路径做基本规范化校验,拒绝明显越权(可选,先不做沙箱) - -**验证**:单元测试或对京东生产那台跑一次 list/upload/download 往返。 - ---- - -## 阶段 2:SFTP 文件管理面板(给人用) - -**目标**:图形界面浏览/上传/下载/删除远端文件,类似宝塔文件管理器的轻量版。 - -后端: -- 新增 `SftpController`,REST 接口(按 hostId 取 LiveSession): - - `GET /api/sftp/{hostId}/list?path=/xxx` — 列目录 - - `POST /api/sftp/{hostId}/upload`(multipart)— 上传 - - `GET /api/sftp/{hostId}/download?path=/xxx` — 下载(流式) - - `DELETE /api/sftp/{hostId}/file?path=/xxx` — 删除 - - `POST /api/sftp/{hostId}/mkdir` — 建目录 -- 复用 `LiveSession.lock()` 串行化,避免 SFTP 与 Agent 命令抢同一 Session 冲突 - -前端: -- 新增 `FilesView.vue`(或在 HostsView 加「文件」入口) -- 文件列表(面包屑路径 + 表格)、上传按钮、下载/删除操作 -- 大文件下载用浏览器原生下载,不走内存 - -**验证**:browse 实测一遍浏览→上传→下载→删除。 - ---- - -## 阶段 3:SFTP 作为 Agent 工具(给 AI 用) - -**目标**:Agent 能自主传文件、改配置(如下载日志分析、上传修复脚本)。 - -- `SshTools` 新增 `@Tool`: - - `downloadAndRead(path)` — 下载并返回文本内容(已有 readRemoteFile,可能够用,差异在二进制/大文件) - - `writeRemoteFile(path, content)` — 写入/覆盖远端文件(**危险操作,过 CommandGuard 安全门禁**) - - `uploadScript(path, content)` — 上传脚本 -- **安全重点**:写文件类工具必须接入现有 `CommandGuard` 的 deny/ask/allow 流程, - 覆盖系统配置(/etc 下)默认 ask。这是这一阶段的核心,不能裸放。 - -**验证**:让 Agent 执行「把 /etc/nginx/nginx.conf 下载下来看看」+「上传一个测试脚本」,确认安全门禁拦截写操作。 - ---- - -## 阶段 4:远端主机指标监控 - -**目标**:实时展示目标服务器 CPU/内存/磁盘/负载,折线图。 - -后端: -- 新增 `MetricsService`:用 `LiveSession.ssh().exec()` 采集 - - CPU:`top -bn1` 或 `/proc/stat` 两次采样算使用率 - - 内存:`free -b` - - 磁盘:`df -B1` - - 负载:`/proc/loadavg` - - 解析成 `HostMetrics` record -- 接口形态二选一(plan 里默认轮询,简单稳;如需推送再升级 SSE): - - `GET /api/metrics/{hostId}` — 返回一次快照,前端定时轮询(默认 5s) -- 采集走 LiveSession.lock(),避免和 Agent / SFTP 抢连接 - -前端: -- 新增 `MonitorView.vue`,用轻量图表库(如 ECharts 或纯 canvas) -- 4 个指标卡片 + 折线图,前端维护滑动窗口(最近 N 个采样点) -- 异常阈值高亮(如磁盘 >90% 标红) - -**验证**:browse 打开监控页,确认指标刷新、图表滚动。 - ---- - -## 阶段 5:应用自身监控(Actuator + Micrometer) - -**目标**:监控 LowenSSH 应用本身:JVM、连接数、请求耗时、GLM 调用量。 - -- `pom.xml` 加 `spring-boot-starter-actuator` + `micrometer-registry-prometheus` -- 暴露 `/actuator/health`、`/actuator/metrics`、`/actuator/prometheus` -- 自定义指标(@Timed / Counter): - - 活跃 LiveSession 数(Gauge) - - Agent 任务执行次数/耗时 - - GLM API 调用次数/token 消耗(如能从 Spring AI 拿到) -- 前端(可选):在监控页加「应用」tab,读 /actuator/metrics 展示 -- 或直接对接 Prometheus + Grafana(如果你有现成的) - -**验证**:curl /actuator/health 返回 UP;/actuator/prometheus 有自定义指标。 - ---- - -## 建议交付顺序 - -1. **阶段 1**(底层,必做,1~2 个文件) -2. **阶段 2**(人用面板,独立可演示) -3. **阶段 4**(远端监控,独立可演示,面试亮点) -4. **阶段 3**(Agent 工具,依赖安全门禁,体现 AI 运维深度) -5. **阶段 5**(应用监控,工程完整性加分) - -每阶段做完即可 commit + browse 验证,互不阻塞。 - -## 风险点 - -- **连接竞争**:SFTP/监控/Agent 共用同一 JSch Session,必须用 LiveSession.lock() 串行化,否则 channel 串数据。这是最大的坑。 -- **监控采集开销**:5s 轮询跑 top/free/df,注意别给目标服务器加负载;间隔可配。 -- **写文件安全**:阶段 3 的写操作是真能改坏服务器的,安全门禁不能省。 diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index 37c904a..0000000 --- a/frontend/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# LowenSSH 前端 - -AI SSH 智能运维 Agent 的 Web 界面。一套代码两种界面,共享同一份会话状态: - -- **图形版** `/`:卡片 + 气泡混排,定位「这是个产品」,适合演示和不熟命令行的用户。 -- **终端版** `/terminal`:仿 SSH 会话的单列 log 流,全等宽字体,定位「这是个工具」,适合运维人员。 - -技术栈:Vite 6 + Vue 3.5(Composition API)+ vue-router 4。设计规范见仓库根目录 `DESIGN.md`。 - -## 开发 - -```bash -npm install -npm run dev # 启动 dev server(默认 5173),/api 代理到 localhost:8081 后端 -``` - -后端需先跑起来(见根目录 README),dev server 通过 `vite.config.js` 里的 proxy 把 `/api` 转发到后端,避免跨域。 - -## 构建 - -```bash -npm run build # 产物输出到 ../src/main/resources/static/,由 Spring Boot 直接托管 -``` - -构建后启动后端即可访问完整应用,无需单独部署前端。`static/` 目录不提交(已在 .gitignore),由构建生成。 - -## 关键实现 - -- **SSE 流式**:后端是 POST 流式端点,`EventSource` 只支持 GET,所以用 `fetch` + `ReadableStream` 手动解析 SSE(见 `composables/useAgentStream.js`)。 -- **跨视图共享**:`useAgentStream` 用模块级单例,图形版和终端版切换时会话不丢。 -- **6 类事件渲染**:`token`/`tool_call`/`tool_result`/`blocked`/`done`/`error`,其中 `blocked`(安全门禁拦截)视觉权重最重——红边框 + 染底 + 图标,让人一眼看到护栏起了作用。 - -## 安全 - -密码字段 `type=password` + `autocomplete=off`,不写入 localStorage/sessionStorage,不打印到 console。 diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index cfb4848..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - LowenSSH · AI 运维 Agent - - - - - - -
- - - diff --git a/frontend/package-lock.json b/frontend/package-lock.json deleted file mode 100644 index 0aa863e..0000000 --- a/frontend/package-lock.json +++ /dev/null @@ -1,1415 +0,0 @@ -{ - "name": "lowenssh-frontend", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "lowenssh-frontend", - "version": "1.0.0", - "dependencies": { - "dompurify": "^3.4.11", - "marked": "^18.0.5", - "vue": "^3.5.13", - "vue-router": "^4.5.0" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^5.2.1", - "vite": "^6.0.7" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", - "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", - "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", - "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", - "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", - "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", - "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", - "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", - "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", - "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", - "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", - "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", - "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", - "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", - "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", - "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", - "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", - "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", - "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", - "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", - "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", - "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", - "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", - "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", - "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", - "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.38", - "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.38.tgz", - "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@vue/shared": "3.5.38", - "entities": "^7.0.1", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.38", - "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", - "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.38", - "@vue/shared": "3.5.38" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.38", - "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", - "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@vue/compiler-core": "3.5.38", - "@vue/compiler-dom": "3.5.38", - "@vue/compiler-ssr": "3.5.38", - "@vue/shared": "3.5.38", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.15", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.38", - "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", - "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.38", - "@vue/shared": "3.5.38" - } - }, - "node_modules/@vue/devtools-api": { - "version": "6.6.4", - "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", - "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", - "license": "MIT" - }, - "node_modules/@vue/reactivity": { - "version": "3.5.38", - "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.38.tgz", - "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.38" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.38", - "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.38.tgz", - "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.38", - "@vue/shared": "3.5.38" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.38", - "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", - "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.38", - "@vue/runtime-core": "3.5.38", - "@vue/shared": "3.5.38", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.38", - "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.38.tgz", - "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.38", - "@vue/shared": "3.5.38" - }, - "peerDependencies": { - "vue": "3.5.38" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.38", - "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.38.tgz", - "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, - "node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/marked": { - "version": "18.0.5", - "resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz", - "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rollup": { - "version": "4.62.0", - "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.0.tgz", - "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.0", - "@rollup/rollup-android-arm64": "4.62.0", - "@rollup/rollup-darwin-arm64": "4.62.0", - "@rollup/rollup-darwin-x64": "4.62.0", - "@rollup/rollup-freebsd-arm64": "4.62.0", - "@rollup/rollup-freebsd-x64": "4.62.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", - "@rollup/rollup-linux-arm-musleabihf": "4.62.0", - "@rollup/rollup-linux-arm64-gnu": "4.62.0", - "@rollup/rollup-linux-arm64-musl": "4.62.0", - "@rollup/rollup-linux-loong64-gnu": "4.62.0", - "@rollup/rollup-linux-loong64-musl": "4.62.0", - "@rollup/rollup-linux-ppc64-gnu": "4.62.0", - "@rollup/rollup-linux-ppc64-musl": "4.62.0", - "@rollup/rollup-linux-riscv64-gnu": "4.62.0", - "@rollup/rollup-linux-riscv64-musl": "4.62.0", - "@rollup/rollup-linux-s390x-gnu": "4.62.0", - "@rollup/rollup-linux-x64-gnu": "4.62.0", - "@rollup/rollup-linux-x64-musl": "4.62.0", - "@rollup/rollup-openbsd-x64": "4.62.0", - "@rollup/rollup-openharmony-arm64": "4.62.0", - "@rollup/rollup-win32-arm64-msvc": "4.62.0", - "@rollup/rollup-win32-ia32-msvc": "4.62.0", - "@rollup/rollup-win32-x64-gnu": "4.62.0", - "@rollup/rollup-win32-x64-msvc": "4.62.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vue": { - "version": "3.5.38", - "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.38.tgz", - "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.38", - "@vue/compiler-sfc": "3.5.38", - "@vue/runtime-dom": "3.5.38", - "@vue/server-renderer": "3.5.38", - "@vue/shared": "3.5.38" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/vue-router": { - "version": "4.6.4", - "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", - "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", - "license": "MIT", - "dependencies": { - "@vue/devtools-api": "^6.6.4" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "vue": "^3.5.0" - } - } - } -} diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index 3af1aea..0000000 --- a/frontend/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "lowenssh-frontend", - "version": "1.0.0", - "description": "LowenSSH AI SSH 智能运维 Agent 前端:图形版 + 终端版双界面", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "dompurify": "^3.4.11", - "marked": "^18.0.5", - "vue": "^3.5.13", - "vue-router": "^4.5.0" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^5.2.1", - "vite": "^6.0.7" - } -} diff --git a/frontend/src/App.vue b/frontend/src/App.vue deleted file mode 100644 index 7e7c9cd..0000000 --- a/frontend/src/App.vue +++ /dev/null @@ -1,307 +0,0 @@ - - - - - diff --git a/frontend/src/components/ChatComposer.vue b/frontend/src/components/ChatComposer.vue deleted file mode 100644 index e7185d8..0000000 --- a/frontend/src/components/ChatComposer.vue +++ /dev/null @@ -1,180 +0,0 @@ - - -