chore: import upstream snapshot with attribution
This commit is contained in:
Symlink
+1
@@ -0,0 +1 @@
|
||||
/Users/bingsen/clawd/openclaw-sansheng-liubu/scripts/agentrec_advisor.py
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""应用 data/pending_model_changes.json → openclaw.json,并重启 Gateway"""
|
||||
import json, pathlib, subprocess, datetime, shutil, logging, glob
|
||||
from file_lock import atomic_json_write, atomic_json_read
|
||||
from utils import get_openclaw_home
|
||||
|
||||
log = logging.getLogger('model_change')
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(message)s', datefmt='%H:%M:%S')
|
||||
|
||||
BASE = pathlib.Path(__file__).parent.parent
|
||||
DATA = BASE / 'data'
|
||||
OPENCLAW_HOME = get_openclaw_home()
|
||||
OPENCLAW_CFG = OPENCLAW_HOME / 'openclaw.json'
|
||||
PENDING = DATA / 'pending_model_changes.json'
|
||||
CHANGE_LOG = DATA / 'model_change_log.json'
|
||||
MAX_BACKUPS = 10
|
||||
|
||||
|
||||
def rj(path, default):
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def cleanup_backups():
|
||||
"""只保留最近 MAX_BACKUPS 个备份"""
|
||||
pattern = str(OPENCLAW_CFG.parent / 'openclaw.json.bak.model-*')
|
||||
baks = sorted(glob.glob(pattern))
|
||||
for old in baks[:-MAX_BACKUPS]:
|
||||
try:
|
||||
pathlib.Path(old).unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
if not PENDING.exists():
|
||||
return
|
||||
pending = rj(PENDING, [])
|
||||
if not pending:
|
||||
return
|
||||
|
||||
cfg = rj(OPENCLAW_CFG, {})
|
||||
agents_list = cfg.get('agents', {}).get('list', [])
|
||||
default_model = cfg.get('agents', {}).get('defaults', {}).get('model', {}).get('primary', '')
|
||||
|
||||
applied, errors = [], []
|
||||
for change in pending:
|
||||
ag_id = change.get('agentId', '').strip()
|
||||
new_model = change.get('model', '').strip()
|
||||
if not ag_id or not new_model:
|
||||
errors.append({'change': change, 'error': 'missing fields'})
|
||||
continue
|
||||
found = False
|
||||
for ag in agents_list:
|
||||
if ag.get('id') == ag_id:
|
||||
old = ag.get('model', default_model)
|
||||
if new_model == default_model:
|
||||
ag.pop('model', None)
|
||||
else:
|
||||
ag['model'] = new_model
|
||||
applied.append({'at': datetime.datetime.now().isoformat(), 'agentId': ag_id, 'oldModel': old, 'newModel': new_model})
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
errors.append({'change': change, 'error': f'agent {ag_id} not found'})
|
||||
|
||||
if applied:
|
||||
# 只有内容真正变化时才备份和写入
|
||||
new_cfg = dict(cfg)
|
||||
new_cfg['agents'] = dict(cfg.get('agents', {}))
|
||||
new_cfg['agents']['list'] = agents_list
|
||||
old_text = json.dumps(cfg, ensure_ascii=False, sort_keys=True)
|
||||
new_text = json.dumps(new_cfg, ensure_ascii=False, sort_keys=True)
|
||||
if old_text != new_text:
|
||||
bak = OPENCLAW_CFG.parent / f'openclaw.json.bak.model-{datetime.datetime.now().strftime("%Y%m%d-%H%M%S")}'
|
||||
shutil.copy2(OPENCLAW_CFG, bak)
|
||||
cleanup_backups()
|
||||
atomic_json_write(OPENCLAW_CFG, new_cfg)
|
||||
cfg = new_cfg
|
||||
|
||||
log_data = rj(CHANGE_LOG, [])
|
||||
if not isinstance(log_data, list):
|
||||
log_data = []
|
||||
log_data.extend(applied)
|
||||
if len(log_data) > 200:
|
||||
log_data = log_data[-200:]
|
||||
atomic_json_write(CHANGE_LOG, log_data)
|
||||
|
||||
for e in applied:
|
||||
log.info(f'{e["agentId"]}: {e["oldModel"]} → {e["newModel"]}')
|
||||
|
||||
restart_ok = False
|
||||
rollback = False
|
||||
try:
|
||||
r = subprocess.run(['openclaw', 'gateway', 'restart'], capture_output=True, text=True, timeout=30)
|
||||
restart_ok = r.returncode == 0
|
||||
log.info(f'gateway restart rc={r.returncode}')
|
||||
except Exception as e:
|
||||
log.error(f'gateway restart failed: {e}')
|
||||
# 回滚配置
|
||||
if bak.exists():
|
||||
shutil.copy2(bak, OPENCLAW_CFG)
|
||||
log.warning('rolled back openclaw.json from backup')
|
||||
rollback = True
|
||||
for a in applied:
|
||||
a['rolledBack'] = True
|
||||
|
||||
atomic_json_write(PENDING, [])
|
||||
atomic_json_write(DATA / 'last_model_change_result.json', {
|
||||
'at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'applied': applied, 'errors': errors,
|
||||
'gatewayRestarted': restart_ok, 'rolledBack': rollback,
|
||||
})
|
||||
elif errors:
|
||||
log.warning(f'{len(errors)} changes failed, 0 applied')
|
||||
atomic_json_write(PENDING, [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
早朝简报采集脚本
|
||||
每日 06:00 自动运行,抓取全球新闻 RSS → data/morning_brief_YYYYMMDD.json
|
||||
覆盖: 政治 | 军事 | 经济 | AI大模型
|
||||
"""
|
||||
import json, pathlib, datetime, subprocess, re, sys, os, logging
|
||||
from xml.etree import ElementTree as ET
|
||||
from file_lock import atomic_json_write
|
||||
from utils import validate_url
|
||||
|
||||
log = logging.getLogger('朝报')
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(message)s', datefmt='%H:%M:%S')
|
||||
|
||||
DATA = pathlib.Path(__file__).resolve().parent.parent / 'data'
|
||||
|
||||
# ── RSS 源配置 ──────────────────────────────────────────────────────────
|
||||
FEEDS = {
|
||||
'政治': [
|
||||
('BBC World', 'https://feeds.bbci.co.uk/news/world/rss.xml'),
|
||||
('Reuters World', 'https://feeds.reuters.com/reuters/worldNews'),
|
||||
('AP Top News', 'https://rsshub.app/apnews/topics/ap-top-news'),
|
||||
],
|
||||
'军事': [
|
||||
('Defense News', 'https://www.defensenews.com/rss/'),
|
||||
('BBC World', 'https://feeds.bbci.co.uk/news/world/rss.xml'),
|
||||
('Reuters', 'https://feeds.reuters.com/reuters/worldNews'),
|
||||
],
|
||||
'经济': [
|
||||
('Reuters Business', 'https://feeds.reuters.com/reuters/businessNews'),
|
||||
('BBC Business', 'https://feeds.bbci.co.uk/news/business/rss.xml'),
|
||||
('CNBC', 'https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=100003114'),
|
||||
],
|
||||
'AI大模型': [
|
||||
('Hacker News', 'https://hnrss.org/newest?q=AI+LLM+model&points=50'),
|
||||
('VentureBeat AI', 'https://venturebeat.com/category/ai/feed/'),
|
||||
('MIT Tech Review', 'https://www.technologyreview.com/feed/'),
|
||||
],
|
||||
}
|
||||
|
||||
CATEGORY_KEYWORDS = {
|
||||
'军事': ['war', 'military', 'troops', 'attack', 'missile', 'army', 'navy', 'weapons',
|
||||
'战', '军', '导弹', '士兵', 'ukraine', 'russia', 'china sea', 'nato'],
|
||||
'AI大模型': ['ai', 'llm', 'gpt', 'claude', 'gemini', 'openai', 'anthropic', 'deepseek',
|
||||
'machine learning', 'neural', 'model', '大模型', '人工智能', 'chatgpt'],
|
||||
}
|
||||
|
||||
def curl_rss(url, timeout=10):
|
||||
"""用 curl 抓取 RSS"""
|
||||
try:
|
||||
from urllib.request import Request, urlopen
|
||||
req = Request(url, headers={'User-Agent': 'Mozilla/5.0 (compatible; MorningBrief/1.0)'})
|
||||
response = urlopen(req, timeout=timeout)
|
||||
return response.read().decode('utf-8', errors='ignore')
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
def _safe_parse_xml(xml_text, max_size=5*1024*1024):
|
||||
"""安全解析 XML:限制大小,禁用外部实体(防 XXE)。"""
|
||||
if len(xml_text) > max_size:
|
||||
log.warning(f'XML 内容过大 ({len(xml_text)} bytes),跳过')
|
||||
return None
|
||||
# 剥离 DOCTYPE / ENTITY 声明以防 XXE
|
||||
cleaned = re.sub(r'<!DOCTYPE[^>]*>', '', xml_text, flags=re.IGNORECASE)
|
||||
cleaned = re.sub(r'<!ENTITY[^>]*>', '', cleaned, flags=re.IGNORECASE)
|
||||
try:
|
||||
return ET.fromstring(cleaned)
|
||||
except ET.ParseError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_rss(xml_text):
|
||||
"""解析 RSS XML → list of {title, desc, link, pub_date, image}"""
|
||||
items = []
|
||||
try:
|
||||
root = _safe_parse_xml(xml_text)
|
||||
if root is None:
|
||||
return items
|
||||
# RSS 2.0
|
||||
ns = {'media': 'http://search.yahoo.com/mrss/'}
|
||||
for item in root.findall('.//item')[:8]:
|
||||
def get(tag):
|
||||
el = item.find(tag)
|
||||
return (el.text or '').strip() if el is not None else ''
|
||||
title = get('title')
|
||||
desc = re.sub(r'<[^>]+>', '', get('description'))[:200]
|
||||
link = get('link')
|
||||
pub = get('pubDate')
|
||||
# 图片
|
||||
img = ''
|
||||
enc = item.find('enclosure')
|
||||
if enc is not None and 'image' in (enc.get('type') or ''):
|
||||
img = enc.get('url', '')
|
||||
media = item.find('media:thumbnail', ns) or item.find('media:content', ns)
|
||||
if media is not None:
|
||||
img = media.get('url', img)
|
||||
items.append({'title': title, 'desc': desc, 'link': link,
|
||||
'pub_date': pub, 'image': img})
|
||||
except Exception:
|
||||
pass
|
||||
return items
|
||||
|
||||
def match_category(item, category):
|
||||
"""判断新闻是否属于该分类(用于军事/AI过滤)"""
|
||||
kws = CATEGORY_KEYWORDS.get(category, [])
|
||||
if not kws:
|
||||
return True
|
||||
text = (item['title'] + ' ' + item['desc']).lower()
|
||||
return any(k in text for k in kws)
|
||||
|
||||
def fetch_category(category, feeds, max_items=5):
|
||||
"""抓取一个分类的新闻"""
|
||||
seen_urls = set()
|
||||
results = []
|
||||
for source_name, url in feeds:
|
||||
if len(results) >= max_items:
|
||||
break
|
||||
xml = curl_rss(url)
|
||||
if not xml:
|
||||
continue
|
||||
items = parse_rss(xml)
|
||||
for item in items:
|
||||
if not item['title']:
|
||||
continue
|
||||
if item['link'] in seen_urls:
|
||||
continue
|
||||
# 军事和AI分类需要关键词过滤
|
||||
if category in CATEGORY_KEYWORDS and not match_category(item, category):
|
||||
continue
|
||||
seen_urls.add(item['link'])
|
||||
results.append({
|
||||
'title': item['title'],
|
||||
'summary': item['desc'] or item['title'],
|
||||
'link': item['link'],
|
||||
'pub_date': item['pub_date'],
|
||||
'image': item['image'],
|
||||
'source': source_name,
|
||||
})
|
||||
if len(results) >= max_items:
|
||||
break
|
||||
return results
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--force', action='store_true', help='强制采集,忽略幂等锁')
|
||||
args = parser.parse_args()
|
||||
|
||||
# 幂等锁:防重复执行
|
||||
today = datetime.date.today().strftime('%Y%m%d')
|
||||
lock_file = DATA / f'morning_brief_{today}.lock'
|
||||
if lock_file.exists() and not args.force:
|
||||
age = datetime.datetime.now().timestamp() - lock_file.stat().st_mtime
|
||||
if age < 3600: # 1小时内不重复
|
||||
log.info(f'今日已采集({today}),跳过(使用 --force 强制采集)')
|
||||
return
|
||||
# 注意:lock 放到采集成功后再 touch,防止失败也锁定
|
||||
|
||||
# 读取用户配置
|
||||
config_file = DATA / 'morning_brief_config.json'
|
||||
config = {}
|
||||
try:
|
||||
config = json.loads(config_file.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 已启用的分类
|
||||
enabled_cats = set()
|
||||
if config.get('categories'):
|
||||
for c in config['categories']:
|
||||
if c.get('enabled', True):
|
||||
enabled_cats.add(c['name'])
|
||||
else:
|
||||
enabled_cats = set(FEEDS.keys())
|
||||
|
||||
# 用户自定义关键词(全局加权)
|
||||
user_keywords = [kw.lower() for kw in config.get('keywords', [])]
|
||||
|
||||
# 合并自定义 RSS 源
|
||||
custom_feeds = config.get('custom_feeds', [])
|
||||
merged_feeds = {}
|
||||
for cat, feeds in FEEDS.items():
|
||||
if cat in enabled_cats:
|
||||
merged_feeds[cat] = list(feeds)
|
||||
for cf in custom_feeds:
|
||||
cat = cf.get('category', '')
|
||||
feed_url = cf.get('url', '')
|
||||
if cat in enabled_cats and feed_url:
|
||||
# 校验自定义源 URL(SSRF 防护)
|
||||
if validate_url(feed_url):
|
||||
merged_feeds.setdefault(cat, []).append((cf.get('name', '自定义'), feed_url))
|
||||
else:
|
||||
log.warning(f'自定义源 URL 不合法,跳过: {feed_url}')
|
||||
|
||||
log.info(f'开始采集 {today}...')
|
||||
log.info(f' 启用分类: {", ".join(enabled_cats)}')
|
||||
if user_keywords:
|
||||
log.info(f' 关注词: {", ".join(user_keywords)}')
|
||||
if custom_feeds:
|
||||
log.info(f' 自定义源: {len(custom_feeds)} 个')
|
||||
|
||||
result = {
|
||||
'date': today,
|
||||
'generated_at': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'categories': {}
|
||||
}
|
||||
|
||||
for category, feeds in merged_feeds.items():
|
||||
log.info(f' 采集 {category}...')
|
||||
items = fetch_category(category, feeds)
|
||||
# Boost items matching user keywords
|
||||
if user_keywords:
|
||||
for item in items:
|
||||
text = (item.get('title', '') + ' ' + item.get('summary', '')).lower()
|
||||
item['_kw_hits'] = sum(1 for kw in user_keywords if kw in text)
|
||||
items.sort(key=lambda x: x.get('_kw_hits', 0), reverse=True)
|
||||
for item in items:
|
||||
item.pop('_kw_hits', None)
|
||||
result['categories'][category] = items
|
||||
log.info(f' {category}: {len(items)} 条')
|
||||
|
||||
# 写入今日文件
|
||||
today_file = DATA / f'morning_brief_{today}.json'
|
||||
atomic_json_write(today_file, result)
|
||||
|
||||
# 覆写 latest(看板读这个)
|
||||
latest_file = DATA / 'morning_brief.json'
|
||||
atomic_json_write(latest_file, result)
|
||||
|
||||
total = sum(len(v) for v in result['categories'].values())
|
||||
log.info(f'✅ 完成:共 {total} 条新闻 → {today_file.name}')
|
||||
|
||||
# 采集成功后才写入幂等锁
|
||||
lock_file.touch()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
文件锁工具 — 防止多进程并发读写 JSON 文件导致数据丢失。
|
||||
|
||||
用法:
|
||||
from file_lock import atomic_json_update, atomic_json_read
|
||||
|
||||
# 原子读取
|
||||
data = atomic_json_read(path, default=[])
|
||||
|
||||
# 原子更新(读 → 修改 → 写回,全程持锁)
|
||||
def modifier(tasks):
|
||||
tasks.append(new_task)
|
||||
return tasks
|
||||
atomic_json_update(path, modifier, default=[])
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
from typing import Any, Callable
|
||||
|
||||
_IS_WINDOWS = os.name == 'nt'
|
||||
|
||||
if _IS_WINDOWS:
|
||||
import msvcrt
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
|
||||
# ── 平台抽象:文件锁 ────────────────────────────────────────────
|
||||
|
||||
def _lock_shared(fd: int) -> None:
|
||||
"""获取共享锁(读锁)。"""
|
||||
if _IS_WINDOWS:
|
||||
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
fcntl.flock(fd, fcntl.LOCK_SH)
|
||||
|
||||
|
||||
def _lock_exclusive(fd: int) -> None:
|
||||
"""获取排他锁(写锁)。"""
|
||||
if _IS_WINDOWS:
|
||||
msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
|
||||
else:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
|
||||
|
||||
def _unlock(fd: int) -> None:
|
||||
"""释放锁。"""
|
||||
if _IS_WINDOWS:
|
||||
try:
|
||||
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _lock_path(path: pathlib.Path) -> pathlib.Path:
|
||||
return path.parent / (path.name + '.lock')
|
||||
|
||||
|
||||
def atomic_json_read(path: pathlib.Path, default: Any = None) -> Any:
|
||||
"""持锁读取 JSON 文件。"""
|
||||
lock_file = _lock_path(path)
|
||||
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(lock_file), os.O_CREAT | os.O_RDWR)
|
||||
try:
|
||||
_lock_shared(fd)
|
||||
try:
|
||||
return json.loads(path.read_text(encoding='utf-8')) if path.exists() else default
|
||||
except Exception:
|
||||
return default
|
||||
finally:
|
||||
_unlock(fd)
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def atomic_json_update(
|
||||
path: pathlib.Path,
|
||||
modifier: Callable[[Any], Any],
|
||||
default: Any = None,
|
||||
) -> Any:
|
||||
"""
|
||||
原子地读取 → 修改 → 写回 JSON 文件。
|
||||
modifier(data) 应返回修改后的数据。
|
||||
使用临时文件 + rename 保证写入原子性。
|
||||
"""
|
||||
lock_file = _lock_path(path)
|
||||
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(lock_file), os.O_CREAT | os.O_RDWR)
|
||||
try:
|
||||
_lock_exclusive(fd)
|
||||
# Read
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding='utf-8')) if path.exists() else default
|
||||
except Exception:
|
||||
data = default
|
||||
# Modify
|
||||
result = modifier(data)
|
||||
# Atomic write via temp file + rename
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(
|
||||
dir=str(path.parent), suffix='.tmp', prefix=path.stem + '_'
|
||||
)
|
||||
try:
|
||||
with os.fdopen(tmp_fd, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp_path, str(path))
|
||||
except Exception:
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
return result
|
||||
finally:
|
||||
_unlock(fd)
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def atomic_json_write(path: pathlib.Path, data: Any) -> None:
|
||||
"""原子写入 JSON 文件(持排他锁 + tmpfile rename)。
|
||||
直接写入,不读取现有内容(避免 atomic_json_update 的多余读开销)。
|
||||
"""
|
||||
lock_file = _lock_path(path)
|
||||
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(lock_file), os.O_CREAT | os.O_RDWR)
|
||||
try:
|
||||
_lock_exclusive(fd)
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(
|
||||
dir=str(path.parent), suffix='.tmp', prefix=path.stem + '_'
|
||||
)
|
||||
try:
|
||||
with os.fdopen(tmp_fd, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp_path, str(path))
|
||||
except Exception:
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
finally:
|
||||
_unlock(fd)
|
||||
os.close(fd)
|
||||
File diff suppressed because it is too large
Load Diff
Symlink
+1
@@ -0,0 +1 @@
|
||||
/Users/bingsen/clawd/openclaw-sansheng-liubu/scripts/linucb_router.py
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Record a demo video of the dashboard and convert to GIF."""
|
||||
from playwright.sync_api import sync_playwright
|
||||
import subprocess, os, time
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), '..')
|
||||
VIDEO_DIR = os.path.join(ROOT, 'docs', '_video_tmp')
|
||||
OUTPUT_GIF = os.path.join(ROOT, 'docs', 'demo.gif')
|
||||
URL = 'http://localhost:7891'
|
||||
|
||||
def main():
|
||||
os.makedirs(VIDEO_DIR, exist_ok=True)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
ctx = browser.new_context(
|
||||
viewport={'width': 1280, 'height': 720},
|
||||
device_scale_factor=2,
|
||||
color_scheme='dark',
|
||||
record_video_dir=VIDEO_DIR,
|
||||
record_video_size={'width': 1280, 'height': 720},
|
||||
)
|
||||
page = ctx.new_page()
|
||||
|
||||
# === Scene 1: Ceremony (3s) ===
|
||||
print('🎬 Scene 1: Ceremony...')
|
||||
page.goto(URL)
|
||||
page.wait_for_timeout(500)
|
||||
page.evaluate("localStorage.removeItem('openclaw_court_date')")
|
||||
page.reload()
|
||||
page.wait_for_timeout(3500)
|
||||
|
||||
# === Scene 2: Kanban overview (3s) ===
|
||||
print('📋 Scene 2: Kanban...')
|
||||
# Ceremony should have auto-dismissed by now, or skip it
|
||||
page.evaluate("localStorage.setItem('openclaw_court_date', new Date().toISOString().substring(0,10))")
|
||||
page.reload()
|
||||
page.wait_for_load_state('networkidle')
|
||||
page.wait_for_timeout(2000)
|
||||
# Slow scroll down to show tasks
|
||||
page.mouse.wheel(0, 300)
|
||||
page.wait_for_timeout(1500)
|
||||
page.mouse.wheel(0, -300)
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# === Scene 3: Click a task (3s) ===
|
||||
print('📜 Scene 3: Task detail...')
|
||||
cards = page.locator('.edict-card')
|
||||
if cards.count() > 0:
|
||||
cards.first.click()
|
||||
page.wait_for_timeout(2500)
|
||||
page.keyboard.press('Escape')
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# === Scene 4: Monitor (2s) ===
|
||||
print('🔭 Scene 4: Monitor...')
|
||||
page.click('[data-tab="monitor"]')
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# === Scene 5: Memorials (2s) ===
|
||||
print('📜 Scene 5: Memorials...')
|
||||
page.click('[data-tab="memorials"]')
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# === Scene 6: Templates (2s) ===
|
||||
print('📜 Scene 6: Templates...')
|
||||
page.click('[data-tab="templates"]')
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# === Scene 7: Officials (2s) ===
|
||||
print('👥 Scene 7: Officials...')
|
||||
page.click('[data-tab="officials"]')
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# === Scene 8: Models (1.5s) ===
|
||||
print('⚙️ Scene 8: Models...')
|
||||
page.click('[data-tab="models"]')
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
# === Scene 9: Back to Kanban (1s) ===
|
||||
print('📋 Scene 9: Back to kanban...')
|
||||
page.click('[data-tab="edicts"]')
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
# Close context to finalize video
|
||||
page.close()
|
||||
ctx.close()
|
||||
browser.close()
|
||||
|
||||
# Find the recorded video
|
||||
videos = [f for f in os.listdir(VIDEO_DIR) if f.endswith('.webm')]
|
||||
if not videos:
|
||||
print('❌ No video recorded!')
|
||||
return
|
||||
|
||||
video_path = os.path.join(VIDEO_DIR, videos[0])
|
||||
print(f'🎥 Video: {video_path} ({os.path.getsize(video_path) / 1024 / 1024:.1f} MB)')
|
||||
|
||||
# Convert to GIF using ffmpeg
|
||||
# Two-pass: generate palette first for quality, then apply
|
||||
palette_path = os.path.join(VIDEO_DIR, 'palette.png')
|
||||
|
||||
print('🎨 Generating palette...')
|
||||
subprocess.run([
|
||||
'ffmpeg', '-y', '-i', video_path,
|
||||
'-vf', 'fps=12,scale=800:-1:flags=lanczos,palettegen=max_colors=128',
|
||||
palette_path
|
||||
], capture_output=True)
|
||||
|
||||
print('🖼️ Converting to GIF...')
|
||||
subprocess.run([
|
||||
'ffmpeg', '-y', '-i', video_path, '-i', palette_path,
|
||||
'-lavfi', 'fps=12,scale=800:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=3',
|
||||
OUTPUT_GIF
|
||||
], capture_output=True)
|
||||
|
||||
size_mb = os.path.getsize(OUTPUT_GIF) / 1024 / 1024
|
||||
print(f'✅ GIF saved: {OUTPUT_GIF} ({size_mb:.1f} MB)')
|
||||
|
||||
if size_mb > 5:
|
||||
print('⚠️ GIF is over 5MB, re-encoding with lower quality...')
|
||||
subprocess.run([
|
||||
'ffmpeg', '-y', '-i', video_path,
|
||||
'-vf', 'fps=10,scale=640:-1:flags=lanczos,palettegen=max_colors=64',
|
||||
palette_path
|
||||
], capture_output=True)
|
||||
subprocess.run([
|
||||
'ffmpeg', '-y', '-i', video_path, '-i', palette_path,
|
||||
'-lavfi', 'fps=10,scale=640:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=5',
|
||||
OUTPUT_GIF
|
||||
], capture_output=True)
|
||||
size_mb = os.path.getsize(OUTPUT_GIF) / 1024 / 1024
|
||||
print(f'✅ Re-encoded GIF: {size_mb:.1f} MB')
|
||||
|
||||
# Cleanup
|
||||
import shutil
|
||||
shutil.rmtree(VIDEO_DIR, ignore_errors=True)
|
||||
print('🧹 Cleaned up temp files')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, pathlib, datetime, logging
|
||||
from file_lock import atomic_json_write, atomic_json_read
|
||||
from utils import read_json
|
||||
|
||||
log = logging.getLogger('refresh')
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(message)s', datefmt='%H:%M:%S')
|
||||
|
||||
BASE = pathlib.Path(__file__).parent.parent
|
||||
DATA = BASE / 'data'
|
||||
|
||||
|
||||
def output_meta(path):
|
||||
p = pathlib.Path(path)
|
||||
if not p.exists():
|
||||
return {"exists": False, "lastModified": None}
|
||||
ts = datetime.datetime.fromtimestamp(p.stat().st_mtime).strftime('%Y-%m-%d %H:%M:%S')
|
||||
return {"exists": True, "lastModified": ts}
|
||||
|
||||
|
||||
def main():
|
||||
# 使用 officials_stats.json(与 sync_officials_stats.py 统一)
|
||||
officials_data = read_json(DATA / 'officials_stats.json', {})
|
||||
officials = officials_data.get('officials', []) if isinstance(officials_data, dict) else officials_data
|
||||
# 任务源优先:tasks_source.json(可对接外部系统同步写入)
|
||||
tasks = atomic_json_read(DATA / 'tasks_source.json', [])
|
||||
if not tasks:
|
||||
tasks = read_json(DATA / 'tasks.json', [])
|
||||
|
||||
sync_status = read_json(DATA / 'sync_status.json', {})
|
||||
|
||||
org_map = {}
|
||||
for o in officials:
|
||||
label = o.get('label', o.get('name', ''))
|
||||
if label:
|
||||
org_map[label] = label
|
||||
|
||||
now_ts = datetime.datetime.now(datetime.timezone.utc)
|
||||
for t in tasks:
|
||||
t['org'] = t.get('org') or org_map.get(t.get('official', ''), '')
|
||||
t['outputMeta'] = output_meta(t.get('output', ''))
|
||||
|
||||
# 心跳时效检测:对 Doing/Assigned 状态的任务标注活跃度
|
||||
if t.get('state') in ('Doing', 'Assigned', 'Review'):
|
||||
updated_raw = t.get('updatedAt') or t.get('sourceMeta', {}).get('updatedAt')
|
||||
age_sec = None
|
||||
if updated_raw:
|
||||
try:
|
||||
if isinstance(updated_raw, (int, float)):
|
||||
updated_dt = datetime.datetime.fromtimestamp(updated_raw / 1000, tz=datetime.timezone.utc)
|
||||
else:
|
||||
updated_dt = datetime.datetime.fromisoformat(str(updated_raw).replace('Z', '+00:00'))
|
||||
age_sec = (now_ts - updated_dt).total_seconds()
|
||||
except Exception:
|
||||
pass
|
||||
if age_sec is None:
|
||||
t['heartbeat'] = {'status': 'unknown', 'label': '⚪ 未知', 'ageSec': None}
|
||||
elif age_sec < 300:
|
||||
t['heartbeat'] = {'status': 'active', 'label': f'🟢 活跃 {int(age_sec//60)}分钟前', 'ageSec': int(age_sec)}
|
||||
elif age_sec < 900:
|
||||
t['heartbeat'] = {'status': 'warn', 'label': f'🟡 可能停滞 {int(age_sec//60)}分钟前', 'ageSec': int(age_sec)}
|
||||
else:
|
||||
t['heartbeat'] = {'status': 'stalled', 'label': f'🔴 已停滞 {int(age_sec//60)}分钟', 'ageSec': int(age_sec)}
|
||||
else:
|
||||
t['heartbeat'] = None
|
||||
|
||||
today_str = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d')
|
||||
def _is_today_done(t):
|
||||
if t.get('state') != 'Done':
|
||||
return False
|
||||
ua = t.get('updatedAt', '')
|
||||
if isinstance(ua, str) and ua[:10] == today_str:
|
||||
return True
|
||||
# fallback: outputMeta lastModified
|
||||
lm = t.get('outputMeta', {}).get('lastModified', '')
|
||||
if isinstance(lm, str) and lm[:10] == today_str:
|
||||
return True
|
||||
return False
|
||||
today_done = sum(1 for t in tasks if _is_today_done(t))
|
||||
total_done = sum(1 for t in tasks if t.get('state') == 'Done')
|
||||
in_progress = sum(1 for t in tasks if t.get('state') in ['Doing', 'Review', 'Next', 'Blocked'])
|
||||
blocked = sum(1 for t in tasks if t.get('state') == 'Blocked')
|
||||
|
||||
history = []
|
||||
for t in tasks:
|
||||
if t.get('state') == 'Done':
|
||||
lm = t.get('outputMeta', {}).get('lastModified')
|
||||
history.append({
|
||||
'at': lm or '未知',
|
||||
'official': t.get('official'),
|
||||
'task': t.get('title'),
|
||||
'out': t.get('output'),
|
||||
'qa': '通过' if t.get('outputMeta', {}).get('exists') else '待补成果'
|
||||
})
|
||||
|
||||
payload = {
|
||||
'generatedAt': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'taskSource': 'tasks_source.json' if (DATA / 'tasks_source.json').exists() else 'tasks.json',
|
||||
'officials': officials,
|
||||
'tasks': tasks,
|
||||
'history': history,
|
||||
'metrics': {
|
||||
'officialCount': len(officials),
|
||||
'todayDone': today_done,
|
||||
'totalDone': total_done,
|
||||
'inProgress': in_progress,
|
||||
'blocked': blocked
|
||||
},
|
||||
'syncStatus': sync_status,
|
||||
'health': {
|
||||
'syncOk': bool(sync_status.get('ok', False)),
|
||||
'syncLatencyMs': sync_status.get('durationMs'),
|
||||
'missingFieldCount': len(sync_status.get('missingFields', {})),
|
||||
}
|
||||
}
|
||||
|
||||
atomic_json_write(DATA / 'live_status.json', payload)
|
||||
log.info(f'updated live_status.json ({len(tasks)} tasks)')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Refresh Watcher — 常驻进程,监控信号文件,debounce 后执行 refresh_live_data.py。
|
||||
|
||||
替代 kanban_update.py 中每次操作都 fork 子进程的方式。
|
||||
多 Agent 并发时,200 次 touch → 合并为 1 次 refresh。
|
||||
|
||||
运行方式:
|
||||
python3 scripts/refresh_watcher.py
|
||||
|
||||
部署方式:
|
||||
- systemd: 参见 edict.service
|
||||
- docker-compose: 参见 edict/docker-compose.yml
|
||||
- 手动前台: python3 scripts/refresh_watcher.py
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
_BASE = pathlib.Path(os.environ.get('EDICT_HOME', '')).resolve() if os.environ.get('EDICT_HOME') else pathlib.Path(__file__).resolve().parent.parent
|
||||
SIGNAL_FILE = _BASE / 'data' / '.refresh_pending'
|
||||
PID_FILE = _BASE / 'data' / '.refresh_watcher_pid'
|
||||
REFRESH_SCRIPT = _BASE / 'scripts' / 'refresh_live_data.py'
|
||||
DEBOUNCE_SEC = 2 # 信号文件稳定 2 秒后才执行
|
||||
POLL_INTERVAL = 0.5 # 检查间隔
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [refresh_watcher] %(message)s',
|
||||
datefmt='%H:%M:%S',
|
||||
)
|
||||
log = logging.getLogger('refresh_watcher')
|
||||
|
||||
_running = True
|
||||
|
||||
|
||||
def _shutdown(signum, frame):
|
||||
global _running
|
||||
_running = False
|
||||
log.info(f'收到信号 {signum},准备退出')
|
||||
|
||||
|
||||
def main():
|
||||
# 写 PID 文件,让 kanban_update.py 知道 watcher 在运行
|
||||
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
PID_FILE.write_text(str(os.getpid()))
|
||||
log.info(f'Refresh watcher started (pid={os.getpid()}, debounce={DEBOUNCE_SEC}s)')
|
||||
|
||||
signal.signal(signal.SIGTERM, _shutdown)
|
||||
signal.signal(signal.SIGINT, _shutdown)
|
||||
|
||||
last_seen_mtime = 0.0
|
||||
refresh_count = 0
|
||||
|
||||
try:
|
||||
while _running:
|
||||
try:
|
||||
if SIGNAL_FILE.exists():
|
||||
mtime = SIGNAL_FILE.stat().st_mtime
|
||||
now = time.time()
|
||||
# 信号文件存在且已稳定 DEBOUNCE_SEC 秒
|
||||
if mtime > last_seen_mtime and (now - mtime) >= DEBOUNCE_SEC:
|
||||
last_seen_mtime = mtime
|
||||
# 删除信号文件(在执行前删,避免执行期间的新 touch 被吞)
|
||||
try:
|
||||
SIGNAL_FILE.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
refresh_count += 1
|
||||
log.info(f'🔄 执行 refresh #{refresh_count}')
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, str(REFRESH_SCRIPT)],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
log.warning('refresh_live_data.py 超时 (30s)')
|
||||
except Exception as e:
|
||||
log.error(f'refresh 执行失败: {e}')
|
||||
except Exception as e:
|
||||
log.error(f'Watcher loop error: {e}')
|
||||
|
||||
time.sleep(POLL_INTERVAL)
|
||||
finally:
|
||||
# 清理 PID 文件
|
||||
try:
|
||||
PID_FILE.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
log.info(f'Refresh watcher stopped (total refreshes: {refresh_count})')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,119 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
三省六部数据刷新循环 (Windows PowerShell 版本)
|
||||
.DESCRIPTION
|
||||
run_loop.sh 的 Windows 等效脚本。
|
||||
用法: .\run_loop.ps1 [-Interval 15] [-ScanInterval 120]
|
||||
.NOTES
|
||||
源自 GitHub Issue #245 (感谢 @Vip4pt 贡献)
|
||||
#>
|
||||
param(
|
||||
[int]$Interval = 15,
|
||||
[int]$ScanInterval = 120
|
||||
)
|
||||
|
||||
# ── 基础配置 ──
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
if (-not $env:OPENCLAW_HOME) {
|
||||
$env:OPENCLAW_HOME = Split-Path -Parent $ScriptDir
|
||||
}
|
||||
|
||||
$Log = "$env:TEMP\sansheng_liubu_refresh.log"
|
||||
$PidFile = "$env:TEMP\sansheng_liubu_refresh.pid"
|
||||
$MaxLogSize = 10MB
|
||||
$ScriptTimeout = 30
|
||||
$DashboardPort = $env:EDICT_DASHBOARD_PORT
|
||||
if (-not $DashboardPort) { $DashboardPort = 7891 }
|
||||
|
||||
# ── 单实例保护 ──
|
||||
if (Test-Path $PidFile) {
|
||||
$OldPid = Get-Content $PidFile -ErrorAction SilentlyContinue
|
||||
if ($OldPid -and (Get-Process -Id $OldPid -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "Already running (PID=$OldPid), exiting."
|
||||
exit 1
|
||||
}
|
||||
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
$PID | Out-File $PidFile
|
||||
|
||||
# ── 优雅退出 ──
|
||||
$cleanup = {
|
||||
"$(Get-Date -Format HH:mm:ss) [loop] Shutting down..." | Out-File $Log -Append
|
||||
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
|
||||
exit
|
||||
}
|
||||
Register-EngineEvent PowerShell.Exiting -Action $cleanup | Out-Null
|
||||
|
||||
# ── 日志轮转 ──
|
||||
function Rotate-Log {
|
||||
if (Test-Path $Log) {
|
||||
$size = (Get-Item $Log).Length
|
||||
if ($size -gt $MaxLogSize) {
|
||||
Move-Item $Log "$Log.1" -Force
|
||||
"$(Get-Date -Format HH:mm:ss) [loop] Log rotated" | Out-File $Log
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ── 安全执行(带超时)──
|
||||
function Safe-Run($script) {
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = "python"
|
||||
$psi.Arguments = "`"$script`""
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$psi.UseShellExecute = $false
|
||||
|
||||
$process = New-Object System.Diagnostics.Process
|
||||
$process.StartInfo = $psi
|
||||
$process.Start() | Out-Null
|
||||
|
||||
if (-not $process.WaitForExit($ScriptTimeout * 1000)) {
|
||||
try {
|
||||
$process.Kill()
|
||||
"$(Get-Date -Format HH:mm:ss) [loop] Script timeout (${ScriptTimeout}s): $script" | Out-File $Log -Append
|
||||
} catch {}
|
||||
}
|
||||
|
||||
$stdout = $process.StandardOutput.ReadToEnd()
|
||||
$stderr = $process.StandardError.ReadToEnd()
|
||||
|
||||
if ($stdout) { $stdout | Out-File $Log -Append }
|
||||
if ($stderr) { $stderr | Out-File $Log -Append }
|
||||
}
|
||||
|
||||
# ── 启动信息 ──
|
||||
Write-Host "Data refresh loop started (PID=$PID)"
|
||||
Write-Host " Script dir: $ScriptDir"
|
||||
Write-Host " Interval: ${Interval}s Scan: ${ScanInterval}s Timeout: ${ScriptTimeout}s"
|
||||
Write-Host " Log: $Log"
|
||||
Write-Host " Ctrl+C to stop"
|
||||
|
||||
$ScanCounter = 0
|
||||
|
||||
# ── 主循环 ──
|
||||
while ($true) {
|
||||
Rotate-Log
|
||||
|
||||
Safe-Run "$ScriptDir\sync_from_openclaw_runtime.py"
|
||||
Safe-Run "$ScriptDir\sync_agent_config.py"
|
||||
Safe-Run "$ScriptDir\apply_model_changes.py"
|
||||
Safe-Run "$ScriptDir\sync_officials_stats.py"
|
||||
Safe-Run "$ScriptDir\refresh_live_data.py"
|
||||
|
||||
# ── 巡检任务 ──
|
||||
$ScanCounter += $Interval
|
||||
if ($ScanCounter -ge $ScanInterval) {
|
||||
$ScanCounter = 0
|
||||
try {
|
||||
Invoke-RestMethod -Uri "http://127.0.0.1:$DashboardPort/api/scheduler-scan" `
|
||||
-Method POST `
|
||||
-ContentType "application/json" `
|
||||
-Body '{"thresholdSec":180}' | Out-Null
|
||||
} catch {
|
||||
$_ | Out-File $Log -Append
|
||||
}
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds $Interval
|
||||
}
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
# 三省六部 · 数据刷新循环
|
||||
# 用法: ./run_loop.sh [间隔秒数 [巡检间隔秒数]]
|
||||
# 间隔秒数:数据刷新频率,默认 15 秒
|
||||
# 巡检间隔秒数:自动重试卡住任务的频率,默认 120 秒
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export EDICT_HOME="${EDICT_HOME:-$(dirname "$SCRIPT_DIR")}"
|
||||
PYTHON_BIN="${EDICT_PYTHON:-python3}"
|
||||
INTERVAL="${1:-15}"
|
||||
LOG="/tmp/sansheng_liubu_refresh.log"
|
||||
PIDFILE="/tmp/sansheng_liubu_refresh.pid"
|
||||
MAX_LOG_SIZE=$((10 * 1024 * 1024)) # 10MB
|
||||
|
||||
# ── 单实例保护 ──
|
||||
if [[ -f "$PIDFILE" ]]; then
|
||||
OLD_PID=$(cat "$PIDFILE" 2>/dev/null)
|
||||
if kill -0 "$OLD_PID" 2>/dev/null; then
|
||||
echo "❌ 已有实例运行中 (PID=$OLD_PID),退出"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$PIDFILE"
|
||||
fi
|
||||
echo $$ > "$PIDFILE"
|
||||
|
||||
# ── 优雅退出 ──
|
||||
cleanup() {
|
||||
echo "$(date '+%H:%M:%S') [loop] 收到退出信号,清理中..." >> "$LOG"
|
||||
rm -f "$PIDFILE"
|
||||
exit 0
|
||||
}
|
||||
trap cleanup SIGINT SIGTERM EXIT
|
||||
|
||||
# ── 日志轮转 ──
|
||||
rotate_log() {
|
||||
if [[ -f "$LOG" ]] && (( $(stat -f%z "$LOG" 2>/dev/null || stat -c%s "$LOG" 2>/dev/null || echo 0) > MAX_LOG_SIZE )); then
|
||||
mv "$LOG" "${LOG}.1"
|
||||
echo "$(date '+%H:%M:%S') [loop] 日志已轮转" > "$LOG"
|
||||
fi
|
||||
}
|
||||
|
||||
SCAN_INTERVAL="${2:-120}" # 巡检间隔(秒), 默认 120
|
||||
SCAN_COUNTER=0
|
||||
SCRIPT_TIMEOUT=30 # 单个脚本最大执行时间(秒)
|
||||
DASHBOARD_PORT="${EDICT_DASHBOARD_PORT:-7891}" # 看板端口,可通过环境变量覆盖
|
||||
|
||||
echo "🏛️ 三省六部数据刷新循环启动 (PID=$$)"
|
||||
echo " 脚本目录: $SCRIPT_DIR"
|
||||
echo " 间隔: ${INTERVAL}s"
|
||||
echo " 巡检间隔: ${SCAN_INTERVAL}s"
|
||||
echo " 脚本超时: ${SCRIPT_TIMEOUT}s"
|
||||
echo " 日志: $LOG"
|
||||
echo " PID文件: $PIDFILE"
|
||||
echo " 按 Ctrl+C 停止"
|
||||
|
||||
# ── 安全执行(带超时保护)──
|
||||
safe_run() {
|
||||
local script="$1"
|
||||
if command -v timeout &>/dev/null; then
|
||||
timeout "$SCRIPT_TIMEOUT" "$PYTHON_BIN" "$script" >> "$LOG" 2>&1 || {
|
||||
local rc=$?
|
||||
if [[ $rc -eq 124 ]]; then
|
||||
echo "$(date '+%H:%M:%S') [loop] ⚠️ 脚本超时(${SCRIPT_TIMEOUT}s): $script" >> "$LOG"
|
||||
fi
|
||||
}
|
||||
else
|
||||
"$PYTHON_BIN" "$script" >> "$LOG" 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
while true; do
|
||||
rotate_log
|
||||
safe_run "$SCRIPT_DIR/sync_from_openclaw_runtime.py"
|
||||
safe_run "$SCRIPT_DIR/sync_agent_config.py"
|
||||
safe_run "$SCRIPT_DIR/apply_model_changes.py"
|
||||
safe_run "$SCRIPT_DIR/sync_officials_stats.py"
|
||||
safe_run "$SCRIPT_DIR/refresh_live_data.py"
|
||||
|
||||
# 定期巡检:检测卡住的任务并自动重试
|
||||
SCAN_COUNTER=$((SCAN_COUNTER + INTERVAL))
|
||||
if (( SCAN_COUNTER >= SCAN_INTERVAL )); then
|
||||
SCAN_COUNTER=0
|
||||
curl -s -X POST "http://127.0.0.1:${DASHBOARD_PORT}/api/scheduler-scan" \
|
||||
-H 'Content-Type: application/json' -d '{"thresholdSec":180}' >> "$LOG" 2>&1 || true
|
||||
fi
|
||||
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
三省六部 · Skill 管理工具
|
||||
支持从本地或远程 URL 添加、更新、查看和移除 skills
|
||||
|
||||
Usage:
|
||||
python3 scripts/skill_manager.py add-remote --agent zhongshu --name code_review \\
|
||||
--source https://raw.githubusercontent.com/org/skills/main/code_review/SKILL.md \\
|
||||
--description "代码审查"
|
||||
|
||||
python3 scripts/skill_manager.py list-remote
|
||||
|
||||
python3 scripts/skill_manager.py update-remote --agent zhongshu --name code_review
|
||||
|
||||
python3 scripts/skill_manager.py remove-remote --agent zhongshu --name code_review
|
||||
|
||||
python3 scripts/skill_manager.py import-official-hub --agents menxia,shangshu
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
import pathlib
|
||||
import argparse
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from utils import get_openclaw_home, now_iso, safe_name, read_json
|
||||
|
||||
OCLAW_HOME = get_openclaw_home()
|
||||
|
||||
|
||||
def _download_file(url: str, timeout: int = 30, retries: int = 3) -> str:
|
||||
"""从 URL 下载文件内容(文本格式),支持重试"""
|
||||
last_error = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'OpenClaw-SkillManager/1.0'})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
content = resp.read(10 * 1024 * 1024) # 最多 10MB
|
||||
return content.decode('utf-8')
|
||||
except urllib.error.HTTPError as e:
|
||||
last_error = f'HTTP {e.code}: {e.reason}'
|
||||
if e.code in (404, 403):
|
||||
break # 不重试 4xx
|
||||
except urllib.error.URLError as e:
|
||||
last_error = f'网络错误: {e.reason}'
|
||||
except Exception as e:
|
||||
last_error = f'{type(e).__name__}: {e}'
|
||||
|
||||
if attempt < retries:
|
||||
import time
|
||||
wait = attempt * 3 # 3s, 6s
|
||||
print(f' ⚠️ 第 {attempt} 次下载失败({last_error}),{wait}秒后重试...')
|
||||
time.sleep(wait)
|
||||
|
||||
# 所有重试失败
|
||||
hint = ''
|
||||
if 'timed out' in str(last_error).lower() or '超时' in str(last_error):
|
||||
hint = '\n 💡 提示: 如果在中国大陆,请设置代理 export https_proxy=http://proxy:port'
|
||||
elif '404' in str(last_error):
|
||||
hint = '\n 💡 提示: 远程 skill URL 不存在,请检查 URL 或自定义 Skills Hub 配置'
|
||||
raise Exception(f'{last_error} (已重试 {retries} 次){hint}')
|
||||
|
||||
|
||||
def _compute_checksum(content: str) -> str:
|
||||
"""计算内容的简单校验和"""
|
||||
import hashlib
|
||||
return hashlib.sha256(content.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def add_remote(agent_id: str, name: str, source_url: str, description: str = '') -> bool:
|
||||
"""从远程 URL 为 Agent 添加 skill"""
|
||||
if not safe_name(agent_id) or not safe_name(name):
|
||||
print(f'❌ 错误:agent_id 或 skill 名称含非法字符')
|
||||
return False
|
||||
|
||||
# 设置 workspace
|
||||
workspace = OCLAW_HOME / f'workspace-{agent_id}' / 'skills' / name
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
skill_md = workspace / 'SKILL.md'
|
||||
|
||||
# 下载文件
|
||||
print(f'⏳ 正在从 {source_url} 下载...')
|
||||
try:
|
||||
content = _download_file(source_url)
|
||||
except Exception as e:
|
||||
print(f'❌ 下载失败:{e}')
|
||||
print(f' URL: {source_url}')
|
||||
return False
|
||||
|
||||
# 基础验证(放宽检查:有些 skill 不以 --- 开头)
|
||||
if len(content.strip()) < 10:
|
||||
print(f'❌ 文件内容过短或为空')
|
||||
return False
|
||||
|
||||
# 保存 SKILL.md
|
||||
skill_md.write_text(content)
|
||||
|
||||
# 保存源信息
|
||||
source_info = {
|
||||
'skillName': name,
|
||||
'sourceUrl': source_url,
|
||||
'description': description,
|
||||
'addedAt': now_iso(),
|
||||
'lastUpdated': now_iso(),
|
||||
'checksum': _compute_checksum(content),
|
||||
'status': 'valid',
|
||||
}
|
||||
source_json = workspace / '.source.json'
|
||||
source_json.write_text(json.dumps(source_info, ensure_ascii=False, indent=2))
|
||||
|
||||
print(f'✅ 技能 {name} 已添加到 {agent_id}')
|
||||
print(f' 路径: {skill_md}')
|
||||
print(f' 大小: {len(content)} 字节')
|
||||
return True
|
||||
|
||||
|
||||
def list_remote() -> bool:
|
||||
"""列出所有已添加的远程 skills"""
|
||||
if not OCLAW_HOME.exists():
|
||||
print('❌ OCLAW_HOME 不存在')
|
||||
return False
|
||||
|
||||
remote_skills = []
|
||||
|
||||
for ws_dir in OCLAW_HOME.glob('workspace-*'):
|
||||
agent_id = ws_dir.name.replace('workspace-', '')
|
||||
skills_dir = ws_dir / 'skills'
|
||||
if not skills_dir.exists():
|
||||
continue
|
||||
|
||||
for skill_dir in skills_dir.iterdir():
|
||||
if not skill_dir.is_dir():
|
||||
continue
|
||||
skill_name = skill_dir.name
|
||||
source_json = skill_dir / '.source.json'
|
||||
|
||||
if not source_json.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
source_info = json.loads(source_json.read_text())
|
||||
remote_skills.append({
|
||||
'agent': agent_id,
|
||||
'skill': skill_name,
|
||||
'source': source_info.get('sourceUrl', 'N/A'),
|
||||
'desc': source_info.get('description', ''),
|
||||
'added': source_info.get('addedAt', 'N/A'),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not remote_skills:
|
||||
print('📭 暂无远程 skills')
|
||||
return True
|
||||
|
||||
print(f'📋 共 {len(remote_skills)} 个远程 skills:\n')
|
||||
print(f'{"Agent":<12} | {"Skill 名称":<20} | {"描述":<30} | 添加时间')
|
||||
print('-' * 100)
|
||||
|
||||
for sk in remote_skills:
|
||||
desc = (sk['desc'] or sk['source'])[:30].ljust(30)
|
||||
print(f"{sk['agent']:<12} | {sk['skill']:<20} | {desc} | {sk['added'][:10]}")
|
||||
|
||||
print()
|
||||
return True
|
||||
|
||||
|
||||
def update_remote(agent_id: str, name: str) -> bool:
|
||||
"""更新远程 skill 为最新版本"""
|
||||
if not safe_name(agent_id) or not safe_name(name):
|
||||
print(f'❌ 错误:agent_id 或 skill 名称含非法字符')
|
||||
return False
|
||||
|
||||
workspace = OCLAW_HOME / f'workspace-{agent_id}' / 'skills' / name
|
||||
source_json = workspace / '.source.json'
|
||||
|
||||
if not source_json.exists():
|
||||
print(f'❌ 技能不存在或不是远程 skill: {name}')
|
||||
return False
|
||||
|
||||
try:
|
||||
source_info = json.loads(source_json.read_text())
|
||||
source_url = source_info.get('sourceUrl')
|
||||
if not source_url:
|
||||
print(f'❌ 无效的源 URL')
|
||||
return False
|
||||
|
||||
# 重新下载
|
||||
return add_remote(agent_id, name, source_url, source_info.get('description', ''))
|
||||
except Exception as e:
|
||||
print(f'❌ 更新失败:{e}')
|
||||
return False
|
||||
|
||||
|
||||
def remove_remote(agent_id: str, name: str) -> bool:
|
||||
"""移除远程 skill"""
|
||||
if not safe_name(agent_id) or not safe_name(name):
|
||||
print(f'❌ 错误:agent_id 或 skill 名称含非法字符')
|
||||
return False
|
||||
|
||||
workspace = OCLAW_HOME / f'workspace-{agent_id}' / 'skills' / name
|
||||
source_json = workspace / '.source.json'
|
||||
|
||||
if not source_json.exists():
|
||||
print(f'❌ 技能不存在或不是远程 skill: {name}')
|
||||
return False
|
||||
|
||||
try:
|
||||
import shutil
|
||||
shutil.rmtree(workspace)
|
||||
print(f'✅ 技能 {name} 已从 {agent_id} 移除')
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f'❌ 移除失败:{e}')
|
||||
return False
|
||||
|
||||
|
||||
# 支持通过环境变量或本地配置指定自定义 Hub 地址
|
||||
_HUB_BASE_ENV = 'OPENCLAW_SKILLS_HUB_BASE'
|
||||
|
||||
|
||||
def _get_configured_hub_base():
|
||||
"""Return a user-provided skills hub base URL, if configured."""
|
||||
env_base = os.environ.get(_HUB_BASE_ENV)
|
||||
if env_base:
|
||||
return env_base
|
||||
|
||||
hub_url_file = OCLAW_HOME / 'skills-hub-url'
|
||||
return hub_url_file.read_text().strip() if hub_url_file.exists() else None
|
||||
|
||||
|
||||
def _get_hub_url(base, skill_name):
|
||||
"""获取 skill 的 Hub URL,支持环境变量/本地配置覆盖"""
|
||||
return f'{base.rstrip("/")}/{skill_name}/SKILL.md'
|
||||
|
||||
|
||||
OFFICIAL_SKILLS_HUB = {
|
||||
'mmx_cli': 'https://raw.githubusercontent.com/MiniMax-AI/cli/main/skill/SKILL.md',
|
||||
}
|
||||
|
||||
_HUB_SKILL_NAMES = (
|
||||
'code_review',
|
||||
'api_design',
|
||||
'security_audit',
|
||||
'data_analysis',
|
||||
'doc_generation',
|
||||
'test_framework',
|
||||
)
|
||||
|
||||
_configured_hub_base = _get_configured_hub_base()
|
||||
if _configured_hub_base:
|
||||
OFFICIAL_SKILLS_HUB.update({
|
||||
skill_name: _get_hub_url(_configured_hub_base, skill_name)
|
||||
for skill_name in _HUB_SKILL_NAMES
|
||||
})
|
||||
|
||||
SKILL_AGENT_MAPPING = {
|
||||
'code_review': ('bingbu', 'xingbu', 'menxia'),
|
||||
'api_design': ('bingbu', 'gongbu', 'menxia'),
|
||||
'security_audit': ('xingbu', 'menxia'),
|
||||
'data_analysis': ('hubu', 'menxia'),
|
||||
'doc_generation': ('libu', 'menxia'),
|
||||
'test_framework': ('gongbu', 'xingbu', 'menxia'),
|
||||
'mmx_cli': ('menxia', 'shangshu'),
|
||||
}
|
||||
|
||||
|
||||
def import_official_hub(agent_ids: list) -> bool:
|
||||
"""从默认 Skills 源或自定义 Hub 导入 skills 到指定 agents。
|
||||
如果未指定 agents,使用该 skill 的推荐 agents。
|
||||
"""
|
||||
requested_agents = list(agent_ids)
|
||||
if not requested_agents:
|
||||
print('ℹ️ 未指定 agent,使用推荐配置...\n')
|
||||
|
||||
total = 0
|
||||
success = 0
|
||||
failed = []
|
||||
|
||||
for skill_name, url in OFFICIAL_SKILLS_HUB.items():
|
||||
# 确定目标 agents
|
||||
target_agents = requested_agents or list(SKILL_AGENT_MAPPING.get(skill_name, ['menxia']))
|
||||
|
||||
print(f'\n📥 正在导入 skill: {skill_name}')
|
||||
print(f' 目标 agents: {", ".join(target_agents)}')
|
||||
|
||||
for agent_id in target_agents:
|
||||
total += 1
|
||||
ok = add_remote(agent_id, skill_name, url, f'默认 skill:{skill_name}')
|
||||
if ok:
|
||||
success += 1
|
||||
else:
|
||||
failed.append(f'{agent_id}/{skill_name}')
|
||||
|
||||
print(f'\n📊 导入完成:{success}/{total} 个 skills 成功')
|
||||
if failed:
|
||||
print(f'\n❌ 失败列表:')
|
||||
for f in failed:
|
||||
print(f' - {f}')
|
||||
print(f'\n💡 排查建议:')
|
||||
print(f' 1. 检查网络: curl -I <skill-url>')
|
||||
print(f' 2. 设置代理: export https_proxy=http://your-proxy:port')
|
||||
print(f' 3. 自定义 Hub: export {_HUB_BASE_ENV}=https://your-hub/raw-base')
|
||||
print(f' 4. 自定义源: echo "https://your-hub/raw-base" > {OCLAW_HOME / "skills-hub-url"}')
|
||||
print(f' 5. 单独重试: python3 scripts/skill_manager.py add-remote --agent <agent> --name <skill> --source <url>')
|
||||
return success == total
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='三省六部 Skill 管理工具',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
subparsers = parser.add_subparsers(dest='cmd', help='命令')
|
||||
|
||||
# add-remote
|
||||
add_parser = subparsers.add_parser('add-remote', help='从远程 URL 添加 skill')
|
||||
add_parser.add_argument('--agent', required=True, help='目标 Agent ID')
|
||||
add_parser.add_argument('--name', required=True, help='Skill 内部名称')
|
||||
add_parser.add_argument('--source', required=True, help='远程 URL 或本地路径')
|
||||
add_parser.add_argument('--description', default='', help='Skill 描述')
|
||||
|
||||
# list-remote
|
||||
subparsers.add_parser('list-remote', help='列出所有远程 skills')
|
||||
|
||||
# update-remote
|
||||
update_parser = subparsers.add_parser('update-remote', help='更新远程 skill')
|
||||
update_parser.add_argument('--agent', required=True, help='Agent ID')
|
||||
update_parser.add_argument('--name', required=True, help='Skill 名称')
|
||||
|
||||
# remove-remote
|
||||
remove_parser = subparsers.add_parser('remove-remote', help='移除远程 skill')
|
||||
remove_parser.add_argument('--agent', required=True, help='Agent ID')
|
||||
remove_parser.add_argument('--name', required=True, help='Skill 名称')
|
||||
|
||||
# import-official-hub
|
||||
import_parser = subparsers.add_parser('import-official-hub', help='从默认 Skills 源或自定义 Hub 导入 skills')
|
||||
import_parser.add_argument('--agents', default='', help='逗号分隔的 Agent IDs(可选)')
|
||||
|
||||
# check-updates
|
||||
check_parser = subparsers.add_parser('check-updates', help='检查更新(未来功能)')
|
||||
check_parser.add_argument('--interval', default='weekly',
|
||||
help='检查间隔 (weekly/daily/monthly)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.cmd:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
if args.cmd == 'add-remote':
|
||||
success = add_remote(args.agent, args.name, args.source, args.description)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
elif args.cmd == 'list-remote':
|
||||
success = list_remote()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
elif args.cmd == 'update-remote':
|
||||
success = update_remote(args.agent, args.name)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
elif args.cmd == 'remove-remote':
|
||||
success = remove_remote(args.agent, args.name)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
elif args.cmd == 'import-official-hub':
|
||||
agent_list = [a.strip() for a in args.agents.split(',') if a.strip()] if args.agents else []
|
||||
success = import_official_hub(agent_list)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
elif args.cmd == 'check-updates':
|
||||
print(f'⏳ 检查更新功能(间隔: {args.interval})尚未实现')
|
||||
print(f' 敬请期待...')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
同步 openclaw.json 中的 agent 配置 → data/agent_config.json
|
||||
支持自动发现 agent workspace 下的 Skills 目录
|
||||
"""
|
||||
import json, os, pathlib, datetime, logging
|
||||
from file_lock import atomic_json_write
|
||||
from utils import get_openclaw_home
|
||||
|
||||
log = logging.getLogger('sync_agent_config')
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(message)s', datefmt='%H:%M:%S')
|
||||
|
||||
# Auto-detect project root (parent of scripts/)
|
||||
BASE = pathlib.Path(__file__).parent.parent
|
||||
DATA = BASE / 'data'
|
||||
OPENCLAW_HOME = get_openclaw_home()
|
||||
OPENCLAW_CFG = OPENCLAW_HOME / 'openclaw.json'
|
||||
|
||||
ID_LABEL = {
|
||||
'taizi': {'label': '太子', 'role': '太子', 'duty': '飞书消息分拣与回奏', 'emoji': '🤴'},
|
||||
'main': {'label': '太子', 'role': '太子', 'duty': '飞书消息分拣与回奏', 'emoji': '🤴'}, # 兼容旧配置
|
||||
'zhongshu': {'label': '中书省', 'role': '中书令', 'duty': '起草任务令与优先级', 'emoji': '📜'},
|
||||
'menxia': {'label': '门下省', 'role': '侍中', 'duty': '审议与退回机制', 'emoji': '🔍'},
|
||||
'shangshu': {'label': '尚书省', 'role': '尚书令', 'duty': '派单与升级裁决', 'emoji': '📮'},
|
||||
'libu': {'label': '礼部', 'role': '礼部尚书', 'duty': '文档/汇报/规范', 'emoji': '📝'},
|
||||
'hubu': {'label': '户部', 'role': '户部尚书', 'duty': '资源/预算/成本', 'emoji': '💰'},
|
||||
'bingbu': {'label': '兵部', 'role': '兵部尚书', 'duty': '工程实现与架构设计', 'emoji': '⚔️'},
|
||||
'xingbu': {'label': '刑部', 'role': '刑部尚书', 'duty': '合规/审计/红线', 'emoji': '⚖️'},
|
||||
'gongbu': {'label': '工部', 'role': '工部尚书', 'duty': '基础设施与部署运维', 'emoji': '🔧'},
|
||||
'libu_hr': {'label': '吏部', 'role': '吏部尚书', 'duty': '人事/培训/Agent管理', 'emoji': '👔'},
|
||||
'zaochao': {'label': '钦天监', 'role': '朝报官', 'duty': '每日新闻采集与简报', 'emoji': '📰'},
|
||||
}
|
||||
|
||||
KNOWN_MODELS = [
|
||||
{'id': 'anthropic/claude-sonnet-4-6', 'label': 'Claude Sonnet 4.6', 'provider': 'Anthropic'},
|
||||
{'id': 'anthropic/claude-opus-4-5', 'label': 'Claude Opus 4.5', 'provider': 'Anthropic'},
|
||||
{'id': 'anthropic/claude-haiku-3-5', 'label': 'Claude Haiku 3.5', 'provider': 'Anthropic'},
|
||||
{'id': 'openai/gpt-4o', 'label': 'GPT-4o', 'provider': 'OpenAI'},
|
||||
{'id': 'openai/gpt-4o-mini', 'label': 'GPT-4o Mini', 'provider': 'OpenAI'},
|
||||
{'id': 'openai-codex/gpt-5.3-codex', 'label': 'GPT-5.3 Codex', 'provider': 'OpenAI Codex'},
|
||||
{'id': 'google/gemini-2.0-flash', 'label': 'Gemini 2.0 Flash', 'provider': 'Google'},
|
||||
{'id': 'google/gemini-2.5-pro', 'label': 'Gemini 2.5 Pro', 'provider': 'Google'},
|
||||
{'id': 'copilot/claude-sonnet-4', 'label': 'Claude Sonnet 4', 'provider': 'Copilot'},
|
||||
{'id': 'copilot/claude-opus-4.5', 'label': 'Claude Opus 4.5', 'provider': 'Copilot'},
|
||||
{'id': 'github-copilot/claude-opus-4.6', 'label': 'Claude Opus 4.6', 'provider': 'GitHub Copilot'},
|
||||
{'id': 'copilot/gpt-4o', 'label': 'GPT-4o', 'provider': 'Copilot'},
|
||||
{'id': 'copilot/gemini-2.5-pro', 'label': 'Gemini 2.5 Pro', 'provider': 'Copilot'},
|
||||
{'id': 'copilot/o3-mini', 'label': 'o3-mini', 'provider': 'Copilot'},
|
||||
]
|
||||
|
||||
|
||||
def normalize_model(model_value, fallback='unknown'):
|
||||
if isinstance(model_value, str) and model_value:
|
||||
return model_value
|
||||
if isinstance(model_value, dict):
|
||||
return model_value.get('primary') or model_value.get('id') or fallback
|
||||
return fallback
|
||||
|
||||
|
||||
def get_skills(workspace: str):
|
||||
skills_dir = pathlib.Path(workspace) / 'skills'
|
||||
skills = []
|
||||
try:
|
||||
if skills_dir.exists():
|
||||
for d in sorted(skills_dir.iterdir()):
|
||||
if d.is_dir():
|
||||
md = d / 'SKILL.md'
|
||||
desc = ''
|
||||
if md.exists():
|
||||
try:
|
||||
for line in md.read_text(encoding='utf-8', errors='ignore').splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and not line.startswith('---'):
|
||||
desc = line[:100]
|
||||
break
|
||||
except Exception:
|
||||
desc = '(读取失败)'
|
||||
skills.append({'name': d.name, 'path': str(md), 'exists': md.exists(), 'description': desc})
|
||||
except PermissionError as e:
|
||||
log.warning(f'Skills 目录访问受限: {e}')
|
||||
return skills
|
||||
|
||||
|
||||
def _collect_openclaw_models(cfg):
|
||||
"""从 openclaw.json 中收集所有已配置的 model id,与 KNOWN_MODELS 合并去重。
|
||||
解决 #127: 自定义 provider 的 model 不在下拉列表中。
|
||||
"""
|
||||
known_ids = {m['id'] for m in KNOWN_MODELS}
|
||||
extra = []
|
||||
agents_cfg = cfg.get('agents', {})
|
||||
# 收集 defaults.model
|
||||
dm = normalize_model(agents_cfg.get('defaults', {}).get('model', {}), '')
|
||||
if dm and dm not in known_ids:
|
||||
extra.append({'id': dm, 'label': dm, 'provider': 'OpenClaw'})
|
||||
known_ids.add(dm)
|
||||
# 收集 defaults.models 中的所有模型(OpenClaw 默认启用的模型列表)
|
||||
defaults_models = agents_cfg.get('defaults', {}).get('models', {})
|
||||
if isinstance(defaults_models, dict):
|
||||
for model_id in defaults_models.keys():
|
||||
if model_id and model_id not in known_ids:
|
||||
provider = 'OpenClaw'
|
||||
if '/' in model_id:
|
||||
provider = model_id.split('/')[0]
|
||||
extra.append({'id': model_id, 'label': model_id, 'provider': provider})
|
||||
known_ids.add(model_id)
|
||||
# 收集每个 agent 的 model
|
||||
for ag in agents_cfg.get('list', []):
|
||||
m = normalize_model(ag.get('model', ''), '')
|
||||
if m and m not in known_ids:
|
||||
extra.append({'id': m, 'label': m, 'provider': 'OpenClaw'})
|
||||
known_ids.add(m)
|
||||
# 收集 providers 中的 model id(如 copilot-proxy、anthropic 等)
|
||||
for pname, pcfg in cfg.get('providers', {}).items():
|
||||
for mid in (pcfg.get('models') or []):
|
||||
mid_str = mid if isinstance(mid, str) else (mid.get('id') or mid.get('name') or '')
|
||||
if mid_str and mid_str not in known_ids:
|
||||
extra.append({'id': mid_str, 'label': mid_str, 'provider': pname})
|
||||
known_ids.add(mid_str)
|
||||
return KNOWN_MODELS + extra
|
||||
|
||||
|
||||
def main():
|
||||
cfg = {}
|
||||
try:
|
||||
cfg = json.loads(OPENCLAW_CFG.read_text(encoding='utf-8'))
|
||||
except Exception as e:
|
||||
log.warning(f'cannot read openclaw.json: {e}')
|
||||
return
|
||||
|
||||
agents_cfg = cfg.get('agents', {})
|
||||
default_model = normalize_model(agents_cfg.get('defaults', {}).get('model', {}), 'unknown')
|
||||
agents_list = agents_cfg.get('list', [])
|
||||
merged_models = _collect_openclaw_models(cfg)
|
||||
|
||||
result = []
|
||||
seen_ids = set()
|
||||
for ag in agents_list:
|
||||
ag_id = ag.get('id', '')
|
||||
if ag_id not in ID_LABEL:
|
||||
continue
|
||||
meta = ID_LABEL[ag_id]
|
||||
workspace = ag.get('workspace', str(OPENCLAW_HOME / f'workspace-{ag_id}'))
|
||||
if 'allowAgents' in ag:
|
||||
allow_agents = ag.get('allowAgents', []) or []
|
||||
else:
|
||||
allow_agents = ag.get('subagents', {}).get('allowAgents', [])
|
||||
result.append({
|
||||
'id': ag_id,
|
||||
'label': meta['label'], 'role': meta['role'], 'duty': meta['duty'], 'emoji': meta['emoji'],
|
||||
'model': normalize_model(ag.get('model', default_model), default_model),
|
||||
'defaultModel': default_model,
|
||||
'workspace': workspace,
|
||||
'skills': get_skills(workspace),
|
||||
'allowAgents': allow_agents,
|
||||
})
|
||||
seen_ids.add(ag_id)
|
||||
|
||||
# 补充不在 openclaw.json agents list 中的 agent(兼容旧版 main)
|
||||
EXTRA_AGENTS = {
|
||||
'taizi': {'model': default_model, 'workspace': str(OPENCLAW_HOME / 'workspace-taizi'),
|
||||
'allowAgents': ['zhongshu']},
|
||||
'main': {'model': default_model, 'workspace': str(OPENCLAW_HOME / 'workspace-main'),
|
||||
'allowAgents': ['zhongshu','menxia','shangshu','hubu','libu','bingbu','xingbu','gongbu','libu_hr']},
|
||||
'zaochao': {'model': default_model, 'workspace': str(OPENCLAW_HOME / 'workspace-zaochao'),
|
||||
'allowAgents': []},
|
||||
'libu_hr': {'model': default_model, 'workspace': str(OPENCLAW_HOME / 'workspace-libu_hr'),
|
||||
'allowAgents': ['shangshu']},
|
||||
}
|
||||
for ag_id, extra in EXTRA_AGENTS.items():
|
||||
if ag_id in seen_ids or ag_id not in ID_LABEL:
|
||||
continue
|
||||
meta = ID_LABEL[ag_id]
|
||||
result.append({
|
||||
'id': ag_id,
|
||||
'label': meta['label'], 'role': meta['role'], 'duty': meta['duty'], 'emoji': meta['emoji'],
|
||||
'model': extra['model'],
|
||||
'defaultModel': default_model,
|
||||
'workspace': extra['workspace'],
|
||||
'skills': get_skills(extra['workspace']),
|
||||
'allowAgents': extra['allowAgents'],
|
||||
'isDefaultModel': True,
|
||||
})
|
||||
|
||||
# 保留已有的 dispatchChannel 配置 (Fix #139)
|
||||
existing_cfg = {}
|
||||
cfg_path = DATA / 'agent_config.json'
|
||||
if cfg_path.exists():
|
||||
try:
|
||||
existing_cfg = json.loads(cfg_path.read_text(encoding='utf-8'))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
payload = {
|
||||
'generatedAt': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'defaultModel': default_model,
|
||||
'knownModels': merged_models,
|
||||
'dispatchChannel': existing_cfg.get('dispatchChannel') or os.getenv('DEFAULT_DISPATCH_CHANNEL', ''),
|
||||
'agents': result,
|
||||
}
|
||||
DATA.mkdir(exist_ok=True)
|
||||
atomic_json_write(DATA / 'agent_config.json', payload)
|
||||
log.info(f'{len(result)} agents synced')
|
||||
|
||||
# 自动部署 SOUL.md 到 workspace(如果项目里有更新)
|
||||
deploy_soul_files()
|
||||
# 同步 scripts/ 到各 workspace(保持 kanban_update.py 等最新)
|
||||
sync_scripts_to_workspaces()
|
||||
|
||||
|
||||
# 项目 agents/ 目录名 → 运行时 agent_id 映射
|
||||
_SOUL_DEPLOY_MAP = {
|
||||
'taizi': 'taizi',
|
||||
'zhongshu': 'zhongshu',
|
||||
'menxia': 'menxia',
|
||||
'shangshu': 'shangshu',
|
||||
'libu': 'libu',
|
||||
'hubu': 'hubu',
|
||||
'bingbu': 'bingbu',
|
||||
'xingbu': 'xingbu',
|
||||
'gongbu': 'gongbu',
|
||||
'libu_hr': 'libu_hr',
|
||||
'zaochao': 'zaochao',
|
||||
}
|
||||
|
||||
def _sync_script_symlink(src_file: pathlib.Path, dst_file: pathlib.Path) -> bool:
|
||||
"""Create a symlink dst_file → src_file (resolved).
|
||||
|
||||
Using symlinks instead of physical copies ensures that ``__file__`` in
|
||||
each script always resolves back to the project ``scripts/`` directory,
|
||||
so relative-path computations like ``Path(__file__).resolve().parent.parent``
|
||||
point to the correct project root regardless of which workspace runs the
|
||||
script. (Fixes #56 — kanban data-path split)
|
||||
|
||||
Returns True if the link was (re-)created, False if already up-to-date.
|
||||
"""
|
||||
src_resolved = src_file.resolve()
|
||||
# Guard: skip if dst resolves to the same real path as src.
|
||||
# This happens when ws_scripts is itself a directory-level symlink pointing
|
||||
# to the project scripts/ dir (created by install.sh link_resources).
|
||||
# Without this check the function would unlink the real source file and
|
||||
# then create a self-referential symlink (foo.py -> foo.py).
|
||||
try:
|
||||
dst_resolved = dst_file.resolve()
|
||||
except OSError:
|
||||
dst_resolved = None
|
||||
if dst_resolved == src_resolved:
|
||||
return False
|
||||
# Already a correct symlink?
|
||||
if dst_file.is_symlink() and dst_resolved == src_resolved:
|
||||
return False
|
||||
# Remove stale file / old physical copy / broken symlink
|
||||
if dst_file.exists() or dst_file.is_symlink():
|
||||
dst_file.unlink()
|
||||
os.symlink(src_resolved, dst_file)
|
||||
return True
|
||||
|
||||
|
||||
def sync_scripts_to_workspaces():
|
||||
"""将项目 scripts/ 目录同步到各 agent workspace(保持 kanban_update.py 等最新)
|
||||
|
||||
Uses symlinks so that ``__file__`` in workspace copies resolves to the
|
||||
project ``scripts/`` directory, keeping path-derived constants like
|
||||
``TASKS_FILE`` pointing to the canonical ``data/`` folder.
|
||||
"""
|
||||
scripts_src = BASE / 'scripts'
|
||||
if not scripts_src.is_dir():
|
||||
return
|
||||
synced = 0
|
||||
for proj_name, runtime_id in _SOUL_DEPLOY_MAP.items():
|
||||
ws_scripts = OPENCLAW_HOME / f'workspace-{runtime_id}' / 'scripts'
|
||||
ws_scripts.mkdir(parents=True, exist_ok=True)
|
||||
for src_file in scripts_src.iterdir():
|
||||
if src_file.suffix not in ('.py', '.sh') or src_file.stem.startswith('__'):
|
||||
continue
|
||||
dst_file = ws_scripts / src_file.name
|
||||
try:
|
||||
if _sync_script_symlink(src_file, dst_file):
|
||||
synced += 1
|
||||
except Exception:
|
||||
continue
|
||||
# also sync to workspace-main for legacy compatibility
|
||||
ws_main_scripts = OPENCLAW_HOME / 'workspace-main' / 'scripts'
|
||||
ws_main_scripts.mkdir(parents=True, exist_ok=True)
|
||||
for src_file in scripts_src.iterdir():
|
||||
if src_file.suffix not in ('.py', '.sh') or src_file.stem.startswith('__'):
|
||||
continue
|
||||
dst_file = ws_main_scripts / src_file.name
|
||||
try:
|
||||
if _sync_script_symlink(src_file, dst_file):
|
||||
synced += 1
|
||||
except Exception:
|
||||
pass
|
||||
if synced:
|
||||
log.info(f'{synced} script symlinks synced to workspaces')
|
||||
|
||||
|
||||
def deploy_soul_files():
|
||||
"""将项目 agents/xxx/SOUL.md 部署到 ~/.openclaw/workspace-xxx/SOUL.md"""
|
||||
agents_dir = BASE / 'agents'
|
||||
deployed = 0
|
||||
for proj_name, runtime_id in _SOUL_DEPLOY_MAP.items():
|
||||
src = agents_dir / proj_name / 'SOUL.md'
|
||||
if not src.exists():
|
||||
continue
|
||||
ws_dst = OPENCLAW_HOME / f'workspace-{runtime_id}' / 'SOUL.md'
|
||||
ws_dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
# 只在内容不同时更新(避免不必要的写入)
|
||||
src_text = src.read_text(encoding='utf-8', errors='ignore')
|
||||
try:
|
||||
dst_text = ws_dst.read_text(encoding='utf-8', errors='ignore')
|
||||
except FileNotFoundError:
|
||||
dst_text = ''
|
||||
if src_text != dst_text:
|
||||
ws_dst.write_text(src_text, encoding='utf-8')
|
||||
deployed += 1
|
||||
# 太子兼容:同步一份到 legacy main agent 目录
|
||||
if runtime_id == 'taizi':
|
||||
ag_dst = OPENCLAW_HOME / 'agents' / 'main' / 'SOUL.md'
|
||||
ag_dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
ag_text = ag_dst.read_text(encoding='utf-8', errors='ignore')
|
||||
except FileNotFoundError:
|
||||
ag_text = ''
|
||||
if src_text != ag_text:
|
||||
ag_dst.write_text(src_text, encoding='utf-8')
|
||||
# 确保 sessions 目录存在
|
||||
sess_dir = OPENCLAW_HOME / 'agents' / runtime_id / 'sessions'
|
||||
sess_dir.mkdir(parents=True, exist_ok=True)
|
||||
if deployed:
|
||||
log.info(f'{deployed} SOUL.md files deployed')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import pathlib
|
||||
import time
|
||||
import datetime
|
||||
import traceback
|
||||
import logging
|
||||
from file_lock import atomic_json_write, atomic_json_read
|
||||
from utils import get_openclaw_home
|
||||
|
||||
log = logging.getLogger('sync_runtime')
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(message)s', datefmt='%H:%M:%S')
|
||||
|
||||
BASE = pathlib.Path(__file__).resolve().parent.parent
|
||||
DATA = BASE / 'data'
|
||||
DATA.mkdir(exist_ok=True)
|
||||
SYNC_STATUS = DATA / 'sync_status.json'
|
||||
SESSIONS_ROOT = get_openclaw_home() / 'agents'
|
||||
|
||||
|
||||
def write_status(**kwargs):
|
||||
atomic_json_write(SYNC_STATUS, kwargs)
|
||||
|
||||
|
||||
def ms_to_str(ts_ms):
|
||||
if not ts_ms:
|
||||
return '-'
|
||||
try:
|
||||
return datetime.datetime.fromtimestamp(ts_ms / 1000).strftime('%Y-%m-%d %H:%M:%S')
|
||||
except Exception:
|
||||
return '-'
|
||||
|
||||
|
||||
def state_from_session(age_ms, aborted):
|
||||
if aborted:
|
||||
return 'Blocked'
|
||||
if age_ms <= 2 * 60 * 1000:
|
||||
return 'Doing'
|
||||
if age_ms <= 60 * 60 * 1000:
|
||||
return 'Review'
|
||||
return 'Next'
|
||||
|
||||
|
||||
def detect_official(agent_id):
|
||||
mapping = {
|
||||
'main': ('储君', '太子'), # legacy id for taizi
|
||||
'taizi': ('储君', '太子'),
|
||||
'zhongshu': ('中书令', '中书省'),
|
||||
'menxia': ('侍中', '门下省'),
|
||||
'shangshu': ('尚书令', '尚书省'),
|
||||
'hubu': ('户部尚书', '户部'),
|
||||
'libu': ('礼部尚书', '礼部'),
|
||||
'bingbu': ('兵部尚书', '兵部'),
|
||||
'xingbu': ('刑部尚书', '刑部'),
|
||||
'gongbu': ('工部尚书', '工部'),
|
||||
'libu_hr': ('吏部尚书', '吏部'),
|
||||
'zaochao': ('钦天监', '钦天监'),
|
||||
}
|
||||
return mapping.get(agent_id, ('尚书令', '尚书省'))
|
||||
|
||||
|
||||
def load_activity(session_file, limit=12):
|
||||
p = pathlib.Path(session_file or '')
|
||||
if not p.exists():
|
||||
return []
|
||||
rows = []
|
||||
try:
|
||||
lines = p.read_text(errors='ignore').splitlines()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# Read all valid JSON lines first
|
||||
events = []
|
||||
for ln in lines:
|
||||
try:
|
||||
item = json.loads(ln)
|
||||
events.append(item)
|
||||
except:
|
||||
continue
|
||||
|
||||
# Process events to extract meaningful activity
|
||||
# We want to show what the agent is *thinking* or *doing*
|
||||
for item in reversed(events):
|
||||
msg = item.get('message') or {}
|
||||
role = msg.get('role')
|
||||
ts = item.get('timestamp') or ''
|
||||
|
||||
if role == 'toolResult':
|
||||
tool = msg.get('toolName', '-')
|
||||
details = msg.get('details') or {}
|
||||
# If tool output is short, show it
|
||||
content = msg.get('content', [{'text': ''}])[0].get('text', '')
|
||||
if len(content) < 50:
|
||||
text = f"Tool '{tool}' returned: {content}"
|
||||
else:
|
||||
text = f"Tool '{tool}' finished"
|
||||
rows.append({'at': ts, 'kind': 'tool', 'text': text})
|
||||
|
||||
elif role == 'assistant':
|
||||
text = ''
|
||||
for c in msg.get('content', []):
|
||||
if c.get('type') == 'text' and c.get('text'):
|
||||
raw_text = c.get('text').strip()
|
||||
# Clean up common prefixes
|
||||
clean_text = raw_text.replace('[[reply_to_current]]', '').strip()
|
||||
if clean_text:
|
||||
text = clean_text
|
||||
break
|
||||
if text:
|
||||
# Prioritize showing the "thought" - usually the first few sentences
|
||||
summary = text.split('\n')[0]
|
||||
if len(summary) > 200:
|
||||
summary = summary[:200] + '...'
|
||||
rows.append({'at': ts, 'kind': 'assistant', 'text': summary})
|
||||
|
||||
elif role == 'user':
|
||||
# Also show what user asked, can be context relevant
|
||||
text = ''
|
||||
for c in msg.get('content', []):
|
||||
if c.get('type') == 'text':
|
||||
text = c.get('text', '')[:100]
|
||||
if text:
|
||||
rows.append({'at': ts, 'kind': 'user', 'text': f"User: {text}..."})
|
||||
|
||||
if len(rows) >= limit:
|
||||
break
|
||||
|
||||
# Re-order to chronological for display if needed, but the caller usually takes the first (latest)
|
||||
return rows
|
||||
|
||||
|
||||
def build_task(agent_id, session_key, row, now_ms):
|
||||
session_id = row.get('sessionId') or session_key
|
||||
updated_at = row.get('updatedAt') or 0
|
||||
age_ms = max(0, now_ms - updated_at) if updated_at else 99 * 24 * 3600 * 1000
|
||||
aborted = bool(row.get('abortedLastRun'))
|
||||
state = state_from_session(age_ms, aborted)
|
||||
|
||||
official, org = detect_official(agent_id)
|
||||
channel = row.get('lastChannel') or (row.get('origin') or {}).get('channel') or '-'
|
||||
session_file = row.get('sessionFile', '')
|
||||
|
||||
# 尝试从 activity 获取更有意义的当前状态描述
|
||||
latest_act = '等待指令'
|
||||
acts = load_activity(session_file, limit=5)
|
||||
|
||||
# If the absolute latest is a tool result, look for the preceding assistant thought
|
||||
# because that explains *why* the tool was called.
|
||||
if acts:
|
||||
first_act = acts[0]
|
||||
if first_act['kind'] == 'tool' and len(acts) > 1:
|
||||
# Look for next assistant message (which is actually previous in time)
|
||||
for next_act in acts[1:]:
|
||||
if next_act['kind'] == 'assistant':
|
||||
latest_act = f"正在执行: {next_act['text'][:80]}"
|
||||
break
|
||||
else:
|
||||
latest_act = first_act['text'][:60]
|
||||
elif first_act['kind'] == 'assistant':
|
||||
latest_act = f"思考中: {first_act['text'][:80]}"
|
||||
else:
|
||||
latest_act = acts[0]['text'][:60]
|
||||
|
||||
title_label = (row.get('origin') or {}).get('label') or session_key
|
||||
# 清洗会话标题:agent:xxx:cron:uuid → 定时任务, agent:xxx:subagent:uuid → 子任务
|
||||
import re
|
||||
if re.match(r'agent:\w+:cron:', title_label):
|
||||
title = f"{org}定时任务"
|
||||
elif re.match(r'agent:\w+:subagent:', title_label):
|
||||
title = f"{org}子任务"
|
||||
elif title_label == session_key or len(title_label) > 40:
|
||||
title = f"{org}会话"
|
||||
else:
|
||||
title = f"{title_label}"
|
||||
|
||||
return {
|
||||
'id': f"OC-{agent_id}-{str(session_id)[:8]}",
|
||||
'title': title,
|
||||
'official': official,
|
||||
'org': org,
|
||||
'state': state,
|
||||
'now': latest_act,
|
||||
'eta': ms_to_str(updated_at),
|
||||
'block': '上次运行中断' if aborted else '无',
|
||||
'output': session_file,
|
||||
'flow': {
|
||||
'draft': f"agent={agent_id}",
|
||||
'review': f"updatedAt={ms_to_str(updated_at)}",
|
||||
'dispatch': f"sessionKey={session_key}",
|
||||
},
|
||||
'ac': '来自 OpenClaw runtime sessions 的实时映射',
|
||||
'activity': load_activity(session_file, limit=10),
|
||||
'sourceMeta': {
|
||||
'agentId': agent_id,
|
||||
'sessionKey': session_key,
|
||||
'sessionId': session_id,
|
||||
'updatedAt': updated_at,
|
||||
'ageMs': age_ms,
|
||||
'systemSent': bool(row.get('systemSent')),
|
||||
'abortedLastRun': aborted,
|
||||
'inputTokens': row.get('inputTokens'),
|
||||
'outputTokens': row.get('outputTokens'),
|
||||
'totalTokens': row.get('totalTokens'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
start = time.time()
|
||||
now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
now_ms = int(time.time() * 1000)
|
||||
|
||||
try:
|
||||
tasks = []
|
||||
scan_files = 0
|
||||
|
||||
if SESSIONS_ROOT.exists():
|
||||
for agent_dir in sorted(SESSIONS_ROOT.iterdir()):
|
||||
if not agent_dir.is_dir():
|
||||
continue
|
||||
agent_id = agent_dir.name
|
||||
sessions_file = agent_dir / 'sessions' / 'sessions.json'
|
||||
if not sessions_file.exists():
|
||||
continue
|
||||
scan_files += 1
|
||||
|
||||
try:
|
||||
raw = json.loads(sessions_file.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
|
||||
for session_key, row in raw.items():
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
tasks.append(build_task(agent_id, session_key, row, now_ms))
|
||||
|
||||
# merge mission control tasks (最小接入)
|
||||
mc_tasks_file = DATA / 'mission_control_tasks.json'
|
||||
if mc_tasks_file.exists():
|
||||
try:
|
||||
mc_tasks = json.loads(mc_tasks_file.read_text())
|
||||
if isinstance(mc_tasks, list):
|
||||
tasks.extend(mc_tasks)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# merge manual parallel tasks (用于军机处并行看板展示)
|
||||
manual_tasks_file = DATA / 'manual_parallel_tasks.json'
|
||||
if manual_tasks_file.exists():
|
||||
try:
|
||||
manual_tasks = json.loads(manual_tasks_file.read_text())
|
||||
if isinstance(manual_tasks, list):
|
||||
tasks.extend(manual_tasks)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tasks.sort(key=lambda x: x.get('sourceMeta', {}).get('updatedAt', 0), reverse=True)
|
||||
|
||||
# 去重(同一 id 只保留第一个=最新的)
|
||||
seen_ids = set()
|
||||
deduped = []
|
||||
for t in tasks:
|
||||
if t['id'] not in seen_ids:
|
||||
seen_ids.add(t['id'])
|
||||
deduped.append(t)
|
||||
tasks = deduped
|
||||
|
||||
# ── 过滤掉非 JJC 且非活跃的系统会话,防止看板噪音 ──
|
||||
# 规则: 仅保留 24小时内更新的活跃会话,且排除 cron/subagent 等纯后台任务
|
||||
filtered_tasks = []
|
||||
one_day_ago = now_ms - 24 * 3600 * 1000
|
||||
for t in tasks:
|
||||
# 始终保留 JJC 任务(如果有的话,虽然这里主要是 OC 任务,但以防万一)
|
||||
if str(t['id']).startswith('JJC'):
|
||||
filtered_tasks.append(t)
|
||||
continue
|
||||
|
||||
# OC 任务过滤
|
||||
updated = t.get('sourceMeta', {}).get('updatedAt', 0)
|
||||
title = t.get('title', '')
|
||||
|
||||
# 1. 排除太旧的 (超过24小时)
|
||||
if updated < one_day_ago:
|
||||
continue
|
||||
|
||||
# 2. 排除纯后台 cron / subagent 任务,除非它们正在报错
|
||||
if '定时任务' in title or '子任务' in title:
|
||||
# 只有当它 block 或者 error 时才显示,否则视为噪音
|
||||
if t.get('state') != 'Blocked':
|
||||
continue
|
||||
|
||||
# 3. 排除已冷却的 OC 会话,避免污染看板
|
||||
# 保留 Doing(<2min)、Review(<60min)、Blocked(报错)
|
||||
# 仅过滤掉 Next(>60min 无响应)等已结束/闲置的会话
|
||||
state = t.get('state')
|
||||
if state not in ('Doing', 'Review', 'Blocked'):
|
||||
continue
|
||||
|
||||
filtered_tasks.append(t)
|
||||
|
||||
tasks = filtered_tasks
|
||||
|
||||
# ── 保留已有的 JJC-* 旨意任务(不覆盖皇上下旨记录)──
|
||||
# JJC 任务的 now 字段由 Agent 自己通过 kanban_update.py progress 命令主动上报,
|
||||
# 不再从会话日志中被动抓取。这里只做合并,不做 activity 映射。
|
||||
existing_tasks_file = DATA / 'tasks_source.json'
|
||||
if existing_tasks_file.exists():
|
||||
try:
|
||||
existing = json.loads(existing_tasks_file.read_text())
|
||||
jjc_existing = [t for t in existing if str(t.get('id', '')).startswith('JJC')]
|
||||
|
||||
# 去掉 tasks 里已有的 JJC(以防重复),再把旨意放到最前面
|
||||
tasks = [t for t in tasks if not str(t.get('id', '')).startswith('JJC')]
|
||||
tasks = jjc_existing + tasks
|
||||
except Exception as e:
|
||||
log.error(f'merge existing JJC tasks failed: {e}')
|
||||
pass
|
||||
|
||||
atomic_json_write(DATA / 'tasks_source.json', tasks)
|
||||
|
||||
duration_ms = int((time.time() - start) * 1000)
|
||||
write_status(
|
||||
ok=True,
|
||||
lastSyncAt=now,
|
||||
durationMs=duration_ms,
|
||||
source='openclaw_runtime_sessions',
|
||||
recordCount=len(tasks),
|
||||
scannedSessionFiles=scan_files,
|
||||
missingFields={},
|
||||
error=None,
|
||||
)
|
||||
log.info(f'synced {len(tasks)} tasks from openclaw runtime in {duration_ms}ms')
|
||||
|
||||
except Exception as e:
|
||||
duration_ms = int((time.time() - start) * 1000)
|
||||
write_status(
|
||||
ok=False,
|
||||
lastSyncAt=now,
|
||||
durationMs=duration_ms,
|
||||
source='openclaw_runtime_sessions',
|
||||
recordCount=0,
|
||||
missingFields={},
|
||||
error=f'{type(e).__name__}: {e}',
|
||||
traceback=traceback.format_exc(limit=3),
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""同步各官员统计数据 → data/officials_stats.json"""
|
||||
import json, pathlib, datetime, logging
|
||||
from file_lock import atomic_json_write
|
||||
from utils import get_openclaw_home
|
||||
|
||||
log = logging.getLogger('officials')
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(message)s', datefmt='%H:%M:%S')
|
||||
|
||||
BASE = pathlib.Path(__file__).resolve().parent.parent
|
||||
DATA = BASE / 'data'
|
||||
OPENCLAW_HOME = get_openclaw_home()
|
||||
AGENTS_ROOT = OPENCLAW_HOME / 'agents'
|
||||
OPENCLAW_CFG = OPENCLAW_HOME / 'openclaw.json'
|
||||
|
||||
# Anthropic 定价(每1M token,美元)
|
||||
MODEL_PRICING = {
|
||||
'anthropic/claude-sonnet-4-6': {'in':3.0, 'out':15.0, 'cr':0.30, 'cw':3.75},
|
||||
'anthropic/claude-opus-4-5': {'in':15.0,'out':75.0, 'cr':1.50, 'cw':18.75},
|
||||
'anthropic/claude-haiku-3-5': {'in':0.8, 'out':4.0, 'cr':0.08, 'cw':1.0},
|
||||
'openai/gpt-4o': {'in':2.5, 'out':10.0, 'cr':1.25, 'cw':0},
|
||||
'openai/gpt-4o-mini': {'in':0.15,'out':0.6, 'cr':0.075,'cw':0},
|
||||
'google/gemini-2.0-flash': {'in':0.075,'out':0.3, 'cr':0, 'cw':0},
|
||||
'google/gemini-2.5-pro': {'in':1.25,'out':10.0, 'cr':0, 'cw':0},
|
||||
}
|
||||
|
||||
OFFICIALS = [
|
||||
{'id':'taizi', 'label':'太子', 'role':'太子', 'emoji':'🤴','rank':'储君'},
|
||||
{'id':'zhongshu','label':'中书省','role':'中书令', 'emoji':'📜','rank':'正一品'},
|
||||
{'id':'menxia', 'label':'门下省','role':'侍中', 'emoji':'🔍','rank':'正一品'},
|
||||
{'id':'shangshu','label':'尚书省','role':'尚书令', 'emoji':'📮','rank':'正一品'},
|
||||
{'id':'libu', 'label':'礼部', 'role':'礼部尚书','emoji':'📝','rank':'正二品'},
|
||||
{'id':'hubu', 'label':'户部', 'role':'户部尚书','emoji':'💰','rank':'正二品'},
|
||||
{'id':'bingbu', 'label':'兵部', 'role':'兵部尚书','emoji':'⚔️','rank':'正二品'},
|
||||
{'id':'xingbu', 'label':'刑部', 'role':'刑部尚书','emoji':'⚖️','rank':'正二品'},
|
||||
{'id':'gongbu', 'label':'工部', 'role':'工部尚书','emoji':'🔧','rank':'正二品'},
|
||||
{'id':'libu_hr', 'label':'吏部', 'role':'吏部尚书','emoji':'👔','rank':'正二品'},
|
||||
{'id':'zaochao', 'label':'钦天监','role':'朝报官', 'emoji':'📰','rank':'正三品'},
|
||||
]
|
||||
|
||||
def rj(p, d):
|
||||
try:
|
||||
return json.loads(pathlib.Path(p).read_text(encoding='utf-8'))
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
|
||||
# Pre-load openclaw config once (avoid re-reading per agent)
|
||||
_OPENCLAW_CACHE = None
|
||||
|
||||
def _load_openclaw_cfg():
|
||||
global _OPENCLAW_CACHE
|
||||
if _OPENCLAW_CACHE is None:
|
||||
_OPENCLAW_CACHE = rj(OPENCLAW_CFG, {})
|
||||
return _OPENCLAW_CACHE
|
||||
|
||||
|
||||
def normalize_model(model_value, fallback='anthropic/claude-sonnet-4-6'):
|
||||
if isinstance(model_value, str) and model_value:
|
||||
return model_value
|
||||
if isinstance(model_value, dict):
|
||||
return model_value.get('primary') or model_value.get('id') or fallback
|
||||
return fallback
|
||||
|
||||
def get_model(agent_id):
|
||||
cfg = _load_openclaw_cfg()
|
||||
default = normalize_model(cfg.get('agents',{}).get('defaults',{}).get('model',{}), 'anthropic/claude-sonnet-4-6')
|
||||
for a in cfg.get('agents',{}).get('list',[]):
|
||||
if a.get('id') == agent_id:
|
||||
return normalize_model(a.get('model', default), default)
|
||||
# 兼容历史:太子曾使用 main 作为运行时 id
|
||||
if agent_id == 'taizi':
|
||||
for a in cfg.get('agents',{}).get('list',[]):
|
||||
if a.get('id') == 'main':
|
||||
return normalize_model(a.get('model', default), default)
|
||||
return default
|
||||
|
||||
def scan_agent(agent_id):
|
||||
"""从 sessions.json 读取 token 统计(累计所有 session)"""
|
||||
sj = AGENTS_ROOT / agent_id / 'sessions' / 'sessions.json'
|
||||
if not sj.exists() and agent_id == 'taizi':
|
||||
sj = AGENTS_ROOT / 'main' / 'sessions' / 'sessions.json'
|
||||
if not sj.exists():
|
||||
return {'tokens_in':0,'tokens_out':0,'cache_read':0,'cache_write':0,'sessions':0,'last_active':None,'messages':0}
|
||||
|
||||
data = rj(sj, {})
|
||||
tin = tout = cr = cw = 0
|
||||
last_ts = None
|
||||
|
||||
for sid, v in data.items():
|
||||
tin += v.get('inputTokens', 0) or 0
|
||||
tout += v.get('outputTokens', 0) or 0
|
||||
cr += v.get('cacheRead', 0) or 0
|
||||
cw += v.get('cacheWrite', 0) or 0
|
||||
ts = v.get('updatedAt')
|
||||
if ts:
|
||||
try:
|
||||
t = datetime.datetime.fromtimestamp(ts/1000) if isinstance(ts,int) else datetime.datetime.fromisoformat(ts.replace('Z','+00:00'))
|
||||
if last_ts is None or t > last_ts: last_ts = t
|
||||
except Exception: pass
|
||||
|
||||
# Estimate message count from most recent session JSONL
|
||||
msg_count = 0
|
||||
if data:
|
||||
try:
|
||||
sf_key = max(data.keys(), key=lambda k: data[k].get('updatedAt',0) or 0, default=None)
|
||||
except Exception:
|
||||
sf_key = None
|
||||
else:
|
||||
sf_key = None
|
||||
if sf_key and data[sf_key].get('sessionFile'):
|
||||
sf = AGENTS_ROOT / agent_id / 'sessions' / pathlib.Path(data[sf_key]['sessionFile']).name
|
||||
try:
|
||||
lines = sf.read_text(errors='ignore').splitlines()
|
||||
for ln in lines:
|
||||
try:
|
||||
e = json.loads(ln)
|
||||
if e.get('type') == 'message' and e.get('message',{}).get('role') == 'assistant':
|
||||
msg_count += 1
|
||||
except Exception: pass
|
||||
except Exception: pass
|
||||
|
||||
return {
|
||||
'tokens_in': tin, 'tokens_out': tout,
|
||||
'cache_read': cr, 'cache_write': cw,
|
||||
'sessions': len(data),
|
||||
'last_active': last_ts.strftime('%Y-%m-%d %H:%M') if last_ts else None,
|
||||
'messages': msg_count,
|
||||
}
|
||||
|
||||
def calc_cost(s, model):
|
||||
p = MODEL_PRICING.get(model, MODEL_PRICING['anthropic/claude-sonnet-4-6'])
|
||||
usd = (s['tokens_in']/1e6*p['in'] + s['tokens_out']/1e6*p['out']
|
||||
+ s['cache_read']/1e6*p['cr'] + s['cache_write']/1e6*p['cw'])
|
||||
return round(usd, 4)
|
||||
|
||||
def get_task_stats(org_label, tasks):
|
||||
done = [t for t in tasks if t.get('state')=='Done' and t.get('org')==org_label]
|
||||
active = [t for t in tasks if t.get('state') in ('Doing','Review','Assigned') and t.get('org')==org_label]
|
||||
fl = sum(1 for t in tasks for f in t.get('flow_log',[])
|
||||
if f.get('from')==org_label or f.get('to')==org_label)
|
||||
# 参与的旨意(JJC)列表
|
||||
participated = []
|
||||
for t in tasks:
|
||||
if not t['id'].startswith('JJC'): continue
|
||||
for f in t.get('flow_log',[]):
|
||||
if f.get('from')==org_label or f.get('to')==org_label:
|
||||
if t['id'] not in [x['id'] for x in participated]:
|
||||
participated.append({'id':t['id'],'title':t.get('title',''),'state':t.get('state','')})
|
||||
break
|
||||
return {'tasks_done':len(done),'tasks_active':len(active),
|
||||
'flow_participations':fl,'participated_edicts':participated}
|
||||
|
||||
def get_hb(agent_id, live_tasks):
|
||||
for t in live_tasks:
|
||||
if t.get('sourceMeta',{}).get('agentId')==agent_id and t.get('heartbeat'):
|
||||
return t['heartbeat']
|
||||
return {'status':'idle','label':'⚪ 待命','ageSec':None}
|
||||
|
||||
def main():
|
||||
tasks = rj(DATA/'tasks_source.json', [])
|
||||
live = rj(DATA/'live_status.json', {})
|
||||
live_tasks = live.get('tasks', [])
|
||||
|
||||
result = []
|
||||
for off in OFFICIALS:
|
||||
model = get_model(off['id'])
|
||||
ss = scan_agent(off['id'])
|
||||
ts = get_task_stats(off['label'], tasks)
|
||||
hb = get_hb(off['id'], live_tasks)
|
||||
cost_usd = calc_cost(ss, model)
|
||||
|
||||
result.append({
|
||||
**off,
|
||||
'model': model,
|
||||
'model_short': model.split('/')[-1] if isinstance(model, str) and '/' in model else str(model),
|
||||
'sessions': ss['sessions'],
|
||||
'tokens_in': ss['tokens_in'],
|
||||
'tokens_out': ss['tokens_out'],
|
||||
'cache_read': ss['cache_read'],
|
||||
'cache_write': ss['cache_write'],
|
||||
'tokens_total': ss['tokens_in'] + ss['tokens_out'],
|
||||
'messages': ss['messages'],
|
||||
'cost_usd': cost_usd,
|
||||
'cost_cny': round(cost_usd * 7.25, 2),
|
||||
'last_active': ss['last_active'],
|
||||
'heartbeat': hb,
|
||||
'tasks_done': ts['tasks_done'],
|
||||
'tasks_active': ts['tasks_active'],
|
||||
'flow_participations': ts['flow_participations'],
|
||||
'participated_edicts': ts['participated_edicts'],
|
||||
'merit_score': ts['tasks_done']*10 + ts['flow_participations']*2 + min(ss['sessions'],20),
|
||||
})
|
||||
|
||||
result.sort(key=lambda x: x['merit_score'], reverse=True)
|
||||
for i, r in enumerate(result): r['merit_rank'] = i+1
|
||||
|
||||
totals = {
|
||||
'tokens_total': sum(r['tokens_total'] for r in result),
|
||||
'cache_total': sum(r['cache_read']+r['cache_write'] for r in result),
|
||||
'cost_usd': round(sum(r['cost_usd'] for r in result), 2),
|
||||
'cost_cny': round(sum(r['cost_cny'] for r in result), 2),
|
||||
'tasks_done': sum(r['tasks_done'] for r in result),
|
||||
}
|
||||
top = max(result, key=lambda x: x['merit_score'], default={})
|
||||
|
||||
payload = {
|
||||
'generatedAt': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'officials': result,
|
||||
'totals': totals,
|
||||
'top_official': top.get('label',''),
|
||||
}
|
||||
atomic_json_write(DATA/'officials_stats.json', payload)
|
||||
log.info(f'{len(result)} officials | cost=¥{totals["cost_cny"]} | top={top.get("label","")}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Take all dashboard screenshots for the README using Playwright."""
|
||||
from playwright.sync_api import sync_playwright
|
||||
import time, os
|
||||
|
||||
SHOTS = os.path.join(os.path.dirname(__file__), '..', 'docs', 'screenshots')
|
||||
URL = 'http://localhost:7891'
|
||||
|
||||
def main():
|
||||
os.makedirs(SHOTS, exist_ok=True)
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
ctx = browser.new_context(
|
||||
viewport={'width': 1920, 'height': 1080},
|
||||
device_scale_factor=2,
|
||||
color_scheme='dark',
|
||||
)
|
||||
page = ctx.new_page()
|
||||
|
||||
# ── Clear ceremony localStorage so it doesn't show on every load
|
||||
page.goto(URL)
|
||||
page.evaluate("localStorage.setItem('openclaw_court_date', new Date().toISOString().substring(0,10))")
|
||||
page.reload()
|
||||
page.wait_for_load_state('networkidle')
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# 1. Kanban main (default tab = edicts)
|
||||
print('📋 01 kanban...')
|
||||
page.screenshot(path=os.path.join(SHOTS, '01-kanban-main.png'), full_page=False)
|
||||
|
||||
# 2. Monitor (省部调度)
|
||||
print('🔭 02 monitor...')
|
||||
page.click('[data-tab="monitor"]')
|
||||
page.wait_for_timeout(800)
|
||||
page.screenshot(path=os.path.join(SHOTS, '02-monitor.png'), full_page=False)
|
||||
|
||||
# 3. Task detail - click first task card
|
||||
print('📜 03 task detail...')
|
||||
page.click('[data-tab="edicts"]')
|
||||
page.wait_for_timeout(500)
|
||||
cards = page.locator('.edict-card')
|
||||
if cards.count() > 0:
|
||||
cards.first.click()
|
||||
page.wait_for_timeout(800)
|
||||
page.screenshot(path=os.path.join(SHOTS, '03-task-detail.png'), full_page=False)
|
||||
# Close modal
|
||||
page.keyboard.press('Escape')
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
# 4. Model config
|
||||
print('⚙️ 04 models...')
|
||||
page.click('[data-tab="models"]')
|
||||
page.wait_for_load_state('networkidle')
|
||||
page.wait_for_timeout(1000)
|
||||
page.screenshot(path=os.path.join(SHOTS, '04-model-config.png'), full_page=False)
|
||||
|
||||
# 5. Skills config
|
||||
print('🛠️ 05 skills...')
|
||||
page.click('[data-tab="skills"]')
|
||||
page.wait_for_load_state('networkidle')
|
||||
page.wait_for_timeout(1000)
|
||||
page.screenshot(path=os.path.join(SHOTS, '05-skills-config.png'), full_page=False)
|
||||
|
||||
# 6. Officials overview
|
||||
print('👥 06 officials...')
|
||||
page.click('[data-tab="officials"]')
|
||||
page.wait_for_timeout(1000)
|
||||
page.screenshot(path=os.path.join(SHOTS, '06-official-overview.png'), full_page=False)
|
||||
|
||||
# 7. Sessions
|
||||
print('💬 07 sessions...')
|
||||
page.click('[data-tab="sessions"]')
|
||||
page.wait_for_timeout(800)
|
||||
page.screenshot(path=os.path.join(SHOTS, '07-sessions.png'), full_page=False)
|
||||
|
||||
# 8. Memorials
|
||||
print('📜 08 memorials...')
|
||||
page.click('[data-tab="memorials"]')
|
||||
page.wait_for_timeout(800)
|
||||
page.screenshot(path=os.path.join(SHOTS, '08-memorials.png'), full_page=False)
|
||||
|
||||
# 9. Templates
|
||||
print('📜 09 templates...')
|
||||
page.click('[data-tab="templates"]')
|
||||
page.wait_for_timeout(800)
|
||||
page.screenshot(path=os.path.join(SHOTS, '09-templates.png'), full_page=False)
|
||||
|
||||
# 10. Morning briefing
|
||||
print('📰 10 morning...')
|
||||
page.click('[data-tab="morning"]')
|
||||
page.wait_for_timeout(1000)
|
||||
page.screenshot(path=os.path.join(SHOTS, '10-morning-briefing.png'), full_page=False)
|
||||
|
||||
# 11. Ceremony - clear date then reload
|
||||
print('🎬 11 ceremony...')
|
||||
page.evaluate("localStorage.removeItem('openclaw_court_date')")
|
||||
page.reload()
|
||||
page.wait_for_timeout(2500)
|
||||
page.screenshot(path=os.path.join(SHOTS, '11-ceremony.png'), full_page=False)
|
||||
|
||||
browser.close()
|
||||
print('✅ All screenshots saved to', SHOTS)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
三省六部 · 公共工具函数
|
||||
避免 read_json / now_iso 等基础函数在多个脚本中重复定义
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json, pathlib, datetime, shutil
|
||||
|
||||
|
||||
def read_json(path, default=None):
|
||||
"""安全读取 JSON 文件,失败返回 default"""
|
||||
try:
|
||||
return json.loads(pathlib.Path(path).read_text(encoding='utf-8'))
|
||||
except Exception:
|
||||
return default if default is not None else {}
|
||||
|
||||
|
||||
def get_openclaw_home() -> pathlib.Path:
|
||||
"""Return OpenClaw home directory, respecting OPENCLAW_HOME env var."""
|
||||
env = os.environ.get('OPENCLAW_HOME')
|
||||
if env:
|
||||
return pathlib.Path(env).expanduser()
|
||||
return pathlib.Path.home() / '.openclaw'
|
||||
|
||||
|
||||
def now_iso():
|
||||
"""返回 UTC ISO 8601 时间字符串(末尾 Z)"""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||
|
||||
|
||||
def today_str(fmt='%Y%m%d'):
|
||||
"""返回今天日期字符串,默认 YYYYMMDD"""
|
||||
return datetime.date.today().strftime(fmt)
|
||||
|
||||
|
||||
def safe_name(s: str) -> bool:
|
||||
"""检查名称是否只含安全字符(字母、数字、下划线、连字符、中文)"""
|
||||
import re
|
||||
return bool(re.match(r'^[a-zA-Z0-9_\-\u4e00-\u9fff]+$', s))
|
||||
|
||||
|
||||
def python_bin() -> str:
|
||||
"""返回当前 Python 解释器路径,兼容 Windows(无 python3 命令)"""
|
||||
return sys.executable or shutil.which('python3') or shutil.which('python') or 'python3'
|
||||
|
||||
|
||||
def validate_url(url: str, allowed_schemes=('https',), allowed_domains=None) -> bool:
|
||||
"""校验 URL 合法性,防 SSRF"""
|
||||
from urllib.parse import urlparse
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in allowed_schemes:
|
||||
return False
|
||||
if allowed_domains and parsed.hostname not in allowed_domains:
|
||||
return False
|
||||
if not parsed.hostname:
|
||||
return False
|
||||
# 禁止内网地址
|
||||
import ipaddress
|
||||
try:
|
||||
ip = ipaddress.ip_address(parsed.hostname)
|
||||
if ip.is_private or ip.is_loopback or ip.is_reserved:
|
||||
return False
|
||||
except ValueError:
|
||||
pass # hostname 不是 IP,放行
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
Reference in New Issue
Block a user