refactor(aibox): 抽离工具类与子系统,附单测基线
A 批(tools/basic_tools.py):迁移 7 个无外部依赖工具类(FileToolBase/ReadFileTool/WriteFileTool/ListFilesTool/SimpleWebCrawlerTool/GetCurrentTimeTool/CalculatorTool) Wave1(subsystems/):迁移 5 个叶子子系统(CommandExecutor/SubprocessCommandExecutor/PromptManager/MemoryDatabase/MemoDatabase/SkillManager),aibox.py 减少 ~1440 行 新增 tests/ 单测 113 个用例全绿,依赖方向维持 common -> subsystems -> tools -> aibox 单向
This commit is contained in:
1 parent
8af8dec1b0
commit
eadba81c26
15 files changed
+3216
-1883
No files matched your search
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
subsystems - aibox 子系统模块(包)
|
||||
|
||||
抽离自 aibox.py 的基础设施子系统,按职责分文件。
|
||||
当前包含(Wave 1):
|
||||
- executors.py : CommandExecutor + SubprocessCommandExecutor
|
||||
- prompt_manager.py : PromptManager(单例)
|
||||
- db.py : MemoryDatabase + MemoDatabase
|
||||
- skill_manager.py : SkillManager
|
||||
|
||||
依赖方向:common <- subsystems <- aibox
|
||||
不反向依赖 aibox 任何符号。
|
||||
|
||||
采用"重导出"策略:外部统一用 `from subsystems import MemoryDatabase`,
|
||||
调用点无需关心具体子模块所在文件。
|
||||
"""
|
||||
|
||||
from .executors import CommandExecutor, SubprocessCommandExecutor
|
||||
from .prompt_manager import PromptManager
|
||||
from .db import MemoryDatabase, MemoDatabase
|
||||
from .skill_manager import SkillManager
|
||||
|
||||
__all__ = [
|
||||
"CommandExecutor",
|
||||
"SubprocessCommandExecutor",
|
||||
"PromptManager",
|
||||
"MemoryDatabase",
|
||||
"MemoDatabase",
|
||||
"SkillManager",
|
||||
]
|
||||
@@ -0,0 +1,639 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
subsystems.db - 数据库子系统
|
||||
|
||||
抽离自 aibox.py,提供两个独立的 SQLite 数据库类:
|
||||
- MemoryDatabase : 记忆数据库(FTS5 全文检索),存储重要对话记忆
|
||||
- MemoDatabase : 备忘录/任务数据库,支持提醒、重复、即时任务
|
||||
|
||||
依赖:common (constants, i18n, Logger) + 标准库 sqlite3
|
||||
不反向依赖 aibox 任何符号。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from common import constants, i18n, Logger
|
||||
|
||||
|
||||
# ==================== 记忆数据库 ====================
|
||||
|
||||
class MemoryDatabase:
|
||||
"""记忆数据库 - 存储重要的对话记忆"""
|
||||
|
||||
def __init__(self, db_path: str = None):
|
||||
if db_path is None:
|
||||
db_path = os.path.join(constants.SAVE_DIR, constants.MEMORY_DB)
|
||||
|
||||
# 确保目录存在
|
||||
os.makedirs(constants.SAVE_DIR, exist_ok=True)
|
||||
self.db_path = db_path
|
||||
self.logger = Logger("MemoryDB")
|
||||
self._init_database()
|
||||
|
||||
def _init_database(self):
|
||||
"""初始化数据库表"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
topic TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
content TEXT,
|
||||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
tags TEXT,
|
||||
importance INTEGER DEFAULT 1
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
|
||||
USING fts5(topic, summary, content, tags, content=memories)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
|
||||
def add_memory(self, topic: str, summary: str, content: str = None,
|
||||
start_time: str = None, end_time: str = None,
|
||||
tags: List[str] = None, importance: int = None) -> int:
|
||||
"""添加记忆到数据库"""
|
||||
if start_time is None:
|
||||
start_time = datetime.now().strftime(constants.DATETIME_FORMAT)
|
||||
if end_time is None:
|
||||
end_time = datetime.now().strftime(constants.DATETIME_FORMAT)
|
||||
if importance is None:
|
||||
importance = constants.MEMORY_DEFAULT_IMPORTANCE
|
||||
|
||||
tags_str = ",".join(tags) if tags else ""
|
||||
created_at = datetime.now().strftime(constants.DATETIME_FORMAT)
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO memories (topic, summary, content, start_time, end_time, created_at, tags, importance)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (topic, summary, content, start_time, end_time, created_at, tags_str, importance))
|
||||
|
||||
memory_id = cursor.lastrowid
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO memories_fts (rowid, topic, summary, content, tags)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (memory_id, topic, summary, content or "", tags_str))
|
||||
|
||||
conn.commit()
|
||||
|
||||
self.logger.info(f"已添加记忆: {topic} (ID: {memory_id})")
|
||||
return memory_id
|
||||
|
||||
def delete_memory(self, memory_id: int) -> bool:
|
||||
"""删除记忆"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM memories WHERE id = ?', (memory_id,))
|
||||
deleted = cursor.rowcount > 0
|
||||
|
||||
if deleted:
|
||||
cursor.execute('DELETE FROM memories_fts WHERE rowid = ?', (memory_id,))
|
||||
conn.commit()
|
||||
self.logger.info(f"已删除记忆 ID: {memory_id}")
|
||||
|
||||
return deleted
|
||||
|
||||
def search_memories(self, keywords: List[str], match_all: bool = False, limit: int = None) -> List[Dict]:
|
||||
"""多关键词模糊搜索记忆"""
|
||||
if not keywords:
|
||||
return []
|
||||
|
||||
if limit is None:
|
||||
limit = constants.MEMORY_MAX_SEARCH_RESULTS
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
if match_all:
|
||||
query = " AND ".join([f'"{kw}"' for kw in keywords])
|
||||
else:
|
||||
query = " OR ".join([f'"{kw}"' for kw in keywords])
|
||||
|
||||
cursor.execute('''
|
||||
SELECT m.*, rank
|
||||
FROM memories m
|
||||
JOIN memories_fts f ON m.rowid = f.rowid
|
||||
WHERE memories_fts MATCH ?
|
||||
ORDER BY m.end_time DESC, rank, m.importance DESC
|
||||
LIMIT ?
|
||||
''', (query, limit))
|
||||
|
||||
results = []
|
||||
for row in cursor.fetchall():
|
||||
memory = dict(row)
|
||||
if memory.get('tags'):
|
||||
memory['tags'] = memory['tags'].split(',')
|
||||
results.append(memory)
|
||||
|
||||
return results
|
||||
|
||||
def get_recent_memories(self, limit: int = 10) -> List[Dict]:
|
||||
"""获取最近的记忆"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM memories ORDER BY end_time DESC LIMIT ?', (limit,))
|
||||
|
||||
results = []
|
||||
for row in cursor.fetchall():
|
||||
memory = dict(row)
|
||||
if memory.get('tags'):
|
||||
memory['tags'] = memory['tags'].split(',')
|
||||
results.append(memory)
|
||||
|
||||
return results
|
||||
|
||||
def get_memory_by_id(self, memory_id: int) -> Optional[Dict]:
|
||||
"""根据ID获取记忆"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM memories WHERE id = ?', (memory_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
memory = dict(row)
|
||||
if memory.get('tags'):
|
||||
memory['tags'] = memory['tags'].split(',')
|
||||
return memory
|
||||
return None
|
||||
|
||||
def delete_memories_by_ids(self, memory_ids: List[int]) -> int:
|
||||
"""批量删除记忆"""
|
||||
if not memory_ids:
|
||||
return 0
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
placeholders = ','.join(['?'] * len(memory_ids))
|
||||
cursor.execute(f'DELETE FROM memories WHERE id IN ({placeholders})', memory_ids)
|
||||
deleted_count = cursor.rowcount
|
||||
|
||||
if deleted_count > 0:
|
||||
cursor.execute(f'DELETE FROM memories_fts WHERE rowid IN ({placeholders})', memory_ids)
|
||||
conn.commit()
|
||||
self.logger.info(f"已批量删除 {deleted_count} 条记忆")
|
||||
|
||||
return deleted_count
|
||||
|
||||
|
||||
# ==================== 备忘录数据库 ====================
|
||||
|
||||
class MemoDatabase:
|
||||
"""备忘录数据库 - 存储和管理任务"""
|
||||
|
||||
def __init__(self, db_path: str = None):
|
||||
if db_path is None:
|
||||
db_path = os.path.join(constants.SAVE_DIR, constants.MEMORY_DB)
|
||||
|
||||
# 确保目录存在
|
||||
os.makedirs(constants.SAVE_DIR, exist_ok=True)
|
||||
self.db_path = db_path
|
||||
self.logger = Logger("MemoDB")
|
||||
self._init_database()
|
||||
|
||||
def _init_database(self):
|
||||
"""初始化备忘录表"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS memos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
reminder_time TEXT,
|
||||
completed_at TEXT,
|
||||
is_completed INTEGER DEFAULT 0,
|
||||
tags TEXT,
|
||||
priority INTEGER DEFAULT 1,
|
||||
created_by TEXT,
|
||||
source TEXT,
|
||||
repeat_type TEXT DEFAULT 'none',
|
||||
repeat_interval INTEGER DEFAULT 0,
|
||||
repeat_interval_unit TEXT DEFAULT 'days',
|
||||
repeat_end_time TEXT,
|
||||
last_triggered_at TEXT,
|
||||
trigger_count INTEGER DEFAULT 0,
|
||||
is_immediate INTEGER DEFAULT 0
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_memos_reminder
|
||||
ON memos(reminder_time) WHERE is_completed = 0
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_memos_immediate
|
||||
ON memos(is_immediate) WHERE is_immediate = 1 AND is_completed = 0
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
|
||||
self.logger.info("任务表初始化完成")
|
||||
|
||||
def add_memo(self, title: str, content: str = None, reminder_time: str = None,
|
||||
tags: List[str] = None, priority: int = 1, created_by: str = None,
|
||||
source: str = None,
|
||||
repeat_type: str = 'none', repeat_interval: int = 0,
|
||||
repeat_interval_unit: str = 'days', repeat_end_time: str = None,
|
||||
is_immediate: bool = False) -> int:
|
||||
"""添加任务 - 所有任务都是对话任务,不再需要metadata和任务类型"""
|
||||
created_at = datetime.now().strftime(constants.DATETIME_FORMAT)
|
||||
tags_str = ",".join(tags) if tags else ""
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO memos
|
||||
(title, content, created_at, reminder_time, tags, priority, created_by,
|
||||
is_completed, source, repeat_type, repeat_interval, repeat_interval_unit,
|
||||
repeat_end_time, is_immediate)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
|
||||
''', (title, content, created_at, reminder_time, tags_str, priority, created_by,
|
||||
source, repeat_type, repeat_interval, repeat_interval_unit,
|
||||
repeat_end_time, 1 if is_immediate else 0))
|
||||
|
||||
memo_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
|
||||
repeat_info = f" (重复: {repeat_type}"
|
||||
if repeat_type == 'custom':
|
||||
repeat_info += f", 每{repeat_interval} {repeat_interval_unit}"
|
||||
repeat_info += ")"
|
||||
immediate_info = " (即时任务)" if is_immediate else ""
|
||||
self.logger.info(i18n.get('task_added', title, memo_id) + repeat_info + immediate_info)
|
||||
return memo_id
|
||||
|
||||
def get_memo(self, memo_id: int) -> Optional[Dict]:
|
||||
"""获取单个任务"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM memos WHERE id = ?', (memo_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
memo = dict(row)
|
||||
if memo.get('tags'):
|
||||
memo['tags'] = memo['tags'].split(',')
|
||||
return memo
|
||||
return None
|
||||
|
||||
def get_pending_memos(self, limit: int = None, before_time: str = None, include_immediate: bool = False) -> List[Dict]:
|
||||
"""获取待执行的任务 - 严格使用当前时间比较"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 使用当前时间作为基准
|
||||
now = datetime.now().strftime(constants.DATETIME_FORMAT)
|
||||
|
||||
# 如果传入了 before_time,使用它,否则使用当前时间
|
||||
compare_time = before_time if before_time else now
|
||||
|
||||
query = '''
|
||||
SELECT * FROM memos
|
||||
WHERE is_completed = 0
|
||||
AND reminder_time IS NOT NULL
|
||||
AND reminder_time <= ? -- 严格小于等于比较时间
|
||||
AND (repeat_end_time IS NULL OR repeat_end_time >= ?)
|
||||
'''
|
||||
|
||||
if not include_immediate:
|
||||
query += ' AND is_immediate = 0'
|
||||
|
||||
query += ' ORDER BY reminder_time ASC, priority DESC'
|
||||
|
||||
params = [compare_time, compare_time]
|
||||
|
||||
if limit:
|
||||
query += f' LIMIT {limit}'
|
||||
|
||||
cursor.execute(query, params)
|
||||
|
||||
results = []
|
||||
for row in cursor.fetchall():
|
||||
memo = dict(row)
|
||||
if memo.get('tags'):
|
||||
memo['tags'] = memo['tags'].split(',')
|
||||
|
||||
# 【关键修复】二次验证:确保 reminder_time 确实 <= 当前时间
|
||||
if memo.get('reminder_time'):
|
||||
try:
|
||||
reminder_time = datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT)
|
||||
current_time = datetime.strptime(compare_time, constants.DATETIME_FORMAT)
|
||||
if reminder_time <= current_time:
|
||||
results.append(memo)
|
||||
else:
|
||||
# 调试日志
|
||||
self.logger.debug(f"过滤掉未到期任务 #{memo['id']}: {memo['reminder_time']} > {compare_time}")
|
||||
except (ValueError, TypeError) as e:
|
||||
self.logger.warning(f"解析任务 #{memo['id']} 的提醒时间失败: {e}")
|
||||
else:
|
||||
# 没有提醒时间的任务不应该在待执行列表中
|
||||
self.logger.warning(f"任务 #{memo['id']} 没有提醒时间,但被返回为待执行")
|
||||
|
||||
return results
|
||||
|
||||
def get_immediate_memos(self, limit: int = None) -> List[Dict]:
|
||||
"""获取待执行的即时任务"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = '''
|
||||
SELECT * FROM memos
|
||||
WHERE is_completed = 0
|
||||
AND is_immediate = 1
|
||||
ORDER BY created_at ASC
|
||||
'''
|
||||
|
||||
if limit:
|
||||
query += f' LIMIT {limit}'
|
||||
|
||||
cursor.execute(query)
|
||||
|
||||
results = []
|
||||
for row in cursor.fetchall():
|
||||
memo = dict(row)
|
||||
if memo.get('tags'):
|
||||
memo['tags'] = memo['tags'].split(',')
|
||||
results.append(memo)
|
||||
|
||||
return results
|
||||
|
||||
def get_overdue_memos(self, limit: int = None) -> List[Dict]:
|
||||
"""获取已过期的任务"""
|
||||
now = datetime.now().strftime(constants.DATETIME_FORMAT)
|
||||
return self.get_pending_memos(limit=limit, before_time=now)
|
||||
|
||||
def get_all_memos(self, include_completed: bool = False, limit: int = 50) -> List[Dict]:
|
||||
"""获取所有任务"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
if include_completed:
|
||||
cursor.execute('''
|
||||
SELECT * FROM memos
|
||||
ORDER BY
|
||||
CASE WHEN is_completed = 0 THEN 0 ELSE 1 END,
|
||||
reminder_time ASC,
|
||||
created_at DESC
|
||||
LIMIT ?
|
||||
''', (limit,))
|
||||
else:
|
||||
cursor.execute('''
|
||||
SELECT * FROM memos
|
||||
WHERE is_completed = 0
|
||||
ORDER BY reminder_time ASC, priority DESC, created_at DESC
|
||||
LIMIT ?
|
||||
''', (limit,))
|
||||
|
||||
results = []
|
||||
for row in cursor.fetchall():
|
||||
memo = dict(row)
|
||||
if memo.get('tags'):
|
||||
memo['tags'] = memo['tags'].split(',')
|
||||
results.append(memo)
|
||||
|
||||
return results
|
||||
|
||||
def mark_as_triggered(self, memo_id: int) -> Optional[str]:
|
||||
"""标记任务已被触发,并根据重复规则计算下一次提醒时间"""
|
||||
memo = self.get_memo(memo_id)
|
||||
if not memo:
|
||||
return None
|
||||
|
||||
now = datetime.now()
|
||||
next_reminder = None
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE memos
|
||||
SET last_triggered_at = ?, trigger_count = trigger_count + 1
|
||||
WHERE id = ?
|
||||
''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
|
||||
|
||||
# 处理重复任务
|
||||
if memo['repeat_type'] != 'none' and not self._should_end_repeat(memo, now):
|
||||
# 使用当前时间作为基准来计算下一次执行时间
|
||||
next_reminder = self._calculate_next_reminder(memo, now)
|
||||
|
||||
if next_reminder:
|
||||
cursor.execute('''
|
||||
UPDATE memos
|
||||
SET reminder_time = ?
|
||||
WHERE id = ?
|
||||
''', (next_reminder.strftime(constants.DATETIME_FORMAT), memo_id))
|
||||
|
||||
self.logger.info(f"任务 #{memo_id} 已更新下一次执行时间: {next_reminder}")
|
||||
else:
|
||||
# 重复结束,标记为完成
|
||||
cursor.execute('''
|
||||
UPDATE memos
|
||||
SET is_completed = 1, completed_at = ?
|
||||
WHERE id = ?
|
||||
''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
|
||||
|
||||
self.logger.info(f"任务 #{memo_id} 重复结束,已标记为完成")
|
||||
elif memo['is_immediate']:
|
||||
# 即时任务执行后标记为完成
|
||||
cursor.execute('''
|
||||
UPDATE memos
|
||||
SET is_completed = 1, completed_at = ?
|
||||
WHERE id = ?
|
||||
''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
|
||||
|
||||
self.logger.info(f"即时任务 #{memo_id} 已完成")
|
||||
else:
|
||||
# 一次性任务,标记为完成
|
||||
cursor.execute('''
|
||||
UPDATE memos
|
||||
SET is_completed = 1, completed_at = ?
|
||||
WHERE id = ?
|
||||
''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
|
||||
|
||||
self.logger.info(f"任务 #{memo_id} 已完成")
|
||||
|
||||
conn.commit()
|
||||
|
||||
return next_reminder.strftime(constants.DATETIME_FORMAT) if next_reminder else None
|
||||
|
||||
def _should_end_repeat(self, memo: Dict, current_time: datetime) -> bool:
|
||||
"""检查是否应该结束重复"""
|
||||
if memo['repeat_end_time']:
|
||||
end_time = datetime.strptime(memo['repeat_end_time'], constants.DATETIME_FORMAT)
|
||||
if current_time > end_time:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _calculate_next_reminder(self, memo: Dict, current_time: datetime) -> Optional[datetime]:
|
||||
"""
|
||||
计算下一次提醒时间,使用当前时间作为基准
|
||||
支持分钟、小时、天为单位
|
||||
"""
|
||||
if memo['repeat_type'] == 'daily':
|
||||
# 每天重复:当前时间 + 1天
|
||||
next_time = current_time + timedelta(days=1)
|
||||
elif memo['repeat_type'] == 'weekly':
|
||||
# 每周重复:当前时间 + 7天
|
||||
next_time = current_time + timedelta(weeks=1)
|
||||
elif memo['repeat_type'] == 'monthly':
|
||||
# 每月重复:尝试在当前时间上加一个月
|
||||
try:
|
||||
# 处理月份进位
|
||||
if current_time.month == 12:
|
||||
next_time = current_time.replace(year=current_time.year + 1, month=1)
|
||||
else:
|
||||
next_time = current_time.replace(month=current_time.month + 1)
|
||||
except ValueError:
|
||||
# 处理月份天数问题(如1月31日加一个月)
|
||||
if current_time.month == 12:
|
||||
next_time = current_time.replace(year=current_time.year + 1, month=1, day=1) - timedelta(days=1)
|
||||
else:
|
||||
next_time = current_time.replace(month=current_time.month + 1, day=1) - timedelta(days=1)
|
||||
elif memo['repeat_type'] == 'custom' and memo['repeat_interval'] > 0:
|
||||
unit = memo.get('repeat_interval_unit', 'days')
|
||||
if unit == 'minutes':
|
||||
# 每N分钟重复:当前时间 + N分钟
|
||||
next_time = current_time + timedelta(minutes=memo['repeat_interval'])
|
||||
elif unit == 'hours':
|
||||
# 每N小时重复:当前时间 + N小时
|
||||
next_time = current_time + timedelta(hours=memo['repeat_interval'])
|
||||
else: # days
|
||||
# 每N天重复:当前时间 + N天
|
||||
next_time = current_time + timedelta(days=memo['repeat_interval'])
|
||||
else:
|
||||
return None
|
||||
|
||||
# 检查是否超过重复结束时间
|
||||
if memo['repeat_end_time']:
|
||||
end_time = datetime.strptime(memo['repeat_end_time'], constants.DATETIME_FORMAT)
|
||||
if next_time > end_time:
|
||||
return None
|
||||
|
||||
return next_time
|
||||
|
||||
def complete_memo(self, memo_id: int, force_complete: bool = False) -> bool:
|
||||
"""完成任务"""
|
||||
memo = self.get_memo(memo_id)
|
||||
if not memo:
|
||||
return False
|
||||
|
||||
if memo['repeat_type'] != 'none' and not force_complete:
|
||||
self.mark_as_triggered(memo_id)
|
||||
return True
|
||||
|
||||
completed_at = datetime.now().strftime(constants.DATETIME_FORMAT)
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
UPDATE memos
|
||||
SET is_completed = 1, completed_at = ?
|
||||
WHERE id = ? AND is_completed = 0
|
||||
''', (completed_at, memo_id))
|
||||
|
||||
affected = cursor.rowcount
|
||||
conn.commit()
|
||||
|
||||
if affected > 0:
|
||||
self.logger.info(i18n.get('memo_completed', memo_id))
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_memo_stats(self) -> Dict:
|
||||
"""获取任务统计信息"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM memos')
|
||||
total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM memos WHERE is_completed = 0')
|
||||
pending = cursor.fetchone()[0]
|
||||
|
||||
now = datetime.now().strftime(constants.DATETIME_FORMAT)
|
||||
cursor.execute('''
|
||||
SELECT COUNT(*) FROM memos
|
||||
WHERE is_completed = 0
|
||||
AND reminder_time <= ?
|
||||
AND is_immediate = 0
|
||||
''', (now,))
|
||||
overdue = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM memos WHERE is_completed = 1')
|
||||
completed = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM memos WHERE is_immediate = 1 AND is_completed = 0')
|
||||
immediate = cursor.fetchone()[0]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"pending": pending,
|
||||
"overdue": overdue,
|
||||
"completed": completed,
|
||||
"immediate": immediate
|
||||
}
|
||||
|
||||
def add_user_task(self, title: str, content: str = None, reminder_time: str = None,
|
||||
repeat_type: str = 'none', repeat_interval: int = 0,
|
||||
repeat_interval_unit: str = 'days', is_immediate: bool = False) -> int:
|
||||
"""添加用户任务(前端直接调用的任务)- 所有任务都是对话任务"""
|
||||
if not constants.MEMO_ALLOW_USER_TASKS:
|
||||
raise Exception("用户任务功能已禁用")
|
||||
|
||||
return self.add_memo(
|
||||
title=title,
|
||||
content=content,
|
||||
reminder_time=reminder_time,
|
||||
repeat_type=repeat_type,
|
||||
repeat_interval=repeat_interval,
|
||||
repeat_interval_unit=repeat_interval_unit,
|
||||
created_by="user",
|
||||
source="websocket",
|
||||
is_immediate=is_immediate
|
||||
)
|
||||
|
||||
def get_pending_tasks_for_user(self) -> List[Dict]:
|
||||
"""获取待执行任务列表(用于前端显示)"""
|
||||
pending = self.get_pending_memos(include_immediate=False)
|
||||
immediate = self.get_immediate_memos()
|
||||
result = []
|
||||
|
||||
now = datetime.now()
|
||||
for memo in pending + immediate:
|
||||
result.append({
|
||||
"id": memo['id'],
|
||||
"title": memo['title'],
|
||||
"content": memo['content'],
|
||||
"reminder_time": memo['reminder_time'],
|
||||
"priority": memo['priority'],
|
||||
"is_overdue": datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT) < now if memo['reminder_time'] else False,
|
||||
"repeat_type": memo['repeat_type'],
|
||||
"repeat_interval": memo.get('repeat_interval', 0),
|
||||
"repeat_interval_unit": memo.get('repeat_interval_unit', 'days'),
|
||||
"trigger_count": memo.get('trigger_count', 0),
|
||||
"is_immediate": memo.get('is_immediate', False)
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
subsystems.executors - 命令执行器
|
||||
|
||||
抽离自 aibox.py,提供命令执行的抽象基类与 subprocess 实现。
|
||||
- CommandExecutor : 抽象基类,定义 execute / execute_streaming / cancel
|
||||
- SubprocessCommandExecutor : 基于 subprocess.Popen 的实现,支持超时/工作目录/
|
||||
1MB 输出上限/线程安全取消
|
||||
|
||||
依赖:common (ConfigManager, constants, Logger) + 标准库
|
||||
不反向依赖 aibox 任何符号。
|
||||
"""
|
||||
|
||||
import os
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from common import ConfigManager, constants, Logger
|
||||
|
||||
|
||||
class CommandExecutor(ABC):
|
||||
"""命令执行器抽象基类 - 负责执行系统命令"""
|
||||
|
||||
def __init__(self, logger: Optional[Logger] = None):
|
||||
self.logger = logger or Logger("CommandExecutor")
|
||||
self.config = ConfigManager()
|
||||
self.process = None
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, command: str, timeout: int = 30, **kwargs) -> str:
|
||||
"""执行命令并返回结果(非流式)"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30, **kwargs):
|
||||
"""流式执行命令,通过队列输出结果"""
|
||||
pass
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""检查命令执行是否启用"""
|
||||
return constants.COMMANDS_ENABLED
|
||||
|
||||
def format_result(self, success: bool, message: str, data: Dict = None) -> str:
|
||||
"""统一结果格式化"""
|
||||
result = []
|
||||
emoji = "✅" if success else "❌"
|
||||
result.append(f"{emoji} {message}")
|
||||
|
||||
if data:
|
||||
for key, value in data.items():
|
||||
if isinstance(value, str) and "\n" in value:
|
||||
result.append(f" {key}:")
|
||||
for line in value.split("\n"):
|
||||
result.append(f" {line}")
|
||||
else:
|
||||
result.append(f" {key}: {value}")
|
||||
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
class SubprocessCommandExecutor(CommandExecutor):
|
||||
"""基于subprocess的命令执行器 - 支持超时、工作目录、输出大小限制和取消"""
|
||||
|
||||
# 命令输出最大字节数(1MB),超出截断
|
||||
MAX_OUTPUT_BYTES = 1 * 1024 * 1024
|
||||
|
||||
def __init__(self, logger=None):
|
||||
super().__init__(logger)
|
||||
self._current_process = None
|
||||
self._process_lock = threading.Lock()
|
||||
|
||||
def cancel(self):
|
||||
"""取消当前正在运行的命令(kill子进程)。线程安全。"""
|
||||
with self._process_lock:
|
||||
proc = self._current_process
|
||||
if proc is not None:
|
||||
try:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
self.logger.info("已终止正在运行的子进程")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _read_with_limit(self, process, timeout: int, start_time: float, output_queue=None) -> Tuple[str, bool, bool]:
|
||||
"""从进程读取输出,限制总大小和超时。
|
||||
返回 (output_str, timeout_occurred, truncated)
|
||||
"""
|
||||
output_chunks = []
|
||||
total_bytes = 0
|
||||
timeout_occurred = False
|
||||
truncated = False
|
||||
|
||||
while True:
|
||||
# 检查超时
|
||||
if timeout > 0 and time.time() - start_time > timeout:
|
||||
process.terminate()
|
||||
timeout_occurred = True
|
||||
break
|
||||
|
||||
# 检查输出大小
|
||||
if total_bytes >= self.MAX_OUTPUT_BYTES:
|
||||
truncated = True
|
||||
process.terminate()
|
||||
break
|
||||
|
||||
# 读取一行
|
||||
line = process.stdout.readline()
|
||||
if not line and process.poll() is not None:
|
||||
break
|
||||
if line:
|
||||
line_bytes = len(line.encode('utf-8', errors='replace'))
|
||||
if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated:
|
||||
allowed = self.MAX_OUTPUT_BYTES - total_bytes
|
||||
if allowed > 0:
|
||||
output_chunks.append(line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='replace'))
|
||||
total_bytes = self.MAX_OUTPUT_BYTES
|
||||
truncated = True
|
||||
process.terminate()
|
||||
break
|
||||
|
||||
output_chunks.append(line)
|
||||
total_bytes += line_bytes
|
||||
|
||||
if output_queue:
|
||||
output_queue.put(("line", line.rstrip('\n') + "\n"))
|
||||
|
||||
return ''.join(output_chunks), timeout_occurred, truncated
|
||||
|
||||
def execute(self, command: str, timeout: int = 30, work_dir: str = None, env: Dict = None, **kwargs) -> str:
|
||||
"""执行命令并返回完整结果(带输出大小限制)"""
|
||||
if not self.is_enabled():
|
||||
return "❌ 命令执行功能已禁用"
|
||||
|
||||
# 默认工作目录为workspace,避免在启动目录散落文件
|
||||
if work_dir is None or work_dir == ".":
|
||||
work_dir = constants.WORKSPACE_DIR
|
||||
|
||||
self.logger.info(f"执行命令: {command} (work_dir={work_dir})")
|
||||
|
||||
# 准备环境变量
|
||||
exec_env = os.environ.copy()
|
||||
if env:
|
||||
exec_env.update(env)
|
||||
|
||||
process = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True,
|
||||
env=exec_env,
|
||||
cwd=os.path.expanduser(work_dir)
|
||||
)
|
||||
# 保存进程引用以便cancel()可以kill它
|
||||
with self._process_lock:
|
||||
self._current_process = process
|
||||
|
||||
start_time = time.time()
|
||||
output_str, timeout_occurred, truncated = self._read_with_limit(process, timeout, start_time)
|
||||
|
||||
# 确保进程终止
|
||||
if process.poll() is None:
|
||||
if timeout_occurred or truncated:
|
||||
process.kill()
|
||||
else:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except:
|
||||
process.kill()
|
||||
|
||||
return_code = process.returncode if process.returncode is not None else -1
|
||||
|
||||
data = {
|
||||
"输出": output_str.strip() if output_str.strip() else "(无输出)",
|
||||
"返回码": return_code
|
||||
}
|
||||
|
||||
if timeout_occurred:
|
||||
return self.format_result(False, f"命令执行超时({timeout}秒)", data)
|
||||
|
||||
if truncated:
|
||||
data["警告"] = f"输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断"
|
||||
return self.format_result(False, "命令执行完成(输出已截断)", data)
|
||||
|
||||
return self.format_result(return_code == 0, "命令执行完成", data)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"❌ 命令执行超时({timeout}秒)"
|
||||
except FileNotFoundError as e:
|
||||
return f"❌ 命令执行错误: 命令未找到 - {str(e)}"
|
||||
except PermissionError as e:
|
||||
return f"❌ 命令执行错误: 权限不足 - {str(e)}"
|
||||
except Exception as e:
|
||||
return f"❌ 执行错误: {str(e)}"
|
||||
finally:
|
||||
if process and process.poll() is None:
|
||||
process.kill()
|
||||
# 清除当前进程引用
|
||||
with self._process_lock:
|
||||
self._current_process = None
|
||||
|
||||
def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30,
|
||||
work_dir: str = None, env: Dict = None, **kwargs):
|
||||
"""流式执行命令,支持超时控制、工作目录和输出大小限制"""
|
||||
if not self.is_enabled():
|
||||
output_queue.put(("line", "❌ 命令执行功能已禁用\n"))
|
||||
output_queue.put(("complete", "❌ 命令执行功能已禁用"))
|
||||
return
|
||||
|
||||
# 默认工作目录为workspace,避免在启动目录散落文件
|
||||
if work_dir is None or work_dir == ".":
|
||||
work_dir = constants.WORKSPACE_DIR
|
||||
|
||||
self.logger.info(f"流式执行命令: {command} (work_dir={work_dir})")
|
||||
output_queue.put(("line", f"\n🔧 执行命令: {command}\n"))
|
||||
output_queue.put(("line", f"⏱️ 超时设置: {timeout} 秒\n"))
|
||||
output_queue.put(("line", f"📦 输出限制: {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB\n"))
|
||||
output_queue.put(("line", f"📁 工作目录: {work_dir}\n"))
|
||||
output_queue.put(("line", "-" * 50 + "\n"))
|
||||
|
||||
# 准备环境变量
|
||||
exec_env = os.environ.copy()
|
||||
if env:
|
||||
exec_env.update(env)
|
||||
|
||||
process = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True,
|
||||
env=exec_env,
|
||||
cwd=os.path.expanduser(work_dir)
|
||||
)
|
||||
self.process = process
|
||||
# 保存进程引用以便cancel()可以kill它
|
||||
with self._process_lock:
|
||||
self._current_process = process
|
||||
|
||||
start_time = time.time()
|
||||
output_lines = []
|
||||
total_bytes = 0
|
||||
timeout_occurred = False
|
||||
truncated = False
|
||||
|
||||
# 实时读取输出
|
||||
while True:
|
||||
# 检查超时
|
||||
if timeout > 0 and time.time() - start_time > timeout:
|
||||
process.terminate()
|
||||
timeout_occurred = True
|
||||
output_queue.put(("line", f"\n⏰ 命令执行超时({timeout}秒)\n"))
|
||||
break
|
||||
|
||||
# 检查输出大小
|
||||
if total_bytes >= self.MAX_OUTPUT_BYTES:
|
||||
truncated = True
|
||||
process.terminate()
|
||||
output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n"))
|
||||
break
|
||||
|
||||
# 读取一行输出
|
||||
line = process.stdout.readline()
|
||||
if not line and process.poll() is not None:
|
||||
break
|
||||
if line:
|
||||
line_bytes = len(line.encode('utf-8', errors='replace'))
|
||||
|
||||
if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated:
|
||||
allowed = self.MAX_OUTPUT_BYTES - total_bytes
|
||||
if allowed > 0:
|
||||
truncated_line = line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='replace')
|
||||
output_queue.put(("line", truncated_line))
|
||||
output_lines.append(truncated_line)
|
||||
total_bytes = self.MAX_OUTPUT_BYTES
|
||||
truncated = True
|
||||
process.terminate()
|
||||
output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n"))
|
||||
break
|
||||
|
||||
stripped = line.rstrip('\n')
|
||||
output_queue.put(("line", stripped + "\n"))
|
||||
output_lines.append(line)
|
||||
total_bytes += line_bytes
|
||||
|
||||
# 如果超时或截断,确保进程被终止
|
||||
if (timeout_occurred or truncated) and process.poll() is None:
|
||||
process.kill()
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except:
|
||||
pass
|
||||
|
||||
return_code = process.returncode if process.returncode is not None else -1
|
||||
|
||||
output_queue.put(("line", "-" * 50 + "\n"))
|
||||
|
||||
if timeout_occurred:
|
||||
output_queue.put(("line", f"⏰ 命令执行超时({timeout}秒),返回码: {return_code}\n"))
|
||||
elif truncated:
|
||||
output_queue.put(("line", f"📦 输出已截断,返回码: {return_code}\n"))
|
||||
else:
|
||||
output_queue.put(("line", f"✅ 命令执行完成,返回码: {return_code}\n"))
|
||||
|
||||
# 准备完整结果
|
||||
full_output = "".join(output_lines)
|
||||
data = {
|
||||
"输出": full_output.strip() if full_output.strip() else "(无输出)",
|
||||
"返回码": return_code
|
||||
}
|
||||
|
||||
if timeout_occurred:
|
||||
result = self.format_result(False, f"命令执行超时({timeout}秒)", data)
|
||||
elif truncated:
|
||||
data["警告"] = f"输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断"
|
||||
result = self.format_result(False, "命令执行完成(输出已截断)", data)
|
||||
else:
|
||||
result = self.format_result(return_code == 0, "命令执行完成", data)
|
||||
|
||||
output_queue.put(("complete", result))
|
||||
|
||||
except Exception as e:
|
||||
output_queue.put(("line", f"❌ 执行错误: {str(e)}\n"))
|
||||
output_queue.put(("complete", f"❌ 执行错误: {str(e)}"))
|
||||
finally:
|
||||
if process and process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except:
|
||||
process.kill()
|
||||
self.process = None
|
||||
with self._process_lock:
|
||||
self._current_process = None
|
||||
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
subsystems.prompt_manager - 提示词管理器
|
||||
|
||||
抽离自 aibox.py,负责从 prompts 目录加载系统提示词。
|
||||
单例模式(__new__ + 类变量 _instance),多实例共享同一份 _prompts。
|
||||
|
||||
文件命名规范:
|
||||
- 00_IDENTITY.md : 用户身份信息
|
||||
- N_*.md/txt : 基础技能提示词(N 为数字),按数字升序加载
|
||||
- 99_SOUL.md : 智能体灵魂文件(角色定位、身份定义、性格等)
|
||||
- 100_STATUS.md : 智能体状态记录
|
||||
|
||||
依赖:common (ConfigManager, constants) + 标准库
|
||||
不反向依赖 aibox 任何符号。
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from common import ConfigManager
|
||||
|
||||
|
||||
class PromptManager:
|
||||
"""提示词管理器 - 负责从 prompts 目录加载系统提示词
|
||||
|
||||
文件命名规范:
|
||||
- 00_IDENTITY.md: 用户身份信息
|
||||
- N_*.md/txt: 基础技能提示词(N 为数字,如 1_memory_search.md、10_task_handling.md),按数字升序加载
|
||||
- 99_SOUL.md: 智能体灵魂文件(角色定位、身份定义、性格等)
|
||||
- 100_STATUS.md: 智能体状态记录
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_prompts = []
|
||||
_prompts_dir = None
|
||||
|
||||
# 核心文件
|
||||
IDENTITY_FILE = "00_IDENTITY.md"
|
||||
STATUS_FILE = "100_STATUS.md"
|
||||
SOUL_FILE = "99_SOUL.md" # 智能体灵魂文件 - 所有角色定位内容
|
||||
|
||||
# 基础技能文件名模式:N_xxx.md(N 为数字,如 1_memory_search.md、10_task_handling.md)
|
||||
_SKILL_FILE_RE = re.compile(r'^(\d+)_')
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not self._prompts:
|
||||
self._load_prompts()
|
||||
|
||||
def _get_prompts_dir(self) -> str:
|
||||
config = ConfigManager()
|
||||
prompts_dir = config.get("system.prompts_dir", "~/.aibox/prompts")
|
||||
return os.path.expanduser(prompts_dir)
|
||||
|
||||
def _load_prompts(self):
|
||||
self._prompts_dir = self._get_prompts_dir()
|
||||
os.makedirs(self._prompts_dir, exist_ok=True)
|
||||
self._prompts = []
|
||||
|
||||
# 确保核心文件存在
|
||||
self._ensure_identity_file()
|
||||
self._ensure_status_file()
|
||||
self._ensure_soul_file() # 智能体灵魂文件
|
||||
|
||||
# 确保基础技能文件存在
|
||||
self._ensure_skill_files()
|
||||
|
||||
prompt_files = []
|
||||
if os.path.exists(self._prompts_dir):
|
||||
# 按文件名排序,确保1_开头的技能文件在99_SOUL.md之前加载
|
||||
prompt_files = sorted([f for f in os.listdir(self._prompts_dir)
|
||||
if os.path.isfile(os.path.join(self._prompts_dir, f))
|
||||
and not f.startswith('.')])
|
||||
|
||||
for filename in prompt_files:
|
||||
filepath = os.path.join(self._prompts_dir, filename)
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read().strip()
|
||||
if content:
|
||||
self._prompts.append({
|
||||
'file': filename,
|
||||
'content': content
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"⚠️ 读取提示词文件 {filename} 失败: {e}")
|
||||
|
||||
essential_files = [self.IDENTITY_FILE, self.STATUS_FILE, self.SOUL_FILE]
|
||||
other_files = [f for f in prompt_files if f not in essential_files] if prompt_files else []
|
||||
|
||||
if not self._prompts:
|
||||
self._create_default_prompts()
|
||||
|
||||
def _get_template_dir(self) -> str:
|
||||
"""获取仓库中的提示词模板目录(aibox/prompts/)"""
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# subsystems/ 上一层是 aibox/,模板在其下的 prompts/
|
||||
aibox_dir = os.path.dirname(script_dir)
|
||||
return os.path.join(aibox_dir, 'prompts')
|
||||
|
||||
def _ensure_from_template(self, filename: str):
|
||||
"""从模板目录复制提示词文件到运行目录。
|
||||
|
||||
模板目录为 aibox/prompts/,若其中存在同名文件则复制过去;
|
||||
若模板不存在则跳过(由调用方保证模板齐全)。
|
||||
已存在的运行时文件不会被覆盖。
|
||||
"""
|
||||
target_path = os.path.join(self._prompts_dir, filename)
|
||||
if os.path.exists(target_path):
|
||||
return # 已存在,不覆盖
|
||||
|
||||
template_path = os.path.join(self._get_template_dir(), filename)
|
||||
if os.path.exists(template_path):
|
||||
try:
|
||||
shutil.copy2(template_path, target_path)
|
||||
print(f"✅ 已从模板创建: {filename}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ 从模板复制 {filename} 失败: {e}")
|
||||
|
||||
def _ensure_identity_file(self):
|
||||
"""确保用户身份文件存在"""
|
||||
self._ensure_from_template(self.IDENTITY_FILE)
|
||||
|
||||
def _ensure_status_file(self):
|
||||
"""确保状态文件存在"""
|
||||
self._ensure_from_template(self.STATUS_FILE)
|
||||
|
||||
def _ensure_soul_file(self):
|
||||
"""确保智能体灵魂文件存在"""
|
||||
self._ensure_from_template(self.SOUL_FILE)
|
||||
|
||||
def _ensure_skill_files(self):
|
||||
"""确保基础技能文件存在 - 从模板目录复制所有 N_*.md 文件"""
|
||||
template_dir = self._get_template_dir()
|
||||
if not os.path.isdir(template_dir):
|
||||
return
|
||||
for filename in sorted(os.listdir(template_dir)):
|
||||
if self._SKILL_FILE_RE.match(filename): # N_ 开头的文件
|
||||
self._ensure_from_template(filename)
|
||||
|
||||
def update_identity(self, content: str) -> bool:
|
||||
"""更新用户身份信息"""
|
||||
identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE)
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
updated_content = f"""# 用户身份信息
|
||||
|
||||
{content}
|
||||
|
||||
【更新记录】
|
||||
- 上次更新:{timestamp}
|
||||
"""
|
||||
with open(identity_path, 'w', encoding='utf-8') as f:
|
||||
f.write(updated_content)
|
||||
self.reload()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⚠️ 更新身份文件失败: {e}")
|
||||
return False
|
||||
|
||||
def update_status(self, content: str) -> bool:
|
||||
"""更新智能体状态"""
|
||||
status_path = os.path.join(self._prompts_dir, self.STATUS_FILE)
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
updated_content = f"""# 智能体状态记录
|
||||
|
||||
{content}
|
||||
|
||||
【更新记录】
|
||||
- 上次更新:{timestamp}
|
||||
"""
|
||||
with open(status_path, 'w', encoding='utf-8') as f:
|
||||
f.write(updated_content)
|
||||
self.reload()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⚠️ 更新状态文件失败: {e}")
|
||||
return False
|
||||
|
||||
def update_soul(self, content: str) -> bool:
|
||||
"""更新智能体灵魂定义 - 所有角色定位内容"""
|
||||
soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE)
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
updated_content = f"""# 智能体灵魂定义 - 我是谁
|
||||
|
||||
{content}
|
||||
|
||||
【更新记录】
|
||||
- 上次更新:{timestamp}
|
||||
"""
|
||||
with open(soul_path, 'w', encoding='utf-8') as f:
|
||||
f.write(updated_content)
|
||||
self.reload()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⚠️ 更新灵魂文件失败: {e}")
|
||||
return False
|
||||
|
||||
def get_identity(self) -> str:
|
||||
"""获取用户身份信息"""
|
||||
identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE)
|
||||
try:
|
||||
if os.path.exists(identity_path):
|
||||
with open(identity_path, 'r', encoding='utf-8') as f:
|
||||
return f.read().strip()
|
||||
except Exception as e:
|
||||
print(f"⚠️ 读取身份文件失败: {e}")
|
||||
return "尚不清楚 - 无法读取身份信息"
|
||||
|
||||
def get_status(self) -> str:
|
||||
"""获取智能体状态"""
|
||||
status_path = os.path.join(self._prompts_dir, self.STATUS_FILE)
|
||||
try:
|
||||
if os.path.exists(status_path):
|
||||
with open(status_path, 'r', encoding='utf-8') as f:
|
||||
return f.read().strip()
|
||||
except Exception as e:
|
||||
print(f"⚠️ 读取状态文件失败: {e}")
|
||||
return "这是第一次对话 - 无法读取状态信息"
|
||||
|
||||
def get_soul(self) -> str:
|
||||
"""获取智能体灵魂定义 - 角色定位核心"""
|
||||
soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE)
|
||||
try:
|
||||
if os.path.exists(soul_path):
|
||||
with open(soul_path, 'r', encoding='utf-8') as f:
|
||||
return f.read().strip()
|
||||
except Exception as e:
|
||||
print(f"⚠️ 读取灵魂文件失败: {e}")
|
||||
return """我是DeepSeek,一个智能AI助手。
|
||||
请创建 99_SOUL.md 文件来定义我的完整身份、角色、性格和能力边界。"""
|
||||
|
||||
def get_skill_prompts(self) -> List[Dict]:
|
||||
"""获取所有基础技能提示词(以1_开头的文件)"""
|
||||
return [p for p in self._prompts if p['file'].startswith(self.SKILL_PREFIX)]
|
||||
|
||||
def get_soul_prompt(self) -> Optional[Dict]:
|
||||
"""获取灵魂提示词(99_SOUL.md)"""
|
||||
for p in self._prompts:
|
||||
if p['file'] == self.SOUL_FILE:
|
||||
return p
|
||||
return None
|
||||
|
||||
def _create_default_prompts(self):
|
||||
"""创建默认提示词(已由各 _ensure_* 方法处理)"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _skill_sort_key(cls, filename: str):
|
||||
"""技能文件按前缀数字排序,非技能文件返回 None。"""
|
||||
m = cls._SKILL_FILE_RE.match(filename)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
def _prompt_sort_key(self, p):
|
||||
"""排序规则:00_IDENTITY → 技能文件(按数字升序) → 99_SOUL → 100_STATUS → 其他"""
|
||||
file = p['file']
|
||||
if file == self.IDENTITY_FILE:
|
||||
return (0,)
|
||||
skill_num = self._skill_sort_key(file)
|
||||
if skill_num is not None:
|
||||
return (1, skill_num)
|
||||
if file == self.SOUL_FILE:
|
||||
return (2,)
|
||||
if file == self.STATUS_FILE:
|
||||
return (4,)
|
||||
return (3, file) # 其他文件按名字排序
|
||||
|
||||
def get_prompts(self, join_with: str = "\n\n") -> str:
|
||||
"""获取所有提示词,按顺序拼接(identity → 技能文件 → soul → status)"""
|
||||
if not self._prompts:
|
||||
return ""
|
||||
sorted_prompts = sorted(self._prompts, key=self._prompt_sort_key)
|
||||
contents = [p['content'] for p in sorted_prompts]
|
||||
return join_with.join(contents)
|
||||
|
||||
def get_sorted_prompts_list(self) -> List[Dict]:
|
||||
"""获取按正确顺序排列的提示词列表(用于 system 消息构建)。"""
|
||||
return sorted(self._prompts, key=self._prompt_sort_key)
|
||||
|
||||
def get_prompts_list(self) -> List[Dict]:
|
||||
"""获取所有提示词列表(原始加载顺序,已弃用,请用 get_sorted_prompts_list)。"""
|
||||
return self._prompts.copy()
|
||||
|
||||
def get_skill_prompts(self) -> List[Dict]:
|
||||
"""获取所有基础技能提示词(N_ 开头的文件)。"""
|
||||
return [p for p in self._prompts if self._skill_sort_key(p['file']) is not None]
|
||||
|
||||
def reload(self):
|
||||
"""重新加载所有提示词"""
|
||||
self._prompts = []
|
||||
self._load_prompts()
|
||||
return len(self._prompts)
|
||||
|
||||
def get_prompt_count(self) -> int:
|
||||
return len(self._prompts)
|
||||
|
||||
def get_prompts_dir_path(self) -> str:
|
||||
return self._prompts_dir
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
subsystems.skill_manager - 扩展技能管理器
|
||||
|
||||
抽离自 aibox.py,收集并管理 ~/.aibox/skills/ 目录下的技能文件。
|
||||
|
||||
支持两种技能格式:
|
||||
1. 可执行脚本/程序:执行 --help,第一行为简介
|
||||
2. Markdown 文件 (.md):第一行为简介(# 标题或普通文本),其余为技能内容
|
||||
|
||||
依赖:common (constants, Logger) + 标准库
|
||||
不反向依赖 aibox 任何符号。
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from common import constants, Logger
|
||||
|
||||
|
||||
class SkillManager:
|
||||
"""扩展技能管理器 - 收集并管理 ~/.aibox/skills/ 目录下的技能文件。
|
||||
|
||||
支持两种技能格式:
|
||||
1. 可执行脚本/程序:执行 --help,第一行为简介
|
||||
2. Markdown 文件 (.md):第一行为简介(# 标题或普通文本),其余为技能内容
|
||||
"""
|
||||
|
||||
def __init__(self, logger: Optional[Logger] = None):
|
||||
self.logger = logger or Logger("SkillManager")
|
||||
# name -> {path, file, summary, content, is_markdown}
|
||||
self.skills: Dict[str, Dict] = {}
|
||||
self._load_skills()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部辅助
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _run_help(self, skill_path: str) -> str:
|
||||
"""执行 skill_path --help 并返回完整输出(stdout 优先,否则 stderr)。"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[skill_path, "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
env=os.environ.copy()
|
||||
)
|
||||
output = result.stdout.strip()
|
||||
if not output:
|
||||
output = result.stderr.strip()
|
||||
return output
|
||||
except Exception as e:
|
||||
self.logger.warning(f"运行 {skill_path} --help 失败: {e}")
|
||||
return ""
|
||||
|
||||
def _read_markdown_skill(self, skill_path: str) -> Tuple[str, str]:
|
||||
"""读取 Markdown 技能文件,返回 (简介, 完整内容)"""
|
||||
try:
|
||||
with open(skill_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read().strip()
|
||||
|
||||
if not content:
|
||||
return "(空文件)", ""
|
||||
|
||||
# 提取第一行非空内容作为简介
|
||||
lines = content.splitlines()
|
||||
summary = ""
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped:
|
||||
# 如果是 Markdown 标题,去掉 # 前缀
|
||||
if stripped.startswith('#'):
|
||||
summary = stripped.lstrip('#').strip()
|
||||
else:
|
||||
summary = stripped
|
||||
break
|
||||
|
||||
if not summary:
|
||||
summary = os.path.basename(skill_path).replace('.md', '')
|
||||
|
||||
return summary, content
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"读取 Markdown 技能文件失败 {skill_path}: {e}")
|
||||
return "(读取失败)", ""
|
||||
|
||||
@staticmethod
|
||||
def _first_nonempty_line(text: str) -> str:
|
||||
"""返回文本中第一行非空内容。"""
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped:
|
||||
return stripped
|
||||
return ""
|
||||
|
||||
def _is_markdown_file(self, filename: str) -> bool:
|
||||
"""判断是否为 Markdown 文件"""
|
||||
return filename.lower().endswith('.md')
|
||||
|
||||
def _is_executable(self, filepath: str) -> bool:
|
||||
"""检查文件是否可执行"""
|
||||
if os.name == 'nt': # Windows
|
||||
# Windows 下检查扩展名
|
||||
executable_extensions = {'.exe', '.bat', '.cmd', '.ps1', '.py', '.sh'}
|
||||
ext = os.path.splitext(filepath)[1].lower()
|
||||
return ext in executable_extensions
|
||||
else: # Unix/Linux/Mac
|
||||
return os.access(filepath, os.X_OK)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 加载 / 重载
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_skills(self):
|
||||
"""扫描技能目录,加载所有技能文件(可执行文件或 Markdown 文件)。"""
|
||||
skills_dir = constants.SKILLS_DIR
|
||||
self.skills = {}
|
||||
|
||||
if not os.path.exists(skills_dir):
|
||||
self.logger.info(f"技能目录不存在: {skills_dir}")
|
||||
return
|
||||
|
||||
skill_files = [
|
||||
f for f in os.listdir(skills_dir)
|
||||
if os.path.isfile(os.path.join(skills_dir, f))
|
||||
and not f.startswith('_')
|
||||
and not f.startswith('.')
|
||||
]
|
||||
|
||||
for skill_file in skill_files:
|
||||
skill_path = os.path.join(skills_dir, skill_file)
|
||||
skill_name = os.path.splitext(skill_file)[0]
|
||||
is_markdown = self._is_markdown_file(skill_file)
|
||||
|
||||
if is_markdown:
|
||||
# Markdown 格式技能:直接读取内容
|
||||
summary, content = self._read_markdown_skill(skill_path)
|
||||
if content:
|
||||
self.skills[skill_name] = {
|
||||
"path": skill_path,
|
||||
"file": skill_file,
|
||||
"summary": summary,
|
||||
"content": content,
|
||||
"is_markdown": True,
|
||||
"is_executable": False
|
||||
}
|
||||
self.logger.info(f"已加载 Markdown 技能: {skill_name} — {summary}")
|
||||
else:
|
||||
self.logger.warning(f"Markdown 技能文件为空(跳过): {skill_file}")
|
||||
|
||||
else:
|
||||
# 可执行文件格式技能:执行 --help 获取简介
|
||||
if not self._is_executable(skill_path):
|
||||
self.logger.warning(f"技能文件不可执行(跳过): {skill_file}")
|
||||
continue
|
||||
|
||||
help_output = self._run_help(skill_path)
|
||||
summary = self._first_nonempty_line(help_output) or f"扩展技能: {skill_name}"
|
||||
|
||||
self.skills[skill_name] = {
|
||||
"path": skill_path,
|
||||
"file": skill_file,
|
||||
"summary": summary,
|
||||
"content": help_output, # 完整的 --help 输出作为内容
|
||||
"is_markdown": False,
|
||||
"is_executable": True
|
||||
}
|
||||
self.logger.info(f"已加载可执行技能: {skill_name} — {summary}")
|
||||
|
||||
self.logger.info(f"共加载 {len(self.skills)} 个技能({len([s for s in self.skills.values() if s['is_markdown']])} 个 Markdown 技能)")
|
||||
|
||||
def reload(self) -> int:
|
||||
"""重新扫描技能目录,返回加载的技能数量。"""
|
||||
self._load_skills()
|
||||
return len(self.skills)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 查询
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def search(self, keyword: str) -> List[Dict]:
|
||||
"""在技能名称、简介和内容中搜索关键字,返回匹配项列表。"""
|
||||
kw = keyword.lower()
|
||||
results = []
|
||||
for name, info in self.skills.items():
|
||||
match = (
|
||||
kw in name.lower() or
|
||||
kw in info["summary"].lower() or
|
||||
kw in info.get("content", "").lower()
|
||||
)
|
||||
if match:
|
||||
results.append({
|
||||
"name": name,
|
||||
"path": info["path"],
|
||||
"summary": info["summary"],
|
||||
"content": info.get("content", ""),
|
||||
"is_markdown": info.get("is_markdown", False),
|
||||
"is_executable": info.get("is_executable", False)
|
||||
})
|
||||
return results
|
||||
|
||||
def get_skill_content(self, skill_name: str) -> Optional[str]:
|
||||
"""获取技能的内容(Markdown 技能返回完整内容,可执行技能返回 --help 输出)"""
|
||||
info = self.skills.get(skill_name)
|
||||
if info:
|
||||
return info.get("content")
|
||||
return None
|
||||
|
||||
def get_skill(self, skill_name: str) -> Optional[Dict]:
|
||||
"""获取单个技能的完整信息"""
|
||||
return self.skills.get(skill_name)
|
||||
|
||||
def get_all(self) -> List[Dict]:
|
||||
"""返回所有技能的列表(含名称、路径、简介和内容)。"""
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"path": info["path"],
|
||||
"summary": info["summary"],
|
||||
"content": info.get("content", ""),
|
||||
"is_markdown": info.get("is_markdown", False),
|
||||
"is_executable": info.get("is_executable", False)
|
||||
}
|
||||
for name, info in self.skills.items()
|
||||
]
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self.skills)
|
||||
Reference in new issue
Block a user