移植自 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 带网关原话 / 正常返回)。
743 lines
29 KiB
Python
743 lines
29 KiB
Python
#!/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)}
|