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.
Adds explicit user-intent gating to the provider catalog. Fresh installs
get an empty dropdown by default — built-in cloud + local providers
(OpenAI, Anthropic, Ollama, LM Studio, MLX, llama.cpp, etc.) live in a
new 'Add Provider' drawer until the user opts them in. Existing installs
upgrade conservatively: V55 promotes any provider with evidence of use
(real api_key, OAuth token, recent chat usage, or current default model).
Backend
- V55 migration (H2 + MySQL): adds enabled BOOLEAN DEFAULT FALSE on
mate_model_provider, plus 4 promote-to-true UPDATE rules. Also
CREATE INDEX idx_message_runtime_provider_time so the 30-day usage
lookup doesn't full-scan mate_message on heavy users.
- ModelProviderEntity, ProviderInfoDTO: enabled field.
- ModelProviderService:
* listProviders() now filters WHERE enabled = TRUE — chat path,
ModelSelector, Settings/Models main grid see only opted-in rows.
* listCatalog() new — full catalog (enabled + disabled) for the drawer.
* setEnabled(id, enabled) flips the flag, publishes
ModelConfigChangedEvent (re-probe via the existing listener), and
on disable auto-promotes a replacement default model when the
disabled provider owned the current default. Returns EnableResult
so the frontend can fire a toast.
* createCustomProvider sets enabled=true (user just made the row).
- ProviderInitProbe.listConfiguredProviders also filters enabled=true —
no point probing rows the user can't see.
- ModelConfigController: GET /catalog, POST /{id}/enable, POST /{id}/disable.
- Plugin-registered ChatModels are unaffected — they live in
pluginChatModels (in-memory map), don't go through DB listProviders,
so the enabled filter doesn't strand them.
Frontend
- New types: ProviderInfo.enabled, EnableResult.
- New API: catalog / enableProvider / disableProvider.
- New composable useProviderEnablement: catalog ref, drawerOpen,
togglingId, loadCatalog, openDrawer / closeDrawer, enableProvider,
disableProvider (fires defaultSwitchedToast on auto-switch).
- AddProviderDrawer.vue: lazy-loaded, reuses DoctorDrawer's Teleport +
overlay + slide-in panel pattern. Two groups (cloud / local),
unenabled rows surface to the top of each group, enabled rows show
an 'Enabled' badge instead of a button. Mobile: full-screen sheet
that slides up from below.
- ProviderCard: new 'Disable' button with soft-danger styling on
enabled providers — soft-hide that keeps the config; user can
re-enable from the drawer.
- Settings/Models index.vue:
* Two top CTAs: 'Enable Provider' (drawer) and 'Custom' (existing
custom-create modal) — distinct workflows, both surfaced.
* Empty state with prominent 'Enable Provider' CTA when zero
enabled providers — paired with onMounted auto-open of the
drawer (sessionStorage guard so closing it doesn't bring it
back on the next route visit in the same session).
* Deep-link: ?addProvider=1 query forces the drawer open and
strips itself after, so a back/forward doesn't re-fire the open.
- ModelSelector: when groups.length === 0 and not searching, show
'No providers configured -> Configure' CTA linking to
/settings/models?addProvider=1 — the natural flow when a fresh
user opens chat before configuring anything.
- i18n: 13 new keys per locale (zh-CN + en-US) plus common.close.
Migration safety
- Conservative default policy: only rows with concrete evidence of
use are auto-enabled; everything else stays hidden. Upgrade users
may notice unused built-ins disappearing from their dropdown —
that's the intended cleanup.
- mate_message index added so the 30-day usage rule doesn't full-scan
on large installations; FlywayRepairConfig handles redeploy idempotency.
Tests
- ModelProviderServiceEnableTest covers all 7 enable/disable branches:
flag flip + event publish, no-op on already-{enabled,disabled},
default-switch when disabled provider owned current default,
no-switch when default belongs elsewhere, no-replacement returns
unchanged, getDefaultModel exception path, candidates with no
models are skipped.
- ProviderInitProbeTest: helper provider() now sets enabled=true so
the new probe filter doesn't strand existing fixtures.
- vip.mate.llm.** suite: 125 tests green. vue-tsc 0 errors. Browser
page renders with both new buttons + drawer.
Mirrors the feishu one-click flow: scan a QR with the DingTalk app,
approve, and the bot's client_id / client_secret get auto-filled instead
of forcing the user through the open-dev console. Saves about seven
manual steps per channel setup.
Backend
- Bump dingtalk-stream from 1.3.5 to 1.3.12. Diff against the classes we
depend on (OpenDingTalkStreamClient, ChatbotMessage, MessageContent,
GenericEventListener) is empty — pure point-release bumps, no API churn.
- New DingTalkAppRegistrationService: synchronously runs init + begin
against /app/registration/{init,begin} on oapi.dingtalk.com to obtain
the device_code and verification URL, then spawns a daemon worker that
polls /app/registration/poll every 5s until SUCCESS / FAIL / EXPIRED is
returned. Sessions evict after 7 minutes, worker has a 6-minute hard
runtime cap, transient HTTP errors do not terminate the loop. Same
shape as the feishu service, but written from scratch because the
dingtalk-stream SDK doesn't wrap this OAuth device flow.
- Two new endpoints under /api/v1/channels/webhook:
POST /dingtalk/register/begin returns session_id;
GET /dingtalk/register/status returns status + qrcode_img (data URI
PNG, ZXing-encoded from the verification URL, matching the feishu and
weixin flows). Status surface: waiting / confirmed / expired / denied.
Frontend
- channelApi.dingtalkRegisterBegin / dingtalkRegisterStatus.
- New useDingTalkAppRegister composable, structurally identical to
useFeishuAppRegister minus the domain argument. Stops polling on
terminal status, fires onConfirmed with {clientId, clientSecret}.
- ChannelEditModal: dingtalk-register-card rendered when channelType is
dingtalk, scoped DingTalk blue (#1f79ff) to differentiate from feishu's
indigo. onConfirmed writes channelConfig.client_id / client_secret so
the existing form fields update reactively.
- i18n: channels.dingtalkRegister.* keys for title / hint / button states
/ scan / confirmed / expired / denied / startFailed.
Saves the user the entire 'go to the open platform -> create an enterprise
app -> copy App ID and Secret' detour. Click a button in the channel form,
scan the QR code, confirm authorization, credentials are auto-filled.
Backend
- Bump com.larksuite.oapi:oapi-sdk from 2.5.3 to 2.6.1, which adds the
scene/registration package wrapping the device-code flow.
- New FeishuAppRegistrationService: each begin() creates a sessionId,
spawns a worker thread, runs the SDK's blocking RegisterApp.register
with onQRCode and onStatusChange wired into a per-session state machine
(PENDING -> WAITING -> CONFIRMED / EXPIRED / DENIED / ERROR). The
session caches the QR data URI so ZXing only encodes once per attempt.
Sessions evict after 5 minutes so closed browsers don't leak the map.
- Two new webhook endpoints under /api/v1/channels/webhook/feishu:
POST /register/begin returns session_id, GET /register/status returns
status + qrcode_img (data URI base64 PNG, ZXing-encoded from the SDK's
verification URL — the raw URL would render as a broken image, so the
encoding step matches the WeCom flow).
- SDK detail caught the hard way: don't pass .domain() or .larkDomain().
The SDK defaults are accounts.feishu.cn / accounts.larksuite.com (the
registration endpoints). open.feishu.cn is the open-API endpoint, a
completely different service. Passing the wrong one makes the SDK parse
HTML as JSON and emit invalid_response.
Frontend
- channelApi: feishuRegisterBegin / feishuRegisterStatus.
- New useFeishuAppRegister composable: state machine that begins the
session, polls status every 2s, prefers qrcode_img over qrcode_url for
the <img> src, stops on terminal status, fires onConfirmed with
{appId, appSecret}.
- ChannelEditModal: a new feishu-register-card above the wecom one. The
composable's onConfirmed writes channelConfig.app_id / app_secret, so
the existing form fields update reactively.
- i18n: channels.feishuRegister.* keys for title / hint / button states /
scan / confirmed / expired / denied / error.
- Reconcile approval status atomically: DB row, message metadata, in-memory store
- Approve and deny both flip the tool-call card + timeline segment to a terminal
state on the gate message — no more orange spinner stuck after a decision
- Frontend hydrate matches by pendingId and reverse-converges to expired so a
refresh after server-side timeout / consume clears the banner without restart
- Stop sweep, GC timeout, and JVM restart all close the loop with consistent
state
- Remove the dead REST /approve endpoint + matching frontend client export so
there is only one resolve path to maintain
Three bugs surfaced when a non-admin workspace member opened the channel
admin page:
- vue-i18n "Invalid linked format" when '@' appeared in message strings
without the linked-format escape. Replaced literal '@' with vue-i18n v9
literal interpolation {'@'} in both zh-CN.ts and en-US.ts (6 strings:
QQ guide step3, accessControl requireMention/Tooltip).
- 403 from WorkspaceAccessInterceptor was being treated as 401 by the
axios interceptor and the chat SSE handler, clearing the token and
redirecting to /login. Split the two:
* 401 = authentication failure -> handleAuthFailure (logout)
* 403 = authorization failure -> keep session, surface to caller
Now a member who lacks workspace permission sees a toast instead of
being silently logged out.
- Two backend exception sites threw with the default code=500 for what
is semantically an auth/authz event, contradicting the codes returned
elsewhere for the same business event:
* AuthService.login() bad credentials 500 -> 401
* WorkspaceService.requirePermission() 500 -> 403
This aligns service-layer denials with SecurityConfig (401 for missing
JWT) and WorkspaceAccessInterceptor (403 for permission denied), so
the same business event always produces the same code.
- Drag-over highlight (orange border + shadow + arrow icon) on the upload
zone, using dragCounter to prevent flicker over nested children
- Optimistic list items appear immediately on drop/select with UPLOADING
badge and progress bar, before the HTTP request completes
- Wire axios onUploadProgress through api.uploadRaw → store.uploadRawFile
so the progress bar tracks real byte transfer (0–100%), with an
indeterminate shimmer until the first tick
- try/catch around every upload: on failure, placeholder flips to an error
state with ElMessage.error toast and a × dismiss button
- Upload all dropped/selected files concurrently via Promise.all
- i18n keys added (zh-CN + en-US): dropToUpload, uploading, uploadFailed,
status.uploading, progress.uploading
Full-stack AI assistant built on Spring AI Alibaba.
Features: ReAct Agent, Plan-and-Execute, MCP Protocol, Multi-Model, Multi-Channel.
Apache-2.0 License