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.
This commit is contained in:
matevip 2026-04-30 15:01:24 +08:00
parent 5192cefa4b
commit 977e181949
29 changed files with 866 additions and 35 deletions

View File

@ -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_<wsId> 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={}",

View File

@ -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.
* <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_");
}
}

View File

@ -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<List<ActiveCronRunVO>> activeRuns(
@RequestParam("conversationId") String conversationId) {
return R.ok(cronJobRunService.listActiveByConversation(conversationId));
}
private static long resolve(Long headerWorkspaceId) {
return headerWorkspaceId != null ? headerWorkspaceId : DEFAULT_WORKSPACE_ID;
}

View File

@ -33,10 +33,17 @@ public class CronJobEntity {
/** 关联 Agent ID */
private Long agentId;
/** 任务类型text | agent */
/**
* 任务类型
* <ul>
* <li>{@code text} single-turn LLM chat (uses {@code triggerMessage})</li>
* <li>{@code agent} Plan-Execute (uses {@code requestBody})</li>
* <li>{@code reminder} direct push of {@code triggerMessage}, no LLM call</li>
* </ul>
*/
private String taskType;
/** 触发消息task_type=text 时使用) */
/** 触发消息task_type=text 或 reminder 时使用) */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String triggerMessage;

View File

@ -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.
* <p>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. */

View File

@ -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_<wsId> 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<CronJobRunEntity>()

View File

@ -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_<wsId> (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_<id> 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 : "");
}

View File

@ -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())) {

View File

@ -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;
}

View File

@ -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 15 minutes when the agent does multi-iteration tool use.
*/
public List<ActiveCronRunVO> listActiveByConversation(String conversationId) {
if (conversationId == null || conversationId.isBlank()) {
return Collections.emptyList();
}
List<CronJobRunEntity> runs = runMapper.selectList(
new LambdaQueryWrapper<CronJobRunEntity>()
.eq(CronJobRunEntity::getConversationId, conversationId)
.eq(CronJobRunEntity::getStatus, "running")
.orderByAsc(CronJobRunEntity::getStartedAt));
if (runs.isEmpty()) return Collections.emptyList();
Set<Long> jobIds = runs.stream()
.map(CronJobRunEntity::getCronJobId)
.filter(java.util.Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, String> jobNameById = new HashMap<>();
if (!jobIds.isEmpty()) {
cronJobMapper.selectList(
new LambdaQueryWrapper<CronJobEntity>()
.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();
}
}

View File

@ -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<Void> deleteCustomProviderByQuery(@RequestParam("providerId") String providerId) {
modelProviderService.deleteCustomProvider(providerId);
return R.ok();
}
@Operation(summary = "向 Provider 添加模型")
@PostMapping("/{providerId}/models")
public R<ProviderInfoDTO> addProviderModel(@PathVariable String providerId,

View File

@ -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 <em>every</em>
* such endpoint fall through to the static-resource handler and become
* undeletable. Reject on the create path.
*
* <p>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}.</p>
*/
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());
}

View File

@ -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()));
}
}
}

View File

@ -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_<wsId> is the unified per-workspace cron output conversation
// (CronConversationResolver.resolve for web-origin jobs). cron_<id>
// 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);

View File

@ -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 时保留原值不做修改

View File

@ -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_<wsId> 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
);

View File

@ -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
);

View File

@ -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=手动触发

View File

@ -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

View File

@ -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 ====================

View File

@ -55,6 +55,14 @@
v-if="isCompressionSummary(msg)"
:message="msg"
/>
<!-- Cron-run 头部分隔卡system 消息且以 📋 开头
tasks_<wsId> / IM 镜像会话里把"这是哪个 cron 跑的"清晰标出来
LLM 历史读取时会跳过 system 消息所以不污染下次提示词 -->
<div v-else-if="isCronHeader(msg)" class="cron-divider">
<div class="cron-divider__line"></div>
<span class="cron-divider__label">{{ msg.content }}</span>
<div class="cron-divider__line"></div>
</div>
<!-- 普通消息气泡 -->
<MessageBubble
v-else
@ -150,6 +158,13 @@ const isCompressionSummary = (msg: Message) => {
}
}
// 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_<wsId> 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_<wsId>. */
.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;
}
</style>

View File

@ -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 164 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',

View File

@ -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: '执行频率',

View File

@ -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

View File

@ -90,6 +90,16 @@
/>
<div v-else class="conv-title" @dblclick.stop="startRename(conv)">
<span>{{ conv.title }}</span>
<!-- Unread dot for the unified tasks conversation: when a
cron run lands but the user hasn't opened the conversation
since, show a small accent dot. Avoids needing a server-side
last-viewed table for MVP localStorage tracks per-conv
last-view timestamp, sidebar compares to lastActiveTime. -->
<span
v-if="hasUnread(conv)"
class="conv-unread-dot"
:title="$t('chat.hasUnread', '有新内容')"
></span>
<span
v-if="conv.streamStatus === 'running'"
class="conv-running-badge"
@ -216,6 +226,21 @@
</template>
</MessageList>
<!-- Cron job in-flight placeholder visible while T2 hasn't committed
the assistant message yet. Populated by pollActivity /cron-jobs/active-runs. -->
<div v-if="activeCronRuns.length > 0" class="cron-running-bar">
<div v-for="run in activeCronRuns" :key="run.runId" class="cron-running-item">
<span class="cron-running-spinner">🌀</span>
<span class="cron-running-text">
<strong>{{ run.jobName || $t('chat.cronRunning.fallbackName') }}</strong>
<span class="cron-running-meta">
· {{ $t('chat.cronRunning.executing') }}
<template v-if="run.startedAt"> · {{ elapsedLabel(run.startedAt) }}</template>
</span>
</span>
</div>
</div>
<!-- 流式处理 Loading 消息和输入框之间 -->
<StreamLoadingBar
:is-loading="isGenerating && !showModelPrompt"
@ -277,7 +302,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ChatDotRound, Delete, Plus, Setting, UploadFilled } from '@element-plus/icons-vue'
import { conversationApi, agentApi, modelApi, chatApi } from '@/api/index'
import { conversationApi, agentApi, modelApi, chatApi, cronJobApi } from '@/api/index'
import { channelIconUrl } from '@/utils/channelSource'
import { useChat } from '@/composables/chat/useChat'
import { reconstructErrorInfo } from '@/types/chatError'
@ -634,13 +659,52 @@ const connectionStatusLabel = computed(() => {
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_<wsId> 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_<wsId>)
// 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_<wsId> / cron_<id>) the assistant bubble only
// appears after T2 commits, which can be 15 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<ActiveCronRun[]>([])
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_<wsId> 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) {
</script>
<style scoped>
.cron-running-bar {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 16px;
margin: 0 12px;
background: var(--mc-warning-bg, rgba(255, 159, 67, 0.08));
border: 1px solid var(--mc-warning, rgba(255, 159, 67, 0.35));
border-radius: 10px;
color: var(--mc-text-primary);
font-size: 13px;
}
.cron-running-item {
display: flex;
align-items: center;
gap: 10px;
}
.cron-running-spinner {
display: inline-block;
font-size: 16px;
animation: cron-spin 1.6s linear infinite;
}
@keyframes cron-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.cron-running-text { line-height: 1.4; }
.cron-running-meta { color: var(--mc-text-secondary); margin-left: 4px; }
.chat-console-shell {
background: transparent;
min-height: 0;
@ -1923,6 +2092,19 @@ function handleCodeCopy(e: MouseEvent) {
pointer-events: none;
}
/* Inline unread accent dot shown next to title when a conversation has
activity since the user's last view (currently scoped to tasks_<wsId>). */
.conv-unread-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--mc-primary, #d97757);
margin-left: 6px;
flex-shrink: 0;
vertical-align: middle;
}
.conv-item.is-running {
background: color-mix(in srgb, #fbbf24 8%, transparent);
}

View File

@ -180,6 +180,10 @@
<div class="detail-label">{{ t('cronJobs.fields.triggerMessage') }}</div>
<div class="detail-value detail-block">{{ detailJob.triggerMessage || '-' }}</div>
</div>
<div class="detail-item detail-item-full" v-else-if="detailJob.taskType === 'reminder'">
<div class="detail-label">{{ t('cronJobs.fields.reminderText') }}</div>
<div class="detail-value detail-block">{{ detailJob.triggerMessage || '-' }}</div>
</div>
<div class="detail-item detail-item-full" v-else>
<div class="detail-label">{{ t('cronJobs.fields.requestBody') }}</div>
<div class="detail-value detail-block">{{ detailJob.requestBody || '-' }}</div>
@ -220,6 +224,10 @@
<input type="radio" v-model="form.taskType" value="text" />
{{ t('cronJobs.taskTypes.text') }}
</label>
<label class="radio-option" :class="{ active: form.taskType === 'reminder' }">
<input type="radio" v-model="form.taskType" value="reminder" />
{{ t('cronJobs.taskTypes.reminder') }}
</label>
<label class="radio-option" :class="{ active: form.taskType === 'agent' }">
<input type="radio" v-model="form.taskType" value="agent" />
{{ t('cronJobs.taskTypes.agent') }}
@ -232,6 +240,11 @@
<textarea v-model="form.triggerMessage" class="form-textarea" rows="3"
:placeholder="t('cronJobs.fields.triggerMessagePlaceholder')"></textarea>
</div>
<div v-else-if="form.taskType === 'reminder'" class="form-group">
<label class="form-label">{{ t('cronJobs.fields.reminderText') }} *</label>
<textarea v-model="form.triggerMessage" class="form-textarea" rows="3"
:placeholder="t('cronJobs.fields.reminderTextPlaceholder')"></textarea>
</div>
<div v-else class="form-group">
<label class="form-label">{{ t('cronJobs.fields.requestBody') }} *</label>
<textarea v-model="form.requestBody" class="form-textarea" rows="3"
@ -344,6 +357,7 @@ const form = ref<any>(defaultForm())
const canSave = computed(() => {
if (!form.value.name || !form.value.agentId) return false
if (form.value.taskType === 'text' && !form.value.triggerMessage) return false
if (form.value.taskType === 'reminder' && !form.value.triggerMessage) return false
if (form.value.taskType === 'agent' && !form.value.requestBody) return false
if (cronType.value === 'custom' && !form.value.cronExpression?.trim()) return false
return true
@ -668,6 +682,7 @@ function formatTime(datetime: string | undefined): string {
}
.type-badge { display: inline-flex; align-items: center; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.type-text { background: var(--mc-primary-bg); color: var(--mc-primary); }
.type-reminder { background: var(--mc-warning-bg, var(--mc-primary-bg)); color: var(--mc-warning, var(--mc-primary-hover)); }
.type-agent { background: var(--mc-success-bg, var(--mc-primary-bg)); color: var(--mc-success, var(--mc-primary-hover)); }
.cron-code { display: inline-flex; background: var(--mc-bg-sunken); padding: 4px 8px; border-radius: 8px; font-size: 12px; color: var(--mc-text-primary); font-family: monospace; }
.cron-readable { font-size: 12px; line-height: 1.45; color: var(--mc-text-tertiary); margin-top: 6px; }

View File

@ -1,11 +1,18 @@
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessageBox } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { modelApi } from '@/api'
import type { ProviderInfo } from '@/types'
import { safeParseJson } from '@/utils/safeJson'
import { chatModelToProtocol, protocolToChatModel } from '@/utils/modelProtocol'
// Provider IDs are used as path segments in DELETE / config endpoints.
// Slashes / spaces / # / ? would make `{providerId}` PathVariable miss
// the controller and fall through to the static-resource handler
// (see issue #39: "No static resource api/v1/models/custom-providers/...").
// Keep this in sync with the backend if a server-side guard is added.
const PROVIDER_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/
interface ListDeps {
loadProviders: () => Promise<void>
loadActiveModel: () => Promise<void>
@ -136,7 +143,19 @@ export function useProviderForm(deps: ListDeps) {
advancedOpen.value = false
}
async function saveProvider() {
async function saveProvider(): Promise<boolean> {
// RFC-074 / issue #39: provider id becomes a URL path segment, so a slash
// or other unsafe char makes the row impossible to delete later. Validate
// before hitting the API on the create path; editing is exempt because the
// id field is hidden and the existing value is reused untouched.
if (!editingProvider.value) {
const id = providerForm.id.trim()
if (!id || !PROVIDER_ID_PATTERN.test(id)) {
ElMessage.error(t('settings.model.providerIdInvalid'))
return false
}
providerForm.id = id
}
const kwargs = safeParseJson(providerForm.generateKwargsText)
if (providerForm.enableSearch) {
kwargs.enableSearch = true
@ -183,6 +202,7 @@ export function useProviderForm(deps: ListDeps) {
}
closeProviderModal()
await Promise.all([deps.loadProviders(), deps.loadActiveModel()])
return true
}
/**

View File

@ -336,8 +336,11 @@ function onCardOAuthLogin(provider: ProviderInfo) {
async function onSaveProvider() {
try {
await saveProvider()
showSavedTip(t('settings.model.providerSaved'))
const saved = await saveProvider()
// Issue #39: saveProvider() returns false when client-side validation
// (e.g. provider id format) blocks the request it has already shown
// its own ElMessage.error, so don't follow up with a "saved" toast.
if (saved) showSavedTip(t('settings.model.providerSaved'))
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : t('settings.messages.saveFailed'))
}

View File

@ -9,7 +9,12 @@
<div class="form-grid">
<div class="form-group" v-if="!editingProvider">
<label class="form-label">{{ t('settings.model.fields.providerId') }}</label>
<input v-model="form.id" class="form-input" />
<input
v-model="form.id"
class="form-input mono"
:placeholder="t('settings.model.providerIdPlaceholder')"
/>
<div class="field-hint">{{ t('settings.model.providerIdHint') }}</div>
</div>
<div class="form-group" v-if="!editingProvider">
<label class="form-label">{{ t('settings.model.fields.providerName') }}</label>