import openai
from typing import Optional, List, Dict, Any
import json
import os
import shutil
import re
import io
import contextlib
import traceback
from pathlib import Path
from urllib.parse import quote
import subprocess
import sys
import tempfile
import requests
import threading
import http.server
from functools import partial
import socketserver
from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Query
from fastapi.responses import JSONResponse, Response
from fastapi.middleware.cors import CORSMiddleware
from typing import List, Optional
import httpx
import uvicorn
import os
import re
import json
from fastapi.responses import StreamingResponse
from fastapi.concurrency import run_in_threadpool
import os
import re
from copy import deepcopy
import openai
from fastapi import FastAPI, Body
from fastapi.responses import StreamingResponse
import re
os.environ.setdefault("MPLBACKEND", "Agg")
Chinese_matplot_str = """
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = False
"""
def execute_code_safe(
code_str: str, workspace_dir: str = None, timeout_sec: int = 120
) -> str:
"""在独立进程中执行代码,支持超时,避免阻塞主进程。"""
if workspace_dir is None:
workspace_dir = WORKSPACE_BASE_DIR
exec_cwd = os.path.abspath(workspace_dir)
os.makedirs(exec_cwd, exist_ok=True)
tmp_path = None
try:
fd, tmp_path = tempfile.mkstemp(suffix=".py", dir=exec_cwd)
os.close(fd)
with open(tmp_path, "w", encoding="utf-8") as f:
f.write(code_str)
# 在子进程中设置无界面环境变量,避免 GUI 后端
child_env = os.environ.copy()
child_env.setdefault("MPLBACKEND", "Agg")
child_env.setdefault("QT_QPA_PLATFORM", "offscreen")
child_env.pop("DISPLAY", None)
completed = subprocess.run(
[sys.executable, tmp_path],
cwd=exec_cwd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout_sec,
env=child_env,
)
output = (completed.stdout or "") + (completed.stderr or "")
return output
except subprocess.TimeoutExpired:
return f"[Timeout]: execution exceeded {timeout_sec} seconds"
except Exception as e:
return f"[Error]: {str(e)}"
finally:
try:
if tmp_path and os.path.exists(tmp_path):
os.remove(tmp_path)
except Exception:
pass
# API endpoint and model path
API_BASE = "http://localhost:8000/v1" # this localhost is for vllm api, do not change
MODEL_PATH = "DeepAnalyze-8B" # replace to your path to DeepAnalyze-8B
# Initialize OpenAI client
client = openai.OpenAI(base_url=API_BASE, api_key="dummy")
# Workspace directory
WORKSPACE_BASE_DIR = "workspace"
HTTP_SERVER_PORT = 8100
HTTP_SERVER_BASE = (
f"http://localhost:{HTTP_SERVER_PORT}" # you can replace localhost to your local ip
)
def get_session_workspace(session_id: str) -> str:
"""返回指定 session 的 workspace 路径(workspace/{session_id}/)。"""
if not session_id:
session_id = "default"
session_dir = os.path.join(WORKSPACE_BASE_DIR, session_id)
os.makedirs(session_dir, exist_ok=True)
return session_dir
def build_download_url(rel_path: str) -> str:
try:
encoded = quote(rel_path, safe="/")
except Exception:
encoded = rel_path
return f"{HTTP_SERVER_BASE}/{encoded}"
# FastAPI app
app = FastAPI()
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def start_http_server():
"""启动HTTP文件服务器(不修改全局工作目录)。"""
os.makedirs(WORKSPACE_BASE_DIR, exist_ok=True)
handler = partial(
http.server.SimpleHTTPRequestHandler, directory=WORKSPACE_BASE_DIR
)
with socketserver.TCPServer(("", HTTP_SERVER_PORT), handler) as httpd:
print(f"HTTP Server serving {WORKSPACE_BASE_DIR} at port {HTTP_SERVER_PORT}")
httpd.serve_forever()
# Start HTTP server in a separate thread
threading.Thread(target=start_http_server, daemon=True).start()
def collect_file_info(directory: str) -> str:
"""收集文件信息"""
all_file_info_str = ""
dir_path = Path(directory)
if not dir_path.exists():
return ""
files = sorted([f for f in dir_path.iterdir() if f.is_file()])
for idx, file_path in enumerate(files, start=1):
size_bytes = os.path.getsize(file_path)
size_kb = size_bytes / 1024
size_str = f"{size_kb:.1f}KB"
file_info = {"name": file_path.name, "size": size_str}
file_info_str = json.dumps(file_info, indent=4, ensure_ascii=False)
all_file_info_str += f"File {idx}:\n{file_info_str}\n\n"
return all_file_info_str
def get_file_icon(extension):
"""获取文件图标"""
ext = extension.lower()
icons = {
(".jpg", ".jpeg", ".png", ".gif", ".bmp"): "🖼️",
(".pdf",): "📕",
(".doc", ".docx"): "📘",
(".txt",): "📄",
(".md",): "📝",
(".csv", ".xlsx"): "📊",
(".json", ".sqlite"): "🗄️",
(".mp4", ".avi", ".mov"): "🎥",
(".mp3", ".wav"): "🎵",
(".zip", ".rar", ".tar"): "🗜️",
}
for extensions, icon in icons.items():
if ext in extensions:
return icon
return "📁"
def uniquify_path(target: Path) -> Path:
"""若目标已存在,生成 'name (1).ext'、'name (2).ext' 形式的新路径。"""
if not target.exists():
return target
parent = target.parent
stem = target.stem
suffix = target.suffix
import re as _re
m = _re.match(r"^(.*) \((\d+)\)$", stem)
base = stem
start = 1
if m:
base = m.group(1)
try:
start = int(m.group(2)) + 1
except Exception:
start = 1
i = start
while True:
candidate = parent / f"{base} ({i}){suffix}"
if not candidate.exists():
return candidate
i += 1
# API Routes
@app.get("/workspace/files")
async def get_workspace_files(session_id: str = Query("default")):
"""获取工作区文件列表(支持 session 隔离)"""
workspace_dir = get_session_workspace(session_id)
generated_dir = Path(workspace_dir) / "generated"
# 获取 generated 目录下的文件名集合
generated_files = (
set(f.name for f in generated_dir.iterdir() if f.is_file())
if generated_dir.exists()
else set()
)
files = []
for file_path in Path(workspace_dir).iterdir():
if file_path.is_file():
if file_path.name in generated_files:
continue
stat = file_path.stat()
rel_path = f"{session_id}/{file_path.name}"
files.append(
{
"name": file_path.name,
"size": stat.st_size,
"extension": file_path.suffix.lower(),
"icon": get_file_icon(file_path.suffix),
"download_url": build_download_url(rel_path),
"preview_url": (
build_download_url(rel_path)
if file_path.suffix.lower()
in [
".jpg",
".jpeg",
".png",
".gif",
".bmp",
".pdf",
".txt",
".doc",
".docx",
".csv",
".xlsx",
]
else None
),
}
)
return {"files": files}
# ---------- Workspace Tree & Single File Delete ----------
def _rel_path(path: Path, root: Path) -> str:
try:
rel = path.relative_to(root)
return rel.as_posix()
except Exception:
return path.name
def build_tree(path: Path, root: Optional[Path] = None) -> dict:
if root is None:
root = path
node: dict = {
"name": path.name or "workspace",
"path": _rel_path(path, root),
"is_dir": path.is_dir(),
}
if path.is_dir():
children = []
# 自定义排序:generated 文件夹放在最后,其他按目录优先、名称排序
def sort_key(p):
is_generated = p.name == "generated"
is_dir = p.is_dir()
return (is_generated, not is_dir, p.name.lower())
for child in sorted(path.iterdir(), key=sort_key):
if child.name.startswith("."):
continue
children.append(build_tree(child, root))
node["children"] = children
else:
node["size"] = path.stat().st_size
node["extension"] = path.suffix.lower()
node["icon"] = get_file_icon(path.suffix)
rel = _rel_path(path, root)
node["download_url"] = build_download_url(rel)
return node
@app.get("/workspace/tree")
async def workspace_tree(session_id: str = Query("default")):
workspace_dir = get_session_workspace(session_id)
root = Path(workspace_dir)
tree_data = build_tree(root, root)
# 在下载链接前加上 session_id 前缀
def prefix_urls(node, sid):
if "download_url" in node and node["download_url"]:
# 重新构建包含 session_id 的路径
rel = node.get("path", "")
node["download_url"] = build_download_url(f"{sid}/{rel}")
if "children" in node:
for child in node["children"]:
prefix_urls(child, sid)
prefix_urls(tree_data, session_id)
return tree_data
@app.delete("/workspace/file")
async def delete_workspace_file(
path: str = Query(..., description="relative path under workspace"),
session_id: str = Query("default"),
):
workspace_dir = get_session_workspace(session_id)
abs_workspace = Path(workspace_dir).resolve()
target = (abs_workspace / path).resolve()
if abs_workspace not in target.parents and target != abs_workspace:
raise HTTPException(status_code=400, detail="Invalid path")
if not target.exists():
raise HTTPException(status_code=404, detail="Not found")
if target.is_dir():
raise HTTPException(status_code=400, detail="Folder deletion not allowed")
try:
target.unlink()
return {"message": "deleted"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/workspace/move")
async def move_path(
src: str = Query(..., description="relative source path under workspace"),
dst_dir: str = Query("", description="relative target directory under workspace"),
session_id: str = Query("default"),
):
"""在同一 workspace 内移动(或重命名)文件/目录。
- src: 源相对路径(必填)
- dst_dir: 目标目录(相对路径,空表示移动到根目录)
"""
workspace_dir = get_session_workspace(session_id)
abs_workspace = Path(workspace_dir).resolve()
abs_src = (abs_workspace / src).resolve()
if abs_workspace not in abs_src.parents and abs_src != abs_workspace:
raise HTTPException(status_code=400, detail="Invalid src path")
if not abs_src.exists():
raise HTTPException(status_code=404, detail="Source not found")
abs_dst_dir = (abs_workspace / (dst_dir or "")).resolve()
if abs_workspace not in abs_dst_dir.parents and abs_dst_dir != abs_workspace:
raise HTTPException(status_code=400, detail="Invalid dst_dir path")
abs_dst_dir.mkdir(parents=True, exist_ok=True)
target = abs_dst_dir / abs_src.name
target = uniquify_path(target)
try:
shutil.move(str(abs_src), str(target))
rel_new = str(target.relative_to(abs_workspace))
return {"message": "moved", "new_path": rel_new}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Move failed: {e}")
@app.delete("/workspace/dir")
async def delete_workspace_dir(
path: str = Query(..., description="relative directory under workspace"),
recursive: bool = Query(True, description="delete directory recursively"),
session_id: str = Query("default"),
):
"""删除 workspace 下的目录。默认递归删除,禁止删除根目录。"""
workspace_dir = get_session_workspace(session_id)
abs_workspace = Path(workspace_dir).resolve()
target = (abs_workspace / path).resolve()
if abs_workspace not in target.parents and target != abs_workspace:
raise HTTPException(status_code=400, detail="Invalid path")
if target == abs_workspace:
raise HTTPException(status_code=400, detail="Cannot delete workspace root")
if not target.exists():
raise HTTPException(status_code=404, detail="Not found")
if not target.is_dir():
raise HTTPException(status_code=400, detail="Not a directory")
try:
if recursive:
shutil.rmtree(target)
else:
target.rmdir()
return {"message": "deleted"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/proxy")
async def proxy(url: str):
"""Simple CORS proxy for previewing external files.
WARNING: For production, add domain allowlist and authentication.
"""
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=15) as client:
r = await client.get(url)
return Response(
content=r.content,
media_type=r.headers.get("content-type", "application/octet-stream"),
headers={"Access-Control-Allow-Origin": "*"},
status_code=r.status_code,
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Proxy fetch failed: {e}")
@app.post("/workspace/upload")
async def upload_files(
files: List[UploadFile] = File(...), session_id: str = Query("default")
):
"""上传文件到工作区(支持 session 隔离)"""
workspace_dir = get_session_workspace(session_id)
uploaded_files = []
for file in files:
# 唯一化文件名,避免覆盖
dst = uniquify_path(Path(workspace_dir) / file.filename)
with open(dst, "wb") as buffer:
content = await file.read()
buffer.write(content)
uploaded_files.append(
{
"name": dst.name,
"size": len(content),
"path": str(dst.relative_to(Path(workspace_dir))),
}
)
return {
"message": f"Successfully uploaded {len(uploaded_files)} files",
"files": uploaded_files,
}
@app.delete("/workspace/clear")
async def clear_workspace(session_id: str = Query("default")):
"""清空工作区(支持 session 隔离)"""
workspace_dir = get_session_workspace(session_id)
if os.path.exists(workspace_dir):
shutil.rmtree(workspace_dir)
os.makedirs(workspace_dir, exist_ok=True)
return {"message": "Workspace cleared successfully"}
@app.post("/workspace/upload-to")
async def upload_to_dir(
dir: str = Query("", description="relative directory under workspace"),
files: List[UploadFile] = File(...),
session_id: str = Query("default"),
):
"""上传文件到 workspace 下的指定子目录(仅限工作区内)。"""
workspace_dir = get_session_workspace(session_id)
abs_workspace = Path(workspace_dir).resolve()
target_dir = (abs_workspace / dir).resolve()
if abs_workspace not in target_dir.parents and target_dir != abs_workspace:
raise HTTPException(status_code=400, detail="Invalid dir path")
target_dir.mkdir(parents=True, exist_ok=True)
saved = []
for f in files:
dst = uniquify_path(target_dir / f.filename)
try:
with open(dst, "wb") as buffer:
content = await f.read()
buffer.write(content)
saved.append(
{
"name": dst.name,
"size": len(content),
"path": str(dst.relative_to(abs_workspace)),
}
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Save failed: {e}")
return {"message": f"uploaded {len(saved)}", "files": saved}
@app.post("/execute")
async def execute_code_api(request: dict):
"""执行 Python 代码"""
try:
code = request.get("code", "")
session_id = request.get("session_id", "default")
workspace_dir = get_session_workspace(session_id)
if not code:
raise HTTPException(status_code=400, detail="No code provided")
# 使用子进程安全执行,避免 GUI/线程问题(在指定 session workspace 中)
result = await run_in_threadpool(execute_code_safe, code, workspace_dir)
return {
"success": True,
"result": result,
"message": "Code executed successfully",
}
except Exception as e:
return {
"success": False,
"result": f"Error: {str(e)}",
"message": "Code execution failed",
}
def fix_code_block(content):
def fix_text(text):
stack = []
lines = text.splitlines(keepends=True)
result = []
for line in lines:
stripped = line.strip()
if stripped.startswith("```python"):
if stack and stack[-1] == "```python":
result.append("```\n")
stack.pop()
stack.append("```python")
result.append(line)
elif stripped == "```":
if stack and stack[-1] == "```python":
stack.pop()
result.append(line)
else:
result.append(line)
while stack:
result.append("```\n")
stack.pop()
return "".join(result)
if isinstance(content, str):
return fix_text(content)
elif isinstance(content, tuple):
text_part = content[0] if content[0] else ""
return (fix_text(text_part), content[1])
return content
def fix_tags_and_codeblock(s: str) -> str:
"""
修复未闭合的tags,并确保后代码块闭合。
"""
pattern = re.compile(
r"<(Analyze|Understand|Code|Execute|Answer)>(.*?)(?:\1>|(?=$))", re.DOTALL
)
# 找所有匹配
matches = list(pattern.finditer(s))
if not matches:
return s # 没有标签,直接返回
# 检查最后一个匹配是否闭合
last_match = matches[-1]
tag_name = last_match.group(1)
matched_text = last_match.group(0)
if not matched_text.endswith(f"{tag_name}>"):
# 没有闭合,补上
if tag_name == "Code":
s = fix_code_block(s) + f"\n```\n{tag_name}>"
else:
s += f"\n{tag_name}>"
return s
def bot_stream(messages, workspace, session_id="default"):
original_cwd = os.getcwd()
WORKSPACE_DIR = get_session_workspace(session_id)
os.makedirs(WORKSPACE_DIR, exist_ok=True)
# 创建 generated 子文件夹用于存放代码生成的文件
GENERATED_DIR = os.path.join(WORKSPACE_DIR, "generated")
os.makedirs(GENERATED_DIR, exist_ok=True)
# print(messages)
if messages and messages[0]["role"] == "assistant":
messages = messages[1:]
if messages and messages[-1]["role"] == "user":
user_message = messages[-1]["content"]
file_info = (
collect_file_info(workspace)
if workspace
else collect_file_info(WORKSPACE_DIR)
)
if file_info:
messages[-1][
"content"
] = f"# Instruction\n{user_message}\n\n# Data\n{file_info}"
else:
messages[-1]["content"] = f"# Instruction\n{user_message}"
# print("111",messages)
initial_workspace = set(workspace)
assistant_reply = ""
finished = False
exe_output = None
while not finished:
response = client.chat.completions.create(
model=MODEL_PATH,
messages=messages,
temperature=0.4,
stream=True,
extra_body={
"add_generation_prompt": False,
"stop_token_ids": [151676, 151645],
"max_new_tokens": 32768,
},
)
cur_res = ""
for chunk in response:
if chunk.choices and chunk.choices[0].delta.content is not None:
delta = chunk.choices[0].delta.content
cur_res += delta
assistant_reply += delta
yield delta
if "" in cur_res:
finished = True
break
if chunk.choices[0].finish_reason == "stop" and not finished:
if not cur_res.endswith(""):
missing_tag = ""
cur_res += missing_tag
assistant_reply += missing_tag
yield missing_tag
if "" in cur_res and not finished:
messages.append({"role": "assistant", "content": cur_res})
code_match = re.search(r"(.*?)", cur_res, re.DOTALL)
if code_match:
code_content = code_match.group(1).strip()
md_match = re.search(r"```(?:python)?(.*?)```", code_content, re.DOTALL)
code_str = md_match.group(1).strip() if md_match else code_content
code_str = Chinese_matplot_str + "\n" + code_str
# 执行前快照(路径 -> (size, mtime))
try:
before_state = {
p.resolve(): (p.stat().st_size, p.stat().st_mtime_ns)
for p in Path(WORKSPACE_DIR).rglob("*")
if p.is_file()
}
except Exception:
before_state = {}
# 在子进程中以固定工作区执行
exe_output = execute_code_safe(code_str, WORKSPACE_DIR)
# 执行后快照
try:
after_state = {
p.resolve(): (p.stat().st_size, p.stat().st_mtime_ns)
for p in Path(WORKSPACE_DIR).rglob("*")
if p.is_file()
}
except Exception:
after_state = {}
# 计算新增与修改
added_paths = [p for p in after_state.keys() if p not in before_state]
modified_paths = [
p
for p in after_state.keys()
if p in before_state and after_state[p] != before_state[p]
]
# 将新增和修改的文件移动到 generated 文件夹
artifact_paths = []
for p in added_paths:
try:
# 如果文件不在 generated 文件夹中,移动它
if not str(p).startswith(GENERATED_DIR):
dest_path = Path(GENERATED_DIR) / p.name
dest_path = uniquify_path(dest_path)
shutil.copy2(str(p), str(dest_path))
artifact_paths.append(dest_path.resolve())
else:
artifact_paths.append(p)
except Exception as e:
print(f"Error moving file {p}: {e}")
artifact_paths.append(p)
# 为修改的文件生成副本并移动到 generated 文件夹
for p in modified_paths:
try:
dest_name = f"{Path(p).stem}_modified{Path(p).suffix}"
dest_path = Path(GENERATED_DIR) / dest_name
dest_path = uniquify_path(dest_path)
shutil.copy2(p, dest_path)
artifact_paths.append(dest_path.resolve())
except Exception as e:
print(f"Error copying modified file {p}: {e}")
# 旧:Execute 内部放控制台输出;新:追加 段落给前端渲染卡片
exe_str = f"\n\n```\n{exe_output}\n```\n\n"
file_block = ""
if artifact_paths:
lines = [""]
for p in artifact_paths:
try:
rel = (
Path(p)
.relative_to(Path(WORKSPACE_DIR).resolve())
.as_posix()
)
except Exception:
rel = Path(p).name
# 在相对路径前加上 session_id 前缀
url = build_download_url(f"{session_id}/{rel}")
name = Path(p).name
lines.append(f"- [{name}]({url})")
if Path(p).suffix.lower() in [
".png",
".jpg",
".jpeg",
".gif",
".webp",
".svg",
]:
lines.append(f"")
lines.append("")
file_block = "\n" + "\n".join(lines) + "\n"
full_execution_block = exe_str + file_block
assistant_reply += full_execution_block
yield full_execution_block
messages.append({"role": "execute", "content": f"{exe_output}"})
# 刷新工作区快照(路径集合)
current_files = set(
[
os.path.join(WORKSPACE_DIR, f)
for f in os.listdir(WORKSPACE_DIR)
if os.path.isfile(os.path.join(WORKSPACE_DIR, f))
]
)
new_files = list(current_files - initial_workspace)
if new_files:
workspace.extend(new_files)
initial_workspace.update(new_files)
os.chdir(original_cwd)
@app.post("/chat/completions")
async def chat(body: dict = Body(...)):
messages = body.get("messages", [])
workspace = body.get("workspace", [])
session_id = body.get("session_id", "default")
def generate():
for delta_content in bot_stream(messages, workspace, session_id):
# print(delta_content)
chunk = {
"id": "chatcmpl-stream",
"object": "chat.completion.chunk", # 标识为流式块
"created": 1677652288,
"model": MODEL_PATH,
"choices": [
{
"index": 0,
# 3. 使用 delta 字段而非 message 字段
"delta": {
"content": delta_content # 直接填入原始内容,不要调用 fix_tags
},
"finish_reason": None, # 传输中为 None
}
],
}
yield json.dumps(chunk) + "\n"
# 5. 循环结束后,发送一个结束标记 (Optional, 但推荐)
end_chunk = {
"id": "chatcmpl-stream",
"object": "chat.completion.chunk",
"created": 1677652288,
"model": MODEL_PATH,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]
}
yield json.dumps(end_chunk) + "\n"
return StreamingResponse(generate(), media_type="text/plain")
# -------- Export Report (PDF + MD) --------
from datetime import datetime
def _extract_sections_from_messages(messages: list[dict]) -> str:
"""从历史消息中抽取 .. 作为报告主体,其余部分按原始顺序作为 Appendix 拼成 Markdown。"""
if not isinstance(messages, list):
return ""
import re as _re
parts: list[str] = []
appendix: list[str] = []
tag_pattern = r"<(Analyze|Understand|Code|Execute|File|Answer)>([\s\S]*?)\1>"
for idx, m in enumerate(messages, start=1):
role = (m or {}).get("role")
if role != "assistant":
continue
content = str((m or {}).get("content") or "")
step = 1
# 按照在文本中的出现顺序依次提取
for match in _re.finditer(tag_pattern, content, _re.DOTALL):
tag, seg = match.groups()
seg = seg.strip()
if tag == "Answer":
parts.append(f"{seg}\n")
appendix.append(f"\n### Step {step}: {tag}\n\n{seg}\n")
step += 1
final_text = "".join(parts).strip()
if appendix:
final_text += (
"\n\n\\newpage\n\n# Appendix: Detailed Process\n"
+ "".join(appendix).strip()
)
# print(final_text)
return final_text
def _save_md(md_text: str, base_name: str, workspace_dir: str) -> Path:
Path(workspace_dir).mkdir(parents=True, exist_ok=True)
md_path = uniquify_path(Path(workspace_dir) / f"{base_name}.md")
with open(md_path, "w", encoding="utf-8") as f:
f.write(md_text)
return md_path
import pypandoc
def _save_pdf(md_text: str, base_name: str, workspace_dir: str) -> Path | None:
Path(workspace_dir).mkdir(parents=True, exist_ok=True)
pdf_path = uniquify_path(Path(workspace_dir) / f"{base_name}.pdf")
try:
pypandoc.convert_text(
md_text,
"pdf",
format="md",
outputfile=str(pdf_path),
extra_args=[
"--standalone",
"--pdf-engine=xelatex",
],
)
return pdf_path
except Exception:
return None
from typing import Optional
def _render_md_to_html(md_text: str, title: Optional[str] = None) -> str:
"""简化为占位实现(仅供未来 PDF 渲染使用)。当前仅生成 MD。"""
doc_title = (title or "Report").strip() or "Report"
safe = (md_text or "").replace("<", "<").replace(">", ">")
return f"{doc_title}{safe}"
def _save_pdf_from_md(html_text: str, base_name: str) -> Path:
"""TODO: 服务端 PDF 渲染未实现。"""
raise NotImplementedError("TODO: implement server-side PDF rendering")
def _save_pdf_with_chromium(html_text: str, base_name: str) -> Path:
"""TODO: 使用 Chromium 渲染 PDF(暂不实现)。"""
raise NotImplementedError("TODO: chromium-based PDF rendering")
def _save_pdf_from_text(text: str, base_name: str) -> Path:
"""TODO: 纯文本 PDF 渲染(暂不实现)。"""
raise NotImplementedError("TODO: text-based PDF rendering")
@app.post("/export/report")
async def export_report(body: dict = Body(...)):
"""
接收全部聊天历史(messages: [{role, content}...]),抽取 .. ~ ..
仅生成 Markdown 文件并保存到 workspace;PDF 渲染留作 TODO。
"""
try:
messages = body.get("messages", [])
title = (body.get("title") or "").strip()
session_id = body.get("session_id", "default")
workspace_dir = get_session_workspace(session_id)
if not isinstance(messages, list):
raise HTTPException(status_code=400, detail="messages must be a list")
md_text = _extract_sections_from_messages(messages)
if not md_text:
md_text = (
"(No //// sections found.)"
)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_title = re.sub(r"[^\w\-_.]+", "_", title) if title else "Report"
base_name = f"{safe_title}_{ts}" if title else f"Report_{ts}"
# Save MD into generated/ folder under workspace
export_dir = os.path.join(workspace_dir, "generated")
os.makedirs(export_dir, exist_ok=True)
md_path = _save_md(md_text, base_name, export_dir)
# PDF 暂不生成(TODO)。
pdf_path = _save_pdf(md_text, base_name, export_dir)
result = {
"message": "exported",
"md": md_path.name,
"pdf": pdf_path.name if pdf_path else None,
"download_urls": {
"md": build_download_url(f"{session_id}/generated/{md_path.name}"),
"pdf": (
build_download_url(f"{session_id}/generated/{pdf_path.name}")
if pdf_path
else None
),
},
}
return JSONResponse(result)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
print("🚀 启动后端服务...")
print(f" - API服务: http://localhost:8200")
print(f" - 文件服务: http://localhost:8100")
uvicorn.run(app, host="0.0.0.0", port=8200)