fix(workflow,trigger): generator triggerDrafts loop closure + 5 paper cuts

This commit is contained in:
matevip 2026-05-08 15:09:06 +08:00
parent 826c20639a
commit 2416d603c4
9 changed files with 268 additions and 16 deletions

View File

@ -97,6 +97,20 @@ public class TriggerPatternMatcher {
if (wantSender != null && !wantSender.equals(envelope.senderId())) {
return false;
}
// contentContains is the keyword filter the templates relied on
// without it a "feishu + 发票" trigger would fire on every
// feishu message. Matches case-insensitively against
// envelope.data.content, the same field content_match uses.
// Keeping the field on channel_message also folds the redundant
// content_match pattern type into the more general channel one;
// content_match remains supported for backwards compatibility
// via {@link #matchesContent}.
String wantContains = textOrNull(pattern, "contentContains");
if (wantContains != null) {
Object content = envelope.data() == null ? null : envelope.data().get("content");
if (!(content instanceof String body)) return false;
if (!body.toLowerCase().contains(wantContains.toLowerCase())) return false;
}
return true;
}

View File

@ -83,7 +83,7 @@ public class WorkflowDraftGenerator {
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
write_memory employeeId + file + mergeStrategy(append/replace_section/upsert_kv/overwrite) + content 必填不要 agentId / agentName / promptTemplate
# 不支持
@ -214,13 +214,33 @@ public class WorkflowDraftGenerator {
}
// --- 6. trigger drafts -----------------------------------------
// patternType allowlist mirrors what TriggerService accepts at
// create time. The generator prompt forbids agent_lifecycle and
// content_match; we filter defensively here too because models
// occasionally hallucinate trigger types under low confidence,
// and we don't want a future UI / tool that calls /draft/generate
// and trusts the response to silently re-introduce dropped types.
java.util.Set<String> allowedPatternTypes = java.util.Set.of(
"cron", "channel_message", "workflow_completion", "webhook");
List<Map<String, Object>> triggerDrafts = new ArrayList<>();
if (root.has("triggerDrafts") && root.get("triggerDrafts").isArray()) {
triggerDrafts = objectMapper.convertValue(root.get("triggerDrafts"),
List<Map<String, Object>> candidates = objectMapper.convertValue(root.get("triggerDrafts"),
new TypeReference<List<Map<String, Object>>>() {});
for (Map<String, Object> td : triggerDrafts) {
int dropped = 0;
for (Map<String, Object> td : candidates) {
String pt = td.get("patternType") instanceof String s ? s : null;
if (pt == null || !allowedPatternTypes.contains(pt)) {
dropped++;
continue;
}
// Belt-and-suspenders: never trust the LLM to honor enabled=false.
td.put("enabled", false);
triggerDrafts.add(td);
}
if (dropped > 0) {
warnings = appendWarning(warnings,
"dropped " + dropped + " unsupported triggerDraft entr" + (dropped == 1 ? "y" : "ies")
+ " (allowed: " + String.join(", ", allowedPatternTypes) + ")");
}
}

View File

@ -160,7 +160,7 @@ public class WorkflowDraftTemplateLibrary {
[{"name":"daily-memory-cron","patternType":"cron","enabled":false,
"patternJson":{"cron":"0 0 22 * * ?","timezone":"Asia/Shanghai"},
"targetType":"workflow",
"payloadTemplate":"{\\"topic\\":\\"工作\\",\\"date\\":\\"{{ trigger.firedAt }}\\"}"}]"""
"payloadTemplate":"{\\"topic\\":\\"工作\\",\\"date\\":\\"{{ event.firedAt }}\\"}"}]"""
);
}

View File

@ -8,6 +8,22 @@
</div>
<div class="modal-body">
<p class="hint">{{ t('workflows.generate.hint') }}</p>
<!-- Template picker for the common patterns the LLM
roundtrip is overkill. Selecting one drops the canonical
draft + triggerDrafts straight into the result panel,
skipping the model call entirely. -->
<div v-if="templates.length" class="template-picker">
<span class="picker-label">{{ t('workflows.generate.applyTemplate') }}</span>
<select v-model="selectedTemplateId" class="picker-select" @change="onTemplatePick">
<option value="">{{ t('workflows.generate.applyTemplatePlaceholder') }}</option>
<option v-for="tpl in templates" :key="tpl.id" :value="tpl.id">
{{ tpl.label }}
</option>
</select>
<p v-if="selectedTemplate" class="picker-desc">{{ selectedTemplate.description }}</p>
</div>
<textarea
ref="descRef"
v-model="description"
@ -41,6 +57,16 @@
<span class="result-tag warn">{{ t('workflows.generate.warning') }}</span> {{ w }}
</li>
</ul>
<div v-if="result.triggerDrafts.length" class="trigger-drafts">
<header>{{ t('workflows.generate.triggersTitle') }} ({{ result.triggerDrafts.length }})</header>
<ul>
<li v-for="(td, i) in result.triggerDrafts" :key="`td-${i}`">
<code>{{ td.patternType }}</code>
<span class="td-name">{{ td.name || '—' }}</span>
</li>
</ul>
<p class="trigger-hint">{{ t('workflows.generate.triggersHint') }}</p>
</div>
<details class="result-raw">
<summary>{{ t('workflows.generate.previewDraft') }}</summary>
<pre>{{ result.draftJson }}</pre>
@ -72,10 +98,10 @@
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { workflowApi, type GeneratedDraft } from '@/api'
import { workflowApi, type GeneratedDraft, type WorkflowDraftTemplate } from '@/api'
interface Props {
modelValue: boolean
@ -94,6 +120,64 @@ const loading = ref(false)
const result = ref<GeneratedDraft | null>(null)
const descRef = ref<HTMLTextAreaElement | null>(null)
const templates = ref<WorkflowDraftTemplate[]>([])
const selectedTemplateId = ref('')
const selectedTemplate = computed(() =>
templates.value.find((t) => t.id === selectedTemplateId.value) ?? null
)
async function loadTemplates() {
try {
const res = await workflowApi.listDraftTemplates()
templates.value = (res.data as unknown as WorkflowDraftTemplate[]) ?? []
} catch (e) {
// Templates are a nice-to-have falling back to free-form description
// is fine if the endpoint isn't available (e.g. older deployment).
console.warn('listDraftTemplates failed', e)
templates.value = []
}
}
/**
* Apply a template directly without going through the LLM. The template
* 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.
*/
function onTemplatePick() {
const tpl = selectedTemplate.value
if (!tpl) {
result.value = null
return
}
let triggerDrafts: Array<Record<string, unknown>> = []
try {
const parsed = JSON.parse(tpl.triggerDraftsJson || '[]')
if (Array.isArray(parsed)) triggerDrafts = parsed
} 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.
const missing: string[] = []
const todoMatches = tpl.draftJson.match(/TODO_[A-Z_]+/g) ?? []
for (const m of new Set(todoMatches)) missing.push(`${m} (template placeholder)`)
result.value = {
name: tpl.id,
description: tpl.description,
draftJson: tpl.draftJson,
triggerDrafts,
warnings: [],
missingFields: missing,
confidence: 1.0,
compileOk: true,
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
}
watch(
() => props.modelValue,
async (open) => {
@ -102,6 +186,8 @@ watch(
description.value = ''
result.value = null
loading.value = false
selectedTemplateId.value = ''
await loadTemplates()
await nextTick()
descRef.value?.focus()
document.addEventListener('keydown', onKey)
@ -205,6 +291,40 @@ function onAccept() {
color: var(--mc-text-secondary);
line-height: 1.5;
}
.template-picker {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
background: var(--mc-bg-sunken);
border: 1px solid var(--mc-border-light);
border-radius: 8px;
}
.picker-label {
font-size: 11px;
font-weight: 600;
color: var(--mc-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
:lang(zh-CN) .picker-label {
text-transform: none;
letter-spacing: 0;
}
.picker-select {
padding: 7px 9px;
border: 1px solid var(--mc-border);
border-radius: 6px;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
font-size: 13px;
}
.picker-desc {
margin: 4px 0 0;
font-size: 11.5px;
color: var(--mc-text-tertiary);
line-height: 1.4;
}
.form-input,
.form-textarea {
width: 100%;
@ -264,6 +384,45 @@ function onAccept() {
letter-spacing: 0.04em;
}
.result-tag.warn { background: rgba(245, 158, 11, 0.16); color: #b45309; }
.trigger-drafts {
border-top: 1px dashed var(--mc-border-light);
padding-top: 8px;
font-size: 12px;
}
.trigger-drafts header {
font-weight: 600;
font-size: 12px;
margin-bottom: 4px;
}
.trigger-drafts ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.trigger-drafts li {
display: flex;
gap: 8px;
align-items: baseline;
}
.trigger-drafts code {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 11px;
background: var(--mc-bg-muted);
padding: 1px 6px;
border-radius: 3px;
flex: 0 0 auto;
}
.trigger-drafts .td-name {
opacity: 0.85;
}
.trigger-hint {
margin: 6px 0 0;
font-size: 11px;
color: var(--mc-text-tertiary);
}
.result-raw summary {
font-size: 11px;
color: var(--mc-text-tertiary);

View File

@ -247,8 +247,8 @@
@change="patchMode({ mergeStrategy: ($event.target as HTMLSelectElement).value })"
>
<option value="append">append</option>
<option value="prepend">prepend</option>
<option value="replace_section">replace_section</option>
<option value="upsert_kv">upsert_kv</option>
<option value="overwrite">overwrite</option>
</select>
</label>

View File

@ -137,7 +137,7 @@ const WORKFLOW_SCHEMA = {
content: { type: 'string' },
employeeId: { type: 'string' },
file: { type: 'string' },
mergeStrategy: { type: 'string', enum: ['append', 'prepend', 'replace_section', 'overwrite'] },
mergeStrategy: { type: 'string', enum: ['append', 'replace_section', 'upsert_kv', 'overwrite'] },
},
additionalProperties: true,
},

View File

@ -2036,6 +2036,12 @@ export default {
warning: 'warning',
previewDraft: 'Preview generated JSON',
failed: 'Generate failed: {msg}',
acceptedWithTriggers: 'Draft saved and {count} trigger(s) created (disabled by default)',
acceptedSomeTriggersFailed: 'Draft saved; {ok} trigger(s) created, {fail} failed',
triggersTitle: 'Suggested triggers',
triggersHint: 'Created with enabled=false; toggle them on later from the Triggers page.',
applyTemplate: 'Apply template (skip the AI roundtrip)',
applyTemplatePlaceholder: '— Pick a canonical pattern —',
},
paused: {
header: 'Paused runs ({count})',

View File

@ -2048,6 +2048,12 @@ export default {
warning: '警告',
previewDraft: '预览生成的 JSON',
failed: '生成失败:{msg}',
acceptedWithTriggers: '已生成草稿并创建 {count} 个触发器(默认未启用)',
acceptedSomeTriggersFailed: '已生成草稿,{ok} 个触发器创建成功,{fail} 个失败',
triggersTitle: '建议的触发器',
triggersHint: '默认 enabled=false可在 Triggers 页面随时启用或调整。',
applyTemplate: '套用模板(直接生成,跳过 AI',
applyTemplatePlaceholder: '— 选择一个常用模板 —',
},
paused: {
header: '待恢复运行 ({count})',

View File

@ -221,6 +221,8 @@ import {
agentApi,
channelApi,
workflowApi,
triggerApi,
type TriggerSummary,
type WorkflowSummary,
type WorkflowRun,
type WorkflowRunStep,
@ -345,15 +347,60 @@ async function onGenerateAccept(draft: GeneratedDraft) {
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
if (!created?.id) {
ElMessage.error(t('workflows.generate.failed', { msg: 'workflow row not returned' }))
return
}
await workflowApi.saveDraft(created.id, draft.draftJson)
// Persist suggested trigger drafts. The generator returned them with
// enabled=false and (typically) targetId placeholders bind each to
// the new workflow id and persist as a real trigger row. Operators
// see them appear in the Triggers list and turn them on after they
// finish the workflow's TODOs.
let triggersCreated = 0
let triggersSkipped = 0
if (Array.isArray(draft.triggerDrafts)) {
for (const td of draft.triggerDrafts) {
try {
await triggerApi.create({
workspaceId: workspaceId.value,
name: typeof td.name === 'string' ? td.name : `from-${draft.name}`,
patternType: typeof td.patternType === 'string' ? td.patternType : '',
// patternJson can come from the LLM as either a string or
// a nested object normalise to the string the API wants.
patternJson: typeof td.patternJson === 'string'
? td.patternJson
: JSON.stringify(td.patternJson ?? {}),
targetType: 'workflow',
// Bind to the just-created workflow regardless of what the
// generator put in targetId (it usually inserted a TODO_*).
targetId: created.id,
enabled: false,
payloadTemplate: typeof td.payloadTemplate === 'string'
? td.payloadTemplate : undefined,
} as unknown as TriggerSummary)
triggersCreated++
} catch (e) {
console.warn('triggerDraft create failed', td, e)
triggersSkipped++
}
}
}
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
}
if (triggersCreated > 0 && triggersSkipped === 0) {
ElMessage.success(t('workflows.generate.acceptedWithTriggers', { count: triggersCreated }))
} else if (triggersSkipped > 0) {
ElMessage.warning(t('workflows.generate.acceptedSomeTriggersFailed',
{ ok: triggersCreated, fail: triggersSkipped }))
} else {
ElMessage.success(t('workflows.generate.compileOk'))
}
} catch (e) {