# cockpit_chat_v2.dspy - SDLC Agent v2 (AgentExecutor 驱动) # 替代 cockpit_chat.dspy,使用 pipeline-core/pipeline-service v2 架构 import aiohttp import os import re import zipfile from ahserver.filestorage import FileStorage def _extract_text(path, name): """提取文件文本内容(docx/txt/md等),返回文本或空字符串。二进制/无法解析返回空。""" ext = os.path.splitext(name)[1].lower() try: if ext in ('.txt', '.md', '.json', '.csv', '.py', '.log', '.yaml', '.yml', '.xml', '.html', '.ini'): with open(path, 'r', encoding='utf-8', errors='ignore') as f: return f.read()[:15000] if ext == '.docx': with zipfile.ZipFile(path) as z: xml = z.read('word/document.xml').decode('utf-8', errors='ignore') texts = re.findall(r']*>(.*?)', xml) return '\n'.join(texts)[:15000] except Exception: pass return '' action = (params_kw or {}).get('action', 'send_message') msg = '' if action != 'send_message': p = params_kw or {} msg = (p.get('message_text') or '').strip() if not msg: inner = p.get('params', {}) if isinstance(inner, dict): msg = (inner.get('prompt') or inner.get('message_text') or '').strip() if msg: action = 'send_message' dbname = get_module_dbname('pipeline-sdlc') # ── Widget helpers ── def _w_text(t, css='agent-text'): return {"widgettype":"Text","options":{"text":t,"halign":"left","css":css}} def _w_progress(t): return {"widgettype":"Text","options":{"text":t,"halign":"left","style":{"color":"#f0a040","fontStyle":"italic"}}} def _w_md(t): return {"widgettype":"MdWidget","options":{"text":t}} def _w_card(title, body, kind="info"): colors = {"success":"#4caf50","error":"#f44336","info":"#2196f3","warn":"#ff9800"} color = colors.get(kind, colors["info"]) title_w = {"widgettype":"Text","options":{"text":title,"halign":"left","style":{"fontWeight":"bold","color":color,"marginBottom":"4px"}}} body_w = body if isinstance(body, dict) else _w_text(str(body)) return {"widgettype":"VBox","subwidgets":[title_w, body_w], "options":{"css":"agent-card","style":{"borderLeft":"3px solid "+color,"paddingLeft":"8px","marginBottom":"8px"}}} if action == 'send_message': # 从 params 中提取 prompt prompt = (params_kw or {}).get('prompt', '') or msg prompt = prompt.strip() # 处理用户上传的文件(multipart file 字段 → web_path): # 1. 抽取文本作为中性上下文注入 prompt # 2. 收集 (绝对路径, 文件名),稍后复制进项目根目录,让会话 agent 的 read_file 能找到 file_ctx = '' _uploads = [] # [(src_abs, filename)] _fval = (params_kw or {}).get('file') _fpaths = _fval if isinstance(_fval, list) else ([_fval] if _fval else []) for _fp in _fpaths: try: _abs = FileStorage().realPath(_fp) _name = os.path.basename(_abs) _uploads.append((_abs, _name)) _txt = _extract_text(_abs, _name) if _txt: file_ctx += f"【文件 {_name} 内容】\n{_txt}\n\n" else: file_ctx += f"【文件 {_name}】二进制文件,无法直接读取文本。\n\n" except Exception: pass # 停止/暂停指令不再用硬编码关键字短路——交给 LLM 意图识别(agent system prompt 已明确: # 用户指令「暂停/停止推进」时调 pause_project)。硬编码子串匹配会把「为什么任务取消了」这类 # 分析问题误判为停止指令(2026-08 实测),故删除。 uid = await get_user() if not uid: uid = 'user-01' # 测试兼容 # ── 加载上下文 ── session_id = (params_kw or {}).get('session_id', '') ctx = {'pid': '', 'name': '', 'pipeline_id': ''} _saved_files = [] _pdir = '' async with DBPools().sqlorContext(dbname) as sor: ctx['pid'], _ = await get_session_context(sor, uid, session_id) if ctx['pid']: proj_recs = await sor.sqlExe( "SELECT name, pipeline_id FROM sd_projects WHERE id=${p}$", {"p": ctx['pid']}) if proj_recs: ctx['name'] = getattr(proj_recs[0], 'name', '') ctx['pipeline_id'] = getattr(proj_recs[0], 'pipeline_id', '') or '' # ── 上传文件落盘到项目根目录(会话 agent 的 read_file 以项目目录为根)── if _uploads: _pdir, _ = await get_project_dir_by_id(sor, ctx['pid']) _saved_files = copy_uploads_to_project(_pdir, _uploads) if file_ctx or _saved_files: _loc = '' if _saved_files: _loc = "文件已保存到项目根目录(" + _pdir + "),可用 read_file 直接读取:\n" _loc += "\n".join(" - " + n for n, _ in _saved_files) + "\n\n" prompt = "用户本次上传了以下文件:\n" + _loc + file_ctx + "\n用户指令:" + prompt # ── 走 gateway 统一入口(Web AgentIO 通道)── # model_id:前端模型下拉选中的模型 → gateway 校验后持久化到项目(项目模型一经设置 # 即生效,直到用户再次选择),空 = 沿用项目已设模型。 model_id = (params_kw or {}).get('model_id', '') or '' from pipeline_service.gateway import get_gateway gateway = get_gateway() async def agent_stream(): # 项目上下文 if ctx['name']: yield json.dumps({"reasoning_content": "项目: " + ctx['name'] + "\n"}, ensure_ascii=False) + '\n' # 运行 gateway(输出 content 流式格式,供前端 AgentIO→AgentOut 渲染)。 # pipeline_id 声明本产线:激活会话项目跨产线隔离——残留的投标/商机项目 # 不会劫持开发产线驾驶舱(否则全局兜底指向跨产线项目时整会话变投标大脑)。 async for chunk in gateway.run_message("web", uid, prompt, model_id=model_id, session_id=session_id, pipeline_id='sdlc_general'): data = json.loads(chunk) t = data.get('type', '') if t == 'progress': yield json.dumps({"reasoning_content": data.get('message', '') + "\n"}, ensure_ascii=False) + '\n' elif t == 'auto_tool': yield json.dumps({"reasoning_content": "🔄 " + data.get('message', '') + "\n"}, ensure_ascii=False) + '\n' elif t == 'debug': continue # skip debug in UI elif t == 'tool_call': tool = data.get('tool', '') params = json.dumps(data.get('params', {}), ensure_ascii=False) yield json.dumps({"content": "**🔧 调用: " + tool + "**\n```\n" + params + "\n```\n\n"}, ensure_ascii=False) + '\n' elif t == 'tool_result': result = data.get('result', '') yield json.dumps({"content": result + "\n\n"}, ensure_ascii=False) + '\n' elif t == 'reply': msg = data.get('message', '') if isinstance(msg, dict) and msg.get('widgettype'): # widget JSON:顶层透传,前端 AgentOut 检测 widgettype 渲染(如 /task 的 PopupWindow) yield json.dumps(msg, ensure_ascii=False) + '\n' else: yield json.dumps({"content": msg}, ensure_ascii=False) + '\n' elif t == 'ask_user': yield json.dumps({"content": "❓ " + data.get('message', '')}, ensure_ascii=False) + '\n' elif t == 'error': yield json.dumps({"error": data.get('message', '')}, ensure_ascii=False) + '\n' else: yield json.dumps({"content": chunk}, ensure_ascii=False) + '\n' # 应答输出结束后追加一行「## 完毕 ##」:Text 控件 otext+i18n,前端按当前语言翻译 # (词条见 pipeline-app/i18n/*/i18n.json;msg.txt 以 # 开头的键会被当注释,故只放 i18n.json) yield json.dumps({"widgettype": "Text", "options": {"otext": "## 完毕 ##", "text": "## 完毕 ##", "i18n": True, "halign": "left", "css": "agent-done", "style": {"color": "#94a3b8"}}}, ensure_ascii=False) + '\n' return await stream_response(request, agent_stream, 'text/plain; charset=utf-8') elif action == 'list_messages': uid = await get_user() if not uid: return json.dumps({"error":"请先登录"}, ensure_ascii=False) async with DBPools().sqlorContext(dbname) as sor: ms = await sor.sqlExe( "SELECT role,content,created_at FROM pipeline_conversations ORDER BY created_at ASC LIMIT 100", {}) result = [{"role":getattr(m,'role',''),"content":getattr(m,'content','')} for m in (ms or [])] return json.dumps({"success":True,"messages":result}, ensure_ascii=False, default=str) else: return json.dumps({"error":"Unknown: " + str(action)}, ensure_ascii=False)