import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; /// MCP 服务器的控制通道。 const MethodChannel mcpChannel = MethodChannel('com.nonvioce.cal/mcp'); /// MCP 服务器的状态。 class McpStatus { final bool running; final int port; final List tools; final String? error; const McpStatus({ required this.running, required this.port, this.tools = const [], this.error, }); /// 局域网地址。真正的 IP 由服务端在状态里给出,这里只是兜底显示。 String get endpoint => 'http://<本机 IP>:$port/mcp'; static const idle = McpStatus(running: false, port: 0); } /// 读写 MCP 服务器状态。 /// /// 按设计(Q18/Q25),MCP 是**只读 + 求值**的服务:AI 客户端能算、能取会话状态、 /// 能读语法文档,但**不能**往里塞代码或文件 —— 插件相关的五个工具已经全部删掉, /// 也没有 Rhino。 class McpController extends ChangeNotifier { McpStatus status = McpStatus.idle; bool busy = false; Future refresh() async { try { final m = await mcpChannel.invokeMapMethod('status'); status = _parse(m); } on PlatformException catch (e) { status = McpStatus(running: false, port: 0, error: e.message); } notifyListeners(); } Future start() async { busy = true; notifyListeners(); try { final m = await mcpChannel.invokeMapMethod('start'); status = _parse(m); } on PlatformException catch (e) { status = McpStatus(running: false, port: 0, error: e.message); } busy = false; notifyListeners(); } Future stop() async { busy = true; notifyListeners(); try { await mcpChannel.invokeMethod('stop'); status = McpStatus.idle; } on PlatformException catch (e) { status = McpStatus(running: false, port: 0, error: e.message); } busy = false; notifyListeners(); } static McpStatus _parse(Map? m) { if (m == null) return McpStatus.idle; return McpStatus( running: m['running'] == true, port: (m['port'] as num?)?.toInt() ?? 0, tools: ((m['tools'] as List?) ?? const []).map((e) => '$e').toList(), error: m['error'] as String?, ); } } /// 弹出 MCP 控制面板。控制台的 `mcp` 命令走这里。 Future showMcpPanel(BuildContext context) async { final ctrl = McpController(); await ctrl.refresh(); if (!context.mounted) return; await showModalBottomSheet( context: context, showDragHandle: true, builder: (_) => ChangeNotifierProvider.value( value: ctrl, child: const _McpSheet(), ), ); } class _McpSheet extends StatelessWidget { const _McpSheet(); @override Widget build(BuildContext context) { final ctrl = context.watch(); final s = ctrl.status; final scheme = Theme.of(context).colorScheme; return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( s.running ? Icons.cloud_done_outlined : Icons.cloud_off_outlined, color: s.running ? scheme.primary : scheme.outline, ), const SizedBox(width: 8), Text('MCP 服务器', style: Theme.of(context).textTheme.titleMedium), const Spacer(), if (s.running) Text('端口 ${s.port}', style: const TextStyle(fontFamily: 'monospace')), ], ), const SizedBox(height: 12), const Text( '把计算引擎开放给 AI 客户端。只读 + 求值:' '对方可以计算、读取当前变量和语法文档,但不能创建插件或写入文件。', style: TextStyle(fontSize: 13), ), if (s.error != null) ...[ const SizedBox(height: 10), Text(s.error!, style: TextStyle(color: scheme.error, fontSize: 13)), ], if (s.running && s.tools.isNotEmpty) ...[ const SizedBox(height: 12), Text('暴露的工具(${s.tools.length})', style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 6), Wrap( spacing: 6, runSpacing: 6, children: [ for (final t in s.tools) Chip( label: Text(t, style: const TextStyle(fontSize: 12)), visualDensity: VisualDensity.compact, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ], ), const SizedBox(height: 12), SelectableText( s.endpoint, style: const TextStyle(fontFamily: 'monospace', fontSize: 13), ), ], const SizedBox(height: 16), Row( children: [ FilledButton.icon( onPressed: ctrl.busy ? null : () => s.running ? ctrl.stop() : ctrl.start(), icon: Icon(s.running ? Icons.stop : Icons.play_arrow), label: Text(s.running ? '停止' : '启动'), ), const SizedBox(width: 12), TextButton( onPressed: ctrl.busy ? null : ctrl.refresh, child: const Text('刷新'), ), ], ), ], ), ), ); } }