Files
ai-worker-platform/app.py
T
hz4th_coder f53e1b264e V3.3.1 修复:WBS 解析 'Extra data' 报错 + 开工接口空 body 容错
- parse_wbs 改用 raw_decode 提取第一个完整 JSON,忽略尾部杂质/多余 JSON(原按首{到尾}切片,模型输出带第二段JSON或解释文字时解析失败)
- 兼容 subtasks/task_list/items/task 等任务键名
- /autostart 接口 get_json 改 silent 容错(无 body 时不再 400)
2026-08-14 12:55:15 +08:00

2350 lines
101 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)