diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 59d5f3d..05b3ea7 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -17,7 +17,7 @@ from app.schemas import ChatRequest from app.store.memory import get_checkpointer from app.store.postgres import Conversation, Message, async_session_factory from app.tools import resolve_tools -from app.tools.search import set_search_model +from app.tools.mcp_jina import create_jina_mcp_client, get_jina_mcp_tools async def _ensure_conversation(conversation_id: str, first_message: str) -> None: @@ -51,26 +51,19 @@ async def _persist_ai_message(conversation_id: str, content: str) -> None: await session.commit() -async def _stream_response(request: ChatRequest) -> AsyncIterator[bytes]: - """Stream LLM response tokens via SSE.""" - # Ensure conversation exists and persist user message - await _ensure_conversation(request.conversation_id, request.message) - - # Resolve tools from frontend tool keys - active_tools = resolve_tools(request.tools) - - # Set search strategy model so the web_search tool knows depth - set_search_model(request.model) - - graph = await get_chat_graph(model=request.model, tools=active_tools) - +async def _run_graph_stream( + request: ChatRequest, + graph, + all_tools: list, +) -> AsyncIterator[bytes]: + """Run the LangGraph agent and yield SSE events.""" config = { "configurable": {"thread_id": request.conversation_id}, } # When using ReAct agent (with tools), input is just messages. # When using plain graph (no tools), input includes model key. - if active_tools: + if all_tools: input_data = {"messages": [HumanMessage(content=request.message)]} else: input_data = { @@ -204,6 +197,27 @@ async def _stream_response(request: ChatRequest) -> AsyncIterator[bytes]: yield _sse({"type": "done"}) +async def _stream_response(request: ChatRequest) -> AsyncIterator[bytes]: + """Stream LLM response tokens via SSE.""" + # Ensure conversation exists and persist user message + await _ensure_conversation(request.conversation_id, request.message) + + # Resolve tools from frontend tool keys + regular_tools, needs_jina_mcp = resolve_tools(request.tools) + + if needs_jina_mcp: + mcp_client = create_jina_mcp_client() + jina_tools = await get_jina_mcp_tools(mcp_client) + all_tools = regular_tools + jina_tools + graph = await get_chat_graph(model=request.model, tools=all_tools) + async for chunk in _run_graph_stream(request, graph, all_tools): + yield chunk + else: + graph = await get_chat_graph(model=request.model, tools=regular_tools) + async for chunk in _run_graph_stream(request, graph, regular_tools): + yield chunk + + # ── Trace helpers ───────────────────────────────────────────────────────────── def _sse(data: dict) -> bytes: @@ -214,9 +228,12 @@ _TOOL_TITLES: dict[str, str] = { "kb_search": "检索知识库", "ticket_list": "查询工单列表", "ticket_detail": "查询工单详情", - "web_search": "外部搜索", "generate_document": "生成文档", "sandbox_run": "执行沙盒代码", + # Jina MCP tools + "search_web": "外部搜索", + "read_url": "读取网页", + "sort_by_relevance": "相关性排序", } _ERROR_KEYWORDS = ( @@ -245,8 +262,15 @@ def _summarize_input(tool_name: str, inp: dict | str) -> str: return f"第 {inp.get('page', 1)} 页,每页 {inp.get('page_size', 20)} 条" case "ticket_detail": return f"工单 ID:{inp.get('ticket_id', '')}" - case "web_search": + case "search_web": return f"搜索:{str(inp.get('query', ''))[:50]}" + case "read_url": + url = str(inp.get('url', '')) + return f"读取:{url[:60]}" + case "sort_by_relevance": + query = str(inp.get('query', ''))[:30] + count = len(inp.get('documents', [])) + return f"排序 {count} 条结果,查询:{query}" case "generate_document": return str(inp.get('prompt', ''))[:60] case "sandbox_run": @@ -270,9 +294,14 @@ def _summarize_output(tool_name: str, output: str) -> str: return f"返回 {m.group(1)} 条工单" if m else "工单列表已获取" case "ticket_detail": return "工单详情已获取" - case "web_search": + case "search_web": count = output.count("##") return f"找到 {max(count, 1)} 条搜索结果" + case "read_url": + lines = len(output.splitlines()) + return f"读取完成,{lines} 行内容" + case "sort_by_relevance": + return "排序完成" case "generate_document": if "Download:" in output: if "[PPT]" in output: diff --git a/backend/app/graph/builder.py b/backend/app/graph/builder.py index ae12dd1..47dc367 100644 --- a/backend/app/graph/builder.py +++ b/backend/app/graph/builder.py @@ -32,7 +32,11 @@ SYSTEM_PROMPT = ( "When the user has enabled specific tools, you may use them if relevant. " "If you decide not to use an available tool, briefly explain why. " "Always respond in the same language the user uses. " - "Be concise, accurate, and helpful." + "Be concise, accurate, and helpful.\n\n" + "When web search tools are available:\n" + "- Use search_web first to find relevant pages\n" + "- Use read_url to get full content from the most relevant URLs (1-3 max)\n" + "- Use sort_by_relevance to rank results if you have many documents\n" ) # Cache compiled graphs to avoid re-creation on every request. @@ -54,16 +58,25 @@ def _get_llm(model: str) -> AzureChatOpenAI: ) +# MCP tool names that should not be cached (bound to per-request client) +_MCP_TOOL_NAMES = {"search_web", "read_url", "sort_by_relevance"} + + async def get_chat_graph(model: str = "flash", tools: list | None = None): """Get or create a compiled graph for the given model and tool set. When tools are provided, creates a ReAct agent that can call tools. When no tools, falls back to a simple single-node graph. + + MCP tools are bound to a per-request client session, so graphs + containing them are never cached. """ tools = tools or [] - cache_key = (model, frozenset(t.name for t in tools)) + tool_names = frozenset(t.name for t in tools) + has_mcp_tools = bool(tool_names & _MCP_TOOL_NAMES) - if cache_key in _graph_cache: + cache_key = (model, tool_names) + if not has_mcp_tools and cache_key in _graph_cache: return _graph_cache[cache_key] checkpointer = await get_checkpointer() @@ -85,5 +98,6 @@ async def get_chat_graph(model: str = "flash", tools: list | None = None): builder.set_finish_point("agent") graph = builder.compile(checkpointer=checkpointer) - _graph_cache[cache_key] = graph + if not has_mcp_tools: + _graph_cache[cache_key] = graph return graph diff --git a/backend/app/tools/__init__.py b/backend/app/tools/__init__.py index e1c95d4..75234dc 100644 --- a/backend/app/tools/__init__.py +++ b/backend/app/tools/__init__.py @@ -2,26 +2,24 @@ from app.tools.kb import kb_search from app.tools.tickets import ticket_list, ticket_detail -from app.tools.search import web_search from app.tools.document import generate_document from app.tools.sandbox import sandbox_run # Mapping from frontend tool names to LangChain tool objects. -# The frontend sends a list of tool *keys* (e.g. ["knowledge", "tickets"]); -# the backend resolves them here and binds them to the ReAct agent. -ALL_TOOLS: dict[str, list] = { +# "search" is no longer here — it is handled via Jina MCP in chat.py. +_TOOL_MAP: dict[str, list] = { "knowledge": [kb_search], "tickets": [ticket_list, ticket_detail], - "search": [web_search], "document": [generate_document], "sandbox": [sandbox_run], } -def resolve_tools(tool_keys: list[str]) -> list: - """Return a flat list of LangChain tools for the given frontend keys.""" +def resolve_tools(tool_keys: list[str]) -> tuple[list, bool]: + """Return (regular_tools, needs_jina_mcp).""" + needs_jina_mcp = "search" in tool_keys tools = [] for key in tool_keys: - if key in ALL_TOOLS: - tools.extend(ALL_TOOLS[key]) - return tools + if key in _TOOL_MAP: + tools.extend(_TOOL_MAP[key]) + return tools, needs_jina_mcp diff --git a/backend/app/tools/mcp_jina.py b/backend/app/tools/mcp_jina.py new file mode 100644 index 0000000..7879dc7 --- /dev/null +++ b/backend/app/tools/mcp_jina.py @@ -0,0 +1,27 @@ +"""Jina MCP tools via langchain-mcp-adapters.""" + +from __future__ import annotations + +from langchain_mcp_adapters.client import MultiServerMCPClient + +from app.config import settings + +# Only load the 3 tools we need, avoid 19 tools filling the context +JINA_MCP_TOOL_NAMES = {"search_web", "read_url", "sort_by_relevance"} +JINA_MCP_URL = "https://mcp.jina.ai/v1" + + +def create_jina_mcp_client() -> MultiServerMCPClient: + return MultiServerMCPClient({ + "jina": { + "url": JINA_MCP_URL, + "transport": "streamable_http", + "headers": {"Authorization": f"Bearer {settings.jina_api_key}"}, + } + }) + + +async def get_jina_mcp_tools(client: MultiServerMCPClient) -> list: + """Get filtered Jina MCP tools (search_web / read_url / sort_by_relevance).""" + all_tools = await client.get_tools() + return [t for t in all_tools if t.name in JINA_MCP_TOOL_NAMES] diff --git a/backend/requirements.txt b/backend/requirements.txt index 28b3036..0931846 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,3 +16,4 @@ redis[hiredis]>=5.0.0 azure-storage-blob>=12.20.0 azure-servicebus>=7.12.0 daytona>=0.6.0 +langchain-mcp-adapters>=0.1.0