feat(workflow): natural-language → workflow draft generator + agent tool

This commit is contained in:
matevip 2026-05-08 15:08:58 +08:00
parent de89df6900
commit 826c20639a
11 changed files with 1216 additions and 1 deletions

View File

@ -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<List<vip.mate.workflow.draftgen.WorkflowDraftTemplate>> 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) {}

View File

@ -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.
*
* <p>{@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.
*
* <p>{@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.
*
* <p>{@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<Map<String, Object>> triggerDrafts,
List<String> warnings,
List<String> missingFields,
Double confidence,
boolean compileOk,
List<vip.mate.workflow.compiler.CompileError> compileErrors
) {}

View File

@ -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.
*
* <p>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.
*
* <p>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();
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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 + promptTemplateoutputContentType 只能 text json
fan_out 至少 2 个连续 fan_out后接 collect每个分支必须 agentId/agentName + promptTemplate
collect 不带 agentIdagentNamepromptTemplate只能跟在 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<String> warnings = readStringArray(root, "metadata", "warnings");
List<String> missingFields = readStringArray(root, "metadata", "missingFields");
// The runtime only consumes the steps part of the draft strip
// everything else into a clean {steps:[...]} shape.
Map<String, Object> draftRoot = new LinkedHashMap<>();
draftRoot.put("steps", objectMapper.convertValue(root.get("steps"),
new TypeReference<List<Map<String, Object>>>() {}));
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<Map<String, Object>> triggerDrafts = new ArrayList<>();
if (root.has("triggerDrafts") && root.get("triggerDrafts").isArray()) {
triggerDrafts = objectMapper.convertValue(root.get("triggerDrafts"),
new TypeReference<List<Map<String, Object>>>() {});
for (Map<String, Object> td : triggerDrafts) {
// Belt-and-suspenders: never trust the LLM to honor enabled=false.
td.put("enabled", false);
}
}
// --- 7. compile preview ---------------------------------------
boolean compileOk;
List<vip.mate.workflow.compiler.CompileError> 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<AgentEntity> agents = agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
.eq(AgentEntity::getWorkspaceId, workspaceId)
.eq(AgentEntity::getEnabled, true));
List<ChannelEntity> channels = channelMapper.selectList(new LambdaQueryWrapper<ChannelEntity>()
.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<String> readStringArray(JsonNode root, String... path) {
JsonNode node = root;
for (String p : path) node = node.path(p);
if (!node.isArray()) return List.of();
List<String> out = new ArrayList<>(node.size());
for (JsonNode item : node) {
if (item.isTextual()) out.add(item.asText());
}
return out;
}
private static List<String> appendWarning(List<String> existing, String msg) {
List<String> 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) + "";
}
}

View File

@ -0,0 +1,43 @@
package vip.mate.workflow.draftgen;
import java.util.List;
/**
* One named exemplar in the workflow template library.
*
* <p>Templates serve two purposes:
* <ol>
* <li>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.</li>
* <li>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).</li>
* </ol>
*
* <p>{@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<String> 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
) {
}

View File

@ -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.
*
* <p>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<WorkflowDraftTemplate> templates = List.of(
weeklySummary(),
approvalAndNotify(),
customerMessageRouting(),
chainedWorkflow(),
dailyMemoryWrite(),
parallelAnalysis(),
channelAlertOnFailure()
);
public List<WorkflowDraftTemplate> 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 }}\\"}"}]"""
);
}
}

View File

@ -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<GeneratedDraft>('/workflows/draft/generate', { description }),
/** Canonical workflow templates the generator can apply directly. */
listDraftTemplates: () =>
http.get<WorkflowDraftTemplate[]>('/workflows/draft/templates'),
}
export interface GeneratedDraft {
name: string
description: string
draftJson: string
triggerDrafts: Array<Record<string, unknown>>
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 ====================

View File

@ -0,0 +1,317 @@
<template>
<Teleport to="body">
<div v-if="visible" class="modal-overlay" @click.self="close">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-header">
<h3>{{ t('workflows.generate.title') }}</h3>
<button class="modal-close" @click="close" aria-label="close">×</button>
</div>
<div class="modal-body">
<p class="hint">{{ t('workflows.generate.hint') }}</p>
<textarea
ref="descRef"
v-model="description"
class="form-input form-textarea"
rows="5"
spellcheck="false"
:placeholder="t('workflows.generate.placeholder')"
/>
<div v-if="result" class="generate-result">
<div class="result-summary">
<strong>{{ result.name }}</strong>
<span class="confidence" v-if="result.confidence != null">
· confidence {{ Math.round((result.confidence ?? 0) * 100) }}%
</span>
<span v-if="result.compileOk" class="status-pill ok">
{{ t('workflows.generate.compileOk') }}
</span>
<span v-else class="status-pill err">
{{ t('workflows.generate.compileFail', { count: result.compileErrors.length }) }}
</span>
</div>
<p v-if="result.description" class="result-desc">{{ result.description }}</p>
<ul v-if="result.missingFields.length" class="result-list">
<li v-for="(m, i) in result.missingFields" :key="`miss-${i}`">
<span class="result-tag">{{ t('workflows.generate.missing') }}</span> {{ m }}
</li>
</ul>
<ul v-if="result.warnings.length" class="result-list">
<li v-for="(w, i) in result.warnings" :key="`warn-${i}`">
<span class="result-tag warn">{{ t('workflows.generate.warning') }}</span> {{ w }}
</li>
</ul>
<details class="result-raw">
<summary>{{ t('workflows.generate.previewDraft') }}</summary>
<pre>{{ result.draftJson }}</pre>
</details>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="close">{{ t('common.cancel') }}</button>
<button
v-if="!result"
class="btn-primary"
:disabled="loading || !description.trim()"
@click="onGenerate"
>
{{ loading ? t('workflows.generate.running') : t('workflows.generate.submit') }}
</button>
<template v-else>
<button class="btn-secondary" @click="onRetry">
{{ t('workflows.generate.retry') }}
</button>
<button class="btn-primary" :disabled="loading" @click="onAccept">
{{ t('workflows.generate.accept') }}
</button>
</template>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { workflowApi, type GeneratedDraft } from '@/api'
interface Props {
modelValue: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
/** User accepted the draft — parent should create the workflow + save the draft. */
(e: 'accept', payload: GeneratedDraft): void
}>()
const { t } = useI18n()
const visible = ref(props.modelValue)
const description = ref('')
const loading = ref(false)
const result = ref<GeneratedDraft | null>(null)
const descRef = ref<HTMLTextAreaElement | null>(null)
watch(
() => props.modelValue,
async (open) => {
visible.value = open
if (open) {
description.value = ''
result.value = null
loading.value = false
await nextTick()
descRef.value?.focus()
document.addEventListener('keydown', onKey)
} else {
document.removeEventListener('keydown', onKey)
}
}
)
watch(visible, (v) => emit('update:modelValue', v))
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && visible.value) close()
}
function close() {
visible.value = false
}
async function onGenerate() {
const desc = description.value.trim()
if (!desc) return
loading.value = true
try {
const res = await workflowApi.generateDraft(desc)
result.value = res.data as unknown as GeneratedDraft
} catch (e) {
ElMessage.error(t('workflows.generate.failed', { msg: (e as Error).message }))
} finally {
loading.value = false
}
}
function onRetry() {
result.value = null
}
function onAccept() {
if (!result.value) return
emit('accept', result.value)
close()
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15, 10, 8, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 2100;
}
.modal {
width: 640px;
max-width: 100%;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 14px;
overflow: hidden;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--mc-border-light);
}
.modal-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.modal-close {
width: 26px;
height: 26px;
border: none;
background: none;
color: var(--mc-text-tertiary);
font-size: 22px;
cursor: pointer;
border-radius: 6px;
}
.modal-close:hover {
background: var(--mc-bg-muted);
color: var(--mc-text-primary);
}
.modal-body {
padding: 18px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.hint {
margin: 0;
font-size: 12.5px;
color: var(--mc-text-secondary);
line-height: 1.5;
}
.form-input,
.form-textarea {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--mc-border);
border-radius: 8px;
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
font-size: 13.5px;
box-sizing: border-box;
font-family: inherit;
}
.form-textarea { resize: vertical; }
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: var(--mc-primary);
}
.generate-result {
margin-top: 6px;
padding: 12px;
border: 1px solid var(--mc-border-light);
border-radius: 8px;
background: var(--mc-bg-sunken);
display: flex;
flex-direction: column;
gap: 8px;
}
.result-summary {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 13px;
}
.confidence { font-size: 11.5px; opacity: 0.7; }
.status-pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
font-weight: 500;
}
.status-pill.ok { background: rgba(46, 204, 113, 0.18); color: #1e8449; }
.status-pill.err { background: rgba(231, 76, 60, 0.14); color: var(--mc-danger); }
.result-desc { margin: 0; font-size: 12.5px; color: var(--mc-text-secondary); }
.result-list { margin: 4px 0 0; padding: 0 0 0 4px; list-style: none; font-size: 12px; }
.result-list li { padding: 3px 0; line-height: 1.5; }
.result-tag {
display: inline-block;
padding: 1px 6px;
margin-right: 5px;
border-radius: 3px;
background: var(--mc-bg-muted);
font-size: 10px;
color: var(--mc-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.result-tag.warn { background: rgba(245, 158, 11, 0.16); color: #b45309; }
.result-raw summary {
font-size: 11px;
color: var(--mc-text-tertiary);
cursor: pointer;
}
.result-raw pre {
margin: 6px 0 0;
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 11px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border-light);
padding: 8px;
border-radius: 6px;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow: auto;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 18px 16px;
border-top: 1px solid var(--mc-border-light);
}
.btn-primary,
.btn-secondary {
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
}
.btn-primary {
background: var(--mc-primary);
color: var(--mc-text-inverse, #ffffff);
border-color: var(--mc-primary);
}
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-primary:hover:not(:disabled) { background: var(--mc-primary-hover, var(--mc-primary)); }
.btn-secondary {
background: transparent;
color: var(--mc-text-secondary);
border-color: var(--mc-border);
}
.btn-secondary:hover {
background: var(--mc-bg-muted);
color: var(--mc-text-primary);
}
</style>

View File

@ -2021,6 +2021,22 @@ export default {
deleteContent: 'Delete workflow "{name}"? This is reversible via the audit log.',
},
selectHint: 'Select a workflow on the left, or click "New Workflow" to start a fresh draft.',
generate: {
entryButton: 'Generate from description',
title: 'Generate workflow draft from natural language',
hint: 'Describe your business process in a sentence — the AI generates a workflow JSON draft. The draft is saved unpublished and no triggers are auto-enabled; you review and publish afterwards.',
placeholder: 'e.g. Every Monday at 9 AM, the sales agent summarises last week\'s data; once the manager approves, post to the Feishu ops group',
submit: 'Generate',
running: 'Generating…',
retry: 'Try again',
accept: 'Accept & open editor',
compileOk: 'Compile preview OK',
compileFail: 'Compile preview failed ({count})',
missing: 'missing',
warning: 'warning',
previewDraft: 'Preview generated JSON',
failed: 'Generate failed: {msg}',
},
paused: {
header: 'Paused runs ({count})',
empty: 'No paused runs.',

View File

@ -2033,6 +2033,22 @@ export default {
deleteContent: '将删除工作流「{name}」?此操作可通过审计日志撤销。',
},
selectHint: '在左侧选择一个工作流,或点击「新建工作流」开始。',
generate: {
entryButton: '从描述生成',
title: '从自然语言生成工作流草稿',
hint: '用一句话描述你的业务流程AI 会生成 workflow JSON 草稿。生成后会保留为草稿,需要你确认后再发布;不会自动启用任何触发器。',
placeholder: '例如:每周一早上 9 点让销售员工汇总上周数据,老板确认后发到飞书运营群',
submit: '生成草稿',
running: '生成中...',
retry: '重试',
accept: '采用并打开编辑器',
compileOk: '编译预校验通过',
compileFail: '编译预校验未通过 ({count})',
missing: '缺失',
warning: '警告',
previewDraft: '预览生成的 JSON',
failed: '生成失败:{msg}',
},
paused: {
header: '待恢复运行 ({count})',
empty: '当前没有暂停中的运行。',

View File

@ -8,7 +8,10 @@
<h1 class="mc-page-title">{{ t('workflows.title') }}</h1>
<p class="mc-page-desc">{{ t('workflows.desc') }}</p>
</div>
<button class="btn-primary" @click="openCreate">{{ t('workflows.newWorkflow') }}</button>
<div class="header-actions">
<button class="btn-ghost" @click="openGenerate">{{ t('workflows.generate.entryButton') }}</button>
<button class="btn-primary" @click="openCreate">{{ t('workflows.newWorkflow') }}</button>
</div>
</div>
<div class="workflows-grid">
@ -205,6 +208,7 @@
<CreateWorkflowDialog v-model="createDialogOpen" :loading="busy" @submit="onCreateSubmit" />
<PublishDialog v-model="publishDialogOpen" :loading="busy" @submit="onPublishSubmit" />
<GenerateWorkflowDialog v-model="generateDialogOpen" @accept="onGenerateAccept" />
</div>
</template>
@ -224,6 +228,7 @@ import {
type WorkflowCompileFailure,
type PausedRunSummary,
type ResumeOutcome,
type GeneratedDraft,
} from '@/api'
import type { Channel } from '@/types'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
@ -232,6 +237,7 @@ import StepPropertyPanel from '@/components/workflow/StepPropertyPanel.vue'
import WorkflowJsonEditor from '@/components/workflow/WorkflowJsonEditor.vue'
import CreateWorkflowDialog from '@/components/workflow/CreateWorkflowDialog.vue'
import PublishDialog from '@/components/workflow/PublishDialog.vue'
import GenerateWorkflowDialog from '@/components/workflow/GenerateWorkflowDialog.vue'
import type { StepNodeData, RawStep } from '@/composables/useWorkflowGraph'
import {
readStepAtIndex,
@ -319,6 +325,43 @@ function onStepDelete(payload: { index: number }) {
const createDialogOpen = ref(false)
const publishDialogOpen = ref(false)
const generateDialogOpen = ref(false)
function openGenerate() {
if (!workspaceId.value) return
generateDialogOpen.value = true
}
async function onGenerateAccept(draft: GeneratedDraft) {
if (!workspaceId.value) return
busy.value = true
try {
// Create a workflow row + save the generated draft, then jump
// straight into the canvas so the operator can finish the TODOs.
const res = await workflowApi.create({
workspaceId: workspaceId.value,
name: draft.name,
description: draft.description || undefined,
enabled: true,
})
const created = res.data as unknown as WorkflowSummary
if (created?.id) {
await workflowApi.saveDraft(created.id, draft.draftJson)
await reload()
await select(created.id)
// If the generator's preview compile failed, surface the errors
// inline so the operator sees them immediately on first load.
if (!draft.compileOk && draft.compileErrors.length) {
compileErrors.value = draft.compileErrors
}
ElMessage.success(t('workflows.generate.compileOk'))
}
} catch (e) {
ElMessage.error(t('workflows.generate.failed', { msg: (e as Error).message }))
} finally {
busy.value = false
}
}
// Live JSON-syntax check on the textarea so the operator sees parse errors
// immediately, instead of waiting for compile to round-trip.