# 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": "var(--ink-0)", "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)