Commit Graph

1064 Commits

Author SHA1 Message Date
matevip
6390abdecc fix(llm): apply read timeout to streaming chat WebClient (openai-compat + anthropic) 2026-04-30 08:54:46 +08:00
matevip
92a7508dac fix(channel): mirror plan-execute events to web sse for im-routed conversations 2026-04-30 08:54:32 +08:00
matevip
1864801c90 fix(tool): browser_use Windows compat + stop LLM treating it as web search 2026-04-30 08:54:15 +08:00
matevip
0aee2a1ca5 fix(agent): raise per-step tool-call ceiling to 100, soften limit-exceeded prompt 2026-04-30 07:00:42 +08:00
matevip
ca5387ed65 fix(agent): always write FINAL_SUMMARY in DirectAnswerNode 2026-04-30 07:00:24 +08:00
matevip
0c5c5bc2c2 fix(channels): hard-delete placeholder seeds; localize wizard verify+identity strings 2026-04-30 07:00:06 +08:00
matevip
3db4230142 feat(channels): redesign list page — show only configured channels, add hero empty state 2026-04-30 06:59:47 +08:00
matevip
a11f0586ba feat(channels): three-step onboarding wizard with live credential verify 2026-04-30 00:26:38 +08:00
matevip
2332792e8b chore: bump version to 1.2.0-SNAPSHOT 2026-04-29 23:20:56 +08:00
matevip
1f342acc95 release: v1.1.137 2026-04-29 16:44:08 +08:00
matevip
7cfb511948 fix(cron): rename conversation id prefix from 'cron:' to 'cron_' (issue #36) 2026-04-29 15:58:43 +08:00
matevip
0a5b1989f3 fix(cron): isolate cron jobs by workspace (issue #37) 2026-04-29 15:49:04 +08:00
matevip
bd36f87cc9 docs(architecture): update biz + tech diagrams and README to v1.1.137 reality 2026-04-29 15:48:35 +08:00
matevip
0ecfec474e fix(conversation): tolerate non-path-safe ids when cleaning attachments (issue #36) 2026-04-29 15:07:36 +08:00
matevip
8ad0fca4ef fix(memory): load structured/*.md by encoding path segments 2026-04-29 15:03:01 +08:00
matevip
172784bc5f docs(readme): lead with team / IT-deployable positioning
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.
2026-04-29 14:10:12 +08:00
matevip
822a77eef5 sync: tile catalog-row provider icons in dark mode (Settings → Models) 2026-04-29 11:36:55 +08:00
matevip
e759ad4a1b sync: settings UI polish, channel reliability fixes, DeepSeek cross-turn fix
- Settings → Models: inline API key, frosted drawer, dark-mode polish, provider icons, i18n sweep
- WeChat Work channel: rebuild HttpClient on reconnect, dedup failure signals, route auth_succeed errcode!=0 through failure handler
- Channel framework: per-adapter error isolation, QR auth SPI, health indicators
- Agent: patch cross-turn assistants for DeepSeek thinking-mode
- GitHub: bilingual issue templates with required fields
2026-04-29 11:22:47 +08:00
matevip
709a0db200 fix(chat): surface stored path for uploaded attachments
Chat attachments with non-ASCII filenames (e.g. Chinese) get sanitized
at upload time — `人人有虾.docx` is stored as `1777391026594_____.docx`.
Tools then receive only the original filename via '[Attachment] foo.docx'
and fail with 'file not found'.

- renderMessageContent now appends the actual server-side path so any
  tool the LLM picks (read_file / extract_document_text /
  detect_file_type) gets a path that resolves directly.
- New ChatUploadResolver helper performs basename-suffix matching inside
  the conversation's chat-upload directory; ReadFileTool, DocumentExtractTool
  and FileTypeDetectorTool fall through to it when the literal path does
  not exist (defense in depth for cases where the LLM ignores the path
  hint).

Refs https://github.com/matevip/mateclaw/issues/29
2026-04-28 23:59:27 +08:00
matevip
ae820f0f94 fix(workspace): make default-workspace owner bootstrap first-run-only
Replace the per-startup admin reconciliation in
WorkspaceSchemaMigration.ensureDefaultWorkspaceMembership() with a
one-shot bootstrap. Once the default workspace has any owner, the
method returns immediately, so an operator's deliberate removal of an
admin from the default workspace persists across restarts. If no owner
exists yet, pick the lowest-id active admin and add them as owner; if
no admin exists at all, log a warning and skip rather than failing
startup.

Refs https://github.com/matevip/mateclaw/issues/29
2026-04-28 23:41:56 +08:00
matevip
a73b640c87 fix(workspace): scope default workspace backfill to admins only
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
2026-04-28 23:40:21 +08:00
matevip
b4697f2806 fix(cron): post-deploy bug bundle — flakiness, scheduler, channel UI
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
2026-04-28 21:45:07 +08:00
matevip
4011050ceb feat(cron): channel delivery via ChatOrigin + Spring AI ToolContext
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
2026-04-28 21:43:58 +08:00
matevip
f57b31f379 feat(llm): add Volcano Ark Coding Plan provider with 6 pre-seeded models
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.
2026-04-28 20:01:15 +08:00
matevip
69f065e212 fix(llm): support Volcano Ark base URLs and surface friendly errors
- 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.
2026-04-28 19:26:58 +08:00
matevip
6a3df2a6e0 feat(llm): enabled column for providers + Add Provider drawer
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.
2026-04-28 15:03:40 +08:00
matevip
a168f91215 refactor(ui): split useProviders into 5 single-responsibility composables
Reshapes the Settings/Models frontend to match the channel-module split
convention (commit 22894ac4 'perf(channels): split Channels.vue...'),
zero behavior change. Paves the way for a follow-up that adds an enabled
column + AddProviderDrawer without bloating useProviders back to monolith.

Frontend split
- useProviders.ts goes from 615-line monolith to a 48-line facade that
  composes five single-responsibility slices:
    * useProviderList — providers / activeModels / currentProvider,
      loaders, status pill, icons
    * useProviderForm — create/edit modal + form, save/delete
    * useProviderDiscovery — manage-models modal, discovery, connection
      and per-model tests
    * useProviderOAuth — openai-chatgpt + claude-code OAuth flows
    * useProviderPool — manual reprobe (most pool surface inlined to
      ProviderInfo.liveness in the prior liveness change)
  Cross-composable refs flow via dep-injection arguments — no module-
  level state, no circular deps. Each composable stays independently
  testable.
- Pure helpers extracted to src/utils:
    * safeJson.ts — strict JSON-object parser
    * modelProtocol.ts — protocol <-> ChatModel class translation
- Modals (ProviderConfigModal, ManageModelsModal) loaded via
  defineAsyncComponent so the route's first paint doesn't drag along
  ~30KB of form/auth UI.
- el-skeleton placeholder during the initial Promise.all so the page
  paints something instead of blank-then-pop.

Layout regression fix
- MainLayout's <keep-alive> slot used :key='workspaceRouteKey' (a
  workspace-scoped string), shared between two <component v-if> blocks.
  Adding a second keepAlive route would have caused two components to
  mount side by side, because Vue saw identical keys and patched in
  place across the v-if boundary. Switched the key to
  ${workspaceRouteKey}:${route.path} so different routes get distinct
  vnode identities while workspace switching still busts the cache.
  Discovered while implementing the split — the multi-line HTML
  comment also had to live OUTSIDE <keep-alive>, since KeepAlive
  treats comments as children and rejects 'more than one'.

Embedding section title fix (drive-by)
- EmbeddingModelsSection.vue's scoped style didn't redeclare
  .group-title's flex layout, so the icon stacked above the title
  text instead of sitting inline. Added the missing flex rules
  locally — now matches the local-models / cloud-models group headers.

Verification
- vue-tsc 0 errors.
- Browser end-to-end: 27 cards render correctly, modals open via lazy
  load, /channels <-> /settings/models switch four times in a row with
  exactly one page title visible at each step (no stacking).
2026-04-28 15:01:14 +08:00
matevip
62b94b522f fix(dingtalk): make inbound images visible to vision model and chat UI
Three knots untangled so an image sent from DingTalk lands in both the
LLM's multimodal prompt and the chat history bubble:

- Prefer MessageContent.downloadCode (universal, used by the new
  api.dingtalk.com messageFiles/download) over pictureDownloadCode
  (legacy oapi field). Sending the legacy code to the new API got
  HTTP 500 unknownError, which was the original 'image not recognized'.
- After fetching bytes, persist to ~/.mateclaw/media/dingtalk/ so vision
  can read via FileSystemResource, AND stuff the same bytes into
  GeneratedFileCache so the UI gets an /api/v1/files/generated/{id} URL
  to render. Without the URL the message bubble showed an empty card.
- Carry filename / contentType / size on the MessageContentPart so the
  chat history doesn't fall back to the 'unknown' caption.

Same treatment applied to the richText branch (inline images from the
PC client) and threaded through the Stream SDK path.

Bundles in the prerequisite ChannelManager wiring of GeneratedFileCache
into DingTalkChannelAdapter and the new DingTalkMediaUploader used by
the outbound attachment flow that this work depends on.

Known limit: GeneratedFileCache TTL is 10 min — fresh refreshes work,
but viewing the image after a JVM restart needs a stable on-disk
serving endpoint, which is intentionally out of scope here.
2026-04-28 14:59:59 +08:00
matevip
c0c642380a feat(llm): provider liveness model + honor requireApiKey on chat path
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.
2026-04-28 14:59:11 +08:00
matevip
b4aef56c89 fix(channels): close QR loading dead-window for feishu and dingtalk register flows
Reported issue: click 'scan to create' -> button momentarily flickers
loading -> button re-enables but no QR shows up -> blank for 1-2 seconds
-> QR suddenly appears. Looks broken even though it works.

Root cause: loading.value flipped back to false the moment the begin HTTP
call returned (sessionId in hand), but the actual QR image only arrives on
the first status poll, which the existing code waited a full 2 seconds
for. Between begin completing and the first poll firing the UI was a
disabled button + nothing.

Three coordinated changes:
- useFeishuAppRegister and useDingTalkAppRegister: keep loading.value true
  through begin AND across the polls, only flip false when the QR image
  is actually populated (or a terminal failure status arrives). Also run
  an immediate first poll right after begin instead of waiting for the
  setInterval tick — usually the first poll already has the rendered QR
  for dingtalk, and pushes the feishu user roughly 2 seconds closer.
- ChannelEditModal: same-sized loading placeholder (min-height 240px,
  matching the QR card) that renders when loading is true and no QR is
  in hand. CSS spinner ring tinted with the channel brand color (feishu
  indigo, dingtalk blue) and a new
  channels.{feishu,dingtalk}Register.qrcodeLoading hint. The placeholder
  swaps to the real image with no layout shift.
- i18n: new qrcodeLoading key in zh-CN and en-US for both flows.

Net effect: click to spinner-visible is ~50ms; the user is never staring
at a frozen button-without-content again.
2026-04-28 11:14:14 +08:00
matevip
5bef83a156 fix(dingtalk): forward voice messages by reading recognition from stream payload
The stream SDK delivers voice messages as ChatbotMessage with msgtype=audio
and the server-side ASR result already filled into MessageContent.recognition
(same shape as WeCom's voice.content). The adapter's handleStreamMessage
only read msg.getText(), which is null for audio events, so the message
landed in handleWebhook with no msgtype, fell through to the default text
branch, found null content, and got dropped at 'Empty message content,
ignoring'. From the user's side: send a voice, nothing happens, no log of
the attempt.

Two surgical edits:
- handleStreamMessage now checks getContent().getRecognition() first; if
  present and non-blank, builds payload {msgtype: audio, audio: {recognition}}
  before falling back to the existing text path. The earlier comment about
  richText being handled inside handleWebhook was wrong — picture and
  richText also need their fields propagated through the payload Map; left
  a TODO for them.
- handleWebhook gains an explicit case 'audio' branch that pulls text out
  of audio.recognition and pushes it onto contentParts.
- ChannelMessage.inputMode now reflects 'voice' when msgtype=audio,
  mirroring feishu's behavior so downstream code (memory-extraction
  filters, voice-themed system prompts) can tell text vs voice turns apart.

No STT call required — DingTalk transcribes server-side and ships text in
the webhook, so this is a 0-network, 0-config fix.
2026-04-28 11:13:13 +08:00
matevip
acf6eccb3a feat(dingtalk): one-click bot creation via OAuth device flow
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.
2026-04-28 11:12:22 +08:00
matevip
a27898507c feat(feishu): one-click app creation via official SDK device-flow registration
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.
2026-04-28 11:11:36 +08:00
matevip
4081469e15 feat(channels): localize seeded channel names to Chinese on zh-CN installs
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.
2026-04-28 11:10:44 +08:00
matevip
b982d4a2d0 feat(feishu): default connection to WebSocket and hide webhook UI when unused
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.
2026-04-28 11:09:17 +08:00
matevip
22894ac4b1 perf(channels): split Channels.vue, lazy-load modal, async locales, keep-alive route
- Extract create/edit modal into ChannelEditModal.vue (defineAsyncComponent),
  shrinking Channels.vue from 1438 to 370 lines and dropping ~30KB from the
  initial route chunk.
- Move side-effect logic into composables: useWeixinQrcodePoll (QR + 2s status
  poll, auto-cleanup) and useWecomBotAuth (lazy SDK script with module-level
  promise dedupe). Pure config-JSON helpers move to utils/channelConfigJson.ts.
- Switch i18n locales from static imports to dynamic import keyed by current
  locale; applyLocale becomes async to avoid first-render flicker.
- /channels route opts into keep-alive (meta.keepAlive=true). Channels.vue
  pauses status polling in onDeactivated and resumes in onActivated, with an
  isActive guard to prevent late-resolving timers from leaking after navigation.
- Initial load goes from serial 3-RTT to Promise.all + 4-card el-skeleton.
2026-04-28 11:08:36 +08:00
matevip
4e22b85557 fix(chat): surface SSE error in retry card and stop poll from wiping local-only failed turn
When SSE setup fails (e.g. workspace permission denied for shared channel
conversations opened from the web console), the failed turn is never
persisted on the backend. Two issues made the failure invisible to the user:

- The fallback errorInfo dropped data.message, so the inline retry card fell
  back to the generic "请求过程中遇到了意外问题" template instead of the
  actual reason. Carry rawMessage through, and lower the MessageBubble
  display threshold from >8 to >3 chars so short-but-informative messages
  (7-char Chinese / "Forbidden") aren't filtered out.

- The status-poll loop in useChat overwrote the local-only failed turn
  with the server's "no message" view, erasing the inline retry card.
  Skip the merge for turns that exist only locally and are in error state,
  so the user can still see the failure and retry.
2026-04-28 00:16:14 +08:00
matevip
5b24a599ca fix(agent): resolve agent tool bindings by class/bean/function name aliases (#24)
Issue #24: tools selected in the agent binding UI had no effect at runtime.
mate_tool.name stores the Java class name (e.g. "BrowserUseTool") and was
written into mate_agent_tool.tool_name, but AgentToolSet.withAllowedToolsOnly
matched by the @Tool function name (e.g. "browser_use") — so every binding
was silently filtered out.

Fix: AgentToolSet builds an alias index per ToolCallback indexed by every
equivalent identifier — function name, Spring bean name, and Java class
simple name. withAllowedToolsOnly / withDeniedToolsFiltered / excluding
all accept any of these aliases, mirroring how Spring's BeanFactory accepts
bean names + aliases.

ToolRegistry.getEnabledToolSet now threads a bean→beanName resolver into
the new AgentToolSet.fromCallbacks(...) overload. Existing two-arg callers
keep working; tests pass without changes.

Zero data migration: stale mate_agent_tool rows that previously had no
effect now resolve correctly via the class-name alias.
2026-04-27 23:51:48 +08:00
matevip
e64752a830 fix(approval): unify tool-approval state machine across DB / message metadata / memory
- 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
2026-04-27 22:25:27 +08:00
matevip
4898b79d49 fix(agent): strip tool_choice="auto" so strict OpenAI-compatible servers accept the request
Some self-hosted OpenAI-compatible serving frameworks return a 400 Bad Request
with a generic Pydantic "body=None / Field required" error when the outbound
request carries tool_choice="auto" but the server was launched without an
auto-tool-choice opt-in flag. The error message hides the real cause: the
request is rejected at validation time before the body is parsed, so the
upstream client sees only the generic body-missing error.

Per the OpenAI spec, omitting tool_choice when tools is non-empty is
functionally equivalent to "auto" — the server defaults to auto-pick.
Adding a stripAutoToolChoice patcher to the buildOpenAiApi chain:

- changes nothing on compliant servers (OpenAI / DashScope / DeepSeek / Kimi
  default to auto when tools are present)
- unblocks strict OpenAI-compatible self-hosted endpoints

Explicit values other than "auto" ({"none", "required", or a function
descriptor}) are passed through unchanged.

Run on both chatCompletionEntity and chatCompletionStream paths so both
buffered and streaming calls benefit.
2026-04-27 20:35:24 +08:00
matevip
03c8584910 fix(channels): resolve issue #19 — non-admin member errors on channel page
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.
2026-04-27 19:31:46 +08:00
matevip
2f93d53737 refactor(approval): unify state machine across DB/metadata/memory
Foundation for the ghost-approval root-cause fix.

Adds ResolveOutcome / MetadataDecision; rewrites ApprovalWorkflowService so
every resolve / consume / timeout / supersede transitions through one
two-phase contract: snapshot → DB UPDATE conditional on status=PENDING →
metadata reconciliation → afterCommit memory mutation. ChatController,
ChannelMessageRouter, and ApprovalController all switch to the workflow;
ApprovalService.resolve / resolveAndConsume / consumeApproved /
cancelStalePending / denyAllByConversation are physically removed so
DB-bypass is no longer reachable at compile time.

Specific fixes:
- recoverFromDb preserves DB pendingId + createdAt (was generating fresh
  random ids, breaking every later DB sync)
- effectiveExpireAt = expireAt ?? createdAt + PENDING_TTL: legacy rows
  with NULL expireAt no longer resurrect as live PENDING after restart
- markPendingApprovalsResolved flips pendingApproval.status + currentPhase
  + MessageEntity.status atomically (was only flipping the first field;
  message.status uses existing completed/stopped, not approved/denied,
  to stay within the frontend Message.status union)
- GC scheduler moves to ApprovalWorkflowService; timeouts and overflow
  evictions now sync DB + metadata + memory through markTimeout
- DB UPDATE rows=0 returns alreadyResolved (concurrent-resolve safe);
  exception propagates so @Transactional rolls back; memory stays untouched
- expireRecoveredRow gates metadata write on DB success (was writing
  metadata even when DB update failed, producing the worst-case ghost)
- Mockito JDK 21 agent attach fixed via maven-dependency-plugin properties
  + surefire argLine (no more flaky self-attach across machines)

Tests: 34 new across 4 classes (recovery, resolve, GC, metadata sync).
Full suite: 788 / 788.
2026-04-27 19:30:52 +08:00
matevip
349f4d7d3c refactor(bootstrap): drop legacy tools-sync.sql in favor of per-tool Flyway migrations
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.
2026-04-27 14:00:08 +08:00
matevip
b4ebab65c7 feat(tool): docx image embedding + multi-file render
Two follow-up improvements on top of renderDocxFromFile so the docx
pipeline can handle real long-form deliverables instead of just
prose-only memos.

Image embedding (P1).
MarkdownDocxRenderer now recognizes single-line ![alt](path) markdown
and embeds the referenced file via POI's XWPFRun.addPicture():
- PNG / JPG / GIF / BMP read straight from disk
- SVG rasterized via Apache Batik (PNGTranscoder, target width 1400px)
  before embedding — OOXML stores raster images, so any vector source
  needs conversion. Batik runs in-JVM, no rsvg-convert / cairo on host.
- Pictures are pinned to roughly the printable page width (≈ 5.77 in
  for A4 minus default 1800-twip margins) and given a 4:3 height
  fallback. Mixing images inline with other paragraph text is not
  supported by design — the markdown subset assumes one image per
  block paragraph. Inline images would require splitting paragraphs
  across runs with explicit positioning, well beyond what this
  renderer covers.
- Failure modes (missing file, unsupported format, Batik blowing up)
  emit an italicised "[image: alt — reason]" placeholder so the rest
  of the document still renders; the agent can read its own log to
  see why the picture didn't make it.
- Adds two transitive deps via pom: batik-transcoder + batik-codec at
  1.18, ~10 MB combined. Worth it given the alternative is shelling
  out to system tooling.

Multi-file render (P2-lite).
New tool renderDocxFromFiles(List<String> filePaths, filename, pageSize)
reads several markdown files in order and renders one combined docx.
Lets the agent split a 30-page proposal into cover.md / ch1.md /
ch2.md / appendix.md and produce a single deliverable in one tool
call. Each path goes through WorkspacePathGuard.validatePath; any
empty or unreadable file aborts with a typed error so the agent
fixes its file list before retrying. Files are joined with a blank
line — no separator markup is injected, headings carry over cleanly.

I deliberately did NOT build the heavier mutable-docx state
("appendDocxChapter / finalizeDocx") flavor of P2: the multi-file
form covers the same workflow with no per-conversation state to
clean up, and the agent can iterate by rewriting the chapter file
and re-running the tool. Stateful append can come later if a
streaming use case actually shows up.

renderDocx and renderDocxFromFile @Tool descriptions updated to point
the agent at renderDocxFromFile for >5 KB markdown and to advertise
the new image-embedding capability.
2026-04-27 08:42:03 +08:00
matevip
9ed9ee6ca7 feat(tool): add renderDocxFromFile to bypass LLM token cost on large markdown
renderDocx requires the markdown body to flow through the LLM as a
tool argument. For an 80 KB project proposal that's ≈ 20 K tokens of
streaming output spent just to repeat back content the model already
wrote to disk a turn earlier — multi-minute generation, real money.

renderDocxFromFile takes a file path instead. The agent uses
write_file / edit_file to assemble the markdown locally, then calls
this tool with just the path. JVM reads the file in one IO syscall
and feeds it to the existing MarkdownDocxRenderer. Token cost drops
from ≈ 20 K to ≈ 50 (the path string).

Behavior:
- Path resolution honors WorkspacePathGuard, same boundary as
  read_file / write_file. No path traversal.
- UTF-8 read; rejects empty / missing / non-regular paths with
  typed error messages so the agent can recover.
- Output cached in GeneratedFileCache and returned as a relative
  /api/v1/files/generated/{id} link, with the same anti-host-
  hallucination instruction renderDocx already carries.
- Same supported markdown subset (headings, bold, lists, tables).
  Image references (![alt](path)) still render as raw text — full
  image embedding (P1) and SVG → PNG conversion (also P1) need
  Apache Batik plus image-rendering plumbing in MarkdownDocxRenderer
  and is tracked separately. Chapter-mode merge (P2) likewise needs
  its own plumbing.

The @Tool description tells the agent to prefer this path when
markdown exceeds ~5 KB and shows the full write_file →
renderDocxFromFile workflow inline.
2026-04-27 08:36:37 +08:00
matevip
cc3c9a8618 fix(ux): preserve in-flight turn on tab switch + raise max_iterations cap to 100
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.
2026-04-27 08:17:17 +08:00
matevip
0476447ab6 fix(agent): persist mid-turn narrative, queue follow-ups without dispose, flush on shutdown
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.
2026-04-27 07:51:49 +08:00
matevip
941653d185 fix(agent): also drop the queue guard in doOnError path
Same bug as the prior queue-drop fix in doOnComplete, but in the
sister branch that fires when the agent's reactive stream errors
out (CancellationException from a user stop). The guard

  cr.queuedInput() != null && !(isUserStop && !isInterruptFollowup)

mis-classified "user stopped, no interrupt-with-followup, but a
message is in the queue" as an explicit abort and silently dropped
the freshly-typed follow-up.

The frontend's enqueue path never sets interruptType — it just
calls requestStop + offers to messageQueue. Whoever puts a message
in the queue means it; just run it. Aligns with doOnComplete and
the four other queue-launch sites in this controller.
2026-04-27 07:51:18 +08:00
matevip
fcdb3fc15e fix(agent): break self-replicating 400, narration, args truncation, queue drop
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'.
2026-04-27 07:51:01 +08:00
matevip
187197e804 fix(sse): preserve done event for late reconnect window 2026-04-27 07:50:35 +08:00