diff --git a/README.md b/README.md index 0598d87..6aa77b7 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,21 @@ > 以「项目」为中心、以「AI Worker」为执行单元的项目管理平台。 > 把大模型团队变成一支可指挥、可审计、可控成本的"虚拟团队"。 -**当前版本:V3.3**(AI 主管自动开工 / 项目创建即开工 / 失败诊断重试) +**当前版本:V3.4**(负责人验收制 / 邮件通道修复 / 事件记录面板 / 真实网页交付) + +--- + +## 🏁 V3.4 负责人验收制 + 交付增强(新增) + +| 能力 | 说明 | +|---|---| +| 🔔 项目级验收 | AI 团队全部完工后**不自动宣布完成**,项目进入「待验收」状态,邮件通知负责人(送达者);负责人验收通过项目才算正式完成 | +| 🔗 公开验收链接 | 验收邮件含免登录链接 `/review/`,负责人一键「验收通过 / 打回返工」(打回必填原因) | +| 🔁 打回自动返工 | 打回后自动生成「返工任务」(含负责人意见),AI 主管立即按意见重做,再次提交验收;平台内也可直接验收/打回 | +| 🌐 真实网页交付 | 任务的 HTML 产出自动落盘工作目录(自动剥离代码围栏/前置叙述),Demo 展示**真实页面**而非占位说明;返工产出自动覆盖入口页 | +| 📋 事件记录面板 | 项目页「事件记录」可展开/收起(记忆状态):合并 AI 主管动态 + 任务日志 + 交付记录,任务事件可点击定位到对应任务 | +| 📮 邮件通道修复 | 修复邮件头缺 Date 导致 amavisd 退信(`invalid header section`)的问题,打包附件邮件/各类通知恢复正常送达 | +| 🔁 规划重试 | 任务拆解解析失败自动重试 3 次(每次重新调用 LLM),仍失败则邮件通知负责人并中断 | --- diff --git a/app.py b/app.py index e61b375..742747b 100644 --- a/app.py +++ b/app.py @@ -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.1'}) + return jsonify({'ok': True, 'service': 'ai-worker-platform', 'version': 'v3.4.0'}) # --------------------------------------------------------------------------- @@ -1094,6 +1094,177 @@ def project_autologs(pid): return jsonify({'ok': True, 'data': rows}) +@app.route('/api/projects//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=?', (pid,)): + 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', (pid,)): + 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=?', (pid,)): + 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 = ''' + +项目验收 · AI Worker 平台 + +
+

🔔 项目验收:{name}

