mirror of
https://mirror.skon.top/github.com/cft0808/edict
synced 2026-04-20 21:00:16 +08:00
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:
@@ -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
693
dashboard/court_discuss.py
Normal 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
84
dashboard/dist/assets/index-BHFX02xH.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dashboard/dist/assets/index-CIswLNpP.css
vendored
1
dashboard/dist/assets/index-CIswLNpP.css
vendored
File diff suppressed because one or more lines are too long
84
dashboard/dist/assets/index-CqMbmm5B.js
vendored
84
dashboard/dist/assets/index-CqMbmm5B.js
vendored
File diff suppressed because one or more lines are too long
1
dashboard/dist/assets/index-NQIHw-yB.css
vendored
Normal file
1
dashboard/dist/assets/index-NQIHw-yB.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
dashboard/dist/index.html
vendored
4
dashboard/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 状态
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
774
edict/frontend/src/components/CourtDiscussion.tsx
Normal file
774
edict/frontend/src/components/CourtDiscussion.tsx
Normal 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}/8,至少2位)</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>
|
||||
);
|
||||
}
|
||||
@@ -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); } }
|
||||
|
||||
@@ -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: '🎯' },
|
||||
|
||||
@@ -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"}
|
||||
69
install.sh
69
install.sh
@@ -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=all(Agent 间可互相通信)"
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user