Compare commits

..
4 Commits
Author SHA1 Message Date
hz4th_coder 5deb447c8e DAG 工作流画布升级:节点可拖动 + 滚轮缩放 + 画布平移
- 节点拖拽调整布局(位移按缩放比例换算,拖动不误触详情点击)
- 滚轮以鼠标为锚点缩放(0.25x-3x),双击空白一键复位
- 拖拽空白区域平移画布(cursor grab/grabbing)
- 布局与视图状态 localStorage 按项目记忆,刷新/重开自动恢复
- 实测:节点精确拖动、锚点缩放、平移、复位、点击详情全部通过
2026-08-12 17:26:30 +08:00
hz4th_coder f28d73f1bb fix: 多Agent协作 JSON 截断导致主管规划解析失败
- 根因: deepseek-v4-flash 规划输出超 max_tokens=2000 被截断,JSON 不完整解析失败
- agents.py: 新增 _chat_json() 统一处理 JSON 类调用,解析失败自动加大 max_tokens 重试(×1/×2/×3)
- 主管规划/辩论质询/辩论裁决/评审 全部改用 _chat_json 并提高初始上限(3000/1200/2500/2000)
- 实测: 主管(计算器网页版4子任务) 评审(92分) 辩论(指定阵容)
2026-08-12 17:08:16 +08:00
hz4th_coder dc49864af4 智能体大模型接口全面切换 + 新增视觉智能体
- 全部 AI Worker 切换到 deepseek/deepseek-v4-flash(api.deepseek.com,实测757ms/次,成本降40倍)
- 团队模板默认 provider/model 同步改为 deepseek/deepseek-v4-flash
- 新增视觉智能体「视觉分析师」:autodl/qwen3.6-plus 多模态
- llm_gateway: chat_vision 多模态调用(URL/base64) + 空content自动重试 + 聚合后端路由容错
- engine: 任务描述支持 ![图](url) 图片注入(视觉任务直接派活)
- API: POST /api/workers/<id>/vision_test;前端 Worker 页新增🖼️视觉测试按钮
- 实测: 截图结构化分析  雪羊图片识别  辩论模式DeepSeek 55秒完成
2026-08-12 13:22:56 +08:00
hz4th_coder 8bae953d7d fix: 任务产出沉淀set误用append + 审计actor回退session用户名 + 启动恢复stale运行状态
- eval.py: eval_sunk_task_ids 用 set 却调 append → 改 add
- enterprise.py: current_actor 增加 session 用户名回退(此前恒为 anonymous)
- db.py/app.py: 新增 recover_stale_runs,服务重启后把遗留 running 的 任务/协作/评估 标记为 failed
- docs/v2: 新增 4 个功能页截图
2026-08-12 12:26:59 +08:00
13 changed files with 393 additions and 61 deletions
+38 -19
View File
@@ -92,6 +92,23 @@ def _extract_score(text):
return 60.0, text
def _chat_json(worker, messages, max_tokens=2000, temperature=None):
"""调用 LLM 并解析 JSON;解析失败自动加大 max_tokens 重试(防止长文截断)。
返回 (data, raw_text, usage);全部失败抛最后异常。"""
last_err = None
for attempt in range(3):
mt = max_tokens * (attempt + 1) # 2000 → 4000 → 6000
text, usage = _chat_worker(worker, messages, temperature=temperature, max_tokens=mt)
try:
data = _extract_json(text)
if isinstance(data, dict) and not data:
raise ValueError('空 JSON')
return data, text, usage
except Exception as e:
last_err = e
raise last_err or ValueError('JSON 解析失败')
# ---------------------------------------------------------------------------
# 主管模式
# ---------------------------------------------------------------------------
@@ -114,17 +131,17 @@ SUPERVISOR_SYNTH_PROMPT = (
def run_supervisor(run_id, run, workers):
_log(run_id, 'supervisor', None, 'plan',
f'主管开始规划:{run["topic"][:200]}')
# 1) 主管拆解
plan_text, u1 = _chat_worker(
workers[0],
[{'role': 'system', 'content': '你只输出 JSON。'},
{'role': 'user', 'content': SUPERVISOR_PLAN_PROMPT.format(
topic=run['topic'], context=run['context'] or '')}],
temperature=0.3, max_tokens=2000)
# 1) 主管拆解(JSON 解析失败自动加大 max_tokens 重试)
try:
subtasks = _extract_json(plan_text)
if isinstance(subtasks, dict):
subtasks = subtasks.get('subtasks') or subtasks.get('tasks') or []
data, _, u1 = _chat_json(
workers[0],
[{'role': 'system', 'content': '你只输出 JSON。'},
{'role': 'user', 'content': SUPERVISOR_PLAN_PROMPT.format(
topic=run['topic'], context=run['context'] or '')}],
max_tokens=3000, temperature=0.3)
subtasks = data.get('subtasks') or data.get('tasks') or []
if isinstance(data, list):
subtasks = data
except Exception as e:
_update_run(run_id, status='failed', error=f'主管规划解析失败: {e}')
_log(run_id, 'supervisor', None, 'plan', f'❌ 规划解析失败: {e}')
@@ -216,15 +233,19 @@ def run_review(run_id, run, workers):
# 评审
review_usage = None
try:
review_text, u2 = _chat_worker(
review_data, review_text, u2 = _chat_json(
reviewer_w,
[{'role': 'system', 'content': '你只输出 JSON。'},
{'role': 'user', 'content': REVIEWER_PROMPT.format(
topic=run['topic'], output=final_output, rubric=rubric)}],
temperature=0.2, max_tokens=1500)
score, judgment = _extract_score(review_text)
max_tokens=2000, temperature=0.2)
review_usage = u2
total_tokens += u2['total_tokens']; total_cost += u2['cost']
if isinstance(review_data, dict):
score = float(review_data.get('score', review_data.get('总分', 60)))
judgment = review_data.get('judgment') or review_data.get('意见') or review_text
else:
score, judgment = _extract_score(review_text)
except Exception as e:
score, judgment = 0, f'评审调用失败: {e}'
final_score = score
@@ -325,14 +346,13 @@ def run_debate(run_id, run, workers):
f"{v['stance']}】(#{k}){v['view'][:500]}"
for k, v in views.items() if k != w['id'])
try:
text, u = _chat_worker(
data, _, u = _chat_json(
w,
[{'role': 'system', 'content': '你只输出 JSON。'},
{'role': 'user', 'content': DEBATE_REBUT_PROMPT.format(
stance=mine['stance'], topic=run['topic'],
my_view=mine['view'], others=others)}],
temperature=0.7, max_tokens=800)
data = _extract_json(text)
max_tokens=1200, temperature=0.7)
rebut = data.get('rebuttal') if isinstance(data, dict) else text
mine['view'] = mine['view'] + '\n\n【质询回应】' + str(rebut)
total_tokens += u['total_tokens']; total_cost += u['cost']
@@ -346,12 +366,11 @@ def run_debate(run_id, run, workers):
f"{v['stance']}{v['view']}" for v in views.values())
_log(run_id, 'judge', judge_w['id'], 'verdict', '裁判综合裁决中…')
try:
verdict, u4 = _chat_worker(
data, verdict, u4 = _chat_json(
judge_w,
[{'role': 'system', 'content': '你只输出 JSON。你是公正严明的首席裁判。'},
{'role': 'user', 'content': JUDGE_PROMPT.format(topic=run['topic'], transcript=transcript)}],
temperature=0.3, max_tokens=1500)
data = _extract_json(verdict)
max_tokens=2500, temperature=0.3)
if isinstance(data, dict):
consensus = data.get('consensus') or data.get('结论') or verdict
summary = data.get('summary') or data.get('摘要') or ''
+32 -3
View File
@@ -263,6 +263,35 @@ def worker_test(wid):
return jsonify({'ok': False, 'error': str(e)})
@app.route('/api/workers/<int:wid>/vision_test', methods=['POST'])
@require_auth
def worker_vision_test(wid):
"""视觉智能体能力测试:传入图片(URL 或 base64)与问题,验证多模态理解"""
w = db.q('SELECT * FROM workers WHERE id=?', (wid,), one=True)
if not w:
return jsonify({'ok': False, 'error': '不存在'}), 404
d = request.get_json(force=True)
image_url = (d.get('image_url') or '').strip()
image_b64 = (d.get('image_base64') or '').strip()
question = (d.get('question') or '请描述这张图片的内容').strip()
if not image_url and not image_b64:
return jsonify({'ok': False, 'error': '请提供图片 URL 或 base64 数据'}), 400
try:
if image_b64:
r = llm_gateway.chat_vision(
w['provider'], w['model'], question, image_url=f'data:image/png;base64,{image_b64}',
temperature=0.3, max_tokens=w['max_tokens'] or 2000,
base_url=w['base_url'] or None, api_key=w['api_key'] or None)
else:
r = llm_gateway.chat_vision(
w['provider'], w['model'], question, image_url=image_url,
temperature=0.3, max_tokens=w['max_tokens'] or 2000,
base_url=w['base_url'] or None, api_key=w['api_key'] or None)
return jsonify({'ok': True, 'data': r})
except Exception as e:
return jsonify({'ok': False, 'error': str(e)})
@app.route('/api/providers')
@require_auth
def providers():
@@ -273,7 +302,7 @@ def providers():
'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__'))],
'qwen': 'qwen', 'vllm': '', 'autodl': 'qwen3'}.get(k, '__none__'))],
})
return jsonify({'ok': True, 'data': data})
@@ -1119,8 +1148,8 @@ def template_apply(tid):
pid, ids = tplmod.apply_project_template(t, variables, worker_id)
return jsonify({'ok': True, 'kind': 'project', 'id': pid, 'task_ids': ids})
if t['type'] == 'team':
ids = tplmod.apply_team_template(t, variables, provider=d.get('provider', 'doubao'),
model=d.get('model', 'doubao-seed-evolving'))
ids = tplmod.apply_team_template(t, variables, provider=d.get('provider', 'deepseek'),
model=d.get('model', 'deepseek-v4-flash'))
return jsonify({'ok': True, 'kind': 'team', 'ids': ids})
return jsonify({'ok': False, 'error': '未知模板类型'}), 400
except Exception as e:
+10 -2
View File
@@ -33,8 +33,14 @@ PROVIDERS = {
},
'deepseek': {
'name': 'DeepSeek',
'base_url': 'https://api.deepseek.com/v1',
'api_key': os.environ.get('DEEPSEEK_API_KEY', ''),
'base_url': 'https://api.deepseek.com',
'api_key': os.environ.get('DEEPSEEK_API_KEY', 'sk-edb9df58ff574f8c98df1cd6a425e97c'),
'timeout': 600,
},
'autodl': {
'name': 'AutoDL 多模态(视觉)',
'base_url': 'https://www.autodl.art/api/v1',
'api_key': os.environ.get('AUTODL_API_KEY', 'F9MBfolzuapqTsD4KmUf9qen720rXvUZ3Sp3IrWiCTukqonx'),
'timeout': 600,
},
'openai': {
@@ -66,10 +72,12 @@ MODEL_PRICING = {
'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},
'deepseek-v4-flash': {'input': 0.2, 'output': 0.6},
'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},
'qwen3.6-plus': {'input': 2.0, 'output': 8.0},
}
DEFAULT_PRICE = {'input': 2.0, 'output': 8.0}
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

+17 -2
View File
@@ -123,7 +123,9 @@ def _budget_alert(project_id):
def _build_messages(task, worker):
"""构造提示词:任务指令 + RAG 知识库上下文"""
"""构造提示词:任务指令 + RAG 知识库上下文
支持图片注入:任务描述中的 ![说明](图片URL) 或 图片:URL 会转成多模态消息(视觉 Worker)。"""
import re as _re
messages = []
if worker['system_prompt']:
messages.append({'role': 'system', 'content': worker['system_prompt']})
@@ -132,7 +134,20 @@ def _build_messages(task, worker):
if ctx:
user_text = f'{ctx}\n\n----\n\n任务指令:{user_text}'
_log(task['id'], 'info', f'📚 RAG 知识库命中 {len(refs)} 个片段:' + ''.join(refs[:5]))
messages.append({'role': 'user', 'content': user_text})
# 提取图片 URL![alt](url) 或 图片:url 或 image:url
img_urls = []
for m in _re.finditer(r'!\[[^\]]*\]\(([^)\s]+)\)', user_text):
img_urls.append(m.group(1))
for m in _re.finditer(r'(?:图片|image)\s*[:]\s*(https?://\S+)', user_text, _re.I):
img_urls.append(m.group(1))
if img_urls:
content = [{'type': 'text', 'text': user_text}]
for u in img_urls:
content.append({'type': 'image_url', 'image_url': {'url': u}})
messages.append({'role': 'user', 'content': content})
_log(task['id'], 'info', f'🖼️ 检测到 {len(img_urls)} 张图片,已注入多模态消息')
else:
messages.append({'role': 'user', 'content': user_text})
return messages
+56 -2
View File
@@ -30,7 +30,12 @@ def calc_cost(model, prompt_tokens, completion_tokens):
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}"""
"""调用 OpenAI 兼容 chat/completions,返回 {text, usage, cost, model}
messages 支持两种格式:
- 纯文本:[{'role':'user','content':'...'}]
- 多模态:[{'role':'user','content':[{'type':'text','text':'...'},
{'type':'image_url','image_url':{'url':'...'}}]}]
"""
cfg = get_provider_cfg(provider)
url = (base_url or cfg['base_url']).rstrip('/') + '/chat/completions'
key = api_key or cfg['api_key']
@@ -56,7 +61,14 @@ def chat(provider, model, messages, temperature=0.7, max_tokens=None,
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 ''
msg = data['choices'][0]['message']
text = msg.get('content') or ''
if not text:
# 推理模型偶发 content 为空:用 reasoning_content 兜底
text = msg.get('reasoning_content') or ''
if not text:
last_err = LLMError('模型返回空内容,重试中…')
continue
usage = data.get('usage', {})
pt = usage.get('prompt_tokens', 0)
ct = usage.get('completion_tokens', 0)
@@ -84,6 +96,48 @@ def chat(provider, model, messages, temperature=0.7, max_tokens=None,
raise last_err or LLMError('未知错误')
def chat_vision(provider, model, text, image_url=None, image_path=None,
temperature=0.4, max_tokens=2000, base_url=None, api_key=None,
retries=3):
"""多模态视觉调用:文本 + 图片(URL 或本地路径/base64)。
返回与 chat() 相同结构。
容错:聚合 API 偶发路由到纯文本后端(不认识 image_url),自动重试。"""
import base64 as _b64
import time as _time
content = [{'type': 'text', 'text': text}]
img_url = image_url
if image_path:
with open(image_path, 'rb') as f:
raw = f.read()
mime = 'image/png'
if image_path.lower().endswith(('.jpg', '.jpeg')):
mime = 'image/jpeg'
elif image_path.lower().endswith('.gif'):
mime = 'image/gif'
elif image_path.lower().endswith('.webp'):
mime = 'image/webp'
img_url = f'data:{mime};base64,{_b64.b64encode(raw).decode()}'
if img_url:
content.append({'type': 'image_url', 'image_url': {'url': img_url}})
messages = [{'role': 'user', 'content': content}]
last_err = None
for attempt in range(max(1, retries)):
try:
return chat(provider, model, messages,
temperature=temperature, max_tokens=max_tokens,
base_url=base_url, api_key=api_key)
except LLMError as e:
last_err = e
msg = str(e)
# 仅对“多模态格式不被支持/图片无效”类错误重试(聚合后端路由问题)
if any(k in msg for k in ('image_url', 'InvalidParameter', 'invalid_parameter',
'does not appear to be valid', 'image')):
_time.sleep(2 * (attempt + 1))
continue
raise
raise last_err or LLMError('视觉调用失败')
def test_connection(provider, model, base_url=None, api_key=None):
"""连通性测试:发一条最小请求"""
t0 = time.time()
+237 -31
View File
@@ -287,14 +287,10 @@ async function moveTask(tid, status) {
toast('状态已更新', 'ok'); refreshProj();
}
/* --- DAG 工作流 --- */
function renderDag() {
const {tasks} = projCtx;
const idMap = {};
tasks.forEach(t => { idMap[t.id] = t; });
const edges = [];
tasks.forEach(t => (t.depends_on || []).forEach(d => { if (idMap[d]) edges.push({from:d, to:t.id}); }));
/* --- DAG 工作流(可交互画布:拖节点 / 滚轮缩放 / 拖画布平移) --- */
let dagState = null; // {pid, pos:{id:{x,y}}, scale, tx, ty, drag:null, moved:false}
function dagLayout(tasks, idMap) {
// 拓扑分层:最长路径
const layer = {};
const visit = id => {
@@ -308,43 +304,231 @@ function renderDag() {
tasks.forEach(t => visit(t.id));
const layers = {};
tasks.forEach(t => { (layers[layer[t.id]] = layers[layer[t.id]] || []).push(t.id); });
const W = 260, H = 110, PAD = 60;
const width = Math.max(2, Object.keys(layers).length) * W + PAD;
const height = Math.max(...tasks.map(t => layer[t.id] === undefined ? 0 : layer[t.id]), 0) * H + PAD + 60;
const pos = {};
Object.entries(layers).forEach(([l, ids]) => {
const x = PAD + parseInt(l) * W;
const step = Math.min(H - 60, Math.max(70, (height - 2*PAD) / (ids.length + 1)));
const step = Math.min(H - 60, Math.max(70, (height - 2 * PAD) / (ids.length + 1)));
ids.forEach((id, i) => { pos[id] = {x, y: PAD + (i + 1) * step}; });
});
return {width, height, pos};
}
function dagSize() {
const {tasks} = projCtx;
const idMap = {};
tasks.forEach(t => { idMap[t.id] = t; });
return dagLayout(tasks, idMap);
}
function saveDagState() {
if (!dagState) return;
try {
const pos = {};
Object.entries(dagState.pos).forEach(([k, v]) => { pos[k] = {x: Math.round(v.x), y: Math.round(v.y)}; });
localStorage.setItem('aw_dag_pos_' + dagState.pid, JSON.stringify(pos));
localStorage.setItem('aw_dag_view_' + dagState.pid,
JSON.stringify({scale: dagState.scale, tx: Math.round(dagState.tx), ty: Math.round(dagState.ty)}));
} catch (e) {}
}
function dagApplyView() {
const s = dagState;
const svg = $('#dag-svg');
if (!svg) return;
svg.style.transform = `translate(${s.tx}px, ${s.ty}px) scale(${s.scale})`;
svg.style.transformOrigin = '0 0';
}
function drawDag() {
const {tasks} = projCtx;
const idMap = {};
tasks.forEach(t => { idMap[t.id] = t; });
const edges = [];
tasks.forEach(t => (t.depends_on || []).forEach(d => { if (idMap[d]) edges.push({from: d, to: t.id}); }));
const {width, height} = dagSize();
const colors = {todo:'#2a3550', running:'#173b6b', review:'#4a3a10', done:'#0f3d2e', rejected:'#4a1c1c', failed:'#3d1c2e', cancelled:'#333'};
const ready = tasks.filter(t => (t.status === 'todo' || t.status === 'failed') && (!t.depends_on || t.depends_on.every(d => idMap[d]?.status === 'done')));
$('#tab-body').innerHTML = `
const body = $('#tab-body');
if (!body) return;
body.innerHTML = `
<div class="toolbar" style="margin-bottom:10px">
<span class="pager-tip">共 ${tasks.length} 个节点 · ${edges.length} 条依赖边${ready.length ? ` · ${ready.length} 个任务就绪` : ''}</span>
<span class="pager-tip">共 ${tasks.length} 个节点 · ${edges.length} 条依赖边${ready.length ? ` · ${ready.length} 个任务就绪` : ''} · 拖节点调位 / 滚轮缩放 / 拖空白平移 / 双击复位</span>
<button class="btn success" ${ready.length ? '' : 'disabled'} onclick="runWorkflow()">▶ 执行整个工作流(${ready.length}</button>
</div>
<div class="dag-box"><svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#4a5878"/></marker></defs>
${edges.map(e => {
const a = pos[e.from], b = pos[e.to];
if (!a || !b) return '';
const mx = (a.x + b.x) / 2;
const act = idMap[e.from].status === 'done';
return `<path class="dag-edge${act ? ' act' : ''}" d="M${a.x+55},${a.y} C${mx},${a.y} ${mx},${b.y} ${b.x-55},${b.y}"/>`;
}).join('')}
${tasks.map(t => {
const p = pos[t.id];
const col = colors[t.status];
return `<g>
<rect class="dag-node" x="${p.x-55}" y="${p.y-24}" width="110" height="48" fill="${col}" stroke="#3a4a6a" onclick="openTaskDetail(${t.id})"/>
<text class="dag-label" x="${p.x}" y="${p.y-2}" fill="#fff">#${t.id} ${esc(t.title).slice(0,10)}</text>
<text class="dag-sub" x="${p.x}" y="${p.y+15}">${STATUS[t.status].label}${t.depends_on?.length ? ' · 前置'+t.depends_on.length : ''}</text>
</g>`;
}).join('')}
</svg></div>
<div class="tab-note" style="margin-top:10px">💡 节点点击查看详情;前置任务完成后下游自动触发。执行整个工作流 = 派发所有就绪任务(含失败重试)。</div>`;
<div class="dag-box" id="dag-box">
<svg id="dag-svg" width="100%" height="${Math.max(480, height)}" viewBox="0 0 ${width} ${height}" style="transform-origin:0 0">
<defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#4a5878"/></marker></defs>
<rect id="dag-bg" x="-2000" y="-2000" width="${width + 4000}" height="${height + 4000}" fill="transparent"/>
${edges.map(e => {
const a = dagState.pos[e.from], b = dagState.pos[e.to];
if (!a || !b) return '';
const mx = (a.x + b.x) / 2;
const act = idMap[e.from].status === 'done';
return `<path data-e="${e.from}-${e.to}" class="dag-edge${act ? ' act' : ''}" d="M${a.x + 55},${a.y} C${mx},${a.y} ${mx},${b.y} ${b.x - 55},${b.y}"/>`;
}).join('')}
${tasks.map(t => {
const p = dagState.pos[t.id];
const col = colors[t.status];
return `<g class="dag-node" data-id="${t.id}" style="cursor:grab">
<rect x="${p.x - 55}" y="${p.y - 24}" width="110" height="48" fill="${col}" stroke="#3a4a6a"/>
<text class="dag-label" x="${p.x}" y="${p.y - 2}" fill="#fff">#${t.id} ${esc(t.title).slice(0, 10)}</text>
<text class="dag-sub" x="${p.x}" y="${p.y + 15}">${STATUS[t.status].label}${t.depends_on?.length ? ' · 前置' + t.depends_on.length : ''}</text>
</g>`;
}).join('')}
</svg>
</div>
<div class="tab-note" style="margin-top:10px">💡 节点点击查看详情;拖动节点可自定义布局(自动记忆);前置任务完成后下游自动触发。执行整个工作流 = 派发所有就绪任务(含失败重试)。</div>`;
dagApplyView();
bindDagEvents();
}
function bindDagEvents() {
const box = $('#dag-box');
const svg = $('#dag-svg');
if (!box || !svg) return;
const s = dagState;
let down = null; // {type:'node'|'pan', id?, sx, sy, ox, oy}
box.addEventListener('mousedown', e => {
if (e.button !== 0) return;
const nodeEl = e.target.closest('.dag-node');
if (nodeEl) {
const id = Number(nodeEl.dataset.id);
const p = s.pos[id];
down = {type: 'node', id, sx: e.clientX, sy: e.clientY, ox: p.x, oy: p.y, moved: false};
nodeEl.style.cursor = 'grabbing';
svg.style.cursor = 'grabbing';
} else {
down = {type: 'pan', sx: e.clientX, sy: e.clientY, ox: s.tx, oy: s.ty, moved: false};
svg.style.cursor = 'grabbing';
}
e.preventDefault();
});
window.addEventListener('mousemove', e => {
if (!down) return;
const dx = e.clientX - down.sx, dy = e.clientY - down.sy;
if (!down.moved && Math.abs(dx) + Math.abs(dy) < 3) return;
down.moved = true;
if (down.type === 'node') {
const p = s.pos[down.id];
if (p) { p.x = down.ox + dx / s.scale; p.y = down.oy + dy / s.scale; }
} else {
s.tx = down.ox + dx;
s.ty = down.oy + dy;
}
dagApplyView();
if (down.type === 'node') {
// 轻量更新:节点坐标 + 关联连线
const svgEl = $('#dag-svg');
if (!svgEl) return;
svgEl.querySelectorAll('.dag-node').forEach(g => {
const nid = Number(g.dataset.id);
const pp = s.pos[nid];
if (!pp) return;
g.querySelector('rect').setAttribute('x', pp.x - 55);
g.querySelector('rect').setAttribute('y', pp.y - 24);
const ts = g.querySelectorAll('text');
ts[0].setAttribute('x', pp.x); ts[0].setAttribute('y', pp.y - 2);
ts[1].setAttribute('x', pp.x); ts[1].setAttribute('y', pp.y + 15);
});
// 以拖动节点为中心更新相关连线
const touched = new Set([down.id]);
projCtx.tasks.forEach(t => (t.depends_on || []).forEach(d => {
if (d === down.id || t.id === down.id) { touched.add(d); touched.add(t.id); }
}));
touched.forEach(tid => {
const a = s.pos[tid];
if (!a) return;
projCtx.tasks.forEach(t => {
if (t.id !== tid) return;
(t.depends_on || []).forEach(d => {
const b = s.pos[d];
if (!b) return;
const path = svgEl.querySelector(`path[data-e="${d}-${tid}"]`);
if (!path) return;
const mx = (b.x + a.x) / 2;
path.setAttribute('d', `M${b.x + 55},${b.y} C${mx},${b.y} ${mx},${a.y} ${a.x - 55},${a.y}`);
});
});
});
}
});
window.addEventListener('mouseup', () => {
if (!down) return;
const wasNode = down.type === 'node';
if (wasNode) {
const nodeEl = svg.querySelector('.dag-node[data-id="' + down.id + '"]');
if (nodeEl) nodeEl.style.cursor = 'grab';
}
svg.style.cursor = '';
if (down.moved) {
dagState.suppressClick = true;
setTimeout(() => { if (dagState) dagState.suppressClick = false; }, 400);
saveDagState();
}
down = null;
});
// 滚轮缩放(以鼠标为锚点)
box.addEventListener('wheel', e => {
e.preventDefault();
const rect = box.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
const ns = Math.min(3, Math.max(0.25, s.scale * factor));
// 保持鼠标指向的内容坐标不动
const cx = (mx - s.tx) / s.scale, cy = (my - s.ty) / s.scale;
s.scale = ns;
s.tx = mx - cx * ns;
s.ty = my - cy * ns;
dagApplyView();
saveDagState();
}, {passive: false});
// 双击空白复位视图
box.addEventListener('dblclick', e => {
if (e.target.closest('.dag-node')) return;
s.scale = 1; s.tx = 0; s.ty = 0;
dagApplyView();
saveDagState();
});
// 点击节点打开详情(拖动超过阈值则不触发)
box.addEventListener('click', e => {
const nodeEl = e.target.closest('.dag-node');
if (!nodeEl) return;
if (dagState.suppressClick) { dagState.suppressClick = false; return; }
openTaskDetail(Number(nodeEl.dataset.id));
});
}
function renderDag() {
const {tasks, pid} = projCtx;
const idMap = {};
tasks.forEach(t => { idMap[t.id] = t; });
const {pos} = dagLayout(tasks, idMap);
// 恢复已保存布局 / 视图
let savedPos = null, savedView = null;
try {
savedPos = JSON.parse(localStorage.getItem('aw_dag_pos_' + pid) || 'null');
savedView = JSON.parse(localStorage.getItem('aw_dag_view_' + pid) || 'null');
} catch (e) {}
if (savedPos) Object.entries(savedPos).forEach(([k, v]) => { if (pos[k]) pos[k] = v; });
dagState = {
pid, pos,
scale: savedView?.scale || 1,
tx: savedView?.tx || 0,
ty: savedView?.ty || 0,
drag: null, moved: false,
};
const box = $('#tab-body');
box.innerHTML = '';
drawDag();
}
async function runWorkflow() {
@@ -623,6 +807,7 @@ async function pageWorkers() {
<td class="mono">${w.task_cost_limit ? '单任务 ¥' + w.task_cost_limit : '—'}<br>${w.monthly_cost_limit ? '月 ¥' + w.monthly_cost_limit : ''}</td>
<td><span class="badge ${w.status === 'enabled' ? 'st-done' : 'st-cancelled'}">${w.status === 'enabled' ? '启用' : '停用'}</span></td>
<td><button class="btn sm" onclick="testWorker(${w.id})">测试</button>
<button class="btn sm" onclick="visionTest(${w.id})">🖼️ 视觉测试</button>
<button class="btn sm" onclick="openWorkerModal(${JSON.stringify(w).replace(/"/g,'&quot;')}, ${JSON.stringify(ps.data).replace(/"/g,'&quot;')})">编辑</button>
<button class="btn sm danger" onclick="delWorker(${w.id})">删除</button></td>
</tr>`).join('') || '<tr><td colspan="9" class="empty">还没有 Worker,注册第一个虚拟员工吧</td></tr>'}
@@ -636,6 +821,27 @@ async function testWorker(wid) {
catch (e) { toast('❌ ' + e.message, 'err'); }
}
async function visionTest(wid) {
openModal(`
<h3>🖼️ 视觉能力测试</h3>
<div class="tab-note">多模态智能体:输入图片 URL 与问题,验证图像理解能力。示例:<br>
https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/640px-Cat03.jpg</div>
<label>图片 URL</label><input id="vt-url" placeholder="https://...">
<label>问题</label><textarea id="vt-q" rows="2">请描述这张图片的内容,并给出专业分析。</textarea>
<button class="btn primary" id="vt-go">🚀 开始分析</button>
<div id="vt-out" style="margin-top:10px"></div>`);
$('#vt-go').addEventListener('click', async () => {
const url = $('#vt-url').value.trim();
if (!url) return toast('请填写图片 URL', 'err');
$('#vt-out').innerHTML = '<div class="tab-note"><span class="spin"></span> 视觉分析中(多模态调用约 10-60 秒)…</div>';
try {
const r = await api(`/api/workers/${wid}/vision_test`, {method:'POST', body: {image_url: url, question: $('#vt-q').value}});
$('#vt-out').innerHTML = `<pre style="white-space:pre-wrap;background:#f6f8fa;padding:10px;border-radius:8px;max-height:320px;overflow:auto">${esc(r.data.text)}</pre>
<div class="tab-note">tokens ${r.data.total_tokens} · 成本 ${fmtMoney(r.data.cost)} · 延迟 ${r.data.latency_ms ?? '—'}ms</div>`;
} catch (e) { $('#vt-out').innerHTML = `<div class="err">${esc(e.message)}</div>`; }
});
}
async function delWorker(wid) {
if (!confirm('确认删除该 Worker?(历史任务将解除指派)')) return;
await api(`/api/workers/${wid}`, {method:'DELETE'});
+2 -1
View File
@@ -131,7 +131,8 @@ tr:hover td{background:var(--panel2)}
.tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:1px solid var(--border)}
.tabs a{padding:9px 16px;color:var(--muted);text-decoration:none;border-bottom:2px solid transparent;font-size:13px}
.tabs a.active{color:var(--accent);border-bottom-color:var(--accent)}
.dag-box{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:14px;overflow:auto}
.dag-box{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden;height:520px;position:relative;touch-action:none;user-select:none;cursor:grab}
.dag-box svg{display:block;transition:transform .05s linear}
.dag-node{fill:var(--panel2);stroke:var(--border);stroke-width:1.5;rx:10;cursor:pointer}
.dag-node:hover{stroke:var(--accent)}
.dag-label{font-size:12px;fill:var(--text);text-anchor:middle}
+1 -1
View File
@@ -180,7 +180,7 @@ def apply_project_template(tpl, variables, worker_id=None):
return pid, created
def apply_team_template(tpl, variables, provider='doubao', model='doubao-seed-evolving'):
def apply_team_template(tpl, variables, provider='deepseek', model='deepseek-v4-flash'):
"""应用团队模板 → 批量注册 Worker"""
content = render(json.loads(tpl['content']), variables)
created = []