mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(workflow): natural-language → workflow draft generator + agent tool
This commit is contained in:
parent
de89df6900
commit
826c20639a
@ -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) {}
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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 + 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<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) + "…";
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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 }}\\"}"}]"""
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 ====================
|
||||
|
||||
317
mateclaw-ui/src/components/workflow/GenerateWorkflowDialog.vue
Normal file
317
mateclaw-ui/src/components/workflow/GenerateWorkflowDialog.vue
Normal 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>
|
||||
@ -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.',
|
||||
|
||||
@ -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: '当前没有暂停中的运行。',
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user