From 472af01708fa29a9e909df891978ce8c0d5c79e1 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Wed, 12 Aug 2026 00:22:36 +0800 Subject: [PATCH] =?UTF-8?q?AI=20Worker=20=E9=A1=B9=E7=9B=AE=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=B9=B3=E5=8F=B0=20MVP=EF=BC=9A=E9=A1=B9=E7=9B=AE/?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=9C=8B=E6=9D=BF=20+=20Worker=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=20+=20=E5=8D=95=E4=BB=BB=E5=8A=A1=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=20+=20HITL=E5=AE=A1=E6=A0=B8=20+=20=E6=88=90=E6=9C=AC=E8=AE=B0?= =?UTF-8?q?=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 70 ++++ __pycache__/config.cpython-312.pyc | Bin 0 -> 2751 bytes __pycache__/db.cpython-312.pyc | Bin 0 -> 6428 bytes __pycache__/engine.cpython-312.pyc | Bin 0 -> 9271 bytes __pycache__/llm_gateway.cpython-312.pyc | Bin 0 -> 5028 bytes app.py | 396 +++++++++++++++++++ config.py | 81 ++++ data/aiworker.db | Bin 0 -> 49152 bytes db.py | 157 ++++++++ engine.py | 176 +++++++++ llm_gateway.py | 95 +++++ logs/app.log | 26 ++ logs/app.pid | 1 + seed.py | 43 ++ start.sh | 37 ++ static/app.js | 505 ++++++++++++++++++++++++ static/index.html | 47 +++ static/style.css | 128 ++++++ 18 files changed, 1762 insertions(+) create mode 100644 README.md create mode 100644 __pycache__/config.cpython-312.pyc create mode 100644 __pycache__/db.cpython-312.pyc create mode 100644 __pycache__/engine.cpython-312.pyc create mode 100644 __pycache__/llm_gateway.cpython-312.pyc create mode 100644 app.py create mode 100644 config.py create mode 100644 data/aiworker.db create mode 100644 db.py create mode 100644 engine.py create mode 100644 llm_gateway.py create mode 100644 logs/app.log create mode 100644 logs/app.pid create mode 100644 seed.py create mode 100755 start.sh create mode 100644 static/app.js create mode 100644 static/index.html create mode 100644 static/style.css diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc499a6 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# AI Worker 项目管理平台(MVP) + +> 以「项目」为中心、以「AI Worker」为执行单元的项目管理平台。 +> 把大模型团队变成一支可指挥、可审计、可控成本的"虚拟团队"。 + +**MVP 目标**:跑通「人派活 → AI 干活 → 人验收」最小闭环。 + +## 功能清单(MVP) + +| 模块 | 能力 | +|---|---| +| 📁 项目管理 | 项目 CRUD、目标/验收标准/预算上限、状态流转(规划/进行/完成/归档) | +| 🧑💻 Worker 管理 | 注册虚拟员工(供应商 + 模型 + 角色提示词 + 温度/Token + 成本上限)、连通性测试、启停用 | +| 📋 任务编排 | 任务 CRUD、看板(拖拽改状态)、优先级、截止时间、**自动路由**(按价格选最便宜可用 Worker) | +| 🚀 执行引擎 | 后台线程执行、统一模型网关(多供应商 OpenAI 兼容)、失败重试、超时、成本上限预检、断点状态 | +| ✅ 人工审核 HITL | 产出自动进入「待审核」,人工通过/打回(必填原因)→ 返工重跑,AI 不可绕过 | +| 💰 成本记账 | Token/成本按 项目×Worker×模型 多维核算、预算硬约束、近 7 天趋势 | +| 📊 质量指标 | 一次通过率、返工次数、失败数 | +| 📜 全链路日志 | 任务级执行日志 + 全局日志流,每个结论可溯源 | + +## 快速开始 + +```bash +./start.sh # 启动(默认 16071) +./start.sh stop # 停止 +./start.sh restart # 重启 +``` + +- 管理界面:`http://:16071/`,默认口令 `admin123`(改 `config.py` 的 `AUTH_PASSWORD`,留空则免登录) +- 首次启动自动灌入演示数据(1 项目 / 1 Worker / 3 任务),可用 `python3 seed.py` 重新初始化 + +## 技术栈 + +- 后端:Python + Flask + SQLite(WAL),无外部依赖,纯标准库 + flask/requests +- 前端:原生 HTML/JS/CSS 单页应用(无 CDN,离线可用) +- 模型网关:OpenAI 兼容协议,内置 doubao / deepseek / openai / qwen / vLLM 供应商,可扩展 + +## 目录结构 + +``` +ai-worker-platform/ +├── app.py # Flask 应用 + REST API +├── config.py # 供应商/定价/鉴权/端口配置 +├── db.py # SQLite 数据层(projects/workers/tasks/logs/cost_records) +├── engine.py # 任务执行引擎(后台线程 + 自动路由 + 预算预检) +├── llm_gateway.py # 统一模型网关(调用/重试/计量/计价) +├── seed.py # 演示数据 +├── start.sh # 启停脚本 +├── static/ # 前端 SPA +└── data/ # SQLite 库;logs/ 运行日志 +``` + +## API 摘要 + +- `POST /api/login` 登录;`GET /api/me` 会话检查 +- `GET|POST /api/projects`、`GET|PUT|DELETE /api/projects/` +- `GET|POST /api/workers`、`GET|PUT|DELETE /api/workers/`、`POST /api/workers//test` +- `GET|POST /api/tasks`、`GET|PUT|DELETE /api/tasks/` +- `POST /api/tasks//run` 派活、`/cancel` 取消、`/review` 审核(approve/reject+reason) +- `GET /api/reports/cost?group=project|worker|model`、`GET /api/stats`、`GET /api/logs` + +## 定价配置 + +`config.py` 中 `MODEL_PRICING` 按「元 / 1M tokens」登记各模型单价,未登记的走默认价。 +成本 = 输入 tokens/1e6 × 输入单价 + 输出 tokens/1e6 × 输出单价。 + +## 路线图(超出 MVP) + +- V1:DAG 多任务编排(Temporal 持久执行)、RAG 知识库、开放 API、飞书/企微集成 +- V2:多 Agent 协作(主管/评审/辩论模式)、自动评估体系、模板市场、企业版 diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0694ca0fae9e10ab2dcc9f65044bd4b8d0fef977 GIT binary patch literal 2751 zcmb7FT}&I<6&{^kW-;7!J38wUf50tQbLhaWq(SvJ;Iqp|M=25gV( zACgR8993=B(yja0*`mmt9^{IEp9!Q$a z1B1l*?z!jt?z!ij8UK@}DFQ$A%F7XG_=&Gp(6{dpi9#NHgIB-1a{y!2LB}J{y+X(pywF;Q?K#g2EE7N zpMH&h<{0dEz`I)Z$6z_ZS&h}I*}2X3qyzj6ya#^1sehwA5Bk*oe&AB=`{06VFJgwm zI}aGux;M?qlbTAh8?ec@fGybrJjqjc^e%6i!TqCxVdtDwScU~>AqZp=Kvv9oeFO6Qg2*(Zn0!Vk%}a$WlU>O(MVKzY z60;2NFg}lO&@<%qmF@W4y}OTgzxn3*XODmQ_@Of-l~aNwGmE8CzBt0NLSfmR&M_${ zE{VAX_llHB!=gJaWmzFFvRFUIt_+~gP-J#I7~zA_Fh9xNl--!d_)?xhkRAri&`@BA z@uvqNlk$1I%)H;{8wd=-z;HUn`22%lSo{!w4_qW7b3zs(Q%Wd8zFf#4BR+%2`66RU z%tEPLLZ%Re`8b5ja_?b=dC?89w}`!JVk=(M72>#PoZ}|7j1Ykgkvz-=!}6(@i4yh+ zqDJeCM7fz@SR<8B;_5Uu{@%Fv$+N$#H7cJ!dtBZ9^f!(5ZP}}d0HL^8Ok)|hkP+{c zbLnPraDno;eW?s&vJ!wS&Z*qD2L*nczpr2^WKpmkH@2GtAVZRqjqjOnz-p|M+TsF z6PdL7AVc%Qi2T(pm_y`BCX-#jnJCX#3pmGIdekoF^0?dvTytz&J6&3crv)lr4cr)8 z`22zL9E`SrP4hHHTlD@hmsxf+Q^Lat0R!CeeyI=NtnL);P$usD^BzLy_`GUj@ z_?Bf_y9kqB6iTw~a9MytQOd!BY*@&b7{4T2nwG)ugeaSp+v1m@T$D{J%nG*~%1;aMMOk6|cwMaaH$Y>;%Ky+|6aSglU z@wrGWgs3<-5#tid>!f74A{L|^YFmIMWRAunAB00(ERN{uNQj%_qp|Qrc;+giL)@IxMJ#BQRFL@++dC&JTQWHy1U_^1dU!;s^nk;v2^h!@HP{PzaCC}kn5 z%t$&T++y8_Nco=N3Zx;kDEmu`d>RjRfmOZi>z^AoE#>CL1$X`qGF`^=U(UcQh0FK^ zrRK%|;Q#w=I-Tx-)a%F>20}-DZ=qKM)xhd#b#%k`Fz_JoaP-0GS3|X4f6a2KVyd^c zFIO*bn6}Q>EH3P$I#&CteXAF%7w_NR>aJ1!6=R*YuFh5GRzIqKw9&tHtwu8y>U-+M z{i`+e=}NFpS}WuCChtz(*KLIUMV{SnBd9a`1_Fy837x4?EEY0$Rmi%rqe9|+TW^J~ z)58^Nzl*S)uRA8{_LJ))Ya{Dd)~-A`=X>G@H9M@^yARCmR>OWLj;}ksuf+IwQZ@T< zGsLEboT@w6S3-O{<28HWWtINAKEK^w4tYGgY^sSw@v$coZ>&QQ#O5t j-tQ)Q-}%b^w2Q&mK+v5PbKQEP(t)x4^j&LV1ULPE@6zMX literal 0 HcmV?d00001 diff --git a/__pycache__/db.cpython-312.pyc b/__pycache__/db.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eb5a42cb9c29cea8b9f54f716ddffb72bb79a0cd GIT binary patch literal 6428 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 literal 0 HcmV?d00001 diff --git a/__pycache__/engine.cpython-312.pyc b/__pycache__/engine.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..28c3f1830956c5d4a3ea18877ff70c55c56f2ed8 GIT binary patch literal 9271 zcmcgSYg80hnpM@+?{1n$qlkzlD$owM2OpZ)Hw>IcL; z_RKkZIeqU}_wn7=_rCnaYBdp%ifVt>@<=X0e1SgViB^HTK>!8`mN-tZBujb8<0LMr z;}kA6$2GX5kJGr+9@ny(XNluFmWEu?Qz9&!_FfZWU)A-7P(Zh|#EOR(lE zlrrCOD@AbDE9&IO(|Ra}C2$5lP1C>see8wdnSo2Q!>41{Uz>U54DdtW>WLW%KZ&YQYEgHmd?`6g28kdUq*8jt&pf4D{3M#__z96j53!Q) z>*AeO5xj;ZjzMh~(XBZ~bde6KzPD~~{lV%34b0y9hW(7+CA2!dzGi_b!k~vOX1rV{ z=PhR1I6-hVbH$9C=Uje{b-Mfx=5S@*q3VN7(e`5e=Wx(6#Wu=hm#k^`b;-u+Za3HA z_xRc!8dOn04wsu-f1Q{F`MCc_YaT_!i5QB&ktN|{K|sBC|h6@OSNdBSdb8t1j#BQ z-usF=YhNlsBrZ8Qn5Bc%IfB(*&@{vN7Gts{4#hG43JB~h`Iat~a2&dN*%att)vNBM z;Nnxai!N;w{O%S)R!fj*%fu5fNgo*`VOrt>^$T*4B#F~zn&=_l(jaQJldS@xc81~6xyi;Y&0IUz-NA3`&2tKz--+m@WQ*75c6o(uC5hH0&{((3)@9OKzA;`m3gB-Sbta zszh^Mq#?3vXq{**5jWJ17QVm!-SxjM1#o<`w4wGBy6!F@Cg#bJG^oIGWKC`@pO$}rV|RHPA= z%Q|JRvNnKZ?0h1#fVfMX6_40$8T2laiLKI-%)ng~({ z35PDQ9XifF|F5wZMrPlC=hmz5f>|(gdSGT`IQGjIXFqra^m^>2*QS5}e(a^^W7p2i zoIZQIe}LiIU5#FjWsYDa)BDiD>bmNk4a{0**Ma@}R2mgjsUrAY{(!KxKpj)S>_1Rd zeSq0<4CGBcDBF5IAC}MzKS-Z%chHy~Wb&kJ_Ilf#%^+;MTs^WG)0ne^_qaKSfnN!o zWt#VOfedQo1ix%Txza3?ysYW)w99mdFW{H8c#MGL7HCwQ6iJod;c>S*RgQ86{62mo z^jQNxp&hb*V%lsyQ#Md0rq|ps*Y;O^YPLmfS<%HSB--9z6H z(lX~vgeASd_S;m2?j2LrJphm+D@Yf`F|YC+xV&e5zs-U8ana6#JyGy*kP!r^%_bf0+<3ol6DF)zlDnAfDvYmU#7oVUl~ zum*|%sbX+WkG*jF_3-UqJv;O3exT9W4_}R)`z2H4Yb|EL_7cFFl5NAe8BVz_Igeg)H|8E=kXLVFLdM^nDP1!XF{)CqCC(#kA!UdS>Xb zSiDtQy=^>WV)c$GTgFXWfn+NfDj!)jyyb>%Lx={o94;4g*GXB$6XxP*R*slgHrg<@ za3l`jsO(v2>?YR5gBbqG%6Bzgn`b z9%>qC81~+#E6wWzbg*0I?Q6*zMsV~+o)LM3cimDQoJFknhh*A7TSTyK*)ob zSLVTNkj@%eQ&7X2fⅅ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 literal 0 HcmV?d00001 diff --git a/__pycache__/llm_gateway.cpython-312.pyc b/__pycache__/llm_gateway.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1077c3a4f82f21a45f957dd517ffe550d4a6102d GIT binary patch literal 5028 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 literal 0 HcmV?d00001 diff --git a/app.py b/app.py new file mode 100644 index 0000000..87e97e0 --- /dev/null +++ b/app.py @@ -0,0 +1,396 @@ +# -*- coding: utf-8 -*- +""" +AI Worker 项目管理平台 - MVP +人派活 → AI 干活 → 人验收 最小闭环 +""" +import os +import functools +from flask import Flask, request, jsonify, session, send_from_directory + +import config +import db +import engine +import llm_gateway + +app = Flask(__name__, static_folder='static', static_url_path='') +app.secret_key = config.SECRET_KEY +db.init_db() + + +# --------------------------------------------------------------------------- +# 鉴权(简单口令登录) +# --------------------------------------------------------------------------- +def auth_enabled(): + return bool(config.AUTH_PASSWORD) + + +@app.route('/api/login', methods=['POST']) +def login(): + data = request.get_json(force=True) + if not auth_enabled(): + return jsonify({'ok': True}) + if data.get('password') == config.AUTH_PASSWORD: + session['authed'] = True + return jsonify({'ok': True}) + return jsonify({'ok': False, 'error': '口令错误'}), 401 + + +@app.route('/api/logout', methods=['POST']) +def logout(): + session.clear() + return jsonify({'ok': True}) + + +@app.route('/api/me') +def me(): + return jsonify({'ok': True, 'authed': not auth_enabled() or session.get('authed')}) + + +def require_auth(fn): + @functools.wraps(fn) + def wrapper(*args, **kwargs): + if auth_enabled() and not session.get('authed'): + return jsonify({'ok': False, 'error': '未登录'}), 401 + return fn(*args, **kwargs) + return wrapper + + +@app.route('/api/health') +def health(): + return jsonify({'ok': True, 'service': 'ai-worker-platform', 'version': 'v1.0.0'}) + + +# --------------------------------------------------------------------------- +# 项目 +# --------------------------------------------------------------------------- +@app.route('/api/projects', methods=['GET', 'POST']) +@require_auth +def projects(): + if request.method == 'POST': + d = request.get_json(force=True) + pid = db.w( + 'INSERT INTO projects (name, description, objective, acceptance_criteria, ' + 'status, budget_limit, 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), db.now(), db.now())) + return jsonify({'ok': True, 'id': pid}) + rows = db.q('SELECT * FROM projects ORDER BY id DESC') + for r in rows: + r['task_count'] = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=?', (r['id'],))[0]['c'] + r['done_count'] = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND status="done"', + (r['id'],))[0]['c'] + return jsonify({'ok': True, 'data': rows}) + + +@app.route('/api/projects/', methods=['GET', 'PUT', 'DELETE']) +@require_auth +def project_detail(pid): + if request.method == 'GET': + p = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True) + if not p: + return jsonify({'ok': False, 'error': '项目不存在'}), 404 + return jsonify({'ok': True, 'data': p}) + if request.method == 'DELETE': + db.w('DELETE FROM tasks WHERE project_id=?', (pid,)) + db.w('DELETE FROM cost_records WHERE project_id=?', (pid,)) + db.w('DELETE FROM projects WHERE id=?', (pid,)) + return jsonify({'ok': True}) + d = request.get_json(force=True) + fields = ['name', 'description', 'objective', 'acceptance_criteria', 'status', 'budget_limit'] + 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 projects SET {", ".join(sets)}, updated_at=? WHERE id=?', (*args, pid)) + return jsonify({'ok': True}) + + +# --------------------------------------------------------------------------- +# Worker +# --------------------------------------------------------------------------- +@app.route('/api/workers', methods=['GET', 'POST']) +@require_auth +def workers(): + if request.method == 'POST': + 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}) + rows = db.q('SELECT * FROM workers ORDER BY id DESC') + for r in rows: + r['month_cost'] = round(db.monthly_worker_cost(r['id']), 6) + return jsonify({'ok': True, 'data': rows}) + + +@app.route('/api/workers/', methods=['GET', 'PUT', 'DELETE']) +@require_auth +def worker_detail(wid): + if request.method == 'GET': + w = db.q('SELECT * FROM workers WHERE id=?', (wid,), one=True) + return jsonify({'ok': True, 'data': w}) if w else (jsonify({'ok': False, 'error': '不存在'}), 404) + if request.method == 'DELETE': + db.w('UPDATE tasks SET worker_id=NULL WHERE worker_id=?', (wid,)) + db.w('DELETE FROM workers WHERE id=?', (wid,)) + return jsonify({'ok': True}) + 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//test', methods=['POST']) +@require_auth +def worker_test(wid): + 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/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': ''}.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) + tid = db.w( + 'INSERT INTO tasks (project_id, worker_id, title, description, priority, ' + 'review_required, deadline, 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', ''), + 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: + rows = db.q('SELECT * FROM tasks WHERE project_id=? ORDER BY id DESC', (int(pid),)) + else: + rows = db.q('SELECT * FROM tasks ORDER BY id DESC LIMIT 200') + return jsonify({'ok': True, 'data': [db.serialize_task(t) for t in rows]}) + + +@app.route('/api/tasks/', 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': + 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}) + if request.method == 'DELETE': + 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}) + 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 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//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 + if t['status'] == 'running': + return jsonify({'ok': False, 'error': '任务已在执行中'}), 400 + if engine.runner.submit(tid): + return jsonify({'ok': True}) + return jsonify({'ok': False, 'error': '任务已在执行中'}), 400 + + +@app.route('/api/tasks//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 + 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//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 + 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())) + 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())) + return jsonify({'ok': True}) + return jsonify({'ok': False, 'error': '未知操作'}), 400 + + +# --------------------------------------------------------------------------- +# 报表 / 统计 +# --------------------------------------------------------------------------- +@app.route('/api/reports/cost') +@require_auth +def report_cost(): + group = request.args.get('group', 'project') + if group == 'worker': + rows = db.q( + 'SELECT worker_id, provider, model, COUNT(*) runs, SUM(total_tokens) tokens, ' + 'SUM(cost) cost FROM cost_records GROUP BY worker_id ORDER BY cost DESC') + 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, ' + 'SUM(cost) cost FROM cost_records GROUP BY model ORDER BY cost DESC') + else: + rows = db.q( + 'SELECT project_id, COUNT(*) runs, SUM(total_tokens) tokens, ' + 'SUM(cost) cost FROM cost_records GROUP BY project_id ORDER BY cost DESC') + 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 +def stats(): + out = {'projects': 0, 'tasks': 0, 'workers': 0, 'total_cost': 0, 'total_tokens': 0, + 'by_status': {}, 'recent': [], 'daily_cost': []} + 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')[0]['c'] + for r in db.q('SELECT status, COUNT(*) c FROM tasks GROUP BY status'): + out['by_status'][r['status']] = r['c'] + c = db.q('SELECT COALESCE(SUM(cost),0) cost, COALESCE(SUM(total_tokens),0) tokens ' + 'FROM cost_records')[0] + out['total_cost'], out['total_tokens'] = round(c['cost'], 4), c['tokens'] + + # 一次通过率:done 且 rejection_count=0 + done = out['by_status'].get('done', 0) + clean = db.q('SELECT COUNT(*) c FROM tasks WHERE status="done" AND rejection_count=0')[0]['c'] + out['one_pass_rate'] = round(clean / done * 100, 1) if done else None + out['rework_count'] = db.q('SELECT COALESCE(SUM(rejection_count),0) c FROM tasks')[0]['c'] + + 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 ' + 'ORDER BY t.updated_at DESC LIMIT 10') + + # 近 7 天成本 + import datetime + rows = db.q('SELECT created_at, cost FROM cost_records ORDER BY created_at') + 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}) + + +@app.route('/api/logs') +@require_auth +def logs(): + limit = min(int(request.args.get('limit', 100)), 500) + 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,)) + return jsonify({'ok': True, 'data': rows}) + + +# --------------------------------------------------------------------------- +# 前端 +# --------------------------------------------------------------------------- +@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) diff --git a/config.py b/config.py new file mode 100644 index 0000000..d375934 --- /dev/null +++ b/config.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +""" +AI Worker 项目管理平台 - 配置文件 +""" +import os + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.path.join(BASE_DIR, 'data') +LOG_DIR = os.path.join(BASE_DIR, 'logs') +STATIC_DIR = os.path.join(BASE_DIR, 'static') + +for d in (DATA_DIR, LOG_DIR): + os.makedirs(d, exist_ok=True) + +DB_PATH = os.path.join(DATA_DIR, 'aiworker.db') +HOST = '0.0.0.0' +PORT = 16071 + +# 登录口令(留空则关闭登录校验) +AUTH_PASSWORD = 'admin123' +SECRET_KEY = 'ai-worker-platform-mvp-secret-key-20260812' + +# --------------------------------------------------------------------------- +# 模型供应商网关(OpenAI 兼容协议) +# api_key 留空则使用 os.environ.get('_API_KEY') +# --------------------------------------------------------------------------- +PROVIDERS = { + 'doubao': { + 'name': '火山方舟 Doubao', + 'base_url': 'https://ark.cn-beijing.volces.com/api/plan/v3', + 'api_key': os.environ.get('DOUBAO_API_KEY', 'ark-2b06dc9d-8878-4c6e-b201-f422376e79cb-246d9'), + 'timeout': 600, + }, + 'deepseek': { + 'name': 'DeepSeek', + 'base_url': 'https://api.deepseek.com/v1', + 'api_key': os.environ.get('DEEPSEEK_API_KEY', ''), + 'timeout': 600, + }, + 'openai': { + 'name': 'OpenAI', + 'base_url': 'https://api.openai.com/v1', + 'api_key': os.environ.get('OPENAI_API_KEY', ''), + 'timeout': 600, + }, + 'qwen': { + 'name': '阿里云通义千问', + 'base_url': 'https://dashscope.aliyuncs.com/compatible-mode/v1', + 'api_key': os.environ.get('QWEN_API_KEY', ''), + 'timeout': 600, + }, + 'vllm': { + 'name': '本地 vLLM(自定义地址)', + 'base_url': os.environ.get('VLLM_BASE_URL', 'http://127.0.0.1:8000/v1'), + 'api_key': os.environ.get('VLLM_API_KEY', 'EMPTY'), + 'timeout': 600, + }, +} + +# --------------------------------------------------------------------------- +# 模型定价(元 / 1M tokens),用于成本记账;未登记模型走默认价 +# --------------------------------------------------------------------------- +MODEL_PRICING = { + 'doubao-seed-evolving': {'input': 2.0, 'output': 8.0}, + 'doubao-seed-1-6-250615': {'input': 2.0, 'output': 8.0}, + 'doubao-pro-32k': {'input': 2.0, 'output': 8.0}, + 'deepseek-chat': {'input': 2.0, 'output': 8.0}, + 'deepseek-reasoner': {'input': 4.0, 'output': 16.0}, + 'gpt-4o': {'input': 17.5, 'output': 70.0}, + 'gpt-4o-mini': {'input': 1.1, 'output': 4.4}, + 'qwen-plus': {'input': 0.8, 'output': 2.0}, + 'qwen-max': {'input': 4.0, 'output': 12.0}, +} +DEFAULT_PRICE = {'input': 2.0, 'output': 8.0} + +# 引擎参数 +MAX_RETRY = 1 # 失败重试次数(429/5xx/网络错误) +TASK_TIMEOUT = 600 # 单任务超时(秒) + +# 自动路由:按模型单价升序挑选可用 Worker +AUTO_ROUTE_POOL = 'enabled' # enabled | all diff --git a/data/aiworker.db b/data/aiworker.db new file mode 100644 index 0000000000000000000000000000000000000000..989698eb3a000a211dd7b15227d9bfb6dae14bc5 GIT binary patch literal 49152 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', (worker_id, month_ts)) + return rows[0]['total'] if rows else 0.0 + + +def task_worker_cost(worker_id): + """某 Worker 最近一次任务成本(用于单任务上限判断前先看历史,不作为硬限制)""" + rows = q( + 'SELECT COALESCE(SUM(cost),0) AS total FROM cost_records WHERE worker_id=?', + (worker_id,)) + return rows[0]['total'] if rows else 0.0 + + +def serialize_task(t): + t = dict(t) + t['review_required'] = bool(t['review_required']) + return t diff --git a/engine.py b/engine.py new file mode 100644 index 0000000..82580a2 --- /dev/null +++ b/engine.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +""" +任务执行引擎:后台线程执行单次 LLM 调用,记录日志与成本, +完成后进入「待审核」或直接「已完成」。 +""" +import threading +import traceback +import db +import llm_gateway +import config + + +def _log(task_id, level, message): + try: + db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)', + (task_id, level, message, db.now())) + except Exception: + pass + + +def _set_task(task_id, **fields): + if not fields: + return + fields['updated_at'] = db.now() + sets = ', '.join(f'{k}=?' for k in fields) + db.w(f'UPDATE tasks SET {sets} WHERE id=?', (*fields.values(), task_id)) + + +def _cost_record(task, worker, usage): + db.w( + 'INSERT INTO cost_records (task_id, project_id, worker_id, provider, model, ' + 'prompt_tokens, completion_tokens, total_tokens, cost, created_at) ' + 'VALUES (?,?,?,?,?,?,?,?,?,?)', + (task['id'], task['project_id'], worker['id'], worker['provider'], + worker['model'], usage['prompt_tokens'], usage['completion_tokens'], + usage['total_tokens'], usage['cost'], db.now())) + + +def pick_worker_auto(task): + """自动路由:按模型输入单价升序挑选 enabled Worker""" + rows = db.q('SELECT * FROM workers WHERE status="enabled" ORDER BY id') + if not rows: + return None + best, best_price = None, None + for r in rows: + pin, pout = llm_gateway.model_price(r['model']) + price = pin + pout * 0.5 + if best_price is None or price < best_price: + best, best_price = r, price + return best + + +def check_worker_limits(worker): + """成本上限预检:返回 (ok, reason)""" + if worker['task_cost_limit'] and worker['task_cost_limit'] > 0: + used = db.task_worker_cost(worker['id']) + if used >= worker['task_cost_limit']: + return False, f'该 Worker 累计成本 {used:.4f} 元已达单任务上限 {worker["task_cost_limit"]} 元' + if worker['monthly_cost_limit'] and worker['monthly_cost_limit'] > 0: + used = db.monthly_worker_cost(worker['id']) + if used >= worker['monthly_cost_limit']: + return False, f'该 Worker 本月成本 {used:.4f} 元已达月度上限 {worker["monthly_cost_limit"]} 元' + return True, '' + + +def _check_project_budget(task): + proj = db.q('SELECT * FROM projects WHERE id=?', (task['project_id'],), one=True) + if proj and proj['budget_limit'] and proj['budget_limit'] > 0: + rows = db.q('SELECT COALESCE(SUM(cost),0) AS t FROM cost_records WHERE project_id=?', + (task['project_id'],)) + used = rows[0]['t'] if rows else 0 + if used >= proj['budget_limit']: + return False, f'项目预算已用完({used:.2f}/{proj["budget_limit"]:.2f} 元)' + return True, '' + + +def run_task(task_id): + """在后台线程中执行任务""" + task = db.q('SELECT * FROM tasks WHERE id=?', (task_id,), one=True) + if not task: + return + if task['status'] == 'running': + return + + # 确定 Worker + worker = None + if task['worker_id']: + worker = db.q('SELECT * FROM workers WHERE id=?', (task['worker_id'],), one=True) + if not worker or worker['status'] != 'enabled': + _set_task(task_id, status='failed', error='指定 Worker 不存在或已停用', + finished_at=db.now()) + _log(task_id, 'error', '指定 Worker 不存在或已停用') + return + else: + worker = pick_worker_auto(task) + if not worker: + _set_task(task_id, status='failed', error='无可用 Worker(自动路由失败)', + finished_at=db.now()) + _log(task_id, 'error', '自动路由失败:无可用 Worker') + return + _set_task(task_id, worker_id=worker['id']) + _log(task_id, 'info', f'自动路由 → Worker「{worker["name"]}」({worker["provider"]}/{worker["model"]})') + + # 预算/成本预检 + ok, reason = check_worker_limits(worker) + if not ok: + _set_task(task_id, status='failed', error=reason, finished_at=db.now()) + _log(task_id, 'error', reason) + return + ok, reason = _check_project_budget(task) + if not ok: + _set_task(task_id, status='failed', error=reason, finished_at=db.now()) + _log(task_id, 'error', reason) + return + + _set_task(task_id, status='running', started_at=db.now(), error='') + _log(task_id, 'info', f'开始执行:Worker「{worker["name"]}」 模型 {worker["provider"]}/{worker["model"]}') + + messages = [] + if worker['system_prompt']: + messages.append({'role': 'system', 'content': worker['system_prompt']}) + messages.append({'role': 'user', 'content': task['description'] or task['title']}) + + try: + usage = llm_gateway.chat( + worker['provider'], worker['model'], messages, + temperature=worker['temperature'], max_tokens=worker['max_tokens'], + base_url=worker['base_url'] or None, api_key=worker['api_key'] or None) + except Exception as e: + _set_task(task_id, status='failed', error=str(e), finished_at=db.now()) + _log(task_id, 'error', f'执行失败: {e}') + return + + _cost_record(task, worker, usage) + _log(task_id, 'success', + f'执行完成:{usage["total_tokens"]} tokens(输入 {usage["prompt_tokens"]} / 输出 {usage["completion_tokens"]}),' + f'成本 ¥{usage["cost"]:.6f}') + + new_status = 'review' if task['review_required'] else 'done' + fields = { + 'status': new_status, + 'output_text': usage['text'], + 'output_version': task['output_version'] + 1, + 'finished_at': db.now(), + } + _set_task(task_id, **fields) + if new_status == 'review': + _log(task_id, 'info', '产出已提交,等待人工审核(HITL)') + else: + _log(task_id, 'success', '任务完成(无需审核)') + + +class TaskRunner: + def __init__(self): + self._threads = {} + + def submit(self, task_id): + if task_id in self._threads and self._threads[task_id].is_alive(): + return False + t = threading.Thread(target=self._safe_run, args=(task_id,), daemon=True) + self._threads[task_id] = t + t.start() + return True + + def _safe_run(self, task_id): + try: + run_task(task_id) + except Exception: + _log(task_id, 'error', '引擎异常: ' + traceback.format_exc()) + try: + _set_task(task_id, status='failed', error='引擎异常,见日志', finished_at=db.now()) + except Exception: + pass + + +runner = TaskRunner() diff --git a/llm_gateway.py b/llm_gateway.py new file mode 100644 index 0000000..183c470 --- /dev/null +++ b/llm_gateway.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +""" +统一模型网关:多供应商 OpenAI 兼容协议调用 + 计量 + 计价 +""" +import time +import requests +import config + + +class LLMError(Exception): + pass + + +def get_provider_cfg(provider): + cfg = config.PROVIDERS.get(provider) + if not cfg: + raise LLMError(f'未知供应商: {provider}') + return cfg + + +def model_price(model): + p = config.MODEL_PRICING.get(model, config.DEFAULT_PRICE) + return p['input'], p['output'] + + +def calc_cost(model, prompt_tokens, completion_tokens): + pin, pout = model_price(model) + return round(prompt_tokens / 1e6 * pin + completion_tokens / 1e6 * pout, 6) + + +def chat(provider, model, messages, temperature=0.7, max_tokens=None, + base_url=None, api_key=None, timeout=None, retries=None): + """调用 OpenAI 兼容 chat/completions,返回 {text, usage, cost, model}""" + cfg = get_provider_cfg(provider) + url = (base_url or cfg['base_url']).rstrip('/') + '/chat/completions' + key = api_key or cfg['api_key'] + if not key: + raise LLMError(f'供应商 {provider} 未配置 API Key') + headers = { + 'Authorization': f'Bearer {key}', + 'Content-Type': 'application/json', + } + payload = { + 'model': model, + 'messages': messages, + 'temperature': temperature, + } + if max_tokens: + payload['max_tokens'] = max_tokens + + timeout = timeout or cfg.get('timeout', 300) + retries = config.MAX_RETRY if retries is None else retries + last_err = None + for attempt in range(retries + 1): + try: + resp = requests.post(url, json=payload, headers=headers, timeout=timeout) + if resp.status_code == 200: + data = resp.json() + text = data['choices'][0]['message']['content'] or '' + usage = data.get('usage', {}) + pt = usage.get('prompt_tokens', 0) + ct = usage.get('completion_tokens', 0) + return { + 'text': text, + 'model': data.get('model', model), + 'prompt_tokens': pt, + 'completion_tokens': ct, + 'total_tokens': pt + ct, + 'cost': calc_cost(model, pt, ct), + } + if resp.status_code == 429: + last_err = LLMError(f'模型限流(429): {resp.text[:200]}') + time.sleep(2 * (attempt + 1)) + continue + if resp.status_code >= 500: + last_err = LLMError(f'服务端错误({resp.status_code}): {resp.text[:200]}') + time.sleep(1) + continue + raise LLMError(f'调用失败({resp.status_code}): {resp.text[:300]}') + except requests.exceptions.Timeout: + last_err = LLMError(f'请求超时({timeout}s)') + except requests.exceptions.ConnectionError as e: + last_err = LLMError(f'连接失败: {e}') + raise last_err or LLMError('未知错误') + + +def test_connection(provider, model, base_url=None, api_key=None): + """连通性测试:发一条最小请求""" + t0 = time.time() + r = chat(provider, model, + [{'role': 'user', 'content': '请回复"OK"两个字'}], + temperature=0, max_tokens=16, + base_url=base_url, api_key=api_key, timeout=30, retries=0) + return {'ok': True, 'latency_ms': int((time.time() - t0) * 1000), + 'reply': r['text'][:50], 'cost': r['cost']} diff --git a/logs/app.log b/logs/app.log new file mode 100644 index 0000000..9468fe5 --- /dev/null +++ b/logs/app.log @@ -0,0 +1,26 @@ +AI Worker 项目管理平台启动: http://0.0.0.0:16071 + * Serving Flask app 'app' + * Debug mode: off +WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. + * Running on all addresses (0.0.0.0) + * Running on http://127.0.0.1:16071 + * Running on http://192.168.0.100:16071 +Press CTRL+C to quit +127.0.0.1 - - [12/Aug/2026 00:19:39] "GET /api/health HTTP/1.1" 200 - +127.0.0.1 - - [12/Aug/2026 00:19:39] "POST /api/login HTTP/1.1" 200 - +127.0.0.1 - - [12/Aug/2026 00:19:42] "GET /api/projects HTTP/1.1" 200 - +127.0.0.1 - - [12/Aug/2026 00:19:42] "GET /api/workers HTTP/1.1" 200 - +127.0.0.1 - - [12/Aug/2026 00:19:42] "POST /api/tasks/1/run HTTP/1.1" 200 - +127.0.0.1 - - [12/Aug/2026 00:20:07] "GET /api/tasks/1 HTTP/1.1" 200 - +127.0.0.1 - - [12/Aug/2026 00:20:29] "GET /api/tasks/1 HTTP/1.1" 200 - +127.0.0.1 - - [12/Aug/2026 00:21:02] "GET /api/tasks/1 HTTP/1.1" 200 - +127.0.0.1 - - [12/Aug/2026 00:21:06] "POST /api/tasks/1/review HTTP/1.1" 200 - +127.0.0.1 - - [12/Aug/2026 00:21:06] "POST /api/tasks/2/run HTTP/1.1" 200 - +127.0.0.1 - - [12/Aug/2026 00:21:36] "GET /api/tasks/2 HTTP/1.1" 200 - +127.0.0.1 - - [12/Aug/2026 00:22:14] "GET /api/tasks/2 HTTP/1.1" 200 - +127.0.0.1 - - [12/Aug/2026 00:22:14] "POST /api/tasks/2/review HTTP/1.1" 200 - +127.0.0.1 - - [12/Aug/2026 00:22:14] "GET /api/stats HTTP/1.1" 200 - +127.0.0.1 - - [12/Aug/2026 00:22:14] "GET /api/reports/cost?group=model HTTP/1.1" 200 - +127.0.0.1 - - [12/Aug/2026 00:22:21] "GET / HTTP/1.1" 200 - +127.0.0.1 - - [12/Aug/2026 00:22:21] "GET /api/stats HTTP/1.1" 200 - +127.0.0.1 - - [12/Aug/2026 00:22:21] "GET /api/tasks HTTP/1.1" 200 - diff --git a/logs/app.pid b/logs/app.pid new file mode 100644 index 0000000..f1937fd --- /dev/null +++ b/logs/app.pid @@ -0,0 +1 @@ +3735134 diff --git a/seed.py b/seed.py new file mode 100644 index 0000000..154ff8b --- /dev/null +++ b/seed.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +""" +演示数据初始化:首次启动时灌入示例项目 + Worker(不自动执行,避免产生费用) +用法: python3 seed.py +""" +import db + +def main(): + db.init_db() + if db.q('SELECT COUNT(*) c FROM projects')[0]['c'] > 0: + print('已有数据,跳过种子初始化') + return + + pid = db.w( + 'INSERT INTO projects (name, description, objective, acceptance_criteria, status, budget_limit, created_at, updated_at) ' + 'VALUES (?,?,?,?,?,?,?,?)', + ('营销内容批量生产(演示)', + '为新品「智能保温杯」批量生产小红书/抖音种草内容,跑通「人派活 → AI 干活 → 人验收」闭环', + '一周内产出 20 篇高转化种草文案,覆盖 5 个核心卖点', + '每篇文案 ≥300 字、包含 3 个卖点、无违禁词、风格符合平台调性', + 'active', 50, db.now(), db.now())) + + wid = db.w( + 'INSERT INTO workers (name, description, provider, model, system_prompt, temperature, max_tokens, task_cost_limit, monthly_cost_limit, status, created_at, updated_at) ' + 'VALUES (?,?,?,?,?,?,?,?,?,?,?,?)', + ('文案小助手', '小红书/抖音种草文案专家', 'doubao', 'doubao-seed-evolving', + '你是资深社交媒体文案专家,擅长小红书种草文案。输出要求:标题吸睛、正文≥300字、自然融入卖点、结尾带话题标签、不用违禁词。', + 0.8, 2000, 5, 30, 'enabled', db.now(), db.now())) + + for i, (title, desc) in enumerate([ + ('生成 3 条小红书种草标题', '为「智能保温杯」生成 3 条小红书风格种草标题,每条不超过 20 字,突出「保温 12 小时」卖点。'), + ('写一篇办公室场景种草笔记', '写一篇小红书种草笔记:场景是办公室白领,痛点是不想喝凉水,产品是智能保温杯,要求 300-400 字,带 5 个话题标签。'), + ('生成抖音口播脚本', '为抖音生成一条 30 秒口播脚本:智能保温杯开箱体验,节奏明快,结尾引导点赞关注。'), + ]): + db.w('INSERT INTO tasks (project_id, worker_id, title, description, priority, review_required, deadline, created_at, updated_at) ' + 'VALUES (?,?,?,?,?,?,?,?,?)', + (pid, wid if i == 0 else None, title, desc, 'medium', 1, '', db.now(), db.now())) + print(f' 已创建演示任务:{title}') + + print('✅ 演示数据初始化完成:1 个项目 / 1 个 Worker / 3 个任务') + +if __name__ == '__main__': + main() diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..ee899a9 --- /dev/null +++ b/start.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# AI Worker 项目管理平台 启动脚本 +# 用法: ./start.sh [stop|restart|status] +cd "$(dirname "$0")" +PY=/home/hz1/miniconda3/envs/openclaw/bin/python3 +PORT=16071 +PIDFILE=logs/app.pid + +start() { + if [ -f "$PIDFILE" ] && kill -0 $(cat "$PIDFILE") 2>/dev/null; then + echo "已在运行 (PID $(cat $PIDFILE))"; exit 0 + fi + nohup $PY app.py > logs/app.log 2>&1 & + echo $! > "$PIDFILE" + sleep 1.5 + if kill -0 $(cat "$PIDFILE") 2>/dev/null; then + echo "✅ AI Worker 平台已启动: http://$(hostname -I 2>/dev/null | awk '{print $1}'):$PORT (PID $(cat $PIDFILE))" + else + echo "❌ 启动失败,查看 logs/app.log"; exit 1 + fi +} + +stop() { + if [ -f "$PIDFILE" ]; then + kill $(cat "$PIDFILE") 2>/dev/null && echo "已停止 (PID $(cat $PIDFILE))" + rm -f "$PIDFILE" + else + echo "未在运行" + fi +} + +case "$1" in + stop) stop ;; + restart) stop; sleep 1; start ;; + status) [ -f "$PIDFILE" ] && kill -0 $(cat "$PIDFILE") 2>/dev/null && echo "运行中 (PID $(cat $PIDFILE))" || echo "未运行" ;; + *) start ;; +esac diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..de065d5 --- /dev/null +++ b/static/app.js @@ -0,0 +1,505 @@ +/* AI Worker 项目管理平台 - 前端 SPA */ +'use strict'; + +const $ = (sel, el = document) => el.querySelector(sel); +const $$ = (sel, el = document) => [...el.querySelectorAll(sel)]; +const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const fmtTime = ts => ts ? new Date(ts * 1000).toLocaleString('zh-CN', {hour12:false}) : '—'; +const fmtMoney = v => (v ?? 0) >= 0.01 ? '¥' + Number(v).toFixed(2) : '¥' + Number(v ?? 0).toFixed(4); + +const STATUS = { + todo: {label:'待执行', cls:'st-todo'}, running:{label:'执行中', cls:'st-running'}, + review:{label:'待审核', cls:'st-review'}, done:{label:'已完成', cls:'st-done'}, + rejected:{label:'已打回', cls:'st-rejected'}, failed:{label:'失败', cls:'st-failed'}, + cancelled:{label:'已取消', cls:'st-cancelled'} +}; +const PRI = {high:'高', medium:'中', low:'低'}; +const KANBAN_ORDER = ['todo','running','review','done','rejected','failed']; + +/* ---------- API ---------- */ +async function api(path, opts = {}) { + const res = await fetch(path, { + headers: {'Content-Type': 'application/json'}, + ...opts, + body: opts.body ? JSON.stringify(opts.body) : undefined + }); + let data = {}; + try { data = await res.json(); } catch (e) {} + if (res.status === 401) { showLogin(); throw new Error('未登录'); } + if (!res.ok || data.ok === false) throw new Error(data.error || `请求失败(${res.status})`); + return data; +} + +/* ---------- Toast ---------- */ +function toast(msg, type = '') { + const el = document.createElement('div'); + el.className = 'toast ' + type; + el.textContent = msg; + $('#toast-wrap').appendChild(el); + setTimeout(() => el.remove(), 3200); +} + +/* ---------- Login ---------- */ +function showLogin() { $('#login-mask').style.display = 'flex'; } +function hideLogin() { $('#login-mask').style.display = 'none'; } +$('#login-btn').addEventListener('click', async () => { + try { + await api('/api/login', {method:'POST', body:{password: $('#login-pwd').value}}); + hideLogin(); $('#logout-btn').style.display = 'block'; router(); + } catch (e) { $('#login-err').textContent = e.message; } +}); +$('#login-pwd').addEventListener('keydown', e => { if (e.key === 'Enter') $('#login-btn').click(); }); +$('#logout-btn').addEventListener('click', async e => { + e.preventDefault(); await api('/api/logout', {method:'POST'}); location.hash = '#/dashboard'; location.reload(); +}); + +/* ---------- Modal ---------- */ +function openModal(html) { + $('#modal-box').innerHTML = html; + $('#modal-mask').style.display = 'flex'; + $$('#modal-box [data-close]').forEach(b => b.addEventListener('click', closeModal)); +} +function closeModal() { $('#modal-mask').style.display = 'none'; $('#modal-box').innerHTML = ''; } +$('#modal-mask').addEventListener('click', e => { if (e.target.id === 'modal-mask') closeModal(); }); + +/* ---------- Router ---------- */ +const routes = { + 'dashboard': pageDashboard, 'projects': pageProjects, 'project': pageProject, + 'workers': pageWorkers, 'reports': pageReports, 'logs': pageLogs +}; +function router() { + const hash = location.hash.replace(/^#\//, '') || 'dashboard'; + const [name, ...rest] = hash.split('/'); + const fn = routes[name] || pageDashboard; + $$('#sidebar nav a').forEach(a => a.classList.toggle('active', a.dataset.route === (name === 'project' ? 'projects' : name))); + $('#main').innerHTML = '
加载中…
'; + fn(rest).catch(e => { $('#main').innerHTML = `
${esc(e.message)}
`; }); +} +window.addEventListener('hashchange', router); + +/* ---------- 仪表盘 ---------- */ +async function pageDashboard() { + const s = (await api('/api/stats')).data; + const total = Object.values(s.by_status).reduce((a, b) => a + b, 0); + const pct = n => total ? Math.round(n / total * 100) : 0; + $('#main').innerHTML = ` +

