-
Notifications
You must be signed in to change notification settings - Fork 695
Expand file tree
/
Copy pathbackend.py
More file actions
955 lines (826 loc) · 32.9 KB
/
Copy pathbackend.py
File metadata and controls
955 lines (826 loc) · 32.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
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,并确保</Code>后代码块闭合。
"""
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 "</Answer>" in cur_res:
finished = True
break
if chunk.choices[0].finish_reason == "stop" and not finished:
if not cur_res.endswith("</Code>"):
missing_tag = "</Code>"
cur_res += missing_tag
assistant_reply += missing_tag
yield missing_tag
if "</Code>" in cur_res and not finished:
messages.append({"role": "assistant", "content": cur_res})
code_match = re.search(r"<Code>(.*?)</Code>", 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 内部放控制台输出;新:追加 <File> 段落给前端渲染卡片
exe_str = f"\n<Execute>\n```\n{exe_output}\n```\n</Execute>\n"
file_block = ""
if artifact_paths:
lines = ["<File>"]
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>")
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:
"""从历史消息中抽取 <Answer>..</Answer> 作为报告主体,其余部分按原始顺序作为 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"<html><head><meta charset='utf-8'><title>{doc_title}</title></head><body><pre>{safe}</pre></body></html>"
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}...]),抽取 <Analyze>..</Analyze> ~ <Answer>..</Answer>
仅生成 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 <Analyze>/<Understand>/<Code>/<Execute>/<Answer> 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)