mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(workflow,trigger): generator triggerDrafts loop closure + 5 paper cuts
This commit is contained in:
parent
826c20639a
commit
2416d603c4
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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) + ")");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 }}\\"}"}]"""
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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})',
|
||||
|
||||
@ -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})',
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user