mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:02:00 +00:00
AgentLab:可视化 Agent 编排实验台
在画布上拖拽编排 agent 工作流,并实时看着它执行。 后端 FastAPI + LangGraph:把画布 JSON 编译成 StateGraph,15 种节点 (模型/Agent/多Agent、工具/沙箱、分支/循环/子图、记忆/检索/整形、 人工介入/结构校验)。选 LangGraph 是因为可视化编排真正难的是暂停、 恢复和回放,而 checkpointer + interrupt() 正好把这几件事做在了底层。 前端 React + React Flow:节点元数据驱动属性面板与连接桩,运行时 节点高亮、边流动画、卡片内流式 token 全部由同一份事件流驱动。 关键实现: - Agent 内工具审批用 @task 缓存模型调用,interrupt 重放时不重复计费 - 沙箱保持只读根文件系统,代码经 exec+base64 写入(put_archive 会被 只读容器拒绝),断网、非 root、内存/CPU/超时限额 - 检索为向量与 BM25 混合,本地哈希 embedding 零配置可用 - 分支条件走 AST 白名单求值器;HTTP 工具逐 IP 拦截内网与元数据地址 - Mock provider 让无 API Key 也能跑通完整链路 7 个内置模板全部冒烟通过,含真实模型端到端验证。
This commit is contained in:
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# 一条命令把前后端都拉起来。Ctrl-C 一起退出。
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
API_PORT="${AGENTLAB_PORT:-8000}"
|
||||
WEB_PORT="${AGENTLAB_WEB_PORT:-5273}"
|
||||
|
||||
command -v uv >/dev/null || { echo "缺少 uv:brew install uv"; exit 1; }
|
||||
command -v pnpm >/dev/null || { echo "缺少 pnpm:npm i -g pnpm"; exit 1; }
|
||||
|
||||
echo "==> 同步依赖"
|
||||
(cd "$ROOT/backend" && uv sync --quiet)
|
||||
[ -d "$ROOT/frontend/node_modules" ] || (cd "$ROOT/frontend" && pnpm install)
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "==> 停止"
|
||||
# 杀掉整个进程组,避免 uvicorn / vite 变成孤儿进程
|
||||
[ -n "${API_PID:-}" ] && kill "$API_PID" 2>/dev/null || true
|
||||
[ -n "${WEB_PID:-}" ] && kill "$WEB_PID" 2>/dev/null || true
|
||||
wait 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "==> 后端 http://127.0.0.1:$API_PORT"
|
||||
(cd "$ROOT/backend" && uv run uvicorn app.main:app --host 127.0.0.1 --port "$API_PORT" --reload) &
|
||||
API_PID=$!
|
||||
|
||||
# 等后端起来再起前端,省得前端第一次请求就打空
|
||||
for _ in $(seq 1 60); do
|
||||
curl -sf "http://127.0.0.1:$API_PORT/api/health" >/dev/null 2>&1 && break
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
echo "==> 前端 http://localhost:$WEB_PORT"
|
||||
(cd "$ROOT/frontend" && pnpm dev --port "$WEB_PORT") &
|
||||
WEB_PID=$!
|
||||
|
||||
echo
|
||||
echo " 界面 http://localhost:$WEB_PORT"
|
||||
echo " 接口 http://127.0.0.1:$API_PORT/docs"
|
||||
echo
|
||||
wait
|
||||
@@ -0,0 +1,48 @@
|
||||
// 冒烟测试:真开一个浏览器,跑一遍「打开画布 → 选模板 → 运行 → 看高亮」。
|
||||
// 依赖 frontend 里的 playwright-core,并复用系统已装的 Chrome,不额外下载浏览器。
|
||||
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
|
||||
const browser = await chromium.launch({
|
||||
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', args: ['--no-sandbox'] })
|
||||
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
const errs = []
|
||||
page.on('pageerror', e => errs.push(e.message))
|
||||
page.on('console', m => m.type() === 'error' && errs.push(m.text()))
|
||||
|
||||
await page.goto('http://localhost:5273/studio', { waitUntil: 'networkidle' })
|
||||
await page.waitForSelector('.react-flow__node', { timeout: 15000 })
|
||||
|
||||
// 切到「最小问答」模板,用 mock 模型跑,验证画布实时高亮
|
||||
await page.getByRole('button', { name: /批量处理|最小问答|工作流/ }).first().click()
|
||||
await page.waitForTimeout(600)
|
||||
const row = page.locator('text=① 最小问答').first()
|
||||
if (await row.count()) { await row.click(); await page.waitForTimeout(900) }
|
||||
|
||||
console.log('当前图节点数:', await page.locator('.react-flow__node').count())
|
||||
|
||||
// 选中 llm 节点,把模型改成 mock,避免这次验证花真钱
|
||||
await page.locator('.react-flow__node').nth(1).click()
|
||||
await page.waitForTimeout(500)
|
||||
const modelSel = page.locator('select').filter({ hasText: /默认|Mock|Claude/ }).first()
|
||||
if (await modelSel.count()) { await modelSel.selectOption({ label: 'Mock(不花钱,用来试编排)' }).catch(()=>{}) }
|
||||
await page.screenshot({ path: '/tmp/shot-inspector.png' })
|
||||
|
||||
// 回到运行面板,填输入并运行
|
||||
await page.getByRole('button', { name: '运行', exact: true }).first().click()
|
||||
await page.waitForTimeout(300)
|
||||
const ta = page.locator('aside textarea').first()
|
||||
await ta.fill('可视化 agent 编排解决了什么问题?')
|
||||
await page.getByRole('button', { name: /^运行$/ }).last().click()
|
||||
|
||||
// 等到有节点进入运行态,抓一张"正在跑"的画面
|
||||
await page.waitForSelector('.node-running, .node-done', { timeout: 20000 }).catch(()=>{})
|
||||
await page.waitForTimeout(1200)
|
||||
await page.screenshot({ path: '/tmp/shot-running.png' })
|
||||
console.log('运行中高亮节点:', await page.locator('.node-running').count(), '已完成:', await page.locator('.node-done').count())
|
||||
|
||||
await page.waitForTimeout(6000)
|
||||
await page.screenshot({ path: '/tmp/shot-done.png' })
|
||||
console.log('完成后 done 节点:', await page.locator('.node-done').count())
|
||||
const timeline = await page.locator('aside').last().innerText()
|
||||
console.log('--- 右侧面板文本 ---'); console.log(timeline.slice(0, 700))
|
||||
console.log('--- 错误 ---'); console.log(errs.slice(0,6).join('\n') || '(无)')
|
||||
await browser.close()
|
||||
Reference in New Issue
Block a user