6094 lines
261 KiB
Python
6094 lines
261 KiB
Python
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 = """
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>知识库添加</title>
|
||
<style>
|
||
body { font-family: sans-serif; max-width: 900px; margin: 40px auto; padding: 20px; }
|
||
textarea { width: 100%; height: 300px; padding: 10px; font-size: 14px; border: 1px solid #ddd; border-radius: 4px; }
|
||
input, select { padding: 8px; border: 1px solid #ddd; border-radius: 4px; }
|
||
button { padding: 10px 24px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }
|
||
button:hover { background: #0056b3; }
|
||
.btn-green { background: #28a745; }
|
||
.btn-green:hover { background: #218838; }
|
||
.btn-cyan { background: #17a2b8; }
|
||
.btn-cyan:hover { background: #138496; }
|
||
.btn-orange { background: #ffc107; color: #212529; }
|
||
.btn-orange:hover { background: #e0a800; }
|
||
.result { margin-top: 15px; padding: 15px; border-radius: 4px; }
|
||
.success { background: #d4edda; border: 1px solid #c3e6cb; color: #155724; }
|
||
.error { background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; }
|
||
.info { background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; }
|
||
.status-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #f8f9fa; padding: 10px 20px; border-top: 1px solid #ddd; display: flex; justify-content: space-between; font-size: 13px; flex-wrap: wrap; gap: 10px; }
|
||
.status-item { display: flex; align-items: center; gap: 6px; }
|
||
.status-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
|
||
.status-dot.ok { background: #28a745; }
|
||
.status-dot.fail { background: #dc3545; }
|
||
.status-dot.unknown { background: #ffc107; }
|
||
.config-box { background: #f8f9fa; padding: 12px; border-radius: 4px; margin: 10px 0; border: 1px solid #dee2e6; font-size: 13px; }
|
||
.config-box span { margin-right: 15px; }
|
||
label { font-weight: 600; }
|
||
.mb-10 { margin-bottom: 10px; }
|
||
.mt-10 { margin-top: 10px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>📝 知识库管理</h1>
|
||
<p>输入内容后点击"添加",系统会自动切分并向量化存储。</p>
|
||
|
||
<div class="config-box">
|
||
<span>🔧 <strong>切分配置:</strong></span>
|
||
<span>chunk_size: <span id="chunkSize">加载中...</span></span>
|
||
<span>overlap: <span id="chunkOverlap">加载中...</span></span>
|
||
<span>默认limit: <span id="defaultLimit">加载中...</span></span>
|
||
<span>阈值: <span id="defaultThreshold">加载中...</span></span>
|
||
</div>
|
||
|
||
<form id="addForm">
|
||
<label for="content"><strong>内容:</strong></label><br>
|
||
<textarea id="content" placeholder="输入要添加到知识库的内容..."></textarea><br><br>
|
||
|
||
<label for="source"><strong>来源标签:</strong></label>
|
||
<input type="text" id="source" value="web_add" style="width: 200px;">
|
||
<br><br>
|
||
|
||
<button type="submit">✅ 添加</button>
|
||
<button type="button" onclick="initFromFile()" class="btn-green">📂 从文件初始化</button>
|
||
<button type="button" onclick="restoreFromBackup()" class="btn-cyan">💾 从备份恢复</button>
|
||
<button type="button" onclick="listAll()" class="btn-orange">📋 查看所有</button>
|
||
</form>
|
||
|
||
<div id="result"></div>
|
||
<div id="listResult" style="margin-top: 20px;"></div>
|
||
|
||
<div class="status-bar">
|
||
<span class="status-item">
|
||
<span class="status-dot ok"></span> Qdrant: <span id="qdrantStatus">检查中...</span>
|
||
</span>
|
||
<span class="status-item">
|
||
<span class="status-dot ok"></span> 向量模型: <span id="embeddingStatus">检查中...</span>
|
||
</span>
|
||
<span id="countDisplay">知识库条目: 0</span>
|
||
<span id="backupDisplay">备份条目: 0</span>
|
||
</div>
|
||
|
||
<script>
|
||
async function checkStatus() {
|
||
try {
|
||
const resp = await fetch('/knowledge_status');
|
||
const data = await resp.json();
|
||
document.getElementById('qdrantStatus').textContent = data.qdrant ? '✅ 已连接' : '❌ 未连接';
|
||
document.getElementById('qdrantStatus').style.color = data.qdrant ? '#28a745' : '#dc3545';
|
||
document.getElementById('embeddingStatus').textContent = data.embedding ? '✅ 可用' : '❌ 不可用';
|
||
document.getElementById('embeddingStatus').style.color = data.embedding ? '#28a745' : '#dc3545';
|
||
document.getElementById('countDisplay').textContent = `知识库条目: ${data.count || 0}`;
|
||
document.getElementById('chunkSize').textContent = data.chunk_size || '?';
|
||
document.getElementById('chunkOverlap').textContent = data.chunk_overlap || '?';
|
||
document.getElementById('defaultLimit').textContent = data.default_limit || '?';
|
||
document.getElementById('defaultThreshold').textContent = data.default_threshold || '?';
|
||
} catch(e) { console.error(e); }
|
||
|
||
try {
|
||
const resp = await fetch('/knowledge_backup_count');
|
||
const data = await resp.json();
|
||
document.getElementById('backupDisplay').textContent = `备份条目: ${data.count || 0}`;
|
||
} catch(e) {}
|
||
}
|
||
checkStatus();
|
||
setInterval(checkStatus, 10000);
|
||
|
||
document.getElementById('addForm').onsubmit = async function(e) {
|
||
e.preventDefault();
|
||
const content = document.getElementById('content').value.trim();
|
||
const source = document.getElementById('source').value.trim() || 'web_add';
|
||
|
||
if (!content) { alert('请填写内容!'); return; }
|
||
|
||
const resultDiv = document.getElementById('result');
|
||
resultDiv.innerHTML = '<div class="info">⏳ 正在处理...</div>';
|
||
|
||
try {
|
||
const resp = await fetch('/knowledge_add', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({content, source})
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
resultDiv.innerHTML = `<div class="result success">✅ ${data.message} (${data.chunks_added}个片段)</div>`;
|
||
document.getElementById('content').value = '';
|
||
} else {
|
||
resultDiv.innerHTML = `<div class="result error">❌ ${data.error}</div>`;
|
||
}
|
||
} catch(e) {
|
||
resultDiv.innerHTML = `<div class="result error">❌ 请求失败: ${e.message}</div>`;
|
||
}
|
||
};
|
||
|
||
async function initFromFile() {
|
||
if (!confirm('将扫描知识库.md文件并导入所有内容,确定吗?')) return;
|
||
|
||
const resultDiv = document.getElementById('result');
|
||
resultDiv.innerHTML = '<div class="info">⏳ 正在扫描文件...</div>';
|
||
|
||
try {
|
||
const resp = await fetch('/knowledge_init', {method: 'POST'});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
resultDiv.innerHTML = `<div class="result success">✅ 初始化完成!添加了 ${data.chunks_added} 个片段</div>`;
|
||
} else {
|
||
resultDiv.innerHTML = `<div class="result error">❌ ${data.error}</div>`;
|
||
}
|
||
} catch(e) {
|
||
resultDiv.innerHTML = `<div class="result error">❌ 请求失败: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
async function restoreFromBackup() {
|
||
if (!confirm('将从SQLite备份恢复所有知识库数据,确定吗?')) return;
|
||
|
||
const resultDiv = document.getElementById('result');
|
||
resultDiv.innerHTML = '<div class="info">⏳ 正在恢复...</div>';
|
||
|
||
try {
|
||
const resp = await fetch('/knowledge_restore', {method: 'POST'});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
resultDiv.innerHTML = `<div class="result success">✅ ${data.message}</div>`;
|
||
} else {
|
||
resultDiv.innerHTML = `<div class="result error">❌ ${data.error}</div>`;
|
||
}
|
||
} catch(e) {
|
||
resultDiv.innerHTML = `<div class="result error">❌ 请求失败: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
async function listAll() {
|
||
const listDiv = document.getElementById('listResult');
|
||
listDiv.innerHTML = '<div class="info">⏳ 加载中...</div>';
|
||
|
||
try {
|
||
const resp = await fetch('/knowledge_list');
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
let html = '<h3>📋 知识库内容</h3>';
|
||
if (data.items.length === 0) {
|
||
html += '<p>暂无内容</p>';
|
||
} else {
|
||
html += `<p>共 ${data.count} 条</p><ul style="max-height:400px;overflow:auto;">`;
|
||
for (const item of data.items) {
|
||
const content = item.content.length > 100 ? item.content.slice(0,100)+'...' : item.content;
|
||
html += `<li style="margin-bottom:8px;border-bottom:1px solid #eee;padding:8px 0;">
|
||
<strong>${item.source}</strong>: ${content}
|
||
<br><small style="color:#999;">ID: ${item.id}</small>
|
||
<button onclick="deleteItem('${item.id}')" style="color:red;background:none;border:none;cursor:pointer;margin-left:10px;">删除</button>
|
||
</li>`;
|
||
}
|
||
html += '</ul>';
|
||
}
|
||
listDiv.innerHTML = html;
|
||
} else {
|
||
listDiv.innerHTML = `<div class="result error">❌ ${data.error}</div>`;
|
||
}
|
||
} catch(e) {
|
||
listDiv.innerHTML = `<div class="result error">❌ 请求失败: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
async function deleteItem(id) {
|
||
if (!confirm(`确定删除条目 ${id} 吗?`)) return;
|
||
|
||
try {
|
||
const resp = await fetch(`/knowledge_delete/${id}`, {method: 'DELETE'});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
alert('删除成功');
|
||
listAll();
|
||
} else {
|
||
alert('删除失败: ' + data.error);
|
||
}
|
||
} catch(e) {
|
||
alert('请求失败: ' + e.message);
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
return HTMLResponse(html_content)
|
||
|
||
|
||
@app.get("/del.html", response_class=HTMLResponse)
|
||
async def del_page():
|
||
html_content = """
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>知识库删除</title>
|
||
<style>
|
||
body { font-family: sans-serif; max-width: 800px; margin: 40px auto; padding: 20px; }
|
||
.item { border: 1px solid #ddd; border-radius: 4px; padding: 12px; margin: 8px 0; display: flex; justify-content: space-between; align-items: center; }
|
||
.item:hover { background: #f8f9fa; }
|
||
.delete-btn { background: #dc3545; color: white; border: none; padding: 6px 16px; border-radius: 4px; cursor: pointer; }
|
||
.delete-btn:hover { background: #c82333; }
|
||
.clear-btn { background: #ffc107; color: #212529; border: none; padding: 10px 24px; border-radius: 4px; cursor: pointer; margin-top: 20px; }
|
||
.clear-btn:hover { background: #e0a800; }
|
||
.result { margin-top: 20px; padding: 15px; border-radius: 4px; }
|
||
.success { background: #d4edda; border: 1px solid #c3e6cb; color: #155724; }
|
||
.error { background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; }
|
||
.info { background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; }
|
||
.status-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #f8f9fa; padding: 10px 20px; border-top: 1px solid #ddd; display: flex; justify-content: space-between; font-size: 13px; flex-wrap: wrap; gap: 10px; }
|
||
button { cursor: pointer; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>🗑️ 知识库删除</h1>
|
||
<p>点击删除按钮移除知识库条目。也可以清空整个知识库。</p>
|
||
|
||
<button onclick="listAll()" style="padding:10px 20px;background:#17a2b8;color:#fff;border:none;border-radius:4px;">📋 刷新列表</button>
|
||
<button onclick="clearAll()" class="clear-btn">⚠️ 清空全部</button>
|
||
|
||
<div id="listResult"></div>
|
||
<div id="result"></div>
|
||
|
||
<div class="status-bar">
|
||
<span>知识库条目: <span id="countDisplay">0</span></span>
|
||
<span>备份条目: <span id="backupDisplay">0</span></span>
|
||
</div>
|
||
|
||
<script>
|
||
async function listAll() {
|
||
const listDiv = document.getElementById('listResult');
|
||
listDiv.innerHTML = '<div class="info">⏳ 加载中...</div>';
|
||
|
||
try {
|
||
const resp = await fetch('/knowledge_list?limit=200');
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
document.getElementById('countDisplay').textContent = data.count;
|
||
|
||
let html = `<h3>📋 知识库内容 (${data.count}条)</h3>`;
|
||
if (data.items.length === 0) {
|
||
html += '<p>✅ 知识库为空</p>';
|
||
} else {
|
||
html += '<div style="max-height:500px;overflow:auto;">';
|
||
for (const item of data.items) {
|
||
const content = item.content.length > 80 ? item.content.slice(0,80)+'...' : item.content;
|
||
html += `<div class="item">
|
||
<div>
|
||
<strong>${item.source}</strong><br>
|
||
<span style="font-size:13px;">${content}</span>
|
||
<br><small style="color:#999;">ID: ${item.id}</small>
|
||
</div>
|
||
<button class="delete-btn" onclick="deleteItem('${item.id}')">删除</button>
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
}
|
||
listDiv.innerHTML = html;
|
||
} else {
|
||
listDiv.innerHTML = `<div class="result error">❌ ${data.error}</div>`;
|
||
}
|
||
} catch(e) {
|
||
listDiv.innerHTML = `<div class="result error">❌ 请求失败: ${e.message}</div>`;
|
||
}
|
||
|
||
try {
|
||
const resp = await fetch('/knowledge_backup_count');
|
||
const data = await resp.json();
|
||
document.getElementById('backupDisplay').textContent = data.count || 0;
|
||
} catch(e) {}
|
||
}
|
||
|
||
async function deleteItem(id) {
|
||
if (!confirm(`确定删除条目 ${id} 吗?`)) return;
|
||
|
||
const resultDiv = document.getElementById('result');
|
||
resultDiv.innerHTML = '<div class="info">⏳ 删除中...</div>';
|
||
|
||
try {
|
||
const resp = await fetch(`/knowledge_delete/${id}`, {method: 'DELETE'});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
resultDiv.innerHTML = `<div class="result success">✅ 删除成功</div>`;
|
||
listAll();
|
||
} else {
|
||
resultDiv.innerHTML = `<div class="result error">❌ ${data.error}</div>`;
|
||
}
|
||
} catch(e) {
|
||
resultDiv.innerHTML = `<div class="result error">❌ 请求失败: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
async function clearAll() {
|
||
if (!confirm('⚠️ 确定要清空整个知识库吗?此操作不可撤销!')) return;
|
||
if (!confirm('再次确认:清空所有知识库条目?')) return;
|
||
|
||
const resultDiv = document.getElementById('result');
|
||
resultDiv.innerHTML = '<div class="info">⏳ 清空中...</div>';
|
||
|
||
try {
|
||
const resp = await fetch('/knowledge_clear', {method: 'POST'});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
resultDiv.innerHTML = `<div class="result success">✅ ${data.message}</div>`;
|
||
listAll();
|
||
} else {
|
||
resultDiv.innerHTML = `<div class="result error">❌ ${data.error}</div>`;
|
||
}
|
||
} catch(e) {
|
||
resultDiv.innerHTML = `<div class="result error">❌ 请求失败: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
listAll();
|
||
setInterval(() => {
|
||
fetch('/knowledge_count')
|
||
.then(r => r.json())
|
||
.then(d => { document.getElementById('countDisplay').textContent = d.count || 0; })
|
||
.catch(() => {});
|
||
fetch('/knowledge_backup_count')
|
||
.then(r => r.json())
|
||
.then(d => { document.getElementById('backupDisplay').textContent = d.count || 0; })
|
||
.catch(() => {});
|
||
}, 10000);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
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")
|