Agents now have a per-agent primary wiki KB stored on
mate_agent.primary_kb_id. KBs remain workspace-shared — selecting one in
the agent editor only chooses the default wiki target for that agent, it
does not change the KB's ownership or visibility.
Backend
- AgentEntity: add primary_kb_id field (FieldStrategy.ALWAYS so the UI
can clear it back to "no primary")
- AgentController#update: switch body to Map<String, Object> so we can
tell "field missing" apart from "explicit null" via containsKey, then
convertValue back to AgentEntity
- WikiKnowledgeBaseService:
- new resolvePrimaryKb(agentId): prefers agent.primary_kb_id when it
points to a workspace-visible KB; falls back to legacy
kb.agent_id marker, then to most-recently-updated workspace KB
- listByAgentId now returns the full workspace set (KBs are
workspace-shared under the new model)
- update(id, name, description) no longer touches agent_id
- WikiController: new GET /knowledge-bases/bindable for the UI picker;
PUT /knowledge-bases/{id} no longer reads agentId
- WikiKnowledgeBaseEntity: add FieldStrategy.ALWAYS on embeddingModelId
and configContent so explicit nulls actually unbind/clear instead of
being silently skipped by MyBatis-Plus's NOT_NULL default
- Migrations V129 (H2 + MySQL): add primary_kb_id column + index, backfill
from legacy kb.agent_id, MySQL uses INFORMATION_SCHEMA guard +
PREPARE/EXECUTE for idempotency
- WikiKnowledgeBaseServiceTest: 13 cases, all passing
Frontend
- Agents.vue: new "Knowledge Base" tab, radio-select bindable KBs
- API: listBindableKBs() + Agent.primaryKbId typed string | number | null
- IDs handled as strings throughout (Snowflake-safe)
- i18n keys for the new tab in zh-CN and en-US
* feat(agent): optional agent-level workspace basePath override
Add workspaceBasePath field to AgentEntity that optionally overrides
the workspace-level basePath. When set, the agent uses its own directory;
when null, it inherits the workspace's basePath (existing behavior).
- AgentEntity: new workspaceBasePath field with ALWAYS update strategy
- AgentGraphBuilder: agent-level override takes priority over workspace
- Flyway migration V121 for H2 and MySQL
- UI: form input in basic tab with i18n (zh-CN, en-US)
* fix(agent): rename migration V121→V125 to avoid Flyway conflict with upstream
Upstream already has V121__tool_disclosure_tier.sql. Rename our
migration to V125 (next available after V124).
* fix(agent): make MySQL V125 migration idempotent
Use INFORMATION_SCHEMA check before ADD COLUMN to avoid
"Duplicate column name" error on re-deploy.
Add a parallel effectiveAllowedToolsDisplay field on the runtime status payload so the SkillMarket detail drawer can render mcp_<server>_<slug>_<hash> with the raw tool name appended in parentheses, while leaving the original prefixed list unchanged for any caller that needs the machine name. McpSkillBridge#decorateToolNameForDisplay reverses a prefixed name via the per-server cached tool list; the frontend prefers the new display field and falls back to effectiveAllowedTools when the field is absent.
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.
User-reported field issues + a deeper code audit revealed multiple
overlapping bugs in the prior cron-channel delivery change. This fixes
all six.
#1 — Concurrency race on ToolExecutionExecutor (root cause of 'sometimes
succeeds, sometimes fails' tool calls). The volatile instance fields
currentRequesterId / currentWorkspaceBasePath / currentChatOrigin
were shared by every conversation routed through the same per-agent
executor; one user mid-build-loop while another's execute()
overwrote the field would cross-contaminate the captured values into
PreparedToolCall. Fix: kill the instance fields, thread
origin/requester/workspace as method params straight into
PreparedToolCall snapshot. Comment pins the rule so it cannot regress.
#2 — CHAT_ORIGIN missing from KeyStrategyFactory (latent timebomb,
masked by spring-ai-alibaba-graph-core's non-filtering builder path).
Without an addStrategy registration, multi-node state merges in long
ReAct / Plan-Execute loops drop the key, ActionNode reads
ChatOrigin.EMPTY, and the cron persists with channel_id=NULL. Also
caught 4 more keys that were latently unregistered:
WORKSPACE_BASE_PATH, STOP_REQUESTED, RETURN_DIRECT_TRIGGERED,
DIRECT_TOOL_OUTPUTS. All five now registered in both ReAct and
Plan-Execute factories.
#3 — CronJobs UI didn't surface channel binding. CronJobDTO carried
channelId / deliveryConfig but the list page never rendered them.
Added: (a) 'channel' column on list page, (b) channel + targetId
rows in the detail modal, (c) backend batch-loads channel names via
ChannelMapper.selectBatchIds so the column shows the human-readable
name, (d) i18n keys (zh + en), (e) channelName field on TS CronJob
type.
#4a — DingTalk targetId expiry. ChannelChatOriginFactory.resolveTargetId
used to prefer ChannelMessage.replyToken which for DingTalk encodes
a sessionWebhook URL that expires ~90 minutes after the inbound
message. Cron persisted with that webhook then dies with 401/403 and
marks NOT_DELIVERED forever. Fix: prefer the stable chatId, fall
back to senderId — both work indefinitely via DingTalk's Robot API.
#4b — Scheduler pool exhaustion under long LLM. CronJobService's
ThreadPoolTaskScheduler ran with poolSize=4 AND the LLM call lived
on the scheduler thread. Four concurrent crons saturated the pool
and the 5th silently missed its tick. Fix: keep scheduler tiny (it
just fires triggers) and offload runAgent to a dedicated
virtual-thread executor (cron-execute-* threads). LLM workload is
I/O-bound — virtual threads scale to thousands at trivial cost.
#5 — Minor latent bugs:
- AbstractCronResultDelivery.claimRun used .in(... 'NONE','PENDING',null),
but SQL IN never matches NULL. Rewrote as IS NULL OR IN
(NONE,PENDING) so legacy pre-V57 rows can still claim.
- CronDeliveryListener.onCompletedRaw was an empty @EventListener
with a wrong-headed comment about test fallbackExecution. Removed.
- CronJobTool.resolveAgentId silently returned 1L when origin
lacked an agentId — would silently bind to whatever agent #1
happens to be. Replaced with explicit error so wiring bugs surface
immediately instead of producing scheduled-but-never-runs crons.
State-key registration guard. New StateKeyRegistrationCoverageTest
scans MateClawStateKeys via reflection and parses
AgentGraphBuilder.java to extract every
.addStrategy(MateClawStateKeys.X, ...). Asserts every non-_NODE
constant appears in at least one factory. Caught the 4 unregistered
keys above on first run; will catch any future 'forgot to register'
regression.
Tests: 33 unit/arch tests + 27 regression in touched areas — all green.
Vue typecheck clean.
Refs: #25, #16
Replaces the prior ThreadLocal context plumbing with explicit Spring AI
ToolContext threading carried by an immutable ChatOrigin value object,
so a cron created from inside WeChat (or any IM channel) delivers its
results back to the originating channel.
Architecture
- ChatOrigin / ChannelTarget value objects + per-entry-point factories
(ChannelChatOriginFactory in vip.mate.channel, CronChatOriginFactory
in vip.mate.cron — symmetric, no cyclic deps).
- LocaleAwareToolCallback now forwards call(String, ToolContext) and
getToolMetadata so the decorator chain cannot silently drop the origin.
- AgentService 6-method overhaul + ChatOriginHolder bridge into
StateGraph buildInitialState which writes CHAT_ORIGIN; ActionNode +
StepExecutionNode forward it to ToolExecutionExecutor.
- ToolExecutionExecutor builds ToolContext per call; 8/8 tools migrated
(CronJobTool, WorkspacePathGuard, Video/Image/Browser/ReadFile/Music,
DelegateAgentTool with parent-origin inheritance).
- CronJobRunner + CronJobLifecycleService 3-segment REQUIRES_NEW model
(T1 startRun / no-tx runAgent / T2 finishRunAndPublish); ArchUnit
pins CronJobRunner as @Transactional-free.
- CronResultDelivery Strategy + AbstractCronResultDelivery Template
with SQL CAS idempotency on mate_cron_job_run.delivery_status —
replaces the prior process-local Caffeine TTL, cluster-safe.
- CronJobCompletedEvent + @Async @TransactionalEventListener(AFTER_COMMIT);
cronDeliveryExecutor (core=2, max=4, queue=1000, AbortPolicy + audit).
- CronRunStaleCleanup @Scheduled(5min) sweeps PENDING-15min and
status='running'-30min in one query each.
- CronJobRunner.wrapWithDeliveryGuard prepends a system note for
channel-bound crons to suppress hallucinated 'install CLI to send
WeChat' suggestions.
- ApprovalWorkflowService Memento: persist ChatOrigin snapshot on
create, restore on replay so cross-restart approvals keep channel
binding; ChannelMessageRouter + ChatController web-replay both prefer
the Memento and fall back to fresh-build.
- ChannelManager.sendToChannel 4-arg DeliveryOptions overload;
ChannelAdapter#proactiveSend default 4-arg pass-through; Slack
overrides for thread_ts and Telegram overrides for message_thread_id.
- CronJobs UI: read-only 'last delivery' badge driven by
CronJobMapper.selectListWithDeliveryStatus subquery.
Schema migrations V57/V58/V59 (V56 was already taken by an unrelated
provider migration — Flyway processes versions in order regardless of
gaps):
- V57: mate_cron_job_run delivery_status / target / error + composite
index (delivery_status, started_at) covering the cleanup sweep.
- V58: mate_cron_job channel_id (indexed) + delivery_config TEXT (JSON
via MyBatis Plus JacksonTypeHandler).
- V59: mate_tool_approval chat_origin TEXT (Memento).
All idempotent in both H2 (IF NOT EXISTS) and MySQL (INFORMATION_SCHEMA
guard + PREPARE).
ArchUnit guards (test scope, archunit-junit5 1.3.0):
- every concrete vip.mate.* ToolCallback must override
call(String, ToolContext) — pins the decorator-forward fix.
- CronJobRunner must NOT carry @Transactional on the class or any
method — pins the 3-segment lifecycle rule.
Tests: 32 new unit tests + 21 regression tests in touched areas, all
53 green:
- ChatOriginTest (6) — value-object invariants + JSON round-trip.
- LocaleAwareToolCallbackToolContextTest (2) — decorator forward.
- DeliveryConfigTest (4) — Jackson round-trip + forward-compat.
- ToolCallbackToolContextForwardArchTest (2) — both ArchUnit guards.
- CronJobRunnerDeliveryGuardTest (3) — channel-cron prefix injection.
- AbstractCronResultDeliveryTest (4) — claim CAS + concurrent CAS.
- ChannelCronResultDeliveryTest (6) — supports / doDeliver / errors.
- ApprovalReplayContinuityTest (5) — Memento round-trip + corrupt
payload fallback + unknown-field tolerance.
Refs: #25, #16
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.
Phase 1 of the model-module refactor: combine pool / cooldown / probe-
completion signals into a single Liveness state surfaced through the
provider DTO, so the dropdown stops listing providers that are provably
unreachable. Zero schema change; one PR backend + frontend.
Backend
- Liveness enum with five mutually-exclusive states: LIVE, COOLDOWN,
REMOVED, UNPROBED, UNCONFIGURED. Computed in ModelProviderService
from AvailableProviderPool / ProviderHealthTracker / ProviderInitProbe
snapshots batched once per listProviders() call.
- ProviderInitProbe.hasBeenProbed exposes a monotonic Set so the UI
can distinguish 'still booting' from 'probed and removed' — without
it the startup window flashes false REMOVED states.
- ProviderInfoDTO gains liveness + unavailableReason +
cooldownRemainingMs + lastProbedAtMs. The legacy 'available' boolean
stays but is now derived from liveness == LIVE so the chat fallback
walker and the dropdown agree about what's usable.
- ProviderInitProbe injected into ModelProviderService via
ObjectProvider to break the startup cycle (probe already depends on
the service).
Frontend
- ProviderInfo type extended with liveness + the three detail fields.
- ModelSelector filters UNCONFIGURED + REMOVED out of the dropdown,
shows COOLDOWN / UNPROBED with a status dot and dimmed rows that the
user can still click to override.
- ProviderCard renders a five-state badge driven by liveness instead
of the old configured + pool-entry combo. Reprobe button now keys
off liveness in {REMOVED, COOLDOWN}.
- useProviders drops loadProviderPool / providerPool — pool data ships
inline on each ProviderInfo, saves a round trip per page load and
keeps a single source of truth.
- i18n: 8 new keys across zh-CN and en-US for liveness labels and the
cooldown countdown tooltips.
Bonus fix (discovered during verification): AgentGraphBuilder.buildOpenAiApi
hard-required a usable API key on every OpenAI-compat provider, ignoring
the per-provider requireApiKey flag. That bug stranded keyless local
runtimes (LM Studio / MLX / llama.cpp) the moment a user actually
launched them; Ollama only worked by accident because its seed row
carries a placeholder string in api_key. keyRequired now honors
requireApiKey, and Spring AI's NoopApiKey is used when no key is needed
so the Authorization header is omitted entirely.
Test
- ModelProviderServiceLivenessTest covers all five Liveness states +
the probe-bean-absent fallback branch.
- vip.mate.llm.** suite (118 tests) green; vue-tsc clean.
- End-to-end browser sanity: 27 raw providers reduce to 6 LIVE groups
in the chat dropdown; LM Studio / MLX / llama.cpp render REMOVED red
badges with reprobe buttons; cloud providers without keys show
UNCONFIGURED.
Backend (FeishuChannelAdapter):
- Default connection_mode flips webhook -> websocket on doStart and doReconnect.
- Stale event filter: drop events whose message.create_time is older than
stale_event_threshold_seconds (default 30s) so SDK reconnect replays do not
re-trigger the agent.
- Silent disconnect watchdog runs every 60s; if no events arrive for
silent_disconnect_threshold_seconds (default 1800s) after the first event,
call onDisconnected to force a reconnect cycle. Setting the threshold to 0
disables the watchdog. The watchdog is scheduled before wsClient.start() on
the bring-up path because that call blocks indefinitely.
- Quoted message context: when a reply has parent_id set, fetch the parent
via GET /open-apis/im/v1/messages/{id}, summarize per msg_type (text / post
first paragraph / [Image]/[File]/[Audio]/[Video] placeholders, capped at
200 chars), and prepend [Quoted: ...] to both content text and the first
content part. LRU-cached (200) per message_id.
- AbstractChannelAdapter gains getConfigLong helper for numeric config keys.
Frontend:
- types/index.ts feishu fields: default connection_mode is websocket; the
recommended option moves to the top; verification_token and encrypt_key
get showIf so they only render in webhook mode; new enable_quoted_context
switch (default on) exposes the quoted-message feature.
- ChannelEditModal builds a feishu-specific WEBHOOK_GUIDES path that picks
webhookStep vs websocketStep based on connection_mode, so users only see
steps for the mode they're using.
- i18n: split feishu.step3/step4 into webhookStep/websocketStep, rename
step5 to permissionStep. Channel type labels in zh-CN drop bilingual
prefix (e.g. 'Feishu / Lark (飞书)' -> '飞书').
Migrations:
- V52 was a no-op the first time it ran (matched compact JSON only) and
Flyway refused to re-run after the SQL was fixed. V52 is documented as a
no-op; V53 carries the actual UPDATE with REPLACE covering both compact
and pretty-printed JSON, and an idempotent WHERE for rows already on
websocket. h2 and mysql variants stay in lockstep.
- 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
The two tools-sync scripts ran on every startup and used H2 MERGE INTO
... KEY(id), which overwrites every column on existing rows. That
silently reverted UI-toggled `enabled` and was the proximate cause of
a recent WriteFileTool/EditFileTool outage.
They were also a strict subset of the fresh-install seed (data-zh.sql /
data-en.sql register all 19 builtins; the sync scripts only 16) and out
of date. Per-tool Flyway migrations (V3, V31) are already the canonical
'register a new builtin' path, so the sync layer was duplicated and
error-prone.
Delete both files and the runToolSyncScript() loader. Tool descriptions
shown to the LLM come from @Tool annotations in code, not the DB row,
so removing per-startup metadata refresh has no functional impact.
Two related issues from the Kimi-401 user report:
1. Backend (NodeStreamingChatHelper): a primary AUTH_ERROR (e.g. Kimi 401
with an invalid API key) returned immediately without trying the
fallback chain — a fallback provider with a different, valid key
never got a chance. Even with DashScope correctly configured as the
fallback, the user chat dead-ended on a 401.
The original assumption ("auth never self-heals so do not retry")
holds for the primary same-model retry loop but is wrong for the
fallback chain — different providers have different keys. Apply the
same break-into-fallback policy that BILLING and MODEL_NOT_FOUND
already use. recordPrimary(false) is preserved so the cooldown
counter still accumulates.
2. Frontend (chatError.ts + i18n): the error-text matching for
/认证|auth|unauthorized|401/i was so broad it matched the substring
"auth" inside URLs like https://api.kimi.com/.../auth, classifying
any model 401 as user "session expired" and rendering the misleading
"页面将自动跳转到登录页" copy. (The redirect itself only fires from
/api/v1/auth/* axios paths and SSE-connection 401s, not from this
payload-text path — but the copy alone is the worst kind of false
alarm.)
Add a new ChatErrorCategory provider_auth_error and split the
pattern matching: narrow auth_expired (HTTP 401 / 登录已过期 /
session expired / 凭证失效) is matched FIRST, then the broad
401-ish pattern routes to provider_auth_error. BACKEND_ERROR_TYPE_MAP
for AUTH_ERROR is also remapped, since structured backend payloads
currently always come from LLM providers — never from our own
/api/v1/auth path.
Tests
- NodeStreamingChatHelperFailoverTest (5 cases): primary 401 →
fallback succeeds; chain skips auth-failing fallback to next healthy
one; whole-chain failure surfaces last AUTH_ERROR (no silent drop);
BILLING regression unchanged; primary-success path does not touch
chain
- Browser preview verified: new i18n keys resolve in en-US, classifier
correctly routes "[错误] 401 from kimi.com" → provider_auth_error
while "[错误] HTTP 401 from /api/v1/auth/ping" stays auth_expired
- 186 tests pass (was 181 + 5 new); vue-tsc clean
Do-not-touch list: handleAuthFailure() in useStream/api/index.ts (real
session-expiry path) is unmodified — only the misclassification
upstream is fixed. auth_expired i18n copy is unchanged.
UI — Failover priority editor
- ProviderConfigRequest + ProviderInfoDTO carry fallbackPriority
- ModelProviderService.updateProviderConfig persists it (null = unchanged);
toProviderInfo exposes the current value to the UI (defaults to 0)
- ProviderConfigModal advanced panel exposes a number input with hint
- ProviderCard shows a "Fallback #N" badge for chain members so the
priority order is visible at a glance without opening the modal
- 5 new i18n keys (zh + en) — verified to resolve at runtime via i18n.global.t
Backend — Per-provider health tracker
- ProviderHealthTracker: ConcurrentHashMap-backed counters; N consecutive
failures (default 3) push the provider into a cooldown window (default
5 min) during which the chain walker skips it. Success resets both
counter and cooldown atomically. Lazy expiry on lookup so dead entries
do not accumulate.
- ProviderHealthProperties exposed under mateclaw.llm.failover.health.*
with sane production defaults
- New FallbackEntry record (providerId + ChatModel) replaces raw
List<ChatModel> in the chain so the walker can correlate cooldown
state to entries; AgentGraphBuilder.buildFallbackChain returns the
new type
- NodeStreamingChatHelper takes the tracker through a new 4-arg
constructor and consults it before each fallback call; records
success/failure on each chain attempt. Legacy 2/3-arg constructors
preserved as @Deprecated wrappers (synthetic providerId means no
health tracking on the legacy path — that path is opt-out anyway)
Tests
- ProviderHealthTrackerTest (9 tests): below/at threshold, success
reset, cooldown expiry (via reflection on the min-clamp setter),
disabled-tracker no-op, null-providerId safety, per-provider
isolation, snapshot output
- NodeStreamingChatHelperFallbackChainTest updated to FallbackEntry
field type — verifies providerId + ChatModel survive the chain
- 168 tests pass (was 159 + 9 new)
Verification
- mvn test green; vue-tsc clean; live UI confirms i18n resolution
- db/migration/mysql: replace ADD COLUMN/CREATE INDEX IF NOT EXISTS with
idempotent checks via information_schema (MySQL 8.0 <8.0.29 and some
forks don't support IF NOT EXISTS for ADD COLUMN). Affects V2/V4/V5/V7
/V8/V9/V11/V12/V13/V14. Fix: gitee#IIYHLJ.
- application-mysql.yml: add createDatabaseIfNotExist=true so MySQL
Connector/J auto-creates the schema on first connection (requires
CREATE privilege — documented fallback for restricted accounts).
- llm/OllamaAutoDiscoveryRunner: rewrite seed tag when fuzzy-matching,
prefer exact tag for default; skip models without tool support when
auto-activating a default (prevents the phantom ':latest' trap when
users pulled a specific size).
- agent/graph/NodeStreamingChatHelper: detect 'does not support tools'
and 'model not found' errors from Ollama and emit actionable Chinese
prompts guiding users to qwen3 / qwen2.5:7b+ / llama3.1:8b+ etc.
- ui/MessageBubble + types/chatError: surface the backend's actionable
rawMessage in the failed-message card instead of a generic '未知错误';
strip redundant prefixes (Bad request: / [错误] / LLM 调用失败:) since
the title already conveys the category.