目前agent infra及rag框架已经搭好
This commit is contained in:
1 parent
87ee138fb5
commit
447f313217
38 files changed
+1431
-790
No files matched your search
@@ -16,8 +16,7 @@ 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-27B-int4-AutoRound"
|
||||
|
||||
LLM_MODEL = "Intel/Qwen3.6-35B-A3B-int4-mixed-AutoRound"
|
||||
|
||||
#A100:
|
||||
# LLM_API_KEY = 'sk-kGhWAQTqLk9ECsmIDb758a456bD34e92B9771dF2E36432Fb'
|
||||
@@ -27,3 +26,12 @@ LLM_MODEL = "Intel/Qwen3.6-27B-int4-AutoRound"
|
||||
# LLM_REQUESTS_PER_SECOND = 10.0
|
||||
# LLM_MAX_BUCKET_SIZE = 10
|
||||
# LLM_CHECK_EVERY_N_SECONDS = 0.1
|
||||
|
||||
#context:
|
||||
LLM_CONTEXT_WINDOW=32768
|
||||
MINI_COMPRESS_RATE=0.4
|
||||
HARD_COMPRESS_RATE=0.6
|
||||
CURRENT_TURNS=4
|
||||
IMG_SIZE=768
|
||||
RAG_CHUNK_SIZE=500
|
||||
RAG_CHUNK_OVERLAP=50
|
||||
@@ -1,32 +1,36 @@
|
||||
# 进入项目目录并提交代码(Windows 下路径为 .\Agent\)
|
||||
# Agent Web Chat
|
||||
|
||||
基于 LangGraph 的多模态对话 Agent,支持文本、图片、音频输入,具备屏幕截图、浏览器控制、RAG 知识库、数据库查询等工具调用能力。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 克隆仓库并进入项目目录
|
||||
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd Agent
|
||||
git add .
|
||||
git commit -m "修改内容的说明"
|
||||
git push
|
||||
|
||||
# 创建并激活虚拟环境(Linux/macOS 用 source,Windows 用 myenv\Scripts\activate)
|
||||
Windows:
|
||||
bash
|
||||
python -m venv myenv
|
||||
source myenv/bin/activate || myenv/Scripts/activate
|
||||
.\myenv\Scripts\activate
|
||||
macOS / Linux:
|
||||
bash
|
||||
python -m venv myenv
|
||||
source myenv/bin/activate
|
||||
|
||||
# 升级 pip 并安装核心依赖
|
||||
python -m pip install --upgrade pip
|
||||
pip install -U langchain langchain-openai python-dotenv
|
||||
|
||||
# 以可编辑模式安装当前项目(配置见 pyproject.toml)
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install -e .
|
||||
|
||||
# 安装 LangGraph CLI(开发模式)
|
||||
pip install "langgraph-cli[inmem]"
|
||||
|
||||
# 可选:安装数据库相关依赖(psycopg2-binary 用于 PostgreSQL)
|
||||
pip install sqlalchemy pymysql loguru psycopg2-binary
|
||||
|
||||
# 环境变量配置
|
||||
# Copy .env.example to .env and fill in the required values before running the app.
|
||||
cp .env.example .env
|
||||
|
||||
# 必须填写的变量:
|
||||
# .env.example
|
||||
gedit .env
|
||||
必须填写的变量:
|
||||
LLM_BASE_URL – 大模型 API 基础地址
|
||||
LLM_API_KEY – API 密钥
|
||||
LLM_MODEL – 模型名称(如 Qwen/Qwen2.5-72B-Instruct)
|
||||
TTS_BASE_URL – 文本转语音服务地址(可选,若不使用音频回复可不填)
|
||||
ASR_BASE_URL – 语音识别服务地址(可选)
|
||||
RAG_BASE_URL – RAG 嵌入与重排序服务地址(可选)
|
||||
|
||||
# 启动 Web 对话服务(推荐)
|
||||
# 确保在 Agent 目录下,且 PYTHONPATH 包含 src 目录
|
||||
@@ -35,8 +39,11 @@ export PYTHONPATH=src:$PYTHONPATH # Linux/macOS
|
||||
# 或 Windows PowerShell: $env:PYTHONPATH="src;$env:PYTHONPATH"
|
||||
uvicorn agent.web.server:app --host 127.0.0.1 --port 8765
|
||||
|
||||
# API 为两阶段调用:POST /api/chat/start(落盘用户消息) + POST /api/chat/complete(推理)
|
||||
# 前端已按此顺序调用。
|
||||
|
||||
# (可选)通过 Cloudflare Tunnel 暴露公网
|
||||
cloudflared tunnel --url http://localhost:8765
|
||||
cloudflared tunnel --url http://localhost:8765
|
||||
|
||||
# 进入项目目录并提交代码(Windows 下路径为 .\Agent\)
|
||||
cd Agent
|
||||
git add .
|
||||
git commit -m "修改内容的说明"
|
||||
git push
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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
|
||||
|
||||
# Web framework & server
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
python-multipart>=0.0.9
|
||||
|
||||
# Database & vector store
|
||||
sqlalchemy>=2.0.0
|
||||
psycopg2-binary>=2.9.0
|
||||
chromadb>=0.4.0
|
||||
|
||||
# Audio / TTS / ASR
|
||||
pyaudio>=0.2.11
|
||||
wave
|
||||
pydub>=0.25.1 # for audio format conversion
|
||||
requests>=2.31.0
|
||||
|
||||
# Screen capture & browser control
|
||||
pyautogui>=0.9.54
|
||||
webbrowser # built-in, no need to list
|
||||
|
||||
# Image processing
|
||||
Pillow>=10.0.0
|
||||
|
||||
# Document parsing (RAG)
|
||||
pandas>=2.0.0
|
||||
python-docx>=1.0.0
|
||||
pdfplumber>=0.10.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.1
|
||||
loguru>=0.7.0
|
||||
pydantic>=2.0.0
|
||||
|
||||
# Optional but often needed
|
||||
typing-extensions>=4.0.0
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+83
-103
@@ -1,101 +1,45 @@
|
||||
from typing import Any, cast, Dict
|
||||
# agent/chatbot_agent.py
|
||||
from typing import Any, Dict
|
||||
|
||||
from langchain_classic.agents import AgentExecutor, create_tool_calling_agent
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
from langchain_core.runnables import RunnablePassthrough, RunnableWithMessageHistory
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
|
||||
from langchain.agents import create_agent
|
||||
|
||||
from agent.llm import Qwen36_35B_A3B_FP8_LLM
|
||||
from agent.tools import get_tools
|
||||
from agent.utils.agent_utils import assistant_text, get_history, normalize_user_text_content, summarize_history
|
||||
from agent.utils.agent_utils import (
|
||||
assistant_text,
|
||||
get_history,
|
||||
summarizer, # 使用新的上下文感知构建器
|
||||
get_session_meta,
|
||||
update_session_meta,
|
||||
)
|
||||
from agent.web.ui_session import (
|
||||
collect_media_paths_and_query_text,
|
||||
rewrite_user_message_files_to_session_dir,
|
||||
session_dir,
|
||||
sync_manifest_from_chat_history,
|
||||
)
|
||||
from agent.memory.system_prompt import MAIN_SYSTEM_PROMPT
|
||||
|
||||
# ---------- 1. 提示模板----------
|
||||
prompt = ChatPromptTemplate.from_messages([
|
||||
("system", "{system_message}"),
|
||||
("placeholder", "{chat_history}"),
|
||||
MessagesPlaceholder(variable_name="human_message"),
|
||||
("placeholder", "{agent_scratchpad}"),
|
||||
])
|
||||
|
||||
# ---------- 1. 工具 ----------
|
||||
tools = get_tools()
|
||||
|
||||
# ---------- 2. 创建工具调用 agent ----------
|
||||
tool_calling_agent = create_tool_calling_agent(
|
||||
llm=Qwen36_35B_A3B_FP8_LLM.bind(tool_choice="auto"),
|
||||
# ---------- 2. 创建 ReAct agent(LangGraph)----------
|
||||
agent_graph = create_agent(
|
||||
model=Qwen36_35B_A3B_FP8_LLM,
|
||||
tools=tools,
|
||||
prompt=prompt,
|
||||
name="main"
|
||||
)
|
||||
|
||||
# ---------- 3. agent executor ----------
|
||||
agent_executor = AgentExecutor(
|
||||
agent=tool_calling_agent,
|
||||
tools=tools,
|
||||
verbose=True,
|
||||
handle_parsing_errors=True,
|
||||
stream_runnable=False,
|
||||
)
|
||||
# ---------- 3. 辅助函数:保存消息到数据库 ----------
|
||||
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)
|
||||
history.add_message(assistant_msg)
|
||||
|
||||
# ---------- 4. 带历史记录的链 ----------
|
||||
agent_with_history = RunnableWithMessageHistory(
|
||||
runnable=agent_executor,
|
||||
get_session_history=get_history,
|
||||
input_messages_key="human_message",
|
||||
history_messages_key="chat_history",
|
||||
)
|
||||
|
||||
# ---------- 5. 注入摘要和系统提示 ----------
|
||||
agent_with_history = (
|
||||
RunnablePassthrough.assign(
|
||||
# ✅ 从输入数据中的 agent_config 获取配置
|
||||
summarize_result=lambda x: summarize_history(x["input"], x["agent_config"])
|
||||
)
|
||||
| RunnablePassthrough.assign(
|
||||
input=lambda x: x["input"],
|
||||
chat_history=lambda x: x["summarize_result"]["chat_history"],
|
||||
system_message=lambda x: (
|
||||
MAIN_SYSTEM_PROMPT
|
||||
+ (
|
||||
"\n\n【此前对话摘要】\n" + x["summarize_result"]["summary"]
|
||||
if x["summarize_result"]["summary"]
|
||||
else ""
|
||||
)
|
||||
),
|
||||
human_message=lambda x: [HumanMessage(content=x["input"])],
|
||||
)
|
||||
| agent_with_history
|
||||
)
|
||||
|
||||
|
||||
# ---------- 6. 辅助函数:将前端 chat_history 转为 LangChain 消息列表 ----------
|
||||
def _extract_user_input_parts(user_msg: Dict[str, Any]) -> tuple[list[str], list[str], list[dict[str, Any]] | str]:
|
||||
image_paths, audio_paths, query_text = collect_media_paths_and_query_text(user_msg)
|
||||
|
||||
content_parts: list[dict[str, Any]] = []
|
||||
for img_path in image_paths:
|
||||
content_parts.append({"type": "image_url", "image_url": {"url": img_path}})
|
||||
|
||||
for audio_path in audio_paths:
|
||||
content_parts.append({
|
||||
"type": "audio",
|
||||
"audio_path": audio_path,
|
||||
})
|
||||
if query_text:
|
||||
content_parts.append({"type": "text", "text": query_text})
|
||||
|
||||
if content_parts:
|
||||
if len(content_parts) == 1 and content_parts[0].get("type") == "text":
|
||||
return image_paths, audio_paths, content_parts[0]["text"]
|
||||
return image_paths, audio_paths, content_parts
|
||||
return image_paths, audio_paths, normalize_user_text_content(user_msg.get("content", query_text))
|
||||
|
||||
# ---------- 7. 核心执行函数 ----------
|
||||
# ---------- 4. 核心执行函数 ----------
|
||||
def execute_agent(
|
||||
chat_history: list[dict[str, Any]],
|
||||
session_id: str = "test",
|
||||
@@ -106,34 +50,65 @@ def execute_agent(
|
||||
if not chat_history or chat_history[-1].get("role") != "user":
|
||||
return chat_history
|
||||
|
||||
# 上传文件处理
|
||||
if session_id:
|
||||
rewrite_user_message_files_to_session_dir(chat_history[-1], session_id)
|
||||
|
||||
_image_paths, _audio_paths, user_content = _extract_user_input_parts(chat_history[-1])
|
||||
# 直接使用 chat_history 中已经正确格式化的 content(由 _build_user_chat_content 生成)
|
||||
user_content = chat_history[-1]["content"]
|
||||
|
||||
# 构建运行时配置
|
||||
config = {
|
||||
"configurable": {
|
||||
"session_id": session_id,
|
||||
},
|
||||
# 动态上下文感知构建消息
|
||||
messages, context_exceeded = summarizer(
|
||||
session_id=session_id,
|
||||
agent_name="main", # 固定 agent 名称,可扩展
|
||||
system_prompt=MAIN_SYSTEM_PROMPT,
|
||||
user_content=user_content,
|
||||
llm=Qwen36_35B_A3B_FP8_LLM,
|
||||
)
|
||||
|
||||
if context_exceeded:
|
||||
# 上下文已满,返回提示信息
|
||||
chat_history.append({
|
||||
"role": "assistant",
|
||||
"content": "上下文已满,请新建会话继续对话。",
|
||||
})
|
||||
if session_id:
|
||||
sync_manifest_from_chat_history(session_id, chat_history, "")
|
||||
return chat_history
|
||||
|
||||
# 调用 graph 的配置
|
||||
graph_config = {
|
||||
"configurable": {"session_id": session_id},
|
||||
"metadata": {
|
||||
"output_type": output_type,
|
||||
"audio_output_dir": session_dir(session_id) if session_id else None,
|
||||
},
|
||||
"llm": Qwen36_35B_A3B_FP8_LLM,
|
||||
}
|
||||
|
||||
invoke_input = {
|
||||
"input": user_content,
|
||||
"agent_config": config,
|
||||
}
|
||||
|
||||
try:
|
||||
result = cast(dict[str, Any], agent_with_history.invoke(invoke_input, config))
|
||||
# 调用 LangGraph agent
|
||||
result = agent_graph.invoke({"messages": messages}, config=graph_config)
|
||||
|
||||
text_content = assistant_text(result.get("output", ""))
|
||||
# 从最终消息中提取文本回复
|
||||
all_messages = result["messages"]
|
||||
assistant_msg = all_messages[-1] if all_messages else AIMessage(content="")
|
||||
text_content = assistant_text(assistant_msg.content)
|
||||
|
||||
output_type_from_config = config.get("metadata", {}).get("output_type", "text")
|
||||
# 提取 token 使用量并更新到 meta
|
||||
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:
|
||||
meta = get_session_meta(session_id, "main")
|
||||
update_session_meta(
|
||||
session_id=session_id,
|
||||
agent_name="main",
|
||||
cursor_index=meta["cursor_index"],
|
||||
compressed_content=meta["compressed_content"],
|
||||
last_total_tokens=total_tokens,
|
||||
)
|
||||
|
||||
# 处理 TTS 音频数据
|
||||
output_type_from_config = graph_config.get("metadata", {}).get("output_type", "text")
|
||||
audio_data = Qwen36_35B_A3B_FP8_LLM.last_audio_kwargs or {}
|
||||
if audio_data.get("output_type") != output_type_from_config:
|
||||
audio_data = {}
|
||||
@@ -144,6 +119,7 @@ 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"):
|
||||
@@ -151,28 +127,32 @@ 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
|
||||
|
||||
except Exception as e:
|
||||
# 异常处理保持不变
|
||||
import traceback
|
||||
print("[ERROR] 调用模型时出错:")
|
||||
traceback.print_exc()
|
||||
err_text = "抱歉,我遇到了一些问题,请稍后再试。"
|
||||
chat_history.append({"role": "assistant", "content": err_text})
|
||||
|
||||
# 同步文件清单
|
||||
if session_id:
|
||||
sync_manifest_from_chat_history(session_id, chat_history, "")
|
||||
|
||||
return chat_history
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pass
|
||||
return chat_history
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 9.9 MiB |
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"turns": [
|
||||
{
|
||||
"user": {
|
||||
"parts": [
|
||||
{
|
||||
"kind": "image_url",
|
||||
"relpath": "20260514_222254/b50c460b07654d568e897d92c776675f.jpg"
|
||||
},
|
||||
{
|
||||
"kind": "audio",
|
||||
"relpath": "20260514_222254/60a5fff67eaf461b979d76449a1a6224.m4a"
|
||||
},
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "请直接告诉我数量。"
|
||||
}
|
||||
],
|
||||
"image_description": ""
|
||||
},
|
||||
"assistant": {
|
||||
"text": "图片中有两只猫。",
|
||||
"output_type": "both",
|
||||
"autoplay": false,
|
||||
"audio_duration_seconds": 2.08,
|
||||
"audio_relpath": "20260514_222254/assistant_tts_222333_0.wav"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"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": "现在几点了"
|
||||
}
|
||||
]
|
||||
},
|
||||
"assistant": {
|
||||
"text": "现在是2026年5月15日 13:02。",
|
||||
"output_type": "text",
|
||||
"autoplay": false,
|
||||
"audio_duration_seconds": null,
|
||||
"audio_relpath": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+58
-12
@@ -1,3 +1,4 @@
|
||||
# agent/llm.py
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
@@ -16,7 +17,7 @@ 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, normalize_user_text_content
|
||||
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,
|
||||
@@ -104,24 +105,31 @@ class ChatQwen(ChatOpenAI):
|
||||
def _audio_to_text(self, audio_path: str) -> str:
|
||||
if not os.path.exists(audio_path):
|
||||
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
|
||||
|
||||
wav_path = self._convert_audio_to_wav(audio_path)
|
||||
cleanup_wav = wav_path != audio_path
|
||||
cleanup_wav = (wav_path != audio_path)
|
||||
|
||||
try:
|
||||
# 构造 file:// URL(vLLM 要求)
|
||||
abs_path = os.path.abspath(wav_path).replace("\\", "/")
|
||||
audio_url = f"file://{abs_path}"
|
||||
|
||||
# 读取音频文件二进制内容
|
||||
with open(wav_path, "rb") as f:
|
||||
audio_bytes = f.read()
|
||||
|
||||
# 构建 Data URL
|
||||
import base64
|
||||
b64 = base64.b64encode(audio_bytes).decode("ascii")
|
||||
mime_type = "audio/wav" # 我们已经转换为 wav
|
||||
data_url = f"data:{mime_type};base64,{b64}"
|
||||
|
||||
payload = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "audio_url", "audio_url": {"url": audio_url}}
|
||||
{"type": "audio_url", "audio_url": {"url": data_url}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
# ASR_BASE_URL 末尾可能已带 /v1,统一拼接 /chat/completions
|
||||
endpoint = f"{ASR_BASE_URL.rstrip('/')}/chat/completions"
|
||||
resp = requests.post(
|
||||
endpoint,
|
||||
@@ -131,7 +139,6 @@ class ChatQwen(ChatOpenAI):
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
# 提取 assistant 回复文本(音频描述/转录)
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
return content.strip()
|
||||
finally:
|
||||
@@ -151,7 +158,12 @@ class ChatQwen(ChatOpenAI):
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
if isinstance(msg, HumanMessage) and isinstance(msg.content, dict):
|
||||
msg = HumanMessage(content=normalize_user_text_content(msg.content), additional_kwargs=msg.additional_kwargs)
|
||||
# 尝试从 dict 中提取文本,否则序列化为字符串
|
||||
text = msg.content.get("text") or msg.content.get("content") or ""
|
||||
if text:
|
||||
msg = HumanMessage(content=str(text), additional_kwargs=msg.additional_kwargs)
|
||||
else:
|
||||
msg = HumanMessage(content=json.dumps(msg.content, ensure_ascii=False), additional_kwargs=msg.additional_kwargs)
|
||||
elif isinstance(msg, HumanMessage) and isinstance(msg.content, list):
|
||||
new_content: List[Dict[str, Any] | str] = []
|
||||
for part in msg.content:
|
||||
@@ -179,7 +191,7 @@ class ChatQwen(ChatOpenAI):
|
||||
else:
|
||||
new_content.append({"type": "text", "text": f"[用户上传了附件文件: {os.path.basename(file_path)}]"})
|
||||
else:
|
||||
text = normalize_user_text_content(part)
|
||||
text = assistant_text(part)
|
||||
if text:
|
||||
new_content.append({"type": "text", "text": text})
|
||||
elif isinstance(part, str):
|
||||
@@ -266,7 +278,7 @@ class ChatQwen(ChatOpenAI):
|
||||
output_type = metadata.get("output_type", self.output_type)
|
||||
audio_output_dir = metadata.get("audio_output_dir")
|
||||
|
||||
# ✅ 移除自定义参数,防止它们传入 OpenAI API
|
||||
# 移除自定义参数,防止它们传入 OpenAI API
|
||||
kwargs.pop("output_type", None)
|
||||
kwargs.pop("audio_output_dir", None)
|
||||
|
||||
@@ -354,3 +366,37 @@ class ChatQwen(ChatOpenAI):
|
||||
|
||||
|
||||
Qwen36_35B_A3B_FP8_LLM = ChatQwen()
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
# 使用原始字符串避免转义问题
|
||||
TEST_IMAGE_PATH = r"C:\Users\86166\Desktop\image_raw\4.jpg"
|
||||
|
||||
if not os.path.exists(TEST_IMAGE_PATH):
|
||||
print(f"❌ 图片不存在: {TEST_IMAGE_PATH}")
|
||||
sys.exit(1)
|
||||
|
||||
# 测试1: 纯图片识别
|
||||
print("=" * 50)
|
||||
print("测试1: 纯图片识别(无文本)")
|
||||
print("=" * 50)
|
||||
result1 = Qwen36_35B_A3B_FP8_LLM.invoke([
|
||||
HumanMessage(content=[{"type": "image_url", "image_url": {"url": TEST_IMAGE_PATH}}])
|
||||
])
|
||||
print(f"返回类型: {type(result1)}")
|
||||
print(f"返回内容: {result1.content}")
|
||||
print(f"所有属性: {[attr for attr in dir(result1) if not attr.startswith('_')]}")
|
||||
|
||||
# 测试2: 图片 + 文本
|
||||
print("\n" + "=" * 50)
|
||||
print("测试2: 图文混合输入")
|
||||
print("=" * 50)
|
||||
result2 = Qwen36_35B_A3B_FP8_LLM.invoke([
|
||||
HumanMessage(content=[
|
||||
{"type": "image_url", "image_url": {"url": TEST_IMAGE_PATH}},
|
||||
{"type": "text", "text": "这张图片里有什么?请详细描述"}
|
||||
])
|
||||
])
|
||||
print(f"回答: {result2.content}")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,5 @@
|
||||
from .tool_main import *
|
||||
from .tool import *
|
||||
from .tool_rag import RAGTool
|
||||
|
||||
# from .tool_database_class import *
|
||||
# from utils.database_utils import PostgreSQL_Manager
|
||||
@@ -20,12 +21,12 @@ from .tool_main import *
|
||||
# def get_sql_tools():
|
||||
# return _all_database_tools
|
||||
|
||||
|
||||
_all_tools = [
|
||||
Websearchclass(),
|
||||
DateQueryClass(),
|
||||
ScreenCaptureDescribeClass(),
|
||||
BrowserControlClass()
|
||||
BrowserControlClass(),
|
||||
RAGTool(),
|
||||
]
|
||||
|
||||
def get_tools():
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+246
-48
@@ -1,54 +1,252 @@
|
||||
from langchain.tools import tool
|
||||
from __future__ import annotations
|
||||
|
||||
@tool("web_search")
|
||||
def web_search(query: str) -> str:
|
||||
"""
|
||||
一个用于执行网络搜索的工具。它接收一个查询语句作为输入,并返回 Google 的搜索结果。
|
||||
|
||||
Args:
|
||||
query (str): 搜索查询内容
|
||||
|
||||
Returns:
|
||||
str: 格式化后的搜索结果
|
||||
"""
|
||||
try:
|
||||
import io
|
||||
from typing import Optional, Literal
|
||||
import sys
|
||||
import time
|
||||
import webbrowser
|
||||
import pyautogui
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
|
||||
from agent.llm import Qwen36_35B_A3B_FP8_LLM
|
||||
from utils.env_utils import BROWSER_MAP
|
||||
|
||||
|
||||
class Websearchclass(BaseTool):
|
||||
|
||||
args_schema : type[BaseModel] = create_model("Input_type", query=(str, Field(description="搜索查询内容")))
|
||||
name : str = "web_search_class"
|
||||
description : str = "一个网络搜索工具,可以根据用户的查询返回相关的搜索结果。仅当需要查询近期知识或已有知识无法解答需要联网查询时调用,其余任何情况严禁调用。"
|
||||
|
||||
def _get_search_wrapper(self):
|
||||
from langchain_community.utilities import GoogleSerperAPIWrapper
|
||||
# 这里直接使用了一个示例的 Serper API 密钥,实际使用时请替换为你自己的密钥
|
||||
SERPER_API_KEY = "d6348dcce0c72bb8102f0eacfd2f536f797da0c5"
|
||||
# LangChain 官方封装的 Serper API 工具
|
||||
search = GoogleSerperAPIWrapper(serper_api_key=SERPER_API_KEY, content_size=2048)
|
||||
# 直接调用 run 方法获取结果,LangChain 已处理好格式
|
||||
return search.run(query)
|
||||
except Exception as e:
|
||||
return f"执行网络搜索时出错: {str(e)}"
|
||||
|
||||
@tool("date_query")
|
||||
def date_query() -> str:
|
||||
"""
|
||||
一个用于执行日期查询的工具。它接收一个查询语句作为输入,并返回相应的日期结果。
|
||||
|
||||
Returns:
|
||||
str: 格式化后的日期查询结果
|
||||
"""
|
||||
try:
|
||||
# 显示当前日期和时间
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
return now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return GoogleSerperAPIWrapper(serper_api_key=SERPER_API_KEY, content_size=2048)
|
||||
|
||||
except Exception as e:
|
||||
return f"执行日期查询时出错: {str(e)}"
|
||||
def _run(self, query: str) -> str:
|
||||
try:
|
||||
return self._get_search_wrapper().run(query)
|
||||
except Exception as e:
|
||||
return f"执行网络搜索时出错: {str(e)}"
|
||||
|
||||
async def _arun(self, query: str) -> str:
|
||||
try:
|
||||
return await self._get_search_wrapper().arun(query)
|
||||
except Exception as e:
|
||||
return f"执行网络搜索时出错: {str(e)}"
|
||||
|
||||
class DateQueryClass(BaseTool):
|
||||
|
||||
# 定义工具的输入参数结构,这里没有输入参数,所以是一个空的模型
|
||||
args_schema : type[BaseModel] = create_model("NoneInput")
|
||||
name : str = "date_query_class"
|
||||
description : str = "一个日期查询工具,可以返回当前的日期信息。仅当需要查询当前时间时调用,其余任何情况严禁调用。"
|
||||
|
||||
def _run(self) -> str:
|
||||
try:
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
return now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception as e:
|
||||
return f"执行日期查询时出错: {str(e)}"
|
||||
|
||||
async def _arun(self) -> str:
|
||||
return self._run()
|
||||
|
||||
|
||||
class ScreenCaptureDescribeClass(BaseTool):
|
||||
"""截取当前屏幕并用与对话上传图片相同的视觉链路解析为文字,供 Agent 推理。"""
|
||||
|
||||
args_schema: type[BaseModel] = create_model(
|
||||
"ScreenCaptureInput",
|
||||
focus_query=(
|
||||
Optional[str],
|
||||
Field(
|
||||
default=None,
|
||||
description="可选。希望重点观察的界面或问题,例如「错误提示全文」「浏览器地址栏」;留空则整体描述屏幕。",
|
||||
),
|
||||
),
|
||||
)
|
||||
name: str = "screen_capture_describe"
|
||||
description: str = (
|
||||
"截取当前主屏幕画面,并用视觉模型转为文字描述。"
|
||||
"注意:这个工具仅用于获取当前屏幕的实时画面,其余任何情况严禁调用。"
|
||||
)
|
||||
|
||||
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 字节流
|
||||
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}"
|
||||
|
||||
# 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()}"
|
||||
|
||||
class BrowserControlClass(BaseTool):
|
||||
"""统一浏览器控制工具:支持打开/关闭浏览器、刷新/关闭标签页、打开指定 URL"""
|
||||
|
||||
args_schema: type[BaseModel] = create_model(
|
||||
"BrowserControlInput",
|
||||
action=(Literal["open", "close"], Field(
|
||||
default="open",
|
||||
description="操作类型:'open' 打开/刷新,'close' 关闭"
|
||||
)),
|
||||
url=(Optional[str], Field(
|
||||
default=None,
|
||||
description="URL 地址、'current' 表示当前页,留空则操作整个浏览器窗口"
|
||||
)),
|
||||
browser=(Optional[str], Field(
|
||||
default=None,
|
||||
description="浏览器名称,如 'edge', 'chrome', 'firefox',不指定则使用系统默认"
|
||||
)),
|
||||
)
|
||||
name: str = "browser_control"
|
||||
description: str = (
|
||||
"统一控制浏览器:打开/关闭浏览器、刷新/关闭标签页、打开指定 URL。\n"
|
||||
"- action='open', url=None: 打开浏览器首页\n"
|
||||
"- 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://...': 关闭当前活动标签页"
|
||||
"仅用于进行浏览器操作时调用,其余任何情况严禁调用。"
|
||||
)
|
||||
|
||||
# ---------- 辅助方法 ----------
|
||||
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)
|
||||
except webbrowser.Error:
|
||||
controller = webbrowser.get(browser_name)
|
||||
return controller, title_keyword
|
||||
|
||||
def _activate_window(self, title_keyword: str) -> bool:
|
||||
"""激活浏览器窗口(仅 Windows)"""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
try:
|
||||
time.sleep(0.3)
|
||||
windows = pyautogui.getWindowsWithTitle(title_keyword)
|
||||
if windows:
|
||||
win = windows[0]
|
||||
if win.isMinimized:
|
||||
win.restore()
|
||||
win.activate()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _send_keys(self, *keys):
|
||||
"""发送快捷键,根据平台调整"""
|
||||
pyautogui.hotkey(*keys)
|
||||
|
||||
# ---------- 核心操作 ----------
|
||||
def _open_action(self, url: Optional[str], browser: Optional[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 '默认'} 浏览器窗口,无法刷新"
|
||||
time.sleep(0.2)
|
||||
if sys.platform == "darwin":
|
||||
self._send_keys("command", "r")
|
||||
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]):
|
||||
"""执行关闭类操作"""
|
||||
_, 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 '默认'} 浏览器窗口,无法关闭"
|
||||
time.sleep(0.2)
|
||||
if sys.platform == "darwin":
|
||||
self._send_keys("command", "q")
|
||||
else:
|
||||
self._send_keys("alt", "f4")
|
||||
return "已尝试关闭浏览器窗口"
|
||||
|
||||
elif url.lower() == "current":
|
||||
# 关闭当前标签页
|
||||
if title_keyword:
|
||||
if not self._activate_window(title_keyword):
|
||||
return f"未找到 {browser or '默认'} 浏览器窗口,无法关闭标签页"
|
||||
time.sleep(0.2)
|
||||
if sys.platform == "darwin":
|
||||
self._send_keys("command", "w")
|
||||
else:
|
||||
self._send_keys("ctrl", "w")
|
||||
return "已尝试关闭当前标签页"
|
||||
|
||||
def _run(self, action: str = "open", url: Optional[str] = None, browser: Optional[str] = None) -> str:
|
||||
try:
|
||||
if action == "open":
|
||||
return self._open_action(url, browser)
|
||||
elif action == "close":
|
||||
return self._close_action(url, browser)
|
||||
else:
|
||||
return f"不支持的操作类型: {action}"
|
||||
except Exception as e:
|
||||
return f"执行浏览器操作时出错: {e}"
|
||||
|
||||
if __name__ == "__main__":
|
||||
# print(web_search.name) # 输出工具名称
|
||||
# print(web_search.description) # 输出工具描述
|
||||
# print(web_search.args_schema.model_json_schema()) # 输出工具参数结构
|
||||
# # 测试工具
|
||||
# query = "Python 中的列表和元组有什么区别?"
|
||||
# result = web_search.invoke({"query": query})
|
||||
# print(result)
|
||||
print(date_query.name) # 输出工具名称
|
||||
print(date_query.description) # 输出工具描述
|
||||
print(date_query.args_schema.model_json_schema()) # 输出工具参数结构
|
||||
print(date_query.invoke({})) # 测试工具
|
||||
|
||||
# 这里可以进行一些简单的测试,例如创建工具实例并调用方法
|
||||
pass
|
||||
@@ -1,252 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Optional, Literal
|
||||
import sys
|
||||
import time
|
||||
import webbrowser
|
||||
import pyautogui
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
|
||||
from agent.llm import Qwen36_35B_A3B_FP8_LLM
|
||||
from utils.env_utils import BROWSER_MAP
|
||||
|
||||
|
||||
class Websearchclass(BaseTool):
|
||||
|
||||
args_schema : type[BaseModel] = create_model("Input_type", query=(str, Field(description="搜索查询内容")))
|
||||
name : str = "web_search_class"
|
||||
description : str = "一个网络搜索工具,可以根据用户的查询返回相关的搜索结果。仅当需要查询近期知识或已有知识无法解答需要联网查询时调用,其余任何情况严禁调用。"
|
||||
|
||||
def _get_search_wrapper(self):
|
||||
from langchain_community.utilities import GoogleSerperAPIWrapper
|
||||
SERPER_API_KEY = "d6348dcce0c72bb8102f0eacfd2f536f797da0c5"
|
||||
return GoogleSerperAPIWrapper(serper_api_key=SERPER_API_KEY, content_size=2048)
|
||||
|
||||
def _run(self, query: str) -> str:
|
||||
try:
|
||||
return self._get_search_wrapper().run(query)
|
||||
except Exception as e:
|
||||
return f"执行网络搜索时出错: {str(e)}"
|
||||
|
||||
async def _arun(self, query: str) -> str:
|
||||
try:
|
||||
return await self._get_search_wrapper().arun(query)
|
||||
except Exception as e:
|
||||
return f"执行网络搜索时出错: {str(e)}"
|
||||
|
||||
class DateQueryClass(BaseTool):
|
||||
|
||||
# 定义工具的输入参数结构,这里没有输入参数,所以是一个空的模型
|
||||
args_schema : type[BaseModel] = create_model("NoneInput")
|
||||
name : str = "date_query_class"
|
||||
description : str = "一个日期查询工具,可以返回当前的日期信息。仅当需要查询当前时间时调用,其余任何情况严禁调用。"
|
||||
|
||||
def _run(self) -> str:
|
||||
try:
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
return now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception as e:
|
||||
return f"执行日期查询时出错: {str(e)}"
|
||||
|
||||
async def _arun(self) -> str:
|
||||
return self._run()
|
||||
|
||||
|
||||
class ScreenCaptureDescribeClass(BaseTool):
|
||||
"""截取当前屏幕并用与对话上传图片相同的视觉链路解析为文字,供 Agent 推理。"""
|
||||
|
||||
args_schema: type[BaseModel] = create_model(
|
||||
"ScreenCaptureInput",
|
||||
focus_query=(
|
||||
Optional[str],
|
||||
Field(
|
||||
default=None,
|
||||
description="可选。希望重点观察的界面或问题,例如「错误提示全文」「浏览器地址栏」;留空则整体描述屏幕。",
|
||||
),
|
||||
),
|
||||
)
|
||||
name: str = "screen_capture_describe"
|
||||
description: str = (
|
||||
"截取当前主屏幕画面,并用视觉模型转为文字描述。"
|
||||
"注意:这个工具仅用于获取当前屏幕的实时画面,其余任何情况严禁调用。"
|
||||
)
|
||||
|
||||
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 字节流
|
||||
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}"
|
||||
|
||||
# 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()}"
|
||||
|
||||
class BrowserControlClass(BaseTool):
|
||||
"""统一浏览器控制工具:支持打开/关闭浏览器、刷新/关闭标签页、打开指定 URL"""
|
||||
|
||||
args_schema: type[BaseModel] = create_model(
|
||||
"BrowserControlInput",
|
||||
action=(Literal["open", "close"], Field(
|
||||
default="open",
|
||||
description="操作类型:'open' 打开/刷新,'close' 关闭"
|
||||
)),
|
||||
url=(Optional[str], Field(
|
||||
default=None,
|
||||
description="URL 地址、'current' 表示当前页,留空则操作整个浏览器窗口"
|
||||
)),
|
||||
browser=(Optional[str], Field(
|
||||
default=None,
|
||||
description="浏览器名称,如 'edge', 'chrome', 'firefox',不指定则使用系统默认"
|
||||
)),
|
||||
)
|
||||
name: str = "browser_control"
|
||||
description: str = (
|
||||
"统一控制浏览器:打开/关闭浏览器、刷新/关闭标签页、打开指定 URL。\n"
|
||||
"- action='open', url=None: 打开浏览器首页\n"
|
||||
"- 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://...': 关闭当前活动标签页"
|
||||
"仅用于进行浏览器操作时调用,其余任何情况严禁调用。"
|
||||
)
|
||||
|
||||
# ---------- 辅助方法 ----------
|
||||
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)
|
||||
except webbrowser.Error:
|
||||
controller = webbrowser.get(browser_name)
|
||||
return controller, title_keyword
|
||||
|
||||
def _activate_window(self, title_keyword: str) -> bool:
|
||||
"""激活浏览器窗口(仅 Windows)"""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
try:
|
||||
time.sleep(0.3)
|
||||
windows = pyautogui.getWindowsWithTitle(title_keyword)
|
||||
if windows:
|
||||
win = windows[0]
|
||||
if win.isMinimized:
|
||||
win.restore()
|
||||
win.activate()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _send_keys(self, *keys):
|
||||
"""发送快捷键,根据平台调整"""
|
||||
pyautogui.hotkey(*keys)
|
||||
|
||||
# ---------- 核心操作 ----------
|
||||
def _open_action(self, url: Optional[str], browser: Optional[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 '默认'} 浏览器窗口,无法刷新"
|
||||
time.sleep(0.2)
|
||||
if sys.platform == "darwin":
|
||||
self._send_keys("command", "r")
|
||||
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]):
|
||||
"""执行关闭类操作"""
|
||||
_, 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 '默认'} 浏览器窗口,无法关闭"
|
||||
time.sleep(0.2)
|
||||
if sys.platform == "darwin":
|
||||
self._send_keys("command", "q")
|
||||
else:
|
||||
self._send_keys("alt", "f4")
|
||||
return "已尝试关闭浏览器窗口"
|
||||
|
||||
elif url.lower() == "current":
|
||||
# 关闭当前标签页
|
||||
if title_keyword:
|
||||
if not self._activate_window(title_keyword):
|
||||
return f"未找到 {browser or '默认'} 浏览器窗口,无法关闭标签页"
|
||||
time.sleep(0.2)
|
||||
if sys.platform == "darwin":
|
||||
self._send_keys("command", "w")
|
||||
else:
|
||||
self._send_keys("ctrl", "w")
|
||||
return "已尝试关闭当前标签页"
|
||||
|
||||
def _run(self, action: str = "open", url: Optional[str] = None, browser: Optional[str] = None) -> str:
|
||||
try:
|
||||
if action == "open":
|
||||
return self._open_action(url, browser)
|
||||
elif action == "close":
|
||||
return self._close_action(url, browser)
|
||||
else:
|
||||
return f"不支持的操作类型: {action}"
|
||||
except Exception as e:
|
||||
return f"执行浏览器操作时出错: {e}"
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 这里可以进行一些简单的测试,例如创建工具实例并调用方法
|
||||
pass
|
||||
@@ -0,0 +1,373 @@
|
||||
# agent/tools/tool_rag.py
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Union, Literal
|
||||
|
||||
import chromadb
|
||||
import requests
|
||||
from langchain.tools import BaseTool
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
from langchain.text_splitter import RecursiveCharacterTextSplitter
|
||||
|
||||
from utils.env_utils import RAG_BASE_URL, RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP
|
||||
|
||||
# ---------- 新增 RAG 工具 ----------
|
||||
# Chroma 持久化路径:agent/memory/chroma_rag_db
|
||||
CHROMA_PERSIST_DIR = os.path.join(os.path.dirname(__file__), '..', 'memory', 'chroma_rag_db')
|
||||
|
||||
# 全局 Chroma 客户端和集合缓存
|
||||
_chroma_client = None
|
||||
_collection_cache = {}
|
||||
|
||||
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_collection(agent_name: str):
|
||||
"""获取或创建某个 agent 的 Collection"""
|
||||
client = get_chroma_client()
|
||||
collection_name = f"rag_{agent_name}"
|
||||
if collection_name not in _collection_cache:
|
||||
# 若 Collection 不存在则创建,使用余弦相似度
|
||||
try:
|
||||
collection = client.get_collection(name=collection_name, embedding_function=None)
|
||||
except Exception:
|
||||
collection = client.create_collection(
|
||||
name=collection_name,
|
||||
metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
_collection_cache[collection_name] = collection
|
||||
return _collection_cache[collection_name]
|
||||
|
||||
# ---------- 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,
|
||||
"documents": documents or []
|
||||
}
|
||||
try:
|
||||
resp = requests.post(f"{RAG_BASE_URL}/embed", json=payload, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data["query_embeddings"], data.get("doc_embeddings", [])
|
||||
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,
|
||||
"documents": documents
|
||||
}
|
||||
try:
|
||||
resp = requests.post(f"{RAG_BASE_URL}/rerank", json=payload, timeout=30)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["scores"]
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Reranker 请求失败: {e}")
|
||||
|
||||
# ---------- 文本分块 ----------
|
||||
def split_into_chunks(text: str) -> List[str]:
|
||||
splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=CHUNK_SIZE,
|
||||
chunk_overlap=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})"
|
||||
})
|
||||
|
||||
else:
|
||||
raise ValueError(f"不支持的文件格式: {ext}")
|
||||
|
||||
return chunks
|
||||
|
||||
# ---------- RAG 工具本体 ----------
|
||||
class RAGTool(BaseTool):
|
||||
name: str = "rag_tool"
|
||||
description: str = (
|
||||
"通用知识库工具,支持增删查操作。\n"
|
||||
"参数说明:\n"
|
||||
"- agent_name: 所属 Agent 名称或名称列表,必填。\n"
|
||||
"- action_type: 操作类型,必填。可选:'query'(查询), 'add_document'(添加文档), 'clear'(清除)。\n"
|
||||
"- content: 查询内容(用于query) 或 直接文本内容(用于add_document,与source二选一)。\n"
|
||||
"- source: 文件路径(用于add_document,与content二选一)。\n"
|
||||
"- filter: 过滤条件(用于query或clear)。\n"
|
||||
"使用示例:\n"
|
||||
"- 查询:action_type='query', content='用户问题', agent_name='main'\n"
|
||||
"- 添加文本:action_type='add_document', content='知识文本', agent_name='main'\n"
|
||||
"- 添加文件:action_type='add_document', source='/path/to/file.pdf', agent_name=['agent1','agent2']\n"
|
||||
"- 清除:action_type='clear', filter='某个来源', agent_name='main'"
|
||||
)
|
||||
|
||||
args_schema: type[BaseModel] = create_model(
|
||||
"RAGInput",
|
||||
agent_name=(Union[str, List[str]], Field(..., description="所属 Agent 名称或名称列表")),
|
||||
action_type=(Literal["query", "add_document", "clear"], Field(..., description="操作类型")),
|
||||
content=(Optional[str], Field(None, description="查询内容或直接添加的文本内容")),
|
||||
source=(Optional[str], Field(None, description="文件路径,用于添加文档")),
|
||||
filter=(Optional[Union[str, List[str]]], Field(None, description="过滤条件,用于查询或清除"))
|
||||
)
|
||||
|
||||
def _run(
|
||||
self,
|
||||
agent_name: Union[str, List[str]],
|
||||
action_type: str,
|
||||
content: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
filter: Optional[Union[str, List[str]]] = None
|
||||
) -> str:
|
||||
try:
|
||||
# 标准化 agent_name 为列表
|
||||
agents = [agent_name] if isinstance(agent_name, str) else agent_name
|
||||
|
||||
if action_type == "query":
|
||||
return self._handle_query(agents, content, filter)
|
||||
elif action_type == "add_document":
|
||||
return self._handle_add(agents, content, source)
|
||||
elif action_type == "clear":
|
||||
return self._handle_clear(agents, filter)
|
||||
else:
|
||||
return f"未知操作: {action_type}"
|
||||
except Exception as e:
|
||||
return f"RAG 工具执行出错: {str(e)}"
|
||||
|
||||
# ---------- 查询 ----------
|
||||
def _handle_query(self, agents: List[str], query: str, filter: Optional[Union[str, List[str]]]) -> str:
|
||||
if not query:
|
||||
return "查询内容不能为空。"
|
||||
|
||||
# 1. 获取查询向量
|
||||
q_embs, _ = embed_texts(queries=[query])
|
||||
query_vector = q_embs[0]
|
||||
|
||||
# 2. 从相关 Collection 中检索
|
||||
all_results = []
|
||||
for agent in agents:
|
||||
coll = get_collection(agent)
|
||||
# 构建 where 条件(基础过滤可叠加元数据过滤,这里简单处理)
|
||||
where = None
|
||||
if filter:
|
||||
if isinstance(filter, str):
|
||||
# 简单实现:按 source 字段包含 filter 字符串
|
||||
where = {"source": {"$contains": filter}}
|
||||
else:
|
||||
# 列表情况,暂不支持,返回空
|
||||
pass
|
||||
|
||||
res = coll.query(
|
||||
query_embeddings=[query_vector],
|
||||
n_results=10,
|
||||
where=where
|
||||
)
|
||||
# 整理结果:[(id, document, metadata), ...]
|
||||
if res["ids"] and res["ids"][0]:
|
||||
ids = res["ids"][0]
|
||||
docs = res["documents"][0]
|
||||
metas = res["metadatas"][0]
|
||||
for i, d, m in zip(ids, docs, metas):
|
||||
all_results.append({"id": i, "document": d, "metadata": m})
|
||||
|
||||
if not all_results:
|
||||
return "未在知识库中找到相关内容。"
|
||||
|
||||
# 去重(同一个文档可能被多个 agent 同时索引)
|
||||
seen = set()
|
||||
unique_results = []
|
||||
for r in all_results:
|
||||
if r["document"] not in seen:
|
||||
seen.add(r["document"])
|
||||
unique_results.append(r)
|
||||
unique_results = unique_results[:10] # 确保最多10条
|
||||
|
||||
# 3. Reranker 精排
|
||||
docs_for_rerank = [r["document"] for r in unique_results]
|
||||
scores = rerank_documents(query, docs_for_rerank)
|
||||
|
||||
# 按分数降序取前3
|
||||
sorted_items = sorted(
|
||||
zip(unique_results, scores),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)[:3]
|
||||
|
||||
if not sorted_items:
|
||||
return "Reranker 未返回有效结果。"
|
||||
|
||||
# 拼接输出
|
||||
outputs = []
|
||||
for item, score in sorted_items:
|
||||
meta = item["metadata"]
|
||||
source_info = meta.get("source", "未知来源")
|
||||
outputs.append(f"【来源:{source_info}(相关度 {score:.3f})】\n{item['document']}")
|
||||
|
||||
return "\n\n".join(outputs)
|
||||
|
||||
# ---------- 添加文档 ----------
|
||||
def _handle_add(self, agents: List[str], content: Optional[str], source: Optional[str]) -> str:
|
||||
if not content and not source:
|
||||
return "必须提供 content 或 source。"
|
||||
if content and source:
|
||||
return "content 和 source 只能提供一个。"
|
||||
|
||||
# 1. 准备 chunks 列表
|
||||
chunks_meta = [] # 元素为 (text, source_desc)
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
if content:
|
||||
# 直接使用 content 分块
|
||||
texts = split_into_chunks(content)
|
||||
chunks_meta = [(t, "直接输入") for t in texts]
|
||||
|
||||
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)]
|
||||
|
||||
if not chunks_meta:
|
||||
return "文档内容为空,未添加任何条目。"
|
||||
|
||||
# 2. 批量获取文档向量
|
||||
doc_texts = [cm[0] for cm in chunks_meta]
|
||||
_, doc_embs = embed_texts(queries=["dummy"], documents=doc_texts)
|
||||
|
||||
# 3. 为每个 agent 写入
|
||||
total_added = 0
|
||||
for agent in agents:
|
||||
coll = get_collection(agent)
|
||||
ids = []
|
||||
embeddings = []
|
||||
metadatas = []
|
||||
documents = []
|
||||
|
||||
for idx, (text, src_desc) in enumerate(chunks_meta):
|
||||
# 使用 hash 作为唯一 ID
|
||||
uid = hashlib.md5(f"{agent}_{timestamp}_{text[:80]}".encode()).hexdigest()
|
||||
ids.append(uid)
|
||||
embeddings.append(doc_embs[idx])
|
||||
metadatas.append({
|
||||
"source": src_desc,
|
||||
"agent_name": agent,
|
||||
"timestamp": timestamp,
|
||||
})
|
||||
documents.append(text)
|
||||
|
||||
coll.add(
|
||||
ids=ids,
|
||||
embeddings=embeddings,
|
||||
metadatas=metadatas,
|
||||
documents=documents
|
||||
)
|
||||
total_added += len(ids)
|
||||
|
||||
return f"成功添加 {total_added} 条知识块到 {', '.join(agents)}。"
|
||||
|
||||
# ---------- 清除 ----------
|
||||
def _handle_clear(self, agents: List[str], filter: Optional[Union[str, List[str]]]) -> str:
|
||||
if not filter:
|
||||
return "清除操作必须提供 filter 条件。"
|
||||
|
||||
total_deleted = 0
|
||||
for agent in agents:
|
||||
coll = get_collection(agent)
|
||||
# 根据 filter 构建 where,当前简化:假设 filter 是 source 的包含条件
|
||||
where = None
|
||||
if isinstance(filter, str):
|
||||
where = {"source": {"$contains": filter}}
|
||||
elif isinstance(filter, list):
|
||||
# 多个条件用 $or
|
||||
where = {"$or": [{"source": {"$contains": f}} for f in filter]}
|
||||
|
||||
try:
|
||||
# 查询要删除的 IDs
|
||||
res = coll.get(where=where)
|
||||
if res["ids"]:
|
||||
coll.delete(ids=res["ids"])
|
||||
total_deleted += len(res["ids"])
|
||||
except Exception as e:
|
||||
return f"清除时出错 (agent={agent}): {e}"
|
||||
|
||||
return f"已从 {', '.join(agents)} 中删除 {total_deleted} 条记录。"
|
||||
|
||||
async def _arun(self, **kwargs):
|
||||
# 直接调用同步版本
|
||||
return self._run(**kwargs)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+225
-124
@@ -1,15 +1,23 @@
|
||||
# agent/agent_utils.py
|
||||
# agent/utils/agent_utils.py
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
from typing import List, Tuple, Any
|
||||
|
||||
from langchain_community.chat_message_histories import SQLChatMessageHistory
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
from langchain_core.messages import BaseMessage, SystemMessage, HumanMessage
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
|
||||
from agent.memory.system_prompt import SUMMARY_SYSTEM_PROMPT
|
||||
from agent.utils.env_utils import (
|
||||
LLM_CONTEXT_WINDOW,
|
||||
MINI_COMPRESS_RATE,
|
||||
HARD_COMPRESS_RATE,
|
||||
CURRENT_TURNS,
|
||||
)
|
||||
|
||||
# 关键改动:__file__ 在 agent/utils/agent_utils.py 下,
|
||||
# 取两次 dirname 得到 agent 目录(即 src/agent)
|
||||
# ---------- 目录与数据库路径 ----------
|
||||
_AGENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
HISTORY_ROOT = os.path.join(_AGENT_DIR, "history")
|
||||
_DB_DIR = os.path.join(HISTORY_ROOT, "data_base")
|
||||
@@ -18,58 +26,234 @@ MULTI_INPUTS_ROOT = os.path.join(HISTORY_ROOT, "multi_inputs")
|
||||
|
||||
def ensure_dirs_and_migrate_db() -> None:
|
||||
os.makedirs(_DB_DIR, exist_ok=True)
|
||||
os.makedirs(os.path.join(HISTORY_ROOT, "multi_inputs"), exist_ok=True)
|
||||
os.makedirs(MULTI_INPUTS_ROOT, exist_ok=True)
|
||||
# 迁移旧数据库
|
||||
old_db = os.path.join(HISTORY_ROOT, "chat_history.db")
|
||||
if os.path.isfile(old_db) and not os.path.isfile(CHAT_HISTORY_DB_FS):
|
||||
shutil.move(old_db, CHAT_HISTORY_DB_FS)
|
||||
|
||||
conn = sqlite3.connect(CHAT_HISTORY_DB_FS)
|
||||
# 创建 message_store 表(与 SQLChatMessageHistory 一致)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS message_store (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
message TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
# 创建 session_meta 表(我们的元数据表)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS session_meta (
|
||||
session_id TEXT NOT NULL,
|
||||
agent_name TEXT NOT NULL,
|
||||
cursor_index INTEGER DEFAULT -1,
|
||||
compressed_content TEXT DEFAULT '',
|
||||
last_total_tokens INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (session_id, agent_name)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
ensure_dirs_and_migrate_db()
|
||||
|
||||
# ---------- 历史记录操作 ----------
|
||||
def get_history(session_id: str) -> SQLChatMessageHistory:
|
||||
return SQLChatMessageHistory(
|
||||
session_id=session_id,
|
||||
connection="sqlite:///" + CHAT_HISTORY_DB_FS.replace("\\", "/"),
|
||||
)
|
||||
|
||||
def summarize_history(query, config):
|
||||
"""
|
||||
剪辑最近8条消息,并对更早的消息生成摘要。
|
||||
返回 {'summary': str|None, 'chat_history': list[BaseMessage]}
|
||||
"""
|
||||
session_id = config["configurable"]["session_id"]
|
||||
if not session_id:
|
||||
raise ValueError("Session ID is required in the config.")
|
||||
chat_history = get_history(session_id)
|
||||
stored_messages = chat_history.messages
|
||||
|
||||
if len(stored_messages) <= 8:
|
||||
return {"summary": None, "chat_history": stored_messages}
|
||||
|
||||
recent = stored_messages[-8:]
|
||||
old = stored_messages[:-8]
|
||||
|
||||
summary_prompt = ChatPromptTemplate.from_messages([
|
||||
("system", SUMMARY_SYSTEM_PROMPT),
|
||||
MessagesPlaceholder(variable_name="messages_to_summarize", optional=True),
|
||||
("human", "请生成包含上述对话核心内容的摘要,保留重要事实和决策。"),
|
||||
])
|
||||
|
||||
llm = config.get("llm")
|
||||
chain = summary_prompt | llm
|
||||
summary = chain.invoke(
|
||||
{"messages_to_summarize": old},
|
||||
config={
|
||||
"metadata": {
|
||||
"output_type": "text",
|
||||
"audio_output_dir": None,
|
||||
}
|
||||
}
|
||||
# ---------- session_meta 元数据管理 ----------
|
||||
def get_session_meta(session_id: str, agent_name: str) -> dict:
|
||||
conn = sqlite3.connect(CHAT_HISTORY_DB_FS)
|
||||
cur = conn.execute(
|
||||
"SELECT cursor_index, compressed_content, last_total_tokens FROM session_meta WHERE session_id=? AND agent_name=?",
|
||||
(session_id, agent_name)
|
||||
)
|
||||
return {"summary": summary.content, "chat_history": recent}
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return {
|
||||
"cursor_index": row[0],
|
||||
"compressed_content": row[1],
|
||||
"last_total_tokens": row[2]
|
||||
}
|
||||
return {"cursor_index": -1, "compressed_content": "", "last_total_tokens": 0}
|
||||
|
||||
def update_session_meta(session_id: str, agent_name: str,
|
||||
cursor_index: int, compressed_content: str, last_total_tokens: int) -> None:
|
||||
conn = sqlite3.connect(CHAT_HISTORY_DB_FS)
|
||||
conn.execute("""
|
||||
INSERT INTO session_meta (session_id, agent_name, cursor_index, compressed_content, last_total_tokens)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_id, agent_name) DO UPDATE SET
|
||||
cursor_index=excluded.cursor_index,
|
||||
compressed_content=excluded.compressed_content,
|
||||
last_total_tokens=excluded.last_total_tokens
|
||||
""", (session_id, agent_name, cursor_index, compressed_content, last_total_tokens))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# ---------- 工具函数 ----------
|
||||
def _get_messages_since_cursor(history: SQLChatMessageHistory, cursor_index: int) -> List[BaseMessage]:
|
||||
all_msgs = history.messages
|
||||
if cursor_index < 0 or cursor_index >= len(all_msgs) - 1:
|
||||
return all_msgs
|
||||
return all_msgs[cursor_index + 1:]
|
||||
|
||||
def _count_turns(messages: List[BaseMessage]) -> int:
|
||||
turns = 0
|
||||
i = 0
|
||||
n = len(messages)
|
||||
while i < n:
|
||||
if messages[i].type == "human":
|
||||
if i + 1 < n and messages[i + 1].type in ("ai", "assistant"):
|
||||
turns += 1
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
else:
|
||||
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:
|
||||
"""轻量压缩:保留关键事实、实体、决策,删除冗余"""
|
||||
prompt = ChatPromptTemplate.from_messages([
|
||||
("system", SUMMARY_SYSTEM_PROMPT),
|
||||
("human", "请对以下对话进行轻量压缩,保留核心事实、实体、决策和对话流向。\n\n{chat}"),
|
||||
])
|
||||
chain = prompt | llm
|
||||
chat_text = "\n".join([
|
||||
f"{'用户' if m.type == 'human' else '助手'}: {m.content}" for m in messages_to_compress
|
||||
])
|
||||
print("轻量化压缩函数被调用")
|
||||
print("需要压缩的内容为"+chat_text)
|
||||
result = chain.invoke({"chat": chat_text}, config={"metadata": {"output_type": "text","audio_output_dir": None,}})
|
||||
return result.content
|
||||
|
||||
def _hard_compress(llm, compressed_existing: str, new_chat_text: str) -> str:
|
||||
"""全量压缩:将已有压缩摘要和新对话内容融合为最终摘要"""
|
||||
prompt = ChatPromptTemplate.from_messages([
|
||||
("system", SUMMARY_SYSTEM_PROMPT),
|
||||
("human", "这是之前的对话摘要:\n{previous_summary}\n\n以及后续对话内容:\n{new_chat}\n\n请生成包含所有信息的最终摘要,保留重要事实和决策。"),
|
||||
])
|
||||
chain = prompt | llm
|
||||
print("全量压缩函数被调用")
|
||||
result = chain.invoke({
|
||||
"previous_summary": compressed_existing,
|
||||
"new_chat": new_chat_text
|
||||
}, config={"metadata": {"output_type": "text"}})
|
||||
return result.content
|
||||
|
||||
# ---------- 核心:上下文感知构建器 (summarizer) ----------
|
||||
def summarizer(
|
||||
session_id: str,
|
||||
agent_name: str,
|
||||
system_prompt: str,
|
||||
user_content: str,
|
||||
llm: Any
|
||||
) -> Tuple[List[BaseMessage], bool]:
|
||||
"""
|
||||
根据上下文窗口与上一轮 token 使用量,动态压缩历史消息并返回完整消息列表。
|
||||
:param session_id: 会话ID
|
||||
:param agent_name: Agent 名称
|
||||
:param system_prompt: 系统提示词
|
||||
:param user_content: 当前用户输入文本(纯文本或已处理的多模态文本)
|
||||
:param llm: 语言模型实例(用于压缩)
|
||||
:return: (messages, context_exceeded)
|
||||
messages: 构建好的消息列表
|
||||
context_exceeded: 是否超过 90% 窗口,需要提示新建会话
|
||||
"""
|
||||
meta = get_session_meta(session_id, agent_name)
|
||||
cursor_index = meta["cursor_index"]
|
||||
compressed_content = meta["compressed_content"]
|
||||
last_total_tokens = meta["last_total_tokens"]
|
||||
|
||||
# 检查上下文是否已满
|
||||
if last_total_tokens > LLM_CONTEXT_WINDOW * 0.9:
|
||||
return [], True
|
||||
|
||||
# 判断是否需要触发压缩
|
||||
need_light = False
|
||||
need_hard = False
|
||||
if last_total_tokens > 0:
|
||||
if last_total_tokens > LLM_CONTEXT_WINDOW * HARD_COMPRESS_RATE:
|
||||
need_hard = True
|
||||
elif last_total_tokens > LLM_CONTEXT_WINDOW * MINI_COMPRESS_RATE:
|
||||
need_light = True
|
||||
|
||||
history = get_history(session_id)
|
||||
all_messages = history.messages
|
||||
|
||||
if need_light:
|
||||
# 轻量压缩:保留最近 CURRENT_TURNS 轮对话不压缩
|
||||
turns = _count_turns(all_messages)
|
||||
keep_turns = min(CURRENT_TURNS, turns)
|
||||
# 找到需要保留的轮数之后的最后一条消息索引
|
||||
idx = len(all_messages) - 1
|
||||
turn_count = 0
|
||||
while idx >= 0:
|
||||
if all_messages[idx].type in ("ai", "assistant"):
|
||||
turn_count += 1
|
||||
if turn_count == keep_turns:
|
||||
break
|
||||
idx -= 1
|
||||
new_cursor = idx - 1 if idx >= 0 else -1 # idx 是保留轮次开始的前一条消息索引
|
||||
|
||||
# 待压缩内容 = 游标及之前的所有消息
|
||||
to_compress = []
|
||||
if new_cursor >= 0:
|
||||
to_compress = all_messages[:new_cursor + 1]
|
||||
if compressed_content:
|
||||
to_compress.insert(0, HumanMessage(content=f"[先前摘要] {compressed_content}"))
|
||||
|
||||
new_compressed = _lightweight_compress(llm, to_compress) if to_compress else compressed_content
|
||||
# 更新游标和压缩内容
|
||||
update_session_meta(session_id, agent_name, new_cursor, new_compressed, last_total_tokens)
|
||||
meta["cursor_index"] = new_cursor
|
||||
meta["compressed_content"] = new_compressed
|
||||
|
||||
elif need_hard:
|
||||
# 全量压缩:将已有压缩内容 + 游标后的所有消息合并为最终摘要
|
||||
after_cursor = _get_messages_since_cursor(history, cursor_index)
|
||||
new_chat_text = "\n".join(
|
||||
[f"{'用户' if m.type == 'human' else '助手'}: {m.content}" for m in after_cursor]
|
||||
)
|
||||
if compressed_content:
|
||||
new_compressed = _hard_compress(llm, compressed_content, new_chat_text)
|
||||
else:
|
||||
new_compressed = _hard_compress(llm, "", new_chat_text)
|
||||
# 游标移到消息列表末尾(全量压缩后无未压缩消息)
|
||||
last_msg_idx = len(all_messages) - 1 if all_messages else -1
|
||||
update_session_meta(session_id, agent_name, last_msg_idx, new_compressed, last_total_tokens)
|
||||
meta["cursor_index"] = last_msg_idx
|
||||
meta["compressed_content"] = new_compressed
|
||||
|
||||
# 构建返回消息列表
|
||||
system_text = system_prompt
|
||||
if meta["compressed_content"]:
|
||||
system_text += f"\n\n【历史对话摘要】\n{meta['compressed_content']}"
|
||||
|
||||
messages = [SystemMessage(content=system_text)]
|
||||
# 添加游标之后的未压缩消息
|
||||
recent = _get_messages_since_cursor(history, meta["cursor_index"])
|
||||
messages.extend(recent)
|
||||
# 添加当前用户消息
|
||||
messages.append(HumanMessage(content=user_content))
|
||||
|
||||
return messages, False
|
||||
|
||||
# ---------- 辅助函数 ----------
|
||||
def assistant_text(value) -> str:
|
||||
"""把网关返回的 content、reasoning 等(str 或块列表)收成一段可见文本。"""
|
||||
"""把网关返回的 content、reasoning 等收成一段可见文本。"""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
@@ -95,87 +279,4 @@ def assistant_text(value) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
except TypeError:
|
||||
return str(value).strip()
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def normalize_user_text_content(value) -> str:
|
||||
"""尽量把用户输入收敛为纯文本,避免把 dict/list 结构直接送进主模型。"""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
if isinstance(value, dict):
|
||||
for key in ("text", "content", "input", "query", "question", "message"):
|
||||
v = value.get(key)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
content = value.get("content")
|
||||
if content is not None:
|
||||
text = normalize_user_text_content(content)
|
||||
if text:
|
||||
return text
|
||||
return assistant_text(value)
|
||||
if isinstance(value, list):
|
||||
parts: list[str] = []
|
||||
for item in value:
|
||||
if isinstance(item, str) and item.strip():
|
||||
parts.append(item.strip())
|
||||
elif isinstance(item, dict):
|
||||
if item.get("type") == "text":
|
||||
text = (item.get("text") or "").strip()
|
||||
if text:
|
||||
parts.append(text)
|
||||
else:
|
||||
text = normalize_user_text_content(item)
|
||||
if text:
|
||||
parts.append(text)
|
||||
return "\n".join(parts).strip()
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def extract_result_from_invoke(value) -> str:
|
||||
"""兼容不同 invoke 返回结构,尽量提取可显示的助手文本。"""
|
||||
if value is None:
|
||||
return ""
|
||||
|
||||
if hasattr(value, "content"):
|
||||
text = assistant_text(getattr(value, "content", None))
|
||||
if text:
|
||||
return text
|
||||
|
||||
if isinstance(value, dict):
|
||||
for key in ("output", "content", "text", "answer", "result"):
|
||||
text = assistant_text(value.get(key))
|
||||
if text:
|
||||
return text
|
||||
|
||||
message = value.get("message")
|
||||
if message is not None:
|
||||
text = extract_result_from_invoke(message)
|
||||
if text:
|
||||
return text
|
||||
|
||||
generations = value.get("generations")
|
||||
if isinstance(generations, list):
|
||||
for generation in generations:
|
||||
text = extract_result_from_invoke(generation)
|
||||
if text:
|
||||
return text
|
||||
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
text = extract_result_from_invoke(item)
|
||||
if text:
|
||||
return text
|
||||
|
||||
if hasattr(value, "message"):
|
||||
text = extract_result_from_invoke(getattr(value, "message", None))
|
||||
if text:
|
||||
return text
|
||||
|
||||
if hasattr(value, "additional_kwargs"):
|
||||
text = assistant_text(getattr(value, "additional_kwargs", {}).get("reasoning_content"))
|
||||
if text:
|
||||
return text
|
||||
|
||||
return ""
|
||||
return str(value).strip()
|
||||
@@ -28,6 +28,13 @@ LLM_TIMEOUT = _get_env("LLM_TIMEOUT", default="60", cast=float)
|
||||
TTS_BASE_URL = _get_env("TTS_BASE_URL")
|
||||
ASR_BASE_URL = _get_env("ASR_BASE_URL")
|
||||
RAG_BASE_URL = _get_env("RAG_BASE_URL")
|
||||
LLM_CONTEXT_WINDOW = int(os.getenv("LLM_CONTEXT_WINDOW", 131072))
|
||||
MINI_COMPRESS_RATE = float(os.getenv("MINI_COMPRESS_RATE", 0.5))
|
||||
HARD_COMPRESS_RATE = float(os.getenv("HARD_COMPRESS_RATE", 0.75))
|
||||
CURRENT_TURNS = int(os.getenv("CURRENT_TURNS", 4))
|
||||
IMG_SIZE = int(os.getenv("IMG_SIZE", 768))
|
||||
RAG_CHUNK_SIZE=int(os.getenv("RAG_CHUNK_SIZE", 500))
|
||||
RAG_CHUNK_OVERLAP=int(os.getenv("RAG_CHUNK_OVERLAP", 50))
|
||||
|
||||
BROWSER_MAP: Dict[str, Tuple[str, str]] = {
|
||||
"edge": ("microsoft-edge", "Microsoft Edge"),
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"""图片缩放、体积控制与 OpenAI 风格多模态 image_url 片段(供 LLM / Agent 使用)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
from typing import Any, Protocol
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from agent.utils.env_utils import IMG_SIZE
|
||||
|
||||
class SupportsChatCompletion(Protocol):
|
||||
"""具备 ``chat_completion(messages, **kwargs)`` 的 LLM(如 ``ChatQwen35OpenAI``)。"""
|
||||
@@ -26,7 +24,7 @@ def guess_image_mime(extension_without_dot: str) -> str:
|
||||
return "image/jpeg"
|
||||
|
||||
|
||||
def resize_image_if_needed(image_path_or_bytes: str | bytes, *, max_size: int = 768) -> bytes:
|
||||
def resize_image_if_needed(image_path_or_bytes: str | bytes, *, max_size: int = IMG_SIZE) -> bytes:
|
||||
"""
|
||||
控制图片体积,避免 data-url base64 触发超长 token。
|
||||
- 若图片尺寸过大,等比例缩放(默认最大边 max_size)
|
||||
@@ -56,9 +54,6 @@ def resize_image_if_needed(image_path_or_bytes: str | bytes, *, max_size: int =
|
||||
new_width = int(original_width * ratio)
|
||||
new_height = int(original_height * ratio)
|
||||
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
print(f"图片已从 {original_width}x{original_height} 缩放到 {new_width}x{new_height}")
|
||||
else:
|
||||
print(f"图片尺寸 {original_width}x{original_height} 未超过限制,无需缩放")
|
||||
|
||||
img_buffer = io.BytesIO()
|
||||
save_format = (original_format or "PNG").upper()
|
||||
@@ -87,7 +82,6 @@ def image_to_base64(image_content: str | bytes) -> str | None:
|
||||
processed = resize_image_if_needed(image_content)
|
||||
return base64.b64encode(processed).decode("utf-8")
|
||||
except Exception as e:
|
||||
print(f"处理图片 {image_content!r} 时出错: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -111,7 +105,6 @@ def image_bytes_to_openai_image_url_part(
|
||||
try:
|
||||
processed = resize_image_if_needed(image_bytes)
|
||||
except Exception as e:
|
||||
print(f"处理图片字节时出错: {e}")
|
||||
return None
|
||||
mime = _mime_from_magic(processed)
|
||||
b64_str = base64.b64encode(processed).decode("utf-8")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+108
-48
@@ -6,7 +6,6 @@
|
||||
--radius: 14px;
|
||||
--sidebar-w: 272px;
|
||||
--rail-w: 44px;
|
||||
/* 背景:景深模糊(px),由 JS 写入 html 行内或仍用默认 */
|
||||
--bg-dof: 0px;
|
||||
--bg-vignette: 0.35;
|
||||
--bubble-user: rgba(236, 254, 255, 0.55);
|
||||
@@ -40,7 +39,7 @@ body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 全屏背景 + 景深(对底图做轻微放大以裁掉模糊边缘) */
|
||||
/* 全屏背景 + 景深 */
|
||||
.bg-scene {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -52,7 +51,6 @@ body {
|
||||
.bg-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/* 默认 cover;加载后 app.js 按 natural 尺寸与视口写死 px,等价 cover + 景深 bleed */
|
||||
background-color: #334155;
|
||||
background-image: url("/image/background.jpg");
|
||||
background-size: cover;
|
||||
@@ -435,6 +433,7 @@ body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ===== 消息容器 ===== */
|
||||
.msg {
|
||||
max-width: 95%;
|
||||
width: fit-content;
|
||||
@@ -444,19 +443,24 @@ body {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
/* 用户消息:靠右并限制最大宽度 */
|
||||
.msg.user {
|
||||
align-self: flex-end;
|
||||
align-items: flex-end;
|
||||
max-width: 20%;
|
||||
}
|
||||
|
||||
/* 助手消息可稍宽 */
|
||||
.msg.assistant {
|
||||
align-self: flex-start;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
/* ===== 聊天气泡 ===== */
|
||||
.msg-bubble {
|
||||
border-radius: 14px;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
border-radius: 18px; /* 更明显的圆角 */
|
||||
padding: 0.85rem 1.05rem; /* 更充足的内边距,让内容不贴边 */
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
@@ -466,49 +470,65 @@ body {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.06);
|
||||
overflow: hidden; /* 裁剪图片/代码块等内容的直角,保持圆角边界 */
|
||||
}
|
||||
|
||||
.msg.user .msg-bubble {
|
||||
background: var(--bubble-user);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.msg.user .msg-bubble img {
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.msg.assistant .msg-bubble {
|
||||
background: var(--bubble-assistant);
|
||||
}
|
||||
|
||||
/* 图片在气泡内留出呼吸空间 */
|
||||
.msg-bubble img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: min(360px, 100%);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
margin-top: 0.25rem;
|
||||
max-width: min(340px, 100%); /* 缩小占比,不撑满气泡 */
|
||||
border-radius: 12px; /* 图片自身圆角,配合气泡圆角 */
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
margin: 0.4rem 0; /* 上下对称间距 */
|
||||
}
|
||||
|
||||
.msg-bubble > img:first-child {
|
||||
margin-top: 0;
|
||||
margin-top: 0.15rem; /* 图片在顶部时也保留一点间距 */
|
||||
}
|
||||
|
||||
.msg-bubble audio {
|
||||
.msg-bubble > img:last-child {
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.msg-bubble audio {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* ===== 纯音频消息 ===== */
|
||||
.msg--audio-only {
|
||||
max-width: min(var(--audio-bubble-max), 82%);
|
||||
max-width: min(var(--audio-bubble-max), 72%);
|
||||
}
|
||||
|
||||
/* 纯音频消息的气泡:透明无边框,保留音频 pill 尾部空间 */
|
||||
.msg--audio-only .msg-bubble {
|
||||
padding: 0.2rem 0;
|
||||
padding: 0.4rem 0.35rem; /* 给 pill 两侧留出呼吸空间 */
|
||||
border: none;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
overflow: visible; /* 允许音频 pill 尾部小三角超出气泡 */
|
||||
}
|
||||
|
||||
/* ===== 音频胶囊 ===== */
|
||||
.audio-pill-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -764,11 +784,12 @@ body {
|
||||
color: #115e59;
|
||||
}
|
||||
|
||||
/* ===== 富文本消息 ===== */
|
||||
.msg-bubble.msg-bubble--rich {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.msg-prose {
|
||||
.msg-prose {
|
||||
margin: 0.4rem 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
@@ -777,7 +798,7 @@ body {
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.markdown-body p,
|
||||
.markdown-body p,
|
||||
.markdown-body ul,
|
||||
.markdown-body ol,
|
||||
.markdown-body blockquote,
|
||||
@@ -826,7 +847,8 @@ body {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.msg-typing .msg-typing-bubble {
|
||||
/* ===== 打字动画 ===== */
|
||||
.msg-typing .msg-typing-bubble {
|
||||
min-width: 5.5rem;
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
@@ -860,33 +882,7 @@ body {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
@keyframes audioWave {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.65);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scaleY(1.15);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typingBounce {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 代码块 ===== */
|
||||
.code-block-wrap {
|
||||
margin: 0.55rem 0;
|
||||
border-radius: 10px;
|
||||
@@ -949,6 +945,7 @@ body {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* ===== 输入区域 ===== */
|
||||
.composer {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.65rem;
|
||||
@@ -1072,6 +1069,17 @@ body {
|
||||
filter: hue-rotate(315deg) saturate(1.2);
|
||||
}
|
||||
|
||||
.mic-btn.is-auto-input {
|
||||
border-color: rgba(148, 163, 184, 0.35);
|
||||
background: rgba(203, 213, 224, 0.65);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.mic-btn.is-auto-input img {
|
||||
filter: grayscale(100%);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.mm-input textarea::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
@@ -1088,6 +1096,27 @@ body {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.composer-auto-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
|
||||
.auto-input-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auto-input-check {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.composer-send {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1127,7 +1156,7 @@ body {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
@@ -1145,7 +1174,7 @@ body {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.file-btn {
|
||||
.file-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
@@ -1167,7 +1196,33 @@ body {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* —— 窄屏 / 手机:侧栏改为抽屉,主区全宽,避免 272px 挤爆布局 —— */
|
||||
/* ===== 动画 ===== */
|
||||
@keyframes audioWave {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.65);
|
||||
opacity: 0.72;
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1.15);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typingBounce {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 窄屏 / 手机 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.main-header {
|
||||
align-items: center;
|
||||
@@ -1177,6 +1232,11 @@ body {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* 用户消息在移动端适度放宽,避免 20% 过窄 */
|
||||
.msg.user {
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
.app-root.is-mobile .sidebar {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
@@ -1266,4 +1326,4 @@ body {
|
||||
.chat-messages {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
-40
@@ -8,6 +8,7 @@
|
||||
const imageInput = $("imageInput");
|
||||
const audioInput = $("audioInput");
|
||||
const micBtn = $("micBtn");
|
||||
const autoInputCheck = $("autoInputCheck");
|
||||
const attachmentPreview = $("attachmentPreview");
|
||||
const sendBtn = $("sendBtn");
|
||||
const clearBtn = $("clearBtn");
|
||||
@@ -47,7 +48,21 @@
|
||||
let recorderStream = null;
|
||||
let recordedChunks = [];
|
||||
let recordingMimeType = "";
|
||||
/** 最近一次 renderMessages 的 JSON 可序列化快照,用于发送前拼接带 blob: 的乐观用户消息 */
|
||||
// 自动语音输入相关变量
|
||||
let autoInputEnabled = false;
|
||||
let audioContext = null;
|
||||
let analyser = null;
|
||||
let dataArray = null;
|
||||
let mediaStreamSource = null;
|
||||
let autoInputStream = null;
|
||||
let autoRecordingTimeout = null;
|
||||
let silenceTimeout = null;
|
||||
// 声音阈值配置(已优化)
|
||||
const START_THRESHOLD = 0.18; // 高于此开始录音(较高阈值)
|
||||
const STOP_THRESHOLD = 0.06; // 低于此停止录音(较低阈值,接近静音)
|
||||
const AUTO_START_DELAY = 100;
|
||||
const AUTO_STOP_DELAY = 800; // 缩短停止延迟
|
||||
/** 最近一次 renderMessages 的 JSON 可序列化快照 */
|
||||
let lastRenderedMessages = [];
|
||||
let activeAudioController = null;
|
||||
|
||||
@@ -113,7 +128,6 @@
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** 与后端气泡结构一致:纯文字为 string,否则为 parts 数组(顺序:图片 → 文字 → 音频) */
|
||||
function buildOptimisticUserContent(text, imageBlobUrls, audioPart) {
|
||||
const parts = [];
|
||||
(imageBlobUrls || []).forEach((url) => {
|
||||
@@ -194,12 +208,179 @@
|
||||
recorderStream = null;
|
||||
}
|
||||
|
||||
function stopAudioContext() {
|
||||
if (audioContext) {
|
||||
try {
|
||||
audioContext.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
audioContext = null;
|
||||
analyser = null;
|
||||
dataArray = null;
|
||||
mediaStreamSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 改用 RMS 计算音量,更符合物理响度
|
||||
function calculateVolume() {
|
||||
if (!analyser || !dataArray) return 0;
|
||||
const bufferLength = analyser.fftSize;
|
||||
const timeData = new Float32Array(bufferLength);
|
||||
analyser.getFloatTimeDomainData(timeData);
|
||||
let sumSquares = 0;
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
sumSquares += timeData[i] * timeData[i];
|
||||
}
|
||||
return Math.sqrt(sumSquares / bufferLength);
|
||||
}
|
||||
|
||||
function handleVolumeDetection() {
|
||||
if (!autoInputEnabled || !analyser) return;
|
||||
|
||||
const volume = calculateVolume();
|
||||
|
||||
handleVolumeDetection.frameCount = (handleVolumeDetection.frameCount || 0) + 1;
|
||||
if (handleVolumeDetection.frameCount % 10 === 0) {
|
||||
console.log("音量检测:", volume.toFixed(3), "| 状态:",
|
||||
mediaRecorder?.state || "idle", "| 自动输入:", autoInputEnabled);
|
||||
}
|
||||
|
||||
if (volume > START_THRESHOLD) {
|
||||
console.log("检测到高分贝,音量:", volume.toFixed(3), "> 阈值:", START_THRESHOLD);
|
||||
if (silenceTimeout) {
|
||||
clearTimeout(silenceTimeout);
|
||||
silenceTimeout = null;
|
||||
}
|
||||
if (!mediaRecorder && !autoRecordingTimeout) {
|
||||
console.log("设置延迟启动录音,延迟:", AUTO_START_DELAY, "ms");
|
||||
autoRecordingTimeout = setTimeout(() => {
|
||||
if (autoInputEnabled && !mediaRecorder) {
|
||||
startMicRecording().catch((e) => {
|
||||
console.error("自动开始录音失败:", e);
|
||||
autoRecordingTimeout = null;
|
||||
});
|
||||
}
|
||||
autoRecordingTimeout = null;
|
||||
}, AUTO_START_DELAY);
|
||||
}
|
||||
} else if (volume < STOP_THRESHOLD && mediaRecorder && mediaRecorder.state === "recording") {
|
||||
console.log("检测到静音,音量:", volume.toFixed(3), "< 阈值:", STOP_THRESHOLD);
|
||||
if (autoRecordingTimeout) {
|
||||
clearTimeout(autoRecordingTimeout);
|
||||
autoRecordingTimeout = null;
|
||||
}
|
||||
if (!silenceTimeout) {
|
||||
console.log("设置延迟停止录音,延迟:", AUTO_STOP_DELAY, "ms");
|
||||
silenceTimeout = setTimeout(() => {
|
||||
if (mediaRecorder && mediaRecorder.state === "recording") {
|
||||
stopMicRecording().catch((e) => {
|
||||
console.error("自动停止录音失败:", e);
|
||||
});
|
||||
}
|
||||
silenceTimeout = null;
|
||||
}, AUTO_STOP_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
if (autoInputEnabled) {
|
||||
requestAnimationFrame(handleVolumeDetection);
|
||||
}
|
||||
}
|
||||
|
||||
async function startAutoInput() {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error("当前浏览器不支持麦克风");
|
||||
}
|
||||
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
|
||||
if (audioContext.state === 'suspended') {
|
||||
await audioContext.resume();
|
||||
}
|
||||
|
||||
analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 512; // 平衡速度与精度
|
||||
dataArray = new Float32Array(analyser.fftSize); // 时域数据
|
||||
mediaStreamSource = null; // 稍后连接
|
||||
|
||||
autoInputStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaStreamSource = audioContext.createMediaStreamSource(autoInputStream);
|
||||
mediaStreamSource.connect(analyser);
|
||||
|
||||
console.log("自动语音输入已开启,音频上下文状态:", audioContext.state);
|
||||
console.log("音量检测阈值 - 开始:", START_THRESHOLD, "停止:", STOP_THRESHOLD);
|
||||
|
||||
handleVolumeDetection();
|
||||
}
|
||||
|
||||
function stopAutoInput() {
|
||||
if (autoRecordingTimeout) {
|
||||
clearTimeout(autoRecordingTimeout);
|
||||
autoRecordingTimeout = null;
|
||||
}
|
||||
if (silenceTimeout) {
|
||||
clearTimeout(silenceTimeout);
|
||||
silenceTimeout = null;
|
||||
}
|
||||
if (autoInputStream) {
|
||||
autoInputStream.getTracks().forEach((track) => {
|
||||
try {
|
||||
track.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
autoInputStream = null;
|
||||
}
|
||||
stopAudioContext();
|
||||
}
|
||||
|
||||
function setAutoInputUi(enabled) {
|
||||
if (!micBtn) return;
|
||||
micBtn.classList.toggle("is-auto-input", enabled);
|
||||
micBtn.disabled = enabled;
|
||||
micBtn.title = enabled ? "自动语音输入中(不可用)" : "使用麦克风录音";
|
||||
micBtn.setAttribute("aria-label", enabled ? "自动语音输入中(不可用)" : "使用麦克风录音");
|
||||
}
|
||||
|
||||
function toggleAutoInput(enabled) {
|
||||
autoInputEnabled = enabled;
|
||||
|
||||
if (enabled) {
|
||||
setAutoInputUi(true);
|
||||
startAutoInput().catch((e) => {
|
||||
console.error("开启自动语音输入失败:", e);
|
||||
autoInputEnabled = false;
|
||||
setAutoInputUi(false);
|
||||
if (autoInputCheck) autoInputCheck.checked = false;
|
||||
alert(e.message || "开启自动语音输入失败");
|
||||
});
|
||||
} else {
|
||||
setAutoInputUi(false);
|
||||
stopAutoInput();
|
||||
if (mediaRecorder && mediaRecorder.state === "recording") {
|
||||
stopMicRecording().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setRecordingUi(recording) {
|
||||
if (!micBtn) return;
|
||||
micBtn.classList.toggle("is-recording", recording);
|
||||
micBtn.setAttribute("aria-pressed", recording ? "true" : "false");
|
||||
micBtn.title = recording ? "停止录音" : "使用麦克风录音";
|
||||
micBtn.setAttribute("aria-label", recording ? "停止录音" : "使用麦克风录音");
|
||||
|
||||
if (recording) {
|
||||
micBtn.title = "停止录音";
|
||||
micBtn.setAttribute("aria-label", "停止录音");
|
||||
micBtn.classList.remove("is-auto-input");
|
||||
} else {
|
||||
micBtn.title = autoInputEnabled ? "自动语音输入中(不可用)" : "使用麦克风录音";
|
||||
micBtn.setAttribute("aria-label", autoInputEnabled ? "自动语音输入中(不可用)" : "使用麦克风录音");
|
||||
if (autoInputEnabled) {
|
||||
micBtn.classList.add("is-auto-input");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function inferRecordedExtension(mimeType) {
|
||||
@@ -234,7 +415,11 @@
|
||||
recordedChunks = [];
|
||||
recordingMimeType = "";
|
||||
mediaRecorder = null;
|
||||
stopRecorderStream();
|
||||
if (!autoInputEnabled) {
|
||||
stopRecorderStream();
|
||||
} else {
|
||||
recorderStream = null;
|
||||
}
|
||||
setRecordingUi(false);
|
||||
}
|
||||
}, { once: true });
|
||||
@@ -242,7 +427,11 @@
|
||||
recordedChunks = [];
|
||||
recordingMimeType = "";
|
||||
mediaRecorder = null;
|
||||
stopRecorderStream();
|
||||
if (!autoInputEnabled) {
|
||||
stopRecorderStream();
|
||||
} else {
|
||||
recorderStream = null;
|
||||
}
|
||||
setRecordingUi(false);
|
||||
reject(event.error || new Error("录音失败"));
|
||||
}, { once: true });
|
||||
@@ -273,7 +462,12 @@
|
||||
}
|
||||
}) || "";
|
||||
|
||||
recorderStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (autoInputEnabled && autoInputStream) {
|
||||
recorderStream = autoInputStream;
|
||||
} else {
|
||||
recorderStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
}
|
||||
|
||||
recordedChunks = [];
|
||||
recordingMimeType = supportedType;
|
||||
mediaRecorder = supportedType
|
||||
@@ -433,7 +627,6 @@
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 解析 ```lang … ``` 与正文,生成片段(用于助手气泡)。 */
|
||||
function parseAssistantContent(full) {
|
||||
return renderMarkdownToFragment(full);
|
||||
}
|
||||
@@ -445,7 +638,6 @@
|
||||
bubble.appendChild(renderMarkdownToFragment(t));
|
||||
}
|
||||
|
||||
/** 整包到达后:先伪流式显示纯文本,再替换为代码块等富结构。 */
|
||||
async function pseudoStreamAssistant(bubble, full) {
|
||||
const t = full == null ? "" : String(full);
|
||||
bubble.classList.add("msg-bubble--rich", "markdown-body");
|
||||
@@ -489,7 +681,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** 流式渲染新生成的 assistant 消息(允许自动播放音频) */
|
||||
async function streamAssistantMessage(msg) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "msg assistant" + (isAudioOnlyAssistantMessage(msg) ? " msg--audio-only" : "");
|
||||
@@ -508,7 +699,6 @@
|
||||
await pseudoStreamAssistant(bubble, textParts.map((part) => part.text || "").join("\n"));
|
||||
}
|
||||
|
||||
// 媒体部分:显式开启 allowAutoplay
|
||||
const mediaOptions = {
|
||||
autoplay: true,
|
||||
compact: textParts.length === 0,
|
||||
@@ -562,7 +752,7 @@
|
||||
compact = false,
|
||||
messageMeta = null,
|
||||
role = "assistant",
|
||||
allowAutoplay = false, // 默认不自动播放,只对新生成消息显式开启
|
||||
allowAutoplay = false,
|
||||
} = options;
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
@@ -680,7 +870,6 @@
|
||||
wrap.appendChild(button);
|
||||
updateProgressUi();
|
||||
|
||||
// 只有 allowAutoplay 为 true 时才自动播放
|
||||
const shouldAutoplay = allowAutoplay !== false && Boolean(
|
||||
autoplay || part.autoplay || (messageMeta && messageMeta.autoplay)
|
||||
);
|
||||
@@ -748,11 +937,6 @@
|
||||
return div;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染单条消息
|
||||
* @param {object} msg - 消息对象
|
||||
* @param {boolean} allowAutoplay - 是否允许音频自动播放(仅新生成消息为 true)
|
||||
*/
|
||||
function renderMessage(msg, allowAutoplay = false) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "msg " + (msg.role === "user" ? "user" : "assistant") + (isAudioOnlyAssistantMessage(msg) ? " msg--audio-only" : "");
|
||||
@@ -796,11 +980,6 @@
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染消息列表
|
||||
* @param {Array} messages - 消息数组
|
||||
* @param {boolean} allowAutoplay - 是否允许音频自动播放(默认 false)
|
||||
*/
|
||||
function renderMessages(messages, allowAutoplay = false) {
|
||||
chatMessages.innerHTML = "";
|
||||
const list = messages || [];
|
||||
@@ -843,7 +1022,7 @@
|
||||
sessionId = data.sessionId;
|
||||
sessions = data.sessions || [];
|
||||
renderSessions();
|
||||
renderMessages(data.messages); // allowAutoplay 默认为 false
|
||||
renderMessages(data.messages);
|
||||
}
|
||||
|
||||
async function selectSession(id) {
|
||||
@@ -852,7 +1031,7 @@
|
||||
sessionId = data.sessionId;
|
||||
sessions = data.sessions || [];
|
||||
renderSessions();
|
||||
renderMessages(data.messages); // 切换会话不自动播放
|
||||
renderMessages(data.messages);
|
||||
closeDrawerIfMobile();
|
||||
}
|
||||
|
||||
@@ -866,7 +1045,7 @@
|
||||
audioInput.value = "";
|
||||
renderSessions();
|
||||
renderAttachmentChips();
|
||||
renderMessages(data.messages); // 新建会话不自动播放
|
||||
renderMessages(data.messages);
|
||||
closeDrawerIfMobile();
|
||||
}
|
||||
|
||||
@@ -885,7 +1064,7 @@
|
||||
audioInput.value = "";
|
||||
renderAttachmentChips();
|
||||
renderSessions();
|
||||
renderMessages(data.messages || []); // 删除后不自动播放
|
||||
renderMessages(data.messages || []);
|
||||
closeDrawerIfMobile();
|
||||
}
|
||||
|
||||
@@ -894,7 +1073,7 @@
|
||||
const fd = new FormData();
|
||||
fd.append("session_id", sessionId);
|
||||
const data = await api("/api/sessions/clear", { method: "POST", body: fd });
|
||||
renderMessages(data.messages); // 清空后不自动播放
|
||||
renderMessages(data.messages);
|
||||
}
|
||||
|
||||
async function sendChat() {
|
||||
@@ -925,7 +1104,7 @@
|
||||
}
|
||||
const optimisticContent = buildOptimisticUserContent(trimmed, imageBlobUrls, audioPart);
|
||||
if (optimisticContent != null) {
|
||||
renderMessages([...priorMessages, { role: "user", content: optimisticContent }]); // 乐观渲染不自动播放
|
||||
renderMessages([...priorMessages, { role: "user", content: optimisticContent }]);
|
||||
}
|
||||
textInput.value = "";
|
||||
pendingImages = [];
|
||||
@@ -955,7 +1134,7 @@
|
||||
renderAttachmentChips();
|
||||
}
|
||||
renderSessions();
|
||||
renderMessages(startData.messages || []); // start 返回不自动播放
|
||||
renderMessages(startData.messages || []);
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
typingEl = appendThinkingIndicator();
|
||||
|
||||
@@ -974,11 +1153,11 @@
|
||||
const last = messages[messages.length - 1];
|
||||
if (last && last.role === "assistant") {
|
||||
const head = messages.slice(0, -1);
|
||||
renderMessages(head); // 历史消息不自动播放
|
||||
renderMessages(head);
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
await streamAssistantMessage(last); // 只有新消息流式渲染时自动播放
|
||||
await streamAssistantMessage(last);
|
||||
} else {
|
||||
renderMessages(messages); // 无 assistant 消息时不自动播放
|
||||
renderMessages(messages);
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -988,7 +1167,7 @@
|
||||
pendingImages = savedImages.slice();
|
||||
pendingAudio = savedAudio;
|
||||
renderAttachmentChips();
|
||||
renderMessages(priorMessages); // 回滚不自动播放
|
||||
renderMessages(priorMessages);
|
||||
}
|
||||
if (typingEl && typingEl.parentNode) typingEl.remove();
|
||||
alert(e.message || String(e));
|
||||
@@ -998,12 +1177,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
imageInput.addEventListener("change", () => {
|
||||
const files = Array.from(imageInput.files || []);
|
||||
pendingImages = pendingImages.concat(files);
|
||||
imageInput.value = "";
|
||||
renderAttachmentChips();
|
||||
textInput.focus();
|
||||
});
|
||||
|
||||
audioInput.addEventListener("change", () => {
|
||||
@@ -1011,6 +1190,7 @@
|
||||
pendingAudio = f || null;
|
||||
audioInput.value = "";
|
||||
renderAttachmentChips();
|
||||
textInput.focus();
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
@@ -1033,6 +1213,7 @@
|
||||
|
||||
sendBtn.addEventListener("click", sendChat);
|
||||
micBtn?.addEventListener("click", () => toggleMicRecording().catch((e) => alert(e.message || String(e))));
|
||||
autoInputCheck?.addEventListener("change", (ev) => toggleAutoInput(ev.target.checked));
|
||||
clearBtn.addEventListener("click", () => clearChat().catch((e) => alert(e.message)));
|
||||
newSessionBtn.addEventListener("click", () => newSession().catch((e) => alert(e.message)));
|
||||
deleteSessionBtn?.addEventListener("click", () => deleteSession().catch((e) => alert(e.message)));
|
||||
@@ -1087,14 +1268,13 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
loadBgPrefs();
|
||||
|
||||
/** 背景图原始像素(预加载成功后写入) */
|
||||
let bgNaturalW = 0;
|
||||
let bgNaturalH = 0;
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
let bgFitTimer = null;
|
||||
|
||||
loadBgPrefs();
|
||||
|
||||
function backgroundImageHref() {
|
||||
try {
|
||||
return new URL("image/background.jpg", window.location.href).href;
|
||||
@@ -1109,7 +1289,6 @@
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** 按「铺满视口」缩放(等价 cover,无黑边;景深略放大以减轻模糊露边) */
|
||||
function applyBackgroundFit() {
|
||||
if (!bgImageLayer || !bgNaturalW || !bgNaturalH) return;
|
||||
const vw = window.innerWidth;
|
||||
@@ -1131,7 +1310,6 @@
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/** 与 CSS 中 url("background.jpg") 一致;预加载得到尺寸后再写 background-size */
|
||||
function wireBackgroundImage() {
|
||||
if (!bgImageLayer) return;
|
||||
const href = backgroundImageHref();
|
||||
|
||||
@@ -115,6 +115,13 @@
|
||||
<option value="audio">纯语音</option>
|
||||
<option value="both">both</option>
|
||||
</select>
|
||||
<!-- 自动语音输入开关 -->
|
||||
<div class="composer-auto-input">
|
||||
<label class="auto-input-label" for="autoInputCheck">
|
||||
自动语音输入
|
||||
</label>
|
||||
<input type="checkbox" id="autoInputCheck" class="auto-input-check" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer-send">
|
||||
|
||||
+13
-52
@@ -15,7 +15,6 @@ from agent.utils.agent_utils import (
|
||||
CHAT_HISTORY_DB_FS,
|
||||
MULTI_INPUTS_ROOT,
|
||||
assistant_text,
|
||||
extract_result_from_invoke,
|
||||
)
|
||||
from agent.utils.image_utils import IMAGE_FILE_EXTENSIONS
|
||||
from agent.chatbot_agent import get_history
|
||||
@@ -98,72 +97,34 @@ def _load_ui_messages_from_db(session_id: str) -> list:
|
||||
if m.type == "human":
|
||||
rows.append({"role": "user", "content": _lc_content_for_ui_messages(m.content)})
|
||||
elif m.type in ("ai", "assistant"):
|
||||
text = assistant_text(m.content) or extract_result_from_invoke(m)
|
||||
text = assistant_text(m.content)
|
||||
rows.append({"role": "assistant", "content": text or "(空回复)"})
|
||||
return rows
|
||||
|
||||
|
||||
def _build_user_chat_content(query) -> str | list | None:
|
||||
"""根据上传文件扩展名直接生成多模态 content(与 LLM 输入格式一致)"""
|
||||
parts: list = []
|
||||
for filepath in query.get("files", []) or []:
|
||||
if filepath:
|
||||
if not filepath:
|
||||
continue
|
||||
ext = os.path.splitext(filepath)[1].lower()
|
||||
if ext in IMAGE_FILE_EXTENSIONS:
|
||||
parts.append({"type": "image_url", "image_url": {"url": filepath}})
|
||||
elif ext in _AUDIO_EXTENSIONS:
|
||||
parts.append({"type": "audio", "audio_path": filepath})
|
||||
else:
|
||||
parts.append({"path": filepath})
|
||||
text = (query.get("text") or "").strip()
|
||||
if text:
|
||||
parts.append({"type": "text", "text": text})
|
||||
if not parts:
|
||||
return None
|
||||
if len(parts) == 1 and isinstance(parts[0], dict) and parts[0].get("type") == "text":
|
||||
if len(parts) == 1 and parts[0].get("type") == "text":
|
||||
return parts[0]["text"]
|
||||
return parts
|
||||
|
||||
|
||||
def collect_media_paths_and_query_text(user_msg: dict) -> tuple[list[str], list[str], str]:
|
||||
raw = user_msg.get("content")
|
||||
text_segments: list[str] = []
|
||||
image_paths: list[str] = []
|
||||
audio_paths: list[str] = []
|
||||
|
||||
if isinstance(raw, str):
|
||||
t = raw.strip()
|
||||
if t:
|
||||
text_segments.append(t)
|
||||
return image_paths, audio_paths, "\n".join(text_segments).strip()
|
||||
|
||||
if not isinstance(raw, list):
|
||||
return image_paths, audio_paths, ""
|
||||
|
||||
for piece in raw:
|
||||
if isinstance(piece, str):
|
||||
t = piece.strip()
|
||||
if t:
|
||||
text_segments.append(t)
|
||||
continue
|
||||
if not isinstance(piece, dict):
|
||||
continue
|
||||
if piece.get("type") == "text":
|
||||
text_content = (piece.get("text") or "").strip()
|
||||
if text_content:
|
||||
text_segments.append(text_content)
|
||||
continue
|
||||
file_path = piece.get("path") or (piece.get("file") or {}).get("path")
|
||||
if piece.get("type") == "image_url":
|
||||
file_path = file_path or (piece.get("image_url") or {}).get("url")
|
||||
if piece.get("type") == "audio":
|
||||
file_path = file_path or piece.get("audio_path")
|
||||
if not file_path:
|
||||
continue
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext in IMAGE_FILE_EXTENSIONS:
|
||||
image_paths.append(file_path)
|
||||
elif ext in _AUDIO_EXTENSIONS:
|
||||
audio_paths.append(file_path)
|
||||
else:
|
||||
text_segments.append(f"[用户上传了附件文件: {os.path.basename(file_path)}]")
|
||||
|
||||
return image_paths, audio_paths, "\n".join(text_segments).strip()
|
||||
|
||||
|
||||
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)
|
||||
@@ -304,7 +265,7 @@ def _serialize_assistant_entry(assistant_msg: dict | None) -> dict:
|
||||
if isinstance(ac, str):
|
||||
atext = ac
|
||||
else:
|
||||
atext = assistant_text(ac) or extract_result_from_invoke(assistant_msg) or "(空回复)"
|
||||
atext = assistant_text(ac) or "(空回复)"
|
||||
audio_relpath = None
|
||||
audio = meta.get("audio")
|
||||
if isinstance(audio, dict) and audio.get("path"):
|
||||
@@ -458,4 +419,4 @@ def clear_ui_and_manifest(session_id: str):
|
||||
os.makedirs(os.path.dirname(mp), exist_ok=True)
|
||||
with open(mp, "w", encoding="utf-8") as f:
|
||||
json.dump({"version": 1, "turns": []}, f, ensure_ascii=False, indent=2)
|
||||
return [], {"text": "", "files": []}
|
||||
return [], {"text": "", "files": []}
|
||||
Reference in new issue
Block a user