fix(workflow,trigger): template preview-compile + 3 alpha consistency cleanups

This commit is contained in:
matevip 2026-05-08 15:09:14 +08:00
parent 2416d603c4
commit 2804905500
7 changed files with 158 additions and 17 deletions

View File

@ -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

View File

@ -899,6 +899,13 @@ export const workflowApi = {
/** Canonical workflow templates the generator can apply directly. */
listDraftTemplates: () =>
http.get<WorkflowDraftTemplate[]>('/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 {

View File

@ -39,7 +39,10 @@
<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">
<span v-if="previewCompiling" class="status-pill pending">
{{ t('workflows.generate.compileChecking') }}
</span>
<span v-else-if="result.compileOk" class="status-pill ok">
{{ t('workflows.generate.compileOk') }}
</span>
<span v-else class="status-pill err">
@ -57,6 +60,14 @@
<span class="result-tag warn">{{ t('workflows.generate.warning') }}</span> {{ w }}
</li>
</ul>
<ul v-if="!previewCompiling && !result.compileOk && result.compileErrors.length"
class="result-list">
<li v-for="(err, i) in result.compileErrors" :key="`cerr-${i}`">
<span class="result-tag err">{{ t('workflows.generate.compileError') }}</span>
<code class="err-path">{{ err.path }}</code>
{{ err.message }}
</li>
</ul>
<div v-if="result.triggerDrafts.length" class="trigger-drafts">
<header>{{ t('workflows.generate.triggersTitle') }} ({{ result.triggerDrafts.length }})</header>
<ul>
@ -87,8 +98,8 @@
<button class="btn-secondary" @click="onRetry">
{{ t('workflows.generate.retry') }}
</button>
<button class="btn-primary" :disabled="loading" @click="onAccept">
{{ t('workflows.generate.accept') }}
<button class="btn-primary" :disabled="loading || previewCompiling" @click="onAccept">
{{ previewCompiling ? t('workflows.generate.compileChecking') : t('workflows.generate.accept') }}
</button>
</template>
</div>
@ -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.
*
* <p>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<string>()
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;

View File

@ -53,6 +53,17 @@
/>
<small class="pf-hint">{{ t('triggers.pattern.senderEqualsHint') }}</small>
</div>
<div class="pf-field">
<span class="pf-label">{{ t('triggers.pattern.contentContains') }}</span>
<input
v-model.trim="form.contentContains"
class="pf-input"
:placeholder="t('triggers.pattern.contentContainsPlaceholder')"
spellcheck="false"
@input="emitFromForm"
/>
<small class="pf-hint">{{ t('triggers.pattern.contentContainsHint') }}</small>
</div>
</template>
<!-- content_match substring (required) -->
@ -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

View File

@ -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).',

View File

@ -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 和 phasespawned / terminated / crashed。',
@ -2141,6 +2143,9 @@ export default {
senderEquals: '发送者匹配',
senderEqualsPlaceholder: '可选,发送者 ID',
senderEqualsHint: '留空表示任意发送者;填写后只匹配该发送者的消息。',
contentContains: '消息包含关键字',
contentContainsPlaceholder: '可选关键字',
contentContainsHint: '对消息正文做不区分大小写的子串匹配;留空表示不限。',
substring: '子串匹配',
substringPlaceholder: '订单 / order',
substringHint: '不区分大小写。空字符串将拒绝触发(避免无意义广播)。',

View File

@ -80,13 +80,24 @@
<input v-model="formState.name" :placeholder="t('triggers.fields.namePlaceholder')" />
</label>
<label>{{ t('triggers.fields.patternType') }}
<!-- v0 ships four pattern types in the manual create UI:
cron, channel_message (which now carries the keyword
filter that content_match used to provide),
workflow_completion, webhook. content_match and
agent_lifecycle remain supported by the matcher for
legacy rows but are hidden from the new-trigger
dropdown so the manual entry matches what the
natural-language generator surfaces. They re-appear
only when editing a row that already uses one, so
the operator can tweak/disable/delete it without
being stuck on a dropdown that excludes its type. -->
<select v-model="formState.patternType">
<option value="cron">cron</option>
<option value="channel_message">channel_message</option>
<option value="content_match">content_match</option>
<option value="agent_lifecycle">agent_lifecycle</option>
<option value="workflow_completion">workflow_completion</option>
<option value="webhook">webhook</option>
<option v-if="showLegacyContentMatch" value="content_match">content_match (legacy)</option>
<option v-if="showLegacyAgentLifecycle" value="agent_lifecycle">agent_lifecycle (legacy)</option>
</select>
</label>
<div class="span-2">
@ -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<Record<string, string>>({})
function onPatternValidation(errs: Record<string, string>) {
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}"` : '—'