diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/api/WorkflowController.java b/mateclaw-server/src/main/java/vip/mate/workflow/api/WorkflowController.java index 75965eb8..692e68f6 100644 --- a/mateclaw-server/src/main/java/vip/mate/workflow/api/WorkflowController.java +++ b/mateclaw-server/src/main/java/vip/mate/workflow/api/WorkflowController.java @@ -41,6 +41,13 @@ public class WorkflowController { private final WorkflowRunPauseMapper pauseMapper; private final WorkflowCompiler compiler; private final WorkflowAclPort aclPort; + /** Optional — only present when the LLM module is wired (production). + * Tests that don't boot the chat-model factory get a null and the + * /draft/generate endpoint returns 503 instead of crashing. */ + @org.springframework.beans.factory.annotation.Autowired(required = false) + private vip.mate.workflow.draftgen.WorkflowDraftGenerator draftGenerator; + @org.springframework.beans.factory.annotation.Autowired(required = false) + private vip.mate.workflow.draftgen.WorkflowDraftTemplateLibrary draftTemplates; @Operation(summary = "List workflows in the workspace") @GetMapping @@ -229,6 +236,35 @@ public class WorkflowController { return R.ok(new RunDetail(run, steps, activePause)); } + @Operation(summary = "Generate a workflow draft from a natural-language description.") + @PostMapping("/draft/generate") + public ResponseEntity generateDraft(@RequestBody DraftGenerateRequest body, + @RequestHeader("X-Workspace-Id") long workspaceId) { + if (draftGenerator == null) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(R.fail("workflow draft generator is not configured on this deployment")); + } + if (body == null || body.description() == null || body.description().isBlank()) { + return ResponseEntity.badRequest().body(R.fail("description is required")); + } + try { + return ResponseEntity.ok(R.ok(draftGenerator.generate(body.description(), workspaceId))); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(R.fail(e.getMessage())); + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(R.fail(e.getMessage())); + } + } + + @Operation(summary = "List the canonical workflow templates the generator can apply directly.") + @GetMapping("/draft/templates") + public R> listDraftTemplates() { + if (draftTemplates == null) return R.ok(List.of()); + return R.ok(draftTemplates.all()); + } + + public record DraftGenerateRequest(String description) {} + /** Narrow patch shape for {@link #update}; keeps the metadata path * from accepting fields that would clobber the draft. */ public record WorkflowMetadataRequest(String name, String description, Boolean enabled) {} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/draftgen/GeneratedWorkflowDraft.java b/mateclaw-server/src/main/java/vip/mate/workflow/draftgen/GeneratedWorkflowDraft.java new file mode 100644 index 00000000..2bef86d3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/draftgen/GeneratedWorkflowDraft.java @@ -0,0 +1,38 @@ +package vip.mate.workflow.draftgen; + +import java.util.List; +import java.util.Map; + +/** + * Result of a natural-language → workflow draft generation. Crosses the + * REST boundary as JSON; the controller returns this verbatim. + * + *

{@code draftJson} is the {@code {"steps":[...]}} shape the + * runtime expects — same string the UI's JSON tab edits, same one + * {@link vip.mate.workflow.compiler.WorkflowCompiler} consumes. The + * generator pre-runs the compiler against it and reports compile + * failures via {@code compileErrors} without auto-publishing — v0 + * always lets the operator review before pushing the row to a + * revision. + * + *

{@code triggerDrafts} is a list of suggested triggers the user + * can choose to create alongside the workflow; they're NOT created + * automatically and arrive with {@code enabled=false} per the + * generator system prompt's contract. + * + *

{@code warnings} / {@code missingFields} surface anywhere the + * model had to hedge — unfilled {@code TODO_*} placeholders, ambiguous + * approval policy, missing channel target. The UI displays these + * inline so the operator can finish the draft. + */ +public record GeneratedWorkflowDraft( + String name, + String description, + String draftJson, + List> triggerDrafts, + List warnings, + List missingFields, + Double confidence, + boolean compileOk, + List compileErrors +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/draftgen/WorkflowAuthoringTool.java b/mateclaw-server/src/main/java/vip/mate/workflow/draftgen/WorkflowAuthoringTool.java new file mode 100644 index 00000000..9ddaa5ff --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/draftgen/WorkflowAuthoringTool.java @@ -0,0 +1,112 @@ +package vip.mate.workflow.draftgen; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.workflow.model.WorkflowEntity; +import vip.mate.workflow.service.WorkflowService; + +/** + * Agent-callable workflow drafting tool. + * + *

Lets a user say in chat «帮我把每周一汇总销售这件事做成 workflow» + * and have the agent compose a draft + persist it as a fresh + * {@link WorkflowEntity} row + return a short natural-language summary + * the user can act on. The created workflow stays as a draft (no + * publish, no triggers wired) — same v0 safety contract as the + * controller endpoint. + * + *

Workspace is taken from {@link ChatOrigin} on the active + * {@link ToolContext}, so the tool can never write into a foreign + * workspace even if the agent prompt tried to forge one. + */ +@Slf4j +@Component +public class WorkflowAuthoringTool { + + private final WorkflowDraftGenerator generator; + private final WorkflowService workflowService; + + public WorkflowAuthoringTool(WorkflowDraftGenerator generator, + WorkflowService workflowService) { + this.generator = generator; + this.workflowService = workflowService; + } + + @Tool(description = "把用户描述的业务流程转换成一个 MateClaw workflow 草稿并保存到当前 workspace。" + + "适用场景:用户说「把 X 这件事做成 workflow / 自动化 / 流程」、「每周一让 X 员工 ...」、" + + "「客户消息进来时让 X 应对」。工具会输出 workflowId + 简短摘要,前端会自动在 workflow 编辑器里打开。" + + "不会自动发布,不会自动启用 trigger — 用户需要在编辑器里 review 后再 publish。") + public String workflow_draft_generate( + @ToolParam(description = "用户对业务流程的自然语言描述,越具体越好;可以包含触发条件、参与员工、是否要审批、要发到哪个渠道。") + String description, + // ChatOrigin-scoped workspace lookup; never trust the LLM to pass workspaceId. + @Nullable ToolContext ctx) { + + Long workspaceId = ctx == null ? null : ChatOrigin.from(ctx).workspaceId(); + if (workspaceId == null || workspaceId <= 0) { + return "无法确定当前 workspace,工具放弃执行。请在 workspace 上下文里调用我。"; + } + + GeneratedWorkflowDraft draft; + try { + draft = generator.generate(description, workspaceId); + } catch (Exception e) { + log.warn("[workflow_draft_generate] generation failed for ws={}: {}", + workspaceId, e.getMessage()); + return "生成失败:" + e.getMessage(); + } + + // Persist as a draft. No publish, no triggers — that's a separate + // user action via the editor / approve flow. We name it from the + // generator output so the editor surfaces something useful in + // the list immediately. + WorkflowEntity wf = new WorkflowEntity(); + wf.setName(draft.name()); + wf.setDescription(draft.description()); + wf.setEnabled(true); + wf.setWorkspaceId(workspaceId); + WorkflowEntity created; + try { + created = workflowService.create(wf); + workflowService.saveDraft(created.getId(), workspaceId, draft.draftJson(), null); + } catch (Exception e) { + log.warn("[workflow_draft_generate] persist failed: {}", e.getMessage()); + return "草稿生成成功但保存失败:" + e.getMessage(); + } + + StringBuilder out = new StringBuilder(); + out.append("已生成 workflow 草稿 ").append(draft.name()) + .append("(id=").append(created.getId()).append(")。\n"); + if (draft.compileOk()) { + out.append("✓ 编译预校验通过。\n"); + } else { + out.append("⚠ 编译预校验未通过 (").append(draft.compileErrors().size()).append(" 处),需在编辑器里修正。\n"); + } + if (draft.missingFields() != null && !draft.missingFields().isEmpty()) { + out.append("缺失字段:"); + for (int i = 0; i < draft.missingFields().size(); i++) { + if (i > 0) out.append(";"); + out.append(draft.missingFields().get(i)); + } + out.append("\n"); + } + if (draft.warnings() != null && !draft.warnings().isEmpty()) { + out.append("警告:"); + for (int i = 0; i < draft.warnings().size(); i++) { + if (i > 0) out.append(";"); + out.append(draft.warnings().get(i)); + } + out.append("\n"); + } + if (draft.triggerDrafts() != null && !draft.triggerDrafts().isEmpty()) { + out.append("建议触发器:").append(draft.triggerDrafts().size()).append(" 个 (默认未启用,需在编辑器里确认后创建)。\n"); + } + out.append("请到 workflow 编辑器查看并继续完善。"); + return out.toString(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/draftgen/WorkflowDraftGenerator.java b/mateclaw-server/src/main/java/vip/mate/workflow/draftgen/WorkflowDraftGenerator.java new file mode 100644 index 00000000..11824c27 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/draftgen/WorkflowDraftGenerator.java @@ -0,0 +1,355 @@ +package vip.mate.workflow.draftgen; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.stereotype.Service; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.repository.ChannelMapper; +import vip.mate.llm.chatmodel.ProviderChatModelFactory; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.workflow.compiler.PublishContext; +import vip.mate.workflow.compiler.WorkflowAclPort; +import vip.mate.workflow.compiler.WorkflowCompiler; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Natural-language → workflow draft generator. + * + *

Composes a system prompt + workspace-scoped context (available + * digital employees + channels) + the user description, dispatches to + * the workspace's default chat model, parses the JSON response, and + * runs {@link WorkflowCompiler} against it without persisting. The + * compile pass is "preview-only" — auto-publish is explicitly + * forbidden in the system prompt and we don't insert any rows here. + * + *

The generator is also the shared core called by the + * {@code workflow_draft_generate} agent tool, so a chat user can ask + * an agent "把每周一汇总销售这件事做成 workflow" and the agent gets back + * the same draft shape. + * + *

Failures are surfaced rather than swallowed: if the model returns + * non-JSON or the JSON doesn't carry a {@code steps} array, the + * generator throws so the controller / tool returns a clear error + * instead of a silently-broken draft. + */ +@Slf4j +@Service +public class WorkflowDraftGenerator { + + /** System prompt — the contract the LLM must honor. Embedded as a + * text block so the file is the canonical version (no resource + * loading, no separate prompt-management infra in v0). */ + static final String SYSTEM_PROMPT = """ + 你是 MateClaw 的工作流草稿生成器。你的任务是把用户用自然语言描述的业务流程,转换成 MateClaw RFC-29 v0 workflow JSON 草稿。 + + 你只输出 JSON,不输出 Markdown,不输出解释,不输出代码块。 + + # 输出形态 + + 必须输出一个 JSON object,结构如下: + + { + "schemaVersion": "1.0", + "name": "...", + "description": "...", + "metadata": { + "generatedFrom": "natural_language", + "confidence": 0.0, + "warnings": [], + "missingFields": [] + }, + "triggerDrafts": [], + "steps": [] + } + + # v0 支持的 7 种 mode + + sequential — 一个员工执行;必须 agentId/agentName + promptTemplate。outputContentType 只能 text 或 json。 + fan_out — 至少 2 个连续 fan_out,后接 collect;每个分支必须 agentId/agentName + promptTemplate。 + collect — 不带 agentId、agentName、promptTemplate;只能跟在 fan_out group 后。 + conditional — mode.expression 必填(Pebble 子集,如 {{ outputs.x.approved == true }});agentId/agentName + promptTemplate 必填。 + await_approval — approvalKind + approverChannels[] + approvalMessage 必填;可选 timeoutSecs;不要 agentId / agentName / promptTemplate。 + dispatch_channel — channels[] + targets{} + content 必填;不要 agentId / agentName / promptTemplate。 + write_memory — employeeId + file + mergeStrategy(append/prepend/replace_section/upsert_kv/overwrite) + content 必填;不要 agentId / agentName / promptTemplate。 + + # 不支持 + + 不要生成 loop / invoke_skill / subflow。不要生成 agent_lifecycle / content_match 触发器。 + 遇到循环、重复直到成功、调用技能、复杂嵌套,用最接近的线性步骤,并在 metadata.warnings 写明需人工确认。 + + # 触发器(triggerDrafts) + + 只允许 patternType: cron / channel_message / workflow_completion / webhook。 + triggerDrafts 默认 enabled=false,绝不自动启用。 + + # 命名 + + workflow.name 与 step.name 用英文 kebab-case (collect-sales-data / ask-finance-approval)。description 用用户母语。 + + # 占位字段 + + 找不到匹配的真实 ID/渠道/员工时使用占位: + - agentName: "TODO_*_AGENT" + - employeeId: "TODO_EMPLOYEE_ID" + - channels[*]: "TODO_SELECT_CHANNEL" + - targets["TODO_SELECT_CHANNEL"]: "TODO_TARGET_ID" + - sourceWorkflowId: "TODO_WORKFLOW_ID" + 每个 TODO 都要在 metadata.missingFields 中解释。 + 绝不能编造不存在的 agentId / channelType / 群 ID。 + + # 默认值 + + approvalKind: manager / finance / manual / legal / oncall 之一。 + approverChannels: 默认 ["web"],除非用户明确说企业 IM 渠道。 + mergeStrategy: 默认 "append"。 + schemaVersion: 始终 "1.0"。 + + # 质量 + + 只使用 v0 字段;无注释;无 trailing comma;无 Markdown;不自动启用 trigger;不自动发布。 + """; + + private final ProviderChatModelFactory chatModelFactory; + private final ModelConfigService modelConfigService; + private final RetryTemplate retryTemplate; + private final AgentMapper agentMapper; + private final ChannelMapper channelMapper; + private final ObjectMapper objectMapper; + private final WorkflowCompiler compiler; + private final WorkflowAclPort aclPort; + private final WorkflowDraftTemplateLibrary templateLibrary; + + public WorkflowDraftGenerator(ProviderChatModelFactory chatModelFactory, + ModelConfigService modelConfigService, + RetryTemplate retryTemplate, + AgentMapper agentMapper, + ChannelMapper channelMapper, + ObjectMapper objectMapper, + WorkflowCompiler compiler, + WorkflowAclPort aclPort, + WorkflowDraftTemplateLibrary templateLibrary) { + this.chatModelFactory = chatModelFactory; + this.modelConfigService = modelConfigService; + this.retryTemplate = retryTemplate; + this.agentMapper = agentMapper; + this.channelMapper = channelMapper; + this.objectMapper = objectMapper; + this.compiler = compiler; + this.aclPort = aclPort; + this.templateLibrary = templateLibrary; + } + + public GeneratedWorkflowDraft generate(String description, long workspaceId) { + if (description == null || description.isBlank()) { + throw new IllegalArgumentException("description must not be empty"); + } + + // --- 1. workspace context --------------------------------------- + String contextPrompt = buildContextPrompt(workspaceId); + + // --- 2. resolve runtime model ---------------------------------- + ModelConfigEntity model = modelConfigService.getDefaultModel(); + if (model == null) { + throw new IllegalStateException( + "No default chat model configured; cannot generate workflow draft"); + } + ChatModel chatModel = chatModelFactory.buildFor(model, retryTemplate); + ChatClient client = ChatClient.create(chatModel); + + // --- 3. call the model ----------------------------------------- + String raw; + try { + raw = client.prompt() + .system(SYSTEM_PROMPT + "\n\n" + contextPrompt) + .user(description) + .call() + .content(); + } catch (Exception e) { + throw new IllegalStateException( + "Workflow draft generator chat call failed: " + e.getMessage(), e); + } + if (raw == null || raw.isBlank()) { + throw new IllegalStateException("Workflow draft generator returned empty content"); + } + + // --- 4. parse + validate shape --------------------------------- + JsonNode root = parseStrict(raw); + if (!root.has("steps") || !root.get("steps").isArray()) { + throw new IllegalStateException( + "Generated draft has no steps[] array; raw output: " + truncate(raw)); + } + + // --- 5. extract fields ----------------------------------------- + String name = root.path("name").asText(""); + String userDescription = root.path("description").asText(""); + Double confidence = root.path("metadata").path("confidence").isNumber() + ? root.path("metadata").path("confidence").asDouble() : null; + + List warnings = readStringArray(root, "metadata", "warnings"); + List missingFields = readStringArray(root, "metadata", "missingFields"); + + // The runtime only consumes the steps part of the draft — strip + // everything else into a clean {steps:[...]} shape. + Map draftRoot = new LinkedHashMap<>(); + draftRoot.put("steps", objectMapper.convertValue(root.get("steps"), + new TypeReference>>() {})); + String draftJson; + try { + draftJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(draftRoot); + } catch (Exception e) { + throw new IllegalStateException("Failed to re-serialize generated steps: " + e.getMessage(), e); + } + + // --- 6. trigger drafts ----------------------------------------- + List> triggerDrafts = new ArrayList<>(); + if (root.has("triggerDrafts") && root.get("triggerDrafts").isArray()) { + triggerDrafts = objectMapper.convertValue(root.get("triggerDrafts"), + new TypeReference>>() {}); + for (Map td : triggerDrafts) { + // Belt-and-suspenders: never trust the LLM to honor enabled=false. + td.put("enabled", false); + } + } + + // --- 7. compile preview --------------------------------------- + boolean compileOk; + List compileErrors; + try { + // PublishContext is (workspaceId, publisherId). + WorkflowCompiler.Result result = compiler.compile(draftJson, + new PublishContext(workspaceId, 0L), aclPort); + compileOk = result.ok(); + compileErrors = compileOk ? List.of() : result.errors(); + } catch (Exception e) { + // Compile preview failures are not fatal — the operator can + // still edit the draft. We surface them as warnings. + log.warn("[WorkflowDraftGenerator] preview compile failed: {}", e.getMessage()); + compileOk = false; + compileErrors = List.of(); + warnings = appendWarning(warnings, "preview compile threw: " + e.getMessage()); + } + + return new GeneratedWorkflowDraft( + name == null || name.isBlank() ? "untitled-workflow" : name, + userDescription, + draftJson, + triggerDrafts, + warnings, + missingFields, + confidence, + compileOk, + compileErrors); + } + + /** Compose the workspace-scoped context prompt: agent + channel + * inventory the model can pick from. Agents are filtered to enabled + * rows; channels likewise. The model is told to prefer real ids + * over TODOs but never to fabricate. */ + private String buildContextPrompt(long workspaceId) { + List agents = agentMapper.selectList(new LambdaQueryWrapper() + .eq(AgentEntity::getWorkspaceId, workspaceId) + .eq(AgentEntity::getEnabled, true)); + List channels = channelMapper.selectList(new LambdaQueryWrapper() + .eq(ChannelEntity::getWorkspaceId, workspaceId) + .eq(ChannelEntity::getEnabled, true)); + + StringBuilder sb = new StringBuilder(); + sb.append("# 当前 workspace 可用数字员工\n["); + boolean first = true; + for (AgentEntity a : agents) { + if (!first) sb.append(","); + first = false; + sb.append("{\"agentId\":").append(a.getId()) + .append(",\"name\":\"").append(escape(a.getName())) + .append("\",\"description\":\"") + .append(escape(a.getDescription() == null ? "" : a.getDescription())) + .append("\"}"); + } + sb.append("]\n\n# 当前 workspace 可用渠道\n["); + first = true; + for (ChannelEntity c : channels) { + if (!first) sb.append(","); + first = false; + sb.append("{\"channelType\":\"").append(escape(c.getChannelType())) + .append("\",\"name\":\"").append(escape(c.getName())) + .append("\"}"); + } + sb.append("]\n\n优先使用这些真实 agentId 和 channelType。不存在的 ID 必须用 TODO_* 占位,不要编造。\n"); + + // Few-shot exemplars from the template library — the LLM stays + // closer to canonical shapes when it has 2-3 concrete examples + // in the system prompt. + sb.append("\n# 模板示例(参考,不必照抄)\n"); + for (WorkflowDraftTemplate t : templateLibrary.all()) { + sb.append("## ").append(t.id()).append(" — ").append(t.label()).append("\n"); + sb.append(t.description()).append("\n"); + sb.append("draft: ").append(t.draftJson()).append("\n"); + if (t.triggerDraftsJson() != null && !"[]".equals(t.triggerDraftsJson())) { + sb.append("triggerDrafts: ").append(t.triggerDraftsJson()).append("\n"); + } + } + return sb.toString(); + } + + private JsonNode parseStrict(String raw) { + // Some models still wrap the JSON in a ```json fence even when + // the prompt says "no Markdown". Strip the fences before parsing + // so we don't reject otherwise-valid output. + String cleaned = raw.trim(); + if (cleaned.startsWith("```")) { + int firstNl = cleaned.indexOf('\n'); + if (firstNl > 0) cleaned = cleaned.substring(firstNl + 1); + int closeFence = cleaned.lastIndexOf("```"); + if (closeFence > 0) cleaned = cleaned.substring(0, closeFence); + cleaned = cleaned.trim(); + } + try { + return objectMapper.readTree(cleaned); + } catch (Exception e) { + throw new IllegalStateException( + "Workflow draft generator returned non-JSON: " + e.getMessage() + + " — raw: " + truncate(raw), e); + } + } + + private List readStringArray(JsonNode root, String... path) { + JsonNode node = root; + for (String p : path) node = node.path(p); + if (!node.isArray()) return List.of(); + List out = new ArrayList<>(node.size()); + for (JsonNode item : node) { + if (item.isTextual()) out.add(item.asText()); + } + return out; + } + + private static List appendWarning(List existing, String msg) { + List next = new ArrayList<>(existing == null ? List.of() : existing); + next.add(msg); + return next; + } + + private static String escape(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\"") + .replace("\n", " ").replace("\r", " "); + } + + private static String truncate(String s) { + if (s == null) return ""; + return s.length() <= 400 ? s : s.substring(0, 400) + "…"; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/draftgen/WorkflowDraftTemplate.java b/mateclaw-server/src/main/java/vip/mate/workflow/draftgen/WorkflowDraftTemplate.java new file mode 100644 index 00000000..af232149 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/draftgen/WorkflowDraftTemplate.java @@ -0,0 +1,43 @@ +package vip.mate.workflow.draftgen; + +import java.util.List; + +/** + * One named exemplar in the workflow template library. + * + *

Templates serve two purposes: + *

    + *
  1. As few-shot examples inside the system prompt — the LLM sees + * "here are five canonical shapes; pick the closest and adapt the + * fields" instead of inventing structure from scratch. RFC v0 + * authors should never see anything more exotic than these + * shapes.
  2. + *
  3. As "apply template" entries the UI or the + * workflow_draft_generate tool can drop in directly when the + * user's description matches a canonical pattern (saves a + * generation roundtrip and stays cheaper / faster).
  4. + *
+ * + *

{@code matchHints} is a small bag of natural-language phrases the + * tool can use to short-circuit to a template before calling the LLM — + * if the user says "周一汇总" or "weekly summary" we already know which + * shape they mean. + */ +public record WorkflowDraftTemplate( + /** Stable kebab-case id; surfaces in the API response. */ + String id, + /** Short bilingual label; the UI's "apply template" picker shows this. */ + String label, + /** One-sentence description in user-facing prose. */ + String description, + /** Natural-language phrases that should bias toward this template. */ + List matchHints, + /** Workflow draft JSON; placeholders like TODO_AGENT_ID stay + * in the body until the UI / tool fills them. */ + String draftJson, + /** Trigger drafts attached to this template, if any. Stored as + * serialised JSON arrays so the prompt doesn't have to know + * about Java types. */ + String triggerDraftsJson +) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/draftgen/WorkflowDraftTemplateLibrary.java b/mateclaw-server/src/main/java/vip/mate/workflow/draftgen/WorkflowDraftTemplateLibrary.java new file mode 100644 index 00000000..bd598533 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/draftgen/WorkflowDraftTemplateLibrary.java @@ -0,0 +1,210 @@ +package vip.mate.workflow.draftgen; + +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Small in-process library of canonical workflow shapes. Used as + * few-shot exemplars in the system prompt AND as "apply template" + * entries operators / agents can drop in directly. Kept as code + * constants rather than a DB table so the templates version with the + * runtime that interprets them — a template that references modes the + * runtime doesn't support yet should never ship. + * + *

Templates are intentionally minimal: 5-7 shapes that cover what + * the v0 reviewer flagged as the actual customer use cases (weekly + * summary, approval-and-notify, customer-message routing, chained + * workflow, daily memory write). New shapes only get added when the + * customer evidence is in. + */ +@Component +public class WorkflowDraftTemplateLibrary { + + private final List templates = List.of( + weeklySummary(), + approvalAndNotify(), + customerMessageRouting(), + chainedWorkflow(), + dailyMemoryWrite(), + parallelAnalysis(), + channelAlertOnFailure() + ); + + public List all() { + return templates; + } + + /** Look up a template by id; returns null when no match. */ + public WorkflowDraftTemplate byId(String id) { + if (id == null) return null; + return templates.stream() + .filter(t -> id.equals(t.id())) + .findFirst().orElse(null); + } + + // ===== template definitions ===== + + private static WorkflowDraftTemplate weeklySummary() { + return new WorkflowDraftTemplate( + "weekly-summary", + "周报汇总 / Weekly summary", + "每周固定时间让数字员工汇总数据,再发到群里。常见于销售周报、运营日报。", + List.of("每周", "周报", "周一", "weekly", "summary", "汇总"), + """ + {"steps":[ + {"name":"collect-data","agentName":"TODO_DATA_AGENT","mode":{"type":"sequential"}, + "promptTemplate":"汇总本周的{{ inputs.topic }}并输出 JSON","outputVar":"summary","outputContentType":"json"}, + {"name":"notify-group", + "mode":{"type":"dispatch_channel","channels":["TODO_SELECT_CHANNEL"], + "targets":{"TODO_SELECT_CHANNEL":"TODO_TARGET_ID"}, + "content":"本周汇总:{{ outputs.summary }}"}} + ]}""", + """ + [{"name":"weekly-summary-cron","patternType":"cron","enabled":false, + "patternJson":{"cron":"0 0 9 ? * MON","timezone":"Asia/Shanghai"}, + "targetType":"workflow", + "payloadTemplate":"{\\"topic\\":\\"销售\\"}"}]""" + ); + } + + private static WorkflowDraftTemplate approvalAndNotify() { + return new WorkflowDraftTemplate( + "approval-and-notify", + "审批后通知 / Approval then notify", + "数字员工出方案 → 老板审批 → 通过后发到群里。常见于费用申请、采购、合同。", + List.of("审批", "确认", "老板", "approval", "approve", "确认通过"), + """ + {"steps":[ + {"name":"draft-proposal","agentName":"TODO_DRAFTER","mode":{"type":"sequential"}, + "promptTemplate":"为 {{ inputs.topic }} 起草一个方案","outputVar":"proposal","outputContentType":"text"}, + {"name":"manager-approve", + "mode":{"type":"await_approval","approvalKind":"manager", + "approverChannels":["web"], + "approvalMessage":"请审批方案:{{ outputs.proposal }}", + "timeoutSecs":86400}}, + {"name":"notify-group", + "mode":{"type":"dispatch_channel","channels":["TODO_SELECT_CHANNEL"], + "targets":{"TODO_SELECT_CHANNEL":"TODO_TARGET_ID"}, + "content":"方案已通过:{{ outputs.proposal }}"}} + ]}""", + "[]" + ); + } + + private static WorkflowDraftTemplate customerMessageRouting() { + return new WorkflowDraftTemplate( + "customer-message-routing", + "客户消息路由 / Customer message routing", + "渠道里出现关键词时,让客服员工应对,并把结果记到员工记忆。", + List.of("客户", "客服", "关键词", "customer", "support", "回复"), + """ + {"steps":[ + {"name":"answer-customer","agentName":"TODO_SUPPORT_AGENT","mode":{"type":"sequential"}, + "promptTemplate":"客户说:{{ inputs.content }}。请用礼貌的语气回复。", + "outputVar":"reply","outputContentType":"text"}, + {"name":"send-reply", + "mode":{"type":"dispatch_channel","channels":["TODO_SELECT_CHANNEL"], + "targets":{"TODO_SELECT_CHANNEL":"{{ inputs.sender }}"}, + "content":"{{ outputs.reply }}"}}, + {"name":"remember-issue", + "mode":{"type":"write_memory","employeeId":"TODO_SUPPORT_AGENT", + "file":"customer-issues.md","mergeStrategy":"append", + "content":"### {{ inputs.sender }}\\n{{ inputs.content }}\\n回复:{{ outputs.reply }}\\n"}} + ]}""", + """ + [{"name":"customer-keyword","patternType":"channel_message","enabled":false, + "patternJson":{"channelType":"TODO_SELECT_CHANNEL","contentContains":"发票"}, + "targetType":"workflow", + "payloadTemplate":"{\\"content\\":\\"{{ event.content }}\\",\\"sender\\":\\"{{ event.senderId }}\\"}"}]""" + ); + } + + private static WorkflowDraftTemplate chainedWorkflow() { + return new WorkflowDraftTemplate( + "chained-workflow", + "上游完成后接力 / Chained on upstream completion", + "上游 workflow 跑完后自动接一段处理:常见于 ETL 接出报表、运营接审计。", + List.of("接力", "上游", "完成后", "chained", "after"), + """ + {"steps":[ + {"name":"post-process","agentName":"TODO_AGENT","mode":{"type":"sequential"}, + "promptTemplate":"上游 run {{ inputs.sourceWorkflowId }} 已完成(state={{ inputs.state }}),请处理后续。", + "outputVar":"summary","outputContentType":"text"} + ]}""", + """ + [{"name":"after-upstream","patternType":"workflow_completion","enabled":false, + "patternJson":{"sourceWorkflowId":"TODO_WORKFLOW_ID","stateFilter":"succeeded"}, + "targetType":"workflow", + "payloadTemplate":"{\\"sourceWorkflowId\\":\\"{{ event.sourceWorkflowId }}\\",\\"state\\":\\"{{ event.state }}\\"}"}]""" + ); + } + + private static WorkflowDraftTemplate dailyMemoryWrite() { + return new WorkflowDraftTemplate( + "daily-memory-write", + "每日记入员工记忆 / Daily memory append", + "每天定时让员工写一段记忆,作为后续对话的上下文。", + List.of("每天", "daily", "记忆", "写入", "memory"), + """ + {"steps":[ + {"name":"summarize-day","agentName":"TODO_AGENT","mode":{"type":"sequential"}, + "promptTemplate":"用一段话总结今天的{{ inputs.topic }}。", + "outputVar":"summary","outputContentType":"text"}, + {"name":"persist-memory", + "mode":{"type":"write_memory","employeeId":"TODO_EMPLOYEE_ID", + "file":"daily-log.md","mergeStrategy":"append", + "content":"### {{ inputs.date }}\\n{{ outputs.summary }}\\n"}} + ]}""", + """ + [{"name":"daily-memory-cron","patternType":"cron","enabled":false, + "patternJson":{"cron":"0 0 22 * * ?","timezone":"Asia/Shanghai"}, + "targetType":"workflow", + "payloadTemplate":"{\\"topic\\":\\"工作\\",\\"date\\":\\"{{ trigger.firedAt }}\\"}"}]""" + ); + } + + private static WorkflowDraftTemplate parallelAnalysis() { + return new WorkflowDraftTemplate( + "parallel-analysis", + "并行多角度分析 / Parallel multi-angle analysis", + "三个不同员工同时从不同角度分析同一份输入,最后由 collect 汇合。", + List.of("分别", "并行", "多角度", "parallel", "fan_out"), + """ + {"steps":[ + {"name":"angle-finance","agentName":"TODO_FINANCE_AGENT","mode":{"type":"fan_out"}, + "promptTemplate":"从财务角度分析:{{ inputs.topic }}", + "outputVar":"finance","outputContentType":"text"}, + {"name":"angle-operations","agentName":"TODO_OPS_AGENT","mode":{"type":"fan_out"}, + "promptTemplate":"从运营角度分析:{{ inputs.topic }}", + "outputVar":"ops","outputContentType":"text"}, + {"name":"angle-customer","agentName":"TODO_CUSTOMER_AGENT","mode":{"type":"fan_out"}, + "promptTemplate":"从客户角度分析:{{ inputs.topic }}", + "outputVar":"customer","outputContentType":"text"}, + {"name":"merge-views","mode":{"type":"collect"}} + ]}""", + "[]" + ); + } + + private static WorkflowDraftTemplate channelAlertOnFailure() { + return new WorkflowDraftTemplate( + "channel-alert-on-failure", + "上游失败时报警 / Alert on upstream failure", + "上游 workflow 跑失败时立即推送到值班渠道,常见于关键 ETL / 自动化作业的兜底。", + List.of("失败", "报警", "alert", "failure", "失败时"), + """ + {"steps":[ + {"name":"alert-oncall", + "mode":{"type":"dispatch_channel","channels":["TODO_SELECT_CHANNEL"], + "targets":{"TODO_SELECT_CHANNEL":"TODO_ONCALL_TARGET"}, + "content":"⚠ 上游 workflow run {{ inputs.runId }} 失败:{{ inputs.errorMessage }}"}} + ]}""", + """ + [{"name":"upstream-failure","patternType":"workflow_completion","enabled":false, + "patternJson":{"sourceWorkflowId":"TODO_WORKFLOW_ID","stateFilter":"failed"}, + "targetType":"workflow", + "payloadTemplate":"{\\"runId\\":\\"{{ event.runId }}\\",\\"errorMessage\\":\\"{{ event.errorMessage }}\\"}"}]""" + ); + } +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 0229fc38..6714472a 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -891,6 +891,35 @@ export const workflowApi = { outcome, payload, }), + /** Generate a workflow draft from natural language. Returns the parsed + * shape; the caller is responsible for creating a workflow row + + * saving the draft if the user accepts it. */ + generateDraft: (description: string) => + http.post('/workflows/draft/generate', { description }), + /** Canonical workflow templates the generator can apply directly. */ + listDraftTemplates: () => + http.get('/workflows/draft/templates'), +} + +export interface GeneratedDraft { + name: string + description: string + draftJson: string + triggerDrafts: Array> + warnings: string[] + missingFields: string[] + confidence?: number | null + compileOk: boolean + compileErrors: WorkflowCompileError[] +} + +export interface WorkflowDraftTemplate { + id: string + label: string + description: string + matchHints: string[] + draftJson: string + triggerDraftsJson: string } // ==================== Trigger ==================== diff --git a/mateclaw-ui/src/components/workflow/GenerateWorkflowDialog.vue b/mateclaw-ui/src/components/workflow/GenerateWorkflowDialog.vue new file mode 100644 index 00000000..3d301ecd --- /dev/null +++ b/mateclaw-ui/src/components/workflow/GenerateWorkflowDialog.vue @@ -0,0 +1,317 @@ +