Compare commits

...

8 Commits

6 changed files with 698 additions and 152 deletions

View File

@@ -1,6 +1,6 @@
"""
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, JSONResponse
@@ -122,6 +122,7 @@ async def get_providers(db: Session = Depends(get_db)):
"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,
@@ -832,7 +833,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)
@@ -846,48 +847,41 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str):
if not message.strip() and not files:
continue
# 处理文件内容,添加到消息
image_contents = [] # 图片内容(用于视觉模型)
text_contents = [] # 文本文件内容
image_paths = [] # 图片服务器路径(用于历史记录显示)
# 处理文件内容
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', '')
})
# 记录服务器路径(用于历史记录)
if f.get('serverPath'):
image_paths.append({
'name': f['name'],
'type': f['type'],
'url': f['serverPath'] # 服务器文件路径
'url': f['serverPath']
})
# 不添加文件名文本,图片信息保存在 extra_data 中
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_paths:
# 图片保存服务器路径URL历史记录可以显示
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/')]
@@ -896,8 +890,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:
@@ -908,12 +903,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",
@@ -921,118 +916,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",
@@ -1041,17 +963,185 @@ 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'],
tool_results=tool_results,
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',
@@ -1059,7 +1149,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回复
@@ -1074,6 +1165,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

@@ -36,6 +36,9 @@ class LLMProvider(Base):
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

@@ -382,5 +382,200 @@ 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,
tool_results: List[Dict],
enable_thinking: bool = True
) -> Tuple[str, Optional[str]]:
"""
第二阶段调用将工具执行结果返回给LLM
Args:
messages: 对话历史(包含工具调用和结果)
provider_config: LLM Provider配置
agent_config: Agent配置
tool_results: 工具执行结果 [{"tool_call_id": "xxx", "content": "..."}]
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)
# 将工具结果添加到消息历史
final_messages = messages.copy()
for result in tool_results:
final_messages.append({
"role": "tool",
"tool_call_id": result['tool_call_id'],
"content": result['content']
})
# 调用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}")
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}")
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><th>操作</th></tr></thead>
<tbody id="providers-list"><tr><td colspan="7" 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>
@@ -164,6 +164,8 @@
<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>
@@ -183,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>
@@ -252,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) {
@@ -262,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() {
@@ -277,6 +333,7 @@
<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>
@@ -295,6 +352,7 @@
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();
@@ -317,6 +375,7 @@
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();
}
@@ -335,7 +394,8 @@
supports_thinking: document.getElementById('provider-supports-thinking').checked,
thinking_model: document.getElementById('provider-thinking-model').value,
supports_vision: document.getElementById('provider-supports-vision').checked,
vision_model: document.getElementById('provider-vision-model').value
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();
@@ -403,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();
}
@@ -425,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,
@@ -452,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();

View File

