339 lines
12 KiB
Plaintext
339 lines
12 KiB
Plaintext
# 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
|
||
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
|
||
|
||
|
||
# ==================== 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
|
||
}
|