From 28049055009a6b4e9ed2459d615f19517d7f0145 Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 8 May 2026 15:09:14 +0800 Subject: [PATCH] fix(workflow,trigger): template preview-compile + 3 alpha consistency cleanups --- .../mate/workflow/api/WorkflowController.java | 23 +++++ mateclaw-ui/src/api/index.ts | 7 ++ .../workflow/GenerateWorkflowDialog.vue | 93 ++++++++++++++++--- .../workflow/TriggerPatternForm.vue | 14 +++ mateclaw-ui/src/i18n/locales/en-US.ts | 7 +- mateclaw-ui/src/i18n/locales/zh-CN.ts | 7 +- mateclaw-ui/src/views/Triggers.vue | 24 ++++- 7 files changed, 158 insertions(+), 17 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/api/WorkflowController.java b/mateclaw-server/src/main/java/vip/mate/workflow/api/WorkflowController.java index 692e68f6..177b42b8 100644 --- a/mateclaw-server/src/main/java/vip/mate/workflow/api/WorkflowController.java +++ b/mateclaw-server/src/main/java/vip/mate/workflow/api/WorkflowController.java @@ -263,6 +263,29 @@ public class WorkflowController { return R.ok(draftTemplates.all()); } + @Operation(summary = "Compile arbitrary draft JSON without persisting — used by the template picker / generator preview to surface real ACL + schema diagnostics before a workflow row exists.") + @PostMapping("/draft/preview-compile") + public ResponseEntity previewCompile(@RequestBody WorkflowDraftRequest body, + @RequestHeader("X-Workspace-Id") long workspaceId) { + if (body == null || body.draftJson() == null || body.draftJson().isBlank()) { + return ResponseEntity.badRequest().body(R.fail("draftJson is required")); + } + WorkflowCompiler.Result result; + try { + result = compiler.compile(body.draftJson(), + new PublishContext(workspaceId, 0L), aclPort); + } catch (vip.mate.workflow.compiler.WorkflowParseException e) { + return ResponseEntity.unprocessableEntity().body(buildCompileFailure(List.of( + new vip.mate.workflow.compiler.CompileError( + "graph.parse_failed", "/", e.getMessage())))); + } + if (!result.ok()) { + return ResponseEntity.unprocessableEntity() + .body(buildCompileFailure(result.errors())); + } + return ResponseEntity.ok(R.ok()); + } + public record DraftGenerateRequest(String description) {} /** Narrow patch shape for {@link #update}; keeps the metadata path diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 6714472a..85bb5ca6 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -899,6 +899,13 @@ export const workflowApi = { /** Canonical workflow templates the generator can apply directly. */ listDraftTemplates: () => http.get('/workflows/draft/templates'), + /** Compile arbitrary draft JSON without persisting. Used by the template + * picker / generator-result preview to give the operator the same compile + * signal the existing /workflows/{id}/compile endpoint provides for saved + * workflows. Resolves on success; rejects with an axios error whose + * response.data.data carries the WorkflowCompileFailure on a 422. */ + previewCompileDraft: (draftJson: string) => + http.post('/workflows/draft/preview-compile', { draftJson }), } export interface GeneratedDraft { diff --git a/mateclaw-ui/src/components/workflow/GenerateWorkflowDialog.vue b/mateclaw-ui/src/components/workflow/GenerateWorkflowDialog.vue index 700cc620..d2358dbb 100644 --- a/mateclaw-ui/src/components/workflow/GenerateWorkflowDialog.vue +++ b/mateclaw-ui/src/components/workflow/GenerateWorkflowDialog.vue @@ -39,7 +39,10 @@ · confidence {{ Math.round((result.confidence ?? 0) * 100) }}% - + + … {{ t('workflows.generate.compileChecking') }} + + ✓ {{ t('workflows.generate.compileOk') }} @@ -57,6 +60,14 @@ {{ t('workflows.generate.warning') }} {{ w }} +
    +
  • + {{ t('workflows.generate.compileError') }} + {{ err.path }} + {{ err.message }} +
  • +
{{ t('workflows.generate.triggersTitle') }} ({{ result.triggerDrafts.length }})
    @@ -87,8 +98,8 @@ -
@@ -101,7 +112,13 @@ import { computed, nextTick, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { ElMessage } from 'element-plus' -import { workflowApi, type GeneratedDraft, type WorkflowDraftTemplate } from '@/api' +import { + workflowApi, + type GeneratedDraft, + type WorkflowCompileError, + type WorkflowCompileFailure, + type WorkflowDraftTemplate, +} from '@/api' interface Props { modelValue: boolean @@ -125,6 +142,11 @@ const selectedTemplateId = ref('') const selectedTemplate = computed(() => templates.value.find((t) => t.id === selectedTemplateId.value) ?? null ) +// While the preview-compile roundtrip is in flight after a template apply +// we suppress the green/red pill and show a neutral "checking" state, so +// the operator never sees the optimistic "compile OK" before the server +// has actually evaluated the draft against schema + ACL. +const previewCompiling = ref(false) async function loadTemplates() { try { @@ -143,8 +165,14 @@ async function loadTemplates() { * already carries a known-good {steps:[...]} JSON + triggerDraftsJson; * we synthesise a GeneratedDraft so the rest of the result-panel UI * (compile pill / missing fields / triggers) keeps working. + * + *

Compile state is verified by hitting /workflows/draft/preview-compile + * after the template is applied — the LLM path returns compileOk straight + * from the generator, so the template path needs the same guarantee, not + * an optimistic "compileOk: true" that hides ACL / schema breakage until + * publish time. */ -function onTemplatePick() { +async function onTemplatePick() { const tpl = selectedTemplate.value if (!tpl) { result.value = null @@ -157,11 +185,21 @@ function onTemplatePick() { } catch (e) { console.warn('template triggerDraftsJson parse failed', e) } - // Templates ship with TODO_* placeholders — list them in missingFields - // so the operator notices before clicking Accept. + // Templates ship with TODO_* placeholders both in the workflow draft + // body and inside trigger patternJson (e.g. workflow_completion's + // TODO_WORKFLOW_ID lives on the trigger, not the workflow). Scan both + // and dedupe so the operator sees every placeholder that needs filling + // before clicking Accept. + const todoSet = new Set() + for (const m of tpl.draftJson.match(/TODO_[A-Z_]+/g) ?? []) todoSet.add(m) + for (const m of (tpl.triggerDraftsJson || '').match(/TODO_[A-Z_]+/g) ?? []) todoSet.add(m) const missing: string[] = [] - const todoMatches = tpl.draftJson.match(/TODO_[A-Z_]+/g) ?? [] - for (const m of new Set(todoMatches)) missing.push(`${m} (template placeholder)`) + for (const m of todoSet) missing.push(`${m} (template placeholder)`) + // Set the pending flag first so the pill renders the "checking" state + // immediately on the same tick we publish the result; otherwise the + // template would flash the red "compile failed" pill for one frame + // because compileOk starts at false. + previewCompiling.value = true result.value = { name: tpl.id, description: tpl.description, @@ -170,12 +208,32 @@ function onTemplatePick() { warnings: [], missingFields: missing, confidence: 1.0, - compileOk: true, + compileOk: false, compileErrors: [], } - // Pre-fill the description box too so the operator can edit + re-run - // through the LLM if the template's prose isn't quite right. if (!description.value.trim()) description.value = tpl.description + // Run the actual compile. Templates with TODO placeholders that resolve + // to ACL-protected ids (agent / channel / employee) will fail here, and + // that failure has to surface — the user can either edit the draft to + // fill placeholders before Accept, or accept the failed draft and fix + // it in the editor with the diagnostics shown. + try { + await workflowApi.previewCompileDraft(tpl.draftJson) + if (result.value && result.value.draftJson === tpl.draftJson) { + result.value.compileOk = true + result.value.compileErrors = [] + } + } catch (e) { + const err = e as { response?: { data?: { data?: WorkflowCompileFailure } } } + const failure = err.response?.data?.data + const errors: WorkflowCompileError[] = failure?.errors ?? [] + if (result.value && result.value.draftJson === tpl.draftJson) { + result.value.compileOk = false + result.value.compileErrors = errors + } + } finally { + previewCompiling.value = false + } } watch( @@ -186,6 +244,7 @@ watch( description.value = '' result.value = null loading.value = false + previewCompiling.value = false selectedTemplateId.value = '' await loadTemplates() await nextTick() @@ -369,6 +428,7 @@ function onAccept() { } .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); } +.status-pill.pending { background: var(--mc-bg-muted); color: var(--mc-text-secondary); } .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; } @@ -384,6 +444,15 @@ function onAccept() { letter-spacing: 0.04em; } .result-tag.warn { background: rgba(245, 158, 11, 0.16); color: #b45309; } +.result-tag.err { background: rgba(231, 76, 60, 0.14); color: var(--mc-danger); } +.err-path { + font-family: 'JetBrains Mono', Consolas, monospace; + font-size: 11px; + background: var(--mc-bg-muted); + padding: 1px 5px; + border-radius: 3px; + margin-right: 5px; +} .trigger-drafts { border-top: 1px dashed var(--mc-border-light); padding-top: 8px; diff --git a/mateclaw-ui/src/components/workflow/TriggerPatternForm.vue b/mateclaw-ui/src/components/workflow/TriggerPatternForm.vue index 4be40a78..d360f3f7 100644 --- a/mateclaw-ui/src/components/workflow/TriggerPatternForm.vue +++ b/mateclaw-ui/src/components/workflow/TriggerPatternForm.vue @@ -53,6 +53,17 @@ /> {{ t('triggers.pattern.senderEqualsHint') }} +

+ {{ t('triggers.pattern.contentContains') }} + + {{ t('triggers.pattern.contentContainsHint') }} +
@@ -177,6 +188,7 @@ interface FormState { timezone?: string channelType?: string senderEquals?: string + contentContains?: string substring?: string agentId?: number | null phase?: string @@ -206,6 +218,7 @@ function loadFromJson(json: string) { if (typeof parsed.timezone === 'string') form.timezone = parsed.timezone if (typeof parsed.channelType === 'string') form.channelType = parsed.channelType if (typeof parsed.senderEquals === 'string') form.senderEquals = parsed.senderEquals + if (typeof parsed.contentContains === 'string') form.contentContains = parsed.contentContains if (typeof parsed.substring === 'string') form.substring = parsed.substring if (typeof parsed.agentId === 'number') form.agentId = parsed.agentId if (typeof parsed.phase === 'string') form.phase = parsed.phase @@ -228,6 +241,7 @@ function buildJsonFromForm(): string { case 'channel_message': if (form.channelType) out.channelType = form.channelType if (form.senderEquals) out.senderEquals = form.senderEquals + if (form.contentContains) out.contentContains = form.contentContains break case 'content_match': if (form.substring) out.substring = form.substring diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index c8b03945..d14f0d56 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -2032,6 +2032,8 @@ export default { accept: 'Accept & open editor', compileOk: 'Compile preview OK', compileFail: 'Compile preview failed ({count})', + compileChecking: 'Validating…', + compileError: 'compile error', missing: 'missing', warning: 'warning', previewDraft: 'Preview generated JSON', @@ -2112,7 +2114,7 @@ export default { }, patternHints: { cron: 'Example: {"cron":"0 0 * * * *","timezone":"UTC"} — every cron change bumps pattern_version.', - channel_message: 'Optional channelType (e.g. feishu) and senderEquals narrow the match.', + channel_message: 'Optional channelType (e.g. feishu), senderEquals and contentContains narrow the match.', webhook: 'Pass-through match. Ensure the HTTP entry validates the webhook secret.', content_match: 'Requires substring (case-insensitive). Empty strings refuse to fire.', agent_lifecycle: 'Optional agentId and phase (spawned / terminated / crashed).', @@ -2129,6 +2131,9 @@ export default { senderEquals: 'Sender equals', senderEqualsPlaceholder: 'Optional sender ID', senderEqualsHint: 'Empty matches any sender; filled narrows to that one sender.', + contentContains: 'Message contains', + contentContainsPlaceholder: 'Optional keyword', + contentContainsHint: 'Case-insensitive substring on the message body. Empty matches any content.', substring: 'Substring', substringPlaceholder: 'order', substringHint: 'Case-insensitive. Empty strings refuse to fire (no broadcast trap).', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index b9d016e6..319cb467 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -2044,6 +2044,8 @@ export default { accept: '采用并打开编辑器', compileOk: '编译预校验通过', compileFail: '编译预校验未通过 ({count})', + compileChecking: '正在校验…', + compileError: '编译错误', missing: '缺失', warning: '警告', previewDraft: '预览生成的 JSON', @@ -2124,7 +2126,7 @@ export default { }, patternHints: { cron: '示例:{"cron":"0 0 * * * *","timezone":"Asia/Shanghai"} —— 每次修改 cron 都会让 pattern_version 自增。', - channel_message: '可填 channelType(如 feishu)和 senderEquals 进一步过滤。', + channel_message: '可填 channelType(如 feishu)、senderEquals 与 contentContains 进一步过滤。', webhook: '透传匹配,请确保 HTTP 入口已校验 webhook 密钥。', content_match: '需要 substring(不区分大小写)。空字符串将拒绝触发。', agent_lifecycle: '可选 agentId 和 phase(spawned / terminated / crashed)。', @@ -2141,6 +2143,9 @@ export default { senderEquals: '发送者匹配', senderEqualsPlaceholder: '可选,发送者 ID', senderEqualsHint: '留空表示任意发送者;填写后只匹配该发送者的消息。', + contentContains: '消息包含关键字', + contentContainsPlaceholder: '可选关键字', + contentContainsHint: '对消息正文做不区分大小写的子串匹配;留空表示不限。', substring: '子串匹配', substringPlaceholder: '订单 / order', substringHint: '不区分大小写。空字符串将拒绝触发(避免无意义广播)。', diff --git a/mateclaw-ui/src/views/Triggers.vue b/mateclaw-ui/src/views/Triggers.vue index 482744d5..cb4cca56 100644 --- a/mateclaw-ui/src/views/Triggers.vue +++ b/mateclaw-ui/src/views/Triggers.vue @@ -80,13 +80,24 @@
@@ -176,6 +187,12 @@ const availableWorkflows = computed(() => workflows.value.filter((w) => w.latestRevisionId) ) +// Show the legacy pattern types only when the trigger being edited +// actually uses one — otherwise they stay hidden so the new-trigger +// flow matches the four-pattern v0 product surface. +const showLegacyContentMatch = computed(() => formState.value?.patternType === 'content_match') +const showLegacyAgentLifecycle = computed(() => formState.value?.patternType === 'agent_lifecycle') + const patternErrors = ref>({}) function onPatternValidation(errs: Record) { patternErrors.value = errs @@ -272,7 +289,8 @@ function patternSummary(row: TriggerSummary): string { case 'channel_message': { const ch = parsed.channelType ? `${parsed.channelType}` : t('triggers.pattern.channelTypeAny') const sender = parsed.senderEquals ? ` · @${parsed.senderEquals}` : '' - return `${ch}${sender}` + const contains = parsed.contentContains ? ` · "${parsed.contentContains}"` : '' + return `${ch}${sender}${contains}` } case 'content_match': { return parsed.substring ? `"${parsed.substring}"` : '—'