""" pipeline_service/workspace.py - 工作空间文件管理 提供项目工作空间的文件浏览、读取、保存功能。 """ import os import json import logging logger = logging.getLogger(__name__) WORKSPACE_BASE = '/d/pipeline/workspaces' # 通用助手项目空间键(保留值,不属于任何专业产线;专业产线用 pipelines.id) GENERAL_SPACE = 'general' def build_workspace_path(workspace_base, org_id, space, name, project_id=''): """构建项目 workspace 路径:{base}/{org_id}/{space}/{name}。 space = 项目空间键(pipeline_id;通用助手为 'general'), 通用助手与专业产线、不同产线之间由此隔离,不共享目录。 同空间重名时追加短 ID 保证唯一,避免多项目共享同一目录互相覆盖。 """ space = (space or GENERAL_SPACE).strip() or GENERAL_SPACE name = (name or 'unnamed').strip() or 'unnamed' base_dir = os.path.join(workspace_base, str(org_id or '0'), space) path = os.path.join(base_dir, name) if project_id and os.path.isdir(path): short = project_id[-8:] if len(project_id) >= 8 else project_id path = os.path.join(base_dir, f"{name}_{short}") return path # 可编辑的文本文件扩展名 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_param(sor, name, default=""): """读 appbase params 表配置(params_name → params_value),表不存在/无值时兑底 default。""" try: recs = await sor.sqlExe( "SELECT params_value FROM params WHERE params_name=${n}$ LIMIT 1", {"n": name}) if recs and getattr(recs[0], 'params_value', '') not in (None, ''): return getattr(recs[0], 'params_value', '') except Exception: pass return default async def get_workspace_base(sor): """读 workspace_base 参数(appbase params 表,表不存在时兜底 WORKSPACE_BASE)。""" return await get_param(sor, 'workspace_base', WORKSPACE_BASE) async def get_max_concurrent_agents(sor): """读全局并发 agent 数限制(appbase params 表 max_concurrent_agents,默认 3)。""" val = await get_param(sor, 'max_concurrent_agents', '3') try: n = int(float(str(val))) return max(1, n) except (ValueError, TypeError): return 3 async def get_max_task_retry(sor): """读任务最大重复数(appbase params 表 task_max_retry,默认 3)。超限即暂停任务链、抛故障给人工。""" val = await get_param(sor, 'task_max_retry', '3') try: n = int(float(str(val))) return max(1, n) except (ValueError, TypeError): return 3 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, pipeline_id 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 '' space = getattr(proj[0], 'pipeline_id', '') or GENERAL_SPACE if ws.startswith('/'): return ws, workspace_base return build_workspace_path(workspace_base, org_id, space, pname, pid), 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_workspace_path = build_workspace_path g.GENERAL_SPACE = GENERAL_SPACE 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