仪表盘AI 虚拟团队运营总览

+
+
项目
${s.projects}
进行中 ${s.by_status.done ?? 0} 个已完成任务
+
任务总数
${s.tasks}
完成率 ${pct(s.by_status.done ?? 0)}% · 待审核 ${s.by_status.review ?? 0}
+
AI Worker
${s.workers}
虚拟员工档案数
+
累计成本
${fmtMoney(s.total_cost)}
${(s.total_tokens/1e6).toFixed(2)}M tokens 消耗
+
+
+
+
质量指标
+
+
一次通过率
${s.one_pass_rate ?? '—'}%(验收一次通过)
+
返工次数
${s.rework_count} 次打回
+
执行失败
${s.by_status.failed ?? 0} 个任务
+
+
+
+
近 7 天成本趋势(元)
+ ${s.daily_cost.map(d => ` +
${d.day} ${d.cost.toFixed(4)}
+
`).join('')} +
+
+
任务状态分布
+ ${Object.entries(STATUS).filter(([k]) => s.by_status[k]).map(([k, v]) => ` +
${v.label} ${s.by_status[k]} 个 + ${pct(s.by_status[k])}%
+
`).join('') || '
暂无数据
'} +
+
+
+
最近任务动态
+ + ${s.recent.map(t => ` + `).join('') || ''} +
项目任务状态更新时间
${esc(t.project_name || '—')}${esc(t.title)}${STATUS[t.status].label}${fmtTime(t.updated_at)}
暂无任务,去 创建项目
+
`; +} + +/* ---------- 项目列表 ---------- */ +async function pageProjects() { + const list = (await api('/api/projects')).data; + $('#main').innerHTML = ` +

项目立项 → 规划 → 执行 → 验收 → 归档

+
+
+ ${list.map(p => ` +
+
+ ${esc(p.name)}${{active:'进行中', planning:'规划中', done:'已完成', archived:'已归档'}[p.status]} +
+
${esc(p.objective || p.description || '暂无描述')}
+
任务 ${p.task_count} · 完成 ${p.done_count} + ${p.budget_limit ? '预算 ¥' + p.budget_limit : ''}
+
`).join('') || '
还没有项目,点右上角新建
'} +
`; +} + +function openProjectModal(p = {}) { + openModal(` +

${p.id ? '编辑项目' : '新建项目'}

+ + + + +
+
+
+
+ `); + $('#pm-save').addEventListener('click', async () => { + const body = { + name: $('#pm-name').value.trim(), objective: $('#pm-objective').value, + acceptance_criteria: $('#pm-accept').value, description: $('#pm-desc').value, + status: $('#pm-status').value, budget_limit: parseFloat($('#pm-budget').value || 0) + }; + if (!body.name) return toast('请填写项目名称', 'err'); + try { + await api(p.id ? `/api/projects/${p.id}` : '/api/projects', {method: p.id ? 'PUT' : 'POST', body}); + toast('已保存', 'ok'); closeModal(); router(); + } catch (e) { toast(e.message, 'err'); } + }); +} + +/* ---------- 项目详情(看板) ---------- */ +let kanbanData = null; +async function pageProject([pid]) { + 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; + kanbanData = {pid, tasks, workers}; + $('#main').innerHTML = ` +
+ ← 返回 +

${esc(p.name)}${esc(p.objective || '')}

+ + +
+
${KANBAN_ORDER.map(s => ` +
+
${STATUS[s].label} + 0
+
+
`).join('')}
`; + renderKanban(); + $$('.kb-body').forEach(bd => { + bd.addEventListener('dragover', e => { e.preventDefault(); bd.classList.add('drag-over'); }); + bd.addEventListener('dragleave', () => bd.classList.remove('drag-over')); + bd.addEventListener('drop', e => { + e.preventDefault(); bd.classList.remove('drag-over'); + const tid = e.dataTransfer.getData('text/plain'); + moveTask(parseInt(tid), bd.dataset.drop); + }); + }); +} + +function renderKanban() { + const {tasks} = kanbanData; + const by = {}; + tasks.forEach(t => { (by[t.status] = by[t.status] || []).push(t); }); + KANBAN_ORDER.forEach(s => { + const list = by[s] || []; + $('#cnt-' + s).textContent = list.length; + $('#kanban .kb-body[data-drop="' + s + '"]').innerHTML = list.map(t => ` +
+
${esc(t.title)}
+
+ ●${PRI[t.priority]} + ${t.worker_id ? `Worker#${t.worker_id}` : '自动路由'} + ${t.rejection_count ? `打回×${t.rejection_count}` : ''} + ${t.status === 'running' ? '' : ''} +
+ ${t.status === 'done' ? '
✅ 已交付 v' + t.output_version + '
' : ''} +
`).join('') || '
'; + }); + $$('.kb-card[draggable="true"]').forEach(c => { + c.addEventListener('dragstart', e => { + e.dataTransfer.setData('text/plain', c.dataset.id); + c.classList.add('dragging'); + }); + c.addEventListener('dragend', () => c.classList.remove('dragging')); + }); +} + +async function moveTask(tid, status) { + const t = kanbanData.tasks.find(x => x.id === tid); + if (!t || t.status === status) return; + if (['running', 'review'].includes(status)) return toast('不能直接拖到该状态', 'err'); + if (status === 'todo' && !['rejected', 'failed', 'done'].includes(t.status)) return; + if (status === 'done') return toast('完成请走审核流(审核通过)', 'err'); + await api(`/api/tasks/${tid}`, {method:'PUT', body:{status}}); + toast('状态已更新', 'ok'); refreshKanban(); +} + +async function refreshKanban() { + const {pid} = kanbanData; + kanbanData.tasks = (await api(`/api/tasks?project_id=${pid}`)).data; + renderKanban(); +} + +function openTaskModal(pid) { + const workers = kanbanData ? kanbanData.workers : []; + openModal(` +

新建任务

+ + +
+
+
+
+
+
+
+
+ `); + $('#tm-save').addEventListener('click', async () => { + const title = $('#tm-title').value.trim(); + if (!title) return toast('请填写任务标题', 'err'); + try { + const r = await api('/api/tasks', {method:'POST', body:{ + project_id: pid, title, description: $('#tm-desc').value, + worker_id: $('#tm-worker').value ? parseInt($('#tm-worker').value) : null, + priority: $('#tm-pri').value, deadline: $('#tm-deadline').value, + review_required: $('#tm-review').value === '1' + }}); + toast('任务已创建', 'ok'); closeModal(); refreshKanban(); + if (confirm('任务已创建,立即派给 AI Worker 执行?')) runTask(r.id); + } catch (e) { toast(e.message, 'err'); } + }); +} + +async function runTask(tid) { + try { await api(`/api/tasks/${tid}/run`, {method:'POST'}); toast('🚀 已派活,AI Worker 开始执行', 'ok'); refreshKanban(); } + catch (e) { toast(e.message, 'err'); } +} + +/* ---------- 任务详情(含审核) ---------- */ +async function openTaskDetail(tid) { + const t = (await api(`/api/tasks/${tid}`)).data; + const workers = kanbanData ? kanbanData.workers : (await api('/api/workers')).data; + const w = t.worker || (t.worker_id ? {id: t.worker_id, name: `#${t.worker_id}`} : null); + openModal(` +

${esc(t.title)}${STATUS[t.status].label} +

+
+
执行 Worker
${w ? `${esc(w.name)} ${esc(w.provider)}/${esc(w.model)}` : '未指派(自动路由)'}
+
优先级
●${PRI[t.priority]} · 返工 ${t.rejection_count} 次
+
创建/更新
${fmtTime(t.created_at)} / ${fmtTime(t.updated_at)}
+
截止
${t.deadline || '—'}
+
+
任务指令
+
${esc(t.description || t.title)}
+ ${t.output_text ? `
产出物 v${t.output_version}(${t.output_text.length} 字)
+
${esc(t.output_text)}
` : ''} + ${t.error ? `
错误信息
+
${esc(t.error)}
` : ''} +
执行日志
+
${t.logs.map(l => logLine(l)).join('') || '
暂无日志
'}
+ `); +} + +function logLine(l) { + const icons = {info:'ℹ️', success:'✅', warn:'⚠️', error:'❌'}; + return `
${fmtTime(l.created_at)}${icons[l.level] || ''} ${esc(l.message)}
`; +} + +async function reviewTask(tid, action) { + if (action === 'reject') { + const reason = prompt('请输入打回原因(将退回重做):'); + if (reason === null) return; + if (!reason.trim()) return toast('打回必须填写原因', 'err'); + await api(`/api/tasks/${tid}/review`, {method:'POST', body:{action, reason: reason.trim()}}); + } else { + await api(`/api/tasks/${tid}/review`, {method:'POST', body:{action}}); + } + toast(action === 'approve' ? '✅ 验收通过,任务完成' : '任务已打回,等待返工', 'ok'); + closeModal(); refreshKanban(); +} + +async function cancelTask(tid) { + if (!confirm('确认取消该任务?')) return; + await api(`/api/tasks/${tid}/cancel`, {method:'POST'}); + toast('已取消', 'ok'); closeModal(); refreshKanban(); +} +async function reopenTask(tid) { + await api(`/api/tasks/${tid}`, {method:'PUT', body:{status:'todo'}}); + toast('任务已重新打开', 'ok'); closeModal(); refreshKanban(); +} +async function delTask(tid) { + if (!confirm('确认删除该任务及其日志/成本记录?')) return; + await api(`/api/tasks/${tid}`, {method:'DELETE'}); + toast('已删除', 'ok'); closeModal(); refreshKanban(); +} + +/* ---------- Worker 管理 ---------- */ +async function pageWorkers() { + const [ws, ps] = await Promise.all([api('/api/workers'), api('/api/providers')]); + $('#main').innerHTML = ` +

AI Worker 管理模型 + 角色提示词 + 工具权限 + 成本上限 = 虚拟员工档案

+
+ + ${ws.data.map(w => ` + + + + + + + + + + + `).join('') || ''} +
ID名称模型角色提示词温度/上限本月成本成本上限状态操作
#${w.id}${esc(w.name)}
${esc(w.description || '')}
${esc(w.provider)} ${esc(w.model)}${esc((w.system_prompt || '—').slice(0, 40))}${w.temperature} / ${w.max_tokens}${fmtMoney(w.month_cost)}${w.task_cost_limit ? '单任务 ¥' + w.task_cost_limit : '—'}
${w.monthly_cost_limit ? '月 ¥' + w.monthly_cost_limit : ''}
${w.status === 'enabled' ? '启用' : '停用'} + +
还没有 Worker,注册第一个虚拟员工吧
`; +} + +async function testWorker(wid) { + toast('正在测试连通性…'); + try { const r = await api(`/api/workers/${wid}/test`, {method:'POST'}); + toast(`✅ 连通正常:${r.data.latency_ms}ms,回复「${r.data.reply}」,成本 ¥${r.data.cost}`, 'ok'); } + catch (e) { toast('❌ ' + e.message, 'err'); } +} + +async function delWorker(wid) { + if (!confirm('确认删除该 Worker?(历史任务将解除指派)')) return; + await api(`/api/workers/${wid}`, {method:'DELETE'}); + toast('已删除', 'ok'); router(); +} + +function openWorkerModal(w, providers) { + w = w || {}; + const sel = (providerId) => providers.find(p => p.id === providerId); + openModal(` +

${w.id ? '编辑 Worker' : '注册 Worker'}

+ + + +
+
+
+
+ + +
+
+
+
+
+
+
+
+ + `); + window.onProviderChange = () => { + const p = providers.find(x => x.id === $('#wm-provider').value); + if (!p) return; + const base = $('#wm-base'); + if (!base.value || base.placeholder === base.value) base.value = p.base_url; + base.placeholder = p.base_url; + const model = $('#wm-model'); + if (p.models.length && !model.value) model.value = p.models[0]; + }; + $('#wm-save').addEventListener('click', async () => { + const body = { + name: $('#wm-name').value.trim(), provider: $('#wm-provider').value, + model: $('#wm-model').value.trim(), base_url: $('#wm-base').value.trim(), + api_key: $('#wm-key').value.trim(), system_prompt: $('#wm-prompt').value, + description: $('#wm-desc').value.trim(), temperature: parseFloat($('#wm-temp').value || 0.7), + max_tokens: parseInt($('#wm-max').value || 2000), + task_cost_limit: parseFloat($('#wm-tasklimit').value || 0), + monthly_cost_limit: parseFloat($('#wm-monthlimit').value || 0), + status: $('#wm-status').value + }; + if (!body.name || !body.provider || !body.model) return toast('名称/供应商/模型必填', 'err'); + try { + await api(w.id ? `/api/workers/${w.id}` : '/api/workers', {method: w.id ? 'PUT' : 'POST', body}); + toast('已保存', 'ok'); closeModal(); router(); + } catch (e) { toast(e.message, 'err'); } + }); +} + +/* ---------- 成本报表 ---------- */ +async function pageReports() { + const group = location.hash.includes('group=') ? location.hash.split('group=')[1] : 'project'; + const r = (await api('/api/reports/cost?group=' + group)).data; + const s = (await api('/api/stats')).data; + const head = {project:['项目','调用次数','Tokens','成本'], worker:['Worker','模型','调用次数','Tokens','成本'], model:['供应商/模型','调用次数','Tokens','成本']}[group]; + $('#main').innerHTML = ` +

成本报表按项目 × 任务 × Worker × 模型 多维核算

+
+ 按项目 + 按 Worker + 按模型 + 累计成本 ${fmtMoney(s.total_cost)} · ${(s.total_tokens/1e6).toFixed(2)}M tokens +
+
${head.map(h => ``).join('')} + ${r.map(x => { + const cost = x.cost || 0, tokens = x.tokens || 0; + const maxCost = Math.max(...r.map(v => v.cost || 0), 0.001); + return `${group === 'project' ? `` : group === 'worker' ? + `` : + ``} + + `; + }).join('') || ''} +
${h}
${esc(x.project_name || '—')}${esc(x.worker_name || '—')}${esc(x.provider || '')} ${esc(x.model || '')}${esc(x.provider || '')} ${esc(x.model || '')}${x.runs}${(tokens/1e6).toFixed(3)}M${fmtMoney(cost)}
暂无成本数据,先派几个任务吧
`; +} + +/* ---------- 运行日志 ---------- */ +async function pageLogs() { + const logs = (await api('/api/logs?limit=200')).data; + $('#main').innerHTML = ` +

运行日志全链路可追溯,每个结论都能溯源

+
${logs.map(l => `
${fmtTime(l.created_at)} + [任务#${l.task_id} ${esc(l.task_title || '')}] ${esc(l.message)}
`).join('') || '
暂无日志
'}
`; +} + +/* ---------- 启动 ---------- */ +(async function init() { + try { + const me = await api('/api/me'); + if (!me.authed) { showLogin(); $('#main').innerHTML = '
请先登录
'; return; } + $('#logout-btn').style.display = 'block'; + } catch (e) { showLogin(); return; } + try { await api('/api/health'); } catch (e) { $('#conn-state').textContent = '● 服务异常'; $('#conn-state').className = 'conn err'; } + router(); +})(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..4e3d2d8 --- /dev/null +++ b/static/index.html @@ -0,0 +1,47 @@ + + + + + +AI Worker 项目管理平台 + + + +
+ +
+
+ + + + + + + +
+ + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..6a3e700 --- /dev/null +++ b/static/style.css @@ -0,0 +1,128 @@ +:root{ + --bg:#0f1420; --panel:#171e2e; --panel2:#1d2638; --border:#2a3550; + --text:#e6ebf5; --muted:#8b97ad; --accent:#4f8cff; --accent2:#38d39f; + --warn:#ffb454; --danger:#ff6b6b; --purple:#a78bfa; +} +*{margin:0;padding:0;box-sizing:border-box} +body{background:var(--bg);color:var(--text);font-family:"PingFang SC","Microsoft YaHei",system-ui,sans-serif;font-size:14px} +#app{display:flex;min-height:100vh} +#sidebar{width:220px;background:var(--panel);border-right:1px solid var(--border);display:flex;flex-direction:column;position:fixed;top:0;bottom:0} +.logo{padding:20px 18px;font-size:18px;font-weight:700;border-bottom:1px solid var(--border)} +.logo span{color:var(--accent)} +.logo small{display:block;font-size:11px;color:var(--muted);font-weight:400;margin-top:2px} +nav{padding:12px 0;flex:1} +nav a{display:block;padding:11px 20px;color:var(--muted);text-decoration:none;border-left:3px solid transparent} +nav a:hover{color:var(--text);background:var(--panel2)} +nav a.active{color:var(--accent);border-left-color:var(--accent);background:var(--panel2)} +.sidebar-foot{padding:14px 18px;border-top:1px solid var(--border);font-size:12px} +.conn.ok{color:var(--accent2)} .conn.err{color:var(--danger)} +.logout{color:var(--muted);text-decoration:none;display:block;margin-top:6px} +.logout:hover{color:var(--danger)} + +#main{flex:1;margin-left:220px;padding:24px 28px;max-width:1400px} +h1.page-title{font-size:20px;margin-bottom:18px} +h1.page-title small{color:var(--muted);font-weight:400;font-size:13px;margin-left:8px} +.toolbar{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap} +.btn{background:var(--panel2);border:1px solid var(--border);color:var(--text);padding:8px 14px;border-radius:8px;cursor:pointer;font-size:13px} +.btn:hover{border-color:var(--accent)} +.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff} +.btn.primary:hover{filter:brightness(1.15)} +.btn.success{background:var(--accent2);border-color:var(--accent2);color:#06281c} +.btn.danger{background:transparent;border-color:var(--danger);color:var(--danger)} +.btn.warn{background:transparent;border-color:var(--warn);color:var(--warn)} +.btn:disabled{opacity:.45;cursor:not-allowed} +.btn.sm{padding:4px 10px;font-size:12px;border-radius:6px} +input,select,textarea{background:var(--panel2);border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:8px;font-size:13px;outline:none;width:100%} +input:focus,select:focus,textarea:focus{border-color:var(--accent)} +textarea{min-height:90px;resize:vertical;font-family:inherit} +label{display:block;font-size:12px;color:var(--muted);margin:12px 0 5px} +.row{display:flex;gap:10px}.row>*{flex:1} + +.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:16px} +.grid{display:grid;gap:14px} +.g4{grid-template-columns:repeat(4,1fr)} +.g3{grid-template-columns:repeat(3,1fr)} +.g2{grid-template-columns:repeat(2,1fr)} +@media(max-width:1100px){.g4{grid-template-columns:repeat(2,1fr)}.g3,.g2{grid-template-columns:1fr}} + +.stat{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:16px 18px} +.stat .num{font-size:26px;font-weight:700;margin-top:6px} +.stat .lbl{color:var(--muted);font-size:12px} +.stat .sub{color:var(--muted);font-size:11px;margin-top:4px} + +table{width:100%;border-collapse:collapse;font-size:13px} +th{color:var(--muted);text-align:left;font-weight:500;padding:10px 12px;border-bottom:1px solid var(--border);white-space:nowrap} +td{padding:10px 12px;border-bottom:1px solid var(--border);vertical-align:middle} +tr:hover td{background:var(--panel2)} +.mono{font-family:ui-monospace,Consolas,monospace} + +.badge{display:inline-block;padding:2px 9px;border-radius:20px;font-size:12px;white-space:nowrap} +.st-todo{background:#2a3550;color:#aeb9d4} +.st-running{background:#173b6b;color:#6db3ff} +.st-review{background:#4a3a10;color:#ffd166} +.st-done{background:#0f3d2e;color:#38d39f} +.st-rejected{background:#4a1c1c;color:#ff8f8f} +.st-failed{background:#3d1c2e;color:#ff7ac2} +.st-cancelled{background:#333;color:#999} +.pri-high{color:#ff6b6b}.pri-medium{color:#ffb454}.pri-low{color:#8b97ad} + +.kanban{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;align-items:start;min-width:1200px} +.kb-col{background:var(--panel);border:1px solid var(--border);border-radius:12px;min-height:200px} +.kb-head{padding:10px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border)} +.kb-head .cnt{color:var(--muted);font-size:12px} +.kb-body{padding:10px;display:flex;flex-direction:column;gap:8px;min-height:120px} +.kb-card{background:var(--panel2);border:1px solid var(--border);border-radius:10px;padding:10px 12px;cursor:grab;transition:border .15s} +.kb-card:hover{border-color:var(--accent)} +.kb-card.dragging{opacity:.4} +.kb-col.drag-over{border-color:var(--accent);background:var(--panel2)} +.kb-card .t{font-size:13px;font-weight:500;margin-bottom:6px} +.kb-card .m{display:flex;gap:6px;align-items:center;flex-wrap:wrap} +.kb-card .cost{color:var(--muted);font-size:11px;margin-top:6px} +.kb-card .out{font-size:11px;color:var(--accent2);margin-top:4px;display:none} + +.list-item{display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid var(--border)} +.list-item:last-child{border-bottom:none} +.list-item .title{font-weight:600} +.list-item .meta{color:var(--muted);font-size:12px;margin-top:3px} + +.modal{width:640px;max-width:94vw;max-height:86vh;overflow-y:auto;background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:22px;position:relative} +.modal h3{font-size:16px;margin-bottom:14px} +.modal .close{position:absolute;top:14px;right:16px;background:none;border:none;color:var(--muted);font-size:20px;cursor:pointer} +.modal .close:hover{color:var(--text)} +#modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:100} +.modal-actions{display:flex;gap:10px;margin-top:18px;justify-content:flex-end;flex-wrap:wrap} + +#login-mask{position:fixed;inset:0;background:var(--bg);z-index:200;display:flex;align-items:center;justify-content:center} +.login-box{width:340px;background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:30px;text-align:center} +.login-box h2{font-size:17px;margin-bottom:6px} +.login-box p{color:var(--muted);font-size:13px;margin-bottom:16px} +.login-box .btn{width:100%;margin-top:12px;padding:10px} +.err{color:var(--danger);font-size:12px;margin-top:10px;min-height:16px} + +#toast-wrap{position:fixed;top:20px;right:20px;z-index:300;display:flex;flex-direction:column;gap:8px} +.toast{background:var(--panel2);border:1px solid var(--border);border-left:3px solid var(--accent);padding:10px 16px;border-radius:8px;font-size:13px;animation:slidein .25s} +.toast.ok{border-left-color:var(--accent2)} +.toast.err{border-left-color:var(--danger)} +@keyframes slidein{from{transform:translateX(30px);opacity:0}to{transform:none;opacity:1}} + +.log-line{font-size:12px;padding:7px 10px;border-left:2px solid var(--border);margin-bottom:6px;background:var(--panel2);border-radius:0 8px 8px 0;font-family:ui-monospace,Consolas,monospace;word-break:break-all} +.log-line.info{border-left-color:var(--accent)} +.log-line.success{border-left-color:var(--accent2)} +.log-line.warn{border-left-color:var(--warn)} +.log-line.error{border-left-color:var(--danger)} +.log-line .ts{color:var(--muted);margin-right:8px} + +.output-box{background:#0b0f18;border:1px solid var(--border);border-radius:10px;padding:14px;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,Consolas,monospace;font-size:13px;max-height:300px;overflow-y:auto} +.kv{display:grid;grid-template-columns:90px 1fr;gap:6px 12px;font-size:13px} +.kv dt{color:var(--muted)} +.kv dd{margin:0} +.sec-title{font-size:14px;font-weight:600;margin:20px 0 10px;color:var(--accent)} +.bar{height:8px;background:var(--panel2);border-radius:6px;overflow:hidden;margin-top:6px} +.bar>div{height:100%;background:var(--accent);border-radius:6px} +.empty{color:var(--muted);text-align:center;padding:40px 0} +.spin{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:rot .8s linear infinite;vertical-align:-2px;margin-right:6px} +@keyframes rot{to{transform:rotate(360deg)}} +.proj-card{cursor:pointer;transition:border .15s} +.proj-card:hover{border-color:var(--accent)} +.pager-tip{color:var(--muted);font-size:12px} +.pill{display:inline-block;background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:2px 8px;font-size:12px;margin:2px}