From 39deb68ba6aeeef9690c377726290934418cb7e7 Mon Sep 17 00:00:00 2001 From: ymq Date: Sat, 8 Aug 2026 15:57:03 +0800 Subject: [PATCH] cockpit v2.2: stream Bricks widgets for frontend rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Each tool progress yields a progress Text widget - Each tool result yields a card widget (VBox with colored left border) - Final reply yields a reply card widget - Colors: green=success, red=error, purple=reply, amber=progress - Frontend: parse NDJSON → bricks.buildWidget() → append to chat --- wwwroot/api/cockpit_chat.dspy | 64 ++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/wwwroot/api/cockpit_chat.dspy b/wwwroot/api/cockpit_chat.dspy index b82b4b8..8de0f21 100644 --- a/wwwroot/api/cockpit_chat.dspy +++ b/wwwroot/api/cockpit_chat.dspy @@ -280,6 +280,37 @@ async def _exec(sor, tool, params, ctx, uid, org_id): return f"错误: {str(e)[:300]}" +# ── Widget Builders ── + +def _text(content): + return {"widgettype":"Text","options":{"text":content,"css":"agent-text"}} + +def _widget_card(title, body, kind="success"): + """Bricks widget: a card with title + body.""" + colors = {"success":"#10b981","error":"#ef4444","reply":"#6366f1","progress":"#f59e0b"} + color = colors.get(kind, "#6b7280") + return { + "widgettype":"VBox", + "options":{"css":"agent-card","style":{"borderLeft":f"3px solid {color}","padding":"8px 12px","margin":"4px 0"}}, + "subwidgets":[ + {"widgettype":"Text","options":{"text":title,"css":"agent-card-title","style":{"fontWeight":"bold","fontSize":"13px","color":color}}}, + body + ] + } + +def _widget_progress(text): + return {"widgettype":"Text","options":{"text":text,"css":"agent-progress","style":{"color":"#f59e0b","fontSize":"12px","padding":"2px 8px"}}} + +_tool_names = { + 'switch_project':'切换项目','create_project':'创建项目','create_task':'创建任务', + 'list_tasks':'查询任务','add_repo':'关联仓库','list_repos':'查看仓库', + 'check_progress':'检查进度','add_bug':'报告Bug','list_bugs':'查看Bug', + 'get_deliverable':'获取交付件','shell_exec':'执行命令','list_skills':'查看技能', +} +def _tool_label(tool): + return _tool_names.get(tool, tool) + + # ── Streaming Agent Loop ── if action == 'send_message': @@ -291,18 +322,19 @@ if action == 'send_message': blocked, reason = _security_scan(message_text) if blocked: - return json.dumps({"success":True,"agent_reply":f"⚠️ {reason}","intent":"security_blocked","context":{}}, ensure_ascii=False) + # Return widget directly for blocked case (no streaming needed) + w = _widget_card("⚠️ 安全拦截", _text(reason), "error") + return json.dumps({"success":True,"agent_reply":f"⚠️ {reason}","widget":w}, ensure_ascii=False) uid = await get_user() org_id = await get_userorgid() or '0' async def agent_stream(): - """Async generator: yield progress chunks to frontend.""" async with DBPools().sqlorContext(dbname) as sor: ctx = await _load_ctx(sor, uid) model = await _sel_model(sor, user_model_id) if not model: - yield json.dumps({"type":"error","message":"No active LLM configured"}, ensure_ascii=False) + '\n' + yield json.dumps({"type":"widget","widget":_widget_card("❌ 错误", _text("没有可用的LLM模型配置"),"error")}, ensure_ascii=False) + '\n' return repos_str = ', '.join([r['n'] for r in ctx['repos']]) or '无' @@ -322,7 +354,7 @@ if action == 'send_message': try: raw = await _call_llm(model, msgs, 0.4) except Exception as e: - yield json.dumps({"type":"error","message":f"LLM: {str(e)[:200]}"}, ensure_ascii=False) + '\n' + yield json.dumps({"type":"widget","widget":_widget_card("❌ LLM错误", _text(str(e)[:200]),"error")}, ensure_ascii=False) + '\n' return act = _parse(raw) @@ -333,13 +365,21 @@ if action == 'send_message': if act.get('action') == 'tool_call': tool = act.get('tool','') tparams = act.get('params',{}) - # Stream: 告知前端正在做什么 - yield json.dumps({"type":"progress","tool":tool,"params":tparams}, ensure_ascii=False) + '\n' + tool_label = _tool_label(tool) + + # Widget: 工具执行中 + yield json.dumps({"type":"widget","widget":_widget_progress(f"🔄 {tool_label}")}, ensure_ascii=False) + '\n' result = await _exec(sor, tool, tparams, ctx, uid, org_id) + ok = not result.startswith("错误") and not result.startswith("失败") - # Stream: 告知前端执行结果 - yield json.dumps({"type":"tool_result","tool":tool,"result":result[:500]}, ensure_ascii=False) + '\n' + # Widget: 工具结果 + result_widget = _widget_card( + f"{'✅' if ok else '❌'} {tool_label}", + _text(result[:500]), + "success" if ok else "error" + ) + yield json.dumps({"type":"widget","widget":result_widget}, ensure_ascii=False) + '\n' msgs.append({"role":"assistant","content":raw}) msgs.append({"role":"user","content":f"工具 {tool} 执行结果:\n{result}"}) @@ -353,7 +393,13 @@ if action == 'send_message': await sor.C('pipeline_conversations',{'id':getID(),'iteration_id':'','task_id':'','role':'agent','content':agent_reply,'attachments':'[]','msg_type':'text','org_id':org_id,'created_by':'system'}) - yield json.dumps({"type":"done","message":agent_reply,"context":ctx['pname']}, ensure_ascii=False) + '\n' + # Widget: 最终回复 + final_widget = _widget_card( + f"🤖 驾驶舱 Agent", + _text(agent_reply), + "reply" + ) + yield json.dumps({"type":"widget","widget":final_widget}, ensure_ascii=False) + '\n' return await stream_response(request, agent_stream(), 'application/x-ndjson')