新增rag功能,支持用户上传、聊天提供文件夹或文件地址上传rag知识库,删除、查询个人知识库及暴露source给agent以获取所有知识来源;新增获取文件tree . /f工具

This commit is contained in:
minorQAQ committed 2026-05-27 17:39:55 +08:00
1 parent 95fefe96dd
commit 14788cad6a
36 files changed
+1238 -404

No files matched your search

+6 -6
View File
@@ -14,14 +14,14 @@ TTS_BASE_URL = "http://192.168.137.96:8902"
RAG_BASE_URL = "http://192.168.137.96:8903"
#6000pro:
LLM_API_KEY='semi-ai.tech'
LLM_BASE_URL="http://semi-ai.tech:40056/v1"
LLM_MODEL = "Intel/Qwen3.6-35B-A3B-int4-mixed-AutoRound"
# LLM_API_KEY='semi-ai.tech'
# LLM_BASE_URL="http://semi-ai.tech:40056/v1"
# LLM_MODEL = "Intel/Qwen3.6-35B-A3B-int4-mixed-AutoRound"
#A100:
# LLM_API_KEY = 'sk-kGhWAQTqLk9ECsmIDb758a456bD34e92B9771dF2E36432Fb'
# LLM_BASE_URL = "https://app.semi-ai.tech:60004/v1"
# LLM_MODEL = "Qwen3.5-122B-A10B"
LLM_API_KEY = 'sk-kGhWAQTqLk9ECsmIDb758a456bD34e92B9771dF2E36432Fb'
LLM_BASE_URL = "https://app.semi-ai.tech:60004/v1"
LLM_MODEL = "Qwen3.5-122B-A10B"
# LLM_TIMEOUT = 300
# LLM_REQUESTS_PER_SECOND = 10.0
# LLM_MAX_BUCKET_SIZE = 10
+6 -6
View File
@@ -1,10 +1,10 @@
# Core LangChain & LangGraph
langchain>=0.1.0
langchain-core>=0.1.0
langchain-community>=0.1.0
langchain-openai>=0.2.0
langgraph>=1.0.0
openai>=1.0.0
langchain>=1.2.0
langchain-core>=1.2.0
langchain-community>=0.4.1
langchain-openai>=1.1.12
langgraph>=1.1.6
openai>=2.25.0
# Web framework & server
fastapi>=0.115.0
Binary file not shown.
Binary file not shown.
Binary file not shown.
+156 -34
View File
@@ -1,15 +1,18 @@
# agent/chatbot_agent.py
from typing import Any
from typing import Any, Literal, Sequence, Annotated, TypedDict
import json
from langchain_core.messages import HumanMessage, AIMessage
from langchain.agents import create_agent
from langgraph.graph import StateGraph, START, END
from langgraph.prebuilt import ToolNode
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, ToolMessage
from agent.llm import Qwen36_35B_A3B_FP8_LLM
from agent.utils.image_utils import image_bytes_to_openai_image_url_part
from agent.llm import llm
from agent.tools import get_tools
from agent.utils.agent_utils import (
assistant_text,
get_history,
summarizer, # 使用新的上下文感知构建器
summarizer,
get_session_meta,
update_session_meta,
)
@@ -20,26 +23,152 @@ from web.ui_session import (
)
from agent.memory.system_prompt import MAIN_SYSTEM_PROMPT
# ---------- 1. 工具 ----------
# ---------- 1. 工具与模型 ----------
tools = get_tools()
model_with_tools = llm.bind_tools(tools)
# ---------- 2. 创建 ReAct agent(LangGraph)----------
agent_graph = create_agent(
model=Qwen36_35B_A3B_FP8_LLM,
tools=tools,
name="main"
)
# ---------- 2. 定义状态 ----------
class AgentState(TypedDict):
messages: Annotated[Sequence[BaseMessage], add_messages]
# ---------- 3. 辅助函数:保存消息到数据库 ----------
# ---------- 3. 节点定义 ----------
def call_model(state: AgentState) -> dict:
"""调用 LLM 生成回复或工具调用"""
response = model_with_tools.invoke(state["messages"])
# ----- 打印 token 使用情况 -----
usage = response.usage_metadata if hasattr(response, 'usage_metadata') else {}
if usage:
input_tokens = usage.get('input_tokens', '?')
output_tokens = usage.get('output_tokens', '?')
total_tokens = usage.get('total_tokens', '?')
print(f"[LLM] Token使用: input={input_tokens}, output={output_tokens}, total={total_tokens}")
# ----- 打印工具调用信息 -----
if hasattr(response, 'tool_calls') and response.tool_calls:
for tc in response.tool_calls:
tool_name = tc.get('name', 'unknown')
args = tc.get('args', {})
# LangChain 中 tool_call 的 args 可能是 JSON 字符串,尝试解析
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
pass
print(f"[LLM] 工具调用: {tool_name}, 参数: {args}")
return {"messages": [response]}
# 工具执行节点(LangGraph 内置,自动解包 content_and_artifact 到 ToolMessage.artifact)
tool_node = ToolNode(tools)
def process_tool_artifact(state: AgentState) -> dict:
last_msg = state["messages"][-1]
# 日志打印
if isinstance(last_msg, ToolMessage):
content_preview = str(last_msg.content)
if len(content_preview) > 200:
content_preview = content_preview[:200] + '...'
print(f"[Tool] 工具返回 (id={last_msg.tool_call_id}): {content_preview}")
# 只处理 ToolMessage 且有 artifact 的情况
if not isinstance(last_msg, ToolMessage) or not last_msg.artifact:
return {}
artifact = last_msg.artifact
# ---------- 统一提取所有 image_bytes ----------
img_bytes_list: list[bytes] = []
if isinstance(artifact, dict) and "image_bytes" in artifact:
# 单张图片:artifact = {"image_bytes": b"..."}
img_bytes_list.append(artifact["image_bytes"])
elif isinstance(artifact, list):
for item in artifact:
if isinstance(item, dict) and "image_bytes" in item:
img_bytes_list.append(item["image_bytes"])
elif isinstance(item, bytes):
img_bytes_list.append(item)
# 兼容 artifact 直接就是 bytes 的情况
elif isinstance(artifact, bytes):
img_bytes_list.append(artifact)
if not img_bytes_list:
return {}
# ---------- 将图片转换为多模态 content ----------
content_parts = []
failed_count = 0
for img_bytes in img_bytes_list:
data_url = image_bytes_to_openai_image_url_part(img_bytes)
if data_url is None:
failed_count += 1
continue
content_parts.append(data_url)
# 所有图片都处理失败时的降级方案
if not content_parts:
human_msg = HumanMessage(
content="工具返回了截图,但图片处理失败,请向用户说明。",
additional_kwargs={"synthetic": True}
)
return {"messages": [human_msg]}
# 追加引导文本(放在最后,也可以放在最前,取决于你的需求)
content_parts.append({
"type": "text",
"text": "上面是工具返回的截图。"
})
# 如果有部分图片失败,可以在 text 中提示(可选)
if failed_count > 0:
content_parts[-1]["text"] += f"(其中 {failed_count} 张图片处理失败,请忽略)"
human_msg = HumanMessage(
content=content_parts,
additional_kwargs={"synthetic": True}
)
return {"messages": [human_msg]}
# ---------- 4. 条件路由 ----------
def should_continue(state: AgentState) -> Literal["tools", END]:
last_msg = state["messages"][-1]
if hasattr(last_msg, "tool_calls") and last_msg.tool_calls:
return "tools"
return END
# ---------- 5. 构建 Graph ----------
workflow = StateGraph(AgentState)
workflow.add_node("agent", call_model)
workflow.add_node("tools", tool_node)
workflow.add_node("process_tool_artifact", process_tool_artifact)
workflow.add_edge(START, "agent")
workflow.add_conditional_edges("agent", should_continue, {
"tools": "tools",
END: END,
})
workflow.add_edge("tools", "process_tool_artifact")
workflow.add_edge("process_tool_artifact", "agent")
agent_graph = workflow.compile()
# ---------- 6. 辅助函数:保存消息(过滤合成消息) ----------
def save_messages_to_history(session_id: str, user_msg: HumanMessage, assistant_msg: AIMessage) -> None:
"""将用户消息和助手消息追加到 SQLite 历史记录中"""
if not session_id:
return
history = get_history(session_id)
history.add_message(user_msg)
# 只保存非合成消息
if not user_msg.additional_kwargs.get("synthetic"):
history.add_message(user_msg)
history.add_message(assistant_msg)
# ---------- 4. 核心执行函数 ----------
# ---------- 7. 核心执行函数 ----------
def execute_agent(
chat_history: list[dict[str, Any]],
session_id: str = "test",
@@ -54,20 +183,18 @@ def execute_agent(
if session_id:
rewrite_user_message_files_to_session_dir(chat_history[-1], session_id)
# 直接使用 chat_history 中已经正确格式化的 content(由 _build_user_chat_content 生成)
user_content = chat_history[-1]["content"]
# 动态上下文感知构建消息
# 上下文感知构建
messages, context_exceeded = summarizer(
session_id=session_id,
agent_name="main", # 固定 agent 名称,可扩展
agent_name="main",
system_prompt=MAIN_SYSTEM_PROMPT,
user_content=user_content,
llm=Qwen36_35B_A3B_FP8_LLM,
llm=llm,
)
if context_exceeded:
# 上下文已满,返回提示信息
chat_history.append({
"role": "assistant",
"content": "上下文已满,请新建会话继续对话。",
@@ -76,7 +203,6 @@ def execute_agent(
sync_manifest_from_chat_history(session_id, chat_history, "")
return chat_history
# 调用 graph 的配置
graph_config = {
"configurable": {"session_id": session_id},
"metadata": {
@@ -86,15 +212,15 @@ def execute_agent(
}
try:
# 调用 LangGraph agent
# 调用自定义 Graph
result = agent_graph.invoke({"messages": messages}, config=graph_config)
# 从最终消息中提取文本回复
# 提取最后一条 AI 消息
all_messages = result["messages"]
assistant_msg = all_messages[-1] if all_messages else AIMessage(content="")
text_content = assistant_text(assistant_msg.content)
# 提取 token 使用量并更新到 meta
# token 统计更新
usage = assistant_msg.usage_metadata if hasattr(assistant_msg, 'usage_metadata') else {}
total_tokens = usage.get('total_tokens', 0) if isinstance(usage, dict) else 0
if total_tokens > 0:
@@ -107,9 +233,9 @@ def execute_agent(
last_total_tokens=total_tokens,
)
# 处理 TTS 音频数据
# TTS 处理(保持原逻辑)
output_type_from_config = graph_config.get("metadata", {}).get("output_type", "text")
audio_data = Qwen36_35B_A3B_FP8_LLM.last_audio_kwargs or {}
audio_data = llm.last_audio_kwargs or {}
if audio_data.get("output_type") != output_type_from_config:
audio_data = {}
@@ -119,7 +245,6 @@ def execute_agent(
"audio": audio_data.get("audio"),
}
# 根据输出类型构建前端 content
if output_type_from_config == "audio" and assistant_meta.get("audio"):
assistant_content = []
elif output_type_from_config == "both" and assistant_meta.get("audio"):
@@ -127,22 +252,20 @@ def execute_agent(
else:
assistant_content = text_content if text_content else "(空回复)"
# 追加到返回的 chat_history
chat_history.append({
"role": "assistant",
"content": assistant_content,
"meta": assistant_meta,
})
# 持久化历史消息
# 持久化历史消息(注意过滤掉合成消息)
save_messages_to_history(
session_id,
HumanMessage(content=user_content),
AIMessage(content=text_content),
)
# 清空音频缓存
Qwen36_35B_A3B_FP8_LLM.last_audio_kwargs = None
llm.last_audio_kwargs = None
except Exception as e:
import traceback
@@ -151,7 +274,6 @@ def execute_agent(
err_text = "抱歉,我遇到了一些问题,请稍后再试。"
chat_history.append({"role": "assistant", "content": err_text})
# 同步文件清单
if session_id:
sync_manifest_from_chat_history(session_id, chat_history, "")
Binary file not shown.
@@ -1,22 +0,0 @@
{
"version": 2,
"turns": [
{
"user": {
"content": [
{
"type": "text",
"text": "你好"
}
]
},
"assistant": {
"text": "你好!很高兴能帮到你。\n\n如果你有图片、文件需要解读,或者有任何问题想要咨询,随时可以发给我或告诉我。请问有什么我可以帮你的吗?",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
}
]
}
@@ -0,0 +1,22 @@
{
"version": 2,
"turns": [
{
"user": {
"content": [
{
"type": "text",
"text": "\"C:\\Users\\86166\\Desktop\\rag_test\",帮我把这个文件夹下的文档全部归入rag个人知识库"
}
]
},
"assistant": {
"text": "已成功将 `C:\\Users\\86166\\Desktop\\rag_test` 文件夹下的所有文档(共4个文件)添加到您的RAG个人知识库中,系统共提取并添加了11条知识块。\n\n包含的文件如下:\n- 曾嘉翔-用户画像.docx\n- 朱振宁-用户画像.docx\n- 赵永顺-今日行程安排.docx\n- 赵永顺-用户画像.docx\n\n如果您需要查询这些内容或进行其他操作,请随时告诉我!",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
}
]
}
@@ -0,0 +1,56 @@
{
"version": 2,
"turns": [
{
"user": {
"content": [
{
"type": "text",
"text": "赵永顺今天上午吃了什么"
}
]
},
"assistant": {
"text": "我无法知道赵永顺今天上午吃了什么,因为我不了解他的个人生活细节,也没有相关的实时信息。如果您是他本人或认识他,可以直接询问他本人。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
},
{
"user": {
"content": [
{
"type": "text",
"text": "你可以去rag库查一查"
}
]
},
"assistant": {
"text": "根据工具返回的文本信息,赵永顺今天上午10点5分醒来后,吃了**两个酱肉包**。\n\n之后他进行了rag开发测试,并计划下午剪辑照片、召开讨论和分享会。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
},
{
"user": {
"content": [
{
"type": "text",
"text": "那他的系统架构是什么样的呢"
}
]
},
"assistant": {
"text": "根据这张截图,赵永顺的系统架构是一个名为 **`chatbot_agent`** 的编排系统,其核心是一个多模态智能体,主要包含以下几个部分:\n\n1. **用户层 (User Layer)**\n * **Gradio 桌面 UI**: 提供桌面应用程序的交互界面。\n * **FastAPI Web 静态前端**: 提供基于Web的交互界面。\n * 这两个前端都作为用户入口,与核心的 `chatbot_agent` 进行交互。\n\n2. **chatbot_agent 编排 (Orchestration)**\n 这是系统的核心处理逻辑,具体流程如下:\n * **用户输入**: 接收来自 `MultimodalTextbox` 或 `Chatbot` 的消息。\n * **消息记录**: 通过 `add_message` 将用户消息写入对话记录。\n * **代理执行 (`execute_agent`)**: 这是核心逻辑处理单元。\n * **图片处理**: 首先判断输入中是否包含图片。\n * **有图片**: 调用 `describe_images` (基于 Qwen-VL 多模态模型) 生成图片描述 (`image_description`)。\n * **无图片**: 直接使用纯文本输入。\n * **输入组合**: 将图片描述(如果有)和用户查询 (`query`) 组合成纯文本 (`chain_input_from_image_and_query`)。\n * **文件处理**: 用户上传的文件会被复制到 `history/multi_inputs/(session_id)/` 目录下。\n * **上下文组合**: 将图片路径、图片描述和用户查询组合成最终的输入 (`chain_input`)。\n * **历史会话管理**:\n * **读取历史**: 从 SQLite 数据库中读取会话历史 (`summarize_history`)。\n * **摘要与截断**: 如果消息数量超过8条,系统会将旧消息生成一个对话摘要,并只保留最近的8条消息 (`chat_history`)。\n * **构建完整上下文**: 将系统提示词 (包含助手人设和摘要占位符) 和对话历史 (`chat_history`) 组合在一起 (`assign`)。\n * **绑定历史**: 使用 `RunnableWithMessageHistory` 将构建好的上下文与历史会话绑定,并准备将新的交互写回数据库。\n\n3. **模型与外部 (Model & External)**\n * 系统通过一个 **ChatOpenAI 兼容客户端** 与外部的 **Qwen35 网关** (以 OpenAI API 风格) 进行交互,调用大语言模型。\n\n4. **持久化 (Persistence)**\n * **SQLite**: 用于存储 `chat_history.db` 和 `message_store`。\n * **ui_messages.json**: 存储会话清单。\n * **multi_inputs 附件目录**: 存储用户上传的文件。\n\n**总结来说**,这是一个结构清晰的智能体应用架构,它通过LangChain进行编排,能够处理多模态输入(文本和图片),智能地管理长对话历史(通过摘要和截断),并持久化存储所有会话和附件信息,最终通过API调用外部的大模型来生成回复。",
"output_type": "text",
"autoplay": false,
"audio_duration_seconds": null,
"audio_relpath": null
}
}
]
}
+23 -12
View File
@@ -9,20 +9,16 @@ from typing import Any, List, Dict, Optional
from pydantic import Field
import re, json
from datetime import datetime as dt
import requests
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage, SystemMessage
from langchain_core.outputs import ChatResult, ChatGeneration
from langchain_openai import ChatOpenAI
from agent.utils.image_utils import image_bytes_to_openai_image_url_part
from agent.utils.env_utils import LLM_API_KEY, LLM_BASE_URL, LLM_MODEL, LLM_TIMEOUT, TTS_BASE_URL, ASR_BASE_URL
from agent.utils.agent_utils import assistant_text
from agent.utils.image_utils import (
image_path_to_openai_image_url_part,
image_bytes_to_openai_image_url_part,
IMAGE_FILE_EXTENSIONS,
)
from agent.utils.agent_utils import assistant_text, Documents_process
from agent.utils.image_utils import image_path_to_openai_image_url_part, image_bytes_to_openai_image_url_part, IMAGE_FILE_EXTENSIONS
from agent.memory.system_prompt import VISION_SYSTEM_PROMPT
class ChatQwen(ChatOpenAI):
@@ -186,10 +182,26 @@ class ChatQwen(ChatOpenAI):
file_path = part.get("path") or (part.get("file") or {}).get("path")
if isinstance(file_path, str) and os.path.isfile(file_path):
ext = os.path.splitext(file_path)[1].lower()
normalized_path = file_path.replace("\\", "/")
if ext in IMAGE_FILE_EXTENSIONS:
new_content.append(self._process_image_content({"type": "image_url", "image_url": {"url": file_path}}))
else:
new_content.append({"type": "text", "text": f"[用户上传了附件文件: {os.path.basename(file_path)}]"})
try:
file_text, file_images = Documents_process.process(file_path)
new_content.append({"type": "text", "text": f"用户上传了一个名为\"{normalized_path}\"的文件。"})
if file_text.strip():
new_content.append({
"type": "text",
"text": f"以下是文件 {normalized_path} 的内容:\n{file_text.strip()}"
})
for image_bytes in file_images or []:
image_part = image_bytes_to_openai_image_url_part(image_bytes)
if image_part is not None:
new_content.append(image_part)
if not file_text.strip() and not file_images:
new_content.append({"type": "text", "text": f"[用户上传了附件文件: {normalized_path}]"})
except Exception as e:
new_content.append({"type": "text", "text": f"[附件处理失败: {normalized_path}: {e}]"})
else:
text = assistant_text(part)
if text:
@@ -365,8 +377,7 @@ class ChatQwen(ChatOpenAI):
result = self.invoke(messages)
return result.content
Qwen36_35B_A3B_FP8_LLM = ChatQwen()
llm = ChatQwen()
if __name__ == "__main__":
import sys
@@ -382,7 +393,7 @@ if __name__ == "__main__":
print("=" * 50)
print("测试1: 纯图片识别(无文本)")
print("=" * 50)
result1 = Qwen36_35B_A3B_FP8_LLM.invoke([
result1 = llm.invoke([
HumanMessage(content=[{"type": "image_url", "image_url": {"url": TEST_IMAGE_PATH}}])
])
print(f"返回类型: {type(result1)}")
@@ -393,7 +404,7 @@ if __name__ == "__main__":
print("\n" + "=" * 50)
print("测试2: 图文混合输入")
print("=" * 50)
result2 = Qwen36_35B_A3B_FP8_LLM.invoke([
result2 = llm.invoke([
HumanMessage(content=[
{"type": "image_url", "image_url": {"url": TEST_IMAGE_PATH}},
{"type": "text", "text": "这张图片里有什么?请详细描述"}
Binary file not shown.
+2 -1
View File
@@ -24,9 +24,10 @@ from .tool_rag import RAGTool
_all_tools = [
Websearchclass(),
DateQueryClass(),
ScreenCaptureDescribeClass(),
ScreenCapture(),
BrowserControlClass(),
RAGTool(),
DirectoryListing(),
]
def get_tools():
Binary file not shown.
Binary file not shown.
Binary file not shown.
+109 -58
View File
@@ -1,16 +1,19 @@
from __future__ import annotations
import os
import io
from typing import Optional, Literal
from typing import Optional, Literal, Tuple
import sys
import time
import webbrowser
import pyautogui
import asyncio
from pathlib import Path
from langchain.tools import BaseTool
from pydantic import BaseModel, Field, create_model
from agent.llm import Qwen36_35B_A3B_FP8_LLM
from agent.llm import llm
from utils.env_utils import BROWSER_MAP
@@ -56,8 +59,8 @@ class DateQueryClass(BaseTool):
return self._run()
class ScreenCaptureDescribeClass(BaseTool):
"""截取当前屏幕并用与对话上传图片相同的视觉链路解析为文字,供 Agent 推理。"""
class ScreenCapture(BaseTool):
"""截取当前屏幕,生成文字描述并同时提供原始截图供模型直接查看。"""
args_schema: type[BaseModel] = create_model(
"ScreenCaptureInput",
@@ -69,44 +72,42 @@ class ScreenCaptureDescribeClass(BaseTool):
),
),
)
name: str = "screen_capture_describe"
name: str = "screen_capture"
description: str = (
"截取当前主屏幕画面,并用视觉模型转为文字描述。"
"截取当前主屏幕画面。除了返回文字描述外,也会提供原始截图给模型进行视觉分析。"
"注意:这个工具仅用于获取当前屏幕的实时画面,其余任何情况严禁调用。"
)
response_format: Literal["content", "content_and_artifact"] = "content_and_artifact"
def _run(self, focus_query: Optional[str] = None) -> str:
# 1. 确保 pyautogui 可用
try:
import pyautogui
except ImportError as e:
return (
f"未安装 pyautogui,无法截屏: {e}。"
"请在项目环境中执行: pip install pyautogui"
)
# 2. 截屏并转为 PNG 字节流
def _run(self, focus_query: Optional[str] = None) -> Tuple[str, dict]:
# 1. 截屏
try:
pil_img = pyautogui.screenshot()
buf = io.BytesIO()
pil_img.save(buf, format="PNG")
raw_png = buf.getvalue()
except Exception as e:
return f"截屏失败: {e}"
return f"截屏失败: {e}", {}
# 3. 构建描述指令
query = (focus_query or "").strip()
# 4. 调用与对话图片上传完全相同的视觉模型进行描述
try:
# Qwen36_35B_A3B_FP8_LLM 为全局 ChatQwen 实例,已在模块中定义
text = Qwen36_35B_A3B_FP8_LLM.describe_image_bytes(raw_png, prompt=query)
except Exception as e:
return f"屏幕图像视觉解析失败: {e}"
if not (text or "").strip():
return "(已截屏,但模型未返回有效描述。)"
return f"【当前屏幕视觉描述】\n{text.strip()}"
if query: # focus_query 存在且非空
# 生成视觉描述,返回文字内容,不返回图片字节
try:
description = llm.describe_image_bytes(raw_png, prompt=query)
except Exception as e:
description = f"屏幕图像视觉解析失败: {e}"
if not description.strip():
description = "(未返回有效描述)"
content_text = f"【当前屏幕视觉描述】\n{description.strip()}"
return content_text, {}
else:
# 不生成描述,直接返回图片字节
artifact = {"image_bytes": raw_png}
return "", artifact
async def _arun(self, focus_query: Optional[str] = None) -> Tuple[str, dict]:
return await asyncio.to_thread(self._run, focus_query)
class BrowserControlClass(BaseTool):
"""统一浏览器控制工具:支持打开/关闭浏览器、刷新/关闭标签页、打开指定 URL"""
@@ -133,21 +134,19 @@ class BrowserControlClass(BaseTool):
"- action='open', url='current': 刷新当前标签页\n"
"- action='open', url='https://...': 在新标签页打开指定 URL\n"
"- action='close', url=None: 关闭整个浏览器窗口\n"
"- action='close', url='current': 关闭当前标签页\n"
"- action='close', url='https://...': 关闭当前活动标签页"
"- action='close', url='任意非空字符(例如current)': 关闭当前标签页\n"
"操作完成后会返回当前屏幕截图,帮助判断操作是否成功。"
"仅用于进行浏览器操作时调用,其余任何情况严禁调用。"
)
response_format: Literal["content", "content_and_artifact"] = "content_and_artifact"
# ---------- 辅助方法 ----------
# ---------- 辅助方法(与原版相同,无改动) ----------
def _get_browser_info(self, browser_name: Optional[str]):
"""获取浏览器控制器和窗口标题关键字"""
if browser_name is None:
return webbrowser.get(), None
name_lower = browser_name.lower().strip()
if name_lower not in BROWSER_MAP:
raise ValueError(f"不支持的浏览器: {browser_name},支持: {', '.join(BROWSER_MAP.keys())}")
reg_name, title_keyword = BROWSER_MAP[name_lower]
try:
controller = webbrowser.get(reg_name)
@@ -156,7 +155,6 @@ class BrowserControlClass(BaseTool):
return controller, title_keyword
def _activate_window(self, title_keyword: str) -> bool:
"""激活浏览器窗口(仅 Windows)"""
if sys.platform != "win32":
return False
try:
@@ -173,24 +171,18 @@ class BrowserControlClass(BaseTool):
return False
def _send_keys(self, *keys):
"""发送快捷键,根据平台调整"""
pyautogui.hotkey(*keys)
# ---------- 核心操作 ----------
def _open_action(self, url: Optional[str], browser: Optional[str]):
"""执行打开类操作"""
# ---------- 核心操作(返回操作结果描述) ----------
def _open_action(self, url: Optional[str], browser: Optional[str]) -> str:
controller, title_keyword = self._get_browser_info(browser)
if url is None:
# 仅打开浏览器首页(激活或启动)
controller.open("about:blank")
if title_keyword:
self._activate_window(title_keyword)
browser_info = f" (使用 {browser})" if browser else ""
return f"已打开浏览器首页{browser_info}"
elif url.lower() == "current":
# 刷新当前页
if title_keyword:
if not self._activate_window(title_keyword):
return f"未找到 {browser or '默认'} 浏览器窗口,无法刷新"
@@ -200,20 +192,15 @@ class BrowserControlClass(BaseTool):
else:
self._send_keys("f5")
return "已刷新当前标签页"
else:
# 打开指定 URL
controller.open(url)
if title_keyword:
self._activate_window(title_keyword)
return f"已尝试打开 URL: {url}"
def _close_action(self, url: Optional[str], browser: Optional[str]):
"""执行关闭类操作"""
def _close_action(self, url: Optional[str], browser: Optional[str]) -> str:
_, title_keyword = self._get_browser_info(browser)
if url is None:
# 关闭整个浏览器
if title_keyword:
if not self._activate_window(title_keyword):
return f"未找到 {browser or '默认'} 浏览器窗口,无法关闭"
@@ -223,9 +210,7 @@ class BrowserControlClass(BaseTool):
else:
self._send_keys("alt", "f4")
return "已尝试关闭浏览器窗口"
elif url.lower() == "current":
# 关闭当前标签页
else:
if title_keyword:
if not self._activate_window(title_keyword):
return f"未找到 {browser or '默认'} 浏览器窗口,无法关闭标签页"
@@ -236,16 +221,82 @@ class BrowserControlClass(BaseTool):
self._send_keys("ctrl", "w")
return "已尝试关闭当前标签页"
def _run(self, action: str = "open", url: Optional[str] = None, browser: Optional[str] = None) -> str:
# ---------- 同步主逻辑,返回 (描述, artifact) ----------
def _run(self, action: str = "open", url: Optional[str] = None, browser: Optional[str] = None) -> Tuple[str, dict]:
try:
if action == "open":
return self._open_action(url, browser)
msg = self._open_action(url, browser)
elif action == "close":
return self._close_action(url, browser)
msg = self._close_action(url, browser)
else:
return f"不支持的操作类型: {action}"
msg = f"不支持的操作类型: {action}"
except Exception as e:
return f"执行浏览器操作时出错: {e}"
msg = f"执行浏览器操作时出错: {e}"
# 无论操作是否成功,都尝试截屏
try:
pil_img = pyautogui.screenshot()
buf = io.BytesIO()
pil_img.save(buf, format="PNG")
raw_png = buf.getvalue()
return msg, {"image_bytes": raw_png}
except Exception as e:
return f"{msg}\n(截屏失败: {e})", {}
# ---------- 异步入口 ----------
async def _arun(self, action: str = "open", url: Optional[str] = None, browser: Optional[str] = None) -> Tuple[str, dict]:
return await asyncio.to_thread(self._run, action, url, browser)
class DirectoryListing(BaseTool):
"""列出指定目录下的文件和文件夹,帮助 Agent 了解本地文件结构。"""
args_schema: type[BaseModel] = create_model(
"DirectoryListingInput",
path=(
Optional[str],
Field(
default=None,
description="要查看的目录路径,默认为当前工作目录。"
),
),
show_hidden=(
bool,
Field(
default=False,
description="是否显示隐藏文件(以点开头的文件/文件夹),默认不显示。"
),
),
)
name: str = "directory_listing"
description: str = (
"列出指定目录下的所有文件和子目录。可用于探索文件系统、查找文件位置。"
"仅当需要获取目录内容或确认文件结构时调用,其余情况严禁调用。"
)
def _run(self, path: Optional[str] = None, show_hidden: bool = False) -> str:
try:
target = Path(path).resolve() if path else Path.cwd()
if not target.exists():
return f"路径不存在: {target}"
if not target.is_dir():
return f"路径不是目录: {target}"
items = []
with os.scandir(target) as entries:
for entry in entries:
if not show_hidden and entry.name.startswith('.'):
continue
item_type = "DIR" if entry.is_dir() else "FILE"
items.append(f"[{item_type}] {entry.name}")
if not items:
return f"目录 {target} 为空"
return f"目录 {target} 的内容:\n" + "\n".join(sorted(items))
except Exception as e:
return f"列出目录时出错: {str(e)}"
async def _arun(self, path: Optional[str] = None, show_hidden: bool = False) -> str:
return await asyncio.to_thread(self._run, path, show_hidden)
if __name__ == "__main__":
# 这里可以进行一些简单的测试,例如创建工具实例并调用方法
+362 -147
View File
@@ -1,11 +1,12 @@
# agent/tools/tool_rag.py
from __future__ import annotations
import asyncio
import re
import hashlib
import json
import os
from datetime import datetime
from typing import List, Optional, Literal, Union
from typing import List, Optional, Literal, Union, Tuple
import chromadb
import requests
@@ -14,24 +15,113 @@ from pydantic import BaseModel, Field, create_model
from langchain_text_splitters import RecursiveCharacterTextSplitter
from utils.env_utils import RAG_BASE_URL, RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP
from utils.agent_utils import Documents_process
from ..llm import llm
# Chroma 持久化路径:agent/memory/chroma_rag_db
# ---------- 前置函数----------
ENDING_PUNCTUATION = set(".!?。!?…\"'”’);:;:…")
def has_ending_punctuation(text: str) -> bool:
stripped = text.rstrip()
if not stripped:
return True
return stripped[-1] in ENDING_PUNCTUATION
def pre_process(text_content: str, list_image_byte: List[bytes]) -> Tuple[str, List[bytes]]:
lines = text_content.split('\n')
new_lines = []
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
# 情形1:当前行是 <image>...</image>
if stripped.startswith('<image>') and stripped.endswith('</image>'):
if i + 1 < len(lines):
next_line = lines[i + 1]
next_stripped = next_line.strip()
# 如果下一行不是 <image> 且没有结束标点,合并
if (next_stripped and
not has_ending_punctuation(next_stripped) and
not next_stripped.startswith('<image>')):
merged = f'<image>{next_stripped}</image>'
new_lines.append(merged)
i += 2
continue
new_lines.append(line)
i += 1
# 情形2:当前行是普通文本,没有结束标点
elif (stripped and
not has_ending_punctuation(stripped) and
not stripped.startswith('<image>')):
if i + 1 < len(lines):
next_line = lines[i + 1]
next_stripped = next_line.strip()
# 如果下一行是 <image>...</image>,合并
if next_stripped.startswith('<image>') and next_stripped.endswith('</image>'):
merged = f'<image>{stripped}</image>'
new_lines.append(merged)
i += 2
continue
new_lines.append(line)
i += 1
else:
new_lines.append(line)
i += 1
merged_text = '\n'.join(new_lines)
# 替换 <image> 内容:插入图片描述
pattern = re.compile(r'<image>(.*?)</image>', re.DOTALL)
matches = list(pattern.finditer(merged_text))
result_parts = []
last_end = 0
for idx, match in enumerate(matches):
result_parts.append(merged_text[last_end:match.start()])
inner = match.group(1).strip()
if idx < len(list_image_byte):
try:
description = llm.describe_image_bytes(
list_image_byte[idx],
prompt="""你是一名专业的图像理解助手,现在需要你为即将加入向量库的图片做文字分析以便于用户更迅速、更高效、较准确地查询到此图片。
首先请用客观、准确的中文描述用户提供的图片,覆盖主要物体、场景、人物动作、界面元素、图表数据与清晰可读的文字;
涉及到你认识的具体品牌、时间、地点、人物、影视剧、物品等特定内容时请直接命名说明;看不清的内容请如实说明,不要臆测。
其次请为此图片设想出3到5个可能会问及的客观问题,放在末尾处。"""
)
except Exception:
description = "图片描述生成失败"
else:
description = ""
if inner:
new_inner = f"{inner}\n{description}" if description else inner
else:
new_inner = description
result_parts.append(f"<image>{new_inner}</image>")
last_end = match.end()
result_parts.append(merged_text[last_end:])
final_text = ''.join(result_parts)
return final_text, list_image_byte
# ---------- 存储路径、客户端 ----------
CHROMA_PERSIST_DIR = os.path.join(os.path.dirname(__file__), '..', 'memory', 'chroma_rag_db')
# 统一的图片存储目录
IMAGE_STORE_DIR = os.path.join(CHROMA_PERSIST_DIR, "images")
# 全局 Chroma 客户端和统一集合
_chroma_client = None
_global_collection = None
GLOBAL_COLLECTION_NAME = "rag_global"
def get_chroma_client():
global _chroma_client
if _chroma_client is None:
_chroma_client = chromadb.PersistentClient(path=CHROMA_PERSIST_DIR)
return _chroma_client
def get_global_collection():
"""获取或创建全局知识库 Collection"""
global _global_collection
if _global_collection is None:
client = get_chroma_client()
@@ -46,11 +136,8 @@ def get_global_collection():
)
return _global_collection
# ---------- Embedding 接口封装 ----------
def embed_texts(queries: List[str], documents: List[str] = None) -> tuple:
"""
调用 /embed,返回 (query_embeddings, doc_embeddings)
"""
payload = {
"task_description": "Given a web search query, retrieve relevant passages that answer the query",
"queries": queries,
@@ -64,8 +151,8 @@ def embed_texts(queries: List[str], documents: List[str] = None) -> tuple:
except Exception as e:
raise RuntimeError(f"Embedding 请求失败: {e}")
def rerank_documents(query: str, documents: List[str]) -> List[float]:
"""调用 /rerank,返回排序后的分数列表"""
payload = {
"task_description": "Given a web search query, retrieve relevant passages that answer the query",
"query": query,
@@ -78,93 +165,49 @@ def rerank_documents(query: str, documents: List[str]) -> List[float]:
except Exception as e:
raise RuntimeError(f"Reranker 请求失败: {e}")
# ---------- 文本分块 ----------
def split_into_chunks(text: str) -> List[str]:
splitter = RecursiveCharacterTextSplitter(
chunk_size=RAG_CHUNK_SIZE,
chunk_overlap=RAG_CHUNK_OVERLAP,
separators=["\n\n", "\n", " ", ""]
)
return splitter.split_text(text)
# ---------- 文件解析 ----------
def parse_file_to_chunks(file_path: str) -> List[dict]:
"""
解析文件,返回每个 chunk 的字典,包含:
- content: 文本内容
- source: 详细来源描述
"""
ext = os.path.splitext(file_path)[1].lower()
chunks = []
if ext == ".txt" or ext == ".md":
with open(file_path, "r", encoding="utf-8") as f:
text = f.read()
texts = split_into_chunks(text)
for i, t in enumerate(texts):
chunks.append({
"content": t,
"source": f"{os.path.basename(file_path)} (chunk {i+1}/{len(texts)})"
})
elif ext == ".json":
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
text = json.dumps(data, ensure_ascii=False, indent=2)
texts = split_into_chunks(text)
for i, t in enumerate(texts):
chunks.append({
"content": t,
"source": f"{os.path.basename(file_path)} (JSON, chunk {i+1}/{len(texts)})"
})
elif ext == ".csv":
import pandas as pd
df = pd.read_csv(file_path)
text = df.to_string(index=False)
texts = split_into_chunks(text)
for i, t in enumerate(texts):
chunks.append({
"content": t,
"source": f"{os.path.basename(file_path)} (CSV, chunk {i+1}/{len(texts)})"
})
elif ext == ".docx":
from docx import Document
doc = Document(file_path)
full_text = "\n".join([p.text for p in doc.paragraphs])
texts = split_into_chunks(full_text)
for i, t in enumerate(texts):
chunks.append({
"content": t,
"source": f"{os.path.basename(file_path)} (DOCX, chunk {i+1}/{len(texts)})"
})
elif ext == ".pdf":
import pdfplumber
with pdfplumber.open(file_path) as pdf:
for page_num, page in enumerate(pdf.pages, start=1):
page_text = page.extract_text() or ""
if page_text.strip():
page_chunks = split_into_chunks(page_text)
for i, t in enumerate(page_chunks):
chunks.append({
"content": t,
"source": f"{os.path.basename(file_path)} (Page {page_num}, chunk {i+1})"
})
def split_into_chunks(input_data) -> List[dict]:
if isinstance(input_data, str):
text = input_data
image_bytes_list = []
elif isinstance(input_data, tuple) and len(input_data) == 2:
text, image_bytes_list = input_data
else:
raise ValueError(f"不支持的文件格式: {ext}")
text = ""
image_bytes_list = []
return chunks
pattern = re.compile(r'(<image>[\s\S]*?</image>|<table>[\s\S]*?</table>)')
parts = pattern.split(text)
all_chunks = []
image_index = 0
for part in parts:
if not part:
continue
if (part.startswith('<image>') and part.endswith('</image>')) or \
(part.startswith('<table>') and part.endswith('</table>')):
chunk_info = {"content": part, "is_protected": True}
if part.startswith('<image>') and image_index < len(image_bytes_list):
chunk_info["image_bytes"] = image_bytes_list[image_index]
image_index += 1
all_chunks.append(chunk_info)
else:
splitter = RecursiveCharacterTextSplitter(
chunk_size=RAG_CHUNK_SIZE,
chunk_overlap=RAG_CHUNK_OVERLAP,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", ".", "!", "?", ";", ",", " ", ""]
)
split_parts = splitter.split_text(part)
all_chunks.extend([{"content": p, "is_protected": False} for p in split_parts])
return all_chunks
# ---------- RAG 工具本体 ----------
# ---------- RAGTool ----------
class RAGTool(BaseTool):
name: str = "rag_tool"
description: str = (
"通用知识库工具,支持增删查操作。\n"
"个人知识库管理工具,支持增删查及列出所有来源操作。\n"
"参数说明:\n"
"- action_type: 操作类型,必填。可选:'query'(查询), 'add_document'(添加文档), 'clear'(清除)。\n"
"- action_type: 操作类型,必填。可选:'query'(查询), 'add_document'(添加文档), 'clear'(清除), 'list_sources'(列出所有来源)。\n"
"- content: 查询内容(用于query) 或 直接文本内容(用于add_document,与source二选一)。\n"
"- source: 文件路径(用于add_document,与content二选一)。\n"
"- filter: 过滤条件(用于query或clear)。\n"
@@ -172,24 +215,29 @@ class RAGTool(BaseTool):
"- 查询:action_type='query', content='用户问题'\n"
"- 添加文本:action_type='add_document', content='知识文本'\n"
"- 添加文件:action_type='add_document', source='/path/to/file.pdf'\n"
"- 清除:action_type='clear', filter='某个来源'"
"- 清除:action_type='clear', filter='某个来源'\n"
"- 列出所有来源:action_type='list_sources'"
)
args_schema: type[BaseModel] = create_model(
"RAGInput",
action_type=(Literal["query", "add_document", "clear"], Field(..., description="操作类型")),
action_type=(Literal["query", "add_document", "clear", "list_sources"], Field(..., description="操作类型")),
content=(Optional[str], Field(None, description="查询内容或直接添加的文本内容")),
source=(Optional[str], Field(None, description="文件路径,用于添加文档")),
source=(Optional[Union[str, List[str]]], Field(None, description="文件路径(字符串或列表),用于添加文档")),
filter=(Optional[Union[str, List[str]]], Field(None, description="过滤条件,用于查询或清除"))
)
# ========== 关键改动:声明返回内容和 artifact ==========
response_format: Literal["content", "content_and_artifact"] = "content_and_artifact"
def _run(
self,
action_type: str,
content: Optional[str] = None,
source: Optional[str] = None,
source: Optional[Union[str, List[str]]] = None,
filter: Optional[Union[str, List[str]]] = None
) -> str:
) -> Tuple[str, Union[dict, list]]:
"""统一返回 (text, artifact) 元组"""
try:
if action_type == "query":
return self._handle_query(content, filter)
@@ -197,27 +245,47 @@ class RAGTool(BaseTool):
return self._handle_add(content, source)
elif action_type == "clear":
return self._handle_clear(filter)
elif action_type == "list_sources":
return self._handle_list_sources()
else:
return f"未知操作: {action_type}"
return f"未知操作: {action_type}", {}
except Exception as e:
return f"RAG 工具执行出错: {str(e)}"
return f"RAG 工具执行出错: {str(e)}", {}
# ---------- 查询 ----------
def _handle_query(self, query: str, filter: Optional[Union[str, List[str]]]) -> str:
async def _arun(self, **kwargs) -> Tuple[str, Union[dict, list]]:
return await asyncio.to_thread(self._run, **kwargs)
def _get_batch_chunks(self, coll, batch_id: str) -> dict:
res = coll.get(where={"batch_id": batch_id})
batch_chunks = {}
if res and res["metadatas"] and res["documents"]:
for meta, doc in zip(res["metadatas"], res["documents"]):
chunk_idx = meta.get("chunk_index")
if chunk_idx is not None:
batch_chunks[chunk_idx] = {
"content": doc,
"is_image": meta.get("is_image", False),
"is_table": meta.get("is_table", False),
"is_protected": meta.get("is_protected", False),
"image_path": meta.get("image_path", "")
}
return batch_chunks
# ---------- 查询(返回 artifact)----------
def _handle_query(self, query: str, filter: Optional[Union[str, List[str]]]) -> Tuple[str, list]:
if not query:
return "查询内容不能为空。"
return "查询内容不能为空。", []
# 1. 获取查询向量
q_embs, _ = embed_texts(queries=[query])
query_vector = q_embs[0]
# 2. 从全局 Collection 中检索
# 2. 检索
coll = get_global_collection()
where = None
if filter:
if isinstance(filter, str):
where = {"source": {"$contains": filter}}
# 列表暂简化为只支持单个字符串,但保留接口
elif isinstance(filter, list) and len(filter) > 0:
where = {"source": {"$contains": filter[0]}}
@@ -228,7 +296,7 @@ class RAGTool(BaseTool):
)
if not res["ids"] or not res["ids"][0]:
return "未在知识库中找到相关内容。"
return "未在知识库中找到相关内容。", []
ids = res["ids"][0]
docs = res["documents"][0]
@@ -243,7 +311,7 @@ class RAGTool(BaseTool):
unique_results = all_results[:10]
# 3. Reranker 精排
# 3. Reranker
docs_for_rerank = [r["document"] for r in unique_results]
scores = rerank_documents(query, docs_for_rerank)
@@ -254,93 +322,240 @@ class RAGTool(BaseTool):
)[:3]
if not sorted_items:
return "Reranker 未返回有效结果。"
return "Reranker 未返回有效结果。", []
outputs = []
all_image_bytes = [] # 收集图片字节
for item, score in sorted_items:
meta = item["metadata"]
source_info = meta.get("source", "未知来源")
outputs.append(f"【来源:{source_info}(相关度 {score:.3f})】\n{item['document']}")
is_image = meta.get("is_image", False)
is_table = meta.get("is_table", False)
batch_id = meta.get("batch_id")
chunk_index = meta.get("chunk_index")
return "\n\n".join(outputs)
batch_chunks = self._get_batch_chunks(coll, batch_id) if batch_id else {}
# 拼接上下文(前、当前、后)
context_parts = []
for idx_offset in [-1, 0, 1]:
current_idx = chunk_index + idx_offset
if current_idx in batch_chunks:
chunk = batch_chunks[current_idx]
context_parts.append(chunk["content"])
# 如果是图片块,从文件读取字节
if chunk["is_image"] and chunk.get("image_path"):
img_path = chunk["image_path"]
if os.path.isfile(img_path):
try:
with open(img_path, "rb") as f:
all_image_bytes.append(f.read())
except Exception:
pass
context_text = "\n\n".join(context_parts)
# 格式化文本输出
if is_image:
outputs.append(
f"【图片数据 - 来源:{meta.get('source', '未知来源')}(相关度 {score:.3f})】\n[图片数据已获取]")
elif is_table:
outputs.append(
f"【表格数据 - 来源:{meta.get('source', '未知来源')}(相关度 {score:.3f})】\n{context_text}")
else:
outputs.append(
f"【文本数据 - 来源:{meta.get('source', '未知来源')}(相关度 {score:.3f})】\n{context_text}")
final_text = "\n\n".join(outputs)
# 去重图片字节,并按每个图片单独打包成一个 dict,放入 artifact 列表
unique_image_bytes = list(dict.fromkeys(all_image_bytes)) # 保持顺序去重
artifact = [{"image_bytes": img_bytes} for img_bytes in unique_image_bytes]
return final_text, artifact
# ---------- 添加文档 ----------
def _handle_add(self, content: Optional[str], source: Optional[str]) -> str:
def _handle_add(self, content: Optional[str], source: Optional[Union[str, List[str]]]) -> Tuple[str, dict]:
if not content and not source:
return "必须提供 content 或 source。"
return "必须提供 content 或 source。", {}
if content and source:
return "content 和 source 只能提供一个。"
return "content 和 source 只能提供一个。", {}
# 1. 准备 chunks 列表
chunks_meta = [] # 元素为 (text, source_desc)
# 统一 batch_id,整个添加操作共享
timestamp = datetime.now().isoformat()
batch_id = hashlib.md5(timestamp.encode()).hexdigest()
# 收集所有文档的 chunk 信息
all_chunks_meta = [] # (text, src_desc, is_protected, image_path)
file_errors = []
# 处理直接文本输入
if content:
texts = split_into_chunks(content)
chunks_meta = [(t, "直接输入") for t in texts]
processed_text, processed_images = pre_process(content, [])
chunk_infos = split_into_chunks((processed_text, processed_images))
base_dir = IMAGE_STORE_DIR
base_name = f"direct_{batch_id[:8]}"
source_desc_prefix = "直接输入"
image_idx = 0
for idx, chunk_info in enumerate(chunk_infos):
text = chunk_info["content"]
img_bytes = chunk_info.get("image_bytes")
is_protected = chunk_info.get("is_protected", False)
src_desc = f"{source_desc_prefix} (chunk {idx + 1}/{len(chunk_infos)})"
image_path = ""
if text.startswith('<image>') and text.endswith('</image>') and img_bytes is not None:
os.makedirs(base_dir, exist_ok=True)
image_path = os.path.join(base_dir, f"{base_name}_image{image_idx}.jpg")
try:
with open(image_path, "wb") as f:
f.write(img_bytes)
except Exception as e:
return f"图片保存失败: {e}", {}
image_idx += 1
all_chunks_meta.append((text, src_desc, is_protected, image_path))
# 处理文件输入(字符串或列表)
elif source:
if not os.path.isfile(source):
return f"文件不存在: {source}"
chunks_meta = [(c["content"], c["source"]) for c in parse_file_to_chunks(source)]
# 统一为列表处理
source_files = [source] if isinstance(source, str) else source
for file_path in source_files:
if not os.path.isfile(file_path):
file_errors.append(f"文件不存在: {file_path}")
continue
if not chunks_meta:
return "文档内容为空,未添加任何条目。"
try:
raw_text, image_list = Documents_process.process(file_path)
except Exception as e:
file_errors.append(f"处理文件失败 {file_path}: {e}")
continue
# 2. 批量获取文档向量
doc_texts = [cm[0] for cm in chunks_meta]
processed_text, processed_images = pre_process(raw_text, image_list)
chunk_infos = split_into_chunks((processed_text, processed_images))
if not chunk_infos:
file_errors.append(f"文件内容为空: {file_path}")
continue
base_dir = os.path.dirname(file_path)
base_name = os.path.splitext(os.path.basename(file_path))[0]
source_desc_prefix = os.path.basename(file_path)
image_idx = 0
for idx, chunk_info in enumerate(chunk_infos):
text = chunk_info["content"]
img_bytes = chunk_info.get("image_bytes")
is_protected = chunk_info.get("is_protected", False)
src_desc = f"{source_desc_prefix} (chunk {idx + 1}/{len(chunk_infos)})"
image_path = ""
if text.startswith('<image>') and text.endswith('</image>') and img_bytes is not None:
os.makedirs(IMAGE_STORE_DIR, exist_ok=True)
image_path = os.path.join(IMAGE_STORE_DIR, f"{base_name}_image{image_idx}.jpg")
try:
with open(image_path, "wb") as f:
f.write(img_bytes)
except Exception as e:
file_errors.append(f"图片保存失败 ({file_path}): {e}")
# 即便图片保存失败,文本块仍可入库,继续执行
image_idx += 1
all_chunks_meta.append((text, src_desc, is_protected, image_path))
if not all_chunks_meta:
error_msg = "没有可用内容添加到知识库。"
if file_errors:
error_msg += " 错误详情: " + "; ".join(file_errors)
return error_msg, {}
# 统一写入 Chroma:分配连续 chunk_index
total_chunks = len(all_chunks_meta)
doc_texts = [m[0] for m in all_chunks_meta]
_, doc_embs = embed_texts(queries=["dummy"], documents=doc_texts)
# 3. 写入全局 Collection
coll = get_global_collection()
ids = []
embeddings = []
metadatas = []
documents = []
ids, embeddings, metadatas, documents = [], [], [], []
for idx, (text, src_desc) in enumerate(chunks_meta):
uid = hashlib.md5(f"{timestamp}_{text[:80]}".encode()).hexdigest()
for idx, (text, src_desc, is_protected, image_path) in enumerate(all_chunks_meta):
uid = hashlib.md5(f"{timestamp}_{text[:80]}_{idx}".encode()).hexdigest()
ids.append(uid)
embeddings.append(doc_embs[idx])
metadatas.append({
metadata = {
"source": src_desc,
"timestamp": timestamp,
})
"batch_id": batch_id,
"chunk_index": idx, # 全局连续索引
"total_chunks": total_chunks,
"is_image": text.startswith('<image>') and text.endswith('</image>'),
"is_table": text.startswith('<table>') and text.endswith('</table>'),
"is_protected": is_protected
}
if image_path:
metadata["image_path"] = image_path
metadatas.append(metadata)
documents.append(text)
coll.add(
ids=ids,
embeddings=embeddings,
metadatas=metadatas,
documents=documents
)
coll.add(ids=ids, embeddings=embeddings, metadatas=metadatas, documents=documents)
return f"成功添加 {len(ids)} 条知识块到全局知识库。"
result_msg = f"成功添加 {total_chunks} 条知识块到全局知识库。"
if file_errors:
result_msg += " 部分文件处理出错: " + "; ".join(file_errors)
return result_msg, {}
# ---------- 清除 ----------
def _handle_clear(self, filter: Optional[Union[str, List[str]]]) -> str:
def _handle_clear(self, filter: Optional[Union[str, List[str]]]) -> Tuple[str, dict]:
if not filter:
return "清除操作必须提供 filter 条件。"
return "清除操作必须提供 filter 条件。", {}
coll = get_global_collection()
where = None
if isinstance(filter, str):
where = {"source": {"$contains": filter}}
elif isinstance(filter, list) and len(filter) > 0:
# 简化为使用第一个条件
where = {"source": {"$contains": filter[0]}}
if not where:
return "无法构建有效的过滤条件。"
return "无法构建有效的过滤条件。", {}
try:
res = coll.get(where=where)
if res["ids"]:
# 清理关联的图片文件
for meta in res.get("metadatas", []):
img_path = meta.get("image_path", "")
if img_path and os.path.isfile(img_path):
try:
os.remove(img_path)
except Exception:
pass
coll.delete(ids=res["ids"])
return f"已从全局知识库中删除 {len(res['ids'])} 条记录。"
return f"已从全局知识库中删除 {len(res['ids'])} 条记录。", {}
else:
return "未找到匹配的记录。"
return "未找到匹配的记录。", {}
except Exception as e:
return f"清除时出错: {e}"
return f"清除时出错: {e}", {}
async def _arun(self, **kwargs):
return self._run(**kwargs)
# ---------- 列出所有来源 ----------
def _handle_list_sources(self) -> Tuple[str, dict]:
"""获取知识库中所有不重复的 source 信息"""
coll = get_global_collection()
try:
all_data = coll.get() # 获取全部文档的元数据
except Exception as e:
return f"获取知识库数据失败: {e}", {}
if not all_data or not all_data["metadatas"]:
return "知识库中暂无数据。", {}
sources = set()
for meta in all_data["metadatas"]:
src = meta.get("source", "未知来源")
sources.add(src)
sorted_sources = sorted(sources)
text = "知识库中的来源列表:\n" + "\n".join(f"- {s}" for s in sorted_sources)
return text, {}
+281 -6
View File
@@ -4,6 +4,8 @@ import os
import shutil
import sqlite3
from typing import List, Tuple, Any
import pathlib
import mimetypes
from langchain_community.chat_message_histories import SQLChatMessageHistory
from langchain_core.messages import BaseMessage, SystemMessage, HumanMessage
@@ -16,6 +18,17 @@ from agent.utils.env_utils import (
HARD_COMPRESS_RATE,
CURRENT_TURNS,
)
from agent.utils.image_utils import IMAGE_FILE_EXTENSIONS
# 扩展名到内部类型标签的映射
DOC_EXT_MAP = {
".txt": "plain_text", ".json": "plain_text", ".md": "plain_text",".cpp": "plain_text",
".c": "plain_text", ".py": "plain_text", ".m": "plain_text", ".java": "plain_text",
".docx": "docx",
".pdf": "pdf",
".csv": "tabular",
".xlsx": "tabular",
}
# ---------- 目录与数据库路径 ----------
_AGENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -117,12 +130,6 @@ def _count_turns(messages: List[BaseMessage]) -> int:
i += 1
return turns
def _last_turn_end_index(messages: List[BaseMessage]) -> int:
for i in range(len(messages) - 1, -1, -1):
if messages[i].type in ("ai", "assistant"):
return i
return -1
# ---------- 压缩函数 ----------
def _lightweight_compress(llm, messages_to_compress: List[BaseMessage]) -> str:
"""轻量压缩:保留关键事实、实体、决策,删除冗余"""
@@ -281,3 +288,271 @@ def assistant_text(value) -> str:
return str(value).strip()
return str(value).strip()
class Documents_process:
"""文件分类与内容提取工具集"""
@staticmethod
def doc_classify(file_path: str) -> str:
"""
根据扩展名和 MIME 类型返回内部处理类型标签。
可能的返回值:'plain_text'、'docx'、'pdf'、'image'、'tabular'。
"""
ext = pathlib.Path(file_path).suffix.lower()
# 先检查文档映射表
if ext in DOC_EXT_MAP:
return DOC_EXT_MAP[ext]
# 再检查是否为图片
if ext in IMAGE_FILE_EXTENSIONS:
return "image"
# 最后使用 MIME 类型判断
mime, _ = mimetypes.guess_type(file_path)
if mime:
if mime.startswith("text/"):
return "plain_text"
if mime.startswith("image/"):
return "image"
raise ValueError(f"不支持的文件类型: {file_path}")
# ---------- 各类文件的具体处理方法 ----------
@staticmethod
def process_plain_text(file_path: str) -> tuple[str, list]:
with open(file_path, "r", encoding="utf-8") as f:
return f.read(), []
@staticmethod
def process_docx(file_path: str) -> tuple[str, list]:
"""按文档顺序提取文本、图片和表格,并将短标题与图表合并。"""
try:
from docx import Document
from docx.oxml.ns import qn
from docx.opc.constants import RELATIONSHIP_TYPE as RT
except ImportError:
raise ImportError("需要安装 python-docx: pip install python-docx")
doc = Document(file_path)
content_parts = []
image_bytes_list = []
image_counter = 0
# ---------- 辅助函数:按文档顺序迭代段落和表格 ----------
def iter_block_items(parent):
"""生成文档中的段落和表格,保持原始顺序"""
from docx.oxml.ns import qn as ns_qn
if hasattr(parent, 'element'):
parent_elm = parent.element.body
else:
parent_elm = parent
# 建立元素到段落的映射,便于 O(1) 查找
para_map = {p._element: p for p in doc.paragraphs}
# 表格同样建立映射
table_map = {t._element: t for t in doc.tables}
for child in parent_elm.iterchildren():
if child.tag == ns_qn('w:p'):
para = para_map.get(child)
if para is not None:
yield para
elif child.tag == ns_qn('w:tbl'):
table = table_map.get(child)
if table is not None:
yield table
def extract_image_from_run(run, para):
"""从 run 中提取图片,返回 <image>N</image> 字符串和图片二进制列表"""
nonlocal image_counter
drawings = run._element.findall('.//' + qn('w:drawing'))
imgs = []
for drawing in drawings:
blips = drawing.findall('.//' + qn('a:blip'))
for blip in blips:
embed = blip.get(qn('r:embed'))
if embed:
image_counter += 1
related_part = para.part.related_parts[embed]
image_bytes_list.append(related_part.blob)
imgs.append(f"<image>{image_counter}</image>")
return imgs
pending_title = "" # 缓存可能存在的图表短标题
for item in iter_block_items(doc):
if hasattr(item, 'text'): # 段落
para = item
para_parts = []
# 提取图片
for run in para.runs:
para_parts.extend(extract_image_from_run(run, para))
# 提取文字
text = para.text.strip()
if text:
para_parts.insert(0, text)
para_str = "".join(para_parts)
if not para_str:
continue
# 判断是否是短标题(如“表1 一个表格”、“图1 一张图片”)
if len(text) < 30 and (text.startswith("表") or text.startswith("图") or text.startswith("Table") or text.startswith("Fig")):
# 暂存为候选标题,等待后续表格/图片
pending_title = para_str
continue
# 普通段落:先输出可能遗留的标题(没有后续图表匹配的),再输出本段
if pending_title:
content_parts.append(pending_title + "\n")
pending_title = ""
content_parts.append(para_str + "\n")
else: # 表格
table = item
table_str_parts = [f"===== Table ??? =====\n"] # 标题稍后合并
for row in table.rows:
row_data = [cell.text.strip() for cell in row.cells]
table_str_parts.append("| " + " | ".join(row_data) + " |\n")
table_content = "".join(table_str_parts)
# 如果有暂存标题,合并到一起
if pending_title:
# 替换表格占位标题
final_table = f"<table>{pending_title}\n{table_content[table_content.index('\n')+1:]}</table>"
content_parts.append(final_table + "\n")
pending_title = ""
else:
# 没有标题,保留原始表格内容
content_parts.append(f"<table>{table_content}</table>\n")
# 文件末尾的遗留标题
if pending_title:
content_parts.append(pending_title + "\n")
return "".join(content_parts), image_bytes_list
@staticmethod
def process_pdf(file_path: str) -> tuple[str, list]:
try:
from pypdf import PdfReader
except ImportError:
raise ImportError("需要安装 pypdf:pip install pypdf")
reader = PdfReader(file_path)
text_parts = []
image_bytes_list = []
image_counter = 0 # 全局图片计数器
for page in reader.pages:
page_content = []
# 提取文字
page_text = page.extract_text()
if page_text:
page_content.append(page_text)
# 提取图片,并在文本中追加占位符
for _ in page.images:
image_counter += 1
page_content.append(f"<image>{image_counter}</image>")
# 收集图片二进制数据
for image_file_object in page.images:
image_bytes_list.append(image_file_object.data)
# 合并当前页的内容
if page_content:
text_parts.append("".join(page_content))
return "\n".join(text_parts), image_bytes_list
@staticmethod
def process_tabular(file_path: str) -> tuple[str, list]:
"""
处理 CSV 和 Excel(.xlsx) 文件。
将所有 sheet 的内容转化为 <table>SheetName\n内容</table> 格式的字符串。
图片列表固定为空。
"""
try:
import csv
from openpyxl import load_workbook
except ImportError:
raise ImportError("需要安装 csv和openpyxl:pip install csv openpyxl")
result_parts = []
ext = pathlib.Path(file_path).suffix.lower()
if ext == '.csv':
# CSV 只有一个“sheet”
with open(file_path, 'r', encoding='utf-8-sig', newline='') as f:
reader = csv.reader(f)
rows = list(reader)
table_str = '\n'.join([' | '.join(row) for row in rows])
result_parts.append(f"<table>Sheet1\n{table_str}</table>")
elif ext == '.xlsx':
wb = load_workbook(file_path, read_only=True, data_only=True)
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
rows = []
for row in ws.iter_rows(values_only=True):
rows.append(' | '.join([str(cell) if cell is not None else '' for cell in row]))
table_str = '\n'.join(rows)
result_parts.append(f"<table>{sheet_name}\n{table_str}</table>")
wb.close()
else:
raise ValueError(f"Unsupported tabular file extension: {ext}")
return "\n".join(result_parts), []
@staticmethod
def process_image(file_path: str) -> tuple[str, list]:
with open(file_path, "rb") as f:
return "", [f.read()]
# ---------- 统一处理入口 ----------
@staticmethod
def process(file_path: str) -> tuple[str, list]:
file_type = Documents_process.doc_classify(file_path)
if file_type == "plain_text":
return Documents_process.process_plain_text(file_path)
elif file_type == "docx":
return Documents_process.process_docx(file_path)
elif file_type == "pdf":
return Documents_process.process_pdf(file_path)
elif file_type == "image":
return Documents_process.process_image(file_path)
elif file_type == "tabular":
return Documents_process.process_tabular(file_path)
else:
raise ValueError(f"未处理的文件类型: {file_type}")
if __name__ == "__main__":
docx_path = r"C:\Users\86166\Desktop\Agent_Learning_minor\信息科学与工程学院-赵永顺-《兼爱》——墨翟一生的“白月光”.docx"
pdf_path = r"C:\Users\86166\Desktop\Agent_Learning_minor\信息科学与工程学院-赵永顺-《兼爱》——墨翟一生的“白月光”.pdf"
xlsx_path = r"C:\Users\86166\Desktop\Agent_Learning_minor\电科二班2025德育评价.xlsx"
py_path = r"C:\Users\86166\Desktop\Agent_Learning_minor\speech_server.py"
output_path = "test_results.txt"
with open(output_path, "w", encoding="utf-8") as f:
test_files = [
("py", py_path),
("docx", docx_path),
("xlsx", xlsx_path),
("pdf", pdf_path),
]
for label, path in test_files:
f.write(f"\n===== 测试 {label} 文件:{path} =====\n")
try:
text, images = Documents_process.process(path)
f.write(f"提取文本长度:{len(text)}\n")
f.write(f"提取图片数量:{len(images)}\n")
f.write("文本预览:\n")
f.write(text)
except Exception as e:
f.write(f"❌ 处理失败: {e}\n")
print(f"测试结果已写入 {output_path}")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+53
View File
@@ -964,6 +964,59 @@ body {
min-height: 0;
}
.file-btn {
position: relative;
}
.file-btn--icon-only {
width: 2.1rem;
min-width: 2.1rem;
height: 2.1rem;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
}
.file-btn-icon {
width: 0.9rem;
height: 0.9rem;
display: block;
}
.document-badge {
display: inline-flex;
align-items: center;
gap: 0.45rem;
max-width: 100%;
margin: 0.3rem 0;
padding: 0.1rem 0;
border: none;
background: transparent;
box-shadow: none;
}
.document-badge--user {
margin-left: auto;
}
.document-badge-icon {
width: 0.95rem;
height: 0.95rem;
flex-shrink: 0;
border: none;
margin: 0;
}
.document-badge-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.88rem;
}
.chip {
font-size: 0.75rem;
padding: 0.22rem 0.5rem;
+69 -86
View File
@@ -5,8 +5,7 @@
const chatMessages = $("chatMessages");
const chatPlaceholder = $("chatPlaceholder");
const textInput = $("textInput");
const imageInput = $("imageInput");
const audioInput = $("audioInput");
const fileInput = $("fileInput");
const micBtn = $("micBtn");
const autoInputCheck = $("autoInputCheck");
const attachmentPreview = $("attachmentPreview");
@@ -17,7 +16,6 @@
const collapseBtn = $("collapseBtn");
const expandBtn = $("expandBtn");
const expandRail = $("expandRail");
const sidebar = $("sidebar");
const appRoot = $("app");
const mainPanel = $("mainPanel");
const headerToggleBtn = $("headerToggleBtn");
@@ -39,10 +37,7 @@
/** @type {string[]} */
let sessions = [];
/** @type {File[]} */
let pendingImages = [];
/** @type {File | null} */
let pendingAudio = null;
let sidebarCollapsed = false;
let pendingFiles = [];
let sending = false;
let mediaRecorder = null;
let recorderStream = null;
@@ -128,21 +123,32 @@
.replace(/"/g, "&quot;");
}
function buildOptimisticUserContent(text, imageBlobUrls, audioPart) {
function buildOptimisticUserContent(text, fileParts) {
const parts = [];
(imageBlobUrls || []).forEach((url) => {
parts.push({ type: "image", url });
(fileParts || []).forEach((filePart) => {
if (filePart && filePart.kind === "image" && filePart.url) {
parts.push({ type: "image", url: filePart.url, name: filePart.name || "图片" });
} else if (filePart && filePart.kind === "audio" && filePart.url) {
parts.push({ type: "audio", url: filePart.url, name: filePart.name || "音频" });
} else if (filePart && filePart.name) {
parts.push({ type: "file", name: filePart.name });
}
});
const t = (text || "").trim();
if (t) parts.push({ type: "text", text: t });
if (audioPart && audioPart.url) {
parts.push({ type: "audio", url: audioPart.url, name: audioPart.name || "音频" });
}
if (parts.length === 0) return null;
if (parts.length === 1 && parts[0].type === "text") return parts[0].text;
return parts;
}
function inferUploadKind(file) {
const name = String(file?.name || "").toLowerCase();
const type = String(file?.type || "").toLowerCase();
if (type.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name)) return "image";
if (type.startsWith("audio/") || /\.(wav|mp3|m4a|flac|ogg|webm)$/.test(name)) return "audio";
return "file";
}
function getTextFromContent(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
@@ -295,7 +301,7 @@
audioContext = new (window.AudioContext || window.webkitAudioContext)();
if (audioContext.state === 'suspended') {
if (audioContext.state === "suspended") {
await audioContext.resume();
}
@@ -406,7 +412,7 @@
recorder.addEventListener("stop", () => {
try {
const blob = new Blob(recordedChunks, { type: recordingMimeType || recorder.mimeType || "audio/webm" });
pendingAudio = buildRecordedAudioFile(blob, recordingMimeType || recorder.mimeType);
pendingFiles = pendingFiles.concat(buildRecordedAudioFile(blob, recordingMimeType || recorder.mimeType));
renderAttachmentChips();
resolve();
} catch (err) {
@@ -627,10 +633,6 @@
return wrap;
}
function parseAssistantContent(full) {
return renderMarkdownToFragment(full);
}
function fillAssistantBubbleImmediate(bubble, text) {
const t = text == null ? "" : String(text);
bubble.classList.add("msg-bubble--rich", "markdown-body");
@@ -895,6 +897,24 @@
return wrap;
}
function renderDocumentBadge(part, options = {}) {
const wrap = document.createElement("div");
wrap.className = "document-badge" + (options.role === "user" ? " document-badge--user" : "");
const icon = document.createElement("img");
icon.className = "document-badge-icon";
icon.src = "/image/文档.svg";
icon.alt = "文档";
const name = document.createElement("span");
name.className = "document-badge-name";
name.textContent = part.name || "附件";
wrap.appendChild(icon);
wrap.appendChild(name);
return wrap;
}
function renderContentPart(part, options = {}) {
if (typeof part === "string") {
const div = document.createElement("div");
@@ -923,13 +943,8 @@
if (part.type === "audio" && part.url) {
return renderAudioBubble(part, options);
}
if (part.type === "file" && part.url) {
const a = document.createElement("a");
a.href = part.url;
a.textContent = part.name || "附件";
a.target = "_blank";
a.rel = "noopener";
return a;
if (part.type === "file") {
return renderDocumentBadge(part, options);
}
const div = document.createElement("div");
div.className = "msg-text";
@@ -994,27 +1009,16 @@
function renderAttachmentChips() {
attachmentPreview.innerHTML = "";
pendingImages.forEach((f, i) => {
pendingFiles.forEach((f, i) => {
const chip = document.createElement("span");
chip.className = "chip";
chip.innerHTML = `${escapeHtml(f.name)} <button type="button" aria-label="移除">×</button>`;
chip.querySelector("button").addEventListener("click", () => {
pendingImages = pendingImages.filter((_, j) => j !== i);
pendingFiles = pendingFiles.filter((_, j) => j !== i);
renderAttachmentChips();
});
attachmentPreview.appendChild(chip);
});
if (pendingAudio) {
const chip = document.createElement("span");
chip.className = "chip";
chip.innerHTML = `${escapeHtml(pendingAudio.name)} <button type="button" aria-label="移除">×</button>`;
chip.querySelector("button").addEventListener("click", () => {
pendingAudio = null;
audioInput.value = "";
renderAttachmentChips();
});
attachmentPreview.appendChild(chip);
}
}
async function bootstrap() {
@@ -1039,10 +1043,8 @@
const data = await api("/api/sessions/new", { method: "POST" });
sessionId = data.sessionId;
sessions = data.sessions || [];
pendingImages = [];
pendingAudio = null;
imageInput.value = "";
audioInput.value = "";
pendingFiles = [];
fileInput.value = "";
renderSessions();
renderAttachmentChips();
renderMessages(data.messages);
@@ -1058,10 +1060,8 @@
const data = await api("/api/sessions/delete", { method: "POST", body: fd });
sessionId = data.sessionId;
sessions = (data.sessions || []).filter((id) => id !== deletingSessionId);
pendingImages = [];
pendingAudio = null;
imageInput.value = "";
audioInput.value = "";
pendingFiles = [];
fileInput.value = "";
renderAttachmentChips();
renderSessions();
renderMessages(data.messages || []);
@@ -1079,46 +1079,40 @@
async function sendChat() {
if (!sessionId || sending) return;
const trimmed = textInput.value.trim();
if (!trimmed && pendingImages.length === 0 && !pendingAudio) return;
if (!trimmed && pendingFiles.length === 0) return;
sending = true;
sendBtn.disabled = true;
const savedTextRaw = textInput.value;
const savedImages = pendingImages.slice();
const savedAudio = pendingAudio;
const savedFiles = pendingFiles.slice();
const priorMessages = JSON.parse(JSON.stringify(lastRenderedMessages));
const blobUrls = [];
const hadOptimisticMedia = savedImages.length > 0 || !!savedAudio;
const hadOptimisticMedia = savedFiles.length > 0;
if (hadOptimisticMedia) {
const imageBlobUrls = savedImages.map((f) => {
const u = URL.createObjectURL(f);
blobUrls.push(u);
return u;
const fileParts = savedFiles.map((f) => {
const kind = inferUploadKind(f);
if (kind === "image" || kind === "audio") {
const u = URL.createObjectURL(f);
blobUrls.push(u);
return { kind, url: u, name: f.name };
}
return { kind, name: f.name };
});
let audioPart = null;
if (savedAudio) {
const u = URL.createObjectURL(savedAudio);
blobUrls.push(u);
audioPart = { url: u, name: savedAudio.name };
}
const optimisticContent = buildOptimisticUserContent(trimmed, imageBlobUrls, audioPart);
const optimisticContent = buildOptimisticUserContent(trimmed, fileParts);
if (optimisticContent != null) {
renderMessages([...priorMessages, { role: "user", content: optimisticContent }]);
}
textInput.value = "";
pendingImages = [];
pendingAudio = null;
imageInput.value = "";
audioInput.value = "";
pendingFiles = [];
fileInput.value = "";
renderAttachmentChips();
}
const fd = new FormData();
fd.append("session_id", sessionId);
fd.append("text", trimmed);
savedImages.forEach((f) => fd.append("images", f, f.name));
if (savedAudio) fd.append("audio", savedAudio, savedAudio.name);
savedFiles.forEach((f) => fd.append("files", f, f.name));
let typingEl = null;
try {
const startData = await api("/api/chat/start", { method: "POST", body: fd });
@@ -1127,10 +1121,8 @@
sessions = startData.sessions || [];
if (!hadOptimisticMedia) {
textInput.value = "";
pendingImages = [];
pendingAudio = null;
imageInput.value = "";
audioInput.value = "";
pendingFiles = [];
fileInput.value = "";
renderAttachmentChips();
}
renderSessions();
@@ -1164,8 +1156,7 @@
revokeBlobUrlStack(blobUrls);
if (hadOptimisticMedia) {
textInput.value = savedTextRaw;
pendingImages = savedImages.slice();
pendingAudio = savedAudio;
pendingFiles = savedFiles.slice();
renderAttachmentChips();
renderMessages(priorMessages);
}
@@ -1177,18 +1168,10 @@
}
}
imageInput.addEventListener("change", () => {
const files = Array.from(imageInput.files || []);
pendingImages = pendingImages.concat(files);
imageInput.value = "";
renderAttachmentChips();
textInput.focus();
});
audioInput.addEventListener("change", () => {
const f = audioInput.files && audioInput.files[0];
pendingAudio = f || null;
audioInput.value = "";
fileInput.addEventListener("change", () => {
const files = Array.from(fileInput.files || []);
pendingFiles = pendingFiles.concat(files);
fileInput.value = "";
renderAttachmentChips();
textInput.focus();
});
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779772912422" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9482" xmlns:xlink="http://www.w3.org/1999/xlink" width="50" height="50"><path d="M646.4512 762.5728c0-46.848 37.9904-84.8384 84.8384-84.8384h125.9008V247.7568c0-73.6256-59.648-133.2736-133.2736-133.2736H300.9024c-73.6256 0-133.2736 59.6992-133.2736 133.2736v528.0256c0 73.6256 59.648 133.2736 133.2736 133.2736h326.6048c6.5536 0 12.9536-1.2288 18.8928-3.6352v-142.848z" fill="#1296db" p-id="9483" data-spm-anchor-id="a313x.search_index.0.i6.739f3a81Zu935j" class="selected"></path><path d="M872.5504 247.7568c0-81.9712-66.6624-148.6336-148.6336-148.6336H300.9024c-81.9712 0-148.6336 66.6624-148.6336 148.6336v528.0256c0 81.9712 66.6624 148.6336 148.6336 148.6336h326.6048c12.6464 0 24.832-3.5328 35.3792-10.1376 0.7168-0.4608 1.3824-0.9728 2.048-1.4336 0.9728-0.6656 1.9968-1.3312 2.9184-2.048 1.6384-1.2288 3.2256-2.56 4.7616-3.9424l166.1952-152.4224c21.4528-19.6608 33.7408-47.616 33.7408-76.6976V247.7568zM300.9024 893.696c-65.024 0-117.9136-52.8896-117.9136-117.9136V247.7568c0-65.024 52.8896-117.9136 117.9136-117.9136h423.0144c65.024 0 117.9136 52.8896 117.9136 117.9136v414.6176h-122.6752c-48.5376 0-88.064 39.5264-88.064 88.064v143.104c-1.1776 0.1024-2.3552 0.2048-3.5328 0.2048h-326.656z m536.9344-192.3584c-0.5632 1.5872-1.1264 3.1744-1.792 4.7616l-0.768 1.8432c-0.8704 1.9456-1.8432 3.8912-2.9184 5.7856l-0.1536 0.3072c-1.1264 1.9456-2.3552 3.84-3.6352 5.7344-0.4096 0.5632-0.8192 1.0752-1.1776 1.6384-0.9728 1.3312-2.048 2.6624-3.1232 3.9424-0.4608 0.512-0.9216 1.0752-1.3824 1.5872-1.4848 1.6384-3.0208 3.2256-4.608 4.7104l-0.2048 0.2048-156.2624 143.36v-124.7232c0-31.5904 25.7024-57.344 57.344-57.344h121.0368c-0.4608 2.2016-1.0752 4.3008-1.6896 6.4512-0.2048 0.512-0.4608 1.1264-0.6656 1.7408z" fill="#424242" p-id="9484"></path><path d="M692.6848 333.9776H326.2464c-8.4992 0-15.36-6.8608-15.36-15.36s6.8608-15.36 15.36-15.36h366.4384c8.4992 0 15.36 6.8608 15.36 15.36s-6.8608 15.36-15.36 15.36zM692.6848 530.0224H326.2464c-8.4992 0-15.36-6.8608-15.36-15.36s6.8608-15.36 15.36-15.36h366.4384c8.4992 0 15.36 6.8608 15.36 15.36s-6.8608 15.36-15.36 15.36zM470.4256 728.32H326.2464c-8.4992 0-15.36-6.8608-15.36-15.36s6.8608-15.36 15.36-15.36h144.1792c8.4992 0 15.36 6.8608 15.36 15.36s-6.8608 15.36-15.36 15.36z" fill="#FFFFFF" p-id="9485"></path></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779772764267" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1660" width="80" height="80" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M556.8512 251.648a32 32 0 1 1-45.2608-45.2608l76.544-76.544a88.32 88.32 0 0 1 124.928 0L886.784 303.616a88.32 88.32 0 0 1 0 124.928l-463.36 463.36a88.32 88.32 0 0 1-124.928 0l-173.7728-173.7728a88.32 88.32 0 0 1 0-124.928l225.792-225.792a88.32 88.32 0 0 1 124.928 0l47.104 47.104a88.32 88.32 0 0 1 0 124.8768l-110.5408 110.4896a32 32 0 0 1-45.2608-45.2608l110.4896-110.4896a24.32 24.32 0 0 0 0-34.4064l-47.0528-47.0528a24.32 24.32 0 0 0-34.4064 0l-225.792 225.792a24.32 24.32 0 0 0 0 34.4064l173.7728 173.824a24.32 24.32 0 0 0 34.4064 0l463.36-463.4112a24.32 24.32 0 0 0 0-34.4064L667.8016 175.104a24.32 24.32 0 0 0-34.3552 0l-76.544 76.544z" fill="#2c2c2c" p-id="1661"></path></svg>

After

Width:  |  Height:  |  Size: 1014 B

+4 -8
View File
@@ -81,7 +81,7 @@
<section class="chat-shell">
<div class="chat-placeholder" id="chatPlaceholder">
<h3>开始对话</h3>
<p>输入文字,或上传<strong>图片</strong> / <strong>音频</strong> 后点击发送。</p>
<p>输入文字,或上传<strong>附件</strong> 后点击发送。</p>
<p class="muted">支持用户音频输入、助手文本/语音/双模输出、会话本地媒体持久化。</p>
</div>
<div class="chat-messages" id="chatMessages" hidden></div>
@@ -101,13 +101,9 @@
</button>
</div>
<div class="mm-actions">
<label class="btn btn-ghost file-btn">
图片
<input type="file" id="imageInput" accept="image/png,image/jpeg,image/jpg,image/gif,image/webp,image/bmp,.png,.jpg,.jpeg,.gif,.webp,.bmp" multiple hidden />
</label>
<label class="btn btn-ghost file-btn">
音频
<input type="file" id="audioInput" accept="audio/*,.wav,.mp3,.m4a,.flac,.ogg" hidden />
<label class="btn btn-ghost file-btn file-btn--icon-only" title="上传附件" aria-label="上传附件">
<img src="/image/附件.svg" alt="" aria-hidden="true" class="file-btn-icon" />
<input type="file" id="fileInput" multiple hidden />
</label>
<label class="output-mode-label" for="outputModeSelect">输出模式</label>
<select id="outputModeSelect" class="output-mode-select" aria-label="Agent 输出模式">
+33 -6
View File
@@ -32,6 +32,16 @@ _WEB_DIR = Path(__file__).resolve().parent
_IMAGE_DIR = _WEB_DIR / "image"
_AUDIO_EXT = frozenset({".wav", ".mp3", ".m4a", ".flac", ".ogg", ".webm"})
_IMAGE_EXT = frozenset(IMAGE_FILE_EXTENSIONS)
def _infer_upload_kind(fp: str) -> str | None:
ext = os.path.splitext(fp)[1].lower()
if ext in _IMAGE_EXT:
return "image"
if ext in _AUDIO_EXT:
return "audio"
return "file"
def _data_url_for_file(fp: str) -> str | None:
@@ -82,6 +92,7 @@ def _serialize_content(content: Any, *, meta: dict[str, Any] | None = None, role
parts.append({"type": "text", "text": piece.get("text") or ""})
continue
fp = piece.get("path") or (piece.get("file") or {}).get("path")
display_name = piece.get("name")
if piece.get("type") == "image_url":
fp = fp or (piece.get("image_url") or {}).get("url")
if piece.get("type") == "audio":
@@ -93,19 +104,19 @@ def _serialize_content(content: Any, *, meta: dict[str, Any] | None = None, role
continue
ext = os.path.splitext(fp)[1].lower()
if ext in IMAGE_FILE_EXTENSIONS:
parts.append({"type": "image", "url": data_url, "path": fp, "name": os.path.basename(fp)})
parts.append({"type": "image", "url": data_url, "path": fp, "name": display_name or os.path.basename(fp)})
elif ext in _AUDIO_EXT:
audio_meta = meta.get("audio") if isinstance(meta.get("audio"), dict) else {}
parts.append({
"type": "audio",
"url": data_url,
"path": fp,
"name": os.path.basename(fp),
"name": display_name or os.path.basename(fp),
"autoplay": bool(meta.get("autoplay") or audio_meta.get("autoplay")) if role == "assistant" else False,
"duration_seconds": audio_meta.get("duration_seconds"),
})
else:
parts.append({"type": "file", "url": data_url, "path": fp, "name": os.path.basename(fp)})
parts.append({"type": "file", "url": data_url, "path": fp, "name": display_name or os.path.basename(fp)})
return parts
@@ -235,12 +246,14 @@ def api_delete_session(session_id: str = Form(...)) -> dict[str, Any]:
def _materialize_uploads_to_temp(
images: list[UploadFile] | None,
files: list[UploadFile] | None,
audio: UploadFile | None,
text: str,
) -> tuple[dict[str, Any], str]:
"""将上传文件落盘到临时目录,返回 ``(query_dict, tmp_root)``;调用方须在 finally 中删除 ``tmp_root``。"""
tmp_root = tempfile.mkdtemp(prefix="web_mm_")
file_paths: list[str] = []
file_names: list[str] = []
for uf in images or []:
if uf is None or not uf.filename:
continue
@@ -249,6 +262,17 @@ def _materialize_uploads_to_temp(
with open(dest, "wb") as f:
shutil.copyfileobj(uf.file, f)
file_paths.append(dest)
file_names.append(uf.filename)
for uf in files or []:
if uf is None or not uf.filename:
continue
ext = Path(uf.filename).suffix or ""
dest = os.path.join(tmp_root, f"{uuid.uuid4().hex}{ext}")
with open(dest, "wb") as f:
shutil.copyfileobj(uf.file, f)
file_paths.append(dest)
file_names.append(uf.filename)
if audio is not None and audio.filename:
ext = Path(audio.filename).suffix or ".bin"
@@ -256,8 +280,9 @@ def _materialize_uploads_to_temp(
with open(dest, "wb") as f:
shutil.copyfileobj(audio.file, f)
file_paths.append(dest)
file_names.append(audio.filename)
query = {"text": (text or "").strip(), "files": file_paths}
query = {"text": (text or "").strip(), "files": file_paths, "file_names": file_names}
return query, tmp_root
@@ -309,6 +334,7 @@ async def api_chat_start(
session_id: str = Form(...),
text: str = Form(""),
images: list[UploadFile] | None = File(None),
files: list[UploadFile] | None = File(None),
audio: UploadFile | None = File(None),
) -> dict[str, Any]:
"""
@@ -318,7 +344,7 @@ async def api_chat_start(
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
query, tmp_root = _materialize_uploads_to_temp(images, audio, text)
query, tmp_root = _materialize_uploads_to_temp(images, files, audio, text)
try:
return _chat_start_core(session_id, query)
finally:
@@ -341,6 +367,7 @@ async def api_chat(
session_id: str = Form(...),
text: str = Form(""),
images: list[UploadFile] | None = File(None),
files: list[UploadFile] | None = File(None),
audio: UploadFile | None = File(None),
output_type: str = Form("text"),
) -> dict[str, Any]:
@@ -350,7 +377,7 @@ async def api_chat(
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
query, tmp_root = _materialize_uploads_to_temp(images, audio, text)
query, tmp_root = _materialize_uploads_to_temp(images, files, audio, text)
try:
_chat_start_core(session_id, query)
return _chat_complete_core(session_id, output_type=output_type)
+54 -12
View File
@@ -8,7 +8,6 @@ import json
import os
import shutil
import sqlite3
import uuid
from datetime import datetime
from agent.utils.agent_utils import (
@@ -105,16 +104,18 @@ def _load_ui_messages_from_db(session_id: str) -> list:
def _build_user_chat_content(query) -> str | list | None:
"""根据上传文件扩展名直接生成多模态 content(与 LLM 输入格式一致)"""
parts: list = []
for filepath in query.get("files", []) or []:
file_names = query.get("file_names") or []
for idx, filepath in enumerate(query.get("files", []) or []):
if not filepath:
continue
original_name = file_names[idx] if idx < len(file_names) else os.path.basename(filepath)
ext = os.path.splitext(filepath)[1].lower()
if ext in IMAGE_FILE_EXTENSIONS:
parts.append({"type": "image_url", "image_url": {"url": filepath}})
parts.append({"type": "image_url", "image_url": {"url": filepath}, "name": original_name})
elif ext in _AUDIO_EXTENSIONS:
parts.append({"type": "audio", "audio_path": filepath})
parts.append({"type": "audio", "audio_path": filepath, "name": original_name})
else:
parts.append({"path": filepath})
parts.append({"path": filepath, "name": original_name})
text = (query.get("text") or "").strip()
if text:
parts.append({"type": "text", "text": text})
@@ -125,6 +126,38 @@ def _build_user_chat_content(query) -> str | list | None:
return parts
def _sanitize_filename_stem(name: str) -> str:
stem = os.path.splitext(name or "")[0].strip()
if not stem:
return "file"
sanitized = []
for ch in stem:
if ch in '<>:"/\\|?*':
sanitized.append("_")
else:
sanitized.append(ch)
stem = "".join(sanitized).rstrip(" .")
return stem or "file"
def _next_session_copy_name(dest_dir: str, session_id: str, original_name: str, ext: str) -> str:
ext = ext or ""
base_name = _sanitize_filename_stem(original_name)
prefix = f"{base_name}_{session_id}_"
max_index = 0
try:
for entry in os.listdir(dest_dir):
stem, _existing_ext = os.path.splitext(entry)
if not stem.startswith(prefix):
continue
suffix = stem[len(prefix):]
if suffix.isdigit():
max_index = max(max_index, int(suffix))
except OSError:
pass
return f"{base_name}_{session_id}_{max_index + 1}{ext}"
def rewrite_user_message_files_to_session_dir(user_msg: dict, session_id: str) -> None:
dest_dir = session_dir(session_id)
os.makedirs(dest_dir, exist_ok=True)
@@ -153,10 +186,14 @@ def rewrite_user_message_files_to_session_dir(user_msg: dict, session_id: str) -
new_list.append(piece)
continue
ext = os.path.splitext(src)[1] or ""
dest = os.path.join(dest_dir, f"{uuid.uuid4().hex}{ext}")
original_name = piece.get("name") or os.path.basename(src)
dest_name = _next_session_copy_name(dest_dir, session_id, original_name, ext)
dest = os.path.join(dest_dir, dest_name)
shutil.copy2(src, dest)
new_piece = dict(piece)
new_piece["path"] = dest
if piece.get("name"):
new_piece["name"] = piece["name"]
if piece.get("type") == "image_url":
new_piece["image_url"] = {"url": dest}
if piece.get("type") == "audio":
@@ -197,7 +234,11 @@ def _serialize_user_msg_for_manifest(user_msg: dict) -> dict:
part_type = "audio"
else:
part_type = "file"
content_list.append({"type": part_type, "relpath": rel.replace("\\", "/")})
content_list.append({
"type": part_type,
"relpath": rel.replace("\\", "/"),
"name": piece.get("name") or os.path.basename(fp),
})
# 不再写入 image_description 字段
return {"content": content_list}
@@ -221,6 +262,7 @@ def _manifest_user_entry_to_content(user_entry: dict) -> str | list:
parts_out.append({"type": "text", "text": t})
continue
rel = (p.get("relpath") or "").replace("/", os.sep)
display_name = p.get("name") or (os.path.basename(rel) if rel else "附件")
abs_p = os.path.normpath(os.path.join(root_norm, rel))
try:
under = os.path.commonpath([abs_p, root_norm]) == root_norm
@@ -234,19 +276,19 @@ def _manifest_user_entry_to_content(user_entry: dict) -> str | list:
if part_type == "image_url":
had_image_parts = True
if os.path.isfile(abs_p):
parts_out.append({"type": "image_url", "image_url": {"url": abs_p}, "path": abs_p})
parts_out.append({"type": "image_url", "image_url": {"url": abs_p}, "path": abs_p, "name": display_name})
else:
any_image_missing = True
elif part_type == "audio":
if os.path.isfile(abs_p):
parts_out.append({"type": "audio", "audio_path": abs_p, "path": abs_p})
parts_out.append({"type": "audio", "audio_path": abs_p, "path": abs_p, "name": display_name})
else:
parts_out.append({"type": "text", "text": f"[语音已丢失: {os.path.basename(rel) or rel}]"})
parts_out.append({"type": "text", "text": f"[语音已丢失: {display_name}]"})
elif part_type == "file":
if os.path.isfile(abs_p):
parts_out.append({"path": abs_p})
parts_out.append({"path": abs_p, "name": display_name})
else:
parts_out.append({"type": "text", "text": f"[附件已丢失: {os.path.basename(rel) or rel}]"})
parts_out.append({"type": "text", "text": f"[附件已丢失: {display_name}]"})
# 不再处理 image_description(新格式中没有)
if had_image_parts and any_image_missing: