From d2f3caf8a7b22ee3edc49b7737414b421bef606d Mon Sep 17 00:00:00 2001 From: ymq Date: Fri, 14 Aug 2026 18:07:33 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20workspace=20=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E6=94=B6=E6=95=9B=E5=88=B0=20workspace.py?= =?UTF-8?q?=EF=BC=8Cdspy=20=E6=B6=88=E9=99=A4=E9=87=8D=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workspace.py 加 get_workspace_dir/get_workspace_base + load_workspace 注册到 ServerEnv - 8 个 workspace dspy 内联重复的'读 workspace_base + 查 project 算 ws_dir'改为调 get_workspace_dir - _t_create_project 复用 get_workspace_base(删掉死代码 _get_param) - workspace.py 从 untracked 变为已跟踪提交 --- pipeline_service/agent_loop_v2.py | 20 +-- pipeline_service/init.py | 4 + pipeline_service/workspace.py | 240 ++++++++++++++++++++++++++++++ 3 files changed, 246 insertions(+), 18 deletions(-) create mode 100644 pipeline_service/workspace.py diff --git a/pipeline_service/agent_loop_v2.py b/pipeline_service/agent_loop_v2.py index 4e05c54..f814066 100644 --- a/pipeline_service/agent_loop_v2.py +++ b/pipeline_service/agent_loop_v2.py @@ -26,23 +26,6 @@ from typing import AsyncGenerator, Dict, List, Optional logger = logging.getLogger("pipeline.agent_executor") -async def _get_param(sor, name: str, default: str = "") -> str: - """从 appbase params 表读参数(params_name → params_value),带默认值兜底。 - - params 表由 appbase 模块提供,用于存系统级可配置参数,避免硬编码。 - 默认值仅作兜底,真正值以 params 表为准(可动态维护)。 - """ - try: - recs = await sor.sqlExe( - "SELECT params_value FROM params WHERE params_name=${n}$ LIMIT 1", - {"n": name}) - if recs: - return getattr(recs[0], "params_value", "") or default - except Exception: - pass - return default - - # ── 默认工具定义(在 pipeline-core 未加载时使用)── _BUILTIN_ASK_USER_SCHEMA = { @@ -688,7 +671,8 @@ class AgentExecutor: # 项目专属工作空间目录(workspace_base 从 appbase params 表读,可动态配置) # 不设置 workspace_dir 会导致 _resolve_workspace 回退到 ~/pipeline_ws/default, # 所有项目共用同一目录,设计文档/代码互相覆盖。 - workspace_base = await _get_param(sor, "workspace_base", "/d/pipeline/workspaces") + from .workspace import get_workspace_base + workspace_base = await get_workspace_base(sor) workspace_dir = os.path.join(workspace_base, str(org_id), name) os.makedirs(workspace_dir, exist_ok=True) diff --git a/pipeline_service/init.py b/pipeline_service/init.py index 0320c51..359cccd 100644 --- a/pipeline_service/init.py +++ b/pipeline_service/init.py @@ -500,6 +500,10 @@ def load_pipeline_service(): # Load built-in interactive step types load_builtin_types() + # Register workspace file-management functions (shared by workspace_*.dspy) + from .workspace import load_workspace + load_workspace() + # Register intent classifier + LLM bridge (shared across all pipelines) from .intent_classifier import intent_classify from .llm_bridge import llm_call diff --git a/pipeline_service/workspace.py b/pipeline_service/workspace.py new file mode 100644 index 0000000..8276036 --- /dev/null +++ b/pipeline_service/workspace.py @@ -0,0 +1,240 @@ +""" +pipeline_service/workspace.py - 工作空间文件管理 + +提供项目工作空间的文件浏览、读取、保存功能。 +""" +import os +import json +import logging + +logger = logging.getLogger(__name__) + +WORKSPACE_BASE = '/d/pipeline/workspaces' + +# 可编辑的文本文件扩展名 +TEXT_EXTENSIONS = { + '.md', '.py', '.js', '.html', '.css', '.json', '.txt', + '.xml', '.yaml', '.yml', '.toml', '.cfg', '.sh', '.sql', + '.dspy', '.ui', '.java', '.go', '.rs', '.ts', '.tsx', '.vue', +} + +# 媒体文件扩展名 +MEDIA_EXTENSIONS = { + '.png', '.jpg', '.jpeg', '.gif', '.svg', '.mp4', '.webm', '.mp3', '.wav', +} + +def _get_file_icon(ext): + """根据扩展名返回图标""" + ext = ext.lower() + if ext in TEXT_EXTENSIONS: + return "📄" + if ext in MEDIA_EXTENSIONS: + return "🎬" + return "📁" + + +async def get_workspace_base(sor): + """读 workspace_base 参数(appbase params 表,表不存在时兜底 WORKSPACE_BASE)。""" + workspace_base = WORKSPACE_BASE + try: + _wbr = await sor.sqlExe( + "SELECT params_value FROM params WHERE params_name='workspace_base' LIMIT 1", {}) + if _wbr and getattr(_wbr[0], 'params_value', ''): + workspace_base = getattr(_wbr[0], 'params_value', '') + except Exception: + pass + return workspace_base + + +async def get_workspace_dir(sor, uid): + """读当前项目的 workspace 目录(统一 workspace_*.dspy 的重复逻辑)。 + + 返回 (ws_dir, workspace_base)。ws_dir 优先用 sd_projects.workspace_dir(绝对路径), + 否则 base/org/name;无当前项目时 ws_dir 返回空字符串。 + """ + workspace_base = await get_workspace_base(sor) + + recs = await sor.sqlExe( + "SELECT current_project_id FROM pipeline_agent_settings WHERE user_id=${u}$", + {"u": uid}) + pid = getattr(recs[0], 'current_project_id', '') if recs else '' + if not pid: + return '', workspace_base + + proj = await sor.sqlExe( + "SELECT name, org_id, workspace_dir FROM sd_projects WHERE id=${p}$", + {"p": pid}) + if not proj: + return '', workspace_base + + pname = getattr(proj[0], 'name', '') + org_id = getattr(proj[0], 'org_id', '0') or '0' + ws = getattr(proj[0], 'workspace_dir', '') or '' + if ws.startswith('/'): + return ws, workspace_base + return workspace_base + '/' + str(org_id) + '/' + pname, workspace_base + + +async def get_workspace_path(sor, user_id): + """获取用户当前项目的工作空间路径(无项目时返回 None)。""" + ws, _ = await get_workspace_dir(sor, user_id) + return ws or None + + +def build_tree_items(ws_dir): + """递归扫描目录,返回 Tree widget 需要的 {id, parentid, label} 列表""" + if not ws_dir or not os.path.isdir(ws_dir): + return [{"id": "__root__", "parentid": "", "label": "📁 工作空间(不可用)"}] + + root_name = os.path.basename(ws_dir) + items = [{"id": "__root__", "parentid": "", "label": "📁 " + root_name}] + + def scan(path, parent_id): + try: + entries = sorted(os.listdir(path)) + except Exception: + return + for name in entries: + full = os.path.join(path, name) + if name.startswith('.') or name == '__pycache__': + continue + if os.path.isdir(full): + items.append({"id": full, "parentid": parent_id, "label": "📁 " + name}) + scan(full, full) + + scan(ws_dir, "__root__") + return items + + +def build_file_widgets(folder_path): + """返回指定目录下的文件列表(Bricks widget 定义)""" + if not folder_path or not os.path.isdir(folder_path): + return {"widgettype": "Text", "options": {"text": "目录不可用", "cfontsize": 0.9}} + + files = [] + try: + entries = sorted(os.listdir(folder_path)) + except Exception as e: + return {"widgettype": "Text", "options": {"text": f"读取错误: {e}", "cfontsize": 0.9, "color": "#f44336"}} + + for name in entries: + full = os.path.join(folder_path, name) + if name.startswith('.'): + continue + if not os.path.isfile(full): + continue + + size = os.path.getsize(full) + ext = os.path.splitext(name)[1].lower() + icon = _get_file_icon(ext) + is_text = ext in TEXT_EXTENSIONS + kb_size = f"{size / 1024:.1f}KB" + + # 为安全起见,在 widget JSON 中不嵌入完整文件路径 + # 文件路径通过 row_id 传递(base64 编码) + import base64 + path_b64 = base64.b64encode(full.encode()).decode() + + if is_text: + row = { + "widgettype": "HBox", + "options": { + "padding": "6px 10px", "alignItems": "center", "gap": "8px", + "borderRadius": "4px", "style": {"cursor": "pointer"}, + }, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": icon, "cfontsize": 1.2}}, + {"widgettype": "Text", "options": {"text": name, "cfontsize": 0.9, "color": "#1e293b", "css": "filler"}}, + {"widgettype": "Text", "options": {"text": kb_size, "cfontsize": 0.75, "color": "#94a3b8"}}, + ], + "binds": [{ + "wid": "self", "event": "click", "actiontype": "script", "target": "self", + "script": _build_open_file_script(path_b64, name) + }], + } + else: + row = { + "widgettype": "HBox", + "options": { + "padding": "6px 10px", "alignItems": "center", "gap": "8px", + "borderRadius": "4px", + }, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": icon, "cfontsize": 1.2}}, + {"widgettype": "Text", "options": {"text": name, "cfontsize": 0.9, "color": "#94a3b8", "css": "filler"}}, + {"widgettype": "Text", "options": {"text": kb_size, "cfontsize": 0.75, "color": "#94a3b8"}}, + ], + } + files.append(row) + + if not files: + return {"widgettype": "Text", "options": {"text": "(空目录)", "cfontsize": 0.85, "color": "#94a3b8"}} + + return { + "widgettype": "VScrollPanel", + "options": {"css": "filler", "padding": "8px", "gap": "4px"}, + "subwidgets": files, + } + + +def _build_open_file_script(path_b64, name): + """生成文件打开脚本(Wterm 编辑器 + 保存按钮)""" + return ( + "var p=atob('" + path_b64 + "');" + "var pw=new bricks.PopupWindow({title:'" + name.replace("'", "\\'") + "',cwidth:70,cheight:32,auto_open:true});" + "var wt=new bricks.Wterm({css:'filler',padding:'0'});pw.content_w.add_widget(wt);" + "var sb=new bricks.Button({label:'保存'});pw.content_w.add_widget(sb);" + "fetch('/pipeline-sdlc/api/workspace_file.dspy?action=read&path='+encodeURIComponent(p))" + ".then(function(r){return r.json()}).then(function(d){" + "if(d.error){wt.write('Error: '+d.error);return;}" + "wt.write(d.content||'');" + "sb.bind('click',function(){" + "var c=wt.get_value();" + "fetch('/pipeline-sdlc/api/workspace_file.dspy',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'}," + "body:'action=save&path='+encodeURIComponent(p)+'&content='+encodeURIComponent(c)})" + ".then(function(r){return r.json()}).then(function(d){" + "if(d.success){sb.set_text('已保存');setTimeout(function(){sb.set_text('保存')},2000);}" + "});" + "});" + "});" + ) + + +def read_file_content(filepath): + """读取文件内容""" + if not filepath or not os.path.isfile(filepath): + return None, "文件不存在" + try: + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read(50000) + return content, None + except Exception as e: + return None, str(e) + + +def save_file_content(filepath, content): + """保存文件内容""" + if not filepath: + return False, "路径为空" + try: + dirpath = os.path.dirname(filepath) + if dirpath: + os.makedirs(dirpath, exist_ok=True) + with open(filepath, 'w', encoding='utf-8') as f: + f.write(content) + return True, None + except Exception as e: + return False, str(e) + + +def load_workspace(): + """注册工作空间函数到 ServerEnv,供 DSPY 直接调用。""" + from ahserver.serverenv import ServerEnv + g = ServerEnv() + g.get_workspace_base = get_workspace_base + g.get_workspace_dir = get_workspace_dir + g.get_workspace_path = get_workspace_path + g.build_tree_items = build_tree_items + g.build_file_widgets = build_file_widgets + g.read_file_content = read_file_content + g.save_file_content = save_file_content