346 lines
14 KiB
Python
346 lines
14 KiB
Python
import signal
|
||
import sys
|
||
import os
|
||
import time
|
||
import re
|
||
import uuid
|
||
current_directory = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.append(current_directory)
|
||
from flask_socketio import SocketIO, join_room, emit, Namespace
|
||
import os
|
||
from apps.models.function import FunctionResponse
|
||
from apps.function_tools.function_manager import FunctionManager
|
||
from dataclasses import dataclass, field
|
||
from typing import Dict, Optional
|
||
import subprocess
|
||
import threading
|
||
from collections import deque
|
||
from eventlet.semaphore import Semaphore
|
||
from apps.utils import *
|
||
|
||
EMPTY_CHAPTER_CHAIN = [ Chapter(1, CHAPTER_FOCUS, "本章是未打开某个具体章节时的默认章节。", "处于本章节时不会有任何章节跳转。请作为一名经验丰富的算法教师,回答用户的问题。") ]
|
||
|
||
@dataclass
|
||
class ProcRecord:
|
||
popen: subprocess.Popen
|
||
user_id: str
|
||
path_dir: str
|
||
port: int
|
||
deadline_ts: Optional[float] = None
|
||
timer: Optional[threading.Timer] = field(default=None, repr=False)
|
||
|
||
|
||
class ChatManager:
|
||
_procs: Dict[str, ProcRecord] = {}
|
||
_lock = threading.RLock()
|
||
def __init__(self):
|
||
self.chapter_chain_now = -1
|
||
self.ase_client = None
|
||
self.app = None
|
||
self.socketio = None
|
||
self.user_uuid = None
|
||
self.raw_markdown = None
|
||
self.raw_markdown_prompts = None
|
||
self.raw_score_prompts = None
|
||
self.material_id = None
|
||
self.chapter_name = None
|
||
self.lesson_name = None
|
||
self.chapter_chain = None
|
||
self.function_manager = FunctionManager()
|
||
self.bb = None
|
||
self.chat_historys = None
|
||
self.vscode_ws = None
|
||
self._vscode_queue = deque()
|
||
self._lock = Semaphore(1)
|
||
|
||
|
||
def init(self,user_uuid,ase_client,raw_markdown,raw_markdown_prompts,raw_score_prompts,material_id,chapter_name, lesson_name, max_iter = 5, app=None, socketio=None):
|
||
self.ase_client = ase_client
|
||
self.app = app
|
||
self.socketio = socketio
|
||
self.user_uuid = user_uuid
|
||
self.raw_markdown = raw_markdown
|
||
self.raw_markdown_prompts = raw_markdown_prompts
|
||
self.raw_score_prompts = raw_score_prompts
|
||
self.material_id = material_id
|
||
self.chapter_name = chapter_name
|
||
self.lesson_name = lesson_name
|
||
self.chapter_chain = load_chapters(raw_markdown, raw_markdown_prompts, raw_score_prompts)
|
||
self.bb = None
|
||
self.chat_historys = []
|
||
|
||
|
||
def send_to_vscode(self, event: str, data: dict, room: str = None, namespace: str = '/vscode'):
|
||
"""统一发送入口:未就绪则入队,就绪则直接 emit。"""
|
||
if room is None:
|
||
room = self.user_uuid
|
||
|
||
with self._lock:
|
||
if self.vscode_ws is not None:
|
||
# 已就绪,直接发
|
||
self.vscode_ws.emit(event, data, room=room, namespace=namespace)
|
||
else:
|
||
# 未就绪,入队缓存
|
||
self._vscode_queue.append((event, data, room, namespace))
|
||
|
||
def set_vscode_ws(self, ws):
|
||
"""在 on_login 时调用,注册 websocket 并立刻 flush 队列。"""
|
||
with self._lock:
|
||
self.vscode_ws = ws
|
||
self._flush_vscode_queue_locked()
|
||
|
||
def _flush_vscode_queue_locked(self):
|
||
"""发送缓存的消息(需在持锁状态下调用)。"""
|
||
while self._vscode_queue:
|
||
event, data, room, namespace = self._vscode_queue.popleft()
|
||
# 这里假设 ws 仍然有效;如果发送失败可以考虑 try/except 并回滚入队
|
||
self.vscode_ws.emit(event, data, room=room, namespace=namespace)
|
||
def mark_vscode_disconnected(self):
|
||
"""可在 on_disconnect 里调用,标记断开(之后会继续缓存)。"""
|
||
with self._lock:
|
||
self.vscode_ws = None
|
||
self._vscode_queue.clear()
|
||
|
||
def disconnect(self, uuid):
|
||
try:
|
||
self.mark_vscode_disconnected()
|
||
self.stop(uuid)
|
||
self.ase_client.disconnect()
|
||
except Exception as e:
|
||
print("ERROR: disconnect",e)
|
||
print("disconnect success stop code-server success")
|
||
|
||
def load_code_in_markdown(self):
|
||
nowchapter_content = self.chapter_chain[self.chapter_chain_now].chapter
|
||
code_texts = extract_code_blocks(nowchapter_content)
|
||
for code_text in code_texts:
|
||
self.send_to_vscode('code-file', {'type':'create', 'data':code_text, 'chapter_title': self.chapter_chain[self.chapter_chain_now].title}, room=self.user_uuid ,namespace='/vscode')
|
||
|
||
|
||
def next_chapter(self):
|
||
assert self.chapter_chain_now + 1 < len(self.chapter_chain), "chapter_chain_now out of range"
|
||
self.chapter_chain_now += 1
|
||
self.bb.next_chapter()
|
||
self.focus_chapter(self.chapter_chain_now)
|
||
|
||
def focus_chapter(self, chapter_index):
|
||
assert chapter_index < len(self.chapter_chain), "chapter_index out of range"
|
||
self.chapter_chain_now = chapter_index
|
||
self.chapter_chain[chapter_index].Focus()
|
||
|
||
def load_next_chapter(self):
|
||
print(f"now load next chapter markdown {self.chapter_chain_now}")
|
||
self.ase_client.loadmarkdown(self.chapter_chain[self.chapter_chain_now].chapter, self.chapter_chain[self.chapter_chain_now].chapter_prompt, self.chapter_chain[self.chapter_chain_now].score_prompt)
|
||
self.load_code_in_markdown()
|
||
|
||
|
||
def upload_bb(self):
|
||
self.ase_client.send_text('backboard-in', self.bb.get_info_prompt())
|
||
|
||
def message_pass(self, messagetype, message):
|
||
print("message_pass", self.user_uuid, messagetype, message)
|
||
with self.app.app_context():
|
||
try:
|
||
self.socketio.emit(messagetype, message, room=self.user_uuid, namespace='/agent')
|
||
except Exception as e:
|
||
print("message_pass",e)
|
||
|
||
def system_message(self, message):
|
||
with self.app.app_context():
|
||
try:
|
||
self.socketio.emit('system_message', message, room=self.user_uuid, namespace='/agent')
|
||
except Exception as e:
|
||
print("ERROR: system_message",e)
|
||
|
||
|
||
def save_chapter_memory(self, agent,course_id, lesson_id, subchapter_title, mem_list, score, is_rebuttal):
|
||
|
||
self.app.my_function.save_chapter_memory(self.user_uuid, course_id, lesson_id, subchapter_title, mem_list, score, is_rebuttal)
|
||
|
||
|
||
|
||
def function_call(self, function_name, arg_function):
|
||
return self.function_manager.call_function(function_name, arg_function)
|
||
|
||
def change_language(self, id, language):
|
||
self.ase_client.send_text('language', language)
|
||
|
||
def sample_judge(self, id, bb):
|
||
with self.app.app_context():
|
||
try:
|
||
print(bb.get_active_file_reletive_path())
|
||
assert bb is not None and bb.active_file_path != "" , "no path specified"
|
||
if bb.get_active_file_reletive_path().endswith(".py"):
|
||
self.socketio.emit('terminal', {'data': f"python {bb.get_active_file_reletive_path()}"}, room=id, namespace='/vscode')
|
||
return FunctionResponse("sample_judge", True, "代码试运行命令已发送,请于Terminal查看结果")
|
||
else:
|
||
self.socketio.emit('system_message', "代码试运行尚仅支持python文件", room=id, namespace='/agent')
|
||
return FunctionResponse("sample_judge", False, "代码试运行尚仅支持python文件")
|
||
except Exception as e:
|
||
print(e)
|
||
if (str(e) == "no path specified"):
|
||
self.socketio.emit("system_message", "未加载IDE文件同步,请尝试重新打开IDE文件或刷新页面,并确保插件已连接", room=id, namespace='/agent')
|
||
return FunctionResponse("sample_judge", False, "未加载IDE文件同步,请尝试重新打开IDE文件或刷新页面,并确保插件已连接")
|
||
|
||
def judge(self, id, bb):
|
||
with self.app.app_context():
|
||
try:
|
||
if (self.chapter_chain[self.chapter_chain_now].append_tools is not None and len(self.chapter_chain[self.chapter_chain_now].append_tools)):
|
||
for tool, tool_kwargs in self.chapter_chain[self.chapter_chain_now].append_tools:
|
||
if tool.__name__ == "judge":
|
||
print(bb.get_active_file_reletive_path())
|
||
assert bb is not None and bb.active_file_path != "" , "no path specified"
|
||
if bb.get_active_file_reletive_path().endswith(".py"):
|
||
# self.socketio.emit('terminal', {'data': f"python {bb.get_active_file_reletive_path()}"}, room=id, namespace='/vscode')
|
||
self.socketio.emit('system_message', "代码评测中,请稍后", room=id, namespace='/agent')
|
||
return self.function_call('judge',{'filepath': bb.get_active_file_reletive_path(),'name':tool_kwargs['name']})
|
||
else:
|
||
self.socketio.emit('system_message', "代码评测尚仅支持python文件", room=id, namespace='/agent')
|
||
return FunctionResponse("judge", False, "代码评测尚仅支持python文件")
|
||
else:
|
||
with self.app.app_context():
|
||
self.socketio.emit("system_message", "本章学习无需评测", room=id, namespace='/agent')
|
||
return FunctionResponse("judge", False, "本章学习无需评测")
|
||
else:
|
||
with self.app.app_context():
|
||
self.socketio.emit("system_message", "本章学习无需评测", room=id, namespace='/agent')
|
||
return FunctionResponse("judge", False, "本章学习无需评测")
|
||
except Exception as e:
|
||
if (str(e) == "no path specified"):
|
||
self.socketio.emit("system_message", "未加载IDE文件同步,请尝试重新打开IDE文件,或刷新页面,并确保插件已连接", room=id, namespace='/agent')
|
||
return FunctionResponse("judge", False, "未加载IDE文件同步,请尝试重新打开IDE文件,或刷新页面,并确保插件已连接")
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
def start_code_server(self, key: str, user_id: str, path_dir: str, code_server_port: int, ttl_seconds: Optional[int] = None) -> ProcRecord:
|
||
# key is user_uuid
|
||
# 注意:建议使用 start_new_session=True 方便后续 kill 进程组
|
||
# 如果必须 sudo,最好加 -n(非交互)避免卡住;也可以考虑用 systemd-run 或专用守护
|
||
cmd = [
|
||
"sudo", "-u", user_id,
|
||
"code-server",
|
||
"--bind-addr", f"0.0.0.0:{code_server_port}",
|
||
"--auth", "none",
|
||
path_dir,
|
||
"--extensions-dir", "/share/extension/"
|
||
]
|
||
try:# 创建.config,.local文件夹
|
||
subprocess.run(["sudo", "mkdir", "-p", f"/home/{user_id}/.config"], check=True)
|
||
subprocess.run(["sudo", "mkdir", "-p", f"/home/{user_id}/.local"], check=True)
|
||
subprocess.run(["sudo", "chown", "-R", f"{user_id}:shared_group_{user_id}", f"/home/{user_id}/.config", f"/home/{user_id}/.local"], check=True)
|
||
except Exception as e:
|
||
print("mkdir .config .local error"+str(e))
|
||
|
||
pop = subprocess.Popen(
|
||
cmd,
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.STDOUT,
|
||
start_new_session=True # 等价 setsid,新建进程组,方便整体结束
|
||
)
|
||
|
||
record = ProcRecord(
|
||
popen=pop,
|
||
user_id=user_id,
|
||
path_dir=path_dir,
|
||
port=code_server_port,
|
||
deadline_ts=(time.time() + ttl_seconds) if ttl_seconds else None
|
||
)
|
||
|
||
# 设置了到期停
|
||
if ttl_seconds:
|
||
t = threading.Timer(ttl_seconds, self.stop, kwargs={"key": key})
|
||
t.daemon = True
|
||
t.start()
|
||
record.timer = t
|
||
|
||
with self._lock:
|
||
self._procs[key] = record
|
||
|
||
return record
|
||
|
||
def stop(self, key: str, grace_seconds: int = 5) -> bool:
|
||
return True # code-like 不需要杀进程
|
||
with self._lock:
|
||
rec = self._procs.pop(key, None)
|
||
|
||
if not rec:
|
||
return False
|
||
|
||
# 先取消到期定时器(如果是手动停)
|
||
if rec.timer:
|
||
try:
|
||
rec.timer.cancel()
|
||
except Exception:
|
||
pass
|
||
|
||
pop = rec.popen
|
||
if pop.poll() is not None:
|
||
return True # 已退出
|
||
|
||
try:
|
||
subprocess.run(
|
||
["sudo", "kill", "-TERM", str(pop.pid)],
|
||
check=True,
|
||
stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||
)
|
||
except ProcessLookupError:
|
||
return True
|
||
|
||
# 等待优雅退出
|
||
try:
|
||
print("wait for code-server to exit")
|
||
pop.wait(timeout=grace_seconds)
|
||
return True
|
||
except subprocess.TimeoutExpired:
|
||
pass
|
||
|
||
# 兜底:强杀进程组
|
||
try:
|
||
print("kill code-server")
|
||
subprocess.run(
|
||
["sudo", "kill", "-KILL", str(pop.pid)],
|
||
check=True,
|
||
stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||
)
|
||
except ProcessLookupError:
|
||
pass
|
||
finally:
|
||
try:
|
||
pop.wait(timeout=1)
|
||
except Exception:
|
||
pass
|
||
|
||
return True
|
||
|
||
def get(self, key: str) -> Optional[ProcRecord]:
|
||
with self._lock:
|
||
return self._procs.get(key)
|
||
|
||
def stop_all(self):
|
||
with self._lock:
|
||
keys = list(self._procs.keys())
|
||
for k in keys:
|
||
self.stop(k)
|
||
|
||
|
||
def extract_code_blocks(text):
|
||
"""
|
||
从文本中提取所有用```标记的代码段(包括标记本身)
|
||
|
||
参数:
|
||
text: 包含代码段的原始文本
|
||
|
||
返回:
|
||
提取到的代码段列表,每个元素是一个完整的代码块(包括```标记)
|
||
"""
|
||
# 正则表达式匹配```开始和结束的代码块
|
||
# 考虑可能的语言指定(如```python)
|
||
pattern = r'```.*?```'
|
||
# 使用DOTALL模式让.匹配包括换行符在内的所有字符
|
||
code_blocks = re.findall(pattern, text, re.DOTALL)
|
||
|
||
return code_blocks |