Compare commits

..

19 Commits

Author SHA1 Message Date
26a76b030d feat: UI重构 - 历史对话列表页 + 新建按钮顶部右侧 + 删除功能 2026-04-26 10:15:51 +08:00
daccc625c3 revert: 撤回错误的修改,恢复原版本
用户指出SiliconFlow平台确实支持标准tool消息类型,之前的修改是错误的

版本: v3.0.7
2026-04-15 10:01:59 +08:00
a2a7fd46c3 chore: 版本号更新到v3.0.6 2026-04-15 09:52:31 +08:00
baf5913bfb fix: SiliconFlow平台Function Calling第二轮调用兼容
问题:SiliconFlow平台不支持标准tool消息类型,第二轮调用返回参数无效

修复:将tool消息转换为user消息格式
- 收集所有tool消息的内容
- 合并为一个用户消息发送给模型
- 添加明确的提示让模型直接根据结果回答

版本: v3.0.6
2026-04-15 09:52:19 +08:00
ae08e01e55 fix: Kimi模型伪工具调用格式过滤
修复Kimi-K2.5模型在第二轮调用时输出伪工具调用格式的问题:
- 添加系统提示告诉模型直接根据工具结果回答
- 过滤 <|tool_calls_section_begin|> 等内部格式标记
- 清理多余空行

版本: v3.0.1
2026-04-15 09:45:08 +08:00
9048d94e33 fix: 添加详细日志诊断工具调用消息格式 2026-04-15 02:25:05 +08:00
291de733a4 fix: chat_with_tool_results不重复添加tool结果,修正消息格式 2026-04-15 01:03:10 +08:00
10f67a807a fix: get_agent_config添加supports_vision和supports_function_calling字段 2026-04-14 19:20:17 +08:00
d9ac2c78f6 feat: 对话区左侧显示Agent信息 2026-04-14 19:14:31 +08:00
4ac67b5816 feat: v3.0 Function Calling模式 - LLM自主调用工具 2026-04-14 18:39:12 +08:00
527885f3d6 fix: 工具按钮放附件右边、输入框左边 2026-04-14 17:19:52 +08:00
c21270195a feat: 工具按钮放输入框右边,面板向上弹出 2026-04-14 17:15:56 +08:00
db7d0fd586 feat: 工具选择折叠面板,不占用快捷语句区域 2026-04-14 17:11:07 +08:00
f9e3708ce0 feat: Agent工具配置从系统工具列表动态选择 2026-04-14 17:02:49 +08:00
64f0aa0c0b feat: Agent工具支持检查,不支持的工具提醒用户 2026-04-14 16:43:30 +08:00
8ddc50a0a9 feat: 用户消息编辑功能,编辑后重新请求AI回复 2026-04-14 11:44:07 +08:00
20ef1abe7d feat: Agent不支持视觉能力时上传图片提示用户 2026-04-14 11:30:38 +08:00
54290019c3 feat: 大模型池添加视觉能力开关配置 2026-04-14 10:56:50 +08:00
d3e80c0afb feat: 图片保存到服务器,历史记录可显示图片 2026-04-14 10:38:59 +08:00
9 changed files with 1170 additions and 419 deletions

View File

@@ -1,9 +1,9 @@
"""
AI对话系统 v2.0.0 - 主应用
支持大模型池、Agent管理、渠道独立绑定、思考功能开关
AI对话系统 v3.0.0 - 主应用
支持大模型池、Agent管理、渠道独立绑定、思考功能开关、Function CallingLLM自主调用工具
"""
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. 构建工具 schemaFunction 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. 调用LLMFunction 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()
}
})

View File

@@ -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)

View File

@@ -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,

View File

@@ -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()

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB