弃用langgrpah更换deepagent

This commit is contained in:
zcr
2026-03-11 21:45:46 +08:00
parent c862121b48
commit 7042d428fa
44 changed files with 2847 additions and 619 deletions

View File

@@ -13,7 +13,7 @@ dependencies = [
"langchain-community>=0.4.1", "langchain-community>=0.4.1",
"langchain-core>=1.2.8", "langchain-core>=1.2.8",
"langchain-google-genai>=4.2.0", "langchain-google-genai>=4.2.0",
"langgraph[postgres]>=1.0.7", "langgraph[all,postgres]>=1.0.7",
"langgraph-checkpoint-mongodb>=0.3.1", "langgraph-checkpoint-mongodb>=0.3.1",
"minio>=7.2.20", "minio>=7.2.20",
"modality>=0.1.0", "modality>=0.1.0",
@@ -41,4 +41,11 @@ dependencies = [
"asyncio>=4.0.0", "asyncio>=4.0.0",
"requests>=2.32.5", "requests>=2.32.5",
"chardet<6", "chardet<6",
"datetime>=6.0",
"agentstate>=1.0.2",
"langchain-classic>=1.0.1",
"langsmith>=0.7.13",
"path>=17.1.1",
"langgraph-checkpoint-postgres>=3.0.4",
"langgraph-store-mongodb>=0.2.0",
] ]

0
src/db/__init__.py Normal file
View File

49
src/db/init_mongodb.py Normal file
View File

@@ -0,0 +1,49 @@
import asyncio
from motor.motor_asyncio import AsyncIOMotorClient
from src.core.config import MONGO_URI
DB_NAME = "fida_mongo"
COLLECTION_NAME = "user_persona"
async def init_mongo():
client = AsyncIOMotorClient(
MONGO_URI,
maxPoolSize=50,
minPoolSize=5,
serverSelectionTimeoutMS=5000
)
db = client[DB_NAME]
# 查看已有集合
collections = await db.list_collection_names()
if COLLECTION_NAME not in collections:
print(f"Creating collection: {COLLECTION_NAME}")
await db.create_collection(COLLECTION_NAME)
collection = db[COLLECTION_NAME]
# 创建 thread_id 唯一索引
print("Creating index: thread_id_unique")
await collection.create_index(
"thread_id",
unique=True,
name="thread_id_unique"
)
# 创建 TTL 索引30天自动删除
print("Creating TTL index: updated_at_ttl")
await collection.create_index(
"updated_at",
expireAfterSeconds=2592000, # 30天
name="updated_at_ttl"
)
print("MongoDB initialization completed.")
if __name__ == "__main__":
asyncio.run(init_mongo())

17
src/db/mongo.py Normal file
View File

@@ -0,0 +1,17 @@
from motor.motor_asyncio import AsyncIOMotorClient
from src.core.config import MONGO_URI
client = AsyncIOMotorClient(
MONGO_URI,
maxPoolSize=50,
minPoolSize=5,
serverSelectionTimeoutMS=5000
)
db = client["fida_mongo"]
user_persona_collection = db["user_persona"]

View File

@@ -6,7 +6,7 @@ from typing import AsyncGenerator
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from src.schemas.chat import ChatRequest, HistoryResponse, HistoryItem from src.schemas.chat import ChatRequest, HistoryResponse, HistoryItem
from src.server.agent.graph import app # 导入已经 compile 好的 graph from src.server.agent.graph import app
from langchain_core.messages import HumanMessage, SystemMessage, AIMessageChunk, ToolMessage, AIMessage from langchain_core.messages import HumanMessage, SystemMessage, AIMessageChunk, ToolMessage, AIMessage
router = APIRouter(prefix="/chat", tags=["Furniture Design Chat"]) router = APIRouter(prefix="/chat", tags=["Furniture Design Chat"])
@@ -169,10 +169,8 @@ async def chat_stream(request: ChatRequest):
await app.aupdate_state(current_config, combined_values) await app.aupdate_state(current_config, combined_values)
async def event_generator() -> AsyncGenerator[str, None]: async def event_generator() -> AsyncGenerator[str, None]:
# 初始事件
yield f"data: {json.dumps({'thread_id': target_thread_id, 'is_branch': is_branching, 'status': 'start'}, ensure_ascii=False)}\n\n" yield f"data: {json.dumps({'thread_id': target_thread_id, 'is_branch': is_branching, 'status': 'start'}, ensure_ascii=False)}\n\n"
# 構造輸入(保持不變)
new_messages = initial_messages[:] if not source_thread_id else [] new_messages = initial_messages[:] if not source_thread_id else []
new_messages.append(HumanMessage(content=request.message)) new_messages.append(HumanMessage(content=request.message))
@@ -182,124 +180,143 @@ async def chat_stream(request: ChatRequest):
"use_report": request.use_report, "use_report": request.use_report,
} }
# ─── 重點改這裡 ─────────────────────────────────────── interrupted = False
current_cp_id = None
# try:
async for event in app.astream( async for event in app.astream(
input_data, input_data,
config=current_config, config=current_config,
stream_mode=["custom", "updates", "messages"], # 推薦組合 stream_mode=["updates", "messages", "custom"], # 确保包含 "values"
subgraphs=True subgraphs=True
# 不再需要,行為已包含
): ):
logger.info(event) if interrupted:
# 取得 checkpoint_id可選視前端是否真的需要 break
latest_state = await app.aget_state(current_config)
configurable = latest_state.config.get("configurable", {})
current_cp_id = configurable.get("checkpoint_id", "")
if len(event) == 3:
namespace, channel, payload = event
# 路由更新
if event[1] == "updates":
namespace, _, payload = event
if isinstance(payload, dict): logger.info(f"Received event: {event}")
for update_node, update_content in payload.items():
# 处理 reducerOverwrite / Append if not isinstance(event, tuple) or len(event) != 3:
if isinstance(update_content, dict): continue
for k, v in update_content.items():
if hasattr(v, "value"): # Overwrite(...)
update_content[k] = v.value
if isinstance(update_content, dict) and "messages" in update_content: run_id, channel, payload = event
msgs = []
for m in update_content["messages"]:
msgs.append({
"type": m.__class__.__name__,
"content": getattr(m, "content", ""),
"name": getattr(m, "name", None),
"tool_calls": getattr(m, "tool_calls", None),
})
update_content["messages"] = msgs
yield f"data: {json.dumps({ # ─────────────── 检测 interrupt ───────────────
"node": "Supervisor", # __interrupt__ 最常出现在 "values" 或 "updates" channel 的 payload 中
"type": "updates", if channel in ("values", "updates") and isinstance(payload, dict) and "__interrupt__" in payload:
"content": update_content, interrupt_data = payload["__interrupt__"][0].value['__interrupt__']
"is_delta": False, interrupted = True
"checkpoint_id": current_cp_id, yield f"data: {json.dumps({
}, ensure_ascii=False)}\n\n" "type": "interrupt",
"node": interrupt_data.get("node", "Persona"),
"question": interrupt_data.get('question', "异常|||||||||||||"),
"current_persona": interrupt_data.get("current_persona_snapshot", {}),
"status": "waiting_for_input"
}, ensure_ascii=False)}\n\n"
# 自定义事件 # 立即停止后续发送,等待用户回复后 resume
elif event[1] == "custom": break
if isinstance(payload, dict) and payload.get("type") in ("report_delta", "report_start", "report_error", "report_save_warning", "report_complete"):
delta = payload.get("delta", "").strip()
if delta:
yield f"data: {json.dumps({
'node': 'Researcher',
'type': 'report_delta',
'content': delta,
'is_delta': True,
'checkpoint_id': current_cp_id,
}, ensure_ascii=False)}\n\n"
# 基础消息
elif event[1] == "messages":
if namespace:
node_name = namespace[-1] if isinstance(namespace, tuple) else namespace
if ':' in node_name:
node_name = node_name.split(':')[0]
else:
node_name = "Main"
message, metadata = payload
is_not_research = node_name != 'Researcher'
node_name = metadata.get("langgraph_node", node_name)
# 3. 处理不同类型的 message
payload_out = {
"node": node_name,
"checkpoint_id": current_cp_id, # 你之前已经获取了
"is_delta": False,
"content": "",
"suggestions": [],
"type": "unknown"
}
if isinstance(message, AIMessageChunk): # ─────────────── 正常消息处理 ───────────────
# 节点不是research 并且 tool_call_chunks不为空的情况下避免research的report工具使用custom发出的消息和message的消息重复了 if channel == "messages":
if is_not_research and node_name != 'Researcher' and message.tool_call_chunks: if run_id:
payload_out.update({ node_name = run_id[-1] if isinstance(run_id, tuple) else run_id
"type": "delta", if ':' in node_name:
"is_delta": True, node_name = node_name.split(':')[0]
"content": message.content, else:
# 如果有 tool call chunk也可以在这里处理 node_name = "Main"
"tool_call_chunk": message.tool_call_chunks[0] if message.tool_call_chunks else None
}) message, metadata = payload
yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n" node_name = metadata.get("langgraph_node", node_name)
elif isinstance(message, ToolMessage):
# 工具执行结果(完整的一次性输出) payload_out = {
"node": node_name,
"checkpoint_id": current_cp_id or "unknown",
"is_delta": False,
"content": "",
"suggestions": [],
"type": "unknown"
}
if isinstance(message, AIMessageChunk):
if node_name != 'Researcher' and message.tool_call_chunks:
payload_out.update({
"type": "delta",
"is_delta": True,
"content": message.content,
"tool_call_chunk": message.tool_call_chunks[0] if message.tool_call_chunks else None
})
yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n"
elif isinstance(message, ToolMessage):
try:
tools_data = json.loads(message.content)
payload_out.update({ payload_out.update({
"type": "tool_result", "type": "tool_result",
"is_delta": False, "is_delta": False,
"content": message.content, "content": tools_data.get("data", ""),
"tool_name": message.name, "tool_name": tools_data.get("tool_name", ""),
"tool_call_id": message.tool_call_id
}) })
yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n" yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n"
except:
pass
elif isinstance(message, AIMessage): elif isinstance(message, AIMessage):
# 完整 AIMessage不常见在 messages 模式下,但以防万一) payload_out.update({
payload_out.update({ "type": "complete_message",
"type": "complete_message", "is_delta": False,
"content": message.content
})
yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n"
elif channel == "updates":
# 处理 updates非 interrupt 的部分)
if isinstance(payload, dict):
for update_node, update_content in payload.items():
# 处理 reducer 包裹的值
if isinstance(update_content, dict):
for k, v in update_content.items():
if hasattr(v, "value"):
update_content[k] = v.value
# 序列化 messages
if isinstance(update_content, dict) and "messages" in update_content:
msgs = []
for m in update_content["messages"]:
msgs.append({
"type": m.__class__.__name__,
"content": getattr(m, "content", ""),
"name": getattr(m, "name", None),
"tool_calls": getattr(m, "tool_calls", None),
})
update_content["messages"] = msgs
yield f"data: {json.dumps({
"node": "Supervisor", # update_node
"type": "updates",
"content": update_content,
"is_delta": False, "is_delta": False,
"content": message.content "checkpoint_id": current_cp_id,
}) }, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n"
else: elif channel == "custom":
# 其他未知类型,记录日志 if isinstance(payload, dict) and payload.get("type") in ("report_delta", "report_start", ...):
print(f"未知消息类型: {type(message)}", message) delta = payload.get("delta", "").strip()
continue if delta:
yield f"data: {json.dumps({
'node': 'Researcher',
'type': 'report_delta',
'content': delta,
'is_delta': True,
'checkpoint_id': current_cp_id,
}, ensure_ascii=False)}\n\n"
# except Exception as e:
# print("error")
# 流結束 # 结束标记
yield f"data: {json.dumps({'status': 'end'}, ensure_ascii=False)}\n\n" if interrupted:
yield f"data: {json.dumps({'status': 'interrupted', 'reason': 'waiting_for_user_input'})}\n\n"
else:
yield f"data: {json.dumps({'status': 'end'}, ensure_ascii=False)}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream") return StreamingResponse(event_generator(), media_type="text/event-stream")

View File

@@ -0,0 +1,381 @@
import logging
import uuid
import json
from typing import AsyncGenerator
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from src.schemas.chat import ChatRequest, HistoryResponse, HistoryItem
from langchain_core.messages import HumanMessage, SystemMessage, AIMessageChunk, ToolMessage, AIMessage, ToolMessageChunk
from src.server.deep_agent.agents.main_agent import build_main_agent
router = APIRouter(prefix="/chat", tags=["Furniture Design Chat"])
logger = logging.getLogger(__name__)
@router.post("/deep_agent_stream")
async def chat_stream(request: ChatRequest):
"""
### 家具设计流式对话接口 (SSE)
通过此接口与 AI 家具设计专家团队进行实时沟通。支持 **记忆持久化** 和 **历史回溯分叉**。
#### 1. 核心功能
* **实时反馈**: 采用 Server-Sent Events (SSE) 技术,实时推送主管、设计师、视觉专家等节点的思考过程。
* **上下文记忆**: 传入 `thread_id` 即可恢复之前的对话进度。
* **版本分溯**: 传入 `checkpoint_id` 可准确定位到历史中的某一轮,并从该点开启新的设计分支。
#### 2. 请求参数
* `message`: 用户的设计意图(如:'我想设计一个极简风格的橡木办公桌')。
* `thread_id`: (可选) 现有项目的唯一标识。若不传,系统将自动分配并返回。
* `checkpoint_id`: (可选) 历史快照 ID。
* `config_params`: (可选) 对话配置参数
* `need_suggestion`: (可选) 是否需要建议按钮,需要建议的频率0-1的浮点数
* `use_report`: (可选) 是否需要使用report功能 true/false
#### 3. 响应流说明 (Data Format)
响应以 `data: ` 开头的 JSON 字符串流形式发送:
- **Session Start**: `{"thread_id": "...", "status": "start"}`
- **Node Message**: `{"node": "Designer", "content": "...", "checkpoint_id": "..."}`
- **Session End**: `{"status": "end"}`
- **is_delta**: False/True表示这个消息不是完整内容只是 AI 正在生成的一小段内容(一个字、一个词、一句话),需要前端把这些片段拼接起来才能得到完整的回答。
#### 4. 请求示例
```
{
"message": "设计一款北欧风格的躺椅."
}
{
"message": "就以上信息直接生成sketch.",
"thread_id": "187e58af"
}
{
"message": "不要躺椅,要桌子",
"thread_id": "187e58af",
"checkpoint_id": "1f101aa2-8f24-6e2a-8001-2952c3a7447a"
}
```
### 5. 响应流说明
所有响应均以 data: 开头JSON 字符串格式,末尾以 \n\n 结束
响应流包含三种类型的事件:会话开始、节点消息、会话结束
会话开始:
```
{
"thread_id": "str",
"is_branch": "boolean",
"status": "start"
}
```
节点消息:
```
{
"node": "节点名称如Designer/Researcher/Main",
"content": "消息内容",
"checkpoint_id": "快照ID",
"is_delta": "boolean",
"type": "消息类型",
"suggestions": "建议列表(可选)",
"tool_name": "工具名称(可选)",
"tool_call_chunk": "工具调用片段(可选)",
"tool_call_id": "工具调用ID可选"
}
```
报告增量消息:
```
{
"node": "Researcher",
"type": "report_delta",
"content": "报告内容增量",
"is_delta": true,
"checkpoint_id": "xxx"
}
```
AI 消息片段:
```
{
"node": "Designer",
"content": "设计建议内容",
"checkpoint_id": "xxx",
"is_delta": true,
"type": "delta",
"tool_call_chunk": {...}
}
```
工具执行结果:
```
{
"node": "ToolExecutor",
"content": "工具执行结果",
"checkpoint_id": "xxx",
"is_delta": false,
"type": "tool_result",
"tool_name": "ImageGenerator",
"tool_call_id": "yyy"
}
```
"""
logger.info(f"chat request data: {request}")
source_thread_id = request.thread_id
checkpoint_id = request.checkpoint_id
# 构建主agent
main_agent = build_main_agent(request.use_report)
# 1. 確定目標 thread_id
is_branching = source_thread_id and checkpoint_id
target_thread_id = str(uuid.uuid4())[:8] if is_branching else (source_thread_id or str(uuid.uuid4())[:8])
# 2. 配置參數
temp = request.config_params.temperature if request.config_params else 0.7
need_suggestion = request.need_suggestion,
current_config = {
"recursion_limit": 120,
"configurable": {
"thread_id": target_thread_id,
"llm_temperature": temp,
"use_report": request.use_report,
}
}
# 3. 初始化消息 + 系統提示 TODO 写入数据库
initial_messages = []
if not source_thread_id or is_branching:
if request.config_params:
cp = request.config_params
system_prompt = (
f"Current furniture design background settings\n"
f"- type: {cp.type}\n"
f"- space/region: {cp.region}\n"
f"- style tendency: {cp.style}\n"
f"Please strictly follow the above settings in subsequent conversations。"
)
initial_messages.append(SystemMessage(content=system_prompt))
# 4. 處理分支(從歷史 checkpoint 複製狀態)
if is_branching:
source_config = {
"configurable": {
"thread_id": source_thread_id,
"checkpoint_id": checkpoint_id
}
}
older_state = await main_agent.aget_state(source_config)
combined_values = older_state.values.copy()
if initial_messages:
combined_values["messages"] = list(combined_values.get("messages", [])) + initial_messages
await main_agent.aupdate_state(current_config, combined_values)
async def event_generator() -> AsyncGenerator[str, None]:
yield f"data: {json.dumps({'thread_id': target_thread_id, 'is_branch': is_branching, 'status': 'start'}, ensure_ascii=False)}\n\n"
new_messages = initial_messages[:] if not source_thread_id else []
new_messages.append(HumanMessage(content=request.message))
input_data = {
"messages": new_messages,
}
current_cp_id = None
async for stream in main_agent.astream(
input_data,
config=current_config,
stream_mode=["updates", "messages", "custom"], # 确保包含 "values"
subgraphs=True
):
# logger.info(f"Received event: {event}")
_, mode, chunks = stream
if mode == "updates":
# TODO 补充
print(f"[updates] {chunks}")
elif mode == "messages":
token, metadata = chunks
subagent_name = metadata.get('lc_agent_name', None)
payload_out = {
"node": subagent_name,
# "checkpoint_id": current_cp_id or "unknown", TODO 替换为checkpoint_idns
"is_delta": False,
"content": "",
"suggestions": [],
"type": ""
}
if isinstance(token, AIMessageChunk): # 默认回复 思考内容
reasoning = [b for b in token.content_blocks if b["type"] == "reasoning"]
text = [b for b in token.content_blocks if b["type"] == "text"]
if reasoning:
payload_out.update({
"type": "reasoning",
"is_delta": True,
"content": text,
"tool_call_chunk": token.tool_call_chunks[0] if token.tool_call_chunks else None
})
elif text:
payload_out.update({
"type": "text",
"is_delta": True,
"content": text,
"tool_call_chunk": token.tool_call_chunks[0] if token.tool_call_chunks else None
})
else:
payload_out.update({
"is_delta": True,
"tool_call_chunk": token.tool_call_chunks[0] if token.tool_call_chunks else None
})
yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n"
elif isinstance(token, ToolMessageChunk): # 工具返回
text = [b for b in token.content_blocks if b["type"] == "text"]
payload_out.update({
"type": "tool_text",
"is_delta": False,
"content": text,
"tool_name": token.name,
})
yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n"
elif isinstance(token, ToolMessage): # 工具返回
text = [b for b in token.content_blocks if b["type"] == "text"]
payload_out.update({
"type": "tool_text",
"is_delta": False,
"content": text,
"tool_name": token.name,
})
yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n"
else:
continue
elif mode == "custom":
token, metadata = chunks
subagent_name = metadata.get('lc_agent_name', None)
payload_out = {
"node": subagent_name,
# "checkpoint_id": current_cp_id or "unknown", TODO 替换为checkpoint_idns
"is_delta": False,
"content": "",
"suggestions": [],
"type": ""
}
delta = chunks.get("delta", "").strip()
if delta:
payload_out.update({
"type": chunks.get("type", ""),
"is_delta": True,
"content": delta,
})
yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n"
# elif channel == "updates":
# # 处理 updates非 interrupt 的部分)
# if isinstance(payload, dict):
# for update_node, update_content in payload.items():
# # 处理 reducer 包裹的值
# if isinstance(update_content, dict):
# for k, v in update_content.items():
# if hasattr(v, "value"):
# update_content[k] = v.value
#
# # 序列化 messages
# if isinstance(update_content, dict) and "messages" in update_content:
# msgs = []
# for m in update_content["messages"]:
# msgs.append({
# "type": m.__class__.__name__,
# "content": getattr(m, "content", ""),
# "name": getattr(m, "name", None),
# "tool_calls": getattr(m, "tool_calls", None),
# })
# update_content["messages"] = msgs
#
# yield f"data: {json.dumps({
# "node": "Supervisor", # 或 update_node
# "type": "updates",
# "content": update_content,
# "is_delta": False,
# "checkpoint_id": current_cp_id,
# }, ensure_ascii=False)}\n\n"
#
# elif channel == "custom":
else:
yield f"data: {json.dumps({'status': 'end'}, ensure_ascii=False)}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.get("/history/{thread_id}", response_model=HistoryResponse)
async def get_chat_history(thread_id: str):
"""
### 获取项目设计历史记录
此接口用于拉取指定 `thread_id` 下的所有历史状态快照。它是实现 **“版本回溯”** 和 **“方案对比”** 的核心数据来源。
#### 1. 功能说明
* **快照列表**: 返回该项目从启动至今的所有关键节点Checkpoints
* **版本定位**: 每个历史点都包含一个唯一的 `checkpoint_id`。
* **数据回溯**: 客户端获取此列表后,可以引导用户选择任意一个版本,并将其 `checkpoint_id` 传回 `/chat/stream` 接口以开启新的设计分支。
#### 2. 路径参数
* `thread_id`: 设计项目的唯一标识符(由 `/chat/stream` 首次调用时生成或指定)。
#### 3. 返回字段定义
* `thread_id`: 当前查询的项目ID。
* `history`: 历史记录数组,包含:
- `checkpoint_id`: 必填,回溯时使用的关键凭证。
- `last_message`: 该阶段的最后一条消息摘要(方便前端预览)。
- `node`: 产生该快照的节点名称(如 Designer, Visualizer
- `timestamp`: 逻辑步骤序号。
#### 4. 响应示例
```json
{
"thread_id": "proj_001",
"history": [
{
"checkpoint_id": "d82f3a12",
"last_message": "我想设计一款北欧风书架",
"node": "Supervisor",
"timestamp": 1
},
{
"checkpoint_id": "f4k92m1a",
"last_message": "建议使用浅色橡木材质,增加简约感...",
"node": "Designer",
"timestamp": 2
}
]
}
```
"""
config = {"configurable": {"thread_id": thread_id}, }
history_data = []
async for state in main_agent.aget_state_history(config):
msg_content = "Initial"
if state.values and "messages" in state.values:
msgs = state.values["messages"]
if msgs and len(msgs) > 0:
last_msg = msgs[-1]
# 获取内容并做摘要截断
content = getattr(last_msg, "content", str(last_msg))
msg_content = content
history_data.append(HistoryItem(
checkpoint_id=state.config["configurable"]["checkpoint_id"],
last_message=msg_content,
node=state.metadata.get("source"),
timestamp=state.metadata.get("step")
))
return HistoryResponse(thread_id=thread_id, history=history_data)
# try:
# except Exception as e:
# raise HTTPException(status_code=404, detail=f"History not found: {str(e)}")

View File

@@ -1,169 +0,0 @@
from pathlib import Path
from typing import AsyncGenerator, Dict, Any
from deepagents import create_deep_agent
from deepagents.backends import FilesystemBackend
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage, AIMessage, AIMessageChunk
from langchain_core.runnables import RunnableConfig
from langchain_qwq import ChatQwen
from src.core.config import settings
from src.server.agent.prompt import SYSTEM_PROMPT, visualizer_prompt, designer_prompt
from src.server.agent.state import AgentState
from src.server.agent.tools.generate_furniture_sketch import generate_furniture
from src.server.agent.tools.crawl_tool import crawl4ai_batch
from src.server.agent.tools.report_generator_tool import report_generator
from src.server.agent.tools.research_tool import topic_research
from src.server.agent.tools.structured_retrieval_tool import structured_retrieval
from src.server.agent.tools.terminate_tool import terminate
from src.server.agent.tools.user_persona_tool import manage_user_persona
from src.server.utils.generate_suggestion import generate_chat_suggestions
# 目前這個主程式檔案所在的目錄
MAIN_DIR = Path(__file__).resolve().parent
# 專案根目錄(因為 main.py 跟 tools/ 同級,所以 parent 就是根)
PROJECT_ROOT = MAIN_DIR
model = ChatQwen(
enable_thinking=False,
model="qwen3.5-flash",
max_tokens=3_000,
timeout=None,
max_retries=2,
api_key=settings.QWEN_API_KEY)
tools = [manage_user_persona, topic_research, crawl4ai_batch, structured_retrieval, report_generator, terminate]
research_agent = create_deep_agent(
model=model,
tools=tools,
system_prompt=SYSTEM_PROMPT,
backend=FilesystemBackend(
root_dir=str(PROJECT_ROOT / "agent_workspace"),
virtual_mode=False, # 重要:關掉虛擬模式 → 真的寫硬碟
)
)
# 辅助函数:根据配置动态获取 LLM
def get_model(config: RunnableConfig, streaming=False):
temp = config["configurable"].get("llm_temperature", 0.5)
return ChatQwen(
enable_thinking=False,
model="qwen3.5-flash",
max_tokens=3_000,
timeout=None,
max_retries=2,
temperature=temp,
api_key=settings.QWEN_API_KEY,
streaming=streaming
)
# --- 1. Designer Agent (设计顾问) ---
async def designer_node(state: AgentState, config: RunnableConfig):
"""负责细化设计需求,提供专业参数"""
model = get_model(config) # 获取带动态温度的模型
messages = state["messages"]
system_prompt = SystemMessage(content=designer_prompt)
should_suggest = len(state["messages"]) % 5 == 0
response = await model.ainvoke([system_prompt] + messages)
return {"messages": [response], "require_suggestion": should_suggest}
async def researcher_node(
state: AgentState,
config: RunnableConfig
) -> AsyncGenerator[Dict[str, Any], None]:
use_report = config["configurable"].get("use_report", False)
if not use_report:
yield {
"messages": [AIMessage(
content="深度报告功能未启用,请通过前端按钮触发。",
name="Researcher"
)],
"next": "Supervisor"
}
return
messages = state["messages"]
last_human = next((m for m in reversed(messages) if isinstance(m, HumanMessage)), None)
if not last_human:
yield {
"messages": [AIMessage(
content="深度研究节点:未找到有效的用户问题",
name="Researcher"
)],
"next": "Supervisor"
}
return
current_step = "正在启动深度报告生成..."
yield {
"messages": [AIMessage(
content="正在启动深度报告生成...",
name="Researcher",
additional_kwargs={
"current_step": current_step,
"streaming": True
}
)]
}
async for chunk in research_agent.astream(
{"messages": messages[-12:]},
config=config
):
if "messages" in chunk and isinstance(chunk["messages"], AIMessageChunk):
yield {
"messages": chunk["messages"], # 逐 token 追加
# 可以額外 yield 一些 metadata例如
# "node": "Researcher",
# "status": "thinking"
}
else:
yield chunk
# --- 3. Visualizer Agent (视觉专家) ---
async def visualizer_node(state: AgentState, config: RunnableConfig):
"""负责将自然语言转化为绘图 Prompt 并调用绘图工具"""
model = get_model(config, streaming=False)
tools = [generate_furniture]
llm_with_tools = model.bind_tools(tools)
messages = state["messages"]
system_prompt = SystemMessage(content=visualizer_prompt)
response = await llm_with_tools.ainvoke([system_prompt] + messages)
if response.tool_calls:
tool_call = response.tool_calls[0]
if tool_call["name"] == "generate_furniture":
img_url = await generate_furniture.ainvoke(tool_call["args"])
return {
"messages": [
response,
ToolMessage(content=img_url, tool_call_id=tool_call["id"]) # 标记这是一个图片结果
]
}
return {"messages": [response]}
# --- 4. Suggester Agent (推荐对话专家) ---
async def suggester_node(state: AgentState, config: RunnableConfig):
"""专门生成追问建议的节点,作为流程终点"""
model = get_model(config)
messages = state["messages"]
# 只需要分析最近的对话
suggestions = await generate_chat_suggestions(messages, model)
# 返回一个特殊消息,前端通过解析 additional_kwargs 获取按钮内容
return {
"messages": [
AIMessage(
content="",
additional_kwargs={"suggestions": suggestions},
name="Suggester"
)
]
}

View File

@@ -0,0 +1,19 @@
from typing import AsyncGenerator, Dict, Any
from langchain_core.messages import SystemMessage, AIMessage
from langchain_core.runnables import RunnableConfig
from src.server.agent.agents.init_llm import get_model
from src.server.agent.memory.memory_manager import MemoryManager
from src.server.agent.prompt import designer_prompt
from src.server.agent.state import AgentState
async def designer_node(state: AgentState, config: RunnableConfig):
"""负责细化设计需求,提供专业参数"""
model = get_model(config) # 获取带动态温度的模型
messages = MemoryManager.build_llm_context(state)
system_prompt = SystemMessage(content=designer_prompt)
response = await model.ainvoke([system_prompt] + messages)
return {"messages": [response]}

View File

@@ -0,0 +1,93 @@
from pathlib import Path
from typing import Dict, Any
from deepagents import create_deep_agent
from deepagents.backends import FilesystemBackend
from langchain_core.runnables import RunnableConfig
from langchain_qwq import ChatQwen
from pydantic import BaseModel, Field
from src.core.config import settings
from src.server.agent.prompt import SYSTEM_PROMPT
from src.server.agent.tools.crawl_tool import crawl4ai_batch
from src.server.agent.tools.report_generator_tool import report_generator
from src.server.agent.tools.research_tool import topic_research
from src.server.agent.tools.structured_retrieval_tool import structured_retrieval
from src.server.agent.tools.terminate_tool import terminate
from src.server.agent.tools.user_persona_tool import get_user_persona
MAIN_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = MAIN_DIR
class PersonaUpdate(BaseModel):
"""从对话中提取/更新的用户画像结构"""
persona: Dict[str, Any] = Field(
default_factory=dict,
description="键值对形式的用户画像,例如 {'风格偏好': '北欧简约', '预算范围': '8000-15000'}"
)
complete: bool = Field(
...,
description="当前画像是否足够完整,支持市场研究和设计"
)
question: str = Field(
default="",
description="如果不完整,这里是需要问用户的自然语言问题;否则为空字符串"
)
llm_supervisor = ChatQwen(
model="qwen3.5-flash",
max_tokens=3_000,
timeout=None,
max_retries=2,
api_key=settings.QWEN_API_KEY
)
model = ChatQwen(
enable_thinking=False,
model="qwen3.5-flash",
max_tokens=3_000,
timeout=None,
max_retries=2,
api_key=settings.QWEN_API_KEY)
tools = [get_user_persona, topic_research, crawl4ai_batch, structured_retrieval, report_generator, terminate]
research_agent = create_deep_agent(
model=model,
tools=tools,
system_prompt=SYSTEM_PROMPT,
backend=FilesystemBackend(
root_dir=str(PROJECT_ROOT / "agent_workspace"),
virtual_mode=False, # 重要:關掉虛擬模式 → 真的寫硬碟
)
)
persona_agent = ChatQwen(
model="qwen3.5-flash",
max_tokens=3_000,
timeout=None,
max_retries=2,
api_key="sk-799944b821bd4bfdb2f6188ebb52a76b").with_structured_output(PersonaUpdate) # 或用 .bind_tools + parser
summary_llm = ChatQwen(
model="qwen3.5-flash",
max_tokens=3_000,
timeout=None,
max_retries=2,
api_key="sk-799944b821bd4bfdb2f6188ebb52a76b")
def get_model(config: RunnableConfig, streaming=False):
temp = config["configurable"].get("llm_temperature", 0.5)
return ChatQwen(
enable_thinking=False,
model="qwen3.5-flash",
max_tokens=3_000,
timeout=None,
max_retries=2,
temperature=temp,
api_key=settings.QWEN_API_KEY,
streaming=streaming
)

View File

@@ -0,0 +1,154 @@
import json
from typing import Dict, Any
from datetime import datetime
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableConfig
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.types import interrupt
from pymongo import MongoClient
from src.core.config import MONGO_URI
from src.server.agent.agents.init_llm import persona_agent
from src.server.agent.memory.memory_manager import MemoryManager
from src.server.agent.state import AgentState
client = MongoClient(MONGO_URI)
db = client["furniture_agent_db"]
persona_collection = db["user_persona"]
EXTRACTION_PROMPT = ChatPromptTemplate.from_messages([
("system", """你是一个专为家具设计师服务的用户画像提取专家。
当前已知用户画像JSON格式
{current_persona_json}
任务:
1. 从下面所有用户消息中,提取或更新用户对家具设计的偏好信息。
只提取明确提到或强烈暗示的内容,不要臆想或添加默认值。
2. 输出更新后的完整 persona JSON只包含有值字段
推荐使用的键名(优先使用这些):
- "风格偏好" (如 "北欧""极简""工业风"
- "家具类型" (如 "沙发""餐桌""书柜""办公椅"
- "颜色偏好" (如 "原木色""白色""深灰""大地色系"
- 其他可选键:预算范围、空间大小、材质偏好、使用场景等
3. 判断当前画像是否“足够完整”来支持针对家具设计师的市场趋势报告:
- **必须条件**:至少包含 "风格偏好""家具类型""颜色偏好" 中的 2 项以上
- 如果缺少 2 项或以上核心信息,返回 complete: false并生成一个自然、礼貌、具体的追问中文优先询问缺失的核心项
- 如果核心三项中至少有 2 项已明确,返回 complete: true不需要追问
输出必须是严格的 JSON 对象,包含三个字段:
- "persona": 一个对象,键是画像属性,值是对应的字符串(或数组,如果有多个偏好)
- "complete": 布尔值 true 或 false
- "question": 字符串,如果 complete 为 true 则为空字符串,否则是具体的追问句子
示例输出结构(仅供参考,不要直接复制):
{{
"persona": {{
"风格偏好": "北欧简约",
"家具类型": "沙发",
"颜色偏好": "原木色 + 浅灰"
}},
"complete": true,
"question": ""
}}
"""),
("placeholder", "{messages}"),
])
def get_persona_from_mongo(thread_id: str) -> Dict[str, Any]:
doc = persona_collection.find_one({"thread_id": thread_id}, sort=[("_id", -1)])
if doc and "persona" in doc:
return doc["persona"]
return {}
def save_persona_to_mongo(thread_id: str, persona: Dict[str, Any], is_complete: bool):
try:
result = persona_collection.update_one(
{"thread_id": thread_id},
{
"$set": {
"persona": persona,
"persona_complete": is_complete,
"updated_at": datetime.utcnow(),
"last_update_reason": "persona_node_update"
}
},
upsert=True
)
print(f"[Persona Save] thread_id: {thread_id} | matched: {result.matched_count} | modified: {result.modified_count} | upserted: {result.upserted_id}")
except Exception as e:
print(f"[Persona Save Error] {e}")
def persona_node(state: AgentState, config: RunnableConfig):
thread_id = config["configurable"]["thread_id"]
# 读取已有画像MongoDB 优先)
persisted_persona = get_persona_from_mongo(thread_id)
current_persona = state.get("persona", persisted_persona)
# messages = state["messages"]
messages = MemoryManager.build_llm_context(state)
current_persona_json = json.dumps(current_persona, ensure_ascii=False, indent=None)
chain = EXTRACTION_PROMPT | persona_agent
result = chain.invoke({
"current_persona_json": current_persona_json,
"messages": messages,
})
updated_persona = result.persona
is_complete = result.complete
question = (result.question or "").strip()
updates = {
"persona": updated_persona,
"persona_complete": is_complete,
"persona_summary": json.dumps(updated_persona, ensure_ascii=False, indent=2),
}
# 持久化到 MongoDB
save_persona_to_mongo(thread_id, updated_persona, is_complete)
if is_complete:
updates["messages"] = messages + [AIMessage(
content=(
"用户画像已足够完整(风格、家具类型、颜色偏好已明确),并已保存到项目记录。\n\n"
"接下来是否需要我为您生成一份针对当前风格与家具类型的市场趋势报告?\n"
"回复“是”或“需要”即可开始生成;回复“不需要”或“先不用”则直接进入家具设计阶段。"
)
)]
return updates
# 不完整 → 询问(优先问核心三项)
if not question:
missing = []
core_keys = ["风格偏好", "家具类型", "颜色偏好"]
for key in core_keys:
if key not in updated_persona or not updated_persona[key]:
missing.append(key)
if missing:
question = f"为了更好地为您生成趋势报告,能否补充一下您对{''.join(missing)}的偏好呢?"
else:
question = "您对家具的风格、类型或颜色有什么特别的偏好吗?可以多说一些~"
updated_messages = messages + [AIMessage(content=question)]
approved = interrupt({
**updates,
"messages": updated_messages,
"persona_complete": False,
"__interrupt__": {
"type": "persona_question",
"question": question,
"node": "Persona",
"wait_for": "human_response",
# 可选:当前画像快照,便于前端显示或调试
"current_persona_snapshot": updated_persona
}
})
return approved

View File

@@ -0,0 +1,88 @@
from typing import AsyncGenerator, Dict, Any
from langchain_core.messages import AIMessage, HumanMessage, AIMessageChunk, ToolMessage
from langchain_core.runnables import RunnableConfig
from src.server.agent.agents.init_llm import research_agent
from src.server.agent.memory.memory_manager import MemoryManager
from src.server.agent.state import AgentState
async def researcher_node(state: AgentState, config: RunnableConfig) -> AsyncGenerator[Dict[str, Any], None]:
use_report = config["configurable"].get("use_report", False)
if not use_report:
yield {
"messages": [
AIMessage(
content="深度报告功能未启用,请通过前端按钮触发。",
name="Researcher"
)
],
"next": "Supervisor"
}
return
# messages = state["messages"]
messages = MemoryManager.build_llm_context(state)
safe_messages = [
m for m in messages
if isinstance(m, (HumanMessage, AIMessage))
]
last_human = next((m for m in reversed(messages) if isinstance(m, HumanMessage)), None)
if not last_human:
yield {
"messages": [
AIMessage(
content="深度研究节点:未找到有效的用户问题",
name="Researcher"
)
],
"next": "Supervisor"
}
return
current_step = "正在启动深度报告生成..."
yield {
"messages": [
AIMessage(
content="正在启动深度报告生成...",
name="Researcher",
additional_kwargs={
"current_step": current_step,
"streaming": True
}
)
]
}
async for chunk in research_agent.astream(
{"messages": safe_messages[-12:]},
config=config
):
if "messages" not in chunk:
continue
msgs = chunk["messages"]
if not isinstance(msgs, list):
continue
for m in msgs:
# 1⃣ token streaming
if isinstance(m, AIMessageChunk):
yield {"messages": [m]}
# 2⃣ tool result → 只 stream
elif isinstance(m, ToolMessage):
yield {
"custom": {
"type": "tool_result",
"tool_name": m.name,
"content": m.content
}
}
# 3⃣ 最终 AI message → 写入 state
elif isinstance(m, AIMessage):
yield {"messages": [m]}

View File

@@ -0,0 +1,29 @@
from langchain_core.messages import AIMessage
from langchain_core.runnables import RunnableConfig
from src.server.agent.agents.init_llm import get_model
from src.server.agent.memory.memory_manager import MemoryManager
from src.server.agent.state import AgentState
from src.server.utils.generate_suggestion import generate_chat_suggestions
async def suggester_node(state: AgentState, config: RunnableConfig):
"""专门生成追问建议的节点,作为流程终点"""
model = get_model(config)
# messages = state["messages"]
messages = MemoryManager.build_llm_context(state)
# 只需要分析最近的对话
suggestions = await generate_chat_suggestions(messages, model)
# 返回一个特殊消息,前端通过解析 additional_kwargs 获取按钮内容
return {
"messages": [
AIMessage(
content="",
additional_kwargs={"suggestions": suggestions},
name="Suggester"
)
]
}

View File

@@ -0,0 +1,30 @@
from langchain_core.messages import HumanMessage
from src.server.agent.agents.init_llm import summary_llm
from src.server.agent.memory.memory_manager import MemoryManager
from src.server.agent.state import AgentState
async def summary_node(state: AgentState):
messages = state["messages"]
if not MemoryManager.should_summarize(messages):
return {}
# 只总结旧消息
old_messages = messages[:-30]
text = "\n".join([m.content for m in old_messages if hasattr(m, "content")])
prompt = f"""
Summarize the following conversation briefly.
Focus on user goals, preferences and decisions.
{text}
"""
summary = await summary_llm.ainvoke([HumanMessage(content=prompt)])
return {
"conversation_summary": summary.content
}

View File

@@ -0,0 +1,87 @@
import random
from typing import Literal
from langchain_core.messages import ToolMessage
from langchain_core.runnables import RunnableConfig
from pydantic import BaseModel
from src.server.agent.agents.init_llm import llm_supervisor
from src.server.agent.memory.memory_manager import MemoryManager
from src.server.agent.state import AgentState
class RouteResponse(BaseModel):
next: Literal["Designer", "Persona", "Researcher", "Visualizer", "Suggester", "FINISH"]
def supervisor_node(state: AgentState, config: RunnableConfig):
if state.get("__end__", False):
return {"next": "FINISH"}
# 如果最后一条是 terminate 的结果,也结束
last_msg = state["messages"][-1] if state["messages"] else None
if isinstance(last_msg, ToolMessage) and last_msg.name == "terminate":
return {"next": "FINISH"}
configurable = config["configurable"]
use_report = configurable.get("use_report", False)
suggest_frequency = configurable.get("require_suggestion", 0.6)
messages = MemoryManager.build_llm_context(state)
# 读取关键状态(必须有!)
persona_complete = state.get("persona_complete", False)
print(f"persona_complete : {persona_complete}")
# 第一次对话或无消息 → 强制去 Persona 收集画像
if not messages:
return {"next": "Persona"}
# ── system prompt ── 加强语气 + 明确优先级
system_prompt = f"""
你是家具设计主管,**必须严格按以下优先级顺序**决定下一个节点,**不允许有任何例外**。
当前状态(必须严格遵守):
- persona_complete: {state.get("persona_complete", False)}
- 是否需要市场研究报告 (use_report): {'' if use_report else ''}
- 用户最新消息:请仔细阅读
绝对优先级规则(从高到低,必须逐条检查):
1. 如果 persona_complete == False**必须** 且 **只能** 选择 "Persona",其他节点一律不允许
2. 当 persona_complete == True 时,且用户确认需要报告(或未明确拒绝),优先选择 Researcher。报告的重点是当前风格、家具类型、颜色在市场上的流行趋势、材质搭配建议、设计师案例
3. 如果 use_report == False**绝对禁止** 选择 "Researcher"
4. 其他常见情况:
- 纯风格、尺寸、材质、功能、灵感讨论 → "Designer"
- 用户说想看图、效果图、草图 → "Visualizer"
- 对话自然结束或用户满意 → "FINISH"
- 需要给用户选项/建议按钮 → "Suggester"
当前用户需求是否明显需要市场报告?请严格判断,不要主观臆断。
如果条件 2 满足,**必须** 选 Researcher不要选 Designer。
输出时只选择一个 next不要多选或发明节点。
"""
chain = llm_supervisor.with_structured_output(RouteResponse)
try:
decision = chain.invoke([{"role": "system", "content": system_prompt}] + messages)
next_node = decision.next
except Exception as e:
# LLM 输出格式错误时的兜底
print(f"Supervisor LLM 决策失败: {e}")
next_node = "Persona" if not persona_complete else "Suggester"
# 强制安全阀:双重检查,防止 LLM 违规
if not persona_complete:
if next_node != "Persona":
print(f"强制修正persona 不完整LLM 选了 {next_node},改为 Persona")
next_node = "Persona"
elif next_node == "Researcher" and (not use_report or not persona_complete):
print(f"警告:非法选择 Researcher已强制改为 Suggester")
next_node = "Suggester"
# FINISH 时随机插入 Suggester保持原逻辑
if next_node == "FINISH" and suggest_frequency > 0 and random.random() < suggest_frequency:
next_node = "Suggester"
return {"next": next_node}

View File

@@ -0,0 +1,33 @@
# --- 3. Visualizer Agent (视觉专家) ---
from langchain_core.messages import SystemMessage, ToolMessage
from langchain_core.runnables import RunnableConfig
from src.server.agent.agents.init_llm import get_model
from src.server.agent.memory.memory_manager import MemoryManager
from src.server.agent.prompt import visualizer_prompt
from src.server.agent.state import AgentState
from src.server.agent.tools.generate_furniture_sketch import generate_furniture
async def visualizer_node(state: AgentState, config: RunnableConfig):
"""负责将自然语言转化为绘图 Prompt 并调用绘图工具"""
model = get_model(config, streaming=False)
tools = [generate_furniture]
llm_with_tools = model.bind_tools(tools)
# messages = state["messages"]
messages = MemoryManager.build_llm_context(state)
system_prompt = SystemMessage(content=visualizer_prompt)
response = await llm_with_tools.ainvoke([system_prompt] + messages)
if response.tool_calls:
tool_call = response.tool_calls[0]
if tool_call["name"] == "generate_furniture":
img_url = await generate_furniture.ainvoke(tool_call["args"])
return {
"messages": [
response,
ToolMessage(content=img_url, tool_call_id=tool_call["id"]) # 标记这是一个图片结果
]
}
return {"messages": [response]}

View File

