pipeline_core/wwwroot/api/agent_project_popup.dspy

334 lines
16 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.

# agent_project_popup.dspy - 会话「📁 项目」统一入口弹窗2026-09-11 用户要求)
# GET op=menu → 小弹窗:「新建项目」「切换项目」两个按钮
# GET op=new → 新建项目弹窗:输入项目名 → 创建成功后关闭,会话进入新项目
# GET op=switch → 项目切换弹窗:列出本产线全部项目 → 选择后关闭,会话进入该项目
# POST op=create → 创建项目(走 project_capability.create_project带 alloc_project_dir+审计,
# 禁止裸 INSERT+ start_project + 初始迭代 + 写会话项目指针
# POST op=do_switch → 校验项目归属(产线+本人创建后写会话项目指针GET op=switch 是列表)
# 参数session_id会话级隔离多 tab 各自项目) pipeline_id产线隔离
# 会话「进入项目」= 写 pipeline_session_settings + pipeline_agent_settings
# AgentExecutor._persist_project 同口径gateway/菜单/工作空间每次请求实时解析,写完即生效。
import json
import re as _re
_op = (params_kw or {}).get('op', 'menu') or 'menu'
session_id = (params_kw or {}).get('session_id', '') or ''
pipeline_id = (params_kw or {}).get('pipeline_id', '') or ''
uid = await get_user()
dbname = get_module_dbname('pipeline_core')
_STATUS_TXT = {
'draft': ('草稿', '#94a3b8'),
'active': ('进行中', '#16a34a'),
'in_progress': ('进行中', '#16a34a'),
'paused': ('已暂停', '#d97706'),
'completed': ('已完成', '#2563eb'),
'archived': ('已归档', '#64748b'),
}
# ══════════════════ POST创建 / 切换 ══════════════════
if _op in ('create', 'do_switch'):
if not uid:
return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False)
if not pipeline_id or pipeline_id == '_generic':
return json.dumps({"success": False, "error": "当前会话不支持项目管理"}, ensure_ascii=False)
userorg = (await get_userorgid()) or '0'
from appPublic.uniqueID import getID as _getID
async def _enter_project(sor, pid):
"""会话进入项目:会话级 + 全局兜底双写迭代上下文清空_persist_project 同口径)。"""
await sor.sqlExe(
"UPDATE pipeline_agent_settings SET current_project_id=${pid}$, current_iteration_id='' "
"WHERE user_id=${uid}$", {"pid": pid, "uid": uid})
_ex = await sor.sqlExe(
"SELECT 1 FROM pipeline_agent_settings WHERE user_id=${uid}$", {"uid": uid})
if not _ex:
await sor.C('pipeline_agent_settings', {
'id': _getID(), 'user_id': uid,
'current_project_id': pid, 'current_iteration_id': ''})
if session_id:
await sor.sqlExe(
"UPDATE pipeline_session_settings SET current_project_id=${pid}$, current_iteration_id='' "
"WHERE user_id=${uid}$ AND session_id=${sid}$",
{"pid": pid, "uid": uid, "sid": session_id})
_sex = await sor.sqlExe(
"SELECT 1 FROM pipeline_session_settings WHERE user_id=${uid}$ AND session_id=${sid}$",
{"uid": uid, "sid": session_id})
if not _sex:
await sor.C('pipeline_session_settings', {
'id': _getID(), 'user_id': uid, 'session_id': session_id,
'current_project_id': pid, 'current_iteration_id': ''})
await sor.sqlExe("COMMIT", {})
if _op == 'create':
name = ((params_kw or {}).get('name', '') or '').strip()
if not name:
return json.dumps({"success": False, "error": "请输入项目名"}, ensure_ascii=False)
if len(name) > 64:
return json.dumps({"success": False, "error": "项目名过长(最多 64 字符)"}, ensure_ascii=False)
# 项目名会作为工作空间目录名alloc_project_dir拒绝路径穿越与非法字符
if ('/' in name) or ('\\' in name) or ('..' in name) or name.startswith('.') or \
(not _re.match(r'^[\w\u4e00-\u9fff .\-()+]+$', name)):
return json.dumps({"success": False,
"error": "项目名含非法字符(仅允许中文、字母、数字、空格 . - _ ( ) +"},
ensure_ascii=False)
# 同产线重名预检:提示走切换,不静默建重名项目
async with DBPools().sqlorContext(dbname) as sor:
_dup = await sor.sqlExe(
"SELECT id FROM sd_projects WHERE name=${n}$ AND pipeline_id=${pl}$ LIMIT 1",
{"n": name, "pl": pipeline_id})
await sor.sqlExe("COMMIT", {})
if _dup:
return json.dumps({"success": False,
"error": "该产线已存在同名项目,请用「切换项目」直接进入"},
ensure_ascii=False)
# 铁律创建项目走能力函数alloc_project_dir 定死目录 + record_audit 审计)
from pipeline_service.project_capability import create_project, start_project
ok, pid = await create_project(
name, project_type='web_app', description='',
org_id=userorg, created_by=uid, who=uid,
pipeline_id=pipeline_id)
if not ok:
return json.dumps({"success": False, "error": str(pid)}, ensure_ascii=False)
# draft → activeCAS+审计),与会话 agent 创建的项目状态一致
await start_project(pid, who=uid)
# 初始迭代(与 agent_loop_v2._t_create_project 行为一致;
# sd_iterations 无 goal 列,范围写 scopeiteration_type 用 'default'
async with DBPools().sqlorContext(dbname) as sor:
await sor.C('sd_iterations', {
'id': _getID(), 'project_id': pid,
'iteration_name': name + '-初始迭代',
'iteration_type': 'default', 'status': 'in_progress',
'priority': 1, 'seq_no': 1})
await _enter_project(sor, pid)
return json.dumps({"success": True, "project_id": pid, "name": name}, ensure_ascii=False)
else: # do_switch
pid = (params_kw or {}).get('project_id', '') or ''
if not pid:
return json.dumps({"success": False, "error": "缺少 project_id"}, ensure_ascii=False)
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT id, name, org_id, pipeline_id, created_by FROM sd_projects "
"WHERE id=${p}$ LIMIT 1", {"p": pid})
await sor.sqlExe("COMMIT", {})
if not recs:
return json.dumps({"success": False, "error": "项目不存在"}, ensure_ascii=False)
r0 = recs[0]
p_pl = getattr(r0, 'pipeline_id', '') or ''
p_org = getattr(r0, 'org_id', '') or ''
p_by = getattr(r0, 'created_by', '') or ''
# 产线隔离:跨产线项目不得切入本会话(与 get_session_project_id 语义一致)
if p_pl and p_pl != pipeline_id:
return json.dumps({"success": False,
"error": "该项目属于其他产线,不能切入当前会话"},
ensure_ascii=False)
# 可见范围2026-09-11 用户拍板):本产线 + 本人创建
if p_by != uid:
return json.dumps({"success": False,
"error": "无权切换到该项目(仅限本人创建的项目)"},
ensure_ascii=False)
await _enter_project(sor, pid)
return json.dumps({"success": True, "project_id": pid,
"name": getattr(r0, 'name', '') or pid}, ensure_ascii=False)
# ══════════════════ GET弹窗 widget ══════════════════
if not uid:
return json.dumps({
"widgettype": "PopupWindow",
"id": "agent_project_menu_pw",
"options": {"title": "📁 项目", "cwidth": 30, "cheight": 8, "auto_open": True},
"subwidgets": [{"widgettype": "Text",
"options": {"text": "请先登录", "cfontsize": 1, "color": "#64748b",
"padding": "20px"}}]
}, ensure_ascii=False)
_self_url = entire_url("/pipeline_core/api/agent_project_popup.dspy")
_qb = []
if session_id:
_qb.append("session_id=" + session_id)
if pipeline_id:
_qb.append("pipeline_id=" + pipeline_id)
_qs_base = ("&" + "&".join(_qb)) if _qb else ""
def _destroy_js(pw_id):
return ("var _pw=bricks.getWidgetById(" + json.dumps(pw_id) + ",bricks.app);"
"if(_pw){_pw.destroy();}")
def _open_get_js(op):
"""fetch 本端点 GET op=xxx → widgetBuild 弹出PopupWindow 挂 body从 bricks.app 搜)。"""
return ("var r=await fetch(" + json.dumps(_self_url + "?op=" + op + _qs_base) + ");"
"var d=await r.json();if(d){bricks.widgetBuild(d,bricks.app);}")
def _post_js(fields):
js = "var body=new URLSearchParams();"
for k, v in fields:
js += "body.append(" + json.dumps(k) + "," + v + ");"
js += ("var rp=await fetch(" + json.dumps(_self_url) + ",{method:'POST',"
"headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body});"
"var d=await rp.json();")
return js
def _btn(label, css, script):
return {"widgettype": "Button", "options": {"label": label, "css": css},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}]}
# ── op=menu新建 / 切换 两按钮 ──
if _op == 'menu':
return json.dumps({
"widgettype": "PopupWindow",
"id": "agent_project_menu_pw",
"options": {"title": "📁 项目", "cwidth": 36, "cheight": 11, "auto_open": True},
"subwidgets": [{
"widgettype": "VBox",
"options": {"width": "100%", "gap": "12px", "padding": "18px 22px"},
"subwidgets": [
{"widgettype": "Text",
"options": {"text": "新建一个项目,或把当前会话切换到已有项目",
"cfontsize": 0.85, "color": "#64748b"}},
{"widgettype": "HBox",
"options": {"width": "100%", "gap": "10px"},
"subwidgets": [
_btn("✨ 新建项目", "primary",
_destroy_js("agent_project_menu_pw") + _open_get_js("new")),
_btn("🔀 切换项目", "small",
_destroy_js("agent_project_menu_pw") + _open_get_js("switch")),
]}
]
}]
}, ensure_ascii=False)
# ── op=new输入项目名 ──
if _op == 'new':
_create_js = (
"var cw=bricks.getWidgetById('proj_new_name',bricks.app);var cv='';"
"if(cw){cv=(typeof cw.resultValue==='function')?cw.resultValue():"
"((cw.dom_element&&cw.dom_element.value)||'');}"
"cv=(cv===null||cv===undefined)?'':String(cv).trim();"
"if(!cv){var mn=new bricks.Message({title:'提示',message:'请输入项目名'});mn.open();return;}"
+ _post_js([('op', "'create'"), ('name', 'cv'),
('session_id', json.dumps(session_id)),
('pipeline_id', json.dumps(pipeline_id))])
+ "if(d.success){"
+ _destroy_js("agent_project_new_pw")
+ "var mo=new bricks.Message({title:'已创建',message:'项目「'+d.name+'」已创建,会话已进入该项目'});mo.open();"
"}else{var mf=new bricks.Message({title:'创建失败',message:d.error||'未知错误'});mf.open();}")
return json.dumps({
"widgettype": "PopupWindow",
"id": "agent_project_new_pw",
"options": {"title": "✨ 新建项目", "cwidth": 40, "cheight": 13, "auto_open": True},
"subwidgets": [{
"widgettype": "VBox",
"options": {"width": "100%", "gap": "10px", "padding": "16px 20px"},
"subwidgets": [
{"widgettype": "Text",
"options": {"text": "项目名(将同时作为项目工作空间目录名)",
"cfontsize": 0.8, "color": "#64748b"}},
{"widgettype": "UiStr", "id": "proj_new_name",
"options": {"name": "proj_new_name", "placeholder": "输入项目名",
"width": "100%", "cfontsize": 0.9}},
{"widgettype": "HBox",
"options": {"width": "100%", "gap": "10px", "halign": "right"},
"subwidgets": [
_btn("创建", "primary", _create_js),
_btn("取消", "small", _destroy_js("agent_project_new_pw")),
]}
]
}]
}, ensure_ascii=False)
# ── op=switch本产线项目列表 ──
projects = []
cur_pid = ''
try:
from pipeline_service.workspace import get_session_project_id
async with DBPools().sqlorContext(dbname) as sor:
cur_pid = await get_session_project_id(sor, uid, session_id, pipeline_id) or ''
recs = await sor.sqlExe(
"SELECT id, name, status, created_at FROM sd_projects "
"WHERE pipeline_id=${pl}$ AND created_by=${uid}$ "
"ORDER BY created_at DESC LIMIT 100",
{"pl": pipeline_id, "uid": uid})
await sor.sqlExe("COMMIT", {})
projects = [dict(r) for r in (recs or [])]
except Exception as e:
debug('agent_project_popup switch list error: ' + str(e))
projects = []
rows = []
if not projects:
rows.append({"widgettype": "Text",
"options": {"text": "本产线暂无项目,请先「新建项目」",
"cfontsize": 0.9, "color": "#64748b", "padding": "16px"}})
for p in projects:
_pid_v = str(p.get('id') or '')
_pname = str(p.get('name') or _pid_v)
_st = str(p.get('status') or '')
_st_txt, _st_color = _STATUS_TXT.get(_st, (_st or '—', '#64748b'))
_is_cur = (_pid_v == cur_pid)
_switch_js = (
_post_js([('op', "'do_switch'"), ('project_id', json.dumps(_pid_v)),
('session_id', json.dumps(session_id)),
('pipeline_id', json.dumps(pipeline_id))])
+ "if(d.success){"
+ _destroy_js("agent_project_switch_pw")
+ "var mo=new bricks.Message({title:'已切换',message:'会话已进入项目「'+d.name+'」'});mo.open();"
"}else{var mf=new bricks.Message({title:'切换失败',message:d.error||'未知错误'});mf.open();}")
rows.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "10px", "alignItems": "center",
"padding": "10px 12px", "borderRadius": "6px",
"bgcolor": "#f0fdf4" if _is_cur else "#ffffff",
"border": "1px solid #e2e8f0",
"style": {"cursor": "pointer"}},
"subwidgets": [
{"widgettype": "Text",
"options": {"text": ("✓ " if _is_cur else "") + _pname,
"cfontsize": 0.95, "color": "#0f172a", "css": "filler",
"halign": "left"}},
{"widgettype": "Text",
"options": {"text": _st_txt, "cfontsize": 0.75, "color": "#ffffff",
"bgcolor": _st_color, "padding": "2px 8px",
"borderRadius": "8px", "whiteSpace": "nowrap"}},
{"widgettype": "Text",
"options": {"text": str(p.get('created_at') or '')[:16],
"cfontsize": 0.75, "color": "#94a3b8"}},
],
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": _switch_js}],
})
return json.dumps({
"widgettype": "PopupWindow",
"id": "agent_project_switch_pw",
"options": {"title": "🔀 切换项目", "width": "60%", "height": "70%", "auto_open": True},
"subwidgets": [{
"widgettype": "VBox",
"options": {"css": "filler", "width": "100%", "height": "100%", "gap": "0px"},
"subwidgets": [
{"widgettype": "Text",
"options": {"text": "点击项目行即切换(仅显示本产线、本人创建的项目)",
"cfontsize": 0.8, "color": "#94a3b8", "padding": "10px 14px 4px 14px"}},
{"widgettype": "VScrollPanel",
"options": {"css": "filler", "width": "100%", "padding": "8px 14px", "gap": "6px"},
"subwidgets": rows}
]
}]
}, ensure_ascii=False)