Compare commits

...
6 Commits
Author SHA1 Message Date
hz4th_coder 1f37344d03 V3.1 自定义角色 + Worker 权限组
角色管理(管理员可编辑角色权限功能):
- 18 个权限功能点按模块分组(仪表盘/项目/Worker/协作/评估/模板/报表/日志/告警/API/设置/企业版)
- 内置角色(admin/auditor/member)权限点可编辑、不可删除;自定义角色任意创建/删除/编辑
- 用户可绑定多个自定义角色,权限自动合并(内置基础 ∪ 自定义),即时生效无需重启
- 前端侧边栏按有效权限点自动显隐;未授权模块 API 返回 403

Worker 权限组(批量授权):
- 把一批 Worker+权限(查看/使用/管理)打包成组,整组授权给用户
- 与直接授权并行生效取高值;组内 Worker 自动对绑定用户可见
- 企业版用户与权限新增三个子页:用户列表/角色管理/Worker 权限组

新表:roles / user_roles / worker_perm_groups / worker_perm_group_members / user_worker_groups
2026-08-13 13:03:57 +08:00
hz4th_coder eb92a4e591 V3.0 交付体系 + 精准权限系统
交付环节优化:
- 新建项目必填送达者(人)邮箱,项目完成/遇到无法绕开的难关时自动邮件及时通知
- 每个项目独立工作目录 data/workspace/project_<id>/,交付物分类存放互不污染(上传/下载/删除)
- 网页交付物一键部署到 /demo/<id>/ 免登录公开 Demo,送达者直接打开链接查看
- 非网页交付物 zip 打包 data/packages/,随邮件附件发送送达者(含手动交付/完成交付/自动交付)
- 任务全部完成自动收尾交付;失败任务触发难关通知(30分钟限频去重)

用户(人)管理 + 精准权限:
- 管理员增删改用户,管理用户项目所属与 Worker 权限(授权弹窗 + 授权总览矩阵)
- 角色体系:admin 全部 / auditor 全量只读 / member 按授权
- 项目授权 view/manage/admin;Worker 授权 view/use/manage;创建者自动成为项目管理员
- 仪表盘/成本报表/日志/协作/评估全部按权限过滤,越权访问 403

新表:project_deliverables / user_projects / user_workers;新模块 delivery.py
2026-08-13 12:38:38 +08:00
hz4th_coder 78ccca61c3 DAG画布连线编辑:新建/重连/删除 + 环检测
- 连线操作三件套:
  * 新建:拖节点右侧输出口到目标节点(幽灵线预览 + 合法/非法高亮)
  * 重连:单击连线选中 → 操作条「🔗重连」→ 点击新目标节点(自动移除旧边)
  * 删除:双击连线 / 悬停中点✕按钮 / 选中后 Delete 键 / 操作条删除按钮
- 单击连线选中高亮 + 顶部操作条(重连/删除/取消),点击空白取消选中
- 前端防环校验(DFS)+ 非法目标标红,后端 PUT/POST depends_on 全面校验:
  依赖存在性、非自依赖、全图环检测(返回环路径)
- 节点新增左右连接端口(悬停/连线模式显示);拖节点时连线与端口同步更新
- 修复:控件条点击不再误触发取消选中/视图复位
2026-08-13 11:31:40 +08:00
hz4th_coder 3cbe3a5996 修复:DAG 画布 tab 切换后节点拖不动(多层根因)
根因链:
1. mousemove handler 更新 rect 用闭包 s.pos(旧 dagState 对象),切回 tab 后 renderDag 重建 dagState,s 指向旧对象 → 节点被设回旧布局值 → 视觉上拖不动 → 改为 handler 内现查的 st.pos
2. overflow:visible 让节点绘制到 svg 元素盒外,盒外内容看得见但命中测试不到 → svg 盒加 ±3000 边距(DAG_M)+viewBox 同步扩大,节点永远可命中
3. 保存的越界节点位置(y=620>画布560)使 fit 看不到 → dagFitView 改为基于节点实际包围盒计算
4. 打开时仅'任一节点可见'就保留旧视图 → 改为'全部节点可见',任一越界自动适配

实测:刷新进入/SPA切看板切回/连续切换3次后,真实鼠标拖动均精确跟随;缩放显示/100%按钮/删除区正常
2026-08-13 00:25:01 +08:00
hz4th_coder dfdcd78f1a DAG画布:节点拖不动根因修复 + 缩放比例显示/一键100%
- 根因:dagLayout 画布宽(1100-1360px)超出视口可用宽度(约1060px),加上保存的偏移视图可能把节点推到视口外 → 节点不可见/不可点,表现为'拖不动'
- 修复:打开画布时若无保存视图、或节点全部在视口外,自动适配缩放(≤100%)并居中,全部节点始终可见可拖
- 新增右上角缩放控件:实时显示缩放百分比(滚轮/适配/拖动联动) + ⤢适配按钮 + 100%一键回原始视图
- 实测:真实鼠标拖动在 0.82x 缩放下精确跟随;滚轮 112%→100% 显示联动
2026-08-12 23:55:57 +08:00
hz4th_coder 9c45a143b9 DAG画布:文字清晰 + 无边画布 + 删除区回收站
1. 文字看不清修复:.dag-node hover stroke 继承到文字导致白色小字被描边盖糊 → label/sub 加 stroke:none,字号加大(13/11px)加粗
2. 无边画布:bg rect 扩大至±4000且与画布同色 + svg overflow:visible,大幅平移缩放不再露出边缘/裁剪节点
3. 删除区与回收站:
- 画布左下角🗑️删除区,节点拖入即软删除(deleted=1)
- 点击删除区打开回收站:恢复/彻底删除
- 后端 tasks 加 deleted/deleted_at 列,全查询过滤,trash/restore/hard-delete API,看板删除改软删
- 修复画布重建后事件失效:box监听每次重绘重绑、window监听只绑一次且拖拽状态提升为模块级(dagDrag)
2026-08-12 23:44:20 +08:00
10 changed files with 2804 additions and 201 deletions
+43 -8
View File
@@ -3,7 +3,7 @@
> 以「项目」为中心、以「AI Worker」为执行单元的项目管理平台。
> 把大模型团队变成一支可指挥、可审计、可控成本的"虚拟团队"。
**当前版本:V2.0**(多 Agent 协作 / 自动评估 / 模板市场 / 企业版)
**当前版本:V3.1**自定义角色 / Worker 权限组 / 交付体系 / 精准权限 / 多 Agent 协作 / 企业版)
---
@@ -49,6 +49,31 @@
| ⚖️ 合规 | 全量数据导出(JSON)、审计 CSV 导出、数据保留期清理、PII 脱敏(邮箱/手机/身份证)、数据使用同意凭证 |
| 🔒 私有化 | 单机 SQLite + 无 CDN 前端,完全离线可用;Docker 一键部署(见下) |
### 📦 交付体系(V3 新增)
| 能力 | 说明 |
|---|---|
| 📬 送达者邮箱 | 新建项目**必填**送达者(人)邮箱;项目**完成**或遇到**无法绕开的难关**时自动邮件及时通知 |
| 🗂️ 项目工作目录 | 每个项目独立工作目录 `data/workspace/project_<id>/`,中间产物/交付物分类存放,互不污染;支持网页上传/下载/删除 |
| 🌐 网页交付物 | 一键部署到 `data/demo/<id>/`,经 `/demo/<id>/` **免登录公开访问**,送达者直接打开链接查看 |
| 🗜️ 打包交付 | 工作目录一键 zip 打包(`data/packages/`),随邮件附件发送给送达者 |
| ✉️ 手动通知 | 难关说明/进展可随时手动邮件通知送达者;交付全流程留痕(交付记录) |
### 🔑 精准权限(V3 新增)
| 能力 | 说明 |
|---|---|
| 👥 用户管理 | 管理员增删改用户,管理用户的项目所属与 Worker 权限(企业版 → 用户与权限 → 🔑 授权) |
| 📁 项目授权 | view 查看 / manage 管理(建任务/执行/上传交付物/发送)/ admin 管理员;创建者自动成为项目管理员 |
| 🤖 Worker 授权 | view 查看档案 / use 使用(可指派任务)/ manage 管理;Worker 的注册/删除仍仅管理员 |
| 🎭 角色体系 | 管理员=全部;审计员=全量**只读**;成员=仅可见被授权内容,仪表盘/报表/日志/协作/评估全部按权限过滤 |
| 🗺️ 授权总览 | 一键查看所有用户的 项目×Worker 授权矩阵,杜绝越权 |
### 🎭 自定义角色 + 🤖 Worker 权限组(V3.1 新增)
| 能力 | 说明 |
|---|---|
| 🎭 角色管理 | 18 个权限功能点按模块分组勾选(仪表盘/项目/Worker/协作/评估/模板/报表/日志/告警/API/设置/企业版);**内置角色可编辑权限点**、自定义角色任意创建/删除/多用户绑定,权限自动合并即时生效 |
| 🤖 Worker 权限组 | 把一批 Worker + 权限(查看/使用/管理)打包成组,整组授权给一个或多个用户;与直接授权并行生效取高值;组内 Worker 自动对绑定用户可见,批量管理不再逐条授权 |
| 📱 权限驱动菜单 | 前端侧边栏按用户有效权限点自动显隐;未授权模块 API 返回 403(如无 agent.run 无法发起协作运行) |
## 快速开始
```bash
@@ -84,22 +109,23 @@ docker run -d --name aiworker -p 16071:16071 \
```
ai-worker-platform/
├── app.py # Flask 应用 + REST APIV1 + V2 路由)
├── app.py # Flask 应用 + REST APIV1 + V2 + V3 路由)
├── config.py # 供应商/定价/鉴权/告警阈值/邮件 SMTP
├── db.py # SQLite 数据层 + V2 迁移(users/audit/agent/eval/templates
├── engine.py # V1 执行引擎:DAG/自动触发/RAG 注入/告警
├── db.py # SQLite 数据层 + V2/V3 迁移(users/audit/agent/eval/templates/deliverables/grants
├── engine.py # V1 执行引擎:DAG/自动触发/RAG 注入/告警/难关通知
├── agents.py # V2 多 Agent 协作引擎:supervisor/review/debate
├── eval.py # V2 自动评估:数据集/LLM 评委/沉淀/排行榜
├── templates.py # V2 模板市场:三类模板 + 占位符渲染 + 应用
├── enterprise.py # V2 企业版:用户/RBAC/OIDC/LDAP/审计/合规
├── enterprise.py # V2/V3 企业版:用户/RBAC/SSO/审计/合规 + 项目/Worker 精准授权 + 自定义角色 + Worker 权限组
├── delivery.py # V3 交付体系:工作目录/Demo 部署/打包/邮件送达/难关通知
├── llm_gateway.py # 统一模型网关
├── rag.py # 知识库:分块 + BM25 检索
├── notify.py # 飞书/企微/邮件通知
├── dag_verify.py # DAG 全链路验证脚本
├── seed.py # 演示数据
├── start.sh # 启停脚本
├── static/ # 前端 SPA(含 V2 四页
└── data/ # SQLite 库;logs/ 运行日志
├── static/ # 前端 SPA(含 V3 交付页/授权管理
└── data/ # SQLite 库;workspace/ 项目工作目录;demo/ 网页Demopackages/ 打包件;logs/ 运行日志
```
## V2 开放 API 摘要
@@ -120,12 +146,21 @@ ai-worker-platform/
企业版:
- `GET/POST /api/enterprise/users`(管理员)| `PUT/DELETE /api/enterprise/users/<id>`
- `GET/PUT /api/enterprise/users/<id>/grants` 精准授权(项目/Worker/角色/权限组)| `GET /api/enterprise/grants/overview` 授权总览
- `GET /api/enterprise/perm_points` 权限点目录 `GET/POST /api/enterprise/roles` 角色 `GET/PUT/DELETE /api/enterprise/roles/<id>`
- `GET/POST /api/enterprise/worker_groups` Worker 权限组 `GET/PUT/DELETE /api/enterprise/worker_groups/<id>`
- `GET/PUT /api/enterprise/sso` SSO 配置 `POST /api/enterprise/sso/oidc/login` 发起 OIDC `GET /api/enterprise/sso/oidc/callback` 回调
- `POST /api/enterprise/sso/ldap/test` LDAP 连通测试
- `GET /api/enterprise/audit` 审计日志 `GET /api/enterprise/audit/export` CSV 导出
- `GET /api/enterprise/export` 全量数据导出(JSON)| `POST /api/enterprise/compliance` 保留期/脱敏设置
交付体系(V3):
- `GET /api/projects/<pid>/workspace` 工作目录文件列表 `POST .../workspace/upload` 上传(multipart)| `GET .../workspace/download?path=` 下载 `DELETE .../workspace?path=` 删除
- `POST /api/projects/<pid>/deploy` 网页交付物部署 Demo `POST .../package` zip 打包 `POST .../deliver` 邮件交付(Demo 链接 + 附件)
- `POST /api/projects/<pid>/complete` 完成项目并交付 `POST .../notify_deliverer` 手动通知送达者 `GET /api/projects/<pid>/deliverables` 交付记录
- `GET /demo/<pid>/` 公开 Demo 地址(送达者免登录访问)
## 路线图
- **V2.1**Temporal 持久执行、多 Agent 协作接入项目任务、eval 回归对比视图
- **V3**:多租户 SaaS 化、工作流画布(拖拽编排)、插件市场
- **V3**:多租户 SaaS 化、工作流画布(拖拽编排)、插件市场 ✅ 交付体系 + 精准权限(V3.0)
+928 -39
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -98,5 +98,8 @@ EMAIL = {
'from_name': 'AI Worker 平台',
}
# 公网访问地址(Demo 链接/邮件中的回链基准;留空则用请求 host)
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://121.40.164.32:16071')
# 自动路由:按模型单价升序挑选可用 Worker
AUTO_ROUTE_POOL = 'enabled' # enabled | all
+146
View File
@@ -16,6 +16,12 @@ CREATE TABLE IF NOT EXISTS projects (
acceptance_criteria TEXT DEFAULT '',
status TEXT DEFAULT 'active', -- planning/active/done/archived
budget_limit REAL DEFAULT 0, -- 项目预算上限(元),0=不限
deliver_email TEXT DEFAULT '', -- 送达者(人)邮箱,新建项目必填
deliver_type TEXT DEFAULT 'web', -- 交付物类型 web=网页 / file=文件包
deliver_note TEXT DEFAULT '', -- 交付说明
workspace_dir TEXT DEFAULT '', -- 项目工作目录(相对 data/ 的目录名)
demo_url TEXT DEFAULT '', -- 网页交付物 Demo 访问地址
delivered_at INTEGER, -- 最近一次交付/送达时间
created_at INTEGER,
updated_at INTEGER
);
@@ -258,6 +264,93 @@ CREATE TABLE IF NOT EXISTS enterprise_settings (
value TEXT DEFAULT ''
);
-- ===================================================================
-- V3 表结构:交付体系(工作目录/交付物/Demo/邮件送达) + 用户授权(项目/Worker 权限)
-- ===================================================================
CREATE TABLE IF NOT EXISTS project_deliverables (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
name TEXT NOT NULL,
kind TEXT DEFAULT 'file', -- file/dir/webpage/package
path TEXT DEFAULT '', -- 相对项目工作目录路径 / 打包文件名
demo_url TEXT DEFAULT '', -- 网页交付物的 Demo 访问地址
size INTEGER DEFAULT 0,
note TEXT DEFAULT '',
created_at INTEGER
);
CREATE TABLE IF NOT EXISTS user_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
perm TEXT DEFAULT 'view', -- view 查看 / manage 管理 / admin 管理员
created_at INTEGER,
UNIQUE(user_id, project_id)
);
CREATE TABLE IF NOT EXISTS user_workers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
worker_id INTEGER NOT NULL,
perm TEXT DEFAULT 'view', -- view 查看 / use 使用(可指派任务)/ manage 管理(可改配置)
created_at INTEGER,
UNIQUE(user_id, worker_id)
);
-- ===================================================================
-- V3.1 表结构:自定义角色(权限功能点)+ Worker 权限组(批量授权)
-- ===================================================================
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT DEFAULT '',
perms TEXT DEFAULT '[]', -- JSON: 权限点 id 列表
is_builtin INTEGER DEFAULT 0, -- 内置角色(admin/auditor/member)不可删除
created_at INTEGER,
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS user_roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
role_id INTEGER NOT NULL,
created_at INTEGER,
UNIQUE(user_id, role_id)
);
CREATE TABLE IF NOT EXISTS worker_perm_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT DEFAULT '',
created_at INTEGER,
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS worker_perm_group_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
worker_id INTEGER NOT NULL,
perm TEXT DEFAULT 'view', -- 组内该 Worker 权限(可覆盖组默认)
created_at INTEGER,
UNIQUE(group_id, worker_id)
);
CREATE TABLE IF NOT EXISTS user_worker_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
created_at INTEGER,
UNIQUE(user_id, group_id)
);
CREATE INDEX IF NOT EXISTS idx_deliverables_project ON project_deliverables(project_id);
CREATE INDEX IF NOT EXISTS idx_user_projects_user ON user_projects(user_id);
CREATE INDEX IF NOT EXISTS idx_user_projects_project ON user_projects(project_id);
CREATE INDEX IF NOT EXISTS idx_user_workers_user ON user_workers(user_id);
CREATE INDEX IF NOT EXISTS idx_user_workers_worker ON user_workers(worker_id);
CREATE INDEX IF NOT EXISTS idx_user_roles_user ON user_roles(user_id);
CREATE INDEX IF NOT EXISTS idx_wpg_members_group ON worker_perm_group_members(group_id);
CREATE INDEX IF NOT EXISTS idx_uwg_user ON user_worker_groups(user_id);
CREATE INDEX IF NOT EXISTS idx_agent_steps_run ON agent_steps(run_id);
CREATE INDEX IF NOT EXISTS idx_agent_runs_status ON agent_runs(status);
CREATE INDEX IF NOT EXISTS idx_eval_cases_ds ON eval_cases(dataset_id);
@@ -282,6 +375,26 @@ def _migrate():
if 'depends_on' not in cols:
conn.execute("ALTER TABLE tasks ADD COLUMN depends_on TEXT DEFAULT '[]'")
conn.execute('CREATE INDEX IF NOT EXISTS idx_tasks_depends ON tasks(depends_on)')
if 'deleted' not in cols:
conn.execute('ALTER TABLE tasks ADD COLUMN deleted INTEGER DEFAULT 0')
conn.execute('ALTER TABLE tasks ADD COLUMN deleted_at INTEGER')
conn.execute('CREATE INDEX IF NOT EXISTS idx_tasks_deleted ON tasks(deleted)')
# V3projects 交付字段
pcols = {r['name'] for r in conn.execute('PRAGMA table_info(projects)')}
for col, ddl in (
('deliver_email', "ALTER TABLE projects ADD COLUMN deliver_email TEXT DEFAULT ''"),
('deliver_type', "ALTER TABLE projects ADD COLUMN deliver_type TEXT DEFAULT 'web'"),
('deliver_note', "ALTER TABLE projects ADD COLUMN deliver_note TEXT DEFAULT ''"),
('workspace_dir', "ALTER TABLE projects ADD COLUMN workspace_dir TEXT DEFAULT ''"),
('demo_url', "ALTER TABLE projects ADD COLUMN demo_url TEXT DEFAULT ''"),
('delivered_at', 'ALTER TABLE projects ADD COLUMN delivered_at INTEGER'),
):
if col not in pcols:
conn.execute(ddl)
# V3:老项目补齐工作目录名
for r in conn.execute("SELECT id, workspace_dir FROM projects WHERE workspace_dir IS NULL OR workspace_dir=''"):
conn.execute('UPDATE projects SET workspace_dir=? WHERE id=?',
('project_%d' % r['id'], r['id']))
conn.commit()
conn.close()
@@ -337,6 +450,39 @@ def init_db():
conn.close()
_migrate()
migrate_v2()
seed_builtin_roles()
# V3.1 内置角色权限点定义(admin 为特殊值 ALL,表示全部权限)
BUILTIN_ROLE_PERMS = {
'admin': ['*'],
# 审计员:全量只读
'auditor': ['dashboard.view', 'project.view', 'worker.view', 'agent.view', 'eval.view',
'template.view', 'report.view', 'log.view', 'alert.view'],
# 成员:基础功能(资源级仍受项目/Worker 授权约束)
'member': ['dashboard.view', 'project.view', 'project.create', 'project.deliver',
'worker.view', 'agent.view', 'agent.run', 'eval.view', 'eval.run',
'template.view', 'report.view', 'log.view', 'alert.view'],
}
def seed_builtin_roles():
"""幂等:仅首次创建 admin/auditor/member 内置角色(不覆盖管理员后续编辑)"""
import json as _json
conn = get_conn()
try:
for name, perms in BUILTIN_ROLE_PERMS.items():
r = conn.execute('SELECT id FROM roles WHERE name=?', (name,)).fetchone()
if not r:
conn.execute(
'INSERT INTO roles (name, description, perms, is_builtin, created_at, updated_at) '
'VALUES (?,?,?,1,?,?)',
(name, {'admin': '超级管理员:全部权限', 'auditor': '审计员:全量只读',
'member': '成员:基础功能,资源级按项目/Worker 授权'}[name],
_json.dumps(perms), now(), now()))
conn.commit()
finally:
conn.close()
def recover_stale_runs():
+369
View File
@@ -0,0 +1,369 @@
# -*- coding: utf-8 -*-
"""
V3 交付体系
- 每个项目独立工作目录:data/workspace/project_<id>/(中间产物与交付物隔离存放)
- 网页交付物 → data/demo/<id>/ 部署,经 /demo/<id>/ 公开访问(送达者无需登录)
- 文件包交付物 → zip 打包到 data/packages/,随邮件附件发送
- 送达者通知:项目完成 / 遇到无法绕开的难关时,邮件及时通知 deliver_email
"""
import os
import re
import time
import shutil
import zipfile
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from email.utils import formataddr
import db
from config import DATA_DIR, EMAIL, PUBLIC_BASE_URL
WORKSPACE_ROOT = os.path.join(DATA_DIR, 'workspace')
DEMO_ROOT = os.path.join(DATA_DIR, 'demo')
PACKAGE_ROOT = os.path.join(DATA_DIR, 'packages')
# 项目维度 blocker 通知去重窗口(秒):同一项目短时间内不重复打扰送达者
BLOCKER_DEDUP_SECONDS = 1800
def ensure_dirs():
for d in (WORKSPACE_ROOT, DEMO_ROOT, PACKAGE_ROOT):
os.makedirs(d, exist_ok=True)
def workspace_path(project):
"""项目工作目录绝对路径(不存在则创建)"""
pid = project['id'] if isinstance(project, dict) else project
d = os.path.join(WORKSPACE_ROOT, f'project_{pid}')
os.makedirs(d, exist_ok=True)
return d
def demo_path(project):
"""Demo 部署目录绝对路径"""
pid = project['id'] if isinstance(project, dict) else project
return os.path.join(DEMO_ROOT, f'project_{pid}')
def package_dir():
os.makedirs(PACKAGE_ROOT, exist_ok=True)
return PACKAGE_ROOT
def _safe_relpath(relpath):
"""路径穿越防护:仅允许工作目录内的相对路径"""
relpath = (relpath or '').replace('\\', '/').strip('/')
if not relpath:
return ''
if '..' in relpath.split('/') or relpath.startswith('/'):
raise ValueError('非法路径')
return relpath
def list_workspace(project):
"""递归列出工作目录文件:相对路径 + 类型 + 大小 + 修改时间"""
root = workspace_path(project)
out = []
for dirpath, dirnames, filenames in os.walk(root):
# 忽略临时目录
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
for fn in sorted(filenames):
if fn.startswith('.'):
continue
full = os.path.join(dirpath, fn)
rel = os.path.relpath(full, root).replace(os.sep, '/')
try:
size = os.path.getsize(full)
mtime = int(os.path.getmtime(full))
except OSError:
size, mtime = 0, 0
out.append({'path': rel, 'name': fn, 'size': size,
'ext': os.path.splitext(fn)[1].lstrip('.').lower(),
'mtime': mtime})
out.sort(key=lambda x: x['path'])
return out
def save_upload(project, file_storage, subdir=''):
"""保存上传文件到工作目录,返回相对路径"""
fn = os.path.basename(file_storage.filename or '')
fn = re.sub(r'[\\/:*?"<>|]', '_', fn).strip()
if not fn:
raise ValueError('文件名为空')
rel = _safe_relpath(subdir)
target_dir = os.path.join(workspace_path(project), rel) if rel else workspace_path(project)
os.makedirs(target_dir, exist_ok=True)
target = os.path.join(target_dir, fn)
file_storage.save(target)
return (rel + '/' if rel else '') + fn
def delete_workspace_file(project, relpath):
rel = _safe_relpath(relpath)
if not rel:
raise ValueError('请指定要删除的文件')
full = os.path.join(workspace_path(project), rel)
if not os.path.isfile(full):
raise ValueError('文件不存在')
os.remove(full)
return rel
def demo_url_of(project, base_url=''):
"""生成 Demo 访问地址"""
base = (base_url or PUBLIC_BASE_URL).rstrip('/')
return f'{base}/demo/{project["id"]}/'
def deploy_demo(project, base_url=''):
"""把项目工作目录部署为可公开访问的 Demo(网页交付物)
- 将工作目录文件复制到 data/demo/project_<id>/
- 无 index.html 时生成一个简易索引页
- 记录 demo_url 到项目
"""
src = workspace_path(project)
dst = demo_path(project)
os.makedirs(dst, exist_ok=True)
# 清空旧内容,避免残留文件污染
for item in os.listdir(dst):
p = os.path.join(dst, item)
if os.path.isdir(p):
shutil.rmtree(p, ignore_errors=True)
else:
os.remove(p)
copied = 0
for dirpath, dirnames, filenames in os.walk(src):
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
rel = os.path.relpath(dirpath, src)
if rel == '.':
rel = ''
for fn in filenames:
if fn.startswith('.') or fn.endswith('.zip'):
continue
sub = os.path.join(dst, rel) if rel else dst
os.makedirs(sub, exist_ok=True)
shutil.copy2(os.path.join(dirpath, fn), os.path.join(sub, fn))
copied += 1
index = os.path.join(dst, 'index.html')
if not os.path.isfile(index):
files = sorted(list_workspace(project), key=lambda x: x['path'])
links = '\n'.join(
f'<li><a href="{os.path.basename(f["path"])}">{os.path.basename(f["path"])}</a>'
f' <small>({f["size"]} B)</small></li>'
for f in files if f['ext'] in ('html', 'htm') or '/' not in f['path'])
if not links:
links = '<li>(工作目录中暂无网页文件)</li>'
with open(index, 'w', encoding='utf-8') as fh:
fh.write(f'''<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8">
<title>{project['name']} · Demo</title>
<style>body{{font-family:system-ui;max-width:720px;margin:40px auto;padding:0 16px;color:#333}}
h1{{font-size:20px}} li{{margin:8px 0}} a{{color:#2f6fed}}</style></head>
<body><h1>📦 {project['name']} · 交付 Demo</h1>
<p>本页面由 AI Worker 平台自动生成,展示项目工作目录中的交付文件:</p>
<ul>{links}</ul></body></html>''')
url = demo_url_of(project, base_url)
db.w('UPDATE projects SET demo_url=?, updated_at=? WHERE id=?', (url, db.now(), project['id']))
return {'copied': copied, 'demo_url': url}
def package_project(project, name=''):
"""把项目工作目录打包为 zip,落盘到 data/packages/,返回 {path, size, relname}"""
ensure_dirs()
src = workspace_path(project)
ts = time.strftime('%Y%m%d_%H%M%S')
base = name or f'project_{project["id"]}_deliverable'
zip_name = f'{base}_{ts}.zip'
zip_path = os.path.join(PACKAGE_ROOT, zip_name)
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for dirpath, dirnames, filenames in os.walk(src):
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
for fn in filenames:
if fn.startswith('.'):
continue
full = os.path.join(dirpath, fn)
rel = os.path.relpath(full, src)
zf.write(full, os.path.join(os.path.basename(src), rel))
size = os.path.getsize(zip_path)
db.w('INSERT INTO project_deliverables (project_id, name, kind, path, size, note, created_at) '
'VALUES (?,?,?,?,?,?,?)',
(project['id'], zip_name, 'package', zip_name, size,
'交付物打包(zip', db.now()))
return {'path': zip_path, 'size': size, 'name': zip_name}
def record_file_deliverables(project, files):
"""把工作目录文件登记为交付物记录"""
for f in files:
db.w('INSERT INTO project_deliverables (project_id, name, kind, path, size, note, created_at) '
'VALUES (?,?,?,?,?,?,?)',
(project['id'], f['name'], 'file', f['path'], f['size'], '工作目录交付物', db.now()))
def auto_complete_if_ready(project_id, base_url=''):
"""项目全部任务完成后自动收尾:置 done + 打包 + 通知送达者。
返回 {'ok','msg'} 或 None(未满足条件/异常)。引擎线程与审核接口共用。"""
try:
proj = db.q('SELECT * FROM projects WHERE id=?', (project_id,), one=True)
if not proj or proj['status'] == 'done':
return None
total = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND deleted=0', (project_id,))[0]['c']
done = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND status="done" AND deleted=0',
(project_id,))[0]['c']
if total == 0 or done < total:
return None
db.w('UPDATE projects SET status="done", updated_at=? WHERE id=?', (db.now(), project_id))
ok, msg = notify_project_complete(proj, base_url)
return {'ok': ok, 'msg': msg}
except Exception:
return None
def project_summary(project):
"""项目交付摘要:任务统计 + 成本"""
rows = db.q('SELECT status, COUNT(*) c FROM tasks WHERE project_id=? AND deleted=0 GROUP BY status',
(project['id'],))
by = {r['status']: r['c'] for r in rows}
total = sum(by.values())
done = by.get('done', 0)
cost = db.q('SELECT COALESCE(SUM(cost),0) t FROM cost_records WHERE project_id=?',
(project['id'],))[0]['t']
return {'total': total, 'done': done, 'failed': by.get('failed', 0),
'review': by.get('review', 0), 'cost': round(cost, 4)}
# ---------------------------------------------------------------------------
# 邮件发送(支持附件)
# ---------------------------------------------------------------------------
def send_mail(to_addr, subject, text, attachments=None, html=None):
"""发送邮件到任意收件人(送达者),支持附件。返回 (ok, msg)"""
if not EMAIL.get('host'):
return False, '邮件服务未配置(config.EMAIL.host 为空)'
if not to_addr:
return False, '收件邮箱为空'
msg = MIMEMultipart()
msg['From'] = formataddr((EMAIL.get('from_name', 'AI Worker 平台'), EMAIL['user']))
msg['To'] = to_addr
msg['Subject'] = subject
if html:
msg.attach(MIMEText(html, 'html', 'utf-8'))
else:
msg.attach(MIMEText(text, 'plain', 'utf-8'))
for f in (attachments or []):
if not f or not os.path.isfile(f):
continue
with open(f, 'rb') as fh:
subtype = os.path.splitext(f)[1].lstrip('.').lower() or 'octet-stream'
part = MIMEApplication(fh.read(), _subtype=subtype)
part.add_header('Content-Disposition', 'attachment',
filename=('utf-8', '', os.path.basename(f)))
msg.attach(part)
try:
s = smtplib.SMTP(EMAIL['host'], EMAIL['port'], timeout=30)
if EMAIL.get('starttls'):
s.starttls()
if EMAIL.get('user'):
s.login(EMAIL['user'], EMAIL['password'])
s.sendmail(EMAIL['user'], [to_addr], msg.as_string())
s.quit()
return True, '已发送'
except Exception as e:
return False, f'邮件发送失败: {e}'
def _email_body(project, extra=''):
p = project
lines = [
f'项目名称:{p["name"]}',
f'项目状态:{ {"planning":"规划中","active":"进行中","done":"已完成","archived":"已归档"}.get(p["status"], p["status"]) }',
f'项目目标:{p.get("objective") or ""}',
]
if p.get('demo_url'):
lines.append(f'在线 Demo(可直接打开查看):{p["demo_url"]}')
if extra:
lines.append('')
lines.append(extra)
lines.append('')
lines.append('—— 来自 AI Worker 项目管理平台')
return '\n'.join(lines)
def notify_deliverer(project, subject, text, attach=None, base_url=''):
"""发邮件给送达者,写交付记录。返回 (ok, msg)"""
email = (project.get('deliver_email') or '').strip()
if not email:
return False, '项目未配置送达者邮箱'
ok, msg = send_mail(email, subject, text, attachments=[attach] if attach else None)
if ok:
db.w('UPDATE projects SET delivered_at=? WHERE id=?', (db.now(), project['id']))
return ok, msg
def notify_blocker(project, task_title, detail):
"""项目遇到无法绕开的难关 → 及时邮件通知送达者(同项目限频防打扰)"""
email = (project.get('deliver_email') or '').strip()
if not email:
return
# 去重:同项目 30 分钟内只提醒一次(detail 带项目标记)
dup = db.q("SELECT COUNT(*) c FROM alerts WHERE type='blocker' AND detail LIKE ? AND created_at>?",
(f'[project:{project["id"]}]%', db.now() - BLOCKER_DEDUP_SECONDS))
if dup and dup[0]['c'] > 0:
return
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
"VALUES ('blocker','warn',?,?,0,?)",
(f'项目难关:{project["name"]} · {task_title}',
f'[project:{project["id"]}] {detail[:500]}', db.now()))
subject = f'⚠️ 项目遇到难关:{project["name"]}'
body = _email_body(project, extra=f'任务「{task_title}」遇到无法绕开的难关:\n{detail[:800]}')
ok, msg = send_mail(email, subject, body)
if ok:
db.w('UPDATE projects SET delivered_at=? WHERE id=?', (db.now(), project['id']))
else:
# 邮件失败也留痕
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
"VALUES ('notify','warn',?,?,0,?)",
(f'难关通知邮件发送失败:{project["name"]}', msg[:300], db.now()))
def notify_project_complete(project, base_url=''):
"""项目完成 → 打包 + 邮件送达(含 Demo 链接与附件)。返回 (ok, msg)"""
email = (project.get('deliver_email') or '').strip()
if not email:
return False, '项目未配置送达者邮箱'
# 网页交付物:确保已部署 Demo
if project.get('deliver_type') == 'web' and not project.get('demo_url'):
try:
deploy_demo(project, base_url)
project = db.q('SELECT * FROM projects WHERE id=?', (project['id'],), one=True)
except Exception as e:
pass
# 打包工作目录
attach = None
try:
pkg = package_project(project)
attach = pkg['path']
except Exception as e:
pkg = None
s = project_summary(project)
extra = (f'项目已完成 ✅\n任务完成情况:{s["done"]}/{s["total"]}(失败 {s["failed"]}\n'
f'累计成本:¥{s["cost"]:.4f}\n交付物打包:{"已生成附件(见邮件附件)" if attach else "无工作目录文件"}')
subject = f'✅ 项目完成交付:{project["name"]}'
body = _email_body(project, extra=extra)
ok, msg = send_mail(email, subject, body, attachments=[attach] if attach else None)
if ok:
db.w('UPDATE projects SET delivered_at=? WHERE id=?', (db.now(), project['id']))
db.w('INSERT INTO project_deliverables (project_id, name, kind, path, demo_url, size, note, created_at) '
'VALUES (?,?,?,?,?,?,?,?)',
(project['id'], f'完成交付邮件 → {email}', 'email',
project.get('demo_url') or '', project.get('demo_url') or '',
pkg['size'] if pkg else 0, '项目完成通知(含附件)', db.now()))
else:
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
"VALUES ('notify','warn',?,?,0,?)",
(f'完成交付邮件失败:{project["name"]}', msg[:300], db.now()))
return ok, msg
ensure_dirs()
+28 -17
View File
@@ -14,6 +14,20 @@ import llm_gateway
import config
import rag
import notify
import delivery
def _notify_failed(task, message):
"""任务失败:写告警 + 推送渠道 + 邮件通知送达者(项目难关)"""
notify.notify('task_failed', f'任务失败:{task["title"]}',
f'项目 #{task["project_id"]} 任务「{task["title"]}{message}',
save_alert=True, level='warn', atype='task_failed')
try:
proj = db.q('SELECT * FROM projects WHERE id=?', (task['project_id'],), one=True)
if proj and (proj.get('deliver_email') or '').strip():
delivery.notify_blocker(proj, task['title'], message)
except Exception:
pass
def _log(task_id, level, message):
@@ -153,7 +167,7 @@ def _build_messages(task, worker):
def _trigger_downstream(task):
"""DAG:任务完成后自动触发所有就绪的下游任务"""
rows = db.q('SELECT * FROM tasks WHERE status IN ("todo","failed")')
rows = db.q('SELECT * FROM tasks WHERE deleted=0 AND status IN ("todo","failed")')
triggered = []
for t in rows:
deps = _deps(t)
@@ -182,9 +196,7 @@ def run_task(task_id):
_set_task(task_id, status='failed', error='前置任务未完成:' + ''.join(blockers),
finished_at=db.now())
_log(task_id, 'error', '❌ 依赖未满足,无法执行:' + ''.join(blockers))
notify.notify('task_failed', f'任务失败:{task["title"]}',
f'项目 #{task["project_id"]} 任务「{task["title"]}」因依赖未完成被拒绝执行:'
+ ''.join(blockers), save_alert=True, level='warn', atype='task_failed')
_notify_failed(task, '因依赖未完成被拒绝执行:' + ''.join(blockers))
return
# 确定 Worker
@@ -195,9 +207,7 @@ def run_task(task_id):
_set_task(task_id, status='failed', error='指定 Worker 不存在或已停用',
finished_at=db.now())
_log(task_id, 'error', '指定 Worker 不存在或已停用')
notify.notify('worker_alert', f'Worker 异常:任务「{task["title"]}',
f'指定 Worker #{task["worker_id"]} 不存在或已停用', save_alert=True,
level='warn', atype='worker_alert')
_notify_failed(task, '指定 Worker 不存在或已停用,无法执行')
return
else:
worker = pick_worker_auto(task)
@@ -205,9 +215,7 @@ def run_task(task_id):
_set_task(task_id, status='failed', error='无可用 Worker(自动路由失败)',
finished_at=db.now())
_log(task_id, 'error', '自动路由失败:无可用 Worker')
notify.notify('worker_alert', f'Worker 异常:任务「{task["title"]}',
'自动路由失败:没有可用的 Worker', save_alert=True,
level='warn', atype='worker_alert')
_notify_failed(task, '自动路由失败:没有可用的 Worker')
return
_set_task(task_id, worker_id=worker['id'])
_log(task_id, 'info', f'自动路由 → Worker「{worker["name"]}」({worker["provider"]}/{worker["model"]}')
@@ -217,15 +225,13 @@ def run_task(task_id):
if not ok:
_set_task(task_id, status='failed', error=reason, finished_at=db.now())
_log(task_id, 'error', reason)
notify.notify('budget_alert', f'成本上限拦截:任务「{task["title"]}', reason,
save_alert=True, level='warn', atype='budget')
_notify_failed(task, reason)
return
ok, reason = _check_project_budget(task)
if not ok:
_set_task(task_id, status='failed', error=reason, finished_at=db.now())
_log(task_id, 'error', reason)
notify.notify('budget_alert', f'预算拦截:任务「{task["title"]}', reason,
save_alert=True, level='warn', atype='budget')
_notify_failed(task, reason)
return
_set_task(task_id, status='running', started_at=db.now(), error='')
@@ -239,9 +245,7 @@ def run_task(task_id):
except Exception as e:
_set_task(task_id, status='failed', error=str(e), finished_at=db.now())
_log(task_id, 'error', f'执行失败: {e}')
notify.notify('task_failed', f'任务失败{task["title"]}',
f'项目 #{task["project_id"]} 任务「{task["title"]}」执行出错:{str(e)[:300]}',
save_alert=True, level='warn', atype='task_failed')
_notify_failed(task, f'执行出错{str(e)[:300]}')
return
_cost_record(task, worker, usage)
@@ -272,6 +276,13 @@ def run_task(task_id):
_budget_alert(task['project_id'])
if new_status == 'done':
# V3:无需审核的任务直接完成后,检查项目是否全部完成 → 自动交付并通知送达者
try:
delivery.auto_complete_if_ready(task['project_id'])
except Exception:
pass
# DAG:触发下游就绪任务
downstream = _trigger_downstream(task)
for t in downstream:
+318 -2
View File
@@ -293,9 +293,10 @@ def export_all():
"""全量数据导出(JSON"""
tables = ['projects', 'workers', 'tasks', 'task_logs', 'cost_records', 'documents',
'agent_runs', 'agent_steps', 'eval_datasets', 'eval_cases', 'eval_runs',
'eval_results', 'templates', 'users', 'audit_logs']
'eval_results', 'templates', 'users', 'audit_logs',
'project_deliverables', 'user_projects', 'user_workers']
out = {'exported_at': time.strftime('%Y-%m-%d %H:%M:%S'),
'platform': 'ai-worker-platform', 'version': 'v2.0.0'}
'platform': 'ai-worker-platform', 'version': 'v3.1.0'}
for t in tables:
try:
out[t] = db.q(f'SELECT * FROM {t}')
@@ -337,3 +338,318 @@ def generate_consent_token():
tok = uuid.uuid4().hex[:12]
audit('system', 'compliance.consent', '数据使用同意', f'consent_token={tok}')
return tok
# ---------------------------------------------------------------------------
# V3 授权:用户 ↔ 项目 / 用户 ↔ Worker(精准权限)
# ---------------------------------------------------------------------------
PERM_LEVEL = {'view': 0, 'use': 1, 'manage': 2, 'admin': 3}
PROJECT_PERMS = ('view', 'manage', 'admin')
WORKER_PERMS = ('view', 'use', 'manage')
def perm_ok(have, need):
"""have 权限是否满足 need 权限(None 视为无权限)"""
if not have:
return False
return PERM_LEVEL.get(have, -1) >= PERM_LEVEL.get(need, 99)
def user_project_perm(user_id, project_id):
"""用户在项目上的权限:None / view / manage / admin"""
r = db.q('SELECT perm FROM user_projects WHERE user_id=? AND project_id=?',
(user_id, project_id), one=True)
return r['perm'] if r else None
def user_worker_perm(user_id, worker_id):
"""用户在 Worker 上的权限:None / view / use / manage(直接授权 ∪ 权限组授权,取高)"""
r = db.q('SELECT perm FROM user_workers WHERE user_id=? AND worker_id=?',
(user_id, worker_id), one=True)
direct = r['perm'] if r else None
grouped = grouped_worker_perm(user_id, worker_id)
if direct is None:
return grouped
if grouped is None:
return direct
return direct if PERM_LEVEL.get(direct, 0) >= PERM_LEVEL.get(grouped, 0) else grouped
def visible_project_ids(user_id):
"""用户可见的项目 id 列表(admin/auditor 返回 None 表示全部)"""
u = db.q('SELECT role FROM users WHERE id=?', (user_id,), one=True)
if u and u['role'] in ('admin', 'auditor'):
return None
rows = db.q('SELECT project_id FROM user_projects WHERE user_id=?', (user_id,))
return [r['project_id'] for r in rows]
def visible_worker_ids(user_id):
"""用户可见的 Worker id 列表 = 直接授权 权限组授权(admin/auditor 返回 None 表示全部)"""
u = db.q('SELECT role FROM users WHERE id=?', (user_id,), one=True)
if u and u['role'] in ('admin', 'auditor'):
return None
rows = db.q('SELECT worker_id FROM user_workers WHERE user_id=?', (user_id,))
ids = {r['worker_id'] for r in rows}
ids |= set(grouped_worker_ids(user_id))
return sorted(ids)
def set_user_grants(user_id, projects=None, workers=None, roles=None, worker_groups=None):
"""批量覆盖用户授权。projects=[{project_id, perm}], workers=[{worker_id, perm}],
roles=[role_id], worker_groups=[group_id]。perm 传空/None 表示收回该授权。"""
out = {'projects': 0, 'workers': 0, 'roles': 0, 'worker_groups': 0}
if projects is not None:
db.w('DELETE FROM user_projects WHERE user_id=?', (user_id,))
for g in projects:
perm = (g.get('perm') or '').strip()
if perm not in PROJECT_PERMS:
continue
pid = int(g.get('project_id') or 0)
if not db.q('SELECT id FROM projects WHERE id=?', (pid,), one=True):
continue
db.w('INSERT INTO user_projects (user_id, project_id, perm, created_at) VALUES (?,?,?,?)',
(user_id, pid, perm, db.now()))
out['projects'] += 1
if workers is not None:
db.w('DELETE FROM user_workers WHERE user_id=?', (user_id,))
for g in workers:
perm = (g.get('perm') or '').strip()
if perm not in WORKER_PERMS:
continue
wid = int(g.get('worker_id') or 0)
if not db.q('SELECT id FROM workers WHERE id=?', (wid,), one=True):
continue
db.w('INSERT INTO user_workers (user_id, worker_id, perm, created_at) VALUES (?,?,?,?)',
(user_id, wid, perm, db.now()))
out['workers'] += 1
if roles is not None:
set_user_roles(user_id, roles)
out['roles'] = len(roles or [])
if worker_groups is not None:
db.w('DELETE FROM user_worker_groups WHERE user_id=?', (user_id,))
for gid in (worker_groups or []):
if db.q('SELECT id FROM worker_perm_groups WHERE id=?', (int(gid),), one=True):
db.w('INSERT INTO user_worker_groups (user_id, group_id, created_at) VALUES (?,?,?)',
(user_id, int(gid), db.now()))
out['worker_groups'] += 1
return out
def user_grants(user_id):
"""用户现有授权 + 全部可选项目/Worker/角色/权限组,供管理界面展示"""
projects = db.q('SELECT p.id, p.name, p.status FROM projects p ORDER BY p.id DESC')
workers = db.q('SELECT id, name, provider, model, status FROM workers ORDER BY id DESC')
for p in projects:
p['perm'] = user_project_perm(user_id, p['id'])
for w in workers:
w['perm'] = user_worker_perm(user_id, w['id'])
roles = db.q('SELECT * FROM roles ORDER BY is_builtin DESC, id')
for r in roles:
r['perms'] = sorted(role_perms(r))
r['bound'] = r['id'] in user_role_ids(user_id)
groups = db.q('SELECT * FROM worker_perm_groups ORDER BY id')
bound_gids = {g['group_id'] for g in db.q('SELECT group_id FROM user_worker_groups WHERE user_id=?',
(user_id,))}
for g in groups:
g['member_count'] = db.q('SELECT COUNT(*) c FROM worker_perm_group_members WHERE group_id=?',
(g['id'],))[0]['c']
g['bound'] = g['id'] in bound_gids
return {'projects': projects, 'workers': workers, 'roles': roles, 'worker_groups': groups}
# ---------------------------------------------------------------------------
# V3.1 自定义角色(权限功能点)
# ---------------------------------------------------------------------------
PERM_POINTS = [
{'id': 'dashboard.view', 'name': '仪表盘', 'group': '总览'},
{'id': 'project.view', 'name': '项目查看', 'group': '项目'},
{'id': 'project.create', 'name': '创建项目', 'group': '项目'},
{'id': 'project.deliver', 'name': '交付操作(上传/部署/打包/发送/完成)', 'group': '项目'},
{'id': 'worker.view', 'name': 'Worker 查看', 'group': 'Worker'},
{'id': 'worker.create', 'name': 'Worker 创建', 'group': 'Worker'},
{'id': 'worker.manage', 'name': 'Worker 编辑/删除', 'group': 'Worker'},
{'id': 'agent.view', 'name': '多Agent协作-查看', 'group': '协作评估'},
{'id': 'agent.run', 'name': '多Agent协作-运行', 'group': '协作评估'},
{'id': 'eval.view', 'name': '自动评估-查看', 'group': '协作评估'},
{'id': 'eval.run', 'name': '自动评估-运行', 'group': '协作评估'},
{'id': 'template.view', 'name': '模板市场-查看/应用', 'group': '模板'},
{'id': 'template.manage', 'name': '模板市场-管理', 'group': '模板'},
{'id': 'report.view', 'name': '成本报表', 'group': '报表日志'},
{'id': 'log.view', 'name': '运行日志', 'group': '报表日志'},
{'id': 'alert.view', 'name': '告警中心', 'group': '报表日志'},
{'id': 'api.manage', 'name': '开放API Token 管理', 'group': '平台管理'},
{'id': 'setting.manage', 'name': '通知设置', 'group': '平台管理'},
{'id': 'enterprise.manage', 'name': '企业版管理(用户/角色/授权/SSO/审计/合规)', 'group': '平台管理'},
]
ALL_POINTS = {p['id'] for p in PERM_POINTS}
def role_perms(role):
"""角色权限点集合(admin 的 ['*'] 展开为全部)"""
try:
perms = json.loads(role.get('perms') or '[]')
except Exception:
perms = []
if '*' in perms:
return set(ALL_POINTS)
return set(perms) & ALL_POINTS
def user_role_ids(user_id):
return [r['role_id'] for r in db.q('SELECT role_id FROM user_roles WHERE user_id=?', (user_id,))]
def user_perms(user_id):
"""用户有效权限点集合 = 内置角色基础(roles 表,可编辑)∪ 自定义角色并集;admin 返回全部"""
u = db.q('SELECT username, role FROM users WHERE id=?', (user_id,), one=True)
if not u:
return set()
# 内置角色基础:从 roles 表读取(管理员可编辑内置角色权限点)
base_role = db.q('SELECT * FROM roles WHERE name=?', (u['role'],), one=True)
s = role_perms(base_role) if base_role else set()
for rid in user_role_ids(user_id):
r = db.q('SELECT * FROM roles WHERE id=?', (rid,), one=True)
if r:
s |= role_perms(r)
return s
def has_perm(user_id, point):
return point in user_perms(user_id)
def list_roles():
rows = db.q('SELECT * FROM roles ORDER BY is_builtin DESC, id')
for r in rows:
r['perms'] = sorted(role_perms(r))
r['user_count'] = db.q('SELECT COUNT(*) c FROM user_roles WHERE role_id=?', (r['id'],))[0]['c']
return rows
def create_role(name, description='', perms=None):
name = (name or '').strip()
if not name:
return None, '角色名不能为空'
if db.q('SELECT id FROM roles WHERE name=?', (name,), one=True):
return None, '角色已存在'
rid = db.w('INSERT INTO roles (name, description, perms, is_builtin, created_at, updated_at) '
'VALUES (?,?,?,0,?,?)',
(name, description, json.dumps(sorted(set(perms or []) & ALL_POINTS)), db.now(), db.now()))
return rid, None
def update_role(role_id, data):
role = db.q('SELECT * FROM roles WHERE id=?', (role_id,), one=True)
if not role:
return None, '角色不存在'
if 'name' in data and (data['name'] or '').strip():
nm = data['name'].strip()
dup = db.q('SELECT id FROM roles WHERE name=? AND id!=?', (nm, role_id), one=True)
if dup:
return None, '角色名已存在'
db.w('UPDATE roles SET name=? WHERE id=?', (nm, role_id))
if 'description' in data:
db.w('UPDATE roles SET description=? WHERE id=?', (data['description'], role_id))
if 'perms' in data:
db.w('UPDATE roles SET perms=? WHERE id=?',
(json.dumps(sorted(set(data['perms'] or []) & ALL_POINTS)), role_id))
db.w('UPDATE roles SET updated_at=? WHERE id=?', (db.now(), role_id))
return role_id, None
def delete_role(role_id):
role = db.q('SELECT * FROM roles WHERE id=?', (role_id,), one=True)
if not role:
return None, '角色不存在'
if role['is_builtin']:
return None, '内置角色不可删除(可编辑权限点)'
db.w('DELETE FROM user_roles WHERE role_id=?', (role_id,))
db.w('DELETE FROM roles WHERE id=?', (role_id,))
return role_id, None
def set_user_roles(user_id, role_ids):
"""覆盖设置用户绑定的自定义角色"""
db.w('DELETE FROM user_roles WHERE user_id=?', (user_id,))
for rid in (role_ids or []):
if db.q('SELECT id FROM roles WHERE id=?', (rid,), one=True):
db.w('INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?,?,?)',
(user_id, rid, db.now()))
# ---------------------------------------------------------------------------
# V3.1 Worker 权限组(批量授权)
# ---------------------------------------------------------------------------
def list_worker_groups():
rows = db.q('SELECT * FROM worker_perm_groups ORDER BY id')
for g in rows:
g['members'] = db.q('SELECT w.id AS worker_id, w.name AS worker_name, m.perm '
'FROM worker_perm_group_members m JOIN workers w ON w.id=m.worker_id '
'WHERE m.group_id=? ORDER BY m.id', (g['id'],))
g['users'] = db.q('SELECT u.id AS user_id, u.username, u.display_name '
'FROM user_worker_groups ug JOIN users u ON u.id=ug.user_id '
'WHERE ug.group_id=? ORDER BY u.id', (g['id'],))
return rows
def save_worker_group(group_id, data):
"""新建/更新 Worker 权限组:members=[{worker_id, perm}]users=[user_id]"""
name = (data.get('name') or '').strip()
if not name:
return None, '组名不能为空'
if group_id:
g = db.q('SELECT * FROM worker_perm_groups WHERE id=?', (group_id,), one=True)
if not g:
return None, '权限组不存在'
db.w('UPDATE worker_perm_groups SET name=?, description=?, updated_at=? WHERE id=?',
(name, data.get('description', ''), db.now(), group_id))
else:
group_id = db.w('INSERT INTO worker_perm_groups (name, description, created_at, updated_at) '
'VALUES (?,?,?,?)', (name, data.get('description', ''), db.now(), db.now()))
# 成员
db.w('DELETE FROM worker_perm_group_members WHERE group_id=?', (group_id,))
for m in (data.get('members') or []):
wid = int(m.get('worker_id') or 0)
perm = (m.get('perm') or 'view').strip()
if perm not in WORKER_PERMS:
perm = 'view'
if db.q('SELECT id FROM workers WHERE id=?', (wid,), one=True):
db.w('INSERT INTO worker_perm_group_members (group_id, worker_id, perm, created_at) '
'VALUES (?,?,?,?)', (group_id, wid, perm, db.now()))
# 授权用户
db.w('DELETE FROM user_worker_groups WHERE group_id=?', (group_id,))
for uid in (data.get('users') or []):
if db.q('SELECT id FROM users WHERE id=?', (int(uid),), one=True):
db.w('INSERT INTO user_worker_groups (user_id, group_id, created_at) VALUES (?,?,?)',
(int(uid), group_id, db.now()))
return group_id, None
def delete_worker_group(group_id):
db.w('DELETE FROM worker_perm_group_members WHERE group_id=?', (group_id,))
db.w('DELETE FROM user_worker_groups WHERE group_id=?', (group_id,))
db.w('DELETE FROM worker_perm_groups WHERE id=?', (group_id,))
return group_id, None
def grouped_worker_perm(user_id, worker_id):
"""用户通过权限组获得的 Worker 权限(取最高)"""
rows = db.q(
'SELECT m.perm FROM worker_perm_group_members m '
'JOIN user_worker_groups ug ON ug.group_id=m.group_id '
'WHERE ug.user_id=? AND m.worker_id=?', (user_id, worker_id))
best = None
for r in rows:
if best is None or PERM_LEVEL.get(r['perm'], 0) > PERM_LEVEL.get(best, 0):
best = r['perm']
return best
def grouped_worker_ids(user_id):
"""用户通过权限组获得的 worker_id 列表(含组内权限)"""
rows = db.q(
'SELECT DISTINCT m.worker_id FROM worker_perm_group_members m '
'JOIN user_worker_groups ug ON ug.group_id=m.group_id '
'WHERE ug.user_id=?', (user_id,))
return [r['worker_id'] for r in rows]
+926 -119
View File
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -11,18 +11,18 @@
<aside id="sidebar">
<div class="logo">🤖 <span>AI Worker</span><small>项目管理平台</small></div>
<nav>
<a href="#/dashboard" data-route="dashboard">📊 仪表盘</a>
<a href="#/projects" data-route="projects">📁 项目</a>
<a href="#/workers" data-route="workers">🧑‍💻 AI Worker</a>
<a href="#/agents" data-route="agents">🤝 多 Agent 协作</a>
<a href="#/eval" data-route="eval">🎯 自动评估</a>
<a href="#/templates" data-route="templates">🧩 模板市场</a>
<a href="#/reports" data-route="reports">💰 成本报表</a>
<a href="#/logs" data-route="logs">📜 运行日志</a>
<a href="#/alerts" data-route="alerts">🚨 告警中心 <span id="alert-badge" class="badge st-review" style="display:none;float:right">0</span></a>
<a href="#/api" data-route="api">🔌 开放 API</a>
<a href="#/enterprise" data-route="enterprise">🏢 企业版</a>
<a href="#/settings" data-route="settings">⚙️ 通知设置</a>
<a href="#/dashboard" data-route="dashboard" data-perm="dashboard.view">📊 仪表盘</a>
<a href="#/projects" data-route="projects" data-perm="project.view">📁 项目</a>
<a href="#/workers" data-route="workers" data-perm="worker.view">🧑‍💻 AI Worker</a>
<a href="#/agents" data-route="agents" data-perm="agent.view">🤝 多 Agent 协作</a>
<a href="#/eval" data-route="eval" data-perm="eval.view">🎯 自动评估</a>
<a href="#/templates" data-route="templates" data-perm="template.view">🧩 模板市场</a>
<a href="#/reports" data-route="reports" data-perm="report.view">💰 成本报表</a>
<a href="#/logs" data-route="logs" data-perm="log.view">📜 运行日志</a>
<a href="#/alerts" data-route="alerts" data-perm="alert.view">🚨 告警中心 <span id="alert-badge" class="badge st-review" style="display:none;float:right">0</span></a>
<a href="#/api" data-route="api" data-perm="api.manage">🔌 开放 API</a>
<a href="#/enterprise" data-route="enterprise" data-perm="enterprise.manage">🏢 企业版</a>
<a href="#/settings" data-route="settings" data-perm="setting.manage">⚙️ 通知设置</a>
</nav>
<div class="sidebar-foot">
<div id="conn-state" class="conn ok">● 服务正常</div>
+31 -4
View File
@@ -132,14 +132,41 @@ tr:hover td{background:var(--panel2)}
.tabs a{padding:9px 16px;color:var(--muted);text-decoration:none;border-bottom:2px solid transparent;font-size:13px}
.tabs a.active{color:var(--accent);border-bottom-color:var(--accent)}
.dag-box{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden;height:520px;position:relative;touch-action:none;user-select:none;cursor:grab}
.dag-box svg{display:block;transition:transform .05s linear}
.dag-box svg{display:block;transition:transform .05s linear;overflow:visible}
.dag-node{stroke:var(--border);stroke-width:1.5;rx:10;cursor:grab}
.dag-node:hover{stroke:var(--accent)}
.dag-node rect{stroke-width:1.5;rx:10}
.dag-label{font-size:12px;fill:#ffffff;text-anchor:middle;pointer-events:none}
.dag-sub{font-size:10px;fill:rgba(255,255,255,.78);text-anchor:middle;pointer-events:none}
.dag-edge{stroke:var(--border);stroke-width:1.5;fill:none;marker-end:url(#arrow)}
.dag-label{font-size:13px;font-weight:600;fill:#ffffff;stroke:none;text-anchor:middle;pointer-events:none}
.dag-sub{font-size:11px;fill:rgba(255,255,255,.82);stroke:none;text-anchor:middle;pointer-events:none}
.dag-edge{stroke:var(--border);stroke-width:1.5;fill:none;marker-end:url(#arrow);pointer-events:none}
.dag-edge.act{stroke:var(--accent)}
.dag-edge-g{cursor:pointer}
.dag-edge-hit{fill:none;stroke:transparent;stroke-width:14;pointer-events:stroke;cursor:pointer}
.dag-edge-g:hover .dag-edge{stroke:var(--accent)}
.dag-edge.sel{stroke:var(--accent);stroke-width:2.5}
.dag-edge-x{opacity:0;fill:var(--danger);stroke:#fff;stroke-width:1.5;pointer-events:none;cursor:pointer;transition:opacity .12s}
.dag-edge-xt{opacity:0;font-size:10px;fill:#fff;text-anchor:middle;pointer-events:none;transition:opacity .12s}
.dag-edge-g:hover .dag-edge-x,.dag-edge-g:hover .dag-edge-xt,.dag-edge-g.sel .dag-edge-x,.dag-edge-g.sel .dag-edge-xt{opacity:1}
.dag-edge-g:hover .dag-edge-x,.dag-edge-g.sel .dag-edge-x{pointer-events:all}
.dag-port{fill:var(--panel2);stroke:var(--accent);stroke-width:2;opacity:0;pointer-events:all;transition:opacity .15s}
.dag-node:hover .dag-port{opacity:1}
.dag-connecting .dag-port{opacity:1}
.dag-port.out{cursor:crosshair}
.dag-port.in{cursor:crosshair}
.dag-node.drop-ok rect{stroke:var(--accent2);stroke-width:2.5}
.dag-node.drop-bad rect{stroke:var(--danger);stroke-width:2.5}
.dag-ghost{stroke:var(--accent);stroke-width:2;stroke-dasharray:6 4;fill:none;marker-end:url(#arrow);pointer-events:none}
.dag-ghost.bad{stroke:var(--danger)}
.dag-edge-actions{position:absolute;top:14px;left:50%;transform:translateX(-50%);background:rgba(23,30,46,.92);border:1px solid var(--border);border-radius:8px;padding:4px 8px;font-size:12px;color:var(--text);display:flex;align-items:center;gap:6px;z-index:7;backdrop-filter:blur(2px);box-shadow:0 4px 14px rgba(0,0,0,.35)}
.dag-edge-actions span{color:var(--muted);white-space:nowrap}
.dag-edge-actions b{color:var(--accent)}
.dag-trash{position:absolute;left:14px;bottom:14px;background:rgba(255,107,107,.1);border:1.5px dashed var(--danger);color:var(--danger);border-radius:10px;padding:8px 14px;font-size:12px;cursor:pointer;z-index:5;display:flex;align-items:center;gap:6px;backdrop-filter:blur(2px)}
.dag-trash:hover,.dag-trash.hover{background:rgba(255,107,107,.25);border-style:solid}
.dag-trash .cnt{background:var(--danger);color:#fff;border-radius:8px;padding:0 6px;font-size:11px}
.dag-zoom{position:absolute;right:14px;top:14px;background:rgba(23,30,46,.88);border:1px solid var(--border);border-radius:8px;padding:4px 10px;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:6px;z-index:6}
.dag-zoom b{color:var(--text);font-weight:600;min-width:42px;text-align:center}
.dag-zoom button{background:var(--panel2);border:1px solid var(--border);color:var(--text);border-radius:5px;padding:2px 8px;font-size:11px;cursor:pointer}
.dag-zoom button:hover{border-color:var(--accent);color:var(--accent)}
.alert-row{border-left:3px solid var(--border);padding:12px 14px;margin-bottom:8px;background:var(--panel2);border-radius:0 10px 10px 0;cursor:pointer}
.alert-row.unread{border-left-color:var(--warn)}
.alert-row.critical{border-left-color:var(--danger)}