TUI 界面改进: - 全面中文化: CommandPalette/HelpPanel/Footer/Chatbox 边框标题等所有用户可见英文汉化 - 辅助中心 (Ctrl+P/F9): 原"设置"重命名, 含 6 项可切换设置 - 独立帮助浮层 (? 键): 重写帮助页面逻辑, 全中文, 带滑入淡入动画 - 背景切换: 辅助中心 [5] 预设背景, [6] 自定义背景; 新增 /bg 命令支持图片提取主色调 - 换行修复: Ctrl+J 作为主推换行键 (Windows 终端不区分 Shift+Enter) - 引擎切换修复: 解决 call_from_thread 在主线程崩溃的 RuntimeError 技术改进: - 文本工具调用解析: 检测模型用 <tool_call> 标签返回的工具调用 - 网络工具优化: network_tools.py 和 ai_search.py 改进请求头/重试/编码 - 流式输出修复: append_chunk 的 refresh 改为 layout=True - 取消响应修复: 引擎层 _cancelled 标志 + TUI 层 worker 身份跟踪 融合 v6.0.0 工作: - 保留 terminal 模拟器/operit 桥接/深度思考/工作模式/记忆模式等 v6.0.0 全部功能 - 在此基础上叠加本会话的 TUI 改进
473 lines
18 KiB
Python
473 lines
18 KiB
Python
"""
|
||
定时任务插件 - AI 可设定定时任务,到时自动激活提醒
|
||
支持一次性和重复性定时任务
|
||
"""
|
||
import json
|
||
import os
|
||
import time
|
||
import threading
|
||
from datetime import datetime, timedelta
|
||
from colorama import Fore, Style
|
||
|
||
|
||
class Liugin:
|
||
"""定时任务插件"""
|
||
|
||
def __init__(self):
|
||
self.usage = """定时任务工具使用方法:
|
||
|
||
JSON格式示例:
|
||
{"action": "use_tool", "tool": "scheduler", "args": "add 30m 提醒我检查代码"} - 30分钟后提醒
|
||
{"action": "use_tool", "tool": "scheduler", "args": "add 2h 提交代码"} - 2小时后提醒
|
||
{"action": "use_tool", "tool": "scheduler", "args": "add 14:30 开会"} - 今天14:30提醒
|
||
{"action": "use_tool", "tool": "scheduler", "args": "add 2026-04-28 10:00 发布v5.2"} - 指定日期时间提醒
|
||
{"action": "use_tool", "tool": "scheduler", "args": "list"} - 列出所有定时任务
|
||
{"action": "use_tool", "tool": "scheduler", "args": "delete 1"} - 删除任务ID为1的任务
|
||
{"action": "use_tool", "tool": "scheduler", "args": "clear"} - 清除所有已完成/已取消的任务
|
||
|
||
时间格式说明:
|
||
- 相对时间: 30s / 30m / 2h / 1d(秒/分/时/天)
|
||
- 今日时间: HH:MM(如 14:30)
|
||
- 绝对时间: YYYY-MM-DD HH:MM(如 2026-04-28 10:00)
|
||
- 重复任务: 每个时间前加 r: 前缀,如 r:30m / r:2h / r:14:30
|
||
重复任务会自动重新调度,不会自动删除
|
||
|
||
功能说明:
|
||
- add <时间> <描述> - 添加定时任务
|
||
- list - 列出所有定时任务
|
||
- delete <任务ID> - 删除指定任务
|
||
- clear - 清除已完成/已取消的任务
|
||
|
||
当定时任务到期时,AI 会自动被激活并收到提醒。"""
|
||
|
||
self.cli = None
|
||
self.tasks_file = os.path.join(
|
||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
"scheduler_tasks.json"
|
||
)
|
||
self.tasks = []
|
||
self._lock = threading.Lock()
|
||
self._monitor_thread = None
|
||
self._monitor_running = False
|
||
self._load_tasks()
|
||
|
||
def set_cli(self, cli):
|
||
"""设置CLI实例引用"""
|
||
self.cli = cli
|
||
self.cli.register_liugin_command('scheduler', self.command_handler)
|
||
self.cli.register_liugin_command('remind', self.command_handler)
|
||
# 启动后台监控线程
|
||
self._start_monitor()
|
||
|
||
def command_handler(self, args):
|
||
"""处理 /scheduler 或 /remind 命令"""
|
||
return self.handle(args)
|
||
|
||
def get_tool_info(self):
|
||
return {
|
||
"name": "scheduler",
|
||
"description": "定时任务工具,AI 可设定定时提醒。支持相对时间(30m/2h)、今日时间(14:30)、绝对时间(2026-04-28 10:00)、重复任务(r:30m)",
|
||
"keywords": ["定时", "提醒", "scheduler", "remind", "闹钟", "alarm", "schedule", "计划"],
|
||
"usage": self.usage
|
||
}
|
||
|
||
# ── 数据持久化 ──
|
||
|
||
def _load_tasks(self):
|
||
"""从文件加载任务"""
|
||
try:
|
||
if os.path.exists(self.tasks_file):
|
||
with open(self.tasks_file, 'r', encoding='utf-8') as f:
|
||
self.tasks = json.load(f)
|
||
except Exception:
|
||
self.tasks = []
|
||
|
||
def _save_tasks(self):
|
||
"""保存任务到文件"""
|
||
try:
|
||
with open(self.tasks_file, 'w', encoding='utf-8') as f:
|
||
json.dump(self.tasks, f, ensure_ascii=False, indent=2)
|
||
except Exception:
|
||
pass
|
||
|
||
# ── 时间解析 ──
|
||
|
||
def _parse_time(self, time_str: str) -> tuple:
|
||
"""
|
||
解析时间字符串
|
||
返回: (datetime, is_repeat, repeat_seconds)
|
||
"""
|
||
time_str = time_str.strip()
|
||
now = datetime.now()
|
||
is_repeat = False
|
||
repeat_seconds = 0
|
||
|
||
# 检查是否是重复任务
|
||
if time_str.startswith('r:'):
|
||
is_repeat = True
|
||
time_str = time_str[2:].strip()
|
||
|
||
# 相对时间: 30s / 30m / 2h / 1d
|
||
if time_str[-1:].lower() in ('s', 'm', 'h', 'd') and time_str[:-1].strip().isdigit():
|
||
num = int(time_str[:-1].strip())
|
||
unit = time_str[-1].lower()
|
||
multipliers = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400}
|
||
seconds = num * multipliers[unit]
|
||
if is_repeat:
|
||
repeat_seconds = seconds
|
||
return now + timedelta(seconds=seconds), is_repeat, repeat_seconds
|
||
|
||
# 绝对时间: YYYY-MM-DD HH:MM
|
||
try:
|
||
dt = datetime.strptime(time_str, "%Y-%m-%d %H:%M")
|
||
if is_repeat:
|
||
# 重复任务的绝对时间当作每日重复
|
||
repeat_seconds = 86400 # 24h
|
||
return dt, is_repeat, repeat_seconds
|
||
except ValueError:
|
||
pass
|
||
|
||
# 今日时间: HH:MM
|
||
try:
|
||
dt = datetime.strptime(time_str, "%H:%M")
|
||
target = now.replace(hour=dt.hour, minute=dt.minute, second=0, microsecond=0)
|
||
if target <= now:
|
||
target += timedelta(days=1)
|
||
if is_repeat:
|
||
repeat_seconds = 86400
|
||
return target, is_repeat, repeat_seconds
|
||
except ValueError:
|
||
pass
|
||
|
||
return None, False, 0
|
||
|
||
# ── 核心操作 ──
|
||
|
||
def _add_task(self, time_str: str, message: str) -> str:
|
||
"""添加定时任务"""
|
||
target_time, is_repeat, repeat_seconds = self._parse_time(time_str)
|
||
if target_time is None:
|
||
return f"无法解析时间: {time_str}。支持格式: 30m / 2h / 14:30 / 2026-04-28 10:00 / r:30m"
|
||
|
||
with self._lock:
|
||
task_id = max([t.get('id', 0) for t in self.tasks], default=0) + 1
|
||
task = {
|
||
"id": task_id,
|
||
"message": message,
|
||
"trigger_at": target_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"repeat": is_repeat,
|
||
"repeat_seconds": repeat_seconds,
|
||
"status": "pending", # pending / fired / canceled
|
||
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
self.tasks.append(task)
|
||
self._save_tasks()
|
||
|
||
time_display = target_time.strftime("%Y-%m-%d %H:%M:%S")
|
||
repeat_info = f" (每{self._format_seconds(repeat_seconds)}重复)" if is_repeat else ""
|
||
return f"定时任务 #{task_id} 已创建{repeat_info}\n⏰ {time_display} → {message}"
|
||
|
||
def _list_tasks(self) -> str:
|
||
"""列出所有定时任务"""
|
||
with self._lock:
|
||
if not self.tasks:
|
||
return "暂无定时任务"
|
||
|
||
lines = []
|
||
now = datetime.now()
|
||
for t in self.tasks:
|
||
tid = t['id']
|
||
status = t['status']
|
||
msg = t['message']
|
||
trigger = t['trigger_at']
|
||
repeat = t.get('repeat', False)
|
||
|
||
# 状态图标
|
||
if status == 'pending':
|
||
remaining = self._calc_remaining(trigger, now)
|
||
icon = "⏰"
|
||
suffix = f" ({remaining})" if remaining else ""
|
||
repeat_tag = f" [每{self._format_seconds(t.get('repeat_seconds', 0))}重复]" if repeat else ""
|
||
lines.append(f" {icon} #{tid}{suffix}{repeat_tag}: {msg}")
|
||
elif status == 'fired':
|
||
lines.append(f" ✅ #{tid} (已触发): {msg}")
|
||
elif status == 'canceled':
|
||
lines.append(f" ❌ #{tid} (已取消): {msg}")
|
||
|
||
return "\n".join(lines) if lines else "暂无定时任务"
|
||
|
||
def _delete_task(self, task_id_str: str) -> str:
|
||
"""删除指定任务"""
|
||
try:
|
||
task_id = int(task_id_str)
|
||
except ValueError:
|
||
return f"无效的任务ID: {task_id_str}"
|
||
|
||
with self._lock:
|
||
for t in self.tasks:
|
||
if t['id'] == task_id:
|
||
t['status'] = 'canceled'
|
||
self._save_tasks()
|
||
return f"定时任务 #{task_id} 已取消: {t['message']}"
|
||
return f"未找到任务 #{task_id}"
|
||
|
||
def _clear_tasks(self) -> str:
|
||
"""清除已完成/已取消的任务"""
|
||
with self._lock:
|
||
before = len(self.tasks)
|
||
self.tasks = [t for t in self.tasks if t['status'] == 'pending']
|
||
removed = before - len(self.tasks)
|
||
self._save_tasks()
|
||
return f"已清除 {removed} 个已结束的任务" if removed else "没有需要清除的任务"
|
||
|
||
# ── 后台监控 ──
|
||
|
||
def _start_monitor(self):
|
||
"""启动后台监控线程"""
|
||
if self._monitor_running:
|
||
return
|
||
self._monitor_running = True
|
||
self._monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||
self._monitor_thread.start()
|
||
|
||
def _stop_monitor(self):
|
||
"""停止后台监控"""
|
||
self._monitor_running = False
|
||
|
||
def _monitor_loop(self):
|
||
"""后台监控循环 — 每秒检查到期任务"""
|
||
while self._monitor_running:
|
||
try:
|
||
self._check_due_tasks()
|
||
except Exception:
|
||
pass
|
||
time.sleep(1)
|
||
|
||
def _check_due_tasks(self):
|
||
"""检查并触发到期任务"""
|
||
now = datetime.now()
|
||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
with self._lock:
|
||
for task in self.tasks:
|
||
if task['status'] != 'pending':
|
||
continue
|
||
|
||
trigger_at = task['trigger_at']
|
||
if now_str >= trigger_at:
|
||
# 触发任务
|
||
self._fire_task(task, now)
|
||
|
||
# 保存可能的变更
|
||
self._save_tasks()
|
||
|
||
def _fire_task(self, task: dict, now: datetime):
|
||
"""触发一个到期任务"""
|
||
task_id = task['id']
|
||
message = task['message']
|
||
is_repeat = task.get('repeat', False)
|
||
repeat_seconds = task.get('repeat_seconds', 0)
|
||
|
||
# 标记为已触发
|
||
task['status'] = 'fired'
|
||
task['fired_at'] = now.strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
# 重复任务:重新调度
|
||
if is_repeat and repeat_seconds > 0:
|
||
next_time = now + timedelta(seconds=repeat_seconds)
|
||
# 创建新的重复任务
|
||
new_task = {
|
||
"id": max([t.get('id', 0) for t in self.tasks], default=0) + 1,
|
||
"message": message,
|
||
"trigger_at": next_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"repeat": True,
|
||
"repeat_seconds": repeat_seconds,
|
||
"status": "pending",
|
||
"created_at": now.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"parent_id": task_id,
|
||
}
|
||
self.tasks.append(new_task)
|
||
|
||
# 发送系统通知
|
||
try:
|
||
from xcli_core.notification import notify_task_complete
|
||
notify_task_complete(f"定时提醒: {message}")
|
||
except Exception:
|
||
pass
|
||
|
||
# 打印醒目提醒(TUI 兼容)
|
||
is_tui = self.cli and getattr(self.cli, 'tui_output_callback', None)
|
||
is_generating = self.cli and getattr(self.cli, 'tui_stream_callback', None)
|
||
|
||
if is_tui and not is_generating:
|
||
# TUI 模式且未在生成中:通过回调输出
|
||
lines = [
|
||
"",
|
||
f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}",
|
||
f"{Fore.YELLOW} ⏰ 定时任务提醒 #{task_id}{Style.RESET_ALL}",
|
||
f"{Fore.YELLOW}{'─' * 50}{Style.RESET_ALL}",
|
||
f" {Fore.CYAN}任务:{Style.RESET_ALL} {message}",
|
||
f" {Fore.CYAN}时间:{Style.RESET_ALL} {now.strftime('%H:%M:%S')}",
|
||
]
|
||
if is_repeat:
|
||
next_str = (now + timedelta(seconds=repeat_seconds)).strftime("%Y-%m-%d %H:%M:%S")
|
||
lines.append(f" {Fore.CYAN}下次:{Style.RESET_ALL} {next_str}")
|
||
lines.append(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}")
|
||
self.cli.tui_output_callback("\n".join(lines))
|
||
elif not is_generating:
|
||
# CLI 模式
|
||
print()
|
||
print(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}")
|
||
print(f"{Fore.YELLOW} ⏰ 定时任务提醒 #{task_id}{Style.RESET_ALL}")
|
||
print(f"{Fore.YELLOW}{'─' * 50}{Style.RESET_ALL}")
|
||
print(f" {Fore.CYAN}任务:{Style.RESET_ALL} {message}")
|
||
print(f" {Fore.CYAN}时间:{Style.RESET_ALL} {now.strftime('%H:%M:%S')}")
|
||
if is_repeat:
|
||
next_str = (now + timedelta(seconds=repeat_seconds)).strftime("%Y-%m-%d %H:%M:%S")
|
||
print(f" {Fore.CYAN}下次:{Style.RESET_ALL} {next_str}")
|
||
print(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}")
|
||
|
||
# 自动激活 AI — 将提醒注入对话
|
||
self._activate_ai(task_id, message)
|
||
|
||
def _activate_ai(self, task_id: int, message: str):
|
||
"""激活 AI 处理定时任务提醒"""
|
||
if not self.cli:
|
||
return
|
||
|
||
reminder = (
|
||
f"[系统定时提醒] 定时任务 #{task_id} 已到期。\n"
|
||
f"任务内容: {message}\n"
|
||
f"请根据任务内容进行相应的操作或提醒用户。"
|
||
)
|
||
|
||
# TUI 模式:使用回调
|
||
if hasattr(self.cli, 'tui_output_callback') and self.cli.tui_output_callback:
|
||
try:
|
||
# TUI 模式下通过线程调用,避免阻塞
|
||
def _tui_activate():
|
||
try:
|
||
self.cli.process_conversation(reminder)
|
||
except Exception:
|
||
pass
|
||
threading.Thread(target=_tui_activate, daemon=True).start()
|
||
except Exception:
|
||
pass
|
||
else:
|
||
# CLI 模式:在新线程中调用,避免阻塞监控
|
||
def _cli_activate():
|
||
try:
|
||
# 先给用户一个视觉提示
|
||
print(f"\n{Fore.GREEN} AI 正在处理定时提醒...{Style.RESET_ALL}")
|
||
print(f"{Fore.WHITE}> {Style.RESET_ALL}", end='', flush=True)
|
||
self.cli.process_conversation(reminder)
|
||
# 重新显示输入提示
|
||
print(f"\n{Fore.WHITE}> {Style.RESET_ALL}", end='', flush=True)
|
||
except Exception:
|
||
pass
|
||
threading.Thread(target=_cli_activate, daemon=True).start()
|
||
|
||
# ── 辅助函数 ──
|
||
|
||
@staticmethod
|
||
def _format_seconds(seconds: int) -> str:
|
||
"""格式化秒数为可读文本"""
|
||
if seconds <= 0:
|
||
return ""
|
||
if seconds < 60:
|
||
return f"{seconds}秒"
|
||
if seconds < 3600:
|
||
return f"{seconds // 60}分钟"
|
||
if seconds < 86400:
|
||
h = seconds // 3600
|
||
m = (seconds % 3600) // 60
|
||
return f"{h}小时{m}分钟" if m else f"{h}小时"
|
||
d = seconds // 86400
|
||
return f"{d}天"
|
||
|
||
@staticmethod
|
||
def _calc_remaining(trigger_str: str, now: datetime) -> str:
|
||
"""计算剩余时间"""
|
||
try:
|
||
trigger = datetime.strptime(trigger_str, "%Y-%m-%d %H:%M:%S")
|
||
diff = trigger - now
|
||
if diff.total_seconds() <= 0:
|
||
return "即将触发"
|
||
secs = int(diff.total_seconds())
|
||
if secs < 60:
|
||
return f"剩余 {secs}秒"
|
||
if secs < 3600:
|
||
return f"剩余 {secs // 60}分钟"
|
||
if secs < 86400:
|
||
h = secs // 3600
|
||
m = (secs % 3600) // 60
|
||
return f"剩余 {h}小时{m}分钟" if m else f"剩余 {h}小时"
|
||
d = secs // 86400
|
||
return f"剩余 {d}天"
|
||
except Exception:
|
||
return ""
|
||
|
||
# ── 插件入口 ──
|
||
|
||
def handle(self, args: str) -> str:
|
||
"""处理工具调用"""
|
||
if not args or not args.strip():
|
||
return self.usage
|
||
|
||
parts = args.strip().split(None, 1)
|
||
action = parts[0].lower()
|
||
rest = parts[1].strip() if len(parts) > 1 else ""
|
||
|
||
if action == 'add':
|
||
if not rest:
|
||
return "用法: add <时间> <描述>\n示例: add 30m 检查代码 / add 14:30 开会 / add r:2h 站会"
|
||
# 分离时间和描述
|
||
time_parts = rest.split(None, 1)
|
||
if len(time_parts) < 2:
|
||
return "请同时提供时间和描述。示例: add 30m 检查代码"
|
||
return self._add_task(time_parts[0], time_parts[1])
|
||
|
||
elif action == 'list':
|
||
return self._list_tasks()
|
||
|
||
elif action in ('delete', 'cancel', 'rm'):
|
||
if not rest:
|
||
return "用法: delete <任务ID>"
|
||
return self._delete_task(rest)
|
||
|
||
elif action == 'clear':
|
||
return self._clear_tasks()
|
||
|
||
else:
|
||
return f"未知操作: {action}\n可用操作: add / list / delete / clear"
|
||
|
||
def get_mcp_definition(self):
|
||
"""返回 MCP 工具定义"""
|
||
return {
|
||
"name": "scheduler",
|
||
"description": "定时任务工具,设定定时提醒。到期时 AI 会自动激活并收到提醒。",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"action": {
|
||
"type": "string",
|
||
"enum": ["add", "list", "delete", "clear"],
|
||
"description": "操作类型"
|
||
},
|
||
"time": {
|
||
"type": "string",
|
||
"description": "时间: 30m(相对) / 14:30(今日) / 2026-04-28 10:00(绝对) / r:30m(重复)"
|
||
},
|
||
"message": {
|
||
"type": "string",
|
||
"description": "任务描述/提醒内容"
|
||
},
|
||
"task_id": {
|
||
"type": "integer",
|
||
"description": "任务ID (用于 delete)"
|
||
}
|
||
},
|
||
"required": ["action"]
|
||
}
|
||
}
|