mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(workflow,trigger): template preview-compile + 3 alpha consistency cleanups
This commit is contained in:
parent
2416d603c4
commit
2804905500
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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).',
|
||||
|
||||
@ -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: '不区分大小写。空字符串将拒绝触发(避免无意义广播)。',
|
||||
|
||||
@ -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}"` : '—'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user