diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index 1d58915c..3d159ab4 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -276,6 +276,19 @@ public abstract class BaseAgent { return null; } + // Stage 0: drop cron-run header rows (system role + "📋 " prefix) + // inserted by CronJobLifecycleService.startRun. These are UI dividers + // for the unified tasks_ view and the IM channel-session + // mirror — they carry no semantic context for the LLM. Without this + // skip, every subsequent IM turn would feed the model unsolicited + // SystemMessage rows like "📋 每日新闻 · 定时触发 · 2026-04-30T10:55" + // and bloat the prompt with scheduler metadata. + if ("system".equals(entity.getRole()) + && entity.getContent() != null + && entity.getContent().startsWith("📋 ")) { + return null; + } + // Stage 1: drop approval-placeholder assistant messages if ("assistant".equals(entity.getRole()) && isApprovalPlaceholder(entity.getContent())) { log.debug("[{}] Filtering approval placeholder from history: msgId={}", diff --git a/mateclaw-server/src/main/java/vip/mate/cron/CronConversationResolver.java b/mateclaw-server/src/main/java/vip/mate/cron/CronConversationResolver.java new file mode 100644 index 00000000..cc3aa192 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/cron/CronConversationResolver.java @@ -0,0 +1,123 @@ +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. + *

+ * 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. + *

+ * The new policy: + *

+ * + * @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: + *
    + *
  1. {@code (channelId, dc.userId)} — the senderId of who created + * this cron, captured by {@code CronJobTool.propagateChannelBinding}. + * This is the stable identifier across replyToken rotations.
  2. + *
  3. {@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.
  4. + *
  5. If both miss, return null and fall back to {@code cron_}.
  6. + *
+ */ + private String findChannelSessionConvId(CronJobEntity job) { + DeliveryConfig dc = job.getDeliveryConfig(); + if (dc == null) return null; + try { + List 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_"); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/cron/controller/CronJobController.java b/mateclaw-server/src/main/java/vip/mate/cron/controller/CronJobController.java index 43487571..fd161d5c 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/controller/CronJobController.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/controller/CronJobController.java @@ -7,6 +7,8 @@ import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; import vip.mate.cron.model.CronJobDTO; import vip.mate.cron.service.CronJobService; +import vip.mate.dashboard.model.ActiveCronRunVO; +import vip.mate.dashboard.service.CronJobRunService; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.util.List; @@ -23,6 +25,7 @@ import java.util.List; public class CronJobController { private final CronJobService cronJobService; + private final CronJobRunService cronJobRunService; /** * RFC-083: every endpoint reads {@code X-Workspace-Id} (the frontend @@ -92,6 +95,20 @@ public class CronJobController { return R.ok(); } + /** + * Lightweight poll target for the chat console. Returns the cron job runs + * currently in {@code running} state for the given conversation, so the + * UI can render a "executing…" placeholder bubble between T1 (run row + * inserted) and T2 (assistant message persisted). + */ + @Operation(summary = "查询会话下正在执行的定时任务运行") + @GetMapping("/active-runs") + @RequireWorkspaceRole("viewer") + public R> activeRuns( + @RequestParam("conversationId") String conversationId) { + return R.ok(cronJobRunService.listActiveByConversation(conversationId)); + } + private static long resolve(Long headerWorkspaceId) { return headerWorkspaceId != null ? headerWorkspaceId : DEFAULT_WORKSPACE_ID; } diff --git a/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobEntity.java b/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobEntity.java index 61bc1fe2..a36eca0a 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/model/CronJobEntity.java @@ -33,10 +33,17 @@ public class CronJobEntity { /** 关联 Agent ID */ private Long agentId; - /** 任务类型:text | agent */ + /** + * 任务类型: + *
    + *
  • {@code text} — single-turn LLM chat (uses {@code triggerMessage})
  • + *
  • {@code agent} — Plan-Execute (uses {@code requestBody})
  • + *
  • {@code reminder} — direct push of {@code triggerMessage}, no LLM call
  • + *
+ */ private String taskType; - /** 触发消息(task_type=text 时使用) */ + /** 触发消息(task_type=text 或 reminder 时使用) */ @TableField(updateStrategy = FieldStrategy.ALWAYS) private String triggerMessage; diff --git a/mateclaw-server/src/main/java/vip/mate/cron/model/DeliveryConfig.java b/mateclaw-server/src/main/java/vip/mate/cron/model/DeliveryConfig.java index ac3b01b4..4fbb7ab8 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/model/DeliveryConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/model/DeliveryConfig.java @@ -18,13 +18,41 @@ import vip.mate.agent.context.ChannelTarget; public record DeliveryConfig( @Nullable String targetId, @Nullable String threadId, - @Nullable String accountId + @Nullable String accountId, + /** + * The IM senderId of the user who created this cron job. Used by + * {@code CronConversationResolver} to find that user's existing + * channel session — matching by {@code targetId} alone is fragile + * because adapters that use a {@code replyToken} (DingTalk + * sessionWebhook etc.) store the token as session.targetId while + * the cron tool captures the message {@code chatId/senderId} as + * deliveryConfig.targetId. The two never match, the lookup always + * misses, and IM cron output never reaches the channel mirror + * conversation. Carrying senderId fixes that without coupling the + * resolver to per-channel reply-target conventions. + *

Nullable for backwards compat with rows written before this + * field was added (V62 baseline). + */ + @Nullable String userId ) { + /** 3-arg legacy constructor preserved so older deserialized rows still work. */ + public DeliveryConfig(@Nullable String targetId, + @Nullable String threadId, + @Nullable String accountId) { + this(targetId, threadId, accountId, null); + } + /** Convert from the {@link ChannelTarget} carried on a {@code ChatOrigin}. */ public static DeliveryConfig from(@Nullable ChannelTarget t) { if (t == null) return null; - return new DeliveryConfig(t.targetId(), t.threadId(), t.accountId()); + return new DeliveryConfig(t.targetId(), t.threadId(), t.accountId(), null); + } + + /** Convert from {@link ChannelTarget} + the requester's senderId. */ + public static DeliveryConfig from(@Nullable ChannelTarget t, @Nullable String userId) { + if (t == null) return null; + return new DeliveryConfig(t.targetId(), t.threadId(), t.accountId(), userId); } /** Convert back to a {@link ChannelTarget} for ChatOrigin reconstruction. */ diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java index 8c2491fe..a9ccba69 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java @@ -13,6 +13,7 @@ import vip.mate.cron.delivery.CronJobCompletedEvent; import vip.mate.cron.model.CronJobEntity; import vip.mate.dashboard.model.CronJobRunEntity; import vip.mate.dashboard.repository.CronJobRunMapper; +import vip.mate.i18n.I18nService; import vip.mate.memory.event.ConversationCompletionPublisher; import vip.mate.workspace.conversation.ConversationService; @@ -47,6 +48,7 @@ public class CronJobLifecycleService { private final ConversationService conversationService; private final ConversationCompletionPublisher completionPublisher; private final ApplicationEventPublisher events; + private final I18nService i18n; /** * T1 — short transaction: persist a run row in {@code running} state, @@ -57,25 +59,50 @@ public class CronJobLifecycleService { * @param triggerType {@code scheduled} (cron tick) or {@code manual} (runNow) */ @Transactional(propagation = Propagation.REQUIRES_NEW) - public CronJobRunEntity startRun(CronJobEntity job, String userMessage, String triggerType) { + public CronJobRunEntity startRun(CronJobEntity job, String userMessage, String triggerType, + String conversationId) { CronJobRunEntity run = new CronJobRunEntity(); run.setCronJobId(job.getId()); - run.setConversationId("cron_" + job.getId()); + run.setConversationId(conversationId); run.setStatus("running"); run.setTriggerType(triggerType != null ? triggerType : "scheduled"); run.setStartedAt(LocalDateTime.now()); run.setDeliveryStatus("NONE"); runMapper.insert(run); + // Cron-run header (system role) so users browsing the unified + // tasks_ conversation can tell which job's run starts here. + // Renderable as a divider card on the frontend; LLM history reads + // skip system messages so this doesn't pollute future prompts. + conversationService.saveMessage(conversationId, "system", + buildHeader(job, run)); + // Persist the user message before the LLM call so history reads // see a coherent (user → assistant) ordering even if the agent // throws mid-run. if (userMessage != null && !userMessage.isBlank()) { - conversationService.saveMessage(run.getConversationId(), "user", userMessage); + conversationService.saveMessage(conversationId, "user", userMessage); } return run; } + /** + * Format a cron-run header row. Pattern is parsed by the frontend + * MessageBubble which renders it as a labeled divider when role=system. + * Format: "📋 [{jobName}] {triggerType} · {timestamp}" + */ + private String buildHeader(CronJobEntity job, CronJobRunEntity run) { + String triggerKey = "manual".equalsIgnoreCase(run.getTriggerType()) + ? "cron.run_header.manual" : "cron.run_header.scheduled"; + String triggerLabel = i18n != null ? i18n.msg(triggerKey) + : ("manual".equalsIgnoreCase(run.getTriggerType()) ? "manual" : "scheduled"); + String fallbackTitle = i18n != null ? i18n.msg("cron.tasks_conversation.title") : "Scheduled Tasks"; + return String.format("📋 %s · %s · %s", + job.getName() != null ? job.getName() : fallbackTitle, + triggerLabel, + run.getStartedAt()); + } + /** * T-fail — short transaction: flag the run row as failed when the agent * throws. Always-best-effort policy: delivery_status stays NONE; nothing @@ -100,8 +127,9 @@ public class CronJobLifecycleService { */ @Transactional(propagation = Propagation.REQUIRES_NEW) public void finishRunAndPublish(CronJobEntity job, CronJobRunEntity run, - String userMessage, AssistantMessage result) { - String convId = "cron_" + job.getId(); + String userMessage, AssistantMessage result, + String conversationId) { + String convId = conversationId != null ? conversationId : run.getConversationId(); String text = result != null && result.getText() != null ? result.getText() : ""; runMapper.update(null, new LambdaUpdateWrapper() diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java index 3beee440..5e58de63 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java @@ -41,6 +41,7 @@ public class CronJobRunner { private final CronJobLifecycleService lifecycle; private final AgentService agentService; private final CronChatOriginFactory originFactory; + private final vip.mate.cron.CronConversationResolver conversationResolver; /** * Scheduler-facing entry. Runs three logical segments: @@ -69,21 +70,54 @@ public class CronJobRunner { ? job.getRequestBody() : job.getTriggerMessage(); + // Resolve once and pass through the lifecycle. CronConversationResolver + // routes Web-origin jobs to tasks_ (single visible conversation + // per workspace) and IM-bound jobs to the channel session conversation + // when one already exists, so cron output appears where the user + // naturally looks rather than in an orphan cron_ row. + String conversationId = conversationResolver.resolve(job); + // T1 — short tx CronJobRunEntity run; try { - run = lifecycle.startRun(job, userMessage, triggerType); + run = lifecycle.startRun(job, userMessage, triggerType, conversationId); } catch (Exception e) { log.error("[CronRunner] T1 startRun failed for job {}: {}", job.getId(), e.getMessage(), e); return; } + // task_type='reminder' — pure notification, no LLM call. The user + // (or the create_reminder tool on their behalf) supplied the exact + // text they want pushed; running it through chat() only echoes / + // rephrases it ("收到提醒,请立即前往…"). Hand the trigger_message + // straight to T2 so the recipient sees the literal reminder. + // 'text' jobs still go through the LLM path below (they may need + // tool use, e.g. weather lookup, news search). + if ("reminder".equals(job.getTaskType()) + && job.getTriggerMessage() != null + && !job.getTriggerMessage().isBlank()) { + try { + AssistantMessage direct = new AssistantMessage(job.getTriggerMessage()); + lifecycle.finishRunAndPublish(job, run, userMessage, direct, conversationId); + } catch (Exception e) { + log.error("[CronRunner] reminder direct-push failed for job {}: {}", + job.getId(), e.getMessage(), e); + try { + lifecycle.markRunFailed(run, e); + } catch (Exception markErr) { + log.warn("[CronRunner] markRunFailed after reminder failure also failed for run {}: {}", + run.getId(), markErr.getMessage()); + } + } + return; + } + // No-tx segment — long LLM call. RFC §5.2 hard rule: must not hold // any DB connection during this call. AssistantMessage result; try { - ChatOrigin origin = originFactory.from(job, "cron_" + job.getId()); - result = runAgent(job, userMessage, origin); + ChatOrigin origin = originFactory.from(job, conversationId); + result = runAgent(job, userMessage, origin, conversationId); } catch (Exception e) { log.error("[CronRunner] runAgent failed for job {}: {}", job.getId(), e.getMessage(), e); try { @@ -98,7 +132,7 @@ public class CronJobRunner { // T2 — short tx try { - lifecycle.finishRunAndPublish(job, run, userMessage, result); + lifecycle.finishRunAndPublish(job, run, userMessage, result, conversationId); } catch (Exception e) { log.error("[CronRunner] T2 finishRunAndPublish failed for job {}: {}", job.getId(), e.getMessage(), e); try { @@ -117,11 +151,12 @@ public class CronJobRunner { * mateclaw cli to send to wechat") by telling the model that delivery is * framework-handled. */ - private AssistantMessage runAgent(CronJobEntity job, String userMessage, ChatOrigin origin) { + private AssistantMessage runAgent(CronJobEntity job, String userMessage, ChatOrigin origin, + String conversationId) { String guarded = wrapWithDeliveryGuard(userMessage, origin); String text = "agent".equals(job.getTaskType()) - ? agentService.execute(job.getAgentId(), guarded, "cron_" + job.getId(), origin) - : agentService.chat(job.getAgentId(), guarded, "cron_" + job.getId(), origin); + ? agentService.execute(job.getAgentId(), guarded, conversationId, origin) + : agentService.chat(job.getAgentId(), guarded, conversationId, origin); return new AssistantMessage(text != null ? text : ""); } diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobService.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobService.java index 9d65470f..62276e9a 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobService.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobService.java @@ -467,7 +467,9 @@ public class CronJobService implements ApplicationRunner { throw new MateClawException("err.cron.expression_required", "Cron 表达式不能为空"); } String taskType = dto.getTaskType() != null ? dto.getTaskType() : "text"; - if ("text".equals(taskType) && (dto.getTriggerMessage() == null || dto.getTriggerMessage().isBlank())) { + // 'text' (LLM chat) and 'reminder' (direct push) both rely on triggerMessage. + if (("text".equals(taskType) || "reminder".equals(taskType)) + && (dto.getTriggerMessage() == null || dto.getTriggerMessage().isBlank())) { throw new MateClawException("err.cron.trigger_required", "触发消息不能为空"); } if ("agent".equals(taskType) && (dto.getRequestBody() == null || dto.getRequestBody().isBlank())) { diff --git a/mateclaw-server/src/main/java/vip/mate/dashboard/model/ActiveCronRunVO.java b/mateclaw-server/src/main/java/vip/mate/dashboard/model/ActiveCronRunVO.java new file mode 100644 index 00000000..8213deed --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/dashboard/model/ActiveCronRunVO.java @@ -0,0 +1,22 @@ +package vip.mate.dashboard.model; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Read-model for the "is a cron job currently running in this conversation?" + * UI poll. Returned by {@code GET /api/v1/cron-jobs/active-runs} so the + * chat console can render a placeholder bubble between T1 (run row inserted) + * and T2 (assistant message persisted) — see CronJobLifecycleService for the + * three-segment transactional flow. + */ +@Data +public class ActiveCronRunVO { + private Long runId; + private Long jobId; + private String jobName; + private String triggerType; + private String conversationId; + private LocalDateTime startedAt; +} diff --git a/mateclaw-server/src/main/java/vip/mate/dashboard/service/CronJobRunService.java b/mateclaw-server/src/main/java/vip/mate/dashboard/service/CronJobRunService.java index ed77fd80..af49008e 100644 --- a/mateclaw-server/src/main/java/vip/mate/dashboard/service/CronJobRunService.java +++ b/mateclaw-server/src/main/java/vip/mate/dashboard/service/CronJobRunService.java @@ -7,12 +7,15 @@ import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.cron.model.CronJobEntity; import vip.mate.cron.repository.CronJobMapper; +import vip.mate.dashboard.model.ActiveCronRunVO; import vip.mate.dashboard.model.CronJobRunEntity; import vip.mate.dashboard.repository.CronJobRunMapper; import java.time.LocalDateTime; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -118,4 +121,47 @@ public class CronJobRunService { .orderByDesc(CronJobRunEntity::getStartedAt) .last("LIMIT " + limit)); } + + /** + * Active runs (status='running') for one conversation, joined with the + * cron job name. Used by the chat console to render a placeholder bubble + * while the LLM is still thinking — covers the gap between T1 (run row + * inserted, user message visible) and T2 (assistant message persisted), + * which can be 1–5 minutes when the agent does multi-iteration tool use. + */ + public List listActiveByConversation(String conversationId) { + if (conversationId == null || conversationId.isBlank()) { + return Collections.emptyList(); + } + List runs = runMapper.selectList( + new LambdaQueryWrapper() + .eq(CronJobRunEntity::getConversationId, conversationId) + .eq(CronJobRunEntity::getStatus, "running") + .orderByAsc(CronJobRunEntity::getStartedAt)); + if (runs.isEmpty()) return Collections.emptyList(); + + Set jobIds = runs.stream() + .map(CronJobRunEntity::getCronJobId) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toSet()); + Map jobNameById = new HashMap<>(); + if (!jobIds.isEmpty()) { + cronJobMapper.selectList( + new LambdaQueryWrapper() + .in(CronJobEntity::getId, jobIds) + .select(CronJobEntity::getId, CronJobEntity::getName)) + .forEach(j -> jobNameById.put(j.getId(), j.getName())); + } + + return runs.stream().map(r -> { + ActiveCronRunVO vo = new ActiveCronRunVO(); + vo.setRunId(r.getId()); + vo.setJobId(r.getCronJobId()); + vo.setJobName(jobNameById.getOrDefault(r.getCronJobId(), "")); + vo.setTriggerType(r.getTriggerType()); + vo.setConversationId(r.getConversationId()); + vo.setStartedAt(r.getStartedAt()); + return vo; + }).toList(); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java b/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java index db7ed298..5384bc9c 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java @@ -112,6 +112,21 @@ public class ModelConfigController { return R.ok(); } + /** + * Issue #39 fallback: query-param variant for provider IDs that cannot be + * expressed as a single path segment (slashes, spaces, etc.). The path + * variant above is the primary entry point — this exists so users with + * already-persisted invalid IDs can still clean up their data, since + * Spring's {@code {providerId}} doesn't match across {@code /} and the + * dispatcher would otherwise fall through to the static-resource handler. + */ + @Operation(summary = "删除自定义 Provider(查询参数变体,兼容含特殊字符的旧 ID)") + @DeleteMapping("/custom-providers") + public R deleteCustomProviderByQuery(@RequestParam("providerId") String providerId) { + modelProviderService.deleteCustomProvider(providerId); + return R.ok(); + } + @Operation(summary = "向 Provider 添加模型") @PostMapping("/{providerId}/models") public R addProviderModel(@PathVariable String providerId, diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java index 7244ee5a..f7d2cade 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java @@ -30,6 +30,21 @@ public class ModelProviderService { /** Provider id whose OAuth token lives on local disk (Keychain / ~/.claude/.credentials.json) instead of the database. */ private static final String CLAUDE_CODE_PROVIDER_ID = "anthropic-claude-code"; + /** + * Issue #39: provider id is used as a single path segment in + * {@code /custom-providers/{providerId}}, {@code /{providerId}/config}, + * {@code /{providerId}/enable}, etc. Spring's PathPatternParser does not + * match across {@code /}, so any unsafe character makes every + * such endpoint fall through to the static-resource handler and become + * undeletable. Reject on the create path. + * + *

Keep this in sync with {@code PROVIDER_ID_PATTERN} in + * {@code mateclaw-ui/src/views/Settings/Models/composables/useProviderForm.ts} + * and the routing fallback in {@code mateclaw-ui/src/api/index.ts}.

+ */ + static final java.util.regex.Pattern PROVIDER_ID_PATTERN = + java.util.regex.Pattern.compile("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$"); + private final ModelProviderMapper modelProviderMapper; private final ModelConfigService modelConfigService; private final ApplicationEventPublisher eventPublisher; @@ -129,6 +144,12 @@ public class ModelProviderService { if (!StringUtils.hasText(request.getId()) || !StringUtils.hasText(request.getName())) { throw new MateClawException("err.llm.provider_fields_required", "Provider id 和名称不能为空"); } + // Issue #39: even though the UI now validates, defend the API directly — + // see PROVIDER_ID_PATTERN above for why this matters. + if (!PROVIDER_ID_PATTERN.matcher(request.getId()).matches()) { + throw new MateClawException("err.llm.provider_id_invalid", + "Provider id 仅允许字母/数字及 . _ -(不允许斜杠或空格),首字符必须是字母或数字,长度 1-64: " + request.getId()); + } if (modelProviderMapper.selectById(request.getId()) != null) { throw new MateClawException("err.llm.provider_exists", "Provider 已存在: " + request.getId()); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java index 0837f59b..cff51f96 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java @@ -34,8 +34,12 @@ public class CronJobTool { private final CronJobService cronJobService; @vip.mate.tool.ConcurrencyUnsafe("cron job creation persists to mate_cron_job; concurrent creates can race on name") - @Tool(description = "Create a scheduled task (cron job). The task will run automatically at the specified time " - + "and send the trigger message to the current agent. Use 5-field cron expressions: minute hour day month weekday. " + @Tool(description = "Create a scheduled task that asks the agent to do something at a specific time — " + + "the trigger message is sent to the LLM, which can use tools (search, weather, etc.) to produce the answer. " + + "Use this for queries like 'every morning give me a weather report' or 'daily news summary'. " + + "DO NOT use this for plain reminders where the user already wrote the exact text they want delivered — " + + "use create_reminder instead, otherwise the LLM will rephrase or echo the reminder. " + + "Use 5-field cron expressions: minute hour day month weekday. " + "Examples: '0 9 * * *' = daily at 9am, '0 9 * * 1-5' = weekdays at 9am, '*/30 * * * *' = every 30 minutes.") public String create_cron_job( @ToolParam(description = "Task name, e.g. 'Daily AI News Summary'") String name, @@ -103,6 +107,65 @@ public class CronJobTool { } } + @vip.mate.tool.ConcurrencyUnsafe("reminder creation persists to mate_cron_job; concurrent creates can race on name") + @Tool(description = "Create a scheduled REMINDER. The reminder text is delivered to the user verbatim at the " + + "scheduled time — no LLM call, no rephrasing, no token cost. " + + "Use this when the user wants a notification with specific content (e.g. 'remind me at 3pm to leave for the meeting' → " + + "reminder text 'It's time to leave for the meeting'). " + + "DO NOT use this if the message requires the agent to compute or look something up — use create_cron_job for that. " + + "Use 5-field cron expressions: minute hour day month weekday. " + + "Examples: '0 15 * * *' = every day at 3pm, '0 9 * * 1' = every Monday at 9am.") + public String create_reminder( + @ToolParam(description = "Reminder name, e.g. 'Meeting at Room 6'") String name, + @ToolParam(description = "5-field cron expression: minute hour day month weekday") String cronExpression, + @ToolParam(description = "The exact text to deliver to the user when the reminder fires, e.g. " + + "'⏰ It's time to leave for the meeting at Room 6.'") String reminderText, + @ToolParam(description = "Timezone, default Asia/Shanghai. Examples: UTC, America/New_York", required = false) String timezone, + @Nullable ToolContext ctx) { + + try { + ChatOrigin origin = ChatOrigin.from(ctx); + String conversationId = origin.conversationId() != null && !origin.conversationId().isEmpty() + ? origin.conversationId() + : ToolExecutionContext.conversationId(); + Long agentId = origin.agentId(); + if (agentId == null) { + log.warn("[CronJobTool] create_reminder invoked without an agentId in ChatOrigin " + + "(conv={}); refusing to silently bind to a default agent.", conversationId); + return errorResult("Cannot create reminder: agent context unavailable."); + } + + CronJobDTO dto = new CronJobDTO(); + dto.setName(name); + dto.setCronExpression(cronExpression); + dto.setTriggerMessage(reminderText); + dto.setTimezone(timezone != null && !timezone.isBlank() ? timezone : "Asia/Shanghai"); + dto.setAgentId(agentId); + dto.setTaskType("reminder"); + dto.setEnabled(true); + + propagateChannelBinding(dto, origin); + + Long workspaceId = origin.workspaceId() != null ? origin.workspaceId() : 1L; + CronJobDTO created = cronJobService.create(dto, workspaceId); + + JSONObject result = new JSONObject(); + result.set("success", true); + result.set("jobId", created.getId()); + result.set("name", created.getName()); + result.set("taskType", "reminder"); + result.set("cronExpression", created.getCronExpression()); + result.set("timezone", created.getTimezone()); + result.set("nextRunTime", created.getNextRunTime() != null ? created.getNextRunTime().toString() : ""); + result.set("enabled", created.getEnabled()); + return JSONUtil.toJsonPrettyStr(result); + + } catch (Exception e) { + log.error("[CronJobTool] create_reminder failed: {}", e.getMessage()); + return errorResult("Failed to create reminder: " + e.getMessage()); + } + } + @Tool(description = "List all scheduled tasks (cron jobs) for the current agent. " + "Returns task name, cron expression, next run time, enabled status, and last run time.") public String list_cron_jobs(@Nullable ToolContext ctx) { @@ -208,7 +271,14 @@ public class CronJobTool { if (origin == null || origin.channelId() == null) return; dto.setChannelId(origin.channelId()); if (origin.channelTarget() != null) { - dto.setDeliveryConfig(vip.mate.cron.model.DeliveryConfig.from(origin.channelTarget())); + // Carry the requesterId (= IM senderId) so CronConversationResolver + // can match (channelId, senderId) instead of (channelId, targetId). + // Adapters that use a replyToken (DingTalk sessionWebhook etc.) + // store it as session.targetId, which never equals the cron's own + // chatId/senderId-derived targetId — the senderId match is the + // stable common key. + dto.setDeliveryConfig(vip.mate.cron.model.DeliveryConfig.from( + origin.channelTarget(), origin.requesterId())); } } } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java index 00b034d6..338b07b8 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java @@ -86,6 +86,14 @@ public class ConversationVO extends ConversationEntity { private static String extractSource(String conversationId) { if (conversationId == null) return "web"; + // Underscore-prefixed cron buckets — use the cron icon for both. + // tasks_ is the unified per-workspace cron output conversation + // (CronConversationResolver.resolve for web-origin jobs). cron_ + // is the legacy per-job orphan kept as the IM-cron fallback when + // no channel session exists yet. + if (conversationId.startsWith("tasks_") || conversationId.startsWith("cron_")) { + return "cron"; + } int colonIdx = conversationId.indexOf(':'); if (colonIdx <= 0) return "web"; String prefix = conversationId.substring(0, colonIdx); diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java index 1e1e493f..9a9d9639 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java @@ -8,6 +8,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import vip.mate.exception.MateClawException; +import vip.mate.i18n.I18nService; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; import vip.mate.workspace.core.model.WorkspaceEntity; import vip.mate.workspace.core.model.WorkspaceMemberEntity; import vip.mate.workspace.core.repository.WorkspaceMapper; @@ -28,6 +31,8 @@ public class WorkspaceService { private final WorkspaceMapper workspaceMapper; private final WorkspaceMemberMapper memberMapper; + private final ConversationMapper conversationMapper; + private final I18nService i18n; /** 默认工作区 slug */ public static final String DEFAULT_SLUG = "default"; @@ -91,10 +96,37 @@ public class WorkspaceService { member.setRole("owner"); memberMapper.insert(member); + // Seed the per-workspace tasks conversation so cron output (now routed + // there by CronConversationResolver) shows up in the sidebar from day + // one. The V65 migration handles existing workspaces; this hook covers + // workspaces created post-upgrade. + seedTasksConversation(entity.getId()); + log.info("Created workspace: {} (slug={}, owner={})", entity.getName(), entity.getSlug(), creatorUserId); return entity; } + private void seedTasksConversation(Long workspaceId) { + if (workspaceId == null) return; + ConversationEntity tasks = new ConversationEntity(); + tasks.setConversationId("tasks_" + workspaceId); + tasks.setTitle(i18n != null ? i18n.msg("cron.tasks_conversation.title") : "📋 Scheduled Tasks"); + tasks.setUsername("system"); + tasks.setMessageCount(0); + tasks.setLastActiveTime(java.time.LocalDateTime.now()); + tasks.setStreamStatus("idle"); + tasks.setWorkspaceId(workspaceId); + try { + conversationMapper.insert(tasks); + } catch (Exception e) { + // Non-fatal: workspace creation succeeds even if the seed fails; + // the conversation will be lazy-created on the first cron save + // since saveMessage upserts the conversation row. + log.warn("[WorkspaceService] Failed to seed tasks conversation for workspace {}: {}", + workspaceId, e.getMessage()); + } + } + public WorkspaceEntity update(WorkspaceEntity entity) { WorkspaceEntity existing = getById(entity.getId()); // slug 为 null 时保留原值,不做修改 diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V65__seed_tasks_conversation_per_workspace.sql b/mateclaw-server/src/main/resources/db/migration/h2/V65__seed_tasks_conversation_per_workspace.sql new file mode 100644 index 00000000..830eb3b6 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V65__seed_tasks_conversation_per_workspace.sql @@ -0,0 +1,34 @@ +-- Cron unification: seed a per-workspace "tasks" conversation so cron-run +-- output (now routed there by CronConversationResolver) is reachable from +-- the sidebar. Without this, the new conversationId tasks_ would be +-- written into mate_message but the conversation row wouldn't exist yet +-- and the sidebar/list query wouldn't surface it. +-- +-- Idempotent via NOT EXISTS — re-running on a populated DB does nothing. + +INSERT INTO mate_conversation + (id, conversation_id, title, agent_id, username, message_count, + last_message, last_active_time, stream_status, workspace_id, + parent_conversation_id, create_time, update_time, deleted) +SELECT + -- Synthesize a stable id in the seed range so re-runs collide harmlessly. + 1000200000 + ws.id, + 'tasks_' || ws.id, + '📋 定时任务', + NULL, + 'system', + 0, + NULL, + NOW(), + 'idle', + ws.id, + NULL, + NOW(), + NOW(), + 0 +FROM mate_workspace ws +WHERE ws.deleted = 0 + AND NOT EXISTS ( + SELECT 1 FROM mate_conversation c + WHERE c.conversation_id = 'tasks_' || ws.id AND c.deleted = 0 + ); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V65__seed_tasks_conversation_per_workspace.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V65__seed_tasks_conversation_per_workspace.sql new file mode 100644 index 00000000..ce5b9c87 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V65__seed_tasks_conversation_per_workspace.sql @@ -0,0 +1,28 @@ +-- See h2/V65 for rationale. MySQL syntax differs only in the string +-- concatenation operator (CONCAT vs ||). + +INSERT INTO mate_conversation + (id, conversation_id, title, agent_id, username, message_count, + last_message, last_active_time, stream_status, workspace_id, + parent_conversation_id, create_time, update_time, deleted) +SELECT + 1000200000 + ws.id, + CONCAT('tasks_', ws.id), + '📋 定时任务', + NULL, + 'system', + 0, + NULL, + NOW(), + 'idle', + ws.id, + NULL, + NOW(), + NOW(), + 0 +FROM mate_workspace ws +WHERE ws.deleted = 0 + AND NOT EXISTS ( + SELECT 1 FROM mate_conversation c + WHERE c.conversation_id = CONCAT('tasks_', ws.id) AND c.deleted = 0 + ); diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties index d89eced8..9deca183 100644 --- a/mateclaw-server/src/main/resources/messages.properties +++ b/mateclaw-server/src/main/resources/messages.properties @@ -261,3 +261,8 @@ research.broadcast.failed=\u7814\u7a76\u5931\u8d25 # --- Agent Limit Exceeded Fallback (RFC: prompt-cleanup) --- agent.limit_exceeded.fallback=\u62b1\u6b49\uff0c\u5df2\u8fbe\u5230\u6700\u5927\u63a8\u7406\u6b65\u6570\uff0c\u672a\u80fd\u83b7\u5f97\u5b8c\u6574\u7ed3\u679c\u3002 agent.limit_exceeded.empty_context=\uff08\u5c1a\u672a\u6536\u96c6\u5230\u5de5\u5177\u8c03\u7528\u7ed3\u679c\uff09 + +# --- Cron unified tasks conversation --- +cron.tasks_conversation.title=📋 定时任务 +cron.run_header.scheduled=定时触发 +cron.run_header.manual=手动触发 diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties index 164a00cb..7bfa9a6b 100644 --- a/mateclaw-server/src/main/resources/messages_en.properties +++ b/mateclaw-server/src/main/resources/messages_en.properties @@ -269,3 +269,8 @@ research.broadcast.failed=Research failed. # --- Agent Limit Exceeded Fallback (RFC: prompt-cleanup) --- agent.limit_exceeded.fallback=Sorry, the maximum reasoning steps were reached before a full answer could be produced. agent.limit_exceeded.empty_context=(No tool call results collected yet.) + +# --- Cron unified tasks conversation --- +cron.tasks_conversation.title=📋 Scheduled Tasks +cron.run_header.scheduled=scheduled +cron.run_header.manual=manual diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 27a02992..f22e6f03 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -287,8 +287,15 @@ export const modelApi = { updateProviderConfig: (providerId: string, data: any) => http.put(`/models/${providerId}/config`, data), createCustomProvider: (data: any) => http.post('/models/custom-providers', data), - deleteCustomProvider: (providerId: string) => - http.delete(`/models/custom-providers/${providerId}`), + // Issue #39: fall back to a query-param endpoint when the providerId can't + // safely sit in a path segment (slash / space / etc.) — those rows would + // otherwise be undeletable because Spring's {providerId} doesn't span "/". + deleteCustomProvider: (providerId: string) => { + const safe = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(providerId) + return safe + ? http.delete(`/models/custom-providers/${providerId}`) + : http.delete('/models/custom-providers', { params: { providerId } }) + }, addProviderModel: (providerId: string, data: any) => http.post(`/models/${providerId}/models`, data), removeProviderModel: (providerId: string, modelId: string) => @@ -433,6 +440,8 @@ export const cronJobApi = { toggle: (id: string | number, enabled: boolean) => http.put(`/cron-jobs/${id}/toggle`, null, { params: { enabled } }), runNow: (id: string | number) => http.post(`/cron-jobs/${id}/run`), + activeRuns: (conversationId: string) => + http.get('/cron-jobs/active-runs', { params: { conversationId } }), } // ==================== Wiki Knowledge Base ==================== diff --git a/mateclaw-ui/src/components/chat/MessageList.vue b/mateclaw-ui/src/components/chat/MessageList.vue index 9fa7baf0..de06125b 100644 --- a/mateclaw-ui/src/components/chat/MessageList.vue +++ b/mateclaw-ui/src/components/chat/MessageList.vue @@ -55,6 +55,14 @@ v-if="isCompressionSummary(msg)" :message="msg" /> + +
+
+ {{ msg.content }} +
+
{ } } +// Cron-run header — system message inserted by CronJobLifecycleService.startRun +// to label which job's run starts here. Pattern: leading "📋 ". Renders as a +// labeled divider so users browsing tasks_ can distinguish runs. +const isCronHeader = (msg: Message) => { + return msg.role === 'system' && typeof msg.content === 'string' && msg.content.startsWith('📋 ') +} + // 智能滚动 const { scrollRef, contentRef, isAtBottom, scrollToBottom } = useStickToBottom({ enabled: props.autoScroll, @@ -423,4 +438,25 @@ watch( padding: 10px 12px; } } + +/* Cron-run header divider — labeled separator between runs in tasks_. */ +.cron-divider { + display: flex; + align-items: center; + gap: 12px; + padding: 18px 24px 6px; + user-select: none; +} +.cron-divider__line { + flex: 1; + height: 1px; + background: var(--mc-border-light, rgba(0, 0, 0, 0.08)); +} +.cron-divider__label { + font-size: 12px; + color: var(--mc-text-tertiary, #999); + white-space: nowrap; + font-weight: 500; + letter-spacing: 0.2px; +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index b0a154db..46baeb00 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -154,10 +154,16 @@ export default { uploadFailed: 'File upload failed', dropToUpload: 'Drop files or folders here', copyFailed: 'Copy failed', + datePinned: 'Pinned', dateToday: 'Today', dateYesterday: 'Yesterday', dateLast7Days: 'Last 7 Days', dateEarlier: 'Earlier', + hasUnread: 'New activity', + cronRunning: { + executing: 'Executing…', + fallbackName: 'Scheduled task', + }, suggestionIntro: 'Remember I hate cilantro and love iced Americanos — remind me when ordering', suggestionPoem: 'Search for today\'s biggest tech news and summarize it in one sentence', suggestionCode: 'Ask the writer agent to polish a draft I\'m about to send you', @@ -414,6 +420,9 @@ export default { claudeCodeOauthHint: 'Reuses your local Claude Code Pro/Max subscription. Sign in via the Claude Code app first, then click "Detect" to pick up the credentials.', claudeCodeOauthInstructions: 'No Claude Code credentials found. Install Claude Code, sign in with a Pro/Max account, then click Detect again.', claudeCodeOauthRevokeHint: 'Sign out from the Claude Code app to revoke. MateClaw does not modify Claude Code\'s on-disk credentials.', + providerIdPlaceholder: 'e.g. my-local-gemma', + providerIdHint: 'Used only as an internal key. Lowercase letters/digits, plus . _ - are fine — no slashes or spaces (cannot be changed after create).', + providerIdInvalid: 'Provider ID may only contain letters, digits, dot, underscore, and hyphen (no slashes or spaces). Must start with a letter or digit and be 1–64 chars.', fields: { providerId: 'Provider ID', providerName: 'Provider Name', @@ -1467,7 +1476,7 @@ export default { delivered: 'Delivered', not_delivered: 'Failed', }, - taskTypes: { text: 'Text Message', agent: 'Agent Goal' }, + taskTypes: { text: 'Text Message', reminder: 'Reminder', agent: 'Agent Goal' }, cronTypes: { hourly: 'Hourly', daily: 'Daily', weekly: 'Weekly', custom: 'Custom' }, days: { mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat', sun: 'Sun' }, fields: { @@ -1478,6 +1487,8 @@ export default { taskType: 'Task Type', triggerMessage: 'Trigger Message', triggerMessagePlaceholder: 'Message to send to the agent', + reminderText: 'Reminder Text', + reminderTextPlaceholder: 'Exact text to push when the reminder fires (no LLM rewriting)', requestBody: 'Goal', requestBodyPlaceholder: 'Describe the goal for the agent', cronFrequency: 'Frequency', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 3b35e6b2..cd766db8 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -154,10 +154,16 @@ export default { uploadFailed: '文件上传失败', dropToUpload: '拖放文件或文件夹到此处', copyFailed: '复制失败', + datePinned: '置顶', dateToday: '今天', dateYesterday: '昨天', dateLast7Days: '近 7 天', dateEarlier: '更早', + hasUnread: '有新内容', + cronRunning: { + executing: '执行中…', + fallbackName: '定时任务', + }, suggestionIntro: '记住我平时不吃香菜、喜欢喝冰美式,以后点餐时提醒我', suggestionPoem: '帮我搜一下今天科技圈有什么大新闻,用一句话总结', suggestionCode: '让写手帮我润色一段文案,我先把草稿发你', @@ -404,6 +410,9 @@ export default { claudeCodeOauthHint: '复用本地 Claude Code Pro/Max 订阅。请先在 Claude Code 客户端中登录,再点击"检测"读取凭据。', claudeCodeOauthInstructions: '未检测到 Claude Code 凭据。请安装 Claude Code 客户端,使用 Pro/Max 账号登录后再点击检测。', claudeCodeOauthRevokeHint: '请在 Claude Code 客户端中退出登录。MateClaw 不会修改 Claude Code 的本地凭据。', + providerIdPlaceholder: '例如:my-local-gemma', + providerIdHint: 'ID 仅作内部 key 使用,建议小写英文/数字,可含 . _ -,不要含斜杠或空格(创建后不可修改)。', + providerIdInvalid: 'Provider ID 只能包含字母、数字、点、下划线、连字符(不允许斜杠或空格),首字符必须是字母或数字,长度 1-64。', fields: { providerId: '提供商 ID', providerName: '提供商名称', @@ -1477,7 +1486,7 @@ export default { delivered: '已送达', not_delivered: '投递失败', }, - taskTypes: { text: '文字消息', agent: 'Agent 目标' }, + taskTypes: { text: '文字消息', reminder: '提醒', agent: 'Agent 目标' }, cronTypes: { hourly: '每小时', daily: '每天', weekly: '每周', custom: '自定义' }, days: { mon: '周一', tue: '周二', wed: '周三', thu: '周四', fri: '周五', sat: '周六', sun: '周日' }, fields: { @@ -1488,6 +1497,8 @@ export default { taskType: '任务类型', triggerMessage: '触发消息', triggerMessagePlaceholder: '输入发送给 Agent 的消息', + reminderText: '提醒内容', + reminderTextPlaceholder: '输入到点要原样推送的提醒内容(不会经过 LLM 改写)', requestBody: '执行目标', requestBodyPlaceholder: '直接描述 Agent 要完成的目标', cronFrequency: '执行频率', diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index f898358e..40ada216 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -781,7 +781,7 @@ export interface CronJob { timezone: string agentId: string | number agentName?: string - taskType: 'text' | 'agent' + taskType: 'text' | 'agent' | 'reminder' triggerMessage?: string requestBody?: string enabled: boolean diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 32c96f30..c26bca16 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -90,6 +90,16 @@ />
{{ conv.title }} + + + +
+
+ 🌀 + + {{ run.jobName || $t('chat.cronRunning.fallbackName') }} + + · {{ $t('chat.cronRunning.executing') }} + + + +
+
+ { const currentAgent = computed(() => agents.value.find(a => String(a.id) === String(selectedAgentId.value))) // 按日期分组的会话列表 +// Per-conversation last-viewed timestamp store (localStorage-backed, MVP). +// Keyed by conversationId. Updated when user opens a conversation; read by +// hasUnread() to decide whether to render the small accent dot in the sidebar. +// Will move to a server-side table once we want cross-device read state. +const VIEWED_KEY_PREFIX = 'mc-conv-viewed:' +function markConversationViewed(conversationId: string | undefined, lastActiveTime?: string) { + if (!conversationId) return + const ts = lastActiveTime ? new Date(lastActiveTime).getTime() : Date.now() + try { + localStorage.setItem(VIEWED_KEY_PREFIX + conversationId, String(ts)) + } catch { + // localStorage full / disabled — degrade silently; dot just stays on. + } +} +function hasUnread(conv: Conversation): boolean { + // Only the unified tasks_ conversation gets the unread treatment. + // Regular chats have explicit user attention via the streaming bubble itself, + // and IM-mirror conversations carry their own platform badges in IM apps. + if (!conv.conversationId || !conv.conversationId.startsWith('tasks_')) return false + if (!conv.lastActiveTime) return false + const lastActive = new Date(conv.lastActiveTime).getTime() + if (!Number.isFinite(lastActive)) return false + let viewed = 0 + try { + viewed = Number(localStorage.getItem(VIEWED_KEY_PREFIX + conv.conversationId) || '0') + } catch { + // Treat as never-viewed when storage is unavailable. + } + // Currently-active conversation is implicitly read. + if (currentConversationId.value === conv.conversationId) return false + return lastActive > viewed +} + const groupedConversations = computed(() => { const now = new Date() const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() const yesterdayStart = todayStart - 86400000 const last7Start = todayStart - 7 * 86400000 + // Pinned group always sits at the top so the unified cron output (tasks_) + // is reachable in one glance even after a busy day pushes other conversations + // ahead of it. Only the unified-cron conversation pattern is pinned for now; + // we'll generalize when we have other always-visible conversations. + const pinned: Conversation[] = [] const groups: { label: string; items: Conversation[] }[] = [ + { label: t('chat.datePinned', '置顶'), items: pinned }, { label: t('chat.dateToday'), items: [] }, { label: t('chat.dateYesterday'), items: [] }, { label: t('chat.dateLast7Days'), items: [] }, @@ -648,11 +712,15 @@ const groupedConversations = computed(() => { ] for (const conv of conversations.value) { + if (conv.conversationId && conv.conversationId.startsWith('tasks_')) { + pinned.push(conv) + continue + } const ts = conv.lastActiveTime ? new Date(conv.lastActiveTime).getTime() : 0 - if (ts >= todayStart) groups[0].items.push(conv) - else if (ts >= yesterdayStart) groups[1].items.push(conv) - else if (ts >= last7Start) groups[2].items.push(conv) - else groups[3].items.push(conv) + if (ts >= todayStart) groups[1].items.push(conv) + else if (ts >= yesterdayStart) groups[2].items.push(conv) + else if (ts >= last7Start) groups[3].items.push(conv) + else groups[4].items.push(conv) } return groups.filter(g => g.items.length > 0) @@ -759,6 +827,58 @@ function handleKeyboardShortcuts(e: KeyboardEvent) { let activityPollTimer: number | null = null const ACTIVITY_POLL_MS = 4000 +// Cron progress placeholder: when a cron job is mid-run on the currently +// visible conversation (tasks_ / cron_) the assistant bubble only +// appears after T2 commits, which can be 1–5 minutes for tool-heavy ReAct +// loops. activeCronRuns is filled by the same pollActivity tick so the user +// sees a "executing…" placeholder instead of staring at a blank screen. +interface ActiveCronRun { + runId: number | string + jobId: number | string + jobName?: string + triggerType?: string + conversationId?: string + startedAt?: string +} +const activeCronRuns = ref([]) +function isCronConversation(cid: string | null | undefined): boolean { + return !!cid && (cid.startsWith('tasks_') || cid.startsWith('cron_')) +} +async function refreshActiveCronRuns(cid: string) { + if (!isCronConversation(cid)) { + activeCronRuns.value = [] + return + } + try { + const res: any = await cronJobApi.activeRuns(cid) + if (currentConversationId.value !== cid) return + const next: ActiveCronRun[] = res?.data ?? [] + const wasRunning = activeCronRuns.value.length > 0 + activeCronRuns.value = next + // Transition from "had runs" to "no runs" → assistant bubble was just + // persisted by T2; fetch messages so it shows up without waiting for the + // next pollActivity tick to align. + if (wasRunning && next.length === 0) { + await refreshCurrentConversationMessages(cid) + } + } catch { + // Network blip — keep the previous state, next tick will retry. + } +} +// Reactive ticker so the elapsed label updates without depending on a poll. +const elapsedNow = ref(Date.now()) +let elapsedTickTimer: number | null = null +function elapsedLabel(startedAt?: string): string { + if (!startedAt) return '' + const ms = elapsedNow.value - new Date(startedAt).getTime() + if (ms < 0 || !Number.isFinite(ms)) return '' + const sec = Math.floor(ms / 1000) + if (sec < 60) return `${sec}s` + const min = Math.floor(sec / 60) + const rem = sec % 60 + return `${min}m${rem > 0 ? rem + 's' : ''}` +} + /** * 判断当前消息列表的末尾是不是一条"本地仅有的失败气泡"。 * 典型场景:SSE setup 阶段就抛错(如"无权操作该会话"), @@ -808,6 +928,9 @@ async function pollActivity() { } catch { // 忽略探测失败 } + // Cron progress placeholder — independent of streamStatus because cron + // runs use the non-streaming chat() path, so streamStatus stays idle. + await refreshActiveCronRuns(cid) } } @@ -826,6 +949,9 @@ onMounted(async () => { await Promise.all([loadAgents(), loadModelState(), loadConversations()]) await hydrateStateFromRoute() activityPollTimer = window.setInterval(pollActivity, ACTIVITY_POLL_MS) + elapsedTickTimer = window.setInterval(() => { + if (activeCronRuns.value.length > 0) elapsedNow.value = Date.now() + }, 1000) }) onBeforeUnmount(() => { @@ -840,6 +966,10 @@ onBeforeUnmount(() => { clearInterval(activityPollTimer) activityPollTimer = null } + if (elapsedTickTimer !== null) { + clearInterval(elapsedTickTimer) + elapsedTickTimer = null + } // Switching tabs / route changes / mouse-detach unmount this component, but the // backend agent should keep running so the user can reconnect later. Use // resetForNewConversation (front-end SSE disconnect only) instead of @@ -985,6 +1115,16 @@ async function selectConversation(conv: Conversation) { } currentConversationId.value = conv.conversationId selectedAgentId.value = conv.agentId || selectedAgentId.value + // Reset cron placeholder state up front; the immediate fetch below repopulates + // it for cron conversations so the user doesn't wait up to 4s for the next tick. + activeCronRuns.value = [] + if (isCronConversation(conv.conversationId)) { + void refreshActiveCronRuns(conv.conversationId) + } + // Mark as read when opened — clears the unread dot on tasks_ after + // the user actually visits the cron output. localStorage-only for MVP; + // server-side last-viewed table is a future enhancement. + markConversationViewed(conv.conversationId, conv.lastActiveTime) const requestedConvId = conv.conversationId try { const res: any = await conversationApi.listMessages(requestedConvId) @@ -1577,6 +1717,35 @@ function handleCodeCopy(e: MouseEvent) {