feat(ui): i18n + step templates for workflow editor; trigger UX polish

This commit is contained in:
matevip 2026-05-08 15:05:36 +08:00
parent 20884c1773
commit 2a25f4fcfb
4 changed files with 554 additions and 109 deletions

View File

@ -301,6 +301,8 @@ export default {
security: 'Security',
tokenUsage: 'Token Usage',
cronJobs: 'Cron Jobs',
workflows: 'Workflows',
triggers: 'Triggers',
settings: 'Settings',
logout: 'Logout',
memory: 'Memory',
@ -1904,6 +1906,129 @@ export default {
goalRequired: 'Goal is required',
},
},
workflows: {
kicker: 'Workflows',
title: 'Workflow Editor',
desc: 'Author multi-step workflows. Save the draft as you go, compile to surface diagnostics, then publish a revision when it is ready to run.',
newWorkflow: '+ New Workflow',
defined: 'Defined ({count})',
refresh: 'Refresh',
unnamed: '(unnamed)',
draftBadge: 'draft',
publishedBadge: 'v{rev}',
listEmpty: 'No workflows yet — create one to get started.',
namePlaceholder: 'Workflow name',
descPlaceholder: 'Optional description',
actions: {
saveMeta: 'Save Meta',
saveDraft: 'Save Draft',
compile: 'Compile',
publish: 'Publish',
delete: 'Delete',
},
bodyPlaceholder: '{"steps":[{"name":"step-a","agentName":"...","mode":{"type":"sequential"},"promptTemplate":"..."}]}',
compileErrorsTitle: '{count} compile error(s)',
templates: {
label: 'Insert template',
placeholder: 'Pick a step template',
sequential: 'sequential (linear step)',
fan_out: 'fan_out (parallel)',
collect: 'collect (join fan_out)',
conditional: 'conditional (gated)',
await_approval: 'await_approval (pause)',
dispatch_channel: 'dispatch_channel (broadcast)',
write_memory: 'write_memory (persist)',
},
jsonInvalid: 'JSON parse failed: {msg}',
jsonOk: 'JSON syntax OK.',
selectHint: 'Select a workflow on the left, or click "New Workflow" to start a fresh draft.',
runs: {
header: 'Recent runs ({count})',
empty: 'No runs yet.',
detailTitle: 'Run #{id} — {state}',
runHash: 'run #{id}',
},
prompts: {
newName: 'New workflow name:',
defaultName: 'untitled-workflow',
publishNote: 'Optional publish note:',
deleteConfirm: 'Delete workflow "{name}"? This is reversible via the audit log.',
},
status: {
metaSaved: 'Metadata saved.',
draftSaved: 'Draft saved.',
compileOk: 'Compile OK.',
published: 'Published.',
deleted: 'Deleted.',
createFailed: 'Create failed: {msg}',
saveFailed: 'Save failed: {msg}',
saveDraftFailed: 'Save draft failed: {msg}',
deleteFailed: 'Delete failed: {msg}',
compileFailed: '{count} compile error(s) — see panel below.',
compileFallback: 'Compile / publish failed.',
},
},
triggers: {
kicker: 'Triggers',
title: 'Workflow Triggers',
desc: 'Connect cron schedules and channel events to your published workflows. pattern_version increments on every cron expression change so multi-node deployments coordinate correctly.',
newTrigger: '+ New Trigger',
columns: {
name: 'Name',
pattern: 'Pattern',
target: 'Target Workflow',
rate: 'Rate',
fires: 'Fires',
patternVersion: 'pattern_v',
lastFired: 'Last fired',
state: 'State',
actions: 'Actions',
},
targetWorkflowSelect: 'Pick a published workflow',
targetWorkflowEmpty: 'No published workflows yet.',
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.',
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).',
workflow_completion: 'Optional sourceWorkflowId and stateFilter (completed / failed / any).',
},
unnamed: '(unnamed)',
rateUnit: '{count}/min',
enabled: 'enabled',
disabled: 'disabled',
empty: 'No triggers configured.',
actions: {
edit: 'Edit',
delete: 'Delete',
cancel: 'Cancel',
save: 'Save',
close: 'Close',
},
formTitleNew: 'New Trigger',
formTitleEdit: 'Edit Trigger',
fields: {
name: 'Name',
namePlaceholder: 'hourly-cleanup',
patternType: 'Pattern type',
patternJson: 'Pattern JSON',
patternJsonPlaceholder: '{"cron":"0 0 * * * *","timezone":"UTC"}',
targetType: 'Target type',
targetId: 'Target id',
ratePerMin: 'Rate / min',
dedupWindowSecs: 'Dedup window (s)',
maxFires: 'Max fires (0 = unlimited)',
botSelfFilter: 'Bot-self filter (recommended)',
payloadTemplate: 'Payload template (Pebble)',
payloadTemplatePlaceholder: '{"who":"{{ event.who }}"}',
enabled: 'Enabled',
},
deleteConfirm: 'Delete trigger "{name}"?',
saveFailed: 'Save failed: {msg}',
toggleFailed: 'Toggle failed: {msg}',
deleteFailed: 'Delete failed: {msg}',
},
login: {
kicker: 'AI Operating System',
subtitle: 'Your intelligent AI assistant',

View File

@ -301,6 +301,8 @@ export default {
security: '安全',
tokenUsage: 'Token 统计',
cronJobs: '定时任务',
workflows: '工作流',
triggers: '触发器',
settings: '设置',
logout: '退出登录',
themeLight: '浅色',
@ -1916,6 +1918,129 @@ export default {
goalRequired: '执行目标不能为空',
},
},
workflows: {
kicker: '工作流',
title: '工作流编辑器',
desc: '编排多步骤工作流:边写边存草稿,编译查看诊断,准备好后发布版本。',
newWorkflow: '+ 新建工作流',
defined: '已定义 ({count})',
refresh: '刷新',
unnamed: '(未命名)',
draftBadge: '草稿',
publishedBadge: 'v{rev}',
listEmpty: '还没有任何工作流,新建一个开始吧。',
namePlaceholder: '工作流名称',
descPlaceholder: '可选描述',
actions: {
saveMeta: '保存元数据',
saveDraft: '保存草稿',
compile: '编译',
publish: '发布',
delete: '删除',
},
bodyPlaceholder: '{"steps":[{"name":"step-a","agentName":"...","mode":{"type":"sequential"},"promptTemplate":"..."}]}',
compileErrorsTitle: '{count} 条编译错误',
templates: {
label: '插入模板',
placeholder: '选择步骤模板',
sequential: 'sequential顺序步骤',
fan_out: 'fan_out并行扇出',
collect: 'collect汇聚结果',
conditional: 'conditional条件分支',
await_approval: 'await_approval等待审批',
dispatch_channel: 'dispatch_channel消息分发',
write_memory: 'write_memory写入记忆',
},
jsonInvalid: 'JSON 解析失败:{msg}',
jsonOk: 'JSON 语法 OK。',
selectHint: '在左侧选择一个工作流,或点击「新建工作流」开始。',
runs: {
header: '近期运行 ({count})',
empty: '暂无运行记录。',
detailTitle: 'Run #{id} — {state}',
runHash: 'run #{id}',
},
prompts: {
newName: '新工作流名称:',
defaultName: 'untitled-workflow',
publishNote: '可选发布备注:',
deleteConfirm: '删除工作流「{name}」?此操作可通过审计日志撤销。',
},
status: {
metaSaved: '元数据已保存。',
draftSaved: '草稿已保存。',
compileOk: '编译通过。',
published: '已发布。',
deleted: '已删除。',
createFailed: '创建失败:{msg}',
saveFailed: '保存失败:{msg}',
saveDraftFailed: '草稿保存失败:{msg}',
deleteFailed: '删除失败:{msg}',
compileFailed: '{count} 条编译错误,详见下方面板。',
compileFallback: '编译/发布失败。',
},
},
triggers: {
kicker: '触发器',
title: '工作流触发器',
desc: '把 cron 调度和渠道事件接到已发布的工作流上。每次修改 cron 表达式都会让 pattern_version 自增,多节点部署据此协调。',
newTrigger: '+ 新建触发器',
columns: {
name: '名称',
pattern: '模式',
target: '目标工作流',
rate: '速率',
fires: '触发次数',
patternVersion: 'pattern_v',
lastFired: '上次触发',
state: '状态',
actions: '操作',
},
targetWorkflowSelect: '选择已发布的工作流',
targetWorkflowEmpty: '尚无已发布的工作流。',
patternHints: {
cron: '示例:{"cron":"0 0 * * * *","timezone":"Asia/Shanghai"} —— 每次修改 cron 都会让 pattern_version 自增。',
channel_message: '可填 channelType如 feishu和 senderEquals 进一步过滤。',
webhook: '透传匹配,请确保 HTTP 入口已校验 webhook 密钥。',
content_match: '需要 substring不区分大小写。空字符串将拒绝触发。',
agent_lifecycle: '可选 agentId 和 phasespawned / terminated / crashed。',
workflow_completion: '可选 sourceWorkflowId 和 stateFiltercompleted / failed / any。',
},
unnamed: '(未命名)',
rateUnit: '{count}/分钟',
enabled: '已启用',
disabled: '已停用',
empty: '暂无触发器配置。',
actions: {
edit: '编辑',
delete: '删除',
cancel: '取消',
save: '保存',
close: '关闭',
},
formTitleNew: '新建触发器',
formTitleEdit: '编辑触发器',
fields: {
name: '名称',
namePlaceholder: 'hourly-cleanup',
patternType: '模式类型',
patternJson: '模式 JSON',
patternJsonPlaceholder: '{"cron":"0 0 * * * *","timezone":"UTC"}',
targetType: '目标类型',
targetId: '目标 ID',
ratePerMin: '每分钟速率',
dedupWindowSecs: '去重窗口(秒)',
maxFires: '最大触发数0 = 不限)',
botSelfFilter: 'Bot 自循环过滤(推荐)',
payloadTemplate: '载荷模板Pebble',
payloadTemplatePlaceholder: '{"who":"{{ event.who }}"}',
enabled: '启用',
},
deleteConfirm: '删除触发器「{name}」?',
saveFailed: '保存失败:{msg}',
toggleFailed: '切换失败:{msg}',
deleteFailed: '删除失败:{msg}',
},
login: {
kicker: 'AI Operating System',
subtitle: '你的智能 AI 助手',

View File

@ -4,52 +4,52 @@
<div class="mc-page-inner triggers-page">
<div class="mc-page-header">
<div>
<div class="mc-page-kicker">Triggers</div>
<h1 class="mc-page-title">Workflow Triggers</h1>
<p class="mc-page-desc">
Connect cron schedules and channel events to your published workflows.
Pattern_version increments on every cron expression change so multi-node
deployments coordinate correctly.
</p>
<div class="mc-page-kicker">{{ t('triggers.kicker') }}</div>
<h1 class="mc-page-title">{{ t('triggers.title') }}</h1>
<p class="mc-page-desc">{{ t('triggers.desc') }}</p>
</div>
<button class="btn-primary" @click="openCreate">+ New Trigger</button>
<button class="btn-primary" @click="openCreate">{{ t('triggers.newTrigger') }}</button>
</div>
<table class="triggers-table mc-surface-card">
<thead>
<tr>
<th>Name</th>
<th>Pattern</th>
<th>Target Workflow</th>
<th>Rate</th>
<th>Fires</th>
<th>State</th>
<th>Actions</th>
<th>{{ t('triggers.columns.name') }}</th>
<th>{{ t('triggers.columns.pattern') }}</th>
<th>{{ t('triggers.columns.target') }}</th>
<th>{{ t('triggers.columns.rate') }}</th>
<th>{{ t('triggers.columns.fires') }}</th>
<th>{{ t('triggers.columns.patternVersion') }}</th>
<th>{{ t('triggers.columns.lastFired') }}</th>
<th>{{ t('triggers.columns.state') }}</th>
<th>{{ t('triggers.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="t in triggers" :key="t.id">
<td>{{ t.name || '(unnamed)' }}</td>
<tr v-for="row in triggers" :key="row.id">
<td>{{ row.name || t('triggers.unnamed') }}</td>
<td>
<code>{{ t.patternType }}</code>
<div class="pattern-detail">{{ t.patternJson }}</div>
<code>{{ row.patternType }}</code>
<div class="pattern-detail">{{ row.patternJson }}</div>
</td>
<td>{{ t.targetType }}#{{ t.targetId }}</td>
<td>{{ t.rateLimitPerMin }}/min</td>
<td>{{ t.fireCount }}<span v-if="t.maxFires > 0"> / {{ t.maxFires }}</span></td>
<td>{{ formatTarget(row) }}</td>
<td>{{ t('triggers.rateUnit', { count: row.rateLimitPerMin }) }}</td>
<td>{{ row.fireCount }}<span v-if="row.maxFires > 0"> / {{ row.maxFires }}</span></td>
<td>{{ row.patternVersion }}</td>
<td>{{ formatTime(row.lastFiredAt) }}</td>
<td>
<label class="toggle">
<input type="checkbox" :checked="t.enabled" @change="toggleEnabled(t)" />
<span>{{ t.enabled ? 'enabled' : 'disabled' }}</span>
<input type="checkbox" :checked="row.enabled" @change="toggleEnabled(row)" />
<span>{{ row.enabled ? t('triggers.enabled') : t('triggers.disabled') }}</span>
</label>
</td>
<td class="actions">
<button class="btn-ghost" @click="openEdit(t)">Edit</button>
<button class="btn-danger" @click="remove(t)">Delete</button>
<button class="btn-ghost" @click="openEdit(row)">{{ t('triggers.actions.edit') }}</button>
<button class="btn-danger" @click="remove(row)">{{ t('triggers.actions.delete') }}</button>
</td>
</tr>
<tr v-if="!triggers.length">
<td colspan="7" class="empty-row">No triggers configured.</td>
<td colspan="9" class="empty-row">{{ t('triggers.empty') }}</td>
</tr>
</tbody>
</table>
@ -57,12 +57,14 @@
<!-- Inline form (replaces modal for v0 simplicity) -->
<section v-if="formOpen" class="form-card mc-surface-card">
<header>
<strong>{{ editing?.id ? 'Edit Trigger' : 'New Trigger' }}</strong>
<button class="btn-ghost" @click="closeForm">Close</button>
<strong>{{ editing?.id ? t('triggers.formTitleEdit') : t('triggers.formTitleNew') }}</strong>
<button class="btn-ghost" @click="closeForm">{{ t('triggers.actions.close') }}</button>
</header>
<div class="form-grid">
<label>Name <input v-model="formState.name" placeholder="hourly-cleanup" /></label>
<label>Pattern type
<label>{{ t('triggers.fields.name') }}
<input v-model="formState.name" :placeholder="t('triggers.fields.namePlaceholder')" />
</label>
<label>{{ t('triggers.fields.patternType') }}
<select v-model="formState.patternType">
<option value="cron">cron</option>
<option value="channel_message">channel_message</option>
@ -70,38 +72,55 @@
<option value="workflow_completion">workflow_completion</option>
</select>
</label>
<label class="span-2">Pattern JSON
<label class="span-2">{{ t('triggers.fields.patternJson') }}
<textarea v-model="formState.patternJson" rows="3"
placeholder='{"cron":"0 0 * * * *","timezone":"UTC"}' />
:placeholder="t('triggers.fields.patternJsonPlaceholder')" />
<small class="pattern-hint">{{ patternHint }}</small>
</label>
<label>Target type
<label>{{ t('triggers.fields.targetType') }}
<select v-model="formState.targetType">
<option value="workflow">workflow</option>
<option value="agent">agent</option>
</select>
</label>
<label>Target id <input v-model.number="formState.targetId" type="number" /></label>
<label>Rate / min <input v-model.number="formState.rateLimitPerMin" type="number" /></label>
<label>Dedup window (s) <input v-model.number="formState.dedupWindowSecs" type="number" /></label>
<label>Max fires (0 = unlimited)
<label v-if="formState.targetType === 'workflow'">{{ t('triggers.fields.targetId') }}
<select v-model.number="formState.targetId">
<option v-if="!availableWorkflows.length" :value="0">
{{ t('triggers.targetWorkflowEmpty') }}
</option>
<option v-else v-for="wf in availableWorkflows" :key="wf.id" :value="wf.id">
#{{ wf.id }} {{ wf.name || t('triggers.unnamed') }}
</option>
</select>
</label>
<label v-else>{{ t('triggers.fields.targetId') }}
<input v-model.number="formState.targetId" type="number" />
</label>
<label>{{ t('triggers.fields.ratePerMin') }}
<input v-model.number="formState.rateLimitPerMin" type="number" />
</label>
<label>{{ t('triggers.fields.dedupWindowSecs') }}
<input v-model.number="formState.dedupWindowSecs" type="number" />
</label>
<label>{{ t('triggers.fields.maxFires') }}
<input v-model.number="formState.maxFires" type="number" />
</label>
<label>
<input type="checkbox" v-model="formState.botSelfFilter" />
Bot-self filter (recommended)
{{ t('triggers.fields.botSelfFilter') }}
</label>
<label class="span-2">Payload template (Pebble)
<label class="span-2">{{ t('triggers.fields.payloadTemplate') }}
<textarea v-model="formState.payloadTemplate" rows="3"
placeholder='{"who":"{{ event.who }}"}' />
:placeholder="t('triggers.fields.payloadTemplatePlaceholder')" />
</label>
<label class="span-2">
<input type="checkbox" v-model="formState.enabled" />
Enabled
{{ t('triggers.fields.enabled') }}
</label>
</div>
<footer>
<button class="btn-ghost" @click="closeForm">Cancel</button>
<button class="btn-primary" :disabled="busy" @click="save">Save</button>
<button class="btn-ghost" @click="closeForm">{{ t('triggers.actions.cancel') }}</button>
<button class="btn-primary" :disabled="busy" @click="save">{{ t('triggers.actions.save') }}</button>
</footer>
</section>
</div>
@ -111,12 +130,24 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { triggerApi, type TriggerSummary } from '@/api'
import { useI18n } from 'vue-i18n'
import { triggerApi, type TriggerSummary, workflowApi, type WorkflowSummary } from '@/api'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const workspaceId = computed(() => workspaceStore.currentWorkspaceId)
const triggers = ref<TriggerSummary[]>([])
const workflows = ref<WorkflowSummary[]>([])
const availableWorkflows = computed(() =>
workflows.value.filter((w) => w.latestRevisionId)
)
const patternHint = computed(() => {
const key = `triggers.patternHints.${formState.value.patternType}`
return t(key, '')
})
const formOpen = ref(false)
const editing = ref<TriggerSummary | null>(null)
@ -162,6 +193,25 @@ async function reload() {
} catch (e) {
console.error('listTriggers failed', e)
}
try {
const res = await workflowApi.list(workspaceId.value)
workflows.value = (res.data as unknown as WorkflowSummary[]) ?? []
} catch (e) {
console.error('listWorkflows failed', e)
}
}
function formatTarget(row: TriggerSummary) {
if (row.targetType === 'workflow') {
const wf = workflows.value.find((w) => w.id === row.targetId)
if (wf?.name) return `${wf.name} (#${row.targetId})`
}
return `${row.targetType}#${row.targetId}`
}
function formatTime(iso?: string) {
if (!iso) return '-'
return iso.replace('T', ' ').slice(0, 19)
}
function openCreate() {
@ -170,20 +220,20 @@ function openCreate() {
formOpen.value = true
}
function openEdit(t: TriggerSummary) {
editing.value = t
function openEdit(row: TriggerSummary) {
editing.value = row
formState.value = {
name: t.name ?? '',
patternType: t.patternType,
patternJson: t.patternJson,
targetType: t.targetType,
targetId: t.targetId,
rateLimitPerMin: t.rateLimitPerMin ?? 60,
dedupWindowSecs: t.dedupWindowSecs ?? 60,
botSelfFilter: t.botSelfFilter ?? true,
enabled: t.enabled,
maxFires: t.maxFires ?? 0,
payloadTemplate: t.payloadTemplate ?? '',
name: row.name ?? '',
patternType: row.patternType,
patternJson: row.patternJson,
targetType: row.targetType,
targetId: row.targetId,
rateLimitPerMin: row.rateLimitPerMin ?? 60,
dedupWindowSecs: row.dedupWindowSecs ?? 60,
botSelfFilter: row.botSelfFilter ?? true,
enabled: row.enabled,
maxFires: row.maxFires ?? 0,
payloadTemplate: row.payloadTemplate ?? '',
}
formOpen.value = true
}
@ -208,28 +258,28 @@ async function save() {
formOpen.value = false
await reload()
} catch (e) {
window.alert('Save failed: ' + (e as Error).message)
window.alert(t('triggers.saveFailed', { msg: (e as Error).message }))
} finally {
busy.value = false
}
}
async function toggleEnabled(t: TriggerSummary) {
async function toggleEnabled(row: TriggerSummary) {
try {
await triggerApi.update(t.id, { ...t, enabled: !t.enabled })
await triggerApi.update(row.id, { ...row, enabled: !row.enabled })
await reload()
} catch (e) {
window.alert('Toggle failed: ' + (e as Error).message)
window.alert(t('triggers.toggleFailed', { msg: (e as Error).message }))
}
}
async function remove(t: TriggerSummary) {
if (!window.confirm(`Delete trigger "${t.name || t.id}"?`)) return
async function remove(row: TriggerSummary) {
if (!window.confirm(t('triggers.deleteConfirm', { name: row.name || String(row.id) }))) return
try {
await triggerApi.delete(t.id)
await triggerApi.delete(row.id)
await reload()
} catch (e) {
window.alert('Delete failed: ' + (e as Error).message)
window.alert(t('triggers.deleteFailed', { msg: (e as Error).message }))
}
}
@ -321,6 +371,15 @@ watch(workspaceId, reload)
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 12px;
}
.pattern-hint {
display: block;
margin-top: 4px;
font-size: 11px;
font-weight: 400;
font-family: 'JetBrains Mono', Consolas, monospace;
opacity: 0.7;
white-space: pre-wrap;
}
.form-card footer {
margin-top: 12px;
display: flex;

View File

@ -4,22 +4,19 @@
<div class="mc-page-inner workflows-page">
<div class="mc-page-header">
<div>
<div class="mc-page-kicker">Workflows</div>
<h1 class="mc-page-title">Workflow Editor</h1>
<p class="mc-page-desc">
Author multi-step workflows. Save the draft as you go, compile to surface diagnostics,
then publish a revision when it is ready to run.
</p>
<div class="mc-page-kicker">{{ t('workflows.kicker') }}</div>
<h1 class="mc-page-title">{{ t('workflows.title') }}</h1>
<p class="mc-page-desc">{{ t('workflows.desc') }}</p>
</div>
<button class="btn-primary" @click="openCreate">+ New Workflow</button>
<button class="btn-primary" @click="openCreate">{{ t('workflows.newWorkflow') }}</button>
</div>
<div class="workflows-grid">
<!-- left: list -->
<aside class="workflows-list mc-surface-card">
<div class="list-header">
<span>Defined ({{ workflows.length }})</span>
<button class="btn-ghost" @click="reload">Refresh</button>
<span>{{ t('workflows.defined', { count: workflows.length }) }}</span>
<button class="btn-ghost" @click="reload">{{ t('workflows.refresh') }}</button>
</div>
<ul class="list-body">
<li
@ -30,37 +27,53 @@
@click="select(wf.id)"
>
<div class="list-row-name">
{{ wf.name || '(unnamed)' }}
<span v-if="wf.latestRevisionId" class="badge published">v{{ wf.latestRevisionId }}</span>
<span v-else class="badge draft">draft</span>
{{ wf.name || t('workflows.unnamed') }}
<span v-if="wf.latestRevisionId" class="badge published">{{ t('workflows.publishedBadge', { rev: wf.latestRevisionId }) }}</span>
<span v-else class="badge draft">{{ t('workflows.draftBadge') }}</span>
</div>
<div class="list-row-desc">{{ wf.description || '-' }}</div>
</li>
<li v-if="!workflows.length" class="list-empty">No workflows yet create one to get started.</li>
<li v-if="!workflows.length" class="list-empty">{{ t('workflows.listEmpty') }}</li>
</ul>
</aside>
<!-- middle: editor -->
<section class="workflows-editor mc-surface-card" v-if="selected">
<header class="editor-header">
<input v-model="selected.name" class="editor-name" placeholder="Workflow name" />
<input v-model="selected.description" class="editor-desc" placeholder="Optional description" />
<input v-model="selected.name" class="editor-name" :placeholder="t('workflows.namePlaceholder')" />
<input v-model="selected.description" class="editor-desc" :placeholder="t('workflows.descPlaceholder')" />
<div class="editor-actions">
<button class="btn-ghost" :disabled="busy" @click="saveMeta">Save Meta</button>
<button class="btn-ghost" :disabled="busy" @click="saveDraft">Save Draft</button>
<button class="btn-ghost" :disabled="busy" @click="compile">Compile</button>
<button class="btn-primary" :disabled="busy" @click="publish">Publish</button>
<button class="btn-danger" :disabled="busy" @click="remove">Delete</button>
<button class="btn-ghost" :disabled="busy" @click="saveMeta">{{ t('workflows.actions.saveMeta') }}</button>
<button class="btn-ghost" :disabled="busy" @click="saveDraft">{{ t('workflows.actions.saveDraft') }}</button>
<button class="btn-ghost" :disabled="busy" @click="compile">{{ t('workflows.actions.compile') }}</button>
<button class="btn-primary" :disabled="busy" @click="publish">{{ t('workflows.actions.publish') }}</button>
<button class="btn-danger" :disabled="busy" @click="remove">{{ t('workflows.actions.delete') }}</button>
</div>
</header>
<div class="editor-toolbar">
<label class="template-picker">
<span>{{ t('workflows.templates.label') }}</span>
<select v-model="templateChoice" @change="insertTemplate">
<option value="">{{ t('workflows.templates.placeholder') }}</option>
<option value="sequential">{{ t('workflows.templates.sequential') }}</option>
<option value="fan_out">{{ t('workflows.templates.fan_out') }}</option>
<option value="collect">{{ t('workflows.templates.collect') }}</option>
<option value="conditional">{{ t('workflows.templates.conditional') }}</option>
<option value="await_approval">{{ t('workflows.templates.await_approval') }}</option>
<option value="dispatch_channel">{{ t('workflows.templates.dispatch_channel') }}</option>
<option value="write_memory">{{ t('workflows.templates.write_memory') }}</option>
</select>
</label>
<span class="json-hint" :class="jsonHintKind">{{ jsonHint }}</span>
</div>
<textarea
v-model="draftJson"
class="editor-body"
spellcheck="false"
placeholder='{"steps":[{"name":"step-a","agentName":"...","mode":{"type":"sequential"},"promptTemplate":"..."}]}'
:placeholder="t('workflows.bodyPlaceholder')"
/>
<div v-if="compileErrors.length" class="errors-panel">
<div class="errors-title">{{ compileErrors.length }} compile error(s)</div>
<div class="errors-title">{{ t('workflows.compileErrorsTitle', { count: compileErrors.length }) }}</div>
<ul>
<li v-for="(err, idx) in compileErrors" :key="idx">
<code>{{ err.code }}</code>
@ -75,14 +88,14 @@
</section>
<section class="workflows-empty mc-surface-card" v-else>
<p>Select a workflow on the left, or click "New Workflow" to start a fresh draft.</p>
<p>{{ t('workflows.selectHint') }}</p>
</section>
<!-- right: runs -->
<aside class="workflows-runs mc-surface-card" v-if="selected">
<header class="runs-header">
<span>Recent runs ({{ runs.length }})</span>
<button class="btn-ghost" @click="reloadRuns">Refresh</button>
<span>{{ t('workflows.runs.header', { count: runs.length }) }}</span>
<button class="btn-ghost" @click="reloadRuns">{{ t('workflows.refresh') }}</button>
</header>
<ul class="runs-list">
<li v-for="run in runs" :key="run.id" class="run-row" @click="loadRun(run.id)">
@ -91,19 +104,19 @@
<span class="run-time">{{ formatTime(run.startedAt) }}</span>
</div>
<div class="run-row-meta">
<span>run #{{ run.id }}</span>
<span>{{ t('workflows.runs.runHash', { id: run.id }) }}</span>
<span v-if="run.triggeredBy">· {{ run.triggeredBy }}</span>
<span v-if="run.errorMessage" class="run-err">· {{ run.errorMessage }}</span>
</div>
</li>
<li v-if="!runs.length" class="runs-empty">No runs yet.</li>
<li v-if="!runs.length" class="runs-empty">{{ t('workflows.runs.empty') }}</li>
</ul>
<section v-if="runDetail" class="run-detail">
<div class="run-detail-title">Run #{{ runDetail.run.id }} {{ runDetail.run.state }}</div>
<div class="run-detail-title">{{ t('workflows.runs.detailTitle', { id: runDetail.run.id, state: runDetail.run.state }) }}</div>
<ol class="run-steps">
<li v-for="step in runDetail.steps" :key="step.id">
<span class="step-state" :class="'state-' + step.state">{{ step.state }}</span>
<span class="step-name">{{ step.stepName || '(unnamed)' }}</span>
<span class="step-name">{{ step.stepName || t('workflows.unnamed') }}</span>
<span v-if="step.durationMs != null" class="step-duration">{{ step.durationMs }} ms</span>
<span v-if="step.errorMessage" class="step-err">{{ step.errorMessage }}</span>
</li>
@ -118,6 +131,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
workflowApi,
type WorkflowSummary,
@ -128,6 +142,7 @@ import {
} from '@/api'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const workspaceId = computed(() => workspaceStore.currentWorkspaceId)
@ -143,6 +158,97 @@ const busy = ref(false)
const runs = ref<WorkflowRun[]>([])
const runDetail = ref<{ run: WorkflowRun; steps: WorkflowRunStep[] } | null>(null)
const templateChoice = ref('')
// Live JSON-syntax check on the textarea so the operator sees parse errors
// immediately, instead of waiting for compile to round-trip.
const jsonHint = computed(() => {
if (!draftJson.value.trim()) return ''
try {
JSON.parse(draftJson.value)
return t('workflows.jsonOk')
} catch (e) {
return t('workflows.jsonInvalid', { msg: (e as Error).message })
}
})
const jsonHintKind = computed(() => (jsonHint.value === t('workflows.jsonOk') ? 'ok' : 'err'))
const STEP_TEMPLATES: Record<string, object> = {
sequential: {
name: 'step-sequential',
agentName: 'agent-name',
mode: { type: 'sequential' },
promptTemplate: 'Process: {{ inputs.payload }}',
},
fan_out: {
name: 'step-fan',
agentName: 'agent-name',
mode: { type: 'fan_out' },
promptTemplate: 'Branch task',
},
collect: {
name: 'step-collect',
agentName: 'agent-name',
mode: { type: 'collect' },
promptTemplate: 'Combine: {{ inputs.payload }}',
},
conditional: {
name: 'step-conditional',
agentName: 'agent-name',
mode: { type: 'conditional', expression: '{{ inputs.payload != null }}' },
promptTemplate: 'Run only when condition holds',
},
await_approval: {
name: 'step-approval',
mode: {
type: 'await_approval',
approvalKind: 'manual',
approvalMessage: 'Please review and approve',
timeoutSecs: 3600,
},
},
dispatch_channel: {
name: 'step-dispatch',
mode: {
type: 'dispatch_channel',
channels: ['feishu'],
targets: { feishu: 'group-id-here' },
content: 'Notification: {{ inputs.payload }}',
},
},
write_memory: {
name: 'step-memory',
mode: {
type: 'write_memory',
employeeId: 'employee-id',
file: 'workspace.md',
mergeStrategy: 'append',
content: '{{ inputs.payload }}',
},
},
}
function insertTemplate() {
const choice = templateChoice.value
if (!choice) return
const stepBlock = STEP_TEMPLATES[choice]
if (!stepBlock) return
// Try to inject into an existing draft's `steps` array; fall back to a
// fresh skeleton if the current text isn't valid JSON or doesn't have
// the expected shape.
let next: string
try {
const parsed = JSON.parse(draftJson.value || '{}') as { steps?: unknown[] }
if (!Array.isArray(parsed.steps)) parsed.steps = []
parsed.steps.push(stepBlock)
next = JSON.stringify(parsed, null, 2)
} catch {
next = JSON.stringify({ steps: [stepBlock] }, null, 2)
}
draftJson.value = next
templateChoice.value = ''
}
async function reload() {
if (!workspaceId.value) return
try {
@ -188,7 +294,7 @@ async function loadRun(runId: number) {
async function openCreate() {
if (!workspaceId.value) return
const name = window.prompt('New workflow name:', 'untitled-workflow')
const name = window.prompt(t('workflows.prompts.newName'), t('workflows.prompts.defaultName'))
if (!name) return
busy.value = true
try {
@ -201,7 +307,7 @@ async function openCreate() {
await reload()
if (created?.id) await select(created.id)
} catch (e) {
setStatus('Create failed: ' + (e as Error).message, 'err')
setStatus(t('workflows.status.createFailed', { msg: (e as Error).message }), 'err')
} finally {
busy.value = false
}
@ -216,10 +322,10 @@ async function saveMeta() {
description: selected.value.description,
enabled: selected.value.enabled,
})
setStatus('Metadata saved.', 'ok')
setStatus(t('workflows.status.metaSaved'), 'ok')
await reload()
} catch (e) {
setStatus('Save failed: ' + (e as Error).message, 'err')
setStatus(t('workflows.status.saveFailed', { msg: (e as Error).message }), 'err')
} finally {
busy.value = false
}
@ -230,9 +336,9 @@ async function saveDraft() {
busy.value = true
try {
await workflowApi.saveDraft(selected.value.id, draftJson.value)
setStatus('Draft saved.', 'ok')
setStatus(t('workflows.status.draftSaved'), 'ok')
} catch (e) {
setStatus('Save draft failed: ' + (e as Error).message, 'err')
setStatus(t('workflows.status.saveDraftFailed', { msg: (e as Error).message }), 'err')
} finally {
busy.value = false
}
@ -245,7 +351,7 @@ async function compile() {
try {
await workflowApi.saveDraft(selected.value.id, draftJson.value)
await workflowApi.compile(selected.value.id)
setStatus('Compile OK.', 'ok')
setStatus(t('workflows.status.compileOk'), 'ok')
} catch (e) {
handleCompileError(e)
} finally {
@ -259,9 +365,9 @@ async function publish() {
compileErrors.value = []
try {
await workflowApi.saveDraft(selected.value.id, draftJson.value)
const note = window.prompt('Optional publish note:', '') ?? undefined
const note = window.prompt(t('workflows.prompts.publishNote'), '') ?? undefined
await workflowApi.publish(selected.value.id, note)
setStatus('Published.', 'ok')
setStatus(t('workflows.status.published'), 'ok')
await reload()
} catch (e) {
handleCompileError(e)
@ -272,7 +378,7 @@ async function publish() {
async function remove() {
if (!selected.value) return
if (!window.confirm(`Delete workflow "${selected.value.name}"? This is reversible via the audit log.`)) return
if (!window.confirm(t('workflows.prompts.deleteConfirm', { name: selected.value.name ?? '' }))) return
busy.value = true
try {
await workflowApi.delete(selected.value.id)
@ -280,9 +386,9 @@ async function remove() {
selectedId.value = null
draftJson.value = ''
await reload()
setStatus('Deleted.', 'ok')
setStatus(t('workflows.status.deleted'), 'ok')
} catch (e) {
setStatus('Delete failed: ' + (e as Error).message, 'err')
setStatus(t('workflows.status.deleteFailed', { msg: (e as Error).message }), 'err')
} finally {
busy.value = false
}
@ -296,9 +402,9 @@ function handleCompileError(e: unknown) {
const failure = err.response?.data?.data
if (failure?.errors?.length) {
compileErrors.value = failure.errors
setStatus(`${failure.errorCount} compile error(s) — see panel below.`, 'err')
setStatus(t('workflows.status.compileFailed', { count: failure.errorCount ?? failure.errors.length }), 'err')
} else {
setStatus(err.message || 'Compile / publish failed.', 'err')
setStatus(err.message || t('workflows.status.compileFallback'), 'err')
}
}
@ -430,6 +536,36 @@ watch(workspaceId, reload)
gap: 6px;
flex-wrap: wrap;
}
.editor-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
font-size: 12px;
}
.template-picker {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
}
.template-picker select {
padding: 4px 6px;
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
border-radius: 6px;
background: transparent;
color: inherit;
font-size: 12px;
}
.json-hint {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 11px;
opacity: 0.85;
flex: 1;
}
.json-hint.ok { color: #1e8449; }
.json-hint.err { color: #c0392b; }
.editor-body {
flex: 1;
font-family: 'JetBrains Mono', Consolas, monospace;