Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb92a4e591 | ||
|
|
78ccca61c3 | ||
|
|
3cbe3a5996 | ||
|
|
dfdcd78f1a | ||
|
|
9c45a143b9 | ||
|
|
cf092da69a | ||
|
|
5deb447c8e |
@@ -3,7 +3,7 @@
|
|||||||
> 以「项目」为中心、以「AI Worker」为执行单元的项目管理平台。
|
> 以「项目」为中心、以「AI Worker」为执行单元的项目管理平台。
|
||||||
> 把大模型团队变成一支可指挥、可审计、可控成本的"虚拟团队"。
|
> 把大模型团队变成一支可指挥、可审计、可控成本的"虚拟团队"。
|
||||||
|
|
||||||
**当前版本:V2.0**(多 Agent 协作 / 自动评估 / 模板市场 / 企业版)
|
**当前版本:V3.0**(交付体系 / 精准权限 / 多 Agent 协作 / 自动评估 / 模板市场 / 企业版)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -49,6 +49,24 @@
|
|||||||
| ⚖️ 合规 | 全量数据导出(JSON)、审计 CSV 导出、数据保留期清理、PII 脱敏(邮箱/手机/身份证)、数据使用同意凭证 |
|
| ⚖️ 合规 | 全量数据导出(JSON)、审计 CSV 导出、数据保留期清理、PII 脱敏(邮箱/手机/身份证)、数据使用同意凭证 |
|
||||||
| 🔒 私有化 | 单机 SQLite + 无 CDN 前端,完全离线可用;Docker 一键部署(见下) |
|
| 🔒 私有化 | 单机 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 授权矩阵,杜绝越权 |
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -84,22 +102,23 @@ docker run -d --name aiworker -p 16071:16071 \
|
|||||||
|
|
||||||
```
|
```
|
||||||
ai-worker-platform/
|
ai-worker-platform/
|
||||||
├── app.py # Flask 应用 + REST API(V1 + V2 路由)
|
├── app.py # Flask 应用 + REST API(V1 + V2 + V3 路由)
|
||||||
├── config.py # 供应商/定价/鉴权/告警阈值/邮件 SMTP
|
├── config.py # 供应商/定价/鉴权/告警阈值/邮件 SMTP
|
||||||
├── db.py # SQLite 数据层 + V2 迁移(users/audit/agent/eval/templates)
|
├── db.py # SQLite 数据层 + V2/V3 迁移(users/audit/agent/eval/templates/deliverables/grants)
|
||||||
├── engine.py # V1 执行引擎:DAG/自动触发/RAG 注入/告警
|
├── engine.py # V1 执行引擎:DAG/自动触发/RAG 注入/告警/难关通知
|
||||||
├── agents.py # V2 多 Agent 协作引擎:supervisor/review/debate
|
├── agents.py # V2 多 Agent 协作引擎:supervisor/review/debate
|
||||||
├── eval.py # V2 自动评估:数据集/LLM 评委/沉淀/排行榜
|
├── eval.py # V2 自动评估:数据集/LLM 评委/沉淀/排行榜
|
||||||
├── templates.py # V2 模板市场:三类模板 + 占位符渲染 + 应用
|
├── templates.py # V2 模板市场:三类模板 + 占位符渲染 + 应用
|
||||||
├── enterprise.py # V2 企业版:用户/RBAC/OIDC/LDAP/审计/合规
|
├── enterprise.py # V2/V3 企业版:用户/RBAC/SSO/审计/合规 + 项目/Worker 精准授权
|
||||||
|
├── delivery.py # V3 交付体系:工作目录/Demo 部署/打包/邮件送达/难关通知
|
||||||
├── llm_gateway.py # 统一模型网关
|
├── llm_gateway.py # 统一模型网关
|
||||||
├── rag.py # 知识库:分块 + BM25 检索
|
├── rag.py # 知识库:分块 + BM25 检索
|
||||||
├── notify.py # 飞书/企微/邮件通知
|
├── notify.py # 飞书/企微/邮件通知
|
||||||
├── dag_verify.py # DAG 全链路验证脚本
|
├── dag_verify.py # DAG 全链路验证脚本
|
||||||
├── seed.py # 演示数据
|
├── seed.py # 演示数据
|
||||||
├── start.sh # 启停脚本
|
├── start.sh # 启停脚本
|
||||||
├── static/ # 前端 SPA(含 V2 四页)
|
├── static/ # 前端 SPA(含 V3 交付页/授权管理)
|
||||||
└── data/ # SQLite 库;logs/ 运行日志
|
└── data/ # SQLite 库;workspace/ 项目工作目录;demo/ 网页Demo;packages/ 打包件;logs/ 运行日志
|
||||||
```
|
```
|
||||||
|
|
||||||
## V2 开放 API 摘要
|
## V2 开放 API 摘要
|
||||||
@@ -120,12 +139,19 @@ ai-worker-platform/
|
|||||||
|
|
||||||
企业版:
|
企业版:
|
||||||
- `GET/POST /api/enterprise/users`(管理员)| `PUT/DELETE /api/enterprise/users/<id>`
|
- `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/PUT /api/enterprise/sso` SSO 配置 | `POST /api/enterprise/sso/oidc/login` 发起 OIDC | `GET /api/enterprise/sso/oidc/callback` 回调
|
- `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 连通测试
|
- `POST /api/enterprise/sso/ldap/test` LDAP 连通测试
|
||||||
- `GET /api/enterprise/audit` 审计日志 | `GET /api/enterprise/audit/export` CSV 导出
|
- `GET /api/enterprise/audit` 审计日志 | `GET /api/enterprise/audit/export` CSV 导出
|
||||||
- `GET /api/enterprise/export` 全量数据导出(JSON)| `POST /api/enterprise/compliance` 保留期/脱敏设置
|
- `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 回归对比视图
|
- **V2.1**:Temporal 持久执行、多 Agent 协作接入项目任务、eval 回归对比视图
|
||||||
- **V3**:多租户 SaaS 化、工作流画布(拖拽编排)、插件市场
|
- **V3**:多租户 SaaS 化、工作流画布(拖拽编排)、插件市场 ✅ 交付体系 + 精准权限(V3.0)
|
||||||
@@ -98,5 +98,8 @@ EMAIL = {
|
|||||||
'from_name': 'AI Worker 平台',
|
'from_name': 'AI Worker 平台',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 公网访问地址(Demo 链接/邮件中的回链基准;留空则用请求 host)
|
||||||
|
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://121.40.164.32:16071')
|
||||||
|
|
||||||
# 自动路由:按模型单价升序挑选可用 Worker
|
# 自动路由:按模型单价升序挑选可用 Worker
|
||||||
AUTO_ROUTE_POOL = 'enabled' # enabled | all
|
AUTO_ROUTE_POOL = 'enabled' # enabled | all
|
||||||
@@ -16,6 +16,12 @@ CREATE TABLE IF NOT EXISTS projects (
|
|||||||
acceptance_criteria TEXT DEFAULT '',
|
acceptance_criteria TEXT DEFAULT '',
|
||||||
status TEXT DEFAULT 'active', -- planning/active/done/archived
|
status TEXT DEFAULT 'active', -- planning/active/done/archived
|
||||||
budget_limit REAL DEFAULT 0, -- 项目预算上限(元),0=不限
|
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,
|
created_at INTEGER,
|
||||||
updated_at INTEGER
|
updated_at INTEGER
|
||||||
);
|
);
|
||||||
@@ -258,6 +264,44 @@ CREATE TABLE IF NOT EXISTS enterprise_settings (
|
|||||||
value TEXT DEFAULT ''
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
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_agent_steps_run ON agent_steps(run_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_agent_runs_status ON agent_runs(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_eval_cases_ds ON eval_cases(dataset_id);
|
CREATE INDEX IF NOT EXISTS idx_eval_cases_ds ON eval_cases(dataset_id);
|
||||||
@@ -282,6 +326,26 @@ def _migrate():
|
|||||||
if 'depends_on' not in cols:
|
if 'depends_on' not in cols:
|
||||||
conn.execute("ALTER TABLE tasks ADD COLUMN depends_on TEXT DEFAULT '[]'")
|
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)')
|
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)')
|
||||||
|
# V3:projects 交付字段
|
||||||
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
+369
@@ -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()
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
@@ -14,6 +14,20 @@ import llm_gateway
|
|||||||
import config
|
import config
|
||||||
import rag
|
import rag
|
||||||
import notify
|
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):
|
def _log(task_id, level, message):
|
||||||
@@ -153,7 +167,7 @@ def _build_messages(task, worker):
|
|||||||
|
|
||||||
def _trigger_downstream(task):
|
def _trigger_downstream(task):
|
||||||
"""DAG:任务完成后自动触发所有就绪的下游任务"""
|
"""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 = []
|
triggered = []
|
||||||
for t in rows:
|
for t in rows:
|
||||||
deps = _deps(t)
|
deps = _deps(t)
|
||||||
@@ -182,9 +196,7 @@ def run_task(task_id):
|
|||||||
_set_task(task_id, status='failed', error='前置任务未完成:' + '、'.join(blockers),
|
_set_task(task_id, status='failed', error='前置任务未完成:' + '、'.join(blockers),
|
||||||
finished_at=db.now())
|
finished_at=db.now())
|
||||||
_log(task_id, 'error', '❌ 依赖未满足,无法执行:' + '、'.join(blockers))
|
_log(task_id, 'error', '❌ 依赖未满足,无法执行:' + '、'.join(blockers))
|
||||||
notify.notify('task_failed', f'任务失败:{task["title"]}',
|
_notify_failed(task, '因依赖未完成被拒绝执行:' + '、'.join(blockers))
|
||||||
f'项目 #{task["project_id"]} 任务「{task["title"]}」因依赖未完成被拒绝执行:'
|
|
||||||
+ '、'.join(blockers), save_alert=True, level='warn', atype='task_failed')
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# 确定 Worker
|
# 确定 Worker
|
||||||
@@ -195,9 +207,7 @@ def run_task(task_id):
|
|||||||
_set_task(task_id, status='failed', error='指定 Worker 不存在或已停用',
|
_set_task(task_id, status='failed', error='指定 Worker 不存在或已停用',
|
||||||
finished_at=db.now())
|
finished_at=db.now())
|
||||||
_log(task_id, 'error', '指定 Worker 不存在或已停用')
|
_log(task_id, 'error', '指定 Worker 不存在或已停用')
|
||||||
notify.notify('worker_alert', f'Worker 异常:任务「{task["title"]}」',
|
_notify_failed(task, '指定 Worker 不存在或已停用,无法执行')
|
||||||
f'指定 Worker #{task["worker_id"]} 不存在或已停用', save_alert=True,
|
|
||||||
level='warn', atype='worker_alert')
|
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
worker = pick_worker_auto(task)
|
worker = pick_worker_auto(task)
|
||||||
@@ -205,9 +215,7 @@ def run_task(task_id):
|
|||||||
_set_task(task_id, status='failed', error='无可用 Worker(自动路由失败)',
|
_set_task(task_id, status='failed', error='无可用 Worker(自动路由失败)',
|
||||||
finished_at=db.now())
|
finished_at=db.now())
|
||||||
_log(task_id, 'error', '自动路由失败:无可用 Worker')
|
_log(task_id, 'error', '自动路由失败:无可用 Worker')
|
||||||
notify.notify('worker_alert', f'Worker 异常:任务「{task["title"]}」',
|
_notify_failed(task, '自动路由失败:没有可用的 Worker')
|
||||||
'自动路由失败:没有可用的 Worker', save_alert=True,
|
|
||||||
level='warn', atype='worker_alert')
|
|
||||||
return
|
return
|
||||||
_set_task(task_id, worker_id=worker['id'])
|
_set_task(task_id, worker_id=worker['id'])
|
||||||
_log(task_id, 'info', f'自动路由 → Worker「{worker["name"]}」({worker["provider"]}/{worker["model"]})')
|
_log(task_id, 'info', f'自动路由 → Worker「{worker["name"]}」({worker["provider"]}/{worker["model"]})')
|
||||||
@@ -217,15 +225,13 @@ def run_task(task_id):
|
|||||||
if not ok:
|
if not ok:
|
||||||
_set_task(task_id, status='failed', error=reason, finished_at=db.now())
|
_set_task(task_id, status='failed', error=reason, finished_at=db.now())
|
||||||
_log(task_id, 'error', reason)
|
_log(task_id, 'error', reason)
|
||||||
notify.notify('budget_alert', f'成本上限拦截:任务「{task["title"]}」', reason,
|
_notify_failed(task, reason)
|
||||||
save_alert=True, level='warn', atype='budget')
|
|
||||||
return
|
return
|
||||||
ok, reason = _check_project_budget(task)
|
ok, reason = _check_project_budget(task)
|
||||||
if not ok:
|
if not ok:
|
||||||
_set_task(task_id, status='failed', error=reason, finished_at=db.now())
|
_set_task(task_id, status='failed', error=reason, finished_at=db.now())
|
||||||
_log(task_id, 'error', reason)
|
_log(task_id, 'error', reason)
|
||||||
notify.notify('budget_alert', f'预算拦截:任务「{task["title"]}」', reason,
|
_notify_failed(task, reason)
|
||||||
save_alert=True, level='warn', atype='budget')
|
|
||||||
return
|
return
|
||||||
|
|
||||||
_set_task(task_id, status='running', started_at=db.now(), error='')
|
_set_task(task_id, status='running', started_at=db.now(), error='')
|
||||||
@@ -239,9 +245,7 @@ def run_task(task_id):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
_set_task(task_id, status='failed', error=str(e), finished_at=db.now())
|
_set_task(task_id, status='failed', error=str(e), finished_at=db.now())
|
||||||
_log(task_id, 'error', f'执行失败: {e}')
|
_log(task_id, 'error', f'执行失败: {e}')
|
||||||
notify.notify('task_failed', f'任务失败:{task["title"]}',
|
_notify_failed(task, f'执行出错:{str(e)[:300]}')
|
||||||
f'项目 #{task["project_id"]} 任务「{task["title"]}」执行出错:{str(e)[:300]}',
|
|
||||||
save_alert=True, level='warn', atype='task_failed')
|
|
||||||
return
|
return
|
||||||
|
|
||||||
_cost_record(task, worker, usage)
|
_cost_record(task, worker, usage)
|
||||||
@@ -272,6 +276,13 @@ def run_task(task_id):
|
|||||||
|
|
||||||
_budget_alert(task['project_id'])
|
_budget_alert(task['project_id'])
|
||||||
|
|
||||||
|
if new_status == 'done':
|
||||||
|
# V3:无需审核的任务直接完成后,检查项目是否全部完成 → 自动交付并通知送达者
|
||||||
|
try:
|
||||||
|
delivery.auto_complete_if_ready(task['project_id'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# DAG:触发下游就绪任务
|
# DAG:触发下游就绪任务
|
||||||
downstream = _trigger_downstream(task)
|
downstream = _trigger_downstream(task)
|
||||||
for t in downstream:
|
for t in downstream:
|
||||||
|
|||||||
+91
-1
@@ -293,7 +293,8 @@ def export_all():
|
|||||||
"""全量数据导出(JSON)"""
|
"""全量数据导出(JSON)"""
|
||||||
tables = ['projects', 'workers', 'tasks', 'task_logs', 'cost_records', 'documents',
|
tables = ['projects', 'workers', 'tasks', 'task_logs', 'cost_records', 'documents',
|
||||||
'agent_runs', 'agent_steps', 'eval_datasets', 'eval_cases', 'eval_runs',
|
'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'),
|
out = {'exported_at': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
'platform': 'ai-worker-platform', 'version': 'v2.0.0'}
|
'platform': 'ai-worker-platform', 'version': 'v2.0.0'}
|
||||||
for t in tables:
|
for t in tables:
|
||||||
@@ -337,3 +338,92 @@ def generate_consent_token():
|
|||||||
tok = uuid.uuid4().hex[:12]
|
tok = uuid.uuid4().hex[:12]
|
||||||
audit('system', 'compliance.consent', '数据使用同意', f'consent_token={tok}')
|
audit('system', 'compliance.consent', '数据使用同意', f'consent_token={tok}')
|
||||||
return 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)
|
||||||
|
return r['perm'] if r else None
|
||||||
|
|
||||||
|
|
||||||
|
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,))
|
||||||
|
return [r['worker_id'] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def set_user_grants(user_id, projects=None, workers=None):
|
||||||
|
"""批量覆盖用户授权。projects=[{project_id, perm}], workers=[{worker_id, perm}]
|
||||||
|
perm 传空/None 表示收回该授权。返回 {'projects': n, 'workers': m}。"""
|
||||||
|
out = {'projects': 0, 'workers': 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
|
||||||
|
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'])
|
||||||
|
return {'projects': projects, 'workers': workers}
|
||||||
+891
-62
File diff suppressed because it is too large
Load Diff
+34
-5
@@ -131,13 +131,42 @@ tr:hover td{background:var(--panel2)}
|
|||||||
.tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:1px solid var(--border)}
|
.tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:1px solid var(--border)}
|
||||||
.tabs a{padding:9px 16px;color:var(--muted);text-decoration:none;border-bottom:2px solid transparent;font-size:13px}
|
.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)}
|
.tabs a.active{color:var(--accent);border-bottom-color:var(--accent)}
|
||||||
.dag-box{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:14px;overflow:auto}
|
.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-node{fill:var(--panel2);stroke:var(--border);stroke-width:1.5;rx:10;cursor:pointer}
|
.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:hover{stroke:var(--accent)}
|
||||||
.dag-label{font-size:12px;fill:var(--text);text-anchor:middle}
|
.dag-node rect{stroke-width:1.5;rx:10}
|
||||||
.dag-sub{font-size:10px;fill:var(--muted);text-anchor:middle}
|
.dag-label{font-size:13px;font-weight:600;fill:#ffffff;stroke:none;text-anchor:middle;pointer-events:none}
|
||||||
.dag-edge{stroke:var(--border);stroke-width:1.5;fill:none;marker-end:url(#arrow)}
|
.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.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{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.unread{border-left-color:var(--warn)}
|
||||||
.alert-row.critical{border-left-color:var(--danger)}
|
.alert-row.critical{border-left-color:var(--danger)}
|
||||||
|
|||||||
Reference in New Issue
Block a user