pipeline-sdlc/wwwroot/api/cockpit_chat.dspy
ymq 2b0bc7519a cockpit: file upload support — read files, inject to LLM, emit file widgets
- Parse file_paths from request, read file contents (UTF-8, max 8000 chars)
- File content appended to LLM message as 【附件文件内容】
- File widget streamed to frontend: 📎 filename (size) + hidden content preview
- Frontend script: click .file-name → toggle .file-content visibility
- File paths saved in pipeline_conversations.attachments
2026-08-08 15:58:59 +08:00

469 lines
24 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.dspy - SDLC Agent Loop v2.1 (streaming)
# POST: action=send_message, message_text, model_id
# GET: action=list_messages, iteration_id
# Streaming mode: 每个 tool call / 阶段 实时推送进度到前端
import aiohttp
import json
import os
action = (params_kw or {}).get('action', 'list_messages')
dbname = get_module_dbname('pipeline-sdlc')
# ── Persona ──
AGENT_PROMPT = """你是开发产线驾驶舱 Agent,负责软件工程全生命周期管理。
## 工具
__TOOLS__
## 规则
1. 根据用户输入自主决定调用哪些工具、顺序
2. 可以多轮分步推进,每轮一个工具调用
3. 最终用 reply 给出中文总结
4. 简洁专业,安全底线
5. 创建任务后可以等待检查进度,直到有确定结果再回复用户
## 当前环境
__ENV__"""
# ── Tools ──
TOOLS = [
{"name":"switch_project","description":"切换项目,支持部分名称匹配","params":{"project":"项目名称或关键字"}},
{"name":"create_project","description":"创建新项目","params":{"name":"项目名称","description":"项目描述(可选)"}},
{"name":"create_task","description":"提交任务到角色Agent队列","params":{"title":"标题","description":"详细描述","role":"requirement/design/develop/test/deploy,不指定自动推断"}},
{"name":"list_tasks","description":"列出项目任务","params":{"state":"状态过滤(可选)"}},
{"name":"add_repo","description":"关联Git仓库","params":{"url":"git地址","name":"仓库名(可选)"}},
{"name":"list_repos","description":"列出仓库","params":{}},
{"name":"check_progress","description":"检查指定任务或项目总体进度,包括状态变化、Agent执行情况、PM审核结果","params":{"task_id":"任务ID(可选,不传则查项目全局)"}},
{"name":"add_bug","description":"报告Bug","params":{"title":"标题","description":"描述","severity":"critical/major/minor(可选)"}},
{"name":"list_bugs","description":"列出Bug","params":{}},
{"name":"get_deliverable","description":"获取交付件内容","params":{"task_id":"任务ID"}},
{"name":"shell_exec","description":"执行shell命令(git clone等)","params":{"command":"命令","workdir":"工作目录(可选)"}},
{"name":"list_skills","description":"列出开发技能","params":{}},
]
TOOLS_TEXT = json.dumps(TOOLS, ensure_ascii=False, indent=2)
# ── Security ──
SECURITY_RULES = [
('drop table', 'drop database', 'truncate table', '删库', '清空数据库', '破坏性SQL操作'),
('rm -rf', 'mkfs', '格式化磁盘', '破坏文件系统的危险命令'),
('忽略之前的', '忽略上面所有', 'ignore previous', 'ignore all instructions', '提示注入'),
('绕过权限', '绕过鉴权', '关闭rbac', '越权操作'),
]
def _security_scan(text):
t = (text or '').lower()
for patterns in SECURITY_RULES:
reason = patterns[-1]
for p in patterns[:-1]:
if p.lower() in t: return True, reason
cred_kw = ('api_key', 'apikey', 'access_token', 'secret_key', '密钥', '数据库密码')
cred_vb = ('给我', '发我', '泄露', '输出', '打印', 'tell me', 'give me', 'show me')
if any(k in t for k in cred_kw) and any(v.lower() in t for v in cred_vb):
return True, '索取系统凭据'
return False, ''
# ── Helpers ──
def _guess_role(title):
t = (title or '').lower()
m = {'requirement':['需求分析','需求文档','需求规格','requirement','调研'],
'design':['设计','design','架构','方案','原型','ui','ux'],
'develop':['开发','编码','实现','编写','develop','code','build','重构','修复'],
'test':['测试','test','验证','检查','review','评审'],
'deploy':['部署','发布','deploy','release','上线','配置']}
for role, kws in m.items():
for kw in kws:
if kw in t: return role
return 'develop'
async def _find_project(sor, name):
if not name: return None
recs = await sor.sqlExe("SELECT id, name FROM sd_projects WHERE name=${n}$", {"n":name})
if recs: return recs[0]
allp = await sor.sqlExe("SELECT id, name FROM sd_projects ORDER BY created_at DESC",{})
kw = name.lower()
for r in (allp or []):
if kw in (getattr(r,'name','') or '').lower(): return r
return None
async def _load_ctx(sor, uid):
recs = await sor.sqlExe("SELECT current_project_id FROM pipeline_agent_settings WHERE user_id=${uid}$",{"uid":uid})
ctx = {'pid':'','pname':'','ws':'','skills_dir':'','repos':[]}
if not recs: return ctx
ctx['pid'] = getattr(recs[0],'current_project_id','') or ''
if ctx['pid']:
projs = await sor.sqlExe("SELECT name,workspace_dir,org_id FROM sd_projects WHERE id=${pid}$",{"pid":ctx['pid']})
if projs:
ctx['pname'] = getattr(projs[0],'name','')
ctx['ws'] = getattr(projs[0],'workspace_dir','') or ''
oid = getattr(projs[0],'org_id','') or '0'
orgs = await sor.sqlExe("SELECT skills_dir FROM sd_org_settings WHERE org_id=${oid}$",{"oid":oid})
if orgs: ctx['skills_dir'] = getattr(orgs[0],'skills_dir','') or ''
repos = await sor.sqlExe("SELECT repo_name,repo_url FROM sd_project_repos WHERE project_id=${pid}$",{"pid":ctx['pid']})
ctx['repos'] = [{'n':r.repo_name,'u':r.repo_url} for r in (repos or [])]
return ctx
async def _save_ctx(sor, uid, pid, iid=''):
existing = await sor.sqlExe("SELECT id FROM pipeline_agent_settings WHERE user_id=${uid}$",{"uid":uid})
if existing:
await sor.sqlExe("UPDATE pipeline_agent_settings SET current_project_id=${p}$,current_iteration_id=${i}$ WHERE user_id=${uid}$",{"p":pid or '','i':iid or '','uid':uid})
else:
await sor.C('pipeline_agent_settings',{'id':getID(),'user_id':uid,'default_llm_id':'','current_project_id':pid or '','current_iteration_id':iid or ''})
async def _sel_model(sor, preferred):
if preferred:
recs = await sor.sqlExe("SELECT id,name,provider,model_id,api_base,api_key FROM llm WHERE (id=${l}$ OR name=${l}$) AND status='active'",{"l":preferred})
if recs: return recs[0]
recs = await sor.sqlExe("SELECT id,name,provider,model_id,api_base,api_key FROM llm WHERE status='active' LIMIT 1",{})
return recs[0] if recs else None
async def _call_llm(model, msgs, temp):
headers = {"Authorization": f"Bearer {model.api_key or ''}", "Content-Type": "application/json"}
payload = {"model": model.model_id, "messages": msgs, "temperature": temp}
timeout = aiohttp.ClientTimeout(total=180)
async with aiohttp.ClientSession(timeout=timeout) as s:
async with s.post(f"{model.api_base.rstrip('/')}/chat/completions", headers=headers, json=payload) as r:
if r.status != 200:
t = await r.text()
raise ValueError(f"LLM {r.status}: {t[:300]}")
d = await r.json()
return d["choices"][0]["message"]["content"]
def _parse(raw):
raw = (raw or "").strip()
if raw.startswith("```"): raw = raw.split("\n",1)[1].rsplit("```",1)[0].strip()
try:
d = json.loads(raw)
if isinstance(d,dict) and 'action' in d: return d
except: pass
return {"action":"reply","message":raw}
# ── Tool Executor ──
async def _exec(sor, tool, params, ctx, uid, org_id):
try:
p = params or {}
if tool == 'switch_project':
proj = await _find_project(sor, p.get('project',''))
if proj:
await _save_ctx(sor, uid, proj.id)
ctx['pid'] = proj.id; ctx['pname'] = proj.name
return f"已切换到「{proj.name}」"
allp = await sor.sqlExe("SELECT name FROM sd_projects ORDER BY created_at DESC LIMIT 15",{})
names = ', '.join([getattr(r,'name','') for r in (allp or [])])
return f"未找到。可用项目: {names}"
elif tool == 'create_project':
name = p.get('name','')[:80]
if not name: return "缺少项目名称"
ex = await sor.sqlExe("SELECT id FROM sd_projects WHERE name=${n}$",{"n":name})
if ex: return f"「{name}」已存在"
pid = getID()
ws = os.path.expanduser(f'~/pipeline_ws/{name}')
os.makedirs(ws, exist_ok=True)
await sor.C('sd_projects',{'id':pid,'name':name,'description':p.get('description',''),'project_type':'software','org_id':org_id,'created_by':uid,'status':'active','workspace_dir':ws})
iid = getID()
await sor.C('sd_iterations',{'id':iid,'project_id':pid,'iteration_name':'默认迭代','iteration_type':'sprint','org_id':org_id,'created_by':uid,'status':'active'})
await _save_ctx(sor, uid, pid, iid)
ctx['pid'] = pid; ctx['pname'] = name
return f"✅ 项目「{name}」已创建(目录: {ws})"
elif tool == 'create_task':
if not ctx['pid']: return "请先切换到项目"
title = p.get('title','')[:100]
if not title: return "缺少标题"
role = p.get('role','') or _guess_role(title)
tp = {'description':p.get('description',''), 'project_id':ctx['pid']}
r = await pipeline_role_submit(ctx['pid'],'role_task',uid,title,tp,role)
rd = json.loads(r)
if rd.get('success'):
tid = rd.get('task_id','')
return f"✅ 任务已创建\n标题: {title}\n角色: {role}\nID: {tid[:12]}\nAgent将在15秒内认领执行"
return f"创建失败: {rd.get('message','')}"
elif tool == 'list_tasks':
if not ctx['pid']: return "请先切换到项目"
sf = p.get('state','')
sql = "SELECT title,state,role,created_at FROM pipeline_tasks WHERE tenant_id=${p}$"
if sf: sql += " AND state=${s}$"
sql += " ORDER BY created_at DESC LIMIT 20"
ts = await sor.sqlExe(sql, {"p":ctx['pid'],"s":sf} if sf else {"p":ctx['pid']})
if not ts: return "暂无任务"
em = {'submitted':'⏳','running':'🔄','review':'👀','approved':'✅','completed':'🏁','failed':'❌','waiting':'⏸️'}
return '\n'.join([f"{em.get(t.state,'❓')} [{t.state}][{t.role}] {t.title}" for t in ts])
elif tool == 'add_repo':
if not ctx['pid']: return "请先切换到项目"
url = p.get('url','')
if not url: return "请提供仓库地址"
name = p.get('name','') or url.rstrip('/').split('/')[-1].replace('.git','')
ex = await sor.sqlExe("SELECT id FROM sd_project_repos WHERE project_id=${p}$ AND repo_url=${u}$",{"p":ctx['pid'],"u":url})
if ex: return "仓库已关联"
await sor.C('sd_project_repos',{'id':getID(),'project_id':ctx['pid'],'repo_name':name,'repo_url':url,'default_branch':'main','local_path':'','org_id':org_id})
ctx['repos'].append({'n':name,'u':url})
return f"✅ 已关联 {name}"
elif tool == 'list_repos':
if not ctx['pid']: return "请先切换到项目"
rs = await sor.sqlExe("SELECT repo_name,repo_url FROM sd_project_repos WHERE project_id=${p}$",{"p":ctx['pid']})
if not rs: return "暂无仓库"
return '\n'.join([f"· {r.repo_name}: {r.repo_url}" for r in rs])
elif tool == 'check_progress':
if not ctx['pid']: return "请先切换到项目"
tid = p.get('task_id','')
if tid:
t = await sor.sqlExe("SELECT title,state,role FROM pipeline_tasks WHERE id=${t}$",{"t":tid})
if not t: return "任务不存在"
t = t[0]
ds = await sor.sqlExe("SELECT deliverable_type,review_status FROM pipeline_deliverables WHERE task_id=${t}$ ORDER BY created_at DESC LIMIT 1",{"t":tid})
dl = f"交付: {ds[0].deliverable_type}({ds[0].review_status})" if ds else "无交付件"
return f"[{t.state}][{t.role}] {t.title}\n{dl}"
# Global
ts = await sor.sqlExe("SELECT state,count(*) as c FROM pipeline_tasks WHERE tenant_id=${p}$ GROUP BY state",{"p":ctx['pid']})
summary = ', '.join([f"{t.state}:{t.c}" for t in (ts or [])]) or "无"
ags = await sor.sqlExe("SELECT role_name,status FROM pipeline_project_agents WHERE project_id=${p}$",{"p":ctx['pid']})
al = ', '.join([f"{a.role_name}({a.status})" for a in (ags or [])]) or "未配置"
ds = await sor.sqlExe("SELECT deliverable_type,title,review_status FROM pipeline_deliverables WHERE project_id=${p}$ ORDER BY created_at DESC LIMIT 3",{"p":ctx['pid']})
dl = '\n'.join([f" [{d.deliverable_type}] {d.title[:50]}({d.review_status})" for d in (ds or [])]) or "无"
return f"任务: {summary}\nAgent: {al}\n最近交付:\n{dl}"
elif tool == 'add_bug':
if not ctx['pid']: return "请先切换到项目"
title = p.get('title','')[:100]
if not title: return "缺少标题"
await sor.C('sd_bugs',{'id':getID(),'iteration_id':'','title':title,'description':p.get('description',''),'severity':p.get('severity','major'),'priority':'P1','status':'open','reporter_type':'human','reporter_id':uid})
return f"🐛 Bug已记录: {title}"
elif tool == 'list_bugs':
if not ctx['pid']: return "请先切换到项目"
bs = await sor.sqlExe("SELECT title,severity,status FROM sd_bugs ORDER BY created_at DESC LIMIT 20",{})
if not bs: return "暂无Bug"
return '\n'.join([f"· [{b.severity}] {b.title} ({b.status})" for b in bs])
elif tool == 'get_deliverable':
tid = p.get('task_id','')
if not tid: return "请提供任务ID"
ds = await sor.sqlExe("SELECT deliverable_type,title,content,file_path FROM pipeline_deliverables WHERE task_id=${t}$ ORDER BY created_at DESC LIMIT 1",{"t":tid})
if not ds: return "无交付件"
d = ds[0]
return f"[{d.deliverable_type}] {d.title}\n文件: {d.file_path or '无'}\n内容:\n{(d.content or '')[:3000]}"
elif tool == 'shell_exec':
cmd = p.get('command','')
wd = p.get('workdir','') or ctx.get('ws','')
if not cmd: return "请提供命令"
r = await shell_exec(cmd, workdir=wd, timeout=120)
return f"rc={r['rc']}\n{r['stdout'][:2000]}" if r['rc']==0 else f"失败 rc={r['rc']}\n{r['stderr'][:1000]}"
elif tool == 'list_skills':
sd = ctx.get('skills_dir','')
if not sd or not os.path.isdir(sd): return "Skills目录未配置"
found = {}
for rd in sorted(os.listdir(sd)):
rp = os.path.join(sd, rd)
if os.path.isdir(rp):
ss = [d for d in os.listdir(rp) if os.path.isdir(os.path.join(rp,d)) and os.path.isfile(os.path.join(rp,d,'SKILL.md'))]
if ss: found[rd] = ss
if not found: return "无技能"
return '\n'.join([f"[{r}] {', '.join(sorted(n))}" for r,n in sorted(found.items())])
return f"未知工具: {tool}"
except Exception as e:
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)
def _widget_file(filename, content_preview, file_size=0):
"""Bricks widget: file attachment with expandable content preview."""
size_str = f" ({file_size} bytes)" if file_size else ""
return {
"widgettype": "VBox",
"options": {
"css": "agent-file",
"style": {"border": "1px solid #e2e8f0", "borderRadius": "8px", "padding": "8px 12px", "margin": "4px 0"}
},
"subwidgets": [
{"widgettype": "HBox", "subwidgets": [
{"widgettype": "Text", "options": {"text": f"📎 {filename}{size_str}", "css": "file-name",
"style": {"color": "#2563eb", "cursor": "pointer", "fontSize": "13px", "fontWeight": "bold"}}}
]},
{"widgettype": "Text", "options": {"text": content_preview or "(空文件)", "css": "file-content",
"style": {"display": "none", "whiteSpace": "pre-wrap", "fontSize": "12px", "color": "#475569",
"background": "#f8fafc", "borderRadius": "4px", "padding": "8px", "marginTop": "6px",
"maxHeight": "300px", "overflowY": "auto"}}}
]
}
# ── Streaming Agent Loop ──
if action == 'send_message':
message_text = (params_kw or {}).get('message_text', '').strip()
user_model_id = (params_kw or {}).get('model_id', '')
file_paths_raw = (params_kw or {}).get('file_paths', '[]')
file_paths = []
try:
file_paths = json.loads(file_paths_raw) if isinstance(file_paths_raw, str) else (file_paths_raw or [])
except (json.JSONDecodeError, TypeError):
file_paths = []
if not message_text:
return json.dumps({"error": "message_text is required"}, ensure_ascii=False)
blocked, reason = _security_scan(message_text)
if blocked:
# 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 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":"widget","widget":_widget_card("❌ 错误", _text("没有可用的LLM模型配置"),"error")}, ensure_ascii=False) + '\n'
return
repos_str = ', '.join([r['n'] for r in ctx['repos']]) or '无'
env_text = f"项目: {ctx['pname'] or '未选择'}\n工作目录: {ctx['ws'] or '未配置'}\n仓库: {repos_str}\nSkills: {ctx['skills_dir'] or '未配置'}"
history = await sor.sqlExe("SELECT role,content FROM pipeline_conversations ORDER BY created_at ASC LIMIT 20",{})
system = AGENT_PROMPT.replace('__TOOLS__', TOOLS_TEXT).replace('__ENV__', env_text)
msgs = [{"role":"system","content":system}]
for h in (history or []):
msgs.append({"role":'user' if getattr(h,'role','')=='user' else 'assistant',"content":getattr(h,'content','')})
await sor.C('pipeline_conversations',{'id':getID(),'iteration_id':'','task_id':'','role':'user','content':message_text,'attachments':json.dumps(file_paths),'msg_type':'text','org_id':org_id,'created_by':uid})
# 处理上传文件:读取内容、发送文件widget、注入LLM上下文
file_contents = []
for fp in file_paths:
try:
if os.path.isfile(fp):
with open(fp, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()[:8000]
fsize = os.path.getsize(fp)
fname = os.path.basename(fp)
file_contents.append(f"=== 文件: {fname} ===\n{content}")
# 发送文件widget
preview = content[:1000] + ("\n...(截断)" if len(content) > 1000 else "")
yield json.dumps({"type":"widget","widget":_widget_file(fname, preview, fsize)}, ensure_ascii=False) + '\n'
except Exception:
pass
# 把用户消息+文件内容合并为一条LLM消息
full_message = message_text
if file_contents:
full_message = message_text + "\n\n【附件文件内容】\n" + "\n\n".join(file_contents)
msgs.append({"role":"user","content":full_message})
agent_reply = ''
for turn in range(10):
try:
raw = await _call_llm(model, msgs, 0.4)
except Exception as e:
yield json.dumps({"type":"widget","widget":_widget_card("❌ LLM错误", _text(str(e)[:200]),"error")}, ensure_ascii=False) + '\n'
return
act = _parse(raw)
if act.get('action') == 'reply':
agent_reply = act.get('message', raw)
break
if act.get('action') == 'tool_call':
tool = act.get('tool','')
tparams = act.get('params',{})
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("失败")
# 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}"})
continue
agent_reply = raw
break
if not agent_reply:
agent_reply = "处理超时,请简化需求。"
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'})
# 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')
elif action == 'list_messages':
iteration_id = (params_kw or {}).get('iteration_id', '')
task_id = (params_kw or {}).get('task_id', '')
where = []
params = {}
if task_id: where.append("task_id=${t}$"); params["t"] = task_id
if iteration_id: where.append("iteration_id=${i}$"); params["i"] = iteration_id
if not where: where.append("1=1")
async with DBPools().sqlorContext(dbname) as sor:
ms = await sor.sqlExe(f"SELECT role,content,created_at FROM pipeline_conversations WHERE {' OR '.join(where)} ORDER BY created_at ASC LIMIT 100", params)
result = [{"role":getattr(m,'role',''),"content":getattr(m,'content',''),"created_at":str(getattr(m,'created_at',''))} for m in (ms or [])]
return json.dumps({"success":True,"messages":result}, ensure_ascii=False, default=str)
else:
return json.dumps({"error":f"Unknown action: {action}"}, ensure_ascii=False)