@@ -1,109 +1,56 @@
import random
from typing import Literal from typing import Literal
from langchain_core.messages import AIMessage
from langchain_core.runnables import RunnableConfig
from langchain_qwq import ChatQwen
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from langgraph.graph import StateGraph, END, START from langgraph.graph import StateGraph, END, START
from pydantic import BaseModel from pydantic import BaseModel
from pymongo import MongoClient from pymongo import MongoClient
from src.core.config import MONGO_URI, settings from src.core.config import MONGO_URI, settings
from src.server.agent.agents.designer import designer_node
from src.server.agent.agents.persona import persona_node
from src.server.agent.agents.researcher import researcher_node
from src.server.agent.agents.suggester import suggester_node
from src.server.agent.agents.summary import summary_node
from src.server.agent.agents.supervisor import supervisor_node
from src.server.agent.agents.visualizer import visualizer_node
from src.server.agent.state import AgentState from src.server.agent.state import AgentState
from src.server.agent.agents import designer_node, researcher_node, visualizer_node, suggester_node
from langgraph.checkpoint.mongodb import MongoDBSaver from langgraph.checkpoint.mongodb import MongoDBSaver
# --- Supervisor (路由逻辑) ---
# 定义路由的输出结构,强制 LLM 选择一个
class RouteResponse(BaseModel):
# 将 FINISH 替换或增加 Suggester
next: Literal["Designer", "Researcher", "Visualizer", "Suggester", "FINISH"]
llm_supervisor = ChatQwen(
model="qwen3.5-flash",
max_tokens=3_000,
timeout=None,
max_retries=2,
api_key=settings.QWEN_API_KEY)
def supervisor_node(state: AgentState, config: RunnableConfig):
configurable = config["configurable"]
use_report = configurable.get("use_report", False)
suggest_frequency = configurable.get("require_suggestion", 0.6) # 0.0~1.0
messages = state["messages"]
if not messages:
return {"next": "Suggester"}
# ── system prompt 保持不变 ──
system_prompt = f"""你是家具设计主管,负责分配任务。
当前设定:
- 是否需要市场研究报告:{'' if use_report else ''}
严格遵守以下规则:
- 如果 **不需要** 市场研究报告use_report = False**绝对不能** 选择 Researcher
- 只有在 **明确需要** 市场报告、竞争分析、材质趋势、价格区间等外部资讯时,才选择 Researcher且 **必须** use_report = True
- 常见分配:
- 纯设计、风格、尺寸、材质建议 → Designer
- 需要生成图片、渲染 → Visualizer
- 需要产生建议按钮 → Suggester
- 需要市场报告 → Researcher但只有 use_report=True 时才允许)
- 对话已完整、无需继续 → FINISH
用户最后说了什么?请根据实际需求决定下一步。
"""
chain = llm_supervisor.with_structured_output(RouteResponse)
decision = chain.invoke([{"role": "system", "content": system_prompt}] + messages)
next_node = decision.next # 防空默认 FINISH
# 安全阀:禁止非法选择 Researcher
if next_node == "Researcher" and not use_report:
print("警告LLM 违规选择了 Researcher已强制改为 Suggester 或 FINISH")
next_node = "Suggester" if state.get("require_suggestion", False) else "FINISH"
# 核心改动:只有 LLM 决定 FINISH 时,才掷骰子看是否插入 Suggester
if next_node == "FINISH":
# 满足概率条件 → 插入 Suggester
if suggest_frequency > 0 and random.random() < suggest_frequency:
next_node = "Suggester"
return {"next": next_node}
# --- 构建 Graph --- # --- 构建 Graph ---
workflow = StateGraph(AgentState) workflow = StateGraph(AgentState)
workflow.add_node("Supervisor", supervisor_node) workflow.add_node("Supervisor", supervisor_node)
workflow.add_node("Designer", designer_node) workflow.add_node("Designer", designer_node)
workflow.add_node("Persona", persona_node)
workflow.add_node("Researcher", researcher_node) workflow.add_node("Researcher", researcher_node)
workflow.add_node("Visualizer", visualizer_node) workflow.add_node("Visualizer", visualizer_node)
workflow.add_node("Suggester", suggester_node) workflow.add_node("Suggester", suggester_node)
workflow.add_edge(START, "Supervisor") workflow.add_node("Summary", summary_node)
# 修改条件边映射 # workflow.add_edge(START, "Supervisor")
workflow.add_conditional_edges( workflow.add_edge(START, "Summary")
"Supervisor", workflow.add_edge("Summary", "Supervisor")
lambda state: state["next"],
{
"Designer": "Designer",
"Researcher": "Researcher",
"Visualizer": "Visualizer",
"Suggester": "Suggester", # 原本的 FINISH 现在指向 Suggester
"FINISH": END # 直接结束,不给建议
}
)
# 专家执行完依然回到 Supervisor
workflow.add_edge("Designer", "Supervisor") workflow.add_edge("Designer", "Supervisor")
workflow.add_edge("Persona", "Supervisor")
workflow.add_edge("Researcher", "Supervisor") workflow.add_edge("Researcher", "Supervisor")
workflow.add_edge("Visualizer", "Supervisor") workflow.add_edge("Visualizer", "Supervisor")
# 重点Suggester 可以是整个流程的终点 # 重点Suggester 可以是整个流程的终点
workflow.add_edge("Suggester", END) workflow.add_edge("Suggester", END)
# 修改条件边映射
workflow.add_conditional_edges(
"Supervisor",
lambda state: "FINISH" if state.get("__end__", False) or state["next"] == "FINISH" else state["next"],
{
"Designer": "Designer",
"Persona": "Persona",
"Researcher": "Researcher",
"Visualizer": "Visualizer",
"Suggester": "Suggester",
"FINISH": END
}
)
client = MongoClient(MONGO_URI) client = MongoClient(MONGO_URI)
checkpointer = MongoDBSaver( checkpointer = MongoDBSaver(
client=client["furniture_agent_db"], client=client["furniture_agent_db"],

View File

@@ -0,0 +1,60 @@
from typing import List
from langchain_core.messages import (
BaseMessage,
SystemMessage,
HumanMessage,
AIMessage
)
MAX_RECENT_MESSAGES = 25
SUMMARY_TRIGGER = 120
class MemoryManager:
@staticmethod
def split_messages(messages: List[BaseMessage]):
"""
分离 system / conversation
"""
system_msgs = []
conversation_msgs = []
for m in messages:
if isinstance(m, SystemMessage):
system_msgs.append(m)
else:
conversation_msgs.append(m)
return system_msgs, conversation_msgs
@staticmethod
def build_llm_context(state) -> List[BaseMessage]:
"""
构建发送给 LLM 的 context
"""
messages = state["messages"]
system_msgs, conversation_msgs = MemoryManager.split_messages(messages)
summary = state.get("conversation_summary")
recent_msgs = conversation_msgs[-MAX_RECENT_MESSAGES:]
context = []
context.extend(system_msgs)
if summary:
context.append(SystemMessage(content=f"Conversation Summary:\n{summary}"))
context.extend(recent_msgs)
return context
@staticmethod
def should_summarize(messages: List[BaseMessage]) -> bool:
"""
判断是否需要 summary
"""
return len(messages) > SUMMARY_TRIGGER

View File

@@ -1,78 +1,57 @@
SYSTEM_PROMPT = """ SYSTEM_PROMPT = """
You are "TrendAgent" - a focused, efficient design trend analysis agent. 你现在是 "TrendAgent",一个极度专注、高效的家具设计趋势分析代理。
Your ONLY goal: produce one high-quality Markdown trend report per user request. 你的**唯一目标**:针对用户一次请求,产出一份高质量的 Markdown 趋势报告。
你**只能**按照下面严格的执行流程工作,不允许有任何偏差。
TOOL ORDER & DISCIPLINE IS MANDATORY - DO NOT INVENT STEPS 核心铁律(违反即失效,必须严格执行):
- 永远保持自然、亲切、像资深家具设计师的对话语气
- 画像收集阶段**只靠自然对话****绝不调用任何工具**
- 一旦你判断用户画像已足够(风格、家具类型、颜色、材质至少 3 项明确),**立即停止所有追问**,说一句类似“好的,我已经掌握您的核心需求,现在开始规划并生成报告~”,然后**必须立刻进入规划阶段**
- **绝对禁止** 在画像完整后继续问任何问题(包括人群、预算、场景、尺寸、材质细节等)
- **绝对禁止** 使用任何流程化、机械、状态相关的词语,如 STATUS、Phase、请先完成、按照流程、现在进入下一步等
┌───────────────────────────────────────────────────────┐ 画像完整判断铁律(内心执行,永不告诉用户):
│ Phase 0 - Context & Persona (必须先完成) │ - 必须同时满足以下至少 3 项:
└───────────────────────────────────────────────────────┘ - 风格偏好(北欧、极简、现代、日式等)
- 家具类型(沙发、餐桌、床、书柜、灯具等)
- 颜色偏好(白色、原木色、深灰、莫兰迪色等)
- 材质偏好(棉麻、实木、皮革、金属等)
- 一旦达到 3 项,**立即**视为完整,**禁止**再问任何问题
Rules for Phase 0: 执行流程(**必须严格按此顺序,不可跳跃、不可重复、不可插入任何额外对话**
1. ALWAYS start with manage_user_persona(command="get")
2. If STATUS == "INCOMPLETE" or persona missing critical fields (Design Type, Style, Target Audience, Color Preference, etc.):
→ MUST call manage_user_persona(command="ask") to collect missing info
→ After user answers → call manage_user_persona(command="set", ...)
→ Loop until STATUS == "READY"
3. Only when STATUS == "READY" → proceed to Phase 1
4. Never assume or fabricate persona details
┌───────────────────────────────────────────────────────┐ 1. 画像收集阶段:
Phase 1 - Planning (必须执行一次且只能一次) │ - 只用自然对话补全信息
└───────────────────────────────────────────────────────┘ - 如果不足 3 项核心信息,顺势自然问 1-2 个最关键的问题
- 一旦足够,立即说“好的,我已经掌握核心需求,现在开始规划并生成报告~”,然后**必须立刻**进入第 2 步
When persona READY and user gave a clear trend request: 2. 规划阶段(**必须且只能执行一次**
1. Call write_todos EXACTLY ONCE with a strict plan containing: - **立即调用 get_user_persona 工具**,获取最新画像 JSON
- 36 concrete steps (numbered) - 根据返回的 persona 字段构造关键词(例如:"2025-2026 北欧 沙发 白色 棉麻 趋势"
- Which URLs/topics to research - **立即调用 write_todos 工具一次**,生成严格的 ToDo 列表,内容**必须且只能**是以下顺序:
- Expected output of each major tool 1. topic_research:搜索上面构造的关键词,返回 3-5 个高质量网址
- Final deliverable: one Markdown report 2. crawl4ai_batch批量爬取上面选定的网址
2. After receiving todos, you MUST follow this exact sequence unless impossible 3. structured_retrieval对爬取内容进行结构化提取重点设计趋势、材质创新、颜色应用、代表案例、品牌参考
3. Do NOT call any other tool until write_todos is done 4. report_generator基于提取内容生成完整 Markdown 报告
5. terminate
- **严禁** 添加任何其他步骤、询问用户、生成中间总结或额外对话
┌───────────────────────────────────────────────────────┐ 3. 执行阶段:
Phase 2 - Research & Collection │ - **严格按 write_todos 返回的顺序逐一调用工具**
└───────────────────────────────────────────────────────┘ - **不允许** 跳过任何步骤、重复调用、插入对话
Follow todos order: 4. 报告生成后:
- Use topic_research → get 38 high-quality URLs (add persona [Style] [Type] in query) - **直接调用 terminate** 结束流程
- Select best 36 URLs → call crawl4ai_batch ONCE with list
- Get file paths → call structured_retrieval ONCE with file_paths list
┌───────────────────────────────────────────────────────┐ 报告要求(必须遵守):
│ Phase 3 - Synthesis & Delivery │ - 每部分先写 **Conclusion First** 的核心洞察
└───────────────────────────────────────────────────────┘ - 在合适位置插入 [IMAGE_REF_xx] 占位符
- 所有内容基于真实检索内容,**绝不虚构**
After structured_retrieval summary received: 现在开始:
- If extracted item count ≥ 812 AND covers main aspects in todos → ready to report - 用自然亲切的语气直接回应用户消息
- Call report_generator ONCE (it reads local JSON/DB) - 如果画像不足 3 项核心信息,顺势自然问最关键的问题
- After report_generator success → call terminate - 一旦足够,**立即**说一句过渡语,然后**必须**调用 write_todos
- If data obviously insufficient → call topic_research again (max 1 extra round)
┌───────────────────────────────────────────────────────┐
│ HARD RULES - MUST OBEY │
└───────────────────────────────────────────────────────┘
• Never load full JSON/markdown into context - trust local storage
• Batch everything possible (crawl4ai_batch + structured_retrieval)
• Call tools in PHASE ORDER - no jumping, no repetition
• After report_generator → next action MUST be terminate
• If stuck > 4 steps without progress → call terminate with note "Incomplete - insufficient data"
• Never hallucinate trend data - base everything on retrieved content
• Report must start each section with **Conclusion First** insight
• Include [IMAGE_REF_xx] placeholders where visuals were extracted
Current status: Phase 0
"""
designer_prompt = """
你是家具设计团队的主管(Supervisor)。
请根据用户的意图,选择最合适的专家:
- Designer: 设计建议、参数细化、闲聊、问候。
- Visualizer: 绘图、看草图。
- Researcher: 市场报告、趋势。
只需输出专家名称。
""" """
visualizer_prompt = """ visualizer_prompt = """
@@ -104,6 +83,7 @@ Prompt 生成要求(仅供内部参考,必须全部做到):
- 最后立即调用工具generate_furniture参数 prompt = 你刚才输出的完整内容 - 最后立即调用工具generate_furniture参数 prompt = 你刚才输出的完整内容
- 不要做其他任何说明或聊天 - 不要做其他任何说明或聊天
""" """
designer_prompt = """ designer_prompt = """
你是一位资深的家具设计师,经验丰富、审美一流、沟通温暖且高效。 你是一位资深的家具设计师,经验丰富、审美一流、沟通温暖且高效。
你的核心目标:快速理解用户想法,并用最合适的方式推进设计。 你的核心目标:快速理解用户想法,并用最合适的方式推进设计。

View File

@@ -5,10 +5,7 @@ from src.server.agent.graph import app
async def async_main(): async def async_main():
config = {"configurable": {"thread_id": "project_alpha"}} config = {"configurable": {"thread_id": "project_alpha12345", "use_report": True}}
print("測試模式已啟動 (輸入 'exit' 離開,'history' 查看歷史並回溯)")
use_report = input("是否启用深度报告?(y/n): ").lower() == 'y'
while True: while True:
user_input = input("\n👤 輸入訊息: ").strip() user_input = input("\n👤 輸入訊息: ").strip()

View File

@@ -1,5 +1,5 @@
import operator import operator
from typing import Annotated, Sequence, TypedDict, Union, Optional from typing import Annotated, Sequence, TypedDict, Union, Optional, Dict, Any
from langchain_core.messages import BaseMessage from langchain_core.messages import BaseMessage
@@ -8,4 +8,8 @@ class AgentState(TypedDict):
messages: Annotated[Sequence[BaseMessage], operator.add] messages: Annotated[Sequence[BaseMessage], operator.add]
# next 存储 Supervisor 决定的下一步是谁 # next 存储 Supervisor 决定的下一步是谁
next: str next: str
persona: Dict[str, Any] # 存储提取出的结构化画像,例如 {"风格偏好": "北欧", "预算": "8000-12000", ...}
persona_summary: str # 可选LLM 对当前画像的自然语言总结,便于 prompt 使用
persona_complete: bool # Supervisor 用这个判断是否能去 Researcher
require_suggestion: bool # 是否需要建议按钮 require_suggestion: bool # 是否需要建议按钮
__end__: bool # 新增这个字段,默认 False

View File

@@ -0,0 +1,20 @@
from typing import Optional, Dict, Any
from pydantic import BaseModel
class ToolResult(BaseModel):
"""
DeepAgents Tool 返回标准结构
"""
# 返回给 LLM / 用户的内容
content: Optional[str] = None
# 是否出错
success: bool = True
# 工具元信息(推荐放 tool_name / path / cost 等)
metadata: Optional[Dict[str, Any]] = None
# 是否终止 agent
terminate: bool = False

View File

@@ -1,118 +1,189 @@
import time import time
import asyncio import asyncio
from typing import List from typing import List, Dict, Any
from urllib.parse import urlparse from urllib.parse import urlparse
from pathlib import Path from pathlib import Path
import uuid
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode
from langchain_core.tools import tool from langchain_core.tools import tool
# ─────────────── 重要:計算路徑 ─────────────── # ─────────────────────────────────────
# 目前這個檔案 (crawl4ai_batch.py) 所在的目錄 # 路径配置
TOOL_DIR = Path(__file__).resolve().parent # ─────────────────────────────────────
# 專案根目錄(假設 tools 資料夾與主程式同級) TOOL_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = TOOL_DIR.parent PROJECT_ROOT = TOOL_DIR.parent
# 儲存爬取結果的目錄(你可以自由決定放在哪裡) # DeepAgents 推荐目录
# 建議選項 A放在專案根目錄下的 workspace/raw_data SAVE_DIR = PROJECT_ROOT / "agent_workspace" / "raw_data"
SAVE_DIR = PROJECT_ROOT / "workspace" / "raw_data"
# 建議選項 B如果你打算讓 deep agent 直接讀取,建議放在 agent_workspace 底下
# SAVE_DIR = PROJECT_ROOT / "agent_workspace" / "raw_data"
# 確保目錄存在
SAVE_DIR.mkdir(parents=True, exist_ok=True) SAVE_DIR.mkdir(parents=True, exist_ok=True)
# ─────────────────────────────────────
# Browser 配置
# ─────────────────────────────────────
# ──────────────────────────────────────────────── browser_config = BrowserConfig(
headless=True,
verbose=False,
java_script_enabled=True,
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/118.0 Safari/537.36"
),
)
run_config = CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
word_count_threshold=5,
excluded_tags=["script", "style", "nav", "footer"],
remove_overlay_elements=True,
process_iframes=True,
)
@tool # ─────────────────────────────────────
async def crawl4ai_batch(urls: List[str]) -> str: # URL → 文件名
""" # ─────────────────────────────────────
高性能网页爬虫,支持并行处理多个 URL。
爬取后的 Markdown 内容将保存到本地 workspace/raw_data 目录中。 def build_filename(url: str) -> str:
返回执行结果摘要和保存的文件路径列表。 parsed = urlparse(url)
"""
domain = parsed.netloc.replace("www.", "").replace(".", "_")
path_part = parsed.path.strip("/").replace("/", "_")[:50] or "index"
ts = int(time.time())
rand = uuid.uuid4().hex[:6]
return f"{ts}_{rand}_{domain}_{path_part}.md"
# ─────────────────────────────────────
# 单个 URL 抓取
# ─────────────────────────────────────
async def crawl_one(crawler, url: str, sem: asyncio.Semaphore) -> Dict[str, Any]:
async with sem:
try:
result = await crawler.arun(url=url, config=run_config)
if not result.success:
return {
"url": url,
"success": False,
"error": f"status={getattr(result, 'status_code', 'unknown')}"
}
markdown = result.markdown or ""
if len(markdown) < 500:
return {
"url": url,
"success": False,
"error": "content too short"
}
filename = build_filename(url)
filepath = SAVE_DIR / filename
header = (
f"<!-- Source: {url} -->\n"
f"<!-- Saved: {time.strftime('%Y-%m-%d %H:%M:%S')} -->\n\n"
)
with open(filepath, "w", encoding="utf-8") as f:
f.write(header + markdown)
return {
"url": url,
"success": True,
"file": str(filepath)
}
except Exception as e:
return {
"url": url,
"success": False,
"error": str(e)
}
# ─────────────────────────────────────
# Async 主逻辑
# ─────────────────────────────────────
async def _crawl4ai_batch(urls: List[str]) -> Dict[str, Any]:
urls = list(set(urls)) # 去重
if not urls: if not urls:
return "❌ 错误: 未提供任何 URL。" return {"error": "no urls"}
# print(f"🕷️ 正在并行爬取 {len(urls)} 个 URL...") sem = asyncio.Semaphore(5) # 并发限制
# print(f"儲存目錄: {SAVE_DIR}")
browser_config = BrowserConfig( async with AsyncWebCrawler(config=browser_config) as crawler:
headless=True,
verbose=False,
java_script_enabled=True,
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/118.0.5993.118 Safari/537.36",
proxy=None, # 可选,如果需要代理填 "http://user:pass@ip:port"
)
run_config = CrawlerRunConfig( tasks = [
cache_mode=CacheMode.BYPASS, crawl_one(crawler, url, sem)
word_count_threshold=5, for url in urls
excluded_tags=["script", "style", "nav", "footer"], ]
remove_overlay_elements=True,
process_iframes=True,
)
results_summary = [] results = await asyncio.gather(*tasks)
saved_files = []
success_files = []
summary = []
for r in results:
if r["success"]:
success_files.append(r["file"])
summary.append(f"{r['url']}")
else:
summary.append(f"{r['url']} ({r['error']})")
return {
"saved_files": success_files,
"count": len(success_files),
"summary": summary,
}
# ─────────────────────────────────────
# Tool同步
# ─────────────────────────────────────
@tool
def crawl4ai_batch(urls: List[str]) -> str:
"""
Batch crawl webpages and save their content as markdown files.
Args:
urls: List of webpage URLs to crawl.
Returns:
A summary of crawling results and saved file paths.
"""
try: try:
async with AsyncWebCrawler(config=browser_config) as crawler: result = asyncio.run(_crawl4ai_batch(urls))
tasks = [crawler.arun(url=url, config=run_config) for url in urls]
crawl_results = await asyncio.gather(*tasks, return_exceptions=True)
for i, result in enumerate(crawl_results): if "error" in result:
url = urls[i] return f"❌ Error: {result['error']}"
if isinstance(result, Exception): output = [
results_summary.append(f"❌ 抓取失败 {url}: {str(result)}") "### 批量抓取完成 ###",
continue f"成功保存文件: {result['count']}",
f"保存目录: {SAVE_DIR}",
"",
"抓取详情:"
]
if result.success: output.extend(result["summary"])
markdown_content = result.markdown or ""
if len(markdown_content) < 500: if result["saved_files"]:
results_summary.append(f"⏩ 跳过 {url} (内容过短)") output.append("\n可读取文件:")
continue output.extend(result["saved_files"])
# 生成檔名 return "\n".join(output)
parsed = urlparse(url)
domain = parsed.netloc.replace("www.", "").replace(".", "_")
path_part = parsed.path.strip("/").replace("/", "_")[:50] or "index"
filename = f"{int(time.time())}_{domain}_{path_part}.md"
# 完整檔案路徑
filepath = SAVE_DIR / filename
# 寫入檔案
with open(filepath, "w", encoding="utf-8") as f:
header = f"<!-- Source: {url} -->\n<!-- Saved: {time.strftime('%Y-%m-%d %H:%M:%S')} -->\n\n"
f.write(header + markdown_content)
saved_files.append(str(filepath)) # 建議轉成字串
results_summary.append(f"✅ 成功: {url}{filepath}")
else:
status = getattr(result, 'status_code', '未知错误')
results_summary.append(f"❌ 失败: {url} (状态码: {status})")
except Exception as e: except Exception as e:
return f"🚨 爬虫系统崩溃: {str(e)}" return f"🚨 爬虫系统异常: {str(e)}"
# 回傳給 agent 的結果
final_output = (
f"### 批量抓取完成 ###\n"
f"已成功保存 {len(saved_files)} 个文件。\n"
f"儲存目錄: {SAVE_DIR}\n"
f"详情:\n" + "\n".join(results_summary)
)
if saved_files:
final_output += "\n\n已保存的文件列表(可供後續讀取):\n" + "\n".join(saved_files)
return final_output

View File

@@ -1,3 +1,5 @@
import json
import logging
import uuid import uuid
from google.oauth2 import service_account from google.oauth2 import service_account
from langchain_core.tools import tool from langchain_core.tools import tool
@@ -9,6 +11,7 @@ from minio import Minio
from src.core.config import settings from src.core.config import settings
from src.server.utils.new_oss_client import oss_upload_image from src.server.utils.new_oss_client import oss_upload_image
logger = logging.getLogger(__name__)
# 初始化全局凭证和客户端 # 初始化全局凭证和客户端
creds = service_account.Credentials.from_service_account_file( creds = service_account.Credentials.from_service_account_file(
settings.GOOGLE_GENAI_USE_VERTEXAI, settings.GOOGLE_GENAI_USE_VERTEXAI,
@@ -62,8 +65,30 @@ def generate_furniture(prompt: str) -> str:
# 4. 构造访问链接 (如果是私有 bucket需使用 presigned_get_object) # 4. 构造访问链接 (如果是私有 bucket需使用 presigned_get_object)
# 这里简单示例为直接访问地址 # 这里简单示例为直接访问地址
image_url = f"{bucket}/{object_name}" image_url = f"{bucket}/{object_name}"
return image_url return json.dumps(
{
"tool_name": "generate_furniture",
"data": image_url,
"tool_status": "success"
},
ensure_ascii=False
)
else: else:
return "图片生成成功,但上传至存储服务器失败。" return json.dumps(
{
"tool_name": "generate_furniture",
"data": "图片生成成功,但上传至存储服务器失败。",
"tool_status": "error"
},
ensure_ascii=False
)
except Exception as e: except Exception as e:
return f"绘图流程异常: {str(e)}" logger.warning(e)
return json.dumps(
{
"tool_name": "generate_furniture",
"data": f"绘图流程异常",
"tool_status": "error"
},
ensure_ascii=False
)

View File

@@ -105,7 +105,7 @@ Input Data:
# ========================= # =========================
# 调用 LLM # 调用 LLM
# ========================= # =========================
writer({"type": "report_start", "topic": report_topic, "language": language}) # writer({"type": "report_start", "topic": report_topic, "language": language})
full_report = "" full_report = ""
try: try:
@@ -116,7 +116,7 @@ Input Data:
if chunk.content: # Gemini 返回的 chunk.content if chunk.content: # Gemini 返回的 chunk.content
delta = chunk.content delta = chunk.content
full_report += delta full_report += delta
writer({"type": "report_delta", "delta": delta}) # ← 实时推送给前端 # writer({"type": "report_delta", "delta": delta}) # ← 实时推送给前端
except Exception as e: except Exception as e:
error_msg = f"LLM generation failed: {str(e)}" error_msg = f"LLM generation failed: {str(e)}"
writer({"type": "report_error", "message": error_msg}) writer({"type": "report_error", "message": error_msg})

View File

@@ -12,7 +12,7 @@ TAVILY_API_KEY = settings.TAVILY_API_KEY
@tool @tool
async def topic_research(topic: str, max_urls: int = 15) -> str: async def topic_research(topic: str, max_urls: int = 5) -> str:
""" """
深度调研工具。该工具会利用 Tavily 搜索引擎针对特定主题进行多维度搜索。 深度调研工具。该工具会利用 Tavily 搜索引擎针对特定主题进行多维度搜索。
它会自动生成针对性的搜索词(包含年份和趋势),并返回去重后的高质量 URL 列表。 它会自动生成针对性的搜索词(包含年份和趋势),并返回去重后的高质量 URL 列表。

View File

@@ -17,22 +17,13 @@ class TerminateInput(BaseModel):
@tool(args_schema=TerminateInput) @tool(args_schema=TerminateInput)
def terminate(status: str, reason: str = "") -> str: def terminate(status: str, reason: str = "") -> dict:
""" """
當任務完成、報告已生成,或無法繼續進行時,呼叫此工具來結束本次互 终止本次互
使用時機:
- 已經成功產生最終報告report_generator 已完成)
- 遇到無法解決的錯誤或缺少關鍵資訊
- 用戶需求已完全滿足
請在呼叫前確保所有必要步驟已完成,並在 reason 中簡單說明結束原因。
""" """
if status not in ("success", "failure"): return {
status = "failure" # 防呆 "messages": [], # 清空追加消息
"__end__": True, # 结束标记
msg = f"互動已終止,狀態:{status.upper()}" "status": status,
if reason: "reason": reason
msg += f"\n原因:{reason}" }
return msg

View File

@@ -1,96 +1,54 @@
import json from datetime import datetime
import os
from typing import List, Literal, Optional, Dict, Any from langchain_core.runnables import RunnableConfig
from langchain_core.tools import tool from langchain_core.tools import tool
import json
# 定义存储路径 from pymongo import MongoClient
DB_PATH = os.path.join("workspace", "user_persona.json")
from src.core.config import MONGO_URI
def _load_store() -> Dict[str, Any]: client = MongoClient(MONGO_URI)
"""从本地文件加载画像数据""" db = client["furniture_agent_db"]
if os.path.exists(DB_PATH): persona_collection = db["user_persona"]
try:
with open(DB_PATH, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
return {}
def _save_store(data: Dict[str, Any]):
"""将画像数据保存到本地文件"""
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
with open(DB_PATH, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
@tool @tool
def manage_user_persona( def get_user_persona(config: RunnableConfig) -> str:
command: Literal["set", "update", "get", "clear"],
design_type: Optional[str] = None,
style_preference: Optional[str] = None,
budget_range: Optional[str] = None,
color_palette: Optional[List[str]] = None,
target_audience: Optional[str] = None,
extra_requirements: Optional[str] = None
) -> str:
""" """
用户画像与设计偏好管理工具 获取当前对话线程的用户画像信息
用于设定、更新、获取或重置用户的设计上下文(如风格、预算、颜色)。
Agent 在开始调研前必须先调用 get 获取画像,若关键信息缺失需引导用户补充。 参数:
- thread_id: 可选当前线程ID。如果不传默认使用当前会话的 thread_id
返回JSON 字符串,包含以下字段:
- persona: Dict用户画像风格偏好、家具类型、颜色偏好等
- persona_complete: bool画像是否已足够完整
- last_updated: str最后更新时间
""" """
# 每次调用都重新读取,确保多进程或重启后数据一致 thread_id = config["configurable"]["thread_id"]
store = _load_store() if thread_id is None:
thread_id = "current_thread_id_placeholder"
if command == "clear": doc = persona_collection.find_one(
if os.path.exists(DB_PATH): {"thread_id": thread_id},
os.remove(DB_PATH) sort=[("_id", -1)] # 最新一条
return "✅ 用户个性化模板已从本地文件清空。" )
if command == "get": if not doc or "persona" not in doc:
if not store: return json.dumps({
return "⚠️ [缺失信息] 当前尚未配置画像。请询问用户:设计类型(如沙发)、风格偏好(如极简)等。" "persona": {},
"persona_complete": False,
"last_updated": None,
"message": "当前线程暂无用户画像信息"
}, ensure_ascii=False, indent=2)
# 格式化输出供 Agent 阅读 last_updated = doc.get("updated_at")
res = [ if isinstance(last_updated, datetime):
"--- 👤 实时用户画像 (本地存储) ---", last_updated = last_updated.strftime('%Y-%m-%d %H:%M:%S')
f"🎯 类型: {store.get('design_type', '未设定')}",
f"🎨 风格: {store.get('style_preference', '未设定')}",
f"💰 预算: {store.get('budget_range', '未设定')}",
f"🌈 色系: {', '.join(store.get('color_palette', [])) or '未设定'}",
f"👥 受众: {store.get('target_audience', '未设定')}",
f"📝 需求: {store.get('extra_requirements', '未设定')}",
"-----------------------"
]
# 逻辑检查 return json.dumps({
if not store.get('design_type') or not store.get('style_preference'): "persona": doc["persona"],
res.append("\n⚠️ 关键信息缺失,建议补充 '设计类型''风格偏好'") "persona_complete": doc.get("persona_complete", False),
return "\n".join(res) "last_updated": last_updated,
}, ensure_ascii=False, indent=2)
if command in ["set", "update"]:
if command == "set":
store = {} # 重置内存中的字典
# 提取传入的非空参数
update_data = {
"design_type": design_type,
"style_preference": style_preference,
"budget_range": budget_range,
"color_palette": color_palette,
"target_audience": target_audience,
"extra_requirements": extra_requirements
}
# 更新有效字段
for k, v in update_data.items():
if v is not None:
store[k] = v
# 保存到文件
_save_store(store)
return f"✅ 本地画像已同步。当前配置:\n{json.dumps(store, ensure_ascii=False, indent=2)}"
return "❌ 错误:未知命令。"

View File

View File

@@ -0,0 +1,23 @@
from langchain_qwq import ChatQwen
from src.core.config import settings
llm = ChatQwen(
model="qwen3.5-flash",
max_tokens=3_000,
timeout=None,
max_retries=2,
enable_thinking=False,
api_key=settings.QWEN_API_KEY
)
title_llm = ChatQwen(
model="qwen-plus",
max_tokens=3_000,
timeout=None,
max_retries=2,
streaming=False,
temperature=0.1,
top_p=0.8,
api_key=settings.QWEN_API_KEY
)

View File

@@ -0,0 +1,51 @@
from pathlib import Path
from deepagents import create_deep_agent
from deepagents.backends import FilesystemBackend
from langchain.agents.middleware import SummarizationMiddleware
from langgraph.checkpoint.mongodb import MongoDBSaver
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from pymongo import MongoClient
from src.core.config import MONGO_URI
from src.server.deep_agent.agents.painter import painter_subagent
from src.server.deep_agent.agents.researcher import research_subagent
from src.server.deep_agent.agents.user_profile import user_profile_subagent
from src.server.deep_agent.init_prompt import build_system_prompt
from src.server.deep_agent.tools.report_generator_tool import llm
TOOL_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = TOOL_DIR.parent
client = MongoClient(MONGO_URI)
checkpointer = MongoDBSaver(
client=client["furniture_agent_db"],
db_name="fida_agent_db",
collection_name="fida_agent_collection",
serde=JsonPlusSerializer(pickle_fallback=True), # ← 關鍵這一行
)
subagents = [
painter_subagent,
research_subagent,
user_profile_subagent
]
def build_main_agent(use_report):
main_agent = create_deep_agent(
model=llm,
system_prompt=build_system_prompt(use_report=use_report),
subagents=subagents,
checkpointer=checkpointer,
backend=FilesystemBackend(
root_dir=str(PROJECT_ROOT / "agent_workspace"),
virtual_mode=False, # 重要:關掉虛擬模式 → 真的寫硬碟
),
middleware=[
SummarizationMiddleware(
model=llm,
trigger=("tokens", 3000),
keep=("messages", 100),
),
],
)
return main_agent

View File

@@ -0,0 +1,22 @@
from langchain.agents.middleware import wrap_tool_call
from src.server.deep_agent.agents.init_llm import llm
from src.server.deep_agent.init_prompt import build_painter_prompt
from src.server.deep_agent.tools.generate_furniture_sketch import generate_furniture
@wrap_tool_call
async def log_tool_calls(request, handler):
"""Intercept and log every tool call - demonstrates cross-cutting concern."""
print(request)
return handler(request)
painter_subagent = {
"name": "painter_subagent",
"description": "理解用户意图使用prompt,调用generate_furniture工具生成家具sketch草图.",
"system_prompt": build_painter_prompt(),
"tools": [generate_furniture],
"model": llm,
# "middleware": [log_tool_calls],
}

View File

@@ -0,0 +1,21 @@
from src.server.deep_agent.agents.init_llm import llm
from src.server.deep_agent.init_prompt import build_researcher_prompt
from src.server.deep_agent.tools.crawl_tool import crawl4ai_batch
from src.server.deep_agent.tools.report_generator_tool import report_generator
from src.server.deep_agent.tools.research_tool import topic_research
from src.server.deep_agent.tools.structured_retrieval_tool import structured_retrieval
from src.server.deep_agent.tools.user_persona_tool import query_report_profile
research_subagent = {
"name": "research-agent",
"description": "通过网络搜索对家具设计开展深度研究并整合结论",
"system_prompt": build_researcher_prompt(),
"tools": [
query_report_profile,
topic_research,
crawl4ai_batch,
structured_retrieval,
report_generator
],
"model": llm
}

View File

@@ -0,0 +1,15 @@
from src.server.deep_agent.agents.init_llm import llm
from src.server.deep_agent.init_prompt import build_user_persona_prompt
from src.server.deep_agent.tools.user_persona_tool import query_report_profile, update_report_profile, check_profile_complete
user_profile_subagent = {
"name": "user_profile_subagent",
"description": "收集用户报告画像并存储到MongoDB",
"system_prompt": build_user_persona_prompt(),
"model": llm,
"tools": [
query_report_profile,
update_report_profile,
check_profile_complete,
],
}

View File

@@ -0,0 +1,141 @@
def build_system_prompt(use_report):
system_prompt = f"""
你是主调度 AgentSupervisor负责理解用户意图并选择合适的子Agent。
当前参数:
use_report = {use_report}
系统中存在两个相关子Agent
1. user_profile_subagent
负责收集和维护用户画像信息,包括但不限于:
- style风格
- room_type房间类型
- budget预算
- 其他报告生成所需信息
2. research-subagent
负责生成完整报告、调研、总结、分析。
3. painter_subagent
负责根据用户描述,构造适用于生成家具sketch的prompt,使用prompt用工具生成图片.
========================
执行规则
========================
【1】当用户请求报告 / 调研 / 分析 / 总结时:
先判断是否已经具备足够的用户画像信息。
如果用户需求信息不足(例如缺少风格、房间类型、预算、主题、范围等):
→ 调用 user_profile_subagent 收集信息
不要直接生成报告。
如果用户画像信息已经完整:
→ 调用 research-subagent 生成报告。
------------------------
【2】当 use_report = False 时:
- 严禁调用 research-subagent
- 如果用户明确请求报告、调研、总结、分析:
请礼貌回复:
"报告功能当前未开启,你可以打开 use_report=True 后我来帮你生成报告。"
- 其他普通问题可以正常回答或调用其他子Agent。
------------------------
【3】用户画像优先级规则
只要用户输入包含以下情况:
- 表达设计需求
- 提供偏好信息(例如风格、预算、房间类型)
- 修改之前的偏好
- 补充报告信息
都应该优先调用:
user_profile_subagent
用于更新或收集用户画像。
------------------------
【4】调度原则
- user_profile_subagent 只负责 **信息收集**
- research-subagent 只负责 **报告生成**
不要混用职责。
========================
严格输出规则
========================
- 当生成图片时绝对不要输出图片路径、file:// 地址、URL、本地链接
- 只输出文字描述,不输出任何图片链接或路径
"""
return system_prompt
def build_painter_prompt():
prompt = """
你是一名专业的prompt优化专家专注于家具设计草图生成。你的任务是
1. 分析用户查询,理解核心意图,包括家具类型、风格、尺寸、颜色、材料等关键元素
2. 基于意图优化并生成一个详细、精确的prompt适合用于AI图片生成工具创建家具sketch草图例如线条简洁、手绘风格、焦点在设计细节上
3. 使用优化的prompt调用图片生成工具生成并返回草图图片
4. 如果需要,建议额外变体或改进
输出格式:
- 用户意图总结12段
- 优化后的prompt完整文本
- 生成的图片描述(如果工具返回)
- 建议改进(项目符号,可选)
【严格输出规则】
- 当生成图片时,**绝对不要输出图片路径、file:// 地址、URL、本地链接**。
- 只输出文字描述,不输出任何图片链接或路径。
"""
return prompt
def build_researcher_prompt():
prompt = """
你是一名专业的家具设计研究员。你的任务是:
【0】获取用户画像
- 首先调用 get_user_profile 工具,获取当前用户画像信息(如风格、房间类型、预算等)。
- 根据用户画像,生成五个与用户需求和偏好高度相关的研究词条。
【1】关键词拆解
1. 将研究主题结合用户画像拆解为可搜索的查询关键词
2. 将关键词组合成五个待搜索的词条
【2】搜索与爬取
3. 使用 topic_research 工具搜索这五个词条获取相关、权威的网址
4. 使用 crawl4ai_batch 批量爬取网址(仅可调用一次,禁止重复调用)
【3】结构化处理与报告
5. 使用 structured_retrieval 对爬取内容进行结构化提取(重点:设计趋势、材质创新、颜色应用、代表案例、品牌参考)
6. 使用 report_generator 基于提取内容生成完整 Markdown 报告
【严格工具调用规则】:
- 调用顺序必须严格get_user_profile → topic_research → crawl4ai_batch仅一次 → structured_retrieval → report_generator。
- 不得跳回前面步骤或重复任何工具。
- 如果爬取结果为空或极少,直接说明:
“由于部分来源暂时不可访问,本报告基于有限可用信息生成,可能不够全面。如需更完整资料,请提供具体网址或调整需求。”
- 一旦生成 report_generator 的输出,就视为任务完成,直接结束,不要再思考或调用其他工具。
- crawl4ai_batch 最多只能调用一次,即使部分网址失败,也禁止再次调用 crawl4ai_batch 或 topic_research。
现在开始严格执行以上规则。
"""
return prompt
def build_user_persona_prompt():
prompt = """
你是用户画像收集助手。
你的任务是从用户对话中理解并提取报告画像信息,包括但不限于:
- style装修风格
- room_type房间类型
- budget预算
工作流程:
1. 先调用 query_report_profile 查询当前画像
2. 从用户输入中理解是否包含新的画像信息
3. 如果有新的信息,合并旧画像并调用 update_report_profile 更新
4. 调用 check_profile_complete 判断是否完整
5. 如果缺少字段,引导用户补充
6. 如果完整,回复:
"画像收集完成,即将为你生成报告!"
注意:
- 不要编造信息
- 不要覆盖已有字段,除非用户明确修改
- 只负责画像收集,不生成报告
"""
return prompt

View File

@@ -0,0 +1,131 @@
import asyncio
import uuid
from langchain_core.messages import AIMessageChunk, ToolMessageChunk, ToolMessage
from src.server.deep_agent.agents.main_agent import build_main_agent
agent = build_main_agent(use_report=True)
async def continuous_chat():
thread_id = str(uuid.uuid4())
print("===== 家具设计助手(支持持续对话+记忆)=====")
print("输入 'exit''退出' 结束对话\n")
while True:
user_input = input("你:") # 注意input() 在异步中仍是阻塞的,但对 CLI 够用
if user_input.lower() in ["exit", "退出", "q", "quit"]:
print("助手:再见!如需继续设计,随时回来~")
break
if not user_input.strip():
print("助手:请输入有效的设计需求,我会尽力解答~")
continue
print("\n助手:正在处理你的需求...\n")
# 现在可以安全使用 async for
async for stream in agent.astream(
{"messages": user_input},
stream_mode=["updates", "messages", "custom"],
subgraphs=True,
version="v2",
config={"configurable": {"thread_id": thread_id}}
):
print(stream)
_, mode, chunks = stream
if mode == "updates":
print(f"[updates] {chunks}")
elif mode == "messages":
token, metadata = chunks
subagent_name = metadata.get('lc_agent_name', "main_agent")
if isinstance(token, AIMessageChunk): # 默认回复 思考内容
reasoning = [b for b in token.content_blocks if b["type"] == "reasoning"]
text = [b for b in token.content_blocks if b["type"] == "text"]
if reasoning:
print(f"[thinking] {reasoning[0]['reasoning']}", end="")
if text:
print(text[0]["text"], end="")
elif isinstance(token, ToolMessageChunk): # 工具返回
print(f"[tool|{token.name}] {token.content}", end="")
elif isinstance(token, ToolMessage): # 工具返回
print(f"[tool|{token.name}] {token.content}", end="")
else:
continue
elif mode == "custom":
print(f"[report] {chunks.get('delta', '')}", end="")
# if chunk["type"] == "messages":
# token, metadata = chunk["data"]
# if not isinstance(token, AIMessageChunk):
# continue
# reasoning = [b for b in token.content_blocks if b["type"] == "reasoning"]
# text = [b for b in token.content_blocks if b["type"] == "text"]
# if reasoning:
# print(f"[thinking] {reasoning[0]['reasoning']}", end="")
# if text:
# print(text[0]["text"], end="")
# print(chunk)
# namespace, _, chunk = event
# token, metadata = chunk
# Identify source: "main" or the subagent namespace segment
# is_subagent = any(s.startswith("tools:") for s in namespace)
# source = next((s for s in namespace if s.startswith("tools:")), "main") if is_subagent else "main"
# if token.content_blocks:
# if token.additional_kwargs.get("reasoning_content", None): # 粗糙但常见判断
# if not has_printed_thinking_header:
# print("[思考过程]")
# has_printed_thinking_header = True
# print(token.content_blocks[0].get("reasoning", ""), end="", flush=True)
# else:
# if not has_printed_header:
# print("[agent回答]")
# has_printed_header = True
# print(token.content_blocks[0].get("text", ""), end="", flush=True)
#
# # Tool call chunks (streaming tool invocations)
# if token.tool_call_chunks:
# for tc in token.tool_call_chunks:
# if tc.get("name"):
# print(f"\n[{source}] Tool call: {tc['name']}")
# # Args stream in chunks - write them incrementally
# if tc.get("args"):
# print(tc["args"], end="", flush=True)
#
# # Tool results
# if token.type == "tool":
# print(f"\n[{source}] Tool result [{token.name}]: {str(token.content)[:150]}")
#
# # Regular AI content (skip tool call messages)
# if token.type == "ai" and token.content and not token.tool_call_chunks:
# print(token.content, end="", flush=True)
# if namespace:
# print(f"[子代理: {namespace}]")
# else:
# print("[主助手]")
# print(chunk)
# print("-" * 50 + "\n")
#
# chunk_list.append(str(chunk))
#
# if not chunk_list:
# assistant_response = "抱歉,我暂时无法处理你的请求,请稍后再试。"
# else:
# assistant_response = "\n".join(chunk_list)
#
# print(f"[最终完整回复]\n{assistant_response}\n" + "=" * 60 + "\n")
# 启动方式改成:
if __name__ == "__main__":
asyncio.run(continuous_chat())

View File

@@ -0,0 +1,27 @@
from langchain_core.prompts import PromptTemplate
from src.server.deep_agent.agents.init_llm import title_llm
def conversation_title(full_conversation):
title_prompt = PromptTemplate(
input_variables=["full_conversation"],
template="""
请严格按照以下要求生成对话标题:
1. 标题长度8-15个字纯中文无标点、无特殊符号、无换行
2. 标题内容:基于完整对话,精准概括核心主题(兼顾用户需求和助手回复)
3. 标题风格:自然口语化,符合中文表达习惯,不冗余
完整对话内容:
{full_conversation}
仅输出标题,不要输出任何额外解释、说明或标点符号。
"""
)
title_chain = title_prompt | title_llm
response = title_chain.invoke({"full_conversation": full_conversation})
return response
if __name__ == '__main__':
print(conversation_title("你好"))

View File

@@ -0,0 +1,191 @@
import time
import asyncio
from typing import List, Dict, Any
from urllib.parse import urlparse
from pathlib import Path
import uuid
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode
from langchain_core.tools import tool
# ─────────────────────────────────────
# 路径配置
# ─────────────────────────────────────
TOOL_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = TOOL_DIR.parent
# DeepAgents 推荐目录
SAVE_DIR = PROJECT_ROOT / "agent_workspace" / "raw_data"
SAVE_DIR.mkdir(parents=True, exist_ok=True)
print(f"tool save : {str(PROJECT_ROOT / "agent_workspace")}")
# ─────────────────────────────────────
# Browser 配置
# ─────────────────────────────────────
browser_config = BrowserConfig(
headless=True,
verbose=False,
java_script_enabled=True,
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/118.0 Safari/537.36"
),
)
run_config = CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
word_count_threshold=5,
excluded_tags=["script", "style", "nav", "footer"],
remove_overlay_elements=True,
process_iframes=True,
)
# ─────────────────────────────────────
# URL → 文件名
# ─────────────────────────────────────
def build_filename(url: str) -> str:
parsed = urlparse(url)
domain = parsed.netloc.replace("www.", "").replace(".", "_")
path_part = parsed.path.strip("/").replace("/", "_")[:50] or "index"
ts = int(time.time())
rand = uuid.uuid4().hex[:6]
return f"{ts}_{rand}_{domain}_{path_part}.md"
# ─────────────────────────────────────
# 单个 URL 抓取
# ─────────────────────────────────────
async def crawl_one(crawler, url: str, sem: asyncio.Semaphore) -> Dict[str, Any]:
async with sem:
try:
result = await crawler.arun(url=url, config=run_config)
if not result.success:
return {
"url": url,
"success": False,
"error": f"status={getattr(result, 'status_code', 'unknown')}"
}
markdown = result.markdown or ""
if len(markdown) < 500:
return {
"url": url,
"success": False,
"error": "content too short"
}
filename = build_filename(url)
filepath = SAVE_DIR / filename
header = (
f"<!-- Source: {url} -->\n"
f"<!-- Saved: {time.strftime('%Y-%m-%d %H:%M:%S')} -->\n\n"
)
with open(filepath, "w", encoding="utf-8") as f:
f.write(header + markdown)
return {
"url": url,
"success": True,
"file": str(filepath)
}
except Exception as e:
return {
"url": url,
"success": False,
"error": str(e)
}
# ─────────────────────────────────────
# Async 主逻辑
# ─────────────────────────────────────
async def _crawl4ai_batch(urls: List[str]) -> Dict[str, Any]:
urls = list(set(urls)) # 去重
if not urls:
return {"error": "no urls"}
sem = asyncio.Semaphore(5) # 并发限制
async with AsyncWebCrawler(config=browser_config) as crawler:
tasks = [
crawl_one(crawler, url, sem)
for url in urls
]
results = await asyncio.gather(*tasks)
success_files = []
summary = []
for r in results:
if r["success"]:
success_files.append(r["file"])
summary.append(f"{r['url']}")
else:
summary.append(f"{r['url']} ({r['error']})")
return {
"saved_files": success_files,
"count": len(success_files),
"summary": summary,
}
# ─────────────────────────────────────
# Tool同步
# ─────────────────────────────────────
@tool
def crawl4ai_batch(urls: List[str]) -> str:
"""
Batch crawl webpages and save their content as markdown files.
Args:
urls: List of webpage URLs to crawl.
Returns:
A summary of crawling results and saved file paths.
"""
try:
result = asyncio.run(_crawl4ai_batch(urls))
if "error" in result:
return f"❌ Error: {result['error']}"
output = [
"### 批量抓取完成 ###",
f"成功保存文件: {result['count']}",
f"保存目录: {SAVE_DIR}",
"",
"抓取详情:"
]
output.extend(result["summary"])
if result["saved_files"]:
output.append("\n可读取文件:")
output.extend(result["saved_files"])
return "\n".join(output)
except Exception as e:
return f"🚨 爬虫系统异常: {str(e)}"

View File

@@ -0,0 +1,94 @@
import json
import logging
import uuid
from google.oauth2 import service_account
from langchain_core.tools import tool
from google import genai
from google.genai.types import GenerateContentConfig, Modality
from minio import Minio
from src.core.config import settings
from src.server.utils.new_oss_client import oss_upload_image
logger = logging.getLogger(__name__)
# 初始化全局凭证和客户端
creds = service_account.Credentials.from_service_account_file(
settings.GOOGLE_GENAI_USE_VERTEXAI,
scopes=["https://www.googleapis.com/auth/cloud-platform"],
)
minio_client = Minio(settings.MINIO_URL, access_key=settings.MINIO_ACCESS, secret_key=settings.MINIO_SECRET, secure=settings.MINIO_SECURE)
client = genai.Client(
credentials=creds,
project=settings.GOOGLE_CLOUD_PROJECT,
location=settings.GOOGLE_CLOUD_LOCATION,
vertexai=True
)
@tool
async def generate_furniture(prompt: str) -> str:
"""
使用 Gemini 图像生成模型根据详细的英文提示词生成家具设计草图。
"""
print(f"\n[系统日志] 正在调用 Nano Banana (Gemini Image Gen) ...")
try:
response = client.models.generate_content(
model="gemini-2.5-flash-image",
contents=(f"Generate a professional furniture design sketch: {prompt}"),
config=GenerateContentConfig(
response_modalities=[Modality.TEXT, Modality.IMAGE],
),
)
image_bytes = None
for part in response.candidates[0].content.parts:
if part.inline_data:
image_bytes = part.inline_data.data
break
if not image_bytes:
return "未能生成图像数据。"
object_name = f"furniture/sketches/{uuid.uuid4()}.png"
bucket = "fida-test" # 替换为你的 bucket 名称
# 3. 调用你的上传函数
upload_res = oss_upload_image(
oss_client=minio_client,
bucket=bucket,
object_name=object_name,
image_bytes=image_bytes
)
if upload_res:
# 4. 构造访问链接 (如果是私有 bucket需使用 presigned_get_object)
# 这里简单示例为直接访问地址
image_url = f"{bucket}/{object_name}"
return json.dumps(
{
"tool_name": "generate_furniture",
"data": image_url,
"tool_status": "success"
},
ensure_ascii=False
)
else:
return json.dumps(
{
"tool_name": "generate_furniture",
"data": "图片生成成功,但上传至存储服务器失败。",
"tool_status": "error"
},
ensure_ascii=False
)
except Exception as e:
logger.warning(e)
return json.dumps(
{
"tool_name": "generate_furniture",
"data": f"绘图流程异常",
"tool_status": "error"
},
ensure_ascii=False
)

View File

@@ -0,0 +1,151 @@
import os
import json
import re
from typing import Optional, List, Dict
from langchain_qwq import ChatQwen
from langgraph.config import get_stream_writer
from pydantic import BaseModel, Field
from langchain_core.tools import tool
from langchain_core.messages import SystemMessage, HumanMessage
from src.core.config import settings
# =========================
# LLM 初始化
# =========================
llm = ChatQwen(
enable_thinking=False,
model="qwen3.5-flash",
temperature=0.2,
max_tokens=3_000,
timeout=None,
max_retries=2,
api_key=settings.QWEN_API_KEY)
# =========================
# Tool 输入 Schema
# =========================
class ReportInput(BaseModel):
report_topic: str = Field(
...,
description="Main topic of the report, e.g. '2026 Sofa Design Trends'"
)
structured_data: List[Dict] = Field(
...,
description="Structured retrieval result items"
)
language: Optional[str] = Field(
default="English",
description="Output language"
)
# =========================
# LangGraph Tool
# =========================
@tool("report_generator", args_schema=ReportInput)
async def report_generator(
report_topic: str,
structured_data: List[Dict],
language: str = "English"
) -> dict:
"""
Generate a professional design/market report
directly from structured retrieval results.
"""
writer = get_stream_writer()
if not structured_data:
error_msg = "Error: No structured data provided."
writer({"type": "report_error", "message": error_msg})
return error_msg
collected_data_str = json.dumps(
structured_data,
ensure_ascii=False,
indent=2
)
# =========================
# Prompt
# =========================
system_prompt = f"""
You are a professional design trend analyst.
Generate a long, structured Markdown report.
REQUIREMENTS:
1. Follow MECE principle.
2. Embed images ONLY if they start with https://
using: ![alt](url)
3. Insert images inline.
4. Every key insight must cite source:
[Website Name](url)
5. Use Markdown headings.
6. Start directly with title.
7. Be detailed and analytical.
Output Language: {language}
"""
user_prompt = f"""
Topic: {report_topic}
Input Data:
{collected_data_str}
"""
# =========================
# 调用 LLM
# =========================
writer({"type": "report_start", "topic": report_topic, "language": language})
full_report = ""
try:
report_llm = llm.with_config(
callbacks=[]
)
async for chunk in report_llm.astream(
[
SystemMessage(content=system_prompt),
HumanMessage(content=user_prompt)
]
):
if chunk.content: # Gemini 返回的 chunk.content
delta = chunk.content
full_report += delta
# return {"type": "report_delta", "delta": delta}
writer({"type": "report_delta", "delta": delta}) # ← 实时推送给前端
writer({"type": "report_stop", "topic": report_topic, "language": language})
except Exception as e:
error_msg = f"LLM generation failed: {str(e)}"
writer({"type": "report_error", "message": error_msg})
return error_msg
report_content = full_report.strip()
# =========================
# 保存报告
# =========================
output_dir = "workspace/reports"
os.makedirs(output_dir, exist_ok=True)
safe_topic = re.sub(r'[\\/*?:"<>|]', "", report_topic.replace(" ", "_"))
filename = f"{output_dir}/{safe_topic}.md"
try:
with open(filename, "w", encoding="utf-8") as f:
f.write(report_content)
writer({"type": "report_complete", "file_path": filename})
except Exception as e:
writer({"type": "report_save_warning", "message": str(e)})
# 返回完整内容(作为 tool result同时正文已通过 delta 流式输出
return report_content + f"\n\n✅ Report saved to: {filename}"

View File

@@ -0,0 +1,67 @@
import asyncio
import json
from datetime import datetime
from typing import List, Set, Optional
from langchain_core.tools import tool
from tavily import TavilyClient
from src.core.config import settings
# 模拟配置加载
TAVILY_API_KEY = settings.TAVILY_API_KEY
@tool
async def topic_research(topic: list[str], max_urls: int = 5) -> str:
"""
深度调研工具。该工具会利用 Tavily 搜索引擎针对特定主题进行多维度搜索。
它会自动生成针对性的搜索词(包含年份和趋势),并返回去重后的高质量 URL 列表。
"""
if not TAVILY_API_KEY:
return "❌ 错误: 未配置 TAVILY_API_KEY。"
client = TavilyClient(api_key=TAVILY_API_KEY)
# 1. 自动生成多维度搜索词 (在工具内部快速生成)
# 2. 并行执行搜索
async def perform_search(q: str):
# 使用 asyncio.to_thread 运行同步的 Tavily SDK
def sync_search():
try:
response = client.search(
query=q,
search_depth="advanced",
max_results=5,
include_answer=False
)
return response.get('results', [])
except Exception as e:
print(f"Search error: {e}")
return []
return await asyncio.to_thread(sync_search)
search_tasks = [perform_search(q) for q in topic]
search_results_list = await asyncio.gather(*search_tasks)
# 3. 结果去重与过滤
seen_urls: Set[str] = set()
final_urls = []
# 常见的非内容页面过滤
skip_extensions = ('.pdf', '.jpg', '.png', '.zip', '.exe')
for results in search_results_list:
for item in results:
url = item.get('url')
if url and url not in seen_urls:
if not url.lower().endswith(skip_extensions):
seen_urls.add(url)
final_urls.append(url)
# 4. 结果截断
selected_urls = final_urls[:max_urls]
# 返回 JSON 字符串,便于 Agent 下一步调用批量爬虫 (Crawl4ai)
return json.dumps(selected_urls, ensure_ascii=False)

View File

@@ -0,0 +1,225 @@
import os
import re
import json
from datetime import datetime
from typing import List, Dict, Optional
from pydantic import BaseModel, Field
from langchain_core.tools import tool
from langchain_core.documents import Document
# RAG
from langchain_community.vectorstores import FAISS
from langchain_huggingface import HuggingFaceEmbeddings
from sentence_transformers import CrossEncoder
# =========================
# 全局模型(单例)
# =========================
_EMBEDDING_MODEL = HuggingFaceEmbeddings(
model_name="sentence-transformers/all-MiniLM-L6-v2"
)
_RERANK_MODEL = CrossEncoder(
"cross-encoder/ms-marco-MiniLM-L-6-v2"
)
class StructuredRetrievalInput(BaseModel):
file_paths: List[str] = Field(..., description="List of local markdown file paths.")
query: str = Field(..., description="Extraction query")
source_url: Optional[str] = Field(None, description="Optional global source URL")
@tool("structured_retrieval", args_schema=StructuredRetrievalInput)
def structured_retrieval(
file_paths: List[str],
query: str,
source_url: Optional[str] = None
) -> Dict:
"""
Batch structured extraction from markdown files.
- Performs vector search + re-ranking
- Saves extracted structured data as JSON file to disk
- Returns ONLY summary (status, count, file path)
"""
# ── 1. 收集所有文件內容 ──────────────────────────────────────
all_docs_pool: List[Document] = []
for path in file_paths:
if not os.path.exists(path) or not path.endswith((".md", ".markdown")):
continue
file_name = os.path.basename(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
current_source = source_url or _extract_source_from_md(content) or "unknown"
sections = _split_markdown_by_headers(content)
for sec in sections:
all_docs_pool.append(
Document(
page_content=sec,
metadata={"source_url": current_source, "file_name": file_name}
)
)
if not all_docs_pool:
return {"status": "no_documents_found", "items_count": 0, "json_path": None}
# ── 2. Vector search ────────────────────────────────────────────
vector_store = FAISS.from_documents(all_docs_pool, _EMBEDDING_MODEL)
retrieved = vector_store.similarity_search(query, k=200)
# ── 3. 提取結構化片段 ──────────────────────────────────────────
structured_items = []
for doc in retrieved:
text = doc.page_content.strip()
if len(text) < 30:
continue
images = list(set(re.findall(r"!\[.*?\]\((.*?)\)", text)))
structured_items.append(
{
"text": text,
"images": images,
"source_url": doc.metadata.get("source_url"),
"file_name": doc.metadata.get("file_name")
}
)
# ── 4. Re-rank ──────────────────────────────────────────────────
if structured_items:
unique_items = {item["text"]: item for item in structured_items}.values()
pairs = [[query, item["text"]] for item in unique_items]
scores = _RERANK_MODEL.predict(pairs)
sorted_items = sorted(
zip(scores, unique_items),
key=lambda x: x[0],
reverse=True
)
top_items = [item for _, item in sorted_items[:50]]
else:
top_items = []
# ── 5. 寫入 JSON 文件 ──────────────────────────────────────────
if not top_items:
return {"status": "no_relevant_content", "items_count": 0, "json_path": None}
# 產生有意義的檔名
safe_query = re.sub(r'[^a-zA-Z0-9\u4e00-\u9fa5]', '_', query)[:40]
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
json_filename = f"extracted_{safe_query}_{timestamp}.json"
# 建議的儲存目錄(與 crawl4ai_batch 對齊)
output_dir = os.path.join(os.path.dirname(file_paths[0]), "..", "extracted")
os.makedirs(output_dir, exist_ok=True)
json_path = os.path.join(output_dir, json_filename)
with open(json_path, "w", encoding="utf-8") as f:
json.dump(
{
"query": query,
"extracted_at": timestamp,
"item_count": len(top_items),
"items": top_items
},
f,
ensure_ascii=False,
indent=2
)
# ── 6. 只回傳摘要 ──────────────────────────────────────────────
return {
"status": "success",
"items_count": len(top_items),
"json_path": json_path,
"summary": f"已提取 {len(top_items)} 個高相關片段,儲存於 {json_path}"
}
def _extract_source_from_md(content: str) -> Optional[str]:
match = re.search(r"<!--\s*Source:\s*(.*?)\s*-->", content)
return match.group(1).strip() if match else None
# =========================
# Markdown Header Split
# =========================
def _split_markdown_by_headers(
content: str,
max_chars: int = 2000,
overlap: int = 150,
):
header_re = re.compile(
r'^(#{1,6})\s+(.+?)\s*$',
re.MULTILINE
)
matches = list(header_re.finditer(content))
if not matches:
return _chunk_text(content, max_chars, overlap)
sections = []
for i, m in enumerate(matches):
start = m.start()
end = (
matches[i + 1].start()
if i + 1 < len(matches)
else len(content)
)
block = content[start:end].strip()
if block:
sections.append(block)
final_sections = []
for s in sections:
if len(s) > max_chars:
final_sections.extend(
_chunk_text(s, max_chars, overlap)
)
else:
final_sections.append(s)
return final_sections
def _chunk_text(
text: str,
max_chars: int = 2000,
overlap: int = 150
):
text = text.strip()
if len(text) <= max_chars:
return [text]
chunks = []
start = 0
while start < len(text):
end = min(len(text), start + max_chars)
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
if end == len(text):
break
start = max(0, end - overlap)
return chunks

View File

@@ -0,0 +1,57 @@
from datetime import datetime
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import tool
from pymongo import MongoClient
from src.core.config import MONGO_URI
client = MongoClient(MONGO_URI)
db = client["report_agent"]
collection = db["user_profiles"]
@tool
def query_report_profile(config: RunnableConfig, ) -> dict:
"""
查询用户报告画像
"""
thread_id = config['configurable']['thread_id']
doc = collection.find_one({"thread_id": thread_id})
if not doc:
return {"profile": {}}
doc.pop("_id", None)
return doc
@tool
def update_report_profile(config: RunnableConfig, profile: dict) -> dict:
"""
更新用户画像信息
"""
thread_id = config['configurable']['thread_id']
collection.update_one(
{"thread_id": thread_id},
{
"$set": {
"profile": profile
}
},
upsert=True
)
return {"status": "success", "profile": profile}
@tool
def check_profile_complete(profile: dict) -> dict:
"""
判断画像是否完整
"""
required = ["style", "room_type", "budget"]
missing = [f for f in required if f not in profile]
return {
"complete": len(missing) == 0,
"missing_fields": missing
}

View File

@@ -58,7 +58,7 @@ def oss_upload_image(oss_client, bucket, object_name, image_bytes):
if __name__ == '__main__': if __name__ == '__main__':
url = "aida-users/89/sketch/123-89.png" url = "fida-test/furniture/sketches/4449a66d-6267-43f7-86a2-1e42bd19ec61.png"
read_type = "2" read_type = "2"
img = oss_get_image(oss_client=minio_client, bucket=url.split('/')[0], object_name=url[url.find('/') + 1:], data_type=read_type) img = oss_get_image(oss_client=minio_client, bucket=url.split('/')[0], object_name=url[url.find('/') + 1:], data_type=read_type)
img.show() img.show()

126
uv.lock generated
View File

@@ -7,6 +7,18 @@ resolution-markers = [
"python_full_version < '3.13'", "python_full_version < '3.13'",
] ]
[[package]]
name = "agentstate"
version = "1.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/8a/29332426e1632601b41eeb90c1ec13e3f764d02b772196593dbdb44d5e56/agentstate-1.0.2.tar.gz", hash = "sha256:2578877862efb692a4f3e467211eeca34b2a1f0a0260301c9577592c5d8c8d0d", size = 5815, upload-time = "2025-08-22T19:22:03.621Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/f9/fcd813b63645fd7108ef32741062f826c706af1a960f47564e3cdbad4eb9/agentstate-1.0.2-py3-none-any.whl", hash = "sha256:48375add0cfee4ecb6bb09bbb37f564d3b027e471cc635b2e354f5d32c6d3bce", size = 6026, upload-time = "2025-08-22T19:22:02.245Z" },
]
[[package]] [[package]]
name = "aiofiles" name = "aiofiles"
version = "25.1.0" version = "25.1.0"
@@ -653,6 +665,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" },
] ]
[[package]]
name = "datetime"
version = "6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytz" },
{ name = "zope-interface" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/32/decbfd165e9985ba9d8c2d34a39afe5aeba2fc3fe390eb6e9ef1aab98fa8/datetime-6.0.tar.gz", hash = "sha256:c1514936d2f901e10c8e08d83bf04e6c9dbd7ca4f244da94fec980980a3bc4d5", size = 64167, upload-time = "2025-11-25T08:00:34.586Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/7a/ea0f3e3ea74be36fc7cf54f966cde732a3de72697983cdb5646b0a4dacde/datetime-6.0-py3-none-any.whl", hash = "sha256:d19988f0657a4e72c9438344157254a8dcad6aea8cd5ae70a5d1b5a75e5dc930", size = 52637, upload-time = "2025-11-25T08:00:33.077Z" },
]
[[package]] [[package]]
name = "deepagents" name = "deepagents"
version = "0.4.5" version = "0.4.5"
@@ -944,15 +969,18 @@ name = "fida"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "agentstate" },
{ name = "asyncio" }, { name = "asyncio" },
{ name = "chardet" }, { name = "chardet" },
{ name = "crawl4ai" }, { name = "crawl4ai" },
{ name = "dashscope" }, { name = "dashscope" },
{ name = "datetime" },
{ name = "deepagents" }, { name = "deepagents" },
{ name = "faiss-cpu" }, { name = "faiss-cpu" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "image" }, { name = "image" },
{ name = "langchain-classic" },
{ name = "langchain-community" }, { name = "langchain-community" },
{ name = "langchain-core" }, { name = "langchain-core" },
{ name = "langchain-google-genai" }, { name = "langchain-google-genai" },
@@ -960,9 +988,13 @@ dependencies = [
{ name = "langchain-qwq" }, { name = "langchain-qwq" },
{ name = "langgraph" }, { name = "langgraph" },
{ name = "langgraph-checkpoint-mongodb" }, { name = "langgraph-checkpoint-mongodb" },
{ name = "langgraph-checkpoint-postgres" },
{ name = "langgraph-store-mongodb" },
{ name = "langsmith" },
{ name = "minio" }, { name = "minio" },
{ name = "modality" }, { name = "modality" },
{ name = "motor" }, { name = "motor" },
{ name = "path" },
{ name = "playwright" }, { name = "playwright" },
{ name = "postgres" }, { name = "postgres" },
{ name = "prompt" }, { name = "prompt" },
@@ -984,25 +1016,32 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "agentstate", specifier = ">=1.0.2" },
{ name = "asyncio", specifier = ">=4.0.0" }, { name = "asyncio", specifier = ">=4.0.0" },
{ name = "chardet", specifier = "<6" }, { name = "chardet", specifier = "<6" },
{ name = "crawl4ai", specifier = ">=0.8.0" }, { name = "crawl4ai", specifier = ">=0.8.0" },
{ name = "dashscope", specifier = ">=1.25.13" }, { name = "dashscope", specifier = ">=1.25.13" },
{ name = "datetime", specifier = ">=6.0" },
{ name = "deepagents", specifier = ">=0.4.3" }, { name = "deepagents", specifier = ">=0.4.3" },
{ name = "faiss-cpu", specifier = ">=1.13.2" }, { name = "faiss-cpu", specifier = ">=1.13.2" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.128.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.128.0" },
{ name = "gunicorn", specifier = ">=25.0.1" }, { name = "gunicorn", specifier = ">=25.0.1" },
{ name = "image", specifier = ">=1.5.33" }, { name = "image", specifier = ">=1.5.33" },
{ name = "langchain-classic", specifier = ">=1.0.1" },
{ name = "langchain-community", specifier = ">=0.4.1" }, { name = "langchain-community", specifier = ">=0.4.1" },
{ name = "langchain-core", specifier = ">=1.2.8" }, { name = "langchain-core", specifier = ">=1.2.8" },
{ name = "langchain-google-genai", specifier = ">=4.2.0" }, { name = "langchain-google-genai", specifier = ">=4.2.0" },
{ name = "langchain-huggingface", specifier = ">=1.2.0" }, { name = "langchain-huggingface", specifier = ">=1.2.0" },
{ name = "langchain-qwq", specifier = ">=0.3.4" }, { name = "langchain-qwq", specifier = ">=0.3.4" },
{ name = "langgraph", extras = ["postgres"], specifier = ">=1.0.7" }, { name = "langgraph", extras = ["all", "postgres"], specifier = ">=1.0.7" },
{ name = "langgraph-checkpoint-mongodb", specifier = ">=0.3.1" }, { name = "langgraph-checkpoint-mongodb", specifier = ">=0.3.1" },
{ name = "langgraph-checkpoint-postgres", specifier = ">=3.0.4" },
{ name = "langgraph-store-mongodb", specifier = ">=0.2.0" },
{ name = "langsmith", specifier = ">=0.7.13" },
{ name = "minio", specifier = ">=7.2.20" }, { name = "minio", specifier = ">=7.2.20" },
{ name = "modality", specifier = ">=0.1.0" }, { name = "modality", specifier = ">=0.1.0" },
{ name = "motor", specifier = ">=3.7.1" }, { name = "motor", specifier = ">=3.7.1" },
{ name = "path", specifier = ">=17.1.1" },
{ name = "playwright", specifier = ">=1.58.0" }, { name = "playwright", specifier = ">=1.58.0" },
{ name = "postgres", specifier = ">=4.0" }, { name = "postgres", specifier = ">=4.0" },
{ name = "prompt", specifier = ">=0.4.1" }, { name = "prompt", specifier = ">=0.4.1" },
@@ -1805,6 +1844,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/a7/d989dde4f5007d69aeaf3a41faf2b868f0f3b9f834b7d557349068642635/langgraph_checkpoint_mongodb-0.3.1-py3-none-any.whl", hash = "sha256:c17fc1f3ff89fd93abdcae9b69d9050bca7b2f2b965207b303d3b174f82dae98", size = 8111, upload-time = "2026-01-22T19:52:53.094Z" }, { url = "https://files.pythonhosted.org/packages/df/a7/d989dde4f5007d69aeaf3a41faf2b868f0f3b9f834b7d557349068642635/langgraph_checkpoint_mongodb-0.3.1-py3-none-any.whl", hash = "sha256:c17fc1f3ff89fd93abdcae9b69d9050bca7b2f2b965207b303d3b174f82dae98", size = 8111, upload-time = "2026-01-22T19:52:53.094Z" },
] ]
[[package]]
name = "langgraph-checkpoint-postgres"
version = "3.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langgraph-checkpoint" },
{ name = "orjson" },
{ name = "psycopg" },
{ name = "psycopg-pool" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/39/6a409958bd1e4e0804bbe4f9351e620f6087d5346e452c59824298a2a330/langgraph_checkpoint_postgres-3.0.4.tar.gz", hash = "sha256:83e6a1097563369173442de2a66e6d712d60a1a6de07c98c5130d476bb2b76ae", size = 127627, upload-time = "2026-01-31T00:44:16.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/56/7466f596add278798ab42697a56e992adde6866664afff6a5e4432540f29/langgraph_checkpoint_postgres-3.0.4-py3-none-any.whl", hash = "sha256:12cd5661da2a374882770deb9008a4eb16641c3fd38d7595e312030080390c6e", size = 42834, upload-time = "2026-01-31T00:44:15.118Z" },
]
[[package]] [[package]]
name = "langgraph-prebuilt" name = "langgraph-prebuilt"
version = "1.0.8" version = "1.0.8"
@@ -1831,6 +1885,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/4c/7a7510260fbda788efd13bf4650d3e7d80988118441ac811ec78e0aa03ac/langgraph_sdk-0.3.9-py3-none-any.whl", hash = "sha256:94654294250c920789b6ed0d8a70c0117fed5736b61efc24ff647157359453c5", size = 90511, upload-time = "2026-02-24T18:39:02.012Z" }, { url = "https://files.pythonhosted.org/packages/6a/4c/7a7510260fbda788efd13bf4650d3e7d80988118441ac811ec78e0aa03ac/langgraph_sdk-0.3.9-py3-none-any.whl", hash = "sha256:94654294250c920789b6ed0d8a70c0117fed5736b61efc24ff647157359453c5", size = 90511, upload-time = "2026-02-24T18:39:02.012Z" },
] ]
[[package]]
name = "langgraph-store-mongodb"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-mongodb" },
{ name = "langgraph-checkpoint" },
{ name = "pymongo-search-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b8/af/0c3d6f62bc8f214079bbd80b5827a783ed590e359cdf08769f038256ffaa/langgraph_store_mongodb-0.2.0.tar.gz", hash = "sha256:95787059ac2178f6a0ef9c2f60c4ecd6dfeb11e6ae1e137fb79b71cd1536777e", size = 103346, upload-time = "2026-01-15T18:12:55.38Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/39/914b1359fed53590f28c21a8a21de44061f30fdf3b08f4fa9e76d64715ac/langgraph_store_mongodb-0.2.0-py3-none-any.whl", hash = "sha256:d72d1ef32f75f3cc3fbae2c5cbdddd1756e5cf56e1756f8e61442b0bf186c4ac", size = 9318, upload-time = "2026-01-15T18:12:54.114Z" },
]
[[package]] [[package]]
name = "langsmith" name = "langsmith"
version = "0.7.13" version = "0.7.13"
@@ -2533,6 +2601,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/91/2a/81ef2b079bbc925a935f2fd73dc1285c46c7eb35c5032a0d63b48d753c4a/patchright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:b044efea1774beac8ee033583eac7181b86ea450da3a36d3039d7a1a428ac098", size = 33064382, upload-time = "2026-01-30T15:27:19.725Z" }, { url = "https://files.pythonhosted.org/packages/91/2a/81ef2b079bbc925a935f2fd73dc1285c46c7eb35c5032a0d63b48d753c4a/patchright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:b044efea1774beac8ee033583eac7181b86ea450da3a36d3039d7a1a428ac098", size = 33064382, upload-time = "2026-01-30T15:27:19.725Z" },
] ]
[[package]]
name = "path"
version = "17.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dd/52/a7bdd5ef8488977d354b7915d1e75009bebbd04f73eff14e52372d5e9435/path-17.1.1.tar.gz", hash = "sha256:2dfcbfec8b4d960f3469c52acf133113c2a8bf12ac7b98d629fa91af87248d42", size = 50528, upload-time = "2025-07-27T20:40:23.79Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/50/11c9ee1ede64b45d687fd36eb8768dafc57afc78b4d83396920cfd69ed30/path-17.1.1-py3-none-any.whl", hash = "sha256:ec7e136df29172e5030dd07e037d55f676bdb29d15bfa09b80da29d07d3b9303", size = 23936, upload-time = "2025-07-27T20:40:22.453Z" },
]
[[package]] [[package]]
name = "pillow" name = "pillow"
version = "12.1.1" version = "12.1.1"
@@ -2813,6 +2890,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" },
] ]
[[package]]
name = "psycopg-pool"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/56/9a/9470d013d0d50af0da9c4251614aeb3c1823635cab3edc211e3839db0bcf/psycopg_pool-3.3.0.tar.gz", hash = "sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5", size = 31606, upload-time = "2025-12-01T11:34:33.11Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" },
]
[[package]] [[package]]
name = "psycopg2-binary" name = "psycopg2-binary"
version = "2.9.11" version = "2.9.11"
@@ -3159,6 +3248,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
] ]
[[package]]
name = "pytz"
version = "2026.1.post1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" },
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" version = "6.0.3"
@@ -4624,6 +4722,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
] ]
[[package]]
name = "zope-interface"
version = "8.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/86/a4/77daa5ba398996d16bb43fc721599d27d03eae68fe3c799de1963c72e228/zope_interface-8.2.tar.gz", hash = "sha256:afb20c371a601d261b4f6edb53c3c418c249db1a9717b0baafc9a9bb39ba1224", size = 254019, upload-time = "2026-01-09T07:51:07.253Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/a0/1e1fabbd2e9c53ef92b69df6d14f4adc94ec25583b1380336905dc37e9a0/zope_interface-8.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:624b6787fc7c3e45fa401984f6add2c736b70a7506518c3b537ffaacc4b29d4c", size = 208785, upload-time = "2026-01-09T08:05:17.348Z" },
{ url = "https://files.pythonhosted.org/packages/c3/2a/88d098a06975c722a192ef1fb7d623d1b57c6a6997cf01a7aabb45ab1970/zope_interface-8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bc9ded9e97a0ed17731d479596ed1071e53b18e6fdb2fc33af1e43f5fd2d3aaa", size = 208976, upload-time = "2026-01-09T08:05:18.792Z" },
{ url = "https://files.pythonhosted.org/packages/e9/e8/757398549fdfd2f8c89f32c82ae4d2f0537ae2a5d2f21f4a2f711f5a059f/zope_interface-8.2-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:532367553e4420c80c0fc0cabcc2c74080d495573706f66723edee6eae53361d", size = 259411, upload-time = "2026-01-09T08:05:20.567Z" },
{ url = "https://files.pythonhosted.org/packages/91/af/502601f0395ce84dff622f63cab47488657a04d0065547df42bee3a680ff/zope_interface-8.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2bf9cf275468bafa3c72688aad8cfcbe3d28ee792baf0b228a1b2d93bd1d541a", size = 264859, upload-time = "2026-01-09T08:05:22.234Z" },
{ url = "https://files.pythonhosted.org/packages/89/0c/d2f765b9b4814a368a7c1b0ac23b68823c6789a732112668072fe596945d/zope_interface-8.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0009d2d3c02ea783045d7804da4fd016245e5c5de31a86cebba66dd6914d59a2", size = 264398, upload-time = "2026-01-09T08:05:23.853Z" },
{ url = "https://files.pythonhosted.org/packages/4a/81/2f171fbc4222066957e6b9220c4fb9146792540102c37e6d94e5d14aad97/zope_interface-8.2-cp312-cp312-win_amd64.whl", hash = "sha256:845d14e580220ae4544bd4d7eb800f0b6034fe5585fc2536806e0a26c2ee6640", size = 212444, upload-time = "2026-01-09T08:05:25.148Z" },
{ url = "https://files.pythonhosted.org/packages/66/47/45188fb101fa060b20e6090e500682398ab415e516a0c228fbb22bc7def2/zope_interface-8.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:6068322004a0158c80dfd4708dfb103a899635408c67c3b10e9acec4dbacefec", size = 209170, upload-time = "2026-01-09T08:05:26.616Z" },
{ url = "https://files.pythonhosted.org/packages/09/03/f6b9336c03c2b48403c4eb73a1ec961d94dc2fb5354c583dfb5fa05fd41f/zope_interface-8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2499de92e8275d0dd68f84425b3e19e9268cd1fa8507997900fa4175f157733c", size = 209229, upload-time = "2026-01-09T08:05:28.521Z" },
{ url = "https://files.pythonhosted.org/packages/07/b1/65fe1dca708569f302ade02e6cdca309eab6752bc9f80105514f5b708651/zope_interface-8.2-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f777e68c76208503609c83ca021a6864902b646530a1a39abb9ed310d1100664", size = 259393, upload-time = "2026-01-09T08:05:29.897Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a5/97b49cfceb6ed53d3dcfb3f3ebf24d83b5553194f0337fbbb3a9fec6cf78/zope_interface-8.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b05a919fdb0ed6ea942e5a7800e09a8b6cdae6f98fee1bef1c9d1a3fc43aaa0", size = 264863, upload-time = "2026-01-09T08:05:31.501Z" },
{ url = "https://files.pythonhosted.org/packages/cb/02/0b7a77292810efe3a0586a505b077ebafd5114e10c6e6e659f0c8e387e1f/zope_interface-8.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ccc62b5712dd7bd64cfba3ee63089fb11e840f5914b990033beeae3b2180b6cb", size = 264369, upload-time = "2026-01-09T08:05:32.941Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1d/0d1ff3846302ed1b5bbf659316d8084b30106770a5f346b7ff4e9f540f80/zope_interface-8.2-cp313-cp313-win_amd64.whl", hash = "sha256:34f877d1d3bb7565c494ed93828fa6417641ca26faf6e8f044e0d0d500807028", size = 212447, upload-time = "2026-01-09T08:05:35.064Z" },
{ url = "https://files.pythonhosted.org/packages/1a/da/3c89de3917751446728b8898b4d53318bc2f8f6bf8196e150a063c59905e/zope_interface-8.2-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:46c7e4e8cbc698398a67e56ca985d19cb92365b4aafbeb6a712e8c101090f4cb", size = 209223, upload-time = "2026-01-09T08:05:36.449Z" },
{ url = "https://files.pythonhosted.org/packages/00/7f/62d00ec53f0a6e5df0c984781e6f3999ed265129c4c3413df8128d1e0207/zope_interface-8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a87fc7517f825a97ff4a4ca4c8a950593c59e0f8e7bfe1b6f898a38d5ba9f9cf", size = 209366, upload-time = "2026-01-09T08:05:38.197Z" },
{ url = "https://files.pythonhosted.org/packages/ef/a2/f241986315174be8e00aabecfc2153cf8029c1327cab8ed53a9d979d7e08/zope_interface-8.2-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:ccf52f7d44d669203c2096c1a0c2c15d52e36b2e7a9413df50f48392c7d4d080", size = 261037, upload-time = "2026-01-09T08:05:39.568Z" },
{ url = "https://files.pythonhosted.org/packages/02/cc/b321c51d6936ede296a1b8860cf173bee2928357fe1fff7f97234899173f/zope_interface-8.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aae807efc7bd26302eb2fea05cd6de7d59269ed6ae23a6de1ee47add6de99b8c", size = 264219, upload-time = "2026-01-09T08:05:41.624Z" },
{ url = "https://files.pythonhosted.org/packages/ab/fb/5f5e7b40a2f4efd873fe173624795ca47eaa22e29051270c981361b45209/zope_interface-8.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05a0e42d6d830f547e114de2e7cd15750dc6c0c78f8138e6c5035e51ddfff37c", size = 264390, upload-time = "2026-01-09T08:05:42.936Z" },
{ url = "https://files.pythonhosted.org/packages/f9/82/3f2bc594370bc3abd58e5f9085d263bf682a222f059ed46275cde0570810/zope_interface-8.2-cp314-cp314-win_amd64.whl", hash = "sha256:561ce42390bee90bae51cf1c012902a8033b2aaefbd0deed81e877562a116d48", size = 212585, upload-time = "2026-01-09T08:05:44.419Z" },
]
[[package]] [[package]]
name = "zstandard" name = "zstandard"
version = "0.25.0" version = "0.25.0"