684 lines
29 KiB
Python
684 lines
29 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 generic_workspace_dir(user_id: str) -> str:
|
||
"""纯通用会话的专属工作目录(2026-09-05 产线隔离第 6 层修复)。
|
||
|
||
通用会话没有项目,文件工具的文件根若用 WORKSPACE_BASE 总根,
|
||
LLM 可浏览 {base}/{org}/{产线}/… 从目录名枚举所有产线的项目。
|
||
改为每用户专属目录 _general/{user_id},配合 _resolve_ws_path 的
|
||
越界保护,把通用会话的文件视野圈死在自己的目录内。
|
||
"""
|
||
uid = (user_id or 'anonymous').replace('/', '_').replace('..', '_') or 'anonymous'
|
||
return os.path.join(WORKSPACE_BASE, '_general', uid)
|
||
|
||
|
||
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
|
||
|
||
|
||
def build_space_path(workspace_base, org_id, space):
|
||
"""构建产线工作空间路径(机构工作空间层):{base}/{org}/{space}。
|
||
|
||
开发产线的机构工作空间(projects/apps/modules 三目录所在层)。
|
||
角色 agent 的工具路径基准用这个,从而能访问 projects/、apps/、modules/。
|
||
"""
|
||
space = (space or GENERAL_SPACE).strip() or GENERAL_SPACE
|
||
return os.path.join(workspace_base, str(org_id or '0'), space)
|
||
|
||
|
||
async def alloc_project_dir(sor, org_id, space, name, project_id=''):
|
||
"""创建项目时定死项目目录(所有创建入口统一用这个,禁止各自推导)。
|
||
|
||
目录 = {base}/{org}/{space}/projects/{项目名}(用名字,不用 id);
|
||
同空间重名时追加 _{project_id 后 8 位} 避让。
|
||
返回 (dir_path, dir_name)——调用方必须把两者写进
|
||
sd_projects.workspace_dir / directory_name,此后一切解析只读库不推导,
|
||
保证「上传放的位置 = agent 读的位置 = 工作控件显示的位置」。
|
||
(2026-09-01 根治:旧代码创建时不写库、解析时按名字推导且口径不一,
|
||
导致上传落盘与项目目录错位,文件在工作控件里找不到)
|
||
"""
|
||
workspace_base = await get_workspace_base(sor)
|
||
space_dir = build_space_path(workspace_base, org_id or '0', space or GENERAL_SPACE)
|
||
dname = (name or '').strip() or 'unnamed'
|
||
path = os.path.join(space_dir, 'projects', dname)
|
||
if os.path.isdir(path) and project_id:
|
||
short = project_id[-8:] if len(project_id) >= 8 else project_id
|
||
dname = f"{dname}_{short}"
|
||
path = os.path.join(space_dir, 'projects', dname)
|
||
os.makedirs(path, exist_ok=True)
|
||
return path, dname
|
||
|
||
# 可编辑的文本文件扩展名
|
||
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_session_project_id(sor, uid, session_id='', pipeline_id=''):
|
||
"""按会话解析当前项目 id。
|
||
|
||
session_id 非空时优先读 pipeline_session_settings(web 多 tab 各自项目上下文),
|
||
无记录或表不存在时回退全局 pipeline_agent_settings.current_project_id。
|
||
|
||
pipeline_id 产线隔离(2026-08-31 修复跨产线串扰):各产线页面默认 tab 共用
|
||
session_id='default',而 session/全局记录不区分产线——投标页会读到开发页切换
|
||
的项目。传入 pipeline_id 时,解析出的项目必须属于该产线(sd_projects.pipeline_id
|
||
匹配)才生效,否则视为无记录继续回退;不匹配的记录**不删除**(它属于另一产线
|
||
的合法上下文)。
|
||
|
||
存在性校验(悬空引用防护):记录指向的项目若已从 sd_projects 删除,
|
||
视为无效——顺手清掉死记录(自愈,避免每次请求重复命中),继续回退全局;
|
||
全局也无效则返回 ''。若不清理,删除项目后该会话的所有项目相关入口
|
||
(工作空间/任务/菜单)都会被死引用遮蔽,报「请先在会话中切换项目」。
|
||
"""
|
||
async def _project_pipeline(pid):
|
||
"""项目所属 pipeline_id;不存在返回 None;查询异常返回 ''(保守放行)。"""
|
||
try:
|
||
recs = await sor.sqlExe(
|
||
"SELECT pipeline_id FROM sd_projects WHERE id=${p}$ LIMIT 1", {"p": pid})
|
||
if not recs:
|
||
return None
|
||
return getattr(recs[0], 'pipeline_id', '') or ''
|
||
except Exception:
|
||
return ''
|
||
|
||
async def _valid(pid):
|
||
"""返回 (vpid, reason):vpid 有效则返回;否则 '' + 原因('gone'=项目已删, 'cross'=跨产线)。"""
|
||
pl = await _project_pipeline(pid)
|
||
if pl is None:
|
||
return '', 'gone'
|
||
if pipeline_id and pl and pl != pipeline_id:
|
||
return '', 'cross'
|
||
return pid, ''
|
||
|
||
if session_id:
|
||
try:
|
||
recs = await sor.sqlExe(
|
||
"SELECT current_project_id FROM pipeline_session_settings "
|
||
"WHERE user_id=${u}$ AND session_id=${s}$",
|
||
{"u": uid, "s": session_id})
|
||
if recs:
|
||
pid = getattr(recs[0], 'current_project_id', '') or ''
|
||
if pid:
|
||
vpid, reason = await _valid(pid)
|
||
if vpid:
|
||
return vpid
|
||
if reason == 'gone':
|
||
# 悬空引用:项目已删除 → 清掉死记录(自愈),回退全局
|
||
try:
|
||
await sor.sqlExe(
|
||
"DELETE FROM pipeline_session_settings "
|
||
"WHERE user_id=${u}$ AND session_id=${s}$",
|
||
{"u": uid, "s": session_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
except Exception:
|
||
pass
|
||
# reason == 'cross':跨产线的合法上下文,不删除(删除会让用户
|
||
# 在该产线页面丢失当前项目),只视为本会话无项目继续回退
|
||
except Exception:
|
||
pass
|
||
try:
|
||
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 pid:
|
||
vpid, reason = await _valid(pid)
|
||
if vpid:
|
||
return vpid
|
||
if reason == 'gone':
|
||
# 悬空引用:项目已删除 → 清全局指针(自愈)
|
||
try:
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_agent_settings SET current_project_id='' "
|
||
"WHERE user_id=${u}$", {"u": uid})
|
||
await sor.sqlExe("COMMIT", {})
|
||
except Exception:
|
||
pass
|
||
# reason == 'cross':全局项目属于另一产线的合法上下文,不删除
|
||
# (2026-09-02 修复:原实现产线不匹配也删全局指针,平台页点一次
|
||
# 「工作空间」就把用户在投标页的当前项目清掉)
|
||
return ''
|
||
except Exception:
|
||
return ''
|
||
|
||
|
||
async def get_session_context(sor, uid, session_id=''):
|
||
"""按会话解析 (project_id, iteration_id)。per-session 优先,回退 per-user 全局。
|
||
|
||
项目 id 统一走 get_session_project_id(含悬空引用防护:指向已删除项目的
|
||
记录视为无效并自愈清理)。禁止在本函数里重复实现解析逻辑——两份实现
|
||
语义漂移正是「会话有项目、工作空间说没有」这类 bug 的根因。
|
||
iteration_id 只从提供有效 pid 的同一条记录取,避免 pid 与 iid 来自不同
|
||
记录造成错配。
|
||
"""
|
||
pid = await get_session_project_id(sor, uid, session_id)
|
||
if not pid:
|
||
return '', ''
|
||
iid = ''
|
||
if session_id:
|
||
try:
|
||
recs = await sor.sqlExe(
|
||
"SELECT current_project_id, current_iteration_id FROM pipeline_session_settings "
|
||
"WHERE user_id=${u}$ AND session_id=${s}$",
|
||
{"u": uid, "s": session_id})
|
||
if recs and (getattr(recs[0], 'current_project_id', '') or '') == pid:
|
||
iid = getattr(recs[0], 'current_iteration_id', '') or ''
|
||
except Exception:
|
||
pass
|
||
if not iid:
|
||
try:
|
||
recs = await sor.sqlExe(
|
||
"SELECT current_project_id, current_iteration_id FROM pipeline_agent_settings "
|
||
"WHERE user_id=${u}$",
|
||
{"u": uid})
|
||
if recs and (getattr(recs[0], 'current_project_id', '') or '') == pid:
|
||
iid = getattr(recs[0], 'current_iteration_id', '') or ''
|
||
except Exception:
|
||
pass
|
||
return pid, iid
|
||
|
||
|
||
async def get_workspace_dir(sor, uid, session_id=''):
|
||
"""读当前项目的 workspace 目录(统一 workspace_*.dspy 的重复逻辑)。
|
||
|
||
返回 (ws_dir, workspace_base)。ws_dir 优先用 sd_projects.workspace_dir(绝对路径),
|
||
否则 base/org/name;无当前项目时 ws_dir 返回空字符串。
|
||
session_id 非空时按会话隔离项目上下文(多 tab 各自项目,互不覆盖)。
|
||
"""
|
||
workspace_base = await get_workspace_base(sor)
|
||
|
||
pid = await get_session_project_id(sor, uid, session_id)
|
||
if not pid:
|
||
return '', workspace_base
|
||
|
||
proj = await sor.sqlExe(
|
||
"SELECT name, directory_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
|
||
# 兜底与项目目录口径一致:{space}/projects/{项目名}(旧平铺 {space}/{项目名}
|
||
# 已废弃——与上传落盘/角色 agent 工作目录错位,2026-09-01 根治)
|
||
dname = (getattr(proj[0], 'directory_name', '') or '').strip() or (pname or '').strip()
|
||
if not dname:
|
||
return '', workspace_base
|
||
return os.path.join(build_space_path(workspace_base, org_id, space),
|
||
'projects', dname), workspace_base
|
||
|
||
|
||
async def get_space_dir(sor, uid, session_id=''):
|
||
"""返回机构工作空间层 {space}/(projects/apps/modules 三目录所在)。
|
||
|
||
用 build_space_path(org_id + pipeline_id)构造,不依赖 workspace_dir 的路径结构
|
||
(workspace_dir 迁移到 {space}/projects/{项目名} 后 dirname 会错)。
|
||
"""
|
||
workspace_base = await get_workspace_base(sor)
|
||
pid = await get_session_project_id(sor, uid, session_id)
|
||
if not pid:
|
||
return '', workspace_base
|
||
try:
|
||
proj = await sor.sqlExe(
|
||
"SELECT org_id, pipeline_id FROM sd_projects WHERE id=${p}$ LIMIT 1",
|
||
{"p": pid})
|
||
if proj:
|
||
org_id = getattr(proj[0], 'org_id', '0') or '0'
|
||
space = getattr(proj[0], 'pipeline_id', '') or GENERAL_SPACE
|
||
return build_space_path(workspace_base, org_id, space), workspace_base
|
||
except Exception:
|
||
pass
|
||
return '', workspace_base
|
||
|
||
|
||
async def get_project_dir(sor, uid, session_id=''):
|
||
"""返回项目目录 {space}/projects/{项目名}/(新结构,docs/env/spec.json/deliverables 所在)。"""
|
||
space_dir, workspace_base = await get_space_dir(sor, uid, session_id)
|
||
if not space_dir:
|
||
return '', workspace_base
|
||
pid = await get_session_project_id(sor, uid, session_id)
|
||
dname = ''
|
||
if pid:
|
||
try:
|
||
r = await sor.sqlExe(
|
||
"SELECT directory_name, name FROM sd_projects WHERE id=${p}$ LIMIT 1",
|
||
{"p": pid})
|
||
if r:
|
||
dname = getattr(r[0], 'directory_name', '') or getattr(r[0], 'name', '') or ''
|
||
except Exception:
|
||
pass
|
||
if not dname:
|
||
ws_dir, _ = await get_workspace_dir(sor, uid, session_id)
|
||
dname = os.path.basename(ws_dir.rstrip('/'))
|
||
return os.path.join(space_dir, 'projects', dname), workspace_base
|
||
|
||
|
||
async def get_project_dir_pl(sor, uid, session_id='', pipeline_id=''):
|
||
"""产线隔离版:会话项目与指定产线不一致时视为无项目(返回空目录)。
|
||
|
||
商机/投标产线入口的工作空间不能显示开发产线项目的空间——
|
||
会话里残留的跨产线项目(如元景项目)不应劫持本产线的工作空间。
|
||
pipeline_id 为空时不做隔离(向后兼容旧调用)。
|
||
"""
|
||
if not pipeline_id:
|
||
return await get_project_dir(sor, uid, session_id)
|
||
pid = await get_session_project_id(sor, uid, session_id)
|
||
if pid:
|
||
try:
|
||
r = await sor.sqlExe(
|
||
"SELECT pipeline_id FROM sd_projects WHERE id=${p}$ LIMIT 1",
|
||
{"p": pid})
|
||
if r:
|
||
proj_pl = getattr(r[0], 'pipeline_id', '') or ''
|
||
if proj_pl != pipeline_id:
|
||
# 跨产线项目:视为本产线无项目(与 gateway 的隔离一致)
|
||
return '', await get_workspace_base(sor)
|
||
except Exception:
|
||
pass
|
||
return await get_project_dir(sor, uid, session_id)
|
||
|
||
|
||
async def get_project_apps_modules(sor, uid, session_id=''):
|
||
"""返回项目用到的 (apps, modules)。
|
||
|
||
数据源(并集去重):
|
||
1. 项目目录下各 *_spec.json 的 app / referenced_modules / generated_modules 字段
|
||
2. sd_project_repos 关联仓库——按仓库在机构空间的实际位置归类({space}/apps/ 或
|
||
{space}/modules/);两个目录都不存在则不挂载(仓库可能尚未克隆)。
|
||
(2026-09-01 修复:元景项目无 spec.json 但有 7 个关联仓库,工作空间树
|
||
因此看不到任何模块/应用仓库——仓库表是项目用哪些仓库的权威来源之一)
|
||
"""
|
||
import json as _json
|
||
project_dir, _ = await get_project_dir(sor, uid, session_id)
|
||
space_dir, _ = await get_space_dir(sor, uid, session_id)
|
||
apps = []
|
||
modules = set()
|
||
if project_dir and os.path.isdir(project_dir):
|
||
try:
|
||
for f in sorted(os.listdir(project_dir)):
|
||
if not f.endswith('_spec.json'):
|
||
continue
|
||
fp = os.path.join(project_dir, f)
|
||
try:
|
||
with open(fp, 'r', encoding='utf-8') as fh:
|
||
data = _json.loads(fh.read())
|
||
except Exception:
|
||
continue
|
||
if not isinstance(data, dict):
|
||
continue
|
||
app = (data.get('app') or '').strip()
|
||
if app:
|
||
apps.append(app)
|
||
for m in (data.get('referenced_modules') or []) + (data.get('generated_modules') or []):
|
||
if isinstance(m, str) and m.strip():
|
||
modules.add(m.strip())
|
||
except Exception:
|
||
pass
|
||
# 关联仓库:按机构空间实际目录归类(仓库表是项目用哪些仓库的权威来源)
|
||
try:
|
||
pid = await get_session_project_id(sor, uid, session_id)
|
||
if pid and space_dir:
|
||
recs = await sor.sqlExe(
|
||
"SELECT repo_name FROM sd_project_repos WHERE project_id=${pid}$",
|
||
{"pid": pid})
|
||
await sor.sqlExe("COMMIT", {})
|
||
for r in (recs or []):
|
||
rn = (getattr(r, 'repo_name', '') or '').strip()
|
||
if not rn or '/' in rn:
|
||
continue
|
||
if os.path.isdir(os.path.join(space_dir, 'apps', rn)):
|
||
apps.append(rn)
|
||
elif os.path.isdir(os.path.join(space_dir, 'modules', rn)):
|
||
modules.add(rn)
|
||
except Exception:
|
||
pass
|
||
return sorted(set(apps)), sorted(modules)
|
||
|
||
|
||
async def get_project_dir_by_id(sor, project_id):
|
||
"""项目根目录(按项目 id 直接解析,不依赖会话上下文)。
|
||
|
||
解析顺序与角色 agent 工作目录(agent_loop._get_project_dir)完全一致,
|
||
保证「上传放的位置 = agent 读的位置 = 工作控件显示的位置」:
|
||
1. sd_projects.workspace_dir 非空 → 直接用
|
||
2. 为空(存量项目)→ 新结构 {base}/{org}/{space}/projects/{directory_name or name}
|
||
(历史教训:曾兜底旧平铺 {space}/{项目名},导致上传落盘与项目目录错位,
|
||
文件在项目控件里找不到——2026-09-01 实测复现并根治)
|
||
"""
|
||
if not project_id:
|
||
return '', ''
|
||
recs = await sor.sqlExe(
|
||
"SELECT name, directory_name, org_id, pipeline_id, workspace_dir "
|
||
"FROM sd_projects WHERE id=${p}$ LIMIT 1",
|
||
{"p": project_id})
|
||
if not recs:
|
||
return '', ''
|
||
r = recs[0]
|
||
workspace_base = await get_workspace_base(sor)
|
||
ws = (getattr(r, 'workspace_dir', '') or '').strip()
|
||
if ws.startswith('/'):
|
||
return ws, workspace_base
|
||
dname = (getattr(r, 'directory_name', '') or '').strip() or \
|
||
(getattr(r, 'name', '') or '').strip()
|
||
if not dname:
|
||
return '', workspace_base
|
||
org_id = getattr(r, 'org_id', '0') or '0'
|
||
space = getattr(r, 'pipeline_id', '') or GENERAL_SPACE
|
||
space_dir = build_space_path(workspace_base, org_id, space)
|
||
return os.path.join(space_dir, 'projects', dname), workspace_base
|
||
|
||
|
||
def copy_uploads_to_project(project_dir, uploads):
|
||
"""把已落盘的上传文件复制进项目根目录(会话上传 → 项目内可被 agent 找到)。
|
||
|
||
uploads: [(src_abs_path, filename)];同名文件自动加 _1/_2 后缀防覆盖。
|
||
返回 [(保存后文件名, 绝对路径)],与入参顺序对应。
|
||
"""
|
||
import shutil
|
||
saved = []
|
||
if not project_dir:
|
||
return saved
|
||
try:
|
||
os.makedirs(project_dir, exist_ok=True)
|
||
except Exception:
|
||
return saved
|
||
for src, name in uploads:
|
||
name = os.path.basename((name or '').strip())
|
||
if not name or not os.path.isfile(src):
|
||
continue
|
||
target = os.path.join(project_dir, name)
|
||
if os.path.exists(target):
|
||
stem, ext = os.path.splitext(name)
|
||
i = 1
|
||
while os.path.exists(os.path.join(project_dir, f"{stem}_{i}{ext}")):
|
||
i += 1
|
||
target = os.path.join(project_dir, f"{stem}_{i}{ext}")
|
||
try:
|
||
shutil.copyfile(src, target)
|
||
saved.append((os.path.basename(target), target))
|
||
except Exception as e:
|
||
logger.warning(f"copy upload failed: {src} -> {target} err={e}")
|
||
return saved
|
||
|
||
|
||
def resolve_workspace_path(project_dir, space_dir, node_id):
|
||
"""把工作空间节点 ID 解析成绝对路径。
|
||
|
||
- '@apps/xxx' → space_dir/apps/xxx(项目用到的应用)
|
||
- '@modules/xxx' → space_dir/modules/xxx(项目用到的模块)
|
||
- '__root__' / 空 → project_dir(项目目录根)
|
||
- 其他 → project_dir/xxx(项目目录内的相对路径)
|
||
"""
|
||
node_id = (node_id or '').strip()
|
||
if node_id.startswith('@apps/'):
|
||
return os.path.join(space_dir, 'apps', node_id[len('@apps/'):])
|
||
if node_id.startswith('@modules/'):
|
||
return os.path.join(space_dir, 'modules', node_id[len('@modules/'):])
|
||
if not node_id or node_id == '__root__':
|
||
return project_dir
|
||
return os.path.join(project_dir, node_id)
|
||
|
||
|
||
async def get_workspace_path(sor, user_id, session_id=''):
|
||
"""获取用户当前项目的工作空间路径(无项目时返回 None)。"""
|
||
ws, _ = await get_workspace_dir(sor, user_id, session_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.get_space_dir = get_space_dir
|
||
g.get_project_dir = get_project_dir
|
||
g.get_project_dir_pl = get_project_dir_pl
|
||
g.generic_workspace_dir = generic_workspace_dir
|
||
g.get_project_dir_by_id = get_project_dir_by_id
|
||
g.copy_uploads_to_project = copy_uploads_to_project
|
||
g.get_project_apps_modules = get_project_apps_modules
|
||
g.get_session_project_id = get_session_project_id
|
||
g.get_session_context = get_session_context
|
||
g.build_workspace_path = build_workspace_path
|
||
g.build_space_path = build_space_path
|
||
g.resolve_workspace_path = resolve_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
|