The KingbaseES JDBC driver is not on Maven Central; declaring it as a
required runtime dependency broke the default build for anyone without
the proprietary jar. Move it into an opt-in `kingbase` Maven profile
(build with `mvn package -Pkingbase`). No Java code imports the driver
classes — it is loaded at runtime via driver-class-name only, so the
default build no longer needs it.
Also drop `mateclaw.browser.ssrf-check-enabled: false` from the default
application.yml: the code default is true, and disabling the SSRF guard
globally is unrelated to KingbaseES support.
Add KingbaseES support as an opt-in profile: dedicated migration tree, bilingual seed data, runtime DbType detection (KINGBASE_ES / POSTGRE_SQL), and JDBC URL handling in the datasource manager.
* feat(wiki): unify raw materials & source watcher into a Sources tab with per-KB auto-sync
The raw-material directory scan and the Advanced "source watcher" sub-tab were
the same engine (same kb.sourceDirectory, same WikiDirectoryScanService) split
across two surfaces with two editable directory inputs. Merge them into one
"Sources" tab (upload / paste / directory manual scan + auto-sync toggle +
the raw-material list) and drop the watcher sub-tab from Advanced.
Auto-sync is now per-KB opt-in: a new watcher_enabled column (V146) gates the
periodic scan per knowledge base. The server-global mate.wiki.watcher-enabled
stays as an ops master switch — a KB is auto-scanned only when both are on
(AND). Manual scans are unaffected. Scan interval stays global for now
(tracked separately).
Closesmatevip/mateclaw#314
* docs(wiki): document source-watcher global switch env vars
Expose MATE_WIKI_WATCHER_ENABLED / MATE_WIKI_WATCHER_INTERVAL_MS as
explicit placeholders in application-mysql.yml, .env.example and
docker-compose.yml, mirroring MATE_WIKI_ALLOWED_SOURCE_ROOTS. Notes the
AND semantics (global ops gate + per-KB toggle) so operators know the
global switch alone is not sufficient.
- Fail closed to a global fallback sandbox root when a conversation has no
per-workspace base path, instead of leaving file/shell tools unconstrained
- Refuse shell commands that delete the workspace root directory itself
- Block workspace-boundary escapes at the policy layer before the approval
prompt, not only at execution time
- Approval bar now shows the actual command / target path being approved
The V85 seed shipped a dev/test placeholder (sse + http://localhost:8085/sse
+ "Bearer ${CKJIA_MCP_KEY}") that can never connect, so the 参考价 price-
comparison skill was unusable until an admin hand-edited the row.
Add V144 (h2 + mysql) to rewrite the seed to the real CKJIA SaaS endpoint:
streamable_http + https://m.ckjia.com/api/ai/mcp, no auth header, connect/read
timeouts raised to 60s. Conditional on the row still carrying the dev
placeholder URL, so an admin who already pointed it at a private deployment is
left untouched; idempotent and leaves `enabled` opt-in.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an execute_code built-in tool that runs python/bash/node code the agent
writes on the fly, so a documentation-only skill (a SKILL.md with no bundled
scripts) can be acted on. Scoped runs inject the skill's secrets and run in
the skill directory; otherwise a private scratch directory is used. Host
secret env vars are scrubbed from the subprocess. execute_code is an
agent-wide capability, registered in the tool catalog (V143), and screened
by the tool guard with a dedicated set of destructive-pattern rules.
Tests cover python/bash/node execution, scratch-dir fallback, env scrubbing,
argument decoding, and guard gating.
Add a backfill path so pages created before a KB's pageType profile changed
can be migrated into newly-added types. A per-page classify-only LLM call
(title + summary in, single page_type out) is normalised through the profile
and written back via a partial update that never touches page content.
Exposed as POST /knowledge-bases/{id}/reclassify (admin) and a "re-classify
existing pages" action in the Wiki advanced panel.
Wiki page classification was only profile-aware in the main ingest pipeline.
Transformation outputs hard-coded "synthesis", agent-created pages were left
untyped, and the frontend hard-coded the built-in ten types for ordering,
colouring and labels — so custom/synthesis types sank to the bottom, rendered
grey and showed raw keys.
Backend:
- Add nullable target_page_type column to mate_wiki_transformation (V142,
mysql + h2) plus the entity field and CRUD normalization (blank = use
profile fallbackType; membership validated at save time, not edit time).
- Route transformation single-run + KB-aggregate page saves through
WikiPageTypeProfileService.normalizePageType so output joins the KB
classification; agent wiki_create_page now lands on the profile fallbackType
instead of an untyped page.
Frontend:
- Load + parse the KB pageType profile into the wiki store (order, labels,
fallbackType) on KB select / refresh.
- New useWikiPageType composable: profile-driven label (3-tier fallback) and
colour (built-in fixed + deterministic hash palette for custom types).
- Sidebar grouping order, graph colouring, node panel, graph filter and the
page header badge now follow the profile; transformation editor gains a
target-type dropdown sourced from the profile when output target is a page.
Refs #292
Add 5-arg buildContextMessage overload that emits [system-context] Model:
for every origin (web/cron/IM). Legacy 3/4-arg overloads delegate to the
new one with null model args, keeping their output byte-identical.
Also fix pre-existing FeishuMentionTest compile error caused by removed
mentionMatchesAnyAlias/collectMentionIdentifiers methods.
Gate MarkdownNormalizer behind mate.agent.markdown-normalize-enabled (default
true) so operators can disable the rewrite verbatim if a normalization edge
case ever mangles a legitimate answer.
Surface directory-scan failures to the user via toast and render ScanResult.errors[]; expose MATE_WIKI_ALLOWED_SOURCE_ROOTS as a Docker env entry with blank-entry filtering in the path validator; set C.UTF-8 locale in the runtime image so non-ASCII file names decode correctly during scans.
Fixes#259
Streaming chat ran render tools on an async thread with no bound request,
so download links lost their host and arrived without a domain. Resolve the
host on the request thread and carry it through ChatOrigin/ToolContext;
falls back to a configurable public-base-url, then a relative path.
Add the user-facing and API-reference docs that explain how an employee
can declare a primary knowledge base, what the runtime fallback chain
looks like, and how the binding is driven from the API.
agents.md (zh + en)
- New section "Knowledge base binding (per-agent primary KB)" right after
the tool-binding section, with a 1.5.0 New In badge
- Lays out the design intent explicitly: KBs stay workspace-shared, the
binding only chooses a default target, multiple agents may pick the
same KB as primary
- Documents the runtime resolution order used by wiki tools: explicit
kbName/kbId, agent.primaryKbId, most-recently-updated workspace KB
- Migration note about the legacy kb.agent_id to agent.primary_kb_id
backfill and the visibility change for anyone who relied on the old
one-to-one isolation
api.md (zh + en)
- Under Agents: document the primaryKbId field with the three-state PUT
semantics (omit / null / value) and example curl calls
- Under LLM Wiki: document GET /api/v1/wiki/knowledge-bases/bindable and
the explicit warning that PUT /api/v1/wiki/knowledge-bases/{id} no
longer processes the agentId field
PR #237 introduced V129__agent_primary_kb.sql while dev also has
V129__wiki_page_broken_links.sql shipped from the broken-link lint work.
Flyway rejects duplicate version numbers at startup, so the agent
primary_kb migration moves to V130 across both H2 and MySQL dialects.
No content change beyond the rename — the column add, index, and
backfill SQL are identical to the V129 originals from #237.
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.
* feat(tool): add send_file tool for sending existing server files as IM attachments
Adds a new built-in tool that reads a file from the server and stashes it
in GeneratedFileCache so the channel adapter (Feishu, DingTalk, etc.)
automatically sends it as a native attachment. This fills the gap where
agents had no way to send existing server files to users — ReadFileTool
only reads text, and render tools only generate new files.
- New SendFileTool with path validation, MIME detection, 20MB limit
- Added "send_file" to tool allowlist in AgentBindingService
- Added i18n error messages (zh-CN + en-US)
* fix(tool): send_file returns URL in scrubber-detectable format
The previous JSON return format caused the LLM to reply with just
"status: sent" without echoing the /api/v1/files/generated/{id} URL.
GeneratedFileScrubber only scans the LLM's final text output, so the
file was never delivered as a native attachment.
Changed to match GeneratedFileLink's format: returns a markdown link
with explicit instructions for the LLM to echo the URL verbatim.
The Lark SDK throws HandlerNotFoundException for any event type without
a registered handler. This exception is caught internally by the SDK's
WebSocket client, which then sends a 500 response to the Feishu server.
The server may close the connection as a result, and the exception is
swallowed — never reaching the application layer.
Added empty handlers for all remaining IM event types:
- P2MessageReadV1 (read receipts)
- P2MessageRecalledV1 (message recall)
- P2ChatMemberBotDeletedV1 (bot removed from chat)
- P2ChatMemberUserAddedV1 / UserDeletedV1 / UserWithdrawnV1
- P2ChatUpdatedV1 (chat info update)
- P2ChatDisbandedV1 (chat disbanded)
- P2ChatAccessEventBotP2pChatEnteredV1 (bot entered p2p chat)
Also added explicit logback config for com.lark.oapi at WARN level
to ensure SDK internal errors are not silently filtered.
Refs: larksuite/oapi-sdk-java#185
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.
Add a callout above the existing intro to make the wedge explicit:
multi-user workspaces, approval-gated sensitive actions, full audit trail,
production-grade health monitoring, per-channel error isolation.
One JAR on your own machine, zero data egress.
Restart-time backfill in WorkspaceSchemaMigration was inserting every
existing user into the default workspace and copying mate_user.role
('user'/'admin') into mate_workspace_member.role, whose valid domain is
{owner, admin, member, viewer}. Result: non-admin users assigned to
other workspaces were silently re-attached to the default workspace
with role='user', failing roleLevel() lookup and 403'ing on Agents.
- Filter the INSERT on u.role = 'admin' and hard-code the membership
role to 'owner', removing the role-domain mismatch and the
workspace-isolation violation in one change.
- Add V60__fix_invalid_workspace_member_roles.sql (h2 + mysql) to
drop already-corrupted default-workspace rows for users who have a
valid membership elsewhere, and downgrade the orphan rows to
'member' so those users aren't locked out entirely.
Refs https://github.com/matevip/mateclaw/issues/29
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
Volcano Ark exposes a separate 'Coding Plan' subscription endpoint at
/api/coding/v3 with its own coding-tuned model catalog (ark-code-latest,
doubao-seed-code, kimi-k2-thinking, glm-4.7 coding edition, etc.). The
same Volcano API key works against it. Splitting into a sibling
volcengine-plan provider lets users keep chat-tuned and coding-tuned
defaults side by side, and the generalized OpenAI-compatible path
resolver already handles the /v3 suffix without a completionsPath
override.
Adds Flyway V56 (h2 + mysql) and updates the 4 seed-data files with
matching rows (ids 1000000320-325) for fresh installs.
- Generalize the OpenAI-compatible chat/models path resolver so any
baseUrl ending in /v{N} (Ark /v3, Zhipu /v4, ...) drops the duplicate
/v1 prefix. Volcano Engine test-connection and chat were posting to
/api/v3/v1/chat/completions and getting 404.
- Replace the six pre-seeded Doubao alias rows (doubao-1.5-*) with five
valid Ark direct-call ids (doubao-seed-1-8-251228 etc.) and flip
support_model_discovery=TRUE so users can refresh their account's
actual catalog. Aliases were marketing names, not API names, so every
call hit InvalidEndpointOrModel.NotFound.
- Translate Ark business errors into actionable Chinese hints: include
the response body in the error chain, match ModelNotOpen and
InvalidEndpointOrModel codes, extract the offending model id, and
classify them as MODEL_NOT_FOUND so failover skips retries.
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.
The zh seed planted channels with English display names (DingTalk Bot,
Feishu Bot, WeCom Bot, ...). The type label localized correctly but the
per-channel name stayed English on the cards page even when UI was Chinese.
- Update zh seed files (data-zh.sql + data-mysql-zh.sql) so fresh installs
get Chinese names from the start: Web 控制台, 钉钉机器人, 飞书机器人,
Telegram 机器人, Discord 机器人, 企业微信机器人, QQ 机器人, Slack 机器人.
id=1000000008 (微信) was already Chinese; left alone. en seeds untouched.
- Add V54 migration that flips existing zh-CN installs in place. Each
UPDATE is gated on system_setting language=zh-CN AND the channel name
still equal to its original English seeded value, so user-renamed
channels are left alone. Subsequent runs match no rows (idempotent).
h2 and mysql variants stay in lockstep.
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.
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.
Three small but high-impact fixes that all surfaced together while
verifying the long-form generation flow.
1. ChatConsole onBeforeUnmount no longer kills the backend turn.
Previously, switching tabs / route navigation / any cause that
unmounted the chat view called stopChatGeneration(), which POSTs
/chat/{cid}/stop and aborts the in-flight LLM call. The user
reported a turn dying mid-generation just from switching pages.
Replaced with resetForNewConversation() — front-end SSE disconnect
only, no /stop. Backend keeps running; pollActivity / status probe
reconnects on return. Aligns with the existing comment in
selectConversation: "let A's backend agent run continue running."
2. Agent max_iterations raised 25 → 100 with a hard ceiling.
The previous 25-step ceiling caused LimitExceededNode to fire on
substantive multi-tool tasks (document generation + image conversion
+ retry loops). 100 matches QwenPaw's _MAX_MAX_ITERATIONS upper
bound. New plumbing:
- BaseAgent.MAX_ITERATIONS_HARD_CEILING = 100 public constant
- BaseAgent default field 25 → 100 (Java-side fallback)
- AgentGraphBuilder clamps any per-agent DB override to the
ceiling at runtime; if the row holds 200, runtime sees 100 and
a WARN is logged with the original value.
- V47 migration (h2 + mysql) idempotently bumps the three default
seeded agents (1000000001, 1000000002, 1000000003) only if they
still hold the old defaults (25 / 20). User-customized values
are not touched.
- data-en/zh/-mysql-en/-mysql-zh seed files updated to 100 for
fresh installs.
3. DocxRenderTool tells the LLM not to prepend a host to the URL.
DeepSeek and Claude have both been observed wrapping the
/api/v1/files/generated/{id} relative path returned by renderDocx
into an absolute URL with a hallucinated domain (e.g.
https://ai-tools-system.com/...), breaking the download link in
the rendered chat bubble. The tool's return string now appends an
explicit "must use the relative path verbatim, do not add any
https:// or http:// prefix" instruction, which Claude and
DeepSeek both honor.
A bundle of stability fixes that all surfaced together while running
the same long-form generation task across multiple turns. Each one
addresses a distinct way the previous behavior silently dropped
content the user had already seen on screen.
1. Mid-turn narrative persistence (StateGraphReActAgent +
SummarizingNode). Intermediate ReasoningNode rounds and
SummarizingNode broadcast their content_delta directly to the
SSE channel for live display, but the StreamAccumulator only
received the final answer. After refresh the assistant message
showed only tool_call cards with no body text.
StateGraphReActAgent now also forwards STREAMED_CONTENT (already
set per round) as a persistOnly StreamDelta whenever it changes,
so every narrative chunk lands in the accumulator's content
buffer and gets written to mate_message. SummarizingNode now
writes its summary into the same key so summarize narratives
persist too.
2. Follow-up message queue, not dispose (ChatController#interruptStream).
Sending a new message while a turn was running called
requestInterrupt, which dispose()d the active Reactor chain mid
LLM call. That cancelled the in-flight generation, lost partial
tokens, and left the user staring at a half-finished bubble.
The endpoint now uses enqueueMessage in all paths, matching
the "wait for current turn, then run" behavior. The old
requestInterrupt API is kept for any future force-replace UI
but no caller routes to it.
3. Queued user message ordering (ChatStreamTracker.QueuedInput +
ChatController.startQueuedMessage). interruptStream used to save
the queued user message immediately, before the in-flight
assistant message finalized in doOnError. listMessages orders
by create_time ASC, so the queued user message ended up above
the assistant reply it was supposed to follow. QueuedInput now
carries contentParts; persistence is delayed to startQueuedMessage,
which runs only after Asst-N is on disk.
4. JVM shutdown flush (ChatStreamTracker @PreDestroy +
emergencySaveAccumulator). A mvn spring-boot:run restart used to
wipe in-flight turns: SSE emitter timed out, ShutdownHook fired,
HikariPool closed before doOnError could save. ChatStreamTracker
now exposes an emergency-save callback per RunState; ChatController
registers one per stream that snapshots the accumulator and
writes status="interrupted_shutdown". @PreDestroy walks active
runs, invokes the callback, then disposes. Spring's reverse-order
bean teardown keeps ConversationService and Hikari alive long
enough for the save to complete.
5. Observation thresholds for summarize (GraphObservationProperties +
application.yml). The previous total-chars threshold of 12 KB
triggered summarize after one or two RFC reads, costing a 40 to
80 second compaction LLM call per loop. Tuned to: total 200 KB,
single 16 KB, large-result 32 KB, rounds safety net 25. Java
field defaults reverted to the conservative original values so
application.yml stays the source of truth.
6. Frontend thinking segmentation (useChat.ts thinking_delta +
phase). Multi-round ReAct turns merged every reasoning + summarize
round's thinking into one segment, accumulating to 9 KB+ in a
single bubble. thinking_delta now uses findLast(running) so a
tool_call_started or phase transition closes the previous segment
and the next delta opens a fresh one. phase event also closes
running thinking/content segments.
7. Other small things bundled: removed a debug metadata-keys log
that flooded the log file with one line per stream chunk; fixed
three stale tests that didn't compile after earlier constructor
changes (WikiLogServiceTest, WikiOverviewSpliceTest,
WikiProcessingServiceLazyTest); added rfc-066 documenting the
unified message queue + priority refactor as the next logical
step on top of these stabilizations.
Verified end-to-end with multiple full sessions: a four-minute
generation that produced the expected docx and a follow-up enqueue
that ran cleanly after the previous turn naturally completed,
without the old "Disposable unavailable" interrupt path.
A series of cross-cutting stability fixes that surfaced together
during a long debugging session.
reasoning_content / Claude prefill self-replicating 400:
- ChatController persists typed errors (content starts with '[错误] ')
with status='error', so the failure text stops being re-sent as
multi-turn context — DeepSeek thinking 400 ('reasoning_content
must be passed back') and Claude 400 ('does not support assistant
message prefill') used to recursively re-create themselves every
retry by polluting history.
- BaseAgent.sanitizeForLlm filters status='error' / '[错误] ' prefix
assistant messages from history before LLM dispatch.
- BaseAgent.fetchHistoryMessages defensively drops trailing
AssistantMessages — Claude rejects assistant-tail prompts.
- NodeStreamingChatHelper.dropTrailingAssistant runs the same
defense at every doStreamCall pre-egress, so the in-turn
summarizing→reasoning transition (which leaves an assistant
scaffold at the tail) doesn't trip Claude either.
- AgentGraphBuilder.FallbackPolicy.DEEPSEEK switched (null,true,true)
→ (' ',false,true), aligning with KIMI/OPENAI's tolerant ' '
fallback. The previous 'force explicit 400' design was the
self-replicating loop's prime mover.
narration + tool args truncation:
- ReasoningNode.DEFAULT_MAX_OUTPUT_TOKENS 4096 → 16384. The 4k cap
was decapitating renderDocx tool_call args mid-stream when the
model emitted a long content field on top of thinking content;
the resulting 'invalid JSON' aborted execution silently.
- ReasoningNode appends a hermes-style TOOL_USE_ENFORCEMENT clause
to every system prompt: 'when you say you will perform an action,
call the tool now in the same response — narration is a protocol
violation'. Treats 'now I will generate the docx' (and never
actually calling renderDocx) as a forbidden pattern.
- ToolExecutionExecutor.normalizeToolExecutionError reframes the
JSON-truncated error as actionable instructions: 're-call the
same tool now with shorter content or split into multiple
sequential calls; do NOT describe the result as text'.
side fixes from the same evening:
- ChatController doOnComplete skips completionPublisher.publish
when isError=true, keeping memory extraction off the garbage path.
- ChatController doOnComplete queued-message guard simplified to
'cr.queuedInput() != null', matching the other 4 sites in the
controller. The previous 'isInterruptFollowup || !wasStopped'
guard silently dropped queued messages when the user did
Stop-then-Enqueue (wasStopped=true && interruptType=null), losing
the freshly-typed follow-up message.
- prompts/graph/summarize-system.txt now distinguishes 'single
task' (default; output one cohesive summary) from 'multiple
independent sub-tasks' (use the子任务 N format). Stops the
summarizer from inventing '子任务 1: PRO-027' decomposition for
unitary requests like 'write me a project proposal'.
- docker-compose.yml: pass SEARXNG_BASE_URL into mateclaw-server so the
app can reach the searxng sidecar container out of the box (default
http://searxng:8080).
- SystemSettingService: resolveSearxngBaseUrl() now falls back to the
SEARXNG_BASE_URL env var when no DB value is set, so Docker users no
longer need to configure it manually in the UI.
- V38 migration (h2 + mysql): expand mate_wiki_chunk.content from TEXT
(64KB) to MEDIUMTEXT (16MB) so large Chinese chunks (~30k chars
≈ 90KB UTF-8) no longer overflow.