pipeline-sdlc/wwwroot/api/cockpit_chat.dspy

335 lines
12 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 - 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 or ''
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
}