refactor: centralize llm backend configuration
This commit is contained in:
@@ -10,6 +10,7 @@ import psutil
|
||||
from collections import deque
|
||||
import gzip
|
||||
import json
|
||||
import yaml
|
||||
|
||||
# 创建系统信息蓝图
|
||||
system_bp = Blueprint('system', __name__)
|
||||
@@ -156,6 +157,52 @@ def get_current_user_info():
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@system_bp.route('/api/system/config/raw', methods=['GET'])
|
||||
@login_required
|
||||
def get_system_config_raw():
|
||||
try:
|
||||
server = current_app.dashboard_server
|
||||
config_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', 'config.yaml'))
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config_text = f.read()
|
||||
robot_config = getattr(getattr(server, "robot", None), "config", None)
|
||||
llm_config = getattr(robot_config, "llm", {}) if robot_config else {}
|
||||
llm_backends = (llm_config or {}).get("backends", {})
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": config_text,
|
||||
"path": config_path,
|
||||
"llm_backends": list((llm_backends or {}).keys()),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"读取系统配置失败: {e}")
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@system_bp.route('/api/system/config/update', methods=['POST'])
|
||||
@login_required
|
||||
def update_system_config():
|
||||
try:
|
||||
server = current_app.dashboard_server
|
||||
data = request.get_json() or {}
|
||||
config_text = data.get("config_text")
|
||||
if config_text is None:
|
||||
return jsonify({"success": False, "message": "缺少配置内容"}), 400
|
||||
|
||||
yaml.safe_load(config_text)
|
||||
config_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', 'config.yaml'))
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
f.write(config_text)
|
||||
|
||||
if getattr(server, "robot", None) and getattr(server.robot, "config", None):
|
||||
server.robot.config.reload()
|
||||
|
||||
return jsonify({"success": True, "message": "全局配置已保存"})
|
||||
except Exception as e:
|
||||
logger.error(f"保存系统配置失败: {e}")
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@system_bp.route('/api/restart_service', methods=['POST'])
|
||||
@login_required
|
||||
def restart_service():
|
||||
|
||||
@@ -29,6 +29,29 @@
|
||||
<iframe ref="monitorFrame" src="{{ src_url }}" frameborder="0"></iframe>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="workspace-card" shadow="hover">
|
||||
<div slot="header" class="workspace-header">
|
||||
<div>
|
||||
<h3>全局配置</h3>
|
||||
<p>集中维护 `config.yaml`,其中 `llm.backends` 用于统一管理所有模型后端。</p>
|
||||
</div>
|
||||
<div class="page-hero-actions">
|
||||
<el-button size="mini" plain @click="loadSystemConfig">刷新配置</el-button>
|
||||
<el-button size="mini" type="success" @click="saveSystemConfig">保存配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-meta" v-if="configPath">
|
||||
<span>配置文件:{% raw %}{{ configPath }}{% endraw %}</span>
|
||||
<span v-if="llmBackends.length">LLM 后端:{% raw %}{{ llmBackends.join(', ') }}{% endraw %}</span>
|
||||
</div>
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="systemConfigText"
|
||||
:rows="18"
|
||||
placeholder="请输入 YAML 格式的全局配置">
|
||||
</el-input>
|
||||
</el-card>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -42,11 +65,15 @@
|
||||
currentView: '14',
|
||||
showTimeRangeSelector: false,
|
||||
frameUrl: '{{ src_url }}',
|
||||
restarting: false
|
||||
restarting: false,
|
||||
systemConfigText: '',
|
||||
configPath: '',
|
||||
llmBackends: []
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.currentView = '14';
|
||||
this.loadSystemConfig();
|
||||
},
|
||||
methods: {
|
||||
reloadIframe() {
|
||||
@@ -81,6 +108,35 @@
|
||||
} finally {
|
||||
this.restarting = false;
|
||||
}
|
||||
},
|
||||
async loadSystemConfig() {
|
||||
try {
|
||||
const response = await axios.get('/api/system/config/raw');
|
||||
if (response.data.success) {
|
||||
this.systemConfigText = response.data.data || '';
|
||||
this.configPath = response.data.path || '';
|
||||
this.llmBackends = response.data.llm_backends || [];
|
||||
} else {
|
||||
this.$message.error(response.data.message || '读取全局配置失败');
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error(error.response?.data?.message || '读取全局配置失败');
|
||||
}
|
||||
},
|
||||
async saveSystemConfig() {
|
||||
try {
|
||||
const response = await axios.post('/api/system/config/update', {
|
||||
config_text: this.systemConfigText
|
||||
});
|
||||
if (response.data.success) {
|
||||
this.$message.success(response.data.message || '保存成功');
|
||||
this.loadSystemConfig();
|
||||
} else {
|
||||
this.$message.error(response.data.message || '保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error(error.response?.data?.message || '保存失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -108,5 +164,7 @@
|
||||
.iframe-shell-card .el-card__body { height: calc(100% - 73px); }
|
||||
.iframe-shell { height: 100%; border-radius: 18px; overflow: hidden; border: 1px solid rgba(148,163,184,0.12); background: rgba(248,250,252,0.82); }
|
||||
.iframe-shell iframe { width: 100%; height: 100%; border: none; display: block; background: #fff; }
|
||||
.workspace-card .el-card__body { display: flex; flex-direction: column; gap: 12px; }
|
||||
.config-meta { display: flex; justify-content: space-between; gap: 12px; color: #64748b; font-size: 12px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
59
config.yaml
59
config.yaml
@@ -36,3 +36,62 @@ wx_config:
|
||||
#微信管理账号,用于接收部分管理员指令
|
||||
#菜单调整和系统更新
|
||||
admin: [ "Jyunere" ]
|
||||
|
||||
llm:
|
||||
default_backend: "dify_workflow_chat"
|
||||
backends:
|
||||
dify_workflow_chat:
|
||||
provider: "dify"
|
||||
mode: "workflow"
|
||||
api_key: "app-u5EnYq3ill19bm6pWJwGkY4D"
|
||||
api_base_url: "http://192.168.2.240/v1"
|
||||
endpoint: "workflows/run"
|
||||
response_mode: "blocking"
|
||||
request_timeout: 40
|
||||
dify_workflow_member_context:
|
||||
provider: "dify"
|
||||
mode: "workflow"
|
||||
api_key: "app-b2cj03DipGCIAmgBfcx7SKsT"
|
||||
api_base_url: "http://192.168.2.240/v1"
|
||||
endpoint: "workflows/run"
|
||||
workflow_output_key: "text"
|
||||
response_mode: "streaming"
|
||||
request_timeout: 240
|
||||
dify_workflow_message_summary:
|
||||
provider: "dify"
|
||||
mode: "workflow"
|
||||
api_key: "app-shCA6bo5l2VDmnvhg2BtuJbk"
|
||||
api_base_url: "http://192.168.2.240/v1"
|
||||
endpoint: "workflows/run"
|
||||
workflow_output_key: "text"
|
||||
response_mode: "streaming"
|
||||
request_timeout: 180
|
||||
dify_chat_global_news:
|
||||
provider: "dify"
|
||||
mode: "chat"
|
||||
api_key: "app-rhhKkbvHd2IAQoGX7xTzXZJj"
|
||||
api_base_url: "http://192.168.2.240/v1"
|
||||
endpoint: "chat-messages"
|
||||
response_mode: "blocking"
|
||||
request_timeout: 60
|
||||
openai_compatible_game_task:
|
||||
provider: "openai_compatible"
|
||||
api_url: "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
||||
api_key: "b8586595-eb81-483d-8e91-a35cc789729e"
|
||||
model: "doubao-1-5-lite-32k-250115"
|
||||
stream: false
|
||||
temperature: 0.2
|
||||
max_tokens: 1000
|
||||
timeout_seconds: 60
|
||||
openai_compatible_ai_auto_response:
|
||||
provider: "openai_compatible"
|
||||
api_base_url: "http://192.168.2.240:3000/v1"
|
||||
endpoint: "chat/completions"
|
||||
api_key: "sk-hC6WMLAsTdItpywyrYdxT6pQ4E7NARGbUKuPWRH0zMheen9e"
|
||||
model: "gpt-5.4"
|
||||
stream: true
|
||||
temperature: 0.35
|
||||
max_tokens: 120
|
||||
timeout_seconds: 45
|
||||
max_retries: 3
|
||||
retry_delay_seconds: 1.0
|
||||
|
||||
@@ -31,3 +31,5 @@ class Config(object):
|
||||
|
||||
# wx 相关配置
|
||||
self.wx_config = yconfig.get("wx_config", {})
|
||||
# LLM 集中配置
|
||||
self.llm = yconfig.get("llm", {})
|
||||
|
||||
@@ -9,17 +9,7 @@ max_reply_sentences = 3
|
||||
familiarity_hint = "有熟悉感,但不过度装熟"
|
||||
|
||||
[api]
|
||||
provider = "openai_compatible"
|
||||
api_base_url = "http://192.168.2.240:3000/v1"
|
||||
endpoint = "chat/completions"
|
||||
api_key = "sk-hC6WMLAsTdItpywyrYdxT6pQ4E7NARGbUKuPWRH0zMheen9e"
|
||||
model = "gpt-5.4"
|
||||
timeout_seconds = 45
|
||||
temperature = 0.35
|
||||
max_tokens = 120
|
||||
stream = true
|
||||
max_retries = 3
|
||||
retry_delay_seconds = 1.0
|
||||
backend = "openai_compatible_ai_auto_response"
|
||||
|
||||
[mode]
|
||||
group_default_mode = "social"
|
||||
|
||||
@@ -1,199 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from utils.ai.unified_llm import UnifiedLLMClient
|
||||
|
||||
|
||||
class LLMClient:
|
||||
def __init__(self, config: Dict):
|
||||
self.config = config or {}
|
||||
self.provider = self.config.get("provider", "openai_compatible")
|
||||
self.base_url = str(self.config.get("api_base_url", "")).rstrip("/")
|
||||
self.endpoint = str(self.config.get("endpoint", "chat/completions")).lstrip("/")
|
||||
self.api_key = self.config.get("api_key", "")
|
||||
self.model = self.config.get("model", "")
|
||||
self.timeout_seconds = int(self.config.get("timeout_seconds", 45))
|
||||
self.temperature = float(self.config.get("temperature", 0.7))
|
||||
self.max_tokens = int(self.config.get("max_tokens", 500))
|
||||
self.stream = bool(self.config.get("stream", True))
|
||||
self.max_retries = max(int(self.config.get("max_retries", 3) or 3), 1)
|
||||
self.retry_delay_seconds = float(self.config.get("retry_delay_seconds", 1.0) or 1.0)
|
||||
self.last_error = ""
|
||||
class LLMClient(UnifiedLLMClient):
|
||||
"""兼容旧调用方式的统一 LLM 客户端别名。"""
|
||||
|
||||
def chat(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
user_id: str,
|
||||
image_urls: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
self.last_error = ""
|
||||
if not self.base_url:
|
||||
self.last_error = "empty_base_url"
|
||||
return ""
|
||||
if self.provider == "openai_compatible":
|
||||
return self._chat_openai_compatible(system_prompt, user_prompt, user_id, image_urls or [])
|
||||
self.last_error = f"unsupported_provider:{self.provider}"
|
||||
return ""
|
||||
|
||||
def _chat_openai_compatible(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
user_id: str,
|
||||
image_urls: List[str],
|
||||
) -> str:
|
||||
if not self.model:
|
||||
return ""
|
||||
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": self._build_messages(system_prompt, user_prompt, image_urls),
|
||||
"temperature": self.temperature,
|
||||
"max_tokens": self.max_tokens,
|
||||
"user": user_id,
|
||||
}
|
||||
if self.stream:
|
||||
payload["stream"] = True
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
|
||||
for attempt in range(1, self.max_retries + 1):
|
||||
try:
|
||||
if self.stream:
|
||||
text = self._chat_streaming(payload, headers)
|
||||
else:
|
||||
text = self._chat_non_streaming(payload, headers)
|
||||
if text:
|
||||
return text
|
||||
except Exception as exc:
|
||||
self.last_error = f"request_failed:attempt_{attempt}:{exc}"
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(self.retry_delay_seconds * attempt)
|
||||
return ""
|
||||
|
||||
def _chat_non_streaming(self, payload: Dict, headers: Dict[str, str]) -> str:
|
||||
response = requests.post(
|
||||
f"{self.base_url}/{self.endpoint}",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=self.timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
text = self._extract_text(data)
|
||||
if text:
|
||||
return text
|
||||
self.last_error = f"empty_model_output:{self.model}"
|
||||
return ""
|
||||
|
||||
def _chat_streaming(self, payload: Dict, headers: Dict[str, str]) -> str:
|
||||
chunks: List[str] = []
|
||||
with requests.post(
|
||||
f"{self.base_url}/{self.endpoint}",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=self.timeout_seconds,
|
||||
stream=True,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
buffer = b""
|
||||
for part in response.iter_content(chunk_size=None):
|
||||
if not part:
|
||||
continue
|
||||
buffer += part
|
||||
while b"\n\n" in buffer:
|
||||
event, buffer = buffer.split(b"\n\n", 1)
|
||||
try:
|
||||
event_text = event.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
buffer = event + b"\n\n" + buffer
|
||||
break
|
||||
text_piece, done = self._parse_sse_event(event_text)
|
||||
if text_piece:
|
||||
chunks.append(text_piece)
|
||||
if done:
|
||||
final_text = "".join(chunks).strip()
|
||||
if final_text:
|
||||
return final_text
|
||||
self.last_error = f"empty_stream_output:{self.model}"
|
||||
return ""
|
||||
final_text = "".join(chunks).strip()
|
||||
if final_text:
|
||||
return final_text
|
||||
self.last_error = f"empty_stream_output:{self.model}"
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _build_messages(system_prompt: str, user_prompt: str, image_urls: List[str]) -> List[Dict]:
|
||||
user_content: str | List[Dict[str, object]]
|
||||
if image_urls:
|
||||
content_parts: List[Dict[str, object]] = [{"type": "text", "text": user_prompt}]
|
||||
for image_url in image_urls:
|
||||
if image_url:
|
||||
content_parts.append({"type": "image_url", "image_url": {"url": image_url}})
|
||||
user_content = content_parts
|
||||
else:
|
||||
user_content = user_prompt
|
||||
return [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _extract_text(data: Dict) -> str:
|
||||
choices = data.get("choices") or []
|
||||
if choices:
|
||||
message = choices[0].get("message", {}) or {}
|
||||
content = message.get("content")
|
||||
if isinstance(content, str) and content.strip():
|
||||
return content.strip()
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text") or item.get("content")
|
||||
if isinstance(text, str) and text.strip():
|
||||
parts.append(text.strip())
|
||||
if parts:
|
||||
return "\n".join(parts).strip()
|
||||
for key in ("reasoning_content", "text", "output_text"):
|
||||
value = message.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
for key in ("output_text", "text", "answer", "response"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _parse_sse_event(cls, event_text: str) -> tuple[str, bool]:
|
||||
lines = [line.strip() for line in event_text.splitlines() if line.strip()]
|
||||
data_lines = [line[5:].strip() for line in lines if line.startswith("data:")]
|
||||
if not data_lines:
|
||||
return "", False
|
||||
data = "\n".join(data_lines)
|
||||
if data == "[DONE]":
|
||||
return "", True
|
||||
obj = json.loads(data)
|
||||
choice = (obj.get("choices") or [{}])[0]
|
||||
delta = choice.get("delta") or {}
|
||||
content = delta.get("content")
|
||||
if isinstance(content, str):
|
||||
return content, False
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text") or item.get("content")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "".join(parts), False
|
||||
return "", False
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
[Dify]
|
||||
enable = true
|
||||
|
||||
api-key = "app-u5EnYq3ill19bm6pWJwGkY4D" # Dify的API Key
|
||||
base-url = "http://192.168.2.240/v1" #Dify API接口base url
|
||||
backend = "dify_workflow_chat"
|
||||
|
||||
commands = ["聊天"]
|
||||
command-tip = """
|
||||
@@ -17,4 +15,4 @@ http-proxy = ""
|
||||
|
||||
# 管理员和白名单用户是否免费使用
|
||||
admin_ignore = true
|
||||
whitelist_ignore = true
|
||||
whitelist_ignore = true
|
||||
|
||||
@@ -22,6 +22,7 @@ from utils.robot_cmd.robot_command import Feature, PermissionStatus, GroupBotMan
|
||||
from utils.decorator.points_decorator import plugin_points_cost
|
||||
from utils.media_downloader import MediaDownloader
|
||||
from utils.string_utils import remove_reasoning_content, remove_trailing_content, remove_grok_render_tags
|
||||
from utils.ai.unified_llm import UnifiedLLMClient
|
||||
from wechat_ipad import WechatAPIClient
|
||||
from wechat_ipad.models.message import MessageType
|
||||
import aiohttp
|
||||
@@ -97,12 +98,25 @@ class DifyPlugin(MessagePluginInterface):
|
||||
self._commands = dify_config.get("commands", ["ai", "dify", "聊天", "AI"])
|
||||
self.command_format = dify_config.get("command-tip", "聊天 请求内容")
|
||||
self.enable = dify_config.get("enable", True)
|
||||
self.api_key = dify_config.get("api-key", "")
|
||||
self.base_url = dify_config.get("base-url", "")
|
||||
self.price = dify_config.get("price", 0)
|
||||
self.admin_ignore = dify_config.get("admin_ignore", False)
|
||||
self.whitelist_ignore = dify_config.get("whitelist_ignore", False)
|
||||
self.http_proxy = dify_config.get("http-proxy", "")
|
||||
llm_config = dify_config.get("llm", {}) or {}
|
||||
if not llm_config:
|
||||
llm_config = {
|
||||
"backend": dify_config.get("backend", ""),
|
||||
"provider": "dify",
|
||||
"mode": "workflow",
|
||||
"api-key": self.api_key,
|
||||
"base-url": self.base_url,
|
||||
"endpoint": "workflows/run",
|
||||
"response_mode": "blocking",
|
||||
"request_timeout": 40,
|
||||
}
|
||||
self.llm_client = UnifiedLLMClient(llm_config)
|
||||
self.api_key = self.llm_client.api_key
|
||||
self.base_url = self.llm_client.base_url
|
||||
|
||||
self.LOG.debug(f"[{self.name}] 插件初始化完成,指令:{self._commands}")
|
||||
return True
|
||||
@@ -445,13 +459,6 @@ class DifyPlugin(MessagePluginInterface):
|
||||
if session_id not in self.conversations:
|
||||
self.conversations[session_id] = []
|
||||
|
||||
# 准备请求头
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream" # 指定接受事件流
|
||||
}
|
||||
|
||||
# 准备历史记录
|
||||
history_text = ""
|
||||
if self.conversations[session_id]:
|
||||
@@ -471,122 +478,72 @@ class DifyPlugin(MessagePluginInterface):
|
||||
# 如果有历史记录,添加到inputs_params中
|
||||
if history_text:
|
||||
inputs_params["history"] = history_text
|
||||
if self.conversations[session_id]:
|
||||
inputs_params["conversation_history"] = self.conversations[session_id]
|
||||
|
||||
if files is None:
|
||||
files = []
|
||||
self.LOG.debug(f"Dify请求准备: files={len(files)}")
|
||||
|
||||
# 准备请求数据
|
||||
data = {
|
||||
"files": files,
|
||||
"user": user_id,
|
||||
"inputs": inputs_params,
|
||||
"response_mode": "blocking" # 使用阻塞响应模式
|
||||
}
|
||||
|
||||
# 如果有历史记录,同时添加到conversation_history中
|
||||
if self.conversations[session_id]:
|
||||
data["conversation_history"] = self.conversations[session_id]
|
||||
|
||||
# 设置代理
|
||||
proxy = self.http_proxy if self.http_proxy else None
|
||||
|
||||
# 发送请求
|
||||
url = f"{self.base_url}/workflows/run"
|
||||
|
||||
self.LOG.info(f"发送请求到Dify API: {url}")
|
||||
self.LOG.info(f"请求数据: {json.dumps(data, ensure_ascii=False)}")
|
||||
|
||||
self.LOG.info(f"Dify请求准备: session_id={session_id}, query_len={len(query)}, files={len(files)}")
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
response = await session.post(url, headers=headers, json=data, proxy=proxy, timeout=40)
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
self.LOG.error(f"Dify API请求失败: {response.status} {error_text}")
|
||||
return False, f"请求失败,状态码: {response.status}"
|
||||
response = await asyncio.to_thread(
|
||||
self.llm_client.generate,
|
||||
query,
|
||||
user_id,
|
||||
inputs_params,
|
||||
f"dify:{session_id}",
|
||||
"",
|
||||
"",
|
||||
None,
|
||||
files,
|
||||
)
|
||||
if not response:
|
||||
self.LOG.error(f"Dify API请求失败: {self.llm_client.last_error}")
|
||||
return False, "请求失败"
|
||||
|
||||
# 解析响应
|
||||
response_data = await response.json()
|
||||
self.LOG.info(f"收到Dify API响应: {json.dumps(response_data, ensure_ascii=False)}")
|
||||
answer = response.get("text", "") or ""
|
||||
total_tokens = int((response.get("usage", {}) or {}).get("total_tokens") or 0)
|
||||
raw_data = response.get("raw", {}) or {}
|
||||
outputs = ((raw_data.get("data") or {}).get("outputs") or {}) if isinstance(raw_data, dict) else {}
|
||||
|
||||
# 提取回答内容
|
||||
answer = ""
|
||||
total_tokens = 0
|
||||
if outputs and "result" in outputs and "type" in outputs:
|
||||
if outputs["type"] in {"image", "video"}:
|
||||
downloader = MediaDownloader()
|
||||
media_path = await downloader.download_media(outputs["result"])
|
||||
answer = media_path
|
||||
|
||||
# 获取输出内容
|
||||
outputs = response_data.get("data", {}).get("outputs", {})
|
||||
if outputs:
|
||||
# 处理媒体类型返回
|
||||
if "result" in outputs and "type" in outputs:
|
||||
if outputs["type"] == "image":
|
||||
downloader = MediaDownloader()
|
||||
image_url = outputs["result"]
|
||||
image_path = await downloader.download_media(image_url)
|
||||
answer = image_path
|
||||
if outputs["type"] == "video":
|
||||
downloader = MediaDownloader()
|
||||
image_url = outputs["result"]
|
||||
image_path = await downloader.download_media(image_url)
|
||||
answer = image_path
|
||||
# 处理文本类型返回
|
||||
elif "text" in outputs and isinstance(outputs["text"], str):
|
||||
answer = outputs["text"]
|
||||
# 兼容旧版处理逻辑
|
||||
else:
|
||||
for key, value in outputs.items():
|
||||
if isinstance(value, str) and value.strip():
|
||||
answer += value
|
||||
elif isinstance(value, dict):
|
||||
# 处理嵌套字典的情况
|
||||
for sub_key, sub_value in value.items():
|
||||
if isinstance(sub_value, str) and sub_value.strip():
|
||||
answer += sub_value
|
||||
elif isinstance(value, list):
|
||||
# 处理列表的情况
|
||||
for item in value:
|
||||
if isinstance(item, str) and item.strip():
|
||||
answer += item
|
||||
elif isinstance(item, dict):
|
||||
# 处理列表中的字典
|
||||
for item_key, item_value in item.items():
|
||||
if isinstance(item_value, str) and item_value.strip():
|
||||
answer += item_value
|
||||
if answer and not os.path.isfile(answer):
|
||||
answer = remove_reasoning_content(answer)
|
||||
answer = remove_trailing_content(answer)
|
||||
answer = remove_grok_render_tags(answer)
|
||||
answer = re.sub(r'\n{3,}', '\n\n', answer).strip()
|
||||
|
||||
# 获取token使用情况
|
||||
total_tokens = response_data.get("data", {}).get("total_tokens", 0)
|
||||
# 更新会话历史
|
||||
self.conversations[session_id].append({
|
||||
"role": "user",
|
||||
"content": query
|
||||
})
|
||||
|
||||
if answer and not os.path.isfile(answer):
|
||||
answer = remove_reasoning_content(answer)
|
||||
answer = remove_trailing_content(answer)
|
||||
answer = remove_grok_render_tags(answer)
|
||||
answer = re.sub(r'\n{3,}', '\n\n', answer).strip()
|
||||
self.conversations[session_id].append({
|
||||
"role": "assistant",
|
||||
"content": answer
|
||||
})
|
||||
|
||||
# 更新会话历史
|
||||
self.conversations[session_id].append({
|
||||
"role": "user",
|
||||
"content": query
|
||||
})
|
||||
# 限制会话历史长度
|
||||
if len(self.conversations[session_id]) > self.max_history_length * 2:
|
||||
self.conversations[session_id] = self.conversations[session_id][-self.max_history_length * 2:]
|
||||
|
||||
self.conversations[session_id].append({
|
||||
"role": "assistant",
|
||||
"content": answer
|
||||
})
|
||||
# 统计token使用情况
|
||||
if total_tokens > 0:
|
||||
if user_id in self.token_usage:
|
||||
self.token_usage[user_id] += total_tokens
|
||||
else:
|
||||
self.token_usage[user_id] = total_tokens
|
||||
|
||||
# 限制会话历史长度
|
||||
if len(self.conversations[session_id]) > self.max_history_length * 2:
|
||||
self.conversations[session_id] = self.conversations[session_id][-self.max_history_length * 2:]
|
||||
self.LOG.info(
|
||||
f"用户 {user_id} 本次消耗 {total_tokens} tokens,累计 {self.token_usage[user_id]} tokens")
|
||||
|
||||
# 统计token使用情况
|
||||
if total_tokens > 0:
|
||||
if user_id in self.token_usage:
|
||||
self.token_usage[user_id] += total_tokens
|
||||
else:
|
||||
self.token_usage[user_id] = total_tokens
|
||||
|
||||
self.LOG.info(
|
||||
f"用户 {user_id} 本次消耗 {total_tokens} tokens,累计 {self.token_usage[user_id]} tokens")
|
||||
|
||||
return True, answer
|
||||
return True, answer
|
||||
|
||||
except Exception as e:
|
||||
self.LOG.error(f"处理Dify响应时出错: {str(e)}")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[GameTask]
|
||||
enable = true
|
||||
backend = "openai_compatible_game_task"
|
||||
command = ["/t", "/a", "/s", "/r", "/l", "/h"]
|
||||
command-format = """
|
||||
🎮 百科问答指令:
|
||||
@@ -10,8 +11,3 @@ command-format = """
|
||||
/l - 查看活跃任务
|
||||
/h - 查看未完成任务
|
||||
"""
|
||||
|
||||
# AI获取题目确认答案用的配置信息
|
||||
authorization = "Bearer b8586595-eb81-483d-8e91-a35cc789729e" # 请替换为真实的Authorization token
|
||||
url = 'https://ark.cn-beijing.volces.com/api/v3/chat/completions'
|
||||
model = "doubao-1-5-lite-32k-250115"
|
||||
@@ -12,8 +12,8 @@ from utils.robot_cmd.robot_command import Feature, PermissionStatus, GroupBotMan
|
||||
from utils.decorator.points_decorator import points_reward_decorator
|
||||
from db.connection import DBConnectionManager
|
||||
from db.encyclopedia import EncyclopediaDB
|
||||
import requests
|
||||
import json
|
||||
from utils.ai.unified_llm import UnifiedLLMClient
|
||||
|
||||
|
||||
class GameTaskPlugin(MessagePluginInterface):
|
||||
@@ -81,11 +81,25 @@ class GameTaskPlugin(MessagePluginInterface):
|
||||
/l - 查看活跃任务
|
||||
/h - 查看未完成任务
|
||||
""")
|
||||
self.authorization = self._config.get("GameTask", {}).get("authorization", "")
|
||||
self.url = self._config.get("GameTask", {}).get("url", "")
|
||||
self.model = self._config.get("GameTask", {}).get("model", "")
|
||||
plugin_config = self._config.get("GameTask", {})
|
||||
self.authorization = plugin_config.get("authorization", "")
|
||||
self.url = plugin_config.get("url", "")
|
||||
self.model = plugin_config.get("model", "")
|
||||
llm_config = plugin_config.get("llm", {}) or {}
|
||||
if not llm_config:
|
||||
llm_config = {
|
||||
"backend": plugin_config.get("backend", ""),
|
||||
"provider": "openai_compatible",
|
||||
"authorization": self.authorization,
|
||||
"url": self.url,
|
||||
"model": self.model,
|
||||
"stream": False,
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 1000,
|
||||
}
|
||||
self.llm_client = UnifiedLLMClient(llm_config)
|
||||
|
||||
self.enable = self._config.get("GameTask", {}).get("enable", True)
|
||||
self.enable = plugin_config.get("enable", True)
|
||||
|
||||
# 初始化数据库连接
|
||||
self.db_manager = DBConnectionManager.get_instance()
|
||||
@@ -584,40 +598,14 @@ class GameTaskPlugin(MessagePluginInterface):
|
||||
return None
|
||||
|
||||
def message_task_json(self, prompt, content):
|
||||
# 设置Authorization和URL
|
||||
authorization = self.authorization # 请替换为真实的Authorization token
|
||||
url = self.url
|
||||
|
||||
data = {
|
||||
# "stream": True,
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": f"{prompt}"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"{content}"
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
|
||||
# 设置请求头
|
||||
headers = {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Authorization": authorization
|
||||
}
|
||||
|
||||
# 发送POST请求
|
||||
response = requests.post(url, headers=headers, data=json.dumps(data), )
|
||||
response.encoding = 'utf-8'
|
||||
|
||||
# 输出响应内容
|
||||
print(response.status_code)
|
||||
print(response.text)
|
||||
return json.loads(self.extract_content(response.text))
|
||||
response = self.llm_client.generate(
|
||||
system_prompt=prompt,
|
||||
user_prompt=str(content),
|
||||
user="game_task_bot",
|
||||
)
|
||||
if not response or not response.get("text"):
|
||||
raise RuntimeError(f"LLM 调用失败: {self.llm_client.last_error}")
|
||||
return json.loads(response["text"])
|
||||
|
||||
def game_question_json(self, question):
|
||||
fields = [
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
[GlobalNews]
|
||||
enable = true
|
||||
command = ["全球新闻", "国际新闻", "环球新闻", "政经新闻", "政治经济新闻"]
|
||||
backend = "dify_chat_global_news"
|
||||
command-format = """
|
||||
🌍全球新闻指令:
|
||||
全球新闻 - 获取最新的全球政治经济新闻
|
||||
"""
|
||||
|
||||
|
||||
authorization = "Bearer app-rhhKkbvHd2IAQoGX7xTzXZJj" # 请替换为真实的Authorization token
|
||||
url = 'http://192.168.2.240/v1/chat-messages'
|
||||
@@ -1,8 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import time # 添加这一行
|
||||
import aiohttp
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
|
||||
from base.plugin_common.message_plugin_interface import MessagePluginInterface
|
||||
@@ -11,6 +9,7 @@ from utils.decorator.plugin_decorators import plugin_stats_decorator
|
||||
from utils.robot_cmd.robot_command import Feature, PermissionStatus, GroupBotManager
|
||||
from utils.decorator.points_decorator import plugin_points_cost
|
||||
from utils.markdown_to_image import convert_md_str_to_image
|
||||
from utils.ai.unified_llm import UnifiedLLMClient
|
||||
from wechat_ipad import WechatAPIClient
|
||||
|
||||
# 导入新闻抓取函数
|
||||
@@ -75,9 +74,19 @@ class GlobalNewsPlugin(MessagePluginInterface):
|
||||
["全球新闻", "国际新闻", "环球新闻", "政经新闻"])
|
||||
self.command_format = self._config.get("GlobalNews", {}).get("command-format",
|
||||
"全球新闻 - 获取最新的全球政治经济新闻")
|
||||
self.enable = self._config.get("GlobalNews", {}).get("enable", True)
|
||||
self._key = self._config.get("GlobalNews", {}).get("authorization", "")
|
||||
self._url = self._config.get("GlobalNews", {}).get("url", "")
|
||||
plugin_config = self._config.get("GlobalNews", {})
|
||||
self.enable = plugin_config.get("enable", True)
|
||||
llm_config = plugin_config.get("llm", {}) or {}
|
||||
if not llm_config:
|
||||
llm_config = {
|
||||
"backend": plugin_config.get("backend", ""),
|
||||
"provider": "dify",
|
||||
"mode": "chat",
|
||||
"authorization": plugin_config.get("authorization", ""),
|
||||
"url": plugin_config.get("url", ""),
|
||||
"response_mode": "blocking",
|
||||
}
|
||||
self.llm_client = UnifiedLLMClient(llm_config)
|
||||
self.LOG.debug(f"[{self.name}] 插件初始化完成,指令:{self._commands}")
|
||||
return True
|
||||
|
||||
@@ -186,9 +195,7 @@ class GlobalNewsPlugin(MessagePluginInterface):
|
||||
news_titles = "\n".join(results)
|
||||
|
||||
# 使用AI分析新闻
|
||||
markdown_news = await self._run_in_executor(
|
||||
self.dify_news_title_analyze, news_titles
|
||||
)
|
||||
markdown_news = await self._run_in_executor(self.analyze_news_titles, news_titles)
|
||||
|
||||
# 转换为图片
|
||||
image_path = await self._run_in_executor(
|
||||
@@ -205,61 +212,15 @@ class GlobalNewsPlugin(MessagePluginInterface):
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, func, *args)
|
||||
|
||||
async def dify_news_title_analyze(self, content: str) -> str:
|
||||
"""异步分析新闻标题
|
||||
Args:
|
||||
content: 新闻标题内容
|
||||
Returns:
|
||||
str: 分析后的内容
|
||||
"""
|
||||
# 设置Authorization和URL
|
||||
data = {
|
||||
"response_mode": "blocking",
|
||||
"conversation_id": "",
|
||||
"inputs": {},
|
||||
"query": content,
|
||||
"user": "a-bot-global_news"
|
||||
}
|
||||
|
||||
# 设置请求头
|
||||
headers = {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Authorization": self._key
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(self._url, headers=headers, json=data) as response:
|
||||
if response.status != 200:
|
||||
self.LOG.error(f"新闻分析请求失败: {response.status}")
|
||||
return None
|
||||
|
||||
response_data = await response.json()
|
||||
self.LOG.debug(f"新闻分析响应: {response_data}")
|
||||
return self.extract_content(response_data)
|
||||
|
||||
except Exception as e:
|
||||
self.LOG.error(f"新闻分析请求出错: {e}")
|
||||
return None
|
||||
|
||||
def extract_content(self, data):
|
||||
"""解析API响应内容
|
||||
Args:
|
||||
data: API返回的响应数据,可以是字典或字符串
|
||||
Returns:
|
||||
str: 提取的answer内容
|
||||
"""
|
||||
try:
|
||||
# 如果是字符串,尝试解析为字典
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
# 如果是字典,直接获取answer
|
||||
if isinstance(data, dict):
|
||||
answer = data.get('answer', '')
|
||||
if answer:
|
||||
return answer
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
self.LOG.error(f"解析响应失败: {str(e)}")
|
||||
def analyze_news_titles(self, content: str) -> Optional[str]:
|
||||
"""同步分析新闻标题,便于在线程池中复用。"""
|
||||
response = self.llm_client.run(
|
||||
prompt=content,
|
||||
user="a-bot-global_news",
|
||||
inputs={"query": content},
|
||||
tag="global_news",
|
||||
)
|
||||
if not response:
|
||||
self.LOG.error(f"新闻分析请求失败: {self.llm_client.last_error}")
|
||||
return None
|
||||
return response.get("text") or None
|
||||
|
||||
@@ -3,12 +3,7 @@ enable = true
|
||||
|
||||
[api]
|
||||
enable = true
|
||||
base_url = "http://192.168.2.240/v1"
|
||||
api_key = "app-b2cj03DipGCIAmgBfcx7SKsT"
|
||||
mode = "workflow"
|
||||
endpoint = "workflows/run"
|
||||
workflow_output_key = "text"
|
||||
response_mode = "streaming"
|
||||
backend = "dify_workflow_member_context"
|
||||
request_timeout = 240
|
||||
|
||||
[profile]
|
||||
|
||||
@@ -1,187 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
from typing import Dict, Optional
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
from utils.ai.unified_llm import UnifiedLLMClient
|
||||
|
||||
|
||||
class DifyClient:
|
||||
"""Dify completion/workflow 通用调用客户端"""
|
||||
class DifyClient(UnifiedLLMClient):
|
||||
"""兼容旧 DifyClient 接口的统一客户端封装。"""
|
||||
|
||||
def __init__(self, api_config: Optional[Dict] = None):
|
||||
api_config = api_config or {}
|
||||
self.LOG = logger
|
||||
self.enabled = bool(api_config.get("enable", api_config.get("enabled", False)))
|
||||
self.base_url = (api_config.get("base_url") or "").rstrip("/")
|
||||
self.api_key = api_config.get("api_key", "")
|
||||
self.timeout = int(api_config.get("request_timeout", 60))
|
||||
self.mode = str(api_config.get("mode", "completion")).strip().lower()
|
||||
default_endpoint = "workflows/run" if self.mode == "workflow" else "completion-messages"
|
||||
self.endpoint = str(api_config.get("endpoint", default_endpoint)).lstrip("/")
|
||||
self.workflow_output_key = str(api_config.get("workflow_output_key", "text")).strip()
|
||||
self.response_mode = str(api_config.get("response_mode", "blocking")).strip().lower()
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self.enabled and bool(self.base_url and self.api_key)
|
||||
|
||||
def run(self, prompt: str, user: str, inputs: Optional[Dict] = None,
|
||||
tag: str = "") -> Optional[Dict]:
|
||||
if not self.is_available():
|
||||
return None
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload_inputs = dict(inputs or {})
|
||||
if self.mode == "completion":
|
||||
payload_inputs.setdefault("query", prompt)
|
||||
elif prompt and "query" not in payload_inputs:
|
||||
payload_inputs["query"] = prompt
|
||||
|
||||
payload = {
|
||||
"inputs": payload_inputs,
|
||||
"response_mode": self.response_mode,
|
||||
"user": user,
|
||||
}
|
||||
url = f"{self.base_url}/{self.endpoint}"
|
||||
try:
|
||||
self.LOG.info(
|
||||
f"[成员交互摘要][Dify] 发起请求: mode={self.mode}, response_mode={self.response_mode}, "
|
||||
f"endpoint={self.endpoint}, tag={tag}"
|
||||
)
|
||||
if self.response_mode == "streaming":
|
||||
parsed = self._run_streaming(url, headers, payload, tag)
|
||||
else:
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
parsed = self._parse_response(data)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
self.LOG.warning(f"[成员交互摘要][Dify] 响应内容为空: mode={self.mode}, tag={tag}")
|
||||
return None
|
||||
except Exception as e:
|
||||
self.LOG.warning(f"[成员交互摘要][Dify] 请求失败: mode={self.mode}, tag={tag}, error={e}")
|
||||
return None
|
||||
|
||||
def _run_streaming(self, url: str, headers: Dict, payload: Dict, tag: str) -> Optional[Dict]:
|
||||
with requests.post(url, headers=headers, json=payload, timeout=self.timeout, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
event_name = ""
|
||||
text_fragments = []
|
||||
final_payload = None
|
||||
|
||||
for raw_line in response.iter_lines(decode_unicode=True):
|
||||
if raw_line is None:
|
||||
continue
|
||||
line = str(raw_line).strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("event:"):
|
||||
event_name = line[6:].strip()
|
||||
continue
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
|
||||
data_text = line[5:].strip()
|
||||
if not data_text or data_text == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(data_text)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
candidate_text = self._extract_stream_text(chunk)
|
||||
if candidate_text:
|
||||
text_fragments.append(candidate_text)
|
||||
|
||||
chunk_event = str(chunk.get("event") or event_name or "").strip()
|
||||
if chunk_event in {"workflow_finished", "message_end"}:
|
||||
final_payload = chunk
|
||||
|
||||
if final_payload:
|
||||
parsed = self._parse_response(final_payload)
|
||||
if parsed and parsed.get("text"):
|
||||
return parsed
|
||||
|
||||
text = "".join(fragment for fragment in text_fragments if fragment)
|
||||
if text:
|
||||
return {
|
||||
"text": text.strip(),
|
||||
"usage": {},
|
||||
"raw": final_payload or {},
|
||||
}
|
||||
|
||||
self.LOG.warning(f"[成员交互摘要][Dify] 流式响应未产出有效内容: tag={tag}")
|
||||
return None
|
||||
|
||||
def _parse_response(self, data: Dict) -> Optional[Dict]:
|
||||
if self.mode == "workflow":
|
||||
return self._parse_workflow_response(data)
|
||||
answer = data.get("answer", "")
|
||||
usage = (data.get("metadata") or {}).get("usage", {}) or {}
|
||||
return {
|
||||
"text": str(answer or "").strip(),
|
||||
"usage": usage,
|
||||
"raw": data,
|
||||
}
|
||||
|
||||
def _parse_workflow_response(self, data: Dict) -> Optional[Dict]:
|
||||
payload = (data or {}).get("data", {}) or {}
|
||||
outputs = payload.get("outputs", {}) or {}
|
||||
text = ""
|
||||
|
||||
if self.workflow_output_key and outputs.get(self.workflow_output_key) is not None:
|
||||
value = outputs.get(self.workflow_output_key)
|
||||
text = self._stringify_output(value)
|
||||
elif outputs.get("text") is not None:
|
||||
text = self._stringify_output(outputs.get("text"))
|
||||
elif outputs.get("answer") is not None:
|
||||
text = self._stringify_output(outputs.get("answer"))
|
||||
elif outputs.get("result_json") is not None:
|
||||
text = self._stringify_output(outputs.get("result_json"))
|
||||
elif outputs.get("result") is not None:
|
||||
text = self._stringify_output(outputs.get("result"))
|
||||
else:
|
||||
for value in outputs.values():
|
||||
text = self._stringify_output(value)
|
||||
if text:
|
||||
break
|
||||
|
||||
usage = {
|
||||
"total_tokens": payload.get("total_tokens"),
|
||||
"latency": payload.get("elapsed_time"),
|
||||
}
|
||||
return {
|
||||
"text": str(text or "").strip(),
|
||||
"usage": usage,
|
||||
"raw": data,
|
||||
}
|
||||
|
||||
def _extract_stream_text(self, chunk: Dict) -> str:
|
||||
if not isinstance(chunk, dict):
|
||||
return ""
|
||||
payload = (chunk.get("data") or {}) if isinstance(chunk.get("data"), dict) else {}
|
||||
outputs = payload.get("outputs", {}) if isinstance(payload.get("outputs"), dict) else {}
|
||||
|
||||
for key in filter(None, [self.workflow_output_key, "text", "answer", "result_json", "result"]):
|
||||
if outputs.get(key) is not None:
|
||||
return self._stringify_output(outputs.get(key))
|
||||
|
||||
for key in ("text", "answer"):
|
||||
if chunk.get(key) is not None:
|
||||
return self._stringify_output(chunk.get(key))
|
||||
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _stringify_output(value) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
return str(value).strip()
|
||||
|
||||
@@ -513,7 +513,7 @@ class MemberContextService:
|
||||
usage = response.get("usage", {}) or {}
|
||||
parsed_meta = parsed.get("meta", {}) or {}
|
||||
parsed_meta.update({
|
||||
"ai_provider": "dify",
|
||||
"ai_provider": self.dify_client.provider,
|
||||
"ai_mode": self.dify_client.mode,
|
||||
"ai_tokens": usage.get("total_tokens"),
|
||||
"ai_latency": usage.get("latency"),
|
||||
|
||||
@@ -4,14 +4,8 @@
|
||||
enabled = true
|
||||
|
||||
[api]
|
||||
api_key = "app-shCA6bo5l2VDmnvhg2BtuJbk"
|
||||
api_base_url = "http://192.168.2.240/v1"
|
||||
mode = "workflow"
|
||||
endpoint = "workflows/run"
|
||||
workflow_output_key = "text"
|
||||
response_mode = "streaming"
|
||||
backend = "dify_workflow_message_summary"
|
||||
connect_timeout_seconds = 10
|
||||
request_timeout_seconds = 180
|
||||
retry_delays_seconds = [10, 20]
|
||||
|
||||
[output]
|
||||
|
||||
@@ -6,8 +6,6 @@ from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Tuple, Optional, List
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientTimeout
|
||||
from loguru import logger
|
||||
|
||||
from base.plugin_common.message_plugin_interface import MessagePluginInterface
|
||||
@@ -22,6 +20,7 @@ from utils.markdown_to_image import convert_md_str_to_image
|
||||
from utils.revoke.message_auto_revoke import MessageAutoRevoke
|
||||
from utils.robot_cmd.robot_command import GroupBotManager, PermissionStatus
|
||||
from utils.string_utils import remove_reasoning_content, remove_trailing_content
|
||||
from utils.ai.unified_llm import UnifiedLLMClient
|
||||
from utils.wechat.contact_manager import ContactManager
|
||||
from utils.wechat.message_to_db import MessageStorage
|
||||
from wechat_ipad import WechatAPIClient
|
||||
@@ -93,6 +92,10 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
||||
self._connect_timeout_seconds = int(api_config.get("connect_timeout_seconds", 10))
|
||||
self._request_timeout_seconds = int(api_config.get("request_timeout_seconds", 180))
|
||||
self._retry_delays_seconds = api_config.get("retry_delays_seconds", [10, 20])
|
||||
self.llm_client = UnifiedLLMClient(api_config)
|
||||
self._api_mode = self.llm_client.mode or self._api_mode
|
||||
self._response_mode = self.llm_client.response_mode or self._response_mode
|
||||
self._workflow_output_key = self.llm_client.workflow_output_key or self._workflow_output_key
|
||||
self.message_storage = MessageStorage()
|
||||
db_manager = context.get("db_manager")
|
||||
if db_manager:
|
||||
@@ -221,81 +224,6 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
||||
sanitized_name = "群聊"
|
||||
return sanitized_name
|
||||
|
||||
async def _parse_streaming_response(self, response: aiohttp.ClientResponse) -> Dict[str, Any]:
|
||||
"""解析 Dify 的 SSE 流式响应"""
|
||||
answer_parts: List[str] = []
|
||||
metadata: Dict[str, Any] = {}
|
||||
final_payload: Dict[str, Any] = {}
|
||||
buffer = ""
|
||||
|
||||
async for chunk in response.content.iter_any():
|
||||
if not chunk:
|
||||
continue
|
||||
|
||||
buffer += chunk.decode("utf-8", errors="ignore")
|
||||
|
||||
while "\n\n" in buffer:
|
||||
raw_event, buffer = buffer.split("\n\n", 1)
|
||||
raw_event = raw_event.strip()
|
||||
if not raw_event:
|
||||
continue
|
||||
|
||||
data_lines = []
|
||||
for line in raw_event.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("data:"):
|
||||
data_lines.append(line[5:].strip())
|
||||
|
||||
if not data_lines:
|
||||
continue
|
||||
|
||||
payload_text = "\n".join(data_lines).strip()
|
||||
if not payload_text or payload_text == "[DONE]":
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = json.loads(payload_text)
|
||||
except json.JSONDecodeError:
|
||||
self.LOG.warning(f"无法解析流式响应片段: {payload_text[:200]}")
|
||||
continue
|
||||
|
||||
event_name = str(payload.get("event", "")).strip()
|
||||
if event_name in {"message", "agent_message"}:
|
||||
chunk_text = payload.get("answer", "")
|
||||
if chunk_text:
|
||||
answer_parts.append(chunk_text)
|
||||
elif event_name in {"message_end", "workflow_finished"}:
|
||||
final_payload = payload
|
||||
if self._api_mode == "workflow":
|
||||
payload_data = payload.get("data", {}) if isinstance(payload.get("data"), dict) else {}
|
||||
outputs = payload_data.get("outputs", {}) if isinstance(payload_data.get("outputs"), dict) else {}
|
||||
if outputs:
|
||||
for key in filter(None, [self._workflow_output_key, "text", "answer", "result_json", "result"]):
|
||||
if outputs.get(key) is not None:
|
||||
answer_parts = [self._stringify_output(outputs.get(key))]
|
||||
break
|
||||
metadata = payload.get("metadata", {}) or payload.get("data", {}).get("metadata", {}) or metadata
|
||||
elif event_name == "error":
|
||||
raise RuntimeError(payload.get("message") or payload.get("error") or "流式总结生成失败")
|
||||
else:
|
||||
if self._api_mode == "workflow":
|
||||
payload_data = payload.get("data", {}) if isinstance(payload.get("data"), dict) else {}
|
||||
outputs = payload_data.get("outputs", {}) if isinstance(payload_data.get("outputs"), dict) else {}
|
||||
for key in filter(None, [self._workflow_output_key, "text", "answer", "result_json", "result"]):
|
||||
if outputs.get(key) is not None:
|
||||
chunk_text = self._stringify_output(outputs.get(key))
|
||||
if chunk_text:
|
||||
answer_parts.append(chunk_text)
|
||||
break
|
||||
|
||||
answer = "".join(answer_parts)
|
||||
return {
|
||||
"answer": answer,
|
||||
"metadata": metadata,
|
||||
"data": final_payload.get("data", {}) if isinstance(final_payload, dict) else {},
|
||||
"event": final_payload.get("event", "") if isinstance(final_payload, dict) else "",
|
||||
}
|
||||
|
||||
def _append_usage_info(self, answer: str, metadata: Dict[str, Any]) -> str:
|
||||
"""把 token 统计追加到总结文本末尾"""
|
||||
if not answer or not answer.strip():
|
||||
@@ -440,7 +368,6 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
||||
|
||||
async def _generate_summary(self, chat_content: str, group_name: str) -> Tuple[str, Optional[str]]:
|
||||
"""生成总结"""
|
||||
# Dify API配置
|
||||
content_compress = chat_content
|
||||
try:
|
||||
content_compress = compress_chat_data(chat_content)
|
||||
@@ -449,96 +376,50 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
||||
self.LOG.error(f"压缩内容失败:{e}")
|
||||
|
||||
prompt = f"请根据[{group_name}]群的群聊记录生成一份总结:\n\n{content_compress}"
|
||||
if self._api_mode == "workflow":
|
||||
data = {
|
||||
"inputs": {
|
||||
"query": prompt,
|
||||
"group_name": group_name,
|
||||
"chat_content": content_compress,
|
||||
},
|
||||
"response_mode": self._response_mode,
|
||||
"user": group_name if group_name is not None else "message_summary_bot",
|
||||
}
|
||||
else:
|
||||
data = {
|
||||
"inputs": {},
|
||||
"query": prompt,
|
||||
"response_mode": self._response_mode,
|
||||
"conversation_id": "",
|
||||
"user": group_name if group_name is not None else "message_summary_bot",
|
||||
"files": []
|
||||
}
|
||||
|
||||
self.LOG.info(f"群聊总结内容:{data}")
|
||||
# 设置请求头
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream" if self._response_mode == "streaming" else "application/json"
|
||||
inputs = {
|
||||
"query": prompt,
|
||||
"group_name": group_name,
|
||||
"chat_content": content_compress,
|
||||
}
|
||||
self.LOG.info(f"群聊总结请求准备: group={group_name}, mode={self._api_mode}, response_mode={self._response_mode}")
|
||||
|
||||
max_retries = len(self._retry_delays_seconds) + 1
|
||||
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
custom_timeout = ClientTimeout(
|
||||
total=None,
|
||||
connect=self._connect_timeout_seconds,
|
||||
sock_read=self._request_timeout_seconds
|
||||
response = await asyncio.to_thread(
|
||||
self.llm_client.run,
|
||||
prompt,
|
||||
group_name if group_name is not None else "message_summary_bot",
|
||||
inputs,
|
||||
f"message_summary:{group_name}",
|
||||
)
|
||||
conn = aiohttp.TCPConnector(keepalive_timeout=60) # 保持连接活跃
|
||||
async with aiohttp.ClientSession(connector=conn, timeout=custom_timeout) as session:
|
||||
async with session.post(self._api_url, headers=headers, json=data) as response:
|
||||
response.raise_for_status() # 检查请求是否成功
|
||||
if self._response_mode == "streaming":
|
||||
response_data = await self._parse_streaming_response(response)
|
||||
else:
|
||||
response_data = await response.json()
|
||||
if not response or not response.get("text"):
|
||||
raise RuntimeError(self.llm_client.last_error or "LLM 未返回有效总结内容")
|
||||
|
||||
self.LOG.info(f"Dify API响应状态码: {response.status}, attempt={attempt}")
|
||||
self.LOG.debug(f"响应数据: {json.dumps(response_data, ensure_ascii=False, indent=2)}")
|
||||
answer = self._clean_summary_output(response.get("text", ""))
|
||||
metadata = {"usage": response.get("usage", {}) or {}}
|
||||
spath = ""
|
||||
answer = self._append_usage_info(answer, metadata)
|
||||
|
||||
if self._api_mode == "workflow":
|
||||
answer, metadata = self._parse_workflow_response(response_data)
|
||||
else:
|
||||
answer = response_data.get("answer", "")
|
||||
metadata = response_data.get("metadata", {})
|
||||
if answer and len(answer.strip()) > 0:
|
||||
try:
|
||||
timestamp = int(time.time())
|
||||
output_path = f"summary_{timestamp}.png"
|
||||
self.LOG.info(f"开始生成图片: {output_path}")
|
||||
spath = await convert_md_str_to_image(answer, output_path)
|
||||
self.LOG.info(f"成功生成图片: {spath}")
|
||||
except Exception as e:
|
||||
self.LOG.error(f"生成图片失败: {e}", exc_info=True)
|
||||
max_length = 2000
|
||||
if len(answer) > max_length:
|
||||
answer = answer[:max_length] + "\n\n... (内容过长,已截断)"
|
||||
self.LOG.info("图片生成失败,将发送文本消息作为备选方案")
|
||||
spath = None
|
||||
else:
|
||||
spath = None
|
||||
return answer, spath
|
||||
|
||||
if not answer or not answer.strip():
|
||||
raise RuntimeError("Dify 未返回有效总结内容")
|
||||
|
||||
answer = self._clean_summary_output(answer)
|
||||
spath = ""
|
||||
answer = self._append_usage_info(answer, metadata)
|
||||
|
||||
if answer and len(answer.strip()) > 0:
|
||||
try:
|
||||
# 使用唯一文件名并指定完整路径
|
||||
timestamp = int(time.time())
|
||||
output_path = f"summary_{timestamp}.png"
|
||||
self.LOG.info(f"开始生成图片: {output_path}")
|
||||
spath = await convert_md_str_to_image(answer, output_path)
|
||||
self.LOG.info(f"成功生成图片: {spath}")
|
||||
except Exception as e:
|
||||
self.LOG.error(f"生成图片失败: {e}", exc_info=True)
|
||||
try:
|
||||
max_length = 2000
|
||||
if len(answer) > max_length:
|
||||
answer = answer[:max_length] + "\n\n... (内容过长,已截断)"
|
||||
self.LOG.info("图片生成失败,将发送文本消息作为备选方案")
|
||||
spath = None
|
||||
except Exception as fallback_error:
|
||||
self.LOG.error(f"备选文本发送也失败: {fallback_error}")
|
||||
spath = None
|
||||
else:
|
||||
spath = None
|
||||
# 返回文本内容和图片路径
|
||||
return answer, spath
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
self.LOG.error(f"请求Dify API时出错: attempt={attempt}/{max_retries}, error={e}")
|
||||
except json.JSONDecodeError as e:
|
||||
self.LOG.error(f"解析Dify API响应时出错: attempt={attempt}/{max_retries}, error={e}")
|
||||
except Exception as e:
|
||||
self.LOG.error(f"处理总结时出现未知错误: attempt={attempt}/{max_retries}, error={e}")
|
||||
|
||||
|
||||
64
utils/ai/llm_registry.py
Normal file
64
utils/ai/llm_registry.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class LLMRegistry:
|
||||
"""从项目根 config.yaml 读取集中式 LLM 后端配置。"""
|
||||
|
||||
_cache: Dict[str, Any] = {"mtime": None, "data": {}}
|
||||
|
||||
@classmethod
|
||||
def get_root_config_path(cls) -> Path:
|
||||
return Path(__file__).resolve().parents[2] / "config.yaml"
|
||||
|
||||
@classmethod
|
||||
def load_root_config(cls) -> Dict[str, Any]:
|
||||
path = cls.get_root_config_path()
|
||||
if not path.exists():
|
||||
return {}
|
||||
|
||||
stat = path.stat()
|
||||
if cls._cache["mtime"] == stat.st_mtime and cls._cache["data"]:
|
||||
return cls._cache["data"]
|
||||
|
||||
with open(path, "r", encoding="utf-8") as fp:
|
||||
data = yaml.safe_load(fp) or {}
|
||||
cls._cache = {"mtime": stat.st_mtime, "data": data}
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def get_llm_config(cls) -> Dict[str, Any]:
|
||||
config = cls.load_root_config()
|
||||
llm_config = config.get("llm", {}) or {}
|
||||
return llm_config if isinstance(llm_config, dict) else {}
|
||||
|
||||
@classmethod
|
||||
def get_backend(cls, backend_name: str) -> Dict[str, Any]:
|
||||
if not backend_name:
|
||||
return {}
|
||||
llm_config = cls.get_llm_config()
|
||||
backends = llm_config.get("backends", {}) or {}
|
||||
backend = backends.get(backend_name, {}) or {}
|
||||
return dict(backend) if isinstance(backend, dict) else {}
|
||||
|
||||
@classmethod
|
||||
def resolve(cls, local_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
local = dict(local_config or {})
|
||||
backend_name = (
|
||||
local.get("backend")
|
||||
or local.get("backend_name")
|
||||
or local.get("backend_ref")
|
||||
or ""
|
||||
)
|
||||
if not backend_name:
|
||||
return local
|
||||
|
||||
merged = cls.get_backend(str(backend_name).strip())
|
||||
merged.update(local)
|
||||
merged["backend"] = backend_name
|
||||
return merged
|
||||
|
||||
540
utils/ai/unified_llm.py
Normal file
540
utils/ai/unified_llm.py
Normal file
@@ -0,0 +1,540 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
from utils.ai.llm_registry import LLMRegistry
|
||||
|
||||
|
||||
class UnifiedLLMClient:
|
||||
"""统一的 LLM 调用客户端,兼容 OpenAI-compatible 与 Dify。"""
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
self.LOG = logger
|
||||
self.raw_config = config or {}
|
||||
self.config = self._normalize_config(self.raw_config)
|
||||
self.enabled = bool(self.config.get("enabled", True))
|
||||
self.provider = str(self.config.get("provider", "openai_compatible")).strip().lower()
|
||||
self.base_url = str(self.config.get("base_url", "")).rstrip("/")
|
||||
self.endpoint = str(self.config.get("endpoint", "")).lstrip("/")
|
||||
self.api_key = str(self.config.get("api_key", "")).strip()
|
||||
self.model = str(self.config.get("model", "")).strip()
|
||||
self.timeout_seconds = int(self.config.get("timeout_seconds", 60))
|
||||
self.timeout = self.timeout_seconds
|
||||
self.temperature = float(self.config.get("temperature", 0.7))
|
||||
self.max_tokens = int(self.config.get("max_tokens", 1024))
|
||||
self.stream = bool(self.config.get("stream", False))
|
||||
self.max_retries = max(int(self.config.get("max_retries", 3) or 3), 1)
|
||||
self.retry_delay_seconds = float(self.config.get("retry_delay_seconds", 1.0) or 1.0)
|
||||
self.mode = str(self.config.get("mode", "chat")).strip().lower()
|
||||
self.response_mode = str(self.config.get("response_mode", "blocking")).strip().lower()
|
||||
self.workflow_output_key = str(self.config.get("workflow_output_key", "text")).strip()
|
||||
self.default_system_prompt = str(self.config.get("system_prompt", "")).strip()
|
||||
self.last_error = ""
|
||||
|
||||
def is_available(self) -> bool:
|
||||
if not self.enabled:
|
||||
return False
|
||||
if self.provider == "openai_compatible":
|
||||
return bool(self.base_url and self.endpoint and self.model)
|
||||
if self.provider == "dify":
|
||||
return bool(self.base_url and self.endpoint and self.api_key)
|
||||
return False
|
||||
|
||||
def chat(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
user_id: str,
|
||||
image_urls: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
result = self.generate(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
user=user_id,
|
||||
image_urls=image_urls or [],
|
||||
)
|
||||
return (result or {}).get("text", "") or ""
|
||||
|
||||
def run(
|
||||
self,
|
||||
prompt: str,
|
||||
user: str,
|
||||
inputs: Optional[Dict[str, Any]] = None,
|
||||
tag: str = "",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if self.provider == "dify":
|
||||
return self.generate(prompt=prompt, user=user, inputs=inputs or {}, tag=tag)
|
||||
|
||||
effective_prompt = prompt or self._stringify_inputs(inputs or {})
|
||||
return self.generate(
|
||||
system_prompt=self.default_system_prompt,
|
||||
user_prompt=effective_prompt,
|
||||
user=user,
|
||||
inputs=inputs or {},
|
||||
tag=tag,
|
||||
)
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str = "",
|
||||
user: str = "",
|
||||
inputs: Optional[Dict[str, Any]] = None,
|
||||
tag: str = "",
|
||||
system_prompt: str = "",
|
||||
user_prompt: str = "",
|
||||
image_urls: Optional[List[str]] = None,
|
||||
files: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
self.last_error = ""
|
||||
if not self.is_available():
|
||||
self.last_error = "client_unavailable"
|
||||
return None
|
||||
|
||||
if self.provider == "dify":
|
||||
return self._generate_dify(
|
||||
prompt=prompt,
|
||||
user=user,
|
||||
inputs=inputs or {},
|
||||
tag=tag,
|
||||
files=files or [],
|
||||
)
|
||||
if self.provider == "openai_compatible":
|
||||
return self._generate_openai(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt or prompt,
|
||||
user=user,
|
||||
image_urls=image_urls or [],
|
||||
)
|
||||
|
||||
self.last_error = f"unsupported_provider:{self.provider}"
|
||||
return None
|
||||
|
||||
def _generate_openai(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
user: str,
|
||||
image_urls: List[str],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": self._build_messages(system_prompt or self.default_system_prompt, user_prompt, image_urls),
|
||||
"temperature": self.temperature,
|
||||
"max_tokens": self.max_tokens,
|
||||
"user": user,
|
||||
"stream": self.stream,
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = self._build_auth_header(self.api_key)
|
||||
|
||||
url = f"{self.base_url}/{self.endpoint}"
|
||||
for attempt in range(1, self.max_retries + 1):
|
||||
try:
|
||||
if self.stream:
|
||||
text, raw = self._request_openai_stream(url, payload, headers)
|
||||
else:
|
||||
text, raw = self._request_openai_json(url, payload, headers)
|
||||
if text:
|
||||
return {
|
||||
"text": text,
|
||||
"usage": self._extract_openai_usage(raw),
|
||||
"raw": raw,
|
||||
}
|
||||
self.last_error = f"empty_model_output:{self.model}"
|
||||
except Exception as exc:
|
||||
self.last_error = f"request_failed:attempt_{attempt}:{exc}"
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(self.retry_delay_seconds * attempt)
|
||||
return None
|
||||
|
||||
def _generate_dify(
|
||||
self,
|
||||
prompt: str,
|
||||
user: str,
|
||||
inputs: Dict[str, Any],
|
||||
tag: str,
|
||||
files: List[Dict[str, Any]],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
headers = {
|
||||
"Authorization": self._build_auth_header(self.api_key),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload_inputs = dict(inputs or {})
|
||||
if self.mode == "workflow":
|
||||
if prompt and "query" not in payload_inputs:
|
||||
payload_inputs["query"] = prompt
|
||||
payload = {
|
||||
"inputs": payload_inputs,
|
||||
"response_mode": self.response_mode,
|
||||
"user": user,
|
||||
"files": files,
|
||||
}
|
||||
elif self.mode == "completion":
|
||||
payload = {
|
||||
"inputs": payload_inputs,
|
||||
"query": prompt,
|
||||
"response_mode": self.response_mode,
|
||||
"user": user,
|
||||
"files": files,
|
||||
}
|
||||
else:
|
||||
payload = {
|
||||
"inputs": payload_inputs,
|
||||
"query": prompt,
|
||||
"response_mode": self.response_mode,
|
||||
"conversation_id": "",
|
||||
"user": user,
|
||||
"files": files,
|
||||
}
|
||||
|
||||
url = f"{self.base_url}/{self.endpoint}"
|
||||
for attempt in range(1, self.max_retries + 1):
|
||||
try:
|
||||
if self.response_mode == "streaming":
|
||||
parsed = self._request_dify_stream(url, payload, headers, tag)
|
||||
else:
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=self.timeout_seconds)
|
||||
response.raise_for_status()
|
||||
parsed = self._parse_dify_response(response.json())
|
||||
if parsed and parsed.get("text"):
|
||||
return parsed
|
||||
self.last_error = f"empty_model_output:{self.mode}"
|
||||
except Exception as exc:
|
||||
self.last_error = f"request_failed:attempt_{attempt}:{exc}"
|
||||
self.LOG.warning(f"[UnifiedLLMClient] Dify 请求失败: tag={tag}, attempt={attempt}, error={exc}")
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(self.retry_delay_seconds * attempt)
|
||||
return None
|
||||
|
||||
def _request_openai_json(self, url: str, payload: Dict[str, Any], headers: Dict[str, str]) -> Tuple[str, Dict[str, Any]]:
|
||||
response = requests.post(url, json=payload, headers=headers, timeout=self.timeout_seconds)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return self._extract_openai_text(data), data
|
||||
|
||||
def _request_openai_stream(
|
||||
self,
|
||||
url: str,
|
||||
payload: Dict[str, Any],
|
||||
headers: Dict[str, str],
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
chunks: List[str] = []
|
||||
with requests.post(url, json=payload, headers=headers, timeout=self.timeout_seconds, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
buffer = b""
|
||||
for part in response.iter_content(chunk_size=None):
|
||||
if not part:
|
||||
continue
|
||||
buffer += part
|
||||
while b"\n\n" in buffer:
|
||||
event, buffer = buffer.split(b"\n\n", 1)
|
||||
try:
|
||||
text_piece, done = self._parse_openai_sse_event(event.decode("utf-8"))
|
||||
except UnicodeDecodeError:
|
||||
buffer = event + b"\n\n" + buffer
|
||||
break
|
||||
if text_piece:
|
||||
chunks.append(text_piece)
|
||||
if done:
|
||||
break
|
||||
return "".join(chunks).strip(), {"stream_text": "".join(chunks).strip()}
|
||||
|
||||
def _request_dify_stream(
|
||||
self,
|
||||
url: str,
|
||||
payload: Dict[str, Any],
|
||||
headers: Dict[str, str],
|
||||
tag: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
with requests.post(url, headers=headers, json=payload, timeout=self.timeout_seconds, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
event_name = ""
|
||||
text_fragments: List[str] = []
|
||||
final_payload = None
|
||||
|
||||
for raw_line in response.iter_lines(decode_unicode=True):
|
||||
if raw_line is None:
|
||||
continue
|
||||
line = str(raw_line).strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("event:"):
|
||||
event_name = line[6:].strip()
|
||||
continue
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
|
||||
data_text = line[5:].strip()
|
||||
if not data_text or data_text == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(data_text)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
candidate_text = self._extract_dify_stream_text(chunk)
|
||||
if candidate_text:
|
||||
text_fragments.append(candidate_text)
|
||||
|
||||
chunk_event = str(chunk.get("event") or event_name or "").strip()
|
||||
if chunk_event in {"workflow_finished", "message_end"}:
|
||||
final_payload = chunk
|
||||
|
||||
if final_payload:
|
||||
parsed = self._parse_dify_response(final_payload)
|
||||
if parsed and parsed.get("text"):
|
||||
return parsed
|
||||
|
||||
text = "".join(fragment for fragment in text_fragments if fragment).strip()
|
||||
if text:
|
||||
return {"text": text, "usage": {}, "raw": final_payload or {}}
|
||||
|
||||
self.LOG.warning(f"[UnifiedLLMClient] Dify 流式响应未产出有效内容: tag={tag}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_messages(system_prompt: str, user_prompt: str, image_urls: List[str]) -> List[Dict[str, Any]]:
|
||||
user_content: str | List[Dict[str, Any]]
|
||||
if image_urls:
|
||||
content_parts: List[Dict[str, Any]] = [{"type": "text", "text": user_prompt}]
|
||||
for image_url in image_urls:
|
||||
if image_url:
|
||||
content_parts.append({"type": "image_url", "image_url": {"url": image_url}})
|
||||
user_content = content_parts
|
||||
else:
|
||||
user_content = user_prompt
|
||||
messages: List[Dict[str, Any]] = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
return messages
|
||||
|
||||
@staticmethod
|
||||
def _extract_openai_text(data: Dict[str, Any]) -> str:
|
||||
choices = data.get("choices") or []
|
||||
if choices:
|
||||
message = choices[0].get("message", {}) or {}
|
||||
content = message.get("content")
|
||||
if isinstance(content, str) and content.strip():
|
||||
return content.strip()
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text") or item.get("content")
|
||||
if isinstance(text, str) and text.strip():
|
||||
parts.append(text.strip())
|
||||
if parts:
|
||||
return "\n".join(parts).strip()
|
||||
for key in ("reasoning_content", "text", "output_text"):
|
||||
value = message.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
for key in ("output_text", "text", "answer", "response"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _parse_openai_sse_event(cls, event_text: str) -> Tuple[str, bool]:
|
||||
lines = [line.strip() for line in event_text.splitlines() if line.strip()]
|
||||
data_lines = [line[5:].strip() for line in lines if line.startswith("data:")]
|
||||
if not data_lines:
|
||||
return "", False
|
||||
data = "\n".join(data_lines)
|
||||
if data == "[DONE]":
|
||||
return "", True
|
||||
obj = json.loads(data)
|
||||
choice = (obj.get("choices") or [{}])[0]
|
||||
delta = choice.get("delta") or {}
|
||||
content = delta.get("content")
|
||||
if isinstance(content, str):
|
||||
return content, False
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text") or item.get("content")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "".join(parts), False
|
||||
return "", False
|
||||
|
||||
def _parse_dify_response(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
if self.mode == "workflow":
|
||||
return self._parse_dify_workflow_response(data)
|
||||
answer = str(data.get("answer", "") or "").strip()
|
||||
usage = (data.get("metadata") or {}).get("usage", {}) or {}
|
||||
return {"text": answer, "usage": usage, "raw": data}
|
||||
|
||||
def _parse_dify_workflow_response(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
payload = (data or {}).get("data", {}) or {}
|
||||
outputs = payload.get("outputs", {}) or {}
|
||||
text = ""
|
||||
|
||||
for key in filter(None, [self.workflow_output_key, "text", "answer", "result_json", "result"]):
|
||||
if outputs.get(key) is not None:
|
||||
text = self._stringify_output(outputs.get(key))
|
||||
if text:
|
||||
break
|
||||
|
||||
if not text:
|
||||
for value in outputs.values():
|
||||
text = self._stringify_output(value)
|
||||
if text:
|
||||
break
|
||||
|
||||
usage = {
|
||||
"total_tokens": payload.get("total_tokens"),
|
||||
"latency": payload.get("elapsed_time"),
|
||||
}
|
||||
return {"text": text.strip(), "usage": usage, "raw": data}
|
||||
|
||||
def _extract_dify_stream_text(self, chunk: Dict[str, Any]) -> str:
|
||||
if not isinstance(chunk, dict):
|
||||
return ""
|
||||
payload = (chunk.get("data") or {}) if isinstance(chunk.get("data"), dict) else {}
|
||||
outputs = payload.get("outputs", {}) if isinstance(payload.get("outputs"), dict) else {}
|
||||
|
||||
for key in filter(None, [self.workflow_output_key, "text", "answer", "result_json", "result"]):
|
||||
if outputs.get(key) is not None:
|
||||
return self._stringify_output(outputs.get(key))
|
||||
|
||||
for key in ("text", "answer"):
|
||||
if chunk.get(key) is not None:
|
||||
return self._stringify_output(chunk.get(key))
|
||||
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_openai_usage(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
usage = data.get("usage", {}) or {}
|
||||
if usage:
|
||||
return usage
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _stringify_output(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
return str(value).strip()
|
||||
|
||||
@classmethod
|
||||
def _normalize_config(cls, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
normalized = LLMRegistry.resolve(config or {})
|
||||
normalized["enabled"] = bool(
|
||||
normalized.get("enabled", normalized.get("enable", True))
|
||||
)
|
||||
|
||||
if not normalized.get("provider"):
|
||||
normalized["provider"] = cls._guess_provider(normalized)
|
||||
|
||||
parsed_url = cls._split_url(
|
||||
normalized.get("api_url")
|
||||
or normalized.get("url")
|
||||
)
|
||||
base_url = (
|
||||
normalized.get("base_url")
|
||||
or normalized.get("api_base_url")
|
||||
or parsed_url[0]
|
||||
or ""
|
||||
)
|
||||
endpoint = (
|
||||
normalized.get("endpoint")
|
||||
or parsed_url[1]
|
||||
or ""
|
||||
)
|
||||
|
||||
normalized["base_url"] = str(base_url).rstrip("/")
|
||||
normalized["endpoint"] = str(endpoint).lstrip("/")
|
||||
normalized["api_key"] = (
|
||||
normalized.get("api_key")
|
||||
or normalized.get("api-key")
|
||||
or normalized.get("authorization")
|
||||
or ""
|
||||
)
|
||||
normalized["timeout_seconds"] = int(
|
||||
normalized.get("timeout_seconds")
|
||||
or normalized.get("request_timeout_seconds")
|
||||
or normalized.get("request_timeout")
|
||||
or 60
|
||||
)
|
||||
normalized["max_retries"] = int(normalized.get("max_retries", len(normalized.get("retry_delays_seconds", [])) + 1 or 3))
|
||||
normalized["retry_delay_seconds"] = float(normalized.get("retry_delay_seconds", 1.0))
|
||||
normalized["response_mode"] = normalized.get("response_mode", "blocking")
|
||||
normalized["workflow_output_key"] = normalized.get("workflow_output_key", "text")
|
||||
|
||||
if normalized["provider"] == "dify":
|
||||
default_endpoint = cls._guess_dify_endpoint(normalized)
|
||||
if not normalized["endpoint"]:
|
||||
normalized["endpoint"] = default_endpoint
|
||||
else:
|
||||
if not normalized["endpoint"]:
|
||||
normalized["endpoint"] = "chat/completions"
|
||||
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _guess_provider(config: Dict[str, Any]) -> str:
|
||||
api_key = str(
|
||||
config.get("api_key")
|
||||
or config.get("api-key")
|
||||
or config.get("authorization")
|
||||
or ""
|
||||
).strip()
|
||||
url = str(config.get("api_url") or config.get("url") or config.get("endpoint") or "").lower()
|
||||
mode = str(config.get("mode", "")).lower()
|
||||
if "workflows/run" in url or "chat-messages" in url or "completion-messages" in url:
|
||||
return "dify"
|
||||
if api_key.startswith("app-") or mode in {"workflow", "completion"}:
|
||||
return "dify"
|
||||
return "openai_compatible"
|
||||
|
||||
@staticmethod
|
||||
def _guess_dify_endpoint(config: Dict[str, Any]) -> str:
|
||||
mode = str(config.get("mode", "chat")).strip().lower()
|
||||
if mode == "workflow":
|
||||
return "workflows/run"
|
||||
if mode == "completion":
|
||||
return "completion-messages"
|
||||
return "chat-messages"
|
||||
|
||||
@staticmethod
|
||||
def _split_url(url: Optional[str]) -> Tuple[str, str]:
|
||||
if not url:
|
||||
return "", ""
|
||||
parsed = urlparse(str(url))
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return "", str(url)
|
||||
base = f"{parsed.scheme}://{parsed.netloc}"
|
||||
return base, parsed.path.lstrip("/")
|
||||
|
||||
@staticmethod
|
||||
def _build_auth_header(value: str) -> str:
|
||||
token = str(value or "").strip()
|
||||
if not token:
|
||||
return ""
|
||||
if token.lower().startswith("bearer "):
|
||||
return token
|
||||
return f"Bearer {token}"
|
||||
|
||||
@staticmethod
|
||||
def _stringify_inputs(inputs: Dict[str, Any]) -> str:
|
||||
if not inputs:
|
||||
return ""
|
||||
try:
|
||||
return json.dumps(inputs, ensure_ascii=False)
|
||||
except Exception:
|
||||
return str(inputs)
|
||||
Reference in New Issue
Block a user