feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 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:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -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()}