merge: reconcile main with origin/main
This commit is contained in:
commit
ffb32ed242
@ -17,6 +17,7 @@
|
||||
<img src="https://trendshift.io/api/badge/repositories/15845" alt="Yuxi-Know | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
|
||||
|
||||
[**文档中心**](https://xerrors.github.io/Yuxi-Know/) |
|
||||
[**视频演示**](https://www.bilibili.com/video/BV1DF14BTETq/)
|
||||
|
||||
@ -107,9 +108,7 @@
|
||||
- 更多智能体开发套件 中间件、子智能体,更简洁,更易上手。
|
||||
</details>
|
||||
|
||||
<img width="1846" height="434" alt="image" src="https://github.com/user-attachments/assets/ccbd2cbf-574a-4cd3-baac-167b3c619b6e" />
|
||||
|
||||
|
||||
<img width="4224" height="1006" alt="image" src="https://github.com/user-attachments/assets/66a85b70-5a40-4c5e-aeaa-18b3c85aa76f" />
|
||||
|
||||
|
||||
## 快速开始
|
||||
|
||||
@ -741,6 +741,11 @@ async def update_thread(
|
||||
)
|
||||
|
||||
|
||||
|
||||
# ================================
|
||||
# > === 附件管理分组 ===
|
||||
# ================================
|
||||
|
||||
@chat.post("/thread/{thread_id}/attachments", response_model=AttachmentResponse)
|
||||
async def upload_thread_attachment(
|
||||
thread_id: str,
|
||||
|
||||
@ -52,9 +52,9 @@ class TableListModel(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
@tool(name_or_callable="查询表名及说明", args_schema=TableListModel)
|
||||
@tool(name_or_callable="mysql_list_tables", args_schema=TableListModel)
|
||||
def mysql_list_tables() -> str:
|
||||
"""获取数据库中的所有表名
|
||||
"""【查询表名及说明】获取数据库中的所有表名
|
||||
|
||||
这个工具用来列出当前数据库中所有的表名,帮助你了解数据库的结构。
|
||||
"""
|
||||
@ -107,9 +107,9 @@ class TableDescribeModel(BaseModel):
|
||||
table_name: str = Field(description="要查询的表名", example="users")
|
||||
|
||||
|
||||
@tool(name_or_callable="描述表", args_schema=TableDescribeModel)
|
||||
@tool(name_or_callable="mysql_describe_table", args_schema=TableDescribeModel)
|
||||
def mysql_describe_table(table_name: Annotated[str, "要查询结构的表名"]) -> str:
|
||||
"""获取指定表的详细结构信息
|
||||
"""【描述表】获取指定表的详细结构信息
|
||||
|
||||
这个工具用来查看表的字段信息、数据类型、是否允许NULL、默认值、键类型等。
|
||||
帮助你了解表的结构,以便编写正确的SQL查询。
|
||||
@ -203,12 +203,12 @@ class QueryModel(BaseModel):
|
||||
timeout: int | None = Field(default=60, description="查询超时时间(秒),默认60秒,最大600秒", ge=1, le=600)
|
||||
|
||||
|
||||
@tool(name_or_callable="执行 SQL 查询", args_schema=QueryModel)
|
||||
@tool(name_or_callable="mysql_query", args_schema=QueryModel)
|
||||
def mysql_query(
|
||||
sql: Annotated[str, "要执行的SQL查询语句(只能是SELECT语句)"],
|
||||
timeout: Annotated[int | None, "查询超时时间(秒),默认60秒,最大600秒"] = 60,
|
||||
) -> str:
|
||||
"""执行只读的SQL查询语句
|
||||
"""【执行 SQL 查询】执行只读的SQL查询语句
|
||||
|
||||
这个工具用来执行SQL查询并返回结果。支持复杂的SELECT查询,包括JOIN、GROUP BY等。
|
||||
注意:只能执行查询操作,不能修改数据。
|
||||
|
||||
@ -1,16 +1,25 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Annotated
|
||||
|
||||
from deepagents.backends import StateBackend
|
||||
from deepagents.middleware.filesystem import FilesystemMiddleware
|
||||
from langchain.agents import create_agent
|
||||
|
||||
from src.agents.common import BaseAgent, BaseContext, load_chat_model
|
||||
from src.agents.common.middlewares import (
|
||||
RuntimeConfigMiddleware,
|
||||
save_attachments_to_fs,
|
||||
)
|
||||
from src.agents.common.toolkits.mysql import get_mysql_tools
|
||||
from src.services.mcp_service import get_mcp_server_names, get_tools_from_all_servers
|
||||
from src.utils import logger
|
||||
|
||||
|
||||
def _create_fs_backend(rt):
|
||||
"""创建文件存储后端"""
|
||||
return StateBackend(rt)
|
||||
|
||||
|
||||
PROMPT = """你的任务是根据用户的指令,使用数据库工具和图表绘制工具,构建 SQL 查询报告。
|
||||
你需要根据用户的指令,生成相应的 SQL 查询,并将查询结果以报表的形式返回给用户。
|
||||
在生成报表时,你可以调用工具生成图表,以更直观地展示数据。
|
||||
@ -52,6 +61,10 @@ class SqlReporterAgent(BaseAgent):
|
||||
"MySQL 工具默认启用,无法选择,mcp 默认启用 Charts MCPs。"
|
||||
)
|
||||
context_schema = ReporterContext
|
||||
capabilities = [
|
||||
"file_upload",
|
||||
"files",
|
||||
]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
@ -66,7 +79,9 @@ class SqlReporterAgent(BaseAgent):
|
||||
system_prompt=context.system_prompt,
|
||||
tools=get_mysql_tools(), # MySQL 工具默认启用,这里添加的 tools,不会在工具选择框中出现
|
||||
middleware=[
|
||||
FilesystemMiddleware(backend=_create_fs_backend), # 文件系统后端
|
||||
RuntimeConfigMiddleware(extra_tools=all_mcp_tools),
|
||||
save_attachments_to_fs, # 附件保存到文件系统
|
||||
],
|
||||
checkpointer=await self._get_checkpointer(),
|
||||
)
|
||||
|
||||
@ -49,9 +49,9 @@ DEFAULT_CHAT_MODEL_PROVIDERS: dict[str, ChatModelProvider] = {
|
||||
name="OpenAI",
|
||||
url="https://platform.openai.com/docs/models",
|
||||
base_url="https://api.openai.com/v1",
|
||||
default="gpt-4o-mini",
|
||||
default="gpt-5-mini",
|
||||
env="OPENAI_API_KEY",
|
||||
models=["gpt-4", "gpt-4o", "gpt-4o-mini"],
|
||||
models=["gpt-5.2", "gpt-5-mini", "gpt-5.2-pro"],
|
||||
),
|
||||
"deepseek": ChatModelProvider(
|
||||
name="DeepSeek",
|
||||
@ -65,22 +65,21 @@ DEFAULT_CHAT_MODEL_PROVIDERS: dict[str, ChatModelProvider] = {
|
||||
name="智谱AI (Zhipu)",
|
||||
url="https://open.bigmodel.cn/dev/api",
|
||||
base_url="https://open.bigmodel.cn/api/paas/v4/",
|
||||
default="glm-4.5-flash",
|
||||
default="glm-4.7-flash",
|
||||
env="ZHIPUAI_API_KEY",
|
||||
models=["glm-4.6", "glm-4.5-air", "glm-4.5-flash"],
|
||||
models=["glm-5", "glm-4.5-air", "glm-4.7-flash"],
|
||||
),
|
||||
"siliconflow": ChatModelProvider(
|
||||
name="SiliconFlow",
|
||||
url="https://cloud.siliconflow.cn/models",
|
||||
base_url="https://api.siliconflow.cn/v1",
|
||||
default="deepseek-ai/DeepSeek-V3.2",
|
||||
default="Pro/deepseek-ai/DeepSeek-V3.2",
|
||||
env="SILICONFLOW_API_KEY",
|
||||
models=[
|
||||
"deepseek-ai/DeepSeek-V3.2",
|
||||
"Qwen/Qwen3-235B-A22B-Thinking-2507",
|
||||
"Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
"moonshotai/Kimi-K2-Instruct-0905",
|
||||
"zai-org/GLM-4.6",
|
||||
"Pro/deepseek-ai/DeepSeek-V3.2",
|
||||
"Pro/MiniMaxAI/MiniMax-M2.5",
|
||||
"Pro/zai-org/GLM-5",
|
||||
"Pro/moonshotai/Kimi-K2.5",
|
||||
],
|
||||
),
|
||||
# "together": ChatModelProvider(
|
||||
@ -101,33 +100,31 @@ DEFAULT_CHAT_MODEL_PROVIDERS: dict[str, ChatModelProvider] = {
|
||||
"qwen-max-latest",
|
||||
"qwen-plus-latest",
|
||||
"qwen-turbo-latest",
|
||||
"qwen3-235b-a22b-thinking-2507",
|
||||
"qwen3-235b-a22b-instruct-2507",
|
||||
],
|
||||
),
|
||||
"ark": ChatModelProvider(
|
||||
name="豆包(Ark)",
|
||||
url="https://console.volcengine.com/ark/region:ark+cn-beijing/model",
|
||||
base_url="https://ark.cn-beijing.volces.com/api/v3",
|
||||
default="doubao-seed-1-6-250615",
|
||||
default="doubao-seed-2-0-lite-260215",
|
||||
env="ARK_API_KEY",
|
||||
models=[
|
||||
"doubao-seed-1-6-250615",
|
||||
"doubao-seed-1-6-thinking-250715",
|
||||
"doubao-seed-1-6-flash-250715",
|
||||
"doubao-seed-2-0-pro-260215",
|
||||
"doubao-seed-2-0-lite-260215",
|
||||
"doubao-seed-2-0-mini-260215",
|
||||
],
|
||||
),
|
||||
"openrouter": ChatModelProvider(
|
||||
name="OpenRouter",
|
||||
url="https://openrouter.ai/models",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
default="openai/gpt-4o",
|
||||
default="x-ai/grok-4.1-fast",
|
||||
env="OPENROUTER_API_KEY",
|
||||
models=[
|
||||
"openai/gpt-4o",
|
||||
"anthropic/claude-opus-4.6",
|
||||
"anthropic/claude-sonnet-4.5",
|
||||
"x-ai/grok-4.1-fast",
|
||||
"x-ai/grok-4",
|
||||
"google/gemini-2.5-pro",
|
||||
"anthropic/claude-sonnet-4",
|
||||
],
|
||||
),
|
||||
# "moonshot": ChatModelProvider(
|
||||
@ -148,7 +145,7 @@ DEFAULT_CHAT_MODEL_PROVIDERS: dict[str, ChatModelProvider] = {
|
||||
base_url="https://api-inference.modelscope.cn/v1/",
|
||||
default="deepseek-ai/DeepSeek-V3.2",
|
||||
env="MODELSCOPE_ACCESS_TOKEN",
|
||||
models=["Qwen/Qwen3-32B", "deepseek-ai/DeepSeek-V3.2"],
|
||||
models=["ZhipuAI/GLM-5", "ZhipuAI/GLM-4.7-Flash", "MiniMax/MiniMax-M2.5", "moonshotai/Kimi-K2.5", ""],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@ -225,7 +225,7 @@ class KnowledgeBase(ABC):
|
||||
|
||||
# Save to metadata
|
||||
self.files_meta[file_id] = metadata
|
||||
await self._save_metadata()
|
||||
await self._persist_file(file_id)
|
||||
|
||||
return metadata
|
||||
|
||||
@ -273,7 +273,7 @@ class KnowledgeBase(ABC):
|
||||
self.files_meta[file_id]["updated_at"] = utc_isoformat()
|
||||
if operator_id:
|
||||
self.files_meta[file_id]["updated_by"] = operator_id
|
||||
await self._save_metadata()
|
||||
await self._persist_file(file_id)
|
||||
|
||||
# Add to processing queue
|
||||
self._add_to_processing_queue(file_id)
|
||||
@ -310,7 +310,7 @@ class KnowledgeBase(ABC):
|
||||
self.files_meta[file_id]["updated_at"] = utc_isoformat()
|
||||
if operator_id:
|
||||
self.files_meta[file_id]["updated_by"] = operator_id
|
||||
await self._save_metadata()
|
||||
await self._persist_file(file_id)
|
||||
|
||||
return self.files_meta[file_id]
|
||||
|
||||
@ -323,7 +323,7 @@ class KnowledgeBase(ABC):
|
||||
self.files_meta[file_id]["updated_at"] = utc_isoformat()
|
||||
if operator_id:
|
||||
self.files_meta[file_id]["updated_by"] = operator_id
|
||||
await self._save_metadata()
|
||||
await self._persist_file(file_id)
|
||||
|
||||
raise
|
||||
|
||||
@ -358,7 +358,7 @@ class KnowledgeBase(ABC):
|
||||
|
||||
logger.debug(f"[update_file_params] file_id={file_id}, updated_params={current_params}")
|
||||
|
||||
await self._save_metadata()
|
||||
await self._persist_file(file_id)
|
||||
|
||||
async def _save_markdown_to_minio(self, db_id: str, file_id: str, content: str) -> str:
|
||||
"""Save markdown content to MinIO and return HTTP URL"""
|
||||
@ -453,7 +453,7 @@ class KnowledgeBase(ABC):
|
||||
"metadata": kwargs,
|
||||
"created_at": utc_isoformat(),
|
||||
}
|
||||
await self._save_metadata()
|
||||
await self._persist_kb(db_id)
|
||||
|
||||
# 创建工作目录
|
||||
working_dir = os.path.join(self.work_dir, db_id)
|
||||
@ -518,7 +518,7 @@ class KnowledgeBase(ABC):
|
||||
"path": folder_name,
|
||||
"file_type": "folder",
|
||||
}
|
||||
await self._save_metadata()
|
||||
await self._persist_file(folder_id)
|
||||
return self.files_meta[folder_id]
|
||||
|
||||
@abstractmethod
|
||||
@ -860,7 +860,7 @@ class KnowledgeBase(ABC):
|
||||
current = parent_meta.get("parent_id")
|
||||
|
||||
meta["parent_id"] = new_parent_id
|
||||
await self._save_metadata()
|
||||
await self._persist_file(file_id)
|
||||
return meta
|
||||
|
||||
@abstractmethod
|
||||
@ -1146,3 +1146,78 @@ class KnowledgeBase(ABC):
|
||||
}
|
||||
if existing is None:
|
||||
await eval_repo.create_benchmark(payload)
|
||||
|
||||
async def _persist_file(self, file_id: str) -> None:
|
||||
"""只保存单个文件到数据库,避免全量遍历"""
|
||||
from src.repositories.knowledge_file_repository import KnowledgeFileRepository
|
||||
|
||||
file_repo = KnowledgeFileRepository()
|
||||
|
||||
if file_id not in self.files_meta:
|
||||
return
|
||||
|
||||
meta = self.files_meta[file_id]
|
||||
db_id = meta.get("database_id")
|
||||
if not db_id:
|
||||
return
|
||||
|
||||
await file_repo.upsert(
|
||||
file_id=file_id,
|
||||
data={
|
||||
"db_id": db_id,
|
||||
"parent_id": meta.get("parent_id"),
|
||||
"filename": meta.get("filename") or "",
|
||||
"original_filename": meta.get("original_filename"),
|
||||
"file_type": meta.get("file_type"),
|
||||
"path": meta.get("path"),
|
||||
"minio_url": meta.get("minio_url"),
|
||||
"markdown_file": meta.get("markdown_file"),
|
||||
"status": meta.get("status"),
|
||||
"content_hash": meta.get("content_hash"),
|
||||
"file_size": meta.get("size"),
|
||||
"content_type": meta.get("content_type"),
|
||||
"processing_params": meta.get("processing_params"),
|
||||
"is_folder": meta.get("is_folder", False),
|
||||
"error_message": meta.get("error"),
|
||||
"created_by": str(meta.get("created_by")) if meta.get("created_by") else None,
|
||||
"updated_by": str(meta.get("updated_by")) if meta.get("updated_by") else None,
|
||||
},
|
||||
)
|
||||
|
||||
async def _persist_kb(self, db_id: str) -> None:
|
||||
"""只保存单个知识库到数据库,避免全量遍历"""
|
||||
from src.repositories.knowledge_base_repository import KnowledgeBaseRepository
|
||||
|
||||
kb_repo = KnowledgeBaseRepository()
|
||||
|
||||
if db_id not in self.databases_meta:
|
||||
return
|
||||
|
||||
meta = self.databases_meta[db_id]
|
||||
existing = await kb_repo.get_by_id(db_id)
|
||||
payload = {
|
||||
"db_id": db_id,
|
||||
"name": meta.get("name") or db_id,
|
||||
"description": meta.get("description"),
|
||||
"kb_type": meta.get("kb_type") or self.kb_type,
|
||||
"embed_info": meta.get("embed_info"),
|
||||
"llm_info": meta.get("llm_info"),
|
||||
"query_params": meta.get("query_params"),
|
||||
"additional_params": meta.get("metadata") or {},
|
||||
}
|
||||
|
||||
if existing is None:
|
||||
await kb_repo.create(payload)
|
||||
else:
|
||||
await kb_repo.update(
|
||||
db_id,
|
||||
{
|
||||
"name": payload["name"],
|
||||
"description": payload["description"],
|
||||
"kb_type": payload["kb_type"],
|
||||
"embed_info": payload["embed_info"],
|
||||
"llm_info": payload["llm_info"],
|
||||
"query_params": payload["query_params"],
|
||||
"additional_params": payload["additional_params"],
|
||||
},
|
||||
)
|
||||
|
||||
@ -313,7 +313,7 @@ class LightRagKB(KnowledgeBase):
|
||||
self.files_meta[file_id]["updated_at"] = utc_isoformat()
|
||||
if operator_id:
|
||||
self.files_meta[file_id]["updated_by"] = operator_id
|
||||
await self._save_metadata()
|
||||
await self._persist_file(file_id)
|
||||
|
||||
# Add to processing queue
|
||||
self._add_to_processing_queue(file_id)
|
||||
@ -358,7 +358,7 @@ class LightRagKB(KnowledgeBase):
|
||||
self.files_meta[file_id]["updated_at"] = utc_isoformat()
|
||||
if operator_id:
|
||||
self.files_meta[file_id]["updated_by"] = operator_id
|
||||
await self._save_metadata()
|
||||
await self._persist_file(file_id)
|
||||
|
||||
return self.files_meta[file_id]
|
||||
|
||||
@ -369,7 +369,7 @@ class LightRagKB(KnowledgeBase):
|
||||
self.files_meta[file_id]["updated_at"] = utc_isoformat()
|
||||
if operator_id:
|
||||
self.files_meta[file_id]["updated_by"] = operator_id
|
||||
await self._save_metadata()
|
||||
await self._persist_file(file_id)
|
||||
raise
|
||||
|
||||
finally:
|
||||
@ -416,7 +416,7 @@ class LightRagKB(KnowledgeBase):
|
||||
)
|
||||
self.files_meta[file_id]["processing_params"] = resolved_params
|
||||
self.files_meta[file_id]["status"] = "processing"
|
||||
await self._save_metadata()
|
||||
await self._persist_file(file_id)
|
||||
|
||||
# 重新解析文件为 markdown
|
||||
if content_type != "file":
|
||||
@ -447,7 +447,7 @@ class LightRagKB(KnowledgeBase):
|
||||
|
||||
# 更新元数据状态
|
||||
self.files_meta[file_id]["status"] = "done"
|
||||
await self._save_metadata()
|
||||
await self._persist_file(file_id)
|
||||
|
||||
# 从处理队列中移除
|
||||
self._remove_from_processing_queue(file_id)
|
||||
@ -463,7 +463,7 @@ class LightRagKB(KnowledgeBase):
|
||||
logger.error(f"更新{content_type} {file_path} 失败: {error_msg}, {traceback.format_exc()}")
|
||||
self.files_meta[file_id]["status"] = "failed"
|
||||
self.files_meta[file_id]["error"] = error_msg
|
||||
await self._save_metadata()
|
||||
await self._persist_file(file_id)
|
||||
|
||||
# 从处理队列中移除
|
||||
self._remove_from_processing_queue(file_id)
|
||||
@ -576,7 +576,6 @@ class LightRagKB(KnowledgeBase):
|
||||
from src.repositories.knowledge_file_repository import KnowledgeFileRepository
|
||||
|
||||
await KnowledgeFileRepository().delete(file_id)
|
||||
await self._save_metadata()
|
||||
|
||||
async def get_file_basic_info(self, db_id: str, file_id: str) -> dict:
|
||||
"""获取文件基本信息(仅元数据)"""
|
||||
|
||||
@ -338,7 +338,7 @@ class MilvusKB(KnowledgeBase):
|
||||
self.files_meta[file_id]["updated_at"] = utc_isoformat()
|
||||
if operator_id:
|
||||
self.files_meta[file_id]["updated_by"] = operator_id
|
||||
await self._save_metadata()
|
||||
await self._persist_file(file_id)
|
||||
return self.files_meta[file_id]
|
||||
|
||||
except Exception as e:
|
||||
@ -349,7 +349,7 @@ class MilvusKB(KnowledgeBase):
|
||||
self.files_meta[file_id]["updated_at"] = utc_isoformat()
|
||||
if operator_id:
|
||||
self.files_meta[file_id]["updated_by"] = operator_id
|
||||
await self._save_metadata()
|
||||
await self._persist_file(file_id)
|
||||
raise
|
||||
|
||||
finally:
|
||||
@ -402,7 +402,7 @@ class MilvusKB(KnowledgeBase):
|
||||
)
|
||||
self.files_meta[file_id]["processing_params"] = resolved_params
|
||||
self.files_meta[file_id]["status"] = "processing"
|
||||
await self._save_metadata()
|
||||
await self._persist_file(file_id)
|
||||
|
||||
# 重新解析文件为 markdown
|
||||
if content_type != "file":
|
||||
@ -440,7 +440,7 @@ class MilvusKB(KnowledgeBase):
|
||||
# 更新元数据状态
|
||||
async with self._metadata_lock:
|
||||
self.files_meta[file_id]["status"] = "done"
|
||||
await self._save_metadata()
|
||||
await self._persist_file(file_id)
|
||||
|
||||
# 从处理队列中移除
|
||||
self._remove_from_processing_queue(file_id)
|
||||
@ -455,7 +455,7 @@ class MilvusKB(KnowledgeBase):
|
||||
logger.error(f"更新{content_type} {file_path} 失败: {e}, {traceback.format_exc()}")
|
||||
async with self._metadata_lock:
|
||||
self.files_meta[file_id]["status"] = "failed"
|
||||
await self._save_metadata()
|
||||
await self._persist_file(file_id)
|
||||
|
||||
# 从处理队列中移除
|
||||
self._remove_from_processing_queue(file_id)
|
||||
@ -712,7 +712,6 @@ class MilvusKB(KnowledgeBase):
|
||||
from src.repositories.knowledge_file_repository import KnowledgeFileRepository
|
||||
|
||||
await KnowledgeFileRepository().delete(file_id)
|
||||
await self._save_metadata()
|
||||
|
||||
async def get_file_basic_info(self, db_id: str, file_id: str) -> dict:
|
||||
"""获取文件基本信息(仅元数据)"""
|
||||
|
||||
@ -452,8 +452,7 @@ async def stream_agent_chat(
|
||||
if agent_state:
|
||||
yield make_chunk(status="agent_state", agent_state=agent_state, meta=meta)
|
||||
|
||||
yield make_chunk(status="finished", meta=meta)
|
||||
|
||||
# 先存储数据库,再返回 finished,避免前端查询时数据未落库
|
||||
await save_messages_from_langgraph_state(
|
||||
agent_instance=agent,
|
||||
thread_id=thread_id,
|
||||
@ -461,6 +460,8 @@ async def stream_agent_chat(
|
||||
config_dict=langgraph_config,
|
||||
)
|
||||
|
||||
yield make_chunk(status="finished", meta=meta)
|
||||
|
||||
except (asyncio.CancelledError, ConnectionError) as e:
|
||||
logger.warning(f"Client disconnected, cancelling stream: {e}")
|
||||
|
||||
@ -593,8 +594,8 @@ async def stream_agent_resume(
|
||||
yield chunk
|
||||
|
||||
meta["time_cost"] = asyncio.get_event_loop().time() - start_time
|
||||
yield make_resume_chunk(status="finished", meta=meta)
|
||||
|
||||
# 先存储数据库,再返回 finished,避免前端查询时数据未落库
|
||||
conv_repo = ConversationRepository(db)
|
||||
await save_messages_from_langgraph_state(
|
||||
agent_instance=agent,
|
||||
@ -603,6 +604,8 @@ async def stream_agent_resume(
|
||||
config_dict=langgraph_config,
|
||||
)
|
||||
|
||||
yield make_resume_chunk(status="finished", meta=meta)
|
||||
|
||||
except (asyncio.CancelledError, ConnectionError) as e:
|
||||
logger.warning(f"Client disconnected during resume: {e}")
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from langgraph.types import Command
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.agents import agent_manager
|
||||
@ -78,6 +79,8 @@ async def _sync_thread_attachment_state(
|
||||
|
||||
graph = await agent.get_graph()
|
||||
config = {"configurable": {"thread_id": thread_id, "user_id": str(user_id)}}
|
||||
|
||||
# 先获取现有 state,保留非附件文件
|
||||
state = await graph.aget_state(config)
|
||||
state_values = getattr(state, "values", {}) if state else {}
|
||||
existing_files = state_values.get("files", {}) if isinstance(state_values, dict) else {}
|
||||
@ -97,6 +100,7 @@ async def _sync_thread_attachment_state(
|
||||
for removed_path in prev_attachment_paths - next_attachment_paths:
|
||||
file_updates[removed_path] = None
|
||||
|
||||
# 使用 Command 确保 reducer 被正确应用
|
||||
await graph.aupdate_state(
|
||||
config=config,
|
||||
values={
|
||||
@ -265,7 +269,7 @@ async def upload_thread_attachment_view(
|
||||
"uploaded_at": utc_isoformat(),
|
||||
"truncated": conversion.truncated,
|
||||
"file_path": file_path, # 用于 StateBackend,前端不返回此字段
|
||||
"minio_url": minio_url,
|
||||
"minio_url": minio_url, # 暂未使用
|
||||
}
|
||||
await conv_repo.add_attachment(conversation.id, attachment_record)
|
||||
all_attachments = await conv_repo.get_attachments(conversation.id)
|
||||
|
||||
@ -1431,7 +1431,7 @@ const handleSendMessage = async ({ image } = {}) => {
|
||||
} finally {
|
||||
threadState.streamAbortController = null
|
||||
// 异步加载历史记录,保持当前消息显示直到历史记录加载完成
|
||||
fetchThreadMessages({ agentId: currentAgentId.value, threadId: threadId, delay: 500 }).finally(
|
||||
fetchThreadMessages({ agentId: currentAgentId.value, threadId: threadId }).finally(
|
||||
() => {
|
||||
// 历史记录加载完成后,安全地清空当前进行中的对话
|
||||
resetOnGoingConv(threadId)
|
||||
@ -1515,7 +1515,7 @@ const handleApprovalWithStream = async (approved) => {
|
||||
}
|
||||
|
||||
// 异步加载历史记录,保持当前消息显示直到历史记录加载完成
|
||||
fetchThreadMessages({ agentId: currentAgentId.value, threadId: threadId, delay: 500 }).finally(
|
||||
fetchThreadMessages({ agentId: currentAgentId.value, threadId: threadId }).finally(
|
||||
() => {
|
||||
resetOnGoingConv(threadId)
|
||||
scrollController.scrollToBottom()
|
||||
@ -1815,7 +1815,7 @@ watch(
|
||||
background: var(--gray-0);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px var(--shadow-1);
|
||||
border: 1px solid var(--gray-200);
|
||||
border: 1px solid var(--gray-150);
|
||||
min-width: 0;
|
||||
will-change: flex-basis;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<MessageInputComponent
|
||||
ref="inputRef"
|
||||
:key="inputKey"
|
||||
:model-value="modelValue"
|
||||
@update:modelValue="updateValue"
|
||||
:is-loading="isLoading"
|
||||
@ -48,7 +49,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import MessageInputComponent from '@/components/MessageInputComponent.vue'
|
||||
import ImagePreviewComponent from '@/components/ImagePreviewComponent.vue'
|
||||
@ -84,6 +85,20 @@ const emit = defineEmits([
|
||||
const inputRef = ref(null)
|
||||
const currentImage = ref(null)
|
||||
|
||||
// 用于强制重建输入组件的 key
|
||||
const inputKey = ref(0)
|
||||
|
||||
// 监听 hasStateContent 变化,当从有 state 切换到无 state 时重建组件
|
||||
watch(
|
||||
() => props.hasStateContent,
|
||||
(newVal, oldVal) => {
|
||||
// 当 hasStateContent 从 true 变为 false 时,重建输入组件
|
||||
if (oldVal === true && newVal === false) {
|
||||
inputKey.value++
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const updateValue = (val) => {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
@ -110,12 +125,14 @@ const handleAttachmentUpload = async (files) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const hide = message.loading({ content: '正在上传附件...', key: 'upload-attachment', duration: 0 })
|
||||
for (const file of files) {
|
||||
await threadApi.uploadThreadAttachment(threadId, file)
|
||||
message.success(`${file.name} 上传成功`)
|
||||
}
|
||||
message.success({ content: '附件上传成功', key: 'upload-attachment', duration: 2 })
|
||||
emit('attachment-changed', threadId)
|
||||
} catch (error) {
|
||||
message.destroy('upload-attachment')
|
||||
handleChatError(error, 'upload')
|
||||
}
|
||||
}
|
||||
|
||||
@ -565,7 +565,7 @@ const stopResize = () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
padding: 4px 16px;
|
||||
height: 40px;
|
||||
background: var(--gray-25);
|
||||
flex-shrink: 0;
|
||||
@ -623,7 +623,7 @@ const stopResize = () => {
|
||||
background: var(--gray-25);
|
||||
position: relative;
|
||||
align-items: center;
|
||||
padding: 8px 6px;
|
||||
padding: 8px 10px;
|
||||
padding-top: 0px;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
@ -655,7 +655,7 @@ const stopResize = () => {
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
padding: 8px;
|
||||
min-height: 0; /* Important for flex child scroll */
|
||||
|
||||
/* 自定义滚动条 */
|
||||
@ -817,10 +817,8 @@ const stopResize = () => {
|
||||
min-height: 300px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
background: var(--main-5);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--gray-200);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
@ -1020,7 +1018,6 @@ const stopResize = () => {
|
||||
|
||||
/* Specific Ant Design Tree Overrides */
|
||||
.file-tree-container :deep(.ant-tree) {
|
||||
background: var(--gray-25);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
</span>
|
||||
|
||||
<!-- 视图模式切换 -->
|
||||
<div class="view-controls" v-if="file && hasContent">
|
||||
<div class="view-controls" v-if="file && hasChunks">
|
||||
<a-segmented v-model:value="viewMode" :options="viewModeOptions" />
|
||||
</div>
|
||||
|
||||
@ -141,21 +141,22 @@ const hasIndexed = computed(() => ['done', 'indexed'].includes(file.value?.statu
|
||||
const hasContent = computed(
|
||||
() => (file.value?.lines && file.value?.lines.length > 0) || file.value?.content
|
||||
)
|
||||
// 是否有实际的分块数据
|
||||
const hasChunks = computed(() => mappedChunks.value && mappedChunks.value.length > 0)
|
||||
|
||||
const viewModeOptions = computed(() => {
|
||||
const options = [{ label: 'Markdown', value: 'markdown' }]
|
||||
if (hasIndexed.value) {
|
||||
// 只有当有实际的分块数据时才显示 Chunks 选项
|
||||
if (hasChunks.value) {
|
||||
options.push({ label: 'Chunks', value: 'chunks' })
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
// 监听文件状态变化,重置视图模式
|
||||
// 监听文件变化,如果没有 chunks 则重置为 markdown
|
||||
watch(file, (newFile) => {
|
||||
if (newFile) {
|
||||
if (!hasIndexed.value) {
|
||||
viewMode.value = 'markdown'
|
||||
}
|
||||
if (newFile && !hasChunks.value) {
|
||||
viewMode.value = 'markdown'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -38,6 +38,15 @@
|
||||
<!-- 编辑文件 -->
|
||||
<EditFileTool v-else-if="isEditFileResult" :tool-call="toolCall" />
|
||||
|
||||
<!-- MySQL 查询 -->
|
||||
<MysqlQueryTool v-else-if="isMysqlQueryResult" :tool-call="toolCall" />
|
||||
|
||||
<!-- MySQL 描述表 -->
|
||||
<MysqlDescribeTableTool v-else-if="isMysqlDescribeTableResult" :tool-call="toolCall" />
|
||||
|
||||
<!-- MySQL 列出表 -->
|
||||
<MysqlListTablesTool v-else-if="isMysqlListTablesResult" :tool-call="toolCall" />
|
||||
|
||||
<!-- 默认展示 -->
|
||||
<BaseToolCall v-else :tool-call="toolCall" />
|
||||
</template>
|
||||
@ -61,6 +70,9 @@ import ListDirectoryTool from './tools/ListDirectoryTool.vue'
|
||||
import SearchFileContentTool from './tools/SearchFileContentTool.vue'
|
||||
import GlobTool from './tools/GlobTool.vue'
|
||||
import EditFileTool from './tools/EditFileTool.vue'
|
||||
import MysqlQueryTool from './tools/MysqlQueryTool.vue'
|
||||
import MysqlDescribeTableTool from './tools/MysqlDescribeTableTool.vue'
|
||||
import MysqlListTablesTool from './tools/MysqlListTablesTool.vue'
|
||||
|
||||
const props = defineProps({
|
||||
toolCall: {
|
||||
@ -170,6 +182,18 @@ const isEditFileResult = computed(() => {
|
||||
return toolName.value === 'edit_file' || toolName.value === 'replace'
|
||||
})
|
||||
|
||||
const isMysqlQueryResult = computed(() => {
|
||||
return toolName.value === 'mysql_query'
|
||||
})
|
||||
|
||||
const isMysqlDescribeTableResult = computed(() => {
|
||||
return toolName.value === 'mysql_describe_table'
|
||||
})
|
||||
|
||||
const isMysqlListTablesResult = computed(() => {
|
||||
return toolName.value === 'mysql_list_tables'
|
||||
})
|
||||
|
||||
// 处理知识图谱刷新
|
||||
const graphToolCallRef = ref(null)
|
||||
const refreshGraph = () => {
|
||||
|
||||
@ -15,3 +15,6 @@ export { default as ListDirectoryTool } from './tools/ListDirectoryTool.vue'
|
||||
export { default as SearchFileContentTool } from './tools/SearchFileContentTool.vue'
|
||||
export { default as GlobTool } from './tools/GlobTool.vue'
|
||||
export { default as EditFileTool } from './tools/EditFileTool.vue'
|
||||
export { default as MysqlQueryTool } from './tools/MysqlQueryTool.vue'
|
||||
export { default as MysqlDescribeTableTool } from './tools/MysqlDescribeTableTool.vue'
|
||||
export { default as MysqlListTablesTool } from './tools/MysqlListTablesTool.vue'
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<BaseToolCall :tool-call="toolCall" :default-expanded="true" :hide-params="true">
|
||||
<template #header-success>
|
||||
<span class="sep-header">
|
||||
<span class="keywords">描述表结构:</span>
|
||||
<span class="description code">{{
|
||||
extractTableName(toolCall.args || toolCall.function?.arguments)
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #result="{ resultContent }">
|
||||
<div class="mysql-result">
|
||||
<pre class="result-text">{{ formatResult(resultContent) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</BaseToolCall>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BaseToolCall from '../BaseToolCall.vue'
|
||||
|
||||
const props = defineProps({
|
||||
toolCall: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const formatResult = (content) => {
|
||||
if (!content) return ''
|
||||
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof content === 'object') {
|
||||
return JSON.stringify(content, null, 2)
|
||||
}
|
||||
|
||||
return String(content)
|
||||
}
|
||||
|
||||
const extractTableName = (args) => {
|
||||
if (!args) return ''
|
||||
|
||||
let parsedArgs = args
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
parsedArgs = JSON.parse(args)
|
||||
} catch {
|
||||
return args
|
||||
}
|
||||
}
|
||||
|
||||
return parsedArgs?.table_name || ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.mysql-result {
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
|
||||
.result-text {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--gray-700);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: var(--gray-50);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<BaseToolCall :tool-call="toolCall" :default-expanded="true" :hide-params="true">
|
||||
<template #header-success>
|
||||
<span class="sep-header">
|
||||
<span class="keywords">列出数据库表</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #result="{ resultContent }">
|
||||
<div class="mysql-result">
|
||||
<pre class="result-text">{{ formatResult(resultContent) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</BaseToolCall>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BaseToolCall from '../BaseToolCall.vue'
|
||||
|
||||
const props = defineProps({
|
||||
toolCall: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const formatResult = (content) => {
|
||||
if (!content) return ''
|
||||
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof content === 'object') {
|
||||
return JSON.stringify(content, null, 2)
|
||||
}
|
||||
|
||||
return String(content)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.mysql-result {
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
|
||||
.result-text {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--gray-700);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: var(--gray-50);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
121
web/src/components/ToolCallingResult/tools/MysqlQueryTool.vue
Normal file
121
web/src/components/ToolCallingResult/tools/MysqlQueryTool.vue
Normal file
@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<BaseToolCall :tool-call="toolCall" :default-expanded="true">
|
||||
<template #header-success>
|
||||
<span class="sep-header">
|
||||
<span class="keywords">执行SQL查询:</span>
|
||||
<span class="description">{{
|
||||
truncateSql(extractSql(toolCall.args || toolCall.function?.arguments))
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #params="{ args }">
|
||||
<div class="mysql-params">
|
||||
<pre class="sql-text">{{ extractSql(args) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #result="{ resultContent }">
|
||||
<div class="mysql-result">
|
||||
<pre class="result-text">{{ formatResult(resultContent) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</BaseToolCall>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BaseToolCall from '../BaseToolCall.vue'
|
||||
|
||||
const props = defineProps({
|
||||
toolCall: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const formatResult = (content) => {
|
||||
if (!content) return ''
|
||||
|
||||
// 如果是字符串,尝试解析为 JSON 后再转为格式化字符串
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是对象,直接格式化
|
||||
if (typeof content === 'object') {
|
||||
return JSON.stringify(content, null, 2)
|
||||
}
|
||||
|
||||
return String(content)
|
||||
}
|
||||
|
||||
const extractSql = (args) => {
|
||||
if (!args) return ''
|
||||
|
||||
// 解析 args(可能是字符串或对象)
|
||||
let parsedArgs = args
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
parsedArgs = JSON.parse(args)
|
||||
} catch {
|
||||
return args
|
||||
}
|
||||
}
|
||||
|
||||
// 提取 sql 字段
|
||||
const sql = parsedArgs?.sql || parsedArgs?.query
|
||||
return sql || JSON.stringify(parsedArgs, null, 2)
|
||||
}
|
||||
|
||||
const truncateSql = (sql, maxLength = 50) => {
|
||||
if (!sql) return ''
|
||||
// 移除换行符和多余空格
|
||||
const singleLine = sql.replace(/\s+/g, ' ').trim()
|
||||
if (singleLine.length <= maxLength) return singleLine
|
||||
return singleLine.slice(0, maxLength) + '...'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.mysql-params {
|
||||
.sql-text {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--gray-800);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.mysql-result {
|
||||
// background: var(--gray-0);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
|
||||
.result-text {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--gray-700);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: var(--gray-50);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -596,20 +596,21 @@ const handleMouseUp = () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: var(--color-warning-50);
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
background: var(--color-info-50);
|
||||
border-left: 3px solid var(--color-info-500);
|
||||
border-radius: 2px;
|
||||
font-size: 13px;
|
||||
color: var(--color-warning-700);
|
||||
color: var(--color-info-700);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-warning-100);
|
||||
background: var(--color-info-100);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--color-warning-700);
|
||||
color: var(--color-info-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -660,7 +661,6 @@ const handleMouseUp = () => {
|
||||
:deep(.ant-tabs-nav) {
|
||||
margin-bottom: 0;
|
||||
// background-color: var(--gray-0);
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-extra-content) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user