| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188 |
- 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<String> 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<void> refresh() async {
- try {
- final m = await mcpChannel.invokeMapMethod<String, dynamic>('status');
- status = _parse(m);
- } on PlatformException catch (e) {
- status = McpStatus(running: false, port: 0, error: e.message);
- }
- notifyListeners();
- }
- Future<void> start() async {
- busy = true;
- notifyListeners();
- try {
- final m = await mcpChannel.invokeMapMethod<String, dynamic>('start');
- status = _parse(m);
- } on PlatformException catch (e) {
- status = McpStatus(running: false, port: 0, error: e.message);
- }
- busy = false;
- notifyListeners();
- }
- Future<void> stop() async {
- busy = true;
- notifyListeners();
- try {
- await mcpChannel.invokeMethod<Object?>('stop');
- status = McpStatus.idle;
- } on PlatformException catch (e) {
- status = McpStatus(running: false, port: 0, error: e.message);
- }
- busy = false;
- notifyListeners();
- }
- static McpStatus _parse(Map<String, dynamic>? 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<void> showMcpPanel(BuildContext context) async {
- final ctrl = McpController();
- await ctrl.refresh();
- if (!context.mounted) return;
- await showModalBottomSheet<void>(
- 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<McpController>();
- 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('刷新'),
- ),
- ],
- ),
- ],
- ),
- ),
- );
- }
- }
|