2026-04-13 20:17:18 +08:00
|
|
|
|
"""
|
|
|
|
|
|
每个智能体在工作区根目录下对应一个 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-13 22:52:36 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-13 20:17:18 +08:00
|
|
|
|
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 "(无输出)",
|
2026-04-13 22:52:36 +08:00
|
|
|
|
"attachments": _extract_attachments(inp),
|
2026-04-13 20:17:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
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/<agent_id>/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)
|