+
AI 团队已全部完工,等待您(项目负责人)验收
+
+
项目状态:待验收
+
目标:{objective}
+
任务完成:{done}/{total}(失败 {failed})
+
累计成本:¥{cost}
+
+{demo_html} + + +
+ + +
+
+
验收通过后项目正式完成并自动打包交付;打回后 AI 主管将按意见返工。
+
+''' + + +@app.route('/review/') +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 '

验收链接无效或已处理

', 404 + s = delivery.project_summary(p) + demo_html = f'👀 查看交付 Demo' \ + 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('&', '&').replace('<', '<').replace('>', '>').replace('"', '"') + + +@app.route('/api/review/', 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//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//dag') @require_auth def project_dag(pid): diff --git a/autostart.py b/autostart.py index 50f93da..d01145b 100644 --- a/autostart.py +++ b/autostart.py @@ -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 = {} diff --git a/db.py b/db.py index 5e28f58..c5b6078 100644 --- a/db.py +++ b/db.py @@ -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, diff --git a/delivery.py b/delivery.py index 0c2d8d4..f3ce695 100644 --- a/delivery.py +++ b/delivery.py @@ -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.4:done=已验收完成;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 文档:全文本定位 ;自动剥离 markdown 代码围栏与前置叙述。返回纯 HTML 或 None。""" + if not text or not isinstance(text, str): + return None + import re as _re + m = _re.search(r']', text, _re.I) + if not m: + return None + html = text[m.start():] + end = html.lower().rfind('') + 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: diff --git a/engine.py b/engine.py index f22f8c7..21f7734 100644 --- a/engine.py +++ b/engine.py @@ -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, diff --git a/enterprise.py b/enterprise.py index e2326da..92fbc80 100644 --- a/enterprise.py +++ b/enterprise.py @@ -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.1'} + 'platform': 'ai-worker-platform', 'version': 'v3.4.0'} for t in tables: try: out[t] = db.q(f'SELECT * FROM {t}') diff --git a/notify.py b/notify.py index 3c2d2eb..9bf3311 100644 --- a/notify.py +++ b/notify.py @@ -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: diff --git a/static/app.js b/static/app.js index c0b6cd3..2ce5549 100644 --- a/static/app.js +++ b/static/app.js @@ -163,7 +163,7 @@ async function pageProjects() { ${list.map(p => `
- ${esc(p.name)}${{active:'进行中', planning:'规划中', done:'已完成', archived:'已归档'}[p.status]} + ${esc(p.name)}${{active:'进行中', review:'待验收', planning:'规划中', done:'已完成', archived:'已归档'}[p.status]}
${esc(p.objective || p.description || '暂无描述')}
任务 ${p.task_count} · 完成 ${p.done_count} @@ -187,7 +187,7 @@ function openProjectModal(p = {}) {
@@ -277,8 +277,8 @@ async function pageProject([pid, tab]) { 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: tab || 'kanban', events}; renderProjectShell(); renderActiveTab(); startProjAutoPoll(); @@ -291,7 +291,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 +305,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 +324,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(` +

🔔 项目验收:${esc(p.name)}

+

AI 团队已全部完工,由您(负责人)最终验收。
验收通过后项目才算正式完成,将自动打包交付。

+ ${p.demo_url ? `

👀 查看交付 Demo

` : ''} + + `); +} + +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 +369,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) : '未设置'; const team = (p.team_workers || []).map(w => `${esc(w.name)}`).join(' ') || '未设置'; - const logs = (projCtx.autoLogs || []).map(l => `
${l.level === 'error' ? '❌' : l.level === 'warn' ? '⚠️' : l.level === 'success' ? '✅' : '🤖'}${esc(l.message)}${fmtTime(l.created_at)}
`).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 => ` +
+ ${icon(e.level)} + ${esc(e.message)}${e.task_id ? `任务 #${e.task_id} ›` : ''} + ${fmtTime(e.created_at)} +
`).join('') || '
暂无事件
'; el.innerHTML = `
${st[0]} @@ -342,12 +385,29 @@ function renderAutoPanel() { ${running ? '' : ''}
👔 AI 主管:${mgr} | 👷 干活团队:${team} | ⏱ ${p.auto_started_at ? '开工于 ' + fmtTime(p.auto_started_at) : '—'}
- ${logs ? `
${logs}
` : ''}`; +
+
+ ${collapsed ? '▸' : '▾'} + 📋 事件记录 + ${evs.length} 条 + ${collapsed ? '点击展开' : '点击收起'} +
+
${items}
+
`; +} + +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 = `
← 返回 @@ -355,7 +415,7 @@ function renderProjectShell() { ${p.perm === 'view' ? '' : ``} ${p.perm === 'view' ? '' : ``} ${p.perm === 'view' ? '' : ``} - ${p.perm === 'view' || running ? '' : ``} + ${p.perm === 'view' || running ? '' : (inReview ? `` : ``)}
看板 @@ -2542,7 +2602,7 @@ async function entGrantUser(uid, uname) {
📁 项目授权(${projects.length} 个)
${projects.map(p => ` - + `).join('') || ''} diff --git a/static/style.css b/static/style.css index ebf61e5..8790008 100644 --- a/static/style.css +++ b/static/style.css @@ -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}
#${p.id} ${esc(p.name)} ${{active:'进行中',planning:'规划中',done:'已完成',archived:'已归档'}[p.status] || p.status}
#${p.id} ${esc(p.name)} ${{active:'进行中',review:'待验收',planning:'规划中',done:'已完成',archived:'已归档'}[p.status] || p.status}
暂无项目