Compare commits

...
3 Commits
Author SHA1 Message Date
hz4th_coder e5d664fb98 性能优化: 项目页/看板/DAG/知识库/交付 加载提速
- /api/tasks 列表接口改用轻量序列化,去掉 output_text/error 大字段
  (300KB -> 6KB, 详情接口 /api/tasks/<id> 仍返回完整数据)
- /api/tasks/trash 与 /api/projects/<id>/dag 同步轻量化
- 前端同一项目内切换 tab 不再重复拉取项目/任务/Worker/事件,
  复用 projCtx 缓存; 离开项目页时自动清缓存
- /api/projects/<id>/events 各来源加 LIMIT, 避免全表加载
2026-08-16 19:26:13 +08:00
hz4th_coder 7c4cbf8994 V3.4 负责人验收制+邮件修复+事件面板+真实网页交付
- 项目级验收:全部任务完成后进入「待验收」,邮件通知负责人,验收通过才算完成;公开验收链接 /review/<token> 免登录一键通过/打回(打回必填原因)
- 打回自动返工:自动生成含负责人意见的返工任务,AI主管立即重做并再次提交验收;平台内也可验收
- 真实网页交付:任务HTML产出自动落盘工作目录(剥离围栏/前置叙述),Demo展示真实页面;返工产出覆盖入口页;老项目已回填
- 事件记录面板:合并AI主管动态+任务日志+交付记录,可展开/收起(记忆状态),任务事件可点击定位
- 邮件通道修复:补 Date/Message-ID 头(amavisd 拒收 invalid header section 根因),打包附件/通知恢复送达;notify.py 改标准 MIME
- 规划重试:WBS 拆解失败自动重试3次,仍失败邮件通知负责人
- 项目状态新增 review(待验收),全端展示
2026-08-14 13:33:59 +08:00
hz4th_coder 3b5c06fdd9 版本号 v3.3.1 2026-08-14 13:01:12 +08:00
10 changed files with 541 additions and 45 deletions
+15 -1
View File
@@ -3,7 +3,21 @@
> 以「项目」为中心、以「AI Worker」为执行单元的项目管理平台。
> 把大模型团队变成一支可指挥、可审计、可控成本的"虚拟团队"。
**当前版本:V3.3**AI 主管自动开工 / 项目创建即开工 / 失败诊断重试
**当前版本:V3.4**负责人验收制 / 邮件通道修复 / 事件记录面板 / 真实网页交付
---
## 🏁 V3.4 负责人验收制 + 交付增强(新增)
| 能力 | 说明 |
|---|---|
| 🔔 项目级验收 | AI 团队全部完工后**不自动宣布完成**,项目进入「待验收」状态,邮件通知负责人(送达者);负责人验收通过项目才算正式完成 |
| 🔗 公开验收链接 | 验收邮件含免登录链接 `/review/<token>`,负责人一键「验收通过 / 打回返工」(打回必填原因) |
| 🔁 打回自动返工 | 打回后自动生成「返工任务」(含负责人意见),AI 主管立即按意见重做,再次提交验收;平台内也可直接验收/打回 |
| 🌐 真实网页交付 | 任务的 HTML 产出自动落盘工作目录(自动剥离代码围栏/前置叙述),Demo 展示**真实页面**而非占位说明;返工产出自动覆盖入口页 |
| 📋 事件记录面板 | 项目页「事件记录」可展开/收起(记忆状态):合并 AI 主管动态 + 任务日志 + 交付记录,任务事件可点击定位到对应任务 |
| 📮 邮件通道修复 | 修复邮件头缺 Date 导致 amavisd 退信(`invalid header section`)的问题,打包附件邮件/各类通知恢复正常送达 |
| 🔁 规划重试 | 任务拆解解析失败自动重试 3 次(每次重新调用 LLM),仍失败则邮件通知负责人并中断 |
---
+178 -4
View File
@@ -231,7 +231,7 @@ def require_admin(fn):
@app.route('/api/health')
def health():
return jsonify({'ok': True, 'service': 'ai-worker-platform', 'version': 'v3.3.0'})
return jsonify({'ok': True, 'service': 'ai-worker-platform', 'version': 'v3.4.0'})
# ---------------------------------------------------------------------------
@@ -708,7 +708,7 @@ def tasks():
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]})
return jsonify({'ok': True, 'data': [db.serialize_task_light(t) for t in rows]})
@app.route('/api/tasks/<int:tid>', methods=['GET', 'PUT', 'DELETE'])
@@ -779,7 +779,7 @@ def tasks_trash():
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]})
return jsonify({'ok': True, 'data': [db.serialize_task_light(t) for t in rows]})
@app.route('/api/tasks/<int:tid>/trash', methods=['POST'])
@@ -1094,6 +1094,180 @@ def project_autologs(pid):
return jsonify({'ok': True, 'data': rows})
@app.route('/api/projects/<int:pid>/events')
@require_auth
def project_events(pid):
"""V3.4 项目事件流水(合并:AI主管动态 + 任务日志 + 告警 + 交付记录),按时间倒序"""
err = _check_project_perm(pid, 'view')
if err:
return err
limit = min(int(request.args.get('limit', 200)), 500)
rows = []
# AI 主管动态
for r in db.q('SELECT * FROM project_logs WHERE project_id=? ORDER BY id DESC LIMIT ?',
(pid, limit)):
rows.append({'id': f'p{r["id"]}', 'kind': 'autolog', 'level': r['level'],
'message': r['message'], 'created_at': r['created_at'], 'task_id': None,
'task_title': None})
# 任务日志(带任务标题,可定位)
for r in db.q('SELECT l.*, t.title AS task_title FROM task_logs l '
'JOIN tasks t ON t.id=l.task_id WHERE t.project_id=? AND t.deleted=0 '
'ORDER BY l.id DESC LIMIT ?', (pid, limit)):
rows.append({'id': f'l{r["id"]}', 'kind': 'task', 'level': r['level'],
'message': r['message'], 'created_at': r['created_at'],
'task_id': r['task_id'], 'task_title': r['task_title']})
# 交付记录
for r in db.q('SELECT * FROM project_deliverables WHERE project_id=? ORDER BY id DESC LIMIT ?',
(pid, limit)):
rows.append({'id': f'd{r["id"]}', 'kind': 'deliver', 'level': 'success',
'message': f'📦 {r["note"]}{r["name"]}', 'created_at': r['created_at'],
'task_id': None, 'task_title': None})
rows.sort(key=lambda x: x['created_at'], reverse=True)
return jsonify({'ok': True, 'data': rows[:limit], 'total': len(rows)})
# ---------------------------------------------------------------------------
# V3.4 负责人验收(公开链接免登录 + 平台内验收)
# ---------------------------------------------------------------------------
REVIEW_PAGE = '''<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>项目验收 · AI Worker 平台</title>
<style>
body{{font-family:system-ui,'PingFang SC','Microsoft YaHei',sans-serif;background:#0f1420;color:#e6ebf5;margin:0;padding:32px 16px;display:flex;justify-content:center}}
.box{{max-width:560px;width:100%;background:#171e2e;border:1px solid #2a3550;border-radius:14px;padding:28px}}
h1{{font-size:19px;margin:0 0 4px}} .sub{{color:#8b97ad;font-size:13px;margin-bottom:18px}}
.kv{{font-size:13px;line-height:1.9;color:#aeb9d4}} .kv b{{color:#e6ebf5}}
.demo{{display:inline-block;margin:14px 0;background:#4f8cff;color:#fff;padding:9px 18px;border-radius:8px;text-decoration:none;font-size:13px}}
.btns{{display:flex;gap:10px;margin-top:20px}}
button{{flex:1;padding:11px;border-radius:8px;border:1px solid #2a3550;cursor:pointer;font-size:14px}}
.approve{{background:#38d39f;border-color:#38d39f;color:#06281c;font-weight:600}}
.reject{{background:transparent;border-color:#ff6b6b;color:#ff6b6b}}
textarea{{width:100%;background:#1d2638;border:1px solid #2a3550;color:#e6ebf5;padding:10px;border-radius:8px;font-size:13px;margin-top:10px;min-height:70px;box-sizing:border-box}}
.msg{{margin-top:14px;font-size:13px;color:#38d39f}} .msg.err{{color:#ff6b6b}}
.hint{{color:#8b97ad;font-size:12px;margin-top:14px}}
</style></head><body>
<div class="box">
<h1>🔔 项目验收:{name}</h1>
<div class="sub">AI 团队已全部完工,等待您(项目负责人)验收</div>
<div class="kv">
<div><b>项目状态:</b>待验收</div>
<div><b>目标:</b>{objective}</div>
<div><b>任务完成:</b>{done}/{total}(失败 {failed}</div>
<div><b>累计成本:</b>¥{cost}</div>
</div>
{demo_html}
<label style="font-size:12px;color:#8b97ad">验收意见(打回时必填)</label>
<textarea id="reason" placeholder="填写验收意见…(通过可不填)"></textarea>
<div class="btns">
<button class="approve" onclick="submitReview('approve')">✅ 验收通过</button>
<button class="reject" onclick="submitReview('reject')">⛔ 打回返工</button>
</div>
<div class="msg" id="msg"></div>
<div class="hint">验收通过后项目正式完成并自动打包交付;打回后 AI 主管将按意见返工。</div>
</div>
<script>
async function submitReview(action) {{
const reason = document.getElementById('reason').value.trim();
if (action === 'reject' && !reason) {{ alert('打回必须填写原因'); return; }}
const m = document.getElementById('msg');
m.className = 'msg'; m.textContent = '提交中…';
try {{
const r = await fetch('/api/review/{token}', {{method:'POST', headers:{{'Content-Type':'application/json'}},
body: JSON.stringify({{action, reason, reviewer: '负责人'}})}});
const d = await r.json();
if (d.ok) {{ m.className = 'msg'; m.textContent = action === 'approve' ? '✅ 验收通过!项目已完成并开始交付。' : '⛔ 已打回,AI 主管将按意见返工。'; }}
else {{ m.className = 'msg err'; m.textContent = d.error || '提交失败'; }}
}} catch (e) {{ m.className = 'msg err'; m.textContent = '网络错误:' + e.message; }}
}}
</script></body></html>'''
@app.route('/review/<token>')
def review_page(token):
"""V3.4 负责人公开验收页(免登录)"""
p = db.q('SELECT * FROM projects WHERE review_token=?', (token,), one=True)
if not p or p['status'] != 'review':
return '<h2 style="font-family:system-ui;color:#8b97ad;padding:40px;text-align:center">验收链接无效或已处理</h2>', 404
s = delivery.project_summary(p)
demo_html = f'<a class="demo" href="{p.get("demo_url") or delivery.demo_url_of(p)}" target="_blank">👀 查看交付 Demo</a>' \
if p.get('deliver_type') == 'web' else ''
return REVIEW_PAGE.format(
name=esc_html(p['name']), objective=esc_html(p.get('objective') or ''),
done=s['done'], total=s['total'], failed=s['failed'], cost=f'{s["cost"]:.4f}',
demo_html=demo_html, token=token)
def esc_html(s):
return str(s).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
@app.route('/api/review/<token>', methods=['POST'])
def review_submit(token):
"""V3.4 负责人公开提交验收结果"""
p = db.q('SELECT * FROM projects WHERE review_token=?', (token,), one=True)
if not p or p['status'] != 'review':
return jsonify({'ok': False, 'error': '验收链接无效或已处理'}), 404
d = request.get_json(silent=True) or {}
action = d.get('action')
reason = (d.get('reason') or '').strip()
reviewer = (d.get('reviewer') or '负责人').strip()[:50]
if action == 'approve':
r = delivery.approve_project(p['id'], request.host_url.rstrip('/'), reviewer=reviewer)
enterprise.audit('review:' + reviewer, 'project.approve', f'project#{p["id"]}',
f'公开链接验收通过:{p["name"]}', request.remote_addr or '')
return jsonify({'ok': r['ok'], 'error': None if r['ok'] else r['msg']})
if action == 'reject':
if not reason:
return jsonify({'ok': False, 'error': '打回必须填写原因'}), 400
r = delivery.reject_project(p['id'], reason)
enterprise.audit('review:' + reviewer, 'project.reject', f'project#{p["id"]}',
f'公开链接打回:{reason[:100]}', request.remote_addr or '')
# 打回后自动让 AI 主管按意见返工
try:
autostart.launch(p['id'])
except Exception:
pass
return jsonify({'ok': True})
return jsonify({'ok': False, 'error': '未知操作'}), 400
@app.route('/api/projects/<int:pid>/review', methods=['POST'])
@require_auth
def project_review(pid):
"""V3.4 平台内验收:项目管理员/负责人操作"""
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 p['status'] != 'review':
return jsonify({'ok': False, 'error': f'项目当前状态「{p["status"]}」不可验收'}), 400
d = request.get_json(silent=True) or {}
action = d.get('action')
reason = (d.get('reason') or '').strip()
u = _session_user()
reviewer = (u.get('display_name') or u.get('username')) if u else '管理员'
if action == 'approve':
r = delivery.approve_project(pid, request.host_url.rstrip('/'), reviewer=reviewer)
enterprise.audit(enterprise.current_actor(), 'project.approve', f'project#{pid}',
f'平台内验收通过:{p["name"]}', request.remote_addr or '')
return jsonify({'ok': r['ok'], 'error': None if r['ok'] else r['msg']})
if action == 'reject':
if not reason:
return jsonify({'ok': False, 'error': '打回必须填写原因'}), 400
r = delivery.reject_project(pid, reason)
enterprise.audit(enterprise.current_actor(), 'project.reject', f'project#{pid}',
f'平台内打回:{reason[:100]}', request.remote_addr or '')
# 打回后自动让 AI 主管按意见返工
try:
autostart.launch(pid)
except Exception:
pass
return jsonify({'ok': True})
return jsonify({'ok': False, 'error': '未知操作'}), 400
@app.route('/api/projects/<int:pid>/dag')
@require_auth
def project_dag(pid):
@@ -1104,7 +1278,7 @@ def project_dag(pid):
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)
node = db.serialize_task_light(t)
nodes.append(node)
id_map[t['id']] = node
for t in nodes:
+51 -21
View File
@@ -150,6 +150,7 @@ def set_team(pid, worker_ids):
# ---------------------------------------------------------------------------
# 规划:AI 主管拆解 WBS → 建任务(轮询分派团队 Worker)
# V3.4:解析失败自动重试 3 次(每次重新调用 LLM),仍失败抛出异常由上层通知负责人
# ---------------------------------------------------------------------------
def plan_tasks(pid, manager, team, review_required=0):
"""AI 主管规划并创建任务。返回 (created_ids, plan_message)"""
@@ -157,14 +158,23 @@ def plan_tasks(pid, manager, team, review_required=0):
goal = (proj.get('objective') or proj.get('name') or '').strip()
if not goal:
raise ValueError('项目缺少目标(Objective),AI 主管无法规划')
r = llm_gateway.chat(
manager['provider'], manager['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,
base_url=manager['base_url'] or None, api_key=manager['api_key'] or None)
tasks = parse_wbs(r['text'])
last_err = None
for attempt in range(1, 4):
try:
r = llm_gateway.chat(
manager['provider'], manager['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 * attempt,
base_url=manager['base_url'] or None, api_key=manager['api_key'] or None)
tasks = parse_wbs(r['text'])
break
except Exception as e:
last_err = e
_log(pid, 'warn', f'任务拆解第 {attempt}/3 次失败({str(e)[:100]}),重新规划中…')
else:
raise ValueError(f'AI 主管连续 3 次拆解任务失败({str(last_err)[:120]}),已中断并通知负责人')
created = []
for i, t in enumerate(tasks):
dep_idx = t.get('depends_on') or []
@@ -270,14 +280,7 @@ def monitor(pid, manager, team, timeout=1800):
def _finalize(pid, status, message):
_set_auto(pid, status, message, finished=True)
if status == 'done':
try:
delivery.auto_complete_if_ready(pid)
except Exception:
pass
_log(pid, 'success', message)
else:
_log(pid, 'warn', message)
_log(pid, 'success' if status == 'done' else 'warn', message)
# ---------------------------------------------------------------------------
@@ -327,7 +330,7 @@ def autostart_project(pid, review_required=0):
if result == 'timeout':
raise ValueError('监控超时,仍有任务未完成(可在任务就绪后点「继续开工」)')
# 4) 收尾
# 4) 收尾:全部任务完成 → 提交负责人验收(AI 无权宣布项目完成)
by = {r['status']: r['c'] for r in db.q(
'SELECT status, COUNT(*) c FROM tasks WHERE project_id=? AND deleted=0 GROUP BY status', (pid,))}
done = by.get('done', 0)
@@ -335,16 +338,43 @@ def autostart_project(pid, review_required=0):
review = by.get('review', 0)
total = sum(by.values())
costs = db.q('SELECT COALESCE(SUM(cost),0) c FROM cost_records WHERE project_id=?', (pid,))[0]['c']
msg = f'AI 主管收工:{done}/{total} 个任务完成' + (f'{failed} 个失败' if failed else '') + \
(f'{review} 个待人工审核' if review else '') + f',总成本 ¥{costs:.4f}'
_finalize(pid, 'done' if failed == 0 else 'failed', msg)
if failed == 0 and review == 0 and total > 0:
# 转待验收:delivery.auto_complete_if_ready → enter_review(发邮件给负责人)
try:
r = delivery.auto_complete_if_ready(pid)
if r and r.get('ok'):
msg = f'AI 主管收工:{done}/{total} 个任务完成,已提交负责人验收,总成本 ¥{costs:.4f}'
else:
msg = f'AI 主管收工:{done}/{total} 个任务完成,但验收通知异常:{((r or {}).get("msg") or "")[:80]}'
except Exception as e:
msg = f'AI 主管收工:{done}/{total} 个任务完成,验收提交异常:{str(e)[:80]}'
_finalize(pid, 'done', msg)
else:
msg = f'AI 主管收工:{done}/{total} 个任务完成' + (f'{failed} 个失败' if failed else '') + \
(f'{review} 个待人工审核' if review else '') + f',总成本 ¥{costs:.4f}'
_finalize(pid, 'done' if failed == 0 else 'failed', msg)
try:
notify.notify('task_done', f'项目自动开工完成:{proj["name"]}', msg, save_alert=False)
except Exception:
pass
except Exception as e:
_finalize(pid, 'failed', f'自动开工中断:{str(e)[:200]}')
msg = f'自动开工中断:{str(e)[:200]}'
_finalize(pid, 'failed', msg)
_log(pid, 'error', traceback.format_exc())
# V3.4:连续失败/中断 → 邮件通知项目负责人(送达者)
try:
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
if proj:
email = delivery.deliver_email_of(proj)
if email:
delivery.notify_deliverer(
proj, f'⚠️ 项目自动开工中断:{proj["name"]}',
f'AI 主管自动开工中断:{msg}\n\n'
f'请到平台查看项目详情(任务/日志),或稍后在项目页点击「🚀 AI 主管开工」重试。')
else:
_log(pid, 'warn', '项目未配置送达者邮箱,中断通知无法发送')
except Exception:
pass
_threads = {}
+20
View File
@@ -28,6 +28,8 @@ CREATE TABLE IF NOT EXISTS projects (
auto_message TEXT DEFAULT '', -- V3.3 自动开工最新动态
auto_started_at INTEGER, -- V3.3 最近一次自动开工时间
auto_finished_at INTEGER, -- V3.3 最近一次自动收尾时间
review_token TEXT DEFAULT '', -- V3.4 项目验收令牌(发给负责人,公开链接免登录验收)
review_required INTEGER DEFAULT 1, -- V3.4 是否需负责人验收后才算完成(默认需要)
created_at INTEGER,
updated_at INTEGER
);
@@ -428,6 +430,14 @@ def _migrate():
):
if col not in pcols:
conn.execute(ddl)
# V3.4:负责人验收
pcols = {r['name'] for r in conn.execute('PRAGMA table_info(projects)')}
for col, ddl in (
('review_token', "ALTER TABLE projects ADD COLUMN review_token TEXT DEFAULT ''"),
('review_required', 'ALTER TABLE projects ADD COLUMN review_required INTEGER DEFAULT 1'),
):
if col not in pcols:
conn.execute(ddl)
conn.execute('''CREATE TABLE IF NOT EXISTS project_team_workers (
project_id INTEGER NOT NULL,
worker_id INTEGER NOT NULL,
@@ -621,6 +631,16 @@ def serialize_task(t):
return t
def serialize_task_light(t):
"""轻量序列化(列表接口用):去掉大字段 output_text / error
看板、DAG、回收站等列表视图不需要它们,可显著减小传输体积。
详情接口 /api/tasks/<id> 仍返回完整数据。"""
t = serialize_task(t)
t.pop('output_text', None)
t.pop('error', None)
return t
def get_setting(key, default=''):
r = q('SELECT value FROM settings WHERE key=?', (key,), one=True)
return r['value'] if r else default
+164 -2
View File
@@ -207,13 +207,19 @@ def auto_complete_if_ready(project_id, base_url=''):
返回 {'ok','msg'} 或 None(未满足条件/异常)。引擎线程与审核接口共用。"""
try:
proj = db.q('SELECT * FROM projects WHERE id=?', (project_id,), one=True)
if not proj or proj['status'] == 'done':
# V3.4done=已验收完成;review=已提交待验收(避免 engine 与 autostart 双触发重复通知)
if not proj:
return None
if proj['status'] in ('done', 'review'):
return {'ok': True, 'msg': '项目已处于待验收/完成状态'}
total = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND deleted=0', (project_id,))[0]['c']
done = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND status="done" AND deleted=0',
(project_id,))[0]['c']
if total == 0 or done < total:
return None
# V3.4:AI 不能替负责人拍板 → 全部任务完成后进入「待负责人验收」
if proj.get('review_required', 1):
return enter_review(proj, base_url)
db.w('UPDATE projects SET status="done", updated_at=? WHERE id=?', (db.now(), project_id))
ok, msg = notify_project_complete(proj, base_url)
return {'ok': ok, 'msg': msg}
@@ -221,6 +227,157 @@ def auto_complete_if_ready(project_id, base_url=''):
return None
def enter_review(project, base_url=''):
"""V3.4 全部任务完成 → 项目置「待验收」:生成验收令牌 + 邮件通知负责人。
返回 {'ok','msg'}。"""
import secrets
proj = project if isinstance(project, dict) else db.q('SELECT * FROM projects WHERE id=?', (project,), one=True)
if not proj:
return {'ok': False, 'msg': '项目不存在'}
token = (proj.get('review_token') or '').strip() or secrets.token_urlsafe(16)
db.w('UPDATE projects SET status="review", review_token=?, updated_at=? WHERE id=?',
(token, db.now(), proj['id']))
base = (base_url or PUBLIC_BASE_URL).rstrip('/')
link = f'{base}/review/{token}'
s = project_summary(proj)
demo = proj.get('demo_url') or (demo_url_of(proj, base) if proj.get('deliver_type') == 'web' else '')
extra = (
f'🤖 AI 团队已全部完工({s["done"]}/{s["total"]} 个任务,失败 {s["failed"]},成本 ¥{s["cost"]:.4f}\n'
f'现在是您(项目负责人)验收的时候了!请打开下面的链接审核交付成果:\n'
f'👉 验收链接:{link}\n'
+ (f'👀 在线 Demo{demo}\n' if demo else '')
+ '验收通过后项目才算正式完成,并自动打包交付。'
)
email = deliver_email_of(proj)
ok, msg = True, '已通知负责人验收'
if email:
ok, msg = send_mail(email, f'🔔 待您验收:{proj["name"]}AI 团队已完工)',
_email_body(proj, extra=extra))
if not ok:
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
"VALUES ('notify','warn',?,?,0,?)",
(f'验收通知邮件失败:{proj["name"]}', msg[:300], db.now()))
else:
ok, msg = False, '项目未配置送达者邮箱,无法通知验收'
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
"VALUES ('notify','warn',?,?,0,?)",
(f'验收通知失败:{proj["name"]} 未配置送达者', '', db.now()))
db.w("INSERT INTO project_logs (project_id, level, message, created_at) VALUES (?,?,?,?)",
(proj['id'], 'info', f'项目进入待验收状态,已通知负责人{(""+email+"") if email else ""}{link}', db.now()))
return {'ok': ok, 'msg': msg}
def approve_project(project_id, base_url='', reviewer='负责人'):
"""V3.4 负责人验收通过 → 部署 Demo/打包 + 完成交付邮件 + 项目置 done"""
proj = db.q('SELECT * FROM projects WHERE id=?', (project_id,), one=True)
if not proj:
return {'ok': False, 'msg': '项目不存在'}
if proj['status'] != 'review':
return {'ok': False, 'msg': f'项目当前状态「{proj["status"]}」不可验收'}
db.w('UPDATE projects SET status="done", updated_at=? WHERE id=?', (db.now(), project_id))
ok, msg = notify_project_complete(proj, base_url)
db.w("INSERT INTO project_logs (project_id, level, message, created_at) VALUES (?,?,?,?)",
(project_id, 'success', f'负责人({reviewer})验收通过 ✅,项目正式完成并交付', db.now()))
return {'ok': ok, 'msg': msg}
def reject_project(project_id, reason=''):
"""V3.4 负责人打回 → 项目回到进行中,记录原因,自动生成返工任务交给 AI 主管重做"""
proj = db.q('SELECT * FROM projects WHERE id=?', (project_id,), one=True)
if not proj:
return {'ok': False, 'msg': '项目不存在'}
db.w('UPDATE projects SET status="active", review_token=\'\', updated_at=? WHERE id=?',
(db.now(), project_id))
msg = f'负责人打回:{reason or "未填写原因"}'
db.w("INSERT INTO project_logs (project_id, level, message, created_at) VALUES (?,?,?,?)",
(project_id, 'warn', msg, db.now()))
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
"VALUES ('reject','warn',?,?,0,?)",
(f'项目被打回:{proj["name"]}', msg[:500], db.now()))
# 自动生成返工任务(AI 主管按负责人意见重做),上层负责触发 autostart.launch
team = db.q('SELECT w.* FROM project_team_workers t JOIN workers w ON w.id=t.worker_id '
'WHERE t.project_id=? ORDER BY t.rowid', (project_id,))
wid = team[0]['id'] if team else None
desc = (f'负责人验收后提出修改意见,请按以下意见返工整个交付物:\n'
f'【负责人意见】{reason or "未填写"}\n\n'
f'项目目标:{proj.get("objective") or proj.get("name") or ""}\n'
f'验收标准:{proj.get("acceptance_criteria") or ""}\n'
f'请直接输出修改后的完整成果(网页项目请输出完整 HTML 文档)。')
tid = db.w('INSERT INTO tasks (project_id, worker_id, title, description, priority, '
'review_required, deadline, depends_on, created_at, updated_at) '
'VALUES (?,?,?,?,?,?,?,?,?,?)',
(project_id, wid, '返工:按负责人意见修改', desc, 'high', 0, '', '[]',
db.now(), db.now()))
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
(tid, 'warn', f'负责人打回,AI 主管返工任务已创建:{reason[:120]}', db.now()))
return {'ok': True, 'msg': msg, 'rework_task_id': tid}
def _strip_fence(text):
"""去掉 LLM 输出外层 markdown 代码围栏(```html ... ```),还原纯 HTML"""
t = text.strip()
if not t.startswith('```'):
return text
lines = t.split('\n')
if lines and lines[0].startswith('```'):
lines = lines[1:]
if lines and lines[-1].strip() == '```':
lines = lines[:-1]
return '\n'.join(lines).strip()
def _extract_html(text):
"""从 LLM 输出中提取完整 HTML 文档:全文本定位 <!doctype html/<html 标签,
截取到 </html>;自动剥离 markdown 代码围栏与前置叙述。返回纯 HTML 或 None。"""
if not text or not isinstance(text, str):
return None
import re as _re
m = _re.search(r'<!doctype\s+html', text, _re.I)
if not m:
m = _re.search(r'<html[\s>]', text, _re.I)
if not m:
return None
html = text[m.start():]
end = html.lower().rfind('</html>')
if end > 0:
html = html[:end + 7]
# 剥离可能残留的围栏(如 ```html 前缀 / 结尾 ```
html = _strip_fence(html)
return html.strip()
def save_task_output(task, text):
"""V3.4 任务产出若为完整网页文档 → 落盘到项目工作目录,供 Demo 真实展示。
返回保存的文件名列表。"""
content = _extract_html(text)
if content is None:
return []
saved = []
root = workspace_path(task['project_id'])
title = (task.get('title') or '').strip()
is_rework = '返工' in title
index = os.path.join(root, 'index.html')
index_bad = False
if os.path.isfile(index):
try:
with open(index, 'r', encoding='utf-8') as fh:
index_bad = fh.read().lstrip().startswith('```')
except Exception:
index_bad = True
if not os.path.isfile(index) or index_bad or is_rework:
fn = 'index.html'
else:
safe = re.sub(r'[\\/:*?"<>|\s]+', '_', title or f'task{task["id"]}')[:40]
fn = f'{safe}-{task["id"]}.html'
try:
with open(os.path.join(root, fn), 'w', encoding='utf-8') as fh:
fh.write(content)
saved.append(fn)
except Exception:
pass
return saved
def project_summary(project):
"""项目交付摘要:任务统计 + 成本"""
rows = db.q('SELECT status, COUNT(*) c FROM tasks WHERE project_id=? AND deleted=0 GROUP BY status',
@@ -238,15 +395,20 @@ def project_summary(project):
# 邮件发送(支持附件)
# ---------------------------------------------------------------------------
def send_mail(to_addr, subject, text, attachments=None, html=None):
"""发送邮件到任意收件人(送达者),支持附件。返回 (ok, msg)"""
"""发送邮件到任意收件人(送达者),支持附件。返回 (ok, msg)
注意:邮件头必须带 Date/Message-ID,否则 mail.tphai.com 的 amavisd 内容过滤器
会以 "invalid header section / Missing required header field: Date" 退信。"""
if not EMAIL.get('host'):
return False, '邮件服务未配置(config.EMAIL.host 为空)'
if not to_addr:
return False, '收件邮箱为空'
from email.utils import formatdate, make_msgid
msg = MIMEMultipart()
msg['From'] = formataddr((EMAIL.get('from_name', 'AI Worker 平台'), EMAIL['user']))
msg['To'] = to_addr
msg['Subject'] = subject
msg['Date'] = formatdate(localtime=True)
msg['Message-ID'] = make_msgid(domain='tphai.com')
if html:
msg.attach(MIMEText(html, 'html', 'utf-8'))
else:
+8
View File
@@ -253,6 +253,14 @@ def run_task(task_id):
f'执行完成:{usage["total_tokens"]} tokens(输入 {usage["prompt_tokens"]} / 输出 {usage["completion_tokens"]}),'
f'成本 ¥{usage["cost"]:.6f}')
# V3.4:产出若为完整网页文档 → 落盘工作目录,供 Demo 真实展示
try:
saved = delivery.save_task_output(task, usage['text'])
if saved:
_log(task_id, 'info', f'🌐 网页产出已保存到工作目录:' + ''.join(saved))
except Exception:
pass
new_status = 'review' if task['review_required'] else 'done'
fields = {
'status': new_status,
+1 -1
View File
@@ -304,7 +304,7 @@ def export_all():
'eval_results', 'templates', 'users', 'audit_logs',
'project_deliverables', 'user_projects', 'user_workers']
out = {'exported_at': time.strftime('%Y-%m-%d %H:%M:%S'),
'platform': 'ai-worker-platform', 'version': 'v3.3.0'}
'platform': 'ai-worker-platform', 'version': 'v3.4.0'}
for t in tables:
try:
out[t] = db.q(f'SELECT * FROM {t}')
+10 -4
View File
@@ -55,15 +55,21 @@ def _send_email(ch, title, text):
from config import EMAIL # 邮件配置在 config.py
if not EMAIL.get('host'):
return None
msg = f'From: {EMAIL["from_name"]} <{EMAIL["user"]}>\nTo: {ch["email"]}\n' \
f'Subject: 【AI Worker 平台】{title}\nContent-Type: text/plain; charset=utf-8\n\n{text}'
from email.mime.text import MIMEText
from email.utils import formataddr, formatdate, make_msgid
msg = MIMEText(text, 'plain', 'utf-8')
msg['From'] = formataddr((EMAIL.get('from_name', 'AI Worker 平台'), EMAIL['user']))
msg['To'] = ch['email']
msg['Subject'] = f'【AI Worker 平台】{title}'
msg['Date'] = formatdate(localtime=True)
msg['Message-ID'] = make_msgid(domain='tphai.com')
try:
s = smtplib.SMTP(EMAIL['host'], EMAIL['port'])
s = smtplib.SMTP(EMAIL['host'], EMAIL['port'], timeout=30)
if EMAIL.get('starttls'):
s.starttls()
if EMAIL.get('user'):
s.login(EMAIL['user'], EMAIL['password'])
s.sendmail(EMAIL['user'], [ch['email']], msg.encode('utf-8'))
s.sendmail(EMAIL['user'], [ch['email']], msg.as_string())
s.quit()
return True
except Exception as e:
+82 -11
View File
@@ -100,6 +100,8 @@ function router() {
const hash = location.hash.replace(/^#\//, '') || 'dashboard';
const parts = hash.split('/');
const name = parts[0];
// 离开项目页时丢弃项目缓存,避免下次进入用旧数据
if (name !== 'project') projCtx = null;
const fn = routes[name] || pageDashboard;
const navMap = {project:'projects', api:'api', settings:'settings', alerts:'alerts'};
$$('#sidebar nav a').forEach(a => a.classList.toggle('active', a.dataset.route === (navMap[name] || name)));
@@ -163,7 +165,7 @@ async function pageProjects() {
${list.map(p => `
<div class="card proj-card" onclick="location.hash='#/project/${p.id}'">
<div style="display:flex;justify-content:space-between;align-items:center">
<b>${esc(p.name)}</b><span class="badge st-${p.status === 'active' ? 'running' : p.status === 'done' ? 'done' : p.status === 'archived' ? 'cancelled' : 'todo'}">${{active:'进行中', planning:'规划中', done:'已完成', archived:'已归档'}[p.status]}</span>
<b>${esc(p.name)}</b><span class="badge st-${p.status === 'active' ? 'running' : p.status === 'review' ? 'review' : p.status === 'done' ? 'done' : p.status === 'archived' ? 'cancelled' : 'todo'}">${{active:'进行中', review:'待验收', planning:'规划中', done:'已完成', archived:'已归档'}[p.status]}</span>
</div>
<div style="color:var(--muted);font-size:12px;margin-top:8px;min-height:32px">${esc(p.objective || p.description || '暂无描述')}</div>
<div style="margin-top:10px;font-size:12px;color:var(--muted)">任务 ${p.task_count} · 完成 ${p.done_count}
@@ -187,7 +189,7 @@ function openProjectModal(p = {}) {
<label>项目描述</label><textarea id="pm-desc">${esc(p.description || '')}</textarea>
<div class="row">
<div><label>状态</label><select id="pm-status">
${['planning','active','done','archived'].map(s => `<option value="${s}" ${p.status === s ? 'selected' : ''}>${{planning:'规划中', active:'进行中', done:'已完成', archived:'已归档'}[s]}</option>`).join('')}
${['planning','active','review','done','archived'].map(s => `<option value="${s}" ${p.status === s ? 'selected' : ''}>${{planning:'规划中', active:'进行中', review:'待验收', done:'已完成', archived:'已归档'}[s]}</option>`).join('')}
</select></div>
<div><label>预算上限(元,0=不限)</label><input id="pm-budget" type="number" value="${p.budget_limit || 0}"></div>
</div>
@@ -274,11 +276,20 @@ let projCtx = null; // {pid, p, tasks, workers, tab, autoLogs}
let projAutoTimer = null;
async function pageProject([pid, tab]) {
tab = tab || 'kanban';
// 同一项目内切 tab:复用已加载的数据,不再重新拉项目/任务/Worker/事件
if (projCtx && String(projCtx.pid) === String(pid)) {
projCtx.tab = tab;
renderProjectShell();
renderActiveTab();
startProjAutoPoll(); // 重启轮询(router 已清掉定时器)
return;
}
const p = (await api(`/api/projects/${pid}`)).data;
const tasks = (await api(`/api/tasks?project_id=${pid}`)).data;
const workers = (await api('/api/workers')).data;
const autoLogs = (await api(`/api/projects/${pid}/autologs`)).data;
projCtx = {pid, p, tasks, workers, tab: tab || 'kanban', autoLogs};
const events = (await api(`/api/projects/${pid}/events`)).data;
projCtx = {pid, p, tasks, workers, tab, events};
renderProjectShell();
renderActiveTab();
startProjAutoPoll();
@@ -291,7 +302,11 @@ function renderActiveTab() {
else renderKanban();
}
/* V3.3:AI 主管自动开工 —— 轮询刷新状态/动态/看板 */
/* V3.3:AI 主管自动开工 —— 轮询刷新状态/事件/看板 */
async function refreshProjEvents() {
try { projCtx.events = (await api(`/api/projects/${projCtx.pid}/events`)).data; } catch (e) {}
}
function startProjAutoPoll() {
clearInterval(projAutoTimer);
renderAutoPanel();
@@ -301,7 +316,7 @@ function startProjAutoPoll() {
const wasRunning = projCtx.p.auto_status === 'running';
const p = (await api(`/api/projects/${projCtx.pid}`)).data;
projCtx.p = p;
projCtx.autoLogs = (await api(`/api/projects/${projCtx.pid}/autologs`)).data;
await refreshProjEvents();
const running = p.auto_status === 'running';
// 状态变化(开工结束/中断)或运行中:都刷新一次任务视图
if (running || wasRunning) {
@@ -320,12 +335,43 @@ async function autoStartProj() {
toast('🚀 AI 主管已开工,正在拆解派活…', 'ok');
const p = (await api(`/api/projects/${projCtx.pid}`)).data;
projCtx.p = p;
projCtx.autoLogs = (await api(`/api/projects/${projCtx.pid}/autologs`)).data;
await refreshProjEvents();
renderAutoPanel();
startProjAutoPoll();
} catch (e) { toast(e.message, 'err'); }
}
/* V3.4:负责人验收(平台内) */
function openReviewModal() {
const {p} = projCtx;
openModal(`
<h3>🔔 项目验收:${esc(p.name)}<button class="close" data-close>×</button></h3>
<p style="color:var(--muted);font-size:13px">AI 团队已全部完工,由您(负责人)最终验收。<br>验收通过后项目才算正式完成,将自动打包交付。</p>
${p.demo_url ? `<p><a href="${esc(p.demo_url)}" target="_blank" style="color:var(--accent)">👀 查看交付 Demo</a></p>` : ''}
<label>验收意见(打回必填)</label><textarea id="rv-reason" placeholder="填写验收意见…"></textarea>
<div class="modal-actions">
<button class="btn" data-close>取消</button>
<button class="btn danger" onclick="submitProjectReview('reject')">⛔ 打回返工</button>
<button class="btn primary" onclick="submitProjectReview('approve')">✅ 验收通过</button>
</div>`);
}
async function submitProjectReview(action) {
const reason = $('#rv-reason').value.trim();
if (action === 'reject' && !reason) return toast('打回必须填写原因', 'err');
try {
await api(`/api/projects/${projCtx.pid}/review`, {method: 'POST', body: {action, reason}});
closeModal();
toast(action === 'approve' ? '✅ 验收通过,项目完成并交付' : '⛔ 已打回,可重新开工返工', 'ok');
const p = (await api(`/api/projects/${projCtx.pid}`)).data;
projCtx.p = p;
await refreshProjEvents();
renderProjectShell();
renderActiveTab();
renderAutoPanel();
} catch (e) { toast(e.message, 'err'); }
}
function renderAutoPanel() {
const el = $('#auto-panel');
if (!el || !projCtx) return;
@@ -334,7 +380,15 @@ function renderAutoPanel() {
const st = {none:['未开工','todo'], running:['🚀 AI 主管工作中','running'], done:['✅ 自动开工完成','done'], failed:['⚠️ 开工中断','failed']}[p.auto_status] || ['—','todo'];
const mgr = p.manager_worker ? esc(p.manager_worker.name) : '<span style="color:var(--warn)">未设置</span>';
const team = (p.team_workers || []).map(w => `<span class="pill" style="color:var(--accent)">${esc(w.name)}</span>`).join(' ') || '<span style="color:var(--warn)">未设置</span>';
const logs = (projCtx.autoLogs || []).map(l => `<div class="al-item"><span class="al-lv">${l.level === 'error' ? '❌' : l.level === 'warn' ? '⚠️' : l.level === 'success' ? '✅' : '🤖'}</span><span class="al-msg">${esc(l.message)}</span><span class="al-time">${fmtTime(l.created_at)}</span></div>`).join('');
const evs = projCtx.events || [];
const collapsed = localStorage.getItem('aw_ev_collapsed_' + projCtx.pid) === '1';
const icon = l => ({error:'❌', warn:'⚠️', success:'✅', info:'🤖'}[l] || '•');
const items = evs.map(e => `
<div class="al-item ${e.task_id ? 'al-task' : ''}" ${e.task_id ? `onclick="openTaskDetail(${e.task_id})" title="点击定位任务 #${e.task_id}"` : ''}>
<span class="al-lv">${icon(e.level)}</span>
<span class="al-msg">${esc(e.message)}${e.task_id ? `<span class="al-go">任务 #${e.task_id} </span>` : ''}</span>
<span class="al-time">${fmtTime(e.created_at)}</span>
</div>`).join('') || '<div class="empty" style="padding:12px">暂无事件</div>';
el.innerHTML = `
<div class="auto-bar">
<span class="badge st-${st[1]}">${st[0]}</span>
@@ -342,12 +396,29 @@ function renderAutoPanel() {
${running ? '<span class="spin"></span>' : ''}
</div>
<div class="auto-meta">👔 AI 主管:${mgr} 👷 干活团队:${team} ${p.auto_started_at ? '开工于 ' + fmtTime(p.auto_started_at) : '—'}</div>
${logs ? `<div class="auto-logs">${logs}</div>` : ''}`;
<div class="ev-panel ${collapsed ? 'collapsed' : ''}">
<div class="ev-head" onclick="toggleEvents()">
<span class="ev-arrow">${collapsed ? '▸' : '▾'}</span>
<span>📋 事件记录</span>
<span class="ev-count">${evs.length} 条</span>
<span class="ev-hint">${collapsed ? '点击展开' : '点击收起'}</span>
</div>
<div class="ev-body">${items}</div>
</div>`;
}
function toggleEvents() {
const panel = $('#auto-panel .ev-panel');
if (!panel) return;
const collapsed = panel.classList.contains('collapsed');
localStorage.setItem('aw_ev_collapsed_' + projCtx.pid, collapsed ? '0' : '1');
renderAutoPanel();
}
function renderProjectShell() {
const {p, pid, tab} = projCtx;
const running = p.auto_status === 'running';
const inReview = p.status === 'review';
$('#main').innerHTML = `
<div class="toolbar">
<a class="btn" href="#/projects">← 返回</a>
@@ -355,7 +426,7 @@ function renderProjectShell() {
${p.perm === 'view' ? '' : `<button class="btn" onclick="openProjectModal(${JSON.stringify(p).replace(/"/g, '&quot;')})">编辑项目</button>`}
${p.perm === 'view' ? '' : `<button class="btn" onclick="openWbsModal()">🧠 AI 规划(WBS)</button>`}
${p.perm === 'view' ? '' : `<button class="btn" onclick="openTaskModal()"> 新建任务</button>`}
${p.perm === 'view' || running ? '' : `<button class="btn primary" onclick="autoStartProj()">🚀 AI 主管开工</button>`}
${p.perm === 'view' || running ? '' : (inReview ? `<button class="btn primary" onclick="openReviewModal()">🔔 验收项目</button>` : `<button class="btn primary" onclick="autoStartProj()">🚀 AI 主管开工</button>`)}
</div>
<div class="tabs">
<a href="#/project/${pid}/kanban" class="${tab === 'kanban' ? 'active' : ''}">看板</a>
@@ -2542,7 +2613,7 @@ async function entGrantUser(uid, uname) {
<div class="sec-title" style="margin-top:14px">📁 项目授权(${projects.length} 个)</div>
<table style="max-height:220px;overflow:auto;display:block"><tbody>
${projects.map(p => `
<tr><td>#${p.id} ${esc(p.name)} <span class="tag">${{active:'进行中',planning:'规划中',done:'已完成',archived:'已归档'}[p.status] || p.status}</span></td>
<tr><td>#${p.id} ${esc(p.name)} <span class="tag">${{active:'进行中',review:'待验收',planning:'规划中',done:'已完成',archived:'已归档'}[p.status] || p.status}</span></td>
<td style="width:140px"><select data-gp="${p.id}">
${PROJ_PERMS.map(([v, l]) => `<option value="${v}" ${p.perm === v ? 'selected' : ''}>${l}</option>`).join('')}
</select></td></tr>`).join('') || '<tr><td class="empty">暂无项目</td></tr>'}
+12 -1
View File
@@ -208,9 +208,20 @@ code{background:var(--panel2);border:1px solid var(--border);border-radius:6px;p
.pick-item small{color:var(--muted)}
.auto-bar{display:flex;align-items:center;gap:10px;background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:10px 14px;margin-bottom:8px}
.auto-meta{font-size:12px;color:var(--muted);padding:0 4px 8px}
.auto-logs{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:8px 12px;margin-bottom:14px;max-height:180px;overflow-y:auto}
.ev-panel{background:var(--panel);border:1px solid var(--border);border-radius:10px;margin-bottom:14px;overflow:hidden}
.ev-head{display:flex;align-items:center;gap:8px;padding:10px 14px;cursor:pointer;font-size:13px;font-weight:600;user-select:none}
.ev-head:hover{background:var(--panel2)}
.ev-arrow{color:var(--accent);font-size:12px;transition:transform .15s}
.ev-count{background:var(--panel2);border:1px solid var(--border);border-radius:20px;padding:0 8px;font-size:11px;color:var(--muted)}
.ev-hint{margin-left:auto;color:var(--muted);font-size:11px;font-weight:400}
.ev-body{max-height:320px;overflow-y:auto;padding:6px 12px 10px;border-top:1px solid var(--border)}
.ev-panel.collapsed .ev-body{display:none}
.ev-panel.collapsed .ev-arrow{transform:rotate(-90deg)}
.al-item{display:flex;gap:8px;align-items:baseline;font-size:12px;padding:4px 0;border-bottom:1px dashed var(--border)}
.al-item:last-child{border-bottom:none}
.al-lv{flex:none}
.al-msg{flex:1;word-break:break-all}
.al-time{flex:none;color:var(--muted);font-size:11px}
.al-task{cursor:pointer}
.al-task:hover .al-msg{color:var(--accent)}
.al-go{color:var(--accent);font-size:11px;margin-left:6px;border:1px solid var(--border);border-radius:4px;padding:0 5px}