334 lines
16 KiB
Plaintext
334 lines
16 KiB
Plaintext
# 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 → active(CAS+审计),与会话 agent 创建的项目状态一致
|
||
await start_project(pid, who=uid)
|
||
|
||
# 初始迭代(与 agent_loop_v2._t_create_project 行为一致;
|
||
# sd_iterations 无 goal 列,范围写 scope;iteration_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)
|