mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 18:41:59 +00:00
发布门禁、操作者归属、治理遥测与前端支撑
审批记录批准人(X-Actor 先落库再恢复执行)。设置页可配操作者署名, 随请求头发送——这是归属记录不是身份认证,界面上写明了区别。 治理遥测三件套,全部从既有事件流取材: - 工具白名单衰减报表:授了权但从未用过的工具摆上台面,放宽可被回收 - 探索问题聚类:同一问题反复出现(≥3 次)标记为晋升候选——模板生长 的触发器 - 轨迹→模板提取器:把探索性运行实际走过的路径确定性提取成草稿工作流 (剪掉未走的分支、输入参数化),人审发布后才成为模板——探索层的 价值在路径沉淀,不在单次答案 前端:口径卡节点与指标编辑器、出具契约配置、发布对话框(两级门禁、 问题列表)、试运行/正式运行双按钮、出具档位横幅(含缺数据声明与 未回指数字点名)、运行列表正式/探索徽标、运行详情版本与清单哈希、 提取模板按钮。 真模型验证闭环:周报模板 governed 发布 → formal 运行,Opus 5 正文 数字全部回指,但结尾'(约 100 字)'被校验器抓住降档——修 prompt 禁元话语而非加白名单,重发 v2 后正式档。8/8 模板回归通过。
This commit is contained in:
@@ -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 校验与自动返工 / 口径卡(受控指标集) |
|
||||
|
||||
节点之间用模板语法传数据:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 的问题反复出现,考虑用「从运行提取模板」晋升为固定节",
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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=[
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 = ['起止', '模型', '执行', '控制', '上下文', '把关']
|
||||
|
||||
@@ -117,6 +117,7 @@ pre,
|
||||
.nt-retrieve { --nt: #0ea5e9; }
|
||||
.nt-transform { --nt: #8b949e; }
|
||||
.nt-subgraph { --nt: #bc8cff; }
|
||||
.nt-metrics { --nt: #34d399; }
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
React Flow 覆盖
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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="暂无事件" />
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user