mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 20:34:39 +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;
|
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
|
// Stage 1: drop approval-placeholder assistant messages
|
||||||
if ("assistant".equals(entity.getRole()) && isApprovalPlaceholder(entity.getContent())) {
|
if ("assistant".equals(entity.getRole()) && isApprovalPlaceholder(entity.getContent())) {
|
||||||
log.debug("[{}] Filtering approval placeholder from history: msgId={}",
|
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.common.result.R;
|
||||||
import vip.mate.cron.model.CronJobDTO;
|
import vip.mate.cron.model.CronJobDTO;
|
||||||
import vip.mate.cron.service.CronJobService;
|
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 vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -23,6 +25,7 @@ import java.util.List;
|
|||||||
public class CronJobController {
|
public class CronJobController {
|
||||||
|
|
||||||
private final CronJobService cronJobService;
|
private final CronJobService cronJobService;
|
||||||
|
private final CronJobRunService cronJobRunService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC-083: every endpoint reads {@code X-Workspace-Id} (the frontend
|
* RFC-083: every endpoint reads {@code X-Workspace-Id} (the frontend
|
||||||
@ -92,6 +95,20 @@ public class CronJobController {
|
|||||||
return R.ok();
|
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) {
|
private static long resolve(Long headerWorkspaceId) {
|
||||||
return headerWorkspaceId != null ? headerWorkspaceId : DEFAULT_WORKSPACE_ID;
|
return headerWorkspaceId != null ? headerWorkspaceId : DEFAULT_WORKSPACE_ID;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,10 +33,17 @@ public class CronJobEntity {
|
|||||||
/** 关联 Agent ID */
|
/** 关联 Agent ID */
|
||||||
private Long agentId;
|
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;
|
private String taskType;
|
||||||
|
|
||||||
/** 触发消息(task_type=text 时使用) */
|
/** 触发消息(task_type=text 或 reminder 时使用) */
|
||||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||||
private String triggerMessage;
|
private String triggerMessage;
|
||||||
|
|
||||||
|
|||||||
@ -18,13 +18,41 @@ import vip.mate.agent.context.ChannelTarget;
|
|||||||
public record DeliveryConfig(
|
public record DeliveryConfig(
|
||||||
@Nullable String targetId,
|
@Nullable String targetId,
|
||||||
@Nullable String threadId,
|
@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}. */
|
/** Convert from the {@link ChannelTarget} carried on a {@code ChatOrigin}. */
|
||||||
public static DeliveryConfig from(@Nullable ChannelTarget t) {
|
public static DeliveryConfig from(@Nullable ChannelTarget t) {
|
||||||
if (t == null) return null;
|
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. */
|
/** 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.cron.model.CronJobEntity;
|
||||||
import vip.mate.dashboard.model.CronJobRunEntity;
|
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||||
import vip.mate.dashboard.repository.CronJobRunMapper;
|
import vip.mate.dashboard.repository.CronJobRunMapper;
|
||||||
|
import vip.mate.i18n.I18nService;
|
||||||
import vip.mate.memory.event.ConversationCompletionPublisher;
|
import vip.mate.memory.event.ConversationCompletionPublisher;
|
||||||
import vip.mate.workspace.conversation.ConversationService;
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
|
||||||
@ -47,6 +48,7 @@ public class CronJobLifecycleService {
|
|||||||
private final ConversationService conversationService;
|
private final ConversationService conversationService;
|
||||||
private final ConversationCompletionPublisher completionPublisher;
|
private final ConversationCompletionPublisher completionPublisher;
|
||||||
private final ApplicationEventPublisher events;
|
private final ApplicationEventPublisher events;
|
||||||
|
private final I18nService i18n;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* T1 — short transaction: persist a run row in {@code running} state,
|
* 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)
|
* @param triggerType {@code scheduled} (cron tick) or {@code manual} (runNow)
|
||||||
*/
|
*/
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
@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();
|
CronJobRunEntity run = new CronJobRunEntity();
|
||||||
run.setCronJobId(job.getId());
|
run.setCronJobId(job.getId());
|
||||||
run.setConversationId("cron_" + job.getId());
|
run.setConversationId(conversationId);
|
||||||
run.setStatus("running");
|
run.setStatus("running");
|
||||||
run.setTriggerType(triggerType != null ? triggerType : "scheduled");
|
run.setTriggerType(triggerType != null ? triggerType : "scheduled");
|
||||||
run.setStartedAt(LocalDateTime.now());
|
run.setStartedAt(LocalDateTime.now());
|
||||||
run.setDeliveryStatus("NONE");
|
run.setDeliveryStatus("NONE");
|
||||||
runMapper.insert(run);
|
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
|
// Persist the user message before the LLM call so history reads
|
||||||
// see a coherent (user → assistant) ordering even if the agent
|
// see a coherent (user → assistant) ordering even if the agent
|
||||||
// throws mid-run.
|
// throws mid-run.
|
||||||
if (userMessage != null && !userMessage.isBlank()) {
|
if (userMessage != null && !userMessage.isBlank()) {
|
||||||
conversationService.saveMessage(run.getConversationId(), "user", userMessage);
|
conversationService.saveMessage(conversationId, "user", userMessage);
|
||||||
}
|
}
|
||||||
return run;
|
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
|
* T-fail — short transaction: flag the run row as failed when the agent
|
||||||
* throws. Always-best-effort policy: delivery_status stays NONE; nothing
|
* throws. Always-best-effort policy: delivery_status stays NONE; nothing
|
||||||
@ -100,8 +127,9 @@ public class CronJobLifecycleService {
|
|||||||
*/
|
*/
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
public void finishRunAndPublish(CronJobEntity job, CronJobRunEntity run,
|
public void finishRunAndPublish(CronJobEntity job, CronJobRunEntity run,
|
||||||
String userMessage, AssistantMessage result) {
|
String userMessage, AssistantMessage result,
|
||||||
String convId = "cron_" + job.getId();
|
String conversationId) {
|
||||||
|
String convId = conversationId != null ? conversationId : run.getConversationId();
|
||||||
String text = result != null && result.getText() != null ? result.getText() : "";
|
String text = result != null && result.getText() != null ? result.getText() : "";
|
||||||
|
|
||||||
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
runMapper.update(null, new LambdaUpdateWrapper<CronJobRunEntity>()
|
||||||
|
|||||||
@ -41,6 +41,7 @@ public class CronJobRunner {
|
|||||||
private final CronJobLifecycleService lifecycle;
|
private final CronJobLifecycleService lifecycle;
|
||||||
private final AgentService agentService;
|
private final AgentService agentService;
|
||||||
private final CronChatOriginFactory originFactory;
|
private final CronChatOriginFactory originFactory;
|
||||||
|
private final vip.mate.cron.CronConversationResolver conversationResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scheduler-facing entry. Runs three logical segments:
|
* Scheduler-facing entry. Runs three logical segments:
|
||||||
@ -69,21 +70,54 @@ public class CronJobRunner {
|
|||||||
? job.getRequestBody()
|
? job.getRequestBody()
|
||||||
: job.getTriggerMessage();
|
: 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
|
// T1 — short tx
|
||||||
CronJobRunEntity run;
|
CronJobRunEntity run;
|
||||||
try {
|
try {
|
||||||
run = lifecycle.startRun(job, userMessage, triggerType);
|
run = lifecycle.startRun(job, userMessage, triggerType, conversationId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[CronRunner] T1 startRun failed for job {}: {}", job.getId(), e.getMessage(), e);
|
log.error("[CronRunner] T1 startRun failed for job {}: {}", job.getId(), e.getMessage(), e);
|
||||||
return;
|
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
|
// No-tx segment — long LLM call. RFC §5.2 hard rule: must not hold
|
||||||
// any DB connection during this call.
|
// any DB connection during this call.
|
||||||
AssistantMessage result;
|
AssistantMessage result;
|
||||||
try {
|
try {
|
||||||
ChatOrigin origin = originFactory.from(job, "cron_" + job.getId());
|
ChatOrigin origin = originFactory.from(job, conversationId);
|
||||||
result = runAgent(job, userMessage, origin);
|
result = runAgent(job, userMessage, origin, conversationId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[CronRunner] runAgent failed for job {}: {}", job.getId(), e.getMessage(), e);
|
log.error("[CronRunner] runAgent failed for job {}: {}", job.getId(), e.getMessage(), e);
|
||||||
try {
|
try {
|
||||||
@ -98,7 +132,7 @@ public class CronJobRunner {
|
|||||||
|
|
||||||
// T2 — short tx
|
// T2 — short tx
|
||||||
try {
|
try {
|
||||||
lifecycle.finishRunAndPublish(job, run, userMessage, result);
|
lifecycle.finishRunAndPublish(job, run, userMessage, result, conversationId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[CronRunner] T2 finishRunAndPublish failed for job {}: {}", job.getId(), e.getMessage(), e);
|
log.error("[CronRunner] T2 finishRunAndPublish failed for job {}: {}", job.getId(), e.getMessage(), e);
|
||||||
try {
|
try {
|
||||||
@ -117,11 +151,12 @@ public class CronJobRunner {
|
|||||||
* mateclaw cli to send to wechat") by telling the model that delivery is
|
* mateclaw cli to send to wechat") by telling the model that delivery is
|
||||||
* framework-handled.
|
* 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 guarded = wrapWithDeliveryGuard(userMessage, origin);
|
||||||
String text = "agent".equals(job.getTaskType())
|
String text = "agent".equals(job.getTaskType())
|
||||||
? agentService.execute(job.getAgentId(), guarded, "cron_" + job.getId(), origin)
|
? agentService.execute(job.getAgentId(), guarded, conversationId, origin)
|
||||||
: agentService.chat(job.getAgentId(), guarded, "cron_" + job.getId(), origin);
|
: agentService.chat(job.getAgentId(), guarded, conversationId, origin);
|
||||||
return new AssistantMessage(text != null ? text : "");
|
return new AssistantMessage(text != null ? text : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -467,7 +467,9 @@ public class CronJobService implements ApplicationRunner {
|
|||||||
throw new MateClawException("err.cron.expression_required", "Cron 表达式不能为空");
|
throw new MateClawException("err.cron.expression_required", "Cron 表达式不能为空");
|
||||||
}
|
}
|
||||||
String taskType = dto.getTaskType() != null ? dto.getTaskType() : "text";
|
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", "触发消息不能为空");
|
throw new MateClawException("err.cron.trigger_required", "触发消息不能为空");
|
||||||
}
|
}
|
||||||
if ("agent".equals(taskType) && (dto.getRequestBody() == null || dto.getRequestBody().isBlank())) {
|
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.agent.repository.AgentMapper;
|
||||||
import vip.mate.cron.model.CronJobEntity;
|
import vip.mate.cron.model.CronJobEntity;
|
||||||
import vip.mate.cron.repository.CronJobMapper;
|
import vip.mate.cron.repository.CronJobMapper;
|
||||||
|
import vip.mate.dashboard.model.ActiveCronRunVO;
|
||||||
import vip.mate.dashboard.model.CronJobRunEntity;
|
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||||
import vip.mate.dashboard.repository.CronJobRunMapper;
|
import vip.mate.dashboard.repository.CronJobRunMapper;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@ -118,4 +121,47 @@ public class CronJobRunService {
|
|||||||
.orderByDesc(CronJobRunEntity::getStartedAt)
|
.orderByDesc(CronJobRunEntity::getStartedAt)
|
||||||
.last("LIMIT " + limit));
|
.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();
|
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 添加模型")
|
@Operation(summary = "向 Provider 添加模型")
|
||||||
@PostMapping("/{providerId}/models")
|
@PostMapping("/{providerId}/models")
|
||||||
public R<ProviderInfoDTO> addProviderModel(@PathVariable String providerId,
|
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. */
|
/** 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";
|
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 ModelProviderMapper modelProviderMapper;
|
||||||
private final ModelConfigService modelConfigService;
|
private final ModelConfigService modelConfigService;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
@ -129,6 +144,12 @@ public class ModelProviderService {
|
|||||||
if (!StringUtils.hasText(request.getId()) || !StringUtils.hasText(request.getName())) {
|
if (!StringUtils.hasText(request.getId()) || !StringUtils.hasText(request.getName())) {
|
||||||
throw new MateClawException("err.llm.provider_fields_required", "Provider id 和名称不能为空");
|
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) {
|
if (modelProviderMapper.selectById(request.getId()) != null) {
|
||||||
throw new MateClawException("err.llm.provider_exists", "Provider 已存在: " + request.getId());
|
throw new MateClawException("err.llm.provider_exists", "Provider 已存在: " + request.getId());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,8 +34,12 @@ public class CronJobTool {
|
|||||||
private final CronJobService cronJobService;
|
private final CronJobService cronJobService;
|
||||||
|
|
||||||
@vip.mate.tool.ConcurrencyUnsafe("cron job creation persists to mate_cron_job; concurrent creates can race on name")
|
@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 "
|
@Tool(description = "Create a scheduled task that asks the agent to do something at a specific time — "
|
||||||
+ "and send the trigger message to the current agent. Use 5-field cron expressions: minute hour day month weekday. "
|
+ "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.")
|
+ "Examples: '0 9 * * *' = daily at 9am, '0 9 * * 1-5' = weekdays at 9am, '*/30 * * * *' = every 30 minutes.")
|
||||||
public String create_cron_job(
|
public String create_cron_job(
|
||||||
@ToolParam(description = "Task name, e.g. 'Daily AI News Summary'") String name,
|
@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. "
|
@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.")
|
+ "Returns task name, cron expression, next run time, enabled status, and last run time.")
|
||||||
public String list_cron_jobs(@Nullable ToolContext ctx) {
|
public String list_cron_jobs(@Nullable ToolContext ctx) {
|
||||||
@ -208,7 +271,14 @@ public class CronJobTool {
|
|||||||
if (origin == null || origin.channelId() == null) return;
|
if (origin == null || origin.channelId() == null) return;
|
||||||
dto.setChannelId(origin.channelId());
|
dto.setChannelId(origin.channelId());
|
||||||
if (origin.channelTarget() != null) {
|
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) {
|
private static String extractSource(String conversationId) {
|
||||||
if (conversationId == null) return "web";
|
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(':');
|
int colonIdx = conversationId.indexOf(':');
|
||||||
if (colonIdx <= 0) return "web";
|
if (colonIdx <= 0) return "web";
|
||||||
String prefix = conversationId.substring(0, colonIdx);
|
String prefix = conversationId.substring(0, colonIdx);
|
||||||
|
|||||||
@ -8,6 +8,9 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import vip.mate.exception.MateClawException;
|
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.WorkspaceEntity;
|
||||||
import vip.mate.workspace.core.model.WorkspaceMemberEntity;
|
import vip.mate.workspace.core.model.WorkspaceMemberEntity;
|
||||||
import vip.mate.workspace.core.repository.WorkspaceMapper;
|
import vip.mate.workspace.core.repository.WorkspaceMapper;
|
||||||
@ -28,6 +31,8 @@ public class WorkspaceService {
|
|||||||
|
|
||||||
private final WorkspaceMapper workspaceMapper;
|
private final WorkspaceMapper workspaceMapper;
|
||||||
private final WorkspaceMemberMapper memberMapper;
|
private final WorkspaceMemberMapper memberMapper;
|
||||||
|
private final ConversationMapper conversationMapper;
|
||||||
|
private final I18nService i18n;
|
||||||
|
|
||||||
/** 默认工作区 slug */
|
/** 默认工作区 slug */
|
||||||
public static final String DEFAULT_SLUG = "default";
|
public static final String DEFAULT_SLUG = "default";
|
||||||
@ -91,10 +96,37 @@ public class WorkspaceService {
|
|||||||
member.setRole("owner");
|
member.setRole("owner");
|
||||||
memberMapper.insert(member);
|
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);
|
log.info("Created workspace: {} (slug={}, owner={})", entity.getName(), entity.getSlug(), creatorUserId);
|
||||||
return entity;
|
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) {
|
public WorkspaceEntity update(WorkspaceEntity entity) {
|
||||||
WorkspaceEntity existing = getById(entity.getId());
|
WorkspaceEntity existing = getById(entity.getId());
|
||||||
// slug 为 null 时保留原值,不做修改
|
// 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 (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.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
|
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 (RFC: prompt-cleanup) ---
|
||||||
agent.limit_exceeded.fallback=Sorry, the maximum reasoning steps were reached before a full answer could be produced.
|
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.)
|
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) =>
|
updateProviderConfig: (providerId: string, data: any) =>
|
||||||
http.put(`/models/${providerId}/config`, data),
|
http.put(`/models/${providerId}/config`, data),
|
||||||
createCustomProvider: (data: any) => http.post('/models/custom-providers', data),
|
createCustomProvider: (data: any) => http.post('/models/custom-providers', data),
|
||||||
deleteCustomProvider: (providerId: string) =>
|
// Issue #39: fall back to a query-param endpoint when the providerId can't
|
||||||
http.delete(`/models/custom-providers/${providerId}`),
|
// 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) =>
|
addProviderModel: (providerId: string, data: any) =>
|
||||||
http.post(`/models/${providerId}/models`, data),
|
http.post(`/models/${providerId}/models`, data),
|
||||||
removeProviderModel: (providerId: string, modelId: string) =>
|
removeProviderModel: (providerId: string, modelId: string) =>
|
||||||
@ -433,6 +440,8 @@ export const cronJobApi = {
|
|||||||
toggle: (id: string | number, enabled: boolean) =>
|
toggle: (id: string | number, enabled: boolean) =>
|
||||||
http.put(`/cron-jobs/${id}/toggle`, null, { params: { enabled } }),
|
http.put(`/cron-jobs/${id}/toggle`, null, { params: { enabled } }),
|
||||||
runNow: (id: string | number) => http.post(`/cron-jobs/${id}/run`),
|
runNow: (id: string | number) => http.post(`/cron-jobs/${id}/run`),
|
||||||
|
activeRuns: (conversationId: string) =>
|
||||||
|
http.get('/cron-jobs/active-runs', { params: { conversationId } }),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Wiki Knowledge Base ====================
|
// ==================== Wiki Knowledge Base ====================
|
||||||
|
|||||||
@ -55,6 +55,14 @@
|
|||||||
v-if="isCompressionSummary(msg)"
|
v-if="isCompressionSummary(msg)"
|
||||||
:message="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
|
<MessageBubble
|
||||||
v-else
|
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({
|
const { scrollRef, contentRef, isAtBottom, scrollToBottom } = useStickToBottom({
|
||||||
enabled: props.autoScroll,
|
enabled: props.autoScroll,
|
||||||
@ -423,4 +438,25 @@ watch(
|
|||||||
padding: 10px 12px;
|
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>
|
</style>
|
||||||
|
|||||||
@ -154,10 +154,16 @@ export default {
|
|||||||
uploadFailed: 'File upload failed',
|
uploadFailed: 'File upload failed',
|
||||||
dropToUpload: 'Drop files or folders here',
|
dropToUpload: 'Drop files or folders here',
|
||||||
copyFailed: 'Copy failed',
|
copyFailed: 'Copy failed',
|
||||||
|
datePinned: 'Pinned',
|
||||||
dateToday: 'Today',
|
dateToday: 'Today',
|
||||||
dateYesterday: 'Yesterday',
|
dateYesterday: 'Yesterday',
|
||||||
dateLast7Days: 'Last 7 Days',
|
dateLast7Days: 'Last 7 Days',
|
||||||
dateEarlier: 'Earlier',
|
dateEarlier: 'Earlier',
|
||||||
|
hasUnread: 'New activity',
|
||||||
|
cronRunning: {
|
||||||
|
executing: 'Executing…',
|
||||||
|
fallbackName: 'Scheduled task',
|
||||||
|
},
|
||||||
suggestionIntro: 'Remember I hate cilantro and love iced Americanos — remind me when ordering',
|
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',
|
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',
|
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.',
|
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.',
|
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.',
|
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: {
|
fields: {
|
||||||
providerId: 'Provider ID',
|
providerId: 'Provider ID',
|
||||||
providerName: 'Provider Name',
|
providerName: 'Provider Name',
|
||||||
@ -1467,7 +1476,7 @@ export default {
|
|||||||
delivered: 'Delivered',
|
delivered: 'Delivered',
|
||||||
not_delivered: 'Failed',
|
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' },
|
cronTypes: { hourly: 'Hourly', daily: 'Daily', weekly: 'Weekly', custom: 'Custom' },
|
||||||
days: { mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat', sun: 'Sun' },
|
days: { mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat', sun: 'Sun' },
|
||||||
fields: {
|
fields: {
|
||||||
@ -1478,6 +1487,8 @@ export default {
|
|||||||
taskType: 'Task Type',
|
taskType: 'Task Type',
|
||||||
triggerMessage: 'Trigger Message',
|
triggerMessage: 'Trigger Message',
|
||||||
triggerMessagePlaceholder: 'Message to send to the agent',
|
triggerMessagePlaceholder: 'Message to send to the agent',
|
||||||
|
reminderText: 'Reminder Text',
|
||||||
|
reminderTextPlaceholder: 'Exact text to push when the reminder fires (no LLM rewriting)',
|
||||||
requestBody: 'Goal',
|
requestBody: 'Goal',
|
||||||
requestBodyPlaceholder: 'Describe the goal for the agent',
|
requestBodyPlaceholder: 'Describe the goal for the agent',
|
||||||
cronFrequency: 'Frequency',
|
cronFrequency: 'Frequency',
|
||||||
|
|||||||
@ -154,10 +154,16 @@ export default {
|
|||||||
uploadFailed: '文件上传失败',
|
uploadFailed: '文件上传失败',
|
||||||
dropToUpload: '拖放文件或文件夹到此处',
|
dropToUpload: '拖放文件或文件夹到此处',
|
||||||
copyFailed: '复制失败',
|
copyFailed: '复制失败',
|
||||||
|
datePinned: '置顶',
|
||||||
dateToday: '今天',
|
dateToday: '今天',
|
||||||
dateYesterday: '昨天',
|
dateYesterday: '昨天',
|
||||||
dateLast7Days: '近 7 天',
|
dateLast7Days: '近 7 天',
|
||||||
dateEarlier: '更早',
|
dateEarlier: '更早',
|
||||||
|
hasUnread: '有新内容',
|
||||||
|
cronRunning: {
|
||||||
|
executing: '执行中…',
|
||||||
|
fallbackName: '定时任务',
|
||||||
|
},
|
||||||
suggestionIntro: '记住我平时不吃香菜、喜欢喝冰美式,以后点餐时提醒我',
|
suggestionIntro: '记住我平时不吃香菜、喜欢喝冰美式,以后点餐时提醒我',
|
||||||
suggestionPoem: '帮我搜一下今天科技圈有什么大新闻,用一句话总结',
|
suggestionPoem: '帮我搜一下今天科技圈有什么大新闻,用一句话总结',
|
||||||
suggestionCode: '让写手帮我润色一段文案,我先把草稿发你',
|
suggestionCode: '让写手帮我润色一段文案,我先把草稿发你',
|
||||||
@ -404,6 +410,9 @@ export default {
|
|||||||
claudeCodeOauthHint: '复用本地 Claude Code Pro/Max 订阅。请先在 Claude Code 客户端中登录,再点击"检测"读取凭据。',
|
claudeCodeOauthHint: '复用本地 Claude Code Pro/Max 订阅。请先在 Claude Code 客户端中登录,再点击"检测"读取凭据。',
|
||||||
claudeCodeOauthInstructions: '未检测到 Claude Code 凭据。请安装 Claude Code 客户端,使用 Pro/Max 账号登录后再点击检测。',
|
claudeCodeOauthInstructions: '未检测到 Claude Code 凭据。请安装 Claude Code 客户端,使用 Pro/Max 账号登录后再点击检测。',
|
||||||
claudeCodeOauthRevokeHint: '请在 Claude Code 客户端中退出登录。MateClaw 不会修改 Claude Code 的本地凭据。',
|
claudeCodeOauthRevokeHint: '请在 Claude Code 客户端中退出登录。MateClaw 不会修改 Claude Code 的本地凭据。',
|
||||||
|
providerIdPlaceholder: '例如:my-local-gemma',
|
||||||
|
providerIdHint: 'ID 仅作内部 key 使用,建议小写英文/数字,可含 . _ -,不要含斜杠或空格(创建后不可修改)。',
|
||||||
|
providerIdInvalid: 'Provider ID 只能包含字母、数字、点、下划线、连字符(不允许斜杠或空格),首字符必须是字母或数字,长度 1-64。',
|
||||||
fields: {
|
fields: {
|
||||||
providerId: '提供商 ID',
|
providerId: '提供商 ID',
|
||||||
providerName: '提供商名称',
|
providerName: '提供商名称',
|
||||||
@ -1477,7 +1486,7 @@ export default {
|
|||||||
delivered: '已送达',
|
delivered: '已送达',
|
||||||
not_delivered: '投递失败',
|
not_delivered: '投递失败',
|
||||||
},
|
},
|
||||||
taskTypes: { text: '文字消息', agent: 'Agent 目标' },
|
taskTypes: { text: '文字消息', reminder: '提醒', agent: 'Agent 目标' },
|
||||||
cronTypes: { hourly: '每小时', daily: '每天', weekly: '每周', custom: '自定义' },
|
cronTypes: { hourly: '每小时', daily: '每天', weekly: '每周', custom: '自定义' },
|
||||||
days: { mon: '周一', tue: '周二', wed: '周三', thu: '周四', fri: '周五', sat: '周六', sun: '周日' },
|
days: { mon: '周一', tue: '周二', wed: '周三', thu: '周四', fri: '周五', sat: '周六', sun: '周日' },
|
||||||
fields: {
|
fields: {
|
||||||
@ -1488,6 +1497,8 @@ export default {
|
|||||||
taskType: '任务类型',
|
taskType: '任务类型',
|
||||||
triggerMessage: '触发消息',
|
triggerMessage: '触发消息',
|
||||||
triggerMessagePlaceholder: '输入发送给 Agent 的消息',
|
triggerMessagePlaceholder: '输入发送给 Agent 的消息',
|
||||||
|
reminderText: '提醒内容',
|
||||||
|
reminderTextPlaceholder: '输入到点要原样推送的提醒内容(不会经过 LLM 改写)',
|
||||||
requestBody: '执行目标',
|
requestBody: '执行目标',
|
||||||
requestBodyPlaceholder: '直接描述 Agent 要完成的目标',
|
requestBodyPlaceholder: '直接描述 Agent 要完成的目标',
|
||||||
cronFrequency: '执行频率',
|
cronFrequency: '执行频率',
|
||||||
|
|||||||
@ -781,7 +781,7 @@ export interface CronJob {
|
|||||||
timezone: string
|
timezone: string
|
||||||
agentId: string | number
|
agentId: string | number
|
||||||
agentName?: string
|
agentName?: string
|
||||||
taskType: 'text' | 'agent'
|
taskType: 'text' | 'agent' | 'reminder'
|
||||||
triggerMessage?: string
|
triggerMessage?: string
|
||||||
requestBody?: string
|
requestBody?: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
|||||||
@ -90,6 +90,16 @@
|
|||||||
/>
|
/>
|
||||||
<div v-else class="conv-title" @dblclick.stop="startRename(conv)">
|
<div v-else class="conv-title" @dblclick.stop="startRename(conv)">
|
||||||
<span>{{ conv.title }}</span>
|
<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
|
<span
|
||||||
v-if="conv.streamStatus === 'running'"
|
v-if="conv.streamStatus === 'running'"
|
||||||
class="conv-running-badge"
|
class="conv-running-badge"
|
||||||
@ -216,6 +226,21 @@
|
|||||||
</template>
|
</template>
|
||||||
</MessageList>
|
</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 栏(消息和输入框之间) -->
|
<!-- 流式处理 Loading 栏(消息和输入框之间) -->
|
||||||
<StreamLoadingBar
|
<StreamLoadingBar
|
||||||
:is-loading="isGenerating && !showModelPrompt"
|
:is-loading="isGenerating && !showModelPrompt"
|
||||||
@ -277,7 +302,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { ChatDotRound, Delete, Plus, Setting, UploadFilled } from '@element-plus/icons-vue'
|
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 { channelIconUrl } from '@/utils/channelSource'
|
||||||
import { useChat } from '@/composables/chat/useChat'
|
import { useChat } from '@/composables/chat/useChat'
|
||||||
import { reconstructErrorInfo } from '@/types/chatError'
|
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)))
|
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 groupedConversations = computed(() => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
||||||
const yesterdayStart = todayStart - 86400000
|
const yesterdayStart = todayStart - 86400000
|
||||||
const last7Start = todayStart - 7 * 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[] }[] = [
|
const groups: { label: string; items: Conversation[] }[] = [
|
||||||
|
{ label: t('chat.datePinned', '置顶'), items: pinned },
|
||||||
{ label: t('chat.dateToday'), items: [] },
|
{ label: t('chat.dateToday'), items: [] },
|
||||||
{ label: t('chat.dateYesterday'), items: [] },
|
{ label: t('chat.dateYesterday'), items: [] },
|
||||||
{ label: t('chat.dateLast7Days'), items: [] },
|
{ label: t('chat.dateLast7Days'), items: [] },
|
||||||
@ -648,11 +712,15 @@ const groupedConversations = computed(() => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
for (const conv of conversations.value) {
|
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
|
const ts = conv.lastActiveTime ? new Date(conv.lastActiveTime).getTime() : 0
|
||||||
if (ts >= todayStart) groups[0].items.push(conv)
|
if (ts >= todayStart) groups[1].items.push(conv)
|
||||||
else if (ts >= yesterdayStart) groups[1].items.push(conv)
|
else if (ts >= yesterdayStart) groups[2].items.push(conv)
|
||||||
else if (ts >= last7Start) groups[2].items.push(conv)
|
else if (ts >= last7Start) groups[3].items.push(conv)
|
||||||
else groups[3].items.push(conv)
|
else groups[4].items.push(conv)
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups.filter(g => g.items.length > 0)
|
return groups.filter(g => g.items.length > 0)
|
||||||
@ -759,6 +827,58 @@ function handleKeyboardShortcuts(e: KeyboardEvent) {
|
|||||||
let activityPollTimer: number | null = null
|
let activityPollTimer: number | null = null
|
||||||
const ACTIVITY_POLL_MS = 4000
|
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 阶段就抛错(如"无权操作该会话"),
|
* 典型场景:SSE setup 阶段就抛错(如"无权操作该会话"),
|
||||||
@ -808,6 +928,9 @@ async function pollActivity() {
|
|||||||
} catch {
|
} 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 Promise.all([loadAgents(), loadModelState(), loadConversations()])
|
||||||
await hydrateStateFromRoute()
|
await hydrateStateFromRoute()
|
||||||
activityPollTimer = window.setInterval(pollActivity, ACTIVITY_POLL_MS)
|
activityPollTimer = window.setInterval(pollActivity, ACTIVITY_POLL_MS)
|
||||||
|
elapsedTickTimer = window.setInterval(() => {
|
||||||
|
if (activeCronRuns.value.length > 0) elapsedNow.value = Date.now()
|
||||||
|
}, 1000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@ -840,6 +966,10 @@ onBeforeUnmount(() => {
|
|||||||
clearInterval(activityPollTimer)
|
clearInterval(activityPollTimer)
|
||||||
activityPollTimer = null
|
activityPollTimer = null
|
||||||
}
|
}
|
||||||
|
if (elapsedTickTimer !== null) {
|
||||||
|
clearInterval(elapsedTickTimer)
|
||||||
|
elapsedTickTimer = null
|
||||||
|
}
|
||||||
// Switching tabs / route changes / mouse-detach unmount this component, but the
|
// Switching tabs / route changes / mouse-detach unmount this component, but the
|
||||||
// backend agent should keep running so the user can reconnect later. Use
|
// backend agent should keep running so the user can reconnect later. Use
|
||||||
// resetForNewConversation (front-end SSE disconnect only) instead of
|
// resetForNewConversation (front-end SSE disconnect only) instead of
|
||||||
@ -985,6 +1115,16 @@ async function selectConversation(conv: Conversation) {
|
|||||||
}
|
}
|
||||||
currentConversationId.value = conv.conversationId
|
currentConversationId.value = conv.conversationId
|
||||||
selectedAgentId.value = conv.agentId || selectedAgentId.value
|
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
|
const requestedConvId = conv.conversationId
|
||||||
try {
|
try {
|
||||||
const res: any = await conversationApi.listMessages(requestedConvId)
|
const res: any = await conversationApi.listMessages(requestedConvId)
|
||||||
@ -1577,6 +1717,35 @@ function handleCodeCopy(e: MouseEvent) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.chat-console-shell {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@ -1923,6 +2092,19 @@ function handleCodeCopy(e: MouseEvent) {
|
|||||||
pointer-events: none;
|
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 {
|
.conv-item.is-running {
|
||||||
background: color-mix(in srgb, #fbbf24 8%, transparent);
|
background: color-mix(in srgb, #fbbf24 8%, transparent);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -180,6 +180,10 @@
|
|||||||
<div class="detail-label">{{ t('cronJobs.fields.triggerMessage') }}</div>
|
<div class="detail-label">{{ t('cronJobs.fields.triggerMessage') }}</div>
|
||||||
<div class="detail-value detail-block">{{ detailJob.triggerMessage || '-' }}</div>
|
<div class="detail-value detail-block">{{ detailJob.triggerMessage || '-' }}</div>
|
||||||
</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-item detail-item-full" v-else>
|
||||||
<div class="detail-label">{{ t('cronJobs.fields.requestBody') }}</div>
|
<div class="detail-label">{{ t('cronJobs.fields.requestBody') }}</div>
|
||||||
<div class="detail-value detail-block">{{ detailJob.requestBody || '-' }}</div>
|
<div class="detail-value detail-block">{{ detailJob.requestBody || '-' }}</div>
|
||||||
@ -220,6 +224,10 @@
|
|||||||
<input type="radio" v-model="form.taskType" value="text" />
|
<input type="radio" v-model="form.taskType" value="text" />
|
||||||
{{ t('cronJobs.taskTypes.text') }}
|
{{ t('cronJobs.taskTypes.text') }}
|
||||||
</label>
|
</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' }">
|
<label class="radio-option" :class="{ active: form.taskType === 'agent' }">
|
||||||
<input type="radio" v-model="form.taskType" value="agent" />
|
<input type="radio" v-model="form.taskType" value="agent" />
|
||||||
{{ t('cronJobs.taskTypes.agent') }}
|
{{ t('cronJobs.taskTypes.agent') }}
|
||||||
@ -232,6 +240,11 @@
|
|||||||
<textarea v-model="form.triggerMessage" class="form-textarea" rows="3"
|
<textarea v-model="form.triggerMessage" class="form-textarea" rows="3"
|
||||||
:placeholder="t('cronJobs.fields.triggerMessagePlaceholder')"></textarea>
|
:placeholder="t('cronJobs.fields.triggerMessagePlaceholder')"></textarea>
|
||||||
</div>
|
</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">
|
<div v-else class="form-group">
|
||||||
<label class="form-label">{{ t('cronJobs.fields.requestBody') }} *</label>
|
<label class="form-label">{{ t('cronJobs.fields.requestBody') }} *</label>
|
||||||
<textarea v-model="form.requestBody" class="form-textarea" rows="3"
|
<textarea v-model="form.requestBody" class="form-textarea" rows="3"
|
||||||
@ -344,6 +357,7 @@ const form = ref<any>(defaultForm())
|
|||||||
const canSave = computed(() => {
|
const canSave = computed(() => {
|
||||||
if (!form.value.name || !form.value.agentId) return false
|
if (!form.value.name || !form.value.agentId) return false
|
||||||
if (form.value.taskType === 'text' && !form.value.triggerMessage) 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 (form.value.taskType === 'agent' && !form.value.requestBody) return false
|
||||||
if (cronType.value === 'custom' && !form.value.cronExpression?.trim()) return false
|
if (cronType.value === 'custom' && !form.value.cronExpression?.trim()) return false
|
||||||
return true
|
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-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-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)); }
|
.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-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; }
|
.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 { computed, reactive, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { modelApi } from '@/api'
|
import { modelApi } from '@/api'
|
||||||
import type { ProviderInfo } from '@/types'
|
import type { ProviderInfo } from '@/types'
|
||||||
import { safeParseJson } from '@/utils/safeJson'
|
import { safeParseJson } from '@/utils/safeJson'
|
||||||
import { chatModelToProtocol, protocolToChatModel } from '@/utils/modelProtocol'
|
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 {
|
interface ListDeps {
|
||||||
loadProviders: () => Promise<void>
|
loadProviders: () => Promise<void>
|
||||||
loadActiveModel: () => Promise<void>
|
loadActiveModel: () => Promise<void>
|
||||||
@ -136,7 +143,19 @@ export function useProviderForm(deps: ListDeps) {
|
|||||||
advancedOpen.value = false
|
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)
|
const kwargs = safeParseJson(providerForm.generateKwargsText)
|
||||||
if (providerForm.enableSearch) {
|
if (providerForm.enableSearch) {
|
||||||
kwargs.enableSearch = true
|
kwargs.enableSearch = true
|
||||||
@ -183,6 +202,7 @@ export function useProviderForm(deps: ListDeps) {
|
|||||||
}
|
}
|
||||||
closeProviderModal()
|
closeProviderModal()
|
||||||
await Promise.all([deps.loadProviders(), deps.loadActiveModel()])
|
await Promise.all([deps.loadProviders(), deps.loadActiveModel()])
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -336,8 +336,11 @@ function onCardOAuthLogin(provider: ProviderInfo) {
|
|||||||
|
|
||||||
async function onSaveProvider() {
|
async function onSaveProvider() {
|
||||||
try {
|
try {
|
||||||
await saveProvider()
|
const saved = await saveProvider()
|
||||||
showSavedTip(t('settings.model.providerSaved'))
|
// 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) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : t('settings.messages.saveFailed'))
|
ElMessage.error(error instanceof Error ? error.message : t('settings.messages.saveFailed'))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,12 @@
|
|||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group" v-if="!editingProvider">
|
<div class="form-group" v-if="!editingProvider">
|
||||||
<label class="form-label">{{ t('settings.model.fields.providerId') }}</label>
|
<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>
|
||||||
<div class="form-group" v-if="!editingProvider">
|
<div class="form-group" v-if="!editingProvider">
|
||||||
<label class="form-label">{{ t('settings.model.fields.providerName') }}</label>
|
<label class="form-label">{{ t('settings.model.fields.providerName') }}</label>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user