Commit Graph

79 Commits

Author SHA1 Message Date
matevip
ee48b114f6 chore: scrub external project references from wecom comments 2026-05-10 08:28:26 +08:00
matevip
f6b4f7e402 feat(channel): forward async tool results to IM channels + slack file upload 2026-05-10 08:28:20 +08:00
matevip
134fa1a975 fix(channel): wecom file pipeline + IM approval hint visibility 2026-05-10 08:28:14 +08:00
matevip
225d14026b feat(channel): wecom approval card + keepalive + chunk dedup 2026-05-10 08:28:07 +08:00
matevip
20393ea679 test(channel): wecom reply queue concurrency stress suite 2026-05-10 08:28:01 +08:00
matevip
78f6ca8d33 feat(channel): reply queue foundation + lifecycle gate 2026-05-10 08:27:48 +08:00
matevip
c2aecf18ef feat(agent,llm): multimodal sidecar routing for unsupported attachments (#87) 2026-05-09 16:41:26 +08:00
matevip
97e2b12f71 feat(chat): show reply model attribution in assistant message bubbles 2026-05-09 16:41:18 +08:00
matevip
dae74cf26c feat(channel,trigger): wire channel_message + content_match event sources 2026-05-08 15:07:11 +08:00
matevip
e9a11f8db4 fix(chat): persist empty assistant turns 2026-05-05 11:12:45 +08:00
matevip
2ab0bd000b fix(chat): suppress duplicate assistant row on force-recycle 2026-05-05 10:39:54 +08:00
matevip
2e0ff8f90f fix(agent): unblock multi-role parallel delegation + propagate force-stop 2026-05-04 20:54:38 +08:00
matevip
42d406ffc8 fix(agent): drop brittle output policing, add evidence-grounded long-task safeguards 2026-05-04 11:55:44 +08:00
matevip
7d02841d1d fix(chat): per-event SSE ids for safe reconnect dedup + queue race recovery 2026-05-03 19:57:06 +08:00
matevip
74edc09b2b feat(backstage): admin runtime console for live agent visibility + force-recycle 2026-05-03 17:15:17 +08:00
matevip
66f09a968a feat(chat-stream): streaming UX overhaul + multi-agent stability layer 2026-05-03 17:15:02 +08:00
matevip
bc417d00ef chore: neutralize internal references in code comments and migrations 2026-05-02 15:42:10 +08:00
matevip
4522dfaebc feat(channel): MediaPathGuard utility for unified media validation 2026-05-02 15:41:27 +08:00
matevip
b0f5dadbe6 fix(feishu): default-on image download for vision pipelines 2026-05-02 15:40:17 +08:00
matevip
fcab47ffb9 fix(feishu): split long messages instead of silent truncate 2026-05-02 15:40:11 +08:00
matevip
e3ab06d57c feat(generative): unified async pipeline + live SSE delivery for music/video/image 2026-05-01 20:15:20 +08:00
matevip
1f368336de fix(wecom): listener identity dedup + cancel ghost reconnect on self-initiated close 2026-05-01 20:15:13 +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
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
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
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
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
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
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
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
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
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
matevip
b7c911f01d feat(stt): DashScope realtime voice + language-aware routing + TalkMode polish
- DashScope paraformer-realtime-v2 WebSocket streaming
- Language-aware provider routing: Whisper for English, Paraformer for Chinese
- PCM WAV recording replaces WebM (provider filename bug + diagnostics)
- TalkMode push-to-talk fixes (audio drop, WS connecting race)
- Vite dev proxy WebSocket upgrade fix
- WebSocket binary buffer 8KB → 8MB (Tomcat default truncated voice clips)
- Audio chunk pacing at 100ms (DashScope returned 0 chars otherwise)
- Resolved language hint propagation + raw frame logging
- V46 seed idempotency fix on UI-toggled STT row
- Diagnostic cleanup after debugging session
2026-04-26 16:37:55 +08:00
matevip
3f10553186 fix(sse): distinguish stream_not_local vs completed on reconnect 2026-04-26 08:32:44 +08:00
matevip
0b55d5a227 feat(agent): Utf8SseEmitter + returnDirect end-to-end chain test 2026-04-26 08:32:44 +08:00
matevip
4a95e7dfe4 feat(tool): tool returnDirect and sensitive-data quarantine 2026-04-25 19:02:35 +08:00
matevip
9632edb008 fix(webchat): persist assistant reply and publish memory event on stream end 2026-04-23 08:09:48 +08:00
matevip
869e0c47e6 refactor(memory): unify ConversationCompletedEvent publish 2026-04-23 08:09:48 +08:00
matevip
762218cd32 refactor(db): drop @TableLogic on all entities, purge soft-deleted rows 2026-04-18 23:31:48 +08:00
matevip
a3e6724cf4 fix(chat): channel conversation sync + running indicator
- ChannelMessageRouter: include assistantMessageId in message_complete
  and done broadcasts so ChatConsole observers can reconcile the
  streaming placeholder to the persisted DB row by id instead of
  falling back to the FIFO 'claim' heuristic (which occasionally
  dropped the assistant bubble on external channel conversations).
  Capture the id from ConversationService.saveMessage in both the
  sync agentService.chat path and the streaming processWithStreaming
  path; switch to HashMap since Map.of rejects null values when save
  is skipped (e.g. under approval).

- ChatConsole: add a 'running' indicator on the sidebar so users can
  tell which conversations have an in-flight agent run. Pulsing amber
  dot on the channel icon (both expanded and collapsed modes) plus a
  '生成中…' / 'Generating…' pill in expanded mode.

- ChatConsole: don't cancel the previous conversation's streaming run
  when switching conversations — let it keep running in the background
  and reconcile when the user comes back.
2026-04-17 10:03:56 +08:00
matevip
b35c29767c feat(chat): realtime sync for external channel conversations 2026-04-17 08:25:32 +08:00
matevip
8a25d723f0 fix(chat): reset stale stopRequested flag on new stream register 2026-04-16 18:13:34 +08:00