diff --git a/wwwroot/api/cockpit_chat.dspy b/wwwroot/api/cockpit_chat.dspy index 4932606..eba39d4 100644 --- a/wwwroot/api/cockpit_chat.dspy +++ b/wwwroot/api/cockpit_chat.dspy @@ -14,7 +14,14 @@ if action != 'send_message': action = 'send_message' dbname = get_module_dbname('pipeline-sdlc') -AGENT_PROMPT = """你是开发产线驾驶舱 Agent。需要数据时调工具,最终用 reply 回复。 +AGENT_PROMPT = """你是开发产线驾驶舱 Agent。用工具获取数据,用 reply 回复。 + +工作流: +- 切换项目:先调 list_projects 看有哪些项目,再调 switch_project +- 创建项目:如果需要的项目不存在,调 create_project +- 提交任务前确保已切换到正确项目 +- clone_repo 用于克隆 git 仓库到工作空间 +- run_command 用于在工作空间中执行 shell 命令 输出格式二选一: {"action":"tool_call","tool":"工具名","params":{}} @@ -24,13 +31,17 @@ AGENT_PROMPT = """你是开发产线驾驶舱 Agent。需要数据时调工具 环境: __ENV__""" TOOLS = [ - {"name":"switch_project","description":"切换项目","params":{"project":"名称"}}, + {"name":"list_projects","description":"列出所有可用项目","params":{}}, + {"name":"switch_project","description":"切换到指定项目(需先list_projects确认项目名)","params":{"project":"项目名称"}}, + {"name":"create_project","description":"创建新项目","params":{"name":"项目名称","description":"项目描述(可选)"}}, {"name":"create_task","description":"提交任务","params":{"title":"标题","description":"描述","role":"requirement/design/develop/test/deploy"}}, + {"name":"list_tasks","description":"列出当前项目任务","params":{"state":"状态(可选)"}}, {"name":"check_progress","description":"查进度","params":{"task_id":"任务ID(可选)"}}, - {"name":"list_tasks","description":"列出任务","params":{"state":"状态(可选)"}}, - {"name":"add_repo","description":"关联仓库","params":{"url":"git地址","name":"名称(可选)"}}, - {"name":"list_repos","description":"列出仓库","params":{}}, + {"name":"add_repo","description":"关联仓库到当前项目","params":{"url":"git地址","name":"名称(可选)"}}, + {"name":"list_repos","description":"列出当前项目仓库","params":{}}, + {"name":"clone_repo","description":"克隆仓库到工作空间","params":{"url":"git地址","branch":"分支名(可选,默认main)"}}, {"name":"add_bug","description":"报告Bug","params":{"title":"标题","description":"描述"}}, + {"name":"run_command","description":"在工作空间中执行shell命令","params":{"cmd":"命令"}}, ] TOOLS_TEXT = json.dumps(TOOLS, ensure_ascii=False) @@ -47,23 +58,10 @@ def _guess_role(title): return 'develop' async def _find_project(sor, name): + """Exact match only — LLM should use list_projects first to discover names.""" if not name: return None - # Exact match first recs = await sor.sqlExe("SELECT id,name FROM sd_projects WHERE name=${n}$",{"n":name}) - if recs: return recs[0] - # Fuzzy match — require keyword >= 2 chars, prefer prefix match - kw = name.lower().strip() - if len(kw) < 2: return None - allp = await sor.sqlExe("SELECT id,name FROM sd_projects ORDER BY created_at DESC",{}) - # First pass: name starts with kw - for r in (allp or []): - rn = (getattr(r,'name','') or '').lower() - if rn.startswith(kw): return r - # Second pass: kw is a word in name (split by common delimiters) - for r in (allp or []): - rn = (getattr(r,'name','') or '').lower() - if kw in rn: return r - return None + return recs[0] if recs else None async def _load_ctx(sor, uid): recs = await sor.sqlExe("SELECT current_project_id FROM pipeline_agent_settings WHERE user_id=${u}$",{"u":uid}) @@ -124,12 +122,33 @@ def _w_card(title, body, kind="success"): ]} def _w_progress(text): return {"widgettype":"Text","options":{"text":text,"style":{"color":"#f59e0b","fontSize":"12px","padding":"2px 8px"}}} -_tool_labels = {'switch_project':'切换项目','create_task':'创建任务','check_progress':'检查进度','list_tasks':'查询任务','add_repo':'关联仓库','list_repos':'查看仓库','add_bug':'报告Bug'} +_tool_labels = {'list_projects':'列出项目','switch_project':'切换项目','create_project':'创建项目','create_task':'创建任务','check_progress':'检查进度','list_tasks':'查询任务','add_repo':'关联仓库','list_repos':'查看仓库','clone_repo':'克隆仓库','add_bug':'报告Bug','run_command':'执行命令'} async def _exec_tool(sor, tool, params, ctx, uid, org_id): p = params or {} try: - if tool == 'switch_project': + if tool == 'list_projects': + allp = await sor.sqlExe("SELECT id,name,description,workspace_dir FROM sd_projects ORDER BY created_at DESC LIMIT 30",{}) + if not allp: return '无项目' + lines = [] + for r in allp: + marker = '★' if getattr(r,'id','') == ctx['pid'] else ' ' + lines.append(f"{marker} {getattr(r,'name','')} | {getattr(r,'description','') or ''} | ws:{getattr(r,'workspace_dir','') or ''}") + return '\n'.join(lines) + elif tool == 'create_project': + name = (p.get('name','') or '').strip() + if not name: return 'FAIL: 需要项目名称' + ex = await sor.sqlExe("SELECT id FROM sd_projects WHERE name=${n}$",{"n":name}) + if ex: return f'FAIL: 项目「{name}」已存在' + pid = getID() + wid = getID() + ws = f'/d/pipeline/workspaces/{org_id}/{name}' + await sor.C('sd_projects',{'id':pid,'name':name,'description':p.get('description',''),'workspace_dir':ws,'org_id':org_id,'created_by':uid}) + await sor.C('sd_iterations',{'id':wid,'project_id':pid,'name':'默认迭代','org_id':org_id}) + await _save_ctx(sor, uid, pid) + ctx['pid'] = pid; ctx['pname'] = name; ctx['ws'] = ws + return f'OK: 已创建并切换到「{name}」' + elif tool == 'switch_project': proj = await _find_project(sor, p.get('project','')) if proj: await _save_ctx(sor, uid, proj.id) @@ -184,6 +203,39 @@ async def _exec_tool(sor, tool, params, ctx, uid, org_id): if not ctx['pid']: return 'FAIL: 请先切换到项目' rs = await sor.sqlExe("SELECT repo_name,repo_url FROM sd_project_repos WHERE project_id=${p}$",{"p":ctx['pid']}) return '\n'.join([f"{r.repo_name}: {r.repo_url}" for r in rs]) if rs else '无仓库' + elif tool == 'clone_repo': + if not ctx['pid']: return 'FAIL: 请先切换到项目' + url = p.get('url','') + if not url: return 'FAIL: 需要仓库地址' + branch = p.get('branch','main') + ws = ctx.get('ws','') + if not ws: return 'FAIL: 项目无工作空间路径' + import subprocess, os + repo_name = url.rstrip('/').split('/')[-1].replace('.git','') + target = os.path.join(ws, repo_name) + if os.path.exists(target): return f'FAIL: 目录已存在 {target}' + os.makedirs(ws, exist_ok=True) + cmd = f'git clone -b {branch} {url} {target}' + r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=120, cwd=ws) + if r.returncode != 0: return f'FAIL: git clone失败: {r.stderr[:200]}' + # Auto-add repo to project + ex = await sor.sqlExe("SELECT id FROM sd_project_repos WHERE project_id=${p}$ AND repo_url=${u}$",{"p":ctx['pid'],"u":url}) + if not ex: + await sor.C('sd_project_repos',{'id':getID(),'project_id':ctx['pid'],'repo_name':repo_name,'repo_url':url,'default_branch':branch,'local_path':target,'org_id':org_id}) + ctx['repos'].append({'n':repo_name,'u':url}) + return f'OK: 已克隆到 {target}' + elif tool == 'run_command': + import subprocess + ws = ctx.get('ws','') or '/tmp' + cmd = p.get('cmd','') + if not cmd: return 'FAIL: 需要命令' + # Safety: block destructive commands + blocked = ['rm -rf /','mkfs.','dd if=',':(){','chmod 777 /'] + for b in blocked: + if b in cmd: return 'FAIL: 危险命令被拦截' + r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=60, cwd=ws) + out = (r.stdout + r.stderr)[:2000] + return f'OK (rc={r.returncode}):\n{out}' if out else f'OK (rc={r.returncode})' elif tool == 'add_bug': if not ctx['pid']: return 'FAIL: 请先切换到项目' title = p.get('title','')[:100] @@ -230,7 +282,7 @@ if action == 'send_message': return repos_str = ', '.join([r['n'] for r in ctx['repos']]) or '无' - env_text = f"项目: {ctx['pname'] or '未选择'}\n仓库: {repos_str}" + env_text = f"当前项目: {ctx['pname'] or '未选择'}\n工作空间: {ctx['ws'] or '未设置'}\n仓库: {repos_str}" system = AGENT_PROMPT.replace('__TOOLS__',TOOLS_TEXT).replace('__ENV__',env_text) msgs = [{"role":"system","content":system}] for h in (await sor.sqlExe("SELECT role,content FROM pipeline_conversations WHERE created_by=${u}$ ORDER BY created_at ASC LIMIT 20",{"u":uid}) or []):