From 2f6483d306e93ed06f42b18d201db50d8e75c253 Mon Sep 17 00:00:00 2001 From: 2233admin <2233admin@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:41:15 +0800 Subject: [PATCH 1/2] chore: add gstack skill routing rules to CLAUDE.md --- CLAUDE.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index f9f15ec..781769b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,3 +24,22 @@ ### Domain docs 采用 single-context:根目录 `CONTEXT.md` 是领域词汇表,`docs/adr/` 保存架构决策。See `docs/agents/domain.md`. + +## Skill routing + +When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill. + +Key routing rules: +- Product ideas/brainstorming → invoke /office-hours +- Strategy/scope → invoke /plan-ceo-review +- Architecture → invoke /plan-eng-review +- Design system/plan review → invoke /design-consultation or /plan-design-review +- Full review pipeline → invoke /autoplan +- Bugs/errors → invoke /investigate +- QA/testing site behavior → invoke /qa or /qa-only +- Code review/diff check → invoke /review +- Visual polish → invoke /design-review +- Ship/deploy/PR → invoke /ship or /land-and-deploy +- Save progress → invoke /context-save +- Resume context → invoke /context-restore +- Author a backlog-ready spec/issue → invoke /spec From 79d26622af1edef0580b8c7665e04b30028658f7 Mon Sep 17 00:00:00 2001 From: 2233admin <2233admin@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:10:14 +0800 Subject: [PATCH 2/2] feat(studio): integrate governed sources and workflow authoring --- .env.example | 9 + DESIGN.md | 81 +- backend/api/v1/identity.py | 2 + backend/api/v1/workflows.py | 13 + backend/api/v1/workspaces.py | 55 + ...7k8l9_repair_plugin_installations_table.py | 60 ++ backend/schemas/workflow.py | 21 + backend/security/identity.py | 38 +- backend/workflow/capability_projection.py | 24 + backend/workflow/opencli_adapter_nodes.py | 54 +- docs/backend-architecture-consolidated.md | 6 +- docs/backend-capability-exposure-matrix.yaml | 20 +- frontend/.env.example | 12 + frontend/app/(app)/sources/page.tsx | 405 +++++--- frontend/app/flow-canvas.css | 154 ++- frontend/app/globals.css | 12 + frontend/app/login/page.tsx | 3 +- frontend/components/auth/auth-provider.tsx | 12 +- frontend/components/flow/command-palette.tsx | 690 +++++++++++-- frontend/components/flow/command-strip.tsx | 2 +- .../components/flow/edges/editable-edge.tsx | 1 + .../components/flow/edges/routed-edge.tsx | 1 + .../components/flow/edges/workflow-edge.tsx | 1 + frontend/components/flow/inspector-shell.tsx | 144 ++- frontend/components/flow/inspector.tsx | 975 ++++++++++++++---- .../components/flow/node-context-menu.tsx | 27 +- .../components/flow/nodes/workflow-node.tsx | 85 +- .../flow/workflow-canvas-geometry.ts | 40 + .../flow/workflow-canvas-interactions.ts | 112 +- .../flow/workflow-canvas-surface.tsx | 62 +- .../flow/workflow-editor-overlays.tsx | 7 - .../flow/workflow-editor-selectors.ts | 3 + frontend/components/flow/workflow-editor.tsx | 201 +++- .../flow/workflow-keyboard-shortcuts.ts | 18 +- .../flow/workflow-node-menu-actions.ts | 15 +- frontend/components/shell/app-header.tsx | 18 +- .../components/shell/global-agent-dock.tsx | 43 +- frontend/lib/api/endpoints.ts | 47 +- frontend/lib/api/hooks.ts | 50 + frontend/lib/api/types.ts | 48 + frontend/lib/auth/oidc.ts | 13 +- frontend/lib/auth/types.ts | 3 +- frontend/lib/flow/graph.ts | 107 +- frontend/lib/flow/store-canonical-actions.ts | 12 + frontend/lib/flow/store-layout-actions.ts | 24 +- frontend/lib/flow/store-slices.ts | 135 ++- frontend/lib/flow/store.ts | 60 +- .../backend-node-capability-adapter.ts | 5 +- .../workflow/backend-opencli-adapter-nodes.ts | 317 +++++- .../lib/workflow/business-node-experience.ts | 58 +- frontend/lib/workflow/node-catalog.ts | 60 +- frontend/lib/workflow/node-contracts.ts | 9 +- frontend/lib/workflow/node-i18n.ts | 199 ++++ frontend/lib/workflow/node-menu.ts | 32 +- frontend/lib/workflow/parameter-interface.ts | 129 ++- .../workflow/use-opencli-adapter-catalog.ts | 11 +- frontend/lib/workflow/workflow-outline.ts | 64 ++ frontend/next.config.mjs | 14 + .../scripts/check-login-theme-regressions.mjs | 24 + ...heck-navigation-transition-regressions.mjs | 15 + ...ck-node-capability-catalog-regressions.mjs | 291 +++++- .../scripts/check-workflow-regressions.mjs | 266 ++++- .../check-workspace-source-regressions.mjs | 16 + frontend/scripts/test-data-operator-nodes.mjs | 65 +- tests/integration/test_auth_api.py | 2 + ...st_legacy_native_intelligence_migration.py | 2 +- .../test_legacy_plugin_migration.py | 47 +- .../test_workflow_capabilities_api.py | 77 +- tests/unit/api/test_workspaces.py | 49 + tests/unit/security/test_identity.py | 28 + tests/unit/test_migration_heads.py | 2 +- 71 files changed, 4850 insertions(+), 857 deletions(-) create mode 100644 backend/migrations/versions/g4h5i6j7k8l9_repair_plugin_installations_table.py create mode 100644 frontend/lib/workflow/workflow-outline.ts create mode 100644 frontend/scripts/check-workspace-source-regressions.mjs diff --git a/.env.example b/.env.example index 5127b17..5859a8a 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,15 @@ CREDENTIAL_ENCRYPTION_KEY= # WS 反向通道(/api/v1/nodes/ws、/api/v1/browsers/agents/ws)两条握手路径。 API_AUTH_TOKEN= +# 组织身份验证(标准 OpenID Connect;提供方可为 Gitea、Keycloak、Authentik、 +# Azure AD 或任何兼容 OIDC 的企业身份源)。 +OIDC_ISSUER= +OIDC_AUDIENCE= +# 可选;留空时后端通过 /.well-known/openid-configuration 发现 jwks_uri。 +OIDC_JWKS_URL= +# 仅用于首次部署或紧急恢复,不替代正式 OIDC 登录。 +BOOTSTRAP_ADMIN_TOKEN= + # 服务端口(两种启动模式均生效) API_PORT=8031 # API 服务对外端口 # Frontend lives in C:\c\Users\Administrator\projects\open-cli-admin. diff --git a/DESIGN.md b/DESIGN.md index 83b3fbe..489b589 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -3,7 +3,7 @@ ## Source of truth - Status: Active -- Last refreshed: 2026-07-25 +- Last refreshed: 2026-07-27 - Primary product surfaces: 概览、任务与通知、项目、工作流编排、项目数据工作台、3D 逻辑与证据、插件中心、自动化与 Agent、成果与数据、执行资源、模型与连接。 - Authority order: 1. 本文档定义产品体验、信息架构和交互决策。 @@ -24,11 +24,18 @@ - `frontend/components/shell/app-shell.tsx` - `frontend/components/shell/app-sidebar.tsx` - `frontend/components/flow/workflow-editor.tsx` + - `frontend/components/flow/workflow-editor-overlays.tsx` + - `frontend/components/flow/workflow-keyboard-shortcuts.ts` - `frontend/components/flow/nodes/workflow-node.tsx` - `frontend/components/flow/command-palette.tsx` - `frontend/components/flow/inspector.tsx` + - `frontend/components/flow/inspector-shell.tsx` + - `frontend/components/flow/workflow-workbench-panel.tsx` - `frontend/lib/workflow/node-catalog.ts` - `frontend/lib/workflow/node-contracts.ts` + - `backend/plugins/capability_catalog.py` + - Dify official node documentation for Question Classifier, If-Else, Iteration, Agent, Tool, single-node testing, orchestration logic, and hotkeys. + - User-supplied Dify Learn screenshots for the node picker, Question Classifier inspector, branch “next step” actions, and provider-grouped tool picker. - Browser evidence for `/studio/workflow` at 1398 × 1288, including Normalize Items, Dedupe Items, Record Acceptance Gate, Record Sink, and Webhook Notify. ## Brand @@ -112,6 +119,7 @@ - Principle 5 — Progressive disclosure: 画布保持低噪声,参数进 Inspector,运行事件进 Trace,管理对象留在各自管理页面。 - Principle 6 — Recovery is part of the state: BLOCKED 和错误态必须同时给出原因、缺失对象和下一步动作。 - Principle 7 — OpenCLI shell, replaceable engines: 导航、权限、项目上下文、视觉 token 与状态语义由 OpenCLI 统一;Perspective、DuckDB、OpenTelemetry、Langfuse、OpenLineage 等成熟能力只通过适配器进入,不把第三方产品外壳直接复制进来。 +- Principle 8 — Borrow proven behavior, keep OpenCLI authority: 对 Dify 等成熟编排器,优先内化高频节点心智、专用编辑器、变量绑定、分支下一步和单节点调试等已验证行为;不复制品牌外壳、前端静态节点表或与 OpenCLI runtime/治理相冲突的实现。 - Tradeoffs: - 为提高信息密度可使用紧凑节点,但关键状态和正文不得牺牲可读性。 - 包降低主画布复杂度,但必须保留进入内部图、查看版本与派生自定义节点的路径。 @@ -146,6 +154,63 @@ - 新节点和新 UI 不再根据 L1-L4 深度硬贴角色标签;迁移期间旧四层图可读、可运行,但新建 Native 图应收敛到上述三种语义角色。 - Managed 外部执行包可以由 L2 自身作为运行权威;Native L2 包以内含的 L3 执行节点作为运行权威。 +## Dify workflow UX internalization + +- Meaning of “Copy”: + - Copy 指复制并内化经验证的任务模型和交互契约,不指直接复制 Dify 源码、品牌、像素样式或独立前端运行模型。 + - 每个借鉴行为必须落到 OpenCLI 的后端 Capability Catalog、typed ports、parameter interface、运行绑定、权限和 trace 契约;UI 不以节点 ID switch 或第二份静态清单伪造能力。 +- Directly internalize: + - 使用用户已熟悉的高频节点名和职责边界:Start/User Input、LLM、Knowledge Retrieval、Output/Answer、Agent、Question Classifier、If-Else、Human Input、Iteration、Loop、Code、Template、Parameter Extractor、Variable Aggregator/Assigner、Document Extractor、List Operator、HTTP Request、Tool。 + - 高频节点使用任务专用编辑器:分类器编辑“模型 + 输入变量 + 分类标题/描述”;If-Else 编辑“IF/ELIF/ELSE + AND/OR 条件组”;Iteration/Loop 编辑输入集合、内部 scope、并发/终止与失败策略;Agent 编辑策略、模型、工具、指令、上下文和安全上限。 + - Router/control 节点的每个输出分支同时具有稳定 port、可读标签和“添加下一步”入口;从某分支添加节点时必须保留 source handle,不让用户再手工补连线。 + - 输入字段优先从上游 typed variables 选择,支持搜索、来源节点、字段路径和类型检查;高级用户仍可输入表达式或打开原始 JSON,但原始 JSON 不是普通任务的默认编辑面。 + - 支持节点级试运行与“上次运行”:普通节点可提供测试输入,结果显示输入、输出、耗时、错误和 trace;Answer/End 等无意义的单节点运行显式不提供该动作。 + - 工具按真实 Provider 分组,并保留动作级选择;OpenCLI 预设、已安装插件、OpenAPI、Workflow-as-Tool、MCP 只有在后端目录存在对应来源时才展示。 +- Adapt for OpenCLI: + - Global Agent 是跨页面的协作者,可读取上下文并提交图变更 proposal;Agent Node 是工作流内有输入输出、运行边界、工具白名单和最大迭代数的可执行步骤。Global Agent 不替代需要精确、可测试、可审计的直接节点。 + - BLOCKED/需配置节点仍可加入草稿并完整编辑;试运行和发布保持阻断,并从节点配置直接跳到缺少的模型、连接、凭证、插件、知识库或执行资源。 + - Iteration/Loop 复用现有可展开 node scope;外层图继续保持可编译的有向结构,重复执行发生在节点内部,不以任意回边破坏当前 compiler/run 不变量。 + - 跨工作流复制节点时携带可移植配置和 typed contract;目标工作区的变量、知识库、模型、插件、凭证与连接必须重新解析,失效引用转为可恢复的 BLOCKED,不静默换成别的资源。 +- Do not copy: + - 不复制 Dify 导航、品牌、Marketplace 外壳、固定面板宽度或视觉 token。 + - 不引入只存在于前端的“看起来可用”节点,不隐藏 runtime readiness,不把凭证或权限错误伪装成普通参数错误。 + - 不让插件注入任意 React 前端;插件只能声明 schema、本地化文案、参数、端口、动作、状态和有限的 experience descriptor,由平台组件渲染。 + - 不把所有节点都做成独立定制页面。低频或简单能力先使用 typed schema 表单;只有任务复杂度和使用频率证明需要时才提升为专用编辑器。 +- Node experience levels: + - `dedicated`:高频或结构性节点的专用编辑器,首批覆盖 LLM、Agent、Knowledge Retrieval、Question Classifier、If-Else、Iteration、Loop、Code、Template、Parameter Extractor、Human Input、HTTP Request 与 Tool。 + - `schema`:由现有 parameter interface 自动生成的 typed form,适用于直接输入/输出、简单 transform、插件能力和长尾节点。 + - `advanced`:JSON/原始 contract/内部 binding,仅作为高级折叠区或降级路径。 + - experience level 和 editor key 应扩展在后端 `manifest.presentation` 中,由 catalog adapter 投影;前端按 editor key 选择平台内建 renderer,不按 capability ID 维护平行判断表。 +- Application-quality acceptance: + - `dedicated` 节点不能以一个通用 JSON textarea 作为主配置面;必须提供业务字段、合理默认值、输入来源、输出说明、空状态和就地校验。 + - Router/control 节点的分支标签、端口、画布连线、右侧大纲和运行 trace 必须使用同一个 branch ID。 + - 节点名称、说明、字段、空状态、错误和恢复动作同时提供中文与英文;稳定标识符保持英文。 + - UI 展示的端口、字段和动作必须来自同一后端能力投影,Agent proposal、人工编辑、校验器和 runtime 不得各自定义一套。 + +## Workflow right dock and outline + +- Role: + - 右侧 Dock 是画布的结构导航和节点工作区,不是第二个图编辑器,也不再把“参数面板”“画布工作台”“大纲”作为互相遮挡的独立浮层。 + - Dock 提供 `大纲 / 配置 / 上次运行 / Trace` 四个模式。未选节点时默认显示大纲;选择节点后打开配置;运行完成后可切换上次运行和 Trace,模式切换不改变画布选择。 + - `P` 只负责显示/隐藏右侧 Dock;不再因为没有选中节点而拒绝打开。关闭后焦点回到画布,重新打开恢复上次模式。 +- Outline projection: + - 大纲直接从当前 canonical graph、typed ports 和 node scope 派生,不持有第二份节点顺序或连接状态。 + - 顶层按入口节点开始,以稳定拓扑顺序展示;并行分支和 router branches 形成可折叠树;未连接、循环无效或无法到达的节点进入“未连接/需处理”分组,而不是从大纲消失。 + - Iteration、Loop 和 Package 显示为可展开 scope;展开只预览内部结构,进入 scope 才改变画布 breadcrumb。 + - 每行仅显示节点图标、业务名、能力/运行状态和问题计数。选中、hover、运行中、失败不能只靠颜色区分。 + - Router 行按 branch ID 展示分支标签;分支下方提供“添加下一步”,沿该 branch port 打开现有节点选择器。大纲和画布双向同步选择与定位。 +- Outline actions: + - 单击:选中并定位;双击或 Enter:打开配置;展开箭头:展开分支或内部 scope。 + - 行菜单只保留重命名、复制、删除、进入内部图/派生(适用时)和查看节点信息;不恢复此前删除的 Table、AI 对话、编辑此节点工具架动作。 + - 大纲拖拽首版只允许改变同级视觉排序/布局提示,不直接改边或运行语义;连接关系必须通过画布或明确的“添加下一步/连接到”动作修改。 +- Keyboard: + - `P` 开关 Dock;上下键移动大纲焦点;左右键折叠/展开;Enter 定位并打开配置;Escape 返回画布。 + - 大纲使用 tree/treeitem 或等价可访问语义,分支和 scope 声明层级、展开状态、选中状态与问题摘要。 +- Scale and layout: + - 桌面使用右侧停靠面板,与画布共享可用宽度;不再以多个 `absolute` 浮层堆叠覆盖。 + - 窄屏转为互斥 Sheet,保留四个模式和选中同步;打开 Sheet 时不重置画布 viewport。 + - 首版使用现有图数据生成稳定投影;超过 200 个可见 outline rows 时再启用虚拟化,未达到阈值不新增依赖。 + ## Visual language - Color: @@ -174,6 +239,8 @@ - Global Agent Dock:由 AppShell 持有会话,在所有产品页面从固定 Header 入口打开;当前路由和查询上下文随消息发送。读取动作可直接执行,所有写动作继续使用 proposal → diff → 人工确认,不再把“与 Agent 创建”作为独立创建模式。 - Canvas context action menu:右键不再直接展开节点全集,也不在菜单内嵌 DOP/primitive 多级目录。空白画布和节点都先提供短动作菜单:`添加节点`、`添加注释`、`测试运行`、`导入应用`;节点右键再以“当前节点”分组提供进入内部网络、选择流程分支、参数与节点信息。 - Node picker:由原来的“⌘K + 所有操作/节点混排”改为渐进式目录,顶部固定 `节点 / 工具 / 开始` 三个入口和当前入口专属搜索。`节点` 按业务/逻辑/数据/输出等真实 catalog 分类展示,`工具` 聚合 OpenCLI、插件与运行工具并提供来源筛选,`开始` 承载 AI 生成、导入应用和画布起始动作;目录项仍只消费后端能力投影与现有节点定义。 + - Dedicated node editors:在现有 Inspector/parameter interface 上增量增加平台内建 editor renderer;共享变量选择器、模型/资源选择器、branch editor、test-run result 和 recovery banner,不为每个节点复制完整面板 Shell。 + - Workflow right dock:复用现有 `Inspector`、`RunTracePanel`、selection store 和 node scope breadcrumb,将当前互斥浮层收敛为一个 Dock;新增 Outline 只派生结构投影,不创建新的图状态 store。 - Context-to-picker placement:右键位置只决定新节点落点;选择器自身保持居中、可滚动且不随画布边缘裁切。通过顶部“添加节点”打开时,落点为当前视口中心。 - Annotation action:`添加注释` 直接创建现有 `note` palette item,不再要求用户先进入完整节点目录;流程图形与分组容器仍留在节点选择器的辅助分类。 - Import action:`导入应用` 复用现有 Dify / n8n / canonical JSON / Mermaid 导入链路,不引入第二套解析逻辑。 @@ -203,6 +270,7 @@ - Keyboard/focus behavior: - 所有按钮、链接、节点菜单、Inspector tabs、对话框和画布工具必须有可见 `:focus-visible`。 - `Ctrl/Cmd+K` 打开全局命令,节点选择器提供键盘搜索、分组导航和 Escape 关闭。 + - `P` 在无选中节点时也可打开右侧 Dock 的大纲;大纲支持树形键盘导航,选择和焦点必须可区分。 - 右键动作菜单使用真实 button 和 menu 语义;打开节点选择器后焦点进入搜索框,Tab 可遍历页签和目录项,Enter 选择首个搜索结果,Escape 关闭并返回画布。 - 节点必须可被键盘选中;进入/退出内部图、打开参数和运行节点都要有非鼠标路径。 - 焦点不能被画布缩放、抽屉或路由转场吞掉。 @@ -220,6 +288,7 @@ - Supported breakpoints/devices: 桌面是完整编排目标;平板支持查看、选择、配置与运行;手机至少支持查看状态、处理 Inbox 和打开只读运行证据。 - Layout adaptations: - 桌面保留侧栏、项目头、画布、可开合 Inspector/Trace。 + - 桌面右侧只保留一个可开合 Dock;大纲、配置、上次运行和 Trace 在 Dock 内切换,不允许多个面板覆盖同一交互区域。 - 窄屏侧栏折叠为 rail;Inspector、Trace 和节点选择器转为互斥 Sheet,不与画布并排挤压。 - 画布节点不为适配屏宽而缩小关键文字;使用 viewport fit、平移和聚焦选中节点。 - 表格使用自身横向滚动;Shell 不产生页面级水平滚动。 @@ -234,6 +303,8 @@ - Disabled: 保留控件上下文,并用邻近说明或 Tooltip 解释具体前置条件。 - Offline/slow network: 保留最近一次已知状态并标记陈旧;保存与发布需要明确 pending/failed,不静默丢失编辑。 - Blocked capability: 节点仍可在草稿中查看和配置,但运行/发布入口显示缺少的插件、连接、凭证、权限或执行资源,并链接到正确管理面。 +- Dedicated editor fallback: 专用编辑器描述缺失或 renderer 不可用时回退到 typed schema form,并显示“使用通用配置界面”;不得回退为空面板、丢字段或自动修改节点配置。 +- Outline graph anomalies: 未连接、不可达、循环无效、端口失配和缺失节点定义在大纲内进入明确问题分组,并可定位到画布;不得为了生成树而丢弃异常节点。 - Locked package: 可改公开参数、可进入只读内部图;结构性编辑只能通过显式“派生为项目节点”,必须展示来源和差异。 - Run trace: `records` 是可路由输出;`rejected`、`metrics`、duplicate evidence 和 lineage 是可检查 trace artifacts,不在父级画布暴露成普通输出端口。 @@ -271,6 +342,7 @@ - 画布平移、缩放、拖拽和流式运行事件不得导致无关节点重挂载。 - 节点卡片只渲染摘要;大型 JSON、事件和工件按需在 Inspector/Trace 加载。 - 命令面板和目录搜索应在本地索引可用时即时响应,远端能力状态增量合并。 + - 大纲只订阅节点结构、选择和摘要状态;运行事件更新不得重建整个树。可见行超过 200 时再使用现有虚拟列表能力或轻量窗口化。 - Compatibility constraints: - 保留现有 WorkflowProject、节点目录、运行能力投影、端口契约和四段物理嵌套兼容校验,直到完成旧图迁移;产品文案和新图只使用三种显式语义角色。 - 中英文混排需保持布局稳定;新增用户可见文本必须进入现有工作流 i18n 边界或明确记录迁移债务。 @@ -280,6 +352,11 @@ - 真实业务工作流只有同时满足“非 fixture 数据运行、数据工作台可见实际记录、至少一条记录可检查来源/血缘、修改节点参数后可重跑并对比前后批次”才算业务跑通;缺少任一项只能算 runtime smoke。 - 浏览器 smoke 覆盖 `/plugins` 与 `/studio/workflow`;桌面基线使用约 1398 × 1288,并额外检查窄屏 Sheet/rail 行为。 - 视觉改动应保存前后截图或自动化证据;当前仓库尚无完整 Playwright/Cypress 视觉回归基线,这是已知验证缺口。 +- Delivery slices: + - Slice 1 — 结构闭环:统一右侧 Dock,完成 Outline 拓扑/分支/scope 投影、画布双向选择、`P` 开关和 branch “添加下一步”;补 Question Classifier 与 If-Else 专用编辑器,证明动态分支端口闭环。 + - Slice 2 — 高频应用:补 LLM、Agent、Knowledge Retrieval、Tool/HTTP、Template/Parameter/Variable 类专用编辑体验;工具目录按真实 OpenCLI/Plugin/OpenAPI/Workflow/MCP Provider 分组;接入 typed variable/resource picker。 + - Slice 3 — 运行闭环:补 Iteration/Loop 内部 scope、Human Input、单节点试运行、上次运行和可编辑测试变量;完成跨工作流复制后的资源重解析与 BLOCKED 恢复。 + - 每个 Slice 都必须保持 blocked 可编辑、中文/英文、同一 branch ID/port contract、无 frontend-only 节点,并通过 catalog regression、TypeScript、ESLint 与桌面/窄屏 browser smoke。 ## Open questions @@ -292,4 +369,6 @@ - [ ] Responsive / 手机端是否正式支持编辑工作流,还是定义为只读与运行处置?在确定前不得通过缩小字号强行塞入完整画布。 - [ ] Design system / `frontend/app/globals.css` 的 shadcn 语义色与 `docs/DESIGN_SYSTEM.md` 的 ops/primary/signal token 何时单轨化?新工作不得扩大现有漂移。 - [ ] i18n / 导航与 Studio 的中文硬编码何时迁移到统一语言资源?影响英文界面和插件本地化验收。 +- [ ] Workflow UX / `manifest.presentation` 的 experience descriptor 首版字段名和版本如何定义,使内建专用编辑器可声明但不把任意插件前端带入平台? +- [ ] Workflow UX / 跨工作流复制首版是否仅支持同一 OpenCLI 实例,还是同时定义跨实例剪贴板格式与兼容性报告? - [ ] Workbench engines / Perspective + DuckDB-Wasm 与 OpenTelemetry + Langfuse 的首个生产适配器边界、数据量阈值和许可证复核何时进入 ADR?当前页面只验证 OpenCLI 内的信息架构与真实数据交互。 diff --git a/backend/api/v1/identity.py b/backend/api/v1/identity.py index 635a679..362ad6f 100644 --- a/backend/api/v1/identity.py +++ b/backend/api/v1/identity.py @@ -19,6 +19,8 @@ async def read_identity( "subject": identity.subject, "email": identity.email, "name": identity.name, + "username": identity.username, + "picture": identity.picture, "is_platform_admin": identity.is_platform_admin, "auth_method": identity.auth_method, } diff --git a/backend/api/v1/workflows.py b/backend/api/v1/workflows.py index 0ac9d96..3455287 100644 --- a/backend/api/v1/workflows.py +++ b/backend/api/v1/workflows.py @@ -1,6 +1,7 @@ """WorkflowProject compile and runtime endpoints.""" import uuid +from typing import Literal from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response from sqlalchemy.ext.asyncio import AsyncSession @@ -132,6 +133,13 @@ def get_opencli_adapter_nodes( site: str | None = None, q: str | None = None, include_write: bool = Query(True, alias="includeWrite"), + access: Literal["read", "write"] | None = None, + capability: Literal["fetch", "store"] | None = None, + browser: bool | None = None, + preset_kind: workflow_schemas.WorkflowOpenCLIAdapterPresetKind + | None = Query(None, alias="presetKind"), + runtime_readiness: workflow_schemas.WorkflowOpenCLIAdapterReadiness + | None = Query(None, alias="runtimeReadiness"), limit: int = Query(2000, ge=1, le=5000), refresh: bool = False, ) -> ApiResponse[workflow_schemas.WorkflowOpenCLIAdapterNodesResponse]: @@ -142,6 +150,11 @@ def get_opencli_adapter_nodes( site=site, q=q, include_write=include_write, + access=access, + capability=capability, + browser=browser, + preset_kind=preset_kind, + runtime_readiness=runtime_readiness, limit=limit, refresh=refresh, ) diff --git a/backend/api/v1/workspaces.py b/backend/api/v1/workspaces.py index a397f85..1796948 100644 --- a/backend/api/v1/workspaces.py +++ b/backend/api/v1/workspaces.py @@ -4,7 +4,9 @@ from backend.database import get_db from backend.models.identity import User, Workspace, WorkspaceMembership, WorkspaceRole +from backend.models.workflow import Project from backend.schemas.common import ApiResponse +from backend.schemas.workflow_asset import ProjectRead from backend.schemas.workspace import ( WorkspaceCreate, WorkspaceCreatedRead, @@ -53,6 +55,59 @@ async def _get_or_create_user( return user +@router.get( + "/governance/workspaces", + response_model=ApiResponse[list[WorkspaceRead]], +) +async def list_accessible_workspaces( + identity: RequestIdentity = Depends(get_request_identity), + db: AsyncSession = Depends(get_db), +) -> ApiResponse: + rows = ( + ( + await db.execute( + select(Workspace) + .join(WorkspaceMembership, WorkspaceMembership.workspace_id == Workspace.id) + .join(User, User.id == WorkspaceMembership.user_id) + .where( + User.subject == identity.subject, + User.disabled.is_(False), + Workspace.active.is_(True), + ) + .order_by(Workspace.name) + ) + ) + .scalars() + .all() + ) + return ApiResponse.ok([WorkspaceRead.model_validate(row) for row in rows]) + + +@router.get( + "/governance/workspaces/{workspace_id}/projects", + response_model=ApiResponse[list[ProjectRead]], +) +async def list_governance_projects( + workspace_id: str, + identity: RequestIdentity = Depends(get_request_identity), + db: AsyncSession = Depends(get_db), +) -> ApiResponse: + access = await get_workspace_access(db, workspace_id, identity) + require_permission(access, WorkspacePermission.READ) + rows = ( + ( + await db.execute( + select(Project) + .where(Project.workspace_id == workspace_id, Project.archived.is_(False)) + .order_by(Project.updated_at.desc()) + ) + ) + .scalars() + .all() + ) + return ApiResponse.ok([ProjectRead.model_validate(row) for row in rows]) + + @router.post( "/platform/workspaces", response_model=ApiResponse[WorkspaceCreatedRead], diff --git a/backend/migrations/versions/g4h5i6j7k8l9_repair_plugin_installations_table.py b/backend/migrations/versions/g4h5i6j7k8l9_repair_plugin_installations_table.py new file mode 100644 index 0000000..8406dec --- /dev/null +++ b/backend/migrations/versions/g4h5i6j7k8l9_repair_plugin_installations_table.py @@ -0,0 +1,60 @@ +"""repair a missing plugin installation registry table + +Revision ID: g4h5i6j7k8l9 +Revises: f3g4h5i6j7k8 +Create Date: 2026-07-26 +""" + +import sqlalchemy as sa +from alembic import context, op + +revision = "g4h5i6j7k8l9" +down_revision = "f3g4h5i6j7k8" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + if context.is_offline_mode(): + return + + if sa.inspect(op.get_bind()).has_table("plugin_installations"): + return + + op.create_table( + "plugin_installations", + sa.Column("provider_key", sa.String(length=257), nullable=False), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("author", sa.String(length=128), nullable=False), + sa.Column("version", sa.String(length=64), nullable=False), + sa.Column("source_kind", sa.String(length=32), nullable=False), + sa.Column("source_digest", sa.String(length=64), nullable=False), + sa.Column("manifest_spec_version", sa.String(length=32), nullable=False), + sa.Column("signature_state", sa.String(length=32), nullable=False), + sa.Column("manifest_json", sa.JSON(), nullable=False), + sa.Column("capabilities_json", sa.JSON(), nullable=False), + sa.Column("permissions_json", sa.JSON(), nullable=False), + sa.Column("runtime_status", sa.String(length=32), nullable=False), + sa.Column("blockers_json", sa.JSON(), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "provider_key", + "version", + "source_digest", + name="uq_plugin_installations_provider_version_digest", + ), + ) + op.create_index( + "ix_plugin_installations_provider_key", + "plugin_installations", + ["provider_key"], + unique=False, + ) + + +def downgrade() -> None: + # The table belongs to z5f6g7h8i9j0; this repair revision never owns its removal. + pass diff --git a/backend/schemas/workflow.py b/backend/schemas/workflow.py index 6d1c79c..ed21cd1 100644 --- a/backend/schemas/workflow.py +++ b/backend/schemas/workflow.py @@ -88,6 +88,12 @@ def _legacy_workflow_node_location(node_path: list[str]) -> tuple[str | None, st "resource", ] WorkflowCapabilityStatus = Literal["runnable", "blocked", "preview_only", "design_only"] +WorkflowOpenCLIAdapterPresetKind = Literal["source_slot", "tool_capability"] +WorkflowOpenCLIAdapterReadiness = Literal[ + "source_slot_ready", + "source_slot_requires_params", + "tool_capability_review_required", +] class WorkflowSourceAnchor(BaseModel): @@ -467,6 +473,8 @@ class WorkflowOpenCLIAdapterNode(BaseModel): catalogId: str = Field(..., min_length=1) kind: WorkflowNodeKind capability: WorkflowCapability + presetKind: WorkflowOpenCLIAdapterPresetKind + runtimeReadiness: WorkflowOpenCLIAdapterReadiness requiredArgs: list[str] = Field(default_factory=list) args: list[WorkflowOpenCLIAdapterNodeArg] = Field(default_factory=list) adapter: dict[str, Any] = Field(default_factory=dict) @@ -474,9 +482,22 @@ class WorkflowOpenCLIAdapterNode(BaseModel): manifest: dict[str, Any] = Field(default_factory=dict) +class WorkflowOpenCLIAdapterNodeFacets(BaseModel): + site: dict[str, int] = Field(default_factory=dict) + capability: dict[str, int] = Field(default_factory=dict) + access: dict[str, int] = Field(default_factory=dict) + browser: dict[str, int] = Field(default_factory=dict) + status: dict[str, int] = Field(default_factory=dict) + presetKind: dict[str, int] = Field(default_factory=dict) + runtimeReadiness: dict[str, int] = Field(default_factory=dict) + + class WorkflowOpenCLIAdapterNodesResponse(BaseModel): total: int = Field(..., ge=0) summary: dict[str, Any] = Field(default_factory=dict) + facets: WorkflowOpenCLIAdapterNodeFacets = Field( + default_factory=WorkflowOpenCLIAdapterNodeFacets + ) nodes: list[WorkflowOpenCLIAdapterNode] = Field(default_factory=list) diff --git a/backend/security/identity.py b/backend/security/identity.py index d3ce8d6..6b1a0f8 100644 --- a/backend/security/identity.py +++ b/backend/security/identity.py @@ -35,6 +35,8 @@ class RequestIdentity: subject: str email: str | None = None name: str | None = None + username: str | None = None + picture: str | None = None is_platform_admin: bool = False auth_method: str = "oidc" claims: Mapping[str, Any] | None = None @@ -76,23 +78,38 @@ async def verify(self, token: str) -> RequestIdentity: subject = claims.get("sub") if not isinstance(subject, str) or not subject: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token has no subject") + username = _string_claim(claims, "preferred_username") return RequestIdentity( subject=subject, - email=claims.get("email"), - name=claims.get("name") or claims.get("preferred_username"), + email=_string_claim(claims, "email"), + name=_string_claim(claims, "name") or username, + username=username, + picture=_string_claim(claims, "picture"), claims=claims, ) async def _get_jwks(self) -> dict[str, Any]: if self._jwks is None: - url = self.settings.jwks_url or f"{self.settings.issuer}/.well-known/jwks.json" if self._client is not None: - response = await self._client.get(url) + client = self._client else: - async with httpx.AsyncClient(timeout=5.0) as client: - response = await client.get(url) - response.raise_for_status() - self._jwks = response.json() + client = httpx.AsyncClient(timeout=5.0) + try: + url = self.settings.jwks_url + if not url: + discovery = await client.get( + f"{self.settings.issuer}/.well-known/openid-configuration" + ) + discovery.raise_for_status() + url = discovery.json().get("jwks_uri", "") + if not isinstance(url, str) or not url: + raise httpx.HTTPError("OIDC discovery has no jwks_uri") + response = await client.get(url) + response.raise_for_status() + self._jwks = response.json() + finally: + if self._client is None: + await client.aclose() return self._jwks @@ -127,3 +144,8 @@ async def get_request_identity(request: Request) -> RequestIdentity: get_request_identity = identity_dependency() + + +def _string_claim(claims: Mapping[str, Any], key: str) -> str | None: + value = claims.get(key) + return value if isinstance(value, str) and value else None diff --git a/backend/workflow/capability_projection.py b/backend/workflow/capability_projection.py index 7e124b5..f0d78b1 100644 --- a/backend/workflow/capability_projection.py +++ b/backend/workflow/capability_projection.py @@ -1303,6 +1303,30 @@ def _resource_capabilities() -> list[WorkflowRuntimeCapability]: "endpoint": "/api/v1/workflows/opencli-adapter-nodes", "summary": opencli_adapter_summary, "canvas": {"node": False}, + "catalogModel": { + "kind": "core_nodes_plus_presets", + "coreNodes": { + "endpoint": "/api/v1/workflows/capabilities", + "role": "node_definition", + }, + "adapterCommands": { + "endpoint": "/api/v1/workflows/opencli-adapter-nodes", + "role": "node_preset", + "presetKinds": ["source_slot", "tool_capability"], + }, + }, + "query": { + "filters": [ + "site", + "q", + "access", + "capability", + "browser", + "presetKind", + "runtimeReadiness", + ], + "grouping": "facets", + }, "materialization": { "readNoRequiredArgs": "intelligence.source.opencli-slot", "readRequiredArgs": "intelligence.source.opencli-slot with params", diff --git a/backend/workflow/opencli_adapter_nodes.py b/backend/workflow/opencli_adapter_nodes.py index 365b758..7b468ca 100644 --- a/backend/workflow/opencli_adapter_nodes.py +++ b/backend/workflow/opencli_adapter_nodes.py @@ -13,7 +13,7 @@ import subprocess from collections import Counter from functools import lru_cache -from typing import Any +from typing import Any, Literal from backend.opencli_runtime import resolve_opencli_bin from backend.schemas.workflow import ( @@ -56,6 +56,16 @@ def list_opencli_adapter_nodes( site: str | None = None, q: str | None = None, include_write: bool = True, + access: Literal["read", "write"] | None = None, + capability: Literal["fetch", "store"] | None = None, + browser: bool | None = None, + preset_kind: Literal["source_slot", "tool_capability"] | None = None, + runtime_readiness: Literal[ + "source_slot_ready", + "source_slot_requires_params", + "tool_capability_review_required", + ] + | None = None, limit: int | None = None, refresh: bool = False, ) -> WorkflowOpenCLIAdapterNodesResponse: @@ -63,6 +73,20 @@ def list_opencli_adapter_nodes( nodes = [_build_adapter_node(entry) for entry in catalog] if not include_write: nodes = [node for node in nodes if node.access != "write"] + if access: + nodes = [node for node in nodes if node.access == access] + if capability: + nodes = [node for node in nodes if node.capability == capability] + if browser is not None: + nodes = [node for node in nodes if node.browser is browser] + if preset_kind: + nodes = [node for node in nodes if node.presetKind == preset_kind] + if runtime_readiness: + nodes = [ + node + for node in nodes + if node.runtimeReadiness == runtime_readiness + ] if site: site_lower = site.lower() nodes = [node for node in nodes if node.site.lower() == site_lower] @@ -79,12 +103,14 @@ def list_opencli_adapter_nodes( ] nodes.sort(key=lambda node: (node.site, node.command)) summary = _summarize_nodes(nodes) + facets = _facet_nodes(nodes) total = len(nodes) if limit is not None: nodes = nodes[:limit] return WorkflowOpenCLIAdapterNodesResponse( total=total, summary=summary, + facets=facets, nodes=nodes, ) @@ -256,6 +282,8 @@ def _build_adapter_node(entry: dict[str, Any]) -> WorkflowOpenCLIAdapterNode: catalog_id = _OPENCLI_SOURCE_CATALOG_ID if is_read else _EXTERNAL_TOOL_CATALOG_ID kind = "source" if is_read else "action" capability = "fetch" if is_read else "store" + preset_kind = "source_slot" if is_read else "tool_capability" + runtime_readiness = _materialization(access, required_args) node_id = f"opencli.adapter.{_safe_id(site)}.{_safe_id(command)}" positional_required = [arg.name for arg in args if arg.required and arg.positional] named_required = [arg.name for arg in args if arg.required and not arg.positional] @@ -281,6 +309,8 @@ def _build_adapter_node(entry: dict[str, Any]) -> WorkflowOpenCLIAdapterNode: catalogId=catalog_id, kind=kind, capability=capability, + presetKind=preset_kind, + runtimeReadiness=runtime_readiness, requiredArgs=required_args, args=args, adapter={ @@ -304,9 +334,11 @@ def _build_adapter_node(entry: dict[str, Any]) -> WorkflowOpenCLIAdapterNode: "example": _read_string(entry.get("example")), }, "canvas": { - "node": is_read, + "node": True, + "runBlocked": runtime_readiness != "source_slot_ready", "catalogId": catalog_id, - "materialization": _materialization(access, required_args), + "materialization": runtime_readiness, + "presetKind": preset_kind, "requiredArgs": required_args, "positionalRequiredArgs": positional_required, "namedRequiredArgs": named_required, @@ -440,6 +472,22 @@ def _summarize_nodes(nodes: list[WorkflowOpenCLIAdapterNode]) -> dict[str, Any]: } +def _facet_nodes(nodes: list[WorkflowOpenCLIAdapterNode]) -> dict[str, dict[str, int]]: + return { + "site": dict(Counter(node.site for node in nodes)), + "capability": dict(Counter(node.capability for node in nodes)), + "access": dict(Counter(node.access for node in nodes)), + "browser": dict( + Counter("browser" if node.browser else "non_browser" for node in nodes) + ), + "status": dict(Counter(node.status for node in nodes)), + "presetKind": dict(Counter(node.presetKind for node in nodes)), + "runtimeReadiness": dict( + Counter(node.runtimeReadiness for node in nodes) + ), + } + + def _read_args(value: Any) -> list[dict[str, Any]]: if not isinstance(value, list): return [] diff --git a/docs/backend-architecture-consolidated.md b/docs/backend-architecture-consolidated.md index 31b593f..0377a8a 100644 --- a/docs/backend-architecture-consolidated.md +++ b/docs/backend-architecture-consolidated.md @@ -1,6 +1,6 @@ # opencli-admin 后端架构声明(分支归一后) -> 基线:main `b65708b`(2026-07-26)。三条产品线——安全加固线(`fix/sec-correctness-hardening`)、运营治理线(`notification-ack-cleanup-work`)、unified 产品线(`codex/unified-product-3002`)——已全部并入 main;迁移头归一后由 Source/Binding V1 顺延为 `f3g4h5i6j7k8`;八处跨线接缝已缝合(见 [#43](https://github.com/2233admin/opencli-admin/issues/43))。 +> 基线:main `b65708b`(2026-07-26)。三条产品线——安全加固线(`fix/sec-correctness-hardening`)、运营治理线(`notification-ack-cleanup-work`)、unified 产品线(`codex/unified-product-3002`)——已全部并入 main;迁移头归一后由 Source/Binding V1 顺延,并由缺失插件表修复迁移推进到 `g4h5i6j7k8l9`;八处跨线接缝已缝合(见 [#43](https://github.com/2233admin/opencli-admin/issues/43))。 > > 本文是给协作 Agent / 新会话的**架构声明**:动 `backend/` 前先读这份。`docs/ARCHITECTURE.md` 为合并前的旧全量文档, 与本文冲突处以本文为准。 @@ -9,7 +9,7 @@ | v1 API 路由模块 | 34 | | OpenAPI operations(治理台账对账后) | 205(台账:`docs/backend-capability-exposure-matrix.yaml`) | | 测试 | unit+compat+integration ≈ 2374 通过, 0 xfail | -| Alembic | 单头 `f3g4h5i6j7k8` | +| Alembic | 单头 `g4h5i6j7k8l9` | ## 1. 分层总图 @@ -92,7 +92,7 @@ flowchart LR ## 5. 存储与迁移 -- SQLite(dev, `aiosqlite`)/ PostgreSQL(compose)。迁移单头 `f3g4h5i6j7k8`——`1901f6da7138` 汇合运营线与产品线后,Source/Binding V1 单链顺延。 +- SQLite(dev, `aiosqlite`)/ PostgreSQL(compose)。迁移单头 `g4h5i6j7k8l9`——`1901f6da7138` 汇合运营线与产品线、Source/Binding V1 单链顺延后,幂等修复曾被标记完成但实际缺失的 `plugin_installations` 表。 - **Legacy 升级路径已验证**:旧 plugin-hub 链 rejoin 拓扑下, 版本化迁移对缺失的 `workflow_runs` 表容忍跳过(online inspector guard;offline SQL 渲染不受影响)。模式参照 spine 迁移的缺表 early-return 惯例。 - **去重是存储层保证**:`store_records` 按 `(source, content_hash)` 拦截。实测:A 股采集两轮 125→126, 仅真实新增入库。 diff --git a/docs/backend-capability-exposure-matrix.yaml b/docs/backend-capability-exposure-matrix.yaml index 524723a..fecc5f5 100644 --- a/docs/backend-capability-exposure-matrix.yaml +++ b/docs/backend-capability-exposure-matrix.yaml @@ -1,6 +1,6 @@ version: 1 source: backend.main.app.openapi -openapi_operation_count: 205 +openapi_operation_count: 207 allowed_dispositions: - operator_ui - studio_binding @@ -1759,6 +1759,24 @@ operations: decision: Added by branch-consolidation merge; governance review pending. target_epic: Epic 8 capability_id: platform.governance +- method: GET + path: /api/v1/governance/workspaces + operation_id: list_accessible_workspaces_api_v1_governance_workspaces_get + disposition: operator_ui + frontend_route: /sources + wrapper: null + decision: Expose the authenticated Workspace context required by Source and Agent governance. + target_epic: Epic 8 + capability_id: studio.sources +- method: GET + path: /api/v1/governance/workspaces/{workspace_id}/projects + operation_id: list_governance_projects_api_v1_governance_workspaces__workspace_id__projects_get + disposition: operator_ui + frontend_route: /sources + wrapper: null + decision: Expose Workspace-owned Projects without colliding with the transitional Studio routes. + target_epic: Epic 8 + capability_id: studio.sources - method: GET path: /api/v1/workspaces/{workspace_id}/automations operation_id: list_automations_api_v1_workspaces__workspace_id__automations_get diff --git a/frontend/.env.example b/frontend/.env.example index 8e62ffa..58eb055 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,2 +1,14 @@ # Experimental local shared-element transitions. Set false for a static fallback build. NEXT_PUBLIC_ENABLE_VIEW_TRANSITIONS=true + +# Provider-neutral organization login (OpenID Connect Authorization Code + PKCE). +# Use endpoints published by your OIDC provider; OpenCLI does not infer provider-specific paths. +NEXT_PUBLIC_OIDC_AUTHORITY= +NEXT_PUBLIC_OIDC_CLIENT_ID= +NEXT_PUBLIC_OIDC_AUTHORIZATION_ENDPOINT= +NEXT_PUBLIC_OIDC_SCOPE=openid profile email +NEXT_PUBLIC_OIDC_REDIRECT_URI= +NEXT_PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI= +# Server-side same-origin proxies for providers that do not expose browser CORS. +OIDC_TOKEN_ENDPOINT= +OIDC_JWKS_URL= diff --git a/frontend/app/(app)/sources/page.tsx b/frontend/app/(app)/sources/page.tsx index e74d5e7..5808cd6 100644 --- a/frontend/app/(app)/sources/page.tsx +++ b/frontend/app/(app)/sources/page.tsx @@ -1,23 +1,17 @@ 'use client' -import { useState } from 'react' -import Link from 'next/link' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { ChevronRight, Play } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { Building2, FolderKanban, Link2 } from 'lucide-react' import { toast } from 'sonner' -import * as api from '@/lib/api/endpoints' -import { useSources } from '@/lib/api/hooks' -import type { DataSource } from '@/lib/api/types' -import { formatRelative } from '@/lib/format' import { BACKEND_HINT, EmptyState, ErrorState, LoadingState } from '@/components/shell/data-states' import { PageContainer } from '@/components/shell/page-container' import { AUTOMATION_TABS, RouteTabs } from '@/components/shell/route-tabs' -import { StatusBadge } from '@/components/shell/status-badge' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' -import { Switch } from '@/components/ui/switch' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Table, TableBody, @@ -26,185 +20,258 @@ import { TableHeader, TableRow, } from '@/components/ui/table' +import { + useCreateProjectSourceBinding, + useGovernedWorkspaces, + useGovernedWorkspaceProjects, + useProjectSourceBindings, + useWorkspaceSources, +} from '@/lib/api/hooks' +import type { WorkspaceSource } from '@/lib/api/types' +import { formatRelative } from '@/lib/format' -const CHANNEL_LABEL: Record = { - opencli: 'OpenCLI', - web_scraper: '网页抓取', - api: 'API', - rss: 'RSS', - cli: 'CLI', - skill: '技能', - crawl4ai: 'Crawl4AI', - browser_act: 'BrowserAct 采集', +const STATUS_LABEL = { + active: '活跃', + disabled: '停用', + revoked: '已撤销', +} as const + +function SourceStatusBadge({ status }: { status: WorkspaceSource['status'] }) { + return ( + + {STATUS_LABEL[status]} + + ) +} + +function bindingSlug(source: WorkspaceSource) { + const base = source.name + .normalize('NFKD') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'source' + return `${base.slice(0, 91)}-${source.id.slice(0, 8)}` } export default function SourcesPage() { - const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all') - const [page, setPage] = useState(1) - const queryClient = useQueryClient() - const params = { - page, - limit: 50, - ...(enabledFilter === 'all' ? {} : { enabled: enabledFilter === 'enabled' }), - } - const { data, isLoading, isError, error } = useSources(params) + const router = useRouter() + const searchParams = useSearchParams() + const workspaces = useGovernedWorkspaces() + const [workspaceId, setWorkspaceId] = useState(searchParams.get('workspace')) + const projects = useGovernedWorkspaceProjects(workspaceId) + const [projectId, setProjectId] = useState(searchParams.get('project')) + const sources = useWorkspaceSources(workspaceId) + const bindings = useProjectSourceBindings(workspaceId, projectId) + const createBinding = useCreateProjectSourceBinding() - const toggle = useMutation({ - mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => - api.updateSource(id, { enabled }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['sources'] }) - toast.success('已更新数据源状态') - }, - onError: (e: Error) => toast.error(e.message), - }) + useEffect(() => { + if (!workspaces.data?.length) return + if (workspaceId && workspaces.data.some((workspace) => workspace.id === workspaceId)) return + setWorkspaceId(workspaces.data[0].id) + }, [workspaceId, workspaces.data]) - const trigger = useMutation({ - mutationFn: (id: string) => api.triggerTask(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['tasks'] }) - toast.success('已触发采集任务') - }, - onError: (e: Error) => toast.error(e.message), - }) + useEffect(() => { + if (!projects.data) return + if (!projects.data.length) { + setProjectId(null) + return + } + if (projectId && projects.data.some((project) => project.id === projectId)) return + setProjectId(projects.data[0].id) + }, [projectId, projects.data]) - const sources = data?.data ?? [] - const pagination = data?.meta + useEffect(() => { + const params = new URLSearchParams(searchParams.toString()) + if (workspaceId) params.set('workspace', workspaceId) + else params.delete('workspace') + if (projectId) params.set('project', projectId) + else params.delete('project') + const query = params.toString() + if (query !== searchParams.toString()) router.replace(query ? `/sources?${query}` : '/sources') + }, [projectId, router, searchParams, workspaceId]) - const filters: { key: typeof enabledFilter; label: string }[] = [ - { key: 'all', label: '全部' }, - { key: 'enabled', label: '启用' }, - { key: 'disabled', label: '停用' }, - ] + const selectedWorkspace = workspaces.data?.find((workspace) => workspace.id === workspaceId) + const selectedProject = projects.data?.find((project) => project.id === projectId) + const sourceById = useMemo( + () => new Map((sources.data ?? []).map((source) => [source.id, source])), + [sources.data], + ) + const boundSourceIds = useMemo( + () => new Set((bindings.data ?? []).map((binding) => binding.source_id)), + [bindings.data], + ) + + async function bindSource(source: WorkspaceSource) { + if (!workspaceId || !projectId) return + try { + await createBinding.mutateAsync({ + workspaceId, + projectId, + data: { + source_id: source.id, + name: source.name, + slug: bindingSlug(source), + source_revision_number: source.current_revision_number, + scope_config: {}, + }, + }) + toast.success(`已将“${source.name}”绑定到 ${selectedProject?.name ?? '项目'}`) + } catch (reason) { + toast.error(reason instanceof Error ? reason.message : '绑定 Source 失败') + } + } + + const error = workspaces.error ?? projects.error ?? sources.error ?? bindings.error + const isLoading = workspaces.isLoading + || (!!workspaceId && (projects.isLoading || sources.isLoading)) + || (!!projectId && bindings.isLoading) return ( } actions={ -
- {filters.map((f) => ( - - ))} +
+ +
} > {isLoading ? ( - ) : isError ? ( - - ) : sources.length === 0 ? ( - + ) : error ? ( + + ) : !workspaceId ? ( + ) : ( - - - - - 名称 - 渠道 - 标签 - 状态 - 更新时间 - 操作 - - - - {sources.map((s) => ( - - - - {s.name} - - - {s.review_required ? ( - - 待复核 - - ) : null} - - - {CHANNEL_LABEL[s.channel_type] ?? s.channel_type} - - -
- {s.tags.slice(0, 3).map((t) => ( - - {t} - - ))} - {s.tags.length === 0 ? : null} -
-
- - - - {formatRelative(s.updated_at)} - -
- - toggle.mutate({ id: s.id, enabled: v })} - aria-label="启用/停用" - /> -
-
-
- ))} -
-
- {pagination && pagination.pages > 1 ? ( -
- - 共 {pagination.total} 个数据源 · 第 {pagination.page}/{pagination.pages} 页 - -
- - -
+
+
+
+

Workspace Sources

+

这些 Source 可被当前 Workspace 下的多个 Project 复用。

- ) : null} - + {!sources.data?.length ? ( + + ) : ( + + + + + 名称 + Adapter + 状态 + 当前 Revision + 更新时间 + Project 绑定 + + + + {sources.data.map((source) => { + const isBound = boundSourceIds.has(source.id) + return ( + + +
{source.name}
+
{source.slug}
+
+ {source.adapter_type} + + r{source.current_revision_number} + {formatRelative(source.updated_at)} + + + +
+ ) + })} +
+
+
+ )} +
+ +
+
+

Project SourceBindings

+

+ {selectedProject ? `${selectedProject.name} 当前的 Source 绑定记录。` : '选择 Project 查看绑定。'} +

+
+ {!projectId ? ( + + ) : !bindings.data?.length ? ( + + ) : ( + + + + + 绑定名称 + Source + 状态 + Binding Revision + 更新时间 + + + + {bindings.data.map((binding) => ( + + +
{binding.name}
+
{binding.slug}
+
+ {sourceById.get(binding.source_id)?.name ?? binding.source_id} + + r{binding.current_revision_number} + {formatRelative(binding.updated_at)} +
+ ))} +
+
+
+ )} +
+
)} ) diff --git a/frontend/app/flow-canvas.css b/frontend/app/flow-canvas.css index d550556..9d55e8d 100644 --- a/frontend/app/flow-canvas.css +++ b/frontend/app/flow-canvas.css @@ -76,32 +76,12 @@ } } -@keyframes workflow-node-scan { - from { - transform: translateX(-120%); - } - to { - transform: translateX(120%); - } -} - @keyframes workflow-edge-flow { to { stroke-dashoffset: -28; } } -@keyframes workflow-edge-confirm { - 0% { - stroke-dashoffset: 16; - opacity: 0.62; - } - 100% { - stroke-dashoffset: 0; - opacity: 1; - } -} - @keyframes workflow-panel-in { from { opacity: 0; @@ -196,16 +176,6 @@ shape-rendering: geometricPrecision; } -.workflow-node-card[data-selected="true"]::after, -.workflow-node-card[data-package-state="draft"]::after { - content: ""; - pointer-events: none; - position: absolute; - inset: 0; - background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.08), transparent); - animation: workflow-node-scan 1.7s linear infinite; -} - .workflow-node-card[data-selected="true"] { box-shadow: 0 0 0 1px rgb(255 255 255 / 0.16), 0 0 24px rgb(255 255 255 / 0.08); } @@ -225,23 +195,125 @@ } .react-flow .workflow-edge-path { - stroke-dasharray: 7 7; - animation: - workflow-edge-confirm var(--motion-slow) var(--motion-ease-out) both, - workflow-edge-flow 1.3s linear infinite !important; + animation: none !important; + stroke-dasharray: none !important; transition: stroke var(--motion-fast) linear, stroke-width var(--motion-fast) var(--motion-ease-out), opacity var(--motion-fast) linear; } +.react-flow .workflow-edge-path[data-running="true"] { + stroke-dasharray: 7 7 !important; + animation: workflow-edge-flow 1.3s linear infinite !important; +} + .react-flow .workflow-edge-path[data-selected="true"] { stroke-width: 1.75; filter: drop-shadow(0 0 4px rgb(255 255 255 / 0.22)); } .react-flow .workflow-edge-path[data-draft="true"] { - stroke-dasharray: 4 6; + stroke-dasharray: 4 6 !important; +} + +.react-flow .workflow-port-handle { + transform-origin: center; + transition: + transform var(--motion-fast) var(--motion-ease-out), + background-color var(--motion-fast) linear, + box-shadow var(--motion-fast) linear, + opacity var(--motion-fast) linear; +} + +.react-flow .workflow-port-handle::after { + content: ""; + position: absolute; + inset: -10px; + border-radius: 999px; +} + +.workflow-port-anchor { + position: absolute; + z-index: 4; + width: 0; + pointer-events: none; +} + +.workflow-port-anchor-input { + top: 0; +} + +.workflow-port-anchor-output { + bottom: 0; +} + +.workflow-port-anchor .workflow-port-handle { + pointer-events: auto; +} + +.workflow-port-name { + position: absolute; + left: 50%; + max-width: 72px; + transform: translateX(-50%); + overflow: hidden; + border: 1px solid var(--oc-line); + border-radius: 2px; + background: var(--oc-surface-raised); + padding: 1px 4px; + color: var(--muted-foreground); + font-family: var(--font-code); + font-size: 10px; + line-height: 12px; + opacity: 0; + text-overflow: ellipsis; + transition: opacity var(--motion-fast) linear; + white-space: nowrap; +} + +.workflow-port-anchor-input .workflow-port-name { + top: -18px; +} + +.workflow-port-anchor-output .workflow-port-name { + bottom: -18px; +} + +.workflow-port-anchor:hover .workflow-port-name, +[data-wiring-state="wiring"] .workflow-port-name, +[data-wiring-state="reconnecting"] .workflow-port-name { + opacity: 1; +} + +[data-wiring-state="wiring"] .react-flow .workflow-port-handle, +[data-wiring-state="reconnecting"] .react-flow .workflow-port-handle { + opacity: 0.52; +} + +[data-wiring-state] .react-flow .workflow-port-handle.connectingfrom, +[data-wiring-state] .react-flow .workflow-port-handle.connectionindicator { + opacity: 1; +} + +.react-flow .workflow-port-handle.connectingto.valid { + opacity: 1; + transform: scale(1.85); + background: var(--primary) !important; + box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 18%, transparent), 0 0 14px color-mix(in srgb, var(--primary) 42%, transparent); +} + +.react-flow .workflow-port-handle.connectingto:not(.valid) { + opacity: 1; + transform: scale(1.45); + background: var(--destructive) !important; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--destructive) 18%, transparent); +} + +.react-flow__connection-path { + stroke: var(--primary); + stroke-width: 1.75; + filter: drop-shadow(0 0 4px color-mix(in srgb, var(--primary) 34%, transparent)); } .workflow-floating-panel { @@ -384,12 +456,12 @@ @media (prefers-reduced-motion: reduce) { .workflow-node-card, - .workflow-node-card:hover, - .workflow-node-card:active, - .workflow-node-card[data-status="running"], - .workflow-node-card[data-selected="true"]::after, - .workflow-node-card[data-package-state="draft"]::after, - .react-flow .workflow-edge-path, + .workflow-node-card:hover, + .workflow-node-card:active, + .workflow-node-card[data-status="running"], + .react-flow .workflow-edge-path, + .react-flow .workflow-port-handle, + .react-flow__connection-path, .workflow-floating-panel, .workflow-context-menu, .workflow-toast, diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 774c218..1148f20 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -47,6 +47,14 @@ --color-card: var(--card); --color-foreground: var(--foreground); --color-background: var(--background); + --color-ops-black: var(--oc-bg); + --color-ops-panel: var(--oc-surface); + --color-ops-raised: var(--oc-surface-raised); + --color-ops-line: var(--oc-line); + --text-3xs: 0.625rem; + --text-3xs--line-height: 0.875rem; + --text-2xs: 0.6875rem; + --text-2xs--line-height: 1rem; --radius-sm: calc(var(--radius) - 2px); --radius-md: var(--radius); --radius-lg: calc(var(--radius) + 2px); @@ -110,6 +118,10 @@ --sidebar-accent-foreground: #0a0a0a; --sidebar-border: #e2e2e0; --sidebar-ring: #0a0a0a; + --oc-bg: #050708; + --oc-surface: #0a0d10; + --oc-surface-raised: #101418; + --oc-line: #252a31; /* Motion tokens (engineered restraint: quick, precise) */ --m3-ease-emphasized: cubic-bezier(0.2, 0, 0, 1); diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index f910a55..1644793 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -354,8 +354,7 @@ function LoginForm() { ) : (
- 当前未配置 OIDC。设置 NEXT_PUBLIC_OIDC_AUTHORITY 和{' '} - NEXT_PUBLIC_OIDC_CLIENT_ID 后即可启用组织账号登录。 + 当前未配置组织登录。请配置 OIDC issuer、client ID 和授权端点。
)} diff --git a/frontend/components/auth/auth-provider.tsx b/frontend/components/auth/auth-provider.tsx index b096bf8..d00ee1b 100644 --- a/frontend/components/auth/auth-provider.tsx +++ b/frontend/components/auth/auth-provider.tsx @@ -35,6 +35,8 @@ const DEVELOPMENT_IDENTITY: AuthIdentity = { subject: 'bootstrap-admin', email: null, name: 'Local Development', + username: null, + picture: null, is_platform_admin: true, auth_method: 'development', } @@ -73,7 +75,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { try { const oidcUser = await getOidcManager()?.getUser() if (oidcUser && !oidcUser.expired) { - await acceptIdentityToken(oidcUser.access_token) + if (!oidcUser.id_token) throw new Error('OIDC 未返回身份令牌') + await acceptIdentityToken(oidcUser.id_token) return } @@ -108,8 +111,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const manager = getOidcManager() if (!manager) return - const onUserLoaded = (user: { access_token: string }) => { - void acceptIdentityToken(user.access_token) + const onUserLoaded = (user: { id_token?: string }) => { + if (user.id_token) void acceptIdentityToken(user.id_token) } const onSessionEnded = () => becomeAnonymous() @@ -143,7 +146,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const manager = getOidcManager() if (!manager) throw new Error('OIDC 登录尚未配置') const user = await manager.signinRedirectCallback() - await acceptIdentityToken(user.access_token) + if (!user.id_token) throw new Error('OIDC 未返回身份令牌') + await acceptIdentityToken(user.id_token) return oidcReturnTo(user) }, [acceptIdentityToken]) diff --git a/frontend/components/flow/command-palette.tsx b/frontend/components/flow/command-palette.tsx index 04ab30b..8f3b2a0 100644 --- a/frontend/components/flow/command-palette.tsx +++ b/frontend/components/flow/command-palette.tsx @@ -14,56 +14,114 @@ import { Save, Search, Sparkles, + Wrench, } from "lucide-react" import { NODE_PALETTE } from "@/lib/flow/palette" +import { portTypesCompatible } from "@/lib/flow/graph" import { getIcon } from "@/lib/flow/icons" import { generateWorkflowLocally } from "@/lib/flow/local-generate" import { useSettingsStore } from "@/lib/flow/settings-store" import { useFlowStore } from "@/lib/flow/store" import type { PaletteItem } from "@/lib/flow/types" import { - featuredOpenCLIAdapterNodes, + featuredOpenCLIAdapterGroups, fetchWorkflowOpenCLIAdapterNodes, + openCLIAdapterNodeMaterialization, openCLIAdapterNodePresentation, + openCLIAdapterNodeSearchText, workflowCatalogItemForOpenCLIAdapterNode, + workflowCatalogItemIsOpenCLIAdapterPreset, type WorkflowOpenCLIAdapterNode, + type WorkflowOpenCLIAdapterNodesResponse, } from "@/lib/workflow/backend-opencli-adapter-nodes" import { primitiveRuntimeCapability, runtimeStatusLabel, runtimeStatusTone } from "@/lib/workflow/capabilities" import { getWorkflowNodeCatalog, - workflowCatalogIsBackendNode, workflowCatalogItemLocked, workflowCatalogPluginProvenance, type WorkflowNodeCatalogItem, } from "@/lib/workflow/node-catalog" import { workflowNodeDepthFromNetworkStack, workflowNodeLayerAtDepth } from "@/lib/workflow/node-hierarchy" -import { localizeNodeText } from "@/lib/workflow/node-i18n" +import { localizeNodeText, type WorkflowLanguage } from "@/lib/workflow/node-i18n" import { groupPrimitivesForNodeMenu } from "@/lib/workflow/node-menu" +import { getNodeContractByCatalogId } from "@/lib/workflow/node-contracts" import { getWorkflowPrimitives, type WorkflowPrimitive } from "@/lib/workflow/node-primitives" +import { openCLIAdapterNodeToCatalogItem } from "@/lib/workflow/opencli-adapter-catalog" import { useWorkflowCapabilities } from "@/lib/workflow/use-workflow-capabilities" import { cn } from "@/lib/utils" -const AI_EXAMPLES = [ - "用户注册后发送欢迎邮件,24 小时后如果未激活则再次提醒", - "监听订单创建事件,校验库存,扣减库存并通知仓库发货", - "收到客服工单,判断优先级,高优先级转人工,其余自动回复", -] +const AI_EXAMPLES: Record = { + "zh-CN": [ + "用户注册后发送欢迎邮件,24 小时后如果未激活则再次提醒", + "监听订单创建事件,校验库存,扣减库存并通知仓库发货", + "收到客服工单,判断优先级,高优先级转人工,其余自动回复", + ], + "en-US": [ + "Send a welcome email after signup, then remind inactive users after 24 hours", + "Validate inventory when an order is created, reserve stock, and notify the warehouse", + "Classify support tickets, route urgent cases to a person, and answer the rest automatically", + ], +} -const CATEGORY_LABELS: Record = { - package: "业务能力包", - trigger: "触发与开始", - source: "数据来源", - transform: "处理与转换", - decision: "逻辑与判断", - action: "动作", - output: "输出", - annotation: "注释与辅助", - shape: "流程图形", +const CATEGORY_LABELS: Record> = { + package: { "zh-CN": "业务能力包", "en-US": "Business packages" }, + trigger: { "zh-CN": "触发与开始", "en-US": "Triggers & start" }, + source: { "zh-CN": "数据来源", "en-US": "Data sources" }, + processing: { "zh-CN": "数据处理", "en-US": "Data processing" }, + transform: { "zh-CN": "处理与转换", "en-US": "Transformations" }, + flow: { "zh-CN": "流程控制", "en-US": "Flow control" }, + decision: { "zh-CN": "逻辑与判断", "en-US": "Logic & decisions" }, + control: { "zh-CN": "治理与门禁", "en-US": "Governance & gates" }, + action: { "zh-CN": "动作", "en-US": "Actions" }, + output: { "zh-CN": "输出", "en-US": "Outputs" }, + sink: { "zh-CN": "数据写入", "en-US": "Data sinks" }, + annotation: { "zh-CN": "注释与辅助", "en-US": "Notes & helpers" }, + shape: { "zh-CN": "流程图形", "en-US": "Flowchart shapes" }, +} + +const AUXILIARY_TEXT: Record> = { + "分组容器": { + "zh-CN": { label: "分组容器", description: "将多个节点组织在一起" }, + "en-US": { label: "Group", description: "Organize several nodes as one visual group" }, + }, + "备注": { + "zh-CN": { label: "备注", description: "添加说明文字" }, + "en-US": { label: "Note", description: "Add explanatory text to the canvas" }, + }, + "矩形": { + "zh-CN": { label: "矩形", description: "流程步骤" }, + "en-US": { label: "Rectangle", description: "Process step" }, + }, + "圆形": { + "zh-CN": { label: "圆形", description: "起止节点" }, + "en-US": { label: "Circle", description: "Start or end node" }, + }, + "菱形": { + "zh-CN": { label: "菱形", description: "判定 / 决策" }, + "en-US": { label: "Diamond", description: "Decision" }, + }, + "六边形": { + "zh-CN": { label: "六边形", description: "准备 / 预处理" }, + "en-US": { label: "Hexagon", description: "Preparation or preprocessing" }, + }, + "平行四边形": { + "zh-CN": { label: "平行四边形", description: "输入 / 输出" }, + "en-US": { label: "Parallelogram", description: "Input or output" }, + }, + "圆柱": { + "zh-CN": { label: "圆柱", description: "数据存储" }, + "en-US": { label: "Cylinder", description: "Data store" }, + }, } type PickerTab = "nodes" | "tools" | "start" type ToolFilter = "all" | "opencli" | "plugin" +type OpenCLIAccessFilter = "all" | "read" | "write" +type OpenCLIReadinessFilter = "all" | "runnable" | "blocked" + +const OPENCLI_RESULT_LIMIT = 60 +const OPENCLI_SEARCH_RESULT_LIMIT = 120 const TAB_META: { id: PickerTab; label: string }[] = [ { id: "nodes", label: "节点" }, @@ -71,6 +129,131 @@ const TAB_META: { id: PickerTab; label: string }[] = [ { id: "start", label: "开始" }, ] +const PALETTE_COPY = { + "zh-CN": { + tabs: { nodes: "节点", tools: "工具", start: "开始" }, + pickerType: "节点选择类型", + switchLanguage: "切换节点语言", + search: "搜索节点选择器", + searchNodes: "搜索节点名称、业务能力或英文关键词", + searchTools: "搜索站点、命令、读取/写入或就绪状态", + all: "全部", + opencliPresets: "OpenCLI 预设", + pluginCapabilities: "插件能力", + accessFilter: "能力类型", + allRoles: "全部角色", + dataRead: "数据读取", + operationTool: "操作工具", + readinessFilter: "就绪状态", + allStates: "全部状态", + runnable: "可运行", + needsSetup: "运行前设置", + currentLayerOnly: "当前只展示本层可用执行节点", + annotations: "注释与辅助", + noNodes: "没有匹配的节点", + opencliCapabilities: "OpenCLI 能力预设", + loadingOpencli: "正在读取 OpenCLI 能力目录", + featuredSites: "国内全网 OODA 数据源", + pluginTools: "插件与后端工具", + noTools: "没有匹配的工具", + createImport: "创建与导入", + aiWorkflow: "AI 生成工作流", + aiWorkflowDescription: "用自然语言生成可编辑的工作流草稿", + importApp: "导入应用", + importAppDescription: "支持 Dify、n8n、JSON、YAML 与 Mermaid", + startFromNode: "从节点开始", + startFromNodeDescription: "进入节点目录,手动搭建业务流程", + canvasActions: "画布操作", + autoLayout: "自动整理画布", + autoLayoutDescription: "按纵向业务流重新排布当前节点", + saveDraft: "保存当前草稿", + saveDraftDescription: "将当前工作流保存到本地状态", + restoreExample: "恢复示例工作流", + restoreExampleDescription: "清空当前改动并恢复默认示例", + morePlugins: "在插件中心查找更多", + chooseStart: "选择一种开始方式", + enterToAdd: "输入搜索 · Enter 添加", + close: "Esc 关闭", + configureSource: "配置 OpenCLI 数据源", + backToTools: "返回工具列表", + requiredBeforeAdd: "配置必填参数后加入画布", + input: "输入", + cancel: "取消", + addSource: "添加数据源", + backToStart: "返回开始", + describeWorkflow: "描述你想要的流程…", + generate: "生成", + statusReady: "可运行", + statusReview: "运行前审核", + loginRequired: "需登录", + addedReviewPending: "已加入草稿;完成审核后即可运行", + parameters: "参数", + shown: "当前显示", + refineSearch: "继续输入站点、命令或就绪状态可定位其余预设。", + }, + "en-US": { + tabs: { nodes: "Nodes", tools: "Tools", start: "Start" }, + pickerType: "Node picker type", + switchLanguage: "Switch node language", + search: "Search node picker", + searchNodes: "Search node names, business capabilities, or Chinese keywords", + searchTools: "Search sites, commands, read/write, or readiness", + all: "All", + opencliPresets: "OpenCLI presets", + pluginCapabilities: "Plugin capabilities", + accessFilter: "Capability type", + allRoles: "All roles", + dataRead: "Data read", + operationTool: "Operation tool", + readinessFilter: "Readiness", + allStates: "All states", + runnable: "Runnable", + needsSetup: "Setup before run", + currentLayerOnly: "Only executable nodes available at this layer are shown", + annotations: "Notes & helpers", + noNodes: "No matching nodes", + opencliCapabilities: "OpenCLI capability presets", + loadingOpencli: "Loading the OpenCLI capability catalog", + featuredSites: "China-wide OODA sources", + pluginTools: "Plugin & backend tools", + noTools: "No matching tools", + createImport: "Create & import", + aiWorkflow: "Generate workflow with AI", + aiWorkflowDescription: "Create an editable workflow draft from natural language", + importApp: "Import app", + importAppDescription: "Supports Dify, n8n, JSON, YAML, and Mermaid", + startFromNode: "Start from nodes", + startFromNodeDescription: "Open the node catalog and build a workflow manually", + canvasActions: "Canvas actions", + autoLayout: "Auto-layout canvas", + autoLayoutDescription: "Rearrange nodes as a top-to-bottom business flow", + saveDraft: "Save current draft", + saveDraftDescription: "Save the current workflow to local state", + restoreExample: "Restore example workflow", + restoreExampleDescription: "Discard current edits and restore the default example", + morePlugins: "Find more in Plugin Center", + chooseStart: "Choose how to start", + enterToAdd: "Type to search · Enter to add", + close: "Esc to close", + configureSource: "Configure OpenCLI data source", + backToTools: "Back to tools", + requiredBeforeAdd: "Complete required parameters before adding to the canvas", + input: "Enter", + cancel: "Cancel", + addSource: "Add data source", + backToStart: "Back to start", + describeWorkflow: "Describe the workflow you want…", + generate: "Generate", + statusReady: "Runnable", + statusReview: "Review before run", + loginRequired: "Login required", + addedReviewPending: "Added to the draft; complete review before running", + parameters: "parameters", + shown: "Showing", + refineSearch: "Keep typing a site, command, or readiness state to find the remaining presets.", + }, +} as const satisfies Record> + function PickerRow({ icon: Icon, label, @@ -114,9 +297,72 @@ function SectionLabel({ children, count }: { children: React.ReactNode; count?: ) } +function catalogItemUnavailable(item: WorkflowNodeCatalogItem): boolean { + return workflowCatalogItemLocked(item) +} + +function openCLIStatusLabel(item: WorkflowOpenCLIAdapterNode, language: WorkflowLanguage): string { + const copy = PALETTE_COPY[language] + const materialization = openCLIAdapterNodeMaterialization(item) + if (materialization !== "unavailable") { + const setup = [ + item.strategy === "cookie" ? copy.loginRequired : "", + materialization === "source_slot_requires_params" ? `${item.requiredArgs.length} ${copy.parameters}` : "", + materialization === "tool_capability_review_required" ? copy.statusReview : "", + ].filter(Boolean) + if (setup.length) return setup.join(" · ") + } + if (materialization === "source_slot_ready") return copy.statusReady + return runtimeStatusLabel(item.status) +} + +function openCLIPresetKind(item: WorkflowOpenCLIAdapterNode): "source_slot" | "tool_capability" { + return item.presetKind ?? (item.access === "read" ? "source_slot" : "tool_capability") +} + +function openCLIPresetUnavailable(item: WorkflowOpenCLIAdapterNode): boolean { + const materialization = openCLIAdapterNodeMaterialization(item) + return materialization === "unavailable" +} + +export type CompatibleConnectionPort = { + handleType: "source" | "target" + type: string +} + +function catalogAcceptsConnection( + item: WorkflowNodeCatalogItem, + compatiblePort: CompatibleConnectionPort | undefined, +) { + if (!compatiblePort) return true + const runtimePorts = compatiblePort.handleType === "source" + ? item.runtimeContract?.inputShape.ports + : item.runtimeContract?.outputShape.ports + const direction = compatiblePort.handleType === "source" ? "input" : "output" + const staticPorts = getNodeContractByCatalogId(item.id)?.ports.filter((port) => port.direction === direction) + const ports = runtimePorts?.length ? runtimePorts : staticPorts + const originType = compatiblePort.type.trim().toLowerCase() + return Boolean(ports?.some((port) => + (originType === "unknown" || port.type.trim().toLowerCase() !== "unknown") && + portTypesCompatible(compatiblePort.type, port.type), + )) +} + +function primitiveAcceptsConnection( + item: WorkflowPrimitive, + compatiblePort: CompatibleConnectionPort | undefined, +) { + if (!compatiblePort) return true + const direction = compatiblePort.handleType === "source" ? "input" : "output" + return item.ports.some( + (port) => port.direction === direction && portTypesCompatible(compatiblePort.type, port.type), + ) +} + export function CommandPalette({ adapterCatalogError, adapterCatalogLoading, + adapterCatalogResponse, catalogItems, open, onClose, @@ -125,9 +371,11 @@ export function CommandPalette({ getAnchor, initialTab = "nodes", onImportApp, + compatiblePort, }: { adapterCatalogError?: string | null adapterCatalogLoading?: boolean + adapterCatalogResponse?: WorkflowOpenCLIAdapterNodesResponse | null catalogItems?: WorkflowNodeCatalogItem[] open: boolean onClose: () => void @@ -136,15 +384,19 @@ export function CommandPalette({ getAnchor?: () => { x: number; y: number } initialTab?: PickerTab onImportApp?: () => void + compatiblePort?: CompatibleConnectionPort }) { const [activeTab, setActiveTab] = useState(initialTab) const [toolFilter, setToolFilter] = useState("all") + const [opencliAccessFilter, setOpencliAccessFilter] = useState("all") + const [opencliReadinessFilter, setOpencliReadinessFilter] = useState("all") const [query, setQuery] = useState("") const [aiMode, setAiMode] = useState(false) const [aiPrompt, setAiPrompt] = useState("") const [loading, setLoading] = useState(false) const [opencliLoading, setOpencliLoading] = useState(false) - const [opencliNodes, setOpencliNodes] = useState([]) + const [fallbackOpenCLINodes, setFallbackOpenCLINodes] = useState([]) + const [fallbackOpenCLIError, setFallbackOpenCLIError] = useState(null) const [selectedOpenCLI, setSelectedOpenCLI] = useState(null) const [requiredValues, setRequiredValues] = useState>({}) const inputRef = useRef(null) @@ -164,12 +416,16 @@ export function CommandPalette({ const nodeDepth = workflowNodeDepthFromNetworkStack(networkStackLength) const nodeLayer = workflowNodeLayerAtDepth(nodeDepth) const language = useSettingsStore((state) => state.language) + const setLanguage = useSettingsStore((state) => state.set) + const copy = PALETTE_COPY[language] const { capabilities } = useWorkflowCapabilities(open) useEffect(() => { if (!open) return setActiveTab(initialTab) setToolFilter("all") + setOpencliAccessFilter("all") + setOpencliReadinessFilter("all") setQuery("") setAiMode(false) setAiPrompt("") @@ -179,12 +435,21 @@ export function CommandPalette({ }, [initialTab, open]) useEffect(() => { - if (!open || opencliNodes.length || opencliLoading) return + if (!open || adapterCatalogResponse !== undefined || fallbackOpenCLINodes.length) return + const controller = new AbortController() setOpencliLoading(true) - void fetchWorkflowOpenCLIAdapterNodes({ includeWrite: false, limit: 5000 }) - .then((result) => setOpencliNodes(result.nodes)) - .finally(() => setOpencliLoading(false)) - }, [open, opencliLoading, opencliNodes.length]) + setFallbackOpenCLIError(null) + void fetchWorkflowOpenCLIAdapterNodes({ includeWrite: true, limit: 5000, signal: controller.signal }) + .then((result) => setFallbackOpenCLINodes(result.nodes)) + .catch((error) => { + if (controller.signal.aborted) return + setFallbackOpenCLIError(error instanceof Error ? error.message : "OpenCLI adapter catalog unavailable") + }) + .finally(() => { + if (!controller.signal.aborted) setOpencliLoading(false) + }) + return () => controller.abort() + }, [adapterCatalogResponse, fallbackOpenCLINodes.length, open]) useEffect(() => { if (aiMode) requestAnimationFrame(() => aiRef.current?.focus()) @@ -202,22 +467,27 @@ export function CommandPalette({ const addOperator = useCallback( (item: PaletteItem) => { addNodeFromPalette(item, anchorPosition()) - onMessage?.(`已添加:${item.label}`) + onMessage?.(language === "zh-CN" ? `已添加:${item.label}` : `Added: ${item.label}`) onNodeCreated?.() onClose() }, - [addNodeFromPalette, anchorPosition, onClose, onMessage, onNodeCreated], + [addNodeFromPalette, anchorPosition, language, onClose, onMessage, onNodeCreated], ) const addCatalogOperator = useCallback( (item: WorkflowNodeCatalogItem) => { - if (workflowCatalogItemLocked(item)) { - onMessage?.(item.runtimeCapability?.reason ?? "该插件能力尚未绑定运行适配器") + if (catalogItemUnavailable(item)) { + onMessage?.( + item.runtimeCapability?.reason ?? + (language === "zh-CN" + ? "该插件能力尚未绑定运行适配器" + : "This plugin capability has no runtime adapter yet"), + ) return } addWorkflowNodeFromCatalog(item, anchorPosition()) const text = localizeNodeText(item.id, { label: item.label, description: item.description }, language) - onMessage?.(`已添加业务节点:${text.label}`) + onMessage?.(language === "zh-CN" ? `已添加业务节点:${text.label}` : `Added business node: ${text.label}`) onNodeCreated?.() onClose() }, @@ -228,7 +498,7 @@ export function CommandPalette({ (item: WorkflowPrimitive) => { addPrimitiveNode(item, anchorPosition(), primitiveRuntimeCapability(capabilities, item.id)) const text = localizeNodeText(item.id, { label: item.label, description: item.description }, language) - onMessage?.(`已添加执行节点:${text.label}`) + onMessage?.(language === "zh-CN" ? `已添加执行节点:${text.label}` : `Added execution node: ${text.label}`) onNodeCreated?.() onClose() }, @@ -237,17 +507,38 @@ export function CommandPalette({ const addOpenCLIAdapter = useCallback( (item: WorkflowOpenCLIAdapterNode, values: Record = {}) => { - if (item.requiredArgs.some((name) => !values[name]?.trim())) { - setSelectedOpenCLI(item) - setRequiredValues(values) + const materialization = openCLIAdapterNodeMaterialization(item) + if (materialization === "source_slot_requires_params") { + if (item.requiredArgs.some((name) => !values[name]?.trim())) { + setSelectedOpenCLI(item) + setRequiredValues(values) + return + } + addWorkflowNodeFromCatalog(workflowCatalogItemForOpenCLIAdapterNode(item, values), anchorPosition()) + } else if (materialization === "source_slot_ready" && item.status === "runnable") { + addWorkflowNodeFromCatalog(openCLIAdapterNodeToCatalogItem(item), anchorPosition()) + } else if (materialization === "tool_capability_review_required") { + addWorkflowNodeFromCatalog(openCLIAdapterNodeToCatalogItem(item), anchorPosition()) + } else { + onMessage?.( + language === "zh-CN" + ? "该 OpenCLI 能力当前不可加入画布" + : "This OpenCLI capability cannot be added to the canvas", + ) return } - addWorkflowNodeFromCatalog(workflowCatalogItemForOpenCLIAdapterNode(item, values), anchorPosition()) - onMessage?.(`已添加实时 OpenCLI 数据源:${openCLIAdapterNodePresentation(item).label}`) + const presentation = openCLIAdapterNodePresentation(item, language) + onMessage?.( + materialization === "tool_capability_review_required" + ? `${copy.addedReviewPending}:${presentation.label}` + : language === "zh-CN" + ? `已添加 OpenCLI 能力预设:${presentation.label}` + : `Added OpenCLI capability preset: ${presentation.label}`, + ) onNodeCreated?.() onClose() }, - [addWorkflowNodeFromCatalog, anchorPosition, onClose, onMessage, onNodeCreated], + [addWorkflowNodeFromCatalog, anchorPosition, copy.addedReviewPending, language, onClose, onMessage, onNodeCreated], ) const generate = useCallback( @@ -263,26 +554,56 @@ export function CommandPalette({ const data = await response.json() if (!response.ok) throw new Error(data?.detail ?? "failed") applyGeneratedWorkflow(data) - onMessage?.(`已生成工作流:${data.title ?? "未命名"}`) + onMessage?.( + language === "zh-CN" + ? `已生成工作流:${data.title ?? "未命名"}` + : `Workflow generated: ${data.title ?? "Untitled"}`, + ) } catch { const spec = generateWorkflowLocally(text) applyGeneratedWorkflow(spec) - onMessage?.(`已生成工作流(本地引擎):${spec.title}`) + onMessage?.( + language === "zh-CN" + ? `已生成工作流(本地引擎):${spec.title}` + : `Workflow generated locally: ${spec.title}`, + ) } finally { setLoading(false) onClose() } }, - [applyGeneratedWorkflow, loading, onClose, onMessage], + [applyGeneratedWorkflow, language, loading, onClose, onMessage], ) const queryText = query.trim().toLowerCase() - const catalogOperators = inNodeNetwork + const allCatalogItems = inNodeNetwork ? [] - : (catalogItems ?? getWorkflowNodeCatalog(workflowProfile, capabilities)).filter( - (item) => item.category === "package" || workflowCatalogIsBackendNode(item) || workflowCatalogPluginProvenance(item) !== null, - ) - const catalogBusy = opencliLoading || adapterCatalogLoading + : (catalogItems ?? getWorkflowNodeCatalog(workflowProfile, capabilities)) + const catalogOperators = allCatalogItems.filter( + (item) => + catalogAcceptsConnection(item, compatiblePort) && + !workflowCatalogItemIsOpenCLIAdapterPreset(item) && + workflowCatalogPluginProvenance(item) === null && + item.runtimeCapability?.source !== "backend.workflow.tool_capabilities", + ) + const pluginTools = allCatalogItems.filter( + (item) => + catalogAcceptsConnection(item, compatiblePort) && + !workflowCatalogItemIsOpenCLIAdapterPreset(item) && + ( + workflowCatalogPluginProvenance(item) !== null || + item.runtimeCapability?.source === "backend.workflow.tool_capabilities" + ), + ) + const opencliNodes = (adapterCatalogResponse?.nodes ?? fallbackOpenCLINodes).filter( + (item) => catalogAcceptsConnection(openCLIAdapterNodeToCatalogItem(item), compatiblePort), + ) + const catalogBusy = adapterCatalogResponse !== undefined + ? Boolean(adapterCatalogLoading) + : opencliLoading + const catalogError = adapterCatalogResponse !== undefined + ? adapterCatalogError + : fallbackOpenCLIError const matchesCatalog = (item: WorkflowNodeCatalogItem) => { if (!queryText) return true const text = localizeNodeText(item.id, { label: item.label, description: item.description }, language) @@ -292,7 +613,7 @@ export function CommandPalette({ } const nodeCatalogGroups = useMemo(() => { const groups = new Map() - for (const item of catalogOperators.filter((item) => workflowCatalogPluginProvenance(item) === null && matchesCatalog(item))) { + for (const item of catalogOperators.filter(matchesCatalog)) { const current = groups.get(item.category) ?? [] current.push(item) groups.set(item.category, current) @@ -300,86 +621,172 @@ export function CommandPalette({ return [...groups.entries()] // catalogOperators and matchesCatalog are derived from current render inputs. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [capabilities, inNodeNetwork, language, queryText, workflowProfile]) + }, [capabilities, catalogItems, compatiblePort, inNodeNetwork, language, queryText, workflowProfile]) const primitiveGroups = groupPrimitivesForNodeMenu( (inNodeNetwork ? getWorkflowPrimitives() : []).filter((item) => { + if (!primitiveAcceptsConnection(item, compatiblePort)) return false if (!queryText) return true const text = localizeNodeText(item.id, { label: item.label, description: item.description }, language) return `${item.label} ${text.label} ${text.description ?? ""} ${item.category} ${item.keywords.join(" ")}`.toLowerCase().includes(queryText) }), + language, ) - const auxiliaryOperators = NODE_PALETTE.filter((item) => item.category === "annotation" || item.category === "shape").filter( - (item) => !queryText || `${item.label} ${item.description} ${item.nodeType}`.toLowerCase().includes(queryText), + const auxiliaryOperators = (compatiblePort ? [] : NODE_PALETTE) + .filter((item) => item.category === "annotation" || item.category === "shape").filter( + (item) => { + const localized = AUXILIARY_TEXT[item.label]?.[language] + return !queryText || + `${item.label} ${item.description} ${localized?.label ?? ""} ${localized?.description ?? ""} ${item.nodeType}` + .toLowerCase() + .includes(queryText) + }, ) - const matchesOpenCLI = (item: WorkflowOpenCLIAdapterNode) => { - const presentation = openCLIAdapterNodePresentation(item) - return !queryText || `${presentation.label} ${presentation.description} ${item.site} ${item.command}`.toLowerCase().includes(queryText) - } - const commonOpenCLINodes = featuredOpenCLIAdapterNodes(opencliNodes).filter(matchesOpenCLI) - const filteredOpenCLINodes = (() => { - const matching = opencliNodes.filter(matchesOpenCLI) - if (queryText) return matching.slice(0, 100) - const commonIds = new Set(commonOpenCLINodes.map((item) => item.id)) - return [...commonOpenCLINodes, ...matching.filter((item) => !commonIds.has(item.id))].slice(0, 18) + const matchesOpenCLI = (item: WorkflowOpenCLIAdapterNode) => + (!queryText || openCLIAdapterNodeSearchText(item).includes(queryText)) && + (opencliAccessFilter === "all" || item.access === opencliAccessFilter) && + ( + opencliReadinessFilter === "all" || + (opencliReadinessFilter === "runnable" && item.status === "runnable") || + (opencliReadinessFilter === "blocked" && item.status !== "runnable") + ) + const matchingOpenCLINodes = opencliNodes.filter(matchesOpenCLI) + const commonOpenCLIGroups = featuredOpenCLIAdapterGroups(matchingOpenCLINodes, language) + const commonOpenCLINodes = commonOpenCLIGroups.flatMap((group) => group.nodes) + const commonOpenCLIIds = new Set(commonOpenCLINodes.map((item) => item.id)) + const opencliResultLimit = queryText ? OPENCLI_SEARCH_RESULT_LIMIT : OPENCLI_RESULT_LIMIT + const visibleOpenCLINodes = matchingOpenCLINodes + .filter((item) => !commonOpenCLIIds.has(item.id)) + .slice(0, opencliResultLimit) + const opencliPresetGroups = (() => { + const groups = new Map() + for (const item of visibleOpenCLINodes) { + const key = `${item.site}:${openCLIPresetKind(item)}` + const current = groups.get(key) ?? [] + current.push(item) + groups.set(key, current) + } + return [...groups.entries()] })() - const pluginTools = catalogOperators.filter( - (item) => matchesCatalog(item) && workflowCatalogPluginProvenance(item) !== null, - ) + const filteredPluginTools = pluginTools.filter(matchesCatalog) - const firstNode = nodeCatalogGroups[0]?.[1][0] + const firstNode = nodeCatalogGroups + .flatMap(([, items]) => items) + .find((item) => !catalogItemUnavailable(item)) const firstPrimitive = primitiveGroups[0]?.items[0] const firstAuxiliary = auxiliaryOperators[0] + const firstOpenCLI = matchingOpenCLINodes.find( + (item) => !openCLIPresetUnavailable(item), + ) + const firstPluginTool = filteredPluginTools.find((item) => !catalogItemUnavailable(item)) if (!open) return null if (selectedOpenCLI) { const missingRequired = selectedOpenCLI.requiredArgs.filter((name) => !requiredValues[name]?.trim()) + const selectedPresentation = openCLIAdapterNodePresentation(selectedOpenCLI, language) return ( -
+
{ event.preventDefault(); addOpenCLIAdapter(selectedOpenCLI, requiredValues) }}>
- -
{selectedOpenCLI.label}
配置必填参数后加入画布
+ +
{selectedPresentation.label}
{copy.requiredBeforeAdd}
{selectedOpenCLI.args.filter((arg) => arg.required).map((arg) => ( - + ))}
-
+
) } return ( -
{ if (event.key === "Escape") close() }} role="dialog" aria-modal="true" aria-label="节点选择器"> +
{ if (event.key === "Escape") close() }} role="dialog" aria-modal="true" aria-label={copy.search}>
event.stopPropagation()}> {aiMode ? (
- -
AI 生成工作流
-