新增rag功能,支持用户上传、聊天提供文件夹或文件地址上传rag知识库,删除、查询个人知识库及暴露source给agent以获取所有知识来源;新增获取文件tree . /f工具
This commit is contained in:
1 parent
95fefe96dd
commit
14788cad6a
36 files changed
+1238
-404
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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.
Binary file not shown.
Binary file not shown.
Whitespace-only changes.
Binary file not shown.
@@ -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
@@ -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
@@ -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, {}
|
||||
Binary file not shown.
Binary file not shown.
@@ -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.
@@ -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
@@ -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, """);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user