pipeline-sdlc/wwwroot/api/cockpit_chat.dspy

806 lines
37 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
import json
import os
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 管理
- 环境部署和验证"""
def _guess_role(title):
"""Guess agent role from task title keywords."""
title_lower = (title or '').lower()
keywords = {
'design': ['设计', 'design', '架构', '方案', '需求', '原型', 'ui', 'ux'],
'develop': ['开发', '编码', '实现', '编写', 'develop', 'code', 'build', '重构', '修复'],
'test': ['测试', 'test', '验证', '检查', 'review', '评审'],
'deploy': ['部署', '发布', 'deploy', 'release', '上线', '配置'],
'ops': ['运维', '监控', 'ops', '日志', '备份', '迁移'],
}
for role, kws in keywords.items():
for kw in kws:
if kw in title_lower:
return role
return 'develop' # default
def _load_skills(skills_dir, role=None):
"""Scan skills_dir for SKILL.md files. If role given, load common/ + {role}/ only."""
skills = []
if not skills_dir:
return skills
dirs_to_scan = ['common']
if role:
dirs_to_scan.append(role)
else:
# No role specified — scan all subdirs
try:
dirs_to_scan = [d for d in os.listdir(skills_dir)
if os.path.isdir(os.path.join(skills_dir, d))]
except Exception:
return skills
for subdir in dirs_to_scan:
subdir_path = os.path.join(skills_dir, subdir)
if not os.path.isdir(subdir_path):
continue
try:
for name in os.listdir(subdir_path):
skill_path = os.path.join(subdir_path, name)
skill_md = os.path.join(skill_path, 'SKILL.md')
if os.path.isdir(skill_path) and os.path.isfile(skill_md):
try:
with open(skill_md, 'r') as f:
content = f.read()
if len(content) > 8000:
content = content[:8000] + '\n\n... (truncated)'
skills.append({'name': name, 'role': subdir, 'content': content})
except Exception:
pass
except Exception:
pass
return skills
def _build_skills_prompt(skills):
"""Build skill context string for injection into system prompt."""
if not skills:
return ''
lines = ['\n\n## 可用的开发技能(Skills)\n']
lines.append('以下是企业定义的开发规范和最佳实践,请在开发过程中严格遵循:\n')
for s in skills:
lines.append(f'### {s["name"]}')
lines.append(s['content'])
lines.append('')
return '\n'.join(lines)
def _import_skill(skills_dir, source_path, role=None):
"""Import a SKILL.md file into skills_dir/{role}/{name}/SKILL.md.
source_path can be a file path or a directory containing SKILL.md.
Returns (success, message, skill_name)."""
if not skills_dir:
return False, 'skills_dir not configured', ''
src = os.path.abspath(source_path)
if not os.path.exists(src):
return False, f'路径不存在: {source_path}', ''
# Determine skill name and content
if os.path.isfile(src) and src.endswith('.md'):
skill_name = os.path.splitext(os.path.basename(src))[0]
with open(src, 'r') as f:
content = f.read()
elif os.path.isdir(src):
md = os.path.join(src, 'SKILL.md')
if not os.path.isfile(md):
return False, f'目录中未找到 SKILL.md: {src}', ''
skill_name = os.path.basename(src)
with open(md, 'r') as f:
content = f.read()
else:
return False, '源文件必须是 .md 文件或包含 SKILL.md 的目录', ''
if not role:
role = _guess_role_skill_name(skill_name)
dest_dir = os.path.join(skills_dir, role, skill_name)
os.makedirs(dest_dir, exist_ok=True)
dest = os.path.join(dest_dir, 'SKILL.md')
with open(dest, 'w') as f:
f.write(content)
return True, f'已导入 {role}/{skill_name}', skill_name
def _guess_role_skill_name(name):
"""Guess role from skill directory name."""
return _guess_role(name)
def _list_imported_skills(skills_dir):
"""List all skills currently in skills_dir."""
result = {}
if not skills_dir or not os.path.isdir(skills_dir):
return result
for role_dir in os.listdir(skills_dir):
rp = os.path.join(skills_dir, role_dir)
if not os.path.isdir(rp):
continue
skills = []
for sn in os.listdir(rp):
sp = os.path.join(rp, sn)
if os.path.isdir(sp) and os.path.isfile(os.path.join(sp, 'SKILL.md')):
skills.append(sn)
if skills:
result[role_dir] = skills
return result
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': '', 'skills': [], '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 enterprise skills
if ctx['skills_dir']:
ctx['skills'] = _load_skills(ctx['skills_dir'])
# 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: 查询当前状态
- skill_list: 列出已导入的企业开发技能(如"查看技能""有哪些skills"
- skill_import: 导入技能文件(如"导入技能 /path/to/skill"
- chat: 开发相关的一般对话
- out_of_scope: 完全无关软件开发
当前上下文:项目={ctx},迭代={iter}
返回纯JSON不要markdown包裹
{"intent":"...","confidence":0.8,"project_name":"...","iteration_name":"...","title":"...","description":"...","source_path":"...","role":"...","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列表"
📊 查询 — "当前项目进度" / "有哪些迭代"
📚 技能管理 — "导入技能 /path/to/skill" / "查看技能列表"
请描述你的需求。"""
# ==================== 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]
role = _guess_role(title)
# Load role-specific skills
task_skills = _load_skills(ctx.get('skills_dir', ''), role)
skills_text = _build_skills_prompt(task_skills)
task_params = {
'description': intent.get('description', ''),
'input_text': message_text,
'role': role,
'skills': skills_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)
# Build repo + workspace context for execution
ws = ctx.get('workspace_dir', '')
ws_root = ctx.get('workspace_root', '')
repos = ctx.get('repos', [])
repo_lines = [f"工作空间根路径:{ws_root}" if ws_root else "工作空间根路径:未配置"]
repo_lines.append(f"项目本地路径:{ws}" if ws else "项目本地路径:未配置")
if repos:
repo_lines.append("关联代码仓库:")
for rp in repos:
repo_lines.append(f" - {rp['name']}: {rp['url']} (分支:{rp['branch']}, 本地:{rp['path']})")
# Execute immediately with skills
sp = settings['system_prompt']
if skills_text:
sp = sp + '\n\n---\n当前角色' + role + skills_text
task_msgs = [{"role": "system", "content": sp}]
task_msgs.append({"role": "user", "content": f"请完成:{title}\n\n" + '\n'.join(repo_lines)})
result_text = await _call_llm(model_info, task_msgs, settings['temperature'])
did = getID()
role_dir = f"{ws}/deliverables/{role}" if ws else f"deliverables/{role}"
primary_repo = repos[0]['name'] if repos else ''
await sor.C('pipeline_deliverables', {
'id': did, 'project_id': pid, 'task_id': task_id,
'deliverable_type': role, 'title': title, 'content': result_text,
'repo_name': primary_repo, 'target_path': '',
'file_path': f"{role_dir}/{task_id}.md",
'quality_score': 80, 'review_status': 'pending', 'created_by': 'agent'
})
await sor.sqlExe("UPDATE pipeline_tasks SET state='completed' WHERE id=${tid}$", {"tid": task_id})
preview = result_text[:200].replace('\n', ' ')
agent_reply = f"✅ 任务「{title}」已完成(角色:{role}\n 交付件:{did}\n {preview}..."
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:
# Guess role and load role-specific skills
role = _guess_role(getattr(t, 'title', ''))
task_skills = _load_skills(ctx.get('skills_dir', ''), role)
sp = settings['system_prompt']
skills_prompt = _build_skills_prompt(task_skills)
if skills_prompt:
sp = sp + skills_prompt
task_msgs = [{"role": "system", "content": sp}]
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活动"
elif intent_type == 'skill_list':
skills_dir = ctx.get('skills_dir', '')
if not skills_dir:
agent_reply = "企业Skills目录未配置。请先在「组织SDLC设置」中设置 skills_dir。"
else:
listing = _list_imported_skills(skills_dir)
if not listing:
agent_reply = "暂无已导入的企业技能。\n\n技能目录结构应为\n {skills_dir}/\n common/技能名/SKILL.md\n design/技能名/SKILL.md\n develop/技能名/SKILL.md\n test/技能名/SKILL.md\n deploy/技能名/SKILL.md\n\n导入方式说「导入技能 /path/to/react-patterns」"
else:
lines = ["📚 已导入的企业技能:"]
for role, names in sorted(listing.items()):
lines.append(f"\n [{role}]")
for n in sorted(names):
lines.append(f" - {n}")
agent_reply = '\n'.join(lines)
elif intent_type == 'skill_import':
skills_dir = ctx.get('skills_dir', '')
source = intent.get('source_path', '') or message_text.split('导入技能')[-1].strip()
if not skills_dir:
agent_reply = "企业Skills目录未配置。请先在「组织SDLC设置」中设置 skills_dir。"
elif not source:
agent_reply = "请提供要导入的技能路径。例如:导入技能 /home/user/my-skill"
else:
role = intent.get('role', None)
ok, msg, name = _import_skill(skills_dir, source, role)
if ok:
agent_reply = f"✅ {msg}"
else:
agent_reply = f"❌ 导入失败:{msg}"
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
}