| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213 |
- """TodoTool - 轻量级待办板
- MVP 目标:
- - 仅支持 add / list / update
- - 状态枚举:pending | in_progress | completed(强约束:同时最多 1 个 in_progress)
- - 存储:.helloagents/todos/todos.json,原子写入 + 简单备份
- - 输出:按状态分组的要点列表,便于 LLM 消化
- """
- from __future__ import annotations
- import json
- from dataclasses import dataclass, asdict
- from datetime import datetime
- from pathlib import Path
- from typing import Any, Dict, List, Optional
- from ..base import Tool, ToolParameter
- STATUSES = ("pending", "in_progress", "completed")
- @dataclass
- class TodoItem:
- id: int
- title: str
- desc: str = ""
- status: str = "pending"
- created_at: str = ""
- updated_at: str = ""
- @classmethod
- def from_dict(cls, data: Dict[str, Any]) -> "TodoItem":
- return cls(
- id=int(data["id"]),
- title=data.get("title", ""),
- desc=data.get("desc", ""),
- status=data.get("status", "pending"),
- created_at=data.get("created_at", ""),
- updated_at=data.get("updated_at", ""),
- )
- class TodoTool(Tool):
- def __init__(self, workspace: str):
- super().__init__(
- name="todo",
- description="待办工具:add/list/update;状态 pending|in_progress|completed(同时仅允许1个in_progress)",
- )
- self.workspace = Path(workspace)
- self.workspace.mkdir(parents=True, exist_ok=True)
- self.data_file = self.workspace / "todos.json"
- self.backup_file = self.workspace / "todos.json.bak"
- if not self.data_file.exists():
- self._save({"items": []})
- def get_parameters(self) -> List[ToolParameter]:
- return [
- ToolParameter(name="action", type="string", description="add | list | update", required=True),
- ToolParameter(name="title", type="string", description="待办标题(add必填,update可选)", required=False),
- ToolParameter(name="desc", type="string", description="待办描述(可选)", required=False),
- ToolParameter(name="status", type="string", description="pending|in_progress|completed(update可选)", required=False),
- ToolParameter(name="id", type="integer", description="要更新的待办ID(update必填)", required=False),
- ]
- # ---------------- core ops ----------------
- def run(self, parameters: Dict[str, Any]) -> str:
- if not self.validate_parameters(parameters):
- return "参数缺失,需包含 action(add/list/update)。"
- action = str(parameters.get("action", "")).strip().lower().rstrip("]")
- if action == "add":
- return self._add(title=parameters.get("title", ""), desc=parameters.get("desc", ""), status=parameters.get("status", "pending"))
- if action == "list":
- return self._list(status_filter=parameters.get("status"))
- if action == "update":
- return self._update(
- todo_id=parameters.get("id"),
- title=parameters.get("title"),
- desc=parameters.get("desc"),
- status=parameters.get("status"),
- )
- return "不支持的 action,应为 add/list/update。"
- # ---------------- storage ----------------
- def _load(self) -> Dict[str, Any]:
- with open(self.data_file, "r", encoding="utf-8") as f:
- return json.load(f)
- def _save(self, data: Dict[str, Any]) -> None:
- tmp = self.data_file.with_suffix(".tmp")
- if self.data_file.exists():
- try:
- self.backup_file.write_text(self.data_file.read_text(encoding="utf-8"), encoding="utf-8")
- except Exception:
- pass
- tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
- tmp.replace(self.data_file)
- # ---------------- helpers ----------------
- def _next_id(self, items: List[TodoItem]) -> int:
- return (max([i.id for i in items], default=0) + 1) if items else 1
- def _now(self) -> str:
- return datetime.now().isoformat(timespec="seconds")
- def _enforce_single_in_progress(self, items: List[TodoItem], incoming_status: str, incoming_id: Optional[int]) -> Optional[str]:
- if incoming_status != "in_progress":
- return None
- for it in items:
- if it.status == "in_progress" and (incoming_id is None or it.id != incoming_id):
- return f"已有进行中的任务 #{it.id}《{it.title}》。先完成/更新它后再切换。"
- return None
- # ---------------- actions ----------------
- def _add(self, title: str, desc: str, status: str) -> str:
- title = (title or "").strip()
- if not title:
- return "❌ add 失败:title 不能为空。"
- status = status if status in STATUSES else "pending"
- data = self._load()
- items = [TodoItem.from_dict(i) for i in data.get("items", [])]
- conflict = self._enforce_single_in_progress(items, status, None)
- if conflict:
- return f"❌ add 失败:{conflict}"
- now = self._now()
- new_item = TodoItem(id=self._next_id(items), title=title, desc=desc or "", status=status, created_at=now, updated_at=now)
- items.append(new_item)
- self._save({"items": [asdict(i) for i in items]})
- return f"✅ 已添加 #{new_item.id} [{new_item.status}] {new_item.title}"
- def _update(self, todo_id: Any, title: Optional[str], desc: Optional[str], status: Optional[str]) -> str:
- try:
- tid = int(todo_id)
- except Exception:
- return "❌ update 失败:缺少有效的 id。"
- if status and status not in STATUSES:
- return "❌ update 失败:status 必须是 pending|in_progress|completed。"
- data = self._load()
- items = [TodoItem.from_dict(i) for i in data.get("items", [])]
- target = next((i for i in items if i.id == tid), None)
- if not target:
- return f"❌ update 失败:未找到 id={tid} 的任务。"
- conflict = self._enforce_single_in_progress(items, status or target.status, tid)
- if conflict:
- return f"❌ update 失败:{conflict}"
- changed = []
- if title is not None:
- target.title = title.strip()
- changed.append("title")
- if desc is not None:
- target.desc = desc
- changed.append("desc")
- if status is not None:
- target.status = status
- changed.append("status")
- target.updated_at = self._now()
- self._save({"items": [asdict(i) for i in items]})
- if not changed:
- return f"⚠️ 未修改任何字段 #{tid}"
- return f"✅ 已更新 #{tid} ({', '.join(changed)}) -> [{target.status}] {target.title}"
- def _list(self, status_filter: Optional[str]) -> str:
- data = self._load()
- items = [TodoItem.from_dict(i) for i in data.get("items", [])]
- if status_filter and status_filter in STATUSES:
- items = [i for i in items if i.status == status_filter]
- groups = {"in_progress": [], "pending": [], "completed": []}
- for it in items:
- groups.setdefault(it.status, []).append(it)
- # ANSI colors similar to v2_todo_agent
- COLOR_PENDING = "\x1b[38;2;176;176;176m"
- COLOR_PROGRESS = "\x1b[38;2;120;200;255m"
- COLOR_DONE = "\x1b[38;2;34;139;34m"
- RESET = "\x1b[0m"
- def fmt(group_name: str, arr: List[TodoItem]) -> str:
- if not arr:
- return ""
- lines = [f"[{group_name.upper()}]"]
- for it in sorted(arr, key=lambda x: x.id):
- mark = "☒" if it.status == "completed" else "☐"
- color = COLOR_PENDING
- if it.status == "in_progress":
- color = COLOR_PROGRESS
- elif it.status == "completed":
- color = COLOR_DONE
- line_main = f"- {mark} #{it.id} {it.title} (updated {it.updated_at})"
- line_desc = f" {it.desc}" if it.desc else None
- if it.status == "completed":
- line_main = f"{color}\x1b[9m{line_main}{RESET}"
- if line_desc:
- line_desc = f"{color}\x1b[9m{line_desc}{RESET}"
- else:
- line_main = f"{color}{line_main}{RESET}"
- if line_desc:
- line_desc = f"{color}{line_desc}{RESET}"
- lines.append(line_main)
- if line_desc:
- lines.append(line_desc)
- return "\n".join(lines)
- parts = [fmt("in_progress", groups["in_progress"]), fmt("pending", groups["pending"]), fmt("completed", groups["completed"])]
- out = "\n\n".join([p for p in parts if p])
- return out or "暂无待办。"
|