发布门禁、操作者归属、治理遥测与前端支撑

审批记录批准人(X-Actor 先落库再恢复执行)。设置页可配操作者署名,
随请求头发送——这是归属记录不是身份认证,界面上写明了区别。

治理遥测三件套,全部从既有事件流取材:
- 工具白名单衰减报表:授了权但从未用过的工具摆上台面,放宽可被回收
- 探索问题聚类:同一问题反复出现(≥3 次)标记为晋升候选——模板生长
  的触发器
- 轨迹→模板提取器:把探索性运行实际走过的路径确定性提取成草稿工作流
  (剪掉未走的分支、输入参数化),人审发布后才成为模板——探索层的
  价值在路径沉淀,不在单次答案

前端:口径卡节点与指标编辑器、出具契约配置、发布对话框(两级门禁、
问题列表)、试运行/正式运行双按钮、出具档位横幅(含缺数据声明与
未回指数字点名)、运行列表正式/探索徽标、运行详情版本与清单哈希、
提取模板按钮。

真模型验证闭环:周报模板 governed 发布 → formal 运行,Opus 5 正文
数字全部回指,但结尾'(约 100 字)'被校验器抓住降档——修 prompt
禁元话语而非加白名单,重发 v2 后正式档。8/8 模板回归通过。
This commit is contained in:
ThearchyHelios
2026-09-14 10:27:13 +08:00
parent 46eb674bc2
commit e905426dfe
18 changed files with 655 additions and 30 deletions
+33 -1
View File
@@ -51,6 +51,38 @@
| **自然语言编排** | Copilot:一句话生成或改写整张图,自动排版 + 校验 |
| **结构化成果** | JSON Schema 校验,不合格把错误喂回模型自动返工 |
| **追踪与成本** | 每步事件全量落库,可回放;逐节点耗时、token、按目录价折算的美元成本 |
| **三档出具** | 口径卡(受控指标集)+ 出具契约:叙述里每个数字回指指标集,formal / degraded / withheld 三档判定,缺数据声明与口径版本随成果印出 |
| **正式 / 探索分级** | 正式运行只从已发布的不可变版本发起(按图哈希钉死),画布试跑自动标探索性;受管(governed)发布过治理 lint |
| **模板生长** | 探索性运行可一键提取为草稿模板(只保留实际走过的路径);探索问题聚类提示"该晋升为固定节了";工具白名单衰减报表 |
## 受限动态编排:模板定骨架、agent 填关节
工业场景的大多数任务步骤是已知的(接单报价、周报、体检),让模型每次现场重新规划
是在重复发明已知答案,而且不可复现就不可验收。AgentLab 的引擎是编译式的——运行时
模型改不了图,它的自由被结构性地关在 agent 节点内部和 branch 决策里。在此之上:
- **模板层**:正式运行只认已发布的不可变版本(`WorkflowVersion` + 图哈希)。
发布后改画布不影响它。governed 级发布要过治理 lint:禁 supervisor(全动态属于
探索层)、方法卡必须钉版本、agent 不得关闭全部审批、output 必须有出具契约。
- **关节层**:agent 的工具白名单、分支的语义分类、缺数据时降档——有边界的自由。
白名单衰减报表(`/api/governance/tool-usage`)把"授了权没用过"的工具摆上台面。
- **探索层**:画布试跑、临时图、追问,结果自动标探索性、不进正式归档。跑通的路径
用「提取模板」沉淀为草稿(只保留实际执行的节点,剪掉没走的分支),人审后发布。
探索问题聚类提示哪些追问该长成模板里的固定一节。
**出具链路**(模板⑧就是完整示范):取数(固定,快照落工件库)→ 口径卡(metrics
节点,所有算术在这里确定性发生,带口径名和版本)→ 叙述(LLM,只许引用指标清单)
→ 出具契约(从叙述抽出所有数字逐个回指指标集,支持千分位/百分数↔比率/万亿后缀/
按书写精度的舍入容差;日期、枚举序号、引用标记不参与)。三档判定印在成果上。
实测:Opus 5 写的正文数字全部回指,但结尾"(约 100 字)"里的 100 被抓住降档——
修的是 prompt 而不是白名单,这正是校验器该有的行为。
钉住的方法卡出现新版本时,正式运行会被拒绝启动,直到模板声明升版处置:
`recompute`(回算历史)/ `dual`(双印)/ `incomparable`(标注不可比),没有默认值
——口径变更最容易静默翻车的地方是"数看起来都对,只是和上周不可比"。
每次运行的完整证据在内容寻址的工件库里(`data/artifacts/`,sha256 即地址,取回
复验哈希);运行终态的事件清单哈希存在 Run 上,事后改流水会对不上。
## 节点类型
@@ -61,7 +93,7 @@
| 执行 | `tool` `code` | 直接调工具 / 沙箱里跑代码 |
| 控制 | `branch` `loop` `subgraph` | 条件或语义分支 / 遍历与条件循环 / 嵌套工作流 |
| 上下文 | `memory` `retrieve` `transform` | 长期记忆读写 / 知识检索 / 数据整形 |
| 把关 | `human` `validate` | 人工介入 / Schema 校验与自动返工 |
| 把关 | `human` `validate` `metrics` | 人工介入 / Schema 校验与自动返工 / 口径卡(受控指标集) |
节点之间用模板语法传数据:
+96
View File
@@ -223,6 +223,102 @@ async def generate(
)
class FromRunIn(BaseModel):
run_id: str
name: str = ""
@router.post("/from-run")
async def extract_template(
payload: FromRunIn, session: AsyncSession = Depends(get_session)
) -> dict[str, Any]:
"""轨迹 → 模板:把一次探索性运行实际走过的路径提取成草稿工作流。
探索层的价值不在单次答案,而在"跑通的路径能沉淀为资产"。
提取是确定性的:只保留真正执行过的节点和它们之间的边,分支上
没走的岔路剪掉;输入值参数化成 input 字段默认值。产物落成 draft,
人审、改名、发布之后才成为正式模板——提取器是模板的作者,不是发布者。
"""
from sqlalchemy import select as _sel
from app.api.copilot import auto_layout # 自引用仅为显式
from app.db.models import Run, RunEvent, Workflow, WorkflowVersion
from app.core.artifact_store import graph_hash
run = await session.get(Run, payload.run_id)
if not run:
raise HTTPException(404, "运行记录不存在")
if not run.graph.get("nodes"):
raise HTTPException(400, "这次运行没有保存图快照,无法提取")
events = list(
(
await session.execute(
_sel(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq)
)
).scalars()
)
executed = {e.node_id for e in events if e.type == "node.started" and e.node_id}
taken = {
(e.node_id, str((e.data or {}).get("branch")))
for e in events
if e.type == "edge.taken" and e.node_id
}
if not executed:
raise HTTPException(400, "事件流里没有节点执行记录")
spec = GraphSpec.model_validate(run.graph)
kept_nodes = [n for n in spec.nodes if n.id in executed]
kept_ids = {n.id for n in kept_nodes}
kept_edges = []
for edge in spec.edges:
if edge.source not in kept_ids or edge.target not in kept_ids:
continue
# 分支/循环节点:只保留实际走过的出口,没走的岔路不进模板
source_node = spec.node_map().get(edge.source)
if source_node and source_node.type in (NodeType.BRANCH,) and edge.sourceHandle:
if (edge.source, edge.sourceHandle) not in taken:
continue
kept_edges.append(edge)
# 输入参数化:实际输入值变成字段默认值
for node in kept_nodes:
if node.type.value == "input":
node.data.config["fields"] = [
{"name": key, "default": value if isinstance(value, (str, int, float)) else json.dumps(value, ensure_ascii=False)}
for key, value in (run.input or {}).items()
] or node.data.config.get("fields", [])
draft_spec = auto_layout(
GraphSpec(nodes=kept_nodes, edges=kept_edges, defaults=spec.defaults)
)
graph = draft_spec.model_dump(mode="json")
workflow = Workflow(
name=payload.name or f"{run.workflow_name or '探索'}·提取模板",
description=f"从运行 {run.id[:8]} 的实际执行路径提取({len(kept_nodes)} 节点),待人审后发布",
graph=graph,
tags=["extracted"],
status="draft",
)
session.add(workflow)
await session.flush()
session.add(
WorkflowVersion(
workflow_id=workflow.id, version=1, graph=graph,
graph_hash=graph_hash(graph), note=f"自运行 {run.id} 提取",
)
)
await session.commit()
return {
"workflow_id": workflow.id,
"name": workflow.name,
"nodes": len(kept_nodes),
"edges": len(kept_edges),
"dropped_nodes": len(spec.nodes) - len(kept_nodes),
"source_run": run.id,
}
class ExplainIn(BaseModel):
graph: dict[str, Any]
provider: str | None = None
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
import json
from collections import defaultdict
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import get_session
from app.db.models import Run, RunEvent, Workflow
from app.engine.schema import GraphSpec, NodeType
router = APIRouter(prefix="/api/governance", tags=["governance"])
@router.get("/tool-usage")
async def tool_usage(
workflow_id: str,
limit_runs: int = Query(default=100, le=500),
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
"""工具白名单衰减报表。
关节层最可能的退化方式不是模板太死,而是白名单为了长尾一个个放宽,
半年后等于没有白名单。这份报表把"授了权但从来没用过"的工具摆到台面上,
放宽才能被定期回收。
"""
workflow = await session.get(Workflow, workflow_id)
if not workflow:
raise HTTPException(404, "工作流不存在")
spec = GraphSpec.model_validate(workflow.graph)
# 各节点声明的白名单
granted: dict[str, list[str]] = {}
for node in spec.nodes:
if node.type == NodeType.AGENT:
granted[node.id] = list(node.config.get("tools") or [])
elif node.type == NodeType.TOOL and node.config.get("tool"):
granted[node.id] = [node.config["tool"]]
elif node.type == NodeType.SUPERVISOR:
merged: list[str] = []
for agent in node.config.get("agents") or []:
merged.extend(agent.get("tools") or [])
granted[node.id] = sorted(set(merged))
run_ids = [
rid
for (rid,) in await session.execute(
select(Run.id)
.where(Run.workflow_id == workflow_id)
.order_by(Run.created_at.desc())
.limit(limit_runs)
)
]
usage: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
if run_ids:
rows = await session.execute(
select(RunEvent.node_id, RunEvent.data).where(
RunEvent.run_id.in_(run_ids), RunEvent.type == "tool.start"
)
)
for node_id, data in rows:
tool = (data or {}).get("tool")
if node_id and tool:
# supervisor 内部子 agent 的 node_id 形如 "team:researcher"
usage[node_id.split(":")[0]][tool] += 1
report = []
for node_id, tools in granted.items():
used = usage.get(node_id, {})
report.append(
{
"node_id": node_id,
"granted": tools,
"used": dict(used),
"unused": [t for t in tools if not used.get(t)],
"ungrated_but_used": [t for t in used if t not in tools],
}
)
return {
"workflow_id": workflow_id,
"runs_considered": len(run_ids),
"nodes": report,
"hint": "unused 里躺了很多轮的工具是白名单衰减的信号,考虑摘除",
}
@router.get("/exploratory-clusters")
async def exploratory_clusters(
limit: int = Query(default=200, le=500),
threshold: float = Query(default=0.62, ge=0.3, le=0.95),
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
"""探索性问题聚类:同一个问题被反复问,就该长成模板里的固定一节。
这就是模板生长的触发器。用本地 embedding 做贪心聚类,
出现 ≥3 次的簇标记为晋升候选。
"""
from app.memory.embeddings import cosine, embed_texts
rows = list(
await session.execute(
select(Run.id, Run.input, Run.workflow_name, Run.created_at)
.where(Run.run_class != "formal")
.order_by(Run.created_at.desc())
.limit(limit)
)
)
items = []
for rid, input_payload, wf_name, created in rows:
text = json.dumps(input_payload or {}, ensure_ascii=False, sort_keys=True)
if len(text) <= 4: # 空输入没有聚类意义
continue
items.append({"run_id": rid, "text": text, "workflow": wf_name,
"at": created.isoformat() if created else None})
if not items:
return {"clusters": [], "considered": 0}
vectors = await embed_texts([i["text"] for i in items])
clusters: list[dict[str, Any]] = []
for i, item in enumerate(items):
placed = False
for cluster in clusters:
if cosine(vectors[i], vectors[cluster["seed"]]) >= threshold:
cluster["members"].append(item)
placed = True
break
if not placed:
clusters.append({"seed": i, "members": [item]})
out = [
{
"count": len(c["members"]),
"representative": c["members"][0]["text"][:200],
"workflow": c["members"][0]["workflow"],
"run_ids": [m["run_id"] for m in c["members"]][:20],
"first_at": c["members"][-1]["at"],
"last_at": c["members"][0]["at"],
"promote_candidate": len(c["members"]) >= 3,
}
for c in clusters
if len(c["members"]) >= 2
]
out.sort(key=lambda c: c["count"], reverse=True)
return {
"clusters": out,
"considered": len(items),
"hint": "promote_candidate=true 的问题反复出现,考虑用「从运行提取模板」晋升为固定节",
}
+8 -1
View File
@@ -409,7 +409,10 @@ class DecideIn(BaseModel):
@approvals_router.post("/{approval_id}/decide", response_model=RunOut)
async def decide(
approval_id: str, payload: DecideIn, session: AsyncSession = Depends(get_session)
approval_id: str,
payload: DecideIn,
session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None),
) -> Run:
approval = await session.get(Approval, approval_id)
if not approval:
@@ -417,6 +420,10 @@ async def decide(
if approval.status != "pending":
raise HTTPException(409, "该请求已经处理过了")
# 责任归属先落库再恢复执行——恢复失败也要留下"谁试图批的"
approval.resolved_by = (x_actor or "").strip() or None
await session.commit()
response: dict[str, Any] = {"approved": payload.approved, "note": payload.note}
if payload.value is not None:
response["value"] = payload.value
+2 -1
View File
@@ -8,7 +8,7 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api import artifacts, copilot, knowledge, runs, sandbox, settings as settings_api, tools, workflows
from app.api import artifacts, copilot, governance, knowledge, runs, sandbox, settings as settings_api, tools, workflows
from app.core.config import settings
from app.db.base import init_db
from app.engine.runner import run_manager
@@ -68,6 +68,7 @@ for router in (
sandbox.router,
copilot.router,
artifacts.router,
governance.router,
):
app.include_router(router)
+4 -2
View File
@@ -204,8 +204,10 @@ TEMPLATES: list[dict[str, Any]] = [
]),
_n("narrate", "llm", "叙述(关节层)",
system="你是周报撰写人。只允许引用下面指标清单里出现的数值,"
"一个清单外的数字都不要写;重点是挑哪些变化值得说、怎么归因。",
prompt="{{ nodes.caliber.text }}\n\n请为 {{ input.week }} 写一段 120 字以内的周报正文。",
"一个清单外的数字都不要写——包括字数统计、序号补充说明等元内容。"
"只输出周报正文本身,不要任何撰写说明。"
"你的自由度在于:挑哪些变化值得说、怎么归因。",
prompt="{{ nodes.caliber.text }}\n\n请为 {{ input.week }} 写一段简短的周报正文。",
assign_to="report"),
_n("done", "output", "出具",
fields=[
+25
View File
@@ -11,11 +11,21 @@ export class ApiError extends Error {
}
}
function actorHeader(): Record<string, string> {
try {
const actor = localStorage.getItem('agentlab_actor')
return actor ? { 'X-Actor': actor } : {}
} catch {
return {}
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(BASE + path, {
...init,
headers: {
...(init?.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...actorHeader(),
...init?.headers,
},
})
@@ -59,6 +69,9 @@ export const api = {
duplicate: (id: string) => post<Workflow>(`/workflows/${id}/duplicate`),
versions: (id: string) => get<any[]>(`/workflows/${id}/versions`),
restore: (id: string, v: number) => post<Workflow>(`/workflows/${id}/versions/${v}/restore`),
publish: (id: string, level: 'published' | 'governed', version?: number) =>
post<{ ok: boolean; level?: string; version?: number; issues: any[] }>(
`/workflows/${id}/publish`, { level, version }),
validate: (graph: GraphSpec) =>
post<{ ok: boolean; issues: ValidationIssue[] }>('/workflows/validate', { graph }),
},
@@ -75,7 +88,9 @@ export const api = {
start: (body: {
workflow_id?: string; graph?: GraphSpec; input?: Record<string, any>
memory_scope?: string; collection?: string
run_class?: 'formal' | 'exploratory'; version?: number
}) => post<Run>('/runs', body),
artifacts: (id: string) => get<any[]>(`/runs/${id}/artifacts`),
cancel: (id: string) => post<{ ok: boolean }>(`/runs/${id}/cancel`),
resume: (id: string, response: any) => post<Run>(`/runs/${id}/resume`, { response }),
events: (id: string, after = 0) => get<RunEvent[]>(`/runs/${id}/events?after=${after}`),
@@ -167,6 +182,14 @@ export const api = {
remove: (id: string) => del(`/skills/${id}`),
},
// ---- 治理 ----
governance: {
toolUsage: (workflowId: string) =>
get<any>(`/governance/tool-usage?workflow_id=${workflowId}`),
clusters: () => get<any>('/governance/exploratory-clusters'),
},
artifact: (id: string) => get<{ id: string; content: any }>(`/artifacts/${id}`),
// ---- Copilot ----
copilot: {
generate: (body: { instruction: string; base_graph?: GraphSpec | null }) =>
@@ -174,6 +197,8 @@ export const api = {
'/copilot/generate', body),
explain: (graph: GraphSpec) => post<{ explanation: string }>('/copilot/explain', { graph }),
layout: (graph: GraphSpec) => post<GraphSpec>('/copilot/layout', { graph }),
fromRun: (runId: string, name?: string) =>
post<any>('/copilot/from-run', { run_id: runId, name }),
},
}
+1 -1
View File
@@ -13,7 +13,7 @@ const NODE_COLOR: Record<string, string> = {
input: '#3fb950', output: '#a371f7', llm: '#4f8cff', agent: '#6366f1',
supervisor: '#db61a2', tool: '#f0883e', code: '#2dd4bf', branch: '#d29922',
loop: '#ec4899', human: '#f85149', validate: '#84cc16', memory: '#14b8a6',
retrieve: '#0ea5e9', transform: '#8b949e', subgraph: '#bc8cff',
retrieve: '#0ea5e9', transform: '#8b949e', subgraph: '#bc8cff', metrics: '#34d399',
}
function CanvasInner() {
+36
View File
@@ -263,6 +263,9 @@ function FieldInput({ field, value, onChange }: {
case 'cases':
return <CaseList value={value ?? []} onChange={onChange} />
case 'metricsList':
return <MetricList value={value ?? []} onChange={onChange} />
case 'agents':
return <AgentList value={value ?? []} onChange={onChange} />
@@ -418,6 +421,39 @@ function CaseList({ value, onChange }: { value: any[]; onChange: (v: any[]) => v
)
}
function MetricList({ value, onChange }: { value: any[]; onChange: (v: any[]) => void }) {
const update = (i: number, patch: any) =>
onChange(value.map((m, idx) => (idx === i ? { ...m, ...patch } : m)))
return (
<div>
{value.map((m, i) => (
<Row key={i} onRemove={() => onChange(value.filter((_, idx) => idx !== i))}>
<div className="flex gap-1.5">
<input className="field" placeholder="指标 id(英文)" value={m.id ?? ''}
onChange={(e) => update(i, { id: e.target.value })} />
<input className="field" placeholder="名称" value={m.name ?? ''}
onChange={(e) => update(i, { name: e.target.value })} />
<input className="field w-16 shrink-0" placeholder="单位" value={m.unit ?? ''}
onChange={(e) => update(i, { unit: e.target.value })} />
</div>
<input className="field mono text-[11px]"
placeholder="表达式,如 round(vars.agg.amount / vars.agg.orders, 2)"
value={m.expression ?? ''}
onChange={(e) => update(i, { expression: e.target.value })} />
</Row>
))}
<button className="btn btn-sm w-full justify-center"
onClick={() => onChange([...value, { id: '', name: '', unit: '', expression: '' }])}>
<Plus size={11} /> 添加指标
</button>
<div className="mt-1.5 text-[10px] leading-snug text-faint">
所有算术都发生在这里(确定性、可复算)。叙述节点只许引用这份清单里的数,
出具时会逐个数字回指校验。
</div>
</div>
)
}
function AgentList({ value, onChange }: { value: any[]; onChange: (v: any[]) => void }) {
const catalog = useCatalog()
const update = (i: number, patch: any) =>
+6 -2
View File
@@ -12,8 +12,10 @@ function summarize(type: NodeType, config: Record<string, any>): string {
switch (type) {
case 'input':
return (config.fields ?? []).map((f: any) => f.name).filter(Boolean).join(' · ') || '未定义输入'
case 'output':
return (config.fields ?? []).map((f: any) => f.name).filter(Boolean).join(' · ') || '未定义成果'
case 'output': {
const names = (config.fields ?? []).map((f: any) => f.name).filter(Boolean).join(' · ')
return (config.contract ? '⚖ 出具契约 · ' : '') + (names || '未定义成果')
}
case 'llm':
return first(config.prompt, config.system) || '未填提示'
case 'agent': {
@@ -40,6 +42,8 @@ function summarize(type: NodeType, config: Record<string, any>): string {
return first(config.title) || '等待人工'
case 'validate':
return 'JSON Schema 校验' + (config.repair_with_llm ? ' · 失败自动返工' : '')
case 'metrics':
return `${config.caliber || '口径'}@${config.caliber_version || 'v1'} · ${(config.metrics ?? []).length} 个指标`
case 'transform':
return first(config.expression, config.template) || '未配置'
case 'subgraph':
+31 -5
View File
@@ -1,13 +1,13 @@
import {
Bot, Braces, Brain, CheckCircle2, Code2, Database, FileInput, FileOutput,
GitBranch, Hand, Repeat, Search, Shuffle, Users, Wrench,
Gauge, GitBranch, Hand, Repeat, Search, Shuffle, Users, Wrench,
} from 'lucide-react'
import type { NodeType } from '../types'
export type FieldType =
| 'text' | 'textarea' | 'prompt' | 'code' | 'number' | 'select' | 'switch'
| 'json' | 'model' | 'tools' | 'skills' | 'collection'
| 'ioFields' | 'cases' | 'agents'
| 'ioFields' | 'cases' | 'agents' | 'metricsList'
export interface FieldDef {
key: string
@@ -92,10 +92,18 @@ export const NODE_DEFS: Record<NodeType, NodeDef> = {
defaults: { fields: [{ name: 'question', required: true }] },
},
output: {
type: 'output', label: '成果', category: '起止', icon: FileOutput,
description: '收集最终结构化成果',
type: 'output', label: '成果 / 出具', category: '起止', icon: FileOutput,
description: '收集结构化成果;配出具契约后升级为三档出具',
hasTarget: true, sources: [],
fields: [{ key: 'fields', label: '成果字段', type: 'ioFields' }],
fields: [
{ key: 'fields', label: '成果字段', type: 'ioFields' },
{
key: 'contract', label: '出具契约', type: 'json', advanced: true,
help: '{"metrics_from":["口径卡节点id"],"narrative":"{{ vars.report }}",'
+ '"required":[…],"expected":[…],"allow_numbers":[…],"strict":false}。'
+ '配了之后叙述里的每个数字必须能回指指标集,否则降档或不予出具',
},
],
defaults: { fields: [{ name: '结果', value: '{{ last_message }}' }] },
},
llm: {
@@ -364,6 +372,24 @@ export const NODE_DEFS: Record<NodeType, NodeDef> = {
schema: { type: 'object', properties: {}, required: [] },
},
},
metrics: {
type: 'metrics', label: '口径卡', category: '把关', icon: Gauge,
description: '受控指标集:所有算术在这里发生,叙述层只能引用',
hasTarget: true, sources: [{ id: 'out', label: '' }],
fields: [
{ key: 'caliber', label: '口径名称', type: 'text', placeholder: '周报口径' },
{ key: 'caliber_version', label: '口径版本', type: 'text', placeholder: 'v1' },
{ key: 'metrics', label: '指标定义', type: 'metricsList' },
{
key: 'assign_to', label: '结果存为变量', type: 'text',
help: '叙述节点用 {{ nodes.节点id.text }} 引用指标清单',
},
],
defaults: {
caliber: '', caliber_version: 'v1',
metrics: [{ id: 'total', name: '', unit: '', expression: '' }],
},
},
}
export const NODE_CATEGORIES = ['起止', '模型', '执行', '控制', '上下文', '把关']
+1
View File
@@ -117,6 +117,7 @@ pre,
.nt-retrieve { --nt: #0ea5e9; }
.nt-transform { --nt: #8b949e; }
.nt-subgraph { --nt: #bc8cff; }
.nt-metrics { --nt: #34d399; }
/* --------------------------------------------------------------------------
React Flow 覆盖
+33 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { History, RefreshCw, Trash2 } from 'lucide-react'
import { GitFork, History, RefreshCw, Trash2 } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../api/client'
import { formatMs } from '../canvas/NodeCard'
@@ -53,6 +53,12 @@ export function RunsPage() {
>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-[12px]">{run.workflow_name || '临时图'}</span>
{run.run_class === 'formal' && (
<span className="chip" style={{ color: 'var(--ok)', borderColor: 'var(--ok)' }}>正式</span>
)}
{(run.output as any)?._issuance?.tier === 'withheld' && (
<span className="chip" style={{ color: 'var(--err)' }}>不予出具</span>
)}
<StatusDot status={run.status} />
</div>
<div className="flex gap-2 text-[10px] text-faint">
@@ -72,10 +78,34 @@ export function RunsPage() {
<div className="p-4">
<div className="mb-3 flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">{selected.workflow_name || '临时图'}</div>
<div className="mono text-[10.5px] text-faint">{selected.id}</div>
<div className="flex items-center gap-2 text-sm font-semibold">
{selected.workflow_name || '临时图'}
{selected.run_class === 'formal'
? <span className="chip" style={{ color: 'var(--ok)', borderColor: 'var(--ok)' }}>正式 v{selected.version}</span>
: <span className="chip">探索性</span>}
</div>
<div className="mono text-[10.5px] text-faint">
{selected.id}
{selected.version_hash && ` · 版本 ${selected.version_hash.slice(0, 10)}`}
{selected.manifest_hash && ` · 清单 ${selected.manifest_hash.slice(0, 10)}`}
{selected.started_by && ` · by ${selected.started_by}`}
</div>
</div>
<StatusDot status={selected.status} />
{selected.run_class !== 'formal' && (
<button
className="btn btn-sm"
title="把这次实际走过的路径提取成草稿模板(探索层 → 模板层)"
onClick={async () => {
try {
const res = await api.copilot.fromRun(selected.id)
toast(`已提取为草稿「${res.name}」(${res.nodes} 节点,剪掉 ${res.dropped_nodes} 个未走节点)`, 'ok')
} catch (e: any) { toast(e.message ?? '提取失败', 'error') }
}}
>
<GitFork size={11} /> 提取模板
</button>
)}
<button
className="btn btn-sm btn-ghost"
onClick={async () => {
+16
View File
@@ -361,6 +361,22 @@ function PrefsTab() {
</select>
</div>
<div>
<div className="mb-2 text-sm font-semibold">操作者署名</div>
<label className="label">名字(发布、审批、发起正式运行会记到这个名下)</label>
<input
className="field max-w-60"
defaultValue={(() => { try { return localStorage.getItem('agentlab_actor') ?? '' } catch { return '' } })()}
placeholder="例如 yilun"
onChange={(e) => {
try { localStorage.setItem('agentlab_actor', e.target.value.trim()) } catch { /* noop */ }
}}
/>
<div className="mt-1 text-[10px] text-faint">
本地署名,随请求头 X-Actor 发送。这是归属记录不是身份认证——多人环境需要真正的登录体系。
</div>
</div>
<div>
<div className="mb-2 text-sm font-semibold">运行默认值</div>
<label className="label">默认记忆作用域</label>
+86 -1
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
AlertTriangle, Check, ChevronDown, Copy, LayoutGrid, Plus, Save, Sparkles, Trash2, Wand2,
AlertTriangle, Check, ChevronDown, Copy, LayoutGrid, Plus, Save, ShieldCheck, Sparkles, Trash2, Wand2,
} from 'lucide-react'
import clsx from 'clsx'
import { api } from '../api/client'
@@ -27,6 +27,7 @@ export function StudioPage() {
const [picker, setPicker] = useState(false)
const [copilot, setCopilot] = useState(false)
const [saving, setSaving] = useState(false)
const [publishing, setPublishing] = useState(false)
// 首次进来自动打开第一张图
useEffect(() => {
@@ -99,6 +100,15 @@ export function StudioPage() {
<ChevronDown size={12} className="text-faint" />
</button>
{workflow?.status === 'governed' && (
<span className="chip" style={{ color: 'var(--ok)', borderColor: 'var(--ok)' }}>
受管 v{workflow.published_version}
</span>
)}
{workflow?.status === 'published' && (
<span className="chip" style={{ color: 'var(--ok)' }}>已发布 v{workflow.published_version}</span>
)}
{(!workflow?.status || workflow.status === 'draft') && <span className="chip">草稿</span>}
{dirty && <span className="chip" style={{ color: 'var(--warn)' }}>未保存</span>}
{errorCount > 0 && (
<span className="chip" style={{ color: 'var(--err)', borderColor: 'var(--err)' }}>
@@ -118,6 +128,10 @@ export function StudioPage() {
<Wand2 size={12} /> Copilot
</button>
<button className="btn" onClick={relayout} title="自动排版"><LayoutGrid size={12} /></button>
<button className="btn" onClick={() => setPublishing(true)} disabled={!workflow || dirty}
title={dirty ? '先保存再发布' : '把当前版本立为正式版本,正式运行只认它'}>
<ShieldCheck size={12} /> 发布
</button>
<button className="btn btn-primary" onClick={doSave} disabled={saving || !dirty}>
{saving ? <Spinner /> : <Save size={12} />} 保存
</button>
@@ -145,6 +159,10 @@ export function StudioPage() {
<WorkflowPicker open={picker} onClose={() => setPicker(false)} />
<CopilotModal open={copilot} onClose={() => setCopilot(false)} />
{publishing && workflow && (
<PublishModal workflow={workflow} onClose={() => setPublishing(false)}
onDone={() => { setPublishing(false); void refresh() }} />
)}
</div>
)
}
@@ -259,6 +277,73 @@ function WorkflowPicker({ open, onClose }: { open: boolean; onClose: () => void
// -------------------------------------------------------------------------
function PublishModal({ workflow, onClose, onDone }: {
workflow: Workflow; onClose: () => void; onDone: () => void
}) {
const toast = useToast()
const load = useStudio((s) => s.load)
const [level, setLevel] = useState<'published' | 'governed'>('published')
const [busy, setBusy] = useState(false)
const [issues, setIssues] = useState<any[] | null>(null)
const publish = async () => {
setBusy(true)
setIssues(null)
try {
const res = await api.workflows.publish(workflow.id, level)
setIssues(res.issues ?? [])
if (res.ok) {
toast(`已发布 v${res.version}(${level === 'governed' ? '受管' : '正式'})`, 'ok')
const fresh = await api.workflows.get(workflow.id)
load(fresh)
onDone()
} else {
toast('发布被门禁拦下,看问题列表', 'error')
}
} catch (e: any) {
toast(e.message ?? '发布失败', 'error')
} finally {
setBusy(false)
}
}
return (
<Modal open onClose={onClose} title={`发布「${workflow.name}」v${workflow.version}`}
footer={<>
<button className="btn" onClick={onClose}>取消</button>
<button className="btn btn-primary" onClick={publish} disabled={busy}>
{busy ? <Spinner /> : <ShieldCheck size={12} />} 发布
</button>
</>}>
<div className="mb-3 grid grid-cols-2 gap-1.5">
<button onClick={() => setLevel('published')}
className={level === 'published' ? 'rounded-lg border border-[var(--accent)] px-3 py-2 text-left' : 'rounded-lg border px-3 py-2 text-left hover:bg-hover'}>
<div className="text-[12px] font-medium">正式(published)</div>
<div className="text-[10.5px] text-faint">基础校验通过即可;正式运行从此版本发起</div>
</button>
<button onClick={() => setLevel('governed')}
className={level === 'governed' ? 'rounded-lg border border-[var(--accent)] px-3 py-2 text-left' : 'rounded-lg border px-3 py-2 text-left hover:bg-hover'}>
<div className="text-[12px] font-medium">受管(governed)</div>
<div className="text-[10.5px] text-faint">出具级门禁:禁 supervisor、方法卡必须钉版、必须有出具契约</div>
</button>
</div>
<div className="text-[10.5px] leading-relaxed text-faint">
发布把当前版本立为不可变的正式版本。之后画布上继续改不影响它——正式运行永远执行发布时的快照。
</div>
{issues && !!issues.length && (
<div className="mt-3 space-y-1">
{issues.map((issue, i) => (
<div key={i} className="text-[10.5px]"
style={{ color: issue.level === 'error' ? 'var(--err)' : 'var(--warn)' }}>
{issue.level === 'error' ? '✕' : '!'} {issue.message}
</div>
))}
</div>
)}
</Modal>
)
}
function CopilotModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const toast = useToast()
const nodes = useStudio((s) => s.nodes)
+96 -12
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
AlertTriangle, Ban, Check, ChevronRight, CircleDot, Hand, Play, Square, Wrench,
AlertTriangle, Ban, Check, ChevronRight, CircleDot, Hand, Play, ShieldCheck, Square, Wrench,
} from 'lucide-react'
import clsx from 'clsx'
import { api } from '../api/client'
@@ -41,6 +41,9 @@ export function RunPanel() {
{run && (
<div className="flex items-center justify-between gap-2 border-t px-3 py-1.5 text-[10.5px] text-faint">
<span className="mono truncate" title={run.id}>#{run.id.slice(0, 8)}</span>
{run.run_class === 'formal'
? <span className="chip" style={{ color: 'var(--ok)', borderColor: 'var(--ok)' }}>正式 v{run.version}</span>
: <span className="chip">探索</span>}
<span className="flex items-center gap-2">
{streaming && <span className="text-[var(--accent)]">● 实时</span>}
{!!run.usage?.total_tokens && <span>{run.usage.total_tokens} tok</span>}
@@ -60,7 +63,11 @@ function RunLauncher() {
const run = useStudio((s) => s.run)
const streaming = useStudio((s) => s.streaming)
const issues = useStudio((s) => s.issues)
const { startRun, stopRun } = useStudio()
const workflow = useStudio((s) => s.workflow)
const dirty = useStudio((s) => s.dirty)
const { startRun, startFormalRun, stopRun } = useStudio()
const canFormal = !!workflow && (workflow.status === 'published' || workflow.status === 'governed')
&& !!workflow.published_version
const toast = useToast()
const [values, setValues] = useState<Record<string, string>>({})
const [busy, setBusy] = useState(false)
@@ -73,7 +80,7 @@ function RunLauncher() {
const errors = issues.filter((i) => i.level === 'error')
const launch = async () => {
const launch = async (formal = false) => {
setBusy(true)
try {
const payload: Record<string, any> = {}
@@ -87,7 +94,8 @@ function RunLauncher() {
payload[field.name] = raw
}
}
await startRun(payload)
if (formal) await startFormalRun(payload)
else await startRun(payload)
} catch (e: any) {
toast(e.message ?? '启动失败', 'error')
} finally {
@@ -132,15 +140,32 @@ function RunLauncher() {
<Square size={12} /> 停止
</button>
) : (
<button
className="btn btn-primary flex-1 justify-center"
onClick={launch}
disabled={busy || !!errors.length || !nodes.length}
>
<Play size={12} /> 运行
</button>
<>
<button
className="btn btn-primary flex-1 justify-center"
onClick={() => launch(false)}
disabled={busy || !!errors.length || !nodes.length}
title="用画布当前内容跑,结果标探索性"
>
<Play size={12} /> 试运行
</button>
{canFormal && (
<button
className="btn flex-1 justify-center"
style={{ borderColor: 'var(--ok)', color: 'var(--ok)' }}
onClick={() => launch(true)}
disabled={busy || dirty}
title={dirty ? '有未保存改动,正式运行只跑已发布版本' : `从已发布的 v${workflow?.published_version} 不可变版本发起`}
>
<ShieldCheck size={12} /> 正式运行 v{workflow?.published_version}
</button>
)}
</>
)}
</div>
{canFormal && dirty && (
<div className="mt-1.5 text-[10px] text-faint">画布有未保存改动;正式运行永远执行已发布的不可变版本</div>
)}
</div>
)
}
@@ -345,6 +370,8 @@ const EVENT_META: Record<string, { text: string; tone: keyof typeof TONE }> = {
'human.requested': { text: '请求人工', tone: 'warn' },
'human.resolved': { text: '人工已回复', tone: 'ok' },
'log': { text: '日志', tone: 'dim' },
'issuance': { text: '出具判定', tone: 'warn' },
'caliber.upgrade': { text: '口径升版', tone: 'warn' },
}
function describe(event: RunEvent): string {
@@ -372,6 +399,15 @@ function describe(event: RunEvent): string {
return String(d.message ?? '')
case 'human.requested':
return String(d.title ?? d.tool ?? '')
case 'issuance': {
const tier = { formal: '正式出具', degraded: '降档出具', withheld: '不予出具' }[d.tier as string] ?? d.tier
const parts = [tier]
if (d.missing_required?.length) parts.push(`缺必需指标 ${d.missing_required.join(',')}`)
if (d.unmatched) parts.push(`${d.unmatched} 个数字无法回指`)
return parts.join(' · ')
}
case 'caliber.upgrade':
return `方法卡 v${d.pinned} → v${d.latest} 已有新版,处置:${d.policy_label ?? d.policy}`
case 'node.finished': {
if (!d.preview) return ''
const text = typeof d.preview === 'string' ? d.preview : JSON.stringify(d.preview)
@@ -410,9 +446,11 @@ function OutputView() {
return <Empty title="还没有成果" hint="运行完成后,output 节点收集的结构化结果会显示在这里。" />
}
const issuance = (output as any)._issuance
return (
<div className="space-y-3 p-3">
{Object.entries(output).map(([key, value]) => (
{issuance && <IssuanceBanner issuance={issuance} runClass={run?.run_class} />}
{Object.entries(output).filter(([k]) => k !== '_issuance').map(([key, value]) => (
<div key={key}>
<div className="label">{key}</div>
<div className="whitespace-pre-wrap break-words rounded border bg-bg p-2 text-[11.5px] leading-relaxed">
@@ -424,6 +462,52 @@ function OutputView() {
)
}
const TIER_META: Record<string, { label: string; color: string; hint: string }> = {
formal: { label: '正式出具', color: 'var(--ok)', hint: '指标齐全,叙述中所有数字均可回指口径卡' },
degraded: { label: '降档出具', color: 'var(--warn)', hint: '存在缺口,结论请对照下方声明使用' },
withheld: { label: '不予出具', color: 'var(--err)', hint: '必需指标缺失或数字无法溯源,本期结论不作数' },
}
function IssuanceBanner({ issuance, runClass }: { issuance: any; runClass?: string }) {
const meta = TIER_META[issuance.tier] ?? TIER_META.degraded
return (
<div className="rounded-lg border p-2.5" style={{ borderColor: meta.color }}>
<div className="flex items-center gap-2">
<span className="text-[12px] font-semibold" style={{ color: meta.color }}>
⚖ {meta.label}
</span>
{runClass === 'exploratory' && (
<span className="chip" style={{ color: 'var(--warn)' }}>探索性运行 · 不进正式归档</span>
)}
<span className="ml-auto text-[10px] text-faint">
回指 {issuance.matched_numbers ?? 0} 个数字 / 核对 {issuance.metrics_checked ?? 0} 个指标
</span>
</div>
<div className="mt-1 text-[10.5px] text-faint">{meta.hint}</div>
{(issuance.calibers ?? []).map((c: any) => (
<div key={c.node} className="mt-1 text-[10.5px] text-dim">
口径:{c.caliber} @ {c.version}
</div>
))}
{!!issuance.missing_required?.length && (
<div className="mt-1 text-[10.5px]" style={{ color: 'var(--err)' }}>
缺必需指标:{issuance.missing_required.join('、')}
</div>
)}
{!!issuance.missing_expected?.length && (
<div className="mt-1 text-[10.5px]" style={{ color: 'var(--warn)' }}>
缺数据声明:{issuance.missing_expected.join('、')} 本期缺失
</div>
)}
{!!issuance.unmatched_numbers?.length && (
<div className="mt-1 text-[10.5px]" style={{ color: 'var(--warn)' }}>
无法回指的数字:{issuance.unmatched_numbers.map((u: any) => u.token).join('、')}
</div>
)}
</div>
)
}
function RawEvents() {
const events = useStudio((s) => s.events)
if (!events.length) return <Empty title="暂无事件" />
+22
View File
@@ -93,6 +93,7 @@ interface StudioState {
validate: () => Promise<void>
startRun: (input: Record<string, any>) => Promise<Run | null>
startFormalRun: (input: Record<string, any>) => Promise<Run | null>
attachRun: (runId: string) => Promise<void>
stopRun: () => Promise<void>
clearRun: () => void
@@ -251,6 +252,27 @@ export const useStudio = create<StudioState>((set, get) => ({
}
},
startFormalRun: async (input) => {
const { workflow } = get()
if (!workflow) return null
get().unsubscribe?.()
set({ events: [], runtime: {}, activeEdges: [], run: null })
try {
// 正式运行不传 graph:后端只认已发布的不可变版本
const run = await api.runs.start({
workflow_id: workflow.id,
run_class: 'formal',
input,
})
set({ run, streaming: true })
await get().attachRun(run.id)
return run
} catch (e) {
set({ streaming: false })
throw e
}
},
attachRun: async (runId) => {
get().unsubscribe?.()
const stop = streamRun(
+8 -1
View File
@@ -1,7 +1,7 @@
export type NodeType =
| 'input' | 'output' | 'llm' | 'agent' | 'supervisor' | 'tool' | 'code'
| 'branch' | 'loop' | 'subgraph' | 'memory' | 'retrieve' | 'transform'
| 'human' | 'validate'
| 'human' | 'validate' | 'metrics'
export interface GraphNode {
id: string
@@ -34,6 +34,8 @@ export interface Workflow {
tags: string[]
version: number
is_template: boolean
status?: 'draft' | 'published' | 'governed'
published_version?: number | null
run_count?: number
created_at?: string
updated_at?: string
@@ -51,6 +53,11 @@ export interface Run {
output: Record<string, any>
error: string | null
usage: Record<string, any>
run_class?: 'formal' | 'exploratory'
version?: number | null
version_hash?: string | null
manifest_hash?: string | null
started_by?: string | null
created_at?: string
started_at?: string
finished_at?: string