mateclaw/mateclaw-server/src/main/java/vip/mate/cron/CronConversationResolver.java
matevip 977e181949 feat(cron): unify output, add reminder task type, in-flight progress UI
Three layers landed together because they share the same routing /
lifecycle plumbing:

1. Cron output unification
   - New CronConversationResolver routes web-origin jobs to the per-workspace
     tasks_<wsId> conversation; IM-bound jobs go to the channel session
     conversation when one exists (matched by senderId then targetId);
     legacy cron_<id> remains as the fallback.
   - CronJobLifecycleService inserts a system-role header divider when a
     run starts so users browsing the unified tasks_<wsId> view can tell
     which job started a run. BaseAgent.sanitizeForLlm filters these
     headers so they never reach the model.
   - WorkspaceService seeds tasks_<wsId> on workspace creation; V65
     migration backfills existing workspaces.
   - DeliveryConfig gains a userId field so IM session lookup can match
     by senderId (replyToken-based targetId is not stable across runs).
   - ConversationVO recognizes tasks_/cron_ underscore prefix as cron
     source. MessageList renders the system header as a labeled divider.
   - ChatConsole pins tasks_* conversations and tracks per-conversation
     read state so new cron output gets a visible unread dot.

2. Reminder task type
   - New task_type='reminder' in CronJobEntity + service validation.
   - CronJobRunner short-circuits 'reminder' jobs: hands trigger_message
     to finishRunAndPublish verbatim, no LLM call. Fixes a regression
     where reminders were rephrased into echoed wrappers.
   - New create_reminder tool alongside create_cron_job, with descriptions
     tightened so the model picks the right one (verbatim push vs LLM
     query that needs computation).
   - CronJobs.vue gets a third radio option + dedicated reminder field.

3. In-flight progress placeholder
   - Cron uses non-streaming chat()/execute(); tool-heavy ReAct loops
     can run 1-5 minutes between start and finish with no visible
     state, looking hung.
   - New GET /api/v1/cron-jobs/active-runs returns runs in status=running
     for a conversation. ChatConsole polls it on the existing 4s tick
     (and on conversation switch) and shows a spinner bar with elapsed
     time. When run count drops to zero, it refetches messages so the
     assistant bubble appears within ~1s of finish.
2026-04-30 15:01:24 +08:00

124 lines
5.4 KiB
Java

package vip.mate.cron;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.channel.ChannelSessionStore;
import vip.mate.channel.model.ChannelSessionEntity;
import vip.mate.cron.model.CronJobEntity;
import vip.mate.cron.model.DeliveryConfig;
import java.util.List;
import java.util.Optional;
/**
* Single source of truth for the {@code conversationId} a cron run writes to.
* <p>
* Cron used to write every run to a per-job orphan conversation
* ({@code "cron_" + job.getId()}). Those rows existed in {@code mate_conversation}
* but had no entry in any sidebar — the user had no way to reach them. The
* delivery pipeline ({@code CronResultDelivery}) covered the IM case (push
* back to DingTalk / Feishu / etc.) but Web-origin cron jobs ended up with
* {@code delivery_status='NONE'} and silent results.
* <p>
* The new policy:
* <ul>
* <li>Web-origin cron (no {@code channelId}) → {@code "tasks_" + workspaceId}.
* A single workspace-scoped conversation pre-seeded as "📋 定时任务"
* (V65 migration). All Web cron output lands here so the user has one
* reliable place to look.</li>
* <li>IM-bound cron with an existing channel session that matches the
* delivery target → the session's conversationId. This makes the cron
* output appear inline in the IM mirror conversation — when the user
* opens the channel in Web Console, they see cron history alongside
* chat history, no separate inbox to check.</li>
* <li>IM-bound cron without a matching session yet (e.g. first run before
* the user has interacted with the channel) → fall back to the old
* per-job {@code cron_<id>} conversation so push delivery still works
* and the run isn't lost.</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CronConversationResolver {
private final ChannelSessionStore channelSessionStore;
public String resolve(CronJobEntity job) {
if (job == null) return "tasks_1";
// IM-bound cron: try to thread output into the existing channel session
// so the IM mirror in Web Console shows it inline with regular chat.
if (job.getChannelId() != null) {
String sessionConvId = findChannelSessionConvId(job);
if (sessionConvId != null) return sessionConvId;
// No session yet — keep the legacy per-job conversation so push
// delivery to the IM still works and the run isn't dropped.
return "cron_" + job.getId();
}
// Web-origin cron: unified per-workspace tasks conversation.
Long ws = job.getWorkspaceId() != null ? job.getWorkspaceId() : 1L;
return "tasks_" + ws;
}
/**
* Find the existing channel session for the cron's creator. Match
* priority — most specific first:
* <ol>
* <li>{@code (channelId, dc.userId)} — the senderId of who created
* this cron, captured by {@code CronJobTool.propagateChannelBinding}.
* This is the stable identifier across replyToken rotations.</li>
* <li>{@code (channelId, dc.targetId)} — fallback for legacy rows that
* were written before the {@code userId} field was added (V62
* baseline / older). Matches when the channel adapter happens to
* use the same value for {@code session.targetId} and
* {@code dc.targetId} (Slack / Discord / Telegram). Will miss for
* DingTalk-style replyToken adapters but those rows will never
* have written a useful targetId match either, so behavior is
* no worse than before.</li>
* <li>If both miss, return null and fall back to {@code cron_<id>}.</li>
* </ol>
*/
private String findChannelSessionConvId(CronJobEntity job) {
DeliveryConfig dc = job.getDeliveryConfig();
if (dc == null) return null;
try {
List<ChannelSessionEntity> sessions = channelSessionStore.listByChannelId(job.getChannelId());
if (sessions.isEmpty()) return null;
// Preferred: match by creator's senderId (V63+ rows).
if (dc.userId() != null && !dc.userId().isBlank()) {
String byUser = sessions.stream()
.filter(s -> dc.userId().equals(s.getSenderId()))
.map(ChannelSessionEntity::getConversationId)
.findFirst()
.orElse(null);
if (byUser != null) return byUser;
}
// Fallback: legacy targetId match (works for non-replyToken adapters).
if (dc.targetId() != null && !dc.targetId().isBlank()) {
return sessions.stream()
.filter(s -> dc.targetId().equals(s.getTargetId()))
.map(ChannelSessionEntity::getConversationId)
.findFirst()
.orElse(null);
}
return null;
} catch (Exception e) {
log.debug("[CronConvResolver] session lookup failed for job {}: {}",
job.getId(), e.getMessage());
return null;
}
}
/** Reused by header insertion to know whether we are in the unified tasks view. */
public boolean isWebOriginTasksConv(String conversationId) {
return conversationId != null && conversationId.startsWith("tasks_");
}
}