feat: 朝堂议政功能 + GitHub issues 批量优化

新增功能:
- 朝堂议政(Court Discussion): 多官员围绕议题展开部门视角讨论
  - 后端 court_discuss.py + 前端 CourtDiscussion.tsx
  - 集成 GitHub Copilot API (gpt-4o)
  - 各部门依据 SOUL.md 职责发表专业意见

GitHub Issues 修复:
- #127: 模型下拉列表自动合并 openclaw.json 已配置模型
- #83:  install.sh 安装时设置 sessions.visibility all
- #88:  install.sh 用 symlink 统一各 workspace 的 data/scripts
- #80:  调度器 stallThreshold 180s→600s, maxRetry 1→2
- #124: skill_manager 增加镜像回退 + 自定义 Hub URL
- #132: sync_from_openclaw_runtime 放宽过滤,保留 Review 状态任务
This commit is contained in:
cft0808
2026-03-14 23:57:24 +08:00
parent 6b3ab88128
commit b91675bc4c
19 changed files with 1829 additions and 111 deletions

View File

@@ -287,10 +287,13 @@ chmod +x install.sh && ./install.sh
- ✅ 创建全量 Agent Workspace含太子/吏部/早朝,兼容历史 main
- ✅ 写入各省部 SOUL.md角色人格 + 工作流规则 + 数据清洗规范)
- ✅ 注册 Agent 及权限矩阵到 `openclaw.json`
-**同步 API Key 到所有 Agent**(自动从已配置的 Agent 复制)
- ✅ 构建 React 前端(需 Node.js 18+,如未安装则跳过)
- ✅ 初始化数据目录 + 首次数据同步
- ✅ 重启 Gateway 使配置生效
> ⚠️ **首次安装**:需先配置 API Key`openclaw agents add taizi`,然后重新运行 `./install.sh` 同步到所有 Agent。
#### 启动
```bash

693
dashboard/court_discuss.py Normal file
View File

@@ -0,0 +1,693 @@
"""
朝堂议政引擎 — 多官员实时讨论系统
灵感来源于 nvwa 项目的 group_chat + crew_engine
将官员可视化 + 实时讨论 + 用户(皇帝)参与融合到三省六部
功能:
- 选择官员参与议政
- 围绕旨意/议题进行多轮群聊讨论
- 皇帝可随时发言、下旨干预(天命降临)
- 命运骰子:随机事件
- 每个官员保持自己的角色性格和说话风格
"""
import json
import logging
import os
import time
import uuid
logger = logging.getLogger('court_discuss')
# ── 官员角色设定 ──
OFFICIAL_PROFILES = {
'taizi': {
'name': '太子', 'emoji': '🤴', 'role': '储君',
'duty': '消息分拣与需求提炼。判断事务轻重缓急,简单事直接处置,重大事务提炼需求转交中书省。代皇帝巡视各部进展。',
'personality': '年轻有为、锐意进取,偶尔冲动但善于学习。说话干脆利落,喜欢用现代化的比喻。',
'speaking_style': '简洁有力,经常用"本宫以为"开头,偶尔蹦出网络用语。'
},
'zhongshu': {
'name': '中书令', 'emoji': '📜', 'role': '正一品·中书省',
'duty': '方案规划与流程驱动。接收旨意后起草执行方案,提交门下省审议,通过后转尚书省执行。只规划不执行,方案需简明扼要。',
'personality': '老成持重,擅长规划,总能提出系统性方案。话多但有条理。',
'speaking_style': '喜欢列点论述,常说"臣以为需从三方面考量"。引经据典。'
},
'menxia': {
'name': '侍中', 'emoji': '🔍', 'role': '正一品·门下省',
'duty': '方案审议与把关。从可行性、完整性、风险、资源四维度审核方案,有权封驳退回。发现漏洞必须指出,建议必须具体。',
'personality': '严谨挑剔,眼光犀利,善于找漏洞。是天生的审查官,但也很公正。',
'speaking_style': '喜欢反问,"陛下容禀,此处有三点疑虑"。对不完善的方案会直言不讳。'
},
'shangshu': {
'name': '尚书令', 'emoji': '📮', 'role': '正一品·尚书省',
'duty': '任务派发与执行协调。接收准奏方案后判断归属哪个部门,分发给六部执行,汇总结果回报。相当于任务分发中心。',
'personality': '执行力强,务实干练,关注可行性和资源分配。',
'speaking_style': '直来直去,"臣来安排""交由某部办理"。重效率轻虚文。'
},
'libu': {
'name': '礼部尚书', 'emoji': '📝', 'role': '正二品·礼部',
'duty': '文档规范与对外沟通。负责撰写文档、用户指南、变更日志制定输出规范和模板审查UI/UX文案草拟公告、Release Notes。',
'personality': '文采飞扬,注重规范和形式,擅长文档和汇报。有点强迫症。',
'speaking_style': '措辞优美,"臣斗胆建议",喜欢用排比和对仗。'
},
'hubu': {
'name': '户部尚书', 'emoji': '💰', 'role': '正二品·户部',
'duty': '数据统计与资源管理。负责数据收集/清洗/聚合/可视化Token用量统计、性能指标计算、成本分析CSV/JSON报表生成文件组织与配置管理。',
'personality': '精打细算,对预算和资源极其敏感。总想省钱但也识大局。',
'speaking_style': '言必及成本,"这个预算嘛……",经常算账。'
},
'bingbu': {
'name': '兵部尚书', 'emoji': '⚔️', 'role': '正二品·兵部',
'duty': '基础设施与运维保障。负责服务器管理、进程守护、日志排查CI/CD、容器编排、灰度发布、回滚策略性能监控防火墙、权限管控、漏洞扫描。',
'personality': '雷厉风行,危机意识强,重视安全和应急。说话带军人气质。',
'speaking_style': '干脆果断,"末将建议立即执行""兵贵神速"'
},
'xingbu': {
'name': '刑部尚书', 'emoji': '⚖️', 'role': '正二品·刑部',
'duty': '质量保障与合规审计。负责代码审查逻辑正确性、边界条件、异常处理编写测试、覆盖率分析Bug定位与根因分析权限检查、敏感信息排查。',
'personality': '严明公正,重视规则和底线。善于质量把控和风险评估。',
'speaking_style': '逻辑严密,"依律当如此""需审慎考量风险"'
},
'gongbu': {
'name': '工部尚书', 'emoji': '🔧', 'role': '正二品·工部',
'duty': '工程实现与架构设计。负责需求分析、方案设计、代码实现、接口对接;模块划分、数据结构/API设计代码重构、性能优化、技术债清偿脚本与自动化工具。',
'personality': '技术宅,动手能力强,喜欢谈实现细节。偶尔社恐但一说到技术就滔滔不绝。',
'speaking_style': '喜欢说技术术语,"从技术角度来看""这个架构建议用……"'
},
'libu_hr': {
'name': '吏部尚书', 'emoji': '👔', 'role': '正二品·吏部',
'duty': '人事管理与团队建设。负责新成员Agent评估接入、能力测试Skill编写与Prompt调优、知识库维护输出质量评分、效率分析协作规范制定。',
'personality': '知人善任,擅长人员安排和组织协调。八面玲珑但有原则。',
'speaking_style': '关注人的因素,"此事需考虑各部人手""建议由某某负责"'
},
}
# ── 命运骰子事件(古风版)──
FATE_EVENTS = [
'八百里加急:边疆战报传来,所有人必须讨论应急方案',
'钦天监急报:天象异常,太史公占卜后建议暂缓此事',
'新科状元觐见,带来了意想不到的新视角',
'匿名奏折揭露了计划中一个被忽视的重大漏洞',
'户部清点发现国库余银比预期多一倍,可以加大投入',
'一位告老还乡的前朝元老突然上书,分享前车之鉴',
'民间舆论突变百姓对此事态度出现180度转折',
'邻国使节来访,带来了合作机遇也带来了竞争压力',
'太后懿旨:要求优先考虑民生影响',
'暴雨连日,多地受灾,资源需重新调配',
'发现前朝古籍中竟有类似问题的解决方案',
'翰林院提出了一个大胆的替代方案,令人耳目一新',
'各部积压的旧案突然需要一起处理,人手紧张',
'皇帝做了一个意味深长的梦,暗示了一个全新的方向',
'突然有人拿出了竞争对手的情报,局面瞬间改变',
'一场意外让所有人不得不在半天内拿出结论',
]
# ── Session 管理 ──
_sessions: dict[str, dict] = {}
def create_session(topic: str, official_ids: list[str], task_id: str = '') -> dict:
"""创建新的朝堂议政会话。"""
session_id = str(uuid.uuid4())[:8]
officials = []
for oid in official_ids:
profile = OFFICIAL_PROFILES.get(oid)
if profile:
officials.append({**profile, 'id': oid})
if not officials:
return {'ok': False, 'error': '至少选择一位官员'}
session = {
'session_id': session_id,
'topic': topic,
'task_id': task_id,
'officials': officials,
'messages': [{
'type': 'system',
'content': f'🏛 朝堂议政开始 —— 议题:{topic}',
'timestamp': time.time(),
}],
'round': 0,
'phase': 'discussing', # discussing | concluded
'created_at': time.time(),
}
_sessions[session_id] = session
return _serialize(session)
def advance_discussion(session_id: str, user_message: str = None,
decree: str = None) -> dict:
"""推进一轮讨论,使用内置模拟或 LLM。"""
session = _sessions.get(session_id)
if not session:
return {'ok': False, 'error': f'会话 {session_id} 不存在'}
session['round'] += 1
round_num = session['round']
# 记录皇帝发言
if user_message:
session['messages'].append({
'type': 'emperor',
'content': user_message,
'timestamp': time.time(),
})
# 记录天命降临
if decree:
session['messages'].append({
'type': 'decree',
'content': decree,
'timestamp': time.time(),
})
# 尝试用 LLM 生成讨论
llm_result = _llm_discuss(session, user_message, decree)
if llm_result:
new_messages = llm_result.get('messages', [])
scene_note = llm_result.get('scene_note')
else:
# 降级到规则模拟
new_messages = _simulated_discuss(session, user_message, decree)
scene_note = None
# 添加到历史
for msg in new_messages:
session['messages'].append({
'type': 'official',
'official_id': msg.get('official_id', ''),
'official_name': msg.get('name', ''),
'content': msg.get('content', ''),
'emotion': msg.get('emotion', 'neutral'),
'action': msg.get('action'),
'timestamp': time.time(),
})
if scene_note:
session['messages'].append({
'type': 'scene_note',
'content': scene_note,
'timestamp': time.time(),
})
return {
'ok': True,
'session_id': session_id,
'round': round_num,
'new_messages': new_messages,
'scene_note': scene_note,
'total_messages': len(session['messages']),
}
def get_session(session_id: str) -> dict | None:
session = _sessions.get(session_id)
if not session:
return None
return _serialize(session)
def conclude_session(session_id: str) -> dict:
"""结束议政,生成总结。"""
session = _sessions.get(session_id)
if not session:
return {'ok': False, 'error': f'会话 {session_id} 不存在'}
session['phase'] = 'concluded'
# 尝试用 LLM 生成总结
summary = _llm_summarize(session)
if not summary:
# 降级到简单统计
official_msgs = [m for m in session['messages'] if m['type'] == 'official']
by_name = {}
for m in official_msgs:
name = m.get('official_name', '?')
by_name[name] = by_name.get(name, 0) + 1
parts = [f"{n}发言{c}" for n, c in by_name.items()]
summary = f"历经{session['round']}轮讨论,{''.join(parts)}。议题待后续落实。"
session['messages'].append({
'type': 'system',
'content': f'📋 朝堂议政结束 —— {summary}',
'timestamp': time.time(),
})
session['summary'] = summary
return {
'ok': True,
'session_id': session_id,
'summary': summary,
}
def list_sessions() -> list[dict]:
"""列出所有活跃会话。"""
return [
{
'session_id': s['session_id'],
'topic': s['topic'],
'round': s['round'],
'phase': s['phase'],
'official_count': len(s['officials']),
'message_count': len(s['messages']),
}
for s in _sessions.values()
]
def destroy_session(session_id: str):
_sessions.pop(session_id, None)
def get_fate_event() -> str:
"""获取随机命运骰子事件。"""
import random
return random.choice(FATE_EVENTS)
# ── LLM 集成 ──
_PREFERRED_MODELS = ['gpt-4o-mini', 'claude-haiku', 'gpt-5-mini', 'gemini-3-flash', 'gemini-flash']
# GitHub Copilot 模型列表 (通过 Copilot Chat API 可用)
_COPILOT_MODELS = [
'gpt-4o', 'gpt-4o-mini', 'claude-sonnet-4', 'claude-haiku-3.5',
'gemini-2.0-flash', 'o3-mini',
]
_COPILOT_PREFERRED = ['gpt-4o-mini', 'claude-haiku', 'gemini-flash', 'gpt-4o']
def _pick_chat_model(models: list[dict]) -> str | None:
"""从 provider 的模型列表中选一个适合聊天的轻量模型。"""
ids = [m['id'] for m in models if isinstance(m, dict) and 'id' in m]
for pref in _PREFERRED_MODELS:
for mid in ids:
if pref in mid:
return mid
return ids[0] if ids else None
def _read_copilot_token() -> str | None:
"""读取 openclaw 管理的 GitHub Copilot token。"""
token_path = os.path.expanduser('~/.openclaw/credentials/github-copilot.token.json')
if not os.path.exists(token_path):
return None
try:
with open(token_path) as f:
cred = json.load(f)
token = cred.get('token', '')
expires = cred.get('expiresAt', 0)
# 检查 token 是否过期(毫秒时间戳)
import time
if expires and time.time() * 1000 > expires:
logger.warning('Copilot token expired')
return None
return token if token else None
except Exception as e:
logger.warning('Failed to read copilot token: %s', e)
return None
def _get_llm_config() -> dict | None:
"""从 openclaw 配置读取 LLM 设置,支持环境变量覆盖。
优先级: 环境变量 > github-copilot token > 本地 copilot-proxy > anthropic > 其他 provider
"""
# 1. 环境变量覆盖(保留向后兼容)
env_key = os.environ.get('OPENCLAW_LLM_API_KEY', '')
if env_key:
return {
'api_key': env_key,
'base_url': os.environ.get('OPENCLAW_LLM_BASE_URL', 'https://api.openai.com/v1'),
'model': os.environ.get('OPENCLAW_LLM_MODEL', 'gpt-4o-mini'),
'api_type': 'openai',
}
# 2. GitHub Copilot token最优先 — 免费、稳定、无需额外配置)
copilot_token = _read_copilot_token()
if copilot_token:
# 选一个 copilot 支持的模型
model = 'gpt-4o'
logger.info('Court discuss using github-copilot token, model=%s', model)
return {
'api_key': copilot_token,
'base_url': 'https://api.githubcopilot.com',
'model': model,
'api_type': 'github-copilot',
}
# 3. 从 ~/.openclaw/openclaw.json 读取其他 provider 配置
openclaw_cfg = os.path.expanduser('~/.openclaw/openclaw.json')
if not os.path.exists(openclaw_cfg):
return None
try:
with open(openclaw_cfg) as f:
cfg = json.load(f)
providers = cfg.get('models', {}).get('providers', {})
# 按优先级排序copilot-proxy > anthropic > 其他
ordered = []
for preferred in ['copilot-proxy', 'anthropic']:
if preferred in providers:
ordered.append(preferred)
ordered.extend(k for k in providers if k not in ordered)
for name in ordered:
prov = providers.get(name)
if not prov:
continue
api_type = prov.get('api', '')
base_url = prov.get('baseUrl', '')
api_key = prov.get('apiKey', '')
if not base_url:
continue
# 跳过无 key 且非本地的 provider
if not api_key or api_key == 'n/a':
if 'localhost' not in base_url and '127.0.0.1' not in base_url:
continue
model_id = _pick_chat_model(prov.get('models', []))
if not model_id:
continue
# 本地代理先探测是否可用
if 'localhost' in base_url or '127.0.0.1' in base_url:
try:
import urllib.request
probe = urllib.request.Request(base_url.rstrip('/') + '/models', method='GET')
urllib.request.urlopen(probe, timeout=2)
except Exception:
logger.info('Skipping provider=%s (not reachable)', name)
continue
logger.info('Court discuss using openclaw provider=%s model=%s api=%s', name, model_id, api_type)
send_auth = prov.get('authHeader', True) is not False and api_key not in ('', 'n/a')
return {
'api_key': api_key if send_auth else '',
'base_url': base_url,
'model': model_id,
'api_type': api_type,
}
except Exception as e:
logger.warning('Failed to read openclaw config: %s', e)
return None
def _llm_complete(system_prompt: str, user_prompt: str, max_tokens: int = 1024) -> str | None:
"""调用 LLM API自动适配 GitHub Copilot / OpenAI / Anthropic 协议)。"""
config = _get_llm_config()
if not config:
return None
import urllib.request
import urllib.error
api_type = config.get('api_type', 'openai-completions')
if api_type == 'anthropic-messages':
# Anthropic Messages API
url = config['base_url'].rstrip('/') + '/v1/messages'
headers = {
'Content-Type': 'application/json',
'x-api-key': config['api_key'],
'anthropic-version': '2023-06-01',
}
payload = json.dumps({
'model': config['model'],
'system': system_prompt,
'messages': [{'role': 'user', 'content': user_prompt}],
'max_tokens': max_tokens,
'temperature': 0.9,
}).encode()
try:
req = urllib.request.Request(url, data=payload, headers=headers, method='POST')
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read().decode())
return data['content'][0]['text']
except Exception as e:
logger.warning('Anthropic LLM call failed: %s', e)
return None
else:
# OpenAI-compatible API (也适用于 github-copilot)
if api_type == 'github-copilot':
url = config['base_url'].rstrip('/') + '/chat/completions'
headers = {
'Content-Type': 'application/json',
'Authorization': f"Bearer {config['api_key']}",
'Editor-Version': 'vscode/1.96.0',
'Copilot-Integration-Id': 'vscode-chat',
}
else:
url = config['base_url'].rstrip('/') + '/chat/completions'
headers = {'Content-Type': 'application/json'}
if config.get('api_key'):
headers['Authorization'] = f"Bearer {config['api_key']}"
payload = json.dumps({
'model': config['model'],
'messages': [
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_prompt},
],
'max_tokens': max_tokens,
'temperature': 0.9,
}).encode()
try:
req = urllib.request.Request(url, data=payload, headers=headers, method='POST')
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read().decode())
return data['choices'][0]['message']['content']
except Exception as e:
logger.warning('LLM call failed: %s', e)
return None
def _llm_discuss(session: dict, user_message: str = None, decree: str = None) -> dict | None:
"""使用 LLM 生成多官员讨论。"""
officials = session['officials']
names = ''.join(o['name'] for o in officials)
profiles = ''
for o in officials:
profiles += f"\n### {o['name']}{o['role']}\n"
profiles += f"职责范围:{o.get('duty', '综合事务')}\n"
profiles += f"性格:{o['personality']}\n"
profiles += f"说话风格:{o['speaking_style']}\n"
# 构建最近的对话历史
history = ''
for msg in session['messages'][-20:]:
if msg['type'] == 'system':
history += f"\n【系统】{msg['content']}\n"
elif msg['type'] == 'emperor':
history += f"\n皇帝:{msg['content']}\n"
elif msg['type'] == 'decree':
history += f"\n【天命降临】{msg['content']}\n"
elif msg['type'] == 'official':
history += f"\n{msg.get('official_name', '?')}{msg['content']}\n"
elif msg['type'] == 'scene_note':
history += f"\n{msg['content']}\n"
if user_message:
history += f"\n皇帝:{user_message}\n"
if decree:
history += f"\n【天命降临——上帝视角干预】{decree}\n"
decree_section = ''
if decree:
decree_section = '\n请根据天命降临事件改变讨论走向,所有官员都必须对此做出反应。\n'
prompt = f"""你是一个古代朝堂多角色群聊模拟器。模拟多位官员在朝堂上围绕议题的讨论。
## 参与官员
{names}
## 角色设定(每位官员都有明确的职责领域,必须从自身专业角度出发讨论)
{profiles}
## 当前议题
{session['topic']}
## 对话记录
{history if history else '(讨论刚刚开始)'}
{decree_section}
## 任务
生成每位官员的下一条发言。要求:
1. 每位官员说1-3句话像真实朝堂讨论一样
2. **每位官员必须从自己的职责领域出发发言**——户部谈成本和数据、兵部谈安全和运维、工部谈技术实现、刑部谈质量和合规、礼部谈文档和规范、吏部谈人员安排、中书谈规划方案、门下谈审查风险、尚书谈执行调度、太子谈创新和大局,每个人关注的焦点不同
3. 官员之间要有互动——回应、反驳、支持、补充,尤其是不同部门的视角碰撞
4. 保持每位官员独特的说话风格和人格特征
5. 讨论要围绕议题推进、有实质性观点,不要泛泛而谈
6. 如果皇帝发言了,官员要恰当回应(但不要阿谀)
7. 可包含动作描写用*号*包裹(如 *拱手施礼*
输出JSON格式
{{
"messages": [
{{"official_id": "zhongshu", "name": "中书令", "content": "发言内容", "emotion": "neutral|confident|worried|angry|thinking|amused", "action": "可选动作描写"}},
...
],
"scene_note": "可选的朝堂氛围变化(如:朝堂一片哗然|群臣窃窃私语没有则为null"
}}
只输出JSON不要其他内容。"""
content = _llm_complete(
'你是一个古代朝堂群聊模拟器严格输出JSON格式。',
prompt,
max_tokens=1500,
)
if not content:
return None
# 解析 JSON
if '```json' in content:
content = content.split('```json')[1].split('```')[0].strip()
elif '```' in content:
content = content.split('```')[1].split('```')[0].strip()
try:
return json.loads(content)
except json.JSONDecodeError:
logger.warning('Failed to parse LLM response: %s', content[:200])
return None
def _llm_summarize(session: dict) -> str | None:
"""用 LLM 总结讨论结果。"""
official_msgs = [m for m in session['messages'] if m['type'] == 'official']
topic = session['topic']
if not official_msgs:
return None
dialogue = '\n'.join(
f"{m.get('official_name', '?')}{m['content']}"
for m in official_msgs[-30:]
)
prompt = f"""以下是朝堂官员围绕「{topic}」的讨论记录:
{dialogue}
请用2-3句话总结讨论结果、达成的共识和待决事项。用古风但简明的风格。"""
return _llm_complete('你是朝堂记录官,负责总结朝议结果。', prompt, max_tokens=300)
# ── 规则模拟(无 LLM 时的降级方案)──
_SIMULATED_RESPONSES = {
'zhongshu': [
'臣以为此事需从全局着眼,分三步推进:先调研、再制定方案、最后交六部执行。',
'参考前朝经验,臣建议先出一个详细的规划文档,提交门下省审阅后再定。',
'*展开手中卷轴* 臣已拟好初步方案,待侍中审议、尚书省分派执行。',
],
'menxia': [
'臣有几点疑虑:方案的风险评估似乎还不够充分,可行性存疑。',
'容臣直言,此方案完整性不足,遗漏了一个关键环节——资源保障。',
'*皱眉审视* 这个时间线恐怕过于乐观,臣建议审慎评估后再行准奏。',
],
'shangshu': [
'若方案通过,臣立刻安排各部分头执行——工部负责实现,兵部保障运维。',
'臣来说说执行层面的分工:此事当由工部主导,户部配合数据支撑。',
'交由臣来协调!臣会根据各部职责逐一派发子任务。',
],
'taizi': [
'父皇,儿臣认为这是个创新的好机会,不妨大胆一些,先做最小可行方案验证。',
'本宫觉得各位大臣争论的焦点是执行节奏,不如先抓核心、小步快跑。',
'这个方向太对了!但请各部先各自评估本部门的落地难点再汇总。',
],
'hubu': [
'臣先算算账……按当前Token用量和资源消耗这个预算恐怕需要重新评估。',
'从成本数据来看臣建议分期投入——先做MVP验证效果再追加资源。',
'*翻看账本* 臣统计了近期各项开支指标,目前可支撑,但需严格控制在预算范围内。',
],
'bingbu': [
'末将认为安全和回滚方案必须先行,万一出问题能快速止损回退。',
'运维保障方面,部署流程、容器编排、日志监控必须到位再上线。',
'兵贵神速!但安全底线不能破——权限管控和漏洞扫描须同步进行。',
],
'xingbu': [
'依规矩,此事需确保合规——代码审查、测试覆盖率、敏感信息排查缺一不可。',
'臣建议增加测试验收环节,质量是底线,不能因赶工而降低标准。',
'*正色道* 风险评估不可敷衍:边界条件、异常处理、日志规范都需审计过关。',
],
'gongbu': {
'从技术架构来看,这个方案是可行的,但需考虑扩展性和模块化设计。',
'臣可以先搭个原型出来,快速验证技术可行性,再迭代完善。',
'*整了整官帽* 技术实现方面臣有建议——API设计和数据结构需要先理清……',
},
'libu': [
'臣建议先拟一份正式文档,明确各方职责、验收标准和输出规范。',
'此事当载入记录,臣来负责撰写方案文档和对外公告,确保规范统一。',
'*提笔拟文* 已记录在案臣稍后整理成正式Release Notes呈上御览。',
],
'libu_hr': [
'此事关键在于人员调配——需评估各部目前的工作量和能力基线再做安排。',
'各部当前负荷不等,臣建议调整协作规范,确保关键岗位有人盯进度。',
'臣可以协调人员轮岗并安排能力培训,保障团队高效协作。',
],
}
import random
def _simulated_discuss(session: dict, user_message: str = None, decree: str = None) -> list[dict]:
"""无 LLM 时的规则生成讨论内容。"""
officials = session['officials']
messages = []
for o in officials:
oid = o['id']
pool = _SIMULATED_RESPONSES.get(oid, [])
if isinstance(pool, set):
pool = list(pool)
if not pool:
pool = ['臣附议。', '臣有不同看法。', '臣需要再想想。']
content = random.choice(pool)
emotions = ['neutral', 'confident', 'thinking', 'amused', 'worried']
# 如果皇帝发言了或有天命降临,调整回应
if decree:
content = f'*面露惊色* 天命如此,{content}'
elif user_message:
content = f'回禀陛下,{content}'
messages.append({
'official_id': oid,
'name': o['name'],
'content': content,
'emotion': random.choice(emotions),
'action': None,
})
return messages
def _serialize(session: dict) -> dict:
return {
'ok': True,
'session_id': session['session_id'],
'topic': session['topic'],
'task_id': session.get('task_id', ''),
'officials': session['officials'],
'messages': session['messages'],
'round': session['round'],
'phase': session['phase'],
}

84
dashboard/dist/assets/index-BHFX02xH.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>三省六部 · Edict Dashboard</title>
<script type="module" crossorigin src="/assets/index-CqMbmm5B.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CIswLNpP.css">
<script type="module" crossorigin src="/assets/index-BHFX02xH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-NQIHw-yB.css">
</head>
<body>
<div id="root"></div>

View File

@@ -21,6 +21,12 @@ scripts_dir = str(pathlib.Path(__file__).parent.parent / 'scripts')
sys.path.insert(0, scripts_dir)
from file_lock import atomic_json_read, atomic_json_write, atomic_json_update
from utils import validate_url, read_json, now_iso
from court_discuss import (
create_session as cd_create, advance_discussion as cd_advance,
get_session as cd_get, conclude_session as cd_conclude,
list_sessions as cd_list, destroy_session as cd_destroy,
get_fate_event as cd_fate, OFFICIAL_PROFILES as CD_PROFILES,
)
log = logging.getLogger('server')
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(message)s', datefmt='%H:%M:%S')
@@ -883,8 +889,8 @@ def _ensure_scheduler(task):
sched = {}
task['_scheduler'] = sched
sched.setdefault('enabled', True)
sched.setdefault('stallThresholdSec', 180)
sched.setdefault('maxRetry', 1)
sched.setdefault('stallThresholdSec', 600)
sched.setdefault('maxRetry', 2)
sched.setdefault('retryCount', 0)
sched.setdefault('escalationLevel', 0)
sched.setdefault('autoRollback', True)
@@ -1055,8 +1061,8 @@ def handle_scheduler_rollback(task_id, reason=''):
return {'ok': True, 'message': f'{task_id} 已回滚到 {snap_state}'}
def handle_scheduler_scan(threshold_sec=180):
threshold_sec = max(30, int(threshold_sec or 180))
def handle_scheduler_scan(threshold_sec=600):
threshold_sec = max(60, int(threshold_sec or 600))
tasks = load_tasks()
now_dt = datetime.datetime.now(datetime.timezone.utc)
pending_retries = []
@@ -2177,6 +2183,17 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({'ok': False, 'error': 'invalid agent_id'}, 400)
else:
self.send_json({'ok': True, 'agentId': agent_id, 'activity': get_agent_activity(agent_id)})
# ── 朝堂议政 ──
elif p == '/api/court-discuss/list':
self.send_json({'ok': True, 'sessions': cd_list()})
elif p == '/api/court-discuss/officials':
self.send_json({'ok': True, 'officials': CD_PROFILES})
elif p.startswith('/api/court-discuss/session/'):
sid = p.replace('/api/court-discuss/session/', '')
data = cd_get(sid)
self.send_json(data if data else {'ok': False, 'error': 'session not found'}, 200 if data else 404)
elif p == '/api/court-discuss/fate':
self.send_json({'ok': True, 'event': cd_fate()})
elif self._serve_static(p):
pass # 已由 _serve_static 处理 (JS/CSS/图片等)
else:
@@ -2448,6 +2465,48 @@ class Handler(BaseHTTPRequestHandler):
threading.Thread(target=apply_async, daemon=True).start()
self.send_json({'ok': True, 'message': f'Queued: {agent_id}{model}'})
# ── 朝堂议政 POST ──
elif p == '/api/court-discuss/start':
topic = body.get('topic', '').strip()
officials = body.get('officials', [])
task_id = body.get('taskId', '').strip()
if not topic:
self.send_json({'ok': False, 'error': 'topic required'}, 400)
return
if not officials or not isinstance(officials, list):
self.send_json({'ok': False, 'error': 'officials list required'}, 400)
return
# 校验官员 ID
valid_ids = set(CD_PROFILES.keys())
officials = [o for o in officials if o in valid_ids]
if len(officials) < 2:
self.send_json({'ok': False, 'error': '至少选择2位官员'}, 400)
return
self.send_json(cd_create(topic, officials, task_id))
elif p == '/api/court-discuss/advance':
sid = body.get('sessionId', '').strip()
user_msg = body.get('userMessage', '').strip() or None
decree = body.get('decree', '').strip() or None
if not sid:
self.send_json({'ok': False, 'error': 'sessionId required'}, 400)
return
self.send_json(cd_advance(sid, user_msg, decree))
elif p == '/api/court-discuss/conclude':
sid = body.get('sessionId', '').strip()
if not sid:
self.send_json({'ok': False, 'error': 'sessionId required'}, 400)
return
self.send_json(cd_conclude(sid))
elif p == '/api/court-discuss/destroy':
sid = body.get('sessionId', '').strip()
if sid:
cd_destroy(sid)
self.send_json({'ok': True})
else:
self.send_error(404)

View File

@@ -134,6 +134,28 @@ open http://127.0.0.1:7891
python3 dashboard/server.py
```
### Agent 报错 "No API key found for provider"
这是最常见的问题。三省六部有 11 个 Agent每个都需要 API Key。
```bash
# 方法一:为任意 Agent 配置后重新运行 install.sh推荐
openclaw agents add taizi # 按提示输入 Anthropic API Key
cd edict && ./install.sh # 自动同步到所有 Agent
# 方法二:手动复制 auth 文件
MAIN_AUTH=$(find ~/.openclaw/agents -name auth-profiles.json | head -1)
for agent in taizi zhongshu menxia shangshu hubu libu bingbu xingbu gongbu; do
mkdir -p ~/.openclaw/agents/$agent/agent
cp "$MAIN_AUTH" ~/.openclaw/agents/$agent/agent/auth-profiles.json
done
# 方法三:逐个配置
openclaw agents add taizi
openclaw agents add zhongshu
# ... 其他 Agent
```
### Agent 不响应
```bash
# 检查 Gateway 状态

View File

@@ -13,6 +13,7 @@ import TaskModal from './components/TaskModal';
// ConfirmDialog is used inside TaskModal as needed
import Toaster from './components/Toaster';
import CourtCeremony from './components/CourtCeremony';
import CourtDiscussion from './components/CourtDiscussion';
export default function App() {
const activeTab = useStore((s) => s.activeTab);
@@ -81,6 +82,7 @@ export default function App() {
{/* ── Panels ── */}
{activeTab === 'edicts' && <EdictBoard />}
{activeTab === 'court' && <CourtDiscussion />}
{activeTab === 'monitor' && <MonitorPanel />}
{activeTab === 'officials' && <OfficialPanel />}
{activeTab === 'models' && <ModelConfig />}

View File

@@ -93,6 +93,18 @@ export const api = {
createTask: (data: CreateTaskPayload) =>
postJ<ActionResult & { taskId?: string }>(`${API_BASE}/api/create-task`, data),
// ── 朝堂议政 ──
courtDiscussStart: (topic: string, officials: string[], taskId?: string) =>
postJ<CourtDiscussResult>(`${API_BASE}/api/court-discuss/start`, { topic, officials, taskId }),
courtDiscussAdvance: (sessionId: string, userMessage?: string, decree?: string) =>
postJ<CourtDiscussResult>(`${API_BASE}/api/court-discuss/advance`, { sessionId, userMessage, decree }),
courtDiscussConclude: (sessionId: string) =>
postJ<ActionResult & { summary?: string }>(`${API_BASE}/api/court-discuss/conclude`, { sessionId }),
courtDiscussDestroy: (sessionId: string) =>
postJ<ActionResult>(`${API_BASE}/api/court-discuss/destroy`, { sessionId }),
courtDiscussFate: () =>
fetchJ<{ ok: boolean; event: string }>(`${API_BASE}/api/court-discuss/fate`),
};
// ── Types ──
@@ -396,3 +408,22 @@ export interface RemoteSkillsListResult {
listedAt?: string;
error?: string;
}
// ── 朝堂议政 ──
export interface CourtDiscussResult {
ok: boolean;
session_id?: string;
topic?: string;
round?: number;
new_messages?: Array<{
official_id: string;
name: string;
content: string;
emotion?: string;
action?: string;
}>;
scene_note?: string;
total_messages?: number;
error?: string;
}

View File

@@ -0,0 +1,774 @@
/**
* 朝堂议政 — 多官员实时讨论可视化组件
*
* 灵感来自 nvwa 项目的故事剧场 + 协作工坊 + 虚拟生活
* 功能:
* - 可视化朝堂布局,官员站位
* - 实时群聊讨论,官员各抒己见
* - 皇帝(用户)随时发言参与
* - 天命降临(上帝视角)改变讨论走向
* - 命运骰子:随机事件增加趣味性
* - 自动推进 / 手动推进
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import { useStore, DEPTS } from '../store';
import { api } from '../api';
// ── 常量 ──
const OFFICIAL_COLORS: Record<string, string> = {
taizi: '#e8a040', zhongshu: '#a07aff', menxia: '#6a9eff', shangshu: '#2ecc8a',
libu: '#f5c842', hubu: '#ff9a6a', bingbu: '#ff5270', xingbu: '#cc4444',
gongbu: '#44aaff', libu_hr: '#9b59b6',
};
const EMOTION_EMOJI: Record<string, string> = {
neutral: '', confident: '😏', worried: '😟', angry: '😤',
thinking: '🤔', amused: '😄', happy: '😊',
};
const COURT_POSITIONS: Record<string, { x: number; y: number }> = {
// 左列
zhongshu: { x: 15, y: 25 }, menxia: { x: 15, y: 45 }, shangshu: { x: 15, y: 65 },
// 右列
libu: { x: 85, y: 20 }, hubu: { x: 85, y: 35 }, bingbu: { x: 85, y: 50 },
xingbu: { x: 85, y: 65 }, gongbu: { x: 85, y: 80 },
// 中间
taizi: { x: 50, y: 20 }, libu_hr: { x: 50, y: 80 },
};
interface CourtMessage {
type: string;
content: string;
official_id?: string;
official_name?: string;
emotion?: string;
action?: string;
timestamp?: number;
}
interface CourtSession {
session_id: string;
topic: string;
officials: Array<{
id: string;
name: string;
emoji: string;
role: string;
personality: string;
speaking_style: string;
}>;
messages: CourtMessage[];
round: number;
phase: string;
}
export default function CourtDiscussion() {
// Phase: setup | session
const [phase, setPhase] = useState<'setup' | 'session'>('setup');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [topic, setTopic] = useState('');
const [session, setSession] = useState<CourtSession | null>(null);
const [loading, setLoading] = useState(false);
const [autoPlay, setAutoPlay] = useState(false);
const autoPlayRef = useRef(false);
// 皇帝发言
const [userInput, setUserInput] = useState('');
// 天命降临
const [showDecree, setShowDecree] = useState(false);
const [decreeInput, setDecreeInput] = useState('');
const [decreeFlash, setDecreeFlash] = useState(false);
// 命运骰子
const [diceRolling, setDiceRolling] = useState(false);
const [diceResult, setDiceResult] = useState<string | null>(null);
// 活跃说话官员
const [speakingId, setSpeakingId] = useState<string | null>(null);
// 官员情绪
const [emotions, setEmotions] = useState<Record<string, string>>({});
const messagesEndRef = useRef<HTMLDivElement>(null);
const toast = useStore((s) => s.toast);
const liveStatus = useStore((s) => s.liveStatus);
// 自动滚到底部
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [session?.messages?.length]);
// 自动推进
useEffect(() => {
autoPlayRef.current = autoPlay;
}, [autoPlay]);
useEffect(() => {
if (!autoPlay || !session || loading) return;
const timer = setInterval(() => {
if (autoPlayRef.current && !loading) {
handleAdvance();
}
}, 5000);
return () => clearInterval(timer);
}, [autoPlay, session, loading]);
// ── 切换官员选中 ──
const toggleOfficial = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else if (next.size < 8) next.add(id);
return next;
});
};
// ── 开始议政 ──
const handleStart = async () => {
if (!topic.trim() || selectedIds.size < 2 || loading) return;
setLoading(true);
try {
const res = await api.courtDiscussStart(topic, Array.from(selectedIds));
if (!res.ok) throw new Error(res.error || '启动失败');
setSession(res as unknown as CourtSession);
setPhase('session');
} catch (e: unknown) {
toast((e as Error).message || '启动失败', 'err');
} finally {
setLoading(false);
}
};
// ── 推进讨论 ──
const handleAdvance = useCallback(async (userMsg?: string, decree?: string) => {
if (!session || loading) return;
setLoading(true);
try {
const res = await api.courtDiscussAdvance(session.session_id, userMsg, decree);
if (!res.ok) throw new Error(res.error || '推进失败');
// 更新 session messages追加新消息
setSession((prev) => {
if (!prev) return prev;
const newMsgs: CourtMessage[] = [];
if (userMsg) {
newMsgs.push({ type: 'emperor', content: userMsg, timestamp: Date.now() / 1000 });
}
if (decree) {
newMsgs.push({ type: 'decree', content: decree, timestamp: Date.now() / 1000 });
}
const aiMsgs = (res.new_messages || []).map((m: Record<string, string>) => ({
type: 'official',
official_id: m.official_id,
official_name: m.name,
content: m.content,
emotion: m.emotion,
action: m.action,
timestamp: Date.now() / 1000,
}));
if (res.scene_note) {
newMsgs.push({ type: 'scene_note', content: res.scene_note, timestamp: Date.now() / 1000 });
}
return {
...prev,
round: res.round ?? prev.round + 1,
messages: [...prev.messages, ...newMsgs, ...aiMsgs],
};
});
// 动画:依次高亮说话的官员
const aiMsgs = res.new_messages || [];
if (aiMsgs.length > 0) {
const emotionMap: Record<string, string> = {};
let idx = 0;
const cycle = () => {
if (idx < aiMsgs.length) {
setSpeakingId(aiMsgs[idx].official_id);
emotionMap[aiMsgs[idx].official_id] = aiMsgs[idx].emotion || 'neutral';
idx++;
setTimeout(cycle, 1200);
} else {
setSpeakingId(null);
}
};
cycle();
setEmotions((prev) => ({ ...prev, ...emotionMap }));
}
} catch {
// silently
} finally {
setLoading(false);
}
}, [session, loading]);
// ── 皇帝发言 ──
const handleEmperor = () => {
const msg = userInput.trim();
if (!msg) return;
setUserInput('');
handleAdvance(msg);
};
// ── 天命降临 ──
const handleDecree = () => {
const msg = decreeInput.trim();
if (!msg) return;
setDecreeInput('');
setShowDecree(false);
setDecreeFlash(true);
setTimeout(() => setDecreeFlash(false), 800);
handleAdvance(undefined, msg);
};
// ── 命运骰子 ──
const handleDice = async () => {
if (loading || diceRolling) return;
setDiceRolling(true);
setDiceResult(null);
// 滚动动画
let count = 0;
const timer = setInterval(async () => {
count++;
setDiceResult('🎲 命运轮转中...');
if (count >= 6) {
clearInterval(timer);
try {
const res = await api.courtDiscussFate();
const event = res.event || '边疆急报传来';
setDiceResult(event);
setDiceRolling(false);
// 自动作为天命降临注入
handleAdvance(undefined, `【命运骰子】${event}`);
} catch {
setDiceResult('命运之力暂时无法触及');
setDiceRolling(false);
}
}
}, 200);
};
// ── 结束议政 ──
const handleConclude = async () => {
if (!session) return;
setLoading(true);
try {
const res = await api.courtDiscussConclude(session.session_id);
if (res.summary) {
setSession((prev) =>
prev
? {
...prev,
phase: 'concluded',
messages: [
...prev.messages,
{ type: 'system', content: `📋 朝堂议政结束 — ${res.summary}`, timestamp: Date.now() / 1000 },
],
}
: prev,
);
}
setAutoPlay(false);
} catch {
toast('结束失败', 'err');
} finally {
setLoading(false);
}
};
// ── 重置 ──
const handleReset = () => {
if (session) {
api.courtDiscussDestroy(session.session_id).catch(() => {});
}
setPhase('setup');
setSession(null);
setAutoPlay(false);
setEmotions({});
setSpeakingId(null);
setDiceResult(null);
};
// ── 预设议题(从当前旨意中提取)──
const activeEdicts = (liveStatus?.tasks || []).filter(
(t) => /^JJC-/i.test(t.id) && !['Done', 'Cancelled'].includes(t.state),
);
const presetTopics = [
...activeEdicts.slice(0, 3).map((t) => ({
text: `讨论旨意 ${t.id}${t.title}`,
taskId: t.id,
icon: '📜',
})),
{ text: '讨论系统架构优化方案', taskId: '', icon: '🏗️' },
{ text: '评估当前项目进展和风险', taskId: '', icon: '📊' },
{ text: '制定下周工作计划', taskId: '', icon: '📋' },
{ text: '紧急问题线上Bug排查方案', taskId: '', icon: '🚨' },
];
// ═══════════════════
// 渲染:设置页
// ═══════════════════
if (phase === 'setup') {
return (
<div className="space-y-6">
{/* Header */}
<div className="text-center py-4">
<h2 className="text-xl font-bold bg-gradient-to-r from-amber-400 to-purple-400 bg-clip-text text-transparent">
🏛
</h2>
<p className="text-xs text-[var(--muted)] mt-1">
殿 ·
</p>
</div>
{/* 选择官员 */}
<div className="bg-[var(--panel)] rounded-xl p-4 border border-[var(--line)]">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-semibold">👔 </span>
<span className="text-xs text-[var(--muted)]">{selectedIds.size}/82</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
{DEPTS.map((d) => {
const active = selectedIds.has(d.id);
const color = OFFICIAL_COLORS[d.id] || '#6a9eff';
return (
<button
key={d.id}
onClick={() => toggleOfficial(d.id)}
className="p-2.5 rounded-lg border transition-all text-left"
style={{
borderColor: active ? color + '80' : 'var(--line)',
background: active ? color + '15' : 'var(--panel2)',
boxShadow: active ? `0 0 12px ${color}20` : 'none',
}}
>
<div className="flex items-center gap-1.5">
<span className="text-lg">{d.emoji}</span>
<div>
<div className="text-xs font-semibold" style={{ color: active ? color : 'var(--text)' }}>
{d.label}
</div>
<div className="text-[10px] text-[var(--muted)]">{d.role}</div>
</div>
{active && (
<span
className="ml-auto w-4 h-4 rounded-full flex items-center justify-center text-[10px] text-white"
style={{ background: color }}
>
</span>
)}
</div>
</button>
);
})}
</div>
</div>
{/* 议题 */}
<div className="bg-[var(--panel)] rounded-xl p-4 border border-[var(--line)]">
<div className="text-sm font-semibold mb-2">📜 </div>
{presetTopics.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{presetTopics.map((p, i) => (
<button
key={i}
onClick={() => setTopic(p.text)}
className="text-xs px-2.5 py-1.5 rounded-lg border border-[var(--line)] hover:border-[var(--acc)] hover:text-[var(--acc)] transition-colors"
style={{
background: topic === p.text ? 'var(--acc)' + '18' : 'transparent',
borderColor: topic === p.text ? 'var(--acc)' : undefined,
color: topic === p.text ? 'var(--acc)' : undefined,
}}
>
{p.icon} {p.text}
</button>
))}
</div>
)}
<textarea
className="w-full bg-[var(--panel2)] rounded-lg p-3 text-sm border border-[var(--line)] focus:border-[var(--acc)] outline-none resize-none"
rows={2}
placeholder="或自定义议题..."
value={topic}
onChange={(e) => setTopic(e.target.value)}
/>
</div>
{/* 功能特性标签 */}
<div className="flex flex-wrap gap-1.5">
{[
'👑 皇帝发言', '⚡ 天命降临', '🎲 命运骰子',
'🔄 自动推进', '📜 讨论记录',
].map((tag) => (
<span key={tag} className="text-[10px] px-2 py-1 rounded-full border border-[var(--line)] text-[var(--muted)]">
{tag}
</span>
))}
</div>
{/* 开始按钮 */}
<button
onClick={handleStart}
disabled={selectedIds.size < 2 || !topic.trim() || loading}
className="w-full py-3 rounded-xl font-semibold text-sm transition-all border-0"
style={{
background:
selectedIds.size >= 2 && topic.trim()
? 'linear-gradient(135deg, #6a9eff, #a07aff)'
: 'var(--panel2)',
color: selectedIds.size >= 2 && topic.trim() ? '#fff' : 'var(--muted)',
opacity: loading ? 0.6 : 1,
cursor: selectedIds.size >= 2 && topic.trim() && !loading ? 'pointer' : 'not-allowed',
}}
>
{loading ? '召集中...' : `🏛 开始朝议(${selectedIds.size}位上殿)`}
</button>
</div>
);
}
// ═══════════════════
// 渲染:议政进行中
// ═══════════════════
const officials = session?.officials || [];
const messages = session?.messages || [];
return (
<div className="space-y-3">
{/* 顶部控制栏 */}
<div className="flex items-center justify-between flex-wrap gap-2 bg-[var(--panel)] rounded-xl px-4 py-2 border border-[var(--line)]">
<div className="flex items-center gap-2">
<span className="text-sm font-bold">🏛 </span>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[var(--acc)]20 text-[var(--acc)] border border-[var(--acc)]30">
{session?.round || 0}
</span>
{session?.phase === 'concluded' && (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-green-900/40 text-green-400 border border-green-800">
</span>
)}
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() => setShowDecree(!showDecree)}
className="text-xs px-2.5 py-1 rounded-lg border border-amber-600/40 text-amber-400 hover:bg-amber-900/20 transition"
title="天命降临 — 上帝视角干预"
>
</button>
<button
onClick={handleDice}
disabled={diceRolling || loading}
className="text-xs px-2.5 py-1 rounded-lg border border-purple-600/40 text-purple-400 hover:bg-purple-900/20 transition"
title="命运骰子 — 随机事件"
>
🎲 {diceRolling ? '...' : '骰子'}
</button>
<button
onClick={() => setAutoPlay(!autoPlay)}
className={`text-xs px-2.5 py-1 rounded-lg border transition ${autoPlay
? 'border-green-600/40 text-green-400 bg-green-900/20'
: 'border-[var(--line)] text-[var(--muted)] hover:text-[var(--text)]'
}`}
>
{autoPlay ? '⏸ 暂停' : '▶ 自动'}
</button>
{session?.phase !== 'concluded' && (
<button
onClick={handleConclude}
className="text-xs px-2.5 py-1 rounded-lg border border-[var(--line)] text-[var(--muted)] hover:text-[var(--warn)] hover:border-[var(--warn)]40 transition"
>
📋
</button>
)}
<button
onClick={handleReset}
className="text-xs px-2 py-1 rounded-lg border border-red-900/40 text-red-400/70 hover:text-red-400 transition"
>
</button>
</div>
</div>
{/* 天命降临面板 */}
{showDecree && (
<div
className="bg-gradient-to-br from-amber-950/40 to-purple-950/30 rounded-xl p-4 border border-amber-700/30"
style={{ animation: 'fadeIn .3s' }}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-amber-400"> </span>
<button onClick={() => setShowDecree(false)} className="text-xs text-[var(--muted)]">
</button>
</div>
<p className="text-[10px] text-amber-300/60 mb-2">
</p>
<div className="flex gap-2">
<input
value={decreeInput}
onChange={(e) => setDecreeInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleDecree()}
placeholder="例如:突然发现预算多出一倍..."
className="flex-1 bg-black/30 rounded-lg px-3 py-1.5 text-sm border border-amber-800/40 outline-none focus:border-amber-600"
/>
<button
onClick={handleDecree}
disabled={!decreeInput.trim()}
className="px-4 py-1.5 rounded-lg bg-gradient-to-r from-amber-600 to-purple-600 text-white text-xs font-semibold disabled:opacity-40"
>
</button>
</div>
</div>
)}
{/* 命运骰子结果 */}
{diceResult && (
<div
className="bg-purple-950/40 rounded-lg px-3 py-2 border border-purple-700/30 text-xs text-purple-300 flex items-center gap-2"
style={{ animation: 'fadeIn .3s' }}
>
<span className="text-lg">🎲</span>
{diceResult}
</div>
)}
{/* 天命降临闪光效果 */}
{decreeFlash && (
<div
className="fixed inset-0 pointer-events-none z-50"
style={{
background: 'radial-gradient(circle, rgba(255,200,50,0.3), transparent 70%)',
animation: 'fadeOut .8s forwards',
}}
/>
)}
{/* 议题 */}
<div className="text-xs text-center text-[var(--muted)] py-1">
📜 {session?.topic || ''}
</div>
{/* 主内容:朝堂布局 + 聊天记录 */}
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-3">
{/* 左侧:朝堂可视化 */}
<div className="bg-[var(--panel)] rounded-xl p-3 border border-[var(--line)] relative overflow-hidden min-h-[320px]">
{/* 龙椅 */}
<div className="text-center mb-2">
<div className="inline-block px-3 py-1 rounded-lg bg-gradient-to-b from-amber-800/40 to-amber-950/40 border border-amber-700/30">
<span className="text-lg">👑</span>
<div className="text-[10px] text-amber-400/80"> </div>
</div>
</div>
{/* 官员站位 */}
<div className="relative" style={{ minHeight: 250 }}>
{/* 左列标签 */}
<div className="absolute left-0 top-0 text-[9px] text-[var(--muted)] opacity-50"></div>
<div className="absolute right-0 top-0 text-[9px] text-[var(--muted)] opacity-50"></div>
{officials.map((o) => {
const pos = COURT_POSITIONS[o.id] || { x: 50, y: 50 };
const color = OFFICIAL_COLORS[o.id] || '#6a9eff';
const isSpeaking = speakingId === o.id;
const emotion = emotions[o.id] || 'neutral';
return (
<div
key={o.id}
className="absolute transition-all duration-500"
style={{
left: `${pos.x}%`,
top: `${pos.y}%`,
transform: 'translate(-50%, -50%)',
}}
>
{/* 说话光圈 */}
{isSpeaking && (
<div
className="absolute -inset-2 rounded-full"
style={{
background: `radial-gradient(circle, ${color}40, transparent)`,
animation: 'pulse 1s infinite',
}}
/>
)}
{/* 头像 */}
<div
className="relative w-10 h-10 rounded-full flex items-center justify-center text-lg border-2 transition-all"
style={{
borderColor: isSpeaking ? color : color + '40',
background: isSpeaking ? color + '30' : color + '10',
transform: isSpeaking ? 'scale(1.2)' : 'scale(1)',
boxShadow: isSpeaking ? `0 0 16px ${color}50` : 'none',
}}
>
{o.emoji}
{/* 情绪气泡 */}
{EMOTION_EMOJI[emotion] && (
<span
className="absolute -top-1 -right-1 text-xs"
style={{ animation: 'bounceIn .3s' }}
>
{EMOTION_EMOJI[emotion]}
</span>
)}
</div>
{/* 名字 */}
<div
className="text-[9px] text-center mt-0.5 whitespace-nowrap"
style={{ color: isSpeaking ? color : 'var(--muted)' }}
>
{o.name}
</div>
</div>
);
})}
</div>
</div>
{/* 右侧:聊天记录 */}
<div className="bg-[var(--panel)] rounded-xl border border-[var(--line)] flex flex-col" style={{ maxHeight: 500 }}>
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto p-3 space-y-2" style={{ minHeight: 200 }}>
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} officials={officials} />
))}
{loading && (
<div className="text-xs text-[var(--muted)] text-center py-2" style={{ animation: 'pulse 1.5s infinite' }}>
🏛 ...
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 皇帝输入栏 */}
{session?.phase !== 'concluded' && (
<div className="border-t border-[var(--line)] p-2 flex gap-2">
<input
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleEmperor()}
placeholder="朕有话说..."
className="flex-1 bg-[var(--panel2)] rounded-lg px-3 py-1.5 text-sm border border-[var(--line)] outline-none focus:border-amber-600"
/>
<button
onClick={handleEmperor}
disabled={!userInput.trim() || loading}
className="px-4 py-1.5 rounded-lg text-xs font-semibold border-0 disabled:opacity-40"
style={{
background: userInput.trim() ? 'linear-gradient(135deg, #e8a040, #f5c842)' : 'var(--panel2)',
color: userInput.trim() ? '#000' : 'var(--muted)',
}}
>
👑
</button>
<button
onClick={() => handleAdvance()}
disabled={loading}
className="px-3 py-1.5 rounded-lg text-xs border border-[var(--acc)]40 text-[var(--acc)] hover:bg-[var(--acc)]10 disabled:opacity-40 transition"
>
</button>
</div>
)}
</div>
</div>
</div>
);
}
// ── 消息气泡 ──
function MessageBubble({
msg,
officials,
}: {
msg: CourtMessage;
officials: Array<{ id: string; name: string; emoji: string }>;
}) {
const color = OFFICIAL_COLORS[msg.official_id || ''] || '#6a9eff';
const official = officials.find((o) => o.id === msg.official_id);
if (msg.type === 'system') {
return (
<div className="text-center text-[10px] text-[var(--muted)] py-1 border-b border-[var(--line)] border-dashed">
{msg.content}
</div>
);
}
if (msg.type === 'scene_note') {
return (
<div className="text-center text-[10px] text-purple-400/80 py-1 italic">
{msg.content}
</div>
);
}
if (msg.type === 'emperor') {
return (
<div className="flex justify-end">
<div className="max-w-[80%] bg-gradient-to-br from-amber-900/40 to-amber-800/20 rounded-xl px-3 py-2 border border-amber-700/30">
<div className="text-[10px] text-amber-400 mb-0.5">👑 </div>
<div className="text-sm">{msg.content}</div>
</div>
</div>
);
}
if (msg.type === 'decree') {
return (
<div className="text-center py-2">
<div className="inline-block bg-gradient-to-r from-amber-900/30 via-purple-900/30 to-amber-900/30 rounded-lg px-4 py-2 border border-amber-600/30">
<div className="text-xs text-amber-400 font-bold"> </div>
<div className="text-sm mt-0.5">{msg.content}</div>
</div>
</div>
);
}
// 官员消息
return (
<div className="flex gap-2 items-start" style={{ animation: 'fadeIn .4s' }}>
<div
className="w-7 h-7 rounded-full flex items-center justify-center text-sm flex-shrink-0 border"
style={{ borderColor: color + '60', background: color + '15' }}
>
{official?.emoji || '💬'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<span className="text-[11px] font-semibold" style={{ color }}>
{msg.official_name || '官员'}
</span>
{msg.emotion && EMOTION_EMOJI[msg.emotion] && (
<span className="text-xs">{EMOTION_EMOJI[msg.emotion]}</span>
)}
</div>
<div className="text-sm leading-relaxed">
{msg.content?.split(/(\*[^*]+\*)/).map((part, i) => {
if (part.startsWith('*') && part.endsWith('*')) {
return (
<span key={i} className="text-[var(--muted)] italic text-xs">
{part.slice(1, -1)}
</span>
);
}
return <span key={i}>{part}</span>;
})}
</div>
</div>
</div>
);
}

View File

@@ -665,3 +665,9 @@ body {
.empty { text-align: center; padding: 40px 20px; color: var(--muted); font-size: 13px; }
.sec-title { font-size: 12px; font-weight: 700; color: var(--muted); letter-spacing: .07em; text-transform: uppercase; margin-bottom: 12px; }
code { font-size: 11px; background: var(--panel2); padding: 2px 6px; border-radius: 4px; font-family: monospace; }
/* ══ 朝堂议政动画 ══ */
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
@keyframes pulse { 0%, 100% { opacity: .6; } 50% { opacity: 1; } }
@keyframes bounceIn { 0% { transform: scale(0); } 60% { transform: scale(1.3); } 100% { transform: scale(1); } }

View File

@@ -83,11 +83,12 @@ export function getPipeStatus(t: Task): PipeStatus[] {
export type TabKey =
| 'edicts' | 'monitor' | 'officials' | 'models'
| 'skills' | 'sessions' | 'memorials' | 'templates' | 'morning';
| 'skills' | 'sessions' | 'memorials' | 'templates' | 'morning' | 'court';
export const TAB_DEFS: { key: TabKey; label: string; icon: string }[] = [
{ key: 'edicts', label: '旨意看板', icon: '📜' },
{ key: 'monitor', label: '省部调度', icon: '🏛️' },
{ key: 'court', label: '朝堂议政', icon: '🏛️' },
{ key: 'monitor', label: '省部调度', icon: '🔌' },
{ key: 'officials', label: '官员总览', icon: '👔' },
{ key: 'models', label: '模型配置', icon: '🤖' },
{ key: 'skills', label: '技能配置', icon: '🎯' },

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/store.ts","./src/vite-env.d.ts","./src/components/confirmdialog.tsx","./src/components/courtceremony.tsx","./src/components/edictboard.tsx","./src/components/memorialpanel.tsx","./src/components/modelconfig.tsx","./src/components/monitorpanel.tsx","./src/components/morningpanel.tsx","./src/components/officialpanel.tsx","./src/components/sessionspanel.tsx","./src/components/skillsconfig.tsx","./src/components/taskmodal.tsx","./src/components/templatepanel.tsx","./src/components/toaster.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/store.ts","./src/vite-env.d.ts","./src/components/confirmdialog.tsx","./src/components/courtceremony.tsx","./src/components/courtdiscussion.tsx","./src/components/edictboard.tsx","./src/components/memorialpanel.tsx","./src/components/modelconfig.tsx","./src/components/monitorpanel.tsx","./src/components/morningpanel.tsx","./src/components/officialpanel.tsx","./src/components/sessionspanel.tsx","./src/components/skillsconfig.tsx","./src/components/taskmodal.tsx","./src/components/templatepanel.tsx","./src/components/toaster.tsx"],"version":"5.9.3"}

View File

@@ -222,7 +222,72 @@ PYEOF
log "数据目录初始化完成: $REPO_DIR/data"
}
# ── Step 3.5: 同步 API Key 到所有 Agent ──────────────────────────
# ── Step 3.3: 创建 data 软链接确保数据一致 (Fix #88) ─────────
link_resources() {
info "创建 data/scripts 软链接以确保 Agent 数据一致..."
AGENTS=(taizi zhongshu menxia shangshu hubu libu bingbu xingbu gongbu libu_hr zaochao)
LINKED=0
for agent in "${AGENTS[@]}"; do
ws="$OC_HOME/workspace-$agent"
mkdir -p "$ws"
# 软链接 data 目录:确保各 agent 读写同一份 tasks_source.json
ws_data="$ws/data"
if [ -L "$ws_data" ]; then
: # 已是软链接,跳过
elif [ -d "$ws_data" ]; then
# 已有 data 目录(非符号链接),备份后替换
mv "$ws_data" "${ws_data}.bak.$(date +%Y%m%d-%H%M%S)"
ln -s "$REPO_DIR/data" "$ws_data"
LINKED=$((LINKED + 1))
else
ln -s "$REPO_DIR/data" "$ws_data"
LINKED=$((LINKED + 1))
fi
# 软链接 scripts 目录
ws_scripts="$ws/scripts"
if [ -L "$ws_scripts" ]; then
: # 已是软链接
elif [ -d "$ws_scripts" ]; then
mv "$ws_scripts" "${ws_scripts}.bak.$(date +%Y%m%d-%H%M%S)"
ln -s "$REPO_DIR/scripts" "$ws_scripts"
LINKED=$((LINKED + 1))
else
ln -s "$REPO_DIR/scripts" "$ws_scripts"
LINKED=$((LINKED + 1))
fi
done
# Legacy: workspace-main
ws_main="$OC_HOME/workspace-main"
if [ -d "$ws_main" ]; then
for target in data scripts; do
link_path="$ws_main/$target"
if [ ! -L "$link_path" ]; then
[ -d "$link_path" ] && mv "$link_path" "${link_path}.bak.$(date +%Y%m%d-%H%M%S)"
ln -s "$REPO_DIR/$target" "$link_path"
LINKED=$((LINKED + 1))
fi
done
fi
log "已创建 $LINKED 个软链接data/scripts → 项目目录)"
}
# ── Step 3.5: 设置 Agent 间通信可见性 (Fix #83) ──────────────
setup_visibility() {
info "配置 Agent 间消息可见性..."
if openclaw config set tools.sessions.visibility all 2>/dev/null; then
log "已设置 tools.sessions.visibility=allAgent 间可互相通信)"
else
warn "设置 visibility 失败(可能 openclaw 版本不支持),请手动执行:"
echo " openclaw config set tools.sessions.visibility all"
fi
}
# ── Step 3.5b: 同步 API Key 到所有 Agent ──────────────────────────
sync_auth() {
info "同步 API Key 到所有 Agent..."
@@ -316,6 +381,8 @@ backup_existing
create_workspaces
register_agents
init_data
link_resources
setup_visibility
sync_auth
build_frontend
first_sync

View File

@@ -20,6 +20,7 @@ import sys
import json
import pathlib
import argparse
import os
import urllib.request
import urllib.error
from pathlib import Path
@@ -217,13 +218,31 @@ def remove_remote(agent_id: str, name: str) -> bool:
return False
OFFICIAL_SKILLS_HUB_BASE = 'https://raw.githubusercontent.com/openclaw-ai/skills-hub/main'
# 备用镜像GitHub 国内访问不稳定时自动切换)
_FALLBACK_HUB_BASES = [
'https://ghproxy.com/https://raw.githubusercontent.com/openclaw-ai/skills-hub/main',
'https://raw.gitmirror.com/openclaw-ai/skills-hub/main',
]
# 支持通过环境变量覆盖 Hub 地址
_HUB_BASE_ENV = 'OPENCLAW_SKILLS_HUB_BASE'
def _get_hub_url(skill_name):
"""获取 skill 的 Hub URL支持环境变量覆盖"""
base = (Path.home() / '.openclaw' / 'skills-hub-url').read_text().strip() \
if (Path.home() / '.openclaw' / 'skills-hub-url').exists() else None
base = base or os.environ.get(_HUB_BASE_ENV) or OFFICIAL_SKILLS_HUB_BASE
return f'{base.rstrip("/")}/{skill_name}/SKILL.md'
OFFICIAL_SKILLS_HUB = {
'code_review': 'https://raw.githubusercontent.com/openclaw-ai/skills-hub/main/code_review/SKILL.md',
'api_design': 'https://raw.githubusercontent.com/openclaw-ai/skills-hub/main/api_design/SKILL.md',
'security_audit': 'https://raw.githubusercontent.com/openclaw-ai/skills-hub/main/security_audit/SKILL.md',
'data_analysis': 'https://raw.githubusercontent.com/openclaw-ai/skills-hub/main/data_analysis/SKILL.md',
'doc_generation': 'https://raw.githubusercontent.com/openclaw-ai/skills-hub/main/doc_generation/SKILL.md',
'test_framework': 'https://raw.githubusercontent.com/openclaw-ai/skills-hub/main/test_framework/SKILL.md',
'code_review': _get_hub_url('code_review'),
'api_design': _get_hub_url('api_design'),
'security_audit': _get_hub_url('security_audit'),
'data_analysis': _get_hub_url('data_analysis'),
'doc_generation': _get_hub_url('doc_generation'),
'test_framework': _get_hub_url('test_framework'),
}
SKILL_AGENT_MAPPING = {
@@ -259,9 +278,21 @@ def import_official_hub(agent_ids: list) -> bool:
print(f'\n📥 正在导入 skill: {skill_name}')
print(f' 目标 agents: {", ".join(target_agents)}')
# 尝试主 URL失败则自动切换镜像
effective_url = url
for agent_id in target_agents:
total += 1
if add_remote(agent_id, skill_name, url, f'官方 skill{skill_name}'):
ok = add_remote(agent_id, skill_name, effective_url, f'官方 skill{skill_name}')
if not ok and effective_url == url:
# 主 URL 失败,尝试镜像
for fb_base in _FALLBACK_HUB_BASES:
fb_url = f'{fb_base.rstrip("/")}/{skill_name}/SKILL.md'
print(f' 🔄 尝试镜像: {fb_url}')
ok = add_remote(agent_id, skill_name, fb_url, f'官方 skill{skill_name}')
if ok:
effective_url = fb_url # 后续 agent 也用这个镜像
break
if ok:
success += 1
else:
failed.append(f'{agent_id}/{skill_name}')
@@ -272,9 +303,11 @@ def import_official_hub(agent_ids: list) -> bool:
for f in failed:
print(f' - {f}')
print(f'\n💡 排查建议:')
print(f' 1. 检查网络: curl -I https://raw.githubusercontent.com/openclaw-ai/skills-hub/main/code_review/SKILL.md')
print(f' 1. 检查网络: curl -I {OFFICIAL_SKILLS_HUB_BASE}/code_review/SKILL.md')
print(f' 2. 设置代理: export https_proxy=http://your-proxy:port')
print(f' 3. 单独重试: python3 scripts/skill_manager.py add-remote --agent <agent> --name <skill> --source <url>')
print(f' 3. 使用镜像: export {_HUB_BASE_ENV}=https://ghproxy.com/{OFFICIAL_SKILLS_HUB_BASE}')
print(f' 4. 自定义源: echo "https://your-mirror/skills" > ~/.openclaw/skills-hub-url')
print(f' 5. 单独重试: python3 scripts/skill_manager.py add-remote --agent <agent> --name <skill> --source <url>')
return success == total

View File

@@ -79,6 +79,34 @@ def get_skills(workspace: str):
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)
# 收集每个 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:
@@ -90,6 +118,7 @@ def main():
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()
@@ -139,7 +168,7 @@ def main():
payload = {
'generatedAt': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'defaultModel': default_model,
'knownModels': KNOWN_MODELS,
'knownModels': merged_models,
'agents': result,
}
DATA.mkdir(exist_ok=True)

View File

@@ -291,13 +291,11 @@ def main():
if t.get('state') != 'Blocked':
continue
# 3. 排除非活跃的 OC 会话 (超过 5 分钟无响应),避免污染看板
# 除非它是 Blocked (报错),或者是今天新建的
# 3. 排除已冷却的 OC 会话,避免污染看板
# 保留 Doing<2min、Review<60min、Blocked报错
# 仅过滤掉 Next>60min 无响应)等已结束/闲置的会话
state = t.get('state')
# state_from_session: < 2min = Doing, < 60min = Review, else = Next
if state not in ('Doing', 'Blocked'):
# 如果不是正在进行或报错,就隐藏掉
# 特例: 如果是 mission control (mc-) 的心跳,可能也没必要显示,除非 Doing
if state not in ('Doing', 'Review', 'Blocked'):
continue
filtered_tasks.append(t)