import { useEffect, useMemo, useRef, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { ChevronDown, Play } from 'lucide-react' import clsx from 'clsx' import { api } from '../api/client' import { NODE_DEFS } from '../canvas/nodeDefs' import { hintOf } from '../canvas/shortcuts' import { isComposing, Kbd, Skeleton, Spinner, toast } from '../components/ui' import { useLocalActor } from '../lib/actor' import { errorMessage } from '../lib/errors' import { explainStartError } from '../lib/explain' import { matchShortcut } from '../lib/keys' import { runClassLabel } from '../lib/terms' import { useStudio, type FlowNode } from '../store/studio' import type { ValidationIssue, WorkflowVersion } from '../types' import { ClassDot, escapeRun, RunCapsule } from './RunHud' import { isActivePhase } from './trace' /** * 发起就被拒。绑定的工具在本机不存在(run_tool_missing)时给直达入口:数据源工具去数据页接入, * 自定义、MCP 工具去工具页——这次运行还没开始,没有「接着跑」可给。别的照通用的出错提示 */ function startFailed(e: unknown, navigate: (to: string) => void) { const x = explainStartError(e) if (!x?.fixTo) { toast.error(e) return } const to = x.fixTo toast.error(`${x.title}:${x.reason ?? ''}${x.action ?? ''}`, { detail: x.raw, key: 'studio:run-tool-missing', action: { label: x.fixLabel ?? '去接入', onClick: () => navigate(to) }, }) } /** * 工具栏上的运行控件。 * * 从右栏顶部搬上来的。原先它和 Copilot 输入框一起常驻在助手栏里,两个都是 * "主要动作",互相压着——而它们其实是两种不同的意图(改这张图 / 跑这张图), * 不该争同一块地方。运行是对整个文档的动作,和保存、发布是一类,属于工具栏。 * * 有了运行之后,这里多一枚运行胶囊(run/RunHud.tsx):跑的时候它就是全部—— * 计时、进度、用量、停止都在里面;结束了它收成结果,旁边再露出发起按钮。 * 相位一律读 store 的 runPhase(由事件推出),不再读启动时那份 run 快照: * 那份快照的 status 永远是 queued,失败后「接着跑」出不来、续跑完「停止」卡死。 * * 两个入口互不牵连: * - 「运行」用画布当前内容发起探索运行,画布有错误时不能点; * - 「正式运行 vN」跑已发布的不可变版本,只看 published_version,和画布上的 * 错误、未保存改动都无关(改了只提示「不含画布改动」),表单字段取那一版的。 */ type Field = { name: string; required?: boolean; description?: string; default?: unknown; example?: unknown } /** 已发布版本的入口字段,按 (工作流, 版本) 缓存:发布之后那一版就不会再变 */ const versionFields = new Map() function fieldsOfGraph(graph: WorkflowVersion['graph']): Field[] { const entry = graph?.nodes?.find((n) => n.type === 'input') return ((entry?.data?.config?.fields ?? []) as Field[]).filter((f) => f?.name) } const shown = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : JSON.stringify(v)) function payloadOf(fields: Field[], values: Record): Record { const payload: Record = {} for (const field of fields) { // 留空就用默认值。占位符里写着「默认:…」,照它说的办 const typed = values[field.name] const raw = typed != null && typed.trim() !== '' ? typed : field.default ?? '' // 看起来像 JSON 就按 JSON 传,让数组/对象类型的输入能用 if (typeof raw === 'string' && /^\s*[[{]/.test(raw)) { try { payload[field.name] = JSON.parse(raw) } catch { payload[field.name] = raw } } else { payload[field.name] = raw } } return payload } function FieldsForm({ fields, values, onChange, onSubmit }: { fields: Field[] values: Record onChange: (v: Record) => void onSubmit: () => void }) { return ( <> {fields.map((field, i) => (