mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
5192cefa4b
commit
977e181949
@ -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={}",
|
||||
|
||||
@ -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_");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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>()
|
||||
|
||||
@ -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 : "");
|
||||
}
|
||||
|
||||
|
||||
@ -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())) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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<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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 时保留原值,不做修改
|
||||
|
||||
@ -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
|
||||
);
|
||||
@ -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
|
||||
);
|
||||
@ -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=手动触发
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '执行频率',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 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<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);
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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'))
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user