- workspace.build_workspace_path: {base}/{org}/{space}/{name},重名追加短ID
- get_workspace_dir 兜底路径加空间段
- AgentExecutor 加 generic 标志派生 self.space;create_project/switch_project 按空间过滤
- gateway 传 generic
288 lines
11 KiB
Python
288 lines
11 KiB
Python
"""
|
||
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
|