@@ -60,6 +60,10 @@
.action-btn:hover { background: #e8e8e8; border-color: #ccc; }
.action-btn.copied { color: #10a37f; border-color: #10a37f; background: #e8f5e9; }
.action-btn.regenerate:hover { color: #667eea; border-color: #667eea; }
.action-btn.edit:hover { color: #10a37f; border-color: #10a37f; }
/* 用户消息编辑样式 */
.edit-textarea:focus { outline: none; border-color: #10a37f; box-shadow: 0 0 0 2px rgba(16,163,127,0.1); }
/* 版本切换控件 - 简洁版 */
.version-switcher { display: none; align-items: center; gap: 4px; margin-left: 4px; }
@@ -126,6 +130,10 @@
/* 快捷语句 - 横向扁平 */
.quick-phrases-bar { display: flex; align-items: center; gap: 8px; margin-top: 12px; position: relative; }
/* 工具调用记录显示 */
.tool-call-record { margin-top: 8px; padding: 8px 12px; background: #e8f5e9; border-radius: 8px; font-size: 12px; color: #10a37f; }
.tool-call-record i { margin-right: 4px; }
.add-phrase-btn { padding: 6px 10px; background: #f0f0f0; border: 1px solid #ddd; border-radius: 6px; cursor: pointer; font-size: 12px; color: #666; white-space: nowrap; flex-shrink: 0; }
.add-phrase-btn:hover { background: #e8e8e8; }
.phrase-list-wrapper { flex: 1; overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; }
@@ -174,7 +182,7 @@
<div class="main-content">
<div class="chat-header">
<h1>AI 对话 v2.0</h1>
<h1>AI 对话 v3.0</h1>
<div class="header-controls">
<div class="agent-selector">
<select id="agentSelect" onchange="switchAgent()"><option value="">加载中...</option></select>
@@ -195,12 +203,10 @@
<textarea id="messageInput" placeholder="输入消息..." rows="1"></textarea>
<button class="send-btn" id="sendBtn" onclick="sendMessage()"><i class="ri-send-plane-fill"></i></button>
</div>
<!-- 工具警告提示 -->
<div id="tool-warning-tip" style="display:none;margin-top:4px;padding:6px 10px;background:#fff3cd;border:1px solid #ffc107;border-radius:6px;font-size:12px;color:#856404;"></div>
<div class="file-preview-area" id="filePreviewArea"></div>
<div class="quick-phrases-bar">
<div class="tool-toggle">
<input type="checkbox" id="enableSearch" checked>
<label for="enableSearch"><i class="ri-search-line"></i> 搜索</label>
</div>
<button class="add-phrase-btn" onclick="showAddPhraseModal()"><i class="ri-add-line"></i> 添加</button>
<div class="phrase-list-wrapper" id="phraseListWrapper" onwheel="scrollPhrases(event)">
<div class="phrase-list" id="quickPhrasesList"></div>
@@ -260,6 +266,7 @@
let currentConversationId = null;
let currentAgentId = null;
let agents = [];
let providers = []; // 大模型池数据
let quickPhrases = [];
let lastSentMessage = null; // 记录最后发送的消息
let lastSentFiles = null; // 记录发送的文件
@@ -272,6 +279,7 @@
let messageVersions = {}; // 存储每个assistant消息的多个版本 { messageId: [{content, thinking}] }
document.addEventListener('DOMContentLoaded', () => {
loadProviders(); // 加载大模型池
loadAgents();
loadQuickPhrases();
connectWebSocket();
@@ -279,6 +287,33 @@
setupTextarea();
});
// 加载大模型池
async function loadProviders() {
try {
const res = await fetch('/api/v2/providers');
const data = await res.json();
providers = data.providers || [];
// 加载后检查工具支持如果agents已加载
if (agents.length > 0) showToolWarning();
} catch (e) { console.error('加载Provider失败:', e); }
}
// 检查当前Agent是否支持视觉
function checkAgentVisionSupport() {
const agent = agents.find(a => a.id === currentAgentId);
if (!agent) return false;
const provider = providers.find(p => p.id === agent.llm_provider_id);
return provider?.supports_vision || false;
}
// 检查当前Agent是否支持某个工具
function checkAgentToolSupport(toolType) {
const agent = agents.find(a => a.id === currentAgentId);
if (!agent) return false;
const agentTools = agent.tools || [];
return agentTools.includes(toolType);
}
// 加载Agent
async function loadAgents() {
try {
@@ -421,6 +456,9 @@
html += `<span class="version-label" id="${messageId}_version_label">1/1</span>`;
html += `<button class="version-arrow" onclick="switchVersion('${messageId}', 1)" data-dir="next"><i class="ri-arrow-right-s-line"></i></button>`;
html += `</span>`;
} else {
// 用户消息添加编辑按钮
html += `<button class="action-btn edit" onclick="editUserMessage(this)" title="编辑并重新发送"><i class="ri-edit-line"></i> 编辑</button>`;
}
html += `</div>`;
@@ -723,6 +761,136 @@
}
}
// 编辑用户消息并重新发送
let editingUserMessage = null; // 正在编辑的用户消息元素
function editUserMessage(btn) {
const messageDiv = btn.closest('.message.user');
if (!messageDiv) return;
// 获取消息内容
const contentDiv = messageDiv.querySelector('.user-message-text');
const originalContent = contentDiv.textContent;
const actionsDiv = messageDiv.querySelector('.message-actions');
// 保存原始状态用于取消
editingUserMessage = {
element: messageDiv,
originalContent: originalContent,
contentDiv: contentDiv,
actionsDiv: actionsDiv
};
// 将消息文本变成可编辑的textarea
contentDiv.innerHTML = `<textarea class="edit-textarea" style="width:100%;min-height:60px;padding:8px;border:1px solid #ddd;border-radius:8px;font-size:15px;resize:vertical;">${escapeHtml(originalContent)}</textarea>`;
// 隐藏原有操作按钮,显示编辑操作按钮
actionsDiv.innerHTML = `
<button class="action-btn" onclick="confirmEditMessage()" style="background:#10a37f;color:white;border-color:#10a37f;"><i class="ri-check-line"></i> 确认</button>
<button class="action-btn" onclick="cancelEditMessage()"><i class="ri-close-line"></i> 取消</button>
`;
// 聚焦到textarea
const textarea = contentDiv.querySelector('.edit-textarea');
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
// 添加Ctrl+Enter快捷键确认
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
confirmEditMessage();
}
if (e.key === 'Escape') {
e.preventDefault();
cancelEditMessage();
}
});
}
function cancelEditMessage() {
if (!editingUserMessage) return;
// 恢复原始内容
editingUserMessage.contentDiv.innerHTML = escapeHtml(editingUserMessage.originalContent);
// 恢复原始操作按钮
editingUserMessage.actionsDiv.innerHTML = `
<button class="action-btn" onclick="copyMessage(this)"><i class="ri-file-copy-line"></i> 复制</button>
<button class="action-btn edit" onclick="editUserMessage(this)" title="编辑并重新发送"><i class="ri-edit-line"></i> 编辑</button>
`;
editingUserMessage = null;
}
function confirmEditMessage() {
if (!editingUserMessage) return;
const textarea = editingUserMessage.contentDiv.querySelector('.edit-textarea');
const newContent = textarea.value.trim();
if (!newContent) {
alert('消息内容不能为空');
return;
}
// 如果内容没有变化,直接取消编辑
if (newContent === editingUserMessage.originalContent) {
cancelEditMessage();
return;
}
// 更新消息内容
editingUserMessage.contentDiv.innerHTML = escapeHtml(newContent);
// 恢复操作按钮
editingUserMessage.actionsDiv.innerHTML = `
<button class="action-btn" onclick="copyMessage(this)"><i class="ri-file-copy-line"></i> 复制</button>
<button class="action-btn edit" onclick="editUserMessage(this)" title="编辑并重新发送"><i class="ri-edit-line"></i> 编辑</button>
`;
// 更新最后用户消息记录
lastUserMessage = newContent;
// 找到下一条assistant消息设置重新生成标志
const container = document.getElementById('messagesContainer');
const allMessages = container.querySelectorAll('.message');
let nextAssistantId = null;
for (let i = 0; i < allMessages.length; i++) {
if (allMessages[i] === editingUserMessage.element && i + 1 < allMessages.length) {
const nextMsg = allMessages[i + 1];
if (nextMsg.classList.contains('assistant')) {
nextAssistantId = nextMsg.id || nextMsg.dataset.messageId;
}
break;
}
}
if (nextAssistantId) {
isRegenerating = true;
regeneratingMessageId = nextAssistantId;
showLoadingIndicator(nextAssistantId);
} else {
// 没有assistant消息需要创建新的
isRegenerating = false;
regeneratingMessageId = null;
}
editingUserMessage = null;
// 重新发送编辑后的消息
document.getElementById('sendBtn').disabled = true;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
action: 'chat',
message: newContent,
conversation_id: currentConversationId,
agent_id: currentAgentId
}));
}
}
function escapeHtml(text) {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
@@ -868,6 +1036,16 @@
// 如果没有消息且没有文件,不发送
if (!msg && pendingFiles.length === 0) return;
// 检查是否有图片上传
const hasImages = pendingFiles.some(f => f.type && f.type.startsWith('image/'));
if (hasImages && !checkAgentVisionSupport()) {
const agent = agents.find(a => a.id === currentAgentId);
const agentName = agent?.display_name || agent?.name || '当前Agent';
alert(`⚠️ ${agentName} 不支持图片识别功能\n\n请选择支持视觉能力的Agent如 vlm-agent或者移除图片后再发送。`);
document.getElementById('sendBtn').disabled = false;
return;
}
document.getElementById('sendBtn').disabled = true;
input.value = '';
input.style.height = 'auto';
@@ -880,10 +1058,7 @@
pendingFiles = [];
document.getElementById('filePreviewArea').innerHTML = '';
// 获取工具禁用状态
const enableSearch = document.getElementById('enableSearch').checked;
const disabledTools = [];
if (!enableSearch) disabledTools.push('search');
// v3.0: Function Calling模式不再需要 disabled_tools
// 发送消息(包含文件)
if (ws?.readyState === WebSocket.OPEN) {
@@ -892,7 +1067,6 @@
message: msg,
conversation_id: currentConversationId,
agent_id: currentAgentId,
disabled_tools: disabledTools,
files: lastSentFiles || [] // 发送的文件列表
}));
}
@@ -1014,6 +1188,21 @@
}
previewArea.appendChild(previewItem);
// 如果上传的是图片且当前Agent不支持视觉显示警告提示
if (file.type.startsWith('image/') && !checkAgentVisionSupport()) {
const agent = agents.find(a => a.id === currentAgentId);
const agentName = agent?.display_name || agent?.name || '当前Agent';
const warningDiv = document.createElement('div');
warningDiv.className = 'vision-warning';
warningDiv.id = 'vision-warning-tip';
warningDiv.style.cssText = 'margin-top:8px;padding:8px 12px;background:#fff3cd;border:1px solid #ffc107;border-radius:6px;font-size:13px;color:#856404;';
warningDiv.innerHTML = `<i class="ri-alert-line"></i> <strong>${agentName}</strong> 不支持图片识别请选择支持视觉的Agent或移除图片`;
// 避免重复添加
if (!document.getElementById('vision-warning-tip')) {
previewArea.appendChild(warningDiv);
}
}
};
// 根据文件类型选择读取方式
@@ -1035,6 +1224,13 @@
pendingFiles = pendingFiles.filter(f => f.id !== fileId);
const preview = document.getElementById(fileId + '-preview');
if (preview) preview.remove();
// 如果没有图片了,移除视觉警告提示
const hasImages = pendingFiles.some(f => f.type && f.type.startsWith('image/'));
if (!hasImages) {
const warning = document.getElementById('vision-warning-tip');
if (warning) warning.remove();
}
}
// 发送消息

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB