mirror of
https://github.com/xszyou/Fay.git
synced 2026-03-12 17:51:28 +08:00
自然进化
1.恢复文字、唤醒词、意图接口打断功能; 2、新增支持本地mcp工具调用; 3、支持mcp工具独立控制; 4、内置mcp工具箱及日程管理mcp工具; 5、结束fay时主动关闭(断开)mcp服务; 6、优化线程管理逻辑; 7、支持ctrl+c退出fay。
This commit is contained in:
@@ -323,7 +323,7 @@ class FeiFei:
|
||||
return result
|
||||
|
||||
except BaseException as e:
|
||||
print(e) #TODO 不合成声音时,这里打印了1,调试了一下
|
||||
print(e)
|
||||
return None
|
||||
|
||||
#下载wav
|
||||
|
||||
@@ -17,6 +17,7 @@ import tempfile
|
||||
import wave
|
||||
from core import fay_core
|
||||
from core import interact
|
||||
from core import stream_manager
|
||||
# 麦克风启动时间 (秒)
|
||||
_ATTACK = 0.1
|
||||
|
||||
@@ -135,6 +136,7 @@ class Recorder:
|
||||
|
||||
intt = interact.Interact("auto_play", 2, {'user': self.username, 'text': "在呢,你说?" , "isfirst" : True, "isend" : True})
|
||||
self.__fay.on_interact(intt)
|
||||
stream_manager.new_instance().clear_Stream_with_audio(self.username)
|
||||
self.processing = False
|
||||
self.timer.cancel() # 取消之前的计时器任务
|
||||
|
||||
@@ -169,9 +171,8 @@ class Recorder:
|
||||
if wsa_server.get_instance().is_connected(self.username):
|
||||
content = {'Topic': 'human', 'Data': {'Key': 'log', 'Value': "唤醒成功!"}, 'Username' : self.username, 'robot': f'{cfg.fay_url}/robot/Listening.jpg'}
|
||||
wsa_server.get_instance().add_cmd(content)
|
||||
#去除唤醒词后语句
|
||||
question = text#[len(wake_up_word):].lstrip()
|
||||
self.__fay.sound_query = Queue()
|
||||
question = text#[len(wake_up_word):].lstrip()不去除唤醒词
|
||||
stream_manager.new_instance().clear_Stream_with_audio(self.username)
|
||||
time.sleep(0.3)
|
||||
self.on_speaking(question)
|
||||
self.processing = False
|
||||
|
||||
@@ -78,11 +78,14 @@ class StreamManager:
|
||||
if len(sentence) > 10240: # 10KB限制
|
||||
sentence = sentence[:10240]
|
||||
|
||||
if sentence.endswith('_<isfirst>'):
|
||||
self.clear_Stream(username)
|
||||
|
||||
# 使用锁保护获取和写入操作
|
||||
with self.lock:
|
||||
# 检查是否包含_<isfirst>标记(可能在句子中间)
|
||||
if '_<isfirst>' in sentence:
|
||||
# 清空文本流
|
||||
self._clear_Stream_internal(username)
|
||||
# 清空音频队列(打断时需要清空音频)
|
||||
self._clear_audio_queue(username)
|
||||
try:
|
||||
Stream, nlp_Stream = self.get_Stream(username)
|
||||
success = Stream.write(sentence)
|
||||
@@ -92,16 +95,41 @@ class StreamManager:
|
||||
print(f"写入句子时出错: {e}")
|
||||
return False
|
||||
|
||||
def _clear_Stream_internal(self, username):
|
||||
"""
|
||||
内部清除文本流方法,不获取锁(调用者必须已持有锁)
|
||||
:param username: 用户名
|
||||
"""
|
||||
if username in self.streams:
|
||||
self.streams[username].clear()
|
||||
if username in self.nlp_streams:
|
||||
self.nlp_streams[username].clear()
|
||||
|
||||
def _clear_audio_queue(self, username):
|
||||
"""
|
||||
清空指定用户的音频队列
|
||||
:param username: 用户名
|
||||
"""
|
||||
import queue
|
||||
fay_core = fay_booter.feiFei
|
||||
fay_core.sound_query = queue.Queue()
|
||||
|
||||
def clear_Stream(self, username):
|
||||
"""
|
||||
清除指定用户ID的文本流数据
|
||||
清除指定用户ID的文本流数据(外部调用接口,仅清除文本流)
|
||||
:param username: 用户名
|
||||
"""
|
||||
with self.lock:
|
||||
if username in self.streams:
|
||||
self.streams[username].clear()
|
||||
if username in self.nlp_streams:
|
||||
self.nlp_streams[username].clear()
|
||||
self._clear_Stream_internal(username)
|
||||
|
||||
def clear_Stream_with_audio(self, username):
|
||||
"""
|
||||
清除指定用户ID的文本流数据和音频队列(完全清除)
|
||||
:param username: 用户名
|
||||
"""
|
||||
with self.lock:
|
||||
self._clear_Stream_internal(username)
|
||||
self._clear_audio_queue(username)
|
||||
|
||||
def listen(self, username, stream, nlp_stream):
|
||||
while self.running:
|
||||
|
||||
@@ -291,6 +291,15 @@ def stop():
|
||||
util.log(1, '正在关闭服务...')
|
||||
__running = False
|
||||
|
||||
# 断开所有MCP服务连接
|
||||
util.log(1, '正在断开所有MCP服务连接...')
|
||||
try:
|
||||
from faymcp import mcp_service
|
||||
mcp_service.disconnect_all_mcp_servers()
|
||||
util.log(1, '所有MCP服务连接已断开')
|
||||
except Exception as e:
|
||||
util.log(1, f'断开MCP服务连接失败: {str(e)}')
|
||||
|
||||
# 保存代理记忆
|
||||
util.log(1, '正在保存代理记忆...')
|
||||
try:
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "time",
|
||||
"ip": "https://mcp.api-inference.modelscope.cn/sse/896ded03280143",
|
||||
"connection_time": "2025-05-17 01:11:25",
|
||||
"key": ""
|
||||
"name": "tools",
|
||||
"ip": "",
|
||||
"connection_time": "2025-08-28 00:17:54",
|
||||
"key": "",
|
||||
"transport": "stdio",
|
||||
"command": "python",
|
||||
"args": [
|
||||
"test/mcp_stdio_example.py"
|
||||
],
|
||||
"cwd": "",
|
||||
"env": {}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Fay日程管理",
|
||||
"ip": "",
|
||||
"connection_time": "2025-08-28 00:17:57",
|
||||
"key": "",
|
||||
"transport": "stdio",
|
||||
"command": "python",
|
||||
"args": [
|
||||
"server.py"
|
||||
],
|
||||
"cwd": "mcp_servers/schedule_manager",
|
||||
"env": {}
|
||||
}
|
||||
]
|
||||
16
faymcp/data/mcp_tool_states.json
Normal file
16
faymcp/data/mcp_tool_states.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"5": {
|
||||
"now": true,
|
||||
"add": false,
|
||||
"upper": false,
|
||||
"echo": false,
|
||||
"ping": false
|
||||
},
|
||||
"1": {
|
||||
"add": false,
|
||||
"upper": false,
|
||||
"echo": false,
|
||||
"ping": false
|
||||
},
|
||||
"2": {}
|
||||
}
|
||||
@@ -4,11 +4,24 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Optional, Dict, Any
|
||||
from mcp import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from utils import util
|
||||
|
||||
# 尝试导入本地 stdio 传输
|
||||
try:
|
||||
from mcp.client.stdio import stdio_client, StdioServerParameters
|
||||
HAS_STDIO = True
|
||||
except Exception:
|
||||
stdio_client = None
|
||||
StdioServerParameters = None
|
||||
HAS_STDIO = False
|
||||
|
||||
# 设置日志记录
|
||||
logging.basicConfig(level=logging.ERROR)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -16,101 +29,166 @@ logger = logging.getLogger(__name__)
|
||||
class McpClient:
|
||||
"""
|
||||
MCP客户端类,用于连接MCP服务器并调用其工具
|
||||
支持两种传输:
|
||||
- SSE: 远程HTTP(S) SSE服务器
|
||||
- STDIO: 本地进程通过stdin/stdout通信
|
||||
"""
|
||||
def __init__(self, server_url, api_key=None):
|
||||
def __init__(self, server_url: Optional[str] = None, api_key: Optional[str] = None,
|
||||
transport: str = "sse", stdio_config: Optional[Dict[str, Any]] = None):
|
||||
"""
|
||||
初始化MCP客户端
|
||||
:param server_url: MCP服务器URL
|
||||
:param api_key: MCP服务器API密钥(可选)
|
||||
:param server_url: MCP服务器URL(SSE模式必填)
|
||||
:param api_key: MCP服务器API密钥(可选,仅SSE)
|
||||
:param transport: 传输类型: 'sse' 或 'stdio'
|
||||
:param stdio_config: 本地stdio配置,如 {command, args, env, cwd}
|
||||
"""
|
||||
self.server_url = server_url
|
||||
self.api_key = api_key
|
||||
self.transport = transport or "sse"
|
||||
# 如果未显式指定,按server_url推断
|
||||
if self.transport not in ("sse", "stdio"):
|
||||
self.transport = "stdio" if (server_url and str(server_url).startswith("stdio:")) else "sse"
|
||||
self.stdio_config = stdio_config or {}
|
||||
self.session = None
|
||||
self.tools = None
|
||||
self.connected = False
|
||||
self.event_loop = None
|
||||
self.exit_stack: Optional[AsyncExitStack] = None
|
||||
# 超时配置(秒)
|
||||
self.init_timeout_seconds = 20
|
||||
self.list_timeout_seconds = 20
|
||||
self.call_timeout_seconds = 60
|
||||
self._ensure_event_loop()
|
||||
|
||||
# stdio 子进程stderr日志文件句柄
|
||||
self._stdio_errlog_file = None
|
||||
# 后台事件循环线程
|
||||
self._loop_thread: Optional[threading.Thread] = None
|
||||
|
||||
def _ensure_event_loop(self):
|
||||
"""
|
||||
确保有可用的事件循环
|
||||
启动一个后台事件循环线程,供所有异步操作使用,避免跨线程无事件循环的问题
|
||||
"""
|
||||
try:
|
||||
self.event_loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
# 如果当前线程没有事件循环,创建一个新的
|
||||
self.event_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self.event_loop)
|
||||
|
||||
if getattr(self, "event_loop", None) and self._loop_thread and self._loop_thread.is_alive():
|
||||
return
|
||||
# 创建独立事件循环并在后台线程中常驻
|
||||
loop = asyncio.new_event_loop()
|
||||
self.event_loop = loop
|
||||
|
||||
def _runner():
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_forever()
|
||||
|
||||
t = threading.Thread(target=_runner, name=f"McpClientLoop-{id(self)}", daemon=True)
|
||||
t.start()
|
||||
self._loop_thread = t
|
||||
|
||||
async def _connect_async(self):
|
||||
"""
|
||||
异步连接到MCP服务器
|
||||
异步连接到MCP服务器或本地进程
|
||||
"""
|
||||
try:
|
||||
# 创建退出栈
|
||||
self.exit_stack = AsyncExitStack()
|
||||
|
||||
logger.info(f"正在连接到 SSE 服务: {self.server_url}")
|
||||
# 准备请求头,如果有API密钥则添加到请求头中
|
||||
headers = {}
|
||||
if self.api_key:
|
||||
headers['Authorization'] = f'Bearer {self.api_key}'
|
||||
|
||||
# 增加超时设置
|
||||
streams = await self.exit_stack.enter_async_context(
|
||||
sse_client(url=self.server_url, timeout=60, headers=headers) # 增加超时时间到60秒并传递请求头
|
||||
)
|
||||
logger.info("SSE 连接已建立")
|
||||
|
||||
|
||||
if self.transport == "stdio":
|
||||
if not HAS_STDIO:
|
||||
return False, "未安装或不可用的 MCP stdio 客户端,请确认 mcp 包版本并包含 mcp.client.stdio"
|
||||
cfg = self.stdio_config or {}
|
||||
command = cfg.get("command")
|
||||
if not command:
|
||||
return False, "本地MCP配置缺少 command"
|
||||
args = cfg.get("args") or []
|
||||
env = cfg.get("env") or None
|
||||
cwd = cfg.get("cwd") or None
|
||||
logger.info(f"正在通过 STDIO 启动本地MCP: {command} {args} (cwd={cwd})")
|
||||
params = StdioServerParameters(
|
||||
command=command,
|
||||
args=list(args or []),
|
||||
env=env,
|
||||
cwd=cwd,
|
||||
)
|
||||
# 将子进程stderr写入日志文件,便于排查
|
||||
try:
|
||||
log_dir = os.path.join(os.getcwd(), 'logs')
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
base = os.path.basename(str(command))
|
||||
log_path = os.path.join(log_dir, f"mcp_stdio_{base}.log")
|
||||
self._stdio_errlog_file = open(log_path, 'a', encoding='utf-8')
|
||||
except Exception:
|
||||
self._stdio_errlog_file = None
|
||||
streams = await self.exit_stack.enter_async_context(
|
||||
stdio_client(params, errlog=self._stdio_errlog_file or sys.stderr)
|
||||
)
|
||||
logger.info("STDIO 连接已建立")
|
||||
else:
|
||||
logger.info(f"正在连接到 SSE 服务: {self.server_url}")
|
||||
# 准备请求头,如果有API密钥则添加到请求头中
|
||||
headers = {}
|
||||
if self.api_key:
|
||||
headers['Authorization'] = f'Bearer {self.api_key}'
|
||||
# 增加超时设置
|
||||
streams = await self.exit_stack.enter_async_context(
|
||||
sse_client(url=self.server_url, timeout=60, headers=headers) # 增加超时时间到60秒并传递请求头
|
||||
)
|
||||
logger.info("SSE 连接已建立")
|
||||
|
||||
# 创建会话
|
||||
self.session = await self.exit_stack.enter_async_context(ClientSession(*streams))
|
||||
await self.session.initialize()
|
||||
try:
|
||||
# 为 initialize 增加超时,避免服务器未握手导致阻塞
|
||||
await asyncio.wait_for(self.session.initialize(), timeout=20)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("会话初始化超时 (initialize) — 请检查本地STDIO服务是否成功启动/输出")
|
||||
return False, "会话初始化超时"
|
||||
logger.info("会话已创建")
|
||||
|
||||
|
||||
# 获取工具列表
|
||||
logger.info("正在获取工具列表...")
|
||||
try:
|
||||
# 使用asyncio.wait_for添加超时控制
|
||||
tools_response = await asyncio.wait_for(self.session.list_tools(), timeout=30)
|
||||
tools_response = await asyncio.wait_for(self.session.list_tools(), timeout=self.list_timeout_seconds)
|
||||
logger.info(f"可用工具: {tools_response}")
|
||||
|
||||
|
||||
# 提取工具列表
|
||||
if hasattr(tools_response, 'tools') and tools_response.tools:
|
||||
self.tools = tools_response.tools
|
||||
else:
|
||||
# 如果返回的是直接的工具列表
|
||||
self.tools = tools_response
|
||||
|
||||
|
||||
self.connected = True
|
||||
return True, self.tools
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("获取工具列表超时")
|
||||
return False, "获取工具列表超时"
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"连接或调用过程中出错: {e}")
|
||||
error_msg = str(e)
|
||||
# 检查是否是网络相关错误
|
||||
if "connection" in error_msg.lower() or "timeout" in error_msg.lower():
|
||||
logger.error("网络连接问题,请检查网络或服务器状态")
|
||||
return False, "网络连接问题,请检查网络或服务器状态"
|
||||
# 检查是否是认证错误
|
||||
elif "auth" in error_msg.lower() or "unauthorized" in error_msg.lower():
|
||||
logger.error("可能存在认证问题,请检查是否需要提供 API 密钥")
|
||||
return False, "认证问题,请检查是否需要提供 API 密钥"
|
||||
# 检查是否是SSE相关错误
|
||||
elif "sse" in error_msg.lower() or "stream" in error_msg.lower():
|
||||
logger.error("SSE流处理错误,可能是服务器提前关闭了连接")
|
||||
return False, "SSE流处理错误,可能是服务器提前关闭了连接"
|
||||
# 分类错误信息
|
||||
if self.transport == "sse":
|
||||
if "connection" in error_msg.lower() or "timeout" in error_msg.lower():
|
||||
logger.error("网络连接问题,请检查网络或服务器状态")
|
||||
return False, "网络连接问题,请检查网络或服务器状态"
|
||||
elif "auth" in error_msg.lower() or "unauthorized" in error_msg.lower():
|
||||
logger.error("可能存在认证问题,请检查是否需要提供 API 密钥")
|
||||
return False, "认证问题,请检查是否需要提供 API 密钥"
|
||||
elif "sse" in error_msg.lower() or "stream" in error_msg.lower():
|
||||
logger.error("SSE流处理错误,可能是服务器提前关闭了连接")
|
||||
return False, "SSE流处理错误,可能是服务器提前关闭了连接"
|
||||
else:
|
||||
if "command" in error_msg.lower() or "not found" in error_msg.lower():
|
||||
return False, "本地MCP命令启动失败,请检查 command/args/cwd 是否正确"
|
||||
return False, f"连接错误: {error_msg}"
|
||||
|
||||
|
||||
def connect(self):
|
||||
"""
|
||||
连接到MCP服务器
|
||||
连接到MCP服务器(提交到后台事件循环)
|
||||
:return: (是否成功, 工具列表或错误信息)
|
||||
"""
|
||||
return self.event_loop.run_until_complete(self._connect_async())
|
||||
|
||||
fut = asyncio.run_coroutine_threadsafe(self._connect_async(), self.event_loop)
|
||||
return fut.result(timeout=self.init_timeout_seconds + self.list_timeout_seconds + 10)
|
||||
|
||||
async def _call_tool_async(self, method, params=None):
|
||||
"""
|
||||
异步调用MCP工具
|
||||
@@ -120,37 +198,39 @@ class McpClient:
|
||||
"""
|
||||
if not self.connected or not self.session:
|
||||
return False, "未连接到MCP服务器"
|
||||
|
||||
|
||||
try:
|
||||
if params is None:
|
||||
params = {}
|
||||
|
||||
|
||||
logger.info(f"调用工具: {method}, 参数: {params}")
|
||||
result = await asyncio.wait_for(self.session.call_tool(method, params), timeout=30)
|
||||
result = await asyncio.wait_for(self.session.call_tool(method, params), timeout=self.call_timeout_seconds)
|
||||
logger.info(f"调用结果: {result}")
|
||||
return True, result
|
||||
except asyncio.TimeoutError:
|
||||
return False, f"调用工具超时({self.call_timeout_seconds}s)"
|
||||
except Exception as e:
|
||||
return False, f"调用工具失败: {str(e)}"
|
||||
|
||||
# 提供更可读的错误类型,并在日志中打印完整异常,便于排查
|
||||
logger.exception("调用工具失败异常堆栈")
|
||||
msg = str(e)
|
||||
if not msg:
|
||||
msg = repr(e)
|
||||
return False, f"调用工具失败: {type(e).__name__}: {msg}"
|
||||
|
||||
def call_tool(self, method, params=None):
|
||||
"""
|
||||
调用MCP工具
|
||||
调用MCP工具(提交到后台事件循环)
|
||||
:param method: 方法名
|
||||
:param params: 参数字典
|
||||
:return: (是否成功, 结果或错误信息)
|
||||
"""
|
||||
try:
|
||||
# 确保在同一个事件循环中执行
|
||||
if asyncio.get_event_loop() != self.event_loop:
|
||||
return self.event_loop.run_until_complete(self._call_tool_async(method, params))
|
||||
else:
|
||||
# 如果已经在事件循环中,创建一个新的任务并等待它完成
|
||||
future = asyncio.run_coroutine_threadsafe(self._call_tool_async(method, params), self.event_loop)
|
||||
return future.result(timeout=30)
|
||||
future = asyncio.run_coroutine_threadsafe(self._call_tool_async(method, params), self.event_loop)
|
||||
return future.result(timeout=self.call_timeout_seconds + 5)
|
||||
except Exception as e:
|
||||
util.log(1, f"调用MCP工具时出错: {str(e)}")
|
||||
return False, f"调用工具失败: {str(e)}"
|
||||
|
||||
|
||||
def list_tools(self):
|
||||
"""
|
||||
获取可用工具列表
|
||||
@@ -161,16 +241,28 @@ class McpClient:
|
||||
if not success:
|
||||
return []
|
||||
return self.tools or []
|
||||
|
||||
|
||||
def disconnect(self):
|
||||
"""
|
||||
断开与MCP服务器的连接
|
||||
"""
|
||||
if self.connected and self.exit_stack:
|
||||
try:
|
||||
self.event_loop.run_until_complete(self.exit_stack.aclose())
|
||||
self.connected = False
|
||||
self.session = None
|
||||
# 在后台事件循环中关闭资源
|
||||
try:
|
||||
if self.exit_stack:
|
||||
fut = asyncio.run_coroutine_threadsafe(self.exit_stack.aclose(), self.event_loop)
|
||||
fut.result(timeout=10)
|
||||
finally:
|
||||
self.connected = False
|
||||
self.session = None
|
||||
# 关闭子进程stderr日志文件
|
||||
try:
|
||||
if self._stdio_errlog_file:
|
||||
self._stdio_errlog_file.close()
|
||||
self._stdio_errlog_file = None
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("已断开与MCP服务器的连接")
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
from flask import Flask, render_template, request, jsonify, redirect, url_for
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
@@ -13,6 +14,8 @@ from flask_cors import CORS
|
||||
from faymcp.mcp_client import McpClient
|
||||
from utils import util
|
||||
|
||||
|
||||
|
||||
# 创建Flask应用
|
||||
app = Flask(__name__)
|
||||
|
||||
@@ -23,6 +26,9 @@ CORS(app, resources={r"/*": {"origins": "*"}})
|
||||
# MCP服务器数据文件路径
|
||||
MCP_DATA_FILE = os.path.join(os.path.dirname(__file__), 'data', 'mcp_servers.json')
|
||||
|
||||
# MCP工具状态数据文件路径
|
||||
MCP_TOOL_STATES_FILE = os.path.join(os.path.dirname(__file__), 'data', 'mcp_tool_states.json')
|
||||
|
||||
# 确保data目录存在
|
||||
os.makedirs(os.path.dirname(MCP_DATA_FILE), exist_ok=True)
|
||||
|
||||
@@ -32,6 +38,9 @@ mcp_clients = {}
|
||||
# 存储MCP服务器工具列表的字典,键为服务器ID
|
||||
mcp_tools = {}
|
||||
|
||||
# 存储工具状态的字典,键为服务器ID,值为工具名称->状态的字典
|
||||
mcp_tool_states = {}
|
||||
|
||||
# 连接检查定时器
|
||||
connection_check_timer = None
|
||||
|
||||
@@ -61,6 +70,42 @@ def load_mcp_servers():
|
||||
util.log(1, f"加载MCP服务器数据失败: {e}")
|
||||
return default_mcp_servers
|
||||
|
||||
# 加载MCP工具状态数据
|
||||
def load_mcp_tool_states():
|
||||
try:
|
||||
if os.path.exists(MCP_TOOL_STATES_FILE):
|
||||
with open(MCP_TOOL_STATES_FILE, 'r', encoding='utf-8') as f:
|
||||
states = json.load(f)
|
||||
# 转换字符串键为整数(因为JSON中的键总是字符串)
|
||||
converted_states = {}
|
||||
for server_id_str, tools in states.items():
|
||||
try:
|
||||
server_id = int(server_id_str)
|
||||
converted_states[server_id] = tools
|
||||
except ValueError:
|
||||
continue
|
||||
return converted_states
|
||||
else:
|
||||
return {}
|
||||
except Exception as e:
|
||||
util.log(1, f"加载MCP工具状态数据失败: {e}")
|
||||
return {}
|
||||
|
||||
# 保存MCP工具状态数据
|
||||
def save_mcp_tool_states():
|
||||
try:
|
||||
# 转换整数键为字符串(JSON要求)
|
||||
states_to_save = {}
|
||||
for server_id, tools in mcp_tool_states.items():
|
||||
states_to_save[str(server_id)] = tools
|
||||
|
||||
with open(MCP_TOOL_STATES_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(states_to_save, f, ensure_ascii=False, indent=4)
|
||||
return True
|
||||
except Exception as e:
|
||||
util.log(1, f"保存MCP工具状态数据失败: {e}")
|
||||
return False
|
||||
|
||||
# 保存MCP服务器数据
|
||||
def save_mcp_servers(servers):
|
||||
try:
|
||||
@@ -71,9 +116,14 @@ def save_mcp_servers(servers):
|
||||
server_copy = {
|
||||
"id": server['id'],
|
||||
"name": server['name'],
|
||||
"ip": server['ip'],
|
||||
"ip": server.get('ip', ''),
|
||||
"connection_time": server.get('connection_time', ''),
|
||||
"key": server.get('key', '') # 保存Key字段
|
||||
"key": server.get('key', ''), # 保存Key字段
|
||||
"transport": server.get('transport', 'sse'),
|
||||
"command": server.get('command', ''),
|
||||
"args": server.get('args', []),
|
||||
"cwd": server.get('cwd', ''),
|
||||
"env": server.get('env', {})
|
||||
}
|
||||
servers_to_save.append(server_copy)
|
||||
|
||||
@@ -87,6 +137,24 @@ def save_mcp_servers(servers):
|
||||
# 初始化MCP服务器数据
|
||||
mcp_servers = load_mcp_servers()
|
||||
|
||||
# 初始化MCP工具状态数据
|
||||
mcp_tool_states = load_mcp_tool_states()
|
||||
|
||||
# 工具状态管理函数
|
||||
def get_tool_state(server_id, tool_name):
|
||||
"""获取工具的启用状态,默认为True"""
|
||||
if server_id not in mcp_tool_states:
|
||||
mcp_tool_states[server_id] = {}
|
||||
return mcp_tool_states[server_id].get(tool_name, True)
|
||||
|
||||
def set_tool_state(server_id, tool_name, enabled):
|
||||
"""设置工具的启用状态"""
|
||||
if server_id not in mcp_tool_states:
|
||||
mcp_tool_states[server_id] = {}
|
||||
mcp_tool_states[server_id][tool_name] = enabled
|
||||
# 立即保存到文件
|
||||
save_mcp_tool_states()
|
||||
|
||||
# 连接真实MCP服务器
|
||||
def connect_to_real_mcp(server):
|
||||
"""
|
||||
@@ -96,15 +164,39 @@ def connect_to_real_mcp(server):
|
||||
"""
|
||||
global mcp_clients
|
||||
try:
|
||||
# 获取服务器IP、ID和Key
|
||||
ip = server['ip']
|
||||
# 获取服务器配置
|
||||
server_id = server['id']
|
||||
api_key = server.get('key', '') # 获取Key,如果不存在则为空字符串
|
||||
transport = server.get('transport', 'sse')
|
||||
api_key = server.get('key', '') # 获取Key
|
||||
|
||||
# 构建MCP服务器端点URL
|
||||
endpoint = ip
|
||||
# 创建MCP客户端,传入API密钥
|
||||
client = McpClient(endpoint, api_key)
|
||||
# 如果已存在旧连接,先断开并清理(防止重复连接)
|
||||
if server_id in mcp_clients:
|
||||
try:
|
||||
old_client = mcp_clients[server_id]
|
||||
if hasattr(old_client, 'disconnect'):
|
||||
old_client.disconnect()
|
||||
# util.log(1, f"已断开服务器 {server['name']} (ID: {server_id}) 的旧连接")
|
||||
except Exception as e:
|
||||
pass # 静默处理断开旧连接的错误
|
||||
del mcp_clients[server_id]
|
||||
|
||||
client = None
|
||||
if transport == 'stdio':
|
||||
# 统一默认工作目录为项目根目录(faymcp 的上一级),避免相对路径在不同启动目录下失效
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
cfg_cwd = server.get('cwd')
|
||||
cwd = cfg_cwd if (cfg_cwd and str(cfg_cwd).strip()) else repo_root
|
||||
stdio_config = {
|
||||
"command": server.get('command'),
|
||||
"args": server.get('args', []) or [],
|
||||
"cwd": cwd,
|
||||
"env": (server.get('env') or None),
|
||||
}
|
||||
client = McpClient(server_url=None, api_key=None, transport='stdio', stdio_config=stdio_config)
|
||||
else:
|
||||
ip = server.get('ip', '')
|
||||
endpoint = ip
|
||||
client = McpClient(endpoint, api_key)
|
||||
|
||||
# 记录开始时间
|
||||
start_time = time.time()
|
||||
@@ -156,6 +248,54 @@ def get_mcp_client(server_id):
|
||||
"""
|
||||
return mcp_clients.get(server_id)
|
||||
|
||||
# 断开所有MCP服务连接
|
||||
def disconnect_all_mcp_servers():
|
||||
"""
|
||||
断开所有MCP服务器连接,清理资源
|
||||
"""
|
||||
global mcp_clients, mcp_tools, mcp_servers, connection_check_timer
|
||||
|
||||
util.log(1, f'开始断开 {len(mcp_clients)} 个MCP服务连接...')
|
||||
|
||||
# 停止连接检查定时器
|
||||
if connection_check_timer:
|
||||
try:
|
||||
connection_check_timer.cancel()
|
||||
util.log(1, '连接检查定时器已停止')
|
||||
except Exception as e:
|
||||
util.log(1, f'停止连接检查定时器失败: {e}')
|
||||
connection_check_timer = None
|
||||
|
||||
# 断开所有MCP客户端连接
|
||||
disconnected_count = 0
|
||||
for server_id, client in list(mcp_clients.items()):
|
||||
try:
|
||||
if hasattr(client, 'disconnect'):
|
||||
client.disconnect()
|
||||
elif hasattr(client, 'close'):
|
||||
client.close()
|
||||
disconnected_count += 1
|
||||
util.log(1, f'已断开MCP服务器连接: ID {server_id}')
|
||||
except Exception as e:
|
||||
util.log(1, f'断开MCP服务器连接失败 (ID: {server_id}): {e}')
|
||||
|
||||
# 清理所有数据
|
||||
mcp_clients.clear()
|
||||
mcp_tools.clear()
|
||||
|
||||
# 更新所有服务器状态为离线
|
||||
for server in mcp_servers:
|
||||
server['status'] = 'offline'
|
||||
server['latency'] = '0ms'
|
||||
|
||||
# 保存服务器状态
|
||||
try:
|
||||
save_mcp_servers(mcp_servers)
|
||||
except Exception as e:
|
||||
util.log(1, f'保存MCP服务器状态失败: {e}')
|
||||
|
||||
util.log(1, f'成功断开 {disconnected_count} 个MCP服务连接,资源已清理')
|
||||
|
||||
# 调用MCP服务器工具
|
||||
def call_mcp_tool(server_id, method, params=None):
|
||||
"""
|
||||
@@ -166,6 +306,10 @@ def call_mcp_tool(server_id, method, params=None):
|
||||
:return: (是否成功, 结果或错误信息)
|
||||
"""
|
||||
try:
|
||||
# 检查工具是否被启用
|
||||
if not get_tool_state(server_id, method):
|
||||
return False, f"工具 '{method}' 已被禁用"
|
||||
|
||||
# 获取客户端对象
|
||||
client = get_mcp_client(server_id)
|
||||
if not client:
|
||||
@@ -204,25 +348,35 @@ def add_mcp_server():
|
||||
data = request.json
|
||||
|
||||
# 验证必要字段
|
||||
required_fields = ['name', 'ip']
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
return jsonify({"error": f"缺少必要字段: {field}"}), 400
|
||||
|
||||
transport = data.get('transport', 'sse')
|
||||
if transport == 'stdio':
|
||||
if 'name' not in data or 'command' not in data:
|
||||
return jsonify({"error": "缺少必要字段: name 或 command"}), 400
|
||||
else:
|
||||
required_fields = ['name', 'ip']
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
return jsonify({"error": f"缺少必要字段: {field}"}), 400
|
||||
|
||||
# 生成新ID (当前最大ID + 1)
|
||||
new_id = 1
|
||||
if mcp_servers:
|
||||
new_id = max(server['id'] for server in mcp_servers) + 1
|
||||
|
||||
|
||||
# 创建新服务器对象
|
||||
new_server = {
|
||||
"id": new_id,
|
||||
"name": data['name'],
|
||||
"status": "offline",
|
||||
"ip": data['ip'],
|
||||
"ip": data.get('ip', ''),
|
||||
"latency": "0ms",
|
||||
"connection_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"key": data.get('key', '') # 添加Key字段,如果不存在则为空字符串
|
||||
"key": data.get('key', ''), # 添加Key字段
|
||||
"transport": transport,
|
||||
"command": data.get('command', ''),
|
||||
"args": data.get('args', []),
|
||||
"cwd": data.get('cwd', ''),
|
||||
"env": data.get('env', {})
|
||||
}
|
||||
|
||||
# 如果请求中包含 auto_connect 字段并且为 True,则尝试连接
|
||||
@@ -245,9 +399,11 @@ def add_mcp_server():
|
||||
for tool in tools:
|
||||
if hasattr(tool, 'name'):
|
||||
# 如果是对象,转换为字典
|
||||
tool_name = str(getattr(tool, 'name', '未知'))
|
||||
tool_dict = {
|
||||
'name': str(getattr(tool, 'name', '未知')),
|
||||
'name': tool_name,
|
||||
'description': str(getattr(tool, 'description', '')),
|
||||
'enabled': get_tool_state(server_id, tool_name)
|
||||
}
|
||||
|
||||
# 处理 inputSchema
|
||||
@@ -261,18 +417,31 @@ def add_mcp_server():
|
||||
else:
|
||||
# 如果是字典
|
||||
if isinstance(tool, dict) and 'name' in tool:
|
||||
tool_name = str(tool.get('name', '未知'))
|
||||
tools_list.append({
|
||||
'name': str(tool.get('name', '未知')),
|
||||
'name': tool_name,
|
||||
'description': str(tool.get('description', '')),
|
||||
'inputSchema': tool.get('inputSchema', {})
|
||||
'inputSchema': tool.get('inputSchema', {}),
|
||||
'enabled': get_tool_state(server_id, tool_name)
|
||||
})
|
||||
else:
|
||||
# 其他情况,尝试转换为字符串
|
||||
tools_list.append({'name': str(tool), 'description': ''})
|
||||
tool_name = str(tool)
|
||||
tools_list.append({
|
||||
'name': tool_name,
|
||||
'description': '',
|
||||
'enabled': get_tool_state(server_id, tool_name)
|
||||
})
|
||||
except Exception as e:
|
||||
util.log(1, f"工具列表序列化失败: {e}")
|
||||
# 如果转换失败,只返回工具名称
|
||||
tools_list = [{'name': str(tool)} for tool in tools]
|
||||
tools_list = []
|
||||
for tool in tools:
|
||||
tool_name = str(tool)
|
||||
tools_list.append({
|
||||
'name': tool_name,
|
||||
'enabled': get_tool_state(server_id, tool_name)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
util.log(1, f"自动连接失败: {e}")
|
||||
@@ -493,11 +662,22 @@ def get_server_tools(server_id):
|
||||
|
||||
# 检查是否已有缓存的工具列表
|
||||
if server_id in mcp_tools and mcp_tools[server_id]:
|
||||
# 使用缓存的工具列表,添加到结果中
|
||||
# 使用缓存的工具列表,但需要更新enabled状态
|
||||
cached_tools = mcp_tools[server_id]
|
||||
updated_tools = []
|
||||
for tool in cached_tools:
|
||||
if isinstance(tool, dict) and 'name' in tool:
|
||||
tool_name = tool['name']
|
||||
updated_tool = tool.copy()
|
||||
updated_tool['enabled'] = get_tool_state(server_id, tool_name)
|
||||
updated_tools.append(updated_tool)
|
||||
else:
|
||||
updated_tools.append(tool)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "获取工具列表成功(缓存)",
|
||||
"tools": mcp_tools[server_id]
|
||||
"message": "获取工具列表成功(缓存+状态更新)",
|
||||
"tools": updated_tools
|
||||
})
|
||||
|
||||
# 获取客户端对象
|
||||
@@ -521,9 +701,11 @@ def get_server_tools(server_id):
|
||||
for tool in tools:
|
||||
if hasattr(tool, 'name'):
|
||||
# 如果是对象,转换为字典
|
||||
tool_name = str(getattr(tool, 'name', '未知'))
|
||||
tool_dict = {
|
||||
'name': str(getattr(tool, 'name', '未知')),
|
||||
'name': tool_name,
|
||||
'description': str(getattr(tool, 'description', '')),
|
||||
'enabled': get_tool_state(server_id, tool_name)
|
||||
}
|
||||
|
||||
# 处理 inputSchema
|
||||
@@ -537,18 +719,31 @@ def get_server_tools(server_id):
|
||||
else:
|
||||
# 如果是字典
|
||||
if isinstance(tool, dict) and 'name' in tool:
|
||||
tool_name = str(tool.get('name', '未知'))
|
||||
tools_list.append({
|
||||
'name': str(tool.get('name', '未知')),
|
||||
'name': tool_name,
|
||||
'description': str(tool.get('description', '')),
|
||||
'inputSchema': tool.get('inputSchema', {})
|
||||
'inputSchema': tool.get('inputSchema', {}),
|
||||
'enabled': get_tool_state(server_id, tool_name)
|
||||
})
|
||||
else:
|
||||
# 其他情况,尝试转换为字符串
|
||||
tools_list.append({'name': str(tool), 'description': ''})
|
||||
tool_name = str(tool)
|
||||
tools_list.append({
|
||||
'name': tool_name,
|
||||
'description': '',
|
||||
'enabled': get_tool_state(server_id, tool_name)
|
||||
})
|
||||
except Exception as e:
|
||||
util.log(1, f"工具列表序列化失败: {e}")
|
||||
# 如果转换失败,只返回工具名称
|
||||
tools_list = [{'name': str(tool)} for tool in tools]
|
||||
tools_list = []
|
||||
for tool in tools:
|
||||
tool_name = str(tool)
|
||||
tools_list.append({
|
||||
'name': tool_name,
|
||||
'enabled': get_tool_state(server_id, tool_name)
|
||||
})
|
||||
|
||||
# 保存工具列表到全局字典中
|
||||
mcp_tools[server_id] = tools_list
|
||||
@@ -585,8 +780,18 @@ def get_all_online_server_tools():
|
||||
|
||||
# 检查是否有缓存的工具列表
|
||||
if server_id in mcp_tools and mcp_tools[server_id]:
|
||||
# 使用缓存的工具列表,添加到结果中
|
||||
all_tools.extend(mcp_tools[server_id])
|
||||
# 使用缓存的工具列表,但需要更新enabled状态
|
||||
cached_tools = mcp_tools[server_id]
|
||||
updated_tools = []
|
||||
for tool in cached_tools:
|
||||
if isinstance(tool, dict) and 'name' in tool:
|
||||
tool_name = tool['name']
|
||||
updated_tool = tool.copy()
|
||||
updated_tool['enabled'] = get_tool_state(server_id, tool_name)
|
||||
updated_tools.append(updated_tool)
|
||||
else:
|
||||
updated_tools.append(tool)
|
||||
all_tools.extend(updated_tools)
|
||||
else:
|
||||
# 获取客户端对象
|
||||
client = get_mcp_client(server_id)
|
||||
@@ -690,8 +895,12 @@ def call_mcp_tool_direct(tool_name):
|
||||
tools = client.list_tools()
|
||||
tool_names = [str(getattr(tool, 'name', tool)) for tool in tools]
|
||||
|
||||
# 检查工具是否存在
|
||||
# 检查工具是否存在且被启用
|
||||
if tool_name in tool_names:
|
||||
# 检查工具是否被启用
|
||||
if not get_tool_state(server_id, tool_name):
|
||||
continue # 如果工具被禁用,跳过这个服务器
|
||||
|
||||
# 调用工具
|
||||
success, result = call_mcp_tool(server_id, tool_name, params)
|
||||
|
||||
@@ -835,6 +1044,49 @@ def schedule_connection_check():
|
||||
connection_check_timer.daemon = True # 设置为守护线程,这样主程序退出时它会自动结束
|
||||
connection_check_timer.start()
|
||||
|
||||
# API路由 - 切换工具状态
|
||||
@app.route('/api/mcp/servers/<int:server_id>/tools/<string:tool_name>/toggle', methods=['POST'])
|
||||
def toggle_tool_state(server_id, tool_name):
|
||||
"""
|
||||
切换工具的启用/禁用状态
|
||||
"""
|
||||
try:
|
||||
# 获取请求数据
|
||||
data = request.json or {}
|
||||
enabled = data.get('enabled', True)
|
||||
|
||||
# 验证服务器是否存在
|
||||
server = None
|
||||
for s in mcp_servers:
|
||||
if s['id'] == server_id:
|
||||
server = s
|
||||
break
|
||||
|
||||
if not server:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "服务器不存在"
|
||||
}), 404
|
||||
|
||||
# 设置工具状态
|
||||
set_tool_state(server_id, tool_name, enabled)
|
||||
|
||||
util.log(1, f"工具 {tool_name} 在服务器 {server['name']} 上已{'启用' if enabled else '禁用'}")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"工具 {tool_name} 已{'启用' if enabled else '禁用'}",
|
||||
"tool_name": tool_name,
|
||||
"enabled": enabled
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
util.log(1, f"切换工具状态失败: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": f"切换工具状态失败: {str(e)}"
|
||||
}), 500
|
||||
|
||||
# 启动连接检查
|
||||
def start_connection_check():
|
||||
"""
|
||||
@@ -870,3 +1122,10 @@ def start():
|
||||
# 启动服务器
|
||||
from scheduler.thread_manager import MyThread
|
||||
MyThread(target=run).start()
|
||||
|
||||
if __name__ == '__main__':
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
app.logger.setLevel(logging.DEBUG)
|
||||
logging.getLogger('werkzeug').setLevel(logging.DEBUG)
|
||||
app.run(host='0.0.0.0', port=5010, debug=True)
|
||||
@@ -292,17 +292,82 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mcp-info-item {
|
||||
width: 33.33%;
|
||||
width: 50%;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
}
|
||||
/* 让工具列表占满整行 */
|
||||
.mcp-info-item:nth-child(4) {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
/* 工具列表容器样式 */
|
||||
.tools-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
/* 工具按钮样式 */
|
||||
.tool-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #dcdfe6;
|
||||
background-color: #fff;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
user-select: none;
|
||||
}
|
||||
.tool-btn:hover {
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
}
|
||||
/* 工具启用状态 */
|
||||
.tool-btn.enabled {
|
||||
background-color: #409eff;
|
||||
color: #fff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
.tool-btn.enabled:hover {
|
||||
background-color: #66b1ff;
|
||||
border-color: #66b1ff;
|
||||
}
|
||||
/* 工具禁用状态 */
|
||||
.tool-btn.disabled {
|
||||
background-color: #f5f7fa;
|
||||
color: #c0c4cc;
|
||||
border-color: #e4e7ed;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* 状态指示点 */
|
||||
.tool-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: #c0c4cc;
|
||||
}
|
||||
.tool-btn.enabled .tool-status-dot {
|
||||
background-color: #67c23a;
|
||||
}
|
||||
.mcp-info-label {
|
||||
font-weight: bold;
|
||||
width: 100px;
|
||||
color: #606266;
|
||||
flex-shrink: 0;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.mcp-info-value {
|
||||
color: #303133;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* 让 main_right 成为定位上下文 */
|
||||
.main_right {
|
||||
@@ -337,7 +402,7 @@
|
||||
<div class="mcp-container">
|
||||
<!-- 使用Jinja2模板循环渲染MCP服务器卡片 -->
|
||||
{% for server in mcp_servers %}
|
||||
<div class="mcp-card tooltip" data-server-id="{{ server.id }}">
|
||||
<div class="mcp-card tooltip" data-server-id="{{ server.id }}" data-transport="{{ server.transport or 'sse' }}" data-command="{{ server.command or '' }}" data-args="{{ (server.args | join(' ')) if server.args else '' }}" data-cwd="{{ server.cwd or '' }}">
|
||||
<div class="mcp-header">
|
||||
<div class="mcp-title">{{ server.name }}</div>
|
||||
<div class="status-{{ server.status }}">
|
||||
@@ -387,12 +452,41 @@
|
||||
<input type="text" class="mcp-input" id="mcpName" placeholder="请输入名称">
|
||||
</div>
|
||||
<div class="mcp-form-item">
|
||||
<label class="mcp-label">SSE服务器:</label>
|
||||
<input type="text" class="mcp-input" id="mcpIp" placeholder="请输入SSE地址">
|
||||
<label class="mcp-label">类型:</label>
|
||||
<select class="mcp-input" id="mcpTransport">
|
||||
<option value="sse" selected>SSE(远程)</option>
|
||||
<option value="stdio">STDIO(本地)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mcp-form-item">
|
||||
<label class="mcp-label">Key:</label>
|
||||
<input type="text" class="mcp-input" id="mcpKey" placeholder="(可留空)请输入Key">
|
||||
<!-- SSE 配置 -->
|
||||
<div id="sseFields">
|
||||
<div class="mcp-form-item">
|
||||
<label class="mcp-label">SSE服务器:</label>
|
||||
<input type="text" class="mcp-input" id="mcpIp" placeholder="请输入SSE地址">
|
||||
</div>
|
||||
<div class="mcp-form-item">
|
||||
<label class="mcp-label">Key:</label>
|
||||
<input type="text" class="mcp-input" id="mcpKey" placeholder="(可留空)请输入Key">
|
||||
</div>
|
||||
</div>
|
||||
<!-- STDIO 配置 -->
|
||||
<div id="stdioFields" style="display:none;">
|
||||
<div class="mcp-form-item">
|
||||
<label class="mcp-label">命令:</label>
|
||||
<input type="text" class="mcp-input" id="mcpCommand" placeholder="例如: node">
|
||||
</div>
|
||||
<div class="mcp-form-item">
|
||||
<label class="mcp-label">参数:</label>
|
||||
<input type="text" class="mcp-input" id="mcpArgs" placeholder='例如: ./servers/fs-server.js --port 0'>
|
||||
</div>
|
||||
<div class="mcp-form-item">
|
||||
<label class="mcp-label">工作目录:</label>
|
||||
<input type="text" class="mcp-input" id="mcpCwd" placeholder="可留空,默认当前目录">
|
||||
</div>
|
||||
<div class="mcp-form-item">
|
||||
<label class="mcp-label">环境变量(JSON):</label>
|
||||
<input type="text" class="mcp-input" id="mcpEnv" placeholder='例如: {"MODE":"local"}'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mcp-dialog-footer">
|
||||
@@ -414,7 +508,7 @@
|
||||
</div>
|
||||
<div class="mcp-info-item">
|
||||
<span class="mcp-info-label">服务器类型:</span>
|
||||
<span class="mcp-info-value">SSE</span>
|
||||
<span class="mcp-info-value">--</span>
|
||||
</div>
|
||||
<div class="mcp-info-item">
|
||||
<span class="mcp-info-label">延迟:</span>
|
||||
@@ -422,7 +516,11 @@
|
||||
</div>
|
||||
<div class="mcp-info-item">
|
||||
<span class="mcp-info-label">可用工具:</span>
|
||||
<span class="mcp-info-value">--</span>
|
||||
<div class="mcp-info-value">
|
||||
<div class="tools-container" id="toolsContainer">
|
||||
<span style="color: #909399;">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -446,24 +544,43 @@
|
||||
console.log("添加按钮被点击");
|
||||
// 获取输入值
|
||||
const name = document.getElementById('mcpName').value.trim();
|
||||
const ip = document.getElementById('mcpIp').value.trim();
|
||||
const key = document.getElementById('mcpKey').value.trim();
|
||||
|
||||
console.log("输入值:", name, ip, key ? "(Key已输入)" : "(无Key)");
|
||||
|
||||
// 简单验证
|
||||
if (!name || !ip) {
|
||||
alert('请填写完整的MCP信息');
|
||||
return;
|
||||
const transport = document.getElementById('mcpTransport').value;
|
||||
|
||||
let data = { name, transport };
|
||||
if (transport === 'stdio') {
|
||||
const command = document.getElementById('mcpCommand').value.trim();
|
||||
const argsStr = document.getElementById('mcpArgs').value.trim();
|
||||
const cwd = document.getElementById('mcpCwd').value.trim();
|
||||
const envStr = document.getElementById('mcpEnv').value.trim();
|
||||
|
||||
if (!name || !command) {
|
||||
alert('请填写名称与命令');
|
||||
return;
|
||||
}
|
||||
|
||||
let args = [];
|
||||
if (argsStr) {
|
||||
// 简单按空格拆分,支持引号可后续优化
|
||||
args = argsStr.match(/\"[^\"]*\"|'[^']*'|[^\s]+/g) || [];
|
||||
args = args.map(s => s.replace(/^"|"$/g, '').replace(/^'|'$/g, ''));
|
||||
}
|
||||
|
||||
let env = {};
|
||||
if (envStr) {
|
||||
try { env = JSON.parse(envStr); } catch (e) { alert('环境变量需为合法JSON'); return; }
|
||||
}
|
||||
|
||||
data = { ...data, command, args, cwd, env };
|
||||
} else {
|
||||
const ip = document.getElementById('mcpIp').value.trim();
|
||||
const key = document.getElementById('mcpKey').value.trim();
|
||||
if (!name || !ip) {
|
||||
alert('请填写名称与SSE地址');
|
||||
return;
|
||||
}
|
||||
data = { ...data, ip, key };
|
||||
}
|
||||
|
||||
// 准备数据
|
||||
const data = {
|
||||
name: name,
|
||||
ip: ip,
|
||||
key: key // 添加 Key 参数
|
||||
};
|
||||
|
||||
console.log("准备发送数据:", JSON.stringify(data));
|
||||
|
||||
// 发送请求到后端
|
||||
@@ -514,6 +631,22 @@
|
||||
} else {
|
||||
console.error('找不到添加服务器按钮元素');
|
||||
}
|
||||
|
||||
// 绑定类型切换,显示/隐藏字段
|
||||
const transportSel = document.getElementById('mcpTransport');
|
||||
const sseFields = document.getElementById('sseFields');
|
||||
const stdioFields = document.getElementById('stdioFields');
|
||||
if (transportSel) {
|
||||
transportSel.addEventListener('change', function() {
|
||||
if (this.value === 'stdio') {
|
||||
sseFields.style.display = 'none';
|
||||
stdioFields.style.display = 'block';
|
||||
} else {
|
||||
sseFields.style.display = 'block';
|
||||
stdioFields.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 删除MCP服务器
|
||||
@@ -639,22 +772,18 @@
|
||||
// 更新信息面板
|
||||
updateInfoPanel(data.server, toolsInfo);
|
||||
|
||||
// 选中当前卡片
|
||||
mcpCards.forEach(c => c.classList.remove('selected'));
|
||||
// 选中当前卡片并更新面板
|
||||
document.querySelectorAll('.mcp-card.selected').forEach(c => c.classList.remove('selected'));
|
||||
card.classList.add('selected');
|
||||
|
||||
// 触发卡片点击事件,更新信息卡片
|
||||
card.click();
|
||||
onCardSelected(card);
|
||||
} else {
|
||||
// 没有工具时也更新信息面板
|
||||
updateInfoPanel(data.server, '无可用工具');
|
||||
|
||||
// 选中当前卡片
|
||||
mcpCards.forEach(c => c.classList.remove('selected'));
|
||||
// 选中当前卡片并更新面板
|
||||
document.querySelectorAll('.mcp-card.selected').forEach(c => c.classList.remove('selected'));
|
||||
card.classList.add('selected');
|
||||
|
||||
// 触发卡片点击事件,更新信息卡片
|
||||
card.click();
|
||||
onCardSelected(card);
|
||||
}
|
||||
} else {
|
||||
// 连接失败
|
||||
@@ -796,22 +925,18 @@
|
||||
// 更新信息面板
|
||||
updateInfoPanel(data.server, toolsInfo);
|
||||
|
||||
// 选中当前卡片
|
||||
mcpCards.forEach(c => c.classList.remove('selected'));
|
||||
// 选中当前卡片并更新面板
|
||||
document.querySelectorAll('.mcp-card.selected').forEach(c => c.classList.remove('selected'));
|
||||
card.classList.add('selected');
|
||||
|
||||
// 触发卡片点击事件,更新信息卡片
|
||||
card.click();
|
||||
onCardSelected(card);
|
||||
} else {
|
||||
// 没有工具时也更新信息面板
|
||||
updateInfoPanel(data.server, '无可用工具');
|
||||
|
||||
// 选中当前卡片
|
||||
mcpCards.forEach(c => c.classList.remove('selected'));
|
||||
// 选中当前卡片并更新面板
|
||||
document.querySelectorAll('.mcp-card.selected').forEach(c => c.classList.remove('selected'));
|
||||
card.classList.add('selected');
|
||||
|
||||
// 触发卡片点击事件,更新信息卡片
|
||||
card.click();
|
||||
onCardSelected(card);
|
||||
}
|
||||
} else {
|
||||
// 连接失败
|
||||
@@ -855,9 +980,10 @@
|
||||
|
||||
// 卡片选中功能
|
||||
const mcpCards = document.querySelectorAll('.mcp-card');
|
||||
const container = document.querySelector('.mcp-container');
|
||||
const infoPanel = document.querySelector('.mcp-info-panel');
|
||||
const infoValues = document.querySelectorAll('.mcp-info-value');
|
||||
|
||||
|
||||
// 处理长文本,超出长度使用省略号替代
|
||||
function truncateText(text, maxLength = 30) {
|
||||
if (!text) return '';
|
||||
@@ -865,83 +991,179 @@
|
||||
return text.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
mcpCards.forEach((card, index) => {
|
||||
// 处理卡片中的服务器地址,如果太长则使用省略号替代
|
||||
const ipValue = card.querySelector('.mcp-info:nth-child(2) .mcp-value');
|
||||
if (ipValue) {
|
||||
ipValue.textContent = truncateText(ipValue.textContent);
|
||||
// 添加完整地址的 title 属性,鼠标悬停时显示完整地址
|
||||
ipValue.title = ipValue.getAttribute('data-full-ip') || ipValue.textContent;
|
||||
// 首次渲染:处理省略显示
|
||||
mcpCards.forEach((card) => {
|
||||
const ipNode = card.querySelector('.mcp-info:nth-child(2) .mcp-value');
|
||||
if (ipNode) {
|
||||
const full = ipNode.getAttribute('data-full-ip') || ipNode.textContent;
|
||||
ipNode.textContent = truncateText(full);
|
||||
ipNode.title = full;
|
||||
}
|
||||
|
||||
card.addEventListener('click', () => {
|
||||
// 移除其他卡片的选中状态
|
||||
mcpCards.forEach(c => c.classList.remove('selected'));
|
||||
|
||||
// 添加当前卡片的选中状态
|
||||
card.classList.add('selected');
|
||||
|
||||
// 显示信息面板
|
||||
infoPanel.classList.add('active');
|
||||
|
||||
// 获取服务器ID和信息
|
||||
const serverId = card.getAttribute('data-server-id');
|
||||
const serverName = card.querySelector('.mcp-title').textContent;
|
||||
const serverIp = card.querySelector('.mcp-info:nth-child(2) .mcp-value').getAttribute('data-full-ip') ||
|
||||
card.querySelector('.mcp-info:nth-child(2) .mcp-value').textContent;
|
||||
const serverLatency = card.querySelector('.mcp-info:nth-child(3) .mcp-value').textContent;
|
||||
const serverStatus = card.querySelector('.status-online, .status-offline').className;
|
||||
|
||||
// 更新信息面板内容
|
||||
const ipValue = infoPanel.querySelector('.mcp-info-item:nth-child(1) .mcp-info-value');
|
||||
const typeValue = infoPanel.querySelector('.mcp-info-item:nth-child(2) .mcp-info-value');
|
||||
const latencyValue = infoPanel.querySelector('.mcp-info-item:nth-child(3) .mcp-info-value');
|
||||
const toolsValue = infoPanel.querySelector('.mcp-info-item:nth-child(4) .mcp-info-value');
|
||||
|
||||
if (ipValue) {
|
||||
ipValue.textContent = truncateText(serverIp);
|
||||
ipValue.title = serverIp; // 添加完整地址的 title 属性
|
||||
}
|
||||
if (latencyValue) latencyValue.textContent = serverLatency;
|
||||
|
||||
// 如果服务器在线,获取工具列表
|
||||
if (serverStatus.includes('online')) {
|
||||
// 显示加载状态
|
||||
if (toolsValue) toolsValue.textContent = '加载中...';
|
||||
|
||||
// 发送请求获取工具列表
|
||||
fetch(`/api/mcp/servers/${serverId}/tools`, {
|
||||
method: 'GET'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.tools && data.tools.length > 0) {
|
||||
const toolNames = data.tools.map(tool => {
|
||||
if (typeof tool === 'object' && tool !== null && tool.name) {
|
||||
return tool.name;
|
||||
} else if (typeof tool === 'string') {
|
||||
return tool;
|
||||
} else {
|
||||
return '未知工具';
|
||||
}
|
||||
});
|
||||
|
||||
toolsValue.textContent = toolNames.join(', ');
|
||||
} else {
|
||||
toolsValue.textContent = '无可用工具';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('获取工具列表失败:', error);
|
||||
toolsValue.textContent = '获取工具列表失败';
|
||||
});
|
||||
} else {
|
||||
// 服务器离线
|
||||
if (toolsValue) toolsValue.textContent = '服务器离线';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 统一的卡片点击处理
|
||||
function initCardSelection() {
|
||||
const cards = document.querySelectorAll('.mcp-card');
|
||||
cards.forEach((card) => {
|
||||
card.addEventListener('click', function(e) {
|
||||
// 如果点击的是操作按钮区域,不处理选中逻辑
|
||||
if (e.target.closest('.mcp-actions')) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
// 移除所有选中状态
|
||||
document.querySelectorAll('.mcp-card.selected').forEach(c => c.classList.remove('selected'));
|
||||
// 添加当前卡片选中状态
|
||||
this.classList.add('selected');
|
||||
// 调用选中处理函数
|
||||
onCardSelected(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
function initPage() {
|
||||
initCardSelection();
|
||||
|
||||
// 自动选中第一个在线的服务器卡片
|
||||
const onlineCard = document.querySelector('.mcp-card .status-online')?.closest('.mcp-card');
|
||||
const firstCard = document.querySelector('.mcp-card');
|
||||
|
||||
if (onlineCard) {
|
||||
onlineCard.classList.add('selected');
|
||||
onCardSelected(onlineCard);
|
||||
} else if (firstCard) {
|
||||
// 如果没有在线的,选中第一个卡片
|
||||
firstCard.classList.add('selected');
|
||||
onCardSelected(firstCard);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initPage);
|
||||
} else {
|
||||
initPage();
|
||||
}
|
||||
|
||||
function onCardSelected(card) {
|
||||
const infoPanel = document.querySelector('.mcp-info-panel');
|
||||
if (!infoPanel) return;
|
||||
|
||||
infoPanel.classList.add('active');
|
||||
const serverId = card.getAttribute('data-server-id');
|
||||
const nameNode = card.querySelector('.mcp-title');
|
||||
const ipNode = card.querySelector('.mcp-info:nth-child(2) .mcp-value');
|
||||
const latencyNode = card.querySelector('.mcp-info:nth-child(3) .mcp-value');
|
||||
const connectionTimeNode = card.querySelector('.mcp-info:nth-child(4) .mcp-value');
|
||||
const statusDiv = card.querySelector('.status-online, .status-offline');
|
||||
const transport = card.getAttribute('data-transport') || 'sse';
|
||||
|
||||
const server = {
|
||||
name: nameNode ? nameNode.textContent : '--',
|
||||
ip: ipNode ? (ipNode.getAttribute('data-full-ip') || ipNode.textContent) : '--',
|
||||
latency: latencyNode ? latencyNode.textContent : '--',
|
||||
connectionTime: connectionTimeNode ? connectionTimeNode.textContent : '--',
|
||||
transport: transport
|
||||
};
|
||||
|
||||
// 更新面板基础信息
|
||||
const ipValue = infoPanel.querySelector('.mcp-info-item:nth-child(1) .mcp-info-value');
|
||||
const typeValue = infoPanel.querySelector('.mcp-info-item:nth-child(2) .mcp-info-value');
|
||||
const latencyValue = infoPanel.querySelector('.mcp-info-item:nth-child(3) .mcp-info-value');
|
||||
const toolsValue = infoPanel.querySelector('.mcp-info-item:nth-child(4) .mcp-info-value');
|
||||
|
||||
if (ipValue) {
|
||||
ipValue.textContent = truncateText(server.ip);
|
||||
ipValue.title = server.ip;
|
||||
}
|
||||
if (typeValue) {
|
||||
typeValue.textContent = transport === 'stdio' ? 'STDIO(本地)' : 'SSE(远程)';
|
||||
}
|
||||
if (latencyValue) {
|
||||
latencyValue.textContent = server.latency;
|
||||
}
|
||||
const toolsContainer = document.getElementById('toolsContainer');
|
||||
if (toolsContainer) {
|
||||
toolsContainer.innerHTML = '<span style="color: #909399;">加载中...</span>';
|
||||
}
|
||||
|
||||
// 检查是否在线
|
||||
const isOnline = statusDiv && statusDiv.classList.contains('status-online');
|
||||
if (!isOnline) {
|
||||
const toolsContainer = document.getElementById('toolsContainer');
|
||||
if (toolsContainer) {
|
||||
toolsContainer.innerHTML = '<span style="color: #909399;">服务器离线(点击"连接"按钮后可查看工具)</span>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载工具列表
|
||||
fetch(`/api/mcp/servers/${serverId}/tools`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch tools');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('工具数据:', data); // 调试信息
|
||||
const toolsContainer = document.getElementById('toolsContainer');
|
||||
if (toolsContainer) {
|
||||
toolsContainer.innerHTML = '';
|
||||
const tools = data.tools || [];
|
||||
|
||||
if (tools.length > 0) {
|
||||
tools.forEach(tool => {
|
||||
console.log('工具对象:', tool); // 调试信息
|
||||
let toolName = '';
|
||||
let toolEnabled = true; // 默认启用
|
||||
|
||||
if (typeof tool === 'object' && tool !== null) {
|
||||
toolName = tool.name || '未知工具';
|
||||
// 如果后端返回了启用状态,使用它
|
||||
if (typeof tool.enabled !== 'undefined') {
|
||||
toolEnabled = tool.enabled;
|
||||
console.log(`工具 ${toolName} 状态:`, toolEnabled); // 调试信息
|
||||
}
|
||||
} else if (typeof tool === 'string') {
|
||||
toolName = tool;
|
||||
} else {
|
||||
toolName = '未知工具';
|
||||
}
|
||||
|
||||
// 创建工具按钮
|
||||
const toolBtn = document.createElement('div');
|
||||
toolBtn.className = `tool-btn ${toolEnabled ? 'enabled' : ''}`;
|
||||
toolBtn.innerHTML = `
|
||||
<span class="tool-status-dot"></span>
|
||||
<span>${toolName}</span>
|
||||
`;
|
||||
toolBtn.title = `点击${toolEnabled ? '禁用' : '启用'}工具: ${toolName}`;
|
||||
toolBtn.dataset.toolName = toolName;
|
||||
toolBtn.dataset.serverId = serverId;
|
||||
toolBtn.dataset.enabled = toolEnabled;
|
||||
|
||||
// 添加点击事件
|
||||
toolBtn.addEventListener('click', function() {
|
||||
toggleTool(this, serverId, toolName);
|
||||
});
|
||||
|
||||
toolsContainer.appendChild(toolBtn);
|
||||
});
|
||||
} else {
|
||||
toolsContainer.innerHTML = '<span style="color: #909399;">无可用工具</span>';
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('获取工具列表失败:', err);
|
||||
const toolsContainer = document.getElementById('toolsContainer');
|
||||
if (toolsContainer) {
|
||||
toolsContainer.innerHTML = '<span style="color: #f56c6c;">获取工具列表失败</span>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 更新信息面板
|
||||
function updateInfoPanel(server, toolsInfo) {
|
||||
const infoPanel = document.querySelector('.mcp-info-panel');
|
||||
@@ -949,7 +1171,7 @@
|
||||
|
||||
const ipValue = infoPanel.querySelector('.mcp-info-item:nth-child(1) .mcp-info-value');
|
||||
const latencyValue = infoPanel.querySelector('.mcp-info-item:nth-child(3) .mcp-info-value');
|
||||
const toolsValue = infoPanel.querySelector('.mcp-info-item:nth-child(4) .mcp-info-value');
|
||||
const toolsContainer = document.getElementById('toolsContainer');
|
||||
|
||||
if (ipValue && server && server.ip) {
|
||||
ipValue.textContent = truncateText(server.ip);
|
||||
@@ -960,14 +1182,62 @@
|
||||
latencyValue.textContent = server.latency;
|
||||
}
|
||||
|
||||
if (toolsValue && toolsInfo) {
|
||||
toolsValue.textContent = toolsInfo;
|
||||
if (toolsContainer && toolsInfo) {
|
||||
// 简单显示工具信息文本,连接成功后会由onCardSelected更新为按钮
|
||||
toolsContainer.innerHTML = `<span style="color: #303133;">${toolsInfo}</span>`;
|
||||
}
|
||||
|
||||
// 显示信息面板
|
||||
infoPanel.classList.add('active');
|
||||
}
|
||||
|
||||
// 切换工具状态
|
||||
function toggleTool(btn, serverId, toolName) {
|
||||
const isEnabled = btn.dataset.enabled === 'true';
|
||||
const newState = !isEnabled;
|
||||
|
||||
// 立即更新UI
|
||||
btn.dataset.enabled = newState;
|
||||
if (newState) {
|
||||
btn.classList.add('enabled');
|
||||
btn.title = `点击禁用工具: ${toolName}`;
|
||||
} else {
|
||||
btn.classList.remove('enabled');
|
||||
btn.title = `点击启用工具: ${toolName}`;
|
||||
}
|
||||
|
||||
// 发送API请求更新状态
|
||||
fetch(`/api/mcp/servers/${serverId}/tools/${encodeURIComponent(toolName)}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ enabled: newState })
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to toggle tool state');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log(`工具 ${toolName} 已${newState ? '启用' : '禁用'}`);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('切换工具状态失败:', err);
|
||||
// 恢复原状态
|
||||
btn.dataset.enabled = isEnabled;
|
||||
if (isEnabled) {
|
||||
btn.classList.add('enabled');
|
||||
btn.title = `点击禁用工具: ${toolName}`;
|
||||
} else {
|
||||
btn.classList.remove('enabled');
|
||||
btn.title = `点击启用工具: ${toolName}`;
|
||||
}
|
||||
alert(`切换工具状态失败: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化Vue实例
|
||||
new Vue({
|
||||
el: '#app',
|
||||
|
||||
@@ -459,7 +459,7 @@ def non_streaming_response(last_content, username):
|
||||
text += sentence.replace("_<isfirst>", "").replace("_<isend>", "")
|
||||
if is_end:
|
||||
if username in fay_booter.feiFei.nlp_streams:
|
||||
stream_manager.new_instance().clear_Stream(username)
|
||||
stream_manager.new_instance().clear_Stream(username)
|
||||
break
|
||||
return jsonify({
|
||||
"id": "fay-" + str(uuid.uuid4()),
|
||||
@@ -562,6 +562,8 @@ def to_stop_talking():
|
||||
username = data.get('username', 'User')
|
||||
message = data.get('text', '你好,请说?')
|
||||
observation = data.get('observation', '')
|
||||
from queue import Queue
|
||||
stream_manager.clear_Stream_with_audio(username)
|
||||
interact = Interact("stop_talking", 2, {'user': username, 'text': message, 'observation': str(observation)})
|
||||
result = fay_booter.feiFei.on_interact(interact)
|
||||
return jsonify({
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 8.7 KiB |
@@ -34,13 +34,19 @@ class MainWindow(QMainWindow):
|
||||
def runnable(self):
|
||||
while True:
|
||||
if not self.isVisible():
|
||||
# try:
|
||||
# wsa_server.get_instance().stop_server()
|
||||
# wsa_server.get_web_instance().stop_server()
|
||||
# thread_manager.stopAll()
|
||||
# except BaseException as e:
|
||||
# print(e)
|
||||
os.system("taskkill /F /PID {}".format(os.getpid()))
|
||||
try:
|
||||
# 正常关闭服务
|
||||
import fay_booter
|
||||
if fay_booter.is_running():
|
||||
print("窗口关闭,正在停止Fay服务...")
|
||||
fay_booter.stop()
|
||||
time.sleep(0.5) # 给服务一点时间完成清理
|
||||
print("服务已停止")
|
||||
except BaseException as e:
|
||||
print(f"正常关闭服务时出错: {e}")
|
||||
finally:
|
||||
# 如果正常关闭失败,再强制终止
|
||||
os.system("taskkill /F /PID {}".format(os.getpid()))
|
||||
time.sleep(0.05)
|
||||
|
||||
def center(self):
|
||||
|
||||
@@ -43,7 +43,7 @@ from core import stream_manager
|
||||
os.environ["LANGCHAIN_TRACING_V2"] = "true"
|
||||
os.environ["LANGCHAIN_ENDPOINT"] = "https://api.smith.langchain.com"
|
||||
os.environ["LANGCHAIN_API_KEY"] = "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"
|
||||
os.environ["LANGCHAIN_PROJECT"] = "fay3.1.2_github"
|
||||
os.environ["LANGCHAIN_PROJECT"] = "fay3.8.2_github"
|
||||
|
||||
# 加载配置
|
||||
cfg.load_config()
|
||||
@@ -818,7 +818,7 @@ def question(content, username, observation=None):
|
||||
# 2. 存在mcp工具,走react agent
|
||||
if mcp_tools:
|
||||
|
||||
is_agent_think_start = False
|
||||
is_agent_think_start = False#记录是否已经写入start标签
|
||||
#2.1 构建react agent
|
||||
tools = [_build_tool(t) for t in mcp_tools] if mcp_tools else []
|
||||
react_agent = create_react_agent(llm, tools)
|
||||
@@ -830,7 +830,7 @@ def question(content, username, observation=None):
|
||||
for chunk in react_agent.stream(
|
||||
{"messages": messages}, {"configurable": {"thread_id": "tid{}".format(username)}}
|
||||
):
|
||||
react_response_text = ""
|
||||
react_response_text = ""
|
||||
# 消息类型1:检测工具调用开始
|
||||
if "agent" in chunk and "tool_calls" in str(chunk):
|
||||
try:
|
||||
@@ -845,6 +845,8 @@ def question(content, username, observation=None):
|
||||
if is_first_sentence:
|
||||
content_temp = react_response_text + "_<isfirst>"
|
||||
is_first_sentence = False
|
||||
else:
|
||||
content_temp = react_response_text
|
||||
|
||||
stream_manager.new_instance().write_sentence(username, content_temp)
|
||||
except (KeyError, IndexError, AttributeError) as e:
|
||||
@@ -1065,9 +1067,14 @@ def perform_daily_reflection():
|
||||
|
||||
# 执行反思,传入当前时间戳
|
||||
for username, agent in agents.items():
|
||||
# 获取当前时间作为time_step
|
||||
current_time_step = get_current_time_step(username)
|
||||
agent.reflect(topic, time_step=current_time_step)
|
||||
try:
|
||||
# 获取当前时间作为time_step
|
||||
current_time_step = get_current_time_step(username)
|
||||
agent.reflect(topic, time_step=current_time_step)
|
||||
except KeyError as e:
|
||||
util.log(1, f"反思时出现KeyError: {e},跳过此次反思")
|
||||
except Exception as e:
|
||||
util.log(1, f"反思时出现错误: {e},跳过此次反思")
|
||||
|
||||
# 记录反思执行情况
|
||||
util.log(1, f"反思主题: {topic}")
|
||||
|
||||
86
main.py
86
main.py
@@ -6,6 +6,9 @@ import time
|
||||
import psutil
|
||||
import re
|
||||
import argparse
|
||||
import signal
|
||||
import atexit
|
||||
import threading
|
||||
from utils import config_util, util
|
||||
from asr import ali_nls
|
||||
from core import wsa_server
|
||||
@@ -15,6 +18,83 @@ import fay_booter
|
||||
from scheduler.thread_manager import MyThread
|
||||
from core.interact import Interact
|
||||
|
||||
# import sys, io, traceback
|
||||
# class StdoutInterceptor(io.TextIOBase):
|
||||
# def __init__(self, orig):
|
||||
# self._orig = orig
|
||||
# def write(self, s):
|
||||
# try:
|
||||
# if s.strip() == "1":
|
||||
# self._orig.write("[debug] caught raw '1', stack:\n")
|
||||
# traceback.print_stack(limit=8, file=self._orig)
|
||||
# except Exception:
|
||||
# pass
|
||||
# return self._orig.write(s)
|
||||
# def flush(self):
|
||||
# return self._orig.flush()
|
||||
# sys.stdout = StdoutInterceptor(sys.stdout)
|
||||
|
||||
# 程序退出处理
|
||||
def cleanup_on_exit():
|
||||
"""程序退出时的清理函数"""
|
||||
try:
|
||||
util.log(1, '程序退出,正在清理资源...')
|
||||
if fay_booter.is_running():
|
||||
fay_booter.stop()
|
||||
|
||||
# 停止所有自定义线程
|
||||
try:
|
||||
from scheduler.thread_manager import stopAll
|
||||
util.log(1, '正在停止所有线程...')
|
||||
stopAll()
|
||||
util.log(1, '所有线程已停止')
|
||||
except Exception as e:
|
||||
util.log(1, f'停止线程时出错: {e}')
|
||||
|
||||
util.log(1, '资源清理完成')
|
||||
except Exception as e:
|
||||
util.log(1, f'清理资源时出错: {e}')
|
||||
|
||||
# 信号处理函数
|
||||
def signal_handler(signum, frame):
|
||||
"""处理终止信号"""
|
||||
util.log(1, f'收到信号 {signum},正在退出程序...')
|
||||
|
||||
# 使用独立线程进行清理,避免信号处理器被阻塞
|
||||
def cleanup_and_exit():
|
||||
try:
|
||||
cleanup_on_exit()
|
||||
except Exception as e:
|
||||
util.log(1, f'清理过程异常: {e}')
|
||||
finally:
|
||||
# 给其他线程一点时间完成清理
|
||||
time.sleep(1.0)
|
||||
|
||||
# 强制退出,避免被非守护线程阻塞
|
||||
util.log(1, '程序即将强制退出...')
|
||||
os._exit(0) # 立即退出,不调用atexit处理器
|
||||
|
||||
# 在单独线程中执行清理,避免阻塞信号处理器
|
||||
cleanup_thread = threading.Thread(target=cleanup_and_exit, daemon=True)
|
||||
cleanup_thread.start()
|
||||
|
||||
# 如果清理线程超过5秒还没完成,强制退出
|
||||
cleanup_thread.join(timeout=5.0)
|
||||
if cleanup_thread.is_alive():
|
||||
util.log(1, '清理超时,立即强制退出...')
|
||||
os._exit(1)
|
||||
|
||||
# 注册退出处理和信号处理
|
||||
atexit.register(cleanup_on_exit)
|
||||
try:
|
||||
signal.signal(signal.SIGINT, signal_handler) # Ctrl+C
|
||||
signal.signal(signal.SIGTERM, signal_handler) # 终止信号
|
||||
# Windows特有信号
|
||||
if hasattr(signal, 'SIGBREAK'):
|
||||
signal.signal(signal.SIGBREAK, signal_handler)
|
||||
except Exception as e:
|
||||
util.log(1, f'注册信号处理器失败: {e}')
|
||||
|
||||
#载入配置
|
||||
config_util.load_config()
|
||||
|
||||
@@ -38,7 +118,11 @@ def __clear_logs():
|
||||
os.mkdir("./logs")
|
||||
for file_name in os.listdir('./logs'):
|
||||
if file_name.endswith('.log'):
|
||||
os.remove('./logs/' + file_name)
|
||||
try:
|
||||
os.remove('./logs/' + file_name)
|
||||
except PermissionError:
|
||||
print(f"Warning: Cannot delete {file_name} - file is in use by another process")
|
||||
continue
|
||||
|
||||
def __create_memory():
|
||||
if not os.path.exists("./memory"):
|
||||
|
||||
161
mcp_servers/schedule_manager/README.md
Normal file
161
mcp_servers/schedule_manager/README.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Fay日程管理MCP Server
|
||||
|
||||
基于MCP协议的智能日程管理系统,深度集成Fay数字人助手,提供自然语言日程管理和自动提醒功能。
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 📅 日程管理
|
||||
- **智能解析**:自然语言输入,如"明天上午提醒我开会"
|
||||
- **灵活调度**:支持单次和周期性任务
|
||||
- **状态管理**:活跃、完成、删除状态跟踪
|
||||
- **多用户支持**:基于UID的用户隔离
|
||||
|
||||
### 🌐 Web管理界面
|
||||
- **自动启动**:连接MCP时自动开启Web界面(http://localhost:5011)
|
||||
- **可视化管理**:卡片式日程展示,支持增删改查
|
||||
- **高级筛选**:按状态、日期、关键词筛选
|
||||
- **响应式设计**:支持PC和移动端访问
|
||||
|
||||
### 🤖 Fay集成
|
||||
- **语音提醒**:到时自动通过Fay播报提醒
|
||||
- **智能交互**:支持语音和文字双向沟通
|
||||
- **无缝集成**:原生支持Fay的文字沟通接口
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
pip install mcp flask flask-cors
|
||||
```
|
||||
|
||||
### 2. 配置Fay
|
||||
|
||||
在Fay的MCP管理界面添加:
|
||||
- **名称**: Fay日程管理
|
||||
- **类型**: STDIO(本地)
|
||||
- **命令**: python
|
||||
- **参数**: server.py
|
||||
- **工作目录**: mcp_servers/schedule_manager
|
||||
|
||||
### 3. 使用方式
|
||||
|
||||
#### 通过Fay语音交互
|
||||
- "明天上午9点提醒我开会"
|
||||
- "查看今天的日程"
|
||||
- "取消下午3点的会议"
|
||||
|
||||
#### 通过Web界面管理
|
||||
连接MCP后访问 http://localhost:5011
|
||||
|
||||
## MCP工具说明
|
||||
|
||||
### parse_natural_schedule
|
||||
解析自然语言日程指令
|
||||
|
||||
**示例输入**:
|
||||
- "明天上午提醒我开会" → 标题:"开会",时间:"明天 09:00"
|
||||
- "10分钟后提醒我休息" → 标题:"休息",时间:"当前时间+10分钟"
|
||||
|
||||
### add_schedule
|
||||
添加日程任务
|
||||
|
||||
**参数**:
|
||||
- `title`: 日程标题
|
||||
- `content`: 详细内容
|
||||
- `schedule_time`: 执行时间(YYYY-MM-DD HH:MM)
|
||||
- `repeat_rule`: 重复规则(7位数字,1=重复,0=不重复)
|
||||
- `uid`: 用户ID
|
||||
|
||||
### get_schedules
|
||||
获取日程列表
|
||||
|
||||
**参数**:
|
||||
- `status`: 状态筛选(active/completed/deleted)
|
||||
- `uid`: 用户ID筛选
|
||||
|
||||
### update_schedule
|
||||
更新日程信息
|
||||
|
||||
### delete_schedule
|
||||
删除日程(软删除)
|
||||
|
||||
### send_message_to_fay
|
||||
直接发送消息给Fay
|
||||
|
||||
## 技术架构
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────┐ ┌───────────┐
|
||||
│ Fay主程序 │────▶│ MCP Server│────▶│ Web界面 │
|
||||
└─────────────┘ └──────────┘ └───────────┘
|
||||
│ │ │
|
||||
└──────────────────┴─────────────────┘
|
||||
│
|
||||
┌──────────┐
|
||||
│ SQLite │
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
## 数据存储
|
||||
|
||||
使用SQLite数据库(schedules.db)存储日程:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|-----|
|
||||
| id | INTEGER | 主键 |
|
||||
| title | TEXT | 标题 |
|
||||
| content | TEXT | 内容 |
|
||||
| schedule_time | TEXT | 执行时间 |
|
||||
| repeat_rule | TEXT | 重复规则 |
|
||||
| status | TEXT | 状态 |
|
||||
| uid | INTEGER | 用户ID |
|
||||
|
||||
## 重复规则说明
|
||||
|
||||
7位数字字符串,每位代表周一到周日:
|
||||
- `0000000`: 单次执行
|
||||
- `1111100`: 工作日重复
|
||||
- `1111111`: 每日重复
|
||||
- `1000100`: 周一和周五重复
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **端口占用**:Web界面使用5011端口,确保未被占用
|
||||
2. **时区设置**:使用系统本地时区
|
||||
3. **进程管理**:断开MCP连接时Web服务器自动关闭
|
||||
4. **数据安全**:日程数据存储在本地,注意备份
|
||||
|
||||
## 故障排除
|
||||
|
||||
### Web界面无法访问
|
||||
- 检查5011端口是否被占用
|
||||
- 确认MCP服务器已连接
|
||||
- 查看日志确认Web服务器已启动
|
||||
|
||||
### 提醒未触发
|
||||
- 检查日程时间设置是否正确
|
||||
- 确认Fay主程序正在运行
|
||||
- 查看日程状态是否为active
|
||||
|
||||
### 自然语言解析错误
|
||||
- 使用更明确的时间表述
|
||||
- 避免过于复杂的句式
|
||||
- 参考示例格式输入
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.2.0 (2025-08-23)
|
||||
- 新增自然语言解析功能
|
||||
- 集成Web管理界面
|
||||
- 优化进程生命周期管理
|
||||
- 修复重复提醒问题
|
||||
|
||||
### v1.0.0
|
||||
- 基础日程管理功能
|
||||
- MCP协议支持
|
||||
- Fay集成
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
2
mcp_servers/schedule_manager/requirements.txt
Normal file
2
mcp_servers/schedule_manager/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
mcp>=1.0.0
|
||||
sqlite3
|
||||
897
mcp_servers/schedule_manager/schedule_web.html
Normal file
897
mcp_servers/schedule_manager/schedule_web.html
Normal file
@@ -0,0 +1,897 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Fay日程管理</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Microsoft YaHei', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 32px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
background: #f8f9fa;
|
||||
padding: 30px;
|
||||
border-right: 1px solid #eee;
|
||||
}
|
||||
|
||||
.add-schedule-btn {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 30px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.add-schedule-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-title {
|
||||
font-size: 18px;
|
||||
margin-bottom: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
border-color: #667eea;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.schedule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.schedule-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border-left: 4px solid #667eea;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.schedule-card.today {
|
||||
border-left-color: #ff6b6b;
|
||||
background: linear-gradient(135deg, rgba(255, 107, 107, 0.05) 0%, rgba(255, 107, 107, 0.02) 100%);
|
||||
}
|
||||
|
||||
.schedule-card.completed {
|
||||
opacity: 0.6;
|
||||
border-left-color: #51cf66;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.card-time {
|
||||
font-size: 14px;
|
||||
color: #667eea;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-status {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background: rgba(81, 207, 102, 0.1);
|
||||
color: #51cf66;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.card-repeat {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #4ecdc4;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-complete {
|
||||
background: #51cf66;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: #667eea;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.repeat-days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.day-btn {
|
||||
padding: 8px;
|
||||
border: 2px solid #e0e0e0;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.day-btn.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 15px;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f8f9fa;
|
||||
color: #666;
|
||||
padding: 12px 24px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover, .btn-secondary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* Toast提示样式 */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 15px 20px;
|
||||
background: #51cf66;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 2000;
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: #ff6b6b;
|
||||
}
|
||||
|
||||
.toast.warning {
|
||||
background: #ffd43b;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.schedule-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📅 Fay日程管理</h1>
|
||||
<p>智能助手的日程安排与提醒系统</p>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="sidebar">
|
||||
<button class="add-schedule-btn" onclick="showAddModal()">
|
||||
➕ 添加新日程
|
||||
</button>
|
||||
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">筛选条件</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>状态</label>
|
||||
<select class="filter-input" id="statusFilter" onchange="filterSchedules()">
|
||||
<option value="all">全部</option>
|
||||
<option value="active">活跃</option>
|
||||
<option value="completed">已完成</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>搜索关键词</label>
|
||||
<input type="text" class="filter-input" id="searchInput" placeholder="搜索标题或内容..." oninput="filterSchedules()">
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>日期范围</label>
|
||||
<input type="date" class="filter-input" id="dateFrom" onchange="filterSchedules()">
|
||||
<input type="date" class="filter-input" id="dateTo" onchange="filterSchedules()" style="margin-top: 8px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-area">
|
||||
<div id="scheduleContainer" class="schedule-grid">
|
||||
<div class="loading">正在加载日程...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑日程模态框 -->
|
||||
<div id="scheduleModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="modalTitle">添加新日程</h2>
|
||||
<button class="close-btn" onclick="hideModal()">×</button>
|
||||
</div>
|
||||
|
||||
<form id="scheduleForm">
|
||||
<input type="hidden" id="scheduleId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">日程标题 *</label>
|
||||
<input type="text" id="scheduleTitle" class="form-input" placeholder="请输入日程标题" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">详细内容</label>
|
||||
<textarea id="scheduleContent" class="form-input form-textarea" placeholder="请输入详细内容..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">执行时间 *</label>
|
||||
<input type="datetime-local" id="scheduleTime" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">重复设置</label>
|
||||
<div class="repeat-days">
|
||||
<button type="button" class="day-btn" data-day="0" onclick="toggleDay(this)">一</button>
|
||||
<button type="button" class="day-btn" data-day="1" onclick="toggleDay(this)">二</button>
|
||||
<button type="button" class="day-btn" data-day="2" onclick="toggleDay(this)">三</button>
|
||||
<button type="button" class="day-btn" data-day="3" onclick="toggleDay(this)">四</button>
|
||||
<button type="button" class="day-btn" data-day="4" onclick="toggleDay(this)">五</button>
|
||||
<button type="button" class="day-btn" data-day="5" onclick="toggleDay(this)">六</button>
|
||||
<button type="button" class="day-btn" data-day="6" onclick="toggleDay(this)">日</button>
|
||||
</div>
|
||||
<small style="color: #999; margin-top: 8px; display: block;">选择重复的日期,不选择表示只执行一次</small>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick="hideModal()">取消</button>
|
||||
<button type="submit" class="btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let schedules = [];
|
||||
let currentEditId = null;
|
||||
|
||||
// Toast提示函数
|
||||
function showToast(message, type = 'success') {
|
||||
// 移除已存在的toast
|
||||
const existingToast = document.querySelector('.toast');
|
||||
if (existingToast) {
|
||||
existingToast.remove();
|
||||
}
|
||||
|
||||
// 创建新的toast
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// 显示toast
|
||||
setTimeout(() => toast.classList.add('show'), 10);
|
||||
|
||||
// 3秒后自动隐藏
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadSchedules();
|
||||
|
||||
// 设置表单提交事件
|
||||
document.getElementById('scheduleForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
saveSchedule();
|
||||
});
|
||||
});
|
||||
|
||||
// 加载日程列表
|
||||
async function loadSchedules() {
|
||||
try {
|
||||
const response = await fetch('/api/schedule/list');
|
||||
if (!response.ok) {
|
||||
throw new Error('获取日程列表失败');
|
||||
}
|
||||
const data = await response.json();
|
||||
schedules = data.schedules || [];
|
||||
renderSchedules();
|
||||
} catch (error) {
|
||||
console.error('加载日程失败:', error);
|
||||
document.getElementById('scheduleContainer').innerHTML =
|
||||
'<div class="error">加载日程失败,请刷新页面重试</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染日程列表
|
||||
function renderSchedules(filteredSchedules = null) {
|
||||
const container = document.getElementById('scheduleContainer');
|
||||
const schedulesToRender = filteredSchedules || schedules;
|
||||
|
||||
if (schedulesToRender.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>暂无日程</h3>
|
||||
<p>点击"添加新日程"开始创建您的第一个日程</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = schedulesToRender.map(schedule => {
|
||||
const scheduleDate = new Date(schedule.schedule_time);
|
||||
const today = new Date();
|
||||
const isToday = scheduleDate.toDateString() === today.toDateString();
|
||||
const isCompleted = schedule.status === 'completed';
|
||||
|
||||
const repeatText = getRepeatText(schedule.repeat_rule);
|
||||
|
||||
return `
|
||||
<div class="schedule-card ${isToday ? 'today' : ''} ${isCompleted ? 'completed' : ''}"
|
||||
onclick="editSchedule(${schedule.id})">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-title">${schedule.title}</div>
|
||||
<div class="card-time">${formatDateTime(schedule.schedule_time)}</div>
|
||||
</div>
|
||||
<div class="card-status status-${schedule.status}">
|
||||
${schedule.status === 'active' ? '活跃' : '已完成'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">${schedule.content}</div>
|
||||
<div class="card-repeat">${repeatText}</div>
|
||||
<div class="card-actions" onclick="event.stopPropagation()">
|
||||
<button class="btn btn-edit" onclick="editSchedule(${schedule.id})">编辑</button>
|
||||
${schedule.status === 'active' && schedule.repeat_rule === '0000000' ?
|
||||
`<button class="btn btn-complete" onclick="completeSchedule(${schedule.id})">完成</button>` : ''
|
||||
}
|
||||
<button class="btn btn-delete" onclick="deleteSchedule(${schedule.id})">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 筛选日程
|
||||
function filterSchedules() {
|
||||
const statusFilter = document.getElementById('statusFilter').value;
|
||||
const searchInput = document.getElementById('searchInput').value.toLowerCase();
|
||||
const dateFrom = document.getElementById('dateFrom').value;
|
||||
const dateTo = document.getElementById('dateTo').value;
|
||||
|
||||
let filtered = schedules.filter(schedule => {
|
||||
// 状态筛选
|
||||
if (statusFilter !== 'all' && schedule.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 搜索筛选
|
||||
if (searchInput &&
|
||||
!schedule.title.toLowerCase().includes(searchInput) &&
|
||||
!schedule.content.toLowerCase().includes(searchInput)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 日期范围筛选
|
||||
const scheduleDate = new Date(schedule.schedule_time);
|
||||
if (dateFrom && scheduleDate < new Date(dateFrom)) {
|
||||
return false;
|
||||
}
|
||||
if (dateTo && scheduleDate > new Date(dateTo + ' 23:59:59')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
renderSchedules(filtered);
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
function showAddModal() {
|
||||
document.getElementById('modalTitle').textContent = '添加新日程';
|
||||
document.getElementById('scheduleForm').reset();
|
||||
document.getElementById('scheduleId').value = '';
|
||||
currentEditId = null;
|
||||
|
||||
// 清除重复日期选择
|
||||
document.querySelectorAll('.day-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
document.getElementById('scheduleModal').style.display = 'block';
|
||||
}
|
||||
|
||||
// 编辑日程
|
||||
function editSchedule(id) {
|
||||
const schedule = schedules.find(s => s.id === id);
|
||||
if (!schedule) return;
|
||||
|
||||
document.getElementById('modalTitle').textContent = '编辑日程';
|
||||
document.getElementById('scheduleId').value = schedule.id;
|
||||
document.getElementById('scheduleTitle').value = schedule.title;
|
||||
document.getElementById('scheduleContent').value = schedule.content;
|
||||
|
||||
// 格式化时间为datetime-local格式
|
||||
const date = new Date(schedule.schedule_time);
|
||||
const formattedDate = date.getFullYear() + '-' +
|
||||
String(date.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(date.getDate()).padStart(2, '0') + 'T' +
|
||||
String(date.getHours()).padStart(2, '0') + ':' +
|
||||
String(date.getMinutes()).padStart(2, '0');
|
||||
document.getElementById('scheduleTime').value = formattedDate;
|
||||
|
||||
// 设置重复日期
|
||||
document.querySelectorAll('.day-btn').forEach((btn, index) => {
|
||||
if (schedule.repeat_rule[index] === '1') {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
currentEditId = id;
|
||||
document.getElementById('scheduleModal').style.display = 'block';
|
||||
}
|
||||
|
||||
// 保存日程
|
||||
async function saveSchedule() {
|
||||
const id = document.getElementById('scheduleId').value;
|
||||
const title = document.getElementById('scheduleTitle').value;
|
||||
const content = document.getElementById('scheduleContent').value;
|
||||
const scheduleTime = document.getElementById('scheduleTime').value;
|
||||
|
||||
// 获取重复规则
|
||||
let repeatRule = '0000000';
|
||||
document.querySelectorAll('.day-btn').forEach((btn, index) => {
|
||||
if (btn.classList.contains('active')) {
|
||||
repeatRule = repeatRule.substring(0, index) + '1' + repeatRule.substring(index + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const scheduleData = {
|
||||
title,
|
||||
content,
|
||||
schedule_time: scheduleTime.replace('T', ' '),
|
||||
repeat_rule: repeatRule
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (id) {
|
||||
// 更新
|
||||
response = await fetch(`/api/schedule/update/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(scheduleData)
|
||||
});
|
||||
} else {
|
||||
// 创建
|
||||
response = await fetch('/api/schedule/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(scheduleData)
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('保存日程失败');
|
||||
}
|
||||
|
||||
hideModal();
|
||||
loadSchedules();
|
||||
showToast('日程保存成功');
|
||||
} catch (error) {
|
||||
console.error('保存日程失败:', error);
|
||||
alert('保存日程失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
// 完成日程(删除)
|
||||
async function completeSchedule(id) {
|
||||
if (!confirm('确定要完成此日程吗?完成后将自动删除。')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/schedule/complete/${id}`, {
|
||||
method: 'PUT'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('完成日程失败');
|
||||
}
|
||||
|
||||
loadSchedules();
|
||||
showToast('日程已完成并删除');
|
||||
} catch (error) {
|
||||
console.error('完成日程失败:', error);
|
||||
alert('操作失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
// 删除日程
|
||||
async function deleteSchedule(id) {
|
||||
if (!confirm('确定要删除此日程吗?删除后无法恢复。')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/schedule/delete/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`删除日程失败,状态码: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// 如果删除的是当前正在编辑的日程,关闭模态框
|
||||
const currentEditId = document.getElementById('scheduleId').value;
|
||||
if (currentEditId && parseInt(currentEditId) === id) {
|
||||
hideModal();
|
||||
}
|
||||
|
||||
// 重新加载日程列表
|
||||
loadSchedules();
|
||||
|
||||
// 显示成功提示
|
||||
showToast('日程已成功删除');
|
||||
} else {
|
||||
throw new Error(data.message || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除日程失败:', error);
|
||||
alert('删除失败:' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏模态框
|
||||
function hideModal() {
|
||||
document.getElementById('scheduleModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// 切换重复日期
|
||||
function toggleDay(btn) {
|
||||
btn.classList.toggle('active');
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
function formatDateTime(dateTimeStr) {
|
||||
const date = new Date(dateTimeStr);
|
||||
return date.getFullYear() + '-' +
|
||||
String(date.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(date.getDate()).padStart(2, '0') + ' ' +
|
||||
String(date.getHours()).padStart(2, '0') + ':' +
|
||||
String(date.getMinutes()).padStart(2, '0');
|
||||
}
|
||||
|
||||
// 获取重复规则文本
|
||||
function getRepeatText(repeatRule) {
|
||||
if (repeatRule === '0000000') {
|
||||
return '单次执行';
|
||||
}
|
||||
|
||||
const days = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
const repeatDays = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
if (repeatRule[i] === '1') {
|
||||
repeatDays.push(days[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (repeatDays.length === 7) {
|
||||
return '每日重复';
|
||||
} else if (repeatDays.length === 5 && !repeatRule.includes('1', 5)) {
|
||||
return '工作日重复';
|
||||
} else {
|
||||
return '重复: ' + repeatDays.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
// 点击模态框外部关闭
|
||||
window.onclick = function(event) {
|
||||
const modal = document.getElementById('scheduleModal');
|
||||
if (event.target === modal) {
|
||||
hideModal();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
mcp_servers/schedule_manager/schedules.db
Normal file
BIN
mcp_servers/schedule_manager/schedules.db
Normal file
Binary file not shown.
1209
mcp_servers/schedule_manager/server.py
Normal file
1209
mcp_servers/schedule_manager/server.py
Normal file
File diff suppressed because it is too large
Load Diff
386
mcp_servers/schedule_manager/web_server.py
Normal file
386
mcp_servers/schedule_manager/web_server.py
Normal file
@@ -0,0 +1,386 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fay日程管理Web服务器
|
||||
提供独立的网页界面和API接口
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import sqlite3
|
||||
import datetime
|
||||
|
||||
# 检查并安装依赖
|
||||
try:
|
||||
from flask import Flask, jsonify, request, send_from_directory
|
||||
from flask_cors import CORS
|
||||
except ImportError:
|
||||
print("正在安装必要的依赖包...")
|
||||
import subprocess
|
||||
try:
|
||||
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'flask', 'flask-cors'])
|
||||
from flask import Flask, jsonify, request, send_from_directory
|
||||
from flask_cors import CORS
|
||||
print("依赖包安装完成")
|
||||
except Exception as e:
|
||||
print(f"安装依赖包失败: {e}")
|
||||
print("请手动运行: pip install flask flask-cors")
|
||||
sys.exit(1)
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app) # 允许跨域请求
|
||||
|
||||
# 数据库文件路径
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), 'schedules.db')
|
||||
|
||||
class ScheduleWebAPI:
|
||||
"""日程管理Web API类"""
|
||||
|
||||
def __init__(self):
|
||||
self.init_database()
|
||||
|
||||
def init_database(self):
|
||||
"""初始化数据库"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS schedules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
schedule_time TEXT NOT NULL,
|
||||
repeat_rule TEXT NOT NULL DEFAULT '0000000',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
uid INTEGER DEFAULT 0
|
||||
)
|
||||
''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_schedules(self, status='active', uid=None):
|
||||
"""获取日程列表"""
|
||||
try:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
if uid is not None:
|
||||
if status == 'all':
|
||||
# 'all' 表示显示所有未删除的日程(active + completed)
|
||||
cursor.execute("SELECT * FROM schedules WHERE status != 'deleted' AND uid = ? ORDER BY schedule_time", (uid,))
|
||||
else:
|
||||
cursor.execute("SELECT * FROM schedules WHERE status = ? AND uid = ? ORDER BY schedule_time", (status, uid))
|
||||
else:
|
||||
if status == 'all':
|
||||
# 'all' 表示显示所有未删除的日程(active + completed)
|
||||
cursor.execute("SELECT * FROM schedules WHERE status != 'deleted' ORDER BY schedule_time")
|
||||
else:
|
||||
cursor.execute("SELECT * FROM schedules WHERE status = ? ORDER BY schedule_time", (status,))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
schedules = []
|
||||
for row in rows:
|
||||
schedules.append({
|
||||
"id": row[0],
|
||||
"title": row[1],
|
||||
"content": row[2],
|
||||
"schedule_time": row[3],
|
||||
"repeat_rule": row[4],
|
||||
"status": row[5],
|
||||
"created_at": row[6],
|
||||
"updated_at": row[7],
|
||||
"uid": row[8]
|
||||
})
|
||||
|
||||
return {"success": True, "schedules": schedules}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"获取日程列表失败: {str(e)}"}
|
||||
|
||||
def add_schedule(self, title, content, schedule_time, repeat_rule='0000000', uid=0):
|
||||
"""添加日程"""
|
||||
try:
|
||||
# 验证时间格式
|
||||
try:
|
||||
datetime.datetime.strptime(schedule_time, '%Y-%m-%d %H:%M')
|
||||
except ValueError:
|
||||
return {"success": False, "message": "时间格式错误,请使用 YYYY-MM-DD HH:MM 格式"}
|
||||
|
||||
# 验证重复规则
|
||||
if not all(c in '01' for c in repeat_rule) or len(repeat_rule) != 7:
|
||||
return {"success": False, "message": "重复规则格式错误"}
|
||||
|
||||
now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO schedules (title, content, schedule_time, repeat_rule, created_at, updated_at, uid) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(title, content, schedule_time, repeat_rule, now, now, uid)
|
||||
)
|
||||
schedule_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {"success": True, "message": "日程添加成功", "schedule_id": schedule_id}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"添加日程失败: {str(e)}"}
|
||||
|
||||
def update_schedule(self, schedule_id, title=None, content=None, schedule_time=None, repeat_rule=None, status=None):
|
||||
"""更新日程"""
|
||||
try:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 先获取当前日程信息
|
||||
cursor.execute("SELECT status, repeat_rule FROM schedules WHERE id = ?", (schedule_id,))
|
||||
current_schedule = cursor.fetchone()
|
||||
if not current_schedule:
|
||||
conn.close()
|
||||
return {"success": False, "message": "日程不存在"}
|
||||
|
||||
current_status, current_repeat_rule = current_schedule
|
||||
|
||||
# 构建更新语句
|
||||
updates = []
|
||||
params = []
|
||||
auto_activate = False # 是否需要自动激活
|
||||
|
||||
if title is not None:
|
||||
updates.append("title = ?")
|
||||
params.append(title)
|
||||
if content is not None:
|
||||
updates.append("content = ?")
|
||||
params.append(content)
|
||||
if schedule_time is not None:
|
||||
# 验证时间格式
|
||||
try:
|
||||
new_time = datetime.datetime.strptime(schedule_time, '%Y-%m-%d %H:%M')
|
||||
updates.append("schedule_time = ?")
|
||||
params.append(schedule_time)
|
||||
|
||||
# 如果更新了时间,并且当前状态是completed,检查是否需要重新激活
|
||||
if current_status == 'completed':
|
||||
now = datetime.datetime.now()
|
||||
# 如果新时间在未来,或者是重复任务,自动激活
|
||||
if new_time > now or (repeat_rule or current_repeat_rule) != '0000000':
|
||||
auto_activate = True
|
||||
print(f"[Web] 日程 ID {schedule_id} 时间更新,自动从completed恢复为active")
|
||||
|
||||
except ValueError:
|
||||
return {"success": False, "message": "时间格式错误"}
|
||||
if repeat_rule is not None:
|
||||
if not all(c in '01' for c in repeat_rule) or len(repeat_rule) != 7:
|
||||
return {"success": False, "message": "重复规则格式错误"}
|
||||
updates.append("repeat_rule = ?")
|
||||
params.append(repeat_rule)
|
||||
|
||||
# 如果从单次改为重复,并且当前是completed,自动激活
|
||||
if current_status == 'completed' and repeat_rule != '0000000':
|
||||
auto_activate = True
|
||||
print(f"[Web] 日程 ID {schedule_id} 改为重复任务,自动从completed恢复为active")
|
||||
|
||||
# 处理状态更新
|
||||
if status is not None:
|
||||
updates.append("status = ?")
|
||||
params.append(status)
|
||||
elif auto_activate:
|
||||
# 如果需要自动激活,并且没有明确指定状态,则设置为active
|
||||
updates.append("status = ?")
|
||||
params.append('active')
|
||||
print(f"[Web] 日程 ID {schedule_id} 状态设置为active")
|
||||
|
||||
if not updates:
|
||||
return {"success": False, "message": "没有提供要更新的字段"}
|
||||
|
||||
updates.append("updated_at = ?")
|
||||
params.append(datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
|
||||
params.append(schedule_id)
|
||||
|
||||
cursor.execute(f"UPDATE schedules SET {', '.join(updates)} WHERE id = ?", params)
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
conn.close()
|
||||
return {"success": False, "message": "日程不存在"}
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 如果有更新时间或状态,通知调度器重新调度(通过创建触发文件)
|
||||
if schedule_time is not None or repeat_rule is not None or status is not None or auto_activate:
|
||||
# 创建一个触发文件,让调度器知道需要重新检查
|
||||
trigger_file = os.path.join(os.path.dirname(__file__), f'.reschedule_{schedule_id}')
|
||||
try:
|
||||
with open(trigger_file, 'w') as f:
|
||||
f.write(str(datetime.datetime.now()))
|
||||
print(f"[Web] 创建重新调度触发文件: {trigger_file}")
|
||||
except:
|
||||
pass
|
||||
|
||||
conn.close()
|
||||
|
||||
if auto_activate:
|
||||
return {"success": True, "message": "日程更新成功,状态已自动恢复为活跃"}
|
||||
else:
|
||||
return {"success": True, "message": "日程更新成功"}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"更新日程失败: {str(e)}"}
|
||||
|
||||
def delete_schedule(self, schedule_id):
|
||||
"""删除日程(软删除)"""
|
||||
return self.update_schedule(schedule_id, status='deleted')
|
||||
|
||||
# 创建API实例
|
||||
schedule_api = ScheduleWebAPI()
|
||||
|
||||
# Web路由
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""主页,返回日程管理网页"""
|
||||
return send_from_directory(os.path.dirname(__file__), 'schedule_web.html')
|
||||
|
||||
@app.route('/api/schedule/list')
|
||||
def api_get_schedules():
|
||||
"""获取日程列表API"""
|
||||
status = request.args.get('status', 'all')
|
||||
uid = request.args.get('uid', type=int)
|
||||
result = schedule_api.get_schedules(status, uid)
|
||||
return jsonify(result)
|
||||
|
||||
@app.route('/api/schedule/create', methods=['POST'])
|
||||
def api_create_schedule():
|
||||
"""创建日程API"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"success": False, "message": "无效的请求数据"})
|
||||
|
||||
title = data.get('title')
|
||||
content = data.get('content', '')
|
||||
schedule_time = data.get('schedule_time')
|
||||
repeat_rule = data.get('repeat_rule', '0000000')
|
||||
uid = data.get('uid', 0)
|
||||
|
||||
if not title or not schedule_time:
|
||||
return jsonify({"success": False, "message": "标题和时间为必填项"})
|
||||
|
||||
result = schedule_api.add_schedule(title, content, schedule_time, repeat_rule, uid)
|
||||
return jsonify(result)
|
||||
|
||||
@app.route('/api/schedule/update/<int:schedule_id>', methods=['PUT'])
|
||||
def api_update_schedule(schedule_id):
|
||||
"""更新日程API"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"success": False, "message": "无效的请求数据"})
|
||||
|
||||
result = schedule_api.update_schedule(
|
||||
schedule_id,
|
||||
title=data.get('title'),
|
||||
content=data.get('content'),
|
||||
schedule_time=data.get('schedule_time'),
|
||||
repeat_rule=data.get('repeat_rule'),
|
||||
status=data.get('status')
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
@app.route('/api/schedule/complete/<int:schedule_id>', methods=['PUT'])
|
||||
def api_complete_schedule(schedule_id):
|
||||
"""完成日程API - 直接删除"""
|
||||
result = schedule_api.delete_schedule(schedule_id)
|
||||
return jsonify(result)
|
||||
|
||||
@app.route('/api/schedule/delete/<int:schedule_id>', methods=['DELETE'])
|
||||
def api_delete_schedule(schedule_id):
|
||||
"""删除日程API"""
|
||||
result = schedule_api.delete_schedule(schedule_id)
|
||||
return jsonify(result)
|
||||
|
||||
@app.route('/api/health')
|
||||
def api_health():
|
||||
"""健康检查API"""
|
||||
return jsonify({"status": "ok", "message": "Fay日程管理服务运行正常"})
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
import signal
|
||||
import atexit
|
||||
import threading
|
||||
import time
|
||||
|
||||
try:
|
||||
import psutil
|
||||
HAS_PSUTIL = True
|
||||
except ImportError:
|
||||
HAS_PSUTIL = False
|
||||
print("警告: psutil未安装,无法监控父进程")
|
||||
|
||||
# 获取父进程ID
|
||||
parent_pid = os.getppid()
|
||||
|
||||
def cleanup():
|
||||
print("Web服务器正在关闭...")
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
print(f"收到信号 {sig},正在关闭Web服务器...")
|
||||
cleanup()
|
||||
sys.exit(0)
|
||||
|
||||
def monitor_parent():
|
||||
"""监控父进程,如果父进程不存在则退出"""
|
||||
if not HAS_PSUTIL:
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
# 检查父进程是否还存在
|
||||
if not psutil.pid_exists(parent_pid):
|
||||
print(f"父进程 {parent_pid} 已退出,关闭Web服务器...")
|
||||
cleanup()
|
||||
os._exit(0) # 强制退出
|
||||
time.sleep(5) # 每5秒检查一次
|
||||
except Exception as e:
|
||||
print(f"监控父进程时出错: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
# 注册清理和信号处理
|
||||
atexit.register(cleanup)
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# 启动父进程监控线程
|
||||
if HAS_PSUTIL:
|
||||
try:
|
||||
monitor_thread = threading.Thread(target=monitor_parent, daemon=True)
|
||||
monitor_thread.start()
|
||||
print(f"已启动父进程监控 (父进程PID: {parent_pid})")
|
||||
except Exception as e:
|
||||
print(f"启动父进程监控失败: {e}")
|
||||
else:
|
||||
print("跳过父进程监控(psutil不可用)")
|
||||
|
||||
parser = argparse.ArgumentParser(description='Fay日程管理Web服务器')
|
||||
parser.add_argument('--host', default='127.0.0.1', help='服务器地址')
|
||||
parser.add_argument('--port', default=5011, type=int, help='服务器端口')
|
||||
parser.add_argument('--debug', action='store_true', help='调试模式')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"启动Fay日程管理Web服务器...")
|
||||
print(f"访问地址: http://{args.host}:{args.port}")
|
||||
print(f"API文档: http://{args.host}:{args.port}/api/health")
|
||||
|
||||
try:
|
||||
app.run(host=args.host, port=args.port, debug=args.debug, use_reloader=False)
|
||||
except KeyboardInterrupt:
|
||||
print("Web服务器已停止")
|
||||
except Exception as e:
|
||||
print(f"Web服务器启动失败: {e}")
|
||||
sys.exit(1)
|
||||
@@ -38,6 +38,29 @@ def remove_thread(thread: MyThread):
|
||||
|
||||
|
||||
def stopAll():
|
||||
for thread in __thread_list:
|
||||
thread.raise_exception()
|
||||
thread.join()
|
||||
"""停止所有MyThread线程"""
|
||||
if not __thread_list:
|
||||
return
|
||||
|
||||
# 先尝试正常停止
|
||||
stopped_threads = []
|
||||
for thread in __thread_list[:]: # 使用副本避免修改时出错
|
||||
try:
|
||||
if thread.is_alive():
|
||||
thread.raise_exception()
|
||||
stopped_threads.append(thread)
|
||||
except Exception as e:
|
||||
print(f"停止线程异常: {e}")
|
||||
|
||||
# 等待线程结束,但设置超时避免无限等待
|
||||
import time
|
||||
for thread in stopped_threads:
|
||||
try:
|
||||
thread.join(timeout=2.0) # 最多等待2秒
|
||||
if thread.is_alive():
|
||||
print(f"线程 {thread.name} 超时未结束")
|
||||
except Exception as e:
|
||||
print(f"等待线程结束异常: {e}")
|
||||
|
||||
# 清空线程列表
|
||||
__thread_list.clear()
|
||||
|
||||
156
test/mcp_stdio_example.py
Normal file
156
test/mcp_stdio_example.py
Normal file
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
本文件是一个最小可用的 MCP STDIO(本地)服务器示例。
|
||||
|
||||
运行:
|
||||
python test/mcp_stdio_example.py
|
||||
|
||||
在 UI 中添加本地 MCP 服务器时:
|
||||
transport: stdio
|
||||
command: python
|
||||
args: ["test/mcp_stdio_example.py"]
|
||||
cwd: 项目根目录 (可选)
|
||||
|
||||
提供的工具:
|
||||
- ping() -> "pong"
|
||||
- echo(text: str)
|
||||
- upper(text: str)
|
||||
- add(a: int, b: int)
|
||||
- now(fmt: str = "%Y-%m-%d %H:%M:%S") -> 当前时间格式化
|
||||
|
||||
注意: 返回内容遵循 MCP 协议,使用 TextContent 包装文本。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
# 核心 MCP 服务器 API(低层)
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp import types
|
||||
except Exception as e:
|
||||
print("[mcp_stdio_example] 请先安装 mcp 包: pip install mcp", file=sys.stderr)
|
||||
raise
|
||||
|
||||
|
||||
server = Server("Fay STDIO Example")
|
||||
|
||||
# --- 定义工具清单(低层 API 需要手动注册 list_tools 和 call_tool)---
|
||||
TOOLS: list[types.Tool] = [
|
||||
types.Tool(
|
||||
name="ping",
|
||||
description="连通性检查:若提供 host 则执行系统 ping,否则返回 pong",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {"host": {"type": "string"}},
|
||||
"required": []
|
||||
}
|
||||
),
|
||||
types.Tool(
|
||||
name="echo",
|
||||
description="返回相同文本",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"]
|
||||
}
|
||||
),
|
||||
types.Tool(
|
||||
name="upper",
|
||||
description="将文本转为大写",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"]
|
||||
}
|
||||
),
|
||||
types.Tool(
|
||||
name="add",
|
||||
description="两个整数求和",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
|
||||
"required": ["a", "b"]
|
||||
}
|
||||
),
|
||||
types.Tool(
|
||||
name="now",
|
||||
description="返回当前时间,默认格式为 %Y-%m-%d %H:%M:%S,可通过 fmt 指定",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {"fmt": {"type": "string"}},
|
||||
"required": []
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools() -> list[types.Tool]:
|
||||
return TOOLS
|
||||
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
||||
try:
|
||||
match name:
|
||||
case "ping":
|
||||
host = (arguments or {}).get("host")
|
||||
if host:
|
||||
# 在不同平台调用系统 ping,避免 Windows 下 shlex.quote 与 shell=True 的兼容问题
|
||||
import platform, subprocess
|
||||
is_win = platform.system().lower().startswith("win")
|
||||
cmd = ["ping", ("-n" if is_win else "-c"), "2", str(host)]
|
||||
try:
|
||||
out = subprocess.run(cmd, shell=False, capture_output=True, text=True, timeout=8)
|
||||
ok = (out.returncode == 0)
|
||||
stdout = out.stdout or ""
|
||||
# 截取最后几行摘要
|
||||
lines = [line for line in stdout.strip().splitlines() if line.strip()]
|
||||
summary = lines[-6:] if lines else []
|
||||
text = ("SUCCESS\n" if ok else "FAIL\n") + "\n".join(summary)
|
||||
except Exception as e:
|
||||
text = f"执行 ping 出错: {type(e).__name__}: {e}"
|
||||
return [types.TextContent(type="text", text=text)]
|
||||
else:
|
||||
return [types.TextContent(type="text", text="pong")]
|
||||
case "echo":
|
||||
return [types.TextContent(type="text", text=str(arguments.get("text", "")))]
|
||||
case "upper":
|
||||
return [types.TextContent(type="text", text=str(arguments.get("text", "")).upper())]
|
||||
case "add":
|
||||
a = int(arguments.get("a", 0))
|
||||
b = int(arguments.get("b", 0))
|
||||
return [types.TextContent(type="text", text=str(a + b))]
|
||||
case "now":
|
||||
fmt = arguments.get("fmt") or "%Y-%m-%d %H:%M:%S"
|
||||
try:
|
||||
txt = datetime.now().strftime(fmt)
|
||||
except Exception:
|
||||
txt = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
return [types.TextContent(type="text", text=txt)]
|
||||
case _:
|
||||
return [types.TextContent(type="text", text=f"未知工具: {name}")]
|
||||
except Exception as e:
|
||||
# 返回错误信息(isError将由框架根据异常处理)
|
||||
return [types.TextContent(type="text", text=f"调用异常: {e}")]
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# 通过 STDIO 暴露 MCP Server
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
# 显式开启 tools 能力,避免部分版本下未注册导致 list_tools/call_tool 问题
|
||||
init_opts = server.create_initialization_options()
|
||||
await server.run(read_stream, write_stream, init_opts)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
@@ -10,7 +10,7 @@ def test_gpt(prompt):
|
||||
data = {
|
||||
'model': 'fay-streaming',
|
||||
'messages': [
|
||||
{'role': 'user_device_32_6', 'content': prompt}
|
||||
{'role': 'User', 'content': prompt}
|
||||
],
|
||||
'stream': True # 启用流式传输
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user