Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26a76b030d | |||
| daccc625c3 | |||
| a2a7fd46c3 | |||
| baf5913bfb | |||
| ae08e01e55 | |||
| 9048d94e33 | |||
| 291de733a4 | |||
| 10f67a807a | |||
| d9ac2c78f6 | |||
| 4ac67b5816 | |||
| 527885f3d6 | |||
| c21270195a | |||
| db7d0fd586 | |||
| f9e3708ce0 | |||
| 64f0aa0c0b | |||
| 8ddc50a0a9 | |||
| 20ef1abe7d | |||
| 54290019c3 | |||
| d3e80c0afb |
467
main_v2.py
467
main_v2.py
@@ -1,9 +1,9 @@
|
||||
"""
|
||||
AI对话系统 v2.0.0 - 主应用
|
||||
支持:大模型池、Agent管理、渠道独立绑定、思考功能开关
|
||||
AI对话系统 v3.0.0 - 主应用
|
||||
支持:大模型池、Agent管理、渠道独立绑定、思考功能开关、Function Calling(LLM自主调用工具)
|
||||
"""
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -13,6 +13,9 @@ import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import os
|
||||
import base64
|
||||
import uuid
|
||||
import time
|
||||
|
||||
# 使用新的数据模型
|
||||
from models_v2 import (
|
||||
@@ -34,7 +37,14 @@ app = FastAPI(title="AI对话系统 v2.0", version="2.0.0")
|
||||
|
||||
# 静态文件和模板
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
UPLOADS_DIR = os.path.join(BASE_DIR, "uploads", "images")
|
||||
|
||||
# 确保上传目录存在
|
||||
os.makedirs(UPLOADS_DIR, exist_ok=True)
|
||||
|
||||
# 静态文件服务
|
||||
app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
|
||||
app.mount("/uploads", StaticFiles(directory=os.path.join(BASE_DIR, "uploads")), name="uploads")
|
||||
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
|
||||
|
||||
# WebSocket连接管理
|
||||
@@ -110,6 +120,9 @@ async def get_providers(db: Session = Depends(get_db)):
|
||||
"default_model": p.default_model,
|
||||
"supports_thinking": p.supports_thinking,
|
||||
"thinking_model": p.thinking_model,
|
||||
"supports_vision": p.supports_vision,
|
||||
"vision_model": p.vision_model,
|
||||
"supports_function_calling": p.supports_function_calling,
|
||||
"max_tokens": p.max_tokens,
|
||||
"temperature": p.temperature,
|
||||
"is_active": p.is_active,
|
||||
@@ -575,6 +588,56 @@ async def perform_search(data: dict, db: Session = Depends(get_db)):
|
||||
return {"success": False, "message": "不支持的搜索提供商"}
|
||||
|
||||
|
||||
# ==================== 图片上传 API ====================
|
||||
|
||||
@app.post("/api/v2/upload-image")
|
||||
async def upload_image(data: dict):
|
||||
"""上传图片到服务器,返回文件路径"""
|
||||
try:
|
||||
image_data = data.get('image')
|
||||
file_name = data.get('name', 'image.png')
|
||||
|
||||
if not image_data:
|
||||
return {"success": False, "message": "缺少图片数据"}
|
||||
|
||||
# 解析 base64 数据
|
||||
if image_data.startswith('data:image/'):
|
||||
# 提取格式和base64内容
|
||||
header, base64_content = image_data.split(',', 1)
|
||||
# 从header中提取图片格式
|
||||
format_match = header.split(':')[1].split(';')[0] # 如 'image/png'
|
||||
ext = format_match.split('/')[1] if '/' in format_match else 'png'
|
||||
else:
|
||||
base64_content = image_data
|
||||
ext = 'png'
|
||||
|
||||
# 生成唯一文件名
|
||||
timestamp = int(time.time())
|
||||
unique_id = uuid.uuid4().hex[:8]
|
||||
safe_name = f"{timestamp}_{unique_id}.{ext}"
|
||||
|
||||
# 保存文件
|
||||
file_path = os.path.join(UPLOADS_DIR, safe_name)
|
||||
image_bytes = base64.b64decode(base64_content)
|
||||
|
||||
# 检查文件大小(限制10MB)
|
||||
if len(image_bytes) > 10 * 1024 * 1024:
|
||||
return {"success": False, "message": "图片大小超过10MB限制"}
|
||||
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
# 返回可访问的URL路径
|
||||
url_path = f"/uploads/images/{safe_name}"
|
||||
logger.info(f"图片已保存: {file_path}, URL: {url_path}")
|
||||
|
||||
return {"success": True, "path": url_path, "name": safe_name}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"图片上传失败: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
|
||||
# ==================== 对话 API(保留原有) ====================
|
||||
|
||||
@app.get("/api/conversations")
|
||||
@@ -584,17 +647,19 @@ async def get_conversations(db: Session = Depends(get_db)):
|
||||
user = conv_service.get_or_create_user(MAIN_USER_ID, display_name="主用户", user_type='web')
|
||||
conversations = conv_service.get_user_conversations(user.id)
|
||||
|
||||
return {
|
||||
"conversations": [
|
||||
{
|
||||
"id": c.conversation_id,
|
||||
"title": c.title or "新对话",
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"updated_at": c.updated_at.isoformat()
|
||||
}
|
||||
for c in conversations
|
||||
]
|
||||
}
|
||||
# 为每个对话计算消息数量
|
||||
result = []
|
||||
for c in conversations:
|
||||
msg_count = db.query(Message).filter(Message.conversation_id == c.id).count()
|
||||
result.append({
|
||||
"id": c.conversation_id,
|
||||
"title": c.title or "新对话",
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"updated_at": c.updated_at.isoformat(),
|
||||
"message_count": msg_count
|
||||
})
|
||||
|
||||
return {"conversations": result}
|
||||
|
||||
|
||||
@app.get("/api/conversations/latest")
|
||||
@@ -630,6 +695,26 @@ async def create_conversation(db: Session = Depends(get_db)):
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/conversations/{conversation_id}")
|
||||
async def get_conversation(conversation_id: str, db: Session = Depends(get_db)):
|
||||
"""获取单个对话详情"""
|
||||
conv_service = ConversationService(db)
|
||||
conversation = conv_service.get_conversation(conversation_id)
|
||||
|
||||
if not conversation:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
|
||||
msg_count = db.query(Message).filter(Message.conversation_id == conversation.id).count()
|
||||
|
||||
return {
|
||||
"id": conversation.conversation_id,
|
||||
"title": conversation.title or "新对话",
|
||||
"created_at": conversation.created_at.isoformat(),
|
||||
"updated_at": conversation.updated_at.isoformat(),
|
||||
"message_count": msg_count
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/conversations/{conversation_id}/messages")
|
||||
async def get_messages(conversation_id: str, db: Session = Depends(get_db)):
|
||||
"""获取会话消息"""
|
||||
@@ -770,7 +855,7 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str):
|
||||
conversation_id = data.get("conversation_id")
|
||||
enable_thinking = data.get("enable_thinking", True)
|
||||
agent_id_override = data.get("agent_id")
|
||||
disabled_tools = data.get("disabled_tools", []) # 禁用的工具列表
|
||||
# v3.0: 移除 disabled_tools,由LLM自主决定
|
||||
|
||||
if agent_id_override:
|
||||
agent = agent_service.get_agent(agent_id_override)
|
||||
@@ -784,34 +869,41 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str):
|
||||
if not message.strip() and not files:
|
||||
continue
|
||||
|
||||
# 处理文件内容,添加到消息
|
||||
image_contents = [] # 图片内容(用于视觉模型)
|
||||
text_contents = [] # 文本文件内容
|
||||
# 处理文件内容
|
||||
image_contents = []
|
||||
text_contents = []
|
||||
image_paths = []
|
||||
if files:
|
||||
for f in files:
|
||||
if f.get('type') and f['type'].startswith('image/'):
|
||||
# 图片:记录 base64 数据,用于视觉模型
|
||||
image_contents.append({
|
||||
'name': f['name'],
|
||||
'type': f['type'],
|
||||
'data': f.get('content', '') # base64 数据
|
||||
'data': f.get('content', '')
|
||||
})
|
||||
# 不添加文件名文本,图片信息保存在 extra_data 中
|
||||
if f.get('serverPath'):
|
||||
image_paths.append({
|
||||
'name': f['name'],
|
||||
'type': f['type'],
|
||||
'url': f['serverPath']
|
||||
})
|
||||
elif f.get('content'):
|
||||
# 文本文件:直接添加内容,不带文件名前缀
|
||||
text_contents.append(f['content'][:3000])
|
||||
if len(f['content']) > 3000:
|
||||
text_contents[-1] += "...(内容过长已截断)"
|
||||
|
||||
# 如果有文本文件内容,追加到消息后面
|
||||
if text_contents:
|
||||
for content in text_contents:
|
||||
message += f"\n\n{content}"
|
||||
|
||||
# 保存图片信息到 extra_data(用于历史记录)
|
||||
# 保存文件信息到 extra_data
|
||||
extra_data_for_msg = None
|
||||
if image_contents:
|
||||
# 只保存图片 URL(不保存完整 base64)
|
||||
if image_paths:
|
||||
extra_data_for_msg = {
|
||||
'images': image_paths,
|
||||
'files': [{'name': f['name'], 'type': f['type']} for f in files if not f['type'].startswith('image/')]
|
||||
}
|
||||
elif image_contents:
|
||||
extra_data_for_msg = {
|
||||
'images': [{'name': i['name'], 'type': i['type']} for i in image_contents],
|
||||
'files': [{'name': f['name'], 'type': f['type']} for f in files if not f['type'].startswith('image/')]
|
||||
@@ -820,8 +912,9 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str):
|
||||
# 1. 获取Agent配置
|
||||
agent_config = agent_service.get_agent_config(current_agent_id)
|
||||
agent_tools = agent_config.get('agent', {}).get('tools', [])
|
||||
supports_function_calling = agent_config.get('provider', {}).get('supports_function_calling', False)
|
||||
|
||||
# 2. 获取或创建会话(先有 conversation_id)
|
||||
# 2. 获取或创建会话
|
||||
if conversation_id:
|
||||
conversation = conv_service.get_conversation(conversation_id)
|
||||
else:
|
||||
@@ -832,12 +925,12 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str):
|
||||
"conversation_id": conversation_id
|
||||
})
|
||||
|
||||
# 3. 广播用户消息(前端立即看到)
|
||||
# 3. 广播用户消息
|
||||
await manager.send_to_user(MAIN_USER_ID, {
|
||||
"type": "user_message",
|
||||
"conversation_id": conversation_id,
|
||||
"message": {
|
||||
"id": None, # 临时,后面会保存
|
||||
"id": None,
|
||||
"role": "user",
|
||||
"content": message,
|
||||
"source": "web",
|
||||
@@ -845,118 +938,45 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str):
|
||||
}
|
||||
})
|
||||
|
||||
# 4. 执行搜索并发送搜索结果
|
||||
search_context = None
|
||||
search_results_for_client = None # 用于发送给前端和保存
|
||||
logger.info(f"检查搜索条件: agent_tools={agent_tools}, disabled_tools={disabled_tools}")
|
||||
|
||||
if 'search' in agent_tools and 'search' not in disabled_tools:
|
||||
logger.info("搜索条件满足,开始执行搜索")
|
||||
|
||||
tool_service = ToolService(db)
|
||||
search_tool = tool_service.get_default_tool('search')
|
||||
logger.info(f"获取到搜索工具: {search_tool.name if search_tool else 'None'}")
|
||||
|
||||
if search_tool and search_tool.config.get('api_key'):
|
||||
import httpx
|
||||
import time
|
||||
start_time = time.time()
|
||||
try:
|
||||
logger.info(f"执行搜索: query={message}")
|
||||
tavily_url = "https://api.tavily.com/search"
|
||||
config = search_tool.config
|
||||
payload = {
|
||||
"api_key": config.get('api_key'),
|
||||
"query": message,
|
||||
"max_results": config.get('max_results', 5),
|
||||
"search_depth": config.get('search_depth', 'basic')
|
||||
}
|
||||
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.post(tavily_url, json=payload)
|
||||
search_result = resp.json()
|
||||
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
if search_result.get("results"):
|
||||
# 构建搜索上下文(给LLM)
|
||||
max_for_llm = config.get('max_results', 5)
|
||||
search_context = "\n\n【搜索结果】\n"
|
||||
for i, r in enumerate(search_result["results"][:max_for_llm], 1):
|
||||
search_context += f"{i}. {r.get('title', 'N/A')}\n {r.get('content', r.get('snippet', 'N/A'))[:200]}\n 来源: {r.get('url', 'N/A')}\n"
|
||||
logger.info(f"搜索完成: {len(search_result['results'])} 条结果,使用 {min(len(search_result['results']), max_for_llm)} 条")
|
||||
|
||||
# 发送搜索结果给前端(按配置的数量)
|
||||
max_display = config.get('max_results', 5)
|
||||
search_results_for_client = [
|
||||
{
|
||||
"title": r.get('title', 'N/A'),
|
||||
"snippet": r.get('content', r.get('snippet', ''))[:150],
|
||||
"url": r.get('url', 'N/A')
|
||||
}
|
||||
for r in search_result["results"][:max_display]
|
||||
]
|
||||
await websocket.send_json({
|
||||
"type": "search_results",
|
||||
"conversation_id": conversation_id,
|
||||
"results": search_results_for_client,
|
||||
"query": message
|
||||
})
|
||||
|
||||
# 更新统计和日志
|
||||
tool_service.increment_stats(search_tool.id, True)
|
||||
tool_service.log_usage({
|
||||
'tool_id': search_tool.id,
|
||||
'tool_type': 'search',
|
||||
'query': message,
|
||||
'success': True,
|
||||
'result_summary': f'{len(search_result["results"])} results',
|
||||
'conversation_id': conversation_id,
|
||||
'agent_id': current_agent_id,
|
||||
'duration_ms': duration_ms
|
||||
})
|
||||
except Exception as e:
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
logger.error(f"搜索失败: {e}")
|
||||
tool_service.increment_stats(search_tool.id, False)
|
||||
tool_service.log_usage({
|
||||
'tool_id': search_tool.id,
|
||||
'tool_type': 'search',
|
||||
'query': message,
|
||||
'success': False,
|
||||
'error_message': str(e),
|
||||
'conversation_id': conversation_id,
|
||||
'duration_ms': duration_ms
|
||||
})
|
||||
|
||||
# 5. 保存用户消息到数据库
|
||||
extra_data_to_save = None
|
||||
if search_results_for_client:
|
||||
extra_data_to_save = {'search_results': search_results_for_client, 'search_query': message}
|
||||
if extra_data_for_msg:
|
||||
if extra_data_to_save:
|
||||
extra_data_to_save.update(extra_data_for_msg)
|
||||
else:
|
||||
extra_data_to_save = extra_data_for_msg
|
||||
|
||||
# 4. 保存用户消息
|
||||
user_msg = conv_service.add_message(
|
||||
conversation_id=conversation.id,
|
||||
role='user',
|
||||
content=message,
|
||||
source='web',
|
||||
extra_data=extra_data_to_save
|
||||
extra_data=extra_data_for_msg
|
||||
)
|
||||
|
||||
# 6. 获取对话历史(包含刚保存的用户消息)
|
||||
# 5. 获取对话历史
|
||||
history = conv_service.get_conversation_history(conversation_id, limit=agent_config['agent'].get('max_history', 20))
|
||||
|
||||
# 7. 如果有搜索结果,添加到消息中
|
||||
if search_context:
|
||||
modified_system_prompt = agent_config['agent'].get('system_prompt', '') + "\n\n如果提供了搜索结果,请基于搜索结果回答用户问题,并注明信息来源。"
|
||||
agent_config['agent']['system_prompt'] = modified_system_prompt
|
||||
history.append({"role": "system", "content": f"以下是搜索到的相关信息,请参考这些内容回答用户问题:{search_context}"})
|
||||
# 6. 构建工具 schema(Function Calling)
|
||||
tools_schema = []
|
||||
if supports_function_calling and agent_tools:
|
||||
# 搜索工具
|
||||
if 'search' in agent_tools:
|
||||
tool_service = ToolService(db)
|
||||
search_tool = tool_service.get_default_tool('search')
|
||||
if search_tool and search_tool.config.get('api_key'):
|
||||
tools_schema.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "搜索互联网获取实时信息、新闻、数据等。当用户询问需要最新信息的问题时使用此工具。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "搜索关键词或问题"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# 8. 调用LLM返回回复
|
||||
# 7. 调用LLM(Function Calling模式)
|
||||
if not agent_config or not agent_config.get('provider'):
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
@@ -965,17 +985,184 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str):
|
||||
continue
|
||||
|
||||
try:
|
||||
response, thinking_content = await llm_service.chat(
|
||||
messages=history,
|
||||
provider_config=agent_config['provider'],
|
||||
agent_config=agent_config['agent'],
|
||||
enable_thinking=enable_thinking,
|
||||
images=image_contents # 传递图片数据给多模态模型
|
||||
)
|
||||
response = None
|
||||
thinking_content = None
|
||||
tool_calls_record = []
|
||||
|
||||
# 第一阶段:让LLM决定是否调用工具
|
||||
if tools_schema:
|
||||
response, thinking_content, tool_calls = await llm_service.chat_with_tools(
|
||||
messages=history,
|
||||
provider_config=agent_config['provider'],
|
||||
agent_config=agent_config['agent'],
|
||||
tools=tools_schema,
|
||||
enable_thinking=enable_thinking,
|
||||
images=image_contents
|
||||
)
|
||||
|
||||
# 如果LLM请求调用工具
|
||||
if tool_calls:
|
||||
logger.info(f"LLM请求调用工具: {tool_calls}")
|
||||
|
||||
# 发送工具调用通知给前端
|
||||
await websocket.send_json({
|
||||
"type": "tool_calls",
|
||||
"conversation_id": conversation_id,
|
||||
"tool_calls": [
|
||||
{"name": tc['name'], "arguments": tc['arguments']}
|
||||
for tc in tool_calls
|
||||
]
|
||||
})
|
||||
|
||||
# 执行工具调用
|
||||
tool_results = []
|
||||
tool_service = ToolService(db)
|
||||
search_tool = tool_service.get_default_tool('search')
|
||||
|
||||
for tc in tool_calls:
|
||||
if tc['name'] == 'web_search':
|
||||
query = tc['arguments'].get('query', message)
|
||||
logger.info(f"执行搜索: query={query}")
|
||||
|
||||
import httpx
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
tavily_url = "https://api.tavily.com/search"
|
||||
config = search_tool.config
|
||||
payload = {
|
||||
"api_key": config.get('api_key'),
|
||||
"query": query,
|
||||
"max_results": config.get('max_results', 5),
|
||||
"search_depth": config.get('search_depth', 'basic')
|
||||
}
|
||||
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.post(tavily_url, json=payload)
|
||||
search_result = resp.json()
|
||||
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
if search_result.get("results"):
|
||||
# 构建搜索结果
|
||||
search_content = []
|
||||
for i, r in enumerate(search_result["results"][:5], 1):
|
||||
search_content.append({
|
||||
"title": r.get('title', 'N/A'),
|
||||
"content": r.get('content', r.get('snippet', ''))[:300],
|
||||
"url": r.get('url', 'N/A')
|
||||
})
|
||||
|
||||
tool_results.append({
|
||||
"tool_call_id": tc['id'],
|
||||
"content": json.dumps(search_content)
|
||||
})
|
||||
|
||||
# 发送搜索结果给前端
|
||||
await websocket.send_json({
|
||||
"type": "search_results",
|
||||
"conversation_id": conversation_id,
|
||||
"results": [
|
||||
{"title": r.get('title'), "snippet": r.get('content', '')[:150], "url": r.get('url')}
|
||||
for r in search_result["results"][:5]
|
||||
],
|
||||
"query": query
|
||||
})
|
||||
|
||||
# 记录日志
|
||||
tool_service.increment_stats(search_tool.id, True)
|
||||
tool_service.log_usage({
|
||||
'tool_id': search_tool.id,
|
||||
'tool_type': 'search',
|
||||
'query': query,
|
||||
'success': True,
|
||||
'result_summary': f'{len(search_result["results"])} results',
|
||||
'conversation_id': conversation_id,
|
||||
'agent_id': current_agent_id,
|
||||
'duration_ms': duration_ms
|
||||
})
|
||||
|
||||
tool_calls_record.append({
|
||||
"name": "web_search",
|
||||
"query": query,
|
||||
"results_count": len(search_result["results"])
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"搜索失败: {e}")
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
tool_service.increment_stats(search_tool.id, False)
|
||||
tool_service.log_usage({
|
||||
'tool_id': search_tool.id,
|
||||
'tool_type': 'search',
|
||||
'query': query,
|
||||
'success': False,
|
||||
'error_message': str(e),
|
||||
'conversation_id': conversation_id,
|
||||
'duration_ms': duration_ms
|
||||
})
|
||||
tool_results.append({
|
||||
"tool_call_id": tc['id'],
|
||||
"content": json.dumps({"error": str(e)})
|
||||
})
|
||||
|
||||
# 将工具调用消息添加到历史
|
||||
# 注意:这里需要将 assistant 的 tool_calls 消息添加到历史
|
||||
# 但我们用的是简化的历史格式,需要重新构建
|
||||
|
||||
# 第二阶段:将工具结果返回给LLM
|
||||
if tool_results:
|
||||
# 重新获取完整历史(包含工具调用)
|
||||
history_with_tools = history.copy()
|
||||
# 添加 assistant 的 tool_calls 消息
|
||||
history_with_tools.append({
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tc['id'],
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc['name'],
|
||||
"arguments": json.dumps(tc['arguments'])
|
||||
}
|
||||
}
|
||||
for tc in tool_calls
|
||||
]
|
||||
})
|
||||
# 添加工具结果
|
||||
for tr in tool_results:
|
||||
history_with_tools.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tr['tool_call_id'],
|
||||
"content": tr['content']
|
||||
})
|
||||
|
||||
response, thinking_content = await llm_service.chat_with_tool_results(
|
||||
messages=history_with_tools,
|
||||
provider_config=agent_config['provider'],
|
||||
agent_config=agent_config['agent'],
|
||||
enable_thinking=enable_thinking
|
||||
)
|
||||
|
||||
# 如果不支持 Function Calling 或没有工具,直接调用普通 chat
|
||||
if response is None:
|
||||
response, thinking_content = await llm_service.chat(
|
||||
messages=history,
|
||||
provider_config=agent_config['provider'],
|
||||
agent_config=agent_config['agent'],
|
||||
enable_thinking=enable_thinking,
|
||||
images=image_contents
|
||||
)
|
||||
|
||||
logger.info(f"LLM响应: response长度={len(response)}, thinking长度={len(thinking_content) if thinking_content else 0}")
|
||||
|
||||
# 保存AI回复
|
||||
extra_data_to_save = None
|
||||
if tool_calls_record:
|
||||
extra_data_to_save = {'tool_calls': tool_calls_record}
|
||||
|
||||
assistant_msg = conv_service.add_message(
|
||||
conversation_id=conversation.id,
|
||||
role='assistant',
|
||||
@@ -983,7 +1170,8 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str):
|
||||
source='web',
|
||||
thinking_content=thinking_content if thinking_content else None,
|
||||
agent_id=current_agent_id,
|
||||
model_used=agent_config['provider'].get('default_model')
|
||||
model_used=agent_config['provider'].get('default_model'),
|
||||
extra_data=extra_data_to_save
|
||||
)
|
||||
|
||||
# 发送AI回复
|
||||
@@ -998,6 +1186,7 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str):
|
||||
"source": "web",
|
||||
"agent_id": current_agent_id,
|
||||
"agent_name": agent_config['agent'].get('display_name'),
|
||||
"tool_calls": tool_calls_record, # v3.0: 返回工具调用记录
|
||||
"created_at": assistant_msg.created_at.isoformat()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -32,6 +32,13 @@ class LLMProvider(Base):
|
||||
supports_thinking = Column(Boolean, default=False) # 是否原生支持思考
|
||||
thinking_model = Column(String(100), nullable=True) # 思考模式模型名(如有单独模型)
|
||||
|
||||
# 视觉能力支持
|
||||
supports_vision = Column(Boolean, default=False) # 是否支持图片理解(多模态)
|
||||
vision_model = Column(String(100), nullable=True) # 视觉模型名(如与默认模型不同)
|
||||
|
||||
# Function Calling 支持
|
||||
supports_function_calling = Column(Boolean, default=False) # 是否支持函数调用(工具自主调用)
|
||||
|
||||
# 配额和限制
|
||||
max_tokens = Column(Integer, default=4096)
|
||||
temperature = Column(Float, default=0.7)
|
||||
|
||||
@@ -137,6 +137,9 @@ class AgentService:
|
||||
'api_key': provider.api_key if provider else None,
|
||||
'supports_thinking': provider.supports_thinking if provider else False,
|
||||
'thinking_model': provider.thinking_model if provider else None,
|
||||
'supports_vision': provider.supports_vision if provider else False,
|
||||
'vision_model': provider.vision_model if provider else None,
|
||||
'supports_function_calling': provider.supports_function_calling if provider else False,
|
||||
'default_model': provider.default_model if provider else 'auto',
|
||||
'max_tokens': provider.max_tokens if provider else 4096,
|
||||
'temperature': provider.temperature if provider else 0.7,
|
||||
|
||||
@@ -382,5 +382,192 @@ class LLMService:
|
||||
yield {"type": "content", "text": buffer}
|
||||
|
||||
|
||||
async def chat_with_tools(
|
||||
self,
|
||||
messages: List[Dict],
|
||||
provider_config: dict,
|
||||
agent_config: dict,
|
||||
tools: List[Dict] = None,
|
||||
enable_thinking: bool = True,
|
||||
images: List[Dict] = None
|
||||
) -> Tuple[str, Optional[str], Optional[List[Dict]]]:
|
||||
"""
|
||||
支持Function Calling的对话
|
||||
|
||||
Args:
|
||||
messages: 对话历史
|
||||
provider_config: LLM Provider配置
|
||||
agent_config: Agent配置
|
||||
tools: 工具定义列表(OpenAI Function Calling格式)
|
||||
enable_thinking: 是否启用思考
|
||||
images: 图片数据列表
|
||||
|
||||
Returns:
|
||||
Tuple[str, Optional[str], Optional[List[Dict]]]: (回复内容, 思考过程, 工具调用记录)
|
||||
"""
|
||||
api_base = provider_config.get('api_base')
|
||||
api_key = provider_config.get('api_key')
|
||||
model = agent_config.get('model_override') or provider_config.get('default_model', 'auto')
|
||||
supports_function_calling = provider_config.get('supports_function_calling', False)
|
||||
|
||||
max_tokens = provider_config.get('max_tokens', 4096)
|
||||
temperature = agent_config.get('temperature_override') or provider_config.get('temperature', 0.7)
|
||||
|
||||
# 如果不支持Function Calling,直接调用普通chat
|
||||
if not supports_function_calling or not tools:
|
||||
response, thinking = await self.chat(messages, provider_config, agent_config, enable_thinking, images)
|
||||
return response, thinking, None
|
||||
|
||||
# 构建消息
|
||||
final_messages = messages.copy()
|
||||
system_prompt = agent_config.get('system_prompt', '你是一个有用的AI助手。')
|
||||
if final_messages and final_messages[0]['role'] != 'system':
|
||||
final_messages.insert(0, {"role": "system", "content": system_prompt})
|
||||
|
||||
# 处理图片(多模态)
|
||||
if images and len(images) > 0:
|
||||
for i in range(len(final_messages) - 1, -1, -1):
|
||||
if final_messages[i]['role'] == 'user':
|
||||
original_text = final_messages[i]['content']
|
||||
multimodal_content = [{"type": "text", "text": original_text if original_text else "请描述这张图片"}]
|
||||
for img in images:
|
||||
multimodal_content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": img['data']}
|
||||
})
|
||||
final_messages[i]['content'] = multimodal_content
|
||||
break
|
||||
|
||||
# 第一次调用:让LLM决定是否调用工具
|
||||
url = f"{api_base.rstrip('/')}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": final_messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"tools": tools # 传入工具定义
|
||||
}
|
||||
|
||||
logger.info(f"Function Calling调用: url={url}, model={model}, tools={len(tools)}")
|
||||
|
||||
tool_calls_record = [] # 记录工具调用
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"API返回错误: status={response.status_code}, body={response.text[:500]}")
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
if 'choices' not in data or len(data['choices']) == 0:
|
||||
raise ValueError("API响应格式错误:缺少choices")
|
||||
|
||||
message = data['choices'][0]['message']
|
||||
|
||||
# 检查是否有工具调用
|
||||
if 'tool_calls' in message and message['tool_calls']:
|
||||
logger.info(f"LLM请求调用工具: {len(message['tool_calls'])} 个")
|
||||
|
||||
# 将LLM的工具调用消息添加到历史
|
||||
final_messages.append({
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": message['tool_calls']
|
||||
})
|
||||
|
||||
# 记录工具调用
|
||||
for tc in message['tool_calls']:
|
||||
tool_calls_record.append({
|
||||
"id": tc['id'],
|
||||
"name": tc['function']['name'],
|
||||
"arguments": json.loads(tc['function']['arguments'])
|
||||
})
|
||||
|
||||
# 返回工具调用记录,由调用方执行工具
|
||||
return None, None, tool_calls_record
|
||||
|
||||
# 没有工具调用,直接返回内容
|
||||
content = message.get('content', '')
|
||||
|
||||
# 处理思考内容(如果有)
|
||||
thinking_content = None
|
||||
# 这里可以添加思考内容提取逻辑
|
||||
|
||||
return content, thinking_content, None
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP错误: {e.response.status_code}, {e.response.text}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Function Calling调用异常: {type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
async def chat_with_tool_results(
|
||||
self,
|
||||
messages: List[Dict],
|
||||
provider_config: dict,
|
||||
agent_config: dict,
|
||||
enable_thinking: bool = True
|
||||
) -> Tuple[str, Optional[str]]:
|
||||
"""
|
||||
第二阶段调用:使用包含工具调用和结果的完整消息历史
|
||||
|
||||
Args:
|
||||
messages: 已包含assistant tool_calls和tool结果的完整消息历史
|
||||
provider_config: LLM Provider配置
|
||||
agent_config: Agent配置
|
||||
|
||||
Returns:
|
||||
Tuple[str, Optional[str]]: (回复内容, 思考过程)
|
||||
"""
|
||||
api_base = provider_config.get('api_base')
|
||||
api_key = provider_config.get('api_key')
|
||||
model = agent_config.get('model_override') or provider_config.get('default_model', 'auto')
|
||||
max_tokens = provider_config.get('max_tokens', 4096)
|
||||
temperature = agent_config.get('temperature_override') or provider_config.get('temperature', 0.7)
|
||||
|
||||
# 消息历史已经包含了assistant的tool_calls和tool结果,直接使用
|
||||
final_messages = messages.copy()
|
||||
|
||||
# 调用LLM生成最终回复
|
||||
url = f"{api_base.rstrip('/')}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": final_messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens
|
||||
}
|
||||
|
||||
logger.info(f"工具结果返回LLM: url={url}, model={model}, 消息数={len(final_messages)}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"API返回错误: status={response.status_code}, body={response.text[:500]}")
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
content = data['choices'][0]['message']['content']
|
||||
|
||||
return content, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"工具结果调用异常: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# 全局实例
|
||||
llm_service = LLMService()
|
||||
@@ -58,8 +58,8 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead><tr><th>名称</th><th>API地址</th><th>默认模型</th><th>思考支持</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody id="providers-list"><tr><td colspan="6" class="text-center">加载中...</td></tr></tbody>
|
||||
<thead><tr><th>名称</th><th>API地址</th><th>默认模型</th><th>思考</th><th>视觉</th><th>FC</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody id="providers-list"><tr><td colspan="8" class="text-center">加载中...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,6 +162,10 @@
|
||||
<div class="mt-3 form-check"><input type="checkbox" class="form-check-input" id="provider-active" checked><label class="form-check-label">启用</label></div>
|
||||
<hr><h6>思考功能</h6>
|
||||
<div class="thinking-config"><div class="row"><div class="col-md-6 form-check"><input type="checkbox" class="form-check-input" id="provider-supports-thinking"><label class="form-check-label">支持原生思考</label></div><div class="col-md-6"><label class="form-label">思考模型名</label><input type="text" class="form-control" id="provider-thinking-model"></div></div></div>
|
||||
<hr><h6>视觉能力</h6>
|
||||
<div class="thinking-config"><div class="row"><div class="col-md-6 form-check"><input type="checkbox" class="form-check-input" id="provider-supports-vision"><label class="form-check-label">支持图片理解</label></div><div class="col-md-6"><label class="form-label">视觉模型名</label><input type="text" class="form-control" id="provider-vision-model" placeholder="留空则使用默认模型"></div></div><small class="text-muted mt-2 d-block">启用后可上传图片让AI识别分析内容</small></div>
|
||||
<hr><h6>Function Calling</h6>
|
||||
<div class="thinking-config"><div class="form-check"><input type="checkbox" class="form-check-input" id="provider-supports-function-calling"><label class="form-check-label">支持函数调用</label></div><small class="text-muted mt-2 d-block">启用后LLM可自主决定何时调用工具(更智能)</small></div>
|
||||
<div class="mt-3"><button type="button" class="btn btn-outline-primary" onclick="fetchProviderModels()"><i class="ri-refresh-line"></i> 获取模型</button><button type="button" class="btn btn-outline-secondary" onclick="testProviderConnection()"><i class="ri-link"></i> 测试连接</button></div>
|
||||
<div class="mt-2" id="provider-models-preview"></div><div class="mt-2" id="provider-test-result"></div>
|
||||
</form></div>
|
||||
@@ -181,7 +185,10 @@
|
||||
<hr><h6>思考功能</h6>
|
||||
<div class="thinking-config"><div class="form-check"><input type="checkbox" class="form-check-input" id="agent-enable-thinking" checked><label class="form-check-label">启用思考</label></div><div class="mt-2"><label class="form-label">思考提示词</label><textarea class="form-control" id="agent-thinking-prompt" rows="2"></textarea></div><div class="row mt-2"><div class="col-md-6"><label class="form-label">前缀</label><input type="text" class="form-control" id="agent-thinking-prefix"></div><div class="col-md-6"><label class="form-label">后缀</label><input type="text" class="form-control" id="agent-thinking-suffix"></div></div></div>
|
||||
<hr><h6>工具配置</h6>
|
||||
<div class="thinking-config"><div class="form-check"><input type="checkbox" class="form-check-input" id="agent-tool-search"><label class="form-check-label">启用搜索工具</label></div><small class="text-muted">启用后 Agent 可以使用搜索功能获取实时信息</small></div>
|
||||
<div class="thinking-config">
|
||||
<div id="agent-tools-list">加载中...</div>
|
||||
<small class="text-muted">从系统工具列表中选择Agent可使用的工具</small>
|
||||
</div>
|
||||
</form></div>
|
||||
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button><button type="button" class="btn btn-primary" onclick="saveAgent()">保存</button></div>
|
||||
</div></div></div>
|
||||
@@ -250,7 +257,7 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// 全局数据
|
||||
let providersData = [], agentsData = [], channelsData = [];
|
||||
let providersData = [], agentsData = [], channelsData = [], toolsData = [];
|
||||
|
||||
// 页面切换函数
|
||||
function loadPageData(page) {
|
||||
@@ -260,6 +267,57 @@
|
||||
if (page === 'tools') loadTools();
|
||||
if (page === 'stats') loadStats();
|
||||
}
|
||||
|
||||
// 加载工具列表(用于Agent配置)
|
||||
async function loadToolsList() {
|
||||
try {
|
||||
const res = await fetch('/api/v2/tools');
|
||||
const data = await res.json();
|
||||
toolsData = data.tools || [];
|
||||
renderAgentToolsList();
|
||||
} catch (e) { console.error('加载工具列表失败:', e); }
|
||||
}
|
||||
|
||||
// 渲染Agent工具选择列表
|
||||
function renderAgentToolsList(selectedTools = []) {
|
||||
const container = document.getElementById('agent-tools-list');
|
||||
if (!container) return;
|
||||
|
||||
if (toolsData.length === 0) {
|
||||
container.innerHTML = '<div class="text-muted">暂无可用工具,请先在「工具管理」中添加</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = toolsData.filter(t => t.is_active).map(t => {
|
||||
const toolType = t.tool_type || 'unknown';
|
||||
const isSelected = selectedTools.includes(toolType);
|
||||
const icon = getToolIcon(toolType);
|
||||
return `<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input agent-tool-checkbox" id="agent-tool-${t.id}" value="${toolType}" ${isSelected ? 'checked' : ''}>
|
||||
<label class="form-check-label" for="agent-tool-${t.id}">
|
||||
<i class="${icon}"></i> ${t.name} <small class="text-muted">(${toolType})</small>
|
||||
</label>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 工具图标
|
||||
function getToolIcon(toolType) {
|
||||
const icons = {
|
||||
'search': 'ri-search-line',
|
||||
'calculator': 'ri-calculator-line',
|
||||
'code': 'ri-code-line',
|
||||
'image': 'ri-image-line',
|
||||
'web': 'ri-global-line'
|
||||
};
|
||||
return icons[toolType] || 'ri-tools-line';
|
||||
}
|
||||
|
||||
// 获取Agent选中的工具列表
|
||||
function getSelectedAgentTools() {
|
||||
const checkboxes = document.querySelectorAll('.agent-tool-checkbox:checked');
|
||||
return Array.from(checkboxes).map(cb => cb.value);
|
||||
}
|
||||
|
||||
// ===== 大模型池 =====
|
||||
async function loadProviders() {
|
||||
@@ -274,6 +332,8 @@
|
||||
tbody.innerHTML = providersData.map(p => `<tr>
|
||||
<td><strong>${p.name}</strong></td><td><small>${p.api_base||'-'}</small></td><td>${p.default_model||'auto'}</td>
|
||||
<td>${p.supports_thinking?'<span class="badge bg-success">支持</span>':'<span class="badge bg-secondary">不支持</span>'}</td>
|
||||
<td>${p.supports_vision?'<span class="badge bg-info">支持</span>':'<span class="badge bg-secondary">不支持</span>'}</td>
|
||||
<td>${p.supports_function_calling?'<span class="badge bg-primary">支持</span>':'<span class="badge bg-secondary">不支持</span>'}</td>
|
||||
<td>${p.is_active?'<span class="badge bg-success">启用</span>':'<span class="badge bg-secondary">禁用</span>'}</td>
|
||||
<td><button class="btn btn-sm btn-outline-primary" onclick="editProvider(${p.id})"><i class="ri-edit-line"></i></button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteProvider(${p.id},'${p.name}')"><i class="ri-delete-bin-line"></i></button></td>
|
||||
@@ -290,6 +350,9 @@
|
||||
document.getElementById('provider-form').reset();
|
||||
document.getElementById('provider-id').value = '';
|
||||
document.getElementById('provider-active').checked = true;
|
||||
document.getElementById('provider-supports-thinking').checked = false;
|
||||
document.getElementById('provider-supports-vision').checked = false;
|
||||
document.getElementById('provider-supports-function-calling').checked = false;
|
||||
document.getElementById('provider-models-preview').innerHTML = '';
|
||||
document.getElementById('provider-test-result').innerHTML = '';
|
||||
new bootstrap.Modal(document.getElementById('providerModal')).show();
|
||||
@@ -310,6 +373,9 @@
|
||||
document.getElementById('provider-active').checked = p.is_active;
|
||||
document.getElementById('provider-supports-thinking').checked = p.supports_thinking;
|
||||
document.getElementById('provider-thinking-model').value = p.thinking_model || '';
|
||||
document.getElementById('provider-supports-vision').checked = p.supports_vision;
|
||||
document.getElementById('provider-vision-model').value = p.vision_model || '';
|
||||
document.getElementById('provider-supports-function-calling').checked = p.supports_function_calling;
|
||||
new bootstrap.Modal(document.getElementById('providerModal')).show();
|
||||
}
|
||||
|
||||
@@ -326,7 +392,10 @@
|
||||
description: document.getElementById('provider-description').value,
|
||||
is_active: document.getElementById('provider-active').checked,
|
||||
supports_thinking: document.getElementById('provider-supports-thinking').checked,
|
||||
thinking_model: document.getElementById('provider-thinking-model').value
|
||||
thinking_model: document.getElementById('provider-thinking-model').value,
|
||||
supports_vision: document.getElementById('provider-supports-vision').checked,
|
||||
vision_model: document.getElementById('provider-vision-model').value,
|
||||
supports_function_calling: document.getElementById('provider-supports-function-calling').checked
|
||||
};
|
||||
const res = await fetch(id ? `/api/v2/providers/${id}` : '/api/v2/providers', { method: id ? 'PUT' : 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(data) });
|
||||
const result = await res.json();
|
||||
@@ -394,6 +463,7 @@
|
||||
document.getElementById('agent-enable-thinking').checked = true;
|
||||
document.getElementById('agent-system-prompt').value = '你是一个有用的AI助手。';
|
||||
updateProviderSelect();
|
||||
loadToolsList(); // 加载工具列表
|
||||
new bootstrap.Modal(document.getElementById('agentModal')).show();
|
||||
}
|
||||
|
||||
@@ -416,17 +486,16 @@
|
||||
document.getElementById('agent-thinking-prompt').value = a.thinking_prompt || '';
|
||||
document.getElementById('agent-thinking-prefix').value = a.thinking_prefix || '';
|
||||
document.getElementById('agent-thinking-suffix').value = a.thinking_suffix || '';
|
||||
// 工具配置
|
||||
const tools = a.tools || [];
|
||||
document.getElementById('agent-tool-search').checked = tools.includes('search');
|
||||
// 工具配置 - 从工具列表渲染并勾选Agent已有的工具
|
||||
loadToolsList();
|
||||
setTimeout(() => renderAgentToolsList(a.tools || []), 100); // 等工具列表加载完成
|
||||
new bootstrap.Modal(document.getElementById('agentModal')).show();
|
||||
}
|
||||
|
||||
async function saveAgent() {
|
||||
const id = document.getElementById('agent-id').value;
|
||||
// 处理工具列表
|
||||
const tools = [];
|
||||
if (document.getElementById('agent-tool-search').checked) tools.push('search');
|
||||
// 获取选中的工具列表
|
||||
const tools = getSelectedAgentTools();
|
||||
|
||||
const data = {
|
||||
name: document.getElementById('agent-name').value,
|
||||
@@ -443,7 +512,7 @@
|
||||
thinking_prompt: document.getElementById('agent-thinking-prompt').value,
|
||||
thinking_prefix: document.getElementById('agent-thinking-prefix').value,
|
||||
thinking_suffix: document.getElementById('agent-thinking-suffix').value,
|
||||
tools: tools // 工具列表
|
||||
tools: tools // 工具列表(从系统工具中选择)
|
||||
};
|
||||
const res = await fetch(id ? `/api/v2/agents/${id}` : '/api/v2/agents', { method: id ? 'PUT' : 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) });
|
||||
const result = await res.json();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
BIN
uploads/images/1776134549_0c993820.jpeg
Normal file
BIN
uploads/images/1776134549_0c993820.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 225 KiB |
BIN
uploads/images/1776134674_10f77dae.png
Normal file
BIN
uploads/images/1776134674_10f77dae.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
BIN
uploads/images/1776137541_298f5318.png
Normal file
BIN
uploads/images/1776137541_298f5318.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
Reference in New Issue
Block a user