# 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 sd_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 _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 if preferred_llm_id: recs = await sor.sqlExe( "SELECT id, name, provider, model_id, api_base, api_key, capabilities FROM llm WHERE id=${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 sd_conversations WHERE {' OR '.join(where)} ORDER BY created_at DESC LIMIT ${max_msgs}$" params["max_msgs"] = 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 model_id = model_info.model_id 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: async with session.post(f"{api_base}/chat/completions", headers=headers, json=payload) as resp: if resp.status != 200: text = await resp.text() raise ValueError(f"LLM API error {resp.status}: {text[:300]}") data = await resp.json() content = data["choices"][0]["message"]["content"] # Truncate overly long responses if len(content) > 8000: content = content[:8000] + "\n\n...(内容过长已截断)" return content # ==================== ACTION HANDLERS ==================== if action == 'send_message': iteration_id = (params_kw or {}).get('iteration_id', '') task_id = (params_kw or {}).get('task_id', '') message_text = (params_kw or {}).get('message_text', '').strip() model_id = (params_kw or {}).get('model_id', '') file_paths_raw = (params_kw or {}).get('file_paths', '[]') if not message_text: return json.dumps({"error": "message_text is required"}, ensure_ascii=False) # Parse file paths has_files = False try: fps = json.loads(file_paths_raw) has_files = len(fps) > 0 except Exception: pass uid = await get_user() async with DBPools().sqlorContext(dbname) as sor: # 1. Save user message msg_id = getID() await sor.C('sd_conversations', { 'id': msg_id, 'iteration_id': iteration_id or '', 'task_id': task_id or '', 'step_name': '', 'role': 'user', 'content': message_text, 'attachments': file_paths_raw, 'msg_type': 'text', 'org_id': '0', 'created_by': uid }) # 2. Load agent settings settings = await _load_agent_settings(sor, uid) # 3. Select model (preferred → auto by file type → first active) selected_llm_id = model_id or settings['llm_id'] model_info = await _select_model(sor, selected_llm_id, has_files) if not model_info: return json.dumps({"error": "No active LLM model configured"}, ensure_ascii=False) # 4. Build context messages = await _build_context( sor, iteration_id, task_id, settings['max_context'], settings['system_prompt'] ) # Append current user message messages.append({"role": "user", "content": message_text}) # 5. Call LLM try: agent_reply = await _call_llm(model_info, messages, settings['temperature']) except Exception as e: agent_reply = f"抱歉,模型调用失败: {str(e)[:200]}" # 6. Save agent response agent_msg_id = getID() await sor.C('sd_conversations', { 'id': agent_msg_id, 'iteration_id': iteration_id or '', 'task_id': task_id or '', 'step_name': '', 'role': 'agent', 'content': agent_reply, 'attachments': '[]', 'msg_type': 'text', 'org_id': '0', 'created_by': 'system' }) return json.dumps({ "success": True, "message_id": msg_id, "agent_reply": agent_reply, "model_used": model_info.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 sd_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 }