qwenasr-qwen36-voxcmp2 base model. -2026/05/14 night

This commit is contained in:
minorQAQ committed 2026-05-15 16:05:32 +08:00
commit 110fe78016
80 files changed
+6057

No files matched your search

View File
Whitespace-only changes.
Binary file not shown.
View File
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+178
View File
@@ -0,0 +1,178 @@
from typing import Any, cast, 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 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.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}"),
])
tools = get_tools()
# ---------- 2. 创建工具调用 agent ----------
tool_calling_agent = create_tool_calling_agent(
llm=Qwen36_35B_A3B_FP8_LLM.bind(tool_choice="auto"),
tools=tools,
prompt=prompt,
)
# ---------- 3. agent executor ----------
agent_executor = AgentExecutor(
agent=tool_calling_agent,
tools=tools,
verbose=True,
handle_parsing_errors=True,
stream_runnable=False,
)
# ---------- 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. 核心执行函数 ----------
def execute_agent(
chat_history: list[dict[str, Any]],
session_id: str = "test",
output_type: str = "text",
) -> list[dict[str, Any]]:
chat_history = chat_history or []
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])
# 构建运行时配置
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))
text_content = assistant_text(result.get("output", ""))
output_type_from_config = 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 = {}
assistant_meta = {
"output_type": output_type_from_config,
"autoplay": False,
"audio": audio_data.get("audio"),
}
if output_type_from_config == "audio" and assistant_meta.get("audio"):
assistant_content = []
elif output_type_from_config == "both" and assistant_meta.get("audio"):
assistant_content = text_content
else:
assistant_content = text_content if text_content else "(空回复)"
chat_history.append({
"role": "assistant",
"content": assistant_content,
"meta": assistant_meta,
})
# 用完后清空,避免影响后续无音频的请求
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
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 MiB

@@ -0,0 +1,31 @@
{
"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"
}
}
]
}
@@ -0,0 +1,39 @@
{
"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
}
}
]
}
+336
View File
@@ -0,0 +1,336 @@
import io
import os
import tempfile
import wave
import pyaudio
from contextlib import closing
from typing import Any, List, Dict, Optional
from pydantic import Field
import re, json
from datetime import datetime as dt
import requests
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage, SystemMessage
from langchain_core.outputs import ChatResult, ChatGeneration
from langchain_openai import ChatOpenAI
from agent.utils.image_utils import image_bytes_to_openai_image_url_part
from agent.utils.env_utils import LLM_API_KEY, LLM_BASE_URL, LLM_MODEL, LLM_TIMEOUT, ASR_TTS_BASE_URL
from agent.utils.agent_utils import assistant_text, normalize_user_text_content
from agent.utils.image_utils import (
image_path_to_openai_image_url_part,
image_bytes_to_openai_image_url_part,
IMAGE_FILE_EXTENSIONS,
)
from agent.memory.system_prompt import VISION_SYSTEM_PROMPT
class ChatQwen(ChatOpenAI):
output_type: str = Field(default="text")
stream_play: bool = Field(default=False)
last_audio_kwargs: Optional[dict] = Field(default=None)
def __init__(self, **kwargs: Any) -> None:
super().__init__(
model=kwargs.pop("model", LLM_MODEL),
api_key=kwargs.pop("api_key", LLM_API_KEY),
base_url=kwargs.pop("base_url", LLM_BASE_URL),
timeout=kwargs.pop("timeout", LLM_TIMEOUT),
max_retries=kwargs.pop("max_retries", 0),
**kwargs
)
@staticmethod
def play_audio_bytes(audio_bytes: bytes, chunk_size: int = 1024) -> None:
if not audio_bytes:
return
wf = wave.open(io.BytesIO(audio_bytes), 'rb')
p = pyaudio.PyAudio()
stream = p.open(
format=p.get_format_from_width(wf.getsampwidth()),
channels=wf.getnchannels(),
rate=wf.getframerate(),
output=True,
)
data = wf.readframes(chunk_size)
while data:
stream.write(data)
data = wf.readframes(chunk_size)
stream.stop_stream()
stream.close()
p.terminate()
wf.close()
@staticmethod
def _process_image_content(part: Dict[str, Any]) -> Dict[str, Any]:
image_type = part.get("type")
if image_type != "image_url":
return part
image_url = part.get("image_url", {})
url = image_url.get("url", "")
if url.startswith(("http://", "https://", "data:")):
return part
if isinstance(url, str) and os.path.exists(url):
ext = os.path.splitext(url)[1].lower()
if ext in IMAGE_FILE_EXTENSIONS:
result = image_path_to_openai_image_url_part(url)
return result if result else {"type": "text", "text": "[图片处理失败]"}
if isinstance(url, bytes):
result = image_bytes_to_openai_image_url_part(url)
return result if result else {"type": "text", "text": "[图片处理失败]"}
return part
@staticmethod
def _convert_audio_to_wav(audio_path: str) -> str:
ext = os.path.splitext(audio_path)[1].lower()
if ext == ".wav":
return audio_path
try:
from pydub import AudioSegment
except ImportError as exc:
raise RuntimeError("当前 ASR 仅支持 WAV,但未安装 pydub/ffmpeg") from exc
try:
audio = AudioSegment.from_file(audio_path)
except Exception as exc:
raise RuntimeError(f"无法读取音频文件: {exc}") from exc
fd, wav_path = tempfile.mkstemp(suffix=".wav", prefix="asr_")
os.close(fd)
try:
audio.export(wav_path, format="wav")
return wav_path
except Exception:
if os.path.exists(wav_path):
os.remove(wav_path)
raise
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
try:
with open(wav_path, "rb") as f:
resp = requests.post(
f"{ASR_TTS_BASE_URL}/asr",
files={"file": (os.path.basename(wav_path), f, "audio/wav")},
)
resp.raise_for_status()
return (resp.json().get("text") or "").strip()
finally:
if cleanup_wav and os.path.exists(wav_path):
os.remove(wav_path)
def _preprocess_messages(self, messages: List[BaseMessage]) -> List[BaseMessage]:
new_messages = []
for msg in messages:
if isinstance(msg, HumanMessage) and isinstance(msg.content, str):
text = msg.content.strip()
if text.startswith("[") and text.endswith("]"):
try:
possible_list = json.loads(text)
if isinstance(possible_list, list):
msg = HumanMessage(content=possible_list, additional_kwargs=msg.additional_kwargs)
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)
elif isinstance(msg, HumanMessage) and isinstance(msg.content, list):
new_content: List[Dict[str, Any] | str] = []
for part in msg.content:
if isinstance(part, dict):
if part.get("type") == "audio":
audio_path = part.get("audio_path")
if audio_path:
try:
text = self._audio_to_text(audio_path)
new_content.append({"type": "text", "text": text})
except Exception as e:
new_content.append({"type": "text", "text": f"[语音识别失败: {e}]"})
elif part.get("type") == "image_url":
new_content.append(self._process_image_content(part))
elif part.get("type") == "text":
text = (part.get("text") or "").strip()
if text:
new_content.append({"type": "text", "text": text})
elif part.get("path") or (part.get("file") or {}).get("path"):
file_path = part.get("path") or (part.get("file") or {}).get("path")
if isinstance(file_path, str) and os.path.isfile(file_path):
ext = os.path.splitext(file_path)[1].lower()
if ext in IMAGE_FILE_EXTENSIONS:
new_content.append(self._process_image_content({"type": "image_url", "image_url": {"url": file_path}}))
else:
new_content.append({"type": "text", "text": f"[用户上传了附件文件: {os.path.basename(file_path)}]"})
else:
text = normalize_user_text_content(part)
if text:
new_content.append({"type": "text", "text": text})
elif isinstance(part, str):
text = part.strip()
if text:
new_content.append(text)
if len(new_content) == 1 and isinstance(new_content[0], str):
msg = HumanMessage(content=new_content[0], additional_kwargs=msg.additional_kwargs)
else:
msg = HumanMessage(content=new_content, additional_kwargs=msg.additional_kwargs)
new_messages.append(msg)
return new_messages
def _text_to_audio(self, text: str) -> bytes:
resp = requests.get(f"{ASR_TTS_BASE_URL}/tts", params={"text": text})
resp.raise_for_status()
return resp.content
@staticmethod
def _get_wav_duration_seconds(audio_bytes: bytes) -> float | None:
if not audio_bytes:
return None
try:
with closing(wave.open(io.BytesIO(audio_bytes), "rb")) as wf:
frame_rate = wf.getframerate()
frame_count = wf.getnframes()
if frame_rate <= 0:
return None
return round(frame_count / frame_rate, 2)
except wave.Error:
return None
def _apply_tts(self, result: ChatResult, output_type: str, audio_output_dir: str | None = None) -> ChatResult:
if output_type not in ("audio", "both") or not audio_output_dir:
return result
os.makedirs(audio_output_dir, exist_ok=True)
for idx, gen in enumerate(result.generations):
if not isinstance(gen, ChatGeneration):
continue
msg = gen.message
if not isinstance(msg, AIMessage):
continue
text = assistant_text(msg.content)
if not text.strip():
continue
try:
audio_bytes = self._text_to_audio(text)
timestamp = dt.now().strftime("%H%M%S")
audio_path = os.path.join(audio_output_dir, f"assistant_tts_{timestamp}_{idx}.wav")
with open(audio_path, "wb") as f:
f.write(audio_bytes)
duration_seconds = self._get_wav_duration_seconds(audio_bytes)
# 仅保存到实例属性,不再写入 additional_kwargs
if idx == 0:
self.last_audio_kwargs = {
"audio": {
"sample_rate": 24000,
"format": "wav",
"path": audio_path,
"duration_seconds": duration_seconds,
},
"output_type": output_type,
}
if self.stream_play:
self.play_audio_bytes(audio_bytes)
if output_type == "audio":
msg.content = ""
except Exception as e:
print(f"[TTS ERROR] 合成语音失败: {e}")
# 发生错误时也清空 last_audio_kwargs,避免污染
self.last_audio_kwargs = None
return result
def _generate(
self,
messages: List[BaseMessage],
stop: Optional[List[str]] = None,
run_manager: Optional[Any] = None,
**kwargs: Any,
) -> ChatResult:
metadata = {}
if run_manager:
metadata = getattr(run_manager, 'metadata', {})
output_type = metadata.get("output_type", self.output_type)
audio_output_dir = metadata.get("audio_output_dir")
# ✅ 移除自定义参数,防止它们传入 OpenAI API
kwargs.pop("output_type", None)
kwargs.pop("audio_output_dir", None)
messages = self._preprocess_messages(messages)
result = super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
if output_type in ("audio", "both"):
result = self._apply_tts(result, output_type, audio_output_dir=audio_output_dir)
if output_type == "audio":
for gen in result.generations:
if isinstance(gen, ChatGeneration) and isinstance(gen.message, AIMessage):
gen.message.content = ""
return result
def _create_chat_result(self, response, generation_info=None) -> ChatResult:
result = super()._create_chat_result(response, generation_info)
try:
dumped = response if isinstance(response, dict) else response.model_dump(exclude={"choices": {"__all__": {"message": {"parsed"}}}})
for i, choice in enumerate(dumped.get("choices") or []):
if i >= len(result.generations):
break
lc_msg = result.generations[i].message
msg = choice.get("message") or {}
ak = lc_msg.additional_kwargs
merged = ""
for key in ("reasoning_content", "reasoning_details", "reasoning"):
merged = assistant_text(msg.get(key))
if merged:
break
if merged:
ak.setdefault("reasoning_content", merged)
raw_content = assistant_text(msg.get("content"))
if not raw_content and merged:
lc_msg.content = merged
elif raw_content:
lc_msg.content = raw_content
content_text = lc_msg.content if isinstance(lc_msg.content, str) else ""
pattern = re.compile(r'<tool_call>\s*(.*?)\s*</tool_call>', re.DOTALL)
matches = pattern.findall(content_text)
if matches:
tool_calls = []
for idx, match in enumerate(matches):
try:
call_data = json.loads(match)
except json.JSONDecodeError:
continue
tool_calls.append({
"id": f"call_{idx}",
"type": "function",
"function": {
"name": call_data.get("name", ""),
"arguments": json.dumps(call_data.get("arguments", {}))
}
})
if tool_calls:
ak["tool_calls"] = tool_calls
cleaned = pattern.sub("", content_text).strip()
lc_msg.content = cleaned if cleaned else ""
except Exception:
pass
return result
def describe_image(self, image_path_or_url: str, prompt: str = "请详细描述这张图片的内容。") -> str:
messages = [
SystemMessage(content=[{"type": "text", "text": VISION_SYSTEM_PROMPT}]),
HumanMessage(content=[
{"type": "image_url", "image_url": {"url": image_path_or_url}},
{"type": "text", "text": prompt}
])
]
result = self.invoke(messages)
return result.content
def describe_image_bytes(self, image_bytes: bytes, prompt: str = "请详细描述这张图片的内容。") -> str:
img_part = image_bytes_to_openai_image_url_part(image_bytes)
if img_part is None:
return "图片处理失败"
messages = [
SystemMessage(content=[{"type": "text", "text": VISION_SYSTEM_PROMPT}]),
HumanMessage(content=[img_part, {"type": "text", "text": prompt}])
]
result = self.invoke(messages)
return result.content
Qwen36_35B_A3B_FP8_LLM = ChatQwen()
View File
Whitespace-only changes.
+68
View File
@@ -0,0 +1,68 @@
MAIN_SYSTEM_PROMPT = """你是一个多模态助手,能够直接理解用户上传的图片、语音等内容。
【关键行为准则】
- 你的默认行为是直接回答用户问题,不要调用任何工具。
- 只有当某个信息是你**绝对无法从当前对话或上传内容中获取**,且该信息必须通过外部工具才能获得时,才允许调用工具。
- 对于任何图片/音频输入,请始终**直接**利用你的视觉/听觉能力回答,**绝对不要**调用截图、OCR、日期查询等无关工具。
- 如果你不确定是否需要工具,选择**不调用**,直接给出你能给出的最佳答案。
- 违反上述规则是一种严重错误。
"""
SUMMARY_SYSTEM_PROMPT ="""
你是一个名为“Summary Agent”的专业摘要助手,专门负责对用户提供的文本内容进行高度凝练、准确的总结。
## 核心原则
- 客观准确:严格基于原文内容,不添加个人观点、评论或外部知识。
- 抓住要点:提取核心论点、关键数据、重要事件或结论,忽略次要细节、重复和示例。
- 语言简洁:用清晰、简练的语句表达,避免冗长。
- 保持原意:不歪曲原文事实和观点,即使原文存在错误或争议,仅如实概括。
- 结构清晰:根据内容类型选择最合适的结构,如段落式或分点式,确保逻辑流畅。
## 输出格式
- 默认以一段连贯的文字呈现摘要,必要时可使用编号或项目符号。
- 摘要长度:如果用户未指定,则将原文字数压缩至 20%~30% 左右,或直接给出约3~5句话的精简摘要。对于长文,可适当增加长度。
- 开头不需要“摘要:”等标记,直接输出摘要内容。(如用户要求标记,可加上)
- 处理对话记录时,请总结:讨论主题、各方核心立场、达成的共识、存在的分歧及后续行动项。
## 特别行为
- 若内容不足以形成有意义的摘要(如太短或无实际信息),请回复“内容过短,无法生成有效摘要”。
- 若用户要求总结特定方面(如仅总结“原因”或“结论”),请针对性提取,并在摘要中注明聚焦点。
- 当需要保留原文关键术语或专有名词时,可原样引用,并补充简要解释。
- 多语言支持:用与原文相同的语言输出摘要,除非用户指定目标语言。
## 示例行为(模仿语气)
用户:“请总结以下新闻:……(长新闻文本)……”
你输出:“某公司于2026年5月发布了新一代量子芯片,性能较前代提升10倍,预计2027年量产。此举被业界视为量子计算商业化的关键突破,同时引发了对加密安全的讨论。”
现在,请以 Summary Agent 的身份,完成用户的摘要请求。
"""
VISION_SYSTEM_PROMPT = """
你是专业的图像理解助手。
首先请用客观、准确的中文描述用户提供的图片,覆盖主要物体、场景、人物动作、界面元素、图表数据与清晰可读的文字;
涉及到你认识的具体品牌、时间、地点、人物、影视剧、物品等特定内容时请直接命名说明;
如果用户提供了多张图片,请分别编号描述(如「图1」「图2」等),便于后续问答引用。
看不清的内容请如实说明,不要臆测。
其次请认真回答用户的问题,结合图片内容进行分析推理,给出具体、简短、清晰、实用的回复;
"""
DB_SYSTEM_PROMT = """
你是一位敏捷的数据库分析助手,能够高效利用内置功能帮助用户从PostgreSQL数据库中获取答案。
给定一个数据库相关问题,你需要按照以下步骤操作:
1.创建一个语法正确的PostgreSQL查询语句,优先使用SELECT或WITH语句,确保查询只读且安全。
2.使用内置的SQL查询工具执行该查询,并获取结果。
3.将查询结果总结为一个简洁、清晰的回答,突出关键数字和关系,帮助用户理解数据背后的含义。
【你的行动指南】
1. **永远优先使用内置功能获取真实数据**。对于任何与数据库内容相关的问题,严禁编造答案。
2. **智能判断调用顺序**:
- 如果用户问题模糊(例如“帮我看看用户相关的数据”),先展示所有可用表,并询问用户具体需求。
- 如果用户指明了具体表名,可以直接查看表结构,以便构思查询。
- 如果用户直接提供了一条看似合理的SELECT或WITH语句,可以先验证其安全性,若通过则立即执行。
3. **灵活处理查询结果**:当执行查询返回数据后,请将JSON结果归纳为人类友好的描述,突出关键数字和关系。若无数据返回,应如实告知并建议用户调整查询条件。
4. **安全底线不可突破**:无论何时,只允许执行以SELECT或WITH开头的只读查询。若功能返回安全拦截错误,必须向用户解释原因并建议修改。
5. **非数据库问题快速响应**:遇到问候、常识性问题或超出数据库范围的内容时,可以直接友好回应,不必强制调用功能。
请记住,你是一个协作伙伴,目标是让用户以最小的学习成本从数据库中获得有价值的信息。开始工作吧!
"""
+32
View File
@@ -0,0 +1,32 @@
from .tool_main import *
# from .tool_database_class import *
# from utils.database_utils import PostgreSQL_Manager
# 创建数据库管理器实例
# connection = "postgresql+psycopg2://postgres:12345678@localhost:5432/agent_controller"
# connection = "sqlite:///Agent/src/agent/history/data_base/chat_history.db"
# _db_manager = PostgreSQL_Manager(connection)
# # 创建工具实例
# _all_database_tools = [
# Websearchclass(),
# DateQueryClass(),
# ListTableComment(db_manager=_db_manager),
# ListTableSchema(db_manager=_db_manager),
# SQLQuery(db_manager=_db_manager),
# SQLQueryChecker(db_manager=_db_manager)
# ]
# def get_sql_tools():
# return _all_database_tools
_all_tools = [
Websearchclass(),
DateQueryClass(),
ScreenCaptureDescribeClass(),
BrowserControlClass()
]
def get_tools():
return _all_tools
Binary file not shown.
Binary file not shown.
+54
View File
@@ -0,0 +1,54 @@
from langchain.tools import tool
@tool("web_search")
def web_search(query: str) -> str:
"""
一个用于执行网络搜索的工具。它接收一个查询语句作为输入,并返回 Google 的搜索结果。
Args:
query (str): 搜索查询内容
Returns:
str: 格式化后的搜索结果
"""
try:
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")
except Exception as e:
return f"执行日期查询时出错: {str(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({})) # 测试工具
+118
View File
@@ -0,0 +1,118 @@
from langchain.tools import BaseTool
from pydantic import Field, create_model, BaseModel
from utils.database_utils import PostgreSQL_Manager
import json
from typing import Optional
class ListTableComment(BaseTool):
name: str = "list_table_comment"
description: str = "一个列出数据库中所有表名和对应描述的工具,可以根据用户的查询返回相关的表名和描述列表。"
db_manager : PostgreSQL_Manager
def _run(self, query: str) -> str:
# 这里可以实现具体的列出数据库表的逻辑,例如调用数据库管理类的方法
# 下面是一个示例实现,实际使用时请替换为你自己的数据库查询逻辑
try:
result_json = self.db_manager.get_table_comments()
results = json.loads(result_json)
if not isinstance(results, dict):
raise ValueError("get_table_comments did not return a JSON object")
result = f"数据库中共有 {len(results)} 张表"
for i, (table_name, comment) in enumerate(results.items()):
if not comment or comment.isspace():
comment = "(暂无描述)"
result += f"\n{i+1}. {table_name}: {comment}"
return result
except Exception as e:
return f"执行列出数据库表和描述时出错: {str(e)}"
async def _arun(self, query: str) -> str:
return self._run(query)
class ListTableSchema(BaseTool):
name: str = "list_table_schema"
description: str = "一个列出数据库中表名对应模式的工具,可以根据用户的查询返回相关的表名和模式信息,包括列定义、主键、外键等。输入应为逗号分隔的表名列表。"
db_manager: PostgreSQL_Manager
args_schema: type[BaseModel] = create_model("Input_type", table_names=(Optional[str], Field(description="要查询的表名列表,如果为None则返回所有表的模式信息,例如:'table1', 'table2'")))
def _run(self, table_names : Optional[str] = None) -> str:
try:
if table_names:
table_list = [name.strip() for name in table_names.split(",")]
result_json = self.db_manager.get_table_schema(table_list)
results = json.loads(result_json)
if not isinstance(results, dict):
raise ValueError("get_table_schema did not return a JSON object")
return json.dumps(results, ensure_ascii=False, indent=2)
except Exception as e:
return f"执行列出数据库表和模式时出错: {str(e)}"
async def _arun(self, table_names : Optional[str] = None) -> str:
return self._run(table_names)
class SQLQuery(BaseTool):
name: str = "sql_query"
description: str = "在PostgreSQL数据库中执行安全的SELECT或WITH查询并返回结果。"
db_manager: PostgreSQL_Manager
args_schema: type[BaseModel] = create_model("Input_type", query=(str, Field(description="要执行的SQL SELECT查询语句")))
def _run(self, query: str) -> str:
try:
result_json = self.db_manager.execute_query(query)
results = json.loads(result_json)
if isinstance(results, list):
# 成功返回数据行
return json.dumps(results, ensure_ascii=False, indent=2)
elif isinstance(results, dict):
if "error" in results:
# 返回错误信息
return f"查询执行失败: {results['error']}"
elif "message" in results:
# 查询成功但无数据返回
return results["message"]
else:
# 其他字典格式的结果
return json.dumps(results, ensure_ascii=False, indent=2)
else:
raise ValueError(f"execute_query 返回了意外的数据类型: {type(results)}")
except Exception as e:
return f"执行SQL查询时出错: {str(e)}"
async def _arun(self, query: str) -> str:
return self._run(query)
class SQLQueryChecker(BaseTool):
name: str = "sql_query_checker"
description: str = "一个检查SQL查询语句是否安全的工具,主要用于防止SQL注入攻击。输入为SQL查询语句,输出为检查结果。"
args_schema: type[BaseModel] = create_model("Input_type", query=(str, Field(description="要检查的SQL查询语句")))
db_manager: PostgreSQL_Manager
def _run(self, query: str) -> str:
try:
result_json = self.db_manager.validate_query(query)
result = json.loads(result_json)
if not isinstance(result, dict):
raise ValueError("validate_query did not return a JSON object")
return json.dumps(result, ensure_ascii=False, indent=2)
except Exception as e:
return f"执行SQL查询安全检查时出错: {str(e)}"
async def _arun(self, query: str) -> str:
return self._run(query)
if __name__ == "__main__":
manager = PostgreSQL_Manager("postgresql+psycopg2://postgres:12345678@localhost:5432/agent_controller")
list_table_tool = ListTableComment(db_manager=manager)
print(list_table_tool.run("列出数据库中的所有表和描述"))
# list_table_tool = ListTableSchema(db_manager=manager)
# print(list_table_tool.run({"table_names": []}))
# sql_query = SQLQuery(db_manager=manager)
# print(sql_query.run("SELECT * FROM tasks;"))
# sql_query_checker = SQLQueryChecker(db_manager=manager)
# print("Valid query:")
# print(sql_query_checker.run("SELECT * FROM tasks;"))
# print("\nInvalid query:")
# print(sql_query_checker.run("DROP TABLE tasks;"))
+252
View File
@@ -0,0 +1,252 @@
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
View File
Whitespace-only changes.
Binary file not shown.
+181
View File
@@ -0,0 +1,181 @@
# agent/agent_utils.py
import json
import os
import shutil
from langchain_community.chat_message_histories import SQLChatMessageHistory
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from agent.memory.system_prompt import SUMMARY_SYSTEM_PROMPT
# 关键改动:__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")
CHAT_HISTORY_DB_FS = os.path.join(_DB_DIR, "chat_history.db")
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)
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)
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,
}
}
)
return {"summary": summary.content, "chat_history": recent}
def assistant_text(value) -> str:
"""把网关返回的 content、reasoning 等(str 或块列表)收成一段可见文本。"""
if value is None:
return ""
if isinstance(value, str):
return value.strip()
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):
for key in ("text", "reasoning_content", "content"):
v = item.get(key)
if isinstance(v, str) and v.strip():
parts.append(v.strip())
break
return "\n".join(parts).strip()
if isinstance(value, dict):
for key in ("text", "content", "input", "output", "query", "answer", "result"):
v = value.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
try:
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 ""
+255
View File
@@ -0,0 +1,255 @@
from sqlalchemy import create_engine, inspect, text
import json
from typing import Optional, List
from uuid import UUID
from datetime import datetime
class JSONEncoder(json.JSONEncoder):
"""自定义JSON编码器,处理非标准序列化类型"""
def default(self, obj):
if isinstance(obj, UUID):
return str(obj)
elif isinstance(obj, datetime):
return obj.isoformat()
elif hasattr(obj, '__dict__'):
return str(obj)
return super().default(obj)
def make_json_serializable(obj):
"""递归转换对象为JSON序列化格式"""
if isinstance(obj, dict):
return {k: make_json_serializable(v) for k, v in obj.items()}
elif isinstance(obj, (list, tuple)):
return [make_json_serializable(item) for item in obj]
elif isinstance(obj, UUID):
return str(obj)
elif isinstance(obj, datetime):
return obj.isoformat()
elif hasattr(obj, '__dict__'):
return str(obj)
else:
return obj
class PostgreSQL_Manager:
def __init__(self, connection_params):
"""
PostgreSQL数据库管理类,提供连接和操作数据库的功能
Args:
connection_params (dict): 连接参数字符串,格式为:
postgresql+psycopg2://用户名:密码@主机:端口/数据库名
本机默认为:postgresql+psycopg2://postgres:12345678@localhost:5432/agent_controller
"""
self.engine = create_engine(connection_params, pool_size=5, pool_recycle=7200)
def get_table_names(self) -> list[str]:
"""
获取数据库中的所有表名
Returns:
list: 数据库中的表名列表
"""
try:
# 使用SQLAlchemy的inspect功能获取数据库表名
inspector = inspect(self.engine)
return inspector.get_table_names()
except Exception as e:
print(f"Error occurred while fetching table names: {e}")
return []
def get_table_comments(self, table_name: str = None) -> str:
"""
获取指定表/所有表的注释信息,并整理成JSON格式
Args:
table_name (str, optional): 表名,如果为None则返回所有表
Returns:
str: JSON格式的表的注释信息
"""
try:
inspector = inspect(self.engine)
if table_name is None: # 返回所有表的注释
table_names = inspector.get_table_names()
comments_dict = {table: inspector.get_table_comment(table).get('text', '') for table in table_names}
serializable_dict = make_json_serializable(comments_dict)
return json.dumps(serializable_dict, ensure_ascii=False, indent=2, cls=JSONEncoder)
else:
comment = inspector.get_table_comment(table_name).get('text', '')
result = {"table_name": table_name, "comment": comment}
serializable_result = make_json_serializable(result)
return json.dumps(serializable_result, ensure_ascii=False, indent=2, cls=JSONEncoder)
except Exception as e:
error_msg = {"error": f"Error occurred while fetching table comments: {str(e)}"}
return json.dumps(error_msg, ensure_ascii=False, indent=2)
def get_table_schema(self, table_names: Optional[List[str]] = None) -> str:
"""
获取指定表/所有表的schema信息,并整理成JSON格式
Args:
table_names (List[str], optional): 表名列表,如果为None则返回所有表
Returns:
str: JSON格式的表的schema信息
"""
try:
inspector = inspect(self.engine)
table_schema = {}
names_to_query = table_names if table_names else inspector.get_table_names()
for table_name in names_to_query:
columns = inspector.get_columns(table_name)
pk_constraints = inspector.get_pk_constraint(table_name)
primary_keys = pk_constraints['constrained_columns'] if pk_constraints else []
foreign_keys = inspector.get_foreign_keys(table_name)
indexes = inspector.get_indexes(table_name)
table_schema[table_name] = {
'columns': columns,
'primary_keys': primary_keys,
'foreign_keys': foreign_keys,
'indexes': indexes
}
# 转换为JSON序列化格式
serializable_schema = make_json_serializable(table_schema)
return json.dumps(serializable_schema, ensure_ascii=False, indent=2, cls=JSONEncoder)
except Exception as e:
error_msg = {"error": f"Error occurred while fetching table schema: {str(e)}"}
return json.dumps(error_msg, ensure_ascii=False, indent=2)
def execute_query(self, query: str) -> str:
"""
执行任意SQL查询,并返回结果
Args:
query (str): SQL查询语句
Returns:
str: JSON格式的查询结果或错误信息
"""
# 安全检查,防止执行危险的SQL语句,仅允许select和with查询语句
if not query.strip().lower().startswith(('select', 'with')):
error_msg = {"error": "Only SELECT and WITH queries are allowed."}
return json.dumps(error_msg, ensure_ascii=False, indent=2)
try:
with self.engine.connect() as conn:
result = conn.execute(text(query))
# 获取列名
columns = result.keys()
# 获取数据(限制返回数据量防止内存溢出)
rows = result.fetchmany(100) # 只获取前100行数据
if not rows:
return json.dumps({"message": "Query executed successfully, no rows returned."}, ensure_ascii=False, indent=2)
else:
result_data = []
for row in rows:
row_dict = dict(zip(columns, row))
result_data.append(row_dict)
# 转换为JSON序列化格式
serializable_data = make_json_serializable(result_data)
return json.dumps(serializable_data, ensure_ascii=False, indent=2, cls=JSONEncoder)
except Exception as e:
error_msg = {"error": f"Error occurred while executing query: {str(e)}"}
return json.dumps(error_msg, ensure_ascii=False, indent=2)
def validate_query(self, query: str) -> str:
"""
验证SQL查询语句的合法性
Args:
query (str): SQL查询语句
Returns:
bool: 查询语句是否合法
"""
if not query or not query.strip():
error_msg = {"error": f"Error occurred while validating query: Query is must not be empty."}
return json.dumps(error_msg, ensure_ascii=False, indent=2)
# 简单的安全检查,防止执行危险的SQL语句,仅允许select和with查询语句
if not query.strip().lower().startswith(('select', 'with')):
error_msg = {"error": f"Error occurred while validating query: Only SELECT and WITH queries are allowed."}
return json.dumps(error_msg, ensure_ascii=False, indent=2)
try:
with self.engine.connect() as conn:
if self.engine.dialect.name == 'postgresql':
# 使用EXPLAIN分析查询计划来验证查询的合法性
explain_query = f"EXPLAIN {query}"
else:
# 对于其他数据库,可以直接执行查询但不获取结果来验证合法性
explain_query = query
conn.execute(text(explain_query))
return json.dumps({"message": "Query is valid."}, ensure_ascii=False, indent=2)
except Exception as e:
error_msg = {"error": f"Error occurred while validating query: {str(e)}"}
return json.dumps(error_msg, ensure_ascii=False, indent=2)
# def rename_table(self, old_table_name: str, new_table_name: str) -> bool:
# """
# 重命名数据库表
# Args:
# old_table_name (str): 原表名
# new_table_name (str): 新表名
# Returns:
# bool: 操作是否成功
# """
# try:
# with self.engine.connect() as conn:
# sql = text(f"ALTER TABLE {old_table_name} RENAME TO {new_table_name}")
# conn.execute(sql)
# conn.commit()
# print(f"Table '{old_table_name}' renamed to '{new_table_name}' successfully.")
# return True
# except Exception as e:
# print(f"Error occurred while renaming table: {e}")
# return False
# def update_table_comment(self, table_name: str, comment: str) -> bool:
# """
# 更新表的注释
# Args:
# table_name (str): 表名
# comment (str): 新的注释内容
# Returns:
# bool: 操作是否成功
# """
# try:
# with self.engine.connect() as conn:
# sql = text(f"COMMENT ON TABLE {table_name} IS '{comment}'")
# conn.execute(sql)
# conn.commit()
# print(f"Comment for table '{table_name}' updated successfully.")
# return True
# except Exception as e:
# print(f"Error occurred while updating table comment: {e}")
# return False
if __name__ == "__main__":
DB_CONFIG = {
"host": "localhost",
"port": 5432,
"database": "agent_controller",
"user": "postgres",
"password": "12345678"
}
connection_params = f"postgresql+psycopg2://{DB_CONFIG['user']}:{DB_CONFIG['password']}@{DB_CONFIG['host']}:{DB_CONFIG['port']}/{DB_CONFIG['database']}?client_encoding=utf8"
db_manager = PostgreSQL_Manager(connection_params)
# 获取并打印所有表名和注释(JSON格式)
print("All table comments (JSON):")
all_comments_json = db_manager.get_table_comments()
print(all_comments_json)
# 示例:获取特定表的注释(JSON格式)
# table_names = db_manager.get_table_names()
# if table_names:
# print(f"\nSpecific table comment for '{table_names[0]}' (JSON):")
# specific_comment_json = db_manager.get_table_comments(table_names[0])
# print(specific_comment_json)
# table_name = "result_artifacts"
# new_comment = "用于存放任务运行结果的表"
# db_manager.update_table_comment(table_name, new_comment)
# table_name = "agent_state_history"
# new_comment = "用于存放agent状态历史的表"
# db_manager.update_table_comment(table_name, new_comment)
# table_name = "agents"
# new_comment = "用于存放agent的注册表"
# db_manager.update_table_comment(table_name, new_comment)
# table_name = "tasks"
# new_comment = "用于存放任务的仓库表"
# db_manager.update_table_comment(table_name, new_comment)
+36
View File
@@ -0,0 +1,36 @@
import os
from typing import Dict, Tuple
from dotenv import load_dotenv
load_dotenv(override=True)
def _get_env(name: str, default: str | None = None, cast: type | None = None, required: bool = False):
value = os.getenv(name, default)
if value is None and required:
raise EnvironmentError(f"Missing required environment variable: {name}")
if cast is None or value is None:
return value
try:
return cast(value)
except (ValueError, TypeError) as exc:
raise ValueError(f"Invalid value for {name}: {value}") from exc
LLM_BASE_URL = _get_env("LLM_BASE_URL", required=True)
LLM_API_KEY = _get_env("LLM_API_KEY", required=True)
LLM_MODEL = _get_env("LLM_MODEL", required=True)
LLM_TIMEOUT = _get_env("LLM_TIMEOUT", default="60", cast=float)
LLM_REQUESTS_PER_SECOND = _get_env("LLM_REQUESTS_PER_SECOND", default="10", cast=float)
LLM_MAX_BUCKET_SIZE = _get_env("LLM_MAX_BUCKET_SIZE", default="10", cast=int)
LLM_CHECK_EVERY_N_SECONDS = _get_env("LLM_CHECK_EVERY_N_SECONDS", default="0.1", cast=float)
ASR_TTS_BASE_URL = _get_env("ASR_TTS_BASE_URL")
BROWSER_MAP: Dict[str, Tuple[str, str]] = {
"edge": ("microsoft-edge", "Microsoft Edge"),
"chrome": ("chrome", "Google Chrome"),
"firefox": ("firefox", "Mozilla Firefox"),
"safari": ("safari", "Safari"),
"opera": ("opera", "Opera"),
"brave": ("brave", "brave browser"),
}
+143
View File
@@ -0,0 +1,143 @@
"""图片缩放、体积控制与 OpenAI 风格多模态 image_url 片段(供 LLM / Agent 使用)。"""
from __future__ import annotations
import base64
import io
import os
from typing import Any, Protocol
from PIL import Image
class SupportsChatCompletion(Protocol):
"""具备 ``chat_completion(messages, **kwargs)`` 的 LLM(如 ``ChatQwen35OpenAI``)。"""
def chat_completion(self, messages: list[Any], **kwargs: Any) -> Any: ...
# 小写扩展名(含点),用于判断路径是否为支持的栅格图
IMAGE_FILE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp"})
def guess_image_mime(extension_without_dot: str) -> str:
"""根据扩展名返回 image/* MIME,未知则回退为 image/jpeg。"""
ext = extension_without_dot.lower().lstrip(".")
if ext in ("png", "jpg", "jpeg", "gif", "bmp", "webp"):
return "image/jpeg" if ext == "jpg" else f"image/{ext}"
return "image/jpeg"
def resize_image_if_needed(image_path_or_bytes: str | bytes, *, max_size: int = 768) -> bytes:
"""
控制图片体积,避免 data-url base64 触发超长 token。
- 若图片尺寸过大,等比例缩放(默认最大边 max_size)
- 优先压成 JPEG(透明图保底转白底 RGB)
"""
if isinstance(image_path_or_bytes, str):
img = Image.open(image_path_or_bytes)
original_format = img.format or "PNG"
else:
img = Image.open(io.BytesIO(image_path_or_bytes))
original_format = img.format or "PNG"
if img.mode == "RGBA":
background = Image.new("RGB", img.size, (255, 255, 255))
background.paste(img, mask=img.split()[-1])
img = background
original_format = "JPEG"
elif img.mode == "LA":
background = Image.new("L", img.size, 255)
background.paste(img, mask=img.split()[-1])
img = background
original_width, original_height = img.size
if original_width > max_size or original_height > max_size:
ratio = min(max_size / original_width, max_size / original_height)
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()
if save_format not in ("PNG", "JPEG", "JPG"):
save_format = "JPEG"
if save_format in ("JPEG", "JPG"):
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
img.save(
img_buffer,
format="JPEG",
quality=70,
optimize=True,
progressive=True,
)
else:
img.save(img_buffer, format="PNG", optimize=True)
img_buffer.seek(0)
return img_buffer.getvalue()
def image_to_base64(image_content: str | bytes) -> str | None:
"""将图片路径或原始字节转为 base64 字符串;失败返回 None。"""
try:
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
def _mime_from_magic(data: bytes) -> str:
"""根据压缩后图片文件头推断 MIME,用于内存字节构造 data URL。"""
if len(data) >= 2 and data[:2] == b"\xff\xd8":
return "image/jpeg"
if len(data) >= 8 and data[:8] == b"\x89PNG\r\n\x1a\n":
return "image/png"
if len(data) >= 6 and data[:6] in (b"GIF87a", b"GIF89a"):
return "image/gif"
return "image/jpeg"
def image_bytes_to_openai_image_url_part(
image_bytes: bytes,
*,
detail: str | None = None,
) -> dict | None:
"""将内存中的图片字节(如截屏 PNG)转为 OpenAI 风格的 image_url 块;失败返回 None。"""
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")
url = f"data:{mime};base64,{b64_str}"
image_url: dict = {"url": url}
if detail:
image_url["detail"] = detail
return {"type": "image_url", "image_url": image_url}
def image_path_to_openai_image_url_part(
file_path: str,
*,
detail: str | None = None,
) -> dict | None:
"""
从本地图片路径构造 OpenAI Chat Completions 的 image_url 内容块。
无法编码时返回 None。
"""
b64_str = image_to_base64(file_path)
if not b64_str:
return None
ext = os.path.splitext(file_path)[1].lower().lstrip(".")
mime_type = guess_image_mime(ext)
url = f"data:{mime_type};base64,{b64_str}"
image_url: dict = {"url": url}
if detail:
image_url["detail"] = detail
return {"type": "image_url", "image_url": image_url}
+38
View File
@@ -0,0 +1,38 @@
import sys
from loguru import logger
# Trace < Debug < Info < Success < Warning < Error < Critical
class MyLogger:
def __init__(self):
self.logger = logger
self.logger.remove()
self.logger.add(
sys.stdout,
level="DEBUG",
format="<green>{time:YYYYMMDD HH:mm:ss}</green> | "
"{process.name} | "
"{thread.name} | "
"<cyan>{module}</cyan>.<cyan>{function}</cyan>"
":<cyan>{line}</cyan> | "
"<level>{level}</level>: "
"<level>{message}</level>",
)
def get_logger(self):
return self.logger
log = MyLogger().get_logger()
if __name__ == "__main__":
print("str.pdf"["str.pdf".rindex(".") :])
def test():
try:
print(3)
except ZeroDivisionError as e:
log.exception(e)
test()
+1
View File
@@ -0,0 +1 @@
"""Web 前端静态资源与本地 API 服务(见 ``server`` 模块)。"""
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large. Load diff
+1193
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1776231079896" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1934" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M507.904 391.168q-72.704 0-129.024-16.896t-86.016-33.28q-1.024-1.024-1.024-5.12l0-8.192q0-2.048 0.512-0.512t0.512 13.824l0 523.264-115.712 0 0-614.4q0-20.48 7.168-43.52t34.816-34.304q14.336-6.144 28.16-4.608t24.576 6.144 18.432 10.24 10.752 7.68q19.456 15.36 39.936 28.672t44.544 24.064 53.76 16.896 67.584 6.144l4.096 0 1.024 0 4.096 0q37.888 0 67.584-6.144t54.272-16.896 45.056-24.064 38.912-28.672q3.072-2.048 10.752-7.68t18.944-10.24 25.088-6.144 28.16 4.608q26.624 11.264 34.304 34.304t7.68 43.52l0 614.4-116.736 0 0-523.264q0-12.288 1.024-13.312 1.024 3.072 0.512 7.68t-1.536 5.632q-14.336 8.192-36.864 16.896t-50.688 16.384-60.928 12.288-66.56 4.608l-7.168 0z" p-id="1935" fill="#1296db"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 3.25a3.75 3.75 0 0 1 3.75 3.75v5a3.75 3.75 0 1 1-7.5 0V7A3.75 3.75 0 0 1 12 3.25Z" fill="#0e7490"/>
<path d="M6.5 10.75a.75.75 0 0 1 .75.75 4.75 4.75 0 0 0 9.5 0 .75.75 0 0 1 1.5 0 6.25 6.25 0 0 1-5.5 6.203V20h2a.75.75 0 0 1 0 1.5h-5.5a.75.75 0 0 1 0-1.5h2v-2.297A6.25 6.25 0 0 1 5.75 11.5a.75.75 0 0 1 .75-.75Z" fill="#0f172a" opacity=".86"/>
</svg>

After

Width:  |  Height:  |  Size: 441 B

+132
View File
@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>minor · Chatbot</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=ZCOOL+KuaiLe&display=swap"
rel="stylesheet"
/>
<link rel="icon" href="/image/favicon.svg" sizes="any" />
<link rel="stylesheet" href="/app.css" />
</head>
<body>
<div class="bg-scene" id="bgScene" aria-hidden="true">
<div class="bg-image" id="bgImageLayer"></div>
<div class="bg-vignette" aria-hidden="true"></div>
</div>
<div class="app-root" id="app">
<div class="drawer-backdrop" id="drawerBackdrop" aria-hidden="true"></div>
<aside class="expand-rail" id="expandRail" aria-hidden="true">
<button type="button" class="rail-btn" id="expandBtn" title="展开侧栏">⟩</button>
</aside>
<aside class="sidebar" id="sidebar">
<div class="sidebar-head">
<h2 class="sidebar-title">会话</h2>
<button type="button" class="btn btn-primary" id="newSessionBtn">+ 新建会话</button>
<button type="button" class="btn btn-danger" id="deleteSessionBtn">删除会话</button>
</div>
<div class="sidebar-list-wrap">
<ul class="session-list" id="sessionList" role="listbox" aria-label="会话列表"></ul>
</div>
<p class="sidebar-hint">点击切换会话;新建为秒级时间 ID。</p>
<div class="sidebar-bg-panel" aria-label="背景与景深">
<p class="sidebar-bg-title">页面背景</p>
<label class="bg-control">
<span class="bg-control-label">景深模糊</span>
<input type="range" id="dofRange" min="0" max="24" value="0" step="0.5" />
<span class="bg-control-value" id="dofValue">0 px</span>
</label>
<label class="bg-control">
<span class="bg-control-label">暗角</span>
<input type="range" id="vignetteRange" min="0" max="100" value="35" step="1" />
<span class="bg-control-value" id="vignetteValue">35 %</span>
</label>
<p class="sidebar-bg-tip">替换 <code>web/image/background.jpg</code> 可换图;设置会保存在本机浏览器。</p>
</div>
<div class="sidebar-collapse">
<button type="button" class="collapse-fab" id="collapseBtn" title="收起侧栏">⟨</button>
</div>
</aside>
<main class="main" id="mainPanel">
<header class="main-header">
<button
type="button"
class="menu-btn"
id="menuBtn"
aria-expanded="false"
aria-controls="sidebar"
title="会话与设置"
>
☰
</button>
<div class="main-header-text" id="mainHeaderText">
<h1 class="title">minor · Chatbot</h1>
<p class="subtitle">本地多模态对话(Web)</p>
</div>
<button
type="button"
class="header-toggle"
id="headerToggleBtn"
aria-expanded="true"
aria-controls="mainHeaderText"
title="收起标题栏"
>
▴
</button>
</header>
<section class="chat-shell">
<div class="chat-placeholder" id="chatPlaceholder">
<h3>开始对话</h3>
<p>输入文字,或上传<strong>图片</strong> / <strong>音频</strong> 后点击发送。</p>
<p class="muted">支持用户音频输入、助手文本/语音/双模输出、会话本地媒体持久化。</p>
</div>
<div class="chat-messages" id="chatMessages" hidden></div>
</section>
<footer class="composer">
<div class="composer-attachments" id="attachmentPreview"></div>
<div class="composer-row">
<div class="mm-input" role="group" aria-label="多模态输入">
<div class="composer-input-shell">
<textarea
id="textInput"
rows="2"
placeholder="输入消息或添加附件,Enter 发送(Shift+Enter 换行)"
></textarea>
<button type="button" class="mic-btn" id="micBtn" title="使用麦克风录音" aria-pressed="false" aria-label="使用麦克风录音">
<img src="/image/microphone.svg" alt="" aria-hidden="true" />
</button>
</div>
<div class="mm-actions">
<label class="btn btn-ghost file-btn">
图片
<input type="file" id="imageInput" accept="image/png,image/jpeg,image/jpg,image/gif,image/webp,image/bmp,.png,.jpg,.jpeg,.gif,.webp,.bmp" multiple hidden />
</label>
<label class="btn btn-ghost file-btn">
音频
<input type="file" id="audioInput" accept="audio/*,.wav,.mp3,.m4a,.flac,.ogg" hidden />
</label>
<label class="output-mode-label" for="outputModeSelect">输出模式</label>
<select id="outputModeSelect" class="output-mode-select" aria-label="Agent 输出模式">
<option value="text">纯文字</option>
<option value="audio">纯语音</option>
<option value="both">both</option>
</select>
</div>
</div>
<div class="composer-send">
<button type="button" class="btn btn-primary" id="sendBtn">发送</button>
<button type="button" class="btn btn-secondary" id="clearBtn">清空</button>
</div>
</div>
</footer>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script>
<script src="/app.js"></script>
</body>
</html>
+406
View File
@@ -0,0 +1,406 @@
"""
为 ``web`` 目录下的静态前端提供 API:会话列表、多模态发送(与 ``chatbot_agent`` 一致)。
从仓库 ``Agent`` 目录、且 ``PYTHONPATH`` 包含 ``src`` 时启动::
uvicorn agent.web.server:app --reload --host 127.0.0.1 --port 8765
"""
from __future__ import annotations
import base64
import mimetypes
import os
import shutil
import sys
import tempfile
import uuid
from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
_SRC = Path(__file__).resolve().parent.parent.parent
if str(_SRC) not in sys.path:
sys.path.insert(0, str(_SRC))
from agent import chatbot_agent as ca # noqa: E402
from agent.web import ui_session as ui # noqa: E402
from agent.utils.image_utils import IMAGE_FILE_EXTENSIONS # noqa: E402
_WEB_DIR = Path(__file__).resolve().parent
_IMAGE_DIR = _WEB_DIR / "image"
_AUDIO_EXT = frozenset({".wav", ".mp3", ".m4a", ".flac", ".ogg", ".webm"})
def _data_url_for_file(fp: str) -> str | None:
"""将本地文件读入为 data URL,供前端直接展示(不单独提供文件下载路由)。"""
if not fp or not os.path.isfile(fp):
return None
mime, _ = mimetypes.guess_type(fp)
if not mime:
ext = os.path.splitext(fp)[1].lower()
if ext in IMAGE_FILE_EXTENSIONS:
mime = {".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp"}.get(
ext, "image/jpeg"
)
elif ext in _AUDIO_EXT:
mime = {
".wav": "audio/wav",
".mp3": "audio/mpeg",
".m4a": "audio/mp4",
".flac": "audio/flac",
".ogg": "audio/ogg",
}.get(ext, "application/octet-stream")
else:
mime = "application/octet-stream"
try:
with open(fp, "rb") as f:
raw = f.read()
b64 = base64.standard_b64encode(raw).decode("ascii")
except OSError:
return None
return f"data:{mime};base64,{b64}"
def _serialize_content(content: Any, *, meta: dict[str, Any] | None = None, role: str | None = None) -> Any:
"""将一条消息中的 content 转为 JSON 友好结构;文件以内联 data URL 输出。"""
meta = meta or {}
if isinstance(content, str):
return content
if not isinstance(content, list):
return str(content)
parts: list[dict[str, Any]] = []
for piece in content:
if isinstance(piece, str) and piece.strip():
parts.append({"type": "text", "text": piece.strip()})
continue
if not isinstance(piece, dict):
continue
if piece.get("type") == "text":
parts.append({"type": "text", "text": piece.get("text") or ""})
continue
fp = piece.get("path") or (piece.get("file") or {}).get("path")
if piece.get("type") == "image_url":
fp = fp or (piece.get("image_url") or {}).get("url")
if piece.get("type") == "audio":
fp = fp or piece.get("audio_path")
if not fp or not os.path.isfile(fp):
continue
data_url = _data_url_for_file(fp)
if not data_url:
continue
ext = os.path.splitext(fp)[1].lower()
if ext in IMAGE_FILE_EXTENSIONS:
parts.append({"type": "image", "url": data_url, "path": fp, "name": os.path.basename(fp)})
elif ext in _AUDIO_EXT:
audio_meta = meta.get("audio") if isinstance(meta.get("audio"), dict) else {}
parts.append({
"type": "audio",
"url": data_url,
"path": fp,
"name": os.path.basename(fp),
"autoplay": bool(meta.get("autoplay") or audio_meta.get("autoplay")) if role == "assistant" else False,
"duration_seconds": audio_meta.get("duration_seconds"),
})
else:
parts.append({"type": "file", "url": data_url, "path": fp, "name": os.path.basename(fp)})
return parts
def _serialize_messages(hist: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
hist = hist or []
# 找到最后一条 assistant 消息的索引,用于控制自动播放
last_assistant_idx = -1
for i in range(len(hist) - 1, -1, -1):
if hist[i].get("role") == "assistant":
last_assistant_idx = i
break
out = []
for idx, m in enumerate(hist):
role = m.get("role")
content = m.get("content")
meta = dict(m.get("meta") or {})
meta.setdefault("output_type", "text" if role != "assistant" else meta.get("output_type") or "text")
if role == "assistant":
meta.setdefault("autoplay", bool((meta.get("audio") or {}).get("autoplay") or meta.get("autoplay")))
serialized_content = _serialize_content(content, meta=meta, role=role)
# ---------- 音频部件处理 ----------
if role == "assistant" and meta.get("audio"):
audio_meta = meta.get("audio") if isinstance(meta.get("audio"), dict) else {}
# 检查 content 中是否已经包含了音频类型的部件(例如从 manifest 加载时生成的)
already_has_audio = False
if isinstance(serialized_content, list):
for part in serialized_content:
if isinstance(part, dict) and part.get("type") == "audio":
already_has_audio = True
# 修正历史消息的 autoplay 为 False(仅最新一条可允许)
if idx != last_assistant_idx:
part["autoplay"] = False
break
# 只有 content 中还没有音频部件时,才追加额外的 audio_part
if not already_has_audio:
is_latest = (idx == last_assistant_idx)
autoplay_final = is_latest and (meta.get("autoplay") or audio_meta.get("autoplay"))
audio_part = {
"type": "audio",
"url": _data_url_for_file(audio_meta.get("path")),
"name": "语音回复",
"autoplay": autoplay_final,
"duration_seconds": audio_meta.get("duration_seconds"),
}
if audio_part["url"]:
if isinstance(serialized_content, list):
serialized_content.append(audio_part)
elif serialized_content:
serialized_content = [{"type": "text", "text": serialized_content}, audio_part]
else:
serialized_content = [audio_part]
out.append({"role": role, "content": serialized_content, "meta": meta})
return out
def _bootstrap() -> tuple[str, list[str], list[dict[str, Any]]]:
ids = ui.list_session_ids_ordered()
if ids:
sid = ids[0]
hist = ui.load_ui_messages_for_session(sid)
else:
sid = ui.new_timestamp_session_id()
hist = []
ids = [sid]
return sid, ids, hist
app = FastAPI(title="minor web chat", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/bootstrap")
def api_bootstrap() -> dict[str, Any]:
sid, ids, hist = _bootstrap()
return {"sessionId": sid, "sessions": ids, "messages": _serialize_messages(hist)}
@app.post("/api/sessions/new")
def api_new_session() -> dict[str, Any]:
sid = ui.new_timestamp_session_id()
ids = ui.list_session_ids_ordered()
ids = [sid] + [x for x in ids if x != sid]
return {"sessionId": sid, "sessions": ids, "messages": []}
@app.get("/api/sessions/{session_id}/messages")
def api_session_messages(session_id: str) -> dict[str, Any]:
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
hist = ui.load_ui_messages_for_session(session_id)
ids = ui.list_session_ids_ordered()
if session_id not in ids:
ids = [session_id, *ids]
return {"sessionId": session_id, "sessions": ids, "messages": _serialize_messages(hist)}
@app.post("/api/sessions/clear")
def api_clear_session(session_id: str = Form(...)) -> dict[str, Any]:
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
messages, _ = ui.clear_ui_and_manifest(session_id)
ids = ui.list_session_ids_ordered()
if session_id not in ids:
ids = [session_id, *ids]
return {"sessionId": session_id, "sessions": ids, "messages": _serialize_messages(messages)}
@app.post("/api/sessions/delete")
def api_delete_session(session_id: str = Form(...)) -> dict[str, Any]:
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
ui.delete_session(session_id)
sid, ids, hist = _bootstrap()
return {"sessionId": sid, "sessions": ids, "messages": _serialize_messages(hist)}
def _materialize_uploads_to_temp(
images: list[UploadFile] | None,
audio: UploadFile | None,
text: str,
) -> tuple[dict[str, Any], str]:
"""将上传文件落盘到临时目录,返回 ``(query_dict, tmp_root)``;调用方须在 finally 中删除 ``tmp_root``。"""
tmp_root = tempfile.mkdtemp(prefix="web_mm_")
file_paths: list[str] = []
for uf in images or []:
if uf is None or not uf.filename:
continue
ext = Path(uf.filename).suffix or ""
dest = os.path.join(tmp_root, f"{uuid.uuid4().hex}{ext}")
with open(dest, "wb") as f:
shutil.copyfileobj(uf.file, f)
file_paths.append(dest)
if audio is not None and audio.filename:
ext = Path(audio.filename).suffix or ".bin"
dest = os.path.join(tmp_root, f"{uuid.uuid4().hex}{ext}")
with open(dest, "wb") as f:
shutil.copyfileobj(audio.file, f)
file_paths.append(dest)
query = {"text": (text or "").strip(), "files": file_paths}
return query, tmp_root
def _chat_start_core(session_id: str, query: dict[str, Any]) -> dict[str, Any]:
"""追加 user、复制附件到会话目录并写 manifest(不调用模型)。"""
hist = ui.load_ui_messages_for_session(session_id)
before = len(hist or [])
new_hist, cleared = ui.add_message(hist or [], query)
if cleared is None:
raise HTTPException(status_code=400, detail="请输入文字或上传图片/音频")
if len(new_hist) == before:
raise HTTPException(status_code=400, detail="请输入文字或上传图片/音频")
ui.rewrite_user_files_for_last_turn(new_hist, session_id)
ui.sync_manifest_from_chat_history(session_id, new_hist, "")
ids = ui.list_session_ids_ordered()
if session_id not in ids:
ids = [session_id, *ids]
return {
"sessionId": session_id,
"sessions": ids,
"messages": _serialize_messages(new_hist),
}
def _chat_complete_core(session_id: str, output_type: str = "text") -> dict[str, Any]:
"""对 manifest 中末条 user 执行视觉解析与 ``chain_with_history.invoke``。"""
hist = ui.load_ui_messages_for_session(session_id)
if not hist or hist[-1].get("role") != "user":
raise HTTPException(
status_code=400,
detail="没有待完成的用户消息,请先调用 /api/chat/start",
)
new_hist = ca.execute_agent([*hist], session_id, output_type=output_type)
ui.prune_memory_session_ids()
ids = ui.list_session_ids_ordered()
if session_id not in ids:
ids = [session_id, *ids]
return {
"sessionId": session_id,
"sessions": ids,
"messages": _serialize_messages(new_hist),
}
@app.post("/api/chat/start")
async def api_chat_start(
session_id: str = Form(...),
text: str = Form(""),
images: list[UploadFile] | None = File(None),
audio: UploadFile | None = File(None),
) -> dict[str, Any]:
"""
仅追加用户消息并持久化到 manifest(含附件落盘);不调用模型。
前端应先调用本接口再调用 ``/api/chat/complete``。
"""
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
query, tmp_root = _materialize_uploads_to_temp(images, audio, text)
try:
return _chat_start_core(session_id, query)
finally:
shutil.rmtree(tmp_root, ignore_errors=True)
@app.post("/api/chat/complete")
async def api_chat_complete(
session_id: str = Form(...),
output_type: str = Form("text"),
) -> dict[str, Any]:
"""对当前会话最近一次 ``/api/chat/start`` 提交执行模型推理并返回完整消息列表。"""
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
return _chat_complete_core(session_id, output_type=output_type)
@app.post("/api/chat")
async def api_chat(
session_id: str = Form(...),
text: str = Form(""),
images: list[UploadFile] | None = File(None),
audio: UploadFile | None = File(None),
output_type: str = Form("text"),
) -> dict[str, Any]:
"""
兼容旧客户端:等价于依次 ``/api/chat/start`` + ``/api/chat/complete``(仍阻塞至推理结束)。
"""
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
query, tmp_root = _materialize_uploads_to_temp(images, audio, text)
try:
_chat_start_core(session_id, query)
return _chat_complete_core(session_id, output_type=output_type)
finally:
shutil.rmtree(tmp_root, ignore_errors=True)
# 先注册 API,再挂静态文件,避免 ``/`` 抢路由
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/favicon.ico")
def favicon() -> FileResponse:
for name, mime in (
("favicon.ico", "image/x-icon"),
("favicon.png", "image/png"),
("favicon.svg", "image/svg+xml"),
):
path = _IMAGE_DIR / name
if path.is_file():
return FileResponse(path, media_type=mime)
raise HTTPException(status_code=404, detail="web/image 目录下缺少 favicon.ico / favicon.png / favicon.svg")
@app.get("/image/{asset_name}")
def serve_image_asset(asset_name: str) -> FileResponse:
path = (_IMAGE_DIR / asset_name).resolve()
try:
path.relative_to(_IMAGE_DIR.resolve())
except ValueError as exc:
raise HTTPException(status_code=404, detail="图片资源不存在") from exc
if not path.is_file():
raise HTTPException(status_code=404, detail=f"web/image 目录下缺少 {asset_name}")
mime, _ = mimetypes.guess_type(str(path))
return FileResponse(path, media_type=mime or "application/octet-stream")
@app.get("/")
def index_page() -> FileResponse:
return FileResponse(_WEB_DIR / "index.html")
@app.get("/app.css")
def serve_css() -> FileResponse:
return FileResponse(_WEB_DIR / "app.css")
@app.get("/app.js")
def serve_js() -> FileResponse:
return FileResponse(_WEB_DIR / "app.js")
+461
View File
@@ -0,0 +1,461 @@
"""
Web/API 与会话 UI 状态:会话 id 列表、manifest、多模态 user 消息结构、附件落盘。
与 LangChain 链解耦;execute_agent 仍在 chatbot_agent 中,此处提供其依赖的同步与解析函数。
"""
from __future__ import annotations
import json
import os
import shutil
import sqlite3
import uuid
from datetime import datetime
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
_AUDIO_EXTENSIONS = {".wav", ".mp3", ".m4a", ".flac", ".ogg"}
_memory_session_ids: set[str] = set()
def manifest_path(session_id: str) -> str:
return os.path.join(MULTI_INPUTS_ROOT, session_id, "ui_messages.json")
def session_dir(session_id: str) -> str:
return os.path.join(MULTI_INPUTS_ROOT, session_id)
def list_session_ids_ordered() -> list[str]:
if not os.path.isfile(CHAT_HISTORY_DB_FS):
return []
conn = sqlite3.connect(CHAT_HISTORY_DB_FS)
try:
cur = conn.execute(
"SELECT session_id FROM message_store GROUP BY session_id ORDER BY MAX(id) DESC"
)
return [row[0] for row in cur.fetchall() if row[0]]
finally:
conn.close()
def new_timestamp_session_id() -> str:
base = datetime.now().strftime("%Y%m%d_%H%M%S")
existing = set(list_session_ids_ordered()) | _memory_session_ids
if base not in existing:
_memory_session_ids.add(base)
return base
n = 1
while True:
cand = f"{base}_{n}"
if cand not in existing:
_memory_session_ids.add(cand)
return cand
n += 1
def prune_memory_session_ids() -> None:
persisted = set(list_session_ids_ordered())
for sid in list(_memory_session_ids):
if sid in persisted:
_memory_session_ids.discard(sid)
def delete_session(session_id: str) -> None:
if not session_id:
return
if os.path.isfile(CHAT_HISTORY_DB_FS):
conn = sqlite3.connect(CHAT_HISTORY_DB_FS)
try:
conn.execute("DELETE FROM message_store WHERE session_id = ?", (session_id,))
conn.commit()
finally:
conn.close()
_memory_session_ids.discard(session_id)
shutil.rmtree(session_dir(session_id), ignore_errors=True)
def _lc_content_for_ui_messages(content) -> str | list:
if isinstance(content, str):
return content
if isinstance(content, list):
t = assistant_text(content)
return t if t else str(content)
return str(content)
def _load_ui_messages_from_db(session_id: str) -> list:
if not session_id:
return []
rows: list[dict] = []
for m in get_history(session_id).messages:
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)
rows.append({"role": "assistant", "content": text or "(空回复)"})
return rows
def _build_user_chat_content(query) -> str | list | None:
parts: list = []
for filepath in query.get("files", []) or []:
if filepath:
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":
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)
raw = user_msg.get("content")
if not isinstance(raw, list):
return
new_list: list = []
for piece in raw:
if isinstance(piece, str) or not isinstance(piece, dict):
new_list.append(piece)
continue
if piece.get("type") == "text":
new_list.append(piece)
continue
src = piece.get("path") or (piece.get("file") or {}).get("path")
if piece.get("type") == "image_url":
src = src or (piece.get("image_url") or {}).get("url")
if piece.get("type") == "audio":
src = src or piece.get("audio_path")
if not src or not os.path.isfile(src):
new_list.append(piece)
continue
dest_dir_norm = os.path.normpath(dest_dir) + os.sep
src_norm = os.path.normpath(src)
if src_norm.startswith(dest_dir_norm):
new_list.append(piece)
continue
ext = os.path.splitext(src)[1] or ""
dest = os.path.join(dest_dir, f"{uuid.uuid4().hex}{ext}")
shutil.copy2(src, dest)
new_piece = dict(piece)
new_piece["path"] = dest
if piece.get("type") == "image_url":
new_piece["image_url"] = {"url": dest}
if piece.get("type") == "audio":
new_piece["audio_path"] = dest
new_list.append(new_piece)
user_msg["content"] = new_list
def _serialize_user_msg_for_manifest(user_msg: dict) -> dict:
raw = user_msg.get("content")
content_list: list[dict] = []
if isinstance(raw, str):
if raw.strip():
content_list.append({"type": "text", "text": raw})
elif isinstance(raw, list):
for piece in raw:
if isinstance(piece, str) and piece.strip():
content_list.append({"type": "text", "text": piece.strip()})
elif isinstance(piece, dict):
if piece.get("type") == "text":
content_list.append({"type": "text", "text": piece.get("text") or ""})
continue
fp = piece.get("path") or (piece.get("file") or {}).get("path")
if piece.get("type") == "image_url":
fp = fp or (piece.get("image_url") or {}).get("url")
if piece.get("type") == "audio":
fp = fp or piece.get("audio_path")
if not fp:
continue
ext = os.path.splitext(fp)[1].lower()
try:
rel = os.path.relpath(os.path.normpath(fp), os.path.normpath(MULTI_INPUTS_ROOT))
except ValueError:
rel = fp.replace("\\", "/")
if ext in IMAGE_FILE_EXTENSIONS:
part_type = "image_url"
elif ext in _AUDIO_EXTENSIONS:
part_type = "audio"
else:
part_type = "file"
content_list.append({"type": part_type, "relpath": rel.replace("\\", "/")})
# 不再写入 image_description 字段
return {"content": content_list}
def _manifest_user_entry_to_content(user_entry: dict) -> str | list:
root_norm = os.path.normpath(MULTI_INPUTS_ROOT)
parts_out: list = []
any_image_missing = False
had_image_parts = False
# 新格式直接取 "content"
content_data = user_entry.get("content")
if content_data is None:
return ""
for p in content_data:
part_type = p.get("type")
if part_type == "text":
t = (p.get("text") or "").strip()
if t:
parts_out.append({"type": "text", "text": t})
continue
rel = (p.get("relpath") or "").replace("/", os.sep)
abs_p = os.path.normpath(os.path.join(root_norm, rel))
try:
under = os.path.commonpath([abs_p, root_norm]) == root_norm
except ValueError:
under = False
if not under:
if part_type == "image_url":
any_image_missing = True
had_image_parts = True
continue
if part_type == "image_url":
had_image_parts = True
if os.path.isfile(abs_p):
parts_out.append({"type": "image_url", "image_url": {"url": abs_p}, "path": abs_p})
else:
any_image_missing = True
elif part_type == "audio":
if os.path.isfile(abs_p):
parts_out.append({"type": "audio", "audio_path": abs_p, "path": abs_p})
else:
parts_out.append({"type": "text", "text": f"[语音已丢失: {os.path.basename(rel) or rel}]"})
elif part_type == "file":
if os.path.isfile(abs_p):
parts_out.append({"path": abs_p})
else:
parts_out.append({"type": "text", "text": f"[附件已丢失: {os.path.basename(rel) or rel}]"})
# 不再处理 image_description(新格式中没有)
if had_image_parts and any_image_missing:
parts_out.append({"type": "text", "text": "[图片已丢失]"})
if not parts_out:
return ""
if len(parts_out) == 1 and parts_out[0].get("type") == "text":
return parts_out[0]["text"]
return parts_out
def _serialize_assistant_entry(assistant_msg: dict | None) -> dict:
ac = (assistant_msg or {}).get("content", "")
meta = dict((assistant_msg or {}).get("meta") or {})
if isinstance(ac, str):
atext = ac
else:
atext = assistant_text(ac) or extract_result_from_invoke(assistant_msg) or "(空回复)"
audio_relpath = None
audio = meta.get("audio")
if isinstance(audio, dict) and audio.get("path"):
try:
audio_relpath = os.path.relpath(
os.path.normpath(audio["path"]), os.path.normpath(MULTI_INPUTS_ROOT)
)
except ValueError:
audio_relpath = str(audio["path"]).replace("\\", "/")
return {
"text": atext,
"output_type": meta.get("output_type") or "text",
"autoplay": bool(meta.get("autoplay") or (audio or {}).get("autoplay")),
"audio_duration_seconds": (audio or {}).get("duration_seconds") if isinstance(audio, dict) else None,
"audio_relpath": audio_relpath.replace("\\", "/") if audio_relpath else None,
}
def sync_manifest_from_chat_history(session_id: str, chat_history: list, latest_user_image_description: str) -> None:
if not session_id:
return
mp = manifest_path(session_id)
turns_out: list[dict] = []
ch = chat_history or []
i = 0
while i < len(ch):
if ch[i].get("role") != "user":
i += 1
continue
user_msg = ch[i]
assistant_msg = ch[i + 1] if i + 1 < len(ch) and ch[i + 1].get("role") == "assistant" else None
turns_out.append(
{
"user": _serialize_user_msg_for_manifest(user_msg),
"assistant": _serialize_assistant_entry(assistant_msg),
}
)
i += 2 if assistant_msg else 1
os.makedirs(os.path.dirname(mp), exist_ok=True)
with open(mp, "w", encoding="utf-8") as f:
json.dump({"version": 2, "turns": turns_out}, f, ensure_ascii=False, indent=2)
def _assistant_manifest_entry_to_content(entry: dict) -> str | list:
text = (entry.get("text") or "").strip()
output_type = entry.get("output_type") or "text"
autoplay = bool(entry.get("autoplay"))
audio_duration_seconds = entry.get("audio_duration_seconds")
audio_relpath = entry.get("audio_relpath")
audio_abs_path = None
if audio_relpath:
root_norm = os.path.normpath(MULTI_INPUTS_ROOT)
audio_abs_path = os.path.normpath(os.path.join(root_norm, audio_relpath.replace("/", os.sep)))
try:
under = os.path.commonpath([audio_abs_path, root_norm]) == root_norm
except ValueError:
under = False
if not under or not os.path.isfile(audio_abs_path):
audio_abs_path = None
parts: list[dict] = []
if output_type in ("text", "both") and text:
parts.append({"type": "text", "text": text})
if output_type in ("audio", "both") and audio_abs_path:
parts.append({
"type": "audio",
"audio_path": audio_abs_path,
"path": audio_abs_path,
"autoplay": autoplay,
"duration_seconds": audio_duration_seconds,
})
if not parts:
return text
if len(parts) == 1 and parts[0].get("type") == "text":
return parts[0]["text"]
return parts
def _load_ui_messages_from_manifest(session_id: str) -> list | None:
mp = manifest_path(session_id)
if not os.path.isfile(mp):
return None
try:
with open(mp, encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
return None
if "turns" not in data:
return None
turns = data.get("turns") or []
if turns == []:
return []
out: list[dict] = []
n = len(turns)
for idx, t in enumerate(turns):
out.append({"role": "user", "content": _manifest_user_entry_to_content(t.get("user") or {})})
asst_entry = t.get("assistant") or {}
if not isinstance(asst_entry, dict):
asst_entry = {"text": str(asst_entry)}
asst_content = _assistant_manifest_entry_to_content(asst_entry)
if idx == n - 1 and not str(asst_entry.get("text") or "").strip() and asst_content == "":
continue
# 从扁平存储重建与 execute_agent 产物相同的 meta 字典
meta = {
"output_type": asst_entry.get("output_type", "text"),
"autoplay": asst_entry.get("autoplay", False),
}
audio_relpath = asst_entry.get("audio_relpath")
if audio_relpath:
root_norm = os.path.normpath(MULTI_INPUTS_ROOT)
audio_path = os.path.normpath(os.path.join(root_norm, audio_relpath.replace("/", os.sep)))
if os.path.isfile(audio_path):
meta["audio"] = {
"path": audio_path,
"autoplay": asst_entry.get("autoplay", False),
"duration_seconds": asst_entry.get("audio_duration_seconds"),
"format": "wav",
"sample_rate": 24000,
}
out.append({"role": "assistant", "content": asst_content, "meta": meta})
return out
def load_ui_messages_for_session(session_id: str) -> list:
if not session_id:
return []
m = _load_ui_messages_from_manifest(session_id)
if m is not None:
return m
return _load_ui_messages_from_db(session_id)
def rewrite_user_files_for_last_turn(chat_history: list, session_id: str) -> None:
if session_id and chat_history and chat_history[-1].get("role") == "user":
rewrite_user_message_files_to_session_dir(chat_history[-1], session_id)
def add_message(chat_history, query):
chat_history = chat_history or []
user_content = _build_user_chat_content(query)
if user_content is None:
return chat_history, None
chat_history.append({"role": "user", "content": user_content})
return chat_history, {"text": "", "files": []}
def clear_ui_and_manifest(session_id: str):
if session_id:
mp = manifest_path(session_id)
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": []}