255 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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_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_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