feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。
**目前能用的(真环境实测,非推断)**:
profile/other : 200, 7075 字节 —— 博主主页指标(粉丝/获赞/作品数/昵称)
aweme/detail : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)
**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
挑中的恰好是「批量拉作品列表」这个最敏感的动作。
2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
代价,过几小时要重测)。
所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。
**排查中控住变量后得到的两条事实**(都写进注释了):
· `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
(我先前猜的 sec-ch-ua 不是关键。)
· 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
从 CDP 取齐 cookie + UA + hints,而不是各自写死。
「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。
测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
# plugins/__init__.py
|
||||
# Dashboard v4.0 插件自动发现与加载
|
||||
# 扫描 plugins/ 目录下所有 plugin.py, 自动注册
|
||||
|
||||
import importlib, pkgutil, logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("dashboard.plugins")
|
||||
|
||||
# 插件白名单 (排除非插件文件)
|
||||
_SKIP = {"base.py", "_registry.py", "__init__.py"}
|
||||
|
||||
|
||||
def discover_plugins() -> dict[str, object]:
|
||||
"""扫描 plugins/ 目录, 实例化所有 DashboardPlugin 子类"""
|
||||
plugins = {}
|
||||
pkg_dir = Path(__file__).parent
|
||||
|
||||
for f in sorted(pkg_dir.iterdir()):
|
||||
if f.suffix != ".py" or f.name in _SKIP:
|
||||
continue
|
||||
mod_name = f"plugins.{f.stem}"
|
||||
try:
|
||||
mod = importlib.import_module(mod_name)
|
||||
for attr_name in dir(mod):
|
||||
attr = getattr(mod, attr_name)
|
||||
if isinstance(attr, type) and attr.__name__ != "DashboardPlugin":
|
||||
# 检查是否是 DashboardPlugin 的子类
|
||||
from .base import DashboardPlugin as Base
|
||||
if issubclass(attr, Base) and attr is not Base:
|
||||
instance = attr()
|
||||
if instance.name:
|
||||
plugins[instance.name] = instance
|
||||
logger.info(f" ✅ 插件已加载: {instance.icon} {instance.label} (v{instance.version})")
|
||||
except Exception as e:
|
||||
logger.warning(f" ⚠️ 插件加载失败: {f.name} — {e}")
|
||||
|
||||
return plugins
|
||||
@@ -0,0 +1,134 @@
|
||||
# plugins/_registry.py
|
||||
# 联邦机器注册表 — 读 cross_machine 获取所有机器信息
|
||||
# WPRA v2.0: 优先 machines/*/ 读聚合, 降级到旧 _registry.json
|
||||
# 版本: 2.0.0 | 更新: 2026-05-31
|
||||
|
||||
import json, re
|
||||
from pathlib import Path
|
||||
|
||||
CROSS_MACHINE = Path(__file__).resolve().parents[3] / "04_memory" / "cross_machine"
|
||||
REGISTRY_PATH = CROSS_MACHINE / "status" / "live" / "_registry.json"
|
||||
DATA_DIR = CROSS_MACHINE / "data"
|
||||
MACHINES_DIR = CROSS_MACHINE / "machines"
|
||||
|
||||
|
||||
# ── WPRA v2.0: 辅助函数 ─────────────────────────────
|
||||
|
||||
def _read_wpra_names() -> list[str]:
|
||||
"""从 machines/*/heartbeat.json 读取所有机器名"""
|
||||
if not MACHINES_DIR.exists():
|
||||
return []
|
||||
names = []
|
||||
for md in sorted(MACHINES_DIR.iterdir()):
|
||||
if not md.is_dir():
|
||||
continue
|
||||
hb = md / "heartbeat.json"
|
||||
if hb.exists():
|
||||
try:
|
||||
data = json.loads(hb.read_text())
|
||||
names.append(data.get("machine_name", md.name[:8]))
|
||||
continue
|
||||
except:
|
||||
pass
|
||||
# 降级: MACHINE.yaml
|
||||
mid = md / "MACHINE.yaml"
|
||||
if mid.exists():
|
||||
try:
|
||||
text = mid.read_text()
|
||||
m = re.search(r'machine_name:\s*"?([^"\n]+)"?', text)
|
||||
names.append(m.group(1) if m else md.name[:8])
|
||||
except:
|
||||
names.append(md.name[:8])
|
||||
return names
|
||||
|
||||
|
||||
def _read_wpra_info() -> list[dict]:
|
||||
"""从 machines/*/heartbeat.json 读取所有机器详情"""
|
||||
if not MACHINES_DIR.exists():
|
||||
return []
|
||||
machines = []
|
||||
for md in sorted(MACHINES_DIR.iterdir()):
|
||||
if not md.is_dir():
|
||||
continue
|
||||
uid = md.name
|
||||
hb = md / "heartbeat.json"
|
||||
if hb.exists():
|
||||
try:
|
||||
d = json.loads(hb.read_text())
|
||||
machines.append({
|
||||
"hostname": d.get("machine_name", uid[:8]),
|
||||
"uid": d.get("machine_uid", uid),
|
||||
"last_seen": d.get("updated_at", ""),
|
||||
"status": d.get("status", "unknown"),
|
||||
"guardd_version": d.get("guardd_version", ""),
|
||||
"source": "machines/heartbeat",
|
||||
})
|
||||
continue
|
||||
except:
|
||||
pass
|
||||
# 降级: 只有目录没有心跳 → 离线
|
||||
machines.append({
|
||||
"hostname": uid[:8],
|
||||
"uid": uid,
|
||||
"last_seen": "",
|
||||
"status": "offline",
|
||||
"guardd_version": "",
|
||||
"source": "machines_dir_only",
|
||||
})
|
||||
return machines
|
||||
|
||||
|
||||
# ── 公开接口 (WPRA 优先, 降级到旧路径) ───────────────
|
||||
|
||||
def get_machine_list() -> list[str]:
|
||||
"""WPRA v2.0: 优先从 machines/*/ 读取,降级到 _registry.json"""
|
||||
names = _read_wpra_names()
|
||||
if names:
|
||||
return sorted(set(names))
|
||||
|
||||
# Fallback: 旧 _registry.json
|
||||
if not REGISTRY_PATH.exists():
|
||||
return []
|
||||
try:
|
||||
reg = json.loads(REGISTRY_PATH.read_text())
|
||||
return sorted(set(
|
||||
info.get("hostname", "") for info in reg.values()
|
||||
))
|
||||
except:
|
||||
return []
|
||||
|
||||
|
||||
def get_machine_info(hostname: str = "") -> dict:
|
||||
"""WPRA v2.0: 优先从 machines/*/ 读取,降级到 _registry.json"""
|
||||
machines = _read_wpra_info()
|
||||
if machines:
|
||||
for m in machines:
|
||||
if m["hostname"] == hostname or not hostname:
|
||||
return m
|
||||
|
||||
# Fallback: 旧 _registry.json
|
||||
if not REGISTRY_PATH.exists():
|
||||
return {}
|
||||
try:
|
||||
reg = json.loads(REGISTRY_PATH.read_text())
|
||||
for uid, info in reg.items():
|
||||
if info.get("hostname") == hostname or not hostname:
|
||||
return {"uid": uid, **info}
|
||||
return {}
|
||||
except:
|
||||
return {}
|
||||
|
||||
|
||||
def get_plugin_data(plugin_name: str) -> list[dict]:
|
||||
"""读取所有机器写入的某插件共享数据(已是每人一文件 ✅)"""
|
||||
path = DATA_DIR / plugin_name
|
||||
if not path.exists():
|
||||
return []
|
||||
results = []
|
||||
for f in sorted(path.iterdir()):
|
||||
if f.suffix == ".json":
|
||||
try:
|
||||
results.append(json.loads(f.read_text()))
|
||||
except:
|
||||
pass
|
||||
return results
|
||||
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
plugins/ave.py — AVE 视频工厂插件 (v2)
|
||||
显示生产管线状态、生产记录、费用统计
|
||||
版本: 2.0.0 | 更新: 2026-05-18
|
||||
"""
|
||||
import json, os
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from plugins.base import DashboardPlugin, AGENT_SYNC, AGENT_LOCAL, CROSS_MACHINE, HOSTNAME, MACHINE_UID
|
||||
from plugins._registry import get_machine_list, get_plugin_data
|
||||
|
||||
|
||||
class AVEDashboardPlugin(DashboardPlugin):
|
||||
name = "ave"
|
||||
label = "视频工厂"
|
||||
icon = "🎬"
|
||||
version = "2.0.0"
|
||||
description = "AVE 视频工厂:管线状态 / 生产记录 / 费用"
|
||||
order = 20
|
||||
|
||||
def _read_local_productions(self):
|
||||
"""读取本机 AVE 生产记录"""
|
||||
log_dirs = [
|
||||
AGENT_LOCAL / "runtime" / "ave" / "productions",
|
||||
AGENT_LOCAL / "logs" / "ave",
|
||||
]
|
||||
records = []
|
||||
for d in log_dirs:
|
||||
if d.exists():
|
||||
for f in sorted(d.iterdir())[-50:]:
|
||||
if f.suffix in (".json", ".log"):
|
||||
try:
|
||||
records.append({"file": f.name, "size": f.stat().st_size, "mtime": f.stat().st_mtime})
|
||||
except:
|
||||
pass
|
||||
return records
|
||||
|
||||
def _read_production_logs(self):
|
||||
"""读取 AVE 生产日志获取管线统计"""
|
||||
log_path = AGENT_LOCAL / "runtime" / "ave" / "production.json"
|
||||
if log_path.exists():
|
||||
try:
|
||||
return json.loads(log_path.read_text())
|
||||
except:
|
||||
pass
|
||||
return {"productions": [], "total_cost": 0}
|
||||
|
||||
def summary(self, machines: list[str]) -> dict:
|
||||
# 本机数据
|
||||
local = self._read_production_logs() or {}
|
||||
prods = local.get("productions", [])
|
||||
strategies = {}
|
||||
for p in prods:
|
||||
s = p.get("strategy", "未知")
|
||||
strategies[s] = strategies.get(s, 0) + 1
|
||||
|
||||
# 检测 API 配置状态
|
||||
config = AGENT_LOCAL / "tools" / "ave" / "config" / "local.yaml"
|
||||
api_configured = config.exists()
|
||||
api_services = []
|
||||
if api_configured:
|
||||
try:
|
||||
import yaml
|
||||
cfg = yaml.safe_load(config.read_text()) or {}
|
||||
for svc in ["volcano","aliyun","pexels","llm"]:
|
||||
if svc in cfg and cfg[svc].get("api_key","") not in ("", "sk-xxx"):
|
||||
api_services.append(svc)
|
||||
except:
|
||||
pass
|
||||
|
||||
by_machine = {HOSTNAME: {
|
||||
"管线数": 6,
|
||||
"总生产": len(prods),
|
||||
"今日": sum(1 for p in prods if p.get("created_at","").startswith(datetime.now().strftime("%Y-%m-%d"))),
|
||||
"API已配置": api_configured,
|
||||
"API服务": api_services,
|
||||
"策略分布": strategies if strategies else {"未生产": 0},
|
||||
"费用": local.get("total_cost", 0),
|
||||
}}
|
||||
|
||||
# 读取其他机器的共享数据
|
||||
for d in get_plugin_data(self.name):
|
||||
hn = d.get("hostname", "")
|
||||
if hn and hn != HOSTNAME:
|
||||
data = d.get("data", {})
|
||||
m = data.get("各机器", {}).get(hn, {})
|
||||
if m:
|
||||
by_machine[hn] = m
|
||||
|
||||
return {
|
||||
"总管线": 6,
|
||||
"各机器": by_machine,
|
||||
}
|
||||
|
||||
def detail(self, machine: str = "") -> dict:
|
||||
"""返回生产记录格式 (兼容前端)"""
|
||||
result = {}
|
||||
logs = self._read_production_logs()
|
||||
prods = logs.get("productions", [])[-20:]
|
||||
|
||||
# 如果没有真实生产记录, 返回空列表而不是管线结构
|
||||
records = []
|
||||
for p in prods:
|
||||
records.append({
|
||||
"id": p.get("id", 0),
|
||||
"strategy": p.get("strategy", ""),
|
||||
"script_name": p.get("script_name", ""),
|
||||
"status": p.get("status", "unknown"),
|
||||
"total_cost": p.get("total_cost", 0),
|
||||
"duration_sec": p.get("duration_sec", 0),
|
||||
"created_at": p.get("created_at", ""),
|
||||
})
|
||||
|
||||
result[HOSTNAME] = {
|
||||
"productions": records,
|
||||
"total_cost": logs.get("total_cost", 0),
|
||||
"today": sum(1 for p in prods if p.get("created_at","").startswith(datetime.now().strftime("%Y-%m-%d"))),
|
||||
}
|
||||
return result
|
||||
|
||||
def actions(self) -> list[dict]:
|
||||
return [
|
||||
{"name": "刷新缓存", "method": "POST", "endpoint": "/api/plugins/ave/refresh"},
|
||||
]
|
||||
|
||||
def get_sub_views(self) -> list[dict]:
|
||||
return [
|
||||
{"key": "characters", "label": "角色管理", "icon": "🧑", "group": "ave"},
|
||||
{"key": "capabilities", "label": "原子能力", "icon": "⚡", "group": "ave"},
|
||||
]
|
||||
|
||||
def get_characters(self) -> dict:
|
||||
"""列出所有已注册角色"""
|
||||
try:
|
||||
from character_registry import CharacterRegistry
|
||||
registry = CharacterRegistry()
|
||||
chars = {}
|
||||
for name in registry.list_characters():
|
||||
char = registry.get_character(name)
|
||||
chars[name] = char.to_dict()
|
||||
return {
|
||||
"characters": chars,
|
||||
"active": registry.get_active_name(),
|
||||
"total": len(chars),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"characters": {}, "active": "", "total": 0, "error": str(e)}
|
||||
|
||||
def get_capabilities(self) -> dict:
|
||||
"""解析 AVE_ARCHITECTURE_PLAN.md 中的原子能力"""
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parent.parent.parent / "09_ave" / "scripts"
|
||||
plan_path = _SCRIPTS_DIR / "AVE_ARCHITECTURE_PLAN.md"
|
||||
if not plan_path.exists():
|
||||
return {"groups": [], "error": "架构文档不存在"}
|
||||
|
||||
try:
|
||||
content = plan_path.read_text(encoding="utf-8")
|
||||
return self._parse_capabilities(content)
|
||||
except Exception as e:
|
||||
return {"groups": [], "error": str(e)}
|
||||
|
||||
def _parse_capabilities(self, content: str) -> dict:
|
||||
"""从 markdown 中提取原子能力表格"""
|
||||
groups = []
|
||||
current_group = None
|
||||
current_items = []
|
||||
headers = []
|
||||
|
||||
# 定义要提取的 section 范围
|
||||
sections = {
|
||||
"音频原子能力": "音频原子能力|3.1 音频",
|
||||
"视觉原子能力": "视觉原子能力|3.2 视觉",
|
||||
"动作迁移原子能力": "动作迁移原子能力|3.3 动作",
|
||||
"角色原子能力": "角色原子能力|3.4 角色",
|
||||
"通用基础能力": "通用基础能力|3.5 通用",
|
||||
}
|
||||
current_section_tag = None
|
||||
|
||||
for line in content.split("\n"):
|
||||
# 检测 section 标题
|
||||
for tag, patterns in sections.items():
|
||||
for p in patterns.split("|"):
|
||||
if p in line:
|
||||
if current_group and current_items:
|
||||
groups.append({"name": current_group, "items": current_items})
|
||||
current_group = tag
|
||||
current_items = []
|
||||
headers = []
|
||||
current_section_tag = tag
|
||||
break
|
||||
if current_section_tag is None:
|
||||
continue
|
||||
|
||||
# 检测表格行
|
||||
if line.startswith("|") and line.endswith("|"):
|
||||
cells = [c.strip() for c in line.split("|")[1:-1]]
|
||||
if not cells or len(cells) < 2:
|
||||
continue
|
||||
# 跳过表头分隔行
|
||||
if all(c.strip().replace("-", "").replace(":", "") == "" for c in cells):
|
||||
continue
|
||||
# 第一行是表头
|
||||
if not headers:
|
||||
headers = cells
|
||||
continue
|
||||
current_items.append(dict(zip(headers, cells + [""] * (len(headers) - len(cells)))))
|
||||
|
||||
if current_group and current_items:
|
||||
groups.append({"name": current_group, "items": current_items})
|
||||
|
||||
# 提取交叉能力矩阵
|
||||
matrix = self._parse_matrix(content)
|
||||
|
||||
return {"groups": groups, "matrix": matrix, "total_items": sum(len(g["items"]) for g in groups)}
|
||||
|
||||
def _parse_matrix(self, content: str) -> list:
|
||||
"""提取交叉能力矩阵表格"""
|
||||
in_matrix = False
|
||||
headers = []
|
||||
rows = []
|
||||
for line in content.split("\n"):
|
||||
if "交叉能力矩阵" in line or "## 6." in line:
|
||||
in_matrix = True
|
||||
continue
|
||||
if in_matrix and line.startswith("|") and line.endswith("|"):
|
||||
cells = [c.strip() for c in line.split("|")[1:-1]]
|
||||
if not cells or len(cells) < 2:
|
||||
continue
|
||||
if all(c.strip().replace("-", "").replace(":", "") == "" for c in cells):
|
||||
continue
|
||||
if not headers:
|
||||
headers = cells
|
||||
continue
|
||||
name = cells[0].strip()
|
||||
usages = {}
|
||||
for i, cell in enumerate(cells[1:], 1):
|
||||
if i < len(headers):
|
||||
usages[headers[i]] = cell.strip()
|
||||
rows.append({"name": name, "usages": usages})
|
||||
if in_matrix and not line.startswith("|") and rows:
|
||||
break
|
||||
return rows
|
||||
@@ -0,0 +1,139 @@
|
||||
# plugins/base.py
|
||||
# Dashboard v4.0 插件基类 — 联邦控制中心规范
|
||||
# 版本: 2.0.0 | 更新: 2026-05-18
|
||||
|
||||
import json, os, uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, Any
|
||||
|
||||
# ── 路径常量(所有插件用这些, 禁止硬编码)──────────────────
|
||||
# 优先级: 环境变量 > Path.home()
|
||||
# 其他机器只需 export AGENT_SYNC=... 和 AGENT_LOCAL=... 即可
|
||||
HOME = Path.home()
|
||||
AGENT_SYNC = Path(os.environ.get("AGENT_SYNC", str(HOME / "workbuddy-agent-os" / "agent-sync")))
|
||||
AGENT_LOCAL = Path(os.environ.get("AGENT_LOCAL", str(HOME / "workbuddy-agent-os" / "agent-local")))
|
||||
CROSS_MACHINE = AGENT_SYNC / "04_memory" / "cross_machine"
|
||||
DASHBOARD_LOCAL = AGENT_LOCAL / "runtime" / "dashboard"
|
||||
|
||||
# ── 本机身份 ───────────────────────────────────────────────
|
||||
HOSTNAME_FILE = AGENT_LOCAL / "identity" / "cached_hostname"
|
||||
UID_FILE = AGENT_LOCAL / "identity" / "machine_uid"
|
||||
|
||||
def resolve_hostname() -> str:
|
||||
if HOSTNAME_FILE.exists():
|
||||
return HOSTNAME_FILE.read_text().strip()
|
||||
return os.uname().nodename
|
||||
|
||||
def resolve_uid() -> str:
|
||||
if UID_FILE.exists():
|
||||
return UID_FILE.read_text().strip()
|
||||
return "unknown"
|
||||
|
||||
HOSTNAME = resolve_hostname()
|
||||
MACHINE_UID = resolve_uid()
|
||||
|
||||
|
||||
class DashboardPlugin:
|
||||
"""插件基类 v2.0
|
||||
|
||||
每个系统模块实现此基类, 自动注册到联邦控制中心。
|
||||
插件实例化后, summary() 数据自动写入 cross_machine/data/{name}/{uid}.json
|
||||
供所有机器的 Dashboard 读取展示。
|
||||
|
||||
子类必须定义:
|
||||
name, label, icon, version, description, order
|
||||
|
||||
子类必须实现:
|
||||
summary(machines) — 返回概览数据 (含各机器分区)
|
||||
detail(machine) — 返回指定机器的详细数据
|
||||
|
||||
子类可选实现:
|
||||
actions() — 返回可执行操作列表
|
||||
"""
|
||||
|
||||
# ── 元信息 (子类必须定义) ───────────────────────────────
|
||||
name: str = "" # 唯一标识, 如 "matrix"
|
||||
label: str = "" # 中文名, 如 "账号矩阵"
|
||||
icon: str = "📦" # 图标
|
||||
version: str = "1.0.0" # 插件版本
|
||||
description: str = "" # 简要说明
|
||||
order: int = 99 # 排序 (值越小越靠前)
|
||||
|
||||
# ── 核心接口 (子类必须实现) ─────────────────────────────
|
||||
|
||||
def summary(self, machines: list[str]) -> dict:
|
||||
"""
|
||||
概览数据。machines: 当前所有在线机器 hostname 列表。
|
||||
返回结构示例:
|
||||
{
|
||||
"总账号": 8,
|
||||
"在线": 6,
|
||||
"各机器": {
|
||||
"chengzigedeAir": {"账号":5, "在线":4},
|
||||
"Redmi-12C": {"账号":3, "在线":2},
|
||||
"7kechengdeAir": {"_note": "未接入此模块"},
|
||||
}
|
||||
}
|
||||
某机器未接入此模块时, 返回 {"_note": "未接入此模块"}
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def detail(self, machine: str = "") -> dict:
|
||||
"""
|
||||
详细面板数据。machine="" 返回所有机器汇总; 否则返回指定机器。
|
||||
返回结构由各插件自定义。
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
# ── 可选覆写 ────────────────────────────────────────────
|
||||
|
||||
def actions(self) -> list[dict]:
|
||||
"""可执行操作列表, 用于 Dashboard 操作面板"""
|
||||
return []
|
||||
|
||||
def get_sub_views(self) -> list[dict]:
|
||||
"""返回该插件下的子视图列表
|
||||
每个子视图: {"key": "characters", "label": "角色管理", "icon": "🧑", "group":"ave"}
|
||||
返回空列表表示该插件没有子视图(仅使用主视图)
|
||||
group: 用于前端侧边栏分组
|
||||
"""
|
||||
return []
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""健康检查"""
|
||||
return self.is_available()
|
||||
|
||||
# ── 数据写入 ────────────────────────────────────────────
|
||||
|
||||
def write_shared(self):
|
||||
"""将 summary() 数据写入 cross_machine, 供其他机器读取"""
|
||||
from ._registry import get_machine_list
|
||||
machines = get_machine_list()
|
||||
try:
|
||||
data = self.summary(machines)
|
||||
except Exception as e:
|
||||
data = {"_error": str(e)}
|
||||
out = {
|
||||
"plugin": self.name,
|
||||
"version": self.version,
|
||||
"machine_uid": MACHINE_UID,
|
||||
"hostname": HOSTNAME,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"data": data,
|
||||
}
|
||||
path = CROSS_MACHINE / "data" / self.name
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
(path / f"{MACHINE_UID}.json").write_text(
|
||||
json.dumps(out, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
# ── 兼容旧版 ────────────────────────────────────────────
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""旧版兼容"""
|
||||
return True
|
||||
|
||||
def get_summary(self) -> dict:
|
||||
"""旧版兼容 — 转为 summary(machines)"""
|
||||
from ._registry import get_machine_list
|
||||
return self.summary(get_machine_list())
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
plugins/crawl.py — 内容抓取插件 v1.1
|
||||
抓取任务管理 / 抓取源管理 / 抓取历史 / 数据库统计
|
||||
版本: 1.1.0 | 更新: 2026-07-10
|
||||
"""
|
||||
import json, logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from plugins.base import DashboardPlugin, AGENT_SYNC, AGENT_LOCAL, HOSTNAME, MACHINE_UID
|
||||
|
||||
logger = logging.getLogger("dashboard.plugins.crawl")
|
||||
|
||||
|
||||
class CrawlDashboardPlugin(DashboardPlugin):
|
||||
name = "crawl"
|
||||
label = "内容抓取"
|
||||
icon = "📡"
|
||||
version = "1.1.0"
|
||||
description = "内容抓取:抓取任务 / 源管理 / 抓取历史"
|
||||
order = 35
|
||||
|
||||
def _get_collect_stats(self):
|
||||
"""从抓取数据库获取统计"""
|
||||
try:
|
||||
from services.scrape_db import ScrapeDB
|
||||
db = ScrapeDB()
|
||||
return {
|
||||
"total": sum(db.count_by_platform().values()),
|
||||
"today": db.count_today(),
|
||||
"sources": db.sources_count(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"抓取数据库不可用: {e}")
|
||||
return None
|
||||
|
||||
def _count_knowledge_items(self):
|
||||
"""统计知识库中采集的内容"""
|
||||
kb_dir = AGENT_SYNC / "03_knowledge"
|
||||
total = 0
|
||||
today = 0
|
||||
today_str = datetime.now().strftime("%Y-%m-%d")
|
||||
for category in ["10_concepts", "20_methods", "40_references", "50_resources"]:
|
||||
cat_dir = kb_dir / category
|
||||
if cat_dir.exists():
|
||||
for f in cat_dir.rglob("*.md"):
|
||||
total += 1
|
||||
try:
|
||||
mtime = datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y-%m-%d")
|
||||
if mtime == today_str:
|
||||
today += 1
|
||||
except:
|
||||
pass
|
||||
return total, today
|
||||
|
||||
def summary(self, machines: list[str]) -> dict:
|
||||
"""返回内容抓取概览(数据库统计优先)"""
|
||||
db_stats = self._get_collect_stats()
|
||||
if db_stats:
|
||||
return {
|
||||
"总抓取数": db_stats["total"],
|
||||
"今日新增": db_stats["today"],
|
||||
"抓取源": db_stats["sources"],
|
||||
}
|
||||
# 兜底:知识库统计
|
||||
total, today = self._count_knowledge_items()
|
||||
return {
|
||||
"总抓取数": total,
|
||||
"今日新增": today,
|
||||
"抓取源": 0,
|
||||
}
|
||||
|
||||
def detail(self, machine: str) -> dict:
|
||||
"""返回指定机器的抓取详情"""
|
||||
db_stats = self._get_collect_stats()
|
||||
if db_stats:
|
||||
return {
|
||||
"machine": machine,
|
||||
"total": db_stats["total"],
|
||||
"today": db_stats["today"],
|
||||
"sources": db_stats["sources"],
|
||||
}
|
||||
total, today = self._count_knowledge_items()
|
||||
return {"machine": machine, "total": total, "today": today}
|
||||
|
||||
def actions(self) -> list[dict]:
|
||||
"""返回抓取可执行操作"""
|
||||
return [
|
||||
{"label": "新建抓取", "action": "switchView('scrape')"},
|
||||
{"label": "抓取管理", "action": "switchView('scrape')"},
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
plugins/federation.py — 联邦管理插件 (v1.0)
|
||||
显示联邦集群状态、机器列表、同步/对账操作
|
||||
版本: 1.0.0 | 更新: 2026-06-16
|
||||
"""
|
||||
import json, subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from plugins.base import DashboardPlugin, AGENT_SYNC, AGENT_LOCAL, HOSTNAME, MACHINE_UID
|
||||
from plugins._registry import get_machine_list, get_plugin_data
|
||||
|
||||
|
||||
class FederationDashboardPlugin(DashboardPlugin):
|
||||
name = "federation"
|
||||
label = "联邦管理"
|
||||
icon = "🖥️"
|
||||
version = "1.0.0"
|
||||
description = "联邦管理:集群状态 / 同步 / 对账 / 远程Shell"
|
||||
order = 40
|
||||
|
||||
def summary(self, machines: list[str]) -> dict:
|
||||
"""返回联邦集群概览"""
|
||||
machine_list = get_machine_list()
|
||||
all_data = get_plugin_data("guardd") # list of all machines' data
|
||||
online = 0
|
||||
for m in machine_list:
|
||||
for d in all_data:
|
||||
if d.get("hostname") == m or d.get("machine_name") == m:
|
||||
if d.get("status") == "online":
|
||||
online += 1
|
||||
break
|
||||
return {
|
||||
"总机器": len(machine_list),
|
||||
"在线": online,
|
||||
"本机": HOSTNAME,
|
||||
}
|
||||
|
||||
def detail(self, machine: str) -> dict:
|
||||
"""返回指定机器的联邦详情"""
|
||||
all_data = get_plugin_data("guardd") or []
|
||||
data = {}
|
||||
for d in all_data:
|
||||
if d.get("hostname") == machine or d.get("machine_name") == machine:
|
||||
data = d
|
||||
break
|
||||
return {
|
||||
"hostname": data.get("hostname", machine),
|
||||
"status": data.get("status", "unknown"),
|
||||
"machine_uid": data.get("machine_uid", ""),
|
||||
"dashboard_online": data.get("dashboard_online", False),
|
||||
}
|
||||
|
||||
def actions(self) -> list[dict]:
|
||||
"""返回联邦管理可执行操作"""
|
||||
return [
|
||||
{"name": "sync", "label": "一键同步", "method": "POST",
|
||||
"endpoint": "/api/fleet/sync", "description": "Git同步所有机器"},
|
||||
{"name": "reconcile", "label": "对账检查", "method": "POST",
|
||||
"endpoint": "/api/fleet/reconcile", "description": "检查ORACLE合规性"},
|
||||
]
|
||||
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
plugins/guardd.py — 联邦状态监控插件 (v3.1)
|
||||
WPRA v2.0: 优先 machines/*/heartbeat.json, 降级 status/live/
|
||||
版本: 3.1.0 | 更新: 2026-05-31
|
||||
"""
|
||||
import json, os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from plugins.base import DashboardPlugin, CROSS_MACHINE, MACHINE_UID, HOSTNAME, AGENT_LOCAL
|
||||
from plugins._registry import get_machine_list
|
||||
|
||||
# ── WPRA v2.0 路径 ────────────────────────────────
|
||||
MACHINES_DIR = CROSS_MACHINE / "machines"
|
||||
|
||||
# ── 人工维护的 IP→hostname 映射(旧版兼容)───────────
|
||||
_MACHINE_ALIASES: dict[str, str] = {
|
||||
"192.168.31.95": "7kecheng",
|
||||
"7kechengdeAir": "7kecheng",
|
||||
"7kechengdeMacBook-Air.local": "7kecheng",
|
||||
"Redmi-12C": "chengzigedeAir",
|
||||
"192.168.31.96": "chengzigedeAir",
|
||||
}
|
||||
|
||||
|
||||
class GuarddPlugin(DashboardPlugin):
|
||||
name = "guardd"
|
||||
label = "联邦机器"
|
||||
icon = "🖥"
|
||||
version = "3.1.0"
|
||||
description = "跨机器联邦状态监控:WPRA v2.0 心跳聚合"
|
||||
order = 10
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# WPRA v2.0: machines/*/heartbeat.json (主数据源)
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
def _read_wpra_heartbeats(self):
|
||||
"""主数据源: machines/*/heartbeat.json
|
||||
|
||||
WPRA v2.0: 每台机器只写自己的 machines/{uid}/heartbeat.json
|
||||
统一字段名映射到旧格式,保持 _build_machine_detail 兼容。
|
||||
"""
|
||||
data = {}
|
||||
if not MACHINES_DIR.exists():
|
||||
return data
|
||||
for md in sorted(MACHINES_DIR.iterdir()):
|
||||
if not md.is_dir():
|
||||
continue
|
||||
uid = md.name
|
||||
hb_file = md / "heartbeat.json"
|
||||
if not hb_file.exists():
|
||||
continue
|
||||
try:
|
||||
hb = json.loads(hb_file.read_text())
|
||||
|
||||
# 统一字段名映射 (新→旧格式兼容)
|
||||
machine_name = hb.get("machine_name", uid[:8])
|
||||
updated_at = hb.get("updated_at", "")
|
||||
mapped = {
|
||||
"_hostname": machine_name,
|
||||
"_uid": uid,
|
||||
"hostname": machine_name,
|
||||
"_received_at": updated_at,
|
||||
"status": hb.get("status", "offline"),
|
||||
"guardd_version": hb.get("guardd_version", ""),
|
||||
"current_task": hb.get("current_task", None),
|
||||
# 从 WPRA 扁平字段转为旧版嵌套格式
|
||||
"cpu": {"load_1m": hb.get("cpu_load", 0)},
|
||||
"disk": {
|
||||
"available_gb": hb.get("disk_avail_gb", 0),
|
||||
"total_gb": hb.get("disk_total_gb", 0),
|
||||
"used_gb": hb.get("disk_used_gb", 0),
|
||||
},
|
||||
"memory": {"percent": hb.get("memory_pct", 0)},
|
||||
"os": "",
|
||||
"_live": bool(updated_at),
|
||||
"_last_push_sec": 0,
|
||||
"_source": "wpra_heartbeat",
|
||||
}
|
||||
|
||||
# 计算 _last_push_sec
|
||||
if updated_at:
|
||||
try:
|
||||
delta = (datetime.now(timezone.utc) -
|
||||
datetime.fromisoformat(updated_at.replace("Z", "+00:00"))).total_seconds()
|
||||
mapped["_last_push_sec"] = round(delta)
|
||||
mapped["_live"] = delta < 120
|
||||
except:
|
||||
pass
|
||||
|
||||
data[uid] = mapped
|
||||
except Exception:
|
||||
continue
|
||||
return data
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 旧版: status/live/{uid}.json (降级)
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
def _read_live_data(self):
|
||||
"""实时数据优先: HTTP Push 写入的 status/live/, 补充 WPRA machines/*/
|
||||
|
||||
双轨制 (v4.2.0):
|
||||
- 远程机器通过 HTTP Push 实时推送心跳 → status/live/{uid}.json(主数据源)
|
||||
- 本地 guardd 写入 → machines/{uid}/heartbeat.json(降级补充)
|
||||
优先读取实时推送数据,WPRA 文件仅作补充(适用于未推送的机器)。
|
||||
"""
|
||||
# 主数据源: HTTP Push 实时推送 → status/live/{uid}.json
|
||||
live_dir = CROSS_MACHINE / "status" / "live"
|
||||
data = {}
|
||||
if live_dir.exists():
|
||||
for f in live_dir.iterdir():
|
||||
if f.suffix != ".json" or f.name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
uid = f.name.replace(".json", "")
|
||||
data[uid] = json.loads(f.read_text())
|
||||
except:
|
||||
pass
|
||||
|
||||
# 补充: WPRA machines/*/heartbeat.json(如 live 中没有该机器的数据)
|
||||
wpra = self._read_wpra_heartbeats()
|
||||
for uid, hb in wpra.items():
|
||||
if uid not in data:
|
||||
data[uid] = hb
|
||||
|
||||
return data
|
||||
|
||||
def _read_registry(self):
|
||||
"""补充数据源: cross_machine/registry/{name}.json"""
|
||||
reg_dir = CROSS_MACHINE / "registry"
|
||||
machines = []
|
||||
if reg_dir.exists():
|
||||
for f in sorted(reg_dir.iterdir()):
|
||||
if f.suffix == ".json":
|
||||
try:
|
||||
d = json.loads(f.read_text())
|
||||
machines.append({
|
||||
"hostname": d.get("hostname", f.stem),
|
||||
"uid": d.get("uid", ""),
|
||||
"role": d.get("role", ""),
|
||||
"source": "registry",
|
||||
})
|
||||
except:
|
||||
pass
|
||||
return machines
|
||||
|
||||
def _read_plugin_data(self):
|
||||
"""补充数据源: 本机 guardd 状态 (local) + 旧 cross_machine/data/ (兼容)"""
|
||||
# 新: 本机 local 目录
|
||||
local_guardd = AGENT_LOCAL / "runtime" / "guardd" / "data"
|
||||
machines = {}
|
||||
if local_guardd.exists():
|
||||
for plugin_dir in local_guardd.iterdir():
|
||||
if not plugin_dir.is_dir():
|
||||
continue
|
||||
for f in plugin_dir.iterdir():
|
||||
if f.suffix == ".json":
|
||||
try:
|
||||
d = json.loads(f.read_text())
|
||||
uid = d.get("machine_uid", "")
|
||||
hn = d.get("hostname", "")
|
||||
if uid and hn:
|
||||
if uid not in machines:
|
||||
machines[uid] = {"hostname": hn, "uid": uid, "source": "local_guardd"}
|
||||
except: pass
|
||||
# 旧: cross_machine/data/ (兼容旧数据)
|
||||
data_dir = CROSS_MACHINE / "data"
|
||||
if data_dir.exists():
|
||||
for plugin_dir in data_dir.iterdir():
|
||||
if not plugin_dir.is_dir():
|
||||
continue
|
||||
for f in plugin_dir.iterdir():
|
||||
if f.suffix == ".json":
|
||||
try:
|
||||
d = json.loads(f.read_text())
|
||||
uid = d.get("machine_uid", "")
|
||||
hn = d.get("hostname", "")
|
||||
if uid and hn and uid not in machines:
|
||||
machines[uid] = {"hostname": hn, "uid": uid, "source": "legacy_cross_machine"}
|
||||
except: pass
|
||||
return list(machines.values())
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 概览 / 详情
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
def summary(self, machines: list[str]) -> dict:
|
||||
live = self._read_live_data()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
by_machine = {}
|
||||
for uid, l in live.items():
|
||||
hn = l.get("_hostname", uid[:8])
|
||||
received = l.get("_received_at", "")
|
||||
online = False
|
||||
realtime = False
|
||||
if received:
|
||||
try:
|
||||
delta = (now - datetime.fromisoformat(
|
||||
received.replace("Z", "+00:00"))).total_seconds()
|
||||
online = delta < 300
|
||||
realtime = delta < 120
|
||||
except:
|
||||
pass
|
||||
by_machine[hn] = {
|
||||
"在线": online,
|
||||
"实时推送": realtime,
|
||||
"guardd版本": l.get("guardd_version", ""),
|
||||
}
|
||||
|
||||
return {
|
||||
"总机器": len(by_machine),
|
||||
"在线": sum(1 for m in by_machine.values() if m["在线"]),
|
||||
"实时": sum(1 for m in by_machine.values() if m["实时推送"]),
|
||||
"各机器": by_machine,
|
||||
}
|
||||
|
||||
def detail(self, machine: str = "") -> dict:
|
||||
live = self._read_live_data()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if machine:
|
||||
for uid, l in live.items():
|
||||
if l.get("_hostname") == machine:
|
||||
return self._build_machine_detail(machine, uid, l, now)
|
||||
return {"_note": "未找到该机器数据"}
|
||||
|
||||
result = {}
|
||||
seen = set()
|
||||
for uid, l in sorted(live.items(), key=lambda x: x[1].get("_hostname", "")):
|
||||
hn = l.get("_hostname", uid[:8])
|
||||
if hn in seen:
|
||||
continue
|
||||
seen.add(hn)
|
||||
result[hn] = self._build_machine_detail(hn, uid, l, now)
|
||||
return result
|
||||
|
||||
def _build_machine_detail(self, hostname, uid, live, now):
|
||||
received = live.get("_received_at", "")
|
||||
entry = {
|
||||
"hostname": hostname,
|
||||
"_uid": uid[:8]+"..." if uid else "",
|
||||
"status": "offline",
|
||||
"last_seen": received,
|
||||
"os": live.get("os", ""),
|
||||
"cpu_load": 0,
|
||||
"disk_used_gb": 0,
|
||||
"disk_total_gb": 0,
|
||||
"disk_avail_gb": 0,
|
||||
"guardd_version": live.get("guardd_version", ""),
|
||||
"current_task": live.get("current_task", ""),
|
||||
"minutes_ago": 999,
|
||||
"_live": False,
|
||||
"_last_push_sec": 0,
|
||||
}
|
||||
if received:
|
||||
try:
|
||||
delta = (now - datetime.fromisoformat(
|
||||
received.replace("Z", "+00:00"))).total_seconds()
|
||||
entry["_live"] = delta < 120
|
||||
entry["_last_push_sec"] = round(delta)
|
||||
entry["minutes_ago"] = round(delta / 60)
|
||||
entry["status"] = "online" if delta < 300 else ("recent" if delta < 3600 else "offline")
|
||||
except:
|
||||
pass
|
||||
cpu = live.get("cpu", {})
|
||||
entry["cpu_load"] = cpu.get("load_1m", 0)
|
||||
disk = live.get("disk", {})
|
||||
entry["disk_used_gb"] = disk.get("used_gb", 0)
|
||||
entry["disk_total_gb"] = disk.get("total_gb", 0)
|
||||
entry["disk_avail_gb"] = disk.get("available_gb", 0)
|
||||
return entry
|
||||
|
||||
def actions(self) -> list[dict]:
|
||||
machines = get_machine_list()
|
||||
return [
|
||||
{"name": f"唤醒 {hn}", "method": "POST", "endpoint": f"/api/wakeup/{hn}"}
|
||||
for hn in machines
|
||||
]
|
||||
|
||||
def get_productions(self, limit=50, offset=0, strategy=None, status=None):
|
||||
result = self.detail()
|
||||
return list(result.values())
|
||||
|
||||
def get_summary(self) -> dict:
|
||||
return self.summary(get_machine_list())
|
||||
@@ -0,0 +1,742 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
kb_api.py — 知识库管理 API 模块
|
||||
|
||||
集中管理知识库的所有操作,供 Dashboard 调用。
|
||||
每个操作都有参数配置 + 日志记录。
|
||||
|
||||
版本: 1.0.0 | 更新: 2026-05-31
|
||||
"""
|
||||
import json, os, sys, subprocess, re, shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
HOME = Path.home()
|
||||
AGENT_SYNC = Path(os.environ.get("AGENT_SYNC", str(HOME / "workbuddy-agent-os" / "agent-sync")))
|
||||
AGENT_LOCAL = Path(os.environ.get("AGENT_LOCAL", str(HOME / "workbuddy-agent-os" / "agent-local")))
|
||||
KNOWLEDGE = AGENT_SYNC / "03_knowledge"
|
||||
TASKS_DIR = AGENT_SYNC / "04_memory" / "cross_machine" / "tasks"
|
||||
|
||||
# 知识库分类目录
|
||||
KB_CATEGORIES = {
|
||||
"00_inbox": "待提纯",
|
||||
"00_stream": "流式",
|
||||
"01_daily": "日志",
|
||||
"01_submissions": "提交箱",
|
||||
"04_ops": "运维",
|
||||
"10_concepts": "概念",
|
||||
"20_methods": "方法",
|
||||
"30_facts": "事实",
|
||||
"40_references": "参考",
|
||||
"50_resources": "素材",
|
||||
"60_opinions": "观点",
|
||||
"90_archive": "归档",
|
||||
"99_system": "系统",
|
||||
}
|
||||
|
||||
|
||||
class KBManager:
|
||||
"""知识库管理——所有操作集中入口"""
|
||||
|
||||
def __init__(self):
|
||||
self._log_path = AGENT_LOCAL / "runtime" / "kb_ops.jsonl"
|
||||
self._log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 统计
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""返回知识库各目录统计"""
|
||||
stats = {}
|
||||
total = 0
|
||||
cat_order = []
|
||||
for d in sorted(KNOWLEDGE.iterdir()):
|
||||
if d.is_dir() and not d.name.startswith("."):
|
||||
count = len(list(d.rglob("*.md")))
|
||||
label = KB_CATEGORIES.get(d.name, d.name)
|
||||
stats[d.name] = {"label": label, "count": count}
|
||||
total += count
|
||||
cat_order.append(d.name)
|
||||
return {
|
||||
"categories": stats,
|
||||
"total": total,
|
||||
"order": cat_order,
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 操作:提纯收件箱 / 汇聚 / 入库 / 搜索 / 备份 / 同步
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
def run_refine(self, params: dict = None) -> dict:
|
||||
"""执行 inbox_refine 提纯
|
||||
|
||||
params:
|
||||
- auto_classify: bool (默认True)
|
||||
- target_dirs: list[str] (默认["00_inbox"])
|
||||
"""
|
||||
params = params or {}
|
||||
self._log_op("refine", params, "started")
|
||||
try:
|
||||
# 调用 inbox_refine 脚本
|
||||
script = AGENT_SYNC / "05_tools" / "01_system" / "inbox_refine.py"
|
||||
cmd = [sys.executable, str(script)]
|
||||
if not params.get("auto_classify", True):
|
||||
cmd.append("--no-classify")
|
||||
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120,
|
||||
cwd=str(script.parent))
|
||||
result = {
|
||||
"status": "ok" if r.returncode == 0 else "error",
|
||||
"stdout": r.stdout[-1000:],
|
||||
"stderr": r.stderr[-500:],
|
||||
"returncode": r.returncode,
|
||||
}
|
||||
self._log_op("refine", params, result["status"], result)
|
||||
return result
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "error", "error": "执行超时(120s)"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
def run_collect_to_inbox(self, params: dict = None) -> dict:
|
||||
"""执行 collect_to_inbox 汇聚
|
||||
|
||||
params:
|
||||
- source_dirs: list[str] (默认全部目录)
|
||||
"""
|
||||
params = params or {}
|
||||
self._log_op("collect_to_inbox", params, "started")
|
||||
try:
|
||||
script = AGENT_SYNC / "05_tools" / "01_system" / "collect_to_inbox.py"
|
||||
cmd = [sys.executable, str(script)]
|
||||
sources = params.get("source_dirs", [])
|
||||
for s in sources:
|
||||
cmd.extend(["--source", s])
|
||||
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=180,
|
||||
cwd=str(script.parent))
|
||||
result = {
|
||||
"status": "ok" if r.returncode == 0 else "error",
|
||||
"stdout": r.stdout[-1000:],
|
||||
"stderr": r.stderr[-500:],
|
||||
"returncode": r.returncode,
|
||||
}
|
||||
self._log_op("collect_to_inbox", params, result["status"], result)
|
||||
return result
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "error", "error": "执行超时(180s)"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
def run_ingest(self, params: dict) -> dict:
|
||||
"""知识入库
|
||||
|
||||
params:
|
||||
- source: str (URL 或文本)
|
||||
- category: str (目标分类目录)
|
||||
- tags: str (逗号分隔的标签)
|
||||
- title: str (可选标题)
|
||||
"""
|
||||
params = params or {}
|
||||
source = params.get("source", "")
|
||||
if not source:
|
||||
return {"status": "error", "error": "需要提供 URL 或文本"}
|
||||
self._log_op("ingest", params, "started")
|
||||
try:
|
||||
# 调用 kb_manager 脚本
|
||||
script = AGENT_SYNC / "05_tools" / "01_system" / "kb_ingest.py"
|
||||
if script.exists():
|
||||
cmd = [sys.executable, str(script), source]
|
||||
else:
|
||||
# 降级: 直接生成 markdown 文件
|
||||
cmd = self._fallback_ingest(source, params)
|
||||
|
||||
if isinstance(cmd, dict):
|
||||
result = cmd
|
||||
else:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60,
|
||||
cwd=str(script.parent))
|
||||
result = {
|
||||
"status": "ok" if r.returncode == 0 else "error",
|
||||
"stdout": r.stdout[-500:],
|
||||
"stderr": r.stderr[-300:],
|
||||
}
|
||||
self._log_op("ingest", params, result["status"], result)
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
def _fallback_ingest(self, source: str, params: dict) -> dict:
|
||||
"""降级入库: 直接写 markdown 文件"""
|
||||
cat = params.get("category", "00_inbox")
|
||||
tags = params.get("tags", "")
|
||||
title = params.get("title", source[:40])
|
||||
target_dir = KNOWLEDGE / cat
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = re.sub(r'[^\w\u4e00-\u9fff]+', '_', title)[:50] + ".md"
|
||||
filepath = target_dir / filename
|
||||
|
||||
content = f"""---
|
||||
title: "{title}"
|
||||
created: {datetime.now().isoformat()}
|
||||
tags: [{tags}]
|
||||
source: "{source[:200]}"
|
||||
---
|
||||
|
||||
# {title}
|
||||
|
||||
{source}
|
||||
"""
|
||||
filepath.write_text(content, encoding="utf-8")
|
||||
return {"status": "ok", "file": str(filepath), "method": "fallback"}
|
||||
|
||||
def search(self, params: dict) -> dict:
|
||||
"""全文搜索知识库
|
||||
|
||||
params:
|
||||
- query: str (搜索关键词)
|
||||
- category: str (可选, 限制目录)
|
||||
- limit: int (最多结果)
|
||||
"""
|
||||
query = (params or {}).get("query", "")
|
||||
if not query:
|
||||
return {"status": "error", "error": "需要搜索关键词"}
|
||||
category = (params or {}).get("category", "")
|
||||
limit = (params or {}).get("limit", 20)
|
||||
|
||||
results = []
|
||||
search_dir = KNOWLEDGE / category if category else KNOWLEDGE
|
||||
if not search_dir.exists():
|
||||
return {"status": "ok", "results": []}
|
||||
|
||||
for f in search_dir.rglob("*.md"):
|
||||
try:
|
||||
text = f.read_text(encoding="utf-8", errors="ignore")
|
||||
if query.lower() in text.lower():
|
||||
# 提取匹配上下文
|
||||
idx = text.lower().index(query.lower())
|
||||
start = max(0, idx - 60)
|
||||
end = min(len(text), idx + len(query) + 60)
|
||||
context = text[start:end].replace("\n", " ")
|
||||
results.append({
|
||||
"file": str(f.relative_to(KNOWLEDGE)),
|
||||
"category": f.parent.name,
|
||||
"context": context,
|
||||
})
|
||||
if len(results) >= limit:
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
return {"status": "ok", "results": results, "total": len(results)}
|
||||
|
||||
def backup(self, params: dict = None) -> dict:
|
||||
"""备份知识库"""
|
||||
params = params or {}
|
||||
label = params.get("label", f"kb_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}")
|
||||
self._log_op("backup", params, "started")
|
||||
try:
|
||||
backup_dir = AGENT_LOCAL / "backups" / "knowledge" / label
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 排除 .git 和 __pycache__
|
||||
shutil.copytree(KNOWLEDGE, backup_dir,
|
||||
ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc"),
|
||||
dirs_exist_ok=True)
|
||||
count = len(list(backup_dir.rglob("*.md")))
|
||||
result = {"status": "ok", "path": str(backup_dir), "files": count}
|
||||
self._log_op("backup", params, "ok", result)
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
def git_sync(self, params: dict = None) -> dict:
|
||||
"""Git 同步"""
|
||||
params = params or {}
|
||||
self._log_op("git_sync", params, "started")
|
||||
try:
|
||||
cmds = [
|
||||
["git", "add", "03_knowledge/"],
|
||||
["git", "status", "--porcelain", "03_knowledge/"],
|
||||
["git", "commit", "-m", "kb: auto sync"],
|
||||
["git", "push"],
|
||||
]
|
||||
outputs = []
|
||||
for cmd in cmds:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30,
|
||||
cwd=str(AGENT_SYNC))
|
||||
if r.returncode != 0 and cmd[1] == "commit":
|
||||
if "nothing to commit" in r.stderr:
|
||||
outputs.append("nothing to commit")
|
||||
continue
|
||||
outputs.append(r.stdout[-200:] or r.stderr[-200:])
|
||||
|
||||
result = {"status": "ok", "outputs": outputs[-3:]}
|
||||
self._log_op("git_sync", params, "ok", result)
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 记忆库管理
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
def get_memory_stats(self) -> dict:
|
||||
"""返回记忆库各层统计"""
|
||||
stats = {"l1_keywords": 0, "l2_facts": 0, "l3_files": 0, "vector_count": 0}
|
||||
|
||||
# L1 关键词
|
||||
kw_path = AGENT_LOCAL / "memory" / "vector_db" / "keyword_index.json"
|
||||
if kw_path.exists():
|
||||
try:
|
||||
import json
|
||||
kw = json.loads(kw_path.read_text())
|
||||
stats["l1_keywords"] = len(kw) if isinstance(kw, (list, dict)) else 0
|
||||
except:
|
||||
pass
|
||||
|
||||
# L2 facts.db
|
||||
l2_path = AGENT_SYNC / "04_memory" / "long_term" / "facts.db"
|
||||
if l2_path.exists():
|
||||
try:
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(str(l2_path))
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT count(*) FROM facts")
|
||||
stats["l2_facts"] = c.fetchone()[0]
|
||||
conn.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
# L3 原文
|
||||
l3_dir = AGENT_LOCAL / "memory" / "raw" / "04_memory"
|
||||
if l3_dir.exists():
|
||||
stats["l3_files"] = len([f for f in l3_dir.iterdir() if f.suffix == ".md"])
|
||||
|
||||
# Chroma 向量
|
||||
chroma_dir = AGENT_LOCAL / "memory" / "vector_db" / "chroma"
|
||||
if chroma_dir.exists():
|
||||
stats["vector_count"] = len(list(chroma_dir.rglob("*.bin"))) + len(list(chroma_dir.rglob("*.pickle")))
|
||||
|
||||
return stats
|
||||
|
||||
def get_l2_facts(self, limit: int = 100, offset: int = 0, search: str = "") -> dict:
|
||||
"""返回 L2 事实列表"""
|
||||
l2_path = AGENT_SYNC / "04_memory" / "long_term" / "facts.db"
|
||||
if not l2_path.exists():
|
||||
return {"total": 0, "facts": []}
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(str(l2_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
if search:
|
||||
c.execute("SELECT count(*) FROM facts WHERE content LIKE ?", (f"%{search}%",))
|
||||
total = c.fetchone()[0]
|
||||
c.execute("SELECT rowid, * FROM facts WHERE content LIKE ? ORDER BY rowid DESC LIMIT ? OFFSET ?",
|
||||
(f"%{search}%", limit, offset))
|
||||
else:
|
||||
total = c.execute("SELECT count(*) FROM facts").fetchone()[0]
|
||||
c.execute("SELECT rowid, * FROM facts ORDER BY rowid DESC LIMIT ? OFFSET ?", (limit, offset))
|
||||
facts = [dict(r) for r in c.fetchall()]
|
||||
conn.close()
|
||||
return {"total": total, "facts": facts}
|
||||
|
||||
def update_l2_fact(self, rowid: int, content: str, category: str = "") -> dict:
|
||||
"""更新 L2 事实"""
|
||||
l2_path = AGENT_SYNC / "04_memory" / "long_term" / "facts.db"
|
||||
if not l2_path.exists():
|
||||
return {"status": "error", "error": "facts.db 不存在"}
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(str(l2_path))
|
||||
c = conn.cursor()
|
||||
try:
|
||||
if category:
|
||||
c.execute("UPDATE facts SET content=?, category=? WHERE rowid=?", (content, category, rowid))
|
||||
else:
|
||||
c.execute("UPDATE facts SET content=? WHERE rowid=?", (content, rowid))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "ok", "updated": rowid}
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
def get_l3_files(self) -> list[dict]:
|
||||
"""返回 L3 原文文件列表"""
|
||||
l3_dir = AGENT_LOCAL / "memory" / "raw"
|
||||
if not l3_dir.exists():
|
||||
return []
|
||||
files = []
|
||||
for f in sorted(l3_dir.iterdir()):
|
||||
if f.suffix == ".md":
|
||||
files.append({
|
||||
"name": f.name,
|
||||
"size_kb": round(f.stat().st_size / 1024, 1),
|
||||
"updated": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
|
||||
})
|
||||
return files
|
||||
|
||||
def get_l3_file_content(self, filename: str) -> dict:
|
||||
"""读取 L3 原文文件内容"""
|
||||
l3_dir = AGENT_LOCAL / "memory" / "raw"
|
||||
filepath = l3_dir / filename
|
||||
if not filepath.exists() or not filename.endswith(".md"):
|
||||
return {"status": "error", "error": "文件不存在"}
|
||||
return {"status": "ok", "name": filename, "content": filepath.read_text(encoding="utf-8", errors="ignore")}
|
||||
|
||||
def get_l1_keywords(self) -> list:
|
||||
"""返回 L1 关键词列表"""
|
||||
kw_path = AGENT_LOCAL / "memory" / "vector_db" / "keyword_index.json"
|
||||
if not kw_path.exists():
|
||||
return []
|
||||
try:
|
||||
import json
|
||||
data = json.loads(kw_path.read_text())
|
||||
if isinstance(data, dict):
|
||||
return [{"word": k, "freq": v.get("freq", v) if isinstance(v, dict) else v} for k, v in data.items()]
|
||||
return data if isinstance(data, list) else []
|
||||
except:
|
||||
return []
|
||||
|
||||
def get_system_docs(self) -> list[dict]:
|
||||
"""返回联邦系统文档目录"""
|
||||
sys_dir = KNOWLEDGE / "99_system"
|
||||
if not sys_dir.exists():
|
||||
return []
|
||||
docs = []
|
||||
for f in sorted(sys_dir.iterdir()):
|
||||
if f.suffix == ".md" and not f.name.startswith("."):
|
||||
docs.append({
|
||||
"name": f.name.replace(".md", ""),
|
||||
"title": f.read_text(encoding="utf-8", errors="ignore").split("\n")[0].replace("#", "").strip()[:60],
|
||||
"size_kb": round(f.stat().st_size / 1024, 1),
|
||||
})
|
||||
return docs
|
||||
|
||||
def get_system_doc_content(self, name: str) -> dict:
|
||||
"""读取系统文档内容"""
|
||||
sys_dir = KNOWLEDGE / "99_system"
|
||||
filepath = sys_dir / (name + ".md")
|
||||
if not filepath.exists():
|
||||
# try without .md
|
||||
filepath = sys_dir / name
|
||||
if not filepath.exists() or not filepath.suffix == ".md":
|
||||
return {"status": "error", "error": "文档不存在"}
|
||||
return {"status": "ok", "name": name, "content": filepath.read_text(encoding="utf-8", errors="ignore")[:5000]}
|
||||
|
||||
def get_skills_catalog(self) -> list[dict]:
|
||||
"""返回已安装技能目录"""
|
||||
skills_dir = Path.home() / ".workbuddy" / "skills"
|
||||
if not skills_dir.exists():
|
||||
return []
|
||||
skills = []
|
||||
for d in sorted(skills_dir.iterdir()):
|
||||
if d.is_dir():
|
||||
sk = d / "SKILL.md"
|
||||
if sk.exists():
|
||||
content = sk.read_text(encoding="utf-8", errors="ignore")
|
||||
name = ""
|
||||
desc = ""
|
||||
triggers = []
|
||||
for line in content.split("\n"):
|
||||
if line.startswith("name:"):
|
||||
name = line.split(":", 1)[1].strip().strip('"').strip("'")
|
||||
elif line.startswith("description:"):
|
||||
desc = line.split(":", 1)[1].strip().strip('"').strip("'")
|
||||
elif "trigger" in line.lower() and "-" in line:
|
||||
t = line.split("-", 1)[1].strip().strip('"').strip("'")
|
||||
if t:
|
||||
triggers.append(t)
|
||||
skills.append({
|
||||
"name": name or d.name,
|
||||
"dir": d.name,
|
||||
"description": desc[:80],
|
||||
"triggers": triggers[:5],
|
||||
})
|
||||
return skills
|
||||
|
||||
def get_tools_catalog(self) -> list[dict]:
|
||||
"""返回工具目录"""
|
||||
tools_dir = AGENT_SYNC / "05_tools"
|
||||
if not tools_dir.exists():
|
||||
return []
|
||||
tools = []
|
||||
for d in sorted(tools_dir.iterdir()):
|
||||
if d.is_dir() and not d.name.startswith("."):
|
||||
tools.append({
|
||||
"name": d.name,
|
||||
"path": str(d.relative_to(AGENT_SYNC)),
|
||||
"items": len([f for f in d.rglob("*") if f.is_file() and f.suffix in (".py",".sh",".md")]),
|
||||
})
|
||||
return tools
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 操作日志
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
def get_logs(self, limit: int = 20) -> list[dict]:
|
||||
"""返回最近操作日志"""
|
||||
if not self._log_path.exists():
|
||||
return []
|
||||
logs = []
|
||||
with open(self._log_path, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
logs.append(json.loads(line))
|
||||
except:
|
||||
pass
|
||||
return logs[-limit:]
|
||||
|
||||
def _log_op(self, op: str, params: dict, status: str, result: dict = None):
|
||||
"""记录操作日志"""
|
||||
entry = {
|
||||
"op": op,
|
||||
"params": params,
|
||||
"status": status,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if result:
|
||||
entry["result"] = {k: v for k, v in result.items()
|
||||
if k in ("status", "files", "returncode", "file")}
|
||||
with open(self._log_path, "a") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# FastAPI router (挂载到 Dashboard app.py)
|
||||
# ═══════════════════════════════════════════════════════
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
router = APIRouter(prefix="/api/kb", tags=["knowledge"])
|
||||
_kb = KBManager()
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def api_kb_stats():
|
||||
return _kb.get_stats()
|
||||
|
||||
|
||||
@router.post("/refine")
|
||||
def api_kb_refine(data: dict = None):
|
||||
return _kb.run_refine(data or {})
|
||||
|
||||
|
||||
@router.post("/collect")
|
||||
def api_kb_collect(data: dict = None):
|
||||
return _kb.run_collect_to_inbox(data or {})
|
||||
|
||||
|
||||
@router.post("/ingest")
|
||||
def api_kb_ingest(data: dict):
|
||||
return _kb.run_ingest(data)
|
||||
|
||||
|
||||
@router.post("/search")
|
||||
def api_kb_search(data: dict):
|
||||
return _kb.search(data)
|
||||
|
||||
|
||||
@router.post("/backup")
|
||||
def api_kb_backup(data: dict = None):
|
||||
return _kb.backup(data or {})
|
||||
|
||||
|
||||
@router.post("/git-sync")
|
||||
def api_kb_git_sync(data: dict = None):
|
||||
return _kb.git_sync(data or {})
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
def api_kb_logs(limit: int = 20):
|
||||
return {"logs": _kb.get_logs(limit)}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 记忆库 API
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/memory/stats")
|
||||
def api_memory_stats():
|
||||
return _kb.get_memory_stats()
|
||||
|
||||
|
||||
@router.get("/memory/l2")
|
||||
def api_memory_l2(limit: int = 100, offset: int = 0, search: str = ""):
|
||||
return _kb.get_l2_facts(limit, offset, search)
|
||||
|
||||
|
||||
@router.put("/memory/l2/{rowid}")
|
||||
def api_memory_l2_update(rowid: int, data: dict):
|
||||
return _kb.update_l2_fact(rowid, data.get("content", ""), data.get("category", ""))
|
||||
|
||||
|
||||
@router.get("/memory/l3")
|
||||
def api_memory_l3():
|
||||
return {"files": _kb.get_l3_files()}
|
||||
|
||||
|
||||
@router.get("/memory/l3/{filename}")
|
||||
def api_memory_l3_read(filename: str):
|
||||
return _kb.get_l3_file_content(filename)
|
||||
|
||||
|
||||
@router.get("/memory/l1")
|
||||
def api_memory_l1():
|
||||
return {"keywords": _kb.get_l1_keywords()}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 联邦系统文档 API
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/system/docs")
|
||||
def api_system_docs():
|
||||
return {"docs": _kb.get_system_docs()}
|
||||
|
||||
|
||||
@router.get("/system/docs/{name}")
|
||||
def api_system_doc_content(name: str):
|
||||
return _kb.get_system_doc_content(name)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 技能与工具 API
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/skills")
|
||||
def api_skills():
|
||||
return {"skills": _kb.get_skills_catalog()}
|
||||
|
||||
|
||||
@router.get("/tools")
|
||||
def api_tools():
|
||||
return {"tools": _kb.get_tools_catalog()}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 自动化任务 API
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/automations")
|
||||
def api_automations():
|
||||
"""返回所有机器的自动化任务"""
|
||||
import sqlite3
|
||||
tasks = {}
|
||||
|
||||
# 本机 WorkBuddy 自动化
|
||||
wb_db = Path.home() / ".workbuddy" / "workbuddy.db"
|
||||
if wb_db.exists():
|
||||
try:
|
||||
conn = sqlite3.connect(str(wb_db))
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT id, name, prompt, rrule, scheduleType, scheduledAt, status, cwds FROM automations ORDER BY id")
|
||||
local = []
|
||||
for row in c.fetchall():
|
||||
d = dict(row)
|
||||
d["source"] = _kb._local_hostname
|
||||
d["source_type"] = "workbuddy"
|
||||
local.append(d)
|
||||
if local:
|
||||
tasks[_kb._local_hostname] = local
|
||||
conn.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
# 其他机器: 从 cross_machine/data/automation/ 读取
|
||||
auto_dir = CROSS_MACHINE / "data" / "automation"
|
||||
if auto_dir.exists():
|
||||
for f in sorted(auto_dir.iterdir()):
|
||||
if f.suffix == ".json":
|
||||
try:
|
||||
data = json.loads(f.read_text())
|
||||
hn = data.get("hostname", f.stem)
|
||||
if hn not in tasks:
|
||||
tasks[hn] = []
|
||||
tasks[hn].append({
|
||||
"source": hn,
|
||||
"source_type": "guardd_heartbeat",
|
||||
"guardd_version": data.get("guardd_version", ""),
|
||||
"last_seen": data.get("timestamp", ""),
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
# 加上 C2 命令历史
|
||||
c2_dir = CROSS_MACHINE / "tasks" / "completed"
|
||||
c2_history = []
|
||||
if c2_dir.exists():
|
||||
for f in sorted(c2_dir.iterdir(), reverse=True)[:20]:
|
||||
if f.suffix == ".json":
|
||||
try:
|
||||
d = json.loads(f.read_text())
|
||||
c2_history.append({
|
||||
"type": d.get("type", ""),
|
||||
"target": d.get("target_machine", ""),
|
||||
"status": d.get("status", ""),
|
||||
"time": (d.get("completed_at") or d.get("created_at") or "").replace("T", " ")[:19],
|
||||
"output": str(d.get("output", {}))[:100],
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
return {"machines": tasks, "history": c2_history}
|
||||
|
||||
|
||||
@router.post("/automations")
|
||||
def api_create_automation(data: dict):
|
||||
"""创建 WorkBuddy 自动化任务"""
|
||||
target = data.get("target", "")
|
||||
prompt = data.get("prompt", "")
|
||||
command_type = data.get("type", "")
|
||||
params = data.get("params", "")
|
||||
schedule = data.get("schedule_at", "")
|
||||
rrule = data.get("rrule", "")
|
||||
|
||||
if not prompt and not command_type:
|
||||
return {"status": "error", "error": "需要 prompt 或命令类型"}
|
||||
|
||||
# 如果是跨机任务, 通过 C2 发送
|
||||
if target and target != _kb._local_hostname:
|
||||
try:
|
||||
import importlib.util
|
||||
c2_path = AGENT_SYNC / "05_tools" / "07_matrix" / "scripts" / "c2" / "command_bus.py"
|
||||
spec = importlib.util.spec_from_file_location("cb", c2_path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
bus = mod.CommandBus()
|
||||
result = bus.send(target, command_type, json.loads(params) if params else {}, schedule)
|
||||
return {"status": "ok", "channel": "c2", "command_id": result.get("command_id")}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
# 本机任务: 写入 WorkBuddy sqlite
|
||||
return _create_workbuddy_automation(prompt, rrule, schedule)
|
||||
|
||||
|
||||
def _create_workbuddy_automation(prompt: str, rrule: str = "", schedule_at: str = "") -> dict:
|
||||
"""写入本机 WorkBuddy 自动化"""
|
||||
import sqlite3, uuid
|
||||
wb_db = Path.home() / ".workbuddy" / "workbuddy.db"
|
||||
if not wb_db.exists():
|
||||
return {"status": "error", "error": "WorkBuddy DB 不存在"}
|
||||
try:
|
||||
conn = sqlite3.connect(str(wb_db))
|
||||
c = conn.cursor()
|
||||
aid = str(uuid.uuid4())[:8]
|
||||
schedule_type = "once" if schedule_at else ("recurring" if rrule else "recurring")
|
||||
c.execute(
|
||||
"INSERT OR IGNORE INTO automations (id, name, prompt, rrule, scheduleType, scheduledAt, status, cwds) VALUES (?,?,?,?,?,?,?,?)",
|
||||
(aid, prompt[:30], prompt, rrule, schedule_type, schedule_at, "ACTIVE", "")
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "ok", "id": aid, "prompt": prompt[:30]}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
plugins/matrix.py — Matrix 账号矩阵插件 (v3.2)
|
||||
跨机注册表: Registry(L0 Gitee) + Override(L1 local) + 运行时(L2 cross_machine)
|
||||
版本: 3.2.0 | 更新: 2026-05-31
|
||||
"""
|
||||
import os, yaml, json, subprocess, time
|
||||
from pathlib import Path
|
||||
|
||||
from plugins.base import DashboardPlugin, AGENT_SYNC, AGENT_LOCAL, HOSTNAME, MACHINE_UID
|
||||
from plugins._registry import get_machine_list, get_plugin_data
|
||||
|
||||
MATRIX_MGMT = AGENT_SYNC / "05_tools" / "07_matrix" / "scripts" / "matrix_mgmt.py"
|
||||
|
||||
|
||||
def _run_mgmt(method: str, *args, **kwargs):
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("matrix_mgmt", MATRIX_MGMT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
mgr = mod.MatrixManager()
|
||||
fn = getattr(mgr, method)
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
|
||||
class MatrixPlugin(DashboardPlugin):
|
||||
name = "matrix"
|
||||
label = "矩阵养号"
|
||||
icon = "📱"
|
||||
version = "3.2.0"
|
||||
description = "Matrix: 跨机账号注册表 / 蓝图编排 / 原子操作 / 备份恢复"
|
||||
order = 30
|
||||
|
||||
def get_sub_views(self) -> list[dict]:
|
||||
return [
|
||||
{"key": "matrix-accounts", "label": "账号管理", "icon": "👤", "group": "matrix"},
|
||||
{"key": "matrix-blueprints", "label": "蓝图编排", "icon": "📋", "group": "matrix"},
|
||||
{"key": "matrix-atom-ops", "label": "原子操作", "icon": "⚡", "group": "matrix"},
|
||||
{"key": "matrix-backup", "label": "备份恢复", "icon": "💾", "group": "matrix"},
|
||||
{"key": "matrix-export", "label": "导入导出", "icon": "📦", "group": "matrix"},
|
||||
]
|
||||
|
||||
def _build_machine_map(self) -> dict:
|
||||
"""聚合 registry 中所有 assigned_machine 及本机/远程状态"""
|
||||
accounts = _run_mgmt("list_accounts")
|
||||
machines = {}
|
||||
for a in accounts:
|
||||
owner = a.get("owner_machine", "未分配")
|
||||
if owner not in machines:
|
||||
machines[owner] = {"总": 0, "本机": 0, "已启用": 0, "已登录": 0, "离线": 0}
|
||||
machines[owner]["总"] += 1
|
||||
if a.get("is_local"): machines[owner]["本机"] += 1
|
||||
if a.get("enabled"): machines[owner]["已启用"] += 1
|
||||
if a.get("_status") == "logged_in": machines[owner]["已登录"] += 1
|
||||
elif a.get("_status") == "remote": machines[owner]["离线"] += 1
|
||||
return machines
|
||||
|
||||
def summary(self, machines):
|
||||
try:
|
||||
info = _run_mgmt("system_info")
|
||||
_run_mgmt("publish_status") # 写 cross_machine
|
||||
accounts = _run_mgmt("list_accounts")
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# 本机概览
|
||||
local = [a for a in accounts if a.get("is_local")]
|
||||
local_enabled = sum(1 for a in local if a.get("enabled"))
|
||||
local_logged = sum(1 for a in local if a.get("_status") == "logged_in")
|
||||
all_enabled = sum(1 for a in accounts if a.get("enabled"))
|
||||
|
||||
machine_map = self._build_machine_map()
|
||||
|
||||
# 聚合其他机器数据
|
||||
for d in get_plugin_data(self.name):
|
||||
hn = d.get("hostname", "")
|
||||
if hn and hn != HOSTNAME:
|
||||
data = d.get("data", {})
|
||||
if hn not in machine_map:
|
||||
machine_map[hn] = data.get("account_summary", {"_note": "在线"})
|
||||
|
||||
return {
|
||||
"注册表账号": info["total_accounts"],
|
||||
"本机": len(local),
|
||||
"本机已启用": local_enabled,
|
||||
"本机已登录": local_logged,
|
||||
"各机器": machine_map,
|
||||
}
|
||||
|
||||
def detail(self, machine=""):
|
||||
try:
|
||||
_run_mgmt("publish_status")
|
||||
info = _run_mgmt("system_info")
|
||||
accounts = _run_mgmt("list_accounts")
|
||||
blueprints = _run_mgmt("list_blueprints")
|
||||
ops = _run_mgmt("list_atomic_ops")
|
||||
except Exception as e:
|
||||
return {HOSTNAME: {"error": str(e)}}
|
||||
|
||||
# 按所有者分组
|
||||
by_owner = {}
|
||||
for a in accounts:
|
||||
owner = a.get("owner_machine", "未分配")
|
||||
if owner not in by_owner:
|
||||
by_owner[owner] = []
|
||||
by_owner[owner].append(a)
|
||||
|
||||
return {HOSTNAME: {
|
||||
"系统信息": info,
|
||||
"注册表": accounts, # 完整列表(含远程)
|
||||
"按机器分组": by_owner, # 按 owner 分组更好展示
|
||||
"本机账号": [a for a in accounts if a.get("is_local")],
|
||||
"蓝图列表": blueprints,
|
||||
"原子操作": ops,
|
||||
"配置路径": str(AGENT_LOCAL / "tools" / "matrix" / "config"),
|
||||
}}
|
||||
|
||||
def actions(self) -> list[dict]:
|
||||
return [
|
||||
{"name": "刷新数据", "method": "POST", "endpoint": "/api/plugins/matrix/refresh"},
|
||||
{"name": "注册表", "method": "GET", "endpoint": "/api/matrix/accounts"},
|
||||
{"name": "蓝图管理", "method": "GET", "endpoint": "/api/matrix/blueprints"},
|
||||
{"name": "导出账号", "method": "GET", "endpoint": "/api/matrix/export"},
|
||||
]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
plugins/scheduler.py — 调度器管理插件 (v1.0)
|
||||
全局定时任务管理 / 调度器状态 / 执行历史
|
||||
版本: 1.0.0 | 更新: 2026-06-16
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from plugins.base import DashboardPlugin, AGENT_SYNC, AGENT_LOCAL, HOSTNAME, MACHINE_UID
|
||||
|
||||
|
||||
class SchedulerDashboardPlugin(DashboardPlugin):
|
||||
name = "scheduler"
|
||||
label = "调度器"
|
||||
icon = "⏰"
|
||||
version = "1.0.0"
|
||||
description = "全局定时任务管理 / 调度器状态 / 执行历史"
|
||||
order = 50
|
||||
|
||||
def _read_schedule_tasks(self):
|
||||
"""读取矩阵定时任务"""
|
||||
sched_dir = AGENT_SYNC / "05_tools" / "07_matrix" / "data" / "schedules"
|
||||
tasks = []
|
||||
if sched_dir.exists():
|
||||
for f in sched_dir.glob("*.json"):
|
||||
try:
|
||||
tasks.append(json.loads(f.read_text()))
|
||||
except:
|
||||
pass
|
||||
return tasks
|
||||
|
||||
def _read_workbuddy_automations(self):
|
||||
"""读取 WorkBuddy 自动化任务"""
|
||||
tasks = []
|
||||
# 扫描 agent-local 中的自动化配置
|
||||
auto_dir = AGENT_LOCAL / "automations"
|
||||
if auto_dir.exists():
|
||||
for f in auto_dir.glob("*.json"):
|
||||
try:
|
||||
tasks.append(json.loads(f.read_text()))
|
||||
except:
|
||||
pass
|
||||
return tasks
|
||||
|
||||
def summary(self, machines: list[str]) -> dict:
|
||||
"""返回调度器概览"""
|
||||
sched_tasks = self._read_schedule_tasks()
|
||||
wb_tasks = self._read_workbuddy_automations()
|
||||
return {
|
||||
"矩阵定时任务": len(sched_tasks),
|
||||
"WorkBuddy自动化": len(wb_tasks),
|
||||
"调度器状态": "运行中",
|
||||
}
|
||||
|
||||
def detail(self, machine: str) -> dict:
|
||||
"""返回指定机器的调度详情"""
|
||||
return {
|
||||
"machine": machine,
|
||||
"schedules": len(self._read_schedule_tasks()),
|
||||
"automations": len(self._read_workbuddy_automations()),
|
||||
}
|
||||
|
||||
def actions(self) -> list[dict]:
|
||||
"""返回调度器可执行操作"""
|
||||
return []
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
plugins/skills.py — 技能树插件 (v1.0)
|
||||
显示已安装技能列表/状态/版本
|
||||
版本: 1.0.0 | 更新: 2026-06-16
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from plugins.base import DashboardPlugin, AGENT_SYNC, AGENT_LOCAL, HOSTNAME, MACHINE_UID
|
||||
|
||||
|
||||
class SkillsDashboardPlugin(DashboardPlugin):
|
||||
name = "skills"
|
||||
label = "技能树"
|
||||
icon = "🧩"
|
||||
version = "1.0.0"
|
||||
description = "技能树:已安装技能列表/状态/版本"
|
||||
order = 60
|
||||
|
||||
def _scan_skills(self) -> list[dict]:
|
||||
"""扫描所有技能"""
|
||||
skills = []
|
||||
skills_dirs = [
|
||||
AGENT_SYNC / "02_skills",
|
||||
Path.home() / ".workbuddy" / "skills",
|
||||
]
|
||||
for sd in skills_dirs:
|
||||
if sd.exists():
|
||||
for d in sd.iterdir():
|
||||
if d.is_dir() and (d / "SKILL.md").exists():
|
||||
skills.append({
|
||||
"name": d.name,
|
||||
"path": str(d),
|
||||
"has_skill_md": True,
|
||||
})
|
||||
return skills
|
||||
|
||||
def summary(self, machines: list[str]) -> dict:
|
||||
skills = self._scan_skills()
|
||||
return {
|
||||
"总技能": len(skills),
|
||||
"技能列表": [s["name"] for s in skills[:20]],
|
||||
}
|
||||
|
||||
def detail(self, machine: str = "") -> dict:
|
||||
return {"machine": machine or HOSTNAME, "skills": self._scan_skills()}
|
||||
@@ -0,0 +1,910 @@
|
||||
"""
|
||||
sms_proxy_api.py — 短信接收 + 代理管理 API
|
||||
|
||||
提供 Dashboard 接口,用于查看/测试短信API配置、代理状态。
|
||||
版本: 1.0.0 | 更新: 2026-06-01
|
||||
"""
|
||||
import json, os, subprocess, sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
HOME = Path.home()
|
||||
AGENT_SYNC = Path(os.environ.get("AGENT_SYNC", str(HOME / "workbuddy-agent-os" / "agent-sync")))
|
||||
AGENT_LOCAL = Path(os.environ.get("AGENT_LOCAL", str(HOME / "workbuddy-agent-os" / "agent-local")))
|
||||
|
||||
SCRIPTS_DIR = AGENT_SYNC / "05_tools" / "07_matrix" / "scripts"
|
||||
IDENTITIES_ROOT = AGENT_LOCAL / "tools" / "matrix" / "identities"
|
||||
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
# 本机主机名
|
||||
_HN_FILE = AGENT_LOCAL / "identity" / "cached_hostname"
|
||||
if _HN_FILE.exists():
|
||||
HOSTNAME = _HN_FILE.read_text().strip()
|
||||
else:
|
||||
import socket
|
||||
HOSTNAME = socket.gethostname()
|
||||
if not HOSTNAME:
|
||||
HOSTNAME = "unknown"
|
||||
|
||||
SMS_YAML = SCRIPTS_DIR / "config" / "sms.yaml"
|
||||
|
||||
# 预导入账号管理
|
||||
try:
|
||||
from matrix_mgmt import MatrixManager
|
||||
_matrix_mgmt_ok = True
|
||||
except Exception as e:
|
||||
_matrix_mgmt_ok = False
|
||||
_matrix_mgmt_err = str(e)
|
||||
|
||||
|
||||
class SMSManager:
|
||||
"""短信API管理"""
|
||||
|
||||
@staticmethod
|
||||
def get_config() -> dict:
|
||||
"""读取当前SMS配置"""
|
||||
try:
|
||||
import yaml
|
||||
if SMS_YAML.exists():
|
||||
data = yaml.safe_load(SMS_YAML.read_text())
|
||||
return data.get("sms", {})
|
||||
except:
|
||||
pass
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def update_config(phone: str = None, api_key: str = None, base_url: str = None) -> dict:
|
||||
"""更新SMS配置"""
|
||||
try:
|
||||
import yaml
|
||||
cfg = {}
|
||||
if SMS_YAML.exists():
|
||||
cfg = yaml.safe_load(SMS_YAML.read_text()) or {}
|
||||
if "sms" not in cfg:
|
||||
cfg["sms"] = {}
|
||||
|
||||
if phone: cfg["sms"]["phone"] = phone
|
||||
if api_key: cfg["sms"]["api_key"] = api_key
|
||||
if base_url: cfg["sms"]["base_url"] = base_url
|
||||
|
||||
SMS_YAML.write_text(yaml.dump(cfg, default_flow_style=False, allow_unicode=True))
|
||||
return {"status": "ok", "config": cfg["sms"]}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@staticmethod
|
||||
def test_receive(phone: str, timeout: int = 15) -> dict:
|
||||
"""测试指定手机号的短信接收"""
|
||||
from urllib.request import Request, urlopen
|
||||
import time
|
||||
cfg = SMSManager.get_config()
|
||||
api_key = cfg.get("api_key", "gtmsg2026")
|
||||
base_url = cfg.get("base_url", "https://wx.tyhtak.com/api/biz/msg/messages")
|
||||
|
||||
url = f"{base_url}?api_key={api_key}&receiver_phone={phone}&page=1&per_page=10"
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "curl/7.0"})
|
||||
resp = urlopen(req, timeout=10)
|
||||
data = json.loads(resp.read().decode())
|
||||
total = data.get("data", {}).get("total", 0)
|
||||
items = data.get("data", {}).get("items", [])
|
||||
latest = []
|
||||
for item in items[:5]:
|
||||
import re
|
||||
c = item.get("content", "")
|
||||
code = re.search(r"验证码[::,,]\s*(\d{4,6})", c)
|
||||
latest.append({
|
||||
"id": item.get("id"),
|
||||
"content": c[:80],
|
||||
"code": code.group(1) if code else None,
|
||||
"time": item.get("created_at", ""),
|
||||
})
|
||||
return {
|
||||
"status": "ok",
|
||||
"phone": phone,
|
||||
"total": total,
|
||||
"messages": latest,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
class ProxyManager:
|
||||
"""代理管理"""
|
||||
|
||||
@staticmethod
|
||||
def get_all_proxies() -> list[dict]:
|
||||
"""获取所有账号的代理配置"""
|
||||
try:
|
||||
from matrix_mgmt import MatrixManager
|
||||
mgr = MatrixManager()
|
||||
accounts = mgr.list_accounts()
|
||||
except Exception as e:
|
||||
return [{"error": str(e)}]
|
||||
|
||||
results = []
|
||||
for a in accounts:
|
||||
if not a.get("is_local"):
|
||||
continue
|
||||
aid = a["id"]
|
||||
identity_dir = IDENTITIES_ROOT / aid
|
||||
config_path = identity_dir / "config.yaml"
|
||||
proxy = None
|
||||
if config_path.exists():
|
||||
try:
|
||||
import yaml
|
||||
data = yaml.safe_load(config_path.read_text())
|
||||
proxy = data.get("identity", {}).get("proxy", None)
|
||||
except:
|
||||
pass
|
||||
results.append({
|
||||
"account": aid,
|
||||
"platform": a.get("platform", ""),
|
||||
"proxy": proxy,
|
||||
"has_proxy": proxy is not None and str(proxy).strip() not in ("null", "None", ""),
|
||||
"login_status": a.get("_status", "unknown"),
|
||||
})
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def set_proxy(account_id: str, proxy: str) -> dict:
|
||||
"""设置账号代理"""
|
||||
identity_dir = IDENTITIES_ROOT / account_id
|
||||
config_path = identity_dir / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return {"status": "error", "error": f"账号 {account_id} 身份目录不存在"}
|
||||
|
||||
try:
|
||||
import yaml
|
||||
data = yaml.safe_load(config_path.read_text()) or {}
|
||||
if "identity" not in data:
|
||||
data["identity"] = {}
|
||||
data["identity"]["proxy"] = proxy if proxy.strip() else None
|
||||
config_path.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True))
|
||||
return {"status": "ok", "account": account_id, "proxy": proxy}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@staticmethod
|
||||
def test_proxy(proxy: str) -> dict:
|
||||
"""测试代理连通性"""
|
||||
import urllib.request
|
||||
try:
|
||||
start = datetime.now()
|
||||
if proxy and str(proxy).strip() not in ("null", "None", ""):
|
||||
handler = urllib.request.ProxyHandler({"http": proxy, "https": proxy})
|
||||
opener = urllib.request.build_opener(handler)
|
||||
resp = opener.open("http://httpbin.org/ip", timeout=10)
|
||||
data = json.loads(resp.read().decode())
|
||||
elapsed = (datetime.now() - start).total_seconds()
|
||||
# 提取代理类型
|
||||
proxy_type = "HTTP"
|
||||
if proxy.startswith("socks5://") or proxy.startswith("socks://"):
|
||||
proxy_type = "SOCKS5"
|
||||
elif proxy.startswith("socks4://"):
|
||||
proxy_type = "SOCKS4"
|
||||
elif proxy.startswith("https://"):
|
||||
proxy_type = "HTTPS"
|
||||
return {
|
||||
"status": "ok",
|
||||
"ip": data.get("origin", ""),
|
||||
"type": proxy_type,
|
||||
"elapsed": f"{elapsed:.1f}s",
|
||||
"speed": "快" if elapsed < 0.5 else ("中" if elapsed < 2 else "慢"),
|
||||
}
|
||||
else:
|
||||
# 直连测试
|
||||
resp = urllib.request.urlopen("http://httpbin.org/ip", timeout=10)
|
||||
data = json.loads(resp.read().decode())
|
||||
elapsed = (datetime.now() - start).total_seconds()
|
||||
return {"status": "ok", "ip": data.get("origin", ""), "elapsed": f"{elapsed:.1f}s", "direct": True}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
# ═══ FastAPI 路由 ═══
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
router = APIRouter(prefix="/api/matrix", tags=["matrix"])
|
||||
_sms = SMSManager()
|
||||
_proxy = ProxyManager()
|
||||
|
||||
|
||||
@router.get("/sms/config")
|
||||
def api_sms_config():
|
||||
return _sms.get_config()
|
||||
|
||||
|
||||
@router.put("/sms/config")
|
||||
def api_sms_update(data: dict):
|
||||
return _sms.update_config(
|
||||
phone=data.get("phone"),
|
||||
api_key=data.get("api_key"),
|
||||
base_url=data.get("base_url"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sms/test/{phone}")
|
||||
def api_sms_test(phone: str, timeout: int = 15):
|
||||
return _sms.test_receive(phone, timeout)
|
||||
|
||||
|
||||
@router.get("/proxies")
|
||||
def api_proxies():
|
||||
return {"proxies": _proxy.get_all_proxies()}
|
||||
|
||||
|
||||
@router.put("/proxies/{account_id}")
|
||||
def api_proxy_set(account_id: str, data: dict):
|
||||
return _proxy.set_proxy(account_id, data.get("proxy", ""))
|
||||
|
||||
|
||||
@router.post("/proxies/test")
|
||||
def api_proxy_test(data: dict):
|
||||
return _proxy.test_proxy(data.get("proxy", ""))
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# SMS 账号管理 & 注册 API
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _account_status(a: dict) -> dict:
|
||||
"""四维状态检测:identity / cookie / profile / registry"""
|
||||
aid = a["id"]
|
||||
# ① 有身份目录(使用 identity_dir 字段,兼容共享身份)
|
||||
ident_name = a.get("identity_dir") or a.get("identity_hint") or aid
|
||||
identity_dir = IDENTITIES_ROOT / ident_name.replace("identities/", "")
|
||||
has_identity = identity_dir.exists()
|
||||
# ② 有 Cookie(已登录)
|
||||
has_cookie = False
|
||||
if has_identity:
|
||||
ck = identity_dir / "user_data" / "cookies.sqlite"
|
||||
if ck.exists() and ck.stat().st_size > 100:
|
||||
try:
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(str(ck), timeout=1)
|
||||
cnt = conn.execute("SELECT count(*) FROM moz_cookies WHERE name LIKE '%session%'").fetchone()[0]
|
||||
conn.close()
|
||||
has_cookie = cnt > 0
|
||||
except:
|
||||
pass
|
||||
# ③ 有昵称(已采集 profile)
|
||||
has_profile = False
|
||||
profiles_path = AGENT_LOCAL / "tools" / "matrix" / "data" / "profiles.json"
|
||||
if profiles_path.exists():
|
||||
try:
|
||||
profiles = json.loads(profiles_path.read_text())
|
||||
p = profiles.get(aid, {})
|
||||
nick = p.get("nickname", "")
|
||||
if nick and nick != aid:
|
||||
has_profile = True
|
||||
except: pass
|
||||
# 也检查 homepage_info.json(新采集系统)
|
||||
if not has_profile:
|
||||
hp_path = AGENT_LOCAL / "tools" / "matrix" / "data" / "homepage_info.json"
|
||||
if hp_path.exists():
|
||||
try:
|
||||
hp_data = json.loads(hp_path.read_text())
|
||||
phone = a.get("phone", "")
|
||||
for r in hp_data.get("results", []):
|
||||
if r.get("phone") != phone:
|
||||
continue
|
||||
plat = a.get("platform", "")
|
||||
pdata = r.get(plat, {})
|
||||
if pdata and pdata.get("nickname") and pdata["nickname"] != aid:
|
||||
has_profile = True
|
||||
break
|
||||
except: pass
|
||||
# ④ 在 registry 中有记录(已同步到联邦)
|
||||
has_registry = False
|
||||
reg_path = AGENT_SYNC / "05_tools" / "07_matrix" / "accounts_registry.yaml"
|
||||
if reg_path.exists():
|
||||
try:
|
||||
import yaml
|
||||
reg = yaml.safe_load(reg_path.read_text()) or {"accounts": []}
|
||||
for ra in reg.get("accounts", []):
|
||||
if ra.get("id") == aid:
|
||||
has_registry = True
|
||||
break
|
||||
except: pass
|
||||
return {"has_identity": has_identity, "has_cookie": has_cookie,
|
||||
"has_profile": has_profile, "has_registry": has_registry,
|
||||
"busy": False, "busy_since": ""}
|
||||
|
||||
|
||||
@router.get("/sms/accounts")
|
||||
def api_sms_accounts():
|
||||
"""返回所有本机账号(含手机号和昵称),供前端下拉选择"""
|
||||
if not _matrix_mgmt_ok:
|
||||
return {"error": f"账号模块未加载: {_matrix_mgmt_err}"}
|
||||
try:
|
||||
mgr = MatrixManager()
|
||||
accounts = mgr.list_accounts()
|
||||
profiles_path = AGENT_LOCAL / "tools" / "matrix" / "data" / "profiles.json"
|
||||
profiles = {}
|
||||
if profiles_path.exists():
|
||||
try:
|
||||
profiles = json.loads(profiles_path.read_text())
|
||||
except: pass
|
||||
|
||||
# ══════════════════════════════════════════════════
|
||||
# 读 homepage_info.json(新采集系统的数据,按 phone 匹配)
|
||||
# ══════════════════════════════════════════════════
|
||||
hp_path = AGENT_LOCAL / "tools" / "matrix" / "data" / "homepage_info.json"
|
||||
hp_map = {} # phone → {douyin: {}, xiaohongshu: {}}
|
||||
if hp_path.exists():
|
||||
try:
|
||||
hp_data = json.loads(hp_path.read_text())
|
||||
for r in hp_data.get("results", []):
|
||||
phone = r.get("phone", "")
|
||||
if phone:
|
||||
hp_map[phone] = {
|
||||
"douyin": r.get("douyin") or {},
|
||||
"xiaohongshu": r.get("xiaohongshu") or {},
|
||||
}
|
||||
except: pass
|
||||
|
||||
result = []
|
||||
for a in accounts:
|
||||
aid = a["id"]
|
||||
p = profiles.get(aid, {})
|
||||
# 平台校验:profiles.json 中的 platform 必须和账号实际 platform 一致
|
||||
acct_plat = a.get("platform", "")
|
||||
profile_plat = p.get("platform", "")
|
||||
if profile_plat and profile_plat != acct_plat:
|
||||
p = {} # 平台不匹配,忽略此 profile
|
||||
|
||||
# 优先用 homepage_info.json 的数据(新采集系统)
|
||||
phone = a.get("phone", "")
|
||||
hp_entry = hp_map.get(phone, {})
|
||||
hp_plat = hp_entry.get(acct_plat, {})
|
||||
hp_nick = hp_plat.get("nickname", "")
|
||||
# 过滤无效昵称
|
||||
if hp_nick and ("登录后" in hp_nick or "问点点" in hp_nick or "推荐" in hp_nick):
|
||||
hp_nick = ""
|
||||
|
||||
nick = hp_nick or p.get("nickname") or a.get("display_name") or aid
|
||||
fans = hp_plat.get("fans") or p.get("fans", "?")
|
||||
if fans == "" or fans is None:
|
||||
fans = p.get("fans", "?")
|
||||
posts = hp_plat.get("posts") or p.get("posts", "?")
|
||||
if posts == "" or posts is None:
|
||||
posts = p.get("posts", "?")
|
||||
following = hp_plat.get("following") or p.get("following", "?")
|
||||
if following == "" or following is None:
|
||||
following = p.get("following", "?")
|
||||
|
||||
st = _account_status(a)
|
||||
|
||||
# 读取指纹信息
|
||||
fingerprint = {}
|
||||
ident_name = a.get("identity_dir") or a.get("identity_hint") or aid
|
||||
cfg_path = IDENTITIES_ROOT / ident_name.replace("identities/", "") / "config.yaml"
|
||||
if cfg_path.exists():
|
||||
try:
|
||||
import yaml
|
||||
cfg = yaml.safe_load(cfg_path.read_text())
|
||||
fingerprint = cfg.get("fingerprint_summary", {}) or {}
|
||||
except: pass
|
||||
|
||||
# 检查账号忙碌状态
|
||||
busy = st.get("busy", False)
|
||||
busy_since = st.get("busy_since", "")
|
||||
|
||||
result.append({
|
||||
"id": aid, "phone": phone,
|
||||
"nickname": nick, "platform": a.get("platform", ""),
|
||||
"is_local": a.get("is_local", False),
|
||||
"owner_machine": a.get("owner_machine") or a.get("_source_machine_name", ""),
|
||||
"has_identity": st["has_identity"],
|
||||
"has_cookie": st["has_cookie"],
|
||||
"has_profile": st["has_profile"],
|
||||
"has_registry": st["has_registry"],
|
||||
"fans": fans,
|
||||
"posts": posts,
|
||||
"following": following,
|
||||
"likes": hp_plat.get("likes") or p.get("likes", "?"),
|
||||
"identity_dir": a.get("identity_dir", ""),
|
||||
"fingerprint": fingerprint,
|
||||
"busy": busy,
|
||||
"busy_since": busy_since,
|
||||
})
|
||||
return {"accounts": result}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@router.get("/sms/test/{phone_or_account}")
|
||||
def api_sms_test_ext(phone_or_account: str, timeout: int = 15):
|
||||
"""查短信(支持手机号或账号ID)"""
|
||||
if phone_or_account and len(phone_or_account) < 11 and _matrix_mgmt_ok:
|
||||
try:
|
||||
mgr = MatrixManager()
|
||||
for a in mgr.list_accounts():
|
||||
if a["id"] == phone_or_account and a.get("phone"):
|
||||
return _sms.test_receive(a["phone"], timeout)
|
||||
except: pass
|
||||
return _sms.test_receive(phone_or_account, timeout)
|
||||
|
||||
|
||||
@router.post("/accounts/register")
|
||||
async def api_account_register(request: Request):
|
||||
"""注册新账号:创建身份目录→写入accounts.yaml→打开浏览器登录"""
|
||||
data = await request.json() if request.headers.get("content-type") == "application/json" else {}
|
||||
plat = data.get("platform", "douyin")
|
||||
phone = data.get("phone", "").strip()
|
||||
nick = data.get("nickname", "").strip()
|
||||
if plat not in ("douyin", "xiaohongshu"):
|
||||
return {"status": "error", "error": "平台只支持 douyin / xiaohongshu"}
|
||||
if not phone:
|
||||
return {"status": "error", "error": "手机号不能为空"}
|
||||
# 生成 account_id
|
||||
prefix = {"douyin": "douyin_", "xiaohongshu": "xhs_"}.get(plat, "acct_")
|
||||
try:
|
||||
mgr = MatrixManager()
|
||||
existing = [a["id"] for a in mgr.list_accounts() if a["id"].startswith(prefix)]
|
||||
nums = [int(a.replace(prefix, "")) for a in existing if a.replace(prefix, "").isdigit()]
|
||||
n = max(nums) + 1 if nums else 1
|
||||
except: n = 1
|
||||
acct_id = f"{prefix}{n}"
|
||||
try:
|
||||
# 1. 判断身份目录:同手机号是否已有 identity
|
||||
import yaml
|
||||
LOCAL_ACCT_YAML = AGENT_LOCAL / "tools" / "matrix" / "config" / "accounts.yaml"
|
||||
existing_accts = []
|
||||
if LOCAL_ACCT_YAML.exists():
|
||||
raw = yaml.safe_load(LOCAL_ACCT_YAML.read_text()) or {}
|
||||
existing_accts = raw.get("accounts", []) if isinstance(raw, dict) else (raw if isinstance(raw, list) else [])
|
||||
|
||||
# 查找同手机号的现有 identity_dir
|
||||
shared_identity_name = None
|
||||
for ea in existing_accts:
|
||||
if isinstance(ea, dict) and ea.get("phone") == phone:
|
||||
ed = ea.get("identity_dir") or ea.get("identity_hint") or ""
|
||||
if ed:
|
||||
shared_identity_name = ed.replace("identities/", "")
|
||||
break
|
||||
|
||||
# 冲突检测:同手机号+同平台
|
||||
conflict = [ea for ea in existing_accts
|
||||
if isinstance(ea, dict) and ea.get("phone") == phone
|
||||
and ea.get("platform") == plat]
|
||||
if conflict:
|
||||
return {"status": "error", "error": f"手机号 {phone} 已有 {plat} 账号: {conflict[0]['id']}。如需重新注册,请先删除旧账号。"}
|
||||
|
||||
if shared_identity_name:
|
||||
# 复用已有身份目录
|
||||
identity_name = shared_identity_name
|
||||
identity_dir = IDENTITIES_ROOT / identity_name
|
||||
# 确保目录存在
|
||||
identity_dir.mkdir(parents=True, exist_ok=True)
|
||||
(identity_dir / "user_data").mkdir(exist_ok=True)
|
||||
else:
|
||||
# 新建身份目录,以 phone_ 开头
|
||||
identity_name = f"phone_{phone}"
|
||||
identity_dir = IDENTITIES_ROOT / identity_name
|
||||
identity_dir.mkdir(parents=True, exist_ok=True)
|
||||
(identity_dir / "user_data").mkdir(exist_ok=True)
|
||||
config = {
|
||||
"fingerprint_summary": {"platform": "Win32", "screen": "1920x1080"},
|
||||
"identity": {"name": identity_name, "platform": plat, "created_at": datetime.now().isoformat()},
|
||||
"window": [802, 783],
|
||||
}
|
||||
(identity_dir / "config.yaml").write_text(yaml.dump(config, allow_unicode=True, default_flow_style=False))
|
||||
|
||||
# 2. 写入 accounts.yaml
|
||||
accts = [a for a in existing_accts if isinstance(a, dict) and a.get("id") != acct_id]
|
||||
accts.append({
|
||||
"id": acct_id, "platform": plat, "phone": phone,
|
||||
"display_name": nick or acct_id, "enabled": True,
|
||||
"browser_type": "camoufox", "profile_dir": identity_name,
|
||||
"identity_dir": identity_name,
|
||||
})
|
||||
LOCAL_ACCT_YAML.write_text(yaml.dump({"accounts": accts}, allow_unicode=True, default_flow_style=False))
|
||||
# 3. 同步到 accounts_registry.yaml
|
||||
registry_path = AGENT_SYNC / "05_tools" / "07_matrix" / "accounts_registry.yaml"
|
||||
try:
|
||||
reg = yaml.safe_load(registry_path.read_text()) or {"accounts": []}
|
||||
reg["accounts"] = [a for a in reg["accounts"] if a.get("id") != acct_id]
|
||||
reg["accounts"].append({
|
||||
"id": acct_id, "platform": plat, "phone": phone,
|
||||
"display_name": nick or acct_id, "assigned_machine": HOSTNAME or "unknown",
|
||||
})
|
||||
registry_path.write_text(yaml.dump(reg, allow_unicode=True, default_flow_style=False))
|
||||
except: pass
|
||||
# 4. 启动浏览器登录
|
||||
agent_python = str(HOME / ".workbuddy" / "binaries" / "python" / "envs" / "matrix" / "bin" / "python3")
|
||||
cmd = [agent_python, "-m", "mc", "account", "login", acct_id]
|
||||
import subprocess
|
||||
subprocess.Popen(cmd, cwd=str(SCRIPTS_DIR), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return {"status": "ok", "account_id": acct_id, "message": f"账号 {acct_id} 已创建,浏览器已打开请登录",
|
||||
"next": "登录成功后可以在 Dashboard 采集昵称"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/login")
|
||||
def api_account_login(account_id: str):
|
||||
"""智能登录: 前置检测 + 自动平台路由 + 全自动登录"""
|
||||
agent_python = str(HOME / ".workbuddy" / "binaries" / "python" / "envs" / "agent-os" / "bin" / "python3")
|
||||
import subprocess
|
||||
try:
|
||||
# 使用 smart-login: 自动检测平台 + 前置检测 + 全自动
|
||||
p = subprocess.Popen(
|
||||
[agent_python, "-m", "mc", "smart-login", account_id],
|
||||
cwd=str(SCRIPTS_DIR), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return {"status": "ok", "pid": p.pid, "message": f"智能登录已启动: {account_id}"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/collect-profile")
|
||||
def api_collect_profile(account_id: str):
|
||||
"""采集账号昵称信息(根据平台选择正确蓝图)"""
|
||||
# 从账号注册表获取平台
|
||||
platform = "douyin"
|
||||
try:
|
||||
from matrix_mgmt import MatrixManager
|
||||
mgr = MatrixManager()
|
||||
for a in mgr.list_accounts():
|
||||
if a["id"] == account_id:
|
||||
p = a.get("platform", "douyin")
|
||||
platform = p if p in ("douyin", "xiaohongshu") else "douyin"
|
||||
break
|
||||
except: pass
|
||||
# 根据平台选择采集蓝图
|
||||
bp_name = "xiaohongshu_read_profile" if platform == "xiaohongshu" else "douyin_read_profile"
|
||||
agent_python = str(HOME / ".workbuddy" / "binaries" / "python" / "envs" / "agent-os" / "bin" / "python3")
|
||||
import subprocess
|
||||
try:
|
||||
p = subprocess.Popen(
|
||||
[agent_python, "-m", "mc", "run", "--accounts", account_id,
|
||||
"--blueprints", bp_name, "--rounds", "1"],
|
||||
cwd=str(SCRIPTS_DIR), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return {"status": "ok", "pid": p.pid, "message": f"正在采集 {account_id} 的昵称信息({bp_name})"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 定时任务 & 人设 API
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
SCHEDULE_YAML = SCRIPTS_DIR / "config" / "schedule.yaml"
|
||||
|
||||
|
||||
@router.get("/schedules")
|
||||
def api_schedules():
|
||||
if not SCHEDULE_YAML.exists():
|
||||
return {"schedules": []}
|
||||
try:
|
||||
import yaml
|
||||
data = yaml.safe_load(SCHEDULE_YAML.read_text())
|
||||
scheds = data.get("schedules", {})
|
||||
result = []
|
||||
for sid, cfg in scheds.items():
|
||||
cfg["id"] = sid
|
||||
result.append(cfg)
|
||||
return {"schedules": result}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@router.get("/schedules")
|
||||
def api_schedules():
|
||||
"""返回所有定时任务"""
|
||||
from mc.scheduler import load_all_schedules
|
||||
return {"schedules": load_all_schedules()}
|
||||
|
||||
|
||||
@router.post("/schedules/toggle")
|
||||
def api_schedule_toggle(data: dict):
|
||||
"""启用/禁用定时任务"""
|
||||
sid = (data or {}).get("id", "")
|
||||
enabled = (data or {}).get("enabled", True)
|
||||
from mc.scheduler import load_all_schedules, save_all_schedules
|
||||
all_s = load_all_schedules()
|
||||
if sid in all_s:
|
||||
all_s[sid]["enabled"] = enabled
|
||||
save_all_schedules(all_s)
|
||||
return {"status": "ok"}
|
||||
return {"status": "error", "error": f"未找到任务 {sid}"}
|
||||
|
||||
|
||||
@router.post("/schedules/delete")
|
||||
def api_schedule_delete(data: dict):
|
||||
"""删除定时任务"""
|
||||
sid = (data or {}).get("id", "")
|
||||
from mc.scheduler import load_all_schedules, save_all_schedules
|
||||
all_s = load_all_schedules()
|
||||
if sid in all_s:
|
||||
del all_s[sid]
|
||||
save_all_schedules(all_s)
|
||||
return {"status": "ok"}
|
||||
return {"status": "error", "error": f"未找到任务 {sid}"}
|
||||
|
||||
|
||||
@router.get("/schedules/history")
|
||||
def api_schedule_history():
|
||||
"""返回最近20条执行历史"""
|
||||
from mc.scheduler import read_history
|
||||
return {"history": read_history(limit=20)}
|
||||
|
||||
|
||||
@router.get("/corpus/personas")
|
||||
def api_corpus_personas():
|
||||
"""列出语料库中的身份(v2格式)"""
|
||||
from mc.corpus import CorpusManager
|
||||
cm = CorpusManager()
|
||||
return {"personas": cm.list_personas()}
|
||||
|
||||
|
||||
@router.get("/corpus/scenes")
|
||||
def api_corpus_scenes(persona: str = ""):
|
||||
"""列出语料库中的场景(v2格式)"""
|
||||
from mc.corpus import CorpusManager
|
||||
cm = CorpusManager()
|
||||
return {"scenes": cm.list_scenes(persona) if not persona else cm.list_scenes(persona)}
|
||||
|
||||
|
||||
@router.get("/personas")
|
||||
def api_personas():
|
||||
pf = AGENT_LOCAL / "tools" / "matrix" / "data" / "profiles.json"
|
||||
if not pf.exists():
|
||||
return {"personas": {}}
|
||||
try:
|
||||
data = json.loads(pf.read_text())
|
||||
personas = {}
|
||||
for aid, info in data.items():
|
||||
if "persona" in info:
|
||||
personas[aid] = info["persona"]
|
||||
return {"personas": personas}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 任务执行 API
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
@router.post("/task/run")
|
||||
async def api_task_run(request: Request):
|
||||
"""从 Dashboard 执行智能任务"""
|
||||
data = await request.json() if request.headers.get("content-type") == "application/json" else {}
|
||||
task_type = data.get("type", "comment")
|
||||
url = data.get("url", "")
|
||||
keyword = data.get("keyword", "")
|
||||
direction = data.get("direction", "")
|
||||
account = data.get("account", "")
|
||||
comment_text = data.get("comment", "")
|
||||
from mc.task import Task, run_task
|
||||
task = Task(type=task_type, url=url, keyword=keyword,
|
||||
direction=direction, account=account, comment_text=comment_text)
|
||||
errors = task.validate()
|
||||
if errors:
|
||||
return {"status": "error", "errors": errors}
|
||||
task.auto_fill()
|
||||
if not task.account:
|
||||
return {"status": "error", "errors": [f"没有可用的{task.platform}账号"]}
|
||||
result = await run_task(task)
|
||||
return {"status": "ok" if result.get("status") != "error" else "error",
|
||||
"success": result.get("success", 0),
|
||||
"failed": result.get("failed", 0),
|
||||
"total": result.get("total_steps", 0),
|
||||
"duration": result.get("duration", 0),
|
||||
"task_summary": task.summary(),
|
||||
"detail": result.get("error", "")}
|
||||
|
||||
|
||||
# [删除账号使用 app.py 中已有的 DELETE /api/matrix/accounts/{id}]
|
||||
# 前端调用时加 ?delete_identity=true
|
||||
# 但 app.py 的 delete 有 bug(不写回 accounts.yaml),前端需额外调用此 cleanup
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/cleanup")
|
||||
def api_account_cleanup(account_id: str):
|
||||
"""删除后被 app.py 的 bug 遗漏时,补清理 accounts.yaml 和 registry"""
|
||||
try:
|
||||
import yaml
|
||||
changed = False
|
||||
deleted_identity = False
|
||||
# 清理 accounts.yaml
|
||||
LOCAL_ACCT = AGENT_LOCAL / "tools" / "matrix" / "config" / "accounts.yaml"
|
||||
if LOCAL_ACCT.exists():
|
||||
raw = yaml.safe_load(LOCAL_ACCT.read_text()) or {}
|
||||
accts = raw.get("accounts", []) if isinstance(raw, dict) else (raw if isinstance(raw, list) else [])
|
||||
# 先记录这个账号的 identity_dir
|
||||
deleted_identity_dir = None
|
||||
for a in accts:
|
||||
if isinstance(a, dict) and a.get("id") == account_id:
|
||||
deleted_identity_dir = a.get("identity_dir") or a.get("identity_hint") or ""
|
||||
break
|
||||
# 从配置中移除
|
||||
before = len(accts)
|
||||
accts = [a for a in accts if isinstance(a, dict) and a.get("id") != account_id]
|
||||
if len(accts) != before:
|
||||
data = {"accounts": accts}
|
||||
LOCAL_ACCT.write_text(yaml.dump(data, allow_unicode=True, default_flow_style=False))
|
||||
changed = True
|
||||
# 删除身份目录(仅当没有其他账号使用同一 identity_dir)
|
||||
if deleted_identity_dir:
|
||||
still_in_use = any(
|
||||
isinstance(a, dict) and (a.get("identity_dir") or a.get("identity_hint") or "") == deleted_identity_dir
|
||||
for a in accts
|
||||
)
|
||||
if not still_in_use:
|
||||
import shutil
|
||||
ident_path = IDENTITIES_ROOT / deleted_identity_dir.replace("identities/", "")
|
||||
if ident_path.exists():
|
||||
shutil.rmtree(ident_path)
|
||||
deleted_identity = True
|
||||
# 清理 registry
|
||||
reg_path = AGENT_SYNC / "05_tools" / "07_matrix" / "accounts_registry.yaml"
|
||||
if reg_path.exists():
|
||||
reg = yaml.safe_load(reg_path.read_text()) or {"accounts": []}
|
||||
before = len(reg["accounts"])
|
||||
reg["accounts"] = [a for a in reg["accounts"] if a.get("id") != account_id]
|
||||
if len(reg["accounts"]) != before:
|
||||
reg_path.write_text(yaml.dump(reg, allow_unicode=True, default_flow_style=False))
|
||||
changed = True
|
||||
return {"status": "ok", "cleaned": changed, "identity_deleted": deleted_identity}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 清除登录状态 API
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
@router.post("/accounts/{account_id}/clear-cookies")
|
||||
def api_clear_cookies(account_id: str):
|
||||
"""清除指定账号的登录状态(只清该平台 cookie,不影响同身份的其他平台)"""
|
||||
try:
|
||||
from matrix_mgmt import MatrixManager
|
||||
mgr = MatrixManager()
|
||||
acct = None
|
||||
for a in mgr.list_accounts():
|
||||
if a["id"] == account_id:
|
||||
acct = a
|
||||
break
|
||||
if not acct:
|
||||
return {"status": "error", "error": "账号不存在"}
|
||||
|
||||
identity_dir = acct.get("identity_dir", account_id).replace("identities/", "")
|
||||
plat = acct.get("platform", "douyin")
|
||||
# 平台对应的域名模式
|
||||
domain_pattern = "%douyin%" if plat == "douyin" else "%xiaohongshu%"
|
||||
|
||||
cookie_path = IDENTITIES_ROOT / identity_dir / "user_data" / "cookies.sqlite"
|
||||
if not cookie_path.exists():
|
||||
return {"status": "ok", "message": f"{plat} 无 cookie 可清除"}
|
||||
|
||||
try:
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(str(cookie_path), timeout=3)
|
||||
cur = conn.execute("DELETE FROM moz_cookies WHERE host LIKE ?", (domain_pattern,))
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "ok", "message": f"已清除 {plat} 的 {deleted} 个 cookie", "deleted": deleted}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": f"清除失败: {e}"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 录制管理 API
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
RECORDINGS_DIR = AGENT_LOCAL / "tools" / "matrix" / "recordings"
|
||||
_recording_process = None
|
||||
|
||||
|
||||
@router.get("/recordings/stats")
|
||||
def api_recording_stats():
|
||||
"""按账号统计录制包数量"""
|
||||
try:
|
||||
from mc.recorder import RecordingSession
|
||||
recordings = RecordingSession.list_recordings()
|
||||
stats = {}
|
||||
for r in recordings:
|
||||
acct = r.get("account", "unknown")
|
||||
stats[acct] = stats.get(acct, 0) + 1
|
||||
return {"stats": stats}
|
||||
except Exception as e:
|
||||
return {"stats": {}, "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/recordings/start")
|
||||
def api_recording_start(data: dict = None):
|
||||
"""启动录制"""
|
||||
global _recording_process
|
||||
account = (data or {}).get("account", "douyin_test")
|
||||
platform = "douyin"
|
||||
try:
|
||||
from matrix_mgmt import MatrixManager
|
||||
mgr = MatrixManager()
|
||||
for a in mgr.list_accounts():
|
||||
if a["id"] == account:
|
||||
p = a.get("platform", "douyin")
|
||||
platform = {"douyin": "douyin", "xiaohongshu": "xiaohongshu"}.get(p, "douyin")
|
||||
break
|
||||
except: pass
|
||||
agent_python = str(HOME / ".workbuddy" / "binaries" / "python" / "envs" / "matrix" / "bin" / "python3")
|
||||
cmd = [agent_python, "-m", "mc.recorder", account, platform]
|
||||
import subprocess
|
||||
try:
|
||||
p = subprocess.Popen(cmd, cwd=str(SCRIPTS_DIR), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
import time; time.sleep(2)
|
||||
if p.poll() is not None:
|
||||
stderr = p.stderr.read().decode()[:200] if p.stderr else ""
|
||||
return {"status": "error", "error": f"录制进程退出 (code={p.returncode})", "detail": stderr}
|
||||
_recording_process = {"pid": p.pid, "account": account}
|
||||
return {"status": "ok", "pid": p.pid, "account": account, "message": "录制已启动"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/recordings/stop")
|
||||
def api_recording_stop():
|
||||
"""停止录制"""
|
||||
global _recording_process
|
||||
if _recording_process:
|
||||
import os, signal
|
||||
try:
|
||||
os.kill(_recording_process["pid"], signal.SIGTERM)
|
||||
import time; time.sleep(1)
|
||||
except: pass
|
||||
_recording_process = None
|
||||
return {"status": "ok", "message": "录制已停止"}
|
||||
return {"status": "ok", "message": "无运行中的录制"}
|
||||
|
||||
|
||||
@router.get("/recordings/status")
|
||||
def api_recording_status():
|
||||
"""查看录制状态"""
|
||||
global _recording_process
|
||||
if _recording_process:
|
||||
import os
|
||||
try:
|
||||
os.kill(_recording_process["pid"], 0)
|
||||
return {"status": "running", "pid": _recording_process["pid"], "account": _recording_process["account"]}
|
||||
except: pass
|
||||
_recording_process = None
|
||||
return {"status": "idle"}
|
||||
|
||||
|
||||
@router.get("/recordings")
|
||||
def api_recordings_list():
|
||||
"""列出录制包"""
|
||||
from mc.recorder import RecordingSession
|
||||
return {"recordings": RecordingSession.list_recordings()}
|
||||
|
||||
|
||||
@router.get("/recordings/{name}")
|
||||
def api_recording_analyze(name: str):
|
||||
"""分析录制包"""
|
||||
from mc.analyzer import analyze_recording_file
|
||||
p = RECORDINGS_DIR / name
|
||||
if p.exists():
|
||||
return analyze_recording_file(str(p))
|
||||
return {"error": f"录制包不存在: {name}"}
|
||||
|
||||
|
||||
@router.post("/recordings/{name}/export")
|
||||
def api_recording_export(name: str):
|
||||
"""导出录制包"""
|
||||
from mc.exporter import export_recording
|
||||
p = RECORDINGS_DIR / name
|
||||
if p.exists():
|
||||
return export_recording(str(p))
|
||||
return {"error": f"录制包不存在: {name}"}
|
||||
|
||||
|
||||
@router.delete("/recordings/{name}")
|
||||
def api_recording_delete(name: str):
|
||||
"""删除录制包"""
|
||||
from mc.recorder import RecordingSession
|
||||
ok = RecordingSession.delete_recording(name)
|
||||
return {"deleted": ok}
|
||||
@@ -0,0 +1,403 @@
|
||||
"""
|
||||
plugins/system_plugins.py — 系统级插件合集 (v2)
|
||||
包含: Collector / Skills / Knowledge / Automation / Tools / System
|
||||
版本: 2.0.0 | 更新: 2026-05-18
|
||||
"""
|
||||
from pathlib import Path
|
||||
import json
|
||||
from plugins.base import DashboardPlugin, AGENT_SYNC, AGENT_LOCAL, CROSS_MACHINE, HOSTNAME, MACHINE_UID
|
||||
from plugins._registry import get_plugin_data
|
||||
|
||||
|
||||
class CollectorPlugin(DashboardPlugin):
|
||||
name = "collector"
|
||||
label = "内容采集"
|
||||
icon = "📡"
|
||||
version = "2.0.0"
|
||||
description = "采集工具 / 收件箱 / 使用统计"
|
||||
order = 40
|
||||
|
||||
MANUAL_DESC = {
|
||||
"doubao_driver": "豆包浏览器驱动 — 抖音视频文字提取/分析",
|
||||
"analyze": "AI分析 — 口播素材内容解析与分类",
|
||||
"collect": "搜索采集 — OpenCLI桥接/百度HTTP双模式",
|
||||
"app": "Web界面 — Gradio交互式素材管理中心",
|
||||
"downloader": "下载器 — 视频/音频/图片批量下载",
|
||||
"script_factory": "脚本工厂 — 自动生成采集脚本",
|
||||
"utils": "工具函数 — Cookie管理/缓存/日志",
|
||||
}
|
||||
|
||||
def _scan_tools(self):
|
||||
"""扫描现有采集工具"""
|
||||
tools_dir = AGENT_SYNC / "05_tools" / "05_crawl" / "content-inspiration"
|
||||
tools = []
|
||||
if tools_dir.exists():
|
||||
for f in sorted(tools_dir.iterdir()):
|
||||
if f.suffix == ".py" and not f.name.startswith("_"):
|
||||
desc = self.MANUAL_DESC.get(f.stem, "")
|
||||
if not desc:
|
||||
content = f.read_text(encoding="utf-8").split("\n")
|
||||
for line in content[:8]:
|
||||
s = line.strip().strip('"').strip("'")
|
||||
if s and not s.startswith("usage") and not s.startswith("用法"):
|
||||
desc = s[:60]
|
||||
break
|
||||
tools.append({"name": f.stem, "desc": desc or "采集脚本"})
|
||||
return tools
|
||||
|
||||
def _count_inbox(self):
|
||||
inbox = AGENT_SYNC / "03_knowledge" / "00_inbox"
|
||||
subs = AGENT_SYNC / "03_knowledge" / "01_submissions"
|
||||
return {
|
||||
"收件箱": len(list(inbox.rglob("*.md"))) if inbox.exists() else 0,
|
||||
"提交箱": len(list(subs.rglob("*.md"))) if subs.exists() else 0,
|
||||
}
|
||||
|
||||
def summary(self, machines):
|
||||
cnt = self._count_inbox()
|
||||
tools = self._scan_tools()
|
||||
return {
|
||||
"采集工具": len(tools),
|
||||
"待处理": cnt["收件箱"],
|
||||
"提交箱": cnt["提交箱"],
|
||||
"各机器": {HOSTNAME: {
|
||||
"工具数": len(tools),
|
||||
"待处理": cnt["收件箱"],
|
||||
}}
|
||||
}
|
||||
|
||||
def detail(self, machine=""):
|
||||
return {HOSTNAME: {
|
||||
"采集工具": self._scan_tools(),
|
||||
"收件箱统计": self._count_inbox(),
|
||||
"收件箱路径": str(AGENT_SYNC / "03_knowledge" / "00_inbox"),
|
||||
}}
|
||||
|
||||
|
||||
class SkillsPlugin(DashboardPlugin):
|
||||
name = "skills"
|
||||
label = "技能树"
|
||||
icon = "🧩"
|
||||
version = "1.0.0"
|
||||
description = "所有已安装技能的清单和版本"
|
||||
order = 50
|
||||
|
||||
def _scan_skills(self):
|
||||
skills_dir = Path.home() / ".workbuddy" / "skills"
|
||||
result = []
|
||||
if skills_dir.exists():
|
||||
for d in sorted(skills_dir.iterdir()):
|
||||
if d.is_dir():
|
||||
sk = d / "SKILL.md"
|
||||
ver = ""
|
||||
desc = ""
|
||||
if sk.exists():
|
||||
for line in sk.read_text().split("\n"):
|
||||
if line.startswith("version:"):
|
||||
ver = line.split(":")[1].strip().strip('"')
|
||||
if line.startswith("description:"):
|
||||
desc = line.split(":")[1].strip().strip('"')[:60]
|
||||
result.append({"name": d.name, "version": ver or "-", "description": desc})
|
||||
return result
|
||||
|
||||
def summary(self, machines):
|
||||
skills = self._scan_skills()
|
||||
custom = sum(1 for s in skills if s["version"] != "-")
|
||||
return {
|
||||
"总技能": len(skills),
|
||||
"自定义": custom,
|
||||
"各机器": {HOSTNAME: {"技能数": len(skills), "自定义": custom}},
|
||||
}
|
||||
|
||||
def detail(self, machine=""):
|
||||
return {HOSTNAME: {"技能列表": self._scan_skills()}}
|
||||
|
||||
|
||||
class KnowledgePlugin(DashboardPlugin):
|
||||
name = "knowledge"
|
||||
label = "知识库"
|
||||
icon = "📚"
|
||||
version = "2.0.0"
|
||||
description = "分仓统计 / 浓缩卡片 / 教科书体系"
|
||||
order = 60
|
||||
|
||||
CATEGORY_LABELS = {
|
||||
"00_inbox": "待提纯",
|
||||
"01_submissions": "提交箱",
|
||||
"10_concepts": "概念",
|
||||
"20_methods": "方法",
|
||||
"30_facts": "事实",
|
||||
"40_references": "参考",
|
||||
"50_resources": "素材",
|
||||
"60_opinions": "观点",
|
||||
"90_archive": "归档",
|
||||
"99_system": "系统",
|
||||
}
|
||||
|
||||
def _count_knowledge(self):
|
||||
kb = AGENT_SYNC / "03_knowledge"
|
||||
stats = {}
|
||||
for d in sorted(kb.iterdir()):
|
||||
if d.is_dir() and not d.name.startswith("."):
|
||||
count = len(list(d.rglob("*.md")))
|
||||
if count > 0:
|
||||
label = self.CATEGORY_LABELS.get(d.name, d.name)
|
||||
stats[d.name] = {"label": label, "count": count}
|
||||
return stats
|
||||
|
||||
def _get_textbook_info(self):
|
||||
"""AI视频教科书信息"""
|
||||
textbook_dir = AGENT_SYNC / "03_knowledge" / "20_methods"
|
||||
chapters = []
|
||||
if textbook_dir.exists():
|
||||
for f in sorted(textbook_dir.iterdir()):
|
||||
if f.suffix == ".md" and not f.name.startswith("_"):
|
||||
chapters.append(f.stem)
|
||||
ai_dir = textbook_dir / "ai-video-system"
|
||||
ai_files = 0
|
||||
if ai_dir.exists():
|
||||
ai_files = len(list(ai_dir.rglob("*.md")))
|
||||
return {
|
||||
"教材章节": len(chapters),
|
||||
"AI视频教科书": f"{len(chapters)}个文件",
|
||||
"Kling知识": ai_files,
|
||||
}
|
||||
|
||||
def summary(self, machines):
|
||||
stats = self._count_knowledge()
|
||||
total = sum(v["count"] for v in stats.values())
|
||||
textbook = self._get_textbook_info()
|
||||
return {
|
||||
"总知识": total,
|
||||
"分类": len(stats),
|
||||
"AI视频教材": textbook.get("AI视频教科书", ""),
|
||||
"各机器": {HOSTNAME: {
|
||||
"分类数": len(stats),
|
||||
"总文件": total,
|
||||
"教材章节": textbook.get("教材章节", 0),
|
||||
}}
|
||||
}
|
||||
|
||||
def detail(self, machine=""):
|
||||
stats = self._count_knowledge()
|
||||
textbook = self._get_textbook_info()
|
||||
return {HOSTNAME: {
|
||||
"分类统计": stats,
|
||||
"教科书": textbook,
|
||||
"README路径": str(AGENT_SYNC / "03_knowledge" / "README.md"),
|
||||
}}
|
||||
|
||||
|
||||
class AutomationPlugin(DashboardPlugin):
|
||||
name = "automation"
|
||||
label = "自动化任务"
|
||||
icon = "⏰"
|
||||
version = "1.0.0"
|
||||
description = "定时任务列表和执行历史"
|
||||
order = 70
|
||||
|
||||
def _read_tasks(self):
|
||||
task_dir = CROSS_MACHINE / "tasks"
|
||||
stats = {"pending": 0, "completed": 0}
|
||||
for status in ["pending", "completed"]:
|
||||
d = task_dir / status
|
||||
if d.exists():
|
||||
for f in d.iterdir():
|
||||
if f.suffix == ".json":
|
||||
stats[status] += 1
|
||||
return stats
|
||||
|
||||
def _read_pending_tasks(self):
|
||||
task_dir = CROSS_MACHINE / "tasks" / "pending"
|
||||
tasks = []
|
||||
if task_dir.exists():
|
||||
for f in sorted(task_dir.iterdir()):
|
||||
if f.suffix == ".json":
|
||||
try:
|
||||
tasks.append(json.loads(f.read_text()))
|
||||
except:
|
||||
pass
|
||||
return tasks
|
||||
|
||||
def summary(self, machines):
|
||||
stats = self._read_tasks()
|
||||
pending = self._read_pending_tasks()
|
||||
by_machine = {}
|
||||
for t in pending:
|
||||
target = t.get("target_host", "未知")
|
||||
by_machine.setdefault(target, 0)
|
||||
by_machine[target] += 1
|
||||
return {"待处理": stats.get("pending", 0), "已完成": stats.get("completed", 0), "各机器": by_machine}
|
||||
|
||||
def detail(self, machine=""):
|
||||
return {HOSTNAME: {"待处理任务": self._read_pending_tasks()}}
|
||||
|
||||
|
||||
class ToolsPlugin(DashboardPlugin):
|
||||
name = "tools"
|
||||
label = "工具集"
|
||||
icon = "🔧"
|
||||
version = "2.0.0"
|
||||
description = "05_tools/ 工具模块清单 + 说明 + 路径"
|
||||
order = 80
|
||||
|
||||
CATEGORY_MAP = {
|
||||
"00_setup": "系统设置",
|
||||
"01_system": "系统",
|
||||
"02_browser": "浏览器",
|
||||
"03_ocr": "OCR",
|
||||
"04_ai": "AI",
|
||||
"04_media": "媒体",
|
||||
"05_crawl": "采集",
|
||||
"06_mobile": "移动端",
|
||||
"07_matrix": "矩阵",
|
||||
"08_trae_agent": "Trae代理",
|
||||
"09_ave": "视频工厂",
|
||||
"10_dashboard": "看板",
|
||||
}
|
||||
|
||||
def _scan_tools(self):
|
||||
tools_dir = AGENT_SYNC / "05_tools"
|
||||
result = []
|
||||
if tools_dir.exists():
|
||||
for d in sorted(tools_dir.iterdir()):
|
||||
if d.is_dir() and not d.name.startswith("."):
|
||||
readme = d / "README.md"
|
||||
desc = ""
|
||||
if readme.exists():
|
||||
for line in readme.read_text().split("\n")[:3]:
|
||||
if line.strip() and not line.startswith("#"):
|
||||
desc = line.strip()[:60]
|
||||
break
|
||||
cat = self.CATEGORY_MAP.get(d.name, "其他")
|
||||
result.append({"name": d.name, "cat": cat, "desc": desc, "path": str(d)})
|
||||
return result
|
||||
|
||||
def summary(self, machines):
|
||||
tools = self._scan_tools()
|
||||
return {"总工具": len(tools), "各机器": {HOSTNAME: {"工具数": len(tools)}}}
|
||||
|
||||
def detail(self, machine=""):
|
||||
return {HOSTNAME: {"工具列表": self._scan_tools()}}
|
||||
|
||||
|
||||
class SystemPlugin(DashboardPlugin):
|
||||
name = "system"
|
||||
label = "系统核心"
|
||||
icon = "⚙️"
|
||||
version = "2.0.0"
|
||||
description = "AgentOS内核 / AI引擎 / 记忆系统 / 知识库"
|
||||
order = 90
|
||||
|
||||
def _get_kernel_info(self):
|
||||
import subprocess, json
|
||||
|
||||
agentos_ver = "v2.1.0"
|
||||
arch_ver = "联邦版 (2026-05)"
|
||||
llm_engine = "oMLX v0.3.6 (Apple MLX)"
|
||||
llm_models = []
|
||||
models_dir = Path.home() / ".omlx" / "models"
|
||||
if models_dir.exists():
|
||||
for m in sorted(models_dir.iterdir()):
|
||||
llm_models.append(m.name)
|
||||
|
||||
memory = {
|
||||
"架构": "四层记忆 + 自然语言知识库 双轨",
|
||||
"L0 硬约束": "SOUL.md (~3KB), 不可绕过",
|
||||
"L1 关键词索引": "keyword_index.json + ChromaDB 语义向量库",
|
||||
"L2 结构化事实": "facts.db (SQLite), 结构化三元组",
|
||||
"L3 原文": "raw/ 目录, 按需加载",
|
||||
"检索引擎": "BM25 + ChromaDB 向量 + RRF 融合",
|
||||
"向量维度": "1024 (Qwen3-Embedding-0.6B)",
|
||||
}
|
||||
|
||||
kb_dirs = AGENT_SYNC / "03_knowledge"
|
||||
kb_stats = {}
|
||||
if kb_dirs.exists():
|
||||
for d in sorted(kb_dirs.iterdir()):
|
||||
if d.is_dir() and not d.name.startswith("."):
|
||||
count = len(list(d.rglob("*.md")))
|
||||
if count > 0:
|
||||
kb_stats[d.name] = count
|
||||
|
||||
skills_dir = Path.home() / ".workbuddy" / "skills"
|
||||
skill_count = 0
|
||||
if skills_dir.exists():
|
||||
skill_count = len([d for d in skills_dir.iterdir() if d.is_dir()])
|
||||
|
||||
machine_count = 0
|
||||
try:
|
||||
r = subprocess.run(["curl","-s","http://localhost:9988/api/machines"],
|
||||
capture_output=True, text=True, timeout=3)
|
||||
if r.stdout:
|
||||
d = json.loads(r.stdout)
|
||||
machine_count = d.get("total", 0)
|
||||
except: pass
|
||||
|
||||
return {
|
||||
"AgentOS 版本": agentos_ver,
|
||||
"架构版本": arch_ver,
|
||||
"LLM引擎": llm_engine,
|
||||
"已加载模型": llm_models,
|
||||
"记忆系统": memory,
|
||||
"知识库规模": kb_stats,
|
||||
"技能总数": skill_count,
|
||||
"联邦机器数": machine_count,
|
||||
"仓库": "mac-agent-os (Gitee + GitHub 双同步)",
|
||||
"运行主机": HOSTNAME,
|
||||
}
|
||||
|
||||
def summary(self, machines):
|
||||
import subprocess, shutil
|
||||
git_ver = ""
|
||||
try:
|
||||
r = subprocess.run(["git","log","-1","--format=%h %s"], capture_output=True, text=True,
|
||||
timeout=5, cwd=str(AGENT_SYNC))
|
||||
git_ver = r.stdout.strip()
|
||||
except: pass
|
||||
disk = shutil.disk_usage(str(AGENT_SYNC))
|
||||
kernel = self._get_kernel_info()
|
||||
return {
|
||||
"各机器": {HOSTNAME: {
|
||||
"AgentOS版本": kernel.get("AgentOS 版本", "-"),
|
||||
"LLM引擎": kernel.get("LLM引擎", "-"),
|
||||
"模型数": len(kernel.get("已加载模型", [])),
|
||||
"记忆层级": "L0+L1+L2+L3",
|
||||
"技能数": kernel.get("技能总数", 0),
|
||||
"知识库": sum(kernel.get("知识库规模", {}).values()),
|
||||
"Git版本": git_ver[:25] if git_ver else "-",
|
||||
"磁盘总GB": round(disk.total / 1e9, 1),
|
||||
"磁盘可用GB": round(disk.free / 1e9, 1),
|
||||
}}
|
||||
}
|
||||
|
||||
def detail(self, machine=""):
|
||||
import subprocess, shutil, os
|
||||
git_log = []
|
||||
try:
|
||||
r = subprocess.run(["git","log","--oneline","-5"], capture_output=True, text=True,
|
||||
timeout=5, cwd=str(AGENT_SYNC))
|
||||
git_log = [l.strip() for l in r.stdout.strip().split("\n") if l]
|
||||
except: pass
|
||||
remotes = []
|
||||
try:
|
||||
r = subprocess.run(["git","remote","-v"], capture_output=True, text=True,
|
||||
timeout=5, cwd=str(AGENT_SYNC))
|
||||
remotes = [l.strip() for l in r.stdout.strip().split("\n") if l]
|
||||
except: pass
|
||||
disk = shutil.disk_usage(str(AGENT_SYNC))
|
||||
kernel = self._get_kernel_info()
|
||||
return {HOSTNAME: {
|
||||
"AI内核": kernel,
|
||||
"Git仓库": remotes,
|
||||
"最近提交": git_log,
|
||||
"磁盘": {"总": round(disk.total/1e9,1), "已用": round((disk.total-disk.free)/1e9,1), "可用": round(disk.free/1e9,1)},
|
||||
"运行时": {
|
||||
"Python": os.popen("python3 --version 2>/dev/null").read().strip() or "-",
|
||||
"Node": os.popen("node --version 2>/dev/null").read().strip() or "-",
|
||||
"Playwright": "✅ 已安装",
|
||||
"AgentOS路径": str(AGENT_SYNC),
|
||||
"机器UID": MACHINE_UID,
|
||||
"主机名": HOSTNAME,
|
||||
},
|
||||
}}
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
plugins/tools.py — 工具集插件 (v1.0)
|
||||
显示可用工具目录/安装状态
|
||||
版本: 1.0.0 | 更新: 2026-06-16
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from plugins.base import DashboardPlugin, AGENT_SYNC, AGENT_LOCAL, HOSTNAME, MACHINE_UID
|
||||
|
||||
|
||||
class ToolsDashboardPlugin(DashboardPlugin):
|
||||
name = "tools"
|
||||
label = "工具集"
|
||||
icon = "🔧"
|
||||
version = "1.0.0"
|
||||
description = "工具集:可用工具目录/安装状态"
|
||||
order = 65
|
||||
|
||||
def _scan_tools(self) -> list[dict]:
|
||||
tools = []
|
||||
tools_dir = AGENT_SYNC / "05_tools"
|
||||
if tools_dir.exists():
|
||||
for d in sorted(tools_dir.iterdir()):
|
||||
if d.is_dir() and d.name[0].isdigit():
|
||||
tools.append({
|
||||
"dir": d.name,
|
||||
"path": str(d),
|
||||
"files": len(list(d.glob("*.py"))) if d.exists() else 0,
|
||||
})
|
||||
return tools
|
||||
|
||||
def summary(self, machines: list[str]) -> dict:
|
||||
tools = self._scan_tools()
|
||||
return {
|
||||
"工具目录数": len(tools),
|
||||
"目录列表": [t["dir"] for t in tools],
|
||||
}
|
||||
|
||||
def detail(self, machine: str = "") -> dict:
|
||||
return {"machine": machine or HOSTNAME, "tools": self._scan_tools()}
|
||||
Reference in New Issue
Block a user