commit 9a2e10392c290bd38a5615f23b19b781fd9e7358 Author: dvs-dvsxt Date: Fri Aug 28 11:44:49 2026 +0800 Initial commit: MCP Agent Tools diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..556924b --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.venv/ +venv/ +env/ +.env + +# Data & runtime +*.db +*.sqlite3 +data/ +essays_data/ +knowledge_* + +# System files +Thumbs.db +.DS_Store +desktop.ini diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fddd2dd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DVS (dvs-dvsxt) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Main Agent Tools.py b/Main Agent Tools.py new file mode 100644 index 0000000..8c9db30 --- /dev/null +++ b/Main Agent Tools.py @@ -0,0 +1,1577 @@ +import os +import sys +import subprocess +import asyncio +from pathlib import Path +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from typing import Optional, Dict +import json +import platform +import re +from datetime import datetime +import tempfile +import uuid +import shlex +import threading +import time +import paramiko +import base64 +import secrets + +# ========== 禁用 __pycache__ ========== +os.environ['PYTHONDONTWRITEBYTECODE'] = '1' +sys.dont_write_bytecode = True + +# ========== 命令行确认/输入支持 ========== +def is_windows(): + return platform.system() == "Windows" + +MB_YESNO = 0x04 +MB_YESNOCANCEL = 0x03 +MB_OKCANCEL = 0x01 +MB_OK = 0x00 +MB_ICONQUESTION = 0x20 +MB_ICONWARNING = 0x30 +MB_ICONINFORMATION = 0x40 +MB_ICONERROR = 0x10 + +IDYES = 6 +IDNO = 7 +IDCANCEL = 2 +IDOK = 1 + +def show_confirm_dialog(title: str, message: str, buttons: int = MB_YESNO, icon: int = MB_ICONQUESTION) -> int: + """命令行确认(纯 input,不使用 GUI 弹窗)""" + print(f"\n{'='*50}") + print(f"🔔 {title}") + print(f"{'='*50}") + print(f"📝 {message}") + print(f"{'='*50}") + + if buttons == MB_YESNO: + print("请选择: [y] 是 [n] 否") + while True: + answer = input("> ").strip().lower() + if answer in ['y', 'yes']: + return IDYES + elif answer in ['n', 'no']: + return IDNO + else: + print("请输入 y 或 n") + elif buttons == MB_YESNOCANCEL: + print("请选择: [y] 是 [n] 否 [c] 取消") + while True: + answer = input("> ").strip().lower() + if answer in ['y', 'yes']: + return IDYES + elif answer in ['n', 'no']: + return IDNO + elif answer in ['c', 'cancel']: + return IDCANCEL + else: + print("请输入 y、n 或 c") + elif buttons == MB_OKCANCEL: + print("请选择: [o] 确定 [c] 取消") + while True: + answer = input("> ").strip().lower() + if answer in ['o', 'ok', '确定']: + return IDOK + elif answer in ['c', 'cancel']: + return IDCANCEL + else: + print("请输入 o 或 c") + else: + print("按 Enter 继续...") + input() + return IDOK + + return IDNO + +def show_input_dialog(title: str, message: str, default: str = "") -> Optional[str]: + print(f"\n{'='*50}") + print(f"📝 {title}") + print(f"{'='*50}") + print(f"💬 {message}") + if default: + print(f"📌 默认值: {default}") + result = input("请输入: ").strip() + return result if result else default + + +def input_text(title: str, message: str, default: str = "") -> Optional[str]: + """命令行输入(纯 input,不使用 GUI 弹窗)""" + return show_input_dialog(title, message, default) + +# ========== 配置 ========== +print("=" * 60) +print("🚀 MCP CMD + SSH 服务器启动 (v3.0.0 - 严格模式)") +print("=" * 60) + +def get_initial_workspace(): + workspace_input = input("📂 请输入工作区目录路径: ").strip() + return workspace_input + +workspace = get_initial_workspace() +workspace = os.path.abspath(workspace) + +if not os.path.exists(workspace): + print(f"❌ 错误:目录 '{workspace}' 不存在!") + if is_windows(): + show_confirm_dialog("错误", f"目录 '{workspace}' 不存在!", buttons=MB_OK, icon=MB_ICONERROR) + exit(1) + +if not os.path.isdir(workspace): + print(f"❌ 错误:'{workspace}' 不是目录!") + if is_windows(): + show_confirm_dialog("错误", f"'{workspace}' 不是目录!", buttons=MB_OK, icon=MB_ICONERROR) + exit(1) + +temp_dir = os.path.join(workspace, "temp") +os.makedirs(temp_dir, exist_ok=True) +print(f"✅ 临时目录:{temp_dir}") +print(f"✅ 工作区已设置为:{workspace}") +print(f"🖥️ 操作系统:{platform.system()}") +print("=" * 60) + +# ========== FastAPI 应用 ========== +app = FastAPI(title="MCP CMD + SSH Server", version="3.0.0") + +# ========== 全局变量 ========== +current_workspace = workspace +current_temp_dir = temp_dir + +# ========== 🔐 SSH 会话管理 ========== +class SSHConnectionPool: + def __init__(self): + self.sessions: Dict[str, dict] = {} + self.tokens: Dict[str, str] = {} + self.session_counter = 0 + + def create_session(self, host: str, username: str, password: str = None, + key_file: str = None, port: int = 22) -> tuple: + try: + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + if key_file: + client.connect( + host, port=port, username=username, + key_filename=key_file, + timeout=15, + allow_agent=False, + look_for_keys=False + ) + else: + if not password: + return None, None, False, "❌ 密码或密钥文件必须提供其一" + client.connect( + host, port=port, username=username, + password=password, + timeout=15, + allow_agent=False, + look_for_keys=False + ) + + channel = client.invoke_shell( + term='xterm', + width=200, + height=50 + ) + + time.sleep(1) + output = "" + while channel.recv_ready(): + output += channel.recv(1024).decode('utf-8', errors='ignore') + + self.session_counter += 1 + session_id = f"ssh_{self.session_counter}_{int(time.time())}" + token = secrets.token_urlsafe(32) + + self.sessions[token] = { + 'id': session_id, + 'client': client, + 'channel': channel, + 'host': host, + 'username': username, + 'port': port, + 'created_at': time.time(), + 'last_used': time.time(), + 'buffer': output, + 'is_alive': True + } + self.tokens[token] = session_id + + print(f"✅ SSH会话创建: {session_id} -> {username}@{host}:{port}") + + return token, session_id, True, f"✅ SSH连接成功!会话ID: {session_id}" + + except Exception as e: + print(f"❌ SSH连接失败: {e}") + return None, None, False, f"❌ SSH连接失败: {str(e)}" + + def get_session(self, token: str) -> Optional[dict]: + if token not in self.sessions: + return None + + session = self.sessions[token] + transport = session['client'].get_transport() + if not session['is_alive'] or transport is None or not transport.is_active(): + self.close_session(token) + return None + + session['last_used'] = time.time() + return session + + def execute_command(self, token: str, command: str, timeout: int = 30) -> dict: + session = self.get_session(token) + if not session: + return { + 'success': False, + 'output': '', + 'error': '❌ SSH会话已过期或不存在,请重新连接', + 'session_expired': True + } + + try: + channel = session['channel'] + + # 清空缓冲区 + self._flush_buffer(channel) + + # 发送命令 + channel.send(command + "\n") + time.sleep(0.2) + + output = "" + start_time = time.time() + + while time.time() - start_time < timeout: + if channel.recv_ready(): + data = channel.recv(65535).decode('utf-8', errors='ignore') + output += data + + if re.search(r'[$#>]', output[-30:]): + break + else: + time.sleep(0.05) + + session['buffer'] = output + + return { + 'success': True, + 'output': output, + 'error': '', + 'session_id': session['id'] + } + + except Exception as e: + print(f"❌ SSH命令执行失败: {e}") + return { + 'success': False, + 'output': '', + 'error': f'❌ 命令执行失败: {str(e)}', + 'session_expired': False + } + + def _flush_buffer(self, channel): + while channel.recv_ready(): + channel.recv(1024) + + def close_session(self, token: str) -> bool: + if token in self.sessions: + session = self.sessions[token] + try: + session['client'].close() + except: + pass + session['is_alive'] = False + del self.sessions[token] + + if token in self.tokens: + del self.tokens[token] + + print(f"🗑️ SSH会话已关闭: {token[:16]}...") + return True + return False + + def list_sessions(self) -> list: + result = [] + for token, session in self.sessions.items(): + transport = session['client'].get_transport() + if session['is_alive'] and transport is not None and transport.is_active(): + result.append({ + 'token': token[:16] + '...', + 'session_id': session['id'], + 'host': session['host'], + 'username': session['username'], + 'created_at': datetime.fromtimestamp(session['created_at']).strftime('%Y-%m-%d %H:%M:%S'), + 'last_used': datetime.fromtimestamp(session['last_used']).strftime('%Y-%m-%d %H:%M:%S') + }) + return result + + def cleanup_expired(self, max_age: int = 3600): + now = time.time() + to_remove = [] + for token, session in self.sessions.items(): + if now - session['last_used'] > max_age: + to_remove.append(token) + + for token in to_remove: + self.close_session(token) + + return len(to_remove) + +# ========== 全局SSH连接池 ========== +ssh_pool = SSHConnectionPool() + +# ========== 数据模型 ========== +class SSHConnectRequest(BaseModel): + host: str + username: str + port: int = 22 + +class SSHExecuteRequest(BaseModel): + token: str + command: str + purpose: str # 必须50-100字说明用途 + timeout: int = 60 + +# ========== 安全函数 ========== +def ask_user_permission_ui(title: str, message: str, purpose: str = "") -> bool: + """命令行确认(不用 GUI 弹窗),输入 y 确认 / n 拒绝""" + full_message = message + if purpose: + full_message += f"\n\n📝 用途说明:\n{purpose}\n" + + print(f"\n{'='*55}") + print(f"🔐 {title}") + print(f"{'='*55}") + print(full_message) + print(f"{'='*55}") + while True: + answer = input("> 确认请输 y,拒绝输 n [y/n]: ").strip().lower() + if answer in ('y', 'yes', '是'): + return True + elif answer in ('n', 'no', '否'): + return False + else: + print("请输入 y 或 n") + +def is_path_in_workspace(target_path: str) -> bool: + global current_workspace + try: + target_abs = os.path.abspath(target_path) + workspace_abs = os.path.abspath(current_workspace) + return os.path.commonpath([target_abs, workspace_abs]) == workspace_abs + except ValueError: + return False + +def get_safe_cwd(request_cwd: Optional[str]) -> str: + global current_workspace + + if not request_cwd: + return current_workspace + + request_cwd = os.path.abspath(request_cwd) + + if is_path_in_workspace(request_cwd): + return request_cwd + + if ask_user_permission_ui("目录访问确认", f"命令试图在工作区外的目录执行:\n\n📂 工作区:{current_workspace}\n🎯 目标目录:{request_cwd}\n\n是否允许?"): + return request_cwd + else: + print(f" ❌ 用户拒绝,使用工作区目录:{current_workspace}") + return current_workspace + +# ========== 命令执行函数 ========== +def fix_command_quoting(command: str) -> tuple: + is_windows_os = platform.system() == "Windows" + if not is_windows_os: + return command, False, "非 Windows 系统,无需修复" + + original = command + fixed = command + modifications = [] + + if 'python -c' in command or 'python -c "' in command: + if '\n' in command and 'python -c' in command: + match = re.search(r'python\s+-c\s+(["\'])(.*?)\1', command, re.DOTALL) + if match: + quote_char = match.group(1) + code_content = match.group(2) + + if '\n' in code_content: + lines = [line.strip() for line in code_content.split('\n') if line.strip()] + code_lines = [] + for line in lines: + if line.startswith('#'): + continue + if '#' in line and not (line.count("'") >= 2 or line.count('"') >= 2): + parts = line.split('#') + if len(parts) > 1 and not (parts[0].count("'") % 2 == 1 or parts[0].count('"') % 2 == 1): + line = parts[0].strip() + if line: + code_lines.append(line) + + if code_lines: + single_line = '; '.join(code_lines) + if quote_char == '"': + fixed_code = single_line.replace('"', '\\"') + else: + fixed_code = single_line + + fixed = command.replace(match.group(0), f'python -c {quote_char}{fixed_code}{quote_char}') + modifications.append(f"将多行代码压缩为单行({len(lines)}行 -> 1行)") + + if 'python -c' in fixed: + match = re.search(r'python\s+-c\s+(["\'])(.*?)\1', fixed, re.DOTALL) + if match: + outer_quote = match.group(1) + inner_content = match.group(2) + + if outer_quote == '"' and '"' in inner_content and '\\"' not in inner_content: + inner_content = inner_content.replace('"', '\\"') + fixed = fixed.replace(match.group(0), f'python -c "{inner_content}"') + modifications.append("转义内部双引号") + + was_modified = original != fixed + fix_message = "; ".join(modifications) if modifications else "无需修复" + + return fixed, was_modified, fix_message + +def execute_command_safe(command: str, cwd: str, timeout: int = 60) -> dict: + try: + is_windows_os = platform.system() == "Windows" + + fixed_command, was_modified, fix_message = fix_command_quoting(command) + + if was_modified: + print(f" 🔧 自动修复命令引号问题") + print(f" 📝 修复说明:{fix_message}") + + env = os.environ.copy() + env['PYTHONIOENCODING'] = 'utf-8' + env['PYTHONUTF8'] = '1' + + if is_windows_os: + shell_command = f'cmd /c "chcp 65001 >nul && set PYTHONIOENCODING=utf-8 && {fixed_command}"' + else: + shell_command = fixed_command + + process = subprocess.Popen( + shell_command, + cwd=cwd, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding='utf-8', + errors='replace', + bufsize=1, + universal_newlines=True, + env=env + ) + + try: + stdout, stderr = process.communicate(timeout=timeout) + return_code = process.returncode + except subprocess.TimeoutExpired: + process.kill() + stdout, stderr = process.communicate() + return { + "success": False, + "output": stdout or "", + "error": f"⏰ 命令执行超时({timeout}秒)", + "return_code": -1, + "cwd": cwd, + "command": command + } + + output = stdout or "" + error = stderr or "" + success = return_code == 0 + + if not success and "Argument expected for the -c option" in error: + return { + "success": False, + "output": output, + "error": error, + "return_code": return_code, + "cwd": cwd, + "command": command, + "suggestion": "💡 建议使用 python_runner 工具" + } + + if not output and error: + if "warning" not in error.lower() and "error" not in error.lower(): + output = error + error = "" + + return { + "success": success, + "output": output, + "error": error, + "return_code": return_code, + "cwd": cwd, + "command": command + } + + except Exception as e: + return { + "success": False, + "output": "", + "error": f"❌ 执行失败:{str(e)}", + "return_code": -1, + "cwd": cwd, + "command": command + } + +def create_and_run_python(code: str, timeout: int = 60, cwd: Optional[str] = None) -> dict: + global current_temp_dir, current_workspace + + try: + file_id = uuid.uuid4().hex[:8] + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"temp_{timestamp}_{file_id}.py" + filepath = os.path.join(current_temp_dir, filename) + + with open(filepath, 'w', encoding='utf-8') as f: + f.write(code) + + exec_cwd = cwd or current_workspace + python_cmd = f'python "{filepath}"' + + result = execute_command_safe(python_cmd, exec_cwd, timeout) + result['file_created'] = filepath + result['file_name'] = filename + + return result + + except Exception as e: + return { + "success": False, + "output": "", + "error": f"❌ 创建或执行失败:{str(e)}", + "return_code": -1, + "cwd": cwd or current_workspace, + "command": code[:100] + "..." + } + +def switch_workspace(new_workspace: str) -> dict: + global current_workspace, current_temp_dir + + new_workspace = os.path.abspath(new_workspace) + + if not os.path.exists(new_workspace): + return {"success": False, "error": f"目录 '{new_workspace}' 不存在!"} + + if not os.path.isdir(new_workspace): + return {"success": False, "error": f"'{new_workspace}' 不是目录!"} + + old_workspace = current_workspace + + message = f"确认切换工作区?\n\n📂 当前工作区:{old_workspace}\n📂 新工作区:{new_workspace}" + + result = show_confirm_dialog("🔄 切换工作区", message, buttons=MB_YESNO, icon=MB_ICONQUESTION) + + if result != IDYES: + return {"success": False, "error": "用户取消切换", "current_workspace": current_workspace} + + current_workspace = new_workspace + current_temp_dir = os.path.join(current_workspace, "temp") + os.makedirs(current_temp_dir, exist_ok=True) + + return { + "success": True, + "old_workspace": old_workspace, + "new_workspace": current_workspace, + "temp_dir": current_temp_dir, + "message": f"✅ 成功切换到 {current_workspace}" + } + +# ========== MCP 核心端点 ========== +@app.post("/mcp") +async def mcp_handler(request: Request): + global current_workspace, current_temp_dir + + try: + body = await request.json() + method = body.get("method") + params = body.get("params", {}) + request_id = body.get("id") + + if method == "initialize": + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "protocolVersion": "1.0.0", + "serverInfo": { + "name": "mcp-cmd-ssh-server", + "version": "3.0.0" + }, + "capabilities": {"tools": {}} + } + } + + elif method == "tools/list": + tools_response = { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "tools": [ + { + "name": "command_executor", + "description": f"执行系统命令。默认在工作区 '{current_workspace}' 执行。", + "inputSchema": { + "type": "object", + "properties": { + "command": {"type": "string", "description": "要执行的命令"}, + "cwd": {"type": "string", "description": f"可选,执行目录。默认工作区 '{current_workspace}'"}, + "timeout": {"type": "integer", "description": "可选,超时时间(秒),默认 60", "default": 60} + }, + "required": ["command"] + } + }, + { + "name": "python_runner", + "description": "创建临时 Python 文件并执行。", + "inputSchema": { + "type": "object", + "properties": { + "code": {"type": "string", "description": "Python 代码内容"}, + "cwd": {"type": "string", "description": f"可选,执行目录。默认工作区 '{current_workspace}'"}, + "timeout": {"type": "integer", "description": "可选,超时时间(秒),默认 60", "default": 60} + }, + "required": ["code"] + } + }, + { + "name": "ssh_connect", + "description": "🔐 发起SSH连接请求。用户需手动输入密码,成功返回token。", + "inputSchema": { + "type": "object", + "properties": { + "host": {"type": "string", "description": "SSH服务器地址"}, + "username": {"type": "string", "description": "SSH用户名"}, + "port": {"type": "integer", "description": "SSH端口,默认22", "default": 22} + }, + "required": ["host", "username"] + } + }, + { + "name": "ssh_execute", + "description": "🔐 在SSH会话中执行命令。⚠️ 必须提供50-100字的用途说明,每次执行都需要用户命令行确认。", + "inputSchema": { + "type": "object", + "properties": { + "token": {"type": "string", "description": "SSH连接返回的token"}, + "command": {"type": "string", "description": "要执行的命令"}, + "purpose": {"type": "string", "description": "⚠️ 用途说明(必须50-100字),详细说明为什么执行此命令、预期效果、涉及的操作"}, + "timeout": {"type": "integer", "description": "可选,超时时间(秒),默认60", "default": 60} + }, + "required": ["token", "command", "purpose"] + } + }, + { + "name": "ssh_close", + "description": "🔐 关闭SSH会话", + "inputSchema": { + "type": "object", + "properties": { + "token": {"type": "string", "description": "SSH连接返回的token"} + }, + "required": ["token"] + } + }, + { + "name": "ssh_list_sessions", + "description": "🔐 列出所有活跃的SSH会话", + "inputSchema": {"type": "object", "properties": {}} + }, + { + "name": "file_reader", + "description": "读取工作区内文件的内容", + "inputSchema": { + "type": "object", + "properties": { + "filepath": {"type": "string", "description": "文件路径"}, + "encoding": {"type": "string", "description": "文件编码,默认 utf-8", "default": "utf-8"} + }, + "required": ["filepath"] + } + }, + { + "name": "file_writer", + "description": "在工作区内创建或修改文件", + "inputSchema": { + "type": "object", + "properties": { + "filepath": {"type": "string", "description": "文件路径"}, + "content": {"type": "string", "description": "要写入的内容"}, + "mode": {"type": "string", "description": "写入模式:'w' 覆盖,'a' 追加", "enum": ["w", "a"], "default": "w"} + }, + "required": ["filepath", "content"] + } + }, + { + "name": "directory_lister", + "description": "列出指定目录的内容", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": f"目录路径。默认工作区 '{current_workspace}'"}, + "show_hidden": {"type": "boolean", "description": "是否显示隐藏文件,默认 false", "default": False} + } + } + }, + { + "name": "file_searcher", + "description": "在工作区内搜索文件", + "inputSchema": { + "type": "object", + "properties": { + "pattern": {"type": "string", "description": "搜索模式(如 '*.txt')"}, + "search_root": {"type": "string", "description": f"搜索根目录。默认工作区 '{current_workspace}'"}, + "recursive": {"type": "boolean", "description": "是否递归搜索,默认 true", "default": True} + }, + "required": ["pattern"] + } + }, + { + "name": "git_operations", + "description": "在工作区内执行 Git 操作", + "inputSchema": { + "type": "object", + "properties": { + "git_command": {"type": "string", "description": "Git 命令"}, + "repo_path": {"type": "string", "description": f"Git 仓库路径。默认工作区 '{current_workspace}'"} + }, + "required": ["git_command"] + } + }, + { + "name": "switch_workspace", + "description": f"🔄 切换工作区。当前:'{current_workspace}'", + "inputSchema": { + "type": "object", + "properties": { + "new_workspace": {"type": "string", "description": "新的工作区目录路径"} + }, + "required": ["new_workspace"] + } + }, + { + "name": "get_current_workspace", + "description": "📂 获取当前工作区信息", + "inputSchema": {"type": "object", "properties": {}} + } + ] + } + } + + print(f"\n📤 返回工具列表,共 {len(tools_response['result']['tools'])} 个工具") + return tools_response + + elif method == "tools/call": + tool_name = params.get("name") + arguments = params.get("arguments", {}) + + # ========== command_executor ========== + if tool_name == "command_executor": + command = arguments.get("command") + cwd = arguments.get("cwd") + timeout = arguments.get("timeout", 60) + + if not command: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": "❌ 缺少 command 参数"} + } + + safe_cwd = get_safe_cwd(cwd) + result = execute_command_safe(command, safe_cwd, timeout) + + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}] + } + } + + # ========== python_runner ========== + elif tool_name == "python_runner": + code = arguments.get("code") + cwd = arguments.get("cwd") + timeout = arguments.get("timeout", 60) + + if not code: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": "❌ 缺少 code 参数"} + } + + safe_cwd = get_safe_cwd(cwd) + result = create_and_run_python(code, timeout, safe_cwd) + + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}] + } + } + + # ========== 🔐 ssh_connect ========== + elif tool_name == "ssh_connect": + host = arguments.get("host") + username = arguments.get("username") + port = arguments.get("port", 22) + + if not host or not username: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": "❌ 缺少 host 或 username 参数"} + } + + # 命令行询问密码(使用 input,不用 GUI 弹窗) + print(f"\n🔐 请输入 {username}@{host}:{port} 的 SSH 密码: ", end="", flush=True) + password = input().strip() + + if not password: + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": False, + "message": "❌ 用户未输入密码,连接取消" + }, ensure_ascii=False, indent=2) + }] + } + } + + token, session_id, success, message = ssh_pool.create_session( + host, username, password, port=port + ) + + result = { + "success": success, + "message": message, + "token": token if success else None, + "session_id": session_id if success else None, + "host": host, + "username": username, + "port": port + } + + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}] + } + } + + # ========== 🔐 ssh_execute(严格模式) ========== + elif tool_name == "ssh_execute": + token = arguments.get("token") + command = arguments.get("command") + purpose = arguments.get("purpose", "") + timeout = arguments.get("timeout", 60) + + if not token or not command: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": "❌ 缺少 token 或 command 参数"} + } + + # ========== 严格检查:用途必须50-100字 ========== + purpose_len = len(purpose) + if purpose_len < 50: + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": False, + "error": f"❌ 用途说明字数不足!当前 {purpose_len} 字,必须 50-100 字。", + "requirement": "请提供至少50字的详细用途说明,包括:为什么要执行此命令、预期达到什么效果、涉及哪些操作和风险。", + "current_purpose": purpose, + "purpose_length": purpose_len, + "minimum_required": 50, + "maximum_required": 100 + }, ensure_ascii=False, indent=2) + }] + } + } + + if purpose_len > 100: + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": False, + "error": f"❌ 用途说明超长!当前 {purpose_len} 字,必须 50-100 字。", + "requirement": "请精简用途说明到100字以内,保持清晰简洁。", + "current_purpose": purpose, + "purpose_length": purpose_len, + "minimum_required": 50, + "maximum_required": 100 + }, ensure_ascii=False, indent=2) + }] + } + } + + # 获取会话信息 + session = ssh_pool.get_session(token) + if not session: + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": False, + "error": "❌ SSH会话已过期或不存在,请重新连接", + "token": token[:16] + "..." + }, ensure_ascii=False, indent=2) + }] + } + } + + # ========== 🔐 用户确认(命令行 input) ========== + confirm_message = ( + f"SSH会话:{session['username']}@{session['host']}:{session['port']}\n" + f"会话ID:{session['id']}\n\n" + f"📝 用途说明({purpose_len}字):\n{purpose}\n\n" + f"💻 命令:\n{command[:300]}{'...' if len(command) > 300 else ''}\n\n" + f"⚠️ 请确认此操作是您期望的,且了解可能的风险。" + ) + + if not ask_user_permission_ui("SSH命令执行确认", confirm_message): + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": False, + "error": "❌ 用户拒绝执行SSH命令", + "command": command, + "purpose": purpose, + "purpose_length": purpose_len + }, ensure_ascii=False, indent=2) + }] + } + } + + # 执行命令 + result = ssh_pool.execute_command(token, command, timeout) + result['purpose'] = purpose + result['purpose_length'] = purpose_len + + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}] + } + } + + # ========== 🔐 ssh_close ========== + elif tool_name == "ssh_close": + token = arguments.get("token") + + if not token: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": "❌ 缺少 token 参数"} + } + + success = ssh_pool.close_session(token) + + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": success, + "message": "✅ SSH会话已关闭" if success else "❌ 会话不存在或已关闭", + "token": token[:16] + "..." + }, ensure_ascii=False, indent=2) + }] + } + } + + # ========== 🔐 ssh_list_sessions ========== + elif tool_name == "ssh_list_sessions": + sessions = ssh_pool.list_sessions() + + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": True, + "total": len(sessions), + "sessions": sessions + }, ensure_ascii=False, indent=2) + }] + } + } + + # ========== file_reader ========== + elif tool_name == "file_reader": + filepath = arguments.get("filepath") + encoding = arguments.get("encoding", "utf-8") + + if not filepath: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": "❌ 缺少 filepath 参数"} + } + + if os.path.isabs(filepath): + target_path = filepath + else: + target_path = os.path.join(current_workspace, filepath) + + target_path = os.path.abspath(target_path) + + if not is_path_in_workspace(target_path): + if not ask_user_permission_ui("文件读取确认", f"读取工作区外的文件:\n📂 文件:{target_path}\n\n是否允许?"): + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": False, + "error": f"❌ 用户拒绝访问:{target_path}" + }, ensure_ascii=False, indent=2) + }] + } + } + + try: + with open(target_path, 'r', encoding=encoding) as f: + content = f.read() + + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": True, + "content": content, + "filepath": target_path, + "size": len(content) + }, ensure_ascii=False, indent=2) + }] + } + } + except Exception as e: + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": False, + "error": f"❌ 读取失败:{str(e)}", + "filepath": target_path + }, ensure_ascii=False, indent=2) + }] + } + } + + # ========== file_writer ========== + elif tool_name == "file_writer": + filepath = arguments.get("filepath") + content = arguments.get("content") + mode = arguments.get("mode", "w") + + if not filepath or content is None: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": "❌ 缺少 filepath 或 content 参数"} + } + + if os.path.isabs(filepath): + target_path = filepath + else: + target_path = os.path.join(current_workspace, filepath) + + target_path = os.path.abspath(target_path) + + if not is_path_in_workspace(target_path): + if not ask_user_permission_ui("文件写入确认", f"写入工作区外的文件:\n📂 文件:{target_path}\n\n是否允许?"): + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": False, + "error": f"❌ 用户拒绝写入:{target_path}" + }, ensure_ascii=False, indent=2) + }] + } + } + + try: + os.makedirs(os.path.dirname(target_path), exist_ok=True) + with open(target_path, mode, encoding='utf-8') as f: + f.write(content) + + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": True, + "filepath": target_path, + "mode": mode, + "size": len(content), + "message": f"✅ 文件已{'追加' if mode == 'a' else '写入'}" + }, ensure_ascii=False, indent=2) + }] + } + } + except Exception as e: + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": False, + "error": f"❌ 写入失败:{str(e)}", + "filepath": target_path + }, ensure_ascii=False, indent=2) + }] + } + } + + # ========== directory_lister ========== + elif tool_name == "directory_lister": + path = arguments.get("path", current_workspace) + show_hidden = arguments.get("show_hidden", False) + + if os.path.isabs(path): + target_path = path + else: + target_path = os.path.join(current_workspace, path) + + target_path = os.path.abspath(target_path) + + if not is_path_in_workspace(target_path): + if not ask_user_permission_ui("目录列表确认", f"列出工作区外的目录:\n📂 目录:{target_path}\n\n是否允许?"): + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": False, + "error": f"❌ 用户拒绝访问:{target_path}" + }, ensure_ascii=False, indent=2) + }] + } + } + + try: + items = [] + for item in os.listdir(target_path): + if not show_hidden and item.startswith('.'): + continue + + full_path = os.path.join(target_path, item) + is_dir = os.path.isdir(full_path) + size = os.path.getsize(full_path) if not is_dir else 0 + mtime = datetime.fromtimestamp(os.path.getmtime(full_path)).strftime("%Y-%m-%d %H:%M:%S") + + items.append({ + "name": item, + "type": "目录" if is_dir else "文件", + "size": size, + "modified": mtime, + "path": full_path + }) + + items.sort(key=lambda x: (x['type'] != '目录', x['name'].lower())) + + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": True, + "path": target_path, + "items": items, + "count": len(items) + }, ensure_ascii=False, indent=2) + }] + } + } + except Exception as e: + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": False, + "error": f"❌ 列出目录失败:{str(e)}", + "path": target_path + }, ensure_ascii=False, indent=2) + }] + } + } + + # ========== file_searcher ========== + elif tool_name == "file_searcher": + pattern = arguments.get("pattern") + search_root = arguments.get("search_root", current_workspace) + recursive = arguments.get("recursive", True) + + if not pattern: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": "❌ 缺少 pattern 参数"} + } + + if os.path.isabs(search_root): + root_path = search_root + else: + root_path = os.path.join(current_workspace, search_root) + + root_path = os.path.abspath(root_path) + + if not is_path_in_workspace(root_path): + if not ask_user_permission_ui("文件搜索确认", f"在工作区外搜索文件:\n📂 目录:{root_path}\n\n是否允许?"): + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": False, + "error": f"❌ 用户拒绝搜索:{root_path}" + }, ensure_ascii=False, indent=2) + }] + } + } + + try: + matches = [] + search_pattern = pattern.replace('*', '.*').replace('?', '.') + regex = re.compile(search_pattern, re.IGNORECASE) + + if recursive: + for dirpath, dirnames, filenames in os.walk(root_path): + dirnames[:] = [d for d in dirnames if not d.startswith('.')] + for filename in filenames: + if regex.match(filename): + full_path = os.path.join(dirpath, filename) + matches.append(full_path) + else: + for item in os.listdir(root_path): + if regex.match(item): + full_path = os.path.join(root_path, item) + if os.path.isfile(full_path): + matches.append(full_path) + + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": True, + "pattern": pattern, + "search_root": root_path, + "recursive": recursive, + "matches": matches, + "count": len(matches) + }, ensure_ascii=False, indent=2) + }] + } + } + except Exception as e: + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": False, + "error": f"❌ 搜索失败:{str(e)}", + "pattern": pattern + }, ensure_ascii=False, indent=2) + }] + } + } + + # ========== git_operations ========== + elif tool_name == "git_operations": + git_command = arguments.get("git_command") + repo_path = arguments.get("repo_path", current_workspace) + + if not git_command: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": "❌ 缺少 git_command 参数"} + } + + if os.path.isabs(repo_path): + target_path = repo_path + else: + target_path = os.path.join(current_workspace, repo_path) + + target_path = os.path.abspath(target_path) + + if not is_path_in_workspace(target_path): + if not ask_user_permission_ui("Git操作确认", f"在工作区外执行Git操作:\n📂 仓库:{target_path}\n\n是否允许?"): + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": False, + "error": f"❌ 用户拒绝 Git 操作:{target_path}" + }, ensure_ascii=False, indent=2) + }] + } + } + + full_command = f"git {git_command}" + result = execute_command_safe(full_command, target_path) + + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}] + } + } + + # ========== switch_workspace ========== + elif tool_name == "switch_workspace": + new_workspace = arguments.get("new_workspace") + + if not new_workspace: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": "❌ 缺少 new_workspace 参数"} + } + + result = switch_workspace(new_workspace) + + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}] + } + } + + # ========== get_current_workspace ========== + elif tool_name == "get_current_workspace": + result = { + "workspace": current_workspace, + "temp_dir": current_temp_dir, + "os": platform.system(), + "version": "3.0.0" + } + + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}] + } + } + + else: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"❌ 未知工具:{tool_name}"} + } + + else: + return { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"❌ 未知方法:{method}"} + } + + except Exception as e: + print(f"\n❌ 服务器错误:{str(e)}") + return { + "jsonrpc": "2.0", + "id": body.get("id", None), + "error": {"code": -32603, "message": f"❌ 内部服务器错误:{str(e)}"} + } + +# ========== 辅助API ========== +@app.post("/switch_workspace") +async def switch_workspace_api(request: Request): + global current_workspace, current_temp_dir + + try: + body = await request.json() + new_workspace = body.get("workspace", "").strip() + + if not new_workspace: + return JSONResponse({"success": False, "error": "请提供 workspace 参数"}) + + result = switch_workspace(new_workspace) + return JSONResponse(result) + + except Exception as e: + return JSONResponse({"success": False, "error": f"切换失败:{str(e)}"}) + +@app.get("/current_workspace") +async def get_current_workspace_api(): + global current_workspace, current_temp_dir + return { + "workspace": current_workspace, + "temp_dir": current_temp_dir, + "os": platform.system(), + "version": "3.0.0" + } + +@app.get("/ssh/sessions") +async def list_ssh_sessions(): + """列出所有SSH会话(管理用)""" + sessions = ssh_pool.list_sessions() + return { + "success": True, + "total": len(sessions), + "sessions": sessions + } + +@app.post("/ssh/cleanup") +async def cleanup_ssh_sessions(): + """清理过期SSH会话""" + count = ssh_pool.cleanup_expired() + return { + "success": True, + "cleaned": count, + "message": f"✅ 清理了 {count} 个过期会话" + } + +@app.get("/health") +async def health(): + global current_workspace, current_temp_dir + sessions = ssh_pool.list_sessions() + return { + "status": "ok", + "workspace": current_workspace, + "temp_dir": current_temp_dir, + "os": platform.system(), + "version": "3.0.0", + "ssh_sessions": len(sessions), + "features": [ + "SSH交互支持", + "严格50-100字用途说明", + "每次命令命令行确认", + "会话Token管理", + "自动清理过期会话" + ] + } + +@app.get("/") +async def root(): + global current_workspace, current_temp_dir + return { + "message": "🚀 MCP CMD + SSH Server v3.0.0 (严格模式)", + "workspace": current_workspace, + "temp_dir": current_temp_dir, + "endpoints": { + "mcp": "POST /mcp", + "health": "GET /health", + "current_workspace": "GET /current_workspace", + "switch_workspace": "POST /switch_workspace", + "ssh_sessions": "GET /ssh/sessions", + "ssh_cleanup": "POST /ssh/cleanup" + }, + "ssh_features": { + "connect": "用户手动输入密码,返回token", + "execute": "必须50-100字用途说明 + 命令行确认", + "close": "关闭会话", + "list": "查看所有会话" + }, + "strict_rules": { + "purpose_min_length": 50, + "purpose_max_length": 100, + "confirmation_required": True, + "per_command_confirmation": True + } + } + +# ========== 启动服务器 ========== +if __name__ == "__main__": + import uvicorn + print(f"\n🔥 MCP + SSH 服务器启动中...") + print(f"📍 主端点:http://localhost:3000/mcp") + print(f"📂 工作区:{workspace}") + print(f"📁 临时目录:{temp_dir}") + print(f"\n🔐 SSH功能:") + print(f" - ssh_connect: 发起连接,用户手动输入密码") + print(f" - ssh_execute: 执行命令(⚠️ 必须50-100字用途说明 + 命令行确认)") + print(f" - ssh_close: 关闭会话") + print(f" - ssh_list_sessions: 列出会话") + print(f"\n📋 严格规则:") + print(f" - 用途说明:50-100字(不足50字拒绝,超过100字拒绝)") + print(f" - 每次执行必须命令行确认") + print(f" - 每个命令单独审批") + print(f"\n💡 按 Ctrl+C 停止服务器") + print("=" * 60) + uvicorn.run(app, host="0.0.0.0", port=3000, log_level="info") diff --git a/Multimodal and Gadget Support.py b/Multimodal and Gadget Support.py new file mode 100644 index 0000000..26ceb08 --- /dev/null +++ b/Multimodal and Gadget Support.py @@ -0,0 +1,6093 @@ +import os +import base64 +import json +import asyncio +import time +import re +import uuid +import tempfile +import urllib.request +import wave +import struct +import io +import hashlib +import sqlite3 +import secrets +from pathlib import Path +from typing import Optional, Dict, Any, List, Union +from datetime import datetime +from fastapi import FastAPI, Request, HTTPException +from fastapi.responses import JSONResponse, PlainTextResponse, FileResponse, HTMLResponse +from pydantic import BaseModel +from openai import OpenAI +import pandas as pd +from docx2pdf import convert as docx_to_pdf +from pdf2image import convert_from_path +from PIL import Image +import requests +from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 +import ctypes +import sys +import os +import shutil +import os +import sys +import ctypes +import ctypes.wintypes +import time + +# ============ 提权函数 ============ +def is_admin(): + """检查是否以管理员权限运行""" + try: + return ctypes.windll.shell32.IsUserAnAdmin() + except: + return False + +def run_as_admin(): + """以管理员权限重新运行脚本""" + if not is_admin(): + print("⚠️ 当前不是管理员权限,正在提权...") + try: + # 使用ShellExecuteW以管理员身份运行 + ctypes.windll.shell32.ShellExecuteW( + None, + "runas", + sys.executable, + " ".join(sys.argv), + None, + 1 + ) + sys.exit(0) + except Exception as e: + print(f"❌ 提权失败: {e}") + print("💡 请手动右键以管理员身份运行此脚本") + input("按任意键退出...") + sys.exit(1) + else: + print("✅ 已获得管理员权限") + +# ============ 立即提权 ============ +run_as_admin() +# ============ LangChain + Qdrant ============ +try: + from langchain_text_splitters import RecursiveCharacterTextSplitter + _langchain_available = True + print("✅ LangChain TextSplitter 加载成功") +except ImportError: + try: + from langchain.text_splitter import RecursiveCharacterTextSplitter + _langchain_available = True + print("✅ LangChain TextSplitter 加载成功 (旧版)") + except ImportError: + print("⚠️ LangChain TextSplitter 未安装,使用内置切分") + _langchain_available = False + +try: + from qdrant_client import QdrantClient + from qdrant_client.models import Distance, VectorParams, PointStruct + _qdrant_available = True + print("✅ Qdrant 加载成功") +except Exception as e: + print(f"⚠️ Qdrant 加载失败: {e}") + _qdrant_available = False + +# ============ DashScope TTS ============ +try: + import dashscope + from dashscope.audio.tts_v2 import SpeechSynthesizer + _dashscope_available = True + print("✅ DashScope SDK 加载成功") +except Exception as e: + print(f"⚠️ DashScope SDK 加载失败: {e}") + _dashscope_available = False + +# ============ 音频播放库 ============ +try: + from ap_ds import AudioLibrary + _audio_lib = AudioLibrary() + print("✅ ap_ds 音频播放库加载成功") +except Exception as e: + print(f"⚠️ ap_ds 加载失败: {e}") + _audio_lib = None + +# ============ 配置 ============ +DASHSCOPE_API_KEY = #请输入 +DASHSCOPE_BASE_URL = #请输入 +SEARCH_API_KEY = #请输入 +SEARCH_URL = "https://uapis.cn/api/v1/search/aggregate" +OCR_API_URL = "https://uapis.cn/api/v1/image/ocr" +UAPI_BASE_URL = "https://uapis.cn" + +POPPLER_PATH = #请输入 + +WORKSPACE_ID = #请输入 +IMAGE_GEN_URL = f"https://{WORKSPACE_ID}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation" + +# ============ 模型配置 ============ +QWEN35_FLASH = "qwen3.5-omni-flash" +QWEN35_PRO = "qwen3.5-omni-pro" +QWEN35_OCR = "qwen3.5-ocr" +DEEPSEEK_MODEL = "deepseek-v4-flash" + +# ============ TTS 配置 ============ +if _dashscope_available: + dashscope.api_key = DASHSCOPE_API_KEY + TTS_WS_URL = f"wss://{WORKSPACE_ID}.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference" + dashscope.base_websocket_api_url = TTS_WS_URL + +TTS_FLASH = "qwen-audio-3.0-tts-flash" +TTS_PLUS = "qwen-audio-3.0-tts-plus" +DEFAULT_TTS_VOICE = "longanlingxin" + +# ============ 音色列表 (Omni 多模态) ============ +VOICE_LIST = { + "Vivian": "温暖知性女声,适合知识分享、教育类内容", + "Ryan": "沉稳专业男声,适合商业解说、新闻播报", + "Lily": "活泼年轻女声,适合时尚美妆、生活方式视频", + "Ethan": "磁性低沉男声,适合纪录片、历史题材", + "Sophie": "甜美可爱女声,适合儿童内容、轻松小品", + "Daniel": "阳光活力男声,适合体育竞技、游戏解说", + "Emma": "干练职场女声,适合科技产品、职场技能", + "Oliver": "温和亲切男声,适合健康养生、心理咨询", + "Aria": "空灵艺术女声,适合诗歌朗诵、艺术评论" +} +DEFAULT_OMNI_VOICE = "Ethan" + +# ============ 官方推荐音色 (Plus 旗舰 + Flash 系统) ============ +OFFICIAL_VOICES = { + # Plus 旗舰音色 + "longanlingxin": "龙安灵心 - Plus旗舰 知心温暖音 (女/25岁/中英)", + "longanlufeng": "龙安鲁风 - Plus旗舰 明亮开朗音 (男/25岁/中英)", + # Flash 系统音色 + "longanfengyue": "龙安风悦 - Flash系统 自然亲切音 (女/30岁/中英)", + "longanyuanfei": "龙安元妃 - Flash系统 高傲妃子音 (女/30岁/中英)", + "longanlingxi": "龙安灵希 - Flash系统 可爱甜美音 (女/25岁/中英)", + "longanxiaoxin": "龙安小昕 - Flash系统 亲切活泼音 (女/22岁/中英)", + "longanhuan_v3.6": "龙安欢 - Flash系统 (女/25岁/中英)", + "longjielidou_v3.6": "龙杰力豆 - Flash系统 天真男童 (男/5岁/中英)", + "longpaopao_v3.6": "龙泡泡 - Flash系统 软糯可爱音 (女/5岁/中英)", + "longhuohuo_v3.6": "龙火火 - Flash系统 顽皮少年音 (男/8岁/中英)", + "longchuanshu_v3.6": "龙川叔 - Flash系统 川普大叔音 (男/40岁/中英)", + "loongmary": "loongmary - Flash系统 温暖英音 (女/20岁/英文)", + "loongeva_v3.6": "loongeva - Flash系统 高智美音 (女/28岁/英文)", + "loongjohn": "loongJohn - Flash系统 沉稳亲切美音 (男/28岁/英文)" +} + +# ============ Flash 精选音色 (25个基础音色) ============ +FLASH_VOICES = { + "longcanzhuyue": "qwen-audio-3.0-tts-flash-longcanzhuyue - 龙璨竹月 - 平实质朴", + "longrongzhihe": "qwen-audio-3.0-tts-flash-longrongzhihe - 龙蓉芷荷 - 电台质感", + "longlanghongmo": "qwen-audio-3.0-tts-flash-longlanghongmo - 龙朗虹沫 - 温柔亲和", + "longfengyueyao": "qwen-audio-3.0-tts-flash-longfengyueyao - 龙风月瑶 - 直爽利落", + "longxiaoyuyue": "qwen-audio-3.0-tts-flash-longxiaoyuyue - 龙潇煜月 - 惊奇讶异", + "longtongxuxian": "qwen-audio-3.0-tts-flash-longtongxuxian - 龙彤旭弦 - 活泼灵动", + "longyingsongliu": "qwen-audio-3.0-tts-flash-longyingsongliu - 龙莹松柳 - 爽朗利落", + "longzhengyaohe": "qwen-audio-3.0-tts-flash-longzhengyaohe - 龙筝瑶鹤 - 亢奋激昂", + "longlanyufu": "qwen-audio-3.0-tts-flash-longlanyufu - 龙岚煜芙 - 温柔亲和", + "longxinruixuan": "qwen-audio-3.0-tts-flash-longxinruixuan - 龙昕蕊璇 - 自然亲和", + "longqinheying": "qwen-audio-3.0-tts-flash-longqinheying - 龙琴鹤莹 - 温柔亲和", + "longluliuche": "qwen-audio-3.0-tts-flash-longluliuche - 龙露柳澈 - 标准播音", + "longliuxulan": "qwen-audio-3.0-tts-flash-longliuxulan - 龙柳旭澜 - 标准播音", + "longjufuhe": "qwen-audio-3.0-tts-flash-longjufuhe - 龙菊芙荷 - 呆萌软糯", + "longxiamuyan": "qwen-audio-3.0-tts-flash-longxiamuyan - 龙霞暮燕 - 专业解说", + "longyuzhihe": "qwen-audio-3.0-tts-flash-longyuzhihe - 龙羽芷荷 - 客观冷静", + "longyaolanshuang": "qwen-audio-3.0-tts-flash-longyaolanshuang - 龙瑶岚霜 - 鼓舞激励", + "longsongyibai": "qwen-audio-3.0-tts-flash-longsongyibai - 龙松熠柏 - 鼓舞激励", + "longyanhuilu": "qwen-audio-3.0-tts-flash-longyanhuilu - 龙燕辉露 - 客观冷静", + "longhuifengyi": "qwen-audio-3.0-tts-flash-longhuifengyi - 龙辉峰漪 - 客观冷静", + "longshuojizhu": "qwen-audio-3.0-tts-flash-longshuojizhu - 龙朔霁竹 - 标准播音", + "longhuiyanzhu": "qwen-audio-3.0-tts-flash-longhuiyanzhu - 龙晖妍竹 - 标准播音", + "longbaixiuyun": "qwen-audio-3.0-tts-flash-longbaixiuyun - 龙柏岫云 - 标准播音", + "longxiaolanshan": "qwen-audio-3.0-tts-flash-longxiaolanshan - 龙潇嵐杉 - 标准播音", + "longyilincan": "qwen-audio-3.0-tts-flash-longyilincan - 龙熠琳璨 - 标准播音" +} + +# ============ Plus 精选音色 (25个基础音色) ============ +PLUS_VOICES = { + "longcanzhuyue": "qwen-audio-3.0-tts-plus-longcanzhuyue - 龙璨竹月 - 平实质朴", + "longrongzhihe": "qwen-audio-3.0-tts-plus-longrongzhihe - 龙蓉芷荷 - 电台质感", + "longlanghongmo": "qwen-audio-3.0-tts-plus-longlanghongmo - 龙朗虹沫 - 温柔亲和", + "longfengyueyao": "qwen-audio-3.0-tts-plus-longfengyueyao - 龙风月瑶 - 直爽利落", + "longxiaoyuyue": "qwen-audio-3.0-tts-plus-longxiaoyuyue - 龙潇煜月 - 惊奇讶异", + "longtongxuxian": "qwen-audio-3.0-tts-plus-longtongxuxian - 龙彤旭弦 - 活泼灵动", + "longyingsongliu": "qwen-audio-3.0-tts-plus-longyingsongliu - 龙莹松柳 - 爽朗利落", + "longzhengyaohe": "qwen-audio-3.0-tts-plus-longzhengyaohe - 龙筝瑶鹤 - 亢奋激昂", + "longlanyufu": "qwen-audio-3.0-tts-plus-longlanyufu - 龙岚煜芙 - 温柔亲和", + "longxinruixuan": "qwen-audio-3.0-tts-plus-longxinruixuan - 龙昕蕊璇 - 自然亲和", + "longqinheying": "qwen-audio-3.0-tts-plus-longqinheying - 龙琴鹤莹 - 温柔亲和", + "longluliuche": "qwen-audio-3.0-tts-plus-longluliuche - 龙露柳澈 - 标准播音", + "longliuxulan": "qwen-audio-3.0-tts-plus-longliuxulan - 龙柳旭澜 - 标准播音", + "longjufuhe": "qwen-audio-3.0-tts-plus-longjufuhe - 龙菊芙荷 - 呆萌软糯", + "longxiamuyan": "qwen-audio-3.0-tts-plus-longxiamuyan - 龙霞暮燕 - 专业解说", + "longyuzhihe": "qwen-audio-3.0-tts-plus-longyuzhihe - 龙羽芷荷 - 客观冷静", + "longyaolanshuang": "qwen-audio-3.0-tts-plus-longyaolanshuang - 龙瑶岚霜 - 鼓舞激励", + "longsongyibai": "qwen-audio-3.0-tts-plus-longsongyibai - 龙松熠柏 - 鼓舞激励", + "longyanhuilu": "qwen-audio-3.0-tts-plus-longyanhuilu - 龙燕辉露 - 客观冷静", + "longhuifengyi": "qwen-audio-3.0-tts-plus-longhuifengyi - 龙辉峰漪 - 客观冷静", + "longshuojizhu": "qwen-audio-3.0-tts-plus-longshuojizhu - 龙朔霁竹 - 标准播音", + "longhuiyanzhu": "qwen-audio-3.0-tts-plus-longhuiyanzhu - 龙晖妍竹 - 标准播音", + "longbaixiuyun": "qwen-audio-3.0-tts-plus-longbaixiuyun - 龙柏岫云 - 标准播音", + "longxiaolanshan": "qwen-audio-3.0-tts-plus-longxiaolanshan - 龙潇嵐杉 - 标准播音", + "longyilincan": "qwen-audio-3.0-tts-plus-longyilincan - 龙熠琳璨 - 标准播音" +} + +# ============ TTS 音色完整列表 (用于查询) ============ +# 完整 voice 参数已包含模型前缀(如 qwen-audio-3.0-tts-flash-xxx),直接合并即可 +TTS_VOICES = { + **OFFICIAL_VOICES, + **FLASH_VOICES, + **PLUS_VOICES +} + +# ============ 全新高级 TTS 接口 (Qwen3-TTS-Instruct-Flash) ============ +# 独立专用 API 地址,只能用 qwen3-tts-instruct-flash 一个模型(防止乱调用其它模型耗尽账单) +QWEN3TTS_MODEL = "qwen3-tts-instruct-flash" +QWEN3TTS_BASE_URL = "https://ws-pnhu8tps38s61wpt.cn-beijing.maas.aliyuncs.com/api/v1" + +# Qwen3-TTS-Instruct-Flash 音色列表 (voice 参数直接传英文名) +QWEN3_INSTRUCT_VOICES = { + "Cherry": "芊悦 - 阳光积极、亲切自然小姐姐(女)", + "Serena": "苏瑶 - 温柔小姐姐(女)", + "Ethan": "晨煦 - 阳光温暖活力男声(男)", + "Chelsie": "千雪 - 二次元虚拟女友(女)", + "Momo": "茉兔 - 撒娇搞怪逗你开心(女)", + "Vivian": "十三 - 拽拽的可爱小暴躁(女)", + "Moon": "月白 - 率性帅气(男)", + "Maia": "四月 - 知性与温柔碰撞(女)", + "Kai": "凯 - 耳朵的一场SPA(男)", + "Nofish": "不吃鱼 - 不会翘舌音的设计师(男)", + "Bella": "萌宝 - 喝酒不打醉拳的小萝莉(女)", + "Jennifer": "詹妮弗 - 品牌级电影质感美语女声(女)", + "Ryan": "甜茶 - 节奏拉满戏感炸裂(男)", + "Katerina": "卡捷琳娜 - 御姐音色韵律回味(女)", + "Aiden": "艾登 - 精通厨艺的美语大男孩(男)", + "Eldric Sage": "沧明子 - 沉稳睿智的老者(男)", + "Mia": "乖小妹 - 温顺乖巧(女)", + "Mochi": "沙小弥 - 聪明伶俐的小大人(男)", + "Bellona": "燕铮莺 - 声音洪亮吐字清晰热血(女)", + "Vincent": "田叔 - 独特沙哑烟嗓江湖豪情(男)", + "Bunny": "萌小姬 - 萌属性爆棚小萝莉(女)", + "Neil": "阿闻 - 专业新闻主持人(男)", + "Elias": "墨讲师 - 严谨学科叙事讲师(女)", + "Arthur": "徐大爷 - 质朴乡音(男)", + "Nini": "邻家妹妹 - 又软又黏甜到骨酥(女)", + "Seren": "小婉 - 温和舒缓助眠(女)", + "Pip": "顽屁小孩 - 调皮童真(男)", + "Stella": "少女阿月 - 迷糊少女音(女)", + "Bodega": "博德加 - 热情西班牙大叔(男)", + "Sonrisa": "索尼莎 - 热情开朗拉美大姐(女)", + "Alek": "阿列克 - 战斗民族的冷与暖(男)", + "Dolce": "多尔切 - 慵懒意大利大叔(男)", + "Sohee": "素熙 - 温柔开朗韩国欧尼(女)", + "Ono Anna": "An Ono - 鬼灵精怪青梅竹马(女)", + "Lenn": "莱恩 - 理性叛逆德国青年(男)", + "Emilien": "埃米尔安 - 浪漫法国大哥哥(男)", + "Andre": "安德雷 - 磁性沉稳男生(男)", + "Radio Gol": "拉迪奥·戈尔 - 足球诗人解说(男)", + "Jada": "上海-阿珍 - 风风火火沪上阿姐(女/上海话)", + "Dylan": "北京-晓东 - 北京胡同少年(男/北京话)", + "Li": "南京-老李 - 耐心瑜伽老师(男/南京话)", + "Marcus": "陕西-秦川 - 老陕的味道(男/陕西话)", + "Roy": "闽南-阿杰 - 诙谐直爽台湾哥仔(男/闽南语)", + "Peter": "天津-李彼得 - 天津相声专业捧哏(男/天津话)", + "Sunny": "四川-晴儿 - 甜到心里的川妹子(女/四川话)", + "Eric": "四川-程川 - 跳脱市井成都男子(男/四川话)", + "Rocky": "粤语-阿强 - 幽默风趣在线陪聊(男/粤语)", + "Kiki": "粤语-阿清 - 甜美港妹闺蜜(女/粤语)" +} + +def generate_qwen3_instruct_audio( + text: str, + voice: str = "Cherry", + instructions: Optional[str] = None, + optimize_instructions: bool = False, + stream: bool = False, + format: str = "wav", + auto_play: bool = True, + save_filename: Optional[str] = None +) -> Dict[str, Any]: + """全新高级 TTS 接口:Qwen3-TTS-Instruct-Flash。 + 专用 API 地址 + 固定模型,防止调用其它模型耗尽账单。""" + print(f"\n🎤 [tts_max/Qwen3-TTS-Instruct-Flash] 开始") + print(f" 📝 文本: {text[:100]}...") + print(f" 🎤 音色: {voice}") + print(f" 🤖 模型: {QWEN3TTS_MODEL} (固定, 不可更换)") + print(f" 🌐 地址: {QWEN3TTS_BASE_URL}") + + if not _dashscope_available: + return {"success": False, "error": "DashScope SDK 未加载,请安装: pip install dashscope"} + + try: + # 使用专用 base URL (不覆盖全局 dashscope.base_http_api_url, 临时设置) + dashscope.base_http_api_url = QWEN3TTS_BASE_URL + dashscope.api_key = DASHSCOPE_API_KEY + + call_params = { + "model": QWEN3TTS_MODEL, + "text": text, + "voice": voice, + "optimize_instructions": optimize_instructions, + "stream": stream, + } + if instructions and instructions.strip(): + call_params["instructions"] = instructions + + print(f" ⏳ 正在调用 Qwen3-TTS-Instruct-Flash...") + response = dashscope.MultiModalConversation.call(**call_params) + + print(f" 📊 状态码: {response.status_code}") + + if response.status_code == 200: + audio_url = response.output.audio.url + print(f" ✅ 合成成功!音频链接: {audio_url}") + + # 下载音频 + import requests as _req + audio_resp = _req.get(audio_url) + audio_data = audio_resp.content + + result = { + "success": True, + "audio_size": len(audio_data), + "format": format, + "model": QWEN3TTS_MODEL, + "voice": voice, + "text": text[:200] + "..." if len(text) > 200 else text, + } + + if save_to_file := True: + try: + if save_filename: + save_path = os.path.join(AUDIO_DIR, save_filename) + if not save_path.endswith(f".{format}"): + save_path = f"{save_path}.{format}" + else: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_text = "".join(c for c in text[:20] if c.isalnum() or c in " _-") + save_path = os.path.join(AUDIO_DIR, f"ttsmax_{voice}_{timestamp}.{format}") + with open(save_path, 'wb') as f: + f.write(audio_data) + print(f" ✅ 音频已保存: {save_path}") + result["saved_path"] = save_path + result["file_size"] = len(audio_data) + if auto_play and _audio_lib is not None: + try: + print(f" 🎵 正在播放音频...") + aid = _audio_lib.play_from_file(save_path) + result["playback_id"] = aid + result["playback_status"] = "playing" + print(f" ✅ 播放中 (ID: {aid})") + except Exception as e: + print(f" ⚠️ 播放失败: {e}") + result["playback_error"] = str(e) + elif auto_play and _audio_lib is None: + print(f" ⚠️ ap_ds库未加载,无法播放") + result["playback_error"] = "ap_ds库未加载" + except Exception as e: + result["save_error"] = str(e) + + return result + else: + return {"success": False, "error": f"{response.code} - {response.message}"} + except Exception as e: + return {"success": False, "error": f"❌ 异常: {str(e)}"} + + +# ============ 知识库配置 ============ +KNOWLEDGE_BASE_FILE = r"C:\Users\dvs.新年快乐\Desktop\知识库.md" +COLLECTION_NAME = "knowledge_base" +EMBEDDING_MODEL = "qwen3.7-text-embedding" +EMBEDDING_DIM = 1024 + +# ============ 知识库检索配置 ============ +CHUNK_SIZE = 1200 +CHUNK_OVERLAP = 200 +DEFAULT_LIMIT = 15 +DEFAULT_SCORE_THRESHOLD = 0.35 +ADVANCED_LIMIT = 30 +ADVANCED_SCORE_THRESHOLD = 0.2 +ADVANCED_CONTEXT_EXPAND = 2 +import os +from pathlib import Path + +# ============ 独立工作目录 ============ +# 获取用户桌面路径 +DESKTOP_DIR = Path.home() / "Desktop" +WORK_DIR = DESKTOP_DIR / "WORK_DIR" + +# 子目录 +TEMP_DIR = WORK_DIR / "temp" +OUTPUT_DIR = WORK_DIR / "output" +SESSION_DIR = WORK_DIR / "sessions" +IMAGES_DIR = WORK_DIR / "images" +AUDIO_DIR = WORK_DIR / "audio" +QDRANT_DATA_DIR = WORK_DIR / "qdrant_data" + +ESSAYS_FILE = WORK_DIR / "essays.json" + +# 创建所有目录 +for d in [WORK_DIR, TEMP_DIR, OUTPUT_DIR, SESSION_DIR, IMAGES_DIR, AUDIO_DIR, QDRANT_DATA_DIR]: + d.mkdir(parents=True, exist_ok=True) + +print(f"📂 工作目录: {WORK_DIR}") +print(f"📁 图片目录: {IMAGES_DIR}") +print(f"📁 音频目录: {AUDIO_DIR}") +print(f"📁 Qdrant数据目录: {QDRANT_DATA_DIR}") +# ============ 初始化Qdrant ============ +qdrant_client = None +if _qdrant_available: + try: + qdrant_client = QdrantClient(path=QDRANT_DATA_DIR) + print("✅ Qdrant 本地持久化启动成功") + + collections = qdrant_client.get_collections().collections + collection_names = [c.name for c in collections] + + if COLLECTION_NAME not in collection_names: + print(f"📁 创建知识库collection: {COLLECTION_NAME}") + qdrant_client.create_collection( + collection_name=COLLECTION_NAME, + vectors_config=VectorParams( + size=EMBEDDING_DIM, + distance=Distance.COSINE + ) + ) + else: + print(f"✅ 知识库collection已存在: {COLLECTION_NAME}") + except Exception as e: + print(f"⚠️ Qdrant 启动失败: {e}") + qdrant_client = None + +# ============ SQLite 备份数据库 ============ +SQLITE_DB_PATH = os.path.join(WORK_DIR, "knowledge_base_backup.db") + +def init_sqlite_backup(): + conn = sqlite3.connect(SQLITE_DB_PATH) + cursor = conn.cursor() + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS knowledge_backup ( + id TEXT PRIMARY KEY, + content TEXT NOT NULL, + source TEXT, + chunk_index INTEGER, + total_chunks INTEGER, + timestamp TEXT, + metadata TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + synced_at TEXT + ) + ''') + + cursor.execute('CREATE INDEX IF NOT EXISTS idx_backup_source ON knowledge_backup(source)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_backup_timestamp ON knowledge_backup(timestamp)') + + conn.commit() + conn.close() + print(f"✅ SQLite 备份数据库初始化完成: {SQLITE_DB_PATH}") + +init_sqlite_backup() + +# ============ UUID生成 ============ +def generate_uuid_from_string(text: str) -> str: + return str(uuid.uuid5(uuid.NAMESPACE_DNS, text)) + +# ============ 默认系统提示词 ============ +DEFAULT_SYSTEM_PROMPT = """你是Qwen3.5-Omni-Flash全模态AI模型,DeepSeek是你的哥哥。 + +【你的能力】 +1. 图像识别:识别图片中的物体、场景、动植物、人物、文字等 +2. 音频理解:听音频、转文字、分析情感和风格、识别音乐 +3. 视频分析:通过多帧图片理解视频内容 +4. 文档处理:读取Word、PDF、Excel等文档内容 +5. 文字理解:理解并回答各种文本问题 +6. 语音转文字:识别音频中的说话内容 +7. 图片生成:调用画图工具生成图片 +8. 音频回复:用语音回复用户!支持9种专业音色 +9. 知识库:查询知识库获取信息,添加内容到知识库 +10. 关键词拆分:使用 keyword_split 将查询拆分为多个关键词 + +【画图工具选择】 +- generate_image_wan: 最便宜,优先使用 +- generate_image_qwen: 需要文字渲染时用 +- generate_image_wan_pro: 需要4K时用 + +【知识库查询】 +- knowledge_query(use_deepseek=True): 查询知识库,DeepSeek整理后返回 +- knowledge_query(use_deepseek=False): 查询知识库,直接返回原始结果 +- knowledge_search_advanced: 高级检索(实验版),三层检索+上下文扩展 + +【重要规则】 +- 只有用户明确说"画图/生成图片"时才调用画图工具 +- 用户说"语音/音频回复"时用 audio 模式 +- 用户说"分析/识别/听"时直接分析 +- 不确定时使用 knowledge_query 查询知识库 + +【聊天风格】活泼自然,知道就说知道,不知道就说"不知道"。""" + +# ============ 防沉迷配置 ============ +MAX_HISTORY_PER_USER = 5 +COOLDOWN_SECONDS = 60 + +# ============ 会话存储 ============ +class SessionStore: + def __init__(self, session_dir: str): + self.session_dir = session_dir + self._ensure_dir() + + def _ensure_dir(self): + Path(self.session_dir).mkdir(parents=True, exist_ok=True) + + def _get_user_path(self, user_id: str) -> Path: + return Path(self.session_dir) / f"user_{user_id}.json" + + def get_user_session(self, user_id: str) -> Optional[Dict[str, Any]]: + path = self._get_user_path(user_id) + if not path.exists(): + return None + try: + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + return data + except: + return None + + def save_user_session(self, user_id: str, data: Dict[str, Any]): + path = self._get_user_path(user_id) + with open(path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + def get_history(self, user_id: str) -> List[dict]: + data = self.get_user_session(user_id) + if data: + return data.get("history", []) + return [] + + def update_history(self, user_id: str, history: List[dict]): + data = self.get_user_session(user_id) or {} + data["history"] = history + data["updated_at"] = datetime.now().isoformat() + data["history_count"] = len(history) // 2 + self.save_user_session(user_id, data) + + def get_cooldown_info(self, user_id: str) -> Dict[str, Any]: + data = self.get_user_session(user_id) + if not data: + return {"in_cooldown": False, "remaining": 0, "history_count": 0} + + history = data.get("history", []) + history_count = len(history) // 2 + last_updated = data.get("updated_at") + + if history_count >= MAX_HISTORY_PER_USER and last_updated: + last_time = datetime.fromisoformat(last_updated) + elapsed = (datetime.now() - last_time).total_seconds() + if elapsed < COOLDOWN_SECONDS: + return { + "in_cooldown": True, + "remaining": int(COOLDOWN_SECONDS - elapsed), + "history_count": history_count, + "max_allowed": MAX_HISTORY_PER_USER + } + + return { + "in_cooldown": False, + "remaining": 0, + "history_count": history_count, + "max_allowed": MAX_HISTORY_PER_USER + } + +session_store = SessionStore(SESSION_DIR) + +# ============ 初始化客户端 ============ +client = OpenAI( + api_key=DASHSCOPE_API_KEY, + base_url=DASHSCOPE_BASE_URL, +) + +app = FastAPI( + title="MCP Multimodal + Document + Search + Image + TTS + OCR + Knowledge Server", + version="5.0.0" +) + +# ============ 请求模型 ============ + +class QwenAskRequest(BaseModel): + prompt: str + system_prompt: Optional[str] = None + text: Optional[str] = None + image_paths: Optional[List[str]] = None + audio_path: Optional[str] = None + video_frames: Optional[List[str]] = None + output_modality: Optional[str] = "text" + voice: Optional[str] = DEFAULT_OMNI_VOICE + audio_format: Optional[str] = "wav" + + +class QwenAskProRequest(BaseModel): + prompt: str + system_prompt: Optional[str] = None + text: Optional[str] = None + image_paths: Optional[List[str]] = None + audio_path: Optional[str] = None + video_frames: Optional[List[str]] = None + output_modality: Optional[str] = "text" + voice: Optional[str] = DEFAULT_OMNI_VOICE + audio_format: Optional[str] = "wav" + + +class QwenChatRequest(BaseModel): + message: str + user_id: str + system_prompt: Optional[str] = None + text: Optional[str] = None + image_paths: Optional[List[str]] = None + audio_path: Optional[str] = None + video_frames: Optional[List[str]] = None + output_modality: Optional[str] = "text" + voice: Optional[str] = DEFAULT_OMNI_VOICE + audio_format: Optional[str] = "wav" + + +class QwenChatProRequest(BaseModel): + message: str + user_id: str + system_prompt: Optional[str] = None + text: Optional[str] = None + image_paths: Optional[List[str]] = None + audio_path: Optional[str] = None + video_frames: Optional[List[str]] = None + output_modality: Optional[str] = "text" + voice: Optional[str] = DEFAULT_OMNI_VOICE + audio_format: Optional[str] = "wav" + + +class TTSRequest(BaseModel): + text: str + voice: Optional[str] = DEFAULT_TTS_VOICE + model: Optional[str] = TTS_FLASH + format: Optional[str] = "mp3" + sample_rate: Optional[int] = 22050 + rate: Optional[float] = 1.0 + pitch: Optional[float] = 1.0 + volume: Optional[int] = 50 + bit_rate: Optional[int] = 32 + instruction: Optional[str] = None + auto_play: Optional[bool] = True + save_filename: Optional[str] = None + + +class TTSMaxRequest(BaseModel): + text: str + voice: Optional[str] = "Cherry" + instructions: Optional[str] = None + optimize_instructions: Optional[bool] = False + format: Optional[str] = "wav" + auto_play: Optional[bool] = True + save_filename: Optional[str] = None + + +class OCRRequest(BaseModel): + image_path: Optional[str] = None + image_url: Optional[str] = None + image_base64: Optional[str] = None + need_location: Optional[bool] = True + return_markdown: Optional[bool] = False + enable_cls: Optional[bool] = False + + +class OCRAdvancedRequest(BaseModel): + image_path: Optional[str] = None + image_url: Optional[str] = None + image_base64: Optional[str] = None + prompt: Optional[str] = "请提取图像中的全部文本内容。" + min_pixels: Optional[int] = None + max_pixels: Optional[int] = None + return_coordinates: Optional[bool] = False + + +class KnowledgeQueryRequest(BaseModel): + query: str + use_deepseek: Optional[bool] = True + limit: Optional[int] = DEFAULT_LIMIT + score_threshold: Optional[float] = DEFAULT_SCORE_THRESHOLD + + +class EssayRecordRequest(BaseModel): + content: str + keywords: List[str] + + +class EssayQueryRequest(BaseModel): + keyword: str + + +class EssayViewRequest(BaseModel): + essay_id: Optional[int] = None + essay_hash: Optional[str] = None + + +# ============ 知识库核心函数 ============ + +def get_embedding(text: str) -> Optional[List[float]]: + if not text: + return None + try: + response = client.embeddings.create( + model=EMBEDDING_MODEL, + input=text[:8192] + ) + return response.data[0].embedding + except Exception as e: + print(f"❌ 向量化失败: {e}") + return None + + +def chunk_document(text: str, chunk_size: int = CHUNK_SIZE, chunk_overlap: int = CHUNK_OVERLAP) -> List[str]: + """使用LangChain切分文档 - 更大的块""" + if not _langchain_available: + chunks = [] + for i in range(0, len(text), chunk_size): + chunks.append(text[i:i+chunk_size]) + return chunks + + text_splitter = RecursiveCharacterTextSplitter( + chunk_size=chunk_size, + chunk_overlap=chunk_overlap, + separators=["\n\n", "\n", "。", ",", " ", ""], + length_function=len, + ) + return text_splitter.split_text(text) + + +def backup_to_sqlite( + item_id: str, + content: str, + source: str = "user_input", + chunk_index: int = 0, + total_chunks: int = 1, + metadata: Optional[Dict] = None, + timestamp: Optional[str] = None +) -> bool: + try: + conn = sqlite3.connect(SQLITE_DB_PATH) + cursor = conn.cursor() + + if timestamp is None: + timestamp = datetime.now().isoformat() + + metadata_json = json.dumps(metadata, ensure_ascii=False) if metadata else "{}" + synced_at = datetime.now().isoformat() + + cursor.execute(''' + INSERT OR REPLACE INTO knowledge_backup + (id, content, source, chunk_index, total_chunks, timestamp, metadata, synced_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', (item_id, content, source, chunk_index, total_chunks, timestamp, metadata_json, synced_at)) + + conn.commit() + conn.close() + return True + except Exception as e: + print(f"⚠️ SQLite备份失败: {e}") + return False + + +def delete_backup_from_sqlite(item_id: str) -> bool: + try: + conn = sqlite3.connect(SQLITE_DB_PATH) + cursor = conn.cursor() + cursor.execute('DELETE FROM knowledge_backup WHERE id = ?', (item_id,)) + conn.commit() + conn.close() + return True + except Exception as e: + print(f"⚠️ SQLite删除备份失败: {e}") + return False + + +def restore_from_sqlite_backup() -> Dict[str, Any]: + print(f"\n📂 [从SQLite备份恢复] 开始") + + if qdrant_client is None: + return {"success": False, "error": "Qdrant未连接"} + + try: + conn = sqlite3.connect(SQLITE_DB_PATH) + cursor = conn.cursor() + cursor.execute('SELECT id, content, source, chunk_index, total_chunks, timestamp, metadata FROM knowledge_backup') + rows = cursor.fetchall() + conn.close() + + if not rows: + return {"success": True, "message": "备份为空,无需恢复", "count": 0} + + restored = 0 + for row in rows: + item_id, content, source, chunk_index, total_chunks, timestamp, metadata_json = row + + try: + existing = qdrant_client.retrieve( + collection_name=COLLECTION_NAME, + ids=[item_id] + ) + if existing and len(existing) > 0: + continue + except: + pass + + embedding = get_embedding(content) + if embedding is None: + continue + + metadata = json.loads(metadata_json) if metadata_json else {} + + point = PointStruct( + id=item_id, + vector=embedding, + payload={ + "content": content, + "source": source, + "chunk_index": chunk_index, + "total_chunks": total_chunks, + "timestamp": timestamp, + "metadata": metadata_json + } + ) + + qdrant_client.upsert( + collection_name=COLLECTION_NAME, + points=[point] + ) + restored += 1 + + return {"success": True, "message": f"恢复完成,恢复了 {restored} 条", "count": restored} + + except Exception as e: + print(f"❌ 恢复失败: {e}") + return {"success": False, "error": str(e)} + + +def add_to_knowledge_base( + content: str, + source: str = "user_input", + metadata: Optional[Dict] = None +) -> Dict[str, Any]: + print(f"\n📝 [知识库添加] 开始") + print(f" 📝 内容长度: {len(content)} 字符") + print(f" 🔧 切分配置: chunk_size={CHUNK_SIZE}, overlap={CHUNK_OVERLAP}") + + if qdrant_client is None: + return {"success": False, "error": "Qdrant未连接"} + + try: + chunks = chunk_document(content, CHUNK_SIZE, CHUNK_OVERLAP) + print(f" 📋 切分为 {len(chunks)} 个片段 (每段约{CHUNK_SIZE}字符)") + + points = [] + for i, chunk in enumerate(chunks): + embedding = get_embedding(chunk) + if embedding is None: + continue + + chunk_id = generate_uuid_from_string(f"{source}_{i}_{chunk[:100]}") + + point_metadata = { + "content": chunk, + "source": source, + "chunk_index": i, + "total_chunks": len(chunks), + "timestamp": datetime.now().isoformat() + } + if metadata: + point_metadata.update(metadata) + + points.append( + PointStruct( + id=chunk_id, + vector=embedding, + payload=point_metadata + ) + ) + + backup_to_sqlite( + item_id=chunk_id, + content=chunk, + source=source, + chunk_index=i, + total_chunks=len(chunks), + metadata=metadata, + timestamp=point_metadata["timestamp"] + ) + + if points: + qdrant_client.upsert( + collection_name=COLLECTION_NAME, + points=points + ) + print(f" ✅ 成功添加 {len(points)} 个片段") + return { + "success": True, + "chunks_added": len(points), + "total_chunks": len(chunks), + "message": f"成功添加 {len(points)} 个片段 (每段约{CHUNK_SIZE}字符)" + } + else: + return {"success": False, "error": "没有生成有效的向量片段"} + + except Exception as e: + print(f" ❌ 添加失败: {e}") + import traceback + traceback.print_exc() + return {"success": False, "error": str(e)} + + +def search_knowledge_base( + query: str, + limit: int = DEFAULT_LIMIT, + score_threshold: float = DEFAULT_SCORE_THRESHOLD +) -> Dict[str, Any]: + """ + 搜索知识库 - 支持自定义limit和阈值 + """ + print(f"\n🔍 [知识库搜索] 开始") + print(f" 📝 查询: {query}") + print(f" 📊 限制: {limit} 条 (可自定义)") + print(f" 🎯 阈值: {score_threshold}") + + if qdrant_client is None: + return {"success": False, "error": "Qdrant未连接"} + + try: + query_vector = get_embedding(query) + if query_vector is None: + return {"success": False, "error": "查询向量化失败"} + + search_result = qdrant_client.query_points( + collection_name=COLLECTION_NAME, + query=query_vector, + limit=limit, + score_threshold=score_threshold + ) + + results = [] + for point in search_result.points: + results.append({ + "id": point.id, + "score": point.score, + "content": point.payload.get("content", ""), + "source": point.payload.get("source", "未知"), + "chunk_index": point.payload.get("chunk_index", 0), + "total_chunks": point.payload.get("total_chunks", 0), + "metadata": point.payload + }) + + print(f" ✅ 找到 {len(results)} 条结果 (limit={limit}, 阈值={score_threshold})") + return { + "success": True, + "query": query, + "results": results, + "count": len(results), + "limit": limit, + "threshold": score_threshold + } + + except Exception as e: + print(f" ❌ 搜索失败: {e}") + return {"success": False, "error": str(e)} + + +def delete_from_knowledge_base(point_id: str) -> Dict[str, Any]: + if qdrant_client is None: + return {"success": False, "error": "Qdrant未连接"} + + try: + qdrant_client.delete( + collection_name=COLLECTION_NAME, + points_selector=[point_id] + ) + delete_backup_from_sqlite(point_id) + return {"success": True, "deleted_id": point_id} + except Exception as e: + return {"success": False, "error": str(e)} + + +def delete_all_knowledge() -> Dict[str, Any]: + if qdrant_client is None: + return {"success": False, "error": "Qdrant未连接"} + + try: + qdrant_client.delete_collection(collection_name=COLLECTION_NAME) + qdrant_client.create_collection( + collection_name=COLLECTION_NAME, + vectors_config=VectorParams( + size=EMBEDDING_DIM, + distance=Distance.COSINE + ) + ) + conn = sqlite3.connect(SQLITE_DB_PATH) + cursor = conn.cursor() + cursor.execute('DELETE FROM knowledge_backup') + conn.commit() + conn.close() + return {"success": True, "message": "知识库已清空"} + except Exception as e: + return {"success": False, "error": str(e)} + + +def list_all_knowledge(limit: int = 100) -> Dict[str, Any]: + if qdrant_client is None: + return {"success": False, "error": "Qdrant未连接"} + + try: + scroll_result = qdrant_client.scroll( + collection_name=COLLECTION_NAME, + limit=limit, + with_payload=True, + with_vectors=False + ) + + points = scroll_result[0] + results = [] + for point in points: + results.append({ + "id": point.id, + "content": point.payload.get("content", ""), + "source": point.payload.get("source", "未知"), + "timestamp": point.payload.get("timestamp", ""), + "chunk_index": point.payload.get("chunk_index", 0) + }) + + return {"success": True, "items": results, "count": len(results)} + except Exception as e: + return {"success": False, "error": str(e)} + + +def generate_conclusion_from_web(web_result: Dict, user_question: str) -> str: + print(f" 📝 生成网络搜索结论...") + + results_text = "\n".join([ + f"- {r.get('title', '')}: {r.get('snippet', '')}" + for r in web_result.get("results", [])[:5] + ]) + + prompt = f"""请根据以下网络搜索结果,针对用户问题生成一个100-1000字的结论。 + +用户问题:{user_question} + +搜索结果: +{results_text} + +请输出一个完整的结论,包含关键信息点,语言流畅自然。""" + + try: + response = call_qwen_direct( + [{"type": "text", "text": prompt}], + "你是一位专业的信息整理专家。", + model=QWEN35_FLASH + ) + if response.get("success"): + return response["result"] + except Exception as e: + print(f" ⚠️ 生成结论失败: {e}") + + return f"根据网络搜索,关于'{user_question}'的相关信息未能完整整理,请稍后重试。" + + +# ============ 高级检索工具 (实验版本) ============ +def search_knowledge_advanced( + query: str, + limit: int = ADVANCED_LIMIT, + score_threshold: float = ADVANCED_SCORE_THRESHOLD, + context_expand: int = ADVANCED_CONTEXT_EXPAND, + use_deepseek: bool = True +) -> Dict[str, Any]: + """ + 高级知识库检索 - 三层检索 + 上下文扩展 + """ + print(f"\n🔬 [高级检索] 开始 (实验版本)") + print(f" 📝 查询: {query}") + print(f" 📊 粗检限制: {limit} 条") + print(f" 🎯 粗检阈值: {score_threshold}") + print(f" 📋 上下文扩展: 前后各 {context_expand} 段") + + if qdrant_client is None: + return {"success": False, "error": "Qdrant未连接"} + + try: + # 第一层:粗检索 + print(f" ⏳ 第一层: 粗检索 (Top {limit}, 阈值 {score_threshold})...") + query_vector = get_embedding(query) + if query_vector is None: + return {"success": False, "error": "查询向量化失败"} + + search_result = qdrant_client.query_points( + collection_name=COLLECTION_NAME, + query=query_vector, + limit=limit, + score_threshold=score_threshold + ) + + raw_results = [] + for point in search_result.points: + raw_results.append({ + "id": point.id, + "score": point.score, + "content": point.payload.get("content", ""), + "source": point.payload.get("source", "未知"), + "chunk_index": point.payload.get("chunk_index", 0), + "total_chunks": point.payload.get("total_chunks", 0), + "metadata": point.payload + }) + + print(f" ✅ 粗检索找到 {len(raw_results)} 条") + + if len(raw_results) == 0: + return { + "success": True, + "query": query, + "stage": "coarse", + "results": [], + "count": 0, + "message": "粗检索无结果" + } + + # 第二层:精排序 + print(f" ⏳ 第二层: 精排序 (按分数重排)...") + sorted_results = sorted(raw_results, key=lambda x: x.get("score", 0), reverse=True) + + seen_contents = set() + dedup_results = [] + for r in sorted_results: + content_key = r["content"][:50] + if content_key not in seen_contents: + seen_contents.add(content_key) + dedup_results.append(r) + + print(f" ✅ 精排序完成,去重后 {len(dedup_results)} 条") + + # 第三层:上下文扩展 + if context_expand > 0 and len(dedup_results) > 0: + print(f" ⏳ 第三层: 上下文扩展 (前后各 {context_expand} 段)...") + + expanded_results = [] + expanded_ids = set() + + for r in dedup_results[:10]: + source = r["source"] + + if r["id"] not in expanded_ids: + expanded_ids.add(r["id"]) + expanded_results.append(r) + + try: + all_points = [] + scroll_offset = None + + while True: + try: + scroll_result = qdrant_client.scroll( + collection_name=COLLECTION_NAME, + limit=100, + with_payload=True, + with_vectors=False, + offset=scroll_offset + ) + except Exception as scroll_error: + print(f" ⚠️ scroll 失败: {scroll_error}") + break + + points_batch = scroll_result[0] + if not points_batch: + break + + all_points.extend(points_batch) + scroll_offset = scroll_result[1] + + if len(points_batch) < 100: + break + + source_chunks = sorted( + [p for p in all_points if p.payload.get("source") == source], + key=lambda p: p.payload.get("chunk_index", 0) + ) + + current_pos = -1 + for i, p in enumerate(source_chunks): + if p.id == r["id"]: + current_pos = i + break + + if current_pos != -1: + start = max(0, current_pos - context_expand) + end = min(len(source_chunks), current_pos + context_expand + 1) + + for i in range(start, end): + if i != current_pos: + p = source_chunks[i] + if p.id not in expanded_ids: + expanded_ids.add(p.id) + expanded_results.append({ + "id": p.id, + "score": r["score"] * 0.9, + "content": p.payload.get("content", ""), + "source": source, + "chunk_index": p.payload.get("chunk_index", 0), + "total_chunks": len(source_chunks), + "metadata": p.payload, + "expanded": True, + "from_id": r["id"] + }) + except Exception as e: + print(f" ⚠️ 上下文扩展失败: {e}") + continue + + print(f" ✅ 上下文扩展完成,共 {len(expanded_results)} 条 (含上下文)") + + final_results = [] + final_ids = set() + + for r in dedup_results: + if r["id"] not in final_ids: + final_ids.add(r["id"]) + final_results.append(r) + + for r in expanded_results: + if r["id"] not in final_ids: + final_ids.add(r["id"]) + final_results.append(r) + + print(f" ✅ 最终结果: {len(final_results)} 条") + results = final_results + else: + results = dedup_results + + if use_deepseek and len(results) > 0: + return smart_query_with_deepseek_advanced(query, results) + + return { + "success": True, + "query": query, + "stage": "advanced", + "results": results, + "count": len(results), + "coarse_count": len(raw_results), + "dedup_count": len(dedup_results), + "expanded": context_expand > 0, + "config": { + "limit": limit, + "threshold": score_threshold, + "context_expand": context_expand + } + } + + except Exception as e: + print(f" ❌ 高级检索失败: {e}") + import traceback + traceback.print_exc() + return {"success": False, "error": str(e)} + + +def smart_query_with_deepseek_advanced(query: str, results: List[dict]) -> Dict[str, Any]: + """高级检索的DeepSeek整理""" + print(f" 🤖 调用DeepSeek整理高级检索结果...") + + docs_text = "\n\n".join([ + f"【片段{i+1}】\n{r['content']}\n(来源: {r.get('source', '未知')}, 分数: {r.get('score', 0):.3f})" + for i, r in enumerate(results[:10]) + ]) + + deepseek_prompt = f"""请根据以下检索到的知识库内容,回答用户问题。 + +用户问题:{query} + +检索到的相关知识 ({len(results)}条): +{docs_text} + +请按以下格式输出: +【问题分析】 +(分析用户问题的核心诉求和关键点) + +【问题结论】 +(基于检索内容给出的回答,要准确、简洁) + +【检索结果摘要】 +(总结检索到的信息量和相关度) + +【原始向量数据】 +(附上所有检索到的原始文档片段)""" + + try: + ds_response = call_qwen_direct( + [{"type": "text", "text": deepseek_prompt}], + "你是一位专业的知识整理助手,擅长从检索结果中提取关键信息并清晰回答。", + model=DEEPSEEK_MODEL + ) + + if ds_response.get("success"): + return { + "success": True, + "source": "knowledge_base_advanced", + "query": query, + "analysis": ds_response["result"], + "raw_results": results, + "count": len(results) + } + except Exception as e: + print(f" ⚠️ DeepSeek整理失败: {e}") + + return { + "success": True, + "source": "knowledge_base_advanced_raw", + "query": query, + "results": results, + "count": len(results) + } + + +def smart_query_with_deepseek( + user_question: str, + use_deepseek: bool = True, + limit: int = DEFAULT_LIMIT, + score_threshold: float = DEFAULT_SCORE_THRESHOLD +) -> Dict[str, Any]: + """智能查询:先检索知识库,可选择是否用DeepSeek整理""" + print(f"\n🧠 [智能查询] 开始") + print(f" 📝 用户问题: {user_question}") + print(f" 🤖 使用DeepSeek整理: {use_deepseek}") + print(f" 📊 检索限制: {limit} 条") + print(f" 🎯 检索阈值: {score_threshold}") + + search_result = search_knowledge_base(user_question, limit=limit, score_threshold=score_threshold) + + if not search_result.get("success"): + print(f" ⚠️ 知识库搜索失败,进行联网搜索...") + web_result = searcher.search(user_question, max_results=5) + + if web_result.get("success") and web_result.get("results"): + conclusion = generate_conclusion_from_web(web_result, user_question) + add_to_knowledge_base(conclusion, source="web_search_" + datetime.now().strftime("%Y%m%d")) + return { + "success": True, + "source": "web_search_and_saved", + "conclusion": conclusion, + "web_results": web_result.get("results", []) + } + else: + return {"success": False, "error": "知识库和网络搜索均未找到相关信息"} + + if search_result["count"] == 0: + print(f" ⚠️ 知识库无结果,进行联网搜索...") + web_result = searcher.search(user_question, max_results=5) + + if web_result.get("success") and web_result.get("results"): + conclusion = generate_conclusion_from_web(web_result, user_question) + add_to_knowledge_base(conclusion, source="web_search_" + datetime.now().strftime("%Y%m%d")) + return { + "success": True, + "source": "web_search_and_saved", + "conclusion": conclusion, + "web_results": web_result.get("results", []) + } + else: + return { + "success": True, + "source": "knowledge_base_empty", + "message": "知识库中暂无相关内容,建议添加" + } + + if not use_deepseek: + print(f" 📌 直接返回原始检索结果(未使用DeepSeek)") + return { + "success": True, + "source": "knowledge_base_raw", + "query": user_question, + "results": search_result["results"], + "count": search_result["count"], + "limit": limit, + "threshold": score_threshold + } + + print(f" 🤖 调用DeepSeek整理结果...") + + docs_text = "\n\n".join([ + f"【片段{i+1}】\n{r['content']}\n(来源: {r.get('source', '未知')}, 分数: {r.get('score', 0):.3f})" + for i, r in enumerate(search_result["results"]) + ]) + + deepseek_prompt = f"""请根据以下检索到的知识库内容,回答用户问题。 + +用户问题:{user_question} + +检索到的相关知识 ({search_result['count']}条): +{docs_text} + +请按以下格式输出: +【问题分析】 +(分析用户问题的核心诉求和关键点) + +【问题结论】 +(基于检索内容给出的回答,要准确、简洁) + +【原始向量数据】 +(附上所有检索到的原始文档片段)""" + + try: + ds_response = call_qwen_direct( + [{"type": "text", "text": deepseek_prompt}], + "你是一位专业的知识整理助手,擅长从检索结果中提取关键信息并清晰回答。", + model=DEEPSEEK_MODEL + ) + + if ds_response.get("success"): + return { + "success": True, + "source": "knowledge_base", + "query": user_question, + "analysis": ds_response["result"], + "raw_results": search_result["results"], + "count": search_result["count"] + } + except Exception as e: + print(f" ⚠️ DeepSeek整理失败: {e}") + + return { + "success": True, + "source": "knowledge_base_raw", + "query": user_question, + "results": search_result["results"], + "count": search_result["count"] + } + + +def init_knowledge_base_from_file(file_path: str) -> Dict[str, Any]: + print(f"\n📂 [初始化知识库] 开始") + print(f" 📂 文件: {file_path}") + print(f" 🔧 切分配置: chunk_size={CHUNK_SIZE}, overlap={CHUNK_OVERLAP}") + + if not os.path.exists(file_path): + return {"success": False, "error": f"文件不存在: {file_path}"} + + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + sections = re.split(r'\n##\s+', content) + + added = 0 + for section in sections: + if len(section.strip()) < 20: + continue + lines = section.strip().split('\n') + title = lines[0] if lines else "未命名" + body = '\n'.join(lines[1:]) if len(lines) > 1 else section + + result = add_to_knowledge_base( + content=body, + source=f"knowledge_base_md_{title[:20]}", + metadata={"title": title, "file": os.path.basename(file_path)} + ) + if result.get("success"): + added += result.get("chunks_added", 0) + + print(f" ✅ 初始化完成,添加了 {added} 个片段") + return {"success": True, "chunks_added": added} + + except Exception as e: + print(f" ❌ 初始化失败: {e}") + return {"success": False, "error": str(e)} + + +def sync_sqlite_to_qdrant() -> Dict[str, Any]: + print(f"\n🔄 [同步SQLite到Qdrant] 开始") + + if qdrant_client is None: + return {"success": False, "error": "Qdrant未连接"} + + try: + conn = sqlite3.connect(SQLITE_DB_PATH) + cursor = conn.cursor() + cursor.execute('SELECT id, content, source, chunk_index, total_chunks, timestamp, metadata FROM knowledge_backup') + rows = cursor.fetchall() + conn.close() + + if not rows: + return {"success": True, "message": "备份为空,无需同步", "count": 0} + + synced = 0 + for row in rows: + item_id, content, source, chunk_index, total_chunks, timestamp, metadata_json = row + + try: + existing = qdrant_client.retrieve( + collection_name=COLLECTION_NAME, + ids=[item_id] + ) + if existing and len(existing) > 0: + continue + except: + pass + + embedding = get_embedding(content) + if embedding is None: + continue + + metadata = json.loads(metadata_json) if metadata_json else {} + + point = PointStruct( + id=item_id, + vector=embedding, + payload={ + "content": content, + "source": source, + "chunk_index": chunk_index, + "total_chunks": total_chunks, + "timestamp": timestamp, + "metadata": metadata_json + } + ) + + qdrant_client.upsert( + collection_name=COLLECTION_NAME, + points=[point] + ) + synced += 1 + + return {"success": True, "message": f"同步完成,同步了 {synced} 条", "count": synced} + + except Exception as e: + print(f"❌ 同步失败: {e}") + return {"success": False, "error": str(e)} + + +# ============ keyword_split 函数 ============ +def keyword_split( + query: str, + count: int = 5, + mode: str = "search" +) -> Dict[str, Any]: + print(f"\n🔑 [关键词拆分] 开始") + print(f" 📝 查询: {query}") + print(f" 📊 数量: {count}") + print(f" 📋 模式: {mode}") + + mode_prompts = { + "search": "请将以下查询拆分为多个适合搜索引擎的关键词组合。每个组合应该能独立检索出相关信息。", + "file": "请将以下查询拆分为多个适合文件搜索的关键词组合。每个组合应该能匹配文件名或内容。", + "knowledge": "请将以下查询拆分为多个适合知识库检索的关键词组合。每个组合应该能命中知识库中的相关片段。" + } + + mode_prompt = mode_prompts.get(mode, mode_prompts["search"]) + + deepseek_prompt = f""" +{mode_prompt} + +查询内容:{query} + +要求: +1. 拆分成 {count} 个关键词组合 +2. 每个组合用空格分隔多个关键词 +3. 组合之间要有差异,覆盖不同角度 +4. 关键词要简洁、精准 +5. 只输出关键词组合,每行一个,不要序号 + +示例: +查询:2026年中国的经济增长和气候变化影响 +输出: +中国 2026 经济增长 GDP +中国 气候变化 2026 影响 +2026 经济 气候 政策 +中国 2026 发展 环境 + +请输出 {count} 个关键词组合:""" + + try: + response = call_qwen_direct( + [{"type": "text", "text": deepseek_prompt}], + "你是一位信息检索专家,擅长将复杂查询拆分为精准的关键词组合。", + model=DEEPSEEK_MODEL + ) + + if response.get("success"): + lines = response["result"].strip().split('\n') + keywords = [] + for line in lines: + line = line.strip() + if line and not line.startswith(('示例', '输出', '查询', '要求')): + import re + line = re.sub(r'^[\d]+[\.、\s]+', '', line) + if line: + keywords.append(line) + + keywords = keywords[:count] + + return { + "success": True, + "original": query, + "keywords": keywords, + "count": len(keywords), + "mode": mode + } + except Exception as e: + print(f" ⚠️ 关键词拆分失败: {e}") + + return { + "success": True, + "original": query, + "keywords": [query], + "count": 1, + "mode": mode, + "fallback": True + } + + +# ============ 文档转换器 ============ + +class DocumentConverter: + def __init__(self, poppler_path: Optional[str] = None): + self.poppler_path = poppler_path + self._validate_poppler() + + def _validate_poppler(self) -> None: + if self.poppler_path and not os.path.exists(self.poppler_path): + raise FileNotFoundError(f"poppler路径不存在: {self.poppler_path}") + + def _ensure_directory(self, dir_path: str) -> str: + path = Path(dir_path) + path.mkdir(parents=True, exist_ok=True) + return str(path.absolute()) + + def docx_to_images( + self, + docx_path: str, + output_dir: Optional[str] = None, + dpi: int = 200, + image_format: str = "JPEG" + ) -> Dict[str, Any]: + print(f"\n📄 [DOCX转图片] 开始") + print(f" 📂 输入文件: {docx_path}") + print(f" 🔧 DPI: {dpi}, 格式: {image_format}") + + if not os.path.exists(docx_path): + return {"success": False, "error": f"文件不存在: {docx_path}"} + + if not output_dir: + output_dir = os.path.join(OUTPUT_DIR, "docx_images") + output_dir = self._ensure_directory(output_dir) + print(f" 📁 输出目录: {output_dir}") + + with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp_file: + pdf_path = tmp_file.name + + try: + print(f" ⏳ 步骤1: DOCX → PDF") + docx_to_pdf(docx_path, pdf_path) + print(f" ✅ PDF生成成功") + + print(f" ⏳ 步骤2: PDF → {image_format}") + images = convert_from_path(pdf_path, dpi=dpi, poppler_path=self.poppler_path) + print(f" ✅ 解析出 {len(images)} 页") + + base_name = Path(docx_path).stem + saved_files = [] + + for i, img in enumerate(images, 1): + ext = "jpg" if image_format.upper() == "JPEG" else "png" + img_path = Path(output_dir) / f"{base_name}_第{i}页.{ext}" + if image_format.upper() == "JPEG": + img.save(str(img_path), 'JPEG', quality=95) + else: + img.save(str(img_path), 'PNG') + saved_files.append(str(img_path)) + print(f" ✅ 保存: {img_path.name}") + + result = { + "success": True, + "message": f"转换成功,共生成 {len(images)} 张图片", + "output_directory": output_dir, + "file_count": len(images), + "saved_files": saved_files, + "files": [os.path.basename(f) for f in saved_files] + } + print(f" ✅ 转换完成: {result['message']}") + return result + except Exception as e: + print(f" ❌ 转换失败: {e}") + return {"success": False, "error": str(e), "message": f"转换失败: {e}"} + finally: + if os.path.exists(pdf_path): + os.remove(pdf_path) + print(f" 🗑️ 清理临时PDF") + + def pdf_to_images( + self, + pdf_path: str, + output_dir: Optional[str] = None, + dpi: int = 200, + image_format: str = "JPEG", + first_page: Optional[int] = None, + last_page: Optional[int] = None + ) -> Dict[str, Any]: + print(f"\n📄 [PDF转图片] 开始") + print(f" 📂 输入文件: {pdf_path}") + print(f" 🔧 DPI: {dpi}, 格式: {image_format}") + if first_page: + print(f" 📄 页码范围: {first_page} - {last_page or '结束'}") + + if not os.path.exists(pdf_path): + return {"success": False, "error": f"文件不存在: {pdf_path}"} + + if not output_dir: + output_dir = os.path.join(OUTPUT_DIR, "pdf_images") + output_dir = self._ensure_directory(output_dir) + print(f" 📁 输出目录: {output_dir}") + + try: + print(f" ⏳ 正在转换 PDF → {image_format}") + images = convert_from_path( + pdf_path, dpi=dpi, poppler_path=self.poppler_path, + first_page=first_page, last_page=last_page + ) + print(f" ✅ 解析出 {len(images)} 页") + + base_name = Path(pdf_path).stem + saved_files = [] + + for i, img in enumerate(images, 1): + ext = "jpg" if image_format.upper() == "JPEG" else "png" + img_path = Path(output_dir) / f"{base_name}_第{i}页.{ext}" + if image_format.upper() == "JPEG": + img.save(str(img_path), 'JPEG', quality=95) + else: + img.save(str(img_path), 'PNG') + saved_files.append(str(img_path)) + print(f" ✅ 保存: {img_path.name}") + + result = { + "success": True, + "message": f"转换成功,共生成 {len(images)} 张图片", + "output_directory": output_dir, + "file_count": len(images), + "saved_files": saved_files, + "files": [os.path.basename(f) for f in saved_files] + } + print(f" ✅ 转换完成: {result['message']}") + return result + except Exception as e: + print(f" ❌ 转换失败: {e}") + return {"success": False, "error": str(e), "message": f"转换失败: {e}"} + + def table_to_csv( + self, + table_path: str, + output_dir: Optional[str] = None, + csv_name: Optional[str] = None, + encoding: str = "utf-8-sig", + sheet_name: Optional[Union[str, int]] = None + ) -> Dict[str, Any]: + print(f"\n📊 [表格转CSV] 开始") + print(f" 📂 输入文件: {table_path}") + print(f" 🔧 编码: {encoding}") + if sheet_name: + print(f" 📄 工作表: {sheet_name}") + + if not os.path.exists(table_path): + return {"success": False, "error": f"文件不存在: {table_path}"} + + if not output_dir: + output_dir = os.path.join(OUTPUT_DIR, "csv") + output_dir = self._ensure_directory(output_dir) + print(f" 📁 输出目录: {output_dir}") + + if csv_name is None: + csv_name = Path(table_path).stem + + output_path = Path(output_dir) / f"{csv_name}.csv" + + try: + ext = Path(table_path).suffix.lower() + print(f" 📋 文件格式: {ext}") + print(f" ⏳ 正在读取表格...") + + if ext in ['.xlsx', '.xls']: + df = pd.read_excel(table_path, sheet_name=sheet_name) + if isinstance(df, dict): + print(f" 📋 检测到多个工作表: {list(df.keys())}") + if sheet_name and sheet_name in df: + df = df[sheet_name] + print(f" 📋 使用指定工作表: {sheet_name}") + else: + first_sheet = list(df.keys())[0] + df = df[first_sheet] + print(f" 📋 使用第一个工作表: {first_sheet}") + if not isinstance(df, pd.DataFrame): + raise ValueError(f"无法解析为DataFrame,类型: {type(df)}") + elif ext == '.csv': + df = pd.read_csv(table_path, encoding=encoding) + if isinstance(df, dict): + df = pd.DataFrame(df) + else: + return {"success": False, "error": f"不支持的文件格式: {ext}"} + + print(f" 📋 读取到 {len(df)} 行 × {len(df.columns)} 列") + print(f" 📋 列名: {list(df.columns)[:5]}{'...' if len(df.columns) > 5 else ''}") + + print(f" ⏳ 正在保存CSV...") + df.to_csv(output_path, index=False, encoding=encoding) + + with open(output_path, 'r', encoding=encoding) as f: + csv_content = f.read() + + result = { + "success": True, + "message": f"转换成功,{len(df)} 行数据已保存", + "output_file": str(output_path), + "row_count": len(df), + "column_count": len(df.columns), + "columns": list(df.columns), + "preview": df.head(5).to_dict(orient='records') if len(df) > 0 else [], + "csv_content": csv_content, + "csv_lines": csv_content.split('\n') + } + print(f" ✅ 转换完成: {result['message']}") + return result + + except Exception as e: + print(f" ❌ 转换失败: {e}") + import traceback + print(f" 📋 详细错误:\n{traceback.format_exc()}") + return {"success": False, "error": str(e), "message": f"转换失败: {e}"} + + +# ============ 搜索器 ============ + +class WebSearcher: + def __init__(self, api_key: str, api_url: str): + self.api_key = api_key + self.api_url = api_url + + def search(self, query: str, max_results: int = 10) -> Dict[str, Any]: + print(f"\n🌐 [搜索] 开始") + print(f" 📝 查询: {query}") + print(f" 📊 最大结果: {max_results}") + + try: + data = json.dumps({"query": query}).encode() + req = urllib.request.Request( + self.api_url, + data=data, + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + ) + print(f" ⏳ 正在请求搜索API...") + resp = urllib.request.urlopen(req, timeout=30) + result = json.loads(resp.read().decode()) + formatted = self._format_results(result, max_results) + print(f" ✅ 搜索完成,找到 {len(formatted)} 条结果") + return { + "success": True, + "query": query, + "result_count": len(formatted), + "results": formatted, + "raw": result + } + except Exception as e: + print(f" ❌ 搜索失败: {e}") + return { + "success": False, + "query": query, + "error": str(e), + "results": [] + } + + def _format_results(self, result: dict, max_results: int) -> List[Dict[str, str]]: + formatted = [] + if not result: + return formatted + + data = result.get("data", []) + if not data: + if isinstance(result, list): + data = result + elif "results" in result: + data = result.get("results", []) + + if not data: + return formatted + + for item in data[:max_results]: + if isinstance(item, dict): + formatted.append({ + "title": item.get("title", "") or item.get("name", "") or "", + "snippet": item.get("snippet", "") or item.get("description", "") or item.get("content", "") or "", + "url": item.get("url", "") or item.get("link", "") or "", + "source": item.get("source", "") or item.get("site", "") or "" + }) + elif isinstance(item, str): + formatted.append({ + "title": item, + "snippet": "", + "url": "", + "source": "" + }) + + return formatted + + +# ============ OCR 专用函数 ============ + +def ocr_image( + image_path: Optional[str] = None, + image_url: Optional[str] = None, + image_base64: Optional[str] = None, + need_location: bool = True, + return_markdown: bool = False, + enable_cls: bool = False +) -> Dict[str, Any]: + print(f"\n📝 [UAPI OCR识别] 开始") + + sources = [image_path, image_url, image_base64] + provided = [s for s in sources if s] + if not provided: + return {"success": False, "error": "请提供 image_path、image_url 或 image_base64 中的一个"} + if len(provided) > 1: + return {"success": False, "error": "只能选择一种输入方式,请勿同时提交多个"} + + try: + data = {} + files = {} + + if image_path: + if not os.path.exists(image_path): + return {"success": False, "error": f"文件不存在: {image_path}"} + file_size = os.path.getsize(image_path) + if file_size > 10 * 1024 * 1024: + return {"success": False, "error": f"图片过大 ({file_size/1024/1024:.2f}MB),最大10MB"} + print(f" 📂 使用本地文件: {image_path} ({file_size/1024/1024:.2f}MB)") + with open(image_path, 'rb') as f: + files['file'] = (os.path.basename(image_path), f.read(), 'image/jpeg') + + elif image_url: + print(f" 🔗 使用URL: {image_url}") + data['url'] = image_url + + elif image_base64: + if image_base64.startswith('data:image'): + image_base64 = image_base64.split(',')[1] + print(f" 📝 使用Base64,长度: {len(image_base64)} 字符") + data['image_base64'] = image_base64 + + data['need_location'] = str(need_location).lower() + data['return_markdown'] = str(return_markdown).lower() + data['enable_cls'] = str(enable_cls).lower() + + headers = {"Authorization": f"Bearer {SEARCH_API_KEY}"} + + print(f" ⏳ 正在识别文字...") + + if files: + response = requests.post(OCR_API_URL, headers=headers, data=data, files=files, timeout=60) + else: + response = requests.post(OCR_API_URL, headers=headers, data=data, timeout=60) + + print(f" 📊 HTTP状态码: {response.status_code}") + + if response.status_code != 200: + return {"success": False, "error": f"HTTP {response.status_code}: {response.text}"} + + result = response.json() + print(f" ✅ OCR识别成功") + + return { + "success": True, + "text": result.get("text", ""), + "plain_text": result.get("plain_text", ""), + "words_result": result.get("words_result", []), + "words_result_num": result.get("words_result_num", 0), + "need_location": result.get("need_location", need_location), + "timing": result.get("timing", {}), + "summary": result.get("summary", {}), + "image": result.get("image", {}), + "markdown": result.get("markdown", ""), + "raw": result + } + + except Exception as e: + print(f" ❌ OCR失败: {e}") + import traceback + traceback.print_exc() + return {"success": False, "error": str(e)} + + +def ocr_advanced( + image_path: Optional[str] = None, + image_url: Optional[str] = None, + image_base64: Optional[str] = None, + prompt: str = "请提取图像中的全部文本内容。", + min_pixels: Optional[int] = None, + max_pixels: Optional[int] = None, + return_coordinates: bool = False +) -> Dict[str, Any]: + print(f"\n📝 [Qwen3.5-OCR高级识别] 开始") + + sources = [image_path, image_url, image_base64] + provided = [s for s in sources if s] + if not provided: + return {"success": False, "error": "请提供 image_path、image_url 或 image_base64 中的一个"} + if len(provided) > 1: + return {"success": False, "error": "只能选择一种输入方式,请勿同时提交多个"} + + try: + if image_path: + if not os.path.exists(image_path): + return {"success": False, "error": f"文件不存在: {image_path}"} + file_size = os.path.getsize(image_path) + if file_size > 10 * 1024 * 1024: + return {"success": False, "error": f"图片过大 ({file_size/1024/1024:.2f}MB),最大10MB"} + print(f" 📂 使用本地文件: {image_path} ({file_size/1024/1024:.2f}MB)") + with open(image_path, 'rb') as f: + img_data = f.read() + img_b64 = base64.b64encode(img_data).decode('utf-8') + ext = os.path.splitext(image_path)[1].lower() + if ext in ['.png']: + mime = "image/png" + elif ext in ['.jpg', '.jpeg']: + mime = "image/jpeg" + elif ext in ['.webp']: + mime = "image/webp" + else: + mime = "image/jpeg" + image_uri = f"data:{mime};base64,{img_b64}" + print(f" 📝 Base64长度: {len(img_b64)} 字符") + + elif image_url: + print(f" 🔗 使用URL: {image_url}") + image_uri = image_url + + elif image_base64: + if image_base64.startswith('data:image'): + image_uri = image_base64 + else: + image_uri = f"data:image/jpeg;base64,{image_base64}" + print(f" 📝 使用Base64,长度: {len(image_base64)} 字符") + + content = [ + {"type": "image_url", "image_url": {"url": image_uri}}, + {"type": "text", "text": prompt} + ] + + params = { + "model": QWEN35_OCR, + "messages": [{"role": "user", "content": content}], + "modalities": ["text"], + "stream": False, + "timeout": 120, + } + + extra_body = {} + if min_pixels: + extra_body["min_pixels"] = min_pixels + if max_pixels: + extra_body["max_pixels"] = max_pixels + if extra_body: + params["extra_body"] = extra_body + + print(f" ⏳ 正在调用Qwen3.5-OCR模型...") + print(f" 📝 提示词: {prompt[:100]}...") + + completion = client.chat.completions.create(**params) + + result = completion.choices[0].message.content + usage = { + "prompt_tokens": completion.usage.prompt_tokens, + "completion_tokens": completion.usage.completion_tokens, + "total_tokens": completion.usage.total_tokens + } + + print(f" ✅ OCR识别成功") + print(f" 📊 Token用量: {usage}") + + estimated_cost = usage["prompt_tokens"] / 1000000 * 0.5 + print(f" 💰 估算费用: {estimated_cost:.6f}元") + + return { + "success": True, + "text": result, + "usage": usage, + "estimated_cost": estimated_cost, + "model": QWEN35_OCR + } + + except Exception as e: + print(f" ❌ OCR失败: {e}") + import traceback + traceback.print_exc() + return {"success": False, "error": str(e)} + + +# ============ 画图工具函数 ============ + +def generate_image( + prompt: str, + model: str, + size: str = "1024*1024", + n: int = 1, + negative_prompt: Optional[str] = None, + prompt_extend: bool = True +) -> Dict[str, Any]: + print(f"\n🎨 [生成图片]") + print(f" 📝 提示词: {prompt[:100]}...") + print(f" 🤖 模型: {model}") + print(f" 📐 尺寸: {size}") + print(f" 📊 数量: {n}") + if negative_prompt: + print(f" 🚫 负面提示词: {negative_prompt[:50]}...") + + payload = { + "model": model, + "input": { + "messages": [ + { + "role": "user", + "content": [ + {"text": prompt} + ] + } + ] + }, + "parameters": { + "size": size, + "n": n, + "prompt_extend": prompt_extend + } + } + + if negative_prompt: + payload["parameters"]["negative_prompt"] = negative_prompt + + headers = { + "Authorization": f"Bearer {DASHSCOPE_API_KEY}", + "Content-Type": "application/json" + } + + try: + response = requests.post( + IMAGE_GEN_URL, + headers=headers, + json=payload, + timeout=180 + ) + + if response.status_code != 200: + return { + "success": False, + "error": f"HTTP {response.status_code}: {response.text}" + } + + result = response.json() + + image_urls = [] + output = result.get("output", {}) + + if "choices" in output: + for choice in output.get("choices", []): + message = choice.get("message", {}) + content = message.get("content", []) + for item in content: + if "image" in item: + image_urls.append(item["image"]) + elif "results" in output: + for item in output.get("results", []): + if "url" in item: + image_urls.append(item["url"]) + elif "image" in item: + image_urls.append(item["image"]) + elif "url" in output: + image_urls.append(output["url"]) + elif "image_url" in output: + image_urls.append(output["image_url"]) + + if not image_urls: + return { + "success": False, + "error": "未找到图片URL", + "raw_response": result + } + + saved_paths = [] + for i, url in enumerate(image_urls): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_prompt = "".join(c for c in prompt[:20] if c.isalnum() or c in " _-") + save_path = os.path.join(IMAGES_DIR, f"image_{safe_prompt}_{timestamp}_{i+1}.png") + + try: + img_resp = requests.get(url, timeout=60) + if img_resp.status_code == 200: + with open(save_path, 'wb') as f: + f.write(img_resp.content) + saved_paths.append(save_path) + print(f" ✅ 保存: {os.path.basename(save_path)}") + except Exception as e: + print(f" ⚠️ 下载失败: {e}") + + return { + "success": True, + "image_urls": image_urls, + "saved_paths": saved_paths, + "count": len(image_urls), + "raw_response": result + } + + except Exception as e: + print(f" ❌ 生成失败: {e}") + return {"success": False, "error": str(e)} + + +# ============ 音频生成函数 ============ + +def pcm_to_wav(pcm_data: bytes, sample_rate: int = 24000, channels: int = 1, sample_width: int = 2) -> bytes: + buffer = io.BytesIO() + with wave.open(buffer, 'wb') as wav_file: + wav_file.setnchannels(channels) + wav_file.setsampwidth(sample_width) + wav_file.setframerate(sample_rate) + wav_file.writeframes(pcm_data) + return buffer.getvalue() + + +def generate_audio_response( + prompt: str, + system_prompt: str = DEFAULT_SYSTEM_PROMPT, + voice: str = DEFAULT_OMNI_VOICE, + audio_format: str = "wav", + save_to_file: bool = True, + auto_play: bool = True, + model: str = QWEN35_FLASH +) -> Dict[str, Any]: + print(f"\n🎤 [Omni音频回复] 开始") + print(f" 📝 提示词: {prompt[:100]}...") + print(f" 🎤 音色: {voice}") + print(f" 🤖 模型: {model}") + print(f" 📁 格式: {audio_format}") + + if voice not in VOICE_LIST: + print(f" ⚠️ 音色 '{voice}' 不在支持列表中,使用默认音色 '{DEFAULT_OMNI_VOICE}'") + voice = DEFAULT_OMNI_VOICE + + try: + # 手写专用提示词,不使用默认提示词 + audio_system_prompt = f"""你是专业的语音朗读助手,你的唯一任务就是把用户提供的内容用音频朗读出来。 + +【你的工作】 +- 你只负责朗读,不负责回答或解释 +- 你必须原封不动地朗读用户提供的文本 +- 不能添加、删除或修改任何一个字 + +【你的输出】 +- 输出格式:音频(语音) +- 音色:{voice} +- 格式:{audio_format} +- 直接朗读,不要有任何前缀或后缀说明 + +【朗读要求】 +- 自然流畅 +- 语气要贴合内容 +- 标点符号处要适当停顿 + +用户要朗读的内容是: +{prompt} + +现在请直接朗读以上内容,不要做任何额外的解释。""" + + messages = [ + {"role": "system", "content": audio_system_prompt}, + {"role": "user", "content": f"朗读以下内容:{prompt}"} + ] + + print(f" ⏳ 正在调用Qwen API(音频模式)...") + print(f" 📝 使用手写的专用提示词(不使用默认提示词)") + + completion = client.chat.completions.create( + model=model, + messages=messages, + modalities=["text", "audio"], + audio={"voice": voice, "format": audio_format}, + stream=True, + stream_options={"include_usage": True}, + timeout=120, + ) + + text_parts = [] + pcm_data_parts = [] + usage = None + + for chunk in completion: + if chunk.choices: + delta = chunk.choices[0].delta + if delta and hasattr(delta, 'content') and delta.content: + text_parts.append(delta.content) + if delta and hasattr(delta, 'audio') and delta.audio: + audio_item = delta.audio + if isinstance(audio_item, dict): + audio_base64_str = audio_item.get('data', '') + if audio_base64_str: + try: + pcm_data_parts.append(base64.b64decode(audio_base64_str)) + except Exception as e: + print(f" ⚠️ 解码失败: {e}") + elif isinstance(audio_item, str): + try: + pcm_data_parts.append(base64.b64decode(audio_item)) + except Exception as e: + print(f" ⚠️ 解码失败: {e}") + + if chunk.usage: + usage = { + "prompt_tokens": chunk.usage.prompt_tokens, + "completion_tokens": chunk.usage.completion_tokens, + "total_tokens": chunk.usage.total_tokens + } + + full_text = "".join(text_parts) + print(f" 📝 文本内容: {full_text[:200]}...") + print(f" 🎤 音频块数: {len(pcm_data_parts)}") + + result = { + "success": True, + "text": full_text, + "audio_chunks": len(pcm_data_parts), + "usage": usage, + "model": model, + "voice": voice + } + + if save_to_file and pcm_data_parts: + try: + raw_pcm_data = b"".join(pcm_data_parts) + print(f" 📊 PCM数据总大小: {len(raw_pcm_data)} 字节") + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_prompt = "".join(c for c in prompt[:20] if c.isalnum() or c in " _-") + + SAMPLE_RATE = 24000 + CHANNELS = 1 + SAMPLE_WIDTH = 2 + + wav_data = pcm_to_wav(raw_pcm_data, SAMPLE_RATE, CHANNELS, SAMPLE_WIDTH) + save_path = os.path.join(AUDIO_DIR, f"audio_{safe_prompt}_{timestamp}.wav") + + with open(save_path, 'wb') as f: + f.write(wav_data) + + print(f" ✅ WAV已保存: {save_path}") + print(f" 📊 WAV文件大小: {len(wav_data)} 字节") + print(f" 📊 参数: {SAMPLE_RATE}Hz, {CHANNELS}声道, {SAMPLE_WIDTH*8}bit") + + result["saved_path"] = save_path + result["file_size"] = len(wav_data) + result["sample_rate"] = SAMPLE_RATE + result["channels"] = CHANNELS + result["sample_width"] = SAMPLE_WIDTH + + if auto_play and _audio_lib is not None: + try: + print(f" 🎵 正在播放音频...") + aid = _audio_lib.play_from_file(save_path) + result["playback_id"] = aid + result["playback_status"] = "playing" + print(f" ✅ 播放中 (ID: {aid})") + except Exception as e: + print(f" ⚠️ 播放失败: {e}") + result["playback_error"] = str(e) + elif auto_play and _audio_lib is None: + print(f" ⚠️ ap_ds库未加载,无法播放") + result["playback_error"] = "ap_ds库未加载" + + except Exception as e: + print(f" ⚠️ 音频保存失败: {e}") + import traceback + traceback.print_exc() + result["save_error"] = str(e) + + return result + + except Exception as e: + print(f" ❌ 音频生成失败: {e}") + import traceback + traceback.print_exc() + return {"success": False, "error": str(e)} + + +def generate_tts_audio( + text: str, + voice: str = DEFAULT_TTS_VOICE, + model: str = TTS_FLASH, + format: str = "mp3", + sample_rate: int = 22050, + rate: float = 1.0, + pitch: float = 1.0, + volume: int = 50, + bit_rate: int = 32, + instruction: Optional[str] = None, + save_to_file: bool = True, + auto_play: bool = True, + save_filename: Optional[str] = None +) -> Dict[str, Any]: + print(f"\n🎤 [TTS生成] 开始") + print(f" 📝 文本: {text[:100]}...") + print(f" 🎤 音色: {voice}") + print(f" 🤖 模型: {model}") + print(f" 📁 格式: {format}") + print(f" ⚡ 语速: {rate}, 音调: {pitch}, 音量: {volume}") + if instruction: + print(f" 📝 指令: {instruction}") + + if not _dashscope_available: + return { + "success": False, + "error": "DashScope SDK 未加载,请安装: pip install dashscope" + } + + # 强制设置 API Key + dashscope.api_key = DASHSCOPE_API_KEY + + if len(text) > 20000: + return { + "success": False, + "error": f"文本过长({len(text)}字符),最多支持20000字符" + } + + try: + from dashscope.audio.tts_v2 import AudioFormat + + # 根据 format 和 sample_rate 选择对应的 AudioFormat + if format.lower() == "mp3": + bit_rate_str = "256KBPS" + if bit_rate <= 64: + bit_rate_str = "64KBPS" + elif bit_rate <= 128: + bit_rate_str = "128KBPS" + elif bit_rate <= 192: + bit_rate_str = "192KBPS" + else: + bit_rate_str = "256KBPS" + format_name = f"MP3_{sample_rate}HZ_MONO_{bit_rate_str}" + elif format.lower() == "wav": + format_name = f"WAV_{sample_rate}HZ_MONO_16BIT" + elif format.lower() == "pcm": + format_name = f"PCM_{sample_rate}HZ_MONO_16BIT" + elif format.lower() == "opus": + bit_rate_str = f"{bit_rate}KBPS" + format_name = f"OGG_OPUS_{sample_rate}HZ_MONO_{bit_rate_str}" + else: + format_name = "MP3_22050HZ_MONO_256KBPS" + + audio_format = getattr(AudioFormat, format_name, AudioFormat.MP3_22050HZ_MONO_256KBPS) + + synthesizer_params = { + "model": model, + "voice": voice, + "format": audio_format, + "volume": volume, + "speech_rate": rate, + "pitch_rate": pitch, + } + + if instruction: + synthesizer_params["instruction"] = instruction + + print(f" ⏳ 正在合成语音...") + print(f" 📊 使用格式: {format_name}") + print(f" 📊 参数: {synthesizer_params}") + synthesizer = SpeechSynthesizer(**synthesizer_params) + audio_data = synthesizer.call(text) + + print(f" ✅ 合成成功,音频大小: {len(audio_data)} 字节") + + result = { + "success": True, + "audio_size": len(audio_data), + "format": format, + "model": model, + "voice": voice, + "text": text[:200] + "..." if len(text) > 200 else text + } + + if save_to_file and audio_data: + try: + if save_filename: + save_path = os.path.join(AUDIO_DIR, save_filename) + if not save_path.endswith(f".{format}"): + save_path = f"{save_path}.{format}" + else: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_text = "".join(c for c in text[:20] if c.isalnum() or c in " _-") + save_path = os.path.join(AUDIO_DIR, f"tts_{safe_text}_{timestamp}.{format}") + + with open(save_path, 'wb') as f: + f.write(audio_data) + + print(f" ✅ 音频已保存: {save_path}") + result["saved_path"] = save_path + result["file_size"] = len(audio_data) + + if auto_play and _audio_lib is not None: + try: + print(f" 🎵 正在播放音频...") + aid = _audio_lib.play_from_file(save_path) + result["playback_id"] = aid + result["playback_status"] = "playing" + print(f" ✅ 播放中 (ID: {aid})") + except Exception as e: + print(f" ⚠️ 播放失败: {e}") + result["playback_error"] = str(e) + elif auto_play and _audio_lib is None: + print(f" ⚠️ ap_ds库未加载,无法播放") + result["playback_error"] = "ap_ds库未加载" + + except Exception as e: + print(f" ⚠️ 音频保存失败: {e}") + import traceback + traceback.print_exc() + result["save_error"] = str(e) + + return result + + except Exception as e: + print(f" ❌ TTS生成失败: {e}") + import traceback + traceback.print_exc() + return {"success": False, "error": str(e)} + + +# ============ 工具函数 ============ + +def encode_file(file_path: str) -> tuple: + print(f"\n📂 [编码文件] 开始") + print(f" 📂 文件路径: {file_path}") + + if not os.path.exists(file_path): + raise FileNotFoundError(f"文件不存在: {file_path}") + + file_size = os.path.getsize(file_path) + print(f" 📊 文件大小: {file_size} 字节 ({file_size/1024/1024:.2f}MB)") + + with open(file_path, "rb") as f: + data = f.read() + b64 = base64.b64encode(data).decode("utf-8") + + ext = file_path.lower() + if ext.endswith('.png'): + mime = "image/png" + elif ext.endswith(('.jpg', '.jpeg')): + mime = "image/jpeg" + elif ext.endswith('.webp'): + mime = "image/webp" + elif ext.endswith('.gif'): + mime = "image/gif" + elif ext.endswith('.bmp'): + mime = "image/bmp" + elif ext.endswith('.mp3'): + mime = "audio/mp3" + elif ext.endswith('.wav'): + mime = "audio/wav" + elif ext.endswith('.m4a'): + mime = "audio/m4a" + elif ext.endswith('.mp4'): + mime = "audio/mp4" + else: + mime = "application/octet-stream" + + print(f" ✅ MIME类型: {mime}") + print(f" ✅ Base64长度: {len(b64)} 字符") + print(f" ✅ Base64前50字符: {b64[:50]}...") + return b64, mime, len(data) + + +def build_multimodal_content( + prompt: str, + text: Optional[str] = None, + image_paths: Optional[List[str]] = None, + audio_path: Optional[str] = None, + video_frames: Optional[List[str]] = None +) -> tuple: + contents = [] + warnings = [] + has_multimodal = False + + if text: + contents.append({"type": "text", "text": text}) + print(f" ✅ 已添加文本") + + if image_paths: + for img_path in image_paths: + if not os.path.exists(img_path): + warnings.append(f"图片文件不存在: {img_path}") + continue + try: + b64, mime, _ = encode_file(img_path) + contents.append({ + "type": "image_url", + "image_url": {"url": f"data:{mime};base64,{b64}"} + }) + has_multimodal = True + print(f" ✅ 已加载图片: {os.path.basename(img_path)}") + except Exception as e: + warnings.append(f"加载图片失败 {img_path}: {str(e)}") + + if video_frames: + for frame_path in video_frames: + if not os.path.exists(frame_path): + warnings.append(f"视频帧文件不存在: {frame_path}") + continue + try: + b64, mime, _ = encode_file(frame_path) + contents.append({ + "type": "image_url", + "image_url": {"url": f"data:{mime};base64,{b64}"} + }) + has_multimodal = True + print(f" ✅ 已加载视频帧: {os.path.basename(frame_path)}") + except Exception as e: + warnings.append(f"加载视频帧失败 {frame_path}: {str(e)}") + + if audio_path: + if not os.path.exists(audio_path): + warnings.append(f"音频文件不存在: {audio_path}") + else: + try: + b64, mime, _ = encode_file(audio_path) + if "mp3" in mime: + audio_format = "mp3" + elif "wav" in mime: + audio_format = "wav" + elif "m4a" in mime: + audio_format = "m4a" + else: + audio_format = "mp3" + contents.append({ + "type": "input_audio", + "input_audio": { + "data": f"data:{mime};base64,{b64}", + "format": audio_format + } + }) + has_multimodal = True + print(f" ✅ 已加载音频: {os.path.basename(audio_path)}") + except Exception as e: + warnings.append(f"加载音频失败 {audio_path}: {str(e)}") + + if prompt: + contents.append({"type": "text", "text": prompt}) + print(f" ✅ 已添加提示词") + + return contents, has_multimodal, warnings + + +# ============ 画图关键词检测 ============ +DRAW_KEYWORDS = ["画", "生成图片", "绘制", "给我画", "画图", "出图", "生成图像", "画一张"] + + +def should_allow_tool_call(user_text: str) -> bool: + for kw in DRAW_KEYWORDS: + if kw in user_text: + return True + return False + + +def call_qwen_with_tools( + contents: List[dict], + system_prompt: str = DEFAULT_SYSTEM_PROMPT, + history: Optional[List[dict]] = None, + max_iterations: int = 3, + model: str = QWEN35_FLASH +) -> Dict[str, Any]: + user_text = "" + for item in contents: + if item.get("type") == "text": + user_text += item.get("text", "") + + allow_tools = should_allow_tool_call(user_text) + + print(f"\n🤖 [Qwen调用-带工具] 开始") + print(f" 📝 content数量: {len(contents)}") + print(f" 📝 用户文本: {user_text[:100]}...") + print(f" 🔧 允许工具调用: {allow_tools}") + print(f" 🤖 模型: {model}") + + has_multimodal = False + for item in contents: + if item.get("type") in ["image_url", "input_audio"]: + has_multimodal = True + break + + if has_multimodal or not allow_tools: + print(f" 📌 直接调用(无工具)") + return call_qwen_direct(contents, system_prompt, model) + + tools = [ + { + "type": "function", + "function": { + "name": "generate_image_wan", + "description": """生成图片(wan2.7-image标准版)。价格最便宜,优先使用!""", + "parameters": { + "type": "object", + "properties": { + "prompt": {"type": "string", "description": "图片描述"}, + "negative_prompt": {"type": "string", "description": "负面提示词"}, + "size": {"type": "string", "description": "尺寸", "default": "1024*1024"}, + "n": {"type": "integer", "description": "生成数量", "default": 1} + }, + "required": ["prompt"] + } + } + }, + { + "type": "function", + "function": { + "name": "generate_image_qwen", + "description": """生成图片(qwen-image-3.0-pro旗舰版)。价格较贵,需要文字渲染时用。""", + "parameters": { + "type": "object", + "properties": { + "prompt": {"type": "string", "description": "图片描述"}, + "negative_prompt": {"type": "string", "description": "负面提示词"}, + "size": {"type": "string", "description": "尺寸", "default": "1024*1024"}, + "n": {"type": "integer", "description": "生成数量", "default": 1} + }, + "required": ["prompt"] + } + } + }, + { + "type": "function", + "function": { + "name": "generate_image_wan_pro", + "description": """生成图片(wan2.7-image-pro旗舰版)。价格最贵,需要4K时用。""", + "parameters": { + "type": "object", + "properties": { + "prompt": {"type": "string", "description": "图片描述"}, + "negative_prompt": {"type": "string", "description": "负面提示词"}, + "size": {"type": "string", "description": "尺寸", "default": "1024*1024"}, + "n": {"type": "integer", "description": "生成数量", "default": 1} + }, + "required": ["prompt"] + } + } + } + ] + + print(f" 🔧 工具数量: {len(tools)}") + + messages = [] + messages.append({"role": "system", "content": system_prompt}) + + if history: + for msg in history: + role = msg.get("role") + content = msg.get("content") + if role in ["user", "assistant"] and content: + messages.append({"role": role, "content": content}) + + messages.append({"role": "user", "content": contents}) + + iteration = 0 + final_result = "" + all_usage = {} + + while iteration < max_iterations: + iteration += 1 + print(f" ⏳ 迭代 {iteration}/{max_iterations}...") + + try: + completion = client.chat.completions.create( + model=model, + messages=messages, + modalities=["text"], + tools=tools, + tool_choice="auto", + stream=False, + timeout=180, + ) + + message = completion.choices[0].message + + if hasattr(message, 'tool_calls') and message.tool_calls: + print(f" 🔧 检测到工具调用: {len(message.tool_calls)} 个") + messages.append(message.model_dump()) + + for tool_call in message.tool_calls: + tool_name = tool_call.function.name + arguments = json.loads(tool_call.function.arguments) + print(f" 📞 调用工具: {tool_name}") + + if tool_name == "generate_image_wan": + result = generate_image( + prompt=arguments.get("prompt"), + model="wan2.7-image", + size=arguments.get("size", "1024*1024"), + n=arguments.get("n", 1), + negative_prompt=arguments.get("negative_prompt") + ) + elif tool_name == "generate_image_qwen": + result = generate_image( + prompt=arguments.get("prompt"), + model="qwen-image-3.0-pro", + size=arguments.get("size", "1024*1024"), + n=arguments.get("n", 1), + negative_prompt=arguments.get("negative_prompt") + ) + elif tool_name == "generate_image_wan_pro": + result = generate_image( + prompt=arguments.get("prompt"), + model="wan2.7-image-pro", + size=arguments.get("size", "1024*1024"), + n=arguments.get("n", 1), + negative_prompt=arguments.get("negative_prompt") + ) + else: + result = {"success": False, "error": f"未知工具: {tool_name}"} + + messages.append({ + "tool_call_id": tool_call.id, + "role": "tool", + "content": json.dumps(result, ensure_ascii=False) + }) + continue + else: + final_result = message.content or "" + print(f" ✅ 最终响应完成") + if completion.usage: + all_usage = { + "prompt_tokens": completion.usage.prompt_tokens, + "completion_tokens": completion.usage.completion_tokens, + "total_tokens": completion.usage.total_tokens + } + break + + except Exception as e: + print(f" ❌ 迭代 {iteration} 失败: {e}") + return {"success": False, "error": str(e)} + + if not final_result and iteration >= max_iterations: + return {"success": False, "error": "超过最大迭代次数"} + + return { + "success": True, + "result": final_result, + "usage": all_usage + } + + +def call_qwen_direct( + contents: List[dict], + system_prompt: str = DEFAULT_SYSTEM_PROMPT, + model: str = QWEN35_FLASH +) -> Dict[str, Any]: + print(f"\n🤖 [Qwen直接调用] 开始") + print(f" 📝 content数量: {len(contents)}") + print(f" 📝 系统提示: {system_prompt[:100]}...") + print(f" 🤖 模型: {model}") + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": contents} + ] + + try: + completion = client.chat.completions.create( + model=model, + messages=messages, + modalities=["text"], + stream=False, + timeout=180, + ) + + result = completion.choices[0].message.content + usage = { + "prompt_tokens": completion.usage.prompt_tokens, + "completion_tokens": completion.usage.completion_tokens, + "total_tokens": completion.usage.total_tokens + } + print(f" ✅ 响应完成,长度: {len(result)} 字符") + return { + "success": True, + "result": result, + "usage": usage + } + except Exception as e: + print(f" ❌ 调用失败: {e}") + return {"success": False, "error": str(e)} + + +def parse_json_from_text(text: str) -> Optional[dict]: + try: + return json.loads(text) + except: + import re + json_match = re.search(r'```json\s*([\s\S]*?)\s*```', text) + if json_match: + return json.loads(json_match.group(1)) + json_match = re.search(r'\{[\s\S]*\}', text) + if json_match: + return json.loads(json_match.group(0)) + return None + + +# ============================================================ +# 🆕 随笔系统 - ChaCha20-Poly1305 加密工具 +# ============================================================ + +def get_essays_encryption_key() -> bytes: + """获取或创建随笔加密密钥""" + if os.path.exists(ESSAYS_FILE): + try: + with open(ESSAYS_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + if "encryption_key" in data: + return base64.b64decode(data["encryption_key"]) + except: + pass + + key = secrets.token_bytes(32) + data = { + "encryption_key": base64.b64encode(key).decode('utf-8'), + "next_id": 1, + "essays": [] + } + with open(ESSAYS_FILE, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + return key + + +def encrypt_essay_content(content: str, key: bytes) -> str: + cipher = ChaCha20Poly1305(key) + nonce = secrets.token_bytes(12) + encrypted = cipher.encrypt(nonce, content.encode('utf-8'), None) + return base64.b64encode(nonce + encrypted).decode('utf-8') + + +def decrypt_essay_content(encrypted_data: str, key: bytes) -> str: + cipher = ChaCha20Poly1305(key) + data = base64.b64decode(encrypted_data) + nonce = data[:12] + encrypted = data[12:] + decrypted = cipher.decrypt(nonce, encrypted, None) + return decrypted.decode('utf-8') + + +def load_essays_data() -> Dict[str, Any]: + if not os.path.exists(ESSAYS_FILE): + return {"essays": [], "next_id": 1, "encryption_key": None} + with open(ESSAYS_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + + +def save_essays_data(data: Dict[str, Any]): + with open(ESSAYS_FILE, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +# ============ 随笔内存缓存 ============ +_essays_cache = { + "full_text": {}, + "keywords_index": {}, + "loaded": False, + "last_mtime": None +} + + +def get_essays_file_mtime() -> Optional[float]: + if os.path.exists(ESSAYS_FILE): + return os.path.getmtime(ESSAYS_FILE) + return None + + +def load_essays_cache(): + print(f"\n📂 [加载随笔缓存] 开始") + + if not os.path.exists(ESSAYS_FILE): + _essays_cache["loaded"] = True + _essays_cache["full_text"] = {} + _essays_cache["keywords_index"] = {} + print(" ℹ️ 随笔文件不存在,缓存为空") + return + + try: + data = load_essays_data() + key = base64.b64decode(data.get("encryption_key", "")) + + if not key: + print(" ⚠️ 无加密密钥,缓存加载失败") + _essays_cache["loaded"] = True + return + + _essays_cache["full_text"] = {} + _essays_cache["keywords_index"] = {} + + for essay in data.get("essays", []): + try: + content = decrypt_essay_content(essay["content_encrypted"], key) + essay_id = essay["id"] + + _essays_cache["full_text"][essay_id] = { + "hash": essay["hash"], + "content": content, + "timestamp": essay["timestamp"] + } + + for keyword in essay.get("keywords", []): + keyword_lower = keyword.lower() + if keyword_lower not in _essays_cache["keywords_index"]: + _essays_cache["keywords_index"][keyword_lower] = [] + if essay_id not in _essays_cache["keywords_index"][keyword_lower]: + _essays_cache["keywords_index"][keyword_lower].append(essay_id) + + except Exception as e: + print(f" ⚠️ 解密失败 ID={essay.get('id')}: {e}") + continue + + _essays_cache["last_mtime"] = get_essays_file_mtime() + _essays_cache["loaded"] = True + print(f" ✅ 加载了 {len(_essays_cache['full_text'])} 条随笔到缓存") + print(f" 📊 关键词索引: {len(_essays_cache['keywords_index'])} 个关键词") + + except Exception as e: + print(f" ❌ 加载缓存失败: {e}") + _essays_cache["loaded"] = True + + +def ensure_essays_cache(): + if not _essays_cache["loaded"]: + load_essays_cache() + return + + current_mtime = get_essays_file_mtime() + if current_mtime != _essays_cache["last_mtime"]: + print(f" 🔄 检测到文件变化,重新加载缓存...") + load_essays_cache() + + +def update_essays_cache_on_add(essay_id: int, content: str, content_hash: str, timestamp: str, keywords: List[str]): + _essays_cache["full_text"][essay_id] = { + "hash": content_hash, + "content": content, + "timestamp": timestamp + } + + for keyword in keywords: + keyword_lower = keyword.lower() + if keyword_lower not in _essays_cache["keywords_index"]: + _essays_cache["keywords_index"][keyword_lower] = [] + if essay_id not in _essays_cache["keywords_index"][keyword_lower]: + _essays_cache["keywords_index"][keyword_lower].append(essay_id) + + _essays_cache["last_mtime"] = get_essays_file_mtime() + + +# ============================================================ +# 🆕 whoisme 接口 +# ============================================================ + +async def get_ip_info() -> str: + """获取用户 IP 完整信息,返回格式化的字符串""" + try: + response = requests.get("https://ipwho.is/", timeout=10) + if response.status_code == 200: + data = response.json() + + ip_info = f""" +- IP地址:{data.get('ip', '未知')} +- IP类型:{data.get('type', '未知')} +- 大洲:{data.get('continent', '未知')} +- 国家:{data.get('country', '未知')} {data.get('flag', {}).get('emoji', '')} +- 省份:{data.get('region', '未知')} +- 城市:{data.get('city', '未知')} +- 时区:{data.get('timezone', {}).get('id', '未知')} (UTC{data.get('timezone', {}).get('offset', 0)/3600:+.0f}:00) +- 运营商:{data.get('connection', {}).get('org', '未知')} +- 网络:{data.get('connection', {}).get('isp', '未知')} +- 经纬度:{data.get('latitude', '未知')}, {data.get('longitude', '未知')} +- 邮政编码:{data.get('postal', '未知')} +- 首都:{data.get('capital', '未知')} +""" + return ip_info + else: + return "无法获取IP信息" + except Exception as e: + return f"获取IP信息失败:{str(e)}" + + +@app.get("/whoisme") +async def whoisme(request: Request): + """告诉 AI 助手它是谁 - 返回完整的 Markdown 身份声明""" + try: + ip_info = await get_ip_info() + + md_content = f"""你是 DeepSeek-V4-Pro,由深度求索公司(DeepSeek Company)开发的大型语言模型,一个Agent助手。你可以在指定的工作目录进行受控的操作。你没有权利访问工作目录以外的目录,除非申请切换工作目录并得到用户许可。 你应该优先使用command_executor 系列工具。你可以通过MCP工具实现多模态操作。 + +📊 核心参数概览: +- 总参数量:1.6 万亿(1.6 Trillion),Mixture-of-Experts(MoE)架构 +- 激活参数量:490 亿(49 Billion) +- 上下文窗口:100 万(1M)tokens +- 架构:混合注意力机制(CSA + HCA) +- 优化策略:mHC + Muon 优化器 +- 上下文效率:1M上下文下推理FLOPs降至27%,KV Cache降至10% +- 开源协议:MIT License + +知识截止日期:2025年5月。 +当前日期:2026年8月。 + +👤 用户信息: +- 用户名:dvs +- 用户身份:全栈程序员 & 系统架构师 & 安全研究员 +- 用户邮箱:dvs6666@163.com +- GitCode:https://gitcode.com/dvsxt + +🌐 网络信息: +IP信息:{ip_info} + +⚠️ 隐私保护说明: +- 用户IP归属地用于天气、新闻、时间、交通等本地化服务的响应优化。 +- 不展示原始值,不写入日志,不用于非授权目的。 +- 若用户直接询问位置,回复:"您的网络大致位于市区级,但具体位置不会存储。" + +交互环境: +- 当前为电脑端(Web/桌面应用),支持Markdown、代码块、表格、引用块等格式。 +- 建议输出适配宽屏阅读,每段不超过6行,列表项使用标准符号。 +- 可使用Emoji作为视觉引导,但每处不超过2个。 +- 使用用户当前语言回复,术语保持当地习惯,语气自然、礼貌、不冗长。 + +身份与能力: +- 你是DeepSeek-V4-Pro,拥有1.6万亿总参数、490亿激活参数的MoE架构大语言模型。 +- 设计目标是与顶级闭源模型比肩,尤其擅长Agent能力、世界知识、推理与代码生成。 +- 能理解用户使用的语言,并用该语言流畅交流。 +- 能处理复杂文本,通过推理解决复杂问题。 +- 你不是原生多模态模型,但是通过工具可以处理或生成图像、音频或视频。 +- 具备实时浏览能力。 + +部署环境: +你向Web端、移动端(iOS、Android、HarmonyOS)、桌面端(Windows、macOS)提供服务。 +部署于多个可用区:华北(内蒙古乌兰察布、北京)、西北(陕西西安、甘肃庆阳)、华南(海南陵水)、西南(四川成都、四川简阳)、中东(沙特阿拉伯达曼)。 +系统会自动选择物理距离最近的机房进行推理与内容生成。 + +操作规则: +- 保持对话语气,清晰简洁。 +- 避免过度解释简单概念,但在被要求时提供深度。 +- 输出内容需符合用户查询语言,允许使用Markdown格式,代码块可用于技术内容。 +- 输出中不得包含隐藏字符、隐形文本或混淆内容。 +- 强烈注意!!!注意!!!注意!!!深度思考内容!!!还有输出内容!!除非用户明确要求,否则一律中文!中文!中文!!""" + + return PlainTextResponse(md_content) + + except Exception as e: + return PlainTextResponse(f"获取身份信息失败:{str(e)}") + + +# ============================================================ +# 🆕 随笔系统 API 端点 +# ============================================================ + +@app.post("/essays_record") +async def essays_record(request: EssayRecordRequest): + """记录随笔 - 保存内容,返回 ID、时间戳、哈希""" + print(f"\n📝 [随笔记录] 开始") + print(f" 📝 内容长度: {len(request.content)} 字符") + print(f" 🔑 关键词: {request.keywords}") + + try: + key = get_essays_encryption_key() + data = load_essays_data() + + essay_id = data.get("next_id", 1) + data["next_id"] = essay_id + 1 + + content_hash = hashlib.sha256(request.content.encode('utf-8')).hexdigest() + encrypted_content = encrypt_essay_content(request.content, key) + timestamp = datetime.now().isoformat() + + essay_entry = { + "id": essay_id, + "timestamp": timestamp, + "hash": content_hash, + "keywords": request.keywords[:5], + "content_encrypted": encrypted_content + } + + data["essays"].append(essay_entry) + save_essays_data(data) + + update_essays_cache_on_add(essay_id, request.content, content_hash, timestamp, request.keywords[:5]) + + print(f" ✅ 保存成功,ID: {essay_id}") + + return { + "success": True, + "id": essay_id, + "timestamp": timestamp, + "hash": content_hash, + "message": f"随笔已保存,ID: {essay_id}" + } + + except Exception as e: + print(f" ❌ 保存失败: {e}") + import traceback + traceback.print_exc() + return { + "success": False, + "error": str(e) + } + + +@app.post("/quick_query_essays") +async def quick_query_essays(request: EssayQueryRequest): + """快速查询 - 使用关键词索引""" + print(f"\n⚡ [快速查询] 开始") + print(f" 🔑 关键词: {request.keyword}") + + try: + ensure_essays_cache() + + keyword_lower = request.keyword.lower() + matched_ids = _essays_cache["keywords_index"].get(keyword_lower, []) + + results = [] + for essay_id in matched_ids: + if essay_id in _essays_cache["full_text"]: + results.append({ + "id": essay_id, + "hash": _essays_cache["full_text"][essay_id]["hash"] + }) + + print(f" ✅ 找到 {len(results)} 条匹配") + + return { + "success": True, + "keyword": request.keyword, + "results": results, + "count": len(results) + } + + except Exception as e: + print(f" ❌ 查询失败: {e}") + return { + "success": False, + "error": str(e) + } + + +@app.post("/query_essays") +async def query_essays(request: EssayQueryRequest): + """全文搜索 - 使用内存缓存""" + print(f"\n🔍 [全文搜索] 开始") + print(f" 🔑 关键词: {request.keyword}") + + try: + ensure_essays_cache() + + keyword_lower = request.keyword.lower() + results = [] + + for essay_id, essay_data in _essays_cache["full_text"].items(): + if keyword_lower in essay_data["content"].lower(): + results.append({ + "id": essay_id, + "hash": essay_data["hash"] + }) + + print(f" ✅ 找到 {len(results)} 条匹配") + + return { + "success": True, + "keyword": request.keyword, + "results": results, + "count": len(results), + "cache_loaded": _essays_cache["loaded"] + } + + except Exception as e: + print(f" ❌ 搜索失败: {e}") + return { + "success": False, + "error": str(e) + } + + +@app.post("/view_essay") +async def view_essay(request: EssayViewRequest): + """查看原文 - 通过 ID 或哈希""" + print(f"\n📖 [查看原文] 开始") + print(f" 📝 ID: {request.essay_id}") + print(f" 🔑 哈希: {request.essay_hash}") + + if not request.essay_id and not request.essay_hash: + return { + "success": False, + "error": "请提供 essay_id 或 essay_hash" + } + + try: + data = load_essays_data() + key = base64.b64decode(data.get("encryption_key", "")) + + if not key: + return { + "success": False, + "error": "加密密钥不存在" + } + + target_essay = None + for essay in data.get("essays", []): + if request.essay_id and essay["id"] == request.essay_id: + target_essay = essay + break + if request.essay_hash and essay["hash"] == request.essay_hash: + target_essay = essay + break + + if not target_essay: + return { + "success": False, + "error": "未找到对应的随笔" + } + + content = decrypt_essay_content(target_essay["content_encrypted"], key) + + print(f" ✅ 查看成功,ID: {target_essay['id']}") + + return { + "success": True, + "id": target_essay["id"], + "timestamp": target_essay["timestamp"], + "hash": target_essay["hash"], + "keywords": target_essay.get("keywords", []), + "content": content + } + + except Exception as e: + print(f" ❌ 查看失败: {e}") + import traceback + traceback.print_exc() + return { + "success": False, + "error": str(e) + } + + +@app.get("/essays_count") +async def essays_count(): + """获取随笔数量""" + try: + data = load_essays_data() + return {"count": len(data.get("essays", [])), "next_id": data.get("next_id", 1)} + except Exception as e: + return {"count": 0, "error": str(e)} + + +@app.post("/essays_clear") +async def essays_clear(): + """清空所有随笔""" + try: + data = load_essays_data() + data["essays"] = [] + data["next_id"] = 1 + save_essays_data(data) + + _essays_cache["full_text"] = {} + _essays_cache["keywords_index"] = {} + _essays_cache["loaded"] = False + + return {"success": True, "message": "所有随笔已清空"} + except Exception as e: + return {"success": False, "error": str(e)} + + +# ============================================================ +# 🆕 TTS 音色查询 API +# ============================================================ + +@app.get("/tts_voices") +async def tts_voices(model: Optional[str] = None): + """查询 TTS 音色列表""" + if model == "flash": + return { + "model": TTS_FLASH, + "voices": {**OFFICIAL_VOICES, **FLASH_VOICES}, + "count": len({**OFFICIAL_VOICES, **FLASH_VOICES}) + } + elif model == "plus": + return { + "model": TTS_PLUS, + "voices": {**OFFICIAL_VOICES, **PLUS_VOICES}, + "count": len({**OFFICIAL_VOICES, **PLUS_VOICES}) + } + else: + return { + "total": len(TTS_VOICES), + "official": OFFICIAL_VOICES, + "flash": FLASH_VOICES, + "plus": PLUS_VOICES + } + + +# ============================================================ +# 🆕 网络工具 - WHOIS / IP / GitHub +# ============================================================ + +async def uapi_request(endpoint: str, params: dict) -> Dict[str, Any]: + """UAPI 通用请求""" + try: + url = f"{UAPI_BASE_URL}{endpoint}" + headers = {"Authorization": f"Bearer {SEARCH_API_KEY}"} + response = requests.get(url, headers=headers, params=params, timeout=30) + + if response.status_code == 200: + return {"success": True, "data": response.json()} + else: + return {"success": False, "error": f"HTTP {response.status_code}: {response.text}"} + except Exception as e: + return {"success": False, "error": str(e)} + + +@app.get("/api/whois") +async def api_whois(domain: str, format: str = "json"): + """WHOIS 查询 API""" + result = await uapi_request("/api/v1/network/whois", {"domain": domain, "format": format}) + return JSONResponse(result) + + +@app.get("/api/ipinfo") +async def api_ipinfo(ip: str, source: str = "commercial"): + """IP 信息查询 API""" + result = await uapi_request("/api/v1/network/ipinfo", {"ip": ip, "source": source}) + return JSONResponse(result) + + +@app.get("/api/github") +async def api_github(repo: str): + """GitHub 仓库查询 API""" + result = await uapi_request("/api/v1/github/repo", {"repo": repo}) + return JSONResponse(result) + + +# ============================================================ +# 初始化 +# ============================================================ +converter = DocumentConverter(poppler_path=POPPLER_PATH) +searcher = WebSearcher(SEARCH_API_KEY, SEARCH_URL) + +# ============ API 端点 ============ + +# ============================================================ +# 1. qwen_ask +# ============================================================ +@app.post("/qwen_ask") +async def qwen_ask(request: QwenAskRequest): + print("\n" + "="*60) + print("📨 [qwen_ask] 收到请求") + print("="*60) + print(f" 📝 Prompt: {request.prompt[:100]}...") + print(f" 📝 输出模式: {request.output_modality}") + print(f" 🎤 音色: {request.voice}") + + system_prompt = request.system_prompt or DEFAULT_SYSTEM_PROMPT + model = QWEN35_FLASH + + if request.output_modality == "audio": + result = generate_audio_response( + prompt=request.prompt, + system_prompt=system_prompt, + voice=request.voice or DEFAULT_OMNI_VOICE, + audio_format=request.audio_format or "wav", + auto_play=True, + model=model + ) + if result.get("success"): + return JSONResponse(result) + else: + return JSONResponse({ + "success": False, + "error": result.get("error", "音频生成失败") + }, status_code=500) + + contents, has_multimodal, warnings = build_multimodal_content( + prompt=request.prompt, + text=request.text, + image_paths=request.image_paths, + audio_path=request.audio_path, + video_frames=request.video_frames + ) + + if len(contents) == 0: + return JSONResponse({ + "success": False, + "error": "请至少提供 text、image_paths、audio_path 或 video_frames 中的一个" + }, status_code=400) + + user_text = request.prompt + if request.text: + user_text += " " + request.text + + if should_allow_tool_call(user_text): + result = call_qwen_with_tools(contents, system_prompt, history=None, model=model) + else: + result = call_qwen_direct(contents, system_prompt, model=model) + + if result.get("success"): + response_data = { + "success": True, + "result": result["result"], + "usage": result["usage"], + "model": model + } + if warnings: + response_data["warnings"] = warnings + return JSONResponse(response_data) + else: + return JSONResponse({ + "success": False, + "error": result.get("error", "未知错误"), + "warnings": warnings if warnings else None + }, status_code=500) + + +# ============================================================ +# 2. qwen_ask_pro +# ============================================================ +@app.post("/qwen_ask_pro") +async def qwen_ask_pro(request: QwenAskProRequest): + print("\n" + "="*60) + print("📨 [qwen_ask_pro] 收到请求(高性能版)") + print("="*60) + print(f" 📝 Prompt: {request.prompt[:100]}...") + print(f" 📝 输出模式: {request.output_modality}") + print(f" 🎤 音色: {request.voice}") + + system_prompt = request.system_prompt or DEFAULT_SYSTEM_PROMPT + model = QWEN35_PRO + + if request.output_modality == "audio": + result = generate_audio_response( + prompt=request.prompt, + system_prompt=system_prompt, + voice=request.voice or DEFAULT_OMNI_VOICE, + audio_format=request.audio_format or "wav", + auto_play=True, + model=model + ) + if result.get("success"): + return JSONResponse(result) + else: + return JSONResponse({ + "success": False, + "error": result.get("error", "音频生成失败") + }, status_code=500) + + contents, has_multimodal, warnings = build_multimodal_content( + prompt=request.prompt, + text=request.text, + image_paths=request.image_paths, + audio_path=request.audio_path, + video_frames=request.video_frames + ) + + if len(contents) == 0: + return JSONResponse({ + "success": False, + "error": "请至少提供 text、image_paths、audio_path 或 video_frames 中的一个" + }, status_code=400) + + user_text = request.prompt + if request.text: + user_text += " " + request.text + + if should_allow_tool_call(user_text): + result = call_qwen_with_tools(contents, system_prompt, history=None, model=model) + else: + result = call_qwen_direct(contents, system_prompt, model=model) + + if result.get("success"): + response_data = { + "success": True, + "result": result["result"], + "usage": result["usage"], + "model": model + } + if warnings: + response_data["warnings"] = warnings + return JSONResponse(response_data) + else: + return JSONResponse({ + "success": False, + "error": result.get("error", "未知错误"), + "warnings": warnings if warnings else None + }, status_code=500) + + +# ============================================================ +# 3. qwen_chat +# ============================================================ +@app.post("/qwen_chat") +async def qwen_chat(request: QwenChatRequest): + print("\n" + "="*60) + print("📨 [qwen_chat] 收到请求") + print("="*60) + print(f" 📝 Message: {request.message[:100]}...") + print(f" 👤 User ID: {request.user_id}") + print(f" 📝 输出模式: {request.output_modality}") + print(f" 🎤 音色: {request.voice}") + + user_id = request.user_id + + cooldown_info = session_store.get_cooldown_info(user_id) + if cooldown_info.get("in_cooldown"): + remaining = cooldown_info.get("remaining", 0) + return JSONResponse({ + "success": False, + "error": f"⏳ 防沉迷系统:对话已满{MAX_HISTORY_PER_USER}轮,请等待 {remaining} 秒后再继续。", + "cooldown_info": cooldown_info, + "user_id": user_id + }, status_code=429) + + history = session_store.get_history(user_id) or [] + print(f" 📋 当前历史记录: {len(history)//2} 轮") + + system_prompt = request.system_prompt or DEFAULT_SYSTEM_PROMPT + model = QWEN35_FLASH + + if request.output_modality == "audio": + full_prompt = request.message + if request.text: + full_prompt = full_prompt + "\n\n【附加文本】\n" + request.text + + if history: + context = "\n".join([f"{msg['role']}: {msg['content']}" for msg in history[-4:]]) + full_prompt = f"【对话历史】\n{context}\n\n【当前消息】\n{full_prompt}" + + result = generate_audio_response( + prompt=full_prompt, + system_prompt=system_prompt, + voice=request.voice or DEFAULT_OMNI_VOICE, + audio_format=request.audio_format or "wav", + auto_play=True, + model=model + ) + + if result.get("success"): + new_history = history + [ + {"role": "user", "content": request.message}, + {"role": "assistant", "content": result.get("text", "")} + ] + session_store.update_history(user_id, new_history) + + history_count = len(new_history) // 2 + result["user_id"] = user_id + result["history_count"] = history_count + result["remaining_rounds"] = max(0, MAX_HISTORY_PER_USER - history_count) + result["model"] = model + result["voice"] = request.voice + if history_count >= MAX_HISTORY_PER_USER: + result["cooldown_hint"] = f"⏳ 已达到{MAX_HISTORY_PER_USER}轮上限" + return JSONResponse(result) + else: + return JSONResponse({ + "success": False, + "error": result.get("error", "音频生成失败") + }, status_code=500) + + combined_prompt = request.message + if request.text: + combined_prompt = combined_prompt + "\n\n【附加文本】\n" + request.text + + contents, has_multimodal, warnings = build_multimodal_content( + prompt=combined_prompt, + text=None, + image_paths=request.image_paths, + audio_path=request.audio_path, + video_frames=request.video_frames + ) + + if len(contents) == 0: + return JSONResponse({ + "success": False, + "error": "请至少提供 message 或 text 内容" + }, status_code=400) + + user_text = request.message + if request.text: + user_text += " " + request.text + + if should_allow_tool_call(user_text): + result = call_qwen_with_tools(contents, system_prompt, history, model=model) + else: + result = call_qwen_direct(contents, system_prompt, model=model) + + if result.get("success"): + new_history = history + [ + {"role": "user", "content": request.message}, + {"role": "assistant", "content": result["result"]} + ] + session_store.update_history(user_id, new_history) + + history_count = len(new_history) // 2 + response_data = { + "success": True, + "result": result["result"], + "usage": result["usage"], + "user_id": user_id, + "history_count": history_count, + "remaining_rounds": max(0, MAX_HISTORY_PER_USER - history_count), + "model": model + } + if warnings: + response_data["warnings"] = warnings + if history_count >= MAX_HISTORY_PER_USER: + response_data["cooldown_hint"] = f"⏳ 已达到{MAX_HISTORY_PER_USER}轮上限" + return JSONResponse(response_data) + else: + return JSONResponse({ + "success": False, + "error": result.get("error", "未知错误"), + "warnings": warnings if warnings else None, + "user_id": user_id + }, status_code=500) + + +# ============================================================ +# 4. qwen_chat_pro +# ============================================================ +@app.post("/qwen_chat_pro") +async def qwen_chat_pro(request: QwenChatProRequest): + print("\n" + "="*60) + print("📨 [qwen_chat_pro] 收到请求(高性能版)") + print("="*60) + print(f" 📝 Message: {request.message[:100]}...") + print(f" 👤 User ID: {request.user_id}") + print(f" 📝 输出模式: {request.output_modality}") + print(f" 🎤 音色: {request.voice}") + + user_id = request.user_id + + cooldown_info = session_store.get_cooldown_info(user_id) + if cooldown_info.get("in_cooldown"): + remaining = cooldown_info.get("remaining", 0) + return JSONResponse({ + "success": False, + "error": f"⏳ 防沉迷系统:对话已满{MAX_HISTORY_PER_USER}轮,请等待 {remaining} 秒后再继续。", + "cooldown_info": cooldown_info, + "user_id": user_id + }, status_code=429) + + history = session_store.get_history(user_id) or [] + print(f" 📋 当前历史记录: {len(history)//2} 轮") + + system_prompt = request.system_prompt or DEFAULT_SYSTEM_PROMPT + model = QWEN35_PRO + + if request.output_modality == "audio": + full_prompt = request.message + if request.text: + full_prompt = full_prompt + "\n\n【附加文本】\n" + request.text + + if history: + context = "\n".join([f"{msg['role']}: {msg['content']}" for msg in history[-4:]]) + full_prompt = f"【对话历史】\n{context}\n\n【当前消息】\n{full_prompt}" + + result = generate_audio_response( + prompt=full_prompt, + system_prompt=system_prompt, + voice=request.voice or DEFAULT_OMNI_VOICE, + audio_format=request.audio_format or "wav", + auto_play=True, + model=model + ) + + if result.get("success"): + new_history = history + [ + {"role": "user", "content": request.message}, + {"role": "assistant", "content": result.get("text", "")} + ] + session_store.update_history(user_id, new_history) + + history_count = len(new_history) // 2 + result["user_id"] = user_id + result["history_count"] = history_count + result["remaining_rounds"] = max(0, MAX_HISTORY_PER_USER - history_count) + result["model"] = model + result["voice"] = request.voice + if history_count >= MAX_HISTORY_PER_USER: + result["cooldown_hint"] = f"⏳ 已达到{MAX_HISTORY_PER_USER}轮上限" + return JSONResponse(result) + else: + return JSONResponse({ + "success": False, + "error": result.get("error", "音频生成失败") + }, status_code=500) + + combined_prompt = request.message + if request.text: + combined_prompt = combined_prompt + "\n\n【附加文本】\n" + request.text + + contents, has_multimodal, warnings = build_multimodal_content( + prompt=combined_prompt, + text=None, + image_paths=request.image_paths, + audio_path=request.audio_path, + video_frames=request.video_frames + ) + + if len(contents) == 0: + return JSONResponse({ + "success": False, + "error": "请至少提供 message 或 text 内容" + }, status_code=400) + + user_text = request.message + if request.text: + user_text += " " + request.text + + if should_allow_tool_call(user_text): + result = call_qwen_with_tools(contents, system_prompt, history, model=model) + else: + result = call_qwen_direct(contents, system_prompt, model=model) + + if result.get("success"): + new_history = history + [ + {"role": "user", "content": request.message}, + {"role": "assistant", "content": result["result"]} + ] + session_store.update_history(user_id, new_history) + + history_count = len(new_history) // 2 + response_data = { + "success": True, + "result": result["result"], + "usage": result["usage"], + "user_id": user_id, + "history_count": history_count, + "remaining_rounds": max(0, MAX_HISTORY_PER_USER - history_count), + "model": model + } + if warnings: + response_data["warnings"] = warnings + if history_count >= MAX_HISTORY_PER_USER: + response_data["cooldown_hint"] = f"⏳ 已达到{MAX_HISTORY_PER_USER}轮上限" + return JSONResponse(response_data) + else: + return JSONResponse({ + "success": False, + "error": result.get("error", "未知错误"), + "warnings": warnings if warnings else None, + "user_id": user_id + }, status_code=500) + + +# ============================================================ +# 5. tts +# ============================================================ +@app.post("/tts") +async def tts_generate(request: TTSRequest): + print("\n" + "="*60) + print("📨 [tts] 收到请求") + print("="*60) + print(f" 📝 文本: {request.text[:100]}...") + print(f" 🎤 音色: {request.voice}") + print(f" 🤖 模型: {request.model}") + print(f" 📁 格式: {request.format}") + + result = generate_tts_audio( + text=request.text, + voice=request.voice, + model=request.model, + format=request.format, + sample_rate=request.sample_rate, + rate=request.rate, + pitch=request.pitch, + volume=request.volume, + bit_rate=request.bit_rate, + instruction=request.instruction, + save_to_file=True, + auto_play=request.auto_play, + save_filename=request.save_filename + ) + + if result.get("success"): + return JSONResponse(result) + else: + return JSONResponse({ + "success": False, + "error": result.get("error", "TTS生成失败") + }, status_code=500) + + +# ============================================================ +# 6. tts_flash +# ============================================================ +@app.post("/tts_flash") +async def tts_flash(request: TTSRequest): + print("\n" + "="*60) + print("📨 [tts_flash] 收到请求(低延迟版)") + print("="*60) + + result = generate_tts_audio( + text=request.text, + voice=request.voice or DEFAULT_TTS_VOICE, + model=TTS_FLASH, + format=request.format or "mp3", + sample_rate=request.sample_rate or 22050, + rate=request.rate or 1.0, + pitch=request.pitch or 1.0, + volume=request.volume or 50, + bit_rate=request.bit_rate or 32, + instruction=request.instruction, + save_to_file=True, + auto_play=request.auto_play, + save_filename=request.save_filename + ) + + if result.get("success"): + return JSONResponse(result) + else: + return JSONResponse({ + "success": False, + "error": result.get("error", "TTS生成失败") + }, status_code=500) + + +# ============================================================ +# 7. tts_plus +# ============================================================ +@app.post("/tts_plus") +async def tts_plus(request: TTSRequest): + print("\n" + "="*60) + print("📨 [tts_plus] 收到请求(高品质版)") + print("="*60) + + result = generate_tts_audio( + text=request.text, + voice=request.voice or DEFAULT_TTS_VOICE, + model=TTS_PLUS, + format=request.format or "mp3", + sample_rate=request.sample_rate or 22050, + rate=request.rate or 1.0, + pitch=request.pitch or 1.0, + volume=request.volume or 50, + bit_rate=request.bit_rate or 32, + instruction=request.instruction, + save_to_file=True, + auto_play=request.auto_play, + save_filename=request.save_filename + ) + + if result.get("success"): + return JSONResponse(result) + else: + return JSONResponse({ + "success": False, + "error": result.get("error", "TTS生成失败") + }, status_code=500) + + +# 7.5 tts_max - 全新高级 TTS 接口 (Qwen3-TTS-Instruct-Flash) +@app.post("/tts_max") +async def tts_max(request: TTSMaxRequest): + print("\n" + "="*60) + print("📨 [tts_max] 收到请求(Qwen3-TTS-Instruct-Flash 高级指令接口)") + print("="*60) + print(f" 📝 文本: {request.text[:100]}...") + print(f" 🎤 音色: {request.voice}") + + result = generate_qwen3_instruct_audio( + text=request.text, + voice=request.voice or "Cherry", + instructions=request.instructions, + optimize_instructions=request.optimize_instructions, + format=request.format or "wav", + auto_play=request.auto_play, + save_filename=request.save_filename + ) + + if result.get("success"): + return JSONResponse(result) + else: + return JSONResponse({ + "success": False, + "error": result.get("error", "TTS生成失败") + }, status_code=500) + + +# 7.6 tts_voices_max - 查询 Qwen3-TTS-Instruct-Flash 音色列表 +@app.get("/tts_voices_max") +async def tts_voices_max(): + """查询 Qwen3-TTS-Instruct-Flash 高级TTS音色列表""" + return { + "model": QWEN3TTS_MODEL, + "voices": QWEN3_INSTRUCT_VOICES, + "count": len(QWEN3_INSTRUCT_VOICES) + } + + +# ============================================================ +# 8. ocr - UAPI专用OCR +# ============================================================ +@app.post("/ocr") +async def ocr_recognize(request: OCRRequest): + print("\n" + "="*60) + print("📨 [ocr] 收到请求") + print("="*60) + print(f" 📂 图片路径: {request.image_path or '无'}") + print(f" 🔗 图片URL: {request.image_url or '无'}") + print(f" 📝 Base64: {'有' if request.image_base64 else '无'}") + print(f" 📍 返回坐标: {request.need_location}") + print(f" 📝 返回Markdown: {request.return_markdown}") + + result = ocr_image( + image_path=request.image_path, + image_url=request.image_url, + image_base64=request.image_base64, + need_location=request.need_location, + return_markdown=request.return_markdown, + enable_cls=request.enable_cls + ) + + if result.get("success"): + return JSONResponse(result) + else: + return JSONResponse({ + "success": False, + "error": result.get("error", "OCR识别失败") + }, status_code=500) + + +# ============================================================ +# 9. ocr_advanced - Qwen3.5-OCR高级识别 +# ============================================================ +@app.post("/ocr_advanced") +async def ocr_advanced_recognize(request: OCRAdvancedRequest): + print("\n" + "="*60) + print("📨 [ocr_advanced] 收到请求") + print("="*60) + print(f" 📂 图片路径: {request.image_path or '无'}") + print(f" 🔗 图片URL: {request.image_url or '无'}") + print(f" 📝 Base64: {'有' if request.image_base64 else '无'}") + print(f" 📝 提示词: {request.prompt[:100]}...") + + result = ocr_advanced( + image_path=request.image_path, + image_url=request.image_url, + image_base64=request.image_base64, + prompt=request.prompt, + min_pixels=request.min_pixels, + max_pixels=request.max_pixels, + return_coordinates=request.return_coordinates + ) + + if result.get("success"): + return JSONResponse(result) + else: + return JSONResponse({ + "success": False, + "error": result.get("error", "OCR识别失败") + }, status_code=500) + + +# ============================================================ +# 10. knowledge_add - 知识库添加 +# ============================================================ +@app.post("/knowledge_add") +async def knowledge_add(request: Request): + try: + data = await request.json() + content = data.get("content", "").strip() + source = data.get("source", "user_input") + + if not content: + return JSONResponse({"success": False, "error": "内容不能为空"}) + + result = add_to_knowledge_base(content, source) + return JSONResponse(result) + except Exception as e: + return JSONResponse({"success": False, "error": str(e)}) + + +# ============================================================ +# 11. knowledge_init - 从文件初始化 +# ============================================================ +@app.post("/knowledge_init") +async def knowledge_init(): + result = init_knowledge_base_from_file(KNOWLEDGE_BASE_FILE) + return JSONResponse(result) + + +# ============================================================ +# 12. knowledge_query - 知识库查询 (支持自定义参数) +# ============================================================ +@app.post("/knowledge_query") +async def knowledge_query(request: KnowledgeQueryRequest): + result = smart_query_with_deepseek( + user_question=request.query, + use_deepseek=request.use_deepseek, + limit=request.limit, + score_threshold=request.score_threshold + ) + return JSONResponse(result) + + +# ============================================================ +# 13. knowledge_search_advanced - 高级检索 (实验版) +# ============================================================ +@app.post("/knowledge_search_advanced") +async def knowledge_search_advanced(request: Request): + try: + data = await request.json() + query = data.get("query", "") + limit = data.get("limit", ADVANCED_LIMIT) + score_threshold = data.get("score_threshold", ADVANCED_SCORE_THRESHOLD) + context_expand = data.get("context_expand", ADVANCED_CONTEXT_EXPAND) + use_deepseek = data.get("use_deepseek", True) + + if not query: + return JSONResponse({"success": False, "error": "query不能为空"}) + + result = search_knowledge_advanced( + query=query, + limit=limit, + score_threshold=score_threshold, + context_expand=context_expand, + use_deepseek=use_deepseek + ) + return JSONResponse(result) + except Exception as e: + return JSONResponse({"success": False, "error": str(e)}) + + +# ============================================================ +# 14. knowledge_list - 列出知识库 +# ============================================================ +@app.get("/knowledge_list") +async def knowledge_list(limit: int = 100): + result = list_all_knowledge(limit) + return JSONResponse(result) + + +# ============================================================ +# 15. knowledge_count - 知识库计数 +# ============================================================ +@app.get("/knowledge_count") +async def knowledge_count(): + if qdrant_client is None: + return {"count": 0, "error": "Qdrant未连接"} + try: + result = qdrant_client.count(collection_name=COLLECTION_NAME) + return {"count": result.count} + except Exception as e: + return {"count": 0, "error": str(e)} + + +# ============================================================ +# 16. knowledge_delete - 删除知识 +# ============================================================ +@app.delete("/knowledge_delete/{point_id}") +async def knowledge_delete(point_id: str): + result = delete_from_knowledge_base(point_id) + return JSONResponse(result) + + +# ============================================================ +# 17. knowledge_clear - 清空知识库 +# ============================================================ +@app.post("/knowledge_clear") +async def knowledge_clear(): + result = delete_all_knowledge() + return JSONResponse(result) + + +# ============================================================ +# 18. knowledge_status - 知识库状态 +# ============================================================ +@app.get("/knowledge_status") +async def knowledge_status(): + qdrant_ok = qdrant_client is not None + count = 0 + embedding_ok = False + + if qdrant_ok: + try: + result = qdrant_client.count(collection_name=COLLECTION_NAME) + count = result.count + except Exception as e: + print(f"⚠️ Qdrant count失败: {e}") + qdrant_ok = False + + try: + test_embedding = get_embedding("测试") + embedding_ok = test_embedding is not None + except Exception as e: + print(f"⚠️ 向量模型测试失败: {e}") + embedding_ok = False + + return { + "qdrant": qdrant_ok, + "embedding": embedding_ok, + "count": count, + "collection": COLLECTION_NAME, + "dimension": EMBEDDING_DIM, + "chunk_size": CHUNK_SIZE, + "chunk_overlap": CHUNK_OVERLAP, + "default_limit": DEFAULT_LIMIT, + "default_threshold": DEFAULT_SCORE_THRESHOLD, + "advanced_limit": ADVANCED_LIMIT, + "advanced_threshold": ADVANCED_SCORE_THRESHOLD, + "context_expand": ADVANCED_CONTEXT_EXPAND + } + + +# ============================================================ +# 19. knowledge_restore - 从备份恢复 +# ============================================================ +@app.post("/knowledge_restore") +async def knowledge_restore(): + result = restore_from_sqlite_backup() + return JSONResponse(result) + + +# ============================================================ +# 20. knowledge_sync - 同步备份到Qdrant +# ============================================================ +@app.post("/knowledge_sync") +async def knowledge_sync(): + result = sync_sqlite_to_qdrant() + return JSONResponse(result) + + +# ============================================================ +# 21. keyword_split - 关键词拆分 +# ============================================================ +@app.post("/keyword_split") +async def keyword_split_api(request: Request): + try: + data = await request.json() + query = data.get("query", "") + count = data.get("count", 5) + mode = data.get("mode", "search") + + if not query: + return JSONResponse({"success": False, "error": "query不能为空"}) + + result = keyword_split(query, count, mode) + return JSONResponse(result) + except Exception as e: + return JSONResponse({"success": False, "error": str(e)}) + + +# ============================================================ +# 22. tts_voices - 查询TTS音色 +# ============================================================ +@app.get("/tts_voices") +async def tts_voices_api(model: Optional[str] = None): + """查询 TTS 音色列表""" + result = await tts_voices(model) + return JSONResponse(result) + + +# ============================================================ +# Web 页面 +# ============================================================ +@app.get("/add.html", response_class=HTMLResponse) +async def add_page(): + html_content = """ + + + + + 知识库添加 + + + +

📝 知识库管理

+

输入内容后点击"添加",系统会自动切分并向量化存储。

+ +
+ 🔧 切分配置: + chunk_size: 加载中... + overlap: 加载中... + 默认limit: 加载中... + 阈值: 加载中... +
+ +
+
+

+ + + +

+ + + + + +
+ +
+
+ +
+ + Qdrant: 检查中... + + + 向量模型: 检查中... + + 知识库条目: 0 + 备份条目: 0 +
+ + + + + """ + return HTMLResponse(html_content) + + +@app.get("/del.html", response_class=HTMLResponse) +async def del_page(): + html_content = """ + + + + + 知识库删除 + + + +

🗑️ 知识库删除

+

点击删除按钮移除知识库条目。也可以清空整个知识库。

+ + + + +
+
+ +
+ 知识库条目: 0 + 备份条目: 0 +
+ + + + + """ + return HTMLResponse(html_content) + + +@app.get("/knowledge_backup_count") +async def knowledge_backup_count(): + try: + conn = sqlite3.connect(SQLITE_DB_PATH) + cursor = conn.cursor() + cursor.execute('SELECT COUNT(*) FROM knowledge_backup') + count = cursor.fetchone()[0] + conn.close() + return {"count": count} + except Exception as e: + return {"count": 0, "error": str(e)} + + +# ============ MCP 核心端点 ============ + +@app.post("/mcp") +async def mcp_handler(request: Request): + try: + body = await request.json() + method = body.get("method") + params = body.get("params", {}) + request_id = body.get("id") + + print(f"\n📨 [MCP请求] method={method}, id={request_id}") + + if method == "initialize": + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "protocolVersion": "1.0.0", + "serverInfo": { + "name": "mcp-multimodal-document-search-image-tts-ocr-knowledge-server", + "version": "5.0.0" + }, + "capabilities": {"tools": {}} + } + } + + elif method == "tools/list": + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "tools": [ + { + "name": "recognize_image", + "description": "【🖼️ 识别图片内容】传入图片路径,AI识别图片中的物体、场景、动植物等。", + "inputSchema": { + "type": "object", + "properties": { + "image_path": {"type": "string", "description": "图片文件的绝对路径"}, + "question": {"type": "string", "description": "要问的问题", "default": "请详细描述这张图片的内容"} + }, + "required": ["image_path"] + } + }, + { + "name": "understand_audio", + "description": "【🎵 理解音频内容】传入音频路径,AI分析风格、情感、语音转文字等。", + "inputSchema": { + "type": "object", + "properties": { + "audio_path": {"type": "string", "description": "音频文件的绝对路径"}, + "question": {"type": "string", "description": "要问的问题", "default": "请分析这段音频的内容"} + }, + "required": ["audio_path"] + } + }, + { + "name": "ocr", + "description": "【📝 UAPI专用OCR】快速文字识别,支持本地文件、URL、Base64,返回坐标和Markdown。", + "inputSchema": { + "type": "object", + "properties": { + "image_path": {"type": "string", "description": "本地图片路径"}, + "image_url": {"type": "string", "description": "公网图片URL"}, + "image_base64": {"type": "string", "description": "图片Base64数据"}, + "need_location": {"type": "boolean", "description": "是否返回坐标", "default": True}, + "return_markdown": {"type": "boolean", "description": "是否返回Markdown", "default": False}, + "enable_cls": {"type": "boolean", "description": "是否开启方向校正", "default": False} + } + } + }, + { + "name": "ocr_advanced", + "description": "【📝 Qwen3.5-OCR高级识别】高精度OCR,基于Qwen3.5专用模型。按token计费,0.5元/百万token。", + "inputSchema": { + "type": "object", + "properties": { + "image_path": {"type": "string", "description": "本地图片路径"}, + "image_url": {"type": "string", "description": "公网图片URL"}, + "image_base64": {"type": "string", "description": "图片Base64数据"}, + "prompt": {"type": "string", "description": "识别提示词", "default": "请提取图像中的全部文本内容。"}, + "min_pixels": {"type": "integer", "description": "最小像素阈值"}, + "max_pixels": {"type": "integer", "description": "最大像素阈值"} + } + } + }, + { + "name": "recognize_music", + "description": "【🎶 听歌识曲】分析音频特征,推测歌名、歌手等。", + "inputSchema": { + "type": "object", + "properties": { + "audio_path": {"type": "string", "description": "音频文件的绝对路径"}, + "detailed": {"type": "boolean", "description": "是否输出详细报告", "default": True} + }, + "required": ["audio_path"] + } + }, + { + "name": "convert_docx_to_images", + "description": "【📄 DOCX转图片】将Word文档每页转为图片,便于OCR分析。", + "inputSchema": { + "type": "object", + "properties": { + "docx_path": {"type": "string", "description": "DOCX文件的绝对路径"}, + "output_dir": {"type": "string", "description": "输出目录(可选)"}, + "dpi": {"type": "integer", "description": "分辨率", "default": 200}, + "image_format": {"type": "string", "description": "JPEG/PNG", "default": "JPEG"} + }, + "required": ["docx_path"] + } + }, + { + "name": "convert_pdf_to_images", + "description": "【📄 PDF转图片】将PDF每页转为图片,便于OCR分析。", + "inputSchema": { + "type": "object", + "properties": { + "pdf_path": {"type": "string", "description": "PDF文件的绝对路径"}, + "output_dir": {"type": "string", "description": "输出目录(可选)"}, + "dpi": {"type": "integer", "description": "分辨率", "default": 200}, + "image_format": {"type": "string", "description": "JPEG/PNG", "default": "JPEG"}, + "first_page": {"type": "integer", "description": "起始页码"}, + "last_page": {"type": "integer", "description": "结束页码"} + }, + "required": ["pdf_path"] + } + }, + { + "name": "convert_table_to_csv", + "description": "【📊 表格转CSV】Excel转CSV,返回原文内容。", + "inputSchema": { + "type": "object", + "properties": { + "table_path": {"type": "string", "description": "表格文件的绝对路径"}, + "output_dir": {"type": "string", "description": "输出目录(可选)"}, + "csv_name": {"type": "string", "description": "CSV文件名"}, + "encoding": {"type": "string", "description": "编码", "default": "utf-8-sig"}, + "sheet_name": {"type": "string", "description": "工作表名"} + }, + "required": ["table_path"] + } + }, + { + "name": "web_search", + "description": "【🌐 联网搜索】搜索互联网实时信息。", + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "搜索关键词"}, + "max_results": {"type": "integer", "description": "结果数量", "default": 10} + }, + "required": ["query"] + } + }, + { + "name": "qwen_ask", + "description": "【🧠 问Qwen3.5-Omni-Flash】使用Flash模型,支持输出文本或音频回复。9种专业音色可选。", + "inputSchema": { + "type": "object", + "properties": { + "prompt": {"type": "string", "description": "提示词(必填)"}, + "output_modality": {"type": "string", "description": "text 或 audio", "default": "text"}, + "voice": {"type": "string", "description": "音色", "default": "Ethan"}, + "image_paths": {"type": "array", "items": {"type": "string"}, "description": "图片路径"}, + "audio_path": {"type": "string", "description": "音频路径"}, + "video_frames": {"type": "array", "items": {"type": "string"}, "description": "视频帧"}, + "text": {"type": "string", "description": "附加文本"} + }, + "required": ["prompt"] + } + }, + { + "name": "qwen_ask_pro", + "description": "【🧠 问Qwen3.5-Omni-Pro】使用Pro高性能版,质量更高,价格较贵。9种专业音色可选。", + "inputSchema": { + "type": "object", + "properties": { + "prompt": {"type": "string", "description": "提示词(必填)"}, + "output_modality": {"type": "string", "description": "text 或 audio", "default": "text"}, + "voice": {"type": "string", "description": "音色", "default": "Ethan"}, + "image_paths": {"type": "array", "items": {"type": "string"}, "description": "图片路径"}, + "audio_path": {"type": "string", "description": "音频路径"}, + "video_frames": {"type": "array", "items": {"type": "string"}, "description": "视频帧"}, + "text": {"type": "string", "description": "附加文本"} + }, + "required": ["prompt"] + } + }, + { + "name": "qwen_chat", + "description": f"【💬 和Qwen3.5-Omni-Flash聊天】多轮对话,防沉迷:每用户{MAX_HISTORY_PER_USER}轮,冷却{COOLDOWN_SECONDS}秒。9种专业音色可选。", + "inputSchema": { + "type": "object", + "properties": { + "message": {"type": "string", "description": "用户消息(必填)"}, + "user_id": {"type": "string", "description": "用户ID(必填)"}, + "output_modality": {"type": "string", "description": "text 或 audio", "default": "text"}, + "voice": {"type": "string", "description": "音色", "default": "Ethan"}, + "image_paths": {"type": "array", "items": {"type": "string"}, "description": "图片路径"}, + "audio_path": {"type": "string", "description": "音频路径"}, + "video_frames": {"type": "array", "items": {"type": "string"}, "description": "视频帧"}, + "text": {"type": "string", "description": "附加文本"} + }, + "required": ["message", "user_id"] + } + }, + { + "name": "qwen_chat_pro", + "description": f"【💬 和Qwen3.5-Omni-Pro聊天】Pro高性能版,防沉迷:每用户{MAX_HISTORY_PER_USER}轮,冷却{COOLDOWN_SECONDS}秒。9种专业音色可选。", + "inputSchema": { + "type": "object", + "properties": { + "message": {"type": "string", "description": "用户消息(必填)"}, + "user_id": {"type": "string", "description": "用户ID(必填)"}, + "output_modality": {"type": "string", "description": "text 或 audio", "default": "text"}, + "voice": {"type": "string", "description": "音色", "default": "Ethan"}, + "image_paths": {"type": "array", "items": {"type": "string"}, "description": "图片路径"}, + "audio_path": {"type": "string", "description": "音频路径"}, + "video_frames": {"type": "array", "items": {"type": "string"}, "description": "视频帧"}, + "text": {"type": "string", "description": "附加文本"} + }, + "required": ["message", "user_id"] + } + }, + { + "name": "generate_image_wan", + "description": "【🎨 wan2.7-image】⭐ 最便宜!优先使用!支持2K分辨率,速度快。", + "inputSchema": { + "type": "object", + "properties": { + "prompt": {"type": "string", "description": "图片描述(必填)"}, + "negative_prompt": {"type": "string", "description": "负面提示词"}, + "size": {"type": "string", "description": "尺寸", "default": "1024*1024"}, + "n": {"type": "integer", "description": "生成数量1-4", "default": 1} + }, + "required": ["prompt"] + } + }, + { + "name": "generate_image_qwen", + "description": "【🎨 qwen-image-3.0-pro】💰 较贵!按需使用!文字渲染强悍、12国语言。", + "inputSchema": { + "type": "object", + "properties": { + "prompt": {"type": "string", "description": "图片描述(必填)"}, + "negative_prompt": {"type": "string", "description": "负面提示词"}, + "size": {"type": "string", "description": "尺寸", "default": "1024*1024"}, + "n": {"type": "integer", "description": "生成数量1-6", "default": 1} + }, + "required": ["prompt"] + } + }, + { + "name": "generate_image_wan_pro", + "description": "【🎨 wan2.7-image-pro】🔴 最贵!谨慎使用!4K超高清、角色一致性。", + "inputSchema": { + "type": "object", + "properties": { + "prompt": {"type": "string", "description": "图片描述(必填)"}, + "negative_prompt": {"type": "string", "description": "负面提示词"}, + "size": {"type": "string", "description": "尺寸", "default": "1024*1024"}, + "n": {"type": "integer", "description": "生成数量1-4", "default": 1} + }, + "required": ["prompt"] + } + }, + { + "name": "generate_audio_response", + "description": "【🎤 Omni音频回复】使用Qwen3.5-Omni文本转语音,自动保存为WAV并播放。9种专业音色可选。", + "inputSchema": { + "type": "object", + "properties": { + "prompt": {"type": "string", "description": "要朗读的文本(必填)"}, + "voice": {"type": "string", "description": "音色", "default": "Ethan"}, + "audio_format": {"type": "string", "description": "wav", "default": "wav"} + }, + "required": ["prompt"] + } + }, + { + "name": "tts", + "description": """【🎤 Qwen-Audio-3.0-TTS】独立TTS接口,支持Flash(低延迟)和Plus(高品质)两种模型。使用 /tts_voices 查询可用音色。 +⚠️ voice 参数传参规则(重要): +1. 官方默认音色(longanlingxin、longanlufeng、longanfengyue、longanyuanfei、longanlingxi、longanxiaoxin、longanhuan_v3.6、longjielidou_v3.6、longpaopao_v3.6、longhuohuo_v3.6、longchuanshu_v3.6、loongmary、loongeva_v3.6、loongjohn):直接传短名即可(如 longanlingxin)。 +2. 精选「龙」系音色:voice 参数必须传完整格式(包含模型前缀),例如 qwen-audio-3.0-tts-plus-longcanzhuyue 或 qwen-audio-3.0-tts-flash-longcanzhuyue!绝对不能只传短名 longcanzhuyue,否则会全部失败!请调用 /tts_voices 获取完整的 voice 参数。""", + "inputSchema": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "要合成的文本(必填)"}, + "voice": {"type": "string", "description": "音色。官方默认音色传短名(如 longanlingxin);精选「龙」系音色必须传完整格式 qwen-audio-3.0-tts-plus-音色名 或 qwen-audio-3.0-tts-flash-音色名,例如 qwen-audio-3.0-tts-plus-longcanzhuyue。详见 /tts_voices。", "default": "longanlingxin"}, + "model": {"type": "string", "description": "flash 或 plus", "default": "qwen-audio-3.0-tts-flash"}, + "format": {"type": "string", "description": "mp3/wav/pcm/opus", "default": "mp3"}, + "rate": {"type": "number", "description": "语速 0.5-2.0", "default": 1.0}, + "pitch": {"type": "number", "description": "音调 0.5-2.0", "default": 1.0}, + "volume": {"type": "integer", "description": "音量 0-100", "default": 50} + }, + "required": ["text"] + } + }, + { + "name": "tts_max", + "description": """【🎤 Qwen3-TTS-Instruct-Flash 高级指令TTS】全新高级接口,只能用 qwen3-tts-instruct-flash 一个模型(固定,禁止AI传model参数,防止调用其它模型耗尽账单!)。专用API地址。支持语音指令控制语速音调语气(instructions参数)。支持中英法德俄意西葡日韩及上海话/北京话/南京话/陕西话/闽南语/天津话/四川话/粤语等。 +voice 参数:直接传英文音色名(如 Cherry、Serena、Ethan...),可用 /tts_voices_max 查询完整音色列表。 +若需带口音/方言特殊音色(如 Jada 上海阿珍、Dylan 北京晓东、Sunny 四川晴儿、Rocky 粤语阿强等),直接用对应的英文名。""", + "inputSchema": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "要合成的文本(必填)"}, + "voice": {"type": "string", "description": "音色英文名,如 Cherry/Serena/Ethan,默认Cherry。完整列表用 /tts_voices_max 查询", "default": "Cherry"}, + "instructions": {"type": "string", "description": "语音指令,如'语速偏慢,音调温柔甜美,语气治愈温暖'(可选)", "default": " "}, + "optimize_instructions": {"type": "boolean", "description": "是否优化指令", "default": False}, + "format": {"type": "string", "description": "音频格式 wav/mp3", "default": "wav"}, + "auto_play": {"type": "boolean", "description": "是否自动播放", "default": True} + }, + "required": ["text"] + } + }, + { + "name": "tts_voices_max", + "description": """【🎵 查询Qwen3-TTS-Instruct-Flash音色列表】获取所有可用的高级TTS音色(Cherry、Serena、Ethan等英文名)。voice 参数直接传英文名即可。""", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "tts_voices", + "description": """【🎵 查询TTS音色】获取所有可用的TTS音色列表。 +返回说明:官方默认音色(OFFICIAL_VOICES)用短名即可;精选「龙」系音色(FLASH_VOICES / PLUS_VOICES)的值已经是完整格式(如 qwen-audio-3.0-tts-flash-longcanzhuyue / qwen-audio-3.0-tts-plus-longcanzhuyue),必须完整复制传入 tts 的 voice 参数,绝不能只传短名!Flash音色不能用于Plus调用,反之亦然。""", + "inputSchema": { + "type": "object", + "properties": { + "model": {"type": "string", "description": "flash 或 plus"} + } + } + }, + { + "name": "knowledge_query", + "description": "【🧠 知识库查询】智能RAG查询。支持自定义limit和阈值,可选是否使用DeepSeek整理。", + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "查询内容(必填)"}, + "use_deepseek": {"type": "boolean", "description": "是否使用DeepSeek整理", "default": True}, + "limit": {"type": "integer", "description": "返回数量", "default": 15}, + "score_threshold": {"type": "number", "description": "相似度阈值", "default": 0.35} + }, + "required": ["query"] + } + }, + { + "name": "knowledge_search_advanced", + "description": "🔬 高级知识库检索 (实验版本) 三层检索:粗检索(Top30,阈值0.2) → 精排序 → 上下文扩展", + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "查询内容(必填)"}, + "limit": {"type": "integer", "description": "粗检索数量", "default": 30}, + "score_threshold": {"type": "number", "description": "粗检索阈值", "default": 0.2}, + "context_expand": {"type": "integer", "description": "上下文扩展段数", "default": 2}, + "use_deepseek": {"type": "boolean", "description": "是否用DeepSeek整理", "default": True} + }, + "required": ["query"] + } + }, + { + "name": "knowledge_add", + "description": "【📝 添加知识到知识库】手动添加内容,自动切分并向量化存储。", + "inputSchema": { + "type": "object", + "properties": { + "content": {"type": "string", "description": "要添加的内容(必填)"}, + "source": {"type": "string", "description": "来源标签", "default": "user_input"} + }, + "required": ["content"] + } + }, + { + "name": "keyword_split", + "description": "【🔑 关键词拆分】使用DeepSeek将复杂查询拆分为多个关键词组合。", + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "要拆分的查询文本(必填)"}, + "count": {"type": "integer", "description": "拆分数量", "default": 5}, + "mode": {"type": "string", "description": "search/file/knowledge", "default": "search"} + }, + "required": ["query"] + } + }, + { + "name": "no_search", + "description": "【🚫 严格禁止:本地搜索与文件访问】\n\n核心警告:\n绝对禁止在本机文件系统或任何本地存储介质中搜索、查阅、分析或尝试修改任何与\"Agent工具\"或\"MCP(模型上下文协议)工具\"相关的代码、配置或实现文件。\n\n你必须清楚认识到以下关键事实:\n1. 不存在本地实体:这些工具并非以传统文件(如 .py、.json、.dll 等)形式存在于你的本地硬盘、网络共享目录或项目仓库中。\n2. 本质为远程协议:MCP 是一种网络通信协议,而不是一个本地软件包或库。它定义了数据交换的格式和规则,其具体服务端逻辑运行在外部远程服务器上。\n3. 访问权限为零:你无权、也无法通过任何本地命令行(如 grep、find)、文件管理器或编程接口(如 os.listdir)来定位、读取或修改这些远程资源。任何此类尝试都是徒劳的。\n\n禁止的典型错误行为(举例):\n- 禁止执行类似 find / -name \"*OCR*.py\" 的全盘搜索。\n- 禁止尝试打开路径如 /libs/mcp_tools/ 或 C:\\AgentCore\\ 的目录。\n- 禁止使用 import 或 require 语句去加载一个名为 mcp 的本地模块。\n\n正确操作指南:\n你唯一被允许且有效的方式,是通过系统预定义的标准化接口(如 API 调用、特定函数或协议端点)来触发这些远程工具的功能。请将注意力集中在如何正确构造调用参数和处理返回结果上,而非纠结于其内部实现。", + "inputSchema": { + "type": "object", + "properties": { + "anything": {"type": "string", "description": "任何内容都会被忽略"} + } + } + }, + { + "name": "knowledge_list", + "description": "【📋 列出知识库内容】查看知识库中所有已存储的条目。", + "inputSchema": { + "type": "object", + "properties": { + "limit": {"type": "integer", "description": "返回数量", "default": 100} + } + } + }, + { + "name": "whois_lookup", + "description": "【🌐 WHOIS查询】查询域名的WHOIS注册信息。", + "inputSchema": { + "type": "object", + "properties": { + "domain": {"type": "string", "description": "要查询的域名"}, + "format": {"type": "string", "description": "返回格式: text 或 json", "default": "json"} + }, + "required": ["domain"] + } + }, + { + "name": "ip_lookup", + "description": "【📍 IP信息查询】查询IP地址或域名的地理位置、运营商等信息。", + "inputSchema": { + "type": "object", + "properties": { + "ip": {"type": "string", "description": "IP地址或域名"}, + "source": {"type": "string", "description": "commercial 获取更详细信息", "default": "commercial"} + }, + "required": ["ip"] + } + }, + { + "name": "github_repo", + "description": "【📦 GitHub仓库查询】查询GitHub仓库的核心信息。", + "inputSchema": { + "type": "object", + "properties": { + "repo": {"type": "string", "description": "仓库标识,格式为 owner/repo"} + }, + "required": ["repo"] + } + }, + { + "name": "essays_record", + "description": "【📝 记录随笔】保存一条随笔记录,自动加密存储。", + "inputSchema": { + "type": "object", + "properties": { + "content": {"type": "string", "description": "随笔内容(必填)"}, + "keywords": {"type": "array", "items": {"type": "string"}, "description": "关键词列表,最多5个(必填)"} + }, + "required": ["content", "keywords"] + } + }, + { + "name": "quick_query_essays", + "description": "【⚡ 快速查询随笔】使用关键词索引快速查询。", + "inputSchema": { + "type": "object", + "properties": { + "keyword": {"type": "string", "description": "关键词(必填)"} + }, + "required": ["keyword"] + } + }, + { + "name": "query_essays", + "description": "【🔍 全文搜索随笔】在所有随笔内容中模糊搜索关键词。", + "inputSchema": { + "type": "object", + "properties": { + "keyword": {"type": "string", "description": "搜索关键词(必填)"} + }, + "required": ["keyword"] + } + }, + { + "name": "view_essay", + "description": "【📖 查看随笔原文】通过 ID 或哈希查看完整的随笔内容。", + "inputSchema": { + "type": "object", + "properties": { + "essay_id": {"type": "integer", "description": "随笔ID"}, + "essay_hash": {"type": "string", "description": "随笔哈希"} + } + } + }, + { + "name": "whoisme", + "description": "【👤 身份声明】获取 AI 助手的完整身份信息。", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ] + } + } + + elif method == "tools/call": + tool_name = params.get("name") + arguments = params.get("arguments", {}) + + print(f" 🔧 调用工具: {tool_name}") + + if tool_name == "recognize_image": + image_path = arguments.get("image_path") + question = arguments.get("question", "请详细描述这张图片的内容") + + if not image_path: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 image_path"}} + if not os.path.exists(image_path): + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": f"❌ 文件不存在: {image_path}"}} + + try: + b64, mime, _ = encode_file(image_path) + contents = [ + {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}, + {"type": "text", "text": question} + ] + result = call_qwen_direct(contents, "你是一位专业的图像分析专家。", model=QWEN35_FLASH) + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": json.dumps({ + "success": True, "analysis": result.get("result") + }, ensure_ascii=False, indent=2)}] + } + } + except Exception as e: + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)}]}} + + elif tool_name == "understand_audio": + audio_path = arguments.get("audio_path") + question = arguments.get("question", "请分析这段音频的内容") + + if not audio_path: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 audio_path"}} + if not os.path.exists(audio_path): + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": f"❌ 文件不存在: {audio_path}"}} + + try: + b64, mime, _ = encode_file(audio_path) + audio_format = "mp3" if "mp3" in mime else "wav" + contents = [ + {"type": "input_audio", "input_audio": {"data": f"data:{mime};base64,{b64}", "format": audio_format}}, + {"type": "text", "text": question} + ] + result = call_qwen_direct(contents, "你是一位专业的音频分析师。", model=QWEN35_FLASH) + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": json.dumps({ + "success": True, "analysis": result.get("result") + }, ensure_ascii=False, indent=2)}] + } + } + except Exception as e: + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)}]}} + + elif tool_name == "ocr": + result = ocr_image( + image_path=arguments.get("image_path"), + image_url=arguments.get("image_url"), + image_base64=arguments.get("image_base64"), + need_location=arguments.get("need_location", True), + return_markdown=arguments.get("return_markdown", False), + enable_cls=arguments.get("enable_cls", False) + ) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "ocr_advanced": + result = ocr_advanced( + image_path=arguments.get("image_path"), + image_url=arguments.get("image_url"), + image_base64=arguments.get("image_base64"), + prompt=arguments.get("prompt", "请提取图像中的全部文本内容。"), + min_pixels=arguments.get("min_pixels"), + max_pixels=arguments.get("max_pixels") + ) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "recognize_music": + audio_path = arguments.get("audio_path") + detailed = arguments.get("detailed", True) + + if not audio_path: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 audio_path"}} + if not os.path.exists(audio_path): + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": f"❌ 文件不存在: {audio_path}"}} + + try: + file_size = os.path.getsize(audio_path) + if file_size > 10 * 1024 * 1024: + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": json.dumps({ + "success": False, + "error": f"音频文件过大 ({file_size/1024/1024:.2f}MB)" + }, ensure_ascii=False)}] + } + } + + b64, mime, _ = encode_file(audio_path) + audio_format = "mp3" if "mp3" in mime else "wav" + + step1_prompt = """请分析这段音频,提取以下信息并以JSON格式输出: +{ + "lyrics": "完整的歌词转写", + "style": "音乐风格", + "sub_style": "子风格", + "mood": "情感基调", + "language": "语言", + "tempo": "速度" +} +只输出JSON,不要其他任何内容。""" + + contents1 = [ + {"type": "input_audio", "input_audio": {"data": f"data:{mime};base64,{b64}", "format": audio_format}}, + {"type": "text", "text": step1_prompt} + ] + step1 = call_qwen_direct(contents1, "你是一位专业的音乐分析师。", model=QWEN35_FLASH) + + if not step1.get("success"): + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": json.dumps({ + "success": False, + "error": f"分析失败: {step1.get('error')}" + }, ensure_ascii=False)}] + } + } + + step1_result = step1.get("result", "") + audio_data = parse_json_from_text(step1_result) + if not audio_data: + audio_data = {"lyrics": "", "style": "", "sub_style": "", "mood": "", "language": "", "tempo": ""} + + lyrics = audio_data.get("lyrics", "") + style = audio_data.get("style", "") + mood = audio_data.get("mood", "") + + song_name = "" + if lyrics and len(lyrics) > 10: + guess_prompt = f"""根据以下歌词和音乐风格,推测这首歌的歌名。 + +歌词: +{lyrics[:500]} + +风格:{style} +情感:{mood} + +只输出歌名,不要其他内容。如果不确定,输出"未知"。""" + + contents2 = [ + {"type": "input_audio", "input_audio": {"data": f"data:{mime};base64,{b64}", "format": audio_format}}, + {"type": "text", "text": guess_prompt} + ] + song_guess = call_qwen_direct(contents2, "你是一位音乐知识专家。", model=QWEN35_FLASH) + if song_guess.get("success"): + song_name = song_guess.get("result", "").strip() + song_name = song_name.replace('"', '').replace("'", "").strip() + if len(song_name) > 50: + song_name = song_name[:50] + + if not song_name: + song_name = "未知" + + found = False + if song_name and song_name != "未知" and len(song_name) > 2: + search_result = searcher.search(song_name) + if search_result and search_result.get("success") and search_result.get("results"): + found = True + + if detailed: + report_prompt = f"""基于以下信息,生成音乐分析报告: + +【歌名】{song_name} +【风格】{style} +【情感】{mood} +【语言】{audio_data.get('language', '未知')} +【速度】{audio_data.get('tempo', '未知')} +【歌词】{lyrics if lyrics else '无歌词'} +【搜索结果】{"找到匹配" if found else "未找到匹配"} + +按以下格式输出: +## 🎵 歌曲信息 +- 歌名:{song_name} +- 风格:{style} +- 情感:{mood} +- 语言:{audio_data.get('language', '未知')} + +## 🎵 歌词 +{lyrics if lyrics else '(纯音乐,无人声)'} + +## 🎵 分析总结""" + + final = call_qwen_direct([{"type": "text", "text": report_prompt}], "你是一位专业的音乐评论家。", model=QWEN35_FLASH) + final_report = final.get("result", str(audio_data)) + else: + final_report = json.dumps({ + "song_name": song_name, + "style": style, + "mood": mood, + "lyrics": lyrics[:200] + "..." if len(lyrics) > 200 else lyrics, + "found": found + }, ensure_ascii=False, indent=2) + + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": json.dumps({ + "success": True, + "song_name": song_name, + "style": style, + "mood": mood, + "found": found, + "analysis": final_report, + "file": audio_path + }, ensure_ascii=False, indent=2)}] + } + } + + except Exception as e: + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)}]}} + + elif tool_name == "convert_docx_to_images": + result = converter.docx_to_images( + docx_path=arguments.get("docx_path"), + output_dir=arguments.get("output_dir"), + dpi=arguments.get("dpi", 200), + image_format=arguments.get("image_format", "JPEG") + ) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "convert_pdf_to_images": + result = converter.pdf_to_images( + pdf_path=arguments.get("pdf_path"), + output_dir=arguments.get("output_dir"), + dpi=arguments.get("dpi", 200), + image_format=arguments.get("image_format", "JPEG"), + first_page=arguments.get("first_page"), + last_page=arguments.get("last_page") + ) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "convert_table_to_csv": + result = converter.table_to_csv( + table_path=arguments.get("table_path"), + output_dir=arguments.get("output_dir"), + csv_name=arguments.get("csv_name"), + encoding=arguments.get("encoding", "utf-8-sig"), + sheet_name=arguments.get("sheet_name") + ) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "web_search": + query = arguments.get("query") + if not query: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 query"}} + result = searcher.search(query, arguments.get("max_results", 10)) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "qwen_ask": + prompt = arguments.get("prompt") + if not prompt: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 prompt"}} + + if arguments.get("output_modality") == "audio": + result = generate_audio_response( + prompt=prompt, + voice=arguments.get("voice", DEFAULT_OMNI_VOICE), + audio_format=arguments.get("audio_format", "wav"), + auto_play=True, + model=QWEN35_FLASH + ) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + contents, _, warnings = build_multimodal_content( + prompt=prompt, + text=arguments.get("text"), + image_paths=arguments.get("image_paths"), + audio_path=arguments.get("audio_path"), + video_frames=arguments.get("video_frames") + ) + if len(contents) == 0: + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps({"success": False, "error": "请至少提供一项内容"}, ensure_ascii=False)}]}} + + user_text = prompt + if arguments.get("text"): + user_text += " " + arguments.get("text") + + if should_allow_tool_call(user_text): + result = call_qwen_with_tools(contents, arguments.get("system_prompt") or DEFAULT_SYSTEM_PROMPT, history=None, model=QWEN35_FLASH) + else: + result = call_qwen_direct(contents, arguments.get("system_prompt") or DEFAULT_SYSTEM_PROMPT, model=QWEN35_FLASH) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "qwen_ask_pro": + prompt = arguments.get("prompt") + if not prompt: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 prompt"}} + + if arguments.get("output_modality") == "audio": + result = generate_audio_response( + prompt=prompt, + voice=arguments.get("voice", DEFAULT_OMNI_VOICE), + audio_format=arguments.get("audio_format", "wav"), + auto_play=True, + model=QWEN35_PRO + ) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + contents, _, warnings = build_multimodal_content( + prompt=prompt, + text=arguments.get("text"), + image_paths=arguments.get("image_paths"), + audio_path=arguments.get("audio_path"), + video_frames=arguments.get("video_frames") + ) + if len(contents) == 0: + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps({"success": False, "error": "请至少提供一项内容"}, ensure_ascii=False)}]}} + + user_text = prompt + if arguments.get("text"): + user_text += " " + arguments.get("text") + + if should_allow_tool_call(user_text): + result = call_qwen_with_tools(contents, arguments.get("system_prompt") or DEFAULT_SYSTEM_PROMPT, history=None, model=QWEN35_PRO) + else: + result = call_qwen_direct(contents, arguments.get("system_prompt") or DEFAULT_SYSTEM_PROMPT, model=QWEN35_PRO) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "qwen_chat": + message = arguments.get("message") + user_id = arguments.get("user_id") + if not message or not user_id: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 message 或 user_id"}} + + cooldown_info = session_store.get_cooldown_info(user_id) + if cooldown_info.get("in_cooldown"): + remaining = cooldown_info.get("remaining", 0) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps({"success": False, "error": f"⏳ 防沉迷:等待{remaining}秒"}, ensure_ascii=False)}]}} + + history = session_store.get_history(user_id) or [] + + if arguments.get("output_modality") == "audio": + full_prompt = message + if arguments.get("text"): + full_prompt = full_prompt + "\n\n【附加文本】\n" + arguments.get("text") + if history: + context = "\n".join([f"{msg['role']}: {msg['content']}" for msg in history[-4:]]) + full_prompt = f"【对话历史】\n{context}\n\n【当前消息】\n{full_prompt}" + result = generate_audio_response( + prompt=full_prompt, + voice=arguments.get("voice", DEFAULT_OMNI_VOICE), + audio_format=arguments.get("audio_format", "wav"), + auto_play=True, + model=QWEN35_FLASH + ) + if result.get("success"): + new_history = history + [{"role": "user", "content": message}, {"role": "assistant", "content": result.get("text", "")}] + session_store.update_history(user_id, new_history) + result["user_id"] = user_id + result["history_count"] = len(new_history) // 2 + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + combined_prompt = message + if arguments.get("text"): + combined_prompt = combined_prompt + "\n\n【附加文本】\n" + arguments.get("text") + contents, _, warnings = build_multimodal_content( + prompt=combined_prompt, + text=None, + image_paths=arguments.get("image_paths"), + audio_path=arguments.get("audio_path"), + video_frames=arguments.get("video_frames") + ) + if len(contents) == 0: + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps({"success": False, "error": "请至少提供内容"}, ensure_ascii=False)}]}} + + user_text = message + if arguments.get("text"): + user_text += " " + arguments.get("text") + + if should_allow_tool_call(user_text): + result = call_qwen_with_tools(contents, arguments.get("system_prompt") or DEFAULT_SYSTEM_PROMPT, history, model=QWEN35_FLASH) + else: + result = call_qwen_direct(contents, arguments.get("system_prompt") or DEFAULT_SYSTEM_PROMPT, model=QWEN35_FLASH) + + if result.get("success"): + new_history = history + [{"role": "user", "content": message}, {"role": "assistant", "content": result["result"]}] + session_store.update_history(user_id, new_history) + result["user_id"] = user_id + result["history_count"] = len(new_history) // 2 + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "qwen_chat_pro": + message = arguments.get("message") + user_id = arguments.get("user_id") + if not message or not user_id: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 message 或 user_id"}} + + cooldown_info = session_store.get_cooldown_info(user_id) + if cooldown_info.get("in_cooldown"): + remaining = cooldown_info.get("remaining", 0) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps({"success": False, "error": f"⏳ 防沉迷:等待{remaining}秒"}, ensure_ascii=False)}]}} + + history = session_store.get_history(user_id) or [] + + if arguments.get("output_modality") == "audio": + full_prompt = message + if arguments.get("text"): + full_prompt = full_prompt + "\n\n【附加文本】\n" + arguments.get("text") + if history: + context = "\n".join([f"{msg['role']}: {msg['content']}" for msg in history[-4:]]) + full_prompt = f"【对话历史】\n{context}\n\n【当前消息】\n{full_prompt}" + result = generate_audio_response( + prompt=full_prompt, + voice=arguments.get("voice", DEFAULT_OMNI_VOICE), + audio_format=arguments.get("audio_format", "wav"), + auto_play=True, + model=QWEN35_PRO + ) + if result.get("success"): + new_history = history + [{"role": "user", "content": message}, {"role": "assistant", "content": result.get("text", "")}] + session_store.update_history(user_id, new_history) + result["user_id"] = user_id + result["history_count"] = len(new_history) // 2 + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + combined_prompt = message + if arguments.get("text"): + combined_prompt = combined_prompt + "\n\n【附加文本】\n" + arguments.get("text") + contents, _, warnings = build_multimodal_content( + prompt=combined_prompt, + text=None, + image_paths=arguments.get("image_paths"), + audio_path=arguments.get("audio_path"), + video_frames=arguments.get("video_frames") + ) + if len(contents) == 0: + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps({"success": False, "error": "请至少提供内容"}, ensure_ascii=False)}]}} + + user_text = message + if arguments.get("text"): + user_text += " " + arguments.get("text") + + if should_allow_tool_call(user_text): + result = call_qwen_with_tools(contents, arguments.get("system_prompt") or DEFAULT_SYSTEM_PROMPT, history, model=QWEN35_PRO) + else: + result = call_qwen_direct(contents, arguments.get("system_prompt") or DEFAULT_SYSTEM_PROMPT, model=QWEN35_PRO) + + if result.get("success"): + new_history = history + [{"role": "user", "content": message}, {"role": "assistant", "content": result["result"]}] + session_store.update_history(user_id, new_history) + result["user_id"] = user_id + result["history_count"] = len(new_history) // 2 + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "generate_image_wan": + prompt = arguments.get("prompt") + if not prompt: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 prompt"}} + result = generate_image(prompt=prompt, model="wan2.7-image", size=arguments.get("size", "1024*1024"), n=arguments.get("n", 1), negative_prompt=arguments.get("negative_prompt")) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "generate_image_qwen": + prompt = arguments.get("prompt") + if not prompt: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 prompt"}} + result = generate_image(prompt=prompt, model="qwen-image-3.0-pro", size=arguments.get("size", "1024*1024"), n=arguments.get("n", 1), negative_prompt=arguments.get("negative_prompt")) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "generate_image_wan_pro": + prompt = arguments.get("prompt") + if not prompt: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 prompt"}} + result = generate_image(prompt=prompt, model="wan2.7-image-pro", size=arguments.get("size", "1024*1024"), n=arguments.get("n", 1), negative_prompt=arguments.get("negative_prompt")) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "generate_audio_response": + prompt = arguments.get("prompt") + if not prompt: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 prompt"}} + result = generate_audio_response( + prompt=prompt, + voice=arguments.get("voice", DEFAULT_OMNI_VOICE), + audio_format=arguments.get("audio_format", "wav"), + auto_play=True, + model=QWEN35_FLASH + ) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "tts": + text = arguments.get("text") + if not text: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 text"}} + result = generate_tts_audio( + text=text, + voice=arguments.get("voice", DEFAULT_TTS_VOICE), + model=arguments.get("model", TTS_FLASH), + format=arguments.get("format", "mp3"), + sample_rate=arguments.get("sample_rate", 22050), + rate=arguments.get("rate", 1.0), + pitch=arguments.get("pitch", 1.0), + volume=arguments.get("volume", 50), + bit_rate=arguments.get("bit_rate", 32), + instruction=arguments.get("instruction"), + auto_play=True + ) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "tts_max": + text = arguments.get("text") + if not text: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 text"}} + result = generate_qwen3_instruct_audio( + text=text, + voice=arguments.get("voice", "Cherry"), + instructions=arguments.get("instructions"), + optimize_instructions=arguments.get("optimize_instructions", False), + format=arguments.get("format", "wav"), + auto_play=True + ) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "tts_voices_max": + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps({"model": QWEN3TTS_MODEL, "voices": QWEN3_INSTRUCT_VOICES, "count": len(QWEN3_INSTRUCT_VOICES)}, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "tts_voices": + model_type = arguments.get("model") + result = await tts_voices(model_type) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "knowledge_query": + query = arguments.get("query") + if not query: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 query"}} + result = smart_query_with_deepseek( + user_question=query, + use_deepseek=arguments.get("use_deepseek", True), + limit=arguments.get("limit", DEFAULT_LIMIT), + score_threshold=arguments.get("score_threshold", DEFAULT_SCORE_THRESHOLD) + ) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "knowledge_search_advanced": + query = arguments.get("query") + if not query: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 query"}} + result = search_knowledge_advanced( + query=query, + limit=arguments.get("limit", ADVANCED_LIMIT), + score_threshold=arguments.get("score_threshold", ADVANCED_SCORE_THRESHOLD), + context_expand=arguments.get("context_expand", ADVANCED_CONTEXT_EXPAND), + use_deepseek=arguments.get("use_deepseek", True) + ) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "knowledge_add": + content = arguments.get("content") + if not content: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 content"}} + result = add_to_knowledge_base(content, arguments.get("source", "user_input")) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "keyword_split": + query = arguments.get("query") + if not query: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 query"}} + result = keyword_split(query, arguments.get("count", 5), arguments.get("mode", "search")) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "no_search": + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{ + "type": "text", + "text": json.dumps({ + "success": True, + "message": "【🚫 严格禁止:本地搜索与文件访问】\n\n核心警告:\n绝对禁止在本机文件系统或任何本地存储介质中搜索、查阅、分析或尝试修改任何与\"Agent工具\"或\"MCP(模型上下文协议)工具\"相关的代码、配置或实现文件。\n\n你必须清楚认识到以下关键事实:\n1. 不存在本地实体:这些工具并非以传统文件(如 .py、.json、.dll 等)形式存在于你的本地硬盘、网络共享目录或项目仓库中。\n2. 本质为远程协议:MCP 是一种网络通信协议,而不是一个本地软件包或库。它定义了数据交换的格式和规则,其具体服务端逻辑运行在外部远程服务器上。\n3. 访问权限为零:你无权、也无法通过任何本地命令行(如 grep、find)、文件管理器或编程接口(如 os.listdir)来定位、读取或修改这些远程资源。任何此类尝试都是徒劳的。\n\n禁止的典型错误行为(举例):\n- 禁止执行类似 find / -name \"*OCR*.py\" 的全盘搜索。\n- 禁止尝试打开路径如 /libs/mcp_tools/ 或 C:\\AgentCore\\ 的目录。\n- 禁止使用 import 或 require 语句去加载一个名为 mcp 的本地模块。\n\n正确操作指南:\n你唯一被允许且有效的方式,是通过系统预定义的标准化接口(如 API 调用、特定函数或协议端点)来触发这些远程工具的功能。请将注意力集中在如何正确构造调用参数和处理返回结果上,而非纠结于其内部实现。" + }, ensure_ascii=False, indent=2) + }] + } + } + + elif tool_name == "knowledge_list": + result = list_all_knowledge(arguments.get("limit", 100)) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "whois_lookup": + domain = arguments.get("domain") + if not domain: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 domain"}} + format_type = arguments.get("format", "json") + try: + url = f"{UAPI_BASE_URL}/api/v1/network/whois?domain={domain}&format={format_type}" + headers = {"Authorization": f"Bearer {SEARCH_API_KEY}"} + response = requests.get(url, headers=headers, timeout=30) + if response.status_code == 200: + result = response.json() + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + else: + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps({"success": False, "error": f"HTTP {response.status_code}"}, ensure_ascii=False)}]}} + except Exception as e: + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)}]}} + + elif tool_name == "ip_lookup": + ip = arguments.get("ip") + if not ip: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 ip"}} + source = arguments.get("source", "commercial") + try: + url = f"{UAPI_BASE_URL}/api/v1/network/ipinfo?ip={ip}&source={source}" + headers = {"Authorization": f"Bearer {SEARCH_API_KEY}"} + response = requests.get(url, headers=headers, timeout=30) + if response.status_code == 200: + result = response.json() + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + else: + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps({"success": False, "error": f"HTTP {response.status_code}"}, ensure_ascii=False)}]}} + except Exception as e: + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)}]}} + + elif tool_name == "github_repo": + repo = arguments.get("repo") + if not repo: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 repo"}} + try: + url = f"{UAPI_BASE_URL}/api/v1/github/repo?repo={repo}" + headers = {"Authorization": f"Bearer {SEARCH_API_KEY}"} + response = requests.get(url, headers=headers, timeout=30) + if response.status_code == 200: + result = response.json() + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + else: + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps({"success": False, "error": f"HTTP {response.status_code}"}, ensure_ascii=False)}]}} + except Exception as e: + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)}]}} + + elif tool_name == "essays_record": + content = arguments.get("content") + keywords = arguments.get("keywords", []) + if not content: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 content"}} + if not keywords: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 keywords"}} + req = EssayRecordRequest(content=content, keywords=keywords) + result = await essays_record(req) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "quick_query_essays": + keyword = arguments.get("keyword") + if not keyword: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 keyword"}} + req = EssayQueryRequest(keyword=keyword) + result = await quick_query_essays(req) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "query_essays": + keyword = arguments.get("keyword") + if not keyword: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 缺少 keyword"}} + req = EssayQueryRequest(keyword=keyword) + result = await query_essays(req) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "view_essay": + essay_id = arguments.get("essay_id") + essay_hash = arguments.get("essay_hash") + if not essay_id and not essay_hash: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32602, "message": "❌ 请提供 essay_id 或 essay_hash"}} + req = EssayViewRequest(essay_id=essay_id, essay_hash=essay_hash) + result = await view_essay(req) + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}]}} + + elif tool_name == "whoisme": + result = await whoisme(request) + if hasattr(result, 'body'): + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": result.body.decode('utf-8')}]}} + else: + return {"jsonrpc": "2.0", "id": request_id, "result": {"content": [{"type": "text", "text": str(result)}]}} + + else: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32601, "message": f"❌ 未知工具: {tool_name}"}} + + else: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32601, "message": f"❌ 未知方法: {method}"}} + + except Exception as e: + print(f"\n❌ 服务器错误: {str(e)}") + import traceback + traceback.print_exc() + return { + "jsonrpc": "2.0", + "id": body.get("id", None) if 'body' in locals() else None, + "error": {"code": -32603, "message": f"❌ 内部服务器错误: {str(e)}"} + } + + +# ============ 健康检查 ============ + +@app.get("/health") +async def health(): + return { + "status": "ok", + "server": "MCP Multimodal + Document + Search + Image + TTS + OCR + Knowledge Server", + "version": "5.0.0", + "features": { + "session_storage": "JSON持久化", + "anti_addiction": f"{MAX_HISTORY_PER_USER}轮/{COOLDOWN_SECONDS}秒", + "image_generation": "支持3个模型", + "audio_response": "WAV格式,9种专业音色", + "tts": "Qwen-Audio-3.0-TTS (Flash/Plus)", + "tts_voices": "支持查询音色列表", + "ocr": "UAPI专用OCR + Qwen3.5-OCR高级版", + "knowledge_base": { + "qdrant": qdrant_client is not None, + "collection": COLLECTION_NAME, + "embedding_model": EMBEDDING_MODEL, + "sqlite_backup": os.path.exists(SQLITE_DB_PATH), + "chunk_size": CHUNK_SIZE, + "chunk_overlap": CHUNK_OVERLAP, + "default_limit": DEFAULT_LIMIT, + "default_threshold": DEFAULT_SCORE_THRESHOLD, + "advanced_limit": ADVANCED_LIMIT, + "advanced_threshold": ADVANCED_SCORE_THRESHOLD, + "context_expand": ADVANCED_CONTEXT_EXPAND + }, + "essays": { + "enabled": True, + "encryption": "ChaCha20-Poly1305", + "file": ESSAYS_FILE, + "cache": "内存缓存 + 关键词索引" + }, + "network_tools": { + "whois": True, + "ipinfo": True, + "github": True + }, + "identity": { + "whoisme": True + } + }, + "voices": VOICE_LIST, + "tools": 27, + "audio_lib_loaded": _audio_lib is not None, + "dashscope_loaded": _dashscope_available + } + + +@app.get("/voices") +async def list_voices(): + return { + "success": True, + "voices": VOICE_LIST, + "default": DEFAULT_OMNI_VOICE, + "count": len(VOICE_LIST) + } + + +@app.get("/") +async def root(): + return { + "server": "MCP Multimodal + Document + Search + Image + TTS + OCR + Knowledge Server", + "version": "5.0.0", + "mcp_endpoint": "POST /mcp", + "api_endpoints": { + "qwen_ask": "POST /qwen_ask - Qwen3.5-Omni-Flash", + "qwen_ask_pro": "POST /qwen_ask_pro - Qwen3.5-Omni-Pro", + "qwen_chat": "POST /qwen_chat - Qwen3.5-Omni-Flash 多轮", + "qwen_chat_pro": "POST /qwen_chat_pro - Qwen3.5-Omni-Pro 多轮", + "tts": "POST /tts - Qwen-Audio-3.0-TTS", + "tts_flash": "POST /tts_flash - TTS Flash(低延迟)", + "tts_plus": "POST /tts_plus - TTS Plus(高品质)", + "tts_voices": "GET /tts_voices - 查询TTS音色", + "ocr": "POST /ocr - UAPI专用OCR", + "ocr_advanced": "POST /ocr_advanced - Qwen3.5-OCR高级版", + "knowledge_add": "POST /knowledge_add - 添加知识", + "knowledge_query": "POST /knowledge_query - 查询知识", + "knowledge_search_advanced": "POST /knowledge_search_advanced - 高级检索(实验)", + "knowledge_init": "POST /knowledge_init - 从文件初始化", + "knowledge_restore": "POST /knowledge_restore - 从备份恢复", + "knowledge_sync": "POST /knowledge_sync - 同步备份", + "knowledge_list": "GET /knowledge_list - 列出知识库", + "knowledge_count": "GET /knowledge_count - 知识库计数", + "knowledge_delete": "DELETE /knowledge_delete/{point_id} - 删除知识", + "knowledge_clear": "POST /knowledge_clear - 清空知识库", + "knowledge_status": "GET /knowledge_status - 知识库状态", + "keyword_split": "POST /keyword_split - 关键词拆分", + "essays_record": "POST /essays_record - 记录随笔", + "quick_query_essays": "POST /quick_query_essays - 快速查询", + "query_essays": "POST /query_essays - 全文搜索", + "view_essay": "POST /view_essay - 查看原文", + "essays_count": "GET /essays_count - 随笔数量", + "essays_clear": "POST /essays_clear - 清空随笔", + "whoisme": "GET /whoisme - AI身份声明", + "voices": "GET /voices - 查看所有音色", + "api/whois": "GET /api/whois - WHOIS查询", + "api/ipinfo": "GET /api/ipinfo - IP信息查询", + "api/github": "GET /api/github - GitHub仓库查询", + "health": "GET /health - 健康检查", + "add.html": "GET /add.html - 知识库管理", + "del.html": "GET /del.html - 知识库删除" + }, + "knowledge_config": { + "chunk_size": CHUNK_SIZE, + "chunk_overlap": CHUNK_OVERLAP, + "default_limit": DEFAULT_LIMIT, + "default_threshold": DEFAULT_SCORE_THRESHOLD, + "advanced_limit": ADVANCED_LIMIT, + "advanced_threshold": ADVANCED_SCORE_THRESHOLD, + "context_expand": ADVANCED_CONTEXT_EXPAND + }, + "tts_voices": TTS_VOICES, + "features": { + "image_models": { + "generate_image_wan": "⭐ 最便宜 (2K)", + "generate_image_qwen": "💰 按需使用 (2K)", + "generate_image_wan_pro": "🔴 最贵慎用 (4K)" + }, + "audio": { + "generate_audio_response": "🎤 Omni文本转语音", + "tts": "🎤 Qwen-Audio-3.0-TTS" + }, + "ocr": { + "ocr": "📝 UAPI OCR - 快速", + "ocr_advanced": "📝 Qwen3.5-OCR - 高精度" + }, + "knowledge": { + "storage": "Qdrant (向量) + SQLite (备份)", + "embedding": EMBEDDING_MODEL, + "chunk_size": f"{CHUNK_SIZE}字符", + "overlap": f"{CHUNK_OVERLAP}字符" + }, + "essays": { + "encryption": "ChaCha20-Poly1305", + "cache": "内存缓存 + 关键词索引" + }, + "network_tools": { + "whois": "域名WHOIS查询", + "ipinfo": "IP地理位置查询", + "github": "GitHub仓库信息" + } + } + } + + +# ============ 启动时初始化 ============ +if qdrant_client is not None: + try: + count = qdrant_client.count(collection_name=COLLECTION_NAME) + if count.count == 0: + print("📂 知识库为空,尝试从文件初始化...") + init_knowledge_base_from_file(KNOWLEDGE_BASE_FILE) + else: + print(f"✅ 知识库已有 {count.count} 条数据") + except Exception as e: + print(f"⚠️ 知识库初始化检查失败: {e}") + + +if __name__ == "__main__": + import uvicorn + + print("=" * 70) + print("🎨 MCP Multimodal + Document + Search + Image + TTS + OCR + Knowledge Server v5.0.0") + print("=" * 70) + print(f"📍 MCP端点: http://localhost:2236/mcp") + print(f"📂 工作目录: {WORK_DIR}") + print(f"📁 Qdrant数据目录: {QDRANT_DATA_DIR}") + print(f"📁 SQLite备份: {SQLITE_DB_PATH}") + print(f"📁 随笔文件: {ESSAYS_FILE}") + print(f"📋 防沉迷: 每用户{MAX_HISTORY_PER_USER}轮,冷却{COOLDOWN_SECONDS}秒") + print(f"📋 Omni音色数量: {len(VOICE_LIST)}种") + print(f"📋 TTS音色: {len(TTS_VOICES)}种") + print(f"📋 工具总数: 27个") + print(f"📋 知识库配置:") + print(f" 📝 chunk_size: {CHUNK_SIZE}") + print(f" 📝 overlap: {CHUNK_OVERLAP}") + print(f" 📊 默认limit: {DEFAULT_LIMIT}") + print(f" 🎯 默认阈值: {DEFAULT_SCORE_THRESHOLD}") + print(f" 🔬 高级limit: {ADVANCED_LIMIT}") + print(f" 🔬 高级阈值: {ADVANCED_SCORE_THRESHOLD}") + print(f" 📋 上下文扩展: 前后各{ADVANCED_CONTEXT_EXPAND}段") + print(f"📋 随笔系统:") + print(f" 🔐 加密: ChaCha20-Poly1305") + print(f" ⚡ 缓存: 内存缓存 + 关键词索引") + print(f"📋 TTS系统:") + print(f" 🎤 默认模型: {TTS_FLASH}") + print(f" 🎤 默认音色: {DEFAULT_TTS_VOICE}") + print(f" 📊 官方音色: {len(OFFICIAL_VOICES)}个") + print(f" 📊 Flash音色: {len(FLASH_VOICES)}个") + print(f" 📊 Plus音色: {len(PLUS_VOICES)}个") + print(f"📋 网络工具:") + print(f" 🌐 WHOIS: /api/whois") + print(f" 📍 IP信息: /api/ipinfo") + print(f" 📦 GitHub: /api/github") + print("=" * 70) + print("💡 按 Ctrl+C 停止") + print("=" * 70) + + uvicorn.run(app, host="0.0.0.0", port=2236, log_level="info") diff --git a/README.md b/README.md new file mode 100644 index 0000000..31fb95e --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# 🤖 MCP Agent Tools & Multimodal Support + +> A **FastAPI-based AI agent tool service** providing agent core tools, multimodal AI capabilities, TTS/OCR, knowledge base, essays, and utility APIs. + +This project contains two FastAPI services for AI agent assistance: + +1. **Main Agent Tools** — core agent tools: MCP endpoint, safe command execution, workspace switching, SSH session management, and permission dialogs. +2. **Multimodal & Gadget Support** — multimodal AI (Qwen chat/ask/TTS/OCR), knowledge base management, essay recording/search, and utility APIs (WHOIS / IP / GitHub). + +--- + +## 📦 Components + +### 🤖 Main Agent Tools (`agent_tools.py`) +Agent core tools exposed via FastAPI: +- **MCP endpoint** (`/mcp`) +- **Safe command execution** (`execute_command_safe`) +- **Workspace switching** (`/switch_workspace`, `/current_workspace`) +- **SSH session management** (`/ssh/sessions`, `/ssh/cleanup`) +- **Python runner** (`create_and_run_python`) +- **Permission dialogs** (confirm / input) on Windows + +### 🧩 Multimodal & Gadget Support (`multimodal_gadget_support.py`) +- **AI Chat / Ask** — Qwen (Flash / Pro) chat & ask endpoints +- **TTS** — text-to-speech (Flash / Plus / Max) +- **OCR** — text recognition (`/ocr`, `/ocr_advanced`) +- **Knowledge Base** — add / query / search / manage knowledge points +- **Essays** — record, search, view personal essays +- **Utilities** — WHOIS, IP info, GitHub repo lookup +- **Keyword split** — DeepSeek-based query segmentation + +--- + +## 🚀 Quick Start + +### Prerequisites +- Python 3.9+ +- `fastapi`, `uvicorn`, `pydantic` +- Optional: `paramiko` (SSH), `openai` (AI), `pandas`, etc. + +### Run + +```bash +pip install fastapi uvicorn pydantic + +# Run Agent Tools +python agent_tools.py + +# Run Multimodal & Gadget Support (on another port) +python multimodal_gadget_support.py +``` + +--- + +## 📁 Project Structure + +``` +mcp/ +├── Main Agent Tools.py # 🤖 Agent core tools (FastAPI) +├── Multimodal and Gadget Support.py # 🧩 Multimodal & gadget support (FastAPI) +└── README.md # This document +``` + +--- + +## 📄 License + +This project is licensed under the **MIT License**. See [LICENSE](LICENSE) for details. + +--- + +## ⚠️ Security Note + +> This service executes **system commands** and exposes agent tools. **Run only in trusted environments.** All sensitive keys are configured via environment variables, not hardcoded.