pipeline-sdlc/wwwroot/api/cockpit_chat.dspy

394 lines
24 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 - SDLC Agent Loop v3.1 (streaming NDJSON)
import aiohttp
action = (params_kw or {}).get('action', 'send_message')
# AgentIO sends JSON body without action field — detect from message_text presence
if action != 'send_message':
p = params_kw or {}
msg = (p.get('message_text') or '').strip()
if not msg:
inner = p.get('params', {})
if isinstance(inner, dict):
msg = (inner.get('prompt') or inner.get('message_text') or '').strip()
if msg:
action = 'send_message'
dbname = get_module_dbname('pipeline-sdlc')
AGENT_PROMPT = """你是开发产线驾驶舱 Agent。用工具获取数据用 reply 回复。
工作流:
- 切换项目:先 list_projects 看有哪些项目,再 switch_project
- 创建项目:不存在则 create_project
- 查看进度list_tasks / task_detail / list_deliverables
- 提交任务:先确保在正确项目,再 create_task
- 启动Agent任务提交后调 start_agents 让角色Agent执行
- 回答问题list_questions 看问题 → answer_question 回答
- clone_repo 用于克隆 git 仓库到工作空间
- run_command 用于在工作空间中执行 shell 命令
输出格式二选一:
{"action":"tool_call","tool":"工具名","params":{}}
{"action":"reply","message":"中文回复"}
工具: __TOOLS__
环境: __ENV__"""
TOOLS = [
{"name":"list_projects","description":"列出所有可用项目","params":{}},
{"name":"switch_project","description":"切换到指定项目需先list_projects确认项目名","params":{"project":"项目名称"}},
{"name":"create_project","description":"创建新项目","params":{"name":"项目名称","description":"项目描述(可选)"}},
{"name":"list_tasks","description":"列出当前项目任务","params":{"state":"状态(可选)"}},
{"name":"task_detail","description":"查看任务详情(含交付件和问答)","params":{"task_id":"任务ID"}},
{"name":"list_deliverables","description":"列出当前项目交付件","params":{"task_id":"任务ID(可选)"}},
{"name":"view_deliverable","description":"查看交付件内容","params":{"deliverable_id":"交付件ID"}},
{"name":"list_questions","description":"列出待回答的问题","params":{}},
{"name":"answer_question","description":"回答agent提出的问题","params":{"question_id":"问题ID","answer":"回答内容"}},
{"name":"create_task","description":"提交开发任务到指定角色","params":{"title":"标题","description":"描述","role":"requirement/design/develop/test/deploy"}},
{"name":"start_agents","description":"启动项目角色agents执行待办任务","params":{}},
{"name":"check_progress","description":"查看项目整体进度","params":{"task_id":"任务ID(可选)"}},
{"name":"add_repo","description":"关联仓库到当前项目","params":{"url":"git地址","name":"名称(可选)"}},
{"name":"list_repos","description":"列出当前项目仓库","params":{}},
{"name":"clone_repo","description":"克隆仓库到工作空间","params":{"url":"git地址","branch":"分支名(可选,默认main)"}},
{"name":"add_bug","description":"报告Bug","params":{"title":"标题","description":"描述"}},
{"name":"run_command","description":"在工作空间中执行shell命令","params":{"cmd":"命令"}},
]
TOOLS_TEXT = json.dumps(TOOLS, ensure_ascii=False)
def _security_scan(text):
t = (text or '').lower()
for kw in ('drop table','truncate','rm -rf','密钥','api_key'):
if kw in t: return True, '危险操作'
return False, ''
def _guess_role(title):
t = (title or '').lower()
for kw,r in [('需求','requirement'),('设计','design'),('开发','develop'),('实现','develop'),('测试','test'),('部署','deploy')]:
if kw in t: return r
return 'develop'
async def _find_project(sor, name):
"""Exact match only — LLM should use list_projects first to discover names."""
if not name: return None
recs = await sor.sqlExe("SELECT id,name FROM sd_projects WHERE name=${n}$",{"n":name})
return recs[0] if recs else None
async def _load_ctx(sor, uid):
recs = await sor.sqlExe("SELECT current_project_id FROM pipeline_agent_settings WHERE user_id=${u}$",{"u":uid})
ctx = {'pid':'','pname':'','ws':'','skills_dir':'','repos':[]}
if not recs: return ctx
ctx['pid'] = getattr(recs[0],'current_project_id','') or ''
if ctx['pid']:
projs = await sor.sqlExe("SELECT name,workspace_dir,org_id FROM sd_projects WHERE id=${p}$",{"p":ctx['pid']})
if projs:
ctx['pname'] = getattr(projs[0],'name','')
ctx['ws'] = getattr(projs[0],'workspace_dir','') or ''
oid = getattr(projs[0],'org_id','') or '0'
orgs = await sor.sqlExe("SELECT skills_dir FROM sd_org_settings WHERE org_id=${o}$",{"o":oid})
if orgs: ctx['skills_dir'] = getattr(orgs[0],'skills_dir','') or ''
repos = await sor.sqlExe("SELECT repo_name,repo_url FROM sd_project_repos WHERE project_id=${p}$",{"p":ctx['pid']})
ctx['repos'] = [{'n':r.repo_name,'u':r.repo_url} for r in (repos or [])]
return ctx
async def _save_ctx(sor, uid, pid):
ex = await sor.sqlExe("SELECT id FROM pipeline_agent_settings WHERE user_id=${u}$",{"u":uid})
if ex:
await sor.sqlExe("UPDATE pipeline_agent_settings SET current_project_id=${p}$ WHERE user_id=${u}$",{"p":pid or '','u':uid})
else:
await sor.C('pipeline_agent_settings',{'id':getID(),'user_id':uid,'default_llm_id':'','current_project_id':pid or '','current_iteration_id':''})
async def _sel_model(sor, pref):
if pref:
recs = await sor.sqlExe("SELECT id,name,provider,model_id,api_base,api_key FROM llm WHERE (id=${l}$ OR name=${l}$) AND status='active'",{"l":pref})
if recs: return recs[0]
recs = await sor.sqlExe("SELECT id,name,provider,model_id,api_base,api_key FROM llm WHERE status='active' LIMIT 1",{})
return recs[0] if recs else None
async def _call_llm(model, msgs, temp):
hdrs = {"Authorization": f"Bearer {model.api_key or ''}", "Content-Type": "application/json"}
payload = {"model": model.model_id, "messages": msgs, "temperature": temp}
to = aiohttp.ClientTimeout(total=180)
async with aiohttp.ClientSession(timeout=to) as s:
async with s.post(f"{model.api_base.rstrip('/')}/chat/completions", headers=hdrs, json=payload) as r:
if r.status != 200: raise ValueError(f"LLM {r.status}")
return (await r.json())["choices"][0]["message"]["content"]
def _parse(raw):
raw = (raw or "").strip()
if raw.startswith("```"): raw = raw.split("\n",1)[1].rsplit("```",1)[0].strip()
try:
d = json.loads(raw)
if isinstance(d,dict) and 'action' in d: return d
except: pass
return {"action":"reply","message":raw}
def _w_text(t): return {"widgettype":"Text","options":{"text":t,"css":"agent-text","halign":"left"}}
def _w_card(title, body, kind="success"):
colors = {"success":"#10b981","error":"#ef4444","reply":"#6366f1","progress":"#f59e0b"}
c = colors.get(kind,"#6b7280")
return {"widgettype":"VBox","options":{"style":{"borderLeft":f"3px solid {c}","padding":"8px 12px","margin":"4px 0"}},"subwidgets":[
{"widgettype":"Text","options":{"text":title,"style":{"fontWeight":"bold","fontSize":"13px","color":c}}},
body
]}
def _w_progress(text): return {"widgettype":"Text","options":{"text":text,"style":{"color":"#f59e0b","fontSize":"12px","padding":"2px 8px"},"halign":"left"}}
_tool_labels = {'list_projects':'列出项目','switch_project':'切换项目','create_project':'创建项目','list_tasks':'查询任务','task_detail':'任务详情','list_deliverables':'查看交付件','view_deliverable':'交付件内容','list_questions':'查看问题','answer_question':'回答问题','create_task':'创建任务','start_agents':'启动角色Agent','check_progress':'检查进度','add_repo':'关联仓库','list_repos':'查看仓库','clone_repo':'克隆仓库','add_bug':'报告Bug','run_command':'执行命令'}
async def _exec_tool(sor, tool, params, ctx, uid, org_id):
p = params or {}
try:
if tool == 'list_projects':
allp = await sor.sqlExe("SELECT id,name,description,workspace_dir FROM sd_projects ORDER BY created_at DESC LIMIT 30",{})
if not allp: return '无项目'
lines = []
for r in allp:
marker = '★' if getattr(r,'id','') == ctx['pid'] else ' '
lines.append(f"{marker} {getattr(r,'name','')} | {getattr(r,'description','') or ''} | ws:{getattr(r,'workspace_dir','') or ''}")
return '\n'.join(lines)
elif tool == 'create_project':
name = (p.get('name','') or '').strip()
if not name: return 'FAIL: 需要项目名称'
ex = await sor.sqlExe("SELECT id FROM sd_projects WHERE name=${n}$",{"n":name})
if ex: return f'FAIL: 项目「{name}」已存在'
pid = getID()
wid = getID()
ws = f'/d/pipeline/workspaces/{org_id}/{name}'
await sor.C('sd_projects',{'id':pid,'name':name,'description':p.get('description',''),'workspace_dir':ws,'org_id':org_id,'created_by':uid})
await sor.C('sd_iterations',{'id':wid,'project_id':pid,'name':'默认迭代','org_id':org_id})
await _save_ctx(sor, uid, pid)
ctx['pid'] = pid; ctx['pname'] = name; ctx['ws'] = ws
return f'OK: 已创建并切换到「{name}」'
elif tool == 'switch_project':
proj = await _find_project(sor, p.get('project',''))
if proj:
await _save_ctx(sor, uid, proj.id)
ctx['pid'] = proj.id; ctx['pname'] = proj.name
return 'OK: 已切换到「'+proj.name+'」'
allp = await sor.sqlExe("SELECT name FROM sd_projects ORDER BY created_at DESC LIMIT 15",{})
return 'FAIL: 未找到。可用: '+', '.join([getattr(r,'name','') for r in (allp or [])])
elif tool == 'create_task':
if not ctx['pid']: return 'FAIL: 请先切换到项目'
title = p.get('title','')[:100]
if not title: return 'FAIL: 缺少标题'
role = p.get('role','') or _guess_role(title)
tp = {'description':p.get('description',''),'project_id':ctx['pid']}
r = await pipeline_role_submit(ctx['pid'],'role_task',uid,title,tp,role)
rd = json.loads(r)
if rd.get('success'): return f"OK: 任务「{title}」已创建(角色:{role})"
return f"FAIL: {rd.get('message','')}"
elif tool == 'check_progress':
if not ctx['pid']: return 'FAIL: 请先切换到项目'
tid = p.get('task_id','')
if tid:
ts = await sor.sqlExe("SELECT title,state,role FROM pipeline_tasks WHERE id=${t}$",{"t":tid})
if not ts: return 'FAIL: 任务不存在'
t = ts[0]; return f"[{t.state}][{t.role}] {t.title}"
ts = await sor.sqlExe("SELECT state,count(*)c FROM pipeline_tasks WHERE tenant_id=${p}$ GROUP BY state",{"p":ctx['pid']})
summary = ', '.join([f"{r.state}:{r.c}" for r in (ts or [])]) or '无'
ags = await sor.sqlExe("SELECT role_name,status FROM pipeline_project_agents WHERE project_id=${p}$",{"p":ctx['pid']})
al = ', '.join([f"{r.role_name}({r.status})" for r in (ags or [])]) or '未配置'
ds = await sor.sqlExe("SELECT deliverable_type,title,review_status FROM pipeline_deliverables WHERE project_id=${p}$ ORDER BY created_at DESC LIMIT 3",{"p":ctx['pid']})
dl = '\n'.join([str(d.deliverable_type)+' '+str(d.title)[:40] for d in (ds or [])]) or '无'
return f"任务: {summary}\nAgent: {al}\n交付:\n{dl}"
elif tool == 'list_tasks':
if not ctx['pid']: return 'FAIL: 请先切换到项目'
sf = p.get('state','')
sql = "SELECT title,state,role FROM pipeline_tasks WHERE tenant_id=${p}$"
if sf: sql += " AND state=${s}$"
sql += " ORDER BY created_at DESC LIMIT 20"
ts = await sor.sqlExe(sql, {"p":ctx['pid'],"s":sf} if sf else {"p":ctx['pid']})
em = {'submitted':'⏳','running':'🔄','review':'👀','approved':'✅','completed':'🏁','failed':'❌'}
return '\n'.join([f"{em.get(t.state,'?')}[{t.state}][{t.role}]{t.title}" for t in ts]) if ts else '无任务'
elif tool == 'add_repo':
if not ctx['pid']: return 'FAIL: 请先切换到项目'
url = p.get('url','')
if not url: return 'FAIL: 需要仓库地址'
name = p.get('name','') or url.rstrip('/').split('/')[-1].replace('.git','')
ex = await sor.sqlExe("SELECT id FROM sd_project_repos WHERE project_id=${p}$ AND repo_url=${u}$",{"p":ctx['pid'],"u":url})
if ex: return '仓库已关联'
await sor.C('sd_project_repos',{'id':getID(),'project_id':ctx['pid'],'repo_name':name,'repo_url':url,'default_branch':'main','local_path':'','org_id':org_id})
ctx['repos'].append({'n':name,'u':url})
return f"OK: 已关联 {name}"
elif tool == 'list_repos':
if not ctx['pid']: return 'FAIL: 请先切换到项目'
rs = await sor.sqlExe("SELECT repo_name,repo_url FROM sd_project_repos WHERE project_id=${p}$",{"p":ctx['pid']})
return '\n'.join([f"{r.repo_name}: {r.repo_url}" for r in rs]) if rs else '无仓库'
elif tool == 'clone_repo':
if not ctx['pid']: return 'FAIL: 请先切换到项目'
url = p.get('url','')
if not url: return 'FAIL: 需要仓库地址'
branch = p.get('branch','main')
ws = ctx.get('ws','')
if not ws: return 'FAIL: 项目无工作空间路径'
import subprocess, os
repo_name = url.rstrip('/').split('/')[-1].replace('.git','')
target = os.path.join(ws, repo_name)
if os.path.exists(target): return f'FAIL: 目录已存在 {target}'
os.makedirs(ws, exist_ok=True)
cmd = f'git clone -b {branch} {url} {target}'
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=120, cwd=ws)
if r.returncode != 0: return f'FAIL: git clone失败: {r.stderr[:200]}'
# Auto-add repo to project
ex = await sor.sqlExe("SELECT id FROM sd_project_repos WHERE project_id=${p}$ AND repo_url=${u}$",{"p":ctx['pid'],"u":url})
if not ex:
await sor.C('sd_project_repos',{'id':getID(),'project_id':ctx['pid'],'repo_name':repo_name,'repo_url':url,'default_branch':branch,'local_path':target,'org_id':org_id})
ctx['repos'].append({'n':repo_name,'u':url})
return f'OK: 已克隆到 {target}'
elif tool == 'run_command':
import subprocess
ws = ctx.get('ws','') or '/tmp'
cmd = p.get('cmd','')
if not cmd: return 'FAIL: 需要命令'
# Safety: block destructive commands
blocked = ['rm -rf /','mkfs.','dd if=',':(){','chmod 777 /']
for b in blocked:
if b in cmd: return 'FAIL: 危险命令被拦截'
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=60, cwd=ws)
out = (r.stdout + r.stderr)[:2000]
return f'OK (rc={r.returncode}):\n{out}' if out else f'OK (rc={r.returncode})'
elif tool == 'task_detail':
tid = p.get('task_id','')
if not tid: return 'FAIL: 需要任务ID'
ts = await sor.sqlExe("SELECT * FROM pipeline_tasks WHERE id=${t}$",{"t":tid})
if not ts: return 'FAIL: 任务不存在'
t = ts[0]
ds = await sor.sqlExe("SELECT deliverable_type,title,review_status,quality_score FROM pipeline_deliverables WHERE task_id=${t}$",{"t":tid})
dl = '\n'.join([f" [{getattr(d,'review_status','')}] {getattr(d,'deliverable_type','')}: {getattr(d,'title','')[:60]}" for d in (ds or [])]) or ' 无'
qs = await sor.sqlExe("SELECT question,answer,status FROM pipeline_questions WHERE task_id=${t}$ ORDER BY created_at DESC LIMIT 5",{"t":tid})
ql = '\n'.join([f" [{getattr(q,'status','')}] Q:{getattr(q,'question','')[:60]}\n A:{getattr(q,'answer','') or '待回答'}" for q in (qs or [])]) or ' 无'
return f"任务: {getattr(t,'title','')}\n状态: {getattr(t,'state','')}\n角色: {getattr(t,'role','')}\n交付件:\n{dl}\n问答:\n{ql}"
elif tool == 'list_deliverables':
if not ctx['pid']: return 'FAIL: 请先切换到项目'
tid = p.get('task_id','')
if tid:
ds = await sor.sqlExe("SELECT id,deliverable_type,title,review_status,quality_score FROM pipeline_deliverables WHERE task_id=${t}$ ORDER BY created_at DESC",{"t":tid})
else:
ds = await sor.sqlExe("SELECT id,deliverable_type,title,review_status,quality_score FROM pipeline_deliverables WHERE project_id=${p}$ ORDER BY created_at DESC LIMIT 20",{"p":ctx['pid']})
if not ds: return '无交付件'
return '\n'.join([f"[{getattr(d,'review_status','?')}] {getattr(d,'id','')[:8]} {getattr(d,'deliverable_type','')}: {getattr(d,'title','')[:60]} ({getattr(d,'quality_score','-')}分)" for d in ds])
elif tool == 'view_deliverable':
did = p.get('deliverable_id','')
if not did: return 'FAIL: 需要交付件ID'
ds = await sor.sqlExe("SELECT deliverable_type,title,content,review_status,quality_score FROM pipeline_deliverables WHERE id=${d}$",{"d":did})
if not ds: return 'FAIL: 交付件不存在'
d = ds[0]
return f"类型: {getattr(d,'deliverable_type','')}\n标题: {getattr(d,'title','')}\n状态: {getattr(d,'review_status','')} | 评分: {getattr(d,'quality_score','')}\n\n{getattr(d,'content','')[:3000]}"
elif tool == 'list_questions':
if not ctx['pid']: return 'FAIL: 请先切换到项目'
qs = await sor.sqlExe("SELECT id,task_id,question,answer,status,created_at FROM pipeline_questions WHERE project_id=${p}$ AND status='pending' ORDER BY created_at DESC LIMIT 10",{"p":ctx['pid']})
if not qs: return '无待回答问题'
return '\n'.join([f"[{getattr(q,'id','')[:8]}] 任务:{getattr(q,'task_id','')[:8]} Q:{getattr(q,'question','')[:80]}" for q in qs])
elif tool == 'answer_question':
qid = p.get('question_id','')
ans = p.get('answer','')
if not qid: return 'FAIL: 需要问题ID'
if not ans: return 'FAIL: 需要回答内容'
qs = await sor.sqlExe("SELECT id,status FROM pipeline_questions WHERE id=${q}$",{"q":qid})
if not qs: return 'FAIL: 问题不存在'
await sor.sqlExe("UPDATE pipeline_questions SET answer=${a}$, status='answered', answered_at=NOW() WHERE id=${q}$",{"a":ans,"q":qid})
return f'OK: 已回答'
elif tool == 'start_agents':
if not ctx['pid']: return 'FAIL: 请先切换到项目'
from pipeline_service.agent_loop import role_agent_run, pm_review_run
import asyncio as _asyncio
results = []
for role in ['requirement','design','develop','test','deploy']:
r = await role_agent_run(ctx['pid'], role)
if r['status'] != 'idle':
results.append(f"{role}: {r['status']} task={r.get('task_id','')[:8]}")
pm_r = await pm_review_run(ctx['pid'])
if pm_r['status'] != 'idle':
results.append(f"pm_review: {pm_r['status']} task={pm_r.get('task_id','')[:8]}")
return '\n'.join(results) if results else '无待执行任务'
elif tool == 'add_bug':
if not ctx['pid']: return 'FAIL: 请先切换到项目'
title = p.get('title','')[:100]
if not title: return 'FAIL: 缺少标题'
await sor.C('sd_bugs',{'id':getID(),'iteration_id':'','title':title,'description':p.get('description',''),'severity':'major','priority':'P1','status':'open','reporter_type':'human','reporter_id':uid})
return f"OK: Bug已记录 {title}"
return f'未实现: {tool}'
except Exception as e:
return f'ERROR: {str(e)[:300]}'
if action == 'send_message':
# AgentIO sends JSON: {"params":{"prompt":"...","model_id":"..."},...}
# FormData sends: action=send_message&message_text=...&model_id=...
# bricks sends prompt/model_id at top level
p = params_kw or {}
message_text = (p.get('message_text') or '').strip()
user_model_id = p.get('model_id', '')
# Try prompt at top level (bricks AgentIO)
if not message_text:
message_text = (p.get('prompt') or '').strip()
# Try JSON body from AgentIO
if not message_text:
inner = p.get('params', {})
if isinstance(inner, dict):
message_text = (inner.get('prompt') or inner.get('message_text') or '').strip()
user_model_id = inner.get('model_id') or inner.get('llmid') or user_model_id
if not message_text: return json.dumps({"error":"message_text is required"},ensure_ascii=False)
blocked, reason = _security_scan(message_text)
if blocked: return json.dumps({"success":True,"agent_reply":f"⚠️ {reason}"},ensure_ascii=False)
uid = await get_user()
org_id = await get_userorgid() or '0'
async def agent_stream():
async with DBPools().sqlorContext(dbname) as sor:
ctx = await _load_ctx(sor, uid)
model = await _sel_model(sor, user_model_id)
if not model:
d = json.dumps({"error": "没有可用的LLM模型配置"}, ensure_ascii=False)+'\n'
debug(f"YIELD error: {d[:80]}")
yield d
return
repos_str = ', '.join([r['n'] for r in ctx['repos']]) or '无'
env_text = f"当前项目: {ctx['pname'] or '未选择'}\n工作空间: {ctx['ws'] or '未设置'}\n仓库: {repos_str}"
system = AGENT_PROMPT.replace('__TOOLS__',TOOLS_TEXT).replace('__ENV__',env_text)
msgs = [{"role":"system","content":system}]
for h in (await sor.sqlExe("SELECT role,content FROM pipeline_conversations WHERE created_by=${u}$ ORDER BY created_at ASC LIMIT 20",{"u":uid}) or []):
msgs.append({"role":'user' if getattr(h,'role','')=='user' else 'assistant',"content":getattr(h,'content','')})
msgs.append({"role":"user","content":message_text})
await sor.C('pipeline_conversations',{'id':getID(),'role':'user','content':message_text,'msg_type':'text','org_id':org_id,'created_by':uid})
agent_reply = ''
for turn in range(10):
raw = await _call_llm(model, msgs, 0.4)
act = _parse(raw)
if act.get('action') == 'reply':
agent_reply = act.get('message', raw); break
if act.get('action') == 'tool_call':
tool = act.get('tool','')
label = _tool_labels.get(tool,tool)
d = json.dumps(_w_progress(f"🔄 {label}..."), ensure_ascii=False)+'\n'
debug(f"YIELD progress: {d[:60]}")
yield d
result = await _exec_tool(sor, tool, act.get('params',{}), ctx, uid, org_id)
ok = result.startswith("OK:")
d = json.dumps(_w_card(label, _w_text(result), "success" if ok else "error"), ensure_ascii=False)+'\n'
debug(f"YIELD result: {d[:60]}")
yield d
msgs.append({"role":"assistant","content":raw})
msgs.append({"role":"user","content":f"工具 {tool} 结果:\n{result}"})
continue
agent_reply = raw; break
if not agent_reply: agent_reply = "处理超时"
d = json.dumps(_w_text(agent_reply), ensure_ascii=False)+'\n'
debug(f"YIELD final: {d[:60]}")
yield d
await sor.C('pipeline_conversations',{'id':getID(),'role':'agent','content':agent_reply,'msg_type':'text','org_id':org_id,'created_by':'system'})
return await stream_response(request, agent_stream, 'text/plain; charset=utf-8')
elif action == 'list_messages':
async with DBPools().sqlorContext(dbname) as sor:
ms = await sor.sqlExe("SELECT role,content,created_at FROM pipeline_conversations ORDER BY created_at ASC LIMIT 100",{})
result = [{"role":getattr(m,'role',''),"content":getattr(m,'content','')} for m in (ms or [])]
return json.dumps({"success":True,"messages":result},ensure_ascii=False,default=str)
else:
return json.dumps({"error":f"Unknown: {action}"},ensure_ascii=False)