refactor: workspace 路径解析收敛到 workspace.py,dspy 消除重复

- 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 变为已跟踪提交
This commit is contained in:
ymq 2026-08-14 18:07:33 +08:00
parent bd3a9a5abd
commit d2f3caf8a7
3 changed files with 246 additions and 18 deletions

View File

@ -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)

View File

@ -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

View File

@ -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