修改了文件系统架构
This commit is contained in:
1 parent
dc246b0d91
commit
4a4bd5c365
30 files changed
+29
-5
No files matched your search
@@ -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 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")
|
||||
Reference in new issue
Block a user