mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
feat(channel,trigger): wire channel_message + content_match event sources
This commit is contained in:
parent
69719eb35e
commit
dae74cf26c
@ -1,6 +1,8 @@
|
|||||||
package vip.mate.channel;
|
package vip.mate.channel;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
@ -8,6 +10,7 @@ import vip.mate.agent.context.ChatOrigin;
|
|||||||
import vip.mate.approval.ApprovalWorkflowService;
|
import vip.mate.approval.ApprovalWorkflowService;
|
||||||
import vip.mate.approval.ResolveOutcome;
|
import vip.mate.approval.ResolveOutcome;
|
||||||
import vip.mate.approval.PendingApproval;
|
import vip.mate.approval.PendingApproval;
|
||||||
|
import vip.mate.channel.event.ChannelMessageReceivedEvent;
|
||||||
import vip.mate.channel.model.ChannelEntity;
|
import vip.mate.channel.model.ChannelEntity;
|
||||||
import vip.mate.channel.notification.ApprovalNotificationService;
|
import vip.mate.channel.notification.ApprovalNotificationService;
|
||||||
import vip.mate.channel.service.ChannelService;
|
import vip.mate.channel.service.ChannelService;
|
||||||
@ -59,6 +62,11 @@ public class ChannelMessageRouter {
|
|||||||
private final ChatStreamTracker streamTracker;
|
private final ChatStreamTracker streamTracker;
|
||||||
private final ChannelChatOriginFactory chatOriginFactory;
|
private final ChannelChatOriginFactory chatOriginFactory;
|
||||||
private final ChannelErrorClassifier errorClassifier;
|
private final ChannelErrorClassifier errorClassifier;
|
||||||
|
/** Field-injected (rather than constructor) to avoid a signature
|
||||||
|
* change that would ripple through every test that constructs the
|
||||||
|
* router directly. Spring's stock publisher is always available. */
|
||||||
|
@Autowired(required = false)
|
||||||
|
private ApplicationEventPublisher events;
|
||||||
|
|
||||||
/** 队列条目:封装消息及其路由上下文 */
|
/** 队列条目:封装消息及其路由上下文 */
|
||||||
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
|
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
|
||||||
@ -189,6 +197,14 @@ public class ChannelMessageRouter {
|
|||||||
* @param channelEntity 渠道配置(含关联 agentId)
|
* @param channelEntity 渠道配置(含关联 agentId)
|
||||||
*/
|
*/
|
||||||
public void enqueue(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {
|
public void enqueue(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {
|
||||||
|
// Fan out to the trigger pipeline FIRST — channel_message and
|
||||||
|
// content_match triggers fire on every received message regardless
|
||||||
|
// of whether the channel has an agent attached. If we returned
|
||||||
|
// early on a missing agent below without publishing, the workflow
|
||||||
|
// side would silently lose every channel-event that doesn't also
|
||||||
|
// route to a chat agent.
|
||||||
|
publishChannelEvent(message, adapter, channelEntity);
|
||||||
|
|
||||||
Long agentId = channelEntity.getAgentId();
|
Long agentId = channelEntity.getAgentId();
|
||||||
if (agentId == null) {
|
if (agentId == null) {
|
||||||
log.warn("Channel {} has no associated agent, ignoring message from {}",
|
log.warn("Channel {} has no associated agent, ignoring message from {}",
|
||||||
@ -231,6 +247,43 @@ public class ChannelMessageRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a {@link ChannelMessageReceivedEvent} so the trigger module's
|
||||||
|
* bridge can fan the message out to channel_message + content_match
|
||||||
|
* triggers. Best-effort — a publish failure must never block the
|
||||||
|
* primary chat-routing path. {@code messageId} is used as the dedup
|
||||||
|
* key downstream so repeated webhook deliveries can't double-fire
|
||||||
|
* the same trigger.
|
||||||
|
*/
|
||||||
|
private void publishChannelEvent(ChannelMessage message, ChannelAdapter adapter,
|
||||||
|
ChannelEntity channelEntity) {
|
||||||
|
if (events == null || message == null || adapter == null || channelEntity == null) return;
|
||||||
|
try {
|
||||||
|
long ws = channelEntity.getWorkspaceId() == null ? 0L : channelEntity.getWorkspaceId();
|
||||||
|
String channelType = adapter.getChannelType();
|
||||||
|
// messageId may be null for adapters that don't surface one;
|
||||||
|
// fall back to a sender+timestamp composite so the dedup key
|
||||||
|
// is at least deterministic-ish per webhook delivery.
|
||||||
|
String messageId = message.getMessageId();
|
||||||
|
if (messageId == null || messageId.isBlank()) {
|
||||||
|
messageId = channelType + ":" + message.getSenderId() + ":"
|
||||||
|
+ (message.getTimestamp() == null ? System.currentTimeMillis()
|
||||||
|
: message.getTimestamp());
|
||||||
|
}
|
||||||
|
events.publishEvent(new ChannelMessageReceivedEvent(
|
||||||
|
ws,
|
||||||
|
channelType,
|
||||||
|
messageId,
|
||||||
|
message.getSenderId(),
|
||||||
|
message.getSenderName(),
|
||||||
|
message.getChatId(),
|
||||||
|
message.getContent()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[ChannelMessageRouter] event publish failed for sender {}: {}",
|
||||||
|
message.getSenderId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 防抖到期:将合并后的消息真正放入渠道队列
|
* 防抖到期:将合并后的消息真正放入渠道队列
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
package vip.mate.channel.event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring application event fired when a channel adapter accepts an
|
||||||
|
* inbound message and hands it off to {@code ChannelMessageRouter}.
|
||||||
|
* The trigger module subscribes via {@code @EventListener} and forwards
|
||||||
|
* the payload through {@code TriggerEventIngestService} so triggers of
|
||||||
|
* pattern type {@code channel_message} or {@code content_match} can fan
|
||||||
|
* out to workflows. Going through the event bus instead of injecting
|
||||||
|
* the trigger service directly into the channel module keeps the two
|
||||||
|
* worlds decoupled and dodges the construction cycle.
|
||||||
|
*
|
||||||
|
* <p>{@code messageId} doubles as the dedup key — repeated webhook
|
||||||
|
* deliveries of the same message can't double-fire downstream triggers
|
||||||
|
* because the {@code mate_trigger_event} unique constraint catches the
|
||||||
|
* second insert.
|
||||||
|
*/
|
||||||
|
public record ChannelMessageReceivedEvent(
|
||||||
|
long workspaceId,
|
||||||
|
String channelType,
|
||||||
|
String messageId,
|
||||||
|
String senderId,
|
||||||
|
String senderName,
|
||||||
|
String chatId,
|
||||||
|
String content
|
||||||
|
) {}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
package vip.mate.trigger.dispatch;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.channel.event.ChannelMessageReceivedEvent;
|
||||||
|
import vip.mate.trigger.ingest.TriggerEventEnvelope;
|
||||||
|
import vip.mate.trigger.ingest.TriggerEventIngestService;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges {@link ChannelMessageReceivedEvent} from the channel module
|
||||||
|
* into two trigger pattern types — {@code channel_message} (matches by
|
||||||
|
* {@code channelType} / {@code senderEquals}) and {@code content_match}
|
||||||
|
* (matches by substring inside the message content). The same envelope
|
||||||
|
* fans out to both since the matcher's per-pattern key on the SQL
|
||||||
|
* candidate query selects which triggers actually run.
|
||||||
|
*
|
||||||
|
* <p>Lives in the trigger module so the channel runtime stays free of
|
||||||
|
* trigger / ingest dependencies. Failures inside ingest are logged and
|
||||||
|
* swallowed — a bad downstream trigger MUST NOT corrupt the primary
|
||||||
|
* chat-routing path that just published the event.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ChannelMessageEventBridge {
|
||||||
|
|
||||||
|
private final TriggerEventIngestService ingestService;
|
||||||
|
|
||||||
|
@EventListener
|
||||||
|
public void onChannelMessage(ChannelMessageReceivedEvent event) {
|
||||||
|
if (event == null) return;
|
||||||
|
try {
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("channelType", event.channelType());
|
||||||
|
data.put("senderId", event.senderId());
|
||||||
|
if (event.senderName() != null) data.put("senderName", event.senderName());
|
||||||
|
if (event.chatId() != null) data.put("chatId", event.chatId());
|
||||||
|
// The matcher's content_match pattern reads `data.content`,
|
||||||
|
// so we put the message body there even when it's blank.
|
||||||
|
data.put("content", event.content() == null ? "" : event.content());
|
||||||
|
|
||||||
|
// Fan to channel_message pattern triggers.
|
||||||
|
ingestService.ingest(new TriggerEventEnvelope(
|
||||||
|
event.workspaceId(),
|
||||||
|
"channel_message",
|
||||||
|
event.messageId(),
|
||||||
|
event.senderId(),
|
||||||
|
data));
|
||||||
|
// And to content_match triggers, which live under a different
|
||||||
|
// patternType but read the same envelope shape. Two separate
|
||||||
|
// ingests instead of one because the SQL candidate query
|
||||||
|
// filters on patternType — a single dispatch with one
|
||||||
|
// patternType cannot reach the other set.
|
||||||
|
ingestService.ingest(new TriggerEventEnvelope(
|
||||||
|
event.workspaceId(),
|
||||||
|
"content_match",
|
||||||
|
event.messageId(),
|
||||||
|
event.senderId(),
|
||||||
|
data));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[ChannelMessageBridge] forwarding message {} from {} failed: {}",
|
||||||
|
event.messageId(), event.senderId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -58,8 +58,13 @@ const selectStep = inject<(data: StepNodeData | null) => void>(
|
|||||||
'selectStepCallback',
|
'selectStepCallback',
|
||||||
() => {}
|
() => {}
|
||||||
)
|
)
|
||||||
function handleClick() {
|
function handleClick(event: MouseEvent) {
|
||||||
selectStep(props.data as StepNodeData)
|
selectStep(props.data as StepNodeData)
|
||||||
|
const root = event.currentTarget as HTMLElement | null
|
||||||
|
const canvasId = root?.closest('.workflow-canvas')?.getAttribute('data-canvas-id') ?? undefined
|
||||||
|
window.dispatchEvent(new CustomEvent('mateclaw:workflow-step-select', {
|
||||||
|
detail: { canvasId, data: props.data as StepNodeData },
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position values come from the parent canvas (LR or TB orientation);
|
// Position values come from the parent canvas (LR or TB orientation);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="workflow-canvas" :class="{ fullscreen }">
|
<div class="workflow-canvas" :class="{ fullscreen }" :data-canvas-id="canvasId">
|
||||||
<div class="canvas-toolbar">
|
<div class="canvas-toolbar">
|
||||||
<div class="canvas-toolbar-group">
|
<div class="canvas-toolbar-group">
|
||||||
<button class="canvas-btn" :class="{ active: direction === 'LR' }" @click="direction = 'LR'">
|
<button class="canvas-btn" :class="{ active: direction === 'LR' }" @click="direction = 'LR'">
|
||||||
@ -75,6 +75,10 @@
|
|||||||
zoomable
|
zoomable
|
||||||
/>
|
/>
|
||||||
</VueFlow>
|
</VueFlow>
|
||||||
|
|
||||||
|
<div v-if="$slots.panel" class="canvas-panel-slot">
|
||||||
|
<slot name="panel" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -155,7 +159,13 @@ function selectStep(data: StepNodeData | null) {
|
|||||||
provide('selectStepCallback', selectStep)
|
provide('selectStepCallback', selectStep)
|
||||||
|
|
||||||
const flow = useVueFlow(props.canvasId)
|
const flow = useVueFlow(props.canvasId)
|
||||||
|
function handleDomStepSelect(event: Event) {
|
||||||
|
const detail = (event as CustomEvent<{ canvasId?: string; data?: StepNodeData }>).detail
|
||||||
|
if (!detail?.data || detail.canvasId !== props.canvasId) return
|
||||||
|
selectStep(detail.data)
|
||||||
|
}
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
window.addEventListener('mateclaw:workflow-step-select', handleDomStepSelect as EventListener)
|
||||||
flow.onNodeClick((evt) => {
|
flow.onNodeClick((evt) => {
|
||||||
const data = (evt?.node as Node<StepNodeData> | undefined)?.data
|
const data = (evt?.node as Node<StepNodeData> | undefined)?.data
|
||||||
if (data) selectStep(data)
|
if (data) selectStep(data)
|
||||||
@ -198,6 +208,7 @@ watch(fullscreen, (on) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('mateclaw:workflow-step-select', handleDomStepSelect as EventListener)
|
||||||
document.removeEventListener('keydown', handleEsc)
|
document.removeEventListener('keydown', handleEsc)
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
})
|
})
|
||||||
@ -322,4 +333,40 @@ onBeforeUnmount(() => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
}
|
}
|
||||||
|
.canvas-flow :deep(.vue-flow__edge-path) {
|
||||||
|
stroke: var(--mc-edge, rgba(74, 85, 104, 0.78));
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
.canvas-flow :deep(.vue-flow__edge-textbg) {
|
||||||
|
fill: var(--mc-bg-elevated, #ffffff);
|
||||||
|
}
|
||||||
|
.canvas-flow :deep(.vue-flow__edge-text) {
|
||||||
|
fill: var(--mc-text-secondary, #666666);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.canvas-panel-slot {
|
||||||
|
position: absolute;
|
||||||
|
top: 56px;
|
||||||
|
right: 14px;
|
||||||
|
bottom: 14px;
|
||||||
|
width: min(340px, calc(100% - 28px));
|
||||||
|
z-index: 12;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.canvas-panel-slot :deep(.step-panel) {
|
||||||
|
height: 100%;
|
||||||
|
max-height: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.canvas-panel-slot {
|
||||||
|
top: auto;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
width: auto;
|
||||||
|
height: min(56vh, 420px);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -124,6 +124,15 @@ export function buildGraph(json: string): { nodes: Node<StepNodeData>[]; edges:
|
|||||||
// mergePoint: the node id whose output the next non-fan_out step should consume
|
// mergePoint: the node id whose output the next non-fan_out step should consume
|
||||||
// fanGroup: the ids of fan_out steps in the most recent open group, ready to be joined by a collect
|
// fanGroup: the ids of fan_out steps in the most recent open group, ready to be joined by a collect
|
||||||
const edges: Edge[] = []
|
const edges: Edge[] = []
|
||||||
|
const pushEdge = (source: string, target: string, extra: Partial<Edge> = {}) => {
|
||||||
|
edges.push({
|
||||||
|
id: `edge-${edges.length + 1}`,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
animated: false,
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
}
|
||||||
let mergePoint: string | null = null
|
let mergePoint: string | null = null
|
||||||
let fanGroup: string[] = []
|
let fanGroup: string[] = []
|
||||||
let prevModeWasFanOut = false
|
let prevModeWasFanOut = false
|
||||||
@ -135,12 +144,7 @@ export function buildGraph(json: string): { nodes: Node<StepNodeData>[]; edges:
|
|||||||
if (!prevModeWasFanOut) fanGroup = []
|
if (!prevModeWasFanOut) fanGroup = []
|
||||||
fanGroup.push(node.id)
|
fanGroup.push(node.id)
|
||||||
if (mergePoint) {
|
if (mergePoint) {
|
||||||
edges.push({
|
pushEdge(mergePoint, node.id)
|
||||||
id: `e-${mergePoint}->${node.id}`,
|
|
||||||
source: mergePoint,
|
|
||||||
target: node.id,
|
|
||||||
animated: false,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
prevModeWasFanOut = true
|
prevModeWasFanOut = true
|
||||||
} else if (mode === 'collect') {
|
} else if (mode === 'collect') {
|
||||||
@ -150,12 +154,7 @@ export function buildGraph(json: string): { nodes: Node<StepNodeData>[]; edges:
|
|||||||
// canvas surfaces the orphan visually.
|
// canvas surfaces the orphan visually.
|
||||||
const sources = fanGroup.length ? fanGroup : (mergePoint ? [mergePoint] : [])
|
const sources = fanGroup.length ? fanGroup : (mergePoint ? [mergePoint] : [])
|
||||||
for (const src of sources) {
|
for (const src of sources) {
|
||||||
edges.push({
|
pushEdge(src, node.id)
|
||||||
id: `e-${src}->${node.id}`,
|
|
||||||
source: src,
|
|
||||||
target: node.id,
|
|
||||||
animated: false,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
fanGroup = []
|
fanGroup = []
|
||||||
mergePoint = node.id
|
mergePoint = node.id
|
||||||
@ -164,16 +163,11 @@ export function buildGraph(json: string): { nodes: Node<StepNodeData>[]; edges:
|
|||||||
// Sequential / conditional / await_approval / dispatch_channel / write_memory
|
// Sequential / conditional / await_approval / dispatch_channel / write_memory
|
||||||
// all attach to the current merge point and become the next merge point.
|
// all attach to the current merge point and become the next merge point.
|
||||||
if (mergePoint) {
|
if (mergePoint) {
|
||||||
const edge: Edge = {
|
const edge: Partial<Edge> = {}
|
||||||
id: `e-${mergePoint}->${node.id}`,
|
|
||||||
source: mergePoint,
|
|
||||||
target: node.id,
|
|
||||||
animated: false,
|
|
||||||
}
|
|
||||||
if (mode === 'conditional' && node.data?.expression) {
|
if (mode === 'conditional' && node.data?.expression) {
|
||||||
edge.label = `if ${node.data.expression}`
|
edge.label = `if ${node.data.expression}`
|
||||||
}
|
}
|
||||||
edges.push(edge)
|
pushEdge(mergePoint, node.id, edge)
|
||||||
}
|
}
|
||||||
mergePoint = node.id
|
mergePoint = node.id
|
||||||
fanGroup = []
|
fanGroup = []
|
||||||
|
|||||||
@ -65,15 +65,17 @@
|
|||||||
v-model="canvasModel"
|
v-model="canvasModel"
|
||||||
:canvas-id="`wf-${selected.id}`"
|
:canvas-id="`wf-${selected.id}`"
|
||||||
@select-step="onCanvasSelect"
|
@select-step="onCanvasSelect"
|
||||||
/>
|
>
|
||||||
<StepPropertyPanel
|
<template v-if="canvasSelection" #panel>
|
||||||
v-if="canvasSelection"
|
<StepPropertyPanel
|
||||||
:step="selectedStep"
|
:step="selectedStep"
|
||||||
:index="canvasSelection.index"
|
:index="canvasSelection.index"
|
||||||
@patch="onStepPatch"
|
@patch="onStepPatch"
|
||||||
@duplicate="onStepDuplicate"
|
@duplicate="onStepDuplicate"
|
||||||
@delete="onStepDelete"
|
@delete="onStepDelete"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
|
</WorkflowCanvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="json-pane">
|
<div v-else class="json-pane">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user