pipeline-sdlc/wwwroot/api/cockpit_chat_v2.dspy

176 lines
8.9 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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'<w:t[^>]*>(.*?)</w:t>', 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.jsonmsg.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)