""" 每个智能体在工作区根目录下对应一个 Markdown 对话文件(自动追加)。 与 builtin_tools._local_file_workspace_root 使用同一根目录,避免越权路径。 """ from __future__ import annotations import json import logging from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional from sqlalchemy.orm import Session from app.core.config import settings from app.models.execution import Execution from app.models.agent import Agent from app.services.builtin_tools import _local_file_workspace_root logger = logging.getLogger(__name__) _MAX_BODY_CHARS = 100_000 def _workspace_subdir() -> str: s = (getattr(settings, "AGENT_WORKSPACE_CHAT_SUBDIR", None) or "agent_workspaces").strip() return s.strip("/\\") or "agent_workspaces" def _extract_user_text(input_data: Optional[Dict[str, Any]]) -> str: if not input_data: return "" keys = ("query", "USER_INPUT", "message", "content", "text", "prompt", "user_message") for k in keys: v = input_data.get(k) if v is None: continue if isinstance(v, str) and v.strip(): return v.strip() if v is not None and not isinstance(v, (dict, list)): s = str(v).strip() if s: return s filtered = {k: v for k, v in input_data.items() if not str(k).startswith("__")} try: raw = json.dumps(filtered, ensure_ascii=False, default=str) except Exception: raw = str(filtered) if len(raw) > _MAX_BODY_CHARS: raw = raw[:_MAX_BODY_CHARS] + "\n\n…(已截断)" return raw or "" def _extract_assistant_text(result: Optional[Dict[str, Any]]) -> str: if not result: return "" r = result.get("result") if r is None: try: raw = json.dumps(result, ensure_ascii=False, default=str) except Exception: raw = str(result) elif isinstance(r, str): return r if len(r) <= _MAX_BODY_CHARS else r[:_MAX_BODY_CHARS] + "\n\n…(已截断)" else: try: raw = json.dumps(r, ensure_ascii=False, default=str) except Exception: raw = str(r) if len(raw) > _MAX_BODY_CHARS: raw = raw[:_MAX_BODY_CHARS] + "\n\n…(已截断)" return raw def _file_already_has_execution_id(md_path: Path, execution_id: str) -> bool: """同一执行只记一次,避免 Celery 与 API 补救重复写入。""" if not md_path.exists(): return False try: text = md_path.read_text(encoding="utf-8", errors="replace") except OSError: return False needle = f"**执行 ID**:`{execution_id}`" return needle in text def _user_id_from_input(input_data: Optional[Dict[str, Any]]) -> Optional[str]: if not input_data: return None for k in ("user_id", "USER_ID", "userId"): v = input_data.get(k) if v is None: continue s = str(v).strip() if s: return s return None def _extract_attachments(input_data: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]: if not input_data: return [] raw = input_data.get("attachments") if not isinstance(raw, list): return [] out: List[Dict[str, Any]] = [] for item in raw: if not isinstance(item, dict): continue rel = str(item.get("relative_path") or "").strip() name = str(item.get("filename") or "").strip() if not rel: continue content_type = str(item.get("content_type") or "").strip() or None out.append( { "relative_path": rel, "filename": name or rel.rsplit("/", 1)[-1], "content_type": content_type, } ) return out def fetch_agent_preview_chat_turns( db: Session, agent_id: str, *, preview_user_id: Optional[str] = None, limit: int = 50, max_scan: int = 500, ) -> List[Dict[str, Any]]: """ 设计器预览侧恢复对话:从已完成执行解析用户/助手文本。 preview_user_id 与 input_data.user_id 一致时只返回该浏览器预览会话的记录。 """ from app.models.execution import Execution limit = max(1, min(int(limit), 200)) max_scan = max(limit, min(int(max_scan), 1000)) rows = ( db.query(Execution) .filter( Execution.agent_id == agent_id, Execution.status == "completed", ) .order_by(Execution.created_at.desc()) .limit(max_scan) .all() ) picked: List[Execution] = [] for ex in rows: if preview_user_id: inp = ex.input_data if isinstance(ex.input_data, dict) else None uid = _user_id_from_input(inp) if uid != preview_user_id: continue picked.append(ex) if len(picked) >= limit: break picked.reverse() out: List[Dict[str, Any]] = [] for ex in picked: inp = ex.input_data if isinstance(ex.input_data, dict) else None od = ex.output_data wf: Dict[str, Any] = od if isinstance(od, dict) else {"result": od} user_text = (_extract_user_text(inp) or "").strip() agent_text = (_extract_assistant_text(wf) or "").strip() if not user_text and not agent_text: continue out.append( { "execution_id": str(ex.id), "created_at": ex.created_at, "user_text": user_text or "(无文本)", "agent_text": agent_text or "(无输出)", "attachments": _extract_attachments(inp), } ) return out def append_agent_dialogue_md( *, db: Session, execution: Execution, input_data: Optional[Dict[str, Any]], workflow_result: Dict[str, Any], ) -> Optional[Path]: """ 在 agent_workspaces//dialogue.md 末尾追加一轮对话。 仅当 execution.agent_id 存在且开关开启时写入。 """ if not getattr(settings, "AGENT_WORKSPACE_CHAT_LOG_ENABLED", True): return None if not execution or not getattr(execution, "agent_id", None): return None agent_id = str(execution.agent_id) ag = db.query(Agent).filter(Agent.id == execution.agent_id).first() agent_name = (ag.name if ag and ag.name else agent_id).strip() or agent_id root = _local_file_workspace_root() sub = _workspace_subdir() agent_dir = root / sub / agent_id try: agent_dir.mkdir(parents=True, exist_ok=True) except OSError as e: logger.warning("创建智能体对话目录失败: %s", e) return None md_path = agent_dir / "dialogue.md" eid = str(execution.id) if _file_already_has_execution_id(md_path, eid): logger.debug("对话 MD 已包含执行 %s,跳过追加", eid) return md_path user_text = _extract_user_text(input_data) assistant_text = _extract_assistant_text(workflow_result) uid = _user_id_from_input(input_data) ts = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S %z") rel_note = f"{sub}/{agent_id}/dialogue.md" block_lines = [ f"### {ts}", "", f"- **执行 ID**:`{eid}`", ] if uid: block_lines.append(f"- **用户 ID**:`{uid}`") block_lines.extend( [ "", "**用户**", "", user_text or "(无文本输入,详见 input_data)", "", "**助手**", "", assistant_text or "(无输出)", "", "---", "", ] ) block = "\n".join(block_lines) try: if not md_path.exists(): header = "\n".join( [ f"# 对话记录 · {agent_name}", "", f"- **Agent ID**:`{agent_id}`", f"- **文件路径**(相对工作区根):`{rel_note}`", "- **说明**:每次关联本智能体的执行成功完成后自动追加。", "", "---", "", ] ) md_path.write_text(header + block, encoding="utf-8") else: with md_path.open("a", encoding="utf-8", newline="\n") as f: f.write(block) except OSError as e: logger.warning("写入智能体对话 MD 失败: %s", e) return None logger.info("智能体对话已写入: %s", md_path) return md_path def ensure_agent_dialogue_logged_from_db_execution( db: Session, execution: Execution ) -> Optional[Path]: """ 根据已落库的执行记录补写对话 MD(供 GET execution 等路径调用)。 仅 completed + 有 agent_id + 有 output_data 时写入;与 Celery 路径幂等。 """ if not getattr(settings, "AGENT_WORKSPACE_CHAT_LOG_ENABLED", True): return None if not execution or execution.status != "completed": return None if not getattr(execution, "agent_id", None): return None od = execution.output_data if od is None: return None wf: Dict[str, Any] if isinstance(od, dict): wf = od else: wf = {"result": od} try: return append_agent_dialogue_md( db=db, execution=execution, input_data=execution.input_data if isinstance(execution.input_data, dict) else None, workflow_result=wf, ) except Exception as e: logger.warning("从执行记录补写对话 MD 失败: %s", e) return None def try_append_agent_dialogue_after_success( db: Session, execution: Optional[Execution], input_data: Optional[Dict[str, Any]], workflow_result: Optional[Dict[str, Any]], execution_logger: Any = None, ) -> None: if not workflow_result: return try: append_agent_dialogue_md( db=db, execution=execution, input_data=input_data, workflow_result=workflow_result, ) except Exception as e: msg = f"写入智能体对话 MD 异常: {e}" logger.warning(msg) if execution_logger: execution_logger.warn(msg)