# cockpit_chat.dspy - Product-grade LLM conversation with context # POST: action=send_message, iteration_id, message_text, model_id, file_paths # GET: action=list_messages, iteration_id, task_id import aiohttp action = (params_kw or {}).get('action', 'list_messages') dbname = get_module_dbname('pipeline-sdlc') DEFAULT_SYSTEM_PROMPT = """你是一个专业的软件开发 Agent,名为「开发产线驾驶舱」。你的职责是帮助用户完成软件开发生命周期的各个环节:需求分析、设计、编码、测试、部署。 对话规则: 1. 简洁专业,用中文回复 2. 当用户描述需求时,先理解并复述确认,然后给出分析和建议 3. 如果用户提到了项目/迭代,主动关联上下文 4. 可以建议启动开发产线来推进工作 5. 对于代码相关问题,给出具体的代码示例 6. 记住对话历史,保持上下文连贯 当前你可以帮助用户完成: - 创建和管理项目、迭代 - 数据表设计、CRUD 设计、API 设计 - 代码生成、规范检查、自动修复 - 测试用例生成、功能测试、Bug 管理 - 环境部署和验证""" async def _load_agent_settings(sor, uid): """Load user's agent settings, return defaults if not set.""" recs = await sor.sqlExe( "SELECT default_llm_id, system_prompt, temperature, max_context_messages FROM pipeline_agent_settings WHERE user_id=${uid}$", {"uid": uid} ) if recs: r = recs[0] return { 'llm_id': getattr(r, 'default_llm_id', None), 'system_prompt': getattr(r, 'system_prompt', None) or DEFAULT_SYSTEM_PROMPT, 'temperature': float(getattr(r, 'temperature', 0.7) or 0.7), 'max_context': int(getattr(r, 'max_context_messages', 30) or 30), } return { 'llm_id': None, 'system_prompt': DEFAULT_SYSTEM_PROMPT, 'temperature': 0.7, 'max_context': 30, } async def _load_context(sor, uid): """Load session context from pipeline_agent_settings.""" recs = await sor.sqlExe( "SELECT current_project_id, current_iteration_id FROM pipeline_agent_settings WHERE user_id=${uid}$", {"uid": uid} ) ctx = {'project_id': '', 'iteration_id': '', 'project_name': '', 'iteration_name': '', 'workspace_dir': '', 'workspace_root': '', 'skills_dir': '', 'repos': []} if recs: r = recs[0] ctx['project_id'] = getattr(r, 'current_project_id', '') or '' ctx['iteration_id'] = getattr(r, 'current_iteration_id', '') or '' if ctx['project_id']: projs = await sor.sqlExe( "SELECT name, workspace_dir, org_id FROM sd_projects WHERE id=${pid}$", {"pid": ctx['project_id']} ) if projs: ctx['project_name'] = getattr(projs[0], 'name', '') ctx['workspace_dir'] = getattr(projs[0], 'workspace_dir', '') or '' org_id = getattr(projs[0], 'org_id', '') or '0' # Load org settings orgs = await sor.sqlExe( "SELECT workspace_root, skills_dir FROM sd_org_settings WHERE org_id=${oid}$", {"oid": org_id} ) if orgs: ctx['workspace_root'] = getattr(orgs[0], 'workspace_root', '') or '' ctx['skills_dir'] = getattr(orgs[0], 'skills_dir', '') or '' # Load repos repos = await sor.sqlExe( "SELECT repo_name, repo_url, default_branch, local_path FROM sd_project_repos WHERE project_id=${pid}$", {"pid": ctx['project_id']} ) ctx['repos'] = [{'name': r.repo_name, 'url': r.repo_url, 'branch': r.default_branch, 'path': r.local_path or ''} for r in (repos or [])] if ctx['iteration_id']: iters = await sor.sqlExe( "SELECT iteration_name FROM sd_iterations WHERE id=${iid}$", {"iid": ctx['iteration_id']} ) if iters: ctx['iteration_name'] = getattr(iters[0], 'iteration_name', '') return ctx async def _save_context(sor, uid, project_id, iteration_id): """Save session context to pipeline_agent_settings.""" 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=${pid}$, current_iteration_id=${iid}$ WHERE user_id=${uid}$", {"pid": project_id or '', "iid": iteration_id or '', "uid": uid} ) else: await sor.C('pipeline_agent_settings', { 'id': getID(), 'user_id': uid, 'default_llm_id': '', 'current_project_id': project_id or '', 'current_iteration_id': iteration_id or '', }) async def _select_model(sor, preferred_llm_id, has_files): """Select best model: prefer user choice, then multimodal if files, else first active text.""" # If user has preferred model, use it (match by id or name) if preferred_llm_id: recs = await sor.sqlExe( "SELECT id, name, provider, model_id, api_base, api_key, capabilities FROM llm WHERE (id=${lid}$ OR name=${lid}$) AND status='active'", {"lid": preferred_llm_id} ) if recs: return recs[0] # Auto-select based on file presence if has_files: recs = await sor.sqlExe( "SELECT id, name, provider, model_id, api_base, api_key, capabilities FROM llm WHERE status='active' AND capabilities LIKE '%multimodal%' LIMIT 1", {} ) if recs: return recs[0] # Fallback: first active model recs = await sor.sqlExe( "SELECT id, name, provider, model_id, api_base, api_key, capabilities FROM llm WHERE status='active' LIMIT 1", {} ) if recs: return recs[0] return None async def _build_context(sor, iteration_id, task_id, max_msgs, system_prompt): """Build LLM messages array with Hermes-style context.""" messages = [{"role": "system", "content": system_prompt}] # Project/iteration context if iteration_id: iters = await sor.sqlExe( "SELECT i.iteration_name, i.iteration_type, i.status, i.scope, p.name as project_name, p.description, p.tech_stack " "FROM sd_iterations i LEFT JOIN sd_projects p ON i.project_id=p.id WHERE i.id=${iid}$", {"iid": iteration_id} ) if iters: it = iters[0] ctx_parts = ["## 当前上下文"] ctx_parts.append(f"项目: {getattr(it, 'project_name', '未知')}") ctx_parts.append(f"迭代: {getattr(it, 'iteration_name', '未知')}") ctx_parts.append(f"类型: {getattr(it, 'iteration_type', '')}") ctx_parts.append(f"状态: {getattr(it, 'status', '')}") desc = getattr(it, 'description', '') if desc: ctx_parts.append(f"项目描述: {desc[:500]}") stack = getattr(it, 'tech_stack', '') if stack: ctx_parts.append(f"技术栈: {stack[:300]}") scope = getattr(it, 'scope', '') if scope: ctx_parts.append(f"迭代范围: {scope[:500]}") messages.append({"role": "system", "content": "\n".join(ctx_parts)}) # Task context if task_id: tasks = await sor.sqlExe( "SELECT id, status, pipeline_id FROM pipeline_tasks WHERE id=${tid}$", {"tid": task_id} ) if tasks: t = tasks[0] messages.append({"role": "system", "content": f"关联 Pipeline 任务: {t.id}, 状态: {getattr(t, 'status', 'unknown')}"}) # Conversation history where = [] params = {} if task_id: where.append("task_id=${tid}$") params["tid"] = task_id if iteration_id: where.append("iteration_id=${iid}$") params["iid"] = iteration_id if where: sql = f"SELECT role, content FROM pipeline_conversations WHERE {' OR '.join(where)} ORDER BY created_at DESC LIMIT {max_msgs}" history = await sor.sqlExe(sql, params) # Reverse to chronological order for h in reversed(history): role = getattr(h, 'role', 'user') content = getattr(h, 'content', '') if role in ('user', 'agent'): messages.append({"role": "user" if role == "user" else "assistant", "content": content}) return messages async def _call_llm(model_info, messages, temperature): """Call LLM API directly using model config from llm table.""" api_base = model_info.api_base.rstrip('/') api_key = model_info.api_key or '' model_id = model_info.model_id debug(f'_call_llm: base={api_base} model={model_id} key_len={len(api_key)} key_prefix={api_key[:10]}') headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } payload = { "model": model_id, "messages": messages, "temperature": temperature, } timeout = aiohttp.ClientTimeout(total=120) async with aiohttp.ClientSession(timeout=timeout) as session: url = f"{api_base}/chat/completions" debug(f'_call_llm: POST {url}') async with session.post(url, headers=headers, json=payload) as resp: if resp.status != 200: text = await resp.text() debug(f'_call_llm: FAIL status={resp.status} body={text[:200]}') raise ValueError(f"LLM API error {resp.status}: {text[:300]}") data = await resp.json() content = data["choices"][0]["message"]["content"] debug(f'_call_llm: OK reply_len={len(content)}') if len(content) > 8000: content = content[:8000] + "\n\n...(内容过长已截断)" return content INTENT_PROMPT = """你是一个开发产线意图分类器。分析用户输入,返回 JSON。 意图类型: - new_project: 创建新项目 - select_project: 切换到已有项目 - new_iteration: 在当前项目下创建新迭代 - new_task: 提交开发任务(需关联项目/迭代) - add_bug: 报告Bug - start_agent: 启动Agent自动执行任务 - agent_status: 查看Agent状态和交付件 - query: 查询当前状态 - chat: 开发相关的一般对话 - out_of_scope: 完全无关软件开发 当前上下文:项目={ctx},迭代={iter} 返回纯JSON(不要markdown包裹): {"intent":"...","confidence":0.8,"project_name":"...","iteration_name":"...","title":"...","description":"...","missing_info":"..."}""" async def _classify_intent(model_info, message, ctx, history_msgs): """Classify user intent using LLM.""" ctx_str = ctx.get('project_name', '') or '无' iter_str = ctx.get('iteration_name', '') or '无' prompt = INTENT_PROMPT.replace('{ctx}', ctx_str).replace('{iter}', iter_str) msgs = [{"role": "system", "content": prompt}] for h in history_msgs[-4:]: msgs.append(h) msgs.append({"role": "user", "content": message}) raw = await _call_llm(model_info, msgs, 0.2) raw = raw.strip() if raw.startswith('```'): raw = raw.split('\n', 1)[1].rsplit('```', 1)[0] try: return json.loads(raw) except Exception: return {"intent": "chat", "confidence": 0.5, "missing_info": ""} async def _find_project(sor, name, org_id): """Find project by name.""" if not name: return None recs = await sor.sqlExe( "SELECT id, name FROM sd_projects WHERE name=${name}$ AND org_id=${oid}$", {"name": name, "oid": org_id}) return recs[0] if recs else None SCOPE_GUIDE = """我可以帮你: 📁 项目管理 — "创建电商平台项目" / "切换到XXX项目" 🔄 迭代管理 — "创建Sprint3" / "查看迭代进度" 📝 提交任务 — "设计用户表结构" / "实现登录API" 🐛 Bug管理 — "登录页报500" / "我的Bug列表" 📊 查询 — "当前项目进度" / "有哪些迭代" 请描述你的需求。""" # ==================== ACTION HANDLERS ==================== if action == 'send_message': iteration_id = (params_kw or {}).get('iteration_id', '') 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', '[]') debug(f'send_message: model_id={user_model_id} iteration_id={iteration_id} msg_len={len(message_text)}') if not message_text: return json.dumps({"error": "message_text is required"}, ensure_ascii=False) uid = await get_user() org_id = await get_userorgid() or '0' msg_id = '' # always defined, even on error agent_reply = '' async with DBPools().sqlorContext(dbname) as sor: # 1. Load context and model ctx = await _load_context(sor, uid) settings = await _load_agent_settings(sor, uid) selected_llm_id = user_model_id or settings['llm_id'] model_info = await _select_model(sor, selected_llm_id, False) if not model_info: return json.dumps({"error": "No active LLM model configured"}, ensure_ascii=False) # 2. Classify intent history = await sor.sqlExe( "SELECT role, content FROM pipeline_conversations WHERE iteration_id=${iid}$ OR iteration_id='' ORDER BY created_at DESC LIMIT 4", {"iid": iteration_id or ctx.get('iteration_id', '')} ) history_msgs = [] for h in reversed(history): role = 'user' if getattr(h, 'role', '') == 'user' else 'assistant' history_msgs.append({"role": role, "content": getattr(h, 'content', '')}) intent = await _classify_intent(model_info, message_text, ctx, history_msgs) debug(f'intent: {intent}') # 3. Route by intent intent_type = intent.get('intent', 'chat') confidence = intent.get('confidence', 0.5) if intent_type == 'out_of_scope' or (intent_type == 'chat' and confidence < 0.6 and not ctx['project_id']): agent_reply = SCOPE_GUIDE elif intent.get('missing_info') and confidence < 0.7: agent_reply = f"让我确认一下:{intent.get('missing_info', '请提供更多信息')}" elif intent_type == 'new_project': pname = intent.get('project_name', '') or message_text[:50] proj = await _find_project(sor, pname, org_id) if proj: agent_reply = f"项目「{pname}」已存在。已切换到该项目。" await _save_context(sor, uid, proj.id, '') else: pid = getID() ws_dir = f"/d/pipeline/workspaces/{org_id}/{pname}" await sor.C('sd_projects', { 'id': pid, 'name': pname, 'description': intent.get('description', ''), 'project_type': 'software', 'org_id': org_id, 'created_by': uid, 'status': 'active', 'workspace_dir': ws_dir }) # Auto-create default iteration 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_context(sor, uid, pid, iid) agent_reply = f"✅ 项目「{pname}」已创建,默认迭代已就绪。现在可以提交任务了。" elif intent_type == 'select_project': pname = intent.get('project_name', '') proj = await _find_project(sor, pname, org_id) if proj: await _save_context(sor, uid, proj.id, ctx['iteration_id']) agent_reply = f"已切换到项目「{pname}」。" else: agent_reply = f"未找到项目「{pname}」。请先创建项目后再切换。" elif intent_type == 'new_task': pid = ctx['project_id'] iid = intent.get('iteration_name', '') or ctx['iteration_id'] if not pid: agent_reply = "请先指定项目。「创建XXX项目」或「切换到XXX项目」" elif not iid: agent_reply = "请指定迭代。「创建Sprint1」或「切换到XXX迭代」" else: title = intent.get('title', '') or message_text[:100] task_params = {'description': intent.get('description', ''), 'input_text': message_text} try: result = await pipeline_submit(org_id, 'sdlc_general', uid, title, task_params) rd = json.loads(result) if rd.get('success'): task_id = rd.get('task_id', '') await _save_context(sor, uid, pid, iid) agent_reply = f"✅ 任务「{title}」已提交({task_id}),产线开始执行。" else: agent_reply = f"任务提交失败:{rd.get('message', '未知错误')}" except Exception as e: agent_reply = f"任务提交失败:{str(e)[:200]}" elif intent_type == 'add_bug': pid = ctx['project_id'] iid = ctx['iteration_id'] if not pid: agent_reply = "请先指定项目后再报告Bug。" else: bid = getID() await sor.C('sd_bugs', { 'id': bid, 'iteration_id': iid or '', 'title': intent.get('title', '') or message_text[:100], 'description': intent.get('description', ''), 'severity': 'major', 'priority': 'P1', 'status': 'open', 'reporter_type': 'human', 'reporter_id': uid, 'created_at': curDateString() }) agent_reply = f"🐛 Bug已记录({bid}):{message_text[:100]}" elif intent_type == 'query': ctx_info = [] if ctx['project_name']: ctx_info.append(f"当前项目:{ctx['project_name']}") if ctx['iteration_name']: ctx_info.append(f"当前迭代:{ctx['iteration_name']}") if ctx_info: agent_reply = '\n'.join(ctx_info) + '\n\n请描述具体想查询什么(如:任务列表、Bug列表等)' else: agent_reply = "当前未选择项目。请先「创建XXX项目」或「切换到XXX项目」。" elif intent_type == 'start_agent': pid = ctx['project_id'] if not pid: agent_reply = "请先指定项目。「创建XXX项目」或「切换到XXX项目」" else: tasks = await sor.sqlExe( "SELECT id, title, state FROM pipeline_tasks WHERE tenant_id=${oid}$ AND state='submitted' LIMIT 5", {"oid": org_id}) if not tasks: agent_reply = f"项目「{ctx['project_name']}」暂无待执行任务。\n\n请先提交开发任务,例如:设计用户表结构" else: results = [] ws = ctx.get('workspace_dir', '') ws_root = ctx.get('workspace_root', '') repos = ctx.get('repos', []) # Build repo info for prompt repo_lines = [f"工作空间根路径:{ws_root}" if ws_root else "工作空间根路径:未配置"] repo_lines.append(f"项目本地路径:{ws}" if ws else "项目本地路径:未配置") skills_dir = ctx.get('skills_dir', '') if skills_dir: repo_lines.append(f"企业Skills目录:{skills_dir}") if repos: repo_lines.append("关联代码仓库:") for rp in repos: repo_lines.append(f" - {rp['name']}: {rp['url']} (分支:{rp['branch']}, 本地:{rp['path']})") results.append('\\n'.join(repo_lines)) for t in tasks: try: task_msgs = [{"role": "system", "content": settings['system_prompt']}] prompt_parts = [f"请完成:{t.title}"] prompt_parts.append('\\n'.join(repo_lines)) prompt_parts.append("请在关联仓库中直接修改代码文件,完成后提供变更摘要。") task_msgs.append({"role": "user", "content": '\\n'.join(prompt_parts)}) result = await _call_llm(model_info, task_msgs, settings['temperature']) did = getID() role_dir = f\"{ws}/deliverables/agent\" if ws else \"deliverables/agent\" # Detect primary repo for deliverable primary_repo = repos[0]['name'] if repos else '' await sor.C('pipeline_deliverables', { 'id': did, 'project_id': pid, 'task_id': t.id, 'deliverable_type': 'code', 'title': t.title, 'content': result, 'repo_name': primary_repo, 'target_path': '', 'file_path': f\"{role_dir}/{t.id}.md\", 'quality_score': 80, 'review_status': 'pending', 'created_by': 'agent' }) await sor.sqlExe("UPDATE pipeline_tasks SET state='completed' WHERE id=${tid}$", {"tid": t.id}) preview = result[:300].replace('\n', ' ') results.append(f"✅ {t.title}\n 交付件 {did}\n {preview}...") except Exception as e2: results.append(f"❌ {t.title}:{str(e2)[:80]}") agent_reply = '\n'.join(results) if results else "无需执行的任务" elif intent_type == 'agent_status': pid = ctx['project_id'] if not pid: agent_reply = "当前未选择项目。" else: async with DBPools().sqlorContext(dbname) as sor2: # Agent status arecs = await sor2.sqlExe( "SELECT role_name, status, model_name FROM pipeline_project_agents WHERE project_id=${pid}$", {"pid": pid}) # Recent deliverables drecs = await sor2.sqlExe( "SELECT title, deliverable_type, quality_score, review_status, created_at " "FROM pipeline_deliverables WHERE project_id=${pid}$ ORDER BY created_at DESC LIMIT 3", {"pid": pid}) lines = [f"📁 项目:{ctx['project_name']}"] for a in arecs: lines.append(f"🤖 {a.role_name}:{a.status}") if drecs: lines.append("📦 最近交付:") for d in drecs: lines.append(f" · {d.title} [{d.review_status}] {d.quality_score}分") agent_reply = '\n'.join(lines) if len(lines) > 1 else "暂无Agent活动" else: # chat: general conversation messages = await _build_context(sor, iteration_id or ctx['iteration_id'], '', settings['max_context'], settings['system_prompt']) messages.append({"role": "user", "content": message_text}) try: agent_reply = await _call_llm(model_info, messages, settings['temperature']) except Exception as e: agent_reply = f"抱歉,模型调用失败: {str(e)[:200]}" # 4. Save conversation msg_id = getID() await sor.C('pipeline_conversations', { 'id': msg_id, 'iteration_id': iteration_id or ctx.get('iteration_id', ''), 'task_id': '', 'step_name': '', 'role': 'user', 'content': message_text, 'attachments': file_paths_raw, 'msg_type': 'text', 'org_id': org_id, 'created_by': uid }) agent_msg_id = getID() await sor.C('pipeline_conversations', { 'id': agent_msg_id, 'iteration_id': iteration_id or ctx.get('iteration_id', ''), 'task_id': '', 'step_name': '', 'role': 'agent', 'content': agent_reply, 'attachments': '[]', 'msg_type': 'text', 'org_id': org_id, 'created_by': 'system' }) return json.dumps({ "success": True, "message_id": msg_id, "agent_reply": agent_reply, "model_used": model_info.name, "intent": intent_type, "context": {"project_name": ctx.get('project_name', ''), "iteration_name": ctx.get('iteration_name', '')} }, ensure_ascii=False) else: # list_messages - return conversation as Bricks widget JSON iteration_id = (params_kw or {}).get('iteration_id', '') task_id = (params_kw or {}).get('task_id', '') msgs = [] if iteration_id or task_id: async with DBPools().sqlorContext(dbname) as sor: where = [] params = {} if task_id: where.append("task_id=${tid}$") params["tid"] = task_id if iteration_id: where.append("iteration_id=${iid}$") params["iid"] = iteration_id sql = f"SELECT role, content, msg_type, created_at FROM pipeline_conversations WHERE {' OR '.join(where)} ORDER BY created_at ASC LIMIT 50" msgs = await sor.sqlExe(sql, params) msg_widgets = [] for m in msgs: role = m.role if hasattr(m, 'role') else '' content = m.content if hasattr(m, 'content') else '' if role == 'agent': bg = '#e8f0fe' align = 'flex-start' label = 'Agent' label_color = '#3b82f6' elif role == 'user': bg = '#dbeafe' align = 'flex-end' label = '\u4f60' label_color = '#2563eb' else: bg = '#f1f5f9' align = 'center' label = '\u7cfb\u7edf' label_color = '#94a3b8' msg_widgets.append({ "widgettype": "VBox", "options": { "width": "85%", "alignSelf": align, "bgcolor": bg, "borderRadius": "12px", "padding": "12px 16px", "marginBottom": "10px", "gap": "4px" }, "subwidgets": [ {"widgettype": "Text", "options": { "text": label, "cfontsize": 0.75, "color": label_color, "fontWeight": "bold" }}, {"widgettype": "Text", "options": { "text": content, "cfontsize": 0.95, "color": "#1e293b", "whiteSpace": "pre-wrap" }} ] }) if not msg_widgets: msg_widgets.append({ "widgettype": "Text", "options": { "text": "\u6682\u65e0\u5bf9\u8bdd\u8bb0\u5f55\u3002\u9009\u62e9\u4e00\u4e2a\u8fed\u4ee3\u540e\uff0c\u5728\u4e0b\u65b9\u8f93\u5165\u6846\u4e2d\u5f00\u59cb\u5bf9\u8bdd\u3002", "cfontsize": 0.9, "color": "#94a3b8", "padding": "20px" } }) return { "widgettype": "VBox", "options": {"width": "100%", "padding": "4px"}, "subwidgets": msg_widgets }