2373 lines
108 KiB
Python
2373 lines
108 KiB
Python
"""Scheduled task orchestration service."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import mimetypes
|
||
import re
|
||
import shutil
|
||
import uuid
|
||
from dataclasses import dataclass
|
||
from datetime import UTC, datetime, timedelta
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from typing import Any
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from croniter import croniter
|
||
from langgraph.checkpoint.base import empty_checkpoint
|
||
|
||
from deerflow.config.agents_config import AGENT_ID_PATTERN, load_agent_config, load_agent_soul
|
||
from deerflow.config.paths import get_paths
|
||
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
|
||
from deerflow.runtime.scheduler import html_page as html_page_utils
|
||
from deerflow.runtime.scheduler import template_fill
|
||
from deerflow.runtime.scheduler.element import ElementDeliveryClient
|
||
from deerflow.runtime.serialization import serialize_channel_values
|
||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||
from deerflow.utils.time import now_iso
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# A single execution attempt may not exceed this wall-clock budget. When it
|
||
# does, the run is treated as stuck (the agent loop hung, a tool blocked, etc.)
|
||
# and retried. The watchdog uses the same window to recover runs orphaned by a
|
||
# crash or restart — those stay "running" in the DB with no in-process owner.
|
||
_RUN_ATTEMPT_TIMEOUT_SECONDS = 30 * 60 # 30 minutes
|
||
# Retries allowed after the first attempt before the run is marked "failed".
|
||
_MAX_RUN_RETRIES = 3
|
||
|
||
# Max HTML length (chars) inlined into the run's ``result_json``. Beyond this
|
||
# the inline copy is dropped to stay under MySQL's 64 KB ``TEXT`` cap; the full
|
||
# document is always retrievable from the page store (LONGTEXT) + index.html.
|
||
_MAX_INLINE_RESULT_HTML = 60000
|
||
|
||
# Minimum length (in characters) the generated Markdown file's content must
|
||
# reach when a task has ``require_markdown`` enabled. The model is NOT told about
|
||
# this threshold — it is enforced silently by the service.
|
||
_MARKDOWN_MIN_CHARS = 100
|
||
|
||
# 模板填充模式:转换结果未通过结构校验时,把错误回喂大模型自动修正重试的最大次数
|
||
# (总尝试次数 = 1 + 该值)。
|
||
_MAX_TEMPLATE_FILL_RETRIES = 2
|
||
|
||
# Instruction appended to the prompt when ``require_markdown`` is on. It only
|
||
# asks the model to produce a ``.md`` file; the length requirement is enforced
|
||
# in code (not surfaced to the model).
|
||
_MARKDOWN_INSTRUCTION = (
|
||
"\n\n【输出要求】请将本次任务的最终结果生成为一个 Markdown(.md) 格式的文件,"
|
||
"保存到 /mnt/user-data/outputs 目录,并使用 present_files 展示该文件。"
|
||
"Markdown 内的目录/锚点跳转必须只跳转到当前这个 Markdown 文件内部:"
|
||
"只使用 [标题](#section-id),不要使用 [标题](xxx.md#section-id)、"
|
||
"[标题](xxx.html#section-id) 或任何跨文件锚点。"
|
||
)
|
||
|
||
_LOCAL_DOC_MARKDOWN_LINK_RE = re.compile(
|
||
r"^(?![a-z][a-z0-9+.-]*:)(?!//)(?P<path>[^#?]*\.(?:html?|md|markdown))(?P<query>\?[^#]*)?(?P<fragment>#.+)?$",
|
||
re.IGNORECASE,
|
||
)
|
||
_MARKDOWN_INLINE_LINK_RE = re.compile(r"(?<!!)\[([^\]]+)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
|
||
_MARKDOWN_REFERENCE_LINK_RE = re.compile(r"^(\s*\[[^\]]+\]:\s*)(\S+)(.*)$", re.MULTILINE)
|
||
|
||
|
||
class MarkdownRequirementError(Exception):
|
||
"""Raised when a ``require_markdown`` task did not produce a valid .md file.
|
||
|
||
The run is marked failed (no retry) so the user can see the output did not
|
||
meet the requirement (a Markdown file longer than the minimum length).
|
||
"""
|
||
|
||
|
||
class HtmlGenerationError(Exception):
|
||
"""Raised when an ``html_page`` run did not produce a complete, valid HTML
|
||
document (missing doctype/html/head/body, empty body, or residual scripts
|
||
after the sanitize + repair passes). The run is marked failed — a page only
|
||
counts as successful when it is a full, previewable HTML document."""
|
||
|
||
|
||
class TemplateFillError(Exception):
|
||
"""Raised when a 模板填充 run could not produce a valid template payload —
|
||
the source JSON was empty, the model never returned parseable JSON, or the
|
||
converted data failed structure validation after all retries. The run is
|
||
marked failed so the user sees why (no template page is archived)."""
|
||
|
||
|
||
class DuplicateFavoriteError(Exception):
|
||
"""A user already bookmarked this page. Surfaced as HTTP 409 by the router
|
||
so the same page cannot be favorited twice."""
|
||
|
||
def __init__(self, page_id: str) -> None:
|
||
self.page_id = page_id
|
||
super().__init__(f"Page {page_id!r} is already favorited")
|
||
|
||
|
||
class DuplicateTaskNameError(Exception):
|
||
"""A user already owns a scheduled task with the requested name.
|
||
|
||
Task names are unique per user (``uq_scheduled_tasks_user_name``). The
|
||
service raises this instead of letting the raw DB ``IntegrityError`` bubble
|
||
up so the API layer can return a friendly 409 rather than a 500.
|
||
"""
|
||
|
||
def __init__(self, name: str) -> None:
|
||
self.name = name
|
||
super().__init__(f"A scheduled task named {name!r} already exists")
|
||
|
||
|
||
@dataclass
|
||
class _RuntimeUser:
|
||
id: str
|
||
|
||
|
||
def _coerce_datetime(value: datetime | str | None) -> datetime | None:
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, datetime):
|
||
dt = value
|
||
else:
|
||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=UTC)
|
||
return dt.astimezone(UTC)
|
||
|
||
|
||
def _normalize_timezone(timezone: str | None) -> str:
|
||
tz = (timezone or "UTC").strip() or "UTC"
|
||
try:
|
||
ZoneInfo(tz)
|
||
return tz
|
||
except Exception:
|
||
return "UTC"
|
||
|
||
|
||
def _schedule_to_cron(schedule: str) -> str:
|
||
value = schedule.strip()
|
||
if re.fullmatch(r"(\S+\s+){4}\S+", value):
|
||
return value
|
||
|
||
lower = value.lower()
|
||
hour_min = re.search(r"(?:(?:at|@)\s*)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?", lower)
|
||
cn_hour_min = re.search(r"(\d{1,2})\s*[点時时](?:(\d{1,2})\s*分?)?", value)
|
||
|
||
hour = 9
|
||
minute = 0
|
||
ampm = None
|
||
if hour_min:
|
||
hour = int(hour_min.group(1))
|
||
minute = int(hour_min.group(2) or 0)
|
||
ampm = hour_min.group(3)
|
||
elif cn_hour_min:
|
||
hour = int(cn_hour_min.group(1))
|
||
minute = int(cn_hour_min.group(2) or 0)
|
||
|
||
if ampm == "pm" and hour < 12:
|
||
hour += 12
|
||
if ampm == "am" and hour == 12:
|
||
hour = 0
|
||
hour = max(0, min(23, hour))
|
||
minute = max(0, min(59, minute))
|
||
|
||
if any(token in lower for token in ("weekday", "weekdays", "workday")) or "工作日" in value:
|
||
return f"{minute} {hour} * * 1-5"
|
||
|
||
weekdays = {
|
||
"monday": 1,
|
||
"tuesday": 2,
|
||
"wednesday": 3,
|
||
"thursday": 4,
|
||
"friday": 5,
|
||
"saturday": 6,
|
||
"sunday": 0,
|
||
"周一": 1,
|
||
"星期一": 1,
|
||
"周二": 2,
|
||
"星期二": 2,
|
||
"周三": 3,
|
||
"星期三": 3,
|
||
"周四": 4,
|
||
"星期四": 4,
|
||
"周五": 5,
|
||
"星期五": 5,
|
||
"周六": 6,
|
||
"星期六": 6,
|
||
"周日": 0,
|
||
"周天": 0,
|
||
"星期日": 0,
|
||
"星期天": 0,
|
||
}
|
||
for token, weekday in weekdays.items():
|
||
if token in lower or token in value:
|
||
return f"{minute} {hour} * * {weekday}"
|
||
|
||
every_hours = re.search(r"every\s+(\d+)\s*h", lower)
|
||
cn_every_hours = re.search(r"每隔\s*(\d+)\s*小?时", value)
|
||
if every_hours or cn_every_hours:
|
||
hours = int((every_hours or cn_every_hours).group(1))
|
||
hours = max(1, min(23, hours))
|
||
return f"0 */{hours} * * *"
|
||
|
||
return f"{minute} {hour} * * *"
|
||
|
||
|
||
def _next_run_at(cron_expr: str, timezone: str, base: datetime | None = None) -> datetime:
|
||
tz = ZoneInfo(timezone)
|
||
base_local = (base or datetime.now(UTC)).astimezone(tz)
|
||
next_local = croniter(cron_expr, base_local).get_next(datetime)
|
||
if next_local.tzinfo is None:
|
||
next_local = next_local.replace(tzinfo=tz)
|
||
return next_local.astimezone(UTC)
|
||
|
||
|
||
def _looks_like_markdown(content: str) -> bool:
|
||
markdown_patterns = (
|
||
r"^#{1,6}\s+",
|
||
r"^\s*[-*+]\s+",
|
||
r"^\s*\d+\.\s+",
|
||
r"```",
|
||
r"\|.+\|",
|
||
r"\[[^\]]+\]\([^)]+\)",
|
||
)
|
||
return any(re.search(pattern, content, flags=re.MULTILINE) for pattern in markdown_patterns)
|
||
|
||
|
||
def _task_requires_markdown(task: dict[str, Any]) -> bool:
|
||
"""Whether a task has the ``require_markdown`` flag set.
|
||
|
||
The flag lives inside ``execution_context`` (no dedicated column) so the
|
||
feature works without a schema migration on existing databases.
|
||
"""
|
||
ctx = task.get("execution_context")
|
||
return bool(isinstance(ctx, dict) and ctx.get("require_markdown"))
|
||
|
||
|
||
def _task_requires_html(task: dict[str, Any]) -> bool:
|
||
"""Whether a task must produce an HTML page (the ``必须生成 HTML`` switch).
|
||
|
||
The flag lives inside ``execution_context.require_html``. For backward
|
||
compatibility, tasks created under the old two-tab UI (which stored
|
||
``task_kind == "html_page"``) are still treated as HTML tasks, so no data
|
||
migration is needed. ``require_html`` and ``require_markdown`` are
|
||
independent — both may be on at once.
|
||
"""
|
||
ctx = task.get("execution_context")
|
||
if not isinstance(ctx, dict):
|
||
return False
|
||
if ctx.get("require_html"):
|
||
return True
|
||
return ctx.get("task_kind") == "html_page"
|
||
|
||
|
||
def _task_template_id(task: dict[str, Any]) -> str | None:
|
||
"""The HTML template a 模板填充 task targets, or ``None`` for a normal task.
|
||
|
||
Stored at ``execution_context.template_config.template_id``. When set, the
|
||
run converts the source JSON into that template's ``DATA`` structure and
|
||
fills it — a distinct mode from the free-form HTML render pipeline.
|
||
"""
|
||
ctx = task.get("execution_context")
|
||
if not isinstance(ctx, dict):
|
||
return None
|
||
cfg = ctx.get("template_config")
|
||
if not isinstance(cfg, dict):
|
||
return None
|
||
template_id = cfg.get("template_id")
|
||
return str(template_id) if template_id else None
|
||
|
||
|
||
def _has_valid_markdown_file(result_files: list[dict[str, Any]] | None) -> bool:
|
||
"""True when the run produced a ``.md`` file whose text exceeds the minimum.
|
||
|
||
For tasks with ``require_markdown`` enabled the run only succeeds if at least
|
||
one generated file is Markdown (``.md``/``.markdown``) and its content is
|
||
longer than :data:`_MARKDOWN_MIN_CHARS` characters. The file is read from its
|
||
archived path; binary/undecodable files never qualify.
|
||
"""
|
||
for item in result_files or []:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
name = str(item.get("name") or "")
|
||
if not name.lower().endswith((".md", ".markdown")):
|
||
continue
|
||
path = item.get("path")
|
||
if not isinstance(path, str) or not path:
|
||
continue
|
||
try:
|
||
text = Path(path).read_text(encoding="utf-8", errors="ignore")
|
||
except Exception:
|
||
continue
|
||
if len(text.strip()) > _MARKDOWN_MIN_CHARS:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _normalize_same_page_markdown_target(target: str) -> str | None:
|
||
value = (target or "").strip()
|
||
if not value or value.startswith("#"):
|
||
return value
|
||
match = _LOCAL_DOC_MARKDOWN_LINK_RE.match(value)
|
||
if not match:
|
||
return value
|
||
fragment = match.group("fragment")
|
||
return fragment or None
|
||
|
||
|
||
def _constrain_markdown_same_page_anchor_links(content: str) -> str:
|
||
"""Keep generated Markdown document jumps inside the current Markdown file."""
|
||
if not content or not isinstance(content, str):
|
||
return content or ""
|
||
|
||
def replace_inline(match: re.Match[str]) -> str:
|
||
label = match.group(1)
|
||
target = match.group(2)
|
||
normalized = _normalize_same_page_markdown_target(target)
|
||
if normalized is None:
|
||
return label
|
||
return f"[{label}]({normalized})"
|
||
|
||
def replace_reference(match: re.Match[str]) -> str:
|
||
prefix = match.group(1)
|
||
target = match.group(2)
|
||
suffix = match.group(3)
|
||
normalized = _normalize_same_page_markdown_target(target)
|
||
if normalized is None:
|
||
return f"{prefix}#"
|
||
return f"{prefix}{normalized}{suffix}"
|
||
|
||
content = _MARKDOWN_INLINE_LINK_RE.sub(replace_inline, content)
|
||
return _MARKDOWN_REFERENCE_LINK_RE.sub(replace_reference, content)
|
||
|
||
|
||
def _build_result(
|
||
task: dict[str, Any],
|
||
*,
|
||
scheduled_for: datetime,
|
||
content: str | None,
|
||
files: list[dict[str, Any]] | None = None,
|
||
) -> dict[str, Any]:
|
||
result_content = content or f"Scheduled task completed: {task['name']}"
|
||
task_tz = ZoneInfo(task.get("timezone") or "UTC")
|
||
local_time = scheduled_for.astimezone(task_tz)
|
||
return {
|
||
"title": f"{task['name']} - {local_time.strftime('%Y-%m-%d %H:%M')}",
|
||
"content": result_content,
|
||
"format": "markdown" if _looks_like_markdown(result_content) else "text",
|
||
"files": files or [],
|
||
"created_at": datetime.now(UTC).isoformat(),
|
||
}
|
||
|
||
|
||
class ScheduledTaskService:
|
||
def __init__(self, app: Any, store: ScheduledTaskStore, *, poll_interval_seconds: float = 30.0) -> None:
|
||
self.app = app
|
||
self.store = store
|
||
self.poll_interval_seconds = poll_interval_seconds
|
||
self._loop_task: asyncio.Task | None = None
|
||
self._stopping = asyncio.Event()
|
||
self._running_task_ids: set[str] = set()
|
||
# All scheduled jobs deliver into one per-user scheduler thread. The
|
||
# current RunManager rejects concurrent runs on the same thread, so
|
||
# serialize starts here instead of letting reports fail with conflicts.
|
||
self._agent_run_lock = asyncio.Lock()
|
||
self._element_client = ElementDeliveryClient.from_env()
|
||
|
||
async def start(self) -> None:
|
||
if self._loop_task is None or self._loop_task.done():
|
||
self._stopping.clear()
|
||
self._loop_task = asyncio.create_task(self._run_loop(), name="scheduled-task-loop")
|
||
|
||
async def stop(self) -> None:
|
||
self._stopping.set()
|
||
if self._loop_task is not None:
|
||
self._loop_task.cancel()
|
||
try:
|
||
await self._loop_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
|
||
async def ensure_scheduler_thread(self, user_id: str) -> dict[str, Any]:
|
||
thread = await self.store.get_or_create_scheduler_thread(user_id, lambda: str(uuid.uuid4()))
|
||
await self._ensure_thread_meta(user_id, thread["thread_id"])
|
||
return thread
|
||
|
||
async def list_tasks(self, user_id: str) -> list[dict[str, Any]]:
|
||
owned = await self.store.list_tasks(user_id)
|
||
for task in owned:
|
||
task["role"] = "owner"
|
||
subscribed = await self.store.list_subscribed_tasks(user_id)
|
||
owned_ids = {task["task_id"] for task in owned}
|
||
for task in subscribed:
|
||
if task["task_id"] in owned_ids:
|
||
continue
|
||
task["role"] = "subscriber"
|
||
owned.append(task)
|
||
return owned
|
||
|
||
async def list_published(self, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
|
||
"""Public discovery list. Strips author prompt details to a short summary."""
|
||
tasks = await self.store.list_published_tasks(limit=limit, offset=offset)
|
||
result: list[dict[str, Any]] = []
|
||
for task in tasks:
|
||
prompt = (task.get("prompt") or "").strip()
|
||
summary = prompt if len(prompt) <= 200 else prompt[:200].rstrip() + "…"
|
||
result.append(
|
||
{
|
||
"task_id": task["task_id"],
|
||
"name": task.get("name"),
|
||
"prompt_summary": summary,
|
||
"schedule_text": task.get("schedule_text"),
|
||
"timezone": task.get("timezone"),
|
||
"published_at": task.get("published_at"),
|
||
"owner_user_id": task.get("user_id"),
|
||
}
|
||
)
|
||
return result
|
||
|
||
async def _accessible_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
|
||
task = await self.store.get_task_any(task_id)
|
||
if task is None:
|
||
return None
|
||
if task["user_id"] == user_id:
|
||
task["role"] = "owner"
|
||
return task
|
||
sub = await self.store.get_subscription(task_id, user_id)
|
||
if sub is None:
|
||
return None
|
||
task["role"] = "subscriber"
|
||
task["_subscription"] = sub
|
||
return task
|
||
|
||
async def list_runs(self, user_id: str, task_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
|
||
accessible = await self._accessible_task(user_id, task_id)
|
||
if accessible is None:
|
||
return []
|
||
runs = await self.store.list_runs(task_id, accessible["user_id"], limit=limit)
|
||
can_edit = accessible.get("role") == "owner"
|
||
for run in runs:
|
||
run["can_edit"] = can_edit
|
||
return runs
|
||
|
||
async def get_run(self, user_id: str, run_id: str) -> dict[str, Any] | None:
|
||
run = await self.store.get_run(run_id, user_id)
|
||
if run is not None:
|
||
run["can_edit"] = True
|
||
return run
|
||
# Subscriber visibility: look up the run unfiltered, then verify the
|
||
# caller has a subscription on the parent task.
|
||
candidate = await self.store.get_run_any(run_id)
|
||
if candidate is None:
|
||
return None
|
||
if await self.store.get_subscription(candidate["task_id"], user_id) is None:
|
||
return None
|
||
candidate["can_edit"] = False
|
||
return candidate
|
||
|
||
async def update_run_file(
|
||
self,
|
||
user_id: str,
|
||
run_id: str,
|
||
file_index: int,
|
||
new_content: str,
|
||
) -> dict[str, Any]:
|
||
"""Overwrite an archived run file in place. Owner-only.
|
||
|
||
Returns updated file metadata. Raises:
|
||
- PermissionError if the caller is not the run owner (subscribers blocked)
|
||
- FileNotFoundError if the run or file index does not exist
|
||
- ValueError if the file is not editable (binary / not previewable)
|
||
"""
|
||
run = await self.store.get_run(run_id, user_id)
|
||
if run is None:
|
||
candidate = await self.store.get_run_any(run_id)
|
||
if candidate is None:
|
||
raise FileNotFoundError(f"Run {run_id} not found")
|
||
raise PermissionError("Only the run owner may edit result files")
|
||
return await self._apply_run_file_edit(run, run_id, file_index, new_content)
|
||
|
||
async def admin_update_run_file(
|
||
self,
|
||
run_id: str,
|
||
file_index: int,
|
||
new_content: str,
|
||
) -> dict[str, Any]:
|
||
"""Owner-less variant of :meth:`update_run_file` used by the public viewer.
|
||
|
||
The public scheduled-task page is unauthenticated, so it cannot supply a
|
||
``user_id``. Mutations from that page route here instead. The disk-write
|
||
and result-row update logic is identical to the owner path.
|
||
"""
|
||
run = await self.store.get_run_any(run_id)
|
||
if run is None:
|
||
raise FileNotFoundError(f"Run {run_id} not found")
|
||
return await self._apply_run_file_edit(run, run_id, file_index, new_content)
|
||
|
||
async def admin_update_run_body(self, run_id: str, new_content: str) -> dict[str, Any]:
|
||
"""Overwrite the inline Markdown body (``result.content``) of a run.
|
||
|
||
Used by the public scheduled-task viewer so anonymous editors can fix
|
||
typos in the "正文" tab. Unlike :meth:`admin_update_run_file`, no disk
|
||
artifact backs the body — it lives entirely in the run row.
|
||
"""
|
||
run = await self.store.get_run_any(run_id)
|
||
if run is None:
|
||
raise FileNotFoundError(f"Run {run_id} not found")
|
||
result = run.get("result") if isinstance(run.get("result"), dict) else {}
|
||
new_result = {**result, "content": new_content}
|
||
await self.store.update_run(run_id, {"result": new_result})
|
||
return {"content": new_content}
|
||
|
||
async def _apply_run_file_edit(
|
||
self,
|
||
run: dict[str, Any],
|
||
run_id: str,
|
||
file_index: int,
|
||
new_content: str,
|
||
) -> dict[str, Any]:
|
||
result = run.get("result") or {}
|
||
files = result.get("files") if isinstance(result, dict) else None
|
||
if not isinstance(files, list) or file_index < 0 or file_index >= len(files):
|
||
raise FileNotFoundError(f"File index {file_index} not found in run {run_id}")
|
||
item = files[file_index]
|
||
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
|
||
raise FileNotFoundError(f"File index {file_index} has no archive path")
|
||
|
||
archive_path = Path(item["path"])
|
||
if not archive_path.exists() or not archive_path.is_file():
|
||
raise FileNotFoundError(f"Archived file {archive_path} is no longer available")
|
||
|
||
# Constrain edits to text-previewable files. We mirror the same rule the
|
||
# router uses for previews so binary files cannot be silently corrupted.
|
||
suffix = archive_path.suffix.lower()
|
||
mime_type = item.get("mime_type")
|
||
is_text = (
|
||
(isinstance(mime_type, str) and (mime_type.startswith("text/") or mime_type in {"application/json", "application/xml"}))
|
||
or suffix in {".md", ".markdown", ".txt", ".json", ".csv", ".xml", ".yaml", ".yml", ".log"}
|
||
)
|
||
if not is_text:
|
||
raise ValueError("Only text-previewable files (markdown, txt, json, etc.) can be edited")
|
||
|
||
# Make sure we stay inside the per-run archive root before touching disk.
|
||
archive_root = (get_paths().base_dir / "scheduled-task-runs" / run_id).resolve()
|
||
try:
|
||
archive_path.resolve().relative_to(archive_root)
|
||
except ValueError as exc:
|
||
raise PermissionError("Refusing to edit file outside the run archive") from exc
|
||
|
||
archive_path.write_text(new_content, encoding="utf-8", newline="")
|
||
new_size = archive_path.stat().st_size
|
||
|
||
# Reflect the new size in the run row so subsequent list/preview calls
|
||
# report the latest figure. Other fields stay untouched.
|
||
files[file_index] = {**item, "size": new_size}
|
||
new_result = {**result, "files": files}
|
||
await self.store.update_run(run_id, {"result": new_result})
|
||
|
||
return {
|
||
"index": file_index,
|
||
"name": item.get("name") or archive_path.name,
|
||
"size": new_size,
|
||
"mime_type": mime_type,
|
||
"kind": item.get("kind"),
|
||
}
|
||
|
||
@staticmethod
|
||
def _remove_run_archive(run_id: str) -> None:
|
||
"""Best-effort cleanup of a run's on-disk result archive."""
|
||
archive_dir = get_paths().base_dir / "scheduled-task-runs" / run_id
|
||
if archive_dir.exists():
|
||
try:
|
||
shutil.rmtree(archive_dir)
|
||
except Exception:
|
||
logger.warning("Failed to delete scheduled task run archive %s", archive_dir, exc_info=True)
|
||
|
||
async def delete_run(self, user_id: str, run_id: str) -> bool:
|
||
deleted = await self.store.delete_run(run_id, user_id)
|
||
if deleted:
|
||
self._remove_run_archive(run_id)
|
||
return True
|
||
candidate = await self.store.get_run_any(run_id)
|
||
if candidate is not None and await self.store.get_subscription(candidate["task_id"], user_id) is not None:
|
||
raise PermissionError("Only the run owner may delete scheduled task runs")
|
||
return deleted
|
||
|
||
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
||
return await self.store.get_delivery_profile(user_id)
|
||
|
||
async def update_delivery_profile(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||
return await self.store.update_delivery_profile(user_id, data)
|
||
|
||
async def _assert_name_available(self, user_id: str, name: str, *, exclude_task_id: str | None = None) -> None:
|
||
"""Raise :class:`DuplicateTaskNameError` if ``user_id`` already owns ``name``.
|
||
|
||
Enforces ``uq_scheduled_tasks_user_name`` proactively so callers get a
|
||
clean conflict error instead of a raw DB ``IntegrityError``. The check
|
||
compares the trimmed name; ``exclude_task_id`` skips the task being
|
||
renamed so an unchanged name on update is not flagged as a collision.
|
||
"""
|
||
for existing in await self.store.list_tasks(user_id):
|
||
if exclude_task_id is not None and existing.get("task_id") == exclude_task_id:
|
||
continue
|
||
if (existing.get("name") or "").strip() == name:
|
||
raise DuplicateTaskNameError(name)
|
||
|
||
async def create_task(
|
||
self,
|
||
*,
|
||
user_id: str,
|
||
name: str,
|
||
prompt: str,
|
||
schedule: str,
|
||
timezone: str | None = None,
|
||
description: str | None = None,
|
||
source_thread_id: str | None = None,
|
||
source_agent_name: str | None = None,
|
||
execution_agent_name: str | None = None,
|
||
execution_context: dict[str, Any] | None = None,
|
||
require_markdown: bool = False,
|
||
) -> dict[str, Any]:
|
||
tz = _normalize_timezone(timezone)
|
||
cron_expr = _schedule_to_cron(schedule)
|
||
next_run_at = _next_run_at(cron_expr, tz)
|
||
final_name = name.strip() or "Scheduled task"
|
||
await self._assert_name_available(user_id, final_name)
|
||
scheduler_thread = await self.ensure_scheduler_thread(user_id)
|
||
context = dict(execution_context or {})
|
||
if execution_agent_name:
|
||
context["agent_name"] = execution_agent_name
|
||
context["require_markdown"] = bool(require_markdown)
|
||
normalized_description = description.strip() if isinstance(description, str) else None
|
||
task = await self.store.create_task(
|
||
{
|
||
"task_id": str(uuid.uuid4()),
|
||
"user_id": user_id,
|
||
"scheduler_thread_id": scheduler_thread["thread_id"],
|
||
"source_thread_id": source_thread_id,
|
||
"source_agent_name": source_agent_name,
|
||
"execution_agent_name": execution_agent_name,
|
||
"name": final_name,
|
||
"description": normalized_description or None,
|
||
"prompt": prompt.strip(),
|
||
"schedule_text": schedule.strip(),
|
||
"cron_expr": cron_expr,
|
||
"timezone": tz,
|
||
"enabled": True,
|
||
"next_run_at": next_run_at,
|
||
"execution_context": context,
|
||
}
|
||
)
|
||
return task
|
||
|
||
@staticmethod
|
||
def _build_task_update(data: dict[str, Any], existing: dict[str, Any] | None) -> dict[str, Any]:
|
||
"""Translate an API update payload into a storage update dict.
|
||
|
||
Shared by owner-scoped :meth:`update_task` and admin :meth:`admin_update_task`
|
||
so both apply identical schedule/timezone normalization.
|
||
"""
|
||
update_data: dict[str, Any] = {}
|
||
for key in ("name", "prompt", "enabled", "execution_agent_name"):
|
||
if key in data and data[key] is not None:
|
||
update_data[key] = data[key]
|
||
# Description is nullable — accept ``None`` (clear) and empty strings
|
||
# (also clear) explicitly, so the field can be removed via PATCH.
|
||
if "description" in data:
|
||
raw = data["description"]
|
||
if isinstance(raw, str):
|
||
trimmed = raw.strip()
|
||
update_data["description"] = trimmed or None
|
||
else:
|
||
update_data["description"] = None
|
||
# Names are matched against the per-user uniqueness constraint, so keep
|
||
# the stored value trimmed to match create_task's normalization.
|
||
if isinstance(update_data.get("name"), str):
|
||
update_data["name"] = update_data["name"].strip() or "Scheduled task"
|
||
timezone = data.get("timezone")
|
||
if timezone is not None:
|
||
update_data["timezone"] = _normalize_timezone(timezone)
|
||
if data.get("schedule") is not None:
|
||
schedule = str(data["schedule"])
|
||
cron_expr = _schedule_to_cron(schedule)
|
||
tz = update_data.get("timezone") or (existing or {}).get("timezone") or "UTC"
|
||
update_data["schedule_text"] = schedule
|
||
update_data["cron_expr"] = cron_expr
|
||
update_data["next_run_at"] = _next_run_at(cron_expr, tz)
|
||
if data.get("execution_context") is not None:
|
||
update_data["execution_context"] = data["execution_context"]
|
||
# ``require_markdown`` is persisted inside execution_context (no dedicated
|
||
# column), so merge it into the context being written — preferring a
|
||
# context supplied in the same request, else the task's existing one.
|
||
if data.get("require_markdown") is not None:
|
||
base_ctx = (
|
||
update_data["execution_context"]
|
||
if "execution_context" in update_data
|
||
else (existing or {}).get("execution_context")
|
||
)
|
||
ctx = dict(base_ctx or {})
|
||
ctx["require_markdown"] = bool(data["require_markdown"])
|
||
update_data["execution_context"] = ctx
|
||
# Re-enabling a task must also schedule it forward from now, otherwise a
|
||
# paused task with a stale/empty next_run_at would never fire again.
|
||
if update_data.get("enabled") is True and "next_run_at" not in update_data:
|
||
cron = (existing or {}).get("cron_expr")
|
||
if cron:
|
||
tz = update_data.get("timezone") or (existing or {}).get("timezone") or "UTC"
|
||
update_data["next_run_at"] = _next_run_at(cron, tz)
|
||
return update_data
|
||
|
||
async def update_task(self, user_id: str, task_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||
existing = await self.store.get_task(task_id, user_id)
|
||
if existing is None:
|
||
return None
|
||
update_data = self._build_task_update(data, existing)
|
||
if "name" in update_data:
|
||
await self._assert_name_available(user_id, update_data["name"], exclude_task_id=task_id)
|
||
return await self.store.update_task(task_id, user_id, update_data)
|
||
|
||
async def delete_task(self, user_id: str, task_id: str) -> bool:
|
||
return await self.store.delete_task(task_id, user_id)
|
||
|
||
# --- Admin (cross-user management) ---
|
||
|
||
async def admin_list_tasks(self) -> list[dict[str, Any]]:
|
||
"""List every scheduled task across all users. Admin-only callers."""
|
||
tasks = await self.store.list_all_tasks()
|
||
for task in tasks:
|
||
task["role"] = "owner"
|
||
return tasks
|
||
|
||
async def admin_update_task(self, task_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||
"""Update any task regardless of owner. Admin-only callers."""
|
||
existing = await self.store.get_task_any(task_id)
|
||
if existing is None:
|
||
return None
|
||
update_data = self._build_task_update(data, existing)
|
||
if "name" in update_data:
|
||
await self._assert_name_available(existing["user_id"], update_data["name"], exclude_task_id=task_id)
|
||
return await self.store.update_task_any(task_id, update_data)
|
||
|
||
async def admin_delete_task(self, task_id: str) -> bool:
|
||
"""Delete any task regardless of owner. Admin-only callers."""
|
||
return await self.store.delete_task_any(task_id)
|
||
|
||
async def admin_delete_run(self, run_id: str) -> bool:
|
||
"""Delete a single run regardless of owner. Admin-only callers."""
|
||
deleted = await self.store.delete_run_any(run_id)
|
||
if deleted:
|
||
self._remove_run_archive(run_id)
|
||
return deleted
|
||
|
||
async def admin_list_runs(self, task_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
|
||
"""List runs for any task regardless of owner. Admin-only callers."""
|
||
runs = await self.store.list_runs_any(task_id, limit=limit)
|
||
for run in runs:
|
||
run["can_edit"] = True
|
||
return runs
|
||
|
||
async def admin_get_run(self, run_id: str) -> dict[str, Any] | None:
|
||
"""Fetch a single run regardless of owner. Admin-only callers."""
|
||
run = await self.store.get_run_any(run_id)
|
||
if run is not None:
|
||
run["can_edit"] = True
|
||
return run
|
||
|
||
async def admin_trigger_task(self, task_id: str) -> dict[str, Any] | None:
|
||
"""Trigger an immediate run of any task regardless of owner."""
|
||
task = await self.store.get_task_any(task_id)
|
||
if task is None:
|
||
return None
|
||
asyncio.create_task(self._execute_tracked(task, scheduled_for=datetime.now(UTC), manual=True))
|
||
return task
|
||
|
||
async def pause_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
|
||
return await self.store.update_task(task_id, user_id, {"enabled": False})
|
||
|
||
async def resume_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
|
||
existing = await self.store.get_task(task_id, user_id)
|
||
if existing is None:
|
||
return None
|
||
next_run_at = _next_run_at(existing["cron_expr"], existing.get("timezone") or "UTC")
|
||
return await self.store.update_task(task_id, user_id, {"enabled": True, "next_run_at": next_run_at})
|
||
|
||
async def trigger_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
|
||
task = await self.store.get_task(task_id, user_id)
|
||
if task is None:
|
||
return None
|
||
asyncio.create_task(self._execute_tracked(task, scheduled_for=datetime.now(UTC), manual=True))
|
||
return task
|
||
|
||
async def _run_loop(self) -> None:
|
||
while not self._stopping.is_set():
|
||
# Recover hung / orphaned runs first so a single stuck task cannot
|
||
# silently wedge the scheduler indefinitely.
|
||
try:
|
||
await self._reconcile_stuck_runs()
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception:
|
||
logger.exception("Scheduled task watchdog scan failed")
|
||
try:
|
||
due = await self.store.due_tasks(datetime.now(UTC), limit=20)
|
||
for task in due:
|
||
task_id = task["task_id"]
|
||
if task_id in self._running_task_ids:
|
||
continue
|
||
self._running_task_ids.add(task_id)
|
||
asyncio.create_task(self._execute_and_untrack(task))
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception:
|
||
logger.exception("Scheduled task scan failed")
|
||
try:
|
||
await asyncio.wait_for(self._stopping.wait(), timeout=self.poll_interval_seconds)
|
||
except TimeoutError:
|
||
pass
|
||
|
||
async def _execute_and_untrack(self, task: dict[str, Any]) -> None:
|
||
try:
|
||
scheduled_for = _coerce_datetime(task.get("next_run_at")) or datetime.now(UTC)
|
||
await self._execute_task(task, scheduled_for=scheduled_for, manual=False)
|
||
finally:
|
||
self._running_task_ids.discard(task["task_id"])
|
||
|
||
async def _execute_tracked(self, task: dict[str, Any], *, scheduled_for: datetime, manual: bool) -> None:
|
||
"""Run a task while marking it busy so the watchdog won't double-drive it."""
|
||
self._running_task_ids.add(task["task_id"])
|
||
try:
|
||
await self._execute_task(task, scheduled_for=scheduled_for, manual=manual)
|
||
finally:
|
||
self._running_task_ids.discard(task["task_id"])
|
||
|
||
async def _reconcile_stuck_runs(self) -> None:
|
||
"""Recover runs left in the ``running`` state with no in-process owner.
|
||
|
||
A run becomes stuck when the agent loop hangs past the per-attempt
|
||
timeout, or when the process crashes / restarts mid-run (the original
|
||
asyncio task is gone but the DB row still says ``running``). For each
|
||
such run we retry up to ``_MAX_RUN_RETRIES`` times, then mark it
|
||
``failed`` so it no longer shows as perpetually executing.
|
||
"""
|
||
cutoff = datetime.now(UTC) - timedelta(seconds=_RUN_ATTEMPT_TIMEOUT_SECONDS)
|
||
stuck = await self.store.list_stuck_runs(cutoff, limit=20)
|
||
for run in stuck:
|
||
task_id = run["task_id"]
|
||
# A run still owned by a live in-process execution is handled by
|
||
# that execution's own timeout/retry loop — don't touch it here.
|
||
if task_id in self._running_task_ids:
|
||
continue
|
||
self._running_task_ids.add(task_id)
|
||
asyncio.create_task(self._recover_and_untrack(run))
|
||
|
||
async def _recover_and_untrack(self, run: dict[str, Any]) -> None:
|
||
try:
|
||
await self._recover_stuck_run(run)
|
||
except Exception:
|
||
logger.exception("Failed to recover stuck scheduled task run %s", run.get("id"))
|
||
finally:
|
||
self._running_task_ids.discard(run["task_id"])
|
||
|
||
async def _recover_stuck_run(self, run: dict[str, Any]) -> None:
|
||
run_id = run["id"]
|
||
retry_count = int(run.get("retry_count") or 0)
|
||
task = await self.store.get_task_any(run["task_id"])
|
||
scheduled_for = _coerce_datetime(run.get("scheduled_for")) or datetime.now(UTC)
|
||
if task is None:
|
||
# Parent task was deleted — just close out the orphaned run.
|
||
await self.store.update_run(
|
||
run_id,
|
||
{"status": "failed", "finished_at": datetime.now(UTC), "error": "执行超时:所属任务已被删除"},
|
||
)
|
||
return
|
||
if retry_count >= _MAX_RUN_RETRIES:
|
||
await self._fail_run(
|
||
task,
|
||
run_id,
|
||
scheduled_for,
|
||
error=f"执行超时:超过 {_RUN_ATTEMPT_TIMEOUT_SECONDS // 60} 分钟未完成,重试 {_MAX_RUN_RETRIES} 次后仍失败",
|
||
retry_count=retry_count,
|
||
)
|
||
return
|
||
logger.warning("Recovering stuck scheduled task run %s (retry budget %s/%s)", run_id, retry_count, _MAX_RUN_RETRIES)
|
||
await self._execute_task(task, scheduled_for=scheduled_for, manual=False, run_row=run)
|
||
|
||
async def _fail_run(
|
||
self,
|
||
task: dict[str, Any],
|
||
run_id: str,
|
||
scheduled_for: datetime,
|
||
*,
|
||
error: str,
|
||
retry_count: int = 0,
|
||
) -> None:
|
||
"""Mark a run failed and reflect the outcome on the parent task."""
|
||
result = _build_result(task, scheduled_for=scheduled_for, content=f"定时任务执行失败:{error}")
|
||
await self.store.update_run(
|
||
run_id,
|
||
{"status": "failed", "finished_at": datetime.now(UTC), "error": error, "result": result, "retry_count": retry_count},
|
||
)
|
||
await self.store.update_task(task["task_id"], task["user_id"], {"last_run_at": datetime.now(UTC), "last_status": "failed"})
|
||
|
||
async def _execute_task(
|
||
self,
|
||
task: dict[str, Any],
|
||
*,
|
||
scheduled_for: datetime,
|
||
manual: bool,
|
||
run_row: dict[str, Any] | None = None,
|
||
) -> None:
|
||
# Each run uses its own throwaway thread so message history cannot accumulate
|
||
# across runs and blow past the model's input window. The per-user
|
||
# task["scheduler_thread_id"] is no longer used to drive the LLM call —
|
||
# we keep it on the task row only for backwards compatibility.
|
||
is_recovery = run_row is not None
|
||
if run_row is None:
|
||
run_row = await self.store.create_run_once(
|
||
{
|
||
"id": str(uuid.uuid4()),
|
||
"task_id": task["task_id"],
|
||
"user_id": task["user_id"],
|
||
"scheduler_thread_id": str(uuid.uuid4()),
|
||
"scheduled_for": scheduled_for,
|
||
"status": "running",
|
||
"started_at": datetime.now(UTC),
|
||
}
|
||
)
|
||
if run_row is None:
|
||
return
|
||
else:
|
||
# Recovery path: re-claim an existing (orphaned) run row.
|
||
await self.store.update_run(run_row["id"], {"status": "running", "started_at": datetime.now(UTC)})
|
||
|
||
# Advancing the schedule only happens once, when the run is first
|
||
# created — recovery retries reuse the original scheduled_for.
|
||
if not manual and not is_recovery:
|
||
next_run_at = _next_run_at(task["cron_expr"], task.get("timezone") or "UTC", base=scheduled_for)
|
||
await self.store.update_task(task["task_id"], task["user_id"], {"next_run_at": next_run_at, "last_status": "running"})
|
||
|
||
run_id = run_row["id"]
|
||
retry_count = int(run_row.get("retry_count") or 0)
|
||
|
||
while True:
|
||
runtime_thread_id = str(uuid.uuid4())
|
||
try:
|
||
result = await asyncio.wait_for(
|
||
self._run_task_attempt(task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id),
|
||
timeout=_RUN_ATTEMPT_TIMEOUT_SECONDS,
|
||
)
|
||
except TimeoutError:
|
||
# The attempt overran the 30-minute cap. Retry a bounded number
|
||
# of times, then give up and mark the run failed.
|
||
if retry_count >= _MAX_RUN_RETRIES:
|
||
await self._fail_run(
|
||
task,
|
||
run_id,
|
||
scheduled_for,
|
||
error=f"执行超时:单次执行超过 {_RUN_ATTEMPT_TIMEOUT_SECONDS // 60} 分钟,重试 {_MAX_RUN_RETRIES} 次后仍失败",
|
||
retry_count=retry_count,
|
||
)
|
||
return
|
||
retry_count += 1
|
||
logger.warning("Scheduled task %s timed out; retry %s/%s", task["task_id"], retry_count, _MAX_RUN_RETRIES)
|
||
await self.store.update_run(
|
||
run_id,
|
||
{
|
||
"retry_count": retry_count,
|
||
"status": "running",
|
||
"started_at": datetime.now(UTC),
|
||
"error": f"执行超时,正在重试(第 {retry_count}/{_MAX_RUN_RETRIES} 次)",
|
||
},
|
||
)
|
||
continue
|
||
except Exception as exc:
|
||
logger.exception("Scheduled task %s failed", task["task_id"])
|
||
await self._fail_run(task, run_id, scheduled_for, error=str(exc), retry_count=retry_count)
|
||
return
|
||
|
||
await self.store.update_run(
|
||
run_id,
|
||
{"status": "succeeded", "finished_at": datetime.now(UTC), "result": result, "retry_count": retry_count, "error": None},
|
||
)
|
||
await self.store.update_task(task["task_id"], task["user_id"], {"last_run_at": datetime.now(UTC), "last_status": "succeeded"})
|
||
await self._deliver_element_result(task, result["content"])
|
||
return
|
||
|
||
async def _run_task_attempt(
|
||
self,
|
||
task: dict[str, Any],
|
||
*,
|
||
runtime_thread_id: str,
|
||
scheduled_for: datetime,
|
||
run_id: str,
|
||
) -> dict[str, Any]:
|
||
"""Dispatch a single execution attempt by the task's output requirements.
|
||
|
||
A task carries two independent switches in ``execution_context``:
|
||
``require_html`` (必须生成 HTML) and ``require_markdown`` (必须生成
|
||
Markdown). When ``require_html`` is set the run goes through the HTML
|
||
render pipeline (which also enforces the Markdown requirement when both
|
||
switches are on); otherwise it runs the regular lead-agent attempt.
|
||
Both flags live in ``execution_context`` so no schema migration is
|
||
needed, and old ``task_kind == "html_page"`` tasks still resolve to the
|
||
HTML pipeline via :func:`_task_requires_html`.
|
||
"""
|
||
# 模板填充模式优先:选中了 HTML 模板就走「转换 JSON → 填充固定模板」分支,
|
||
# 与「检索数据 → 自由渲染 HTML」(require_html) 是两套不同流程。
|
||
if _task_template_id(task) is not None:
|
||
return await self._run_template_fill_attempt(
|
||
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
|
||
)
|
||
if _task_requires_html(task):
|
||
return await self._run_html_page_attempt(
|
||
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
|
||
)
|
||
return await self._run_agent_attempt(
|
||
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
|
||
)
|
||
|
||
@staticmethod
|
||
def _html_config(task: dict[str, Any]) -> dict[str, Any]:
|
||
ctx = task.get("execution_context")
|
||
cfg = ctx.get("html_config") if isinstance(ctx, dict) else None
|
||
return cfg if isinstance(cfg, dict) else {}
|
||
|
||
async def _invoke_chat_model(self, model_name: str | None, prompt: str) -> str:
|
||
"""Invoke a chat model once and return its text content.
|
||
|
||
Used for the GLM5 HTML generation / repair / style-extraction passes.
|
||
Thinking is disabled to keep structured-JSON output stable.
|
||
"""
|
||
from langchain_core.messages import HumanMessage
|
||
|
||
from deerflow.models import create_chat_model
|
||
|
||
# No hardcoded model: use exactly the selected model; only when none was
|
||
# selected do we fall back to the system default (config models[0]).
|
||
model = create_chat_model(name=model_name or None, thinking_enabled=False)
|
||
response = await model.ainvoke([HumanMessage(content=prompt)])
|
||
content = getattr(response, "content", response)
|
||
if isinstance(content, str):
|
||
return content
|
||
if isinstance(content, list):
|
||
parts = [part.get("text", "") if isinstance(part, dict) else str(part) for part in content]
|
||
return "\n".join(p for p in parts if p)
|
||
return str(content)
|
||
|
||
async def _gather_html_data(
|
||
self,
|
||
task: dict[str, Any],
|
||
*,
|
||
runtime_thread_id: str,
|
||
scheduled_for: datetime,
|
||
run_id: str,
|
||
) -> tuple[str, list[dict[str, Any]]]:
|
||
"""Run the lead agent to collect source data for the HTML page.
|
||
|
||
Reuses the regular agent-run primitives (with the task's search query
|
||
folded into the prompt) and returns ``(text, files)`` — the agent's
|
||
final text (feeds the render step as ``本次数据``) plus any files it
|
||
produced. The files are only captured/needed when the task also has the
|
||
Markdown switch on (both 必须生成 HTML and 必须生成 Markdown), so the run
|
||
can validate and surface the ``.md`` file alongside the HTML page;
|
||
otherwise the list is empty.
|
||
"""
|
||
cfg = self._html_config(task)
|
||
page_prompt = str(cfg.get("page_prompt") or "").strip()
|
||
negative_prompt = str(cfg.get("negative_prompt") or "").strip()
|
||
also_markdown = _task_requires_markdown(task)
|
||
gather_instruction = (
|
||
"请运用你自身的检索/搜索等技能,围绕下述页面需求获取最新、真实的资料,"
|
||
"并整理成结构化的中文要点(可用列表/小标题),供后续生成 HTML 页面使用,本步骤不要生成 HTML:\n"
|
||
)
|
||
parts = [gather_instruction]
|
||
if page_prompt:
|
||
parts.append(f"页面需求:{page_prompt}")
|
||
base_prompt = str(task.get("prompt") or "").strip()
|
||
if base_prompt:
|
||
parts.append(f"补充说明:{base_prompt}")
|
||
# Apply the user's constraints to data gathering too, not just rendering
|
||
# — a "限制条件" like "只要最近 24 小时的新闻" or "排除某主题" must shape
|
||
# which sources/data the agent collects, otherwise it silently leaks in.
|
||
if negative_prompt:
|
||
parts.append(f"限制条件(最高优先级,必须严格遵守):{negative_prompt}")
|
||
gather_prompt = "\n".join(parts)
|
||
# When the Markdown switch is also on, this same gather pass must save a
|
||
# .md file. We do NOT append _MARKDOWN_INSTRUCTION here — `_start_agent_run`
|
||
# already appends it exactly once when `require_markdown` is present in the
|
||
# execution context (kept below). Appending it here too made the prompt
|
||
# carry the 【输出要求】 block twice when both HTML and Markdown were on.
|
||
gather_task = {**task, "prompt": gather_prompt}
|
||
gather_ctx = dict(task.get("execution_context") or {})
|
||
# Strip the HTML-specific flags so the gather pass is a plain agent run.
|
||
gather_ctx.pop("require_html", None)
|
||
gather_ctx.pop("task_kind", None)
|
||
gather_ctx.pop("html_config", None)
|
||
# Keep ``require_markdown`` only when both switches are on, so the agent
|
||
# is told (via _start_agent_run) to produce the .md file.
|
||
if not also_markdown:
|
||
gather_ctx.pop("require_markdown", None)
|
||
# Use the task's selected model for the data-gathering agent too, so the
|
||
# whole HTML pipeline honors ``html_config.model_name`` instead of
|
||
# silently falling back to the default model for this step.
|
||
gather_model = str(cfg.get("model_name") or "").strip()
|
||
if gather_model:
|
||
gather_ctx["model_name"] = gather_model
|
||
gather_task["execution_context"] = gather_ctx
|
||
|
||
await self._ensure_thread_meta(task["user_id"], runtime_thread_id)
|
||
files: list[dict[str, Any]] = []
|
||
async with self._agent_run_lock:
|
||
output_snapshot = self._snapshot_result_files(runtime_thread_id, task["user_id"]) if also_markdown else None
|
||
record = await self._start_agent_run(gather_task, scheduled_for=scheduled_for, runtime_thread_id=runtime_thread_id)
|
||
await self.store.update_run(run_id, {"agent_run_id": record.run_id})
|
||
if record.task is not None:
|
||
await record.task
|
||
text = await self._extract_latest_ai_text(runtime_thread_id)
|
||
if also_markdown:
|
||
files = await self._extract_result_files(
|
||
runtime_thread_id,
|
||
task["user_id"],
|
||
run_id=run_id,
|
||
output_snapshot=output_snapshot,
|
||
)
|
||
return text or "", files
|
||
|
||
def _collect_html_skill_instructions(self) -> str:
|
||
"""Concatenate the bodies of enabled skills marked for HTML pages.
|
||
|
||
A skill opts in via ``metadata.kind: html_page`` in its SKILL.md
|
||
frontmatter. Only enabled skills (per the skills settings the user
|
||
toggles) are injected, so the render step honors the user's skill
|
||
selection. Best-effort — any failure yields an empty string.
|
||
"""
|
||
try:
|
||
from deerflow.skills import get_or_new_skill_storage
|
||
|
||
storage = get_or_new_skill_storage()
|
||
skills = storage.load_skills(enabled_only=True)
|
||
except Exception:
|
||
logger.debug("Failed to load skills for HTML render injection", exc_info=True)
|
||
return ""
|
||
blocks: list[str] = []
|
||
for skill in skills:
|
||
try:
|
||
text = Path(skill.skill_file).read_text(encoding="utf-8")
|
||
except Exception:
|
||
continue
|
||
frontmatter, body = html_page_utils.parse_frontmatter(text)
|
||
meta = frontmatter.get("metadata") if isinstance(frontmatter, dict) else None
|
||
if not (isinstance(meta, dict) and str(meta.get("kind")) == "html_page"):
|
||
continue
|
||
body = body.strip()
|
||
if body:
|
||
blocks.append(f"### 技能:{skill.name}\n{body}")
|
||
return "\n\n".join(blocks)
|
||
|
||
def _archive_html_index(self, run_id: str, html: str) -> Path | None:
|
||
"""Write the generated HTML to the run's archive dir as ``index.html``."""
|
||
try:
|
||
archive_dir = get_paths().base_dir / "scheduled-task-runs" / run_id / "files"
|
||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||
target = archive_dir / "index.html"
|
||
target.write_text(html, encoding="utf-8")
|
||
return target
|
||
except Exception:
|
||
logger.warning("Failed to archive generated HTML for run %s", run_id, exc_info=True)
|
||
return None
|
||
|
||
async def _run_html_page_attempt(
|
||
self,
|
||
task: dict[str, Any],
|
||
*,
|
||
runtime_thread_id: str,
|
||
scheduled_for: datetime,
|
||
run_id: str,
|
||
) -> dict[str, Any]:
|
||
"""Generate an HTML page for an ``html_page`` task.
|
||
|
||
Pipeline: gather data via the lead agent → optionally load a reference
|
||
favorite's style/layout → GLM5 render → sanitize → validate → (one
|
||
GLM5 repair pass if invalid) → best-effort screenshot → archive
|
||
``index.html`` → persist a ``scheduled_html_pages`` row → build the run
|
||
result payload (``format: "html"`` with the full HTML inline).
|
||
"""
|
||
cfg = self._html_config(task)
|
||
# The user's selected model only — no hardcoded GLM-5. Empty → None →
|
||
# system default (config models[0]).
|
||
model_name = str(cfg.get("model_name") or "").strip() or None
|
||
page_prompt = str(cfg.get("page_prompt") or "").strip()
|
||
negative_prompt = str(cfg.get("negative_prompt") or "").strip()
|
||
reference_favorite_id = cfg.get("reference_favorite_id") or None
|
||
|
||
# 选了内置模板作参考(``tpl:<id>``) → 用**真实模板**出页,保留全部子页面;
|
||
# 绝不走下面的自由渲染(模型重画会丢掉 JS 驱动的多页/子页面)。
|
||
tpl_ref_id = (
|
||
template_fill.reference_template_id(str(reference_favorite_id))
|
||
if reference_favorite_id
|
||
else None
|
||
)
|
||
if tpl_ref_id is not None:
|
||
return await self._run_html_template_reference(
|
||
task, tpl_id=tpl_ref_id, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
|
||
)
|
||
|
||
# 1) Gather source data (real, via the agent's search tools). When the
|
||
# Markdown switch is also on, the gather pass additionally saves the .md
|
||
# file, returned here so we can validate + surface it next to the page.
|
||
also_markdown = _task_requires_markdown(task)
|
||
data_text, gathered_files = await self._gather_html_data(
|
||
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
|
||
)
|
||
if also_markdown and not _has_valid_markdown_file(gathered_files):
|
||
raise MarkdownRequirementError(
|
||
f"未满足输出要求:需生成 Markdown(.md) 文件,且文件正文不少于 {_MARKDOWN_MIN_CHARS} 字"
|
||
)
|
||
|
||
# 2) Load reference style/layout from a favorite if one was selected.
|
||
# (模板参考 ``tpl:<id>`` 已在上面分支提前 return,这里只剩用户收藏的页面。)
|
||
reference_style = None
|
||
reference_layout = None
|
||
if reference_favorite_id:
|
||
store = self._html_store
|
||
if store is not None:
|
||
try:
|
||
favorite = await store.get_favorite(str(reference_favorite_id))
|
||
except Exception:
|
||
favorite = None
|
||
if favorite is not None:
|
||
reference_style = favorite.get("reuse_prompt") or favorite.get("style_summary")
|
||
reference_layout = favorite.get("layout_summary")
|
||
|
||
# 3) Render with the chosen model, injecting any enabled HTML skills.
|
||
skill_instructions = self._collect_html_skill_instructions()
|
||
gen_prompt = html_page_utils.build_generation_prompt(
|
||
page_prompt=page_prompt,
|
||
negative_prompt=negative_prompt,
|
||
data=data_text,
|
||
reference_style=reference_style,
|
||
reference_layout=reference_layout,
|
||
skill_instructions=skill_instructions,
|
||
)
|
||
raw = await self._invoke_chat_model(model_name, gen_prompt)
|
||
parsed = html_page_utils.extract_json_object(raw) or {}
|
||
title = str(parsed.get("title") or task.get("name") or "生成页面").strip()
|
||
html = parsed.get("html")
|
||
if not isinstance(html, str) or "<" not in html:
|
||
# The JSON wrapper couldn't be parsed (the model emitted literal
|
||
# newlines / unescaped quotes inside the "html" value), or it replied
|
||
# with bare HTML. Either way, recover just the HTML document so we
|
||
# never archive the {"title":...,"html":"..."} JSON blob as the page.
|
||
recovered = html_page_utils.recover_html_document(raw)
|
||
if recovered is None:
|
||
raise ValueError("模型未返回有效的 HTML(JSON 解析失败)")
|
||
html = recovered
|
||
style_summary = str(parsed.get("style_summary") or "").strip() or None
|
||
layout_summary = str(parsed.get("layout_summary") or "").strip() or None
|
||
|
||
# 4) Sanitize + validate, with one repair pass on failure.
|
||
html, _removed = html_page_utils.sanitize_html(html)
|
||
html, _link_changes = html_page_utils.constrain_same_page_anchor_links(html)
|
||
errors = html_page_utils.validate_html(html)
|
||
if errors:
|
||
logger.info("Generated HTML failed validation (%s); attempting repair", errors)
|
||
repair_prompt = html_page_utils.build_repair_prompt(
|
||
html=html, errors=errors, requirements=page_prompt or task.get("prompt")
|
||
)
|
||
try:
|
||
repaired_raw = await self._invoke_chat_model(model_name, repair_prompt)
|
||
repaired = html_page_utils.extract_json_object(repaired_raw) or {}
|
||
fixed = repaired.get("fixed_html")
|
||
if isinstance(fixed, str) and "<" in fixed:
|
||
fixed_clean, _ = html_page_utils.sanitize_html(fixed)
|
||
fixed_clean, _ = html_page_utils.constrain_same_page_anchor_links(fixed_clean)
|
||
if not html_page_utils.validate_html(fixed_clean):
|
||
html = fixed_clean
|
||
except Exception:
|
||
logger.warning("HTML repair pass failed; keeping sanitized original", exc_info=True)
|
||
|
||
# 4b) Completeness gate — a page only counts as successful when it is a
|
||
# full, valid HTML document. If it still fails validation after the
|
||
# repair pass, fail the run (no half-baked page is archived/persisted).
|
||
final_errors = html_page_utils.validate_html(html)
|
||
if final_errors:
|
||
raise HtmlGenerationError(
|
||
"HTML 生成未通过完整性校验:" + ";".join(final_errors) + "。请调整页面要求或重试。"
|
||
)
|
||
|
||
# 5) Best-effort screenshot QA (no-op without Playwright).
|
||
screenshot_path = None
|
||
try:
|
||
shot_target = get_paths().base_dir / "scheduled-task-runs" / run_id / "files" / "preview.png"
|
||
shot_target.parent.mkdir(parents=True, exist_ok=True)
|
||
screenshot_path = await html_page_utils.screenshot_html(html, shot_target)
|
||
except Exception:
|
||
screenshot_path = None
|
||
|
||
# 6) Archive index.html.
|
||
archive_path = self._archive_html_index(run_id, html)
|
||
|
||
files: list[dict[str, Any]] = []
|
||
# When both switches are on, surface the gathered Markdown file(s)
|
||
# alongside the generated page so the run delivers both artifacts.
|
||
if also_markdown:
|
||
for item in gathered_files:
|
||
name = str(item.get("name") or "") if isinstance(item, dict) else ""
|
||
if name.lower().endswith((".md", ".markdown")):
|
||
files.append(item)
|
||
if archive_path is not None:
|
||
files.append(
|
||
{
|
||
"name": "index.html",
|
||
"path": str(archive_path),
|
||
"virtual_path": "/mnt/user-data/outputs/index.html",
|
||
"mime_type": "text/html",
|
||
"size": archive_path.stat().st_size if archive_path.exists() else None,
|
||
"kind": "html",
|
||
}
|
||
)
|
||
if screenshot_path:
|
||
shot = Path(screenshot_path)
|
||
files.append(
|
||
{
|
||
"name": "preview.png",
|
||
"path": str(shot),
|
||
"mime_type": "image/png",
|
||
"size": shot.stat().st_size if shot.exists() else None,
|
||
"kind": "artifact",
|
||
}
|
||
)
|
||
|
||
data_snapshot = {"data_text": data_text[:8000] if isinstance(data_text, str) else ""}
|
||
prompt_snapshot = {
|
||
"page_prompt": page_prompt,
|
||
"negative_prompt": negative_prompt,
|
||
"reference_favorite_id": reference_favorite_id,
|
||
"model_name": model_name,
|
||
}
|
||
|
||
# 7) Persist the page asset (best-effort — failure must not fail the run).
|
||
store = self._html_store
|
||
if store is not None:
|
||
try:
|
||
await store.create_page(
|
||
{
|
||
"id": str(uuid.uuid4()),
|
||
"task_id": task["task_id"],
|
||
"run_id": run_id,
|
||
"user_id": task["user_id"],
|
||
"title": title,
|
||
"html_content": html,
|
||
"html_file_path": str(archive_path) if archive_path else None,
|
||
"data_snapshot": data_snapshot,
|
||
"prompt_snapshot": prompt_snapshot,
|
||
"reference_favorite_id": reference_favorite_id,
|
||
"style_summary": style_summary,
|
||
"layout_summary": layout_summary,
|
||
}
|
||
)
|
||
except Exception:
|
||
logger.warning("Failed to persist scheduled_html_pages row for run %s", run_id, exc_info=True)
|
||
|
||
task_tz = ZoneInfo(task.get("timezone") or "UTC")
|
||
local_time = scheduled_for.astimezone(task_tz)
|
||
# ``scheduled_task_runs.result_json`` is TEXT (64 KB) on MySQL, so only
|
||
# inline the HTML when it comfortably fits. The full document always
|
||
# lives in ``scheduled_html_pages.html_content`` (LONGTEXT) + the
|
||
# archived ``index.html``; the ``/runs/{id}/html`` endpoints read from
|
||
# there, so previews work even when the inline copy is omitted here.
|
||
inline_html = html if len(html) <= _MAX_INLINE_RESULT_HTML else None
|
||
return {
|
||
"title": f"{title} - {local_time.strftime('%Y-%m-%d %H:%M')}",
|
||
"content": page_prompt or f"HTML 页面已生成:{title}",
|
||
"format": "html",
|
||
"html": inline_html,
|
||
"style_summary": style_summary,
|
||
"layout_summary": layout_summary,
|
||
"data_snapshot": data_snapshot,
|
||
"prompt_snapshot": prompt_snapshot,
|
||
"files": files,
|
||
"created_at": datetime.now(UTC).isoformat(),
|
||
}
|
||
|
||
@staticmethod
|
||
def _build_conversion_prompt(
|
||
skeleton_json: str,
|
||
source: str,
|
||
embedded_json: Any | None = None,
|
||
instructions: str | None = None,
|
||
) -> str:
|
||
parts = [
|
||
"请把下面的【源数据】转换成【目标结构】要求的 JSON,用于填充一个 HTML 展示模板。",
|
||
"模板只是一层**样式外壳**:它的字段名固定(页面脚本按字段名读取,绝不能改),"
|
||
"但每个字段的**含义要按源数据的主题重新解释**——把主题内容语义映射进各分区,"
|
||
"例如:总览指标填主题的核心数字;分组/区域类列表填主题的分区、品类或阵营;"
|
||
"组织/单位类列表填主题的机构、厂商或层级体系;装备/条目类列表填主题的产品、"
|
||
"技术或关键要素;事件/项目类列表填主题的时间线事项。内容必须全部来自【源数据】"
|
||
"的主题,不要被模板原有示例的领域限制住。",
|
||
"源数据可能是一段 JSON、一段纯文本、或包含 JSON 的文本——"
|
||
"其中若有结构化 JSON 以 JSON 内容为准;若是纯文本请理解其语义后据实填充;"
|
||
"源里没有对应内容的字段按其类型留空(\"\" / [] / null),不要臆造。",
|
||
"若下面的【任务要求】里带有对页面的明确修改指示(如“标题改为…”“只更新某部分”),"
|
||
"优先满足这些指示,其余字段可留空(页面将沿用模板现有内容);"
|
||
"没有指示到的部分按源数据自由发挥,尽量把各分区填满。",
|
||
"字段名必须与【目标结构】逐字一致(包括中文键名,原样复制),"
|
||
"严禁自造、翻译或改写字段名;数组元素的字段集合必须与样例元素相同,"
|
||
"源里没有的字段按类型留空即可,绝不能用自造的字段名顶替,也不要照抄任何示例值。",
|
||
"另外请在输出的 JSON 里额外携带两个顶层字符串字段:「页面标题」(按主题拟一个"
|
||
"页面大标题;若任务要求里明确给了标题,就用它)和「页面副标题」(一句话副标题)。",
|
||
"",
|
||
"【目标结构】(字段与类型的空占位骨架,数组只示意 1 个样例元素,实际按源数据填充全部元素):",
|
||
"```json\n" + skeleton_json + "\n```",
|
||
"",
|
||
]
|
||
if instructions and instructions.strip():
|
||
parts.append("【任务要求】(用户对本次页面的要求/修改指示,优先级最高):")
|
||
parts.append(instructions.strip())
|
||
parts.append("")
|
||
if embedded_json is not None:
|
||
# 已从源数据里识别出结构化 JSON,单独标注帮助(尤其弱)模型对齐。
|
||
parts.append("【从源数据中识别到的结构化数据】(优先以此为准):")
|
||
parts.append("```json\n" + json.dumps(embedded_json, ensure_ascii=False) + "\n```")
|
||
parts.append("")
|
||
parts.append("【源数据原文】(含上面 JSON 之外的补充说明,一并参考):")
|
||
else:
|
||
parts.append("【源数据】:")
|
||
parts.append(source)
|
||
parts.append("")
|
||
parts.append("只输出一个 ```json 代码块,内含填充好的完整目标 JSON(含「页面标题」「页面副标题」),不要任何解释文字。")
|
||
return "\n".join(parts)
|
||
|
||
@staticmethod
|
||
def _build_retry_prompt(base_prompt: str, problems: list[str], gaps: list[str]) -> str:
|
||
lines = [base_prompt, "\n\n上一轮转换结果未通过校验,请严格修正后重新输出**完整**的目标 JSON:"]
|
||
if problems:
|
||
lines.append("- " + ";".join(problems[:12]))
|
||
if gaps:
|
||
lines.append("- 字段缺失(请补齐):" + "、".join(gaps[:30]))
|
||
return "\n".join(lines)
|
||
|
||
async def _invoke_conversion_model(self, model_name: str | None, soul: str | None, user_prompt: str) -> str:
|
||
"""Invoke the conversion model once (SOUL as system, prompt as human).
|
||
|
||
Thinking is disabled for stable JSON output. Returns the model's text.
|
||
"""
|
||
from langchain_core.messages import HumanMessage, SystemMessage
|
||
|
||
from deerflow.models import create_chat_model
|
||
|
||
model = create_chat_model(name=model_name or None, thinking_enabled=False)
|
||
messages: list[Any] = []
|
||
if soul:
|
||
messages.append(SystemMessage(content=soul))
|
||
messages.append(HumanMessage(content=user_prompt))
|
||
response = await model.ainvoke(messages)
|
||
content = getattr(response, "content", response)
|
||
if isinstance(content, str):
|
||
return content
|
||
if isinstance(content, list):
|
||
parts = [part.get("text", "") if isinstance(part, dict) else str(part) for part in content]
|
||
return "\n".join(p for p in parts if p)
|
||
return str(content)
|
||
|
||
async def _agent_soul_and_model(
|
||
self, task: dict[str, Any], model_name: str | None
|
||
) -> tuple[str | None, str | None]:
|
||
"""Resolve the task agent's SOUL; fall back to its model when none chosen.
|
||
|
||
Used by both template pipelines so the **task's selected agent** shapes
|
||
content conversion: the SOUL carries the agent's domain instructions,
|
||
and when the user didn't pick an explicit model the agent's configured
|
||
model is preferred over the system default. Returns ``(soul, model)``.
|
||
"""
|
||
agent_ref = task.get("execution_agent_name")
|
||
agent_id = await self._resolve_agent_id(agent_ref, task["user_id"]) if agent_ref else None
|
||
soul = load_agent_soul(agent_id) if agent_id else None
|
||
if not model_name and agent_id:
|
||
try:
|
||
agent_cfg = load_agent_config(agent_id)
|
||
model_name = (agent_cfg.model if agent_cfg else None) or None
|
||
except Exception:
|
||
model_name = None
|
||
return soul, model_name
|
||
|
||
async def _convert_to_template_data(
|
||
self,
|
||
template_id: str,
|
||
source: str,
|
||
*,
|
||
model_name: str | None,
|
||
soul: str | None,
|
||
run_id: str,
|
||
instructions: str | None = None,
|
||
) -> dict[str, Any]:
|
||
"""把源数据(JSON/文本/含JSON文本)转换成模板的 DATA 结构,校验+回喂重试。
|
||
|
||
通过(结构+字段对齐)返回校验过的对象;重试耗尽时**尽力而为**——只要解析
|
||
出过 JSON 对象就返回最后一个(缺顶层键/字段错位的分支由随后的
|
||
``merge_with_reference`` 回退模板默认数据,抽多少用多少);只有全程没解析
|
||
出 JSON 才抛 :class:`TemplateFillError`(由调用方兜底为默认数据,不让
|
||
run 失败)。
|
||
"""
|
||
skeleton = template_fill.schema_skeleton_json(template_id)
|
||
# 源数据可能是纯 JSON / 纯文本 / 含 JSON 的文本:先尽力抽出内嵌 JSON 单独标注。
|
||
embedded_json = template_fill.extract_embedded_json(source)
|
||
base_prompt = self._build_conversion_prompt(skeleton, source, embedded_json, instructions=instructions)
|
||
last_problems: list[str] = []
|
||
prompt = base_prompt
|
||
candidate: dict[str, Any] | None = None
|
||
for attempt in range(_MAX_TEMPLATE_FILL_RETRIES + 1):
|
||
raw = await self._invoke_conversion_model(model_name, soul, prompt)
|
||
parsed = html_page_utils.extract_json_object(raw)
|
||
if parsed is None:
|
||
last_problems = ["模型未返回可解析的 JSON 对象"]
|
||
else:
|
||
candidate = parsed # 记住最后一个可解析结果,供重试耗尽时尽力返回
|
||
errors = [f"结构错误:{e}" for e in template_fill.validate_data(template_id, candidate)]
|
||
mismatches = [f"字段错位:{m}" for m in template_fill.shape_mismatches(template_id, candidate)]
|
||
if not errors and not mismatches:
|
||
return candidate
|
||
last_problems = errors + mismatches
|
||
gaps = template_fill.field_coverage_gaps(template_id, candidate)
|
||
prompt = self._build_retry_prompt(base_prompt, last_problems, gaps)
|
||
logger.info(
|
||
"模板填充[run=%s]第 %d 次校验未通过:%s",
|
||
run_id,
|
||
attempt + 1,
|
||
";".join(last_problems[:5]),
|
||
)
|
||
if candidate is not None:
|
||
logger.warning(
|
||
"模板填充[run=%s]重试耗尽仍未通过校验,尽力返回待兜底:%s",
|
||
run_id,
|
||
";".join(last_problems[:5]),
|
||
)
|
||
return candidate
|
||
raise TemplateFillError("转换结果未通过结构校验:" + ";".join(last_problems[:8]))
|
||
|
||
async def _finalize_template_html(
|
||
self,
|
||
task: dict[str, Any],
|
||
*,
|
||
spec: Any,
|
||
template_id: str,
|
||
html: str,
|
||
run_id: str,
|
||
scheduled_for: datetime,
|
||
content: str,
|
||
source_chars: int,
|
||
used_default: bool,
|
||
extra_files: list[dict[str, Any]] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""归档 index.html + 持久化 scheduled_html_pages + 构造 format=html 结果。
|
||
|
||
``extra_files`` 会排在 index.html 之前一并带上(如必须生成 Markdown 时的 .md)。
|
||
"""
|
||
task_tz = ZoneInfo(task.get("timezone") or "UTC")
|
||
local_time = scheduled_for.astimezone(task_tz)
|
||
archive_path = self._archive_html_index(run_id, html)
|
||
title = str(task.get("name") or spec.name).strip()
|
||
files: list[dict[str, Any]] = list(extra_files or [])
|
||
if archive_path is not None:
|
||
files.append(
|
||
{
|
||
"name": "index.html",
|
||
"path": str(archive_path),
|
||
"virtual_path": "/mnt/user-data/outputs/index.html",
|
||
"mime_type": "text/html",
|
||
"size": archive_path.stat().st_size if archive_path.exists() else None,
|
||
"kind": "html",
|
||
}
|
||
)
|
||
data_snapshot = {"template_id": template_id, "source_chars": source_chars, "used_default": used_default}
|
||
prompt_snapshot = {"template_id": template_id, "used_default": used_default}
|
||
store = self._html_store
|
||
if store is not None:
|
||
try:
|
||
await store.create_page(
|
||
{
|
||
"id": str(uuid.uuid4()),
|
||
"task_id": task["task_id"],
|
||
"run_id": run_id,
|
||
"user_id": task["user_id"],
|
||
"title": title,
|
||
"html_content": html,
|
||
"html_file_path": str(archive_path) if archive_path else None,
|
||
"data_snapshot": data_snapshot,
|
||
"prompt_snapshot": prompt_snapshot,
|
||
"reference_favorite_id": None,
|
||
"style_summary": None,
|
||
"layout_summary": None,
|
||
}
|
||
)
|
||
except Exception:
|
||
logger.warning("Failed to persist scheduled_html_pages row for template run %s", run_id, exc_info=True)
|
||
inline_html = html if len(html) <= _MAX_INLINE_RESULT_HTML else None
|
||
return {
|
||
"title": f"{title} - {local_time.strftime('%Y-%m-%d %H:%M')}",
|
||
"content": content,
|
||
"format": "html",
|
||
"html": inline_html,
|
||
"data_snapshot": data_snapshot,
|
||
"prompt_snapshot": prompt_snapshot,
|
||
"files": files,
|
||
"created_at": datetime.now(UTC).isoformat(),
|
||
}
|
||
|
||
async def _run_html_template_reference(
|
||
self,
|
||
task: dict[str, Any],
|
||
*,
|
||
tpl_id: str,
|
||
runtime_thread_id: str,
|
||
scheduled_for: datetime,
|
||
run_id: str,
|
||
) -> dict[str, Any]:
|
||
"""require_html 模式选了内置模板(``tpl:<id>``):用**真实模板**出页,保留全部子页面。
|
||
|
||
模板是 JS 驱动的多页应用,子页面靠脚本从 DATA 渲染——绝不能让模型重画(会丢子
|
||
页面)。故:可转换模板=先用智能体检索真实数据→转换成模板 DATA→填进真模板;只展示
|
||
模板=原样输出。转换失败兜底为模板默认数据,至少保证页面与子页面可用。
|
||
"""
|
||
spec = template_fill.get_template(tpl_id)
|
||
if spec is None:
|
||
raise HtmlGenerationError(f"未知的 HTML 模板:{tpl_id}")
|
||
cfg = self._html_config(task)
|
||
model_name = str(cfg.get("model_name") or "").strip() or None
|
||
# 「执行数据抽取」开关**只**控制 HTML 用「抽取数据」还是「模板默认数据」,
|
||
# 不影响 Markdown 生成等其它正常操作。缺省(未存该键的老任务)视为开启:
|
||
# 模板有真实数据来源后,默认走「智能体检索 → 转换填充」,显式 False 才关闭。
|
||
extract = cfg.get("template_extract") is not False
|
||
also_markdown = _task_requires_markdown(task)
|
||
prompt = str(task.get("prompt") or "").strip()
|
||
description = str(task.get("description") or "").strip()
|
||
# 真正抽取 HTML 数据:开关开 且 有描述/任务 prompt 且 模板可转换(只展示模板恒不抽取)。
|
||
do_extract = extract and bool(prompt or description) and spec.convertible
|
||
task_tz = ZoneInfo(task.get("timezone") or "UTC")
|
||
local_time = scheduled_for.astimezone(task_tz)
|
||
|
||
def _pick_md(gathered: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
return [
|
||
item
|
||
for item in (gathered or [])
|
||
if isinstance(item, dict) and str(item.get("name") or "").lower().endswith((".md", ".markdown"))
|
||
]
|
||
|
||
# gather:HTML 要抽取 或 需要 Markdown 时才跑(同一次 agent 运行可兼顾两者);
|
||
# 都不需要则完全不跑 agent。Markdown 校验独立于抽取开关。
|
||
data_text = ""
|
||
md_files: list[dict[str, Any]] = []
|
||
if do_extract or also_markdown:
|
||
data_text, gathered = await self._gather_html_data(
|
||
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
|
||
)
|
||
if also_markdown:
|
||
if not _has_valid_markdown_file(gathered):
|
||
raise MarkdownRequirementError(
|
||
f"未满足输出要求:需生成 Markdown(.md) 文件,且文件正文不少于 {_MARKDOWN_MIN_CHARS} 字"
|
||
)
|
||
md_files = _pick_md(gathered)
|
||
|
||
# 只展示模板:HTML 原样输出(含全部子页面);Markdown 已按需独立生成。
|
||
if not spec.convertible:
|
||
html = template_fill.read_template_text(tpl_id)
|
||
return await self._finalize_template_html(
|
||
task,
|
||
spec=spec,
|
||
template_id=tpl_id,
|
||
html=html,
|
||
run_id=run_id,
|
||
scheduled_for=scheduled_for,
|
||
content=f"已套用模板「{spec.name}」(含全部子页面)",
|
||
source_chars=0,
|
||
used_default=True,
|
||
extra_files=md_files,
|
||
)
|
||
|
||
# HTML 数据来源:抽取(开关开 + 有数据)→ 转换填充;否则用模板默认数据(子页面齐全)。
|
||
source = (data_text or "").strip()
|
||
used_default = True
|
||
page_title: str | None = None
|
||
page_subtitle: str | None = None
|
||
if do_extract and source:
|
||
# 转换阶段同样带上任务所选智能体:SOUL 提供领域口径,未显式选模型时
|
||
# 兜底用该智能体配置的模型(与模板填充模式一致)。
|
||
soul, conv_model = await self._agent_soul_and_model(task, model_name)
|
||
# 任务的描述/prompt 作为「任务要求」注入:用户对页面的修改指示(如
|
||
# "标题改为…"、"只更新某部分")优先级最高,没指示就按检索内容自由发挥。
|
||
instructions = "\n".join(part for part in (description, prompt) if part)
|
||
try:
|
||
converted = await self._convert_to_template_data(
|
||
tpl_id, source, model_name=conv_model, soul=soul, run_id=run_id, instructions=instructions or None
|
||
)
|
||
# 页面标题/副标题是额外键,摘出来替换页头,不进 DATA。
|
||
page_title, page_subtitle = template_fill.pop_page_titles(converted)
|
||
# 子页面没抽到有效内容的,回退模板原有数据,避免子页面空白。
|
||
data_obj = template_fill.merge_with_reference(tpl_id, converted)
|
||
content = f"已检索数据并套用模板「{spec.name}」(空子页面回退原数据,含全部子页面)"
|
||
used_default = False
|
||
except TemplateFillError:
|
||
logger.warning("模板参考转换失败,回退默认数据 run=%s tpl=%s", run_id, tpl_id, exc_info=True)
|
||
data_obj = template_fill.reference_data(tpl_id)
|
||
content = f"数据转换未通过,已回退模板「{spec.name}」默认数据(含全部子页面)"
|
||
else:
|
||
data_obj = template_fill.reference_data(tpl_id)
|
||
content = (
|
||
f"未开启数据抽取,已用模板「{spec.name}」默认数据(含全部子页面)"
|
||
if not extract
|
||
else f"未提供描述/未获取到数据,已用模板「{spec.name}」默认数据(含全部子页面)"
|
||
)
|
||
|
||
try:
|
||
html = template_fill.fill_template(
|
||
tpl_id, data_obj, update_date=local_time.date(), title=page_title, subtitle=page_subtitle
|
||
)
|
||
except template_fill.TemplateError as exc:
|
||
raise HtmlGenerationError(f"模板填充失败:{exc}") from exc
|
||
return await self._finalize_template_html(
|
||
task,
|
||
spec=spec,
|
||
template_id=tpl_id,
|
||
html=html,
|
||
run_id=run_id,
|
||
scheduled_for=scheduled_for,
|
||
content=content,
|
||
source_chars=len(source),
|
||
used_default=used_default,
|
||
extra_files=md_files,
|
||
)
|
||
|
||
async def _run_template_fill_attempt(
|
||
self,
|
||
task: dict[str, Any],
|
||
*,
|
||
runtime_thread_id: str,
|
||
scheduled_for: datetime,
|
||
run_id: str,
|
||
) -> dict[str, Any]:
|
||
"""模板填充模式:把源 JSON 转换成所选模板的 DATA 结构,校验后填充产出 HTML。
|
||
|
||
Pipeline: 取源 JSON(任务 prompt) → 用内置「模板数据转换器」agent 的 SOUL +
|
||
模型把它映射成目标结构 → 结构校验(不过则回喂错误重试) → 填充模板 → 归档
|
||
``index.html`` + 持久化 ``scheduled_html_pages`` → 返回 ``format: "html"``
|
||
结果(复用现有 HTML 预览)。转换质量问题一律**不失败**:抽不到/抽错的分支由
|
||
``merge_with_reference`` 回退模板原有数据,转换彻底失败则整页用模板默认数据;
|
||
只有模板文件本身缺失/损坏才抛 :class:`TemplateFillError` 让 run 失败。
|
||
"""
|
||
template_id = _task_template_id(task)
|
||
spec = template_fill.get_template(template_id) if template_id else None
|
||
if spec is None:
|
||
raise TemplateFillError(f"未知的 HTML 模板:{template_id!r}")
|
||
|
||
source = str(task.get("prompt") or "").strip()
|
||
|
||
# 「只展示」模板:原样输出已有 HTML(含全部子页面)。
|
||
if not spec.convertible:
|
||
html = template_fill.read_template_text(template_id)
|
||
return await self._finalize_template_html(
|
||
task,
|
||
spec=spec,
|
||
template_id=template_id,
|
||
html=html,
|
||
run_id=run_id,
|
||
scheduled_for=scheduled_for,
|
||
content=f"已展示模板「{spec.name}」的现有数据",
|
||
source_chars=len(source),
|
||
used_default=True,
|
||
)
|
||
|
||
page_title: str | None = None
|
||
page_subtitle: str | None = None
|
||
if not source:
|
||
# 未提供源数据 → 用模板自带默认数据(不调模型、不校验)。
|
||
data_obj = template_fill.reference_data(template_id)
|
||
content = f"未提供源数据,已用模板「{spec.name}」的默认数据生成页面"
|
||
used_default = True
|
||
else:
|
||
ctx = task.get("execution_context") or {}
|
||
tcfg = ctx.get("template_config") if isinstance(ctx, dict) else {}
|
||
tcfg = tcfg if isinstance(tcfg, dict) else {}
|
||
|
||
# 选中的内置 agent 的 SOUL + 模型(template_config.model_name 优先,其次 agent.model)。
|
||
model_name = str(tcfg.get("model_name") or "").strip() or None
|
||
soul, model_name = await self._agent_soul_and_model(task, model_name)
|
||
|
||
try:
|
||
converted = await self._convert_to_template_data(
|
||
template_id, source, model_name=model_name, soul=soul, run_id=run_id
|
||
)
|
||
except TemplateFillError:
|
||
# 转换彻底失败(全程没解析出 JSON)→ 也不让 run 失败:用模板默认
|
||
# 数据出页,与 require_html 模板参考路径的兜底一致。
|
||
logger.warning(
|
||
"模板填充转换失败,回退默认数据 run=%s tpl=%s", run_id, template_id, exc_info=True
|
||
)
|
||
data_obj = template_fill.reference_data(template_id)
|
||
content = f"数据转换未通过,已用模板「{spec.name}」的默认数据生成页面(含全部子页面)"
|
||
used_default = True
|
||
else:
|
||
# 页面标题/副标题是额外键,摘出来替换页头,不进 DATA。
|
||
page_title, page_subtitle = template_fill.pop_page_titles(converted)
|
||
# 抽不到/抽错的子页面与字段回退模板原有数据,避免子页面空白或 undefined。
|
||
data_obj = template_fill.merge_with_reference(template_id, converted)
|
||
content = f"已根据源数据填充模板「{spec.name}」生成页面(抽取不到的部分沿用模板原有数据)"
|
||
used_default = False
|
||
|
||
task_tz = ZoneInfo(task.get("timezone") or "UTC")
|
||
local_time = scheduled_for.astimezone(task_tz)
|
||
try:
|
||
html = template_fill.fill_template(
|
||
template_id, data_obj, update_date=local_time.date(), title=page_title, subtitle=page_subtitle
|
||
)
|
||
except template_fill.TemplateError as exc:
|
||
raise TemplateFillError(f"模板填充失败:{exc}") from exc
|
||
return await self._finalize_template_html(
|
||
task,
|
||
spec=spec,
|
||
template_id=template_id,
|
||
html=html,
|
||
run_id=run_id,
|
||
scheduled_for=scheduled_for,
|
||
content=content,
|
||
source_chars=len(source),
|
||
used_default=used_default,
|
||
)
|
||
|
||
async def _run_agent_attempt(
|
||
self,
|
||
task: dict[str, Any],
|
||
*,
|
||
runtime_thread_id: str,
|
||
scheduled_for: datetime,
|
||
run_id: str,
|
||
) -> dict[str, Any]:
|
||
"""Run the agent once and build the result payload.
|
||
|
||
Wrapped in :func:`asyncio.wait_for` by the caller so a hung agent loop
|
||
cannot hold the shared run lock — and the whole scheduler — forever.
|
||
"""
|
||
await self._ensure_thread_meta(task["user_id"], runtime_thread_id)
|
||
async with self._agent_run_lock:
|
||
output_snapshot = self._snapshot_result_files(runtime_thread_id, task["user_id"])
|
||
record = await self._start_agent_run(task, scheduled_for=scheduled_for, runtime_thread_id=runtime_thread_id)
|
||
await self.store.update_run(run_id, {"agent_run_id": record.run_id})
|
||
if record.task is not None:
|
||
await record.task
|
||
result_text = await self._extract_latest_ai_text(runtime_thread_id)
|
||
result_files = await self._extract_result_files(
|
||
runtime_thread_id,
|
||
task["user_id"],
|
||
run_id=run_id,
|
||
output_snapshot=output_snapshot,
|
||
)
|
||
if _task_requires_markdown(task) and not _has_valid_markdown_file(result_files):
|
||
raise MarkdownRequirementError(
|
||
f"未满足输出要求:需生成 Markdown(.md) 文件,且文件正文不少于 {_MARKDOWN_MIN_CHARS} 字"
|
||
)
|
||
return _build_result(task, scheduled_for=scheduled_for, content=result_text, files=result_files)
|
||
|
||
async def _ensure_thread_meta(self, user_id: str, thread_id: str) -> None:
|
||
token = set_current_user(_RuntimeUser(id=user_id))
|
||
try:
|
||
thread_store = self.app.state.thread_store
|
||
existing = await thread_store.get(thread_id)
|
||
if existing is None:
|
||
await thread_store.create(
|
||
thread_id,
|
||
assistant_id="lead_agent",
|
||
display_name="定时任务助手",
|
||
metadata={"thread_type": "scheduler", "system": True},
|
||
)
|
||
checkpointer = self.app.state.checkpointer
|
||
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||
await checkpointer.aput(
|
||
config,
|
||
empty_checkpoint(),
|
||
{
|
||
"step": -1,
|
||
"source": "input",
|
||
"writes": None,
|
||
"parents": {},
|
||
"created_at": now_iso(),
|
||
"thread_type": "scheduler",
|
||
},
|
||
{},
|
||
)
|
||
finally:
|
||
reset_current_user(token)
|
||
|
||
async def _start_agent_run(self, task: dict[str, Any], *, scheduled_for: datetime, runtime_thread_id: str):
|
||
from app.gateway.routers.thread_runs import RunCreateRequest
|
||
from app.gateway.services import start_run
|
||
|
||
token = set_current_user(_RuntimeUser(id=task["user_id"]))
|
||
try:
|
||
# 虚拟 request:带空 state(真请求由 auth 中间件写 state.user/state.auth;
|
||
# 进程内调用没有这层,下游读 request.state.X 必须拿到 None 而非 AttributeError)。
|
||
request = SimpleNamespace(app=self.app, state=SimpleNamespace(user=None, auth=None))
|
||
execution_context = dict(task.get("execution_context") or {})
|
||
stored_agent_ref = task.get("execution_agent_name")
|
||
if stored_agent_ref:
|
||
resolved_agent_id = await self._resolve_agent_id(stored_agent_ref, task["user_id"])
|
||
if resolved_agent_id:
|
||
execution_context["agent_name"] = resolved_agent_id
|
||
else:
|
||
logger.warning(
|
||
"Scheduled task %s references unknown agent %r; falling back to default lead agent",
|
||
task.get("task_id"),
|
||
stored_agent_ref,
|
||
)
|
||
execution_context.update(
|
||
{
|
||
"is_scheduled_run": True,
|
||
"thread_id": runtime_thread_id,
|
||
"thinking_enabled": execution_context.get("thinking_enabled", True),
|
||
"is_plan_mode": execution_context.get("is_plan_mode", True),
|
||
}
|
||
)
|
||
content = (
|
||
f"【定时任务触发】{task['name']}\n"
|
||
f"计划时间:{scheduled_for.astimezone(ZoneInfo(task.get('timezone') or 'UTC')).isoformat()}\n\n"
|
||
f"{task['prompt']}"
|
||
)
|
||
if execution_context.get("require_markdown"):
|
||
content += _MARKDOWN_INSTRUCTION
|
||
body = RunCreateRequest(
|
||
assistant_id="lead_agent",
|
||
input={"messages": [{"type": "human", "content": content}]},
|
||
metadata={"scheduled_task_id": task["task_id"], "scheduled_task_name": task["name"]},
|
||
context=execution_context,
|
||
multitask_strategy="enqueue" if False else "reject",
|
||
)
|
||
return await start_run(body, runtime_thread_id, request) # type: ignore[arg-type]
|
||
finally:
|
||
reset_current_user(token)
|
||
|
||
async def _resolve_agent_id(self, stored_ref: str, user_id: str) -> str | None:
|
||
"""Resolve a scheduled task's ``execution_agent_name`` to a stable agent id.
|
||
|
||
Older tasks (created before the frontend was fixed) stored the agent's
|
||
Chinese **display name** here; the runtime validator only accepts ids
|
||
matching ``^[A-Za-z0-9_-]+$``, so those rows would 422 forever. We
|
||
gracefully recover by looking the stored value up first as an id, then
|
||
as a display name. Returns ``None`` when nothing visible matches — the
|
||
caller then falls back to the default lead agent rather than crashing.
|
||
"""
|
||
store = getattr(self.app.state, "agent_store", None)
|
||
if store is None:
|
||
return stored_ref if AGENT_ID_PATTERN.fullmatch(stored_ref) else None
|
||
if AGENT_ID_PATTERN.fullmatch(stored_ref):
|
||
try:
|
||
row = await store.get_visible(stored_ref, user_id)
|
||
except Exception:
|
||
row = None
|
||
if row is not None:
|
||
return row["id"]
|
||
try:
|
||
agents = await store.list_visible(user_id)
|
||
except Exception:
|
||
return None
|
||
for agent in agents:
|
||
if agent.get("name") == stored_ref:
|
||
return agent.get("id")
|
||
return None
|
||
|
||
async def _extract_latest_ai_text(self, thread_id: str) -> str | None:
|
||
checkpointer = self.app.state.checkpointer
|
||
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||
checkpoint_tuple = await checkpointer.aget_tuple(config)
|
||
if checkpoint_tuple is None:
|
||
return None
|
||
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
||
channel_values = checkpoint.get("channel_values", {})
|
||
values = serialize_channel_values(channel_values)
|
||
messages = values.get("messages") or []
|
||
for message in reversed(messages):
|
||
if not isinstance(message, dict):
|
||
continue
|
||
message_type = message.get("type") or message.get("role")
|
||
if message_type not in {"ai", "assistant"}:
|
||
continue
|
||
content = message.get("content")
|
||
if isinstance(content, str):
|
||
return content
|
||
if isinstance(content, list):
|
||
parts: list[str] = []
|
||
for part in content:
|
||
if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str):
|
||
parts.append(part["text"])
|
||
if parts:
|
||
return "\n".join(parts)
|
||
return None
|
||
|
||
def _result_scan_dirs(self, thread_id: str, user_id: str) -> list[tuple[str, Path]]:
|
||
paths = get_paths()
|
||
return [
|
||
("/mnt/user-data/outputs", paths.sandbox_outputs_dir(thread_id, user_id=user_id)),
|
||
("/mnt/user-data/workspace", paths.sandbox_work_dir(thread_id, user_id=user_id)),
|
||
]
|
||
|
||
def _snapshot_result_files(self, thread_id: str, user_id: str) -> dict[str, tuple[int, int]]:
|
||
snapshot: dict[str, tuple[int, int]] = {}
|
||
for _, directory in self._result_scan_dirs(thread_id, user_id):
|
||
if not directory.exists() or not directory.is_dir():
|
||
continue
|
||
try:
|
||
for path in directory.rglob("*"):
|
||
if not path.is_file():
|
||
continue
|
||
stat = path.stat()
|
||
snapshot[str(path.resolve())] = (stat.st_mtime_ns, stat.st_size)
|
||
except Exception:
|
||
logger.debug("Failed to snapshot scheduled task result dir %s", directory, exc_info=True)
|
||
return snapshot
|
||
|
||
def _virtual_result_path(self, virtual_prefix: str, result_dir: Path, path: Path) -> str | None:
|
||
try:
|
||
relative = path.resolve().relative_to(result_dir.resolve())
|
||
except ValueError:
|
||
return None
|
||
return f"{virtual_prefix}/{relative.as_posix()}"
|
||
|
||
def _collect_changed_output_files(
|
||
self,
|
||
thread_id: str,
|
||
user_id: str,
|
||
output_snapshot: dict[str, tuple[int, int]],
|
||
) -> list[tuple[str, Path]]:
|
||
changed: list[tuple[str, Path]] = []
|
||
for virtual_prefix, directory in self._result_scan_dirs(thread_id, user_id):
|
||
if not directory.exists() or not directory.is_dir():
|
||
continue
|
||
try:
|
||
for path in sorted(directory.rglob("*"), key=lambda item: item.as_posix()):
|
||
if not path.is_file():
|
||
continue
|
||
resolved = path.resolve()
|
||
stat = path.stat()
|
||
previous = output_snapshot.get(str(resolved))
|
||
if previous == (stat.st_mtime_ns, stat.st_size):
|
||
continue
|
||
virtual_path = self._virtual_result_path(virtual_prefix, directory, resolved)
|
||
if virtual_path is not None:
|
||
changed.append((virtual_path, resolved))
|
||
except Exception:
|
||
logger.debug("Failed to collect changed scheduled task result files from %s", directory, exc_info=True)
|
||
return changed
|
||
|
||
def _is_in_result_scan_dirs(self, thread_id: str, user_id: str, path: Path) -> bool:
|
||
try:
|
||
resolved = path.resolve()
|
||
except Exception:
|
||
return False
|
||
for _, directory in self._result_scan_dirs(thread_id, user_id):
|
||
try:
|
||
resolved.relative_to(directory.resolve())
|
||
return True
|
||
except ValueError:
|
||
continue
|
||
return False
|
||
|
||
def _archive_result_file(self, run_id: str, path: Path) -> Path | None:
|
||
if not path.exists() or not path.is_file():
|
||
return None
|
||
|
||
archive_dir = get_paths().base_dir / "scheduled-task-runs" / run_id / "files"
|
||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
candidate = archive_dir / path.name
|
||
if candidate.exists():
|
||
stem = path.stem or "file"
|
||
suffix = path.suffix
|
||
index = 2
|
||
while candidate.exists():
|
||
candidate = archive_dir / f"{stem}-{index}{suffix}"
|
||
index += 1
|
||
|
||
shutil.copy2(path, candidate)
|
||
if candidate.suffix.lower() in {".md", ".markdown"}:
|
||
try:
|
||
original = candidate.read_text(encoding="utf-8", errors="ignore")
|
||
constrained = _constrain_markdown_same_page_anchor_links(original)
|
||
if constrained != original:
|
||
candidate.write_text(constrained, encoding="utf-8", newline="")
|
||
except Exception:
|
||
logger.debug("Failed to constrain scheduled markdown links in %s", candidate, exc_info=True)
|
||
return candidate
|
||
|
||
def _build_file_result(self, *, virtual_path: str, source_path: Path, archive_path: Path) -> dict[str, Any]:
|
||
mime_type, _ = mimetypes.guess_type(archive_path.name)
|
||
suffix = archive_path.suffix.lower()
|
||
return {
|
||
"name": archive_path.name,
|
||
"path": str(archive_path),
|
||
"source_path": str(source_path),
|
||
"virtual_path": virtual_path,
|
||
"mime_type": mime_type or ("text/markdown" if suffix == ".md" else None),
|
||
"size": archive_path.stat().st_size if archive_path.exists() and archive_path.is_file() else None,
|
||
"kind": "markdown" if suffix == ".md" else "artifact",
|
||
}
|
||
|
||
def _is_new_or_changed_output_file(
|
||
self,
|
||
thread_id: str,
|
||
user_id: str,
|
||
path: Path,
|
||
output_snapshot: dict[str, tuple[int, int]],
|
||
) -> bool:
|
||
if not self._is_in_result_scan_dirs(thread_id, user_id, path):
|
||
return True
|
||
|
||
resolved = path.resolve()
|
||
if not resolved.exists() or not resolved.is_file():
|
||
return False
|
||
stat = resolved.stat()
|
||
return output_snapshot.get(str(resolved)) != (stat.st_mtime_ns, stat.st_size)
|
||
|
||
async def _extract_result_files(
|
||
self,
|
||
thread_id: str,
|
||
user_id: str,
|
||
*,
|
||
run_id: str,
|
||
output_snapshot: dict[str, tuple[int, int]] | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
checkpointer = self.app.state.checkpointer
|
||
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||
checkpoint_tuple = await checkpointer.aget_tuple(config)
|
||
artifacts: list[Any] = []
|
||
if checkpoint_tuple is not None:
|
||
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
||
channel_values = checkpoint.get("channel_values", {})
|
||
values = serialize_channel_values(channel_values)
|
||
raw_artifacts = values.get("artifacts") or []
|
||
if isinstance(raw_artifacts, list):
|
||
artifacts = raw_artifacts
|
||
|
||
result_files: list[dict[str, Any]] = []
|
||
seen: set[str] = set()
|
||
|
||
def add_file(virtual_path: str, source_path: Path) -> None:
|
||
resolved_source = source_path.resolve()
|
||
seen_key = str(resolved_source)
|
||
if seen_key in seen:
|
||
return
|
||
seen.add(seen_key)
|
||
try:
|
||
archive_path = self._archive_result_file(run_id, resolved_source)
|
||
except Exception:
|
||
logger.debug("Failed to archive scheduled task artifact %s", resolved_source, exc_info=True)
|
||
return
|
||
if archive_path is None:
|
||
return
|
||
result_files.append(self._build_file_result(virtual_path=virtual_path, source_path=resolved_source, archive_path=archive_path))
|
||
|
||
for artifact in artifacts:
|
||
if not isinstance(artifact, str) or not artifact:
|
||
continue
|
||
try:
|
||
actual_path = get_paths().resolve_virtual_path(thread_id, artifact, user_id=user_id)
|
||
except TypeError:
|
||
actual_path = get_paths().resolve_virtual_path(thread_id, artifact)
|
||
except Exception:
|
||
logger.debug("Failed to resolve scheduled task artifact %s", artifact, exc_info=True)
|
||
continue
|
||
path = Path(actual_path)
|
||
if self._is_new_or_changed_output_file(thread_id, user_id, path, output_snapshot or {}):
|
||
add_file(artifact, path)
|
||
|
||
for virtual_path, path in self._collect_changed_output_files(thread_id, user_id, output_snapshot or {}):
|
||
add_file(virtual_path, path)
|
||
|
||
return result_files
|
||
|
||
async def _deliver_element_result(self, task: dict[str, Any], result_text: str) -> None:
|
||
if self._element_client is None:
|
||
return
|
||
body = f"【定时任务】{task['name']}\n\n{result_text}"
|
||
# 1) Author
|
||
await self._deliver_to_user(task["user_id"], task, body)
|
||
# 2) Subscribers who opted into notifications
|
||
try:
|
||
subscribers = await self.store.list_notify_subscribers(task["task_id"])
|
||
except Exception:
|
||
logger.exception("Failed to list scheduled task subscribers for %s", task["task_id"])
|
||
subscribers = []
|
||
for sub in subscribers:
|
||
sub_user_id = sub.get("user_id")
|
||
if not sub_user_id or sub_user_id == task["user_id"]:
|
||
continue
|
||
await self._deliver_to_user(sub_user_id, task, body)
|
||
|
||
async def _deliver_to_user(self, user_id: str, task: dict[str, Any], body: str) -> None:
|
||
if self._element_client is None:
|
||
return
|
||
try:
|
||
profile = await self.store.get_delivery_profile(user_id)
|
||
except Exception:
|
||
logger.exception("Failed to load delivery profile for user %s", user_id)
|
||
return
|
||
element_user_id = profile.get("element_user_id")
|
||
if not isinstance(element_user_id, str) or not element_user_id.strip():
|
||
return
|
||
existing_room_id: str | None = profile.get("element_room_id") or None
|
||
try:
|
||
room_id = await self._element_client.send_direct_message(element_user_id.strip(), body, room_id=existing_room_id)
|
||
if room_id and room_id != existing_room_id:
|
||
await self.store.update_delivery_profile(user_id, {"element_room_id": room_id})
|
||
except Exception:
|
||
logger.exception("Failed to deliver scheduled task %s to Element user %s", task["task_id"], element_user_id)
|
||
|
||
# --- Publish / subscribe ---
|
||
|
||
async def publish_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
|
||
return await self.store.set_task_published(task_id, user_id, published=True)
|
||
|
||
async def unpublish_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
|
||
return await self.store.set_task_published(task_id, user_id, published=False)
|
||
|
||
async def subscribe_task(
|
||
self, user_id: str, task_id: str, *, notify_element: bool
|
||
) -> dict[str, Any] | None:
|
||
task = await self.store.get_task_any(task_id)
|
||
if task is None:
|
||
return None
|
||
if task["user_id"] == user_id:
|
||
# Authors don't need to subscribe to their own tasks.
|
||
return None
|
||
existing = await self.store.get_subscription(task_id, user_id)
|
||
if existing is None and not task.get("published"):
|
||
# First-time subscription requires the task to currently be published.
|
||
return None
|
||
return await self.store.upsert_subscription(task_id, user_id, notify_element=notify_element)
|
||
|
||
async def unsubscribe_task(self, user_id: str, task_id: str) -> bool:
|
||
return await self.store.delete_subscription(task_id, user_id)
|
||
|
||
# --- HTML pages / favorites ---
|
||
|
||
@property
|
||
def _html_store(self):
|
||
state = getattr(self.app, "state", None)
|
||
return getattr(state, "html_page_store", None)
|
||
|
||
async def get_html_page_for_run(self, run_id: str) -> dict[str, Any] | None:
|
||
"""Return the generated HTML page for a run (page-store row or run result)."""
|
||
store = self._html_store
|
||
if store is not None:
|
||
page = await store.get_page_by_run(run_id)
|
||
if page is not None:
|
||
return page
|
||
# Fallback to the inline HTML on the run result.
|
||
run = await self.store.get_run_any(run_id)
|
||
if run is None:
|
||
return None
|
||
result = run.get("result") if isinstance(run.get("result"), dict) else None
|
||
if not result or not isinstance(result.get("html"), str):
|
||
return None
|
||
return {
|
||
"run_id": run_id,
|
||
"task_id": run.get("task_id"),
|
||
"title": result.get("title"),
|
||
"html_content": result.get("html"),
|
||
"style_summary": result.get("style_summary"),
|
||
"layout_summary": result.get("layout_summary"),
|
||
"created_at": result.get("created_at"),
|
||
}
|
||
|
||
async def get_latest_html_page_for_task(self, task_id: str) -> dict[str, Any] | None:
|
||
store = self._html_store
|
||
if store is not None:
|
||
page = await store.latest_page_for_task(task_id)
|
||
if page is not None:
|
||
return page
|
||
# Fallback: newest run that carries inline HTML.
|
||
runs = await self.store.list_runs_any(task_id, limit=20)
|
||
for run in runs:
|
||
result = run.get("result") if isinstance(run.get("result"), dict) else None
|
||
if result and isinstance(result.get("html"), str):
|
||
return await self.get_html_page_for_run(run["id"])
|
||
return None
|
||
|
||
async def list_html_page_favorites(self, user_id: str) -> list[dict[str, Any]]:
|
||
store = self._html_store
|
||
if store is None:
|
||
return []
|
||
return await store.list_favorites(user_id)
|
||
|
||
async def get_html_page_favorite(self, user_id: str, favorite_id: str) -> dict[str, Any] | None:
|
||
store = self._html_store
|
||
if store is None:
|
||
return None
|
||
favorite = await store.get_favorite(favorite_id)
|
||
if favorite is None or favorite.get("user_id") != user_id:
|
||
return None
|
||
return favorite
|
||
|
||
async def delete_html_page_favorite(self, user_id: str, favorite_id: str) -> bool:
|
||
store = self._html_store
|
||
if store is None:
|
||
return False
|
||
return await store.delete_favorite(favorite_id, user_id)
|
||
|
||
async def create_html_page_favorite(
|
||
self,
|
||
user_id: str,
|
||
page_id: str,
|
||
*,
|
||
title: str | None = None,
|
||
description: str | None = None,
|
||
tags: list[str] | None = None,
|
||
) -> dict[str, Any] | None:
|
||
"""Bookmark a generated page, extracting a reusable style summary via GLM5.
|
||
|
||
Returns ``None`` when the page does not exist or is not owned by the
|
||
caller. Style extraction is best-effort — a failure still produces a
|
||
favorite (without the reuse hints) so the user's bookmark is never lost.
|
||
"""
|
||
store = self._html_store
|
||
if store is None:
|
||
return None
|
||
page = await store.get_page(page_id)
|
||
if page is None or page.get("user_id") != user_id:
|
||
return None
|
||
# A page can only be bookmarked once per user.
|
||
existing = await store.get_favorite_by_page(user_id, page_id)
|
||
if existing is not None:
|
||
raise DuplicateFavoriteError(page_id)
|
||
html = page.get("html_content") or ""
|
||
|
||
style_summary = page.get("style_summary")
|
||
layout_summary = page.get("layout_summary")
|
||
content_structure_summary = None
|
||
reuse_prompt = None
|
||
if html:
|
||
try:
|
||
# Reuse the model the page was generated with (recorded in the
|
||
# page's prompt snapshot) so favoriting doesn't fall back to the
|
||
# default model.
|
||
extract_model = (page.get("prompt_snapshot") or {}).get("model_name")
|
||
extract_prompt = html_page_utils.build_style_extract_prompt(html=html)
|
||
raw = await self._invoke_chat_model(extract_model, extract_prompt)
|
||
extracted = html_page_utils.extract_json_object(raw) or {}
|
||
style_summary = extracted.get("style_summary") or style_summary
|
||
layout_summary = extracted.get("layout_summary") or layout_summary
|
||
content_structure_summary = extracted.get("content_structure_summary")
|
||
reuse_prompt = extracted.get("reuse_prompt")
|
||
except Exception:
|
||
logger.warning("HTML style extraction failed for page %s; saving favorite without hints", page_id, exc_info=True)
|
||
|
||
return await store.create_favorite(
|
||
{
|
||
"id": str(uuid.uuid4()),
|
||
"user_id": user_id,
|
||
"page_id": page_id,
|
||
"title": (title or page.get("title") or "收藏页面").strip(),
|
||
"description": (description or None),
|
||
"tags": tags or [],
|
||
"html_snapshot": html,
|
||
"style_summary": style_summary,
|
||
"layout_summary": layout_summary,
|
||
"content_structure_summary": content_structure_summary,
|
||
"reuse_prompt": reuse_prompt,
|
||
}
|
||
)
|