- parse_wbs 改用 raw_decode 提取第一个完整 JSON,忽略尾部杂质/多余 JSON(原按首{到尾}切片,模型输出带第二段JSON或解释文字时解析失败)
- 兼容 subtasks/task_list/items/task 等任务键名
- /autostart 接口 get_json 改 silent 容错(无 body 时不再 400)
2350 lines
101 KiB
Python
2350 lines
101 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
AI Worker 项目管理平台 - MVP
|
||
人派活 → AI 干活 → 人验收 最小闭环
|
||
"""
|
||
import os
|
||
import secrets
|
||
import functools
|
||
import json as _json
|
||
from flask import Flask, request, jsonify, session, send_from_directory, redirect
|
||
|
||
import config
|
||
import db
|
||
import engine
|
||
import llm_gateway
|
||
import rag
|
||
import notify
|
||
import agents
|
||
import eval as evalmod
|
||
import templates as tplmod
|
||
import enterprise
|
||
import delivery
|
||
import autostart
|
||
|
||
app = Flask(__name__, static_folder='static', static_url_path='')
|
||
app.secret_key = config.SECRET_KEY
|
||
db.init_db()
|
||
db.recover_stale_runs()
|
||
evalmod.create_builtin_datasets()
|
||
tplmod.create_builtin_templates()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 鉴权(简单口令登录)
|
||
# ---------------------------------------------------------------------------
|
||
def auth_enabled():
|
||
return bool(config.AUTH_PASSWORD)
|
||
|
||
|
||
def _dag_cycle_check(project_id, task_id, new_deps):
|
||
"""校验给 task_id 设置 new_deps 后项目 DAG 是否成环,返回环路径或 None"""
|
||
rows = db.q('SELECT id, depends_on FROM tasks WHERE project_id=? AND deleted=0', (project_id,))
|
||
deps = {}
|
||
for r in rows:
|
||
try:
|
||
deps[r['id']] = _json.loads(r['depends_on'] or '[]')
|
||
except Exception:
|
||
deps[r['id']] = []
|
||
deps[task_id] = [x for x in (new_deps or []) if x in deps]
|
||
visiting, visited, path = set(), set(), []
|
||
def dfs(n):
|
||
if n in visiting:
|
||
return path[path.index(n):] + [n]
|
||
if n in visited:
|
||
return None
|
||
visiting.add(n)
|
||
path.append(n)
|
||
for d in deps.get(n, []):
|
||
r = dfs(d)
|
||
if r:
|
||
return r
|
||
visiting.remove(n)
|
||
path.pop()
|
||
visited.add(n)
|
||
return None
|
||
for n in list(deps):
|
||
r = dfs(n)
|
||
if r:
|
||
return r
|
||
return None
|
||
|
||
|
||
def _validate_depends_on(project_id, task_id, new_deps):
|
||
"""依赖校验:存在性 / 非自身 / 无环。返回 (ok, error_message_or_cycle_path)"""
|
||
new_deps = new_deps or []
|
||
valid_ids = {r['id'] for r in db.q('SELECT id FROM tasks WHERE project_id=? AND deleted=0', (project_id,))}
|
||
bad = [x for x in new_deps if x not in valid_ids]
|
||
if bad:
|
||
return False, f'依赖任务不存在(已删除或不属于本项目):{bad}'
|
||
if task_id in new_deps:
|
||
return False, '任务不能依赖自身'
|
||
cycle = _dag_cycle_check(project_id, task_id, new_deps)
|
||
if cycle:
|
||
return False, '⚠️ 会形成依赖环:' + ' → '.join(f'#{x}' for x in cycle)
|
||
return True, ''
|
||
|
||
|
||
@app.route('/api/login', methods=['POST'])
|
||
def login():
|
||
data = request.get_json(force=True)
|
||
if not auth_enabled():
|
||
session['username'] = 'admin'
|
||
session['authed'] = True
|
||
return jsonify({'ok': True})
|
||
username = (data.get('username') or 'admin').strip()
|
||
password = data.get('password') or ''
|
||
# 1) 本地用户(含默认 admin)
|
||
u = enterprise.verify_local(username, password)
|
||
if u:
|
||
session['username'] = u['username']
|
||
session['role'] = u['role']
|
||
session['authed'] = True
|
||
enterprise.audit(u['username'], 'auth.login', '本地登录', ip=request.remote_addr or '')
|
||
return jsonify({'ok': True, 'user': {'username': u['username'], 'role': u['role'],
|
||
'display_name': u['display_name']}})
|
||
# 2) LDAP SSO
|
||
if enterprise.sso_config().get('ldap_enabled') == '1':
|
||
u, err = enterprise.ldap_login(username, password)
|
||
if u:
|
||
session['username'] = u['username']
|
||
session['role'] = u['role']
|
||
session['authed'] = True
|
||
enterprise.audit(u['username'], 'auth.login', 'LDAP 登录', ip=request.remote_addr or '')
|
||
return jsonify({'ok': True, 'user': {'username': u['username'], 'role': u['role'],
|
||
'display_name': u['display_name']}})
|
||
# 3) 兼容 V1:口令直登(映射为 admin)
|
||
if password == config.AUTH_PASSWORD:
|
||
session['username'] = 'admin'
|
||
session['role'] = 'admin'
|
||
session['authed'] = True
|
||
enterprise.audit('admin', 'auth.login', '口令登录(V1 兼容)', ip=request.remote_addr or '')
|
||
return jsonify({'ok': True, 'user': {'username': 'admin', 'role': 'admin', 'display_name': '管理员'}})
|
||
enterprise.audit(username, 'auth.login_failed', '登录失败', ip=request.remote_addr or '')
|
||
return jsonify({'ok': False, 'error': '用户名或口令错误'}), 401
|
||
|
||
|
||
@app.route('/api/logout', methods=['POST'])
|
||
def logout():
|
||
enterprise.audit(session.get('username', '?'), 'auth.logout', '退出登录', ip=request.remote_addr or '')
|
||
session.clear()
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@app.route('/api/me')
|
||
def me():
|
||
authed = not auth_enabled() or session.get('authed')
|
||
u = None
|
||
if authed:
|
||
uname = session.get('username', 'admin')
|
||
row = enterprise.get_user(uname)
|
||
uid = row['id'] if row else None
|
||
u = {'username': uname, 'role': session.get('role') or (row['role'] if row else 'admin'),
|
||
'display_name': row['display_name'] if row else uname,
|
||
'email': row['email'] if row else ''}
|
||
if uid:
|
||
perms = enterprise.user_perms(uid)
|
||
u['perms'] = sorted(perms)
|
||
else:
|
||
u['perms'] = sorted(enterprise.ALL_POINTS)
|
||
return jsonify({'ok': True, 'authed': authed, 'user': u})
|
||
|
||
|
||
def _check_perm_point(point):
|
||
"""模块权限点拦截:返回 None=通过,否则 (jsonify, code)。admin/token 恒通过。"""
|
||
if not _auth_ok():
|
||
return jsonify({'ok': False, 'error': '未登录或 Token 无效'}), 401
|
||
actor = enterprise.current_actor()
|
||
if actor.startswith('token:'):
|
||
return None
|
||
u = _session_user()
|
||
if not u:
|
||
return jsonify({'ok': False, 'error': '未登录'}), 401
|
||
if u['role'] == 'admin' or enterprise.has_perm(u['id'], point):
|
||
return None
|
||
return jsonify({'ok': False, 'error': f'无该功能权限({point})'}), 403
|
||
|
||
|
||
def require_perm(point):
|
||
"""模块权限点装饰器"""
|
||
def deco(fn):
|
||
@functools.wraps(fn)
|
||
def wrapper(*args, **kwargs):
|
||
err = _check_perm_point(point)
|
||
if err:
|
||
return err
|
||
return fn(*args, **kwargs)
|
||
return wrapper
|
||
return deco
|
||
|
||
|
||
def _auth_ok():
|
||
"""会话或 API Token 任一通过即可"""
|
||
if not auth_enabled():
|
||
return True
|
||
if session.get('authed'):
|
||
return True
|
||
hdr = request.headers.get('Authorization', '')
|
||
if hdr.startswith('Bearer '):
|
||
tok = hdr[7:].strip()
|
||
r = db.q('SELECT id FROM api_tokens WHERE token=?', (tok,), one=True)
|
||
if r:
|
||
db.w('UPDATE api_tokens SET last_used_at=? WHERE id=?', (db.now(), r['id']))
|
||
return True
|
||
return False
|
||
|
||
|
||
def require_auth(fn):
|
||
@functools.wraps(fn)
|
||
def wrapper(*args, **kwargs):
|
||
if not _auth_ok():
|
||
return jsonify({'ok': False, 'error': '未登录或 Token 无效'}), 401
|
||
try:
|
||
import flask
|
||
flask.g.auth_actor = enterprise.current_actor()
|
||
except Exception:
|
||
pass
|
||
return fn(*args, **kwargs)
|
||
return wrapper
|
||
|
||
|
||
def require_admin(fn):
|
||
"""RBAC:管理员(admin 角色 / token 调用者 / 拥有 enterprise.manage 权限点的角色)可访问"""
|
||
@functools.wraps(fn)
|
||
def wrapper(*args, **kwargs):
|
||
if not _auth_ok():
|
||
return jsonify({'ok': False, 'error': '未登录或 Token 无效'}), 401
|
||
try:
|
||
import flask
|
||
flask.g.auth_actor = enterprise.current_actor()
|
||
except Exception:
|
||
pass
|
||
actor = enterprise.current_actor()
|
||
if actor.startswith('token:'):
|
||
return fn(*args, **kwargs)
|
||
u = _session_user()
|
||
if u and (u['role'] == 'admin' or enterprise.has_perm(u['id'], 'enterprise.manage')):
|
||
return fn(*args, **kwargs)
|
||
return jsonify({'ok': False, 'error': '需要管理员权限'}), 403
|
||
return wrapper
|
||
|
||
|
||
@app.route('/api/health')
|
||
def health():
|
||
return jsonify({'ok': True, 'service': 'ai-worker-platform', 'version': 'v3.3.0'})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# V3 精准权限:角色(admin/auditor/member)+ 项目授权 + Worker 授权
|
||
# ---------------------------------------------------------------------------
|
||
def _session_user():
|
||
"""当前会话用户(dict)或 None"""
|
||
if not session.get('authed'):
|
||
return None
|
||
return enterprise.get_user(session.get('username'))
|
||
|
||
|
||
def _is_super():
|
||
"""admin / auditor / API Token 视为超管可见范围(auditor 只读)"""
|
||
actor = enterprise.current_actor()
|
||
if actor.startswith('token:'):
|
||
return True
|
||
u = _session_user()
|
||
return bool(u and u['role'] in ('admin', 'auditor'))
|
||
|
||
|
||
def _role():
|
||
u = _session_user()
|
||
return u['role'] if u else None
|
||
|
||
|
||
def _project_perm(pid):
|
||
if _is_super():
|
||
return 'admin'
|
||
u = _session_user()
|
||
if not u:
|
||
return None
|
||
return enterprise.user_project_perm(u['id'], pid)
|
||
|
||
|
||
def _worker_perm(wid):
|
||
if _is_super():
|
||
return 'admin'
|
||
u = _session_user()
|
||
if not u:
|
||
return None
|
||
return enterprise.user_worker_perm(u['id'], wid)
|
||
|
||
|
||
def _visible_project_ids():
|
||
"""当前用户可见项目 id 列表;None = 全部"""
|
||
u = _session_user()
|
||
if not u or _is_super():
|
||
return None
|
||
return enterprise.visible_project_ids(u['id'])
|
||
|
||
|
||
def _visible_worker_ids():
|
||
u = _session_user()
|
||
if not u or _is_super():
|
||
return None
|
||
return enterprise.visible_worker_ids(u['id'])
|
||
|
||
|
||
def _check_project_perm(pid, need='view'):
|
||
"""返回 None=通过,否则为错误响应 (jsonify, code)"""
|
||
if not _auth_ok():
|
||
return jsonify({'ok': False, 'error': '未登录或 Token 无效'}), 401
|
||
if not db.q('SELECT id FROM projects WHERE id=?', (pid,), one=True):
|
||
return jsonify({'ok': False, 'error': '项目不存在'}), 404
|
||
perm = _project_perm(pid)
|
||
if not perm:
|
||
return jsonify({'ok': False, 'error': '无权访问该项目'}), 403
|
||
if not enterprise.perm_ok(perm, need):
|
||
return jsonify({'ok': False, 'error': f'需要更高级别的项目权限({need})'}), 403
|
||
if need != 'view' and _role() == 'auditor':
|
||
return jsonify({'ok': False, 'error': '审计员为只读角色,禁止写操作'}), 403
|
||
return None
|
||
|
||
|
||
def _check_worker_perm(wid, need='view'):
|
||
if not _auth_ok():
|
||
return jsonify({'ok': False, 'error': '未登录或 Token 无效'}), 401
|
||
if not db.q('SELECT id FROM workers WHERE id=?', (wid,), one=True):
|
||
return jsonify({'ok': False, 'error': 'Worker 不存在'}), 404
|
||
perm = _worker_perm(wid)
|
||
if not perm:
|
||
return jsonify({'ok': False, 'error': '无权访问该 Worker'}), 403
|
||
if not enterprise.perm_ok(perm, need):
|
||
return jsonify({'ok': False, 'error': f'需要更高级别的 Worker 权限({need})'}), 403
|
||
if need != 'view' and _role() == 'auditor':
|
||
return jsonify({'ok': False, 'error': '审计员为只读角色,禁止写操作'}), 403
|
||
return None
|
||
|
||
|
||
def _check_task_perm(tid, need='view'):
|
||
"""按任务所在项目校验权限"""
|
||
t = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True)
|
||
if not t:
|
||
return jsonify({'ok': False, 'error': '任务不存在'}), 404
|
||
err = _check_project_perm(t['project_id'], need)
|
||
if err:
|
||
return err
|
||
return None
|
||
|
||
|
||
def _attach_deliver_user(project):
|
||
"""项目附带送达者用户信息(用户名/邮箱实时取)"""
|
||
duid = project.get('deliver_user_id')
|
||
project['deliver_user'] = None
|
||
if duid:
|
||
u = db.q('SELECT id, username, display_name, email FROM users WHERE id=?', (duid,), one=True)
|
||
if u:
|
||
project['deliver_user'] = dict(u)
|
||
project['deliver_email'] = u['email'] or project.get('deliver_email', '')
|
||
return project
|
||
|
||
|
||
def _attach_ai_team(project):
|
||
"""V3.3 项目附带 AI 主管 + 干活团队 Worker 信息"""
|
||
pid = project['id']
|
||
mgr = db.q('SELECT id, name, provider, model FROM workers WHERE id=?',
|
||
(project.get('manager_worker_id') or 0,), one=True)
|
||
project['manager_worker'] = dict(mgr) if mgr else None
|
||
project['team_workers'] = [dict(r) for r in db.q(
|
||
'SELECT w.id, w.name, w.provider, w.model FROM project_team_workers t '
|
||
'JOIN workers w ON w.id=t.worker_id WHERE t.project_id=? ORDER BY t.rowid', (pid,))]
|
||
return project
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 项目
|
||
# ---------------------------------------------------------------------------
|
||
@app.route('/api/projects', methods=['GET', 'POST'])
|
||
@require_auth
|
||
def projects():
|
||
if request.method == 'POST':
|
||
err = _check_perm_point('project.create')
|
||
if err:
|
||
return err
|
||
d = request.get_json(force=True)
|
||
# 送达者:必须选择系统用户(绑定 user_id,邮箱实时取)
|
||
duid = d.get('deliver_user_id')
|
||
if not duid:
|
||
return jsonify({'ok': False, 'error': '必填:选择项目送达者用户(项目完成/难关时通知)'}), 400
|
||
du = db.q('SELECT id, email FROM users WHERE id=?', (int(duid),), one=True)
|
||
if not du:
|
||
return jsonify({'ok': False, 'error': '送达者用户不存在'}), 400
|
||
if not (du.get('email') or '').strip():
|
||
return jsonify({'ok': False, 'error': f'送达者用户「{d.get("deliver_username", "")}」未设置邮箱,请先在用户管理中补全'}), 400
|
||
# V3.3:必须指定 AI 主管(管理者)与干活团队(≥1 个 Worker)
|
||
mgr_id = d.get('manager_worker_id')
|
||
if not mgr_id:
|
||
return jsonify({'ok': False, 'error': '必填:选择 AI 主管 Worker(负责拆解任务、派活、监控)'}), 400
|
||
mgr = db.q('SELECT * FROM workers WHERE id=? AND status="enabled"', (int(mgr_id),), one=True)
|
||
if not mgr:
|
||
return jsonify({'ok': False, 'error': 'AI 主管 Worker 不存在或已停用'}), 400
|
||
team_ids = [int(x) for x in (d.get('team_worker_ids') or []) if x]
|
||
if not team_ids:
|
||
return jsonify({'ok': False, 'error': '必填:至少勾选 1 个干活 AI Worker 供 AI 主管支配'}), 400
|
||
team = db.q(f'SELECT * FROM workers WHERE id IN ({scope_args_ph(team_ids)}) AND status="enabled"',
|
||
tuple(team_ids))
|
||
if len(team) != len(set(team_ids)):
|
||
return jsonify({'ok': False, 'error': '存在不可用的干活 Worker(不存在或已停用)'}), 400
|
||
review_required = 1 if d.get('auto_review') else 0
|
||
pid = db.w(
|
||
'INSERT INTO projects (name, description, objective, acceptance_criteria, '
|
||
'status, budget_limit, deliver_user_id, deliver_email, deliver_type, deliver_note, workspace_dir, '
|
||
'manager_worker_id, auto_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||
(d.get('name', '').strip(), d.get('description', ''), d.get('objective', ''),
|
||
d.get('acceptance_criteria', ''), d.get('status', 'active'),
|
||
float(d.get('budget_limit') or 0), int(duid), du['email'],
|
||
d.get('deliver_type', 'web'), d.get('deliver_note', ''),
|
||
'', int(mgr_id), 'none', db.now(), db.now()))
|
||
# 工作目录名依赖自增 id:先拿到 id 再补写
|
||
db.w('UPDATE projects SET workspace_dir=? WHERE id=?', (f'project_{pid}', pid))
|
||
delivery.workspace_path(pid)
|
||
# 干活团队
|
||
autostart.set_team(pid, team_ids)
|
||
# 创建者自动成为项目管理员
|
||
u = _session_user()
|
||
if u and not _is_super():
|
||
db.w('INSERT INTO user_projects (user_id, project_id, perm, created_at) VALUES (?,?,?,?)',
|
||
(u['id'], pid, 'admin', db.now()))
|
||
enterprise.audit(enterprise.current_actor(), 'project.create', f'project#{pid}',
|
||
f'「{d.get("name","")}」送达者用户#{duid},AI主管#{mgr_id},团队{len(team_ids)}人',
|
||
request.remote_addr or '')
|
||
# 🚀 创建后立即自动开工:AI 主管规划 → 派活 → 执行 → 监控
|
||
autostart.launch(pid, review_required=review_required)
|
||
return jsonify({'ok': True, 'id': pid, 'auto_started': True})
|
||
err = _check_perm_point('project.view')
|
||
if err:
|
||
return err
|
||
rows = db.q('SELECT * FROM projects ORDER BY id DESC')
|
||
visible = _visible_project_ids()
|
||
if visible is not None:
|
||
rows = [r for r in rows if r['id'] in visible]
|
||
for r in rows:
|
||
r['task_count'] = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND deleted=0', (r['id'],))[0]['c']
|
||
r['done_count'] = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND status="done" AND deleted=0',
|
||
(r['id'],))[0]['c']
|
||
r['perm'] = _project_perm(r['id'])
|
||
_attach_deliver_user(r)
|
||
_attach_ai_team(r)
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
@app.route('/api/projects/<int:pid>', methods=['GET', 'PUT', 'DELETE'])
|
||
@require_auth
|
||
def project_detail(pid):
|
||
if request.method == 'GET':
|
||
err = _check_perm_point('project.view')
|
||
if err:
|
||
return err
|
||
err = _check_project_perm(pid, 'view')
|
||
if err:
|
||
return err
|
||
p = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||
if not p:
|
||
return jsonify({'ok': False, 'error': '项目不存在'}), 404
|
||
p['perm'] = _project_perm(pid)
|
||
_attach_deliver_user(p)
|
||
_attach_ai_team(p)
|
||
return jsonify({'ok': True, 'data': p})
|
||
if request.method == 'DELETE':
|
||
err = _check_project_perm(pid, 'admin')
|
||
if err:
|
||
return err
|
||
db.w('DELETE FROM user_projects WHERE project_id=?', (pid,))
|
||
db.w('DELETE FROM project_deliverables WHERE project_id=?', (pid,))
|
||
db.w('DELETE FROM tasks WHERE project_id=?', (pid,))
|
||
db.w('DELETE FROM cost_records WHERE project_id=?', (pid,))
|
||
db.w('DELETE FROM project_team_workers WHERE project_id=?', (pid,))
|
||
db.w('DELETE FROM project_logs WHERE project_id=?', (pid,))
|
||
db.w('DELETE FROM projects WHERE id=?', (pid,))
|
||
import shutil
|
||
for d in (delivery.workspace_path(pid), delivery.demo_path(pid)):
|
||
if os.path.isdir(d):
|
||
shutil.rmtree(d, ignore_errors=True)
|
||
return jsonify({'ok': True})
|
||
err = _check_project_perm(pid, 'manage')
|
||
if err:
|
||
return err
|
||
d = request.get_json(force=True)
|
||
fields = ['name', 'description', 'objective', 'acceptance_criteria', 'status', 'budget_limit',
|
||
'deliver_email', 'deliver_type', 'deliver_note']
|
||
sets, args = [], []
|
||
for f in fields:
|
||
if f in d:
|
||
if f == 'deliver_email':
|
||
v = (d[f] or '').strip()
|
||
if v and '@' not in v:
|
||
return jsonify({'ok': False, 'error': '送达者邮箱格式不正确'}), 400
|
||
sets.append(f'{f}=?')
|
||
args.append(v)
|
||
else:
|
||
sets.append(f'{f}=?')
|
||
args.append(d[f])
|
||
if sets:
|
||
args.append(db.now())
|
||
db.w(f'UPDATE projects SET {", ".join(sets)}, updated_at=? WHERE id=?', (*args, pid))
|
||
# 送达者变更:校验用户 + 同步邮箱快照
|
||
if 'deliver_user_id' in d:
|
||
du = db.q('SELECT id, email FROM users WHERE id=?', (int(d['deliver_user_id']),), one=True)
|
||
if not du:
|
||
return jsonify({'ok': False, 'error': '送达者用户不存在'}), 400
|
||
if not (du.get('email') or '').strip():
|
||
return jsonify({'ok': False, 'error': '送达者用户未设置邮箱,请先补全'}), 400
|
||
db.w('UPDATE projects SET deliver_user_id=?, deliver_email=?, updated_at=? WHERE id=?',
|
||
(int(d['deliver_user_id']), du['email'], db.now(), pid))
|
||
# V3.3:AI 主管 / 干活团队变更(下次「开工」生效)
|
||
if 'manager_worker_id' in d and d.get('manager_worker_id'):
|
||
mgr = db.q('SELECT id FROM workers WHERE id=? AND status="enabled"',
|
||
(int(d['manager_worker_id']),), one=True)
|
||
if not mgr:
|
||
return jsonify({'ok': False, 'error': 'AI 主管 Worker 不存在或已停用'}), 400
|
||
db.w('UPDATE projects SET manager_worker_id=?, updated_at=? WHERE id=?',
|
||
(int(d['manager_worker_id']), db.now(), pid))
|
||
if 'team_worker_ids' in d:
|
||
team_ids = [int(x) for x in (d.get('team_worker_ids') or []) if x]
|
||
if not team_ids:
|
||
return jsonify({'ok': False, 'error': '至少勾选 1 个干活 AI Worker'}), 400
|
||
team = db.q(f'SELECT id FROM workers WHERE id IN ({scope_args_ph(team_ids)}) AND status="enabled"',
|
||
tuple(team_ids))
|
||
if len(team) != len(set(team_ids)):
|
||
return jsonify({'ok': False, 'error': '存在不可用的干活 Worker(不存在或已停用)'}), 400
|
||
autostart.set_team(pid, team_ids)
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Worker
|
||
# ---------------------------------------------------------------------------
|
||
@app.route('/api/workers', methods=['GET', 'POST'])
|
||
@require_auth
|
||
def workers():
|
||
if request.method == 'POST':
|
||
err = _check_perm_point('worker.create')
|
||
if err:
|
||
return err
|
||
if not (enterprise.current_actor().startswith('token:') or _role() == 'admin'):
|
||
return jsonify({'ok': False, 'error': '仅管理员可创建 Worker'}), 403
|
||
d = request.get_json(force=True)
|
||
wid = db.w(
|
||
'INSERT INTO workers (name, description, provider, model, base_url, api_key, '
|
||
'system_prompt, temperature, max_tokens, task_cost_limit, monthly_cost_limit, '
|
||
'status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||
(d.get('name', '').strip(), d.get('description', ''), d.get('provider', ''),
|
||
d.get('model', ''), d.get('base_url', ''), d.get('api_key', ''),
|
||
d.get('system_prompt', ''), float(d.get('temperature', 0.7)),
|
||
int(d.get('max_tokens', 2000)), float(d.get('task_cost_limit') or 0),
|
||
float(d.get('monthly_cost_limit') or 0), d.get('status', 'enabled'),
|
||
db.now(), db.now()))
|
||
return jsonify({'ok': True, 'id': wid})
|
||
err = _check_perm_point('worker.view')
|
||
if err:
|
||
return err
|
||
rows = db.q('SELECT * FROM workers ORDER BY id DESC')
|
||
visible = _visible_worker_ids()
|
||
if visible is not None:
|
||
rows = [r for r in rows if r['id'] in visible]
|
||
for r in rows:
|
||
r['month_cost'] = round(db.monthly_worker_cost(r['id']), 6)
|
||
r['perm'] = _worker_perm(r['id'])
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
@app.route('/api/workers/<int:wid>', methods=['GET', 'PUT', 'DELETE'])
|
||
@require_auth
|
||
def worker_detail(wid):
|
||
if request.method == 'GET':
|
||
err = _check_perm_point('worker.view')
|
||
if err:
|
||
return err
|
||
err = _check_worker_perm(wid, 'view')
|
||
if err:
|
||
return err
|
||
w = db.q('SELECT * FROM workers WHERE id=?', (wid,), one=True)
|
||
w['perm'] = _worker_perm(wid)
|
||
return jsonify({'ok': True, 'data': w}) if w else (jsonify({'ok': False, 'error': '不存在'}), 404)
|
||
if request.method == 'DELETE':
|
||
err = _check_worker_perm(wid, 'manage')
|
||
if err:
|
||
return err
|
||
if not (enterprise.current_actor().startswith('token:') or _role() == 'admin'):
|
||
return jsonify({'ok': False, 'error': '仅管理员可删除 Worker'}), 403
|
||
db.w('UPDATE tasks SET worker_id=NULL WHERE worker_id=?', (wid,))
|
||
db.w('DELETE FROM user_workers WHERE worker_id=?', (wid,))
|
||
db.w('DELETE FROM workers WHERE id=?', (wid,))
|
||
return jsonify({'ok': True})
|
||
err = _check_worker_perm(wid, 'manage')
|
||
if err:
|
||
return err
|
||
if not (enterprise.current_actor().startswith('token:') or _role() == 'admin'):
|
||
return jsonify({'ok': False, 'error': '仅管理员可修改 Worker 配置'}), 403
|
||
d = request.get_json(force=True)
|
||
fields = ['name', 'description', 'provider', 'model', 'base_url', 'api_key',
|
||
'system_prompt', 'temperature', 'max_tokens', 'task_cost_limit',
|
||
'monthly_cost_limit', 'status']
|
||
sets, args = [], []
|
||
for f in fields:
|
||
if f in d:
|
||
sets.append(f'{f}=?')
|
||
args.append(d[f])
|
||
if sets:
|
||
args.append(db.now())
|
||
db.w(f'UPDATE workers SET {", ".join(sets)}, updated_at=? WHERE id=?', (*args, wid))
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@app.route('/api/workers/<int:wid>/test', methods=['POST'])
|
||
@require_auth
|
||
def worker_test(wid):
|
||
err = _check_perm_point('worker.view')
|
||
if err:
|
||
return err
|
||
err = _check_worker_perm(wid, 'use')
|
||
if err:
|
||
return err
|
||
w = db.q('SELECT * FROM workers WHERE id=?', (wid,), one=True)
|
||
if not w:
|
||
return jsonify({'ok': False, 'error': '不存在'}), 404
|
||
try:
|
||
r = llm_gateway.test_connection(w['provider'], w['model'],
|
||
base_url=w['base_url'] or None,
|
||
api_key=w['api_key'] or None)
|
||
return jsonify({'ok': True, 'data': r})
|
||
except Exception as e:
|
||
return jsonify({'ok': False, 'error': str(e)})
|
||
|
||
|
||
@app.route('/api/workers/<int:wid>/vision_test', methods=['POST'])
|
||
@require_auth
|
||
def worker_vision_test(wid):
|
||
"""视觉智能体能力测试:传入图片(URL 或 base64)与问题,验证多模态理解"""
|
||
err = _check_perm_point('worker.view')
|
||
if err:
|
||
return err
|
||
err = _check_worker_perm(wid, 'use')
|
||
if err:
|
||
return err
|
||
w = db.q('SELECT * FROM workers WHERE id=?', (wid,), one=True)
|
||
if not w:
|
||
return jsonify({'ok': False, 'error': '不存在'}), 404
|
||
d = request.get_json(force=True)
|
||
image_url = (d.get('image_url') or '').strip()
|
||
image_b64 = (d.get('image_base64') or '').strip()
|
||
question = (d.get('question') or '请描述这张图片的内容').strip()
|
||
if not image_url and not image_b64:
|
||
return jsonify({'ok': False, 'error': '请提供图片 URL 或 base64 数据'}), 400
|
||
try:
|
||
if image_b64:
|
||
r = llm_gateway.chat_vision(
|
||
w['provider'], w['model'], question, image_url=f'data:image/png;base64,{image_b64}',
|
||
temperature=0.3, max_tokens=w['max_tokens'] or 2000,
|
||
base_url=w['base_url'] or None, api_key=w['api_key'] or None)
|
||
else:
|
||
r = llm_gateway.chat_vision(
|
||
w['provider'], w['model'], question, image_url=image_url,
|
||
temperature=0.3, max_tokens=w['max_tokens'] or 2000,
|
||
base_url=w['base_url'] or None, api_key=w['api_key'] or None)
|
||
return jsonify({'ok': True, 'data': r})
|
||
except Exception as e:
|
||
return jsonify({'ok': False, 'error': str(e)})
|
||
|
||
|
||
@app.route('/api/providers')
|
||
@require_auth
|
||
def providers():
|
||
data = []
|
||
for k, v in config.PROVIDERS.items():
|
||
data.append({
|
||
'id': k, 'name': v['name'], 'base_url': v['base_url'],
|
||
'has_key': bool(v['api_key']),
|
||
'models': [m for m in config.MODEL_PRICING.keys() if m.startswith(
|
||
{'doubao': 'doubao', 'deepseek': 'deepseek', 'openai': 'gpt',
|
||
'qwen': 'qwen', 'vllm': '', 'autodl': 'qwen3'}.get(k, '__none__'))],
|
||
})
|
||
return jsonify({'ok': True, 'data': data})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 任务
|
||
# ---------------------------------------------------------------------------
|
||
@app.route('/api/tasks', methods=['GET', 'POST'])
|
||
@require_auth
|
||
def tasks():
|
||
if request.method == 'POST':
|
||
d = request.get_json(force=True)
|
||
err = _check_project_perm(d.get('project_id'), 'manage')
|
||
if err:
|
||
return err
|
||
if d.get('worker_id'):
|
||
werr = _check_worker_perm(d['worker_id'], 'use')
|
||
if werr:
|
||
return werr
|
||
if d.get('depends_on'):
|
||
ok, errmsg = _validate_depends_on(d.get('project_id'), None, d['depends_on'])
|
||
if not ok:
|
||
return jsonify({'ok': False, 'error': errmsg}), 400
|
||
tid = db.w(
|
||
'INSERT INTO tasks (project_id, worker_id, title, description, priority, '
|
||
'review_required, deadline, depends_on, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)',
|
||
(d.get('project_id'), d.get('worker_id'), d.get('title', '').strip(),
|
||
d.get('description', ''), d.get('priority', 'medium'),
|
||
1 if d.get('review_required', True) else 0, d.get('deadline', ''),
|
||
_json.dumps(d.get('depends_on') or []), db.now(), db.now()))
|
||
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
|
||
(tid, 'info', f'任务创建:{d.get("title","")}', db.now()))
|
||
return jsonify({'ok': True, 'id': tid})
|
||
pid = request.args.get('project_id')
|
||
if pid:
|
||
err = _check_project_perm(int(pid), 'view')
|
||
if err:
|
||
return err
|
||
rows = db.q('SELECT * FROM tasks WHERE project_id=? AND deleted=0 ORDER BY id DESC', (int(pid),))
|
||
else:
|
||
rows = db.q('SELECT * FROM tasks WHERE deleted=0 ORDER BY id DESC LIMIT 200')
|
||
visible = _visible_project_ids()
|
||
if visible is not None:
|
||
rows = [r for r in rows if r['project_id'] in visible]
|
||
return jsonify({'ok': True, 'data': [db.serialize_task(t) for t in rows]})
|
||
|
||
|
||
@app.route('/api/tasks/<int:tid>', methods=['GET', 'PUT', 'DELETE'])
|
||
@require_auth
|
||
def task_detail(tid):
|
||
t = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True)
|
||
if not t:
|
||
return jsonify({'ok': False, 'error': '任务不存在'}), 404
|
||
if request.method == 'GET':
|
||
err = _check_task_perm(tid, 'view')
|
||
if err:
|
||
return err
|
||
t = db.serialize_task(t)
|
||
t['logs'] = db.q('SELECT * FROM task_logs WHERE task_id=? ORDER BY id', (tid,))
|
||
t['costs'] = db.q('SELECT * FROM cost_records WHERE task_id=? ORDER BY id', (tid,))
|
||
if t['worker_id']:
|
||
t['worker'] = db.q('SELECT id,name,provider,model FROM workers WHERE id=?',
|
||
(t['worker_id'],), one=True)
|
||
return jsonify({'ok': True, 'data': t})
|
||
err = _check_task_perm(tid, 'manage')
|
||
if err:
|
||
return err
|
||
if request.method == 'DELETE':
|
||
# 软删除(进回收站);?hard=1 物理删除
|
||
if request.args.get('hard') == '1':
|
||
db.w('DELETE FROM task_logs WHERE task_id=?', (tid,))
|
||
db.w('DELETE FROM cost_records WHERE task_id=?', (tid,))
|
||
db.w('DELETE FROM tasks WHERE id=?', (tid,))
|
||
return jsonify({'ok': True})
|
||
db.w('UPDATE tasks SET deleted=1, deleted_at=? WHERE id=?', (db.now(), tid))
|
||
return jsonify({'ok': True, 'trashed': True})
|
||
d = request.get_json(force=True)
|
||
# 只允许在非运行中修改基础字段
|
||
if t['status'] == 'running':
|
||
return jsonify({'ok': False, 'error': '任务执行中,禁止修改'}), 400
|
||
fields = ['title', 'description', 'worker_id', 'priority', 'review_required',
|
||
'deadline', 'status']
|
||
sets, args = [], []
|
||
for f in fields:
|
||
if f in d:
|
||
sets.append(f'{f}=?')
|
||
args.append(d[f])
|
||
if 'depends_on' in d:
|
||
ok, err = _validate_depends_on(t['project_id'], tid, d['depends_on'])
|
||
if not ok:
|
||
return jsonify({'ok': False, 'error': err}), 400
|
||
sets.append('depends_on=?')
|
||
args.append(_json.dumps(d['depends_on'] or []))
|
||
if sets:
|
||
args.append(db.now())
|
||
db.w(f'UPDATE tasks SET {", ".join(sets)}, updated_at=? WHERE id=?', (*args, tid))
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@app.route('/api/tasks/trash')
|
||
@require_auth
|
||
def tasks_trash():
|
||
"""回收站:已软删除的任务列表"""
|
||
pid = request.args.get('project_id')
|
||
if pid:
|
||
err = _check_project_perm(int(pid), 'view')
|
||
if err:
|
||
return err
|
||
rows = db.q('SELECT * FROM tasks WHERE deleted=1 AND project_id=? ORDER BY deleted_at DESC',
|
||
(int(pid),))
|
||
else:
|
||
rows = db.q('SELECT * FROM tasks WHERE deleted=1 ORDER BY deleted_at DESC LIMIT 200')
|
||
visible = _visible_project_ids()
|
||
if visible is not None:
|
||
rows = [r for r in rows if r['project_id'] in visible]
|
||
return jsonify({'ok': True, 'data': [db.serialize_task(t) for t in rows]})
|
||
|
||
|
||
@app.route('/api/tasks/<int:tid>/trash', methods=['POST'])
|
||
@require_auth
|
||
def task_trash(tid):
|
||
"""软删除:移入回收站"""
|
||
t = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True)
|
||
if not t:
|
||
return jsonify({'ok': False, 'error': '任务不存在'}), 404
|
||
err = _check_task_perm(tid, 'manage')
|
||
if err:
|
||
return err
|
||
if t['status'] == 'running':
|
||
return jsonify({'ok': False, 'error': '任务执行中,禁止删除'}), 400
|
||
db.w('UPDATE tasks SET deleted=1, deleted_at=? WHERE id=?', (db.now(), tid))
|
||
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
|
||
(tid, 'warn', '🗑️ 任务已移入回收站', db.now()))
|
||
enterprise.audit(enterprise.current_actor(), 'task.trash', f'task#{tid}',
|
||
f'「{t["title"]}」移入回收站', request.remote_addr or '')
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@app.route('/api/tasks/<int:tid>/restore', methods=['POST'])
|
||
@require_auth
|
||
def task_restore(tid):
|
||
"""从回收站恢复任务"""
|
||
t = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True)
|
||
if not t:
|
||
return jsonify({'ok': False, 'error': '任务不存在'}), 404
|
||
err = _check_task_perm(tid, 'manage')
|
||
if err:
|
||
return err
|
||
db.w('UPDATE tasks SET deleted=0, deleted_at=NULL WHERE id=?', (tid,))
|
||
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
|
||
(tid, 'info', '♻️ 任务已从回收站恢复', db.now()))
|
||
enterprise.audit(enterprise.current_actor(), 'task.restore', f'task#{tid}',
|
||
f'「{t["title"]}」恢复', request.remote_addr or '')
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@app.route('/api/tasks/<int:tid>/run', methods=['POST'])
|
||
@require_auth
|
||
def task_run(tid):
|
||
t = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True)
|
||
if not t:
|
||
return jsonify({'ok': False, 'error': '任务不存在'}), 404
|
||
err = _check_task_perm(tid, 'manage')
|
||
if err:
|
||
return err
|
||
if t['deleted']:
|
||
return jsonify({'ok': False, 'error': '任务已在回收站,请先恢复'}), 400
|
||
if t['status'] == 'running':
|
||
return jsonify({'ok': False, 'error': '任务已在执行中'}), 400
|
||
ok, blockers = engine.check_dependencies(t)
|
||
if not ok:
|
||
return jsonify({'ok': False, 'error': '前置任务未完成:' + '、'.join(blockers)}), 400
|
||
if engine.runner.submit(tid):
|
||
enterprise.audit(enterprise.current_actor(), 'task.run', f'task#{tid}',
|
||
f'「{t["title"]}」', request.remote_addr or '')
|
||
return jsonify({'ok': True})
|
||
return jsonify({'ok': False, 'error': '任务已在执行中'}), 400
|
||
|
||
|
||
@app.route('/api/tasks/<int:tid>/cancel', methods=['POST'])
|
||
@require_auth
|
||
def task_cancel(tid):
|
||
t = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True)
|
||
if not t:
|
||
return jsonify({'ok': False, 'error': '任务不存在'}), 404
|
||
err = _check_task_perm(tid, 'manage')
|
||
if err:
|
||
return err
|
||
if t['status'] != 'running':
|
||
return jsonify({'ok': False, 'error': '仅执行中的任务可取消'}), 400
|
||
# MVP:标记取消(线程无法强杀,完成后会回到 review;这里直接置 cancelled 并忽略结果)
|
||
db.w('UPDATE tasks SET status="cancelled", updated_at=?, finished_at=? WHERE id=?',
|
||
(db.now(), db.now(), tid))
|
||
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
|
||
(tid, 'warn', '任务已由人工取消(引擎线程将在后台自然结束)', db.now()))
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@app.route('/api/tasks/<int:tid>/review', methods=['POST'])
|
||
@require_auth
|
||
def task_review(tid):
|
||
t = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True)
|
||
if not t:
|
||
return jsonify({'ok': False, 'error': '任务不存在'}), 404
|
||
err = _check_task_perm(tid, 'manage')
|
||
if err:
|
||
return err
|
||
if t['status'] != 'review':
|
||
return jsonify({'ok': False, 'error': '仅待审核状态可审核'}), 400
|
||
d = request.get_json(force=True)
|
||
action = d.get('action')
|
||
reason = (d.get('reason') or '').strip()
|
||
if action == 'approve':
|
||
db.w('UPDATE tasks SET status="done", updated_at=? WHERE id=?', (db.now(), tid))
|
||
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
|
||
(tid, 'success', '✅ 人工审核通过,任务完成', db.now()))
|
||
enterprise.audit(enterprise.current_actor(), 'task.approve', f'task#{tid}',
|
||
f'「{t["title"]}」审核通过', request.remote_addr or '')
|
||
# DAG:审核放行等同完成,触发下游就绪任务
|
||
fresh = db.q('SELECT * FROM tasks WHERE id=?', (tid,), one=True)
|
||
for t in engine._trigger_downstream(fresh):
|
||
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
|
||
(t['id'], 'info', f'🔗 前置任务「{fresh["title"]}」已验收完成,自动触发执行', db.now()))
|
||
# V3:项目全部任务完成后自动收尾 → 通知送达者
|
||
try:
|
||
r = delivery.auto_complete_if_ready(t['project_id'], request.host_url.rstrip('/'))
|
||
if r:
|
||
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
|
||
(0, 'success', f'🎉 项目全部任务完成,已自动交付并通知送达者({r["msg"]})', db.now()))
|
||
except Exception:
|
||
pass
|
||
return jsonify({'ok': True})
|
||
if action == 'reject':
|
||
if not reason:
|
||
return jsonify({'ok': False, 'error': '打回必须填写原因'}), 400
|
||
db.w('UPDATE tasks SET status="todo", rejection_count=rejection_count+1, '
|
||
'output_text="", updated_at=? WHERE id=?', (db.now(), tid))
|
||
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
|
||
(tid, 'warn', f'⛔ 人工打回:{reason}(等待返工重跑)', db.now()))
|
||
enterprise.audit(enterprise.current_actor(), 'task.reject', f'task#{tid}',
|
||
f'「{t["title"]}」打回:{reason}', request.remote_addr or '')
|
||
return jsonify({'ok': True})
|
||
return jsonify({'ok': False, 'error': '未知操作'}), 400
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 报表 / 统计
|
||
# ---------------------------------------------------------------------------
|
||
@app.route('/api/reports/cost')
|
||
@require_auth
|
||
@require_perm('report.view')
|
||
def report_cost():
|
||
visible = _visible_project_ids()
|
||
scope_sql, scope_args = '', []
|
||
if visible is not None:
|
||
if not visible:
|
||
return jsonify({'ok': True, 'data': []})
|
||
scope_sql = 'WHERE project_id IN (%s)' % ','.join('?' * len(visible))
|
||
scope_args = list(visible)
|
||
group = request.args.get('group', 'project')
|
||
if group == 'worker':
|
||
rows = db.q(
|
||
'SELECT worker_id, provider, model, COUNT(*) runs, SUM(total_tokens) tokens, '
|
||
f'SUM(cost) cost FROM cost_records {scope_sql} GROUP BY worker_id ORDER BY cost DESC',
|
||
scope_args)
|
||
for r in rows:
|
||
w = db.q('SELECT name FROM workers WHERE id=?', (r['worker_id'],), one=True)
|
||
r['worker_name'] = w['name'] if w else f'#{r["worker_id"]}'
|
||
elif group == 'model':
|
||
rows = db.q(
|
||
'SELECT provider, model, COUNT(*) runs, SUM(total_tokens) tokens, '
|
||
f'SUM(cost) cost FROM cost_records {scope_sql} GROUP BY model ORDER BY cost DESC',
|
||
scope_args)
|
||
else:
|
||
rows = db.q(
|
||
'SELECT project_id, COUNT(*) runs, SUM(total_tokens) tokens, '
|
||
f'SUM(cost) cost FROM cost_records {scope_sql} GROUP BY project_id ORDER BY cost DESC',
|
||
scope_args)
|
||
for r in rows:
|
||
p = db.q('SELECT name FROM projects WHERE id=?', (r['project_id'],), one=True)
|
||
r['project_name'] = p['name'] if p else f'#{r["project_id"]}'
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
@app.route('/api/stats')
|
||
@require_auth
|
||
@require_perm('dashboard.view')
|
||
def stats():
|
||
visible = _visible_project_ids()
|
||
scope_sql, scope_args = '', []
|
||
if visible is not None:
|
||
if not visible:
|
||
return jsonify({'ok': True, 'data': {
|
||
'projects': 0, 'tasks': 0, 'workers': 0, 'total_cost': 0, 'total_tokens': 0,
|
||
'by_status': {}, 'recent': [], 'daily_cost': [], 'one_pass_rate': None,
|
||
'rework_count': 0, 'visible_workers': 0}})
|
||
scope_sql = 'WHERE project_id IN (%s)' % ','.join('?' * len(visible))
|
||
scope_args = list(visible)
|
||
out = {'projects': 0, 'tasks': 0, 'workers': 0, 'total_cost': 0, 'total_tokens': 0,
|
||
'by_status': {}, 'recent': [], 'daily_cost': [], 'one_pass_rate': None,
|
||
'rework_count': 0, 'visible_workers': 0}
|
||
if visible is None:
|
||
out['projects'] = db.q('SELECT COUNT(*) c FROM projects')[0]['c']
|
||
out['workers'] = db.q('SELECT COUNT(*) c FROM workers')[0]['c']
|
||
out['tasks'] = db.q('SELECT COUNT(*) c FROM tasks WHERE deleted=0')[0]['c']
|
||
for r in db.q('SELECT status, COUNT(*) c FROM tasks WHERE deleted=0 GROUP BY status'):
|
||
out['by_status'][r['status']] = r['c']
|
||
else:
|
||
out['projects'] = len(visible)
|
||
vw = _visible_worker_ids()
|
||
out['workers'] = len(vw) if vw is not None else db.q('SELECT COUNT(*) c FROM workers')[0]['c']
|
||
out['visible_workers'] = out['workers']
|
||
for r in db.q(f'SELECT project_id, status, COUNT(*) c FROM tasks WHERE deleted=0 '
|
||
f'AND project_id IN ({scope_args_ph(visible)}) GROUP BY project_id, status', visible):
|
||
out['by_status'][r['status']] = out['by_status'].get(r['status'], 0) + r['c']
|
||
out['tasks'] = sum(out['by_status'].values())
|
||
c = db.q(f'SELECT COALESCE(SUM(cost),0) cost, COALESCE(SUM(total_tokens),0) tokens '
|
||
f'FROM cost_records {scope_sql}', scope_args)[0]
|
||
out['total_cost'], out['total_tokens'] = round(c['cost'], 4), c['tokens']
|
||
|
||
# 一次通过率:done 且 rejection_count=0
|
||
done = out['by_status'].get('done', 0)
|
||
if visible is None:
|
||
clean = db.q('SELECT COUNT(*) c FROM tasks WHERE status="done" AND rejection_count=0 AND deleted=0')[0]['c']
|
||
out['rework_count'] = db.q('SELECT COALESCE(SUM(rejection_count),0) c FROM tasks WHERE deleted=0')[0]['c']
|
||
else:
|
||
clean = db.q(f'SELECT COUNT(*) c FROM tasks WHERE status="done" AND rejection_count=0 AND deleted=0 '
|
||
f'AND project_id IN ({scope_args_ph(visible)})', visible)[0]['c']
|
||
out['rework_count'] = db.q(f'SELECT COALESCE(SUM(rejection_count),0) c FROM tasks WHERE deleted=0 '
|
||
f'AND project_id IN ({scope_args_ph(visible)})', visible)[0]['c']
|
||
out['one_pass_rate'] = round(clean / done * 100, 1) if done else None
|
||
|
||
out['recent'] = db.q(
|
||
'SELECT t.id, t.title, t.status, t.project_id, p.name AS project_name, t.updated_at '
|
||
'FROM tasks t LEFT JOIN projects p ON p.id=t.project_id '
|
||
'WHERE t.deleted=0 '
|
||
+ (f'AND t.project_id IN ({scope_args_ph(visible)}) ' if visible is not None else '') +
|
||
'ORDER BY t.updated_at DESC LIMIT 10', visible or ())
|
||
|
||
# 近 7 天成本
|
||
import datetime
|
||
rows = db.q(f'SELECT created_at, cost FROM cost_records {scope_sql} ORDER BY created_at', scope_args)
|
||
day_map = {}
|
||
for r in rows:
|
||
d = datetime.datetime.fromtimestamp(r['created_at']).strftime('%m-%d')
|
||
day_map[d] = round(day_map.get(d, 0) + r['cost'], 4)
|
||
for i in range(6, -1, -1):
|
||
d = (datetime.datetime.now() - datetime.timedelta(days=i)).strftime('%m-%d')
|
||
out['daily_cost'].append({'day': d, 'cost': day_map.get(d, 0)})
|
||
return jsonify({'ok': True, 'data': out})
|
||
|
||
|
||
def scope_args_ph(ids):
|
||
"""生成 ? 占位符串"""
|
||
return ','.join('?' * len(ids)) if ids else 'NULL'
|
||
|
||
|
||
@app.route('/api/logs')
|
||
@require_auth
|
||
@require_perm('log.view')
|
||
def logs():
|
||
limit = min(int(request.args.get('limit', 100)), 500)
|
||
visible = _visible_project_ids()
|
||
if visible is None:
|
||
rows = db.q(
|
||
'SELECT l.*, t.title AS task_title FROM task_logs l '
|
||
'LEFT JOIN tasks t ON t.id=l.task_id ORDER BY l.id DESC LIMIT ?', (limit,))
|
||
else:
|
||
if not visible:
|
||
return jsonify({'ok': True, 'data': []})
|
||
rows = db.q(
|
||
f'SELECT l.*, t.title AS task_title FROM task_logs l '
|
||
f'LEFT JOIN tasks t ON t.id=l.task_id '
|
||
f'WHERE (t.project_id IN ({scope_args_ph(visible)}) OR t.id IS NULL) '
|
||
f'ORDER BY l.id DESC LIMIT ?', (*visible, limit))
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
@app.route('/api/projects/<int:pid>/workflow/run', methods=['POST'])
|
||
@require_auth
|
||
def workflow_run(pid):
|
||
"""执行整个工作流:跑所有就绪(无未完成前置)任务"""
|
||
err = _check_project_perm(pid, 'manage')
|
||
if err:
|
||
return err
|
||
rows = db.q('SELECT * FROM tasks WHERE project_id=? AND deleted=0 AND status IN ("todo","failed")', (pid,))
|
||
started, blocked = [], []
|
||
for t in rows:
|
||
ok, blockers = engine.check_dependencies(t)
|
||
if ok:
|
||
if engine.runner.submit(t['id']):
|
||
started.append({'id': t['id'], 'title': t['title']})
|
||
else:
|
||
blocked.append({'id': t['id'], 'title': t['title'], 'by': blockers})
|
||
return jsonify({'ok': True, 'started': started, 'blocked': blocked})
|
||
|
||
|
||
@app.route('/api/projects/<int:pid>/autostart', methods=['POST'])
|
||
@require_auth
|
||
def project_autostart(pid):
|
||
"""V3.3 AI 主管开工/继续开工:拆解(无任务时)→ 派活 → 执行 → 监控
|
||
换过 AI 主管/团队后调用即按新配置生效;已有任务跳过规划直接跑存量。"""
|
||
err = _check_project_perm(pid, 'manage')
|
||
if err:
|
||
return err
|
||
p = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||
if not p:
|
||
return jsonify({'ok': False, 'error': '项目不存在'}), 404
|
||
if not p.get('manager_worker_id'):
|
||
return jsonify({'ok': False, 'error': '请先在编辑项目中设置 AI 主管 Worker'}), 400
|
||
if not db.q('SELECT 1 FROM project_team_workers WHERE project_id=?', (pid,)):
|
||
return jsonify({'ok': False, 'error': '请先在编辑项目中勾选干活团队 Worker(≥1 个)'}), 400
|
||
d = request.get_json(silent=True) or {}
|
||
ok = autostart.launch(pid, review_required=1 if d.get('auto_review') else 0)
|
||
if not ok:
|
||
return jsonify({'ok': False, 'error': 'AI 主管正在工作中,请稍候'}), 400
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@app.route('/api/projects/<int:pid>/autologs')
|
||
@require_auth
|
||
def project_autologs(pid):
|
||
"""V3.3 AI 主管项目级动态(拆解/派活/监控/诊断重试)"""
|
||
err = _check_project_perm(pid, 'view')
|
||
if err:
|
||
return err
|
||
rows = db.q('SELECT * FROM project_logs WHERE project_id=? ORDER BY id DESC LIMIT 50', (pid,))
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
@app.route('/api/projects/<int:pid>/dag')
|
||
@require_auth
|
||
def project_dag(pid):
|
||
"""DAG 图数据:节点 + 边"""
|
||
err = _check_project_perm(pid, 'view')
|
||
if err:
|
||
return err
|
||
rows = db.q('SELECT * FROM tasks WHERE project_id=? AND deleted=0 ORDER BY id', (pid,))
|
||
nodes, edges, id_map = [], [], {}
|
||
for t in rows:
|
||
node = db.serialize_task(t)
|
||
nodes.append(node)
|
||
id_map[t['id']] = node
|
||
for t in nodes:
|
||
for dep_id in t['depends_on']:
|
||
if dep_id in id_map:
|
||
edges.append({'from': dep_id, 'to': t['id']})
|
||
return jsonify({'ok': True, 'nodes': nodes, 'edges': edges})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# AI 辅助规划(WBS 生成 + 导入)
|
||
# 提示词与解析复用 autostart.py(V3.3 与 AI 主管自动开工同一套)
|
||
# ---------------------------------------------------------------------------
|
||
WBS_PROMPT = autostart.WBS_PROMPT
|
||
|
||
|
||
def _parse_wbs(text):
|
||
return autostart.parse_wbs(text)
|
||
|
||
|
||
@app.route('/api/projects/<int:pid>/wbs/generate', methods=['POST'])
|
||
@require_auth
|
||
def wbs_generate(pid):
|
||
err = _check_project_perm(pid, 'manage')
|
||
if err:
|
||
return err
|
||
d = request.get_json(force=True) or {}
|
||
goal = d.get('goal') or ''
|
||
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||
if not proj:
|
||
return jsonify({'ok': False, 'error': '项目不存在'}), 404
|
||
if not goal:
|
||
goal = proj.get('objective') or proj.get('name') or ''
|
||
if not goal:
|
||
return jsonify({'ok': False, 'error': '请提供项目目标'}), 400
|
||
worker = None
|
||
vw = _visible_worker_ids()
|
||
if vw is None:
|
||
worker = db.q('SELECT * FROM workers WHERE status="enabled" ORDER BY id', one=True)
|
||
else:
|
||
candidates = [r for r in db.q('SELECT * FROM workers WHERE status="enabled" ORDER BY id') if r['id'] in vw]
|
||
worker = candidates[0] if candidates else None
|
||
if not worker:
|
||
return jsonify({'ok': False, 'error': '请先注册至少一个 Worker 用于规划(或联系管理员授权)'}), 400
|
||
try:
|
||
r = llm_gateway.chat(worker['provider'], worker['model'], [
|
||
{'role': 'system', 'content': '你只输出 JSON,不输出任何解释文字。'},
|
||
{'role': 'user', 'content': WBS_PROMPT.format(goal=goal, accept=proj.get('acceptance_criteria') or '—')},
|
||
], temperature=0.3, max_tokens=3000)
|
||
tasks = _parse_wbs(r['text'])
|
||
return jsonify({'ok': True, 'data': tasks, 'usage': r['total_tokens'], 'cost': r['cost']})
|
||
except Exception as e:
|
||
return jsonify({'ok': False, 'error': f'WBS 生成失败:{e}'}), 500
|
||
|
||
|
||
@app.route('/api/projects/<int:pid>/wbs/import', methods=['POST'])
|
||
@require_auth
|
||
def wbs_import(pid):
|
||
err = _check_project_perm(pid, 'manage')
|
||
if err:
|
||
return err
|
||
d = request.get_json(force=True)
|
||
tasks = d.get('tasks') or []
|
||
worker_id = d.get('worker_id')
|
||
if not tasks:
|
||
return jsonify({'ok': False, 'error': '任务列表为空'}), 400
|
||
created = []
|
||
for i, t in enumerate(tasks):
|
||
dep_idx = t.get('depends_on') or []
|
||
dep_ids = [created[idx] for idx in dep_idx
|
||
if isinstance(idx, int) and 0 <= idx < len(created)]
|
||
tid = db.w(
|
||
'INSERT INTO tasks (project_id, worker_id, title, description, priority, '
|
||
'review_required, deadline, depends_on, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)',
|
||
(pid, worker_id, t.get('title', f'任务{i+1}'), t.get('description', ''),
|
||
t.get('priority', 'medium'), 1, '', _json.dumps(dep_ids), db.now(), db.now()))
|
||
created.append(tid)
|
||
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
|
||
(tid, 'info', f'AI 规划导入:{t.get("title", "")}', db.now()))
|
||
return jsonify({'ok': True, 'created': len(created), 'ids': created})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# RAG 知识库
|
||
# ---------------------------------------------------------------------------
|
||
@app.route('/api/projects/<int:pid>/documents', methods=['GET', 'POST'])
|
||
@require_auth
|
||
def documents(pid):
|
||
if request.method == 'POST':
|
||
err = _check_project_perm(pid, 'manage')
|
||
if err:
|
||
return err
|
||
d = request.get_json(force=True)
|
||
doc_id = db.w(
|
||
'INSERT INTO documents (project_id, name, content, source, created_at, updated_at) '
|
||
'VALUES (?,?,?,?,?,?)',
|
||
(pid, d.get('name', '未命名文档').strip(), d.get('content', ''),
|
||
d.get('source', 'manual'), db.now(), db.now()))
|
||
n = rag.rebuild_document(doc_id)
|
||
return jsonify({'ok': True, 'id': doc_id, 'chunks': n})
|
||
err = _check_project_perm(pid, 'view')
|
||
if err:
|
||
return err
|
||
rows = db.q('SELECT * FROM documents WHERE project_id=? ORDER BY id DESC', (pid,))
|
||
for r in rows:
|
||
r['chunks'] = db.q('SELECT COUNT(*) c FROM doc_chunks WHERE document_id=?', (r['id'],))[0]['c']
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
@app.route('/api/documents/<int:doc_id>', methods=['GET', 'PUT', 'DELETE'])
|
||
@require_auth
|
||
def document_detail(doc_id):
|
||
doc = db.q('SELECT * FROM documents WHERE id=?', (doc_id,), one=True)
|
||
if not doc:
|
||
return jsonify({'ok': False, 'error': '文档不存在'}), 404
|
||
err = _check_project_perm(doc['project_id'], 'view' if request.method == 'GET' else 'manage')
|
||
if err:
|
||
return err
|
||
if request.method == 'GET':
|
||
return jsonify({'ok': True, 'data': doc})
|
||
if request.method == 'DELETE':
|
||
db.w('DELETE FROM doc_chunks WHERE document_id=?', (doc_id,))
|
||
db.w('DELETE FROM documents WHERE id=?', (doc_id,))
|
||
return jsonify({'ok': True})
|
||
d = request.get_json(force=True)
|
||
if 'content' in d:
|
||
db.w('UPDATE documents SET content=?, updated_at=? WHERE id=?', (d['content'], db.now(), doc_id))
|
||
rag.rebuild_document(doc_id)
|
||
if 'name' in d:
|
||
db.w('UPDATE documents SET name=? WHERE id=?', (d['name'].strip(), doc_id))
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@app.route('/api/projects/<int:pid>/search')
|
||
@require_auth
|
||
def kb_search(pid):
|
||
err = _check_project_perm(pid, 'view')
|
||
if err:
|
||
return err
|
||
q = request.args.get('q', '')
|
||
if not q:
|
||
return jsonify({'ok': True, 'data': []})
|
||
hits, hit = rag.search_project(pid, q, top_k=5)
|
||
return jsonify({'ok': True, 'data': hits if hit else [], 'hit': hit})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# V3 · 交付体系:工作目录 / Demo 部署 / 打包 / 邮件送达
|
||
# ---------------------------------------------------------------------------
|
||
@app.route('/api/projects/<int:pid>/deliverables')
|
||
@require_auth
|
||
def project_deliverables(pid):
|
||
"""交付记录列表"""
|
||
err = _check_project_perm(pid, 'view')
|
||
if err:
|
||
return err
|
||
rows = db.q('SELECT * FROM project_deliverables WHERE project_id=? ORDER BY id DESC', (pid,))
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
@app.route('/api/projects/<int:pid>/workspace')
|
||
@require_auth
|
||
def project_workspace_list(pid):
|
||
"""项目工作目录文件列表"""
|
||
err = _check_project_perm(pid, 'view')
|
||
if err:
|
||
return err
|
||
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||
files = delivery.list_workspace(proj)
|
||
return jsonify({'ok': True, 'data': files, 'dir': f'data/workspace/project_{pid}/'})
|
||
|
||
|
||
@app.route('/api/projects/<int:pid>/workspace/upload', methods=['POST'])
|
||
@require_auth
|
||
def project_workspace_upload(pid):
|
||
"""上传交付物文件到项目工作目录(multipart,支持多文件)"""
|
||
err = _check_project_perm(pid, 'manage')
|
||
if err:
|
||
return err
|
||
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||
if 'files' not in request.files:
|
||
return jsonify({'ok': False, 'error': '缺少文件字段 files'}), 400
|
||
saved = []
|
||
try:
|
||
for fs in request.files.getlist('files'):
|
||
if fs and fs.filename:
|
||
rel = delivery.save_upload(proj, fs)
|
||
saved.append(rel)
|
||
except Exception as e:
|
||
return jsonify({'ok': False, 'error': f'上传失败:{e}'}), 400
|
||
enterprise.audit(enterprise.current_actor(), 'deliver.upload', f'project#{pid}',
|
||
f'上传 {len(saved)} 个文件:{", ".join(saved)[:200]}', request.remote_addr or '')
|
||
return jsonify({'ok': True, 'saved': saved})
|
||
|
||
|
||
@app.route('/api/projects/<int:pid>/workspace/download')
|
||
@require_auth
|
||
def project_workspace_download(pid):
|
||
"""下载工作目录文件"""
|
||
err = _check_project_perm(pid, 'view')
|
||
if err:
|
||
return err
|
||
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||
try:
|
||
rel = delivery._safe_relpath(request.args.get('path', ''))
|
||
except ValueError as e:
|
||
return jsonify({'ok': False, 'error': str(e)}), 400
|
||
return send_from_directory(delivery.workspace_path(proj), rel, as_attachment=True)
|
||
|
||
|
||
@app.route('/api/projects/<int:pid>/workspace', methods=['DELETE'])
|
||
@require_auth
|
||
def project_workspace_delete(pid):
|
||
"""删除工作目录文件"""
|
||
err = _check_project_perm(pid, 'manage')
|
||
if err:
|
||
return err
|
||
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||
try:
|
||
rel = delivery.delete_workspace_file(proj, request.args.get('path', ''))
|
||
except Exception as e:
|
||
return jsonify({'ok': False, 'error': str(e)}), 400
|
||
return jsonify({'ok': True, 'deleted': rel})
|
||
|
||
|
||
@app.route('/api/projects/<int:pid>/deploy', methods=['POST'])
|
||
@require_auth
|
||
def project_deploy(pid):
|
||
"""网页交付物:部署到 Demo 地址(送达者可直接访问)"""
|
||
err = _check_project_perm(pid, 'manage')
|
||
if err:
|
||
return err
|
||
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||
if proj['deliver_type'] != 'web':
|
||
return jsonify({'ok': False, 'error': '该项目交付类型不是网页(web),无需部署 Demo'}), 400
|
||
base = request.host_url.rstrip('/')
|
||
try:
|
||
r = delivery.deploy_demo(proj, base)
|
||
except Exception as e:
|
||
return jsonify({'ok': False, 'error': f'部署失败:{e}'}), 500
|
||
enterprise.audit(enterprise.current_actor(), 'deliver.deploy', f'project#{pid}',
|
||
f'Demo 部署 {r["demo_url"]}({r["copied"]} 个文件)', request.remote_addr or '')
|
||
return jsonify({'ok': True, **r})
|
||
|
||
|
||
@app.route('/api/projects/<int:pid>/package', methods=['POST'])
|
||
@require_auth
|
||
def project_package(pid):
|
||
"""把项目工作目录打包为 zip"""
|
||
err = _check_project_perm(pid, 'manage')
|
||
if err:
|
||
return err
|
||
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||
try:
|
||
pkg = delivery.package_project(proj)
|
||
except Exception as e:
|
||
return jsonify({'ok': False, 'error': f'打包失败:{e}'}), 500
|
||
enterprise.audit(enterprise.current_actor(), 'deliver.package', f'project#{pid}',
|
||
f'打包 {pkg["name"]}({pkg["size"]} B)', request.remote_addr or '')
|
||
return jsonify({'ok': True, **pkg})
|
||
|
||
|
||
@app.route('/api/projects/<int:pid>/deliver', methods=['POST'])
|
||
@require_auth
|
||
def project_deliver(pid):
|
||
"""把项目交付物发送给送达者:网页→Demo 链接;文件→打包附件;全部走邮件"""
|
||
err = _check_project_perm(pid, 'manage')
|
||
if err:
|
||
return err
|
||
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||
if not delivery.deliver_email_of(proj):
|
||
return jsonify({'ok': False, 'error': '项目未配置送达者用户,请先在编辑项目中指定'}), 400
|
||
base = request.host_url.rstrip('/')
|
||
# 网页交付物:确保 Demo 已部署
|
||
demo = proj.get('demo_url') or ''
|
||
if proj['deliver_type'] == 'web' and not demo:
|
||
try:
|
||
r = delivery.deploy_demo(proj, base)
|
||
demo = r['demo_url']
|
||
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||
except Exception as e:
|
||
return jsonify({'ok': False, 'error': f'Demo 部署失败:{e}'}), 500
|
||
# 打包附件
|
||
attach = None
|
||
try:
|
||
pkg = delivery.package_project(proj)
|
||
attach = pkg['path']
|
||
except Exception as e:
|
||
return jsonify({'ok': False, 'error': f'打包失败:{e}'}), 500
|
||
s = delivery.project_summary(proj)
|
||
kind = '网页 Demo' if proj['deliver_type'] == 'web' else '文件包'
|
||
extra = (f'本次交付:{kind}\n'
|
||
f'任务完成:{s["done"]}/{s["total"]} · 累计成本 ¥{s["cost"]:.4f}\n'
|
||
f'交付物已打包为附件({pkg["size"]} B),请查收。')
|
||
subject = f'📦 项目交付:{proj["name"]}'
|
||
body = delivery._email_body(proj, extra=extra)
|
||
ok, msg = delivery.send_mail(delivery.deliver_email_of(proj), subject, body,
|
||
attachments=[attach] if attach else None)
|
||
if not ok:
|
||
return jsonify({'ok': False, 'error': msg}), 500
|
||
db.w('UPDATE projects SET delivered_at=? WHERE id=?', (db.now(), pid))
|
||
db.w('INSERT INTO project_deliverables (project_id, name, kind, path, demo_url, size, note, created_at) '
|
||
'VALUES (?,?,?,?,?,?,?,?)',
|
||
(pid, f'交付邮件 → {delivery.deliver_email_of(proj)}', 'email', pkg['name'], demo,
|
||
pkg['size'], '手动交付(含附件)', db.now()))
|
||
enterprise.audit(enterprise.current_actor(), 'deliver.send', f'project#{pid}',
|
||
f'邮件交付 → {delivery.deliver_email_of(proj)}({pkg["name"]})', request.remote_addr or '')
|
||
return jsonify({'ok': True, 'msg': msg, 'demo_url': demo, 'package': pkg['name']})
|
||
|
||
|
||
@app.route('/api/projects/<int:pid>/complete', methods=['POST'])
|
||
@require_auth
|
||
def project_complete(pid):
|
||
"""完成项目并交付:置 done + 部署 Demo + 打包 + 邮件通知送达者"""
|
||
err = _check_project_perm(pid, 'manage')
|
||
if err:
|
||
return err
|
||
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||
if not delivery.deliver_email_of(proj):
|
||
return jsonify({'ok': False, 'error': '项目未配置送达者用户,请先在编辑项目中指定'}), 400
|
||
if proj['status'] == 'done':
|
||
return jsonify({'ok': False, 'error': '项目已完成,无需重复交付'}), 400
|
||
base = request.host_url.rstrip('/')
|
||
ok, msg = delivery.notify_project_complete(proj, base)
|
||
if not ok:
|
||
return jsonify({'ok': False, 'error': msg}), 500
|
||
db.w('UPDATE projects SET status="done", updated_at=? WHERE id=?', (db.now(), pid))
|
||
enterprise.audit(enterprise.current_actor(), 'project.complete', f'project#{pid}',
|
||
f'「{proj["name"]}」完成交付 → {delivery.deliver_email_of(proj)}', request.remote_addr or '')
|
||
return jsonify({'ok': True, 'msg': msg})
|
||
|
||
|
||
@app.route('/api/projects/<int:pid>/notify_deliverer', methods=['POST'])
|
||
@require_auth
|
||
def project_notify_deliverer(pid):
|
||
"""手动向送达者发送一条消息(如说明难关/进展)"""
|
||
err = _check_project_perm(pid, 'manage')
|
||
if err:
|
||
return err
|
||
d = request.get_json(force=True) or {}
|
||
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||
if not delivery.deliver_email_of(proj):
|
||
return jsonify({'ok': False, 'error': '项目未配置送达者用户'}), 400
|
||
subject = (d.get('subject') or f'项目动态:{proj["name"]}').strip()
|
||
text = (d.get('message') or '').strip()
|
||
if not text:
|
||
return jsonify({'ok': False, 'error': '请填写消息内容'}), 400
|
||
ok, msg = delivery.notify_deliverer(proj, subject, delivery._email_body(proj, extra=text))
|
||
if not ok:
|
||
return jsonify({'ok': False, 'error': msg}), 500
|
||
return jsonify({'ok': True, 'msg': msg})
|
||
|
||
|
||
@app.route('/demo/<int:pid>/')
|
||
@app.route('/demo/<int:pid>/<path:subpath>')
|
||
def demo_serve(pid, subpath=''):
|
||
"""公开 Demo 地址(送达者免登录直接查看网页交付物)"""
|
||
proj = db.q('SELECT id FROM projects WHERE id=?', (pid,), one=True)
|
||
if not proj:
|
||
return '项目不存在', 404
|
||
return send_from_directory(delivery.demo_path(proj), subpath or 'index.html')
|
||
|
||
|
||
@app.route('/demo/<int:pid>')
|
||
def demo_serve_root(pid):
|
||
return redirect(f'/demo/{pid}/')
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 告警中心
|
||
# ---------------------------------------------------------------------------
|
||
@app.route('/api/alerts')
|
||
@require_auth
|
||
@require_perm('alert.view')
|
||
def alerts():
|
||
limit = min(int(request.args.get('limit', 100)), 500)
|
||
rows = db.q('SELECT * FROM alerts ORDER BY id DESC LIMIT ?', (limit,))
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
@app.route('/api/alerts/unread_count')
|
||
@require_auth
|
||
@require_perm('alert.view')
|
||
def alerts_unread():
|
||
c = db.q('SELECT COUNT(*) c FROM alerts WHERE read=0')[0]['c']
|
||
return jsonify({'ok': True, 'count': c})
|
||
|
||
|
||
@app.route('/api/alerts/<int:aid>/read', methods=['POST'])
|
||
@require_auth
|
||
def alert_read(aid):
|
||
db.w('UPDATE alerts SET read=1 WHERE id=?', (aid,))
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@app.route('/api/alerts/read_all', methods=['POST'])
|
||
@require_auth
|
||
def alerts_read_all():
|
||
db.w('UPDATE alerts SET read=1 WHERE read=0')
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 开放 API Token
|
||
# ---------------------------------------------------------------------------
|
||
@app.route('/api/tokens', methods=['GET', 'POST'])
|
||
@require_auth
|
||
@require_perm('api.manage')
|
||
def api_tokens():
|
||
if request.method == 'POST':
|
||
d = request.get_json(force=True)
|
||
tok = secrets.token_hex(24)
|
||
db.w('INSERT INTO api_tokens (name, token, created_at) VALUES (?,?,?)',
|
||
(d.get('name', '未命名').strip(), tok, db.now()))
|
||
return jsonify({'ok': True, 'token': tok})
|
||
rows = db.q('SELECT id, name, created_at, last_used_at FROM api_tokens ORDER BY id DESC')
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
@app.route('/api/tokens/<int:tid>', methods=['DELETE'])
|
||
@require_auth
|
||
def api_token_delete(tid):
|
||
db.w('DELETE FROM api_tokens WHERE id=?', (tid,))
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 通知渠道(飞书/企微/邮件)
|
||
# ---------------------------------------------------------------------------
|
||
@app.route('/api/channels', methods=['GET', 'POST'])
|
||
@require_auth
|
||
def channels():
|
||
if request.method == 'POST':
|
||
d = request.get_json(force=True)
|
||
cid = db.w(
|
||
'INSERT INTO notify_channels (name, type, webhook, email, events, enabled, created_at) '
|
||
'VALUES (?,?,?,?,?,?,?)',
|
||
(d.get('name', '').strip(), d.get('type', 'feishu'), d.get('webhook', ''),
|
||
d.get('email', ''), _json.dumps(d.get('events') or []),
|
||
1 if d.get('enabled', True) else 0, db.now()))
|
||
return jsonify({'ok': True, 'id': cid})
|
||
rows = db.q('SELECT * FROM notify_channels ORDER BY id DESC')
|
||
for r in rows:
|
||
try:
|
||
r['events'] = _json.loads(r['events'] or '[]')
|
||
except Exception:
|
||
r['events'] = []
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
@app.route('/api/channels/<int:cid>', methods=['PUT', 'DELETE'])
|
||
@require_auth
|
||
def channel_detail(cid):
|
||
if request.method == 'DELETE':
|
||
db.w('DELETE FROM notify_channels WHERE id=?', (cid,))
|
||
return jsonify({'ok': True})
|
||
d = request.get_json(force=True)
|
||
fields = ['name', 'type', 'webhook', 'email', 'enabled']
|
||
sets, args = [], []
|
||
for f in fields:
|
||
if f in d:
|
||
sets.append(f'{f}=?')
|
||
args.append(d[f])
|
||
if 'events' in d:
|
||
sets.append('events=?')
|
||
args.append(_json.dumps(d['events'] or []))
|
||
if sets:
|
||
db.w(f'UPDATE notify_channels SET {", ".join(sets)} WHERE id=?', (*args, cid))
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@app.route('/api/channels/<int:cid>/test', methods=['POST'])
|
||
@require_auth
|
||
def channel_test(cid):
|
||
ch = db.q('SELECT * FROM notify_channels WHERE id=?', (cid,), one=True)
|
||
if not ch:
|
||
return jsonify({'ok': False, 'error': '渠道不存在'}), 404
|
||
ok, msg = notify.test_channel(ch)
|
||
return jsonify({'ok': ok, 'msg': msg})
|
||
|
||
|
||
@app.route('/api/events')
|
||
@require_auth
|
||
def events():
|
||
return jsonify({'ok': True, 'data': [{'id': k, 'name': v} for k, v in notify.EVENTS.items()]})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 设置
|
||
# ---------------------------------------------------------------------------
|
||
@app.route('/api/settings', methods=['GET', 'PUT'])
|
||
@require_auth
|
||
@require_perm('setting.manage')
|
||
def settings():
|
||
if request.method == 'PUT':
|
||
d = request.get_json(force=True)
|
||
for k, v in d.items():
|
||
db.set_setting(k, v)
|
||
if 'budget_alert_ratio' in d:
|
||
config.BUDGET_ALERT_RATIO = float(d['budget_alert_ratio'])
|
||
return jsonify({'ok': True})
|
||
return jsonify({'ok': True, 'data': {
|
||
'budget_alert_ratio': config.BUDGET_ALERT_RATIO,
|
||
'auth_enabled': auth_enabled(),
|
||
'email_configured': bool(config.EMAIL.get('host')),
|
||
}})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# V2 · 多 Agent 协作
|
||
# ---------------------------------------------------------------------------
|
||
AGENT_MODES = [
|
||
{'id': 'supervisor', 'name': '主管模式', 'icon': '👔',
|
||
'desc': '主管拆解任务 → 多个 Worker 并行执行 → 汇总合成最终交付物',
|
||
'params': [{'key': 'workers', 'label': '执行 Agent(多选)', 'type': 'multi_worker'},
|
||
{'key': 'context', 'label': '背景上下文', 'type': 'textarea'}]},
|
||
{'id': 'review', 'name': '评审模式', 'icon': '🔍',
|
||
'desc': 'Worker 产出 → Reviewer 打分评审 → 未达标自动返工(最多 N 轮)→ 终稿',
|
||
'params': [{'key': 'workers', 'label': '执行 Agent(第1个)', 'type': 'multi_worker'},
|
||
{'key': 'rounds', 'label': '最大返工轮次', 'type': 'number', 'default': 2},
|
||
{'key': 'pass_score', 'label': '通过分数线(0-100)', 'type': 'number', 'default': 70},
|
||
{'key': 'context', 'label': '背景上下文', 'type': 'textarea'}]},
|
||
{'id': 'debate', 'name': '辩论模式', 'icon': '⚖️',
|
||
'desc': '多辩手各自立论 → 互相质询反驳 → 裁判综合裁决输出共识结论',
|
||
'params': [{'key': 'workers', 'label': '辩手(最后1个为裁判)', 'type': 'multi_worker'},
|
||
{'key': 'rounds', 'label': '质询轮次', 'type': 'number', 'default': 1},
|
||
{'key': 'stances', 'label': '立场(逗号分隔,如:正方,反方)', 'type': 'text'},
|
||
{'key': 'context', 'label': '背景上下文', 'type': 'textarea'}]},
|
||
]
|
||
|
||
|
||
@app.route('/api/agents/modes')
|
||
@require_auth
|
||
@require_perm('agent.view')
|
||
def agent_modes():
|
||
return jsonify({'ok': True, 'data': AGENT_MODES})
|
||
|
||
|
||
@app.route('/api/agents/runs', methods=['GET', 'POST'])
|
||
@require_auth
|
||
def agent_runs():
|
||
if request.method == 'POST':
|
||
err = _check_perm_point('agent.run')
|
||
if err:
|
||
return err
|
||
d = request.get_json(force=True)
|
||
mode = d.get('mode')
|
||
if mode not in ('supervisor', 'review', 'debate'):
|
||
return jsonify({'ok': False, 'error': '未知协作模式'}), 400
|
||
topic = (d.get('topic') or '').strip()
|
||
if not topic:
|
||
return jsonify({'ok': False, 'error': '请填写任务主题'}), 400
|
||
params = dict(d.get('params') or {})
|
||
# 从 params 里拆出 workers 列表与 context
|
||
worker_ids = params.pop('workers', None) or d.get('worker_ids') or []
|
||
context = params.pop('context', '') or ''
|
||
if not worker_ids:
|
||
rows = db.q('SELECT id FROM workers WHERE status="enabled" ORDER BY id LIMIT 3')
|
||
worker_ids = [r['id'] for r in rows]
|
||
if not worker_ids:
|
||
return jsonify({'ok': False, 'error': '请先注册至少一个 Worker'}), 400
|
||
# 成员只能使用自己有权限的 Worker
|
||
vw = _visible_worker_ids()
|
||
if vw is not None:
|
||
allowed = set(vw)
|
||
bad = [w for w in worker_ids if w not in allowed]
|
||
if bad:
|
||
return jsonify({'ok': False, 'error': f'无权使用 Worker:{bad}'}), 403
|
||
if not worker_ids:
|
||
return jsonify({'ok': False, 'error': '没有可用的 Worker(请管理员授权)'}), 403
|
||
rid = db.w(
|
||
'INSERT INTO agent_runs (mode, title, topic, context, worker_ids, params, status, '
|
||
'created_at) VALUES (?,?,?,?,?,?,?,?)',
|
||
(mode, d.get('title') or f'{mode} · {topic[:40]}', topic, context,
|
||
_json.dumps(worker_ids), _json.dumps(params), 'running', db.now()))
|
||
agents.runner.submit(rid)
|
||
enterprise.audit(enterprise.current_actor(), 'agent.run', f'run#{rid}',
|
||
f'mode={mode} workers={worker_ids}', request.remote_addr or '')
|
||
return jsonify({'ok': True, 'id': rid})
|
||
err = _check_perm_point('agent.view')
|
||
if err:
|
||
return err
|
||
rows = db.q('SELECT * FROM agent_runs ORDER BY id DESC LIMIT 100')
|
||
vw = _visible_worker_ids()
|
||
if vw is not None:
|
||
rows = [r for r in rows if _run_visible(r, vw)]
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
def _run_visible(run, visible_worker_ids):
|
||
"""协作/评估运行是否对当前用户可见:任一参与 Worker 可见即可"""
|
||
try:
|
||
wids = _json.loads(run.get('worker_ids') or '[]')
|
||
except Exception:
|
||
wids = []
|
||
return any(wid in visible_worker_ids for wid in wids)
|
||
|
||
|
||
@app.route('/api/agents/runs/<int:rid>', methods=['GET', 'DELETE'])
|
||
@require_auth
|
||
@require_perm('agent.view')
|
||
def agent_run_detail(rid):
|
||
run = db.q('SELECT * FROM agent_runs WHERE id=?', (rid,), one=True)
|
||
if not run:
|
||
return jsonify({'ok': False, 'error': '不存在'}), 404
|
||
vw = _visible_worker_ids()
|
||
if vw is not None and not _run_visible(run, vw):
|
||
return jsonify({'ok': False, 'error': '无权访问该协作运行'}), 403
|
||
if request.method == 'DELETE':
|
||
db.w('DELETE FROM agent_steps WHERE run_id=?', (rid,))
|
||
db.w('DELETE FROM agent_runs WHERE id=?', (rid,))
|
||
return jsonify({'ok': True})
|
||
run['steps'] = db.q('SELECT * FROM agent_steps WHERE run_id=? ORDER BY seq', (rid,))
|
||
try:
|
||
run['worker_ids'] = _json.loads(run['worker_ids'] or '[]')
|
||
run['params'] = _json.loads(run['params'] or '{}')
|
||
except Exception:
|
||
pass
|
||
wmap = {w['id']: w['name'] for w in db.q('SELECT id,name FROM workers')}
|
||
for s in run['steps']:
|
||
s['worker_name'] = wmap.get(s['worker_id'], '—')
|
||
return jsonify({'ok': True, 'data': run})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# V2 · 自动评估体系
|
||
# ---------------------------------------------------------------------------
|
||
@app.route('/api/eval/datasets', methods=['GET', 'POST'])
|
||
@require_auth
|
||
def eval_datasets():
|
||
if request.method == 'POST':
|
||
err = _check_perm_point('eval.run')
|
||
if err:
|
||
return err
|
||
d = request.get_json(force=True)
|
||
did = db.w(
|
||
'INSERT INTO eval_datasets (name, description, rubric, tags, is_builtin, created_at, updated_at) '
|
||
'VALUES (?,?,?,?,0,?,?)',
|
||
(d.get('name', '').strip(), d.get('description', ''), d.get('rubric', ''),
|
||
_json.dumps(d.get('tags') or []), db.now(), db.now()))
|
||
for c in (d.get('cases') or []):
|
||
db.w('INSERT INTO eval_cases (dataset_id, input, expected, tags, created_at) VALUES (?,?,?,?,?)',
|
||
(did, c.get('input', ''), c.get('expected', ''), _json.dumps(d.get('tags') or []), db.now()))
|
||
return jsonify({'ok': True, 'id': did})
|
||
err = _check_perm_point('eval.view')
|
||
if err:
|
||
return err
|
||
rows = db.q('SELECT * FROM eval_datasets ORDER BY id DESC')
|
||
for r in rows:
|
||
r['case_count'] = db.q('SELECT COUNT(*) c FROM eval_cases WHERE dataset_id=?', (r['id'],))[0]['c']
|
||
r['best_score'] = db.q('SELECT MAX(score) s FROM eval_runs WHERE dataset_id=? AND status="done"',
|
||
(r['id'],))[0]['s']
|
||
r['tags'] = _json.loads(r.get('tags') or '[]')
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
@app.route('/api/eval/datasets/<int:did>', methods=['GET', 'PUT', 'DELETE'])
|
||
@require_auth
|
||
@require_perm('eval.view')
|
||
def eval_dataset_detail(did):
|
||
ds = db.q('SELECT * FROM eval_datasets WHERE id=?', (did,), one=True)
|
||
if not ds:
|
||
return jsonify({'ok': False, 'error': '数据集不存在'}), 404
|
||
if request.method == 'GET':
|
||
ds['cases'] = db.q('SELECT * FROM eval_cases WHERE dataset_id=? ORDER BY id', (did,))
|
||
ds['tags'] = _json.loads(ds.get('tags') or '[]')
|
||
return jsonify({'ok': True, 'data': ds})
|
||
if request.method == 'DELETE':
|
||
db.w('DELETE FROM eval_results WHERE case_id IN (SELECT id FROM eval_cases WHERE dataset_id=?)', (did,))
|
||
db.w('DELETE FROM eval_cases WHERE dataset_id=?', (did,))
|
||
db.w('DELETE FROM eval_runs WHERE dataset_id=?', (did,))
|
||
db.w('DELETE FROM eval_datasets WHERE id=?', (did,))
|
||
return jsonify({'ok': True})
|
||
d = request.get_json(force=True)
|
||
fields = ['name', 'description', 'rubric']
|
||
sets, args = [], []
|
||
for f in fields:
|
||
if f in d:
|
||
sets.append(f'{f}=?')
|
||
args.append(d[f])
|
||
if 'tags' in d:
|
||
sets.append('tags=?')
|
||
args.append(_json.dumps(d['tags']))
|
||
if sets:
|
||
args.append(db.now())
|
||
db.w(f'UPDATE eval_datasets SET {", ".join(sets)}, updated_at=? WHERE id=?', (*args, did))
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@app.route('/api/eval/datasets/<int:did>/cases', methods=['POST'])
|
||
@require_auth
|
||
@require_perm('eval.run')
|
||
def eval_case_create(did):
|
||
d = request.get_json(force=True)
|
||
cid = db.w('INSERT INTO eval_cases (dataset_id, input, expected, tags, created_at) VALUES (?,?,?,?,?)',
|
||
(did, d.get('input', ''), d.get('expected', ''), _json.dumps(d.get('tags') or []), db.now()))
|
||
return jsonify({'ok': True, 'id': cid})
|
||
|
||
|
||
@app.route('/api/eval/cases/<int:cid>', methods=['PUT', 'DELETE'])
|
||
@require_auth
|
||
@require_perm('eval.view')
|
||
def eval_case_detail(cid):
|
||
case = db.q('SELECT * FROM eval_cases WHERE id=?', (cid,), one=True)
|
||
if not case:
|
||
return jsonify({'ok': False, 'error': '用例不存在'}), 404
|
||
if request.method == 'DELETE':
|
||
db.w('DELETE FROM eval_results WHERE case_id=?', (cid,))
|
||
db.w('DELETE FROM eval_cases WHERE id=?', (cid,))
|
||
return jsonify({'ok': True})
|
||
d = request.get_json(force=True)
|
||
fields = ['input', 'expected', 'tags']
|
||
sets, args = [], []
|
||
for f in fields:
|
||
if f in d:
|
||
sets.append(f'{f}=?')
|
||
args.append(_json.dumps(d[f]) if f == 'tags' else d[f])
|
||
if sets:
|
||
db.w(f'UPDATE eval_cases SET {", ".join(sets)} WHERE id=?', (*args, cid))
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@app.route('/api/eval/datasets/<int:did>/sink', methods=['POST'])
|
||
@require_auth
|
||
def eval_sink(did):
|
||
"""把已验收任务产出沉淀为该数据集用例"""
|
||
n = evalmod.sink_done_tasks()
|
||
return jsonify({'ok': True, 'sunk': n})
|
||
|
||
|
||
@app.route('/api/eval/runs', methods=['GET', 'POST'])
|
||
@require_auth
|
||
def eval_runs():
|
||
if request.method == 'POST':
|
||
d = request.get_json(force=True)
|
||
dataset_id = d.get('dataset_id')
|
||
worker_id = d.get('worker_id')
|
||
if not dataset_id or not worker_id:
|
||
return jsonify({'ok': False, 'error': '请选择数据集与 Worker'}), 400
|
||
rid = db.w(
|
||
'INSERT INTO eval_runs (dataset_id, worker_id, status, cases_total, created_at) '
|
||
'VALUES (?,?,?,?,?)',
|
||
(dataset_id, worker_id, 'running',
|
||
db.q('SELECT COUNT(*) c FROM eval_cases WHERE dataset_id=?', (dataset_id,))[0]['c'], db.now()))
|
||
evalmod.runner.submit(rid)
|
||
enterprise.audit(enterprise.current_actor(), 'eval.run', f'run#{rid}',
|
||
f'dataset={dataset_id} worker={worker_id}', request.remote_addr or '')
|
||
return jsonify({'ok': True, 'id': rid})
|
||
rows = db.q('SELECT * FROM eval_runs ORDER BY id DESC LIMIT 100')
|
||
vw = _visible_worker_ids()
|
||
if vw is not None:
|
||
rows = [r for r in rows if r['worker_id'] in vw]
|
||
wmap = {w['id']: w['name'] for w in db.q('SELECT id,name FROM workers')}
|
||
dmap = {d['id']: d['name'] for d in db.q('SELECT id,name FROM eval_datasets')}
|
||
for r in rows:
|
||
r['worker_name'] = wmap.get(r['worker_id'], f'#{r["worker_id"]}')
|
||
r['dataset_name'] = dmap.get(r['dataset_id'], f'#{r["dataset_id"]}')
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
@app.route('/api/eval/runs/<int:rid>', methods=['GET', 'DELETE'])
|
||
@require_auth
|
||
def eval_run_detail(rid):
|
||
run = db.q('SELECT * FROM eval_runs WHERE id=?', (rid,), one=True)
|
||
if not run:
|
||
return jsonify({'ok': False, 'error': '不存在'}), 404
|
||
if request.method == 'DELETE':
|
||
db.w('DELETE FROM eval_results WHERE run_id=?', (rid,))
|
||
db.w('DELETE FROM eval_runs WHERE id=?', (rid,))
|
||
return jsonify({'ok': True})
|
||
run['results'] = db.q('SELECT * FROM eval_results WHERE run_id=? ORDER BY id', (rid,))
|
||
cid_map = {c['id']: c for c in db.q('SELECT * FROM eval_cases WHERE dataset_id=?', (run['dataset_id'],))}
|
||
for r in run['results']:
|
||
c = cid_map.get(r['case_id'])
|
||
r['input'] = c['input'][:200] if c else ''
|
||
r['expected'] = (c['expected'] or '')[:200] if c else ''
|
||
return jsonify({'ok': True, 'data': run})
|
||
|
||
|
||
@app.route('/api/eval/leaderboard')
|
||
@require_auth
|
||
def eval_leaderboard():
|
||
"""Worker 评估排行榜:每个 worker 在完成 run 上的平均分"""
|
||
rows = db.q(
|
||
'SELECT worker_id, COUNT(*) runs, ROUND(AVG(score),2) avg_score, '
|
||
'ROUND(SUM(cost),4) cost, SUM(total_tokens) tokens '
|
||
'FROM eval_runs WHERE status="done" GROUP BY worker_id ORDER BY avg_score DESC')
|
||
wmap = {w['id']: w for w in db.q('SELECT id,name,provider,model FROM workers')}
|
||
for r in rows:
|
||
w = wmap.get(r['worker_id'])
|
||
r['worker_name'] = w['name'] if w else f'#{r["worker_id"]}'
|
||
r['model'] = f"{w['provider']}/{w['model']}" if w else ''
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# V2 · 模板市场
|
||
# ---------------------------------------------------------------------------
|
||
@app.route('/api/templates', methods=['GET', 'POST'])
|
||
@require_auth
|
||
def templates_api():
|
||
if request.method == 'POST':
|
||
err = _check_perm_point('template.manage')
|
||
if err:
|
||
return err
|
||
d = request.get_json(force=True)
|
||
tid = db.w(
|
||
'INSERT INTO templates (type, name, description, content, tags, author, is_builtin, '
|
||
'usage_count, created_at, updated_at) VALUES (?,?,?,?,?,?,0,0,?,?)',
|
||
(d.get('type'), d.get('name', '').strip(), d.get('description', ''),
|
||
_json.dumps(d.get('content') or {}), _json.dumps(d.get('tags') or []),
|
||
enterprise.current_actor(), db.now(), db.now()))
|
||
return jsonify({'ok': True, 'id': tid})
|
||
ttype = request.args.get('type', '')
|
||
if ttype:
|
||
rows = db.q('SELECT * FROM templates WHERE type=? ORDER BY usage_count DESC, id DESC', (ttype,))
|
||
else:
|
||
rows = db.q('SELECT * FROM templates ORDER BY type, usage_count DESC, id DESC')
|
||
for r in rows:
|
||
try:
|
||
r['content'] = _json.loads(r['content'] or '{}')
|
||
r['tags'] = _json.loads(r.get('tags') or '[]')
|
||
except Exception:
|
||
pass
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
@app.route('/api/templates/<int:tid>', methods=['GET', 'PUT', 'DELETE'])
|
||
@require_auth
|
||
@require_perm('template.view')
|
||
def template_detail(tid):
|
||
t = tplmod.get_template(tid)
|
||
if not t:
|
||
return jsonify({'ok': False, 'error': '模板不存在'}), 404
|
||
if request.method == 'GET':
|
||
try:
|
||
t['content'] = _json.loads(t['content'] or '{}')
|
||
t['tags'] = _json.loads(t.get('tags') or '[]')
|
||
except Exception:
|
||
pass
|
||
return jsonify({'ok': True, 'data': t})
|
||
if request.method == 'DELETE':
|
||
if t['is_builtin']:
|
||
return jsonify({'ok': False, 'error': '内置模板不可删除'}), 400
|
||
db.w('DELETE FROM templates WHERE id=?', (tid,))
|
||
return jsonify({'ok': True})
|
||
d = request.get_json(force=True)
|
||
fields = ['name', 'description', 'type']
|
||
sets, args = [], []
|
||
for f in fields:
|
||
if f in d:
|
||
sets.append(f'{f}=?')
|
||
args.append(d[f])
|
||
if 'content' in d:
|
||
sets.append('content=?')
|
||
args.append(_json.dumps(d['content']))
|
||
if 'tags' in d:
|
||
sets.append('tags=?')
|
||
args.append(_json.dumps(d['tags']))
|
||
if sets:
|
||
args.append(db.now())
|
||
db.w(f'UPDATE templates SET {", ".join(sets)}, updated_at=? WHERE id=?', (*args, tid))
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@app.route('/api/templates/<int:tid>/apply', methods=['POST'])
|
||
@require_auth
|
||
def template_apply(tid):
|
||
t = tplmod.get_template(tid)
|
||
if not t:
|
||
return jsonify({'ok': False, 'error': '模板不存在'}), 404
|
||
d = request.get_json(force=True)
|
||
variables = d.get('variables') or {}
|
||
worker_id = d.get('worker_id')
|
||
try:
|
||
if t['type'] == 'task':
|
||
pid = d.get('project_id')
|
||
if not pid:
|
||
return jsonify({'ok': False, 'error': '请选择目标项目'}), 400
|
||
new_id = tplmod.apply_task_template(t, variables, pid, worker_id)
|
||
return jsonify({'ok': True, 'kind': 'task', 'id': new_id})
|
||
if t['type'] == 'project':
|
||
pid, ids = tplmod.apply_project_template(t, variables, worker_id)
|
||
return jsonify({'ok': True, 'kind': 'project', 'id': pid, 'task_ids': ids})
|
||
if t['type'] == 'team':
|
||
ids = tplmod.apply_team_template(t, variables, provider=d.get('provider', 'deepseek'),
|
||
model=d.get('model', 'deepseek-v4-flash'))
|
||
return jsonify({'ok': True, 'kind': 'team', 'ids': ids})
|
||
return jsonify({'ok': False, 'error': '未知模板类型'}), 400
|
||
except Exception as e:
|
||
return jsonify({'ok': False, 'error': f'应用模板失败: {e}'}), 500
|
||
|
||
|
||
@app.route('/api/templates/builtin/restore', methods=['POST'])
|
||
@require_admin
|
||
def templates_restore():
|
||
n = tplmod.create_builtin_templates()
|
||
return jsonify({'ok': True, 'note': '内置模板已就绪' if n is None else '已存在,跳过'})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# V2 · 企业版:用户 / SSO / 审计 / 合规
|
||
# ---------------------------------------------------------------------------
|
||
@app.route('/api/enterprise/users', methods=['GET', 'POST'])
|
||
@require_admin
|
||
def ent_users():
|
||
if request.method == 'POST':
|
||
d = request.get_json(force=True)
|
||
username = (d.get('username') or '').strip()
|
||
email = (d.get('email') or '').strip()
|
||
if not username:
|
||
return jsonify({'ok': False, 'error': '用户名不能为空'}), 400
|
||
if not email or '@' not in email:
|
||
return jsonify({'ok': False, 'error': '必填:用户邮箱(用于项目送达/通知)'}), 400
|
||
if d.get('source') in ('oidc', 'ldap'):
|
||
if enterprise.get_user(username):
|
||
return jsonify({'ok': False, 'error': '用户已存在'}), 400
|
||
db.w('INSERT INTO users (username, password_hash, display_name, email, role, source, status, created_at) '
|
||
'VALUES (?,?,?,?,?,?,?,?)',
|
||
(username, '', d.get('display_name', username), email, d.get('role', 'member'),
|
||
d.get('source'), 'active', db.now()))
|
||
else:
|
||
if not d.get('password'):
|
||
return jsonify({'ok': False, 'error': '本地用户必须设置密码'}), 400
|
||
uid, err = enterprise.create_local_user(username, d['password'],
|
||
d.get('display_name', ''), d.get('role', 'member'), email)
|
||
if err:
|
||
return jsonify({'ok': False, 'error': err}), 400
|
||
return jsonify({'ok': True})
|
||
rows = db.q('SELECT id, username, display_name, email, role, source, status, last_login_at, created_at '
|
||
'FROM users ORDER BY id')
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
@app.route('/api/users')
|
||
@require_auth
|
||
def users_light():
|
||
"""轻量用户列表(登录即可):用于创建项目时选择送达者用户"""
|
||
rows = db.q('SELECT id, username, display_name, email, role, status FROM users ORDER BY id')
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
@app.route('/api/enterprise/users/<int:uid>', methods=['PUT', 'DELETE'])
|
||
@require_admin
|
||
def ent_user_detail(uid):
|
||
u = db.q('SELECT * FROM users WHERE id=?', (uid,), one=True)
|
||
if not u:
|
||
return jsonify({'ok': False, 'error': '用户不存在'}), 404
|
||
if request.method == 'DELETE':
|
||
if u['username'] == session.get('username'):
|
||
return jsonify({'ok': False, 'error': '不能删除自己'}), 400
|
||
if u['username'] == 'admin' and u['source'] == 'local':
|
||
return jsonify({'ok': False, 'error': '内置管理员不可删除'}), 400
|
||
db.w('DELETE FROM users WHERE id=?', (uid,))
|
||
return jsonify({'ok': True})
|
||
d = request.get_json(force=True)
|
||
if 'role' in d and d['role'] in ('admin', 'member', 'auditor'):
|
||
db.w('UPDATE users SET role=? WHERE id=?', (d['role'], uid))
|
||
if 'status' in d and d['status'] in ('active', 'disabled'):
|
||
db.w('UPDATE users SET status=? WHERE id=?', (d['status'], uid))
|
||
if 'display_name' in d:
|
||
db.w('UPDATE users SET display_name=? WHERE id=?', (d['display_name'], uid))
|
||
if 'email' in d:
|
||
email = (d['email'] or '').strip()
|
||
if not email or '@' not in email:
|
||
return jsonify({'ok': False, 'error': '邮箱格式不正确'}), 400
|
||
db.w('UPDATE users SET email=? WHERE id=?', (email, uid))
|
||
# 同步:该用户送达的旧项目邮箱快照刷新
|
||
db.w('UPDATE projects SET deliver_email=? WHERE deliver_user_id=?', (email, uid))
|
||
if d.get('password'):
|
||
db.w('UPDATE users SET password_hash=? WHERE id=?', (db.hash_password(d['password']), uid))
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@app.route('/api/enterprise/users/<int:uid>/grants', methods=['GET', 'PUT'])
|
||
@require_admin
|
||
def ent_user_grants(uid):
|
||
"""用户授权管理:项目权限(view/manage/admin)+ Worker 权限(view/use/manage)"""
|
||
u = db.q('SELECT * FROM users WHERE id=?', (uid,), one=True)
|
||
if not u:
|
||
return jsonify({'ok': False, 'error': '用户不存在'}), 404
|
||
if request.method == 'PUT':
|
||
d = request.get_json(force=True) or {}
|
||
out = enterprise.set_user_grants(uid,
|
||
projects=d.get('projects'),
|
||
workers=d.get('workers'),
|
||
roles=d.get('roles'),
|
||
worker_groups=d.get('worker_groups'))
|
||
enterprise.audit(enterprise.current_actor(), 'user.grants', f'user#{uid}',
|
||
f'{u["username"]} 授权更新:项目 {out["projects"]} / Worker {out["workers"]} / '
|
||
f'角色 {out["roles"]} / 权限组 {out["worker_groups"]}',
|
||
request.remote_addr or '')
|
||
return jsonify({'ok': True, **out})
|
||
data = enterprise.user_grants(uid)
|
||
return jsonify({'ok': True, 'data': data})
|
||
|
||
|
||
@app.route('/api/enterprise/grants/overview')
|
||
@require_admin
|
||
def ent_grants_overview():
|
||
"""授权总览:所有用户的 项目/Worker 授权矩阵"""
|
||
users = db.q('SELECT id, username, display_name, role FROM users ORDER BY id')
|
||
up = db.q('SELECT * FROM user_projects ORDER BY user_id, project_id')
|
||
uw = db.q('SELECT * FROM user_workers ORDER BY user_id, worker_id')
|
||
pname = {r['id']: r['name'] for r in db.q('SELECT id,name FROM projects')}
|
||
wname = {r['id']: r['name'] for r in db.q('SELECT id,name FROM workers')}
|
||
for u in users:
|
||
u['projects'] = [{'project_id': r['project_id'], 'project_name': pname.get(r['project_id'], f'#{r["project_id"]}'),
|
||
'perm': r['perm']} for r in up if r['user_id'] == u['id']]
|
||
u['workers'] = [{'worker_id': r['worker_id'], 'worker_name': wname.get(r['worker_id'], f'#{r["worker_id"]}'),
|
||
'perm': r['perm']} for r in uw if r['user_id'] == u['id']]
|
||
u['roles'] = [{'role_id': r['role_id'], 'role_name':
|
||
db.q('SELECT name FROM roles WHERE id=?', (r['role_id'],), one=True)['name']}
|
||
for r in db.q('SELECT role_id FROM user_roles WHERE user_id=?', (u['id'],))]
|
||
u['worker_groups'] = [{'group_id': r['group_id'], 'group_name':
|
||
db.q('SELECT name FROM worker_perm_groups WHERE id=?', (r['group_id'],), one=True)['name']}
|
||
for r in db.q('SELECT group_id FROM user_worker_groups WHERE user_id=?', (u['id'],))]
|
||
return jsonify({'ok': True, 'data': users})
|
||
|
||
|
||
@app.route('/api/enterprise/perm_points')
|
||
@require_admin
|
||
def ent_perm_points():
|
||
"""权限功能点目录(角色编辑勾选用)"""
|
||
groups = []
|
||
seen = {}
|
||
for p in enterprise.PERM_POINTS:
|
||
if p['group'] not in seen:
|
||
seen[p['group']] = len(groups)
|
||
groups.append({'name': p['group'], 'points': []})
|
||
groups[seen[p['group']]]['points'].append(p)
|
||
return jsonify({'ok': True, 'data': groups})
|
||
|
||
|
||
@app.route('/api/enterprise/roles', methods=['GET', 'POST'])
|
||
@require_admin
|
||
def ent_roles():
|
||
if request.method == 'POST':
|
||
d = request.get_json(force=True)
|
||
rid, err = enterprise.create_role(d.get('name'), d.get('description', ''), d.get('perms'))
|
||
if err:
|
||
return jsonify({'ok': False, 'error': err}), 400
|
||
enterprise.audit(enterprise.current_actor(), 'role.create', f'role#{rid}',
|
||
f'新建角色「{d.get("name","")}」', request.remote_addr or '')
|
||
return jsonify({'ok': True, 'id': rid})
|
||
return jsonify({'ok': True, 'data': enterprise.list_roles()})
|
||
|
||
|
||
@app.route('/api/enterprise/roles/<int:rid>', methods=['GET', 'PUT', 'DELETE'])
|
||
@require_admin
|
||
def ent_role_detail(rid):
|
||
role = db.q('SELECT * FROM roles WHERE id=?', (rid,), one=True)
|
||
if not role:
|
||
return jsonify({'ok': False, 'error': '角色不存在'}), 404
|
||
if request.method == 'GET':
|
||
role['perms'] = sorted(enterprise.role_perms(role))
|
||
role['user_count'] = db.q('SELECT COUNT(*) c FROM user_roles WHERE role_id=?', (rid,))[0]['c']
|
||
return jsonify({'ok': True, 'data': role})
|
||
if request.method == 'DELETE':
|
||
_, err = enterprise.delete_role(rid)
|
||
if err:
|
||
return jsonify({'ok': False, 'error': err}), 400
|
||
enterprise.audit(enterprise.current_actor(), 'role.delete', f'role#{rid}',
|
||
f'删除角色「{role["name"]}」', request.remote_addr or '')
|
||
return jsonify({'ok': True})
|
||
d = request.get_json(force=True)
|
||
_, err = enterprise.update_role(rid, d)
|
||
if err:
|
||
return jsonify({'ok': False, 'error': err}), 400
|
||
enterprise.audit(enterprise.current_actor(), 'role.update', f'role#{rid}',
|
||
f'编辑角色「{d.get("name") or role["name"]}」', request.remote_addr or '')
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@app.route('/api/enterprise/worker_groups', methods=['GET', 'POST'])
|
||
@require_admin
|
||
def ent_worker_groups():
|
||
if request.method == 'POST':
|
||
d = request.get_json(force=True)
|
||
gid, err = enterprise.save_worker_group(None, d)
|
||
if err:
|
||
return jsonify({'ok': False, 'error': err}), 400
|
||
enterprise.audit(enterprise.current_actor(), 'workergroup.create', f'group#{gid}',
|
||
f'新建 Worker 权限组「{d.get("name","")}」', request.remote_addr or '')
|
||
return jsonify({'ok': True, 'id': gid})
|
||
return jsonify({'ok': True, 'data': enterprise.list_worker_groups()})
|
||
|
||
|
||
@app.route('/api/enterprise/worker_groups/<int:gid>', methods=['GET', 'PUT', 'DELETE'])
|
||
@require_admin
|
||
def ent_worker_group_detail(gid):
|
||
g = db.q('SELECT * FROM worker_perm_groups WHERE id=?', (gid,), one=True)
|
||
if not g:
|
||
return jsonify({'ok': False, 'error': '权限组不存在'}), 404
|
||
if request.method == 'GET':
|
||
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=?', (gid,))
|
||
g['users'] = db.q('SELECT u.id AS user_id, u.username FROM user_worker_groups ug '
|
||
'JOIN users u ON u.id=ug.user_id WHERE ug.group_id=?', (gid,))
|
||
return jsonify({'ok': True, 'data': g})
|
||
if request.method == 'DELETE':
|
||
_, err = enterprise.delete_worker_group(gid)
|
||
if err:
|
||
return jsonify({'ok': False, 'error': err}), 400
|
||
enterprise.audit(enterprise.current_actor(), 'workergroup.delete', f'group#{gid}',
|
||
f'删除 Worker 权限组「{g["name"]}」', request.remote_addr or '')
|
||
return jsonify({'ok': True})
|
||
d = request.get_json(force=True)
|
||
_, err = enterprise.save_worker_group(gid, d)
|
||
if err:
|
||
return jsonify({'ok': False, 'error': err}), 400
|
||
enterprise.audit(enterprise.current_actor(), 'workergroup.update', f'group#{gid}',
|
||
f'编辑 Worker 权限组「{d.get("name") or g["name"]}」', request.remote_addr or '')
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
@app.route('/api/enterprise/audit')
|
||
@require_auth
|
||
def ent_audit():
|
||
limit = min(int(request.args.get('limit', 200)), 1000)
|
||
rows = db.q('SELECT * FROM audit_logs ORDER BY id DESC LIMIT ?', (limit,))
|
||
return jsonify({'ok': True, 'data': rows})
|
||
|
||
|
||
@app.route('/api/enterprise/audit/export')
|
||
@require_admin
|
||
def ent_audit_export():
|
||
from flask import Response
|
||
csv_text = enterprise.export_audit_csv()
|
||
enterprise.audit(enterprise.current_actor(), 'compliance.audit_export', '导出审计日志',
|
||
ip=request.remote_addr or '')
|
||
return Response(csv_text, mimetype='text/csv',
|
||
headers={'Content-Disposition': 'attachment; filename=audit_logs.csv'})
|
||
|
||
|
||
@app.route('/api/enterprise/sso', methods=['GET', 'PUT'])
|
||
@require_admin
|
||
def ent_sso():
|
||
if request.method == 'PUT':
|
||
d = request.get_json(force=True)
|
||
enterprise.save_sso_config(d)
|
||
return jsonify({'ok': True})
|
||
cfg = enterprise.sso_config()
|
||
# 密钥打码返回
|
||
for k in ('oidc_client_secret', 'ldap_bind_password'):
|
||
if cfg.get(k):
|
||
cfg[k] = '********'
|
||
return jsonify({'ok': True, 'data': cfg})
|
||
|
||
|
||
@app.route('/api/enterprise/sso/oidc/login', methods=['POST'])
|
||
def ent_oidc_login():
|
||
"""发起 OIDC 授权(未登录也允许,SSO 登录页用)"""
|
||
try:
|
||
state = secrets.token_hex(16)
|
||
session['oidc_state'] = state
|
||
url = enterprise.oidc_authorize_url(state)
|
||
return jsonify({'ok': True, 'redirect': url})
|
||
except Exception as e:
|
||
return jsonify({'ok': False, 'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/enterprise/sso/oidc/callback')
|
||
def ent_oidc_callback():
|
||
code = request.args.get('code')
|
||
state = request.args.get('state')
|
||
if not code or state != session.get('oidc_state'):
|
||
return 'SSO 回调校验失败(state 不匹配或缺少 code)', 400
|
||
try:
|
||
userinfo = enterprise.oidc_exchange(code)
|
||
u, err = enterprise.sso_login(userinfo)
|
||
if err:
|
||
return f'SSO 登录失败: {err}', 403
|
||
session['username'] = u['username']
|
||
session['role'] = u['role']
|
||
session['authed'] = True
|
||
enterprise.audit(u['username'], 'auth.sso_login', 'OIDC SSO 登录', ip=request.remote_addr or '')
|
||
return redirect('/#/settings')
|
||
except Exception as e:
|
||
return f'SSO 登录异常: {str(e)[:200]}', 500
|
||
|
||
|
||
@app.route('/api/enterprise/sso/ldap/test', methods=['POST'])
|
||
@require_admin
|
||
def ent_ldap_test():
|
||
d = request.get_json(force=True)
|
||
username = d.get('username', '')
|
||
password = d.get('password', '')
|
||
if not username or not password:
|
||
return jsonify({'ok': False, 'error': '请填写测试账号密码'}), 400
|
||
info, err = enterprise.ldap_authenticate(username, password)
|
||
if err:
|
||
return jsonify({'ok': False, 'error': err}), 400
|
||
return jsonify({'ok': True, 'data': info})
|
||
|
||
|
||
@app.route('/api/enterprise/export')
|
||
@require_admin
|
||
def ent_export():
|
||
"""合规:全量数据导出(JSON)"""
|
||
from flask import Response
|
||
data = enterprise.export_all()
|
||
enterprise.audit(enterprise.current_actor(), 'compliance.export', '全量数据导出',
|
||
ip=request.remote_addr or '')
|
||
return Response(_json.dumps(data, ensure_ascii=False, indent=2), mimetype='application/json',
|
||
headers={'Content-Disposition': 'attachment; filename=aiworker_export.json'})
|
||
|
||
|
||
@app.route('/api/enterprise/compliance', methods=['GET', 'POST'])
|
||
@require_admin
|
||
def ent_compliance():
|
||
if request.method == 'POST':
|
||
d = request.get_json(force=True)
|
||
if 'retention_days' in d:
|
||
db.set_setting('compliance_retention_days', int(d['retention_days']))
|
||
if 'mask_pii' in d:
|
||
db.set_setting('compliance_mask_pii', '1' if d['mask_pii'] else '0')
|
||
if d.get('apply_retention'):
|
||
enterprise.apply_retention()
|
||
return jsonify({'ok': True})
|
||
return jsonify({'ok': True, 'data': {
|
||
'retention_days': int(db.get_setting('compliance_retention_days', '0') or 0),
|
||
'mask_pii': db.get_setting('compliance_mask_pii', '0') == '1',
|
||
'audit_count': db.q('SELECT COUNT(*) c FROM audit_logs')[0]['c'],
|
||
'agent_runs_count': db.q('SELECT COUNT(*) c FROM agent_runs')[0]['c'],
|
||
'eval_runs_count': db.q('SELECT COUNT(*) c FROM eval_runs')[0]['c'],
|
||
'consent_token': enterprise.generate_consent_token(),
|
||
}})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 前端
|
||
# ---------------------------------------------------------------------------
|
||
@app.route('/')
|
||
def index():
|
||
return send_from_directory(app.static_folder, 'index.html')
|
||
|
||
|
||
if __name__ == '__main__':
|
||
print(f'AI Worker 项目管理平台启动: http://0.0.0.0:{config.PORT}')
|
||
app.run(host=config.HOST, port=config.PORT, debug=False, threaded=True)
|