mcp.dart 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:provider/provider.dart';
  4. /// MCP 服务器的控制通道。
  5. const MethodChannel mcpChannel = MethodChannel('com.nonvioce.cal/mcp');
  6. /// MCP 服务器的状态。
  7. class McpStatus {
  8. final bool running;
  9. final int port;
  10. final List<String> tools;
  11. final String? error;
  12. const McpStatus({
  13. required this.running,
  14. required this.port,
  15. this.tools = const [],
  16. this.error,
  17. });
  18. /// 局域网地址。真正的 IP 由服务端在状态里给出,这里只是兜底显示。
  19. String get endpoint => 'http://<本机 IP>:$port/mcp';
  20. static const idle = McpStatus(running: false, port: 0);
  21. }
  22. /// 读写 MCP 服务器状态。
  23. ///
  24. /// 按设计(Q18/Q25),MCP 是**只读 + 求值**的服务:AI 客户端能算、能取会话状态、
  25. /// 能读语法文档,但**不能**往里塞代码或文件 —— 插件相关的五个工具已经全部删掉,
  26. /// 也没有 Rhino。
  27. class McpController extends ChangeNotifier {
  28. McpStatus status = McpStatus.idle;
  29. bool busy = false;
  30. Future<void> refresh() async {
  31. try {
  32. final m = await mcpChannel.invokeMapMethod<String, dynamic>('status');
  33. status = _parse(m);
  34. } on PlatformException catch (e) {
  35. status = McpStatus(running: false, port: 0, error: e.message);
  36. }
  37. notifyListeners();
  38. }
  39. Future<void> start() async {
  40. busy = true;
  41. notifyListeners();
  42. try {
  43. final m = await mcpChannel.invokeMapMethod<String, dynamic>('start');
  44. status = _parse(m);
  45. } on PlatformException catch (e) {
  46. status = McpStatus(running: false, port: 0, error: e.message);
  47. }
  48. busy = false;
  49. notifyListeners();
  50. }
  51. Future<void> stop() async {
  52. busy = true;
  53. notifyListeners();
  54. try {
  55. await mcpChannel.invokeMethod<Object?>('stop');
  56. status = McpStatus.idle;
  57. } on PlatformException catch (e) {
  58. status = McpStatus(running: false, port: 0, error: e.message);
  59. }
  60. busy = false;
  61. notifyListeners();
  62. }
  63. static McpStatus _parse(Map<String, dynamic>? m) {
  64. if (m == null) return McpStatus.idle;
  65. return McpStatus(
  66. running: m['running'] == true,
  67. port: (m['port'] as num?)?.toInt() ?? 0,
  68. tools: ((m['tools'] as List?) ?? const []).map((e) => '$e').toList(),
  69. error: m['error'] as String?,
  70. );
  71. }
  72. }
  73. /// 弹出 MCP 控制面板。控制台的 `mcp` 命令走这里。
  74. Future<void> showMcpPanel(BuildContext context) async {
  75. final ctrl = McpController();
  76. await ctrl.refresh();
  77. if (!context.mounted) return;
  78. await showModalBottomSheet<void>(
  79. context: context,
  80. showDragHandle: true,
  81. builder: (_) => ChangeNotifierProvider.value(
  82. value: ctrl,
  83. child: const _McpSheet(),
  84. ),
  85. );
  86. }
  87. class _McpSheet extends StatelessWidget {
  88. const _McpSheet();
  89. @override
  90. Widget build(BuildContext context) {
  91. final ctrl = context.watch<McpController>();
  92. final s = ctrl.status;
  93. final scheme = Theme.of(context).colorScheme;
  94. return SafeArea(
  95. child: Padding(
  96. padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
  97. child: Column(
  98. mainAxisSize: MainAxisSize.min,
  99. crossAxisAlignment: CrossAxisAlignment.start,
  100. children: [
  101. Row(
  102. children: [
  103. Icon(
  104. s.running ? Icons.cloud_done_outlined : Icons.cloud_off_outlined,
  105. color: s.running ? scheme.primary : scheme.outline,
  106. ),
  107. const SizedBox(width: 8),
  108. Text('MCP 服务器',
  109. style: Theme.of(context).textTheme.titleMedium),
  110. const Spacer(),
  111. if (s.running)
  112. Text('端口 ${s.port}',
  113. style: const TextStyle(fontFamily: 'monospace')),
  114. ],
  115. ),
  116. const SizedBox(height: 12),
  117. const Text(
  118. '把计算引擎开放给 AI 客户端。只读 + 求值:'
  119. '对方可以计算、读取当前变量和语法文档,但不能创建插件或写入文件。',
  120. style: TextStyle(fontSize: 13),
  121. ),
  122. if (s.error != null) ...[
  123. const SizedBox(height: 10),
  124. Text(s.error!,
  125. style: TextStyle(color: scheme.error, fontSize: 13)),
  126. ],
  127. if (s.running && s.tools.isNotEmpty) ...[
  128. const SizedBox(height: 12),
  129. Text('暴露的工具(${s.tools.length})',
  130. style: Theme.of(context).textTheme.labelLarge),
  131. const SizedBox(height: 6),
  132. Wrap(
  133. spacing: 6,
  134. runSpacing: 6,
  135. children: [
  136. for (final t in s.tools)
  137. Chip(
  138. label: Text(t, style: const TextStyle(fontSize: 12)),
  139. visualDensity: VisualDensity.compact,
  140. materialTapTargetSize:
  141. MaterialTapTargetSize.shrinkWrap,
  142. ),
  143. ],
  144. ),
  145. const SizedBox(height: 12),
  146. SelectableText(
  147. s.endpoint,
  148. style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
  149. ),
  150. ],
  151. const SizedBox(height: 16),
  152. Row(
  153. children: [
  154. FilledButton.icon(
  155. onPressed: ctrl.busy
  156. ? null
  157. : () => s.running ? ctrl.stop() : ctrl.start(),
  158. icon: Icon(s.running ? Icons.stop : Icons.play_arrow),
  159. label: Text(s.running ? '停止' : '启动'),
  160. ),
  161. const SizedBox(width: 12),
  162. TextButton(
  163. onPressed: ctrl.busy ? null : ctrl.refresh,
  164. child: const Text('刷新'),
  165. ),
  166. ],
  167. ),
  168. ],
  169. ),
  170. ),
  171. );
  172. }
  173. }