diff --git a/.github/ISSUE_TEMPLATE/bug-en.yml b/.github/ISSUE_TEMPLATE/bug-en.yml
new file mode 100644
index 00000000..0e2afc36
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug-en.yml
@@ -0,0 +1,74 @@
+name: "🐛 Bug Report (English)"
+description: Report something that's broken. Three required fields — fill them and submit.
+title: "[Bug] "
+labels: ["bug"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to report this. Three things, that's it — any less and we can't locate it; any more wastes your time.
+
+ > **Issues without a screenshot, log, or repro steps will be closed.** Not because we don't care — we genuinely can't fix what we can't reproduce.
+
+ - type: textarea
+ id: what
+ attributes:
+ label: What broke? (required, attach screenshot)
+ description: |
+ One sentence describing the symptom + at least one screenshot (drag it into the text box).
+ If it's a backend error, paste the stack trace here too (wrapped in ```).
+ placeholder: |
+ Example: As a `member`-role user in ws-b, I clicked "Create from template". The new Agent appeared in the default workspace instead of ws-b.
+
+ [drag in screenshot / screen recording]
+
+ [paste backend stack trace or frontend console error]
+ validations:
+ required: true
+
+ - type: textarea
+ id: repro
+ attributes:
+ label: How to reproduce? (required, numbered steps)
+ description: |
+ Steps that someone with zero context can follow. **A symptom you can't reproduce is a guess, not a bug.**
+ placeholder: |
+ 1. Log in as admin / admin123, create workspace ws-b
+ 2. Add user bob as ws-b member
+ 3. Log out, log back in as bob, switch UI to ws-b
+ 4. Go to Agents → "Create from template" → pick assistant → apply
+ 5. Switch to default workspace — the Agent shows up here
+ validations:
+ required: true
+
+ - type: dropdown
+ id: module
+ attributes:
+ label: Affected module (optional, multi-select)
+ description: Which part of the system? Skip if unsure — helps maintainers triage.
+ multiple: true
+ options:
+ - Backend / 后端
+ - Frontend (admin UI) / 前端
+ - Desktop / 桌面端
+ - Webchat embed widget
+ - Channel (DingTalk / Feishu / Telegram / Discord / QQ / Slack ...)
+ - Tool / 工具
+ - Skill / 技能
+ - Wiki / 知识库
+ - Memory / 记忆
+ - Agent / StateGraph runtime
+ - Auth / Workspace permission
+ - Deployment / DB migration
+ - Other
+ validations:
+ required: false
+
+ - type: input
+ id: env
+ attributes:
+ label: Environment (required, one line)
+ description: version / workspace role / browser or client. One line.
+ placeholder: "v0.x.y / member / Chrome 130 on macOS 14.5"
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/bug-zh.yml b/.github/ISSUE_TEMPLATE/bug-zh.yml
new file mode 100644
index 00000000..e859f71e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug-zh.yml
@@ -0,0 +1,74 @@
+name: "🐛 Bug 报告(中文)"
+description: 报告一个不工作的功能。三个必填项,写完就交。
+title: "[Bug] "
+labels: ["bug"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ 感谢花时间反馈。三件事,写完就好——少一件我们没法定位,多一件浪费你时间。
+
+ > **没截图、没日志、没步骤的 issue 我们会直接关掉**,不是不在乎,是真的修不了。
+
+ - type: textarea
+ id: what
+ attributes:
+ label: 出了什么问题?(必填,附截图)
+ description: |
+ 一句话说清现象 + 至少一张截图(直接拖进文本框即可)。
+ 如果是后端报错,把后端日志也贴这里(用 ``` 包起来)。
+ placeholder: |
+ 例:作为 member 角色用户,在 ws-b 工作区点「从模板创建」,新建出来的 Agent 出现在了默认工作区,不在 ws-b。
+
+ [拖入截图 / 录屏]
+
+ [贴出后端 stack trace 或前端 console error]
+ validations:
+ required: true
+
+ - type: textarea
+ id: repro
+ attributes:
+ label: 怎么复现?(必填,编号步骤)
+ description: |
+ 让一个完全不知情的人能按步骤复现。**说不出步骤的现象不是 bug,是猜想。**
+ placeholder: |
+ 1. 用 admin / admin123 登录,新建工作区 ws-b
+ 2. 添加用户 bob 为 ws-b 的 member
+ 3. 注销,用 bob 登录,前端切到 ws-b
+ 4. 点 Agents 页面 → 「从模板创建」 → 选 assistant → 应用
+ 5. 切回默认工作区,看到 Agent 出现在了这里
+ validations:
+ required: true
+
+ - type: dropdown
+ id: module
+ attributes:
+ label: 影响模块(选填,多选)
+ description: 大致是哪一块?不确定就空着,方便维护者打 label。
+ multiple: true
+ options:
+ - 后端 / Backend
+ - 前端 / Frontend (admin UI)
+ - 桌面端 / Desktop
+ - Webchat 嵌入组件
+ - Channel(钉钉/飞书/Telegram/Discord/QQ/Slack...)
+ - Tool / 工具
+ - Skill / 技能
+ - Wiki / 知识库
+ - Memory / 记忆
+ - Agent / StateGraph 运行时
+ - Auth / 工作区权限
+ - 部署 / 数据库迁移
+ - 其它
+ validations:
+ required: false
+
+ - type: input
+ id: env
+ attributes:
+ label: 环境(必填,一行)
+ description: 版本 / 工作区角色 / 浏览器或客户端。一行写完。
+ placeholder: "v0.x.y / member / Chrome 130 macOS 14.5"
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 00000000..ef3ad23b
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+ - name: 💬 使用问题先看文档 / Check the docs first
+ url: https://claw.mate.vip/docs
+ about: 安装、配置、用法问题文档里大多有答案 / Install, config, and usage questions are usually answered in the docs.
+ - name: 🔒 安全漏洞私下报告 / Report security issues privately
+ url: https://github.com/matevip/mateclaw/security/advisories/new
+ about: 安全相关问题请走 Security Advisory,不要开公开 issue / Please use Security Advisory for security-related issues, don't open a public issue.
diff --git a/.github/ISSUE_TEMPLATE/feature-en.yml b/.github/ISSUE_TEMPLATE/feature-en.yml
new file mode 100644
index 00000000..9ba4cae8
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature-en.yml
@@ -0,0 +1,41 @@
+name: "✨ Feature Request (English)"
+description: Propose a new feature or improvement. Start with why, then what.
+title: "[Feature] "
+labels: ["enhancement"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ The key to a new feature is not "what it is" but "**who suffers without it, and how**".
+
+ If you can't articulate who would use it and why, the feature probably shouldn't be built.
+
+ - type: textarea
+ id: why
+ attributes:
+ label: What problem are you solving? (required)
+ description: |
+ Describe a real scenario. **Don't jump to "add an XX button"** — first explain why you need that button, and what hurts without it.
+ placeholder: |
+ Example: I switch the default model for 5 different Agents every day, and each switch takes 3 clicks in the settings page.
+ A global "quick switch default model" menu would save me 30 clicks a day.
+ validations:
+ required: true
+
+ - type: textarea
+ id: what
+ attributes:
+ label: How should it work? (required)
+ description: |
+ A paragraph or a few bullets. If you can sketch it or share a mockup, even better (drag in images).
+ validations:
+ required: true
+
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives you've tried? (optional)
+ description: |
+ If you can't think of any, leave it blank. **Don't invent content just to fill the field.**
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/feature-zh.yml b/.github/ISSUE_TEMPLATE/feature-zh.yml
new file mode 100644
index 00000000..ded8f32d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature-zh.yml
@@ -0,0 +1,41 @@
+name: "✨ 功能建议(中文)"
+description: 提一个新功能或改进。先讲为什么,再讲是什么。
+title: "[Feature] "
+labels: ["enhancement"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ 新功能的关键不是"它是什么",是"**没有它,谁在受什么苦**"。
+
+ 如果你说不清谁会用、为什么用,这个功能大概率不该做。
+
+ - type: textarea
+ id: why
+ attributes:
+ label: 你在解决什么问题?(必填)
+ description: |
+ 描述真实场景。**不要直接写"应该加一个 XX 按钮"** —— 先说为什么要这个按钮、不加会怎样。
+ placeholder: |
+ 例:我每天要给 5 个不同的 Agent 切换默认模型,每次都要进设置页改 3 处。
+ 如果有一个"快速切换默认模型"的全局菜单,我每天能少点 30 次鼠标。
+ validations:
+ required: true
+
+ - type: textarea
+ id: what
+ attributes:
+ label: 你期望它怎么工作?(必填)
+ description: |
+ 一段话或几个 bullet。如果你能画个草图、贴个 mockup,更好(直接拖图)。
+ validations:
+ required: true
+
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: 你试过的替代方案?(选填)
+ description: |
+ 如果想不到替代方案,就空着。**不要为了填字段而瞎写。**
+ validations:
+ required: false
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
index ada07c94..dd31d672 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
@@ -1556,8 +1556,14 @@ public class AgentGraphBuilder {
continue;
}
- // Cross-turn assistant: never patch (symmetric with stripThinkingFromPrompt)
- if (i <= lastUserIdx) {
+ // Cross-turn assistant: usually skip per stripThinkingFromPrompt's
+ // "thinking resets across user turns" rule. But DeepSeek (since
+ // 2026-04) requires reasoning_content even on prior-turn assistants
+ // and rejects requests where any prior assistant has it null. For
+ // policies with patchCrossTurn=true, fall through and patch with
+ // the empty fallback (" ") so multi-turn conversations don't 400
+ // before sanitizeForLlm has a chance to filter the previous error.
+ if (i <= lastUserIdx && !policy.patchCrossTurn) {
patched.add(msg);
continue;
}
@@ -1667,19 +1673,33 @@ public class AgentGraphBuilder {
// tolerance KIMI/OPENAI use restores forward progress; the producer-side
// capture gap remains a real bug to fix in RFC-049 PR-3 but doesn't
// belong on the user-facing failure path.
- DEEPSEEK(" ", false, true),
- KIMI (" ", false, false),
- OPENAI (" ", false, false),
- DEFAULT (" ", false, false);
+ //
+ // 2026-04-29 follow-up: DeepSeek tightened thinking-mode validation to
+ // require reasoning_content on EVERY assistant message in the request,
+ // including prior-turn history. We never persist reasoning_content to
+ // mate_message, so any conversation with >=1 prior turn fails with
+ // 400 "reasoning_content must be passed back" on the very first reasoning
+ // call. patchCrossTurn=true lets us extend the " " fallback to prior-turn
+ // assistants too, restoring forward progress for multi-turn IM chats.
+ // Real reasoning_content recovery (RFC-049 PR-3) is the proper long-term
+ // fix; this keeps users unblocked.
+ DEEPSEEK(" ", false, true, true),
+ KIMI (" ", false, false, false),
+ OPENAI (" ", false, false, false),
+ DEFAULT (" ", false, false, false);
final String emptyFallback;
final boolean warnOnMissingReal;
final boolean patchNonToolCall;
+ /** Whether to also patch prior-turn assistants ({@code i <= lastUserIdx}). */
+ final boolean patchCrossTurn;
- FallbackPolicy(String emptyFallback, boolean warnOnMissingReal, boolean patchNonToolCall) {
+ FallbackPolicy(String emptyFallback, boolean warnOnMissingReal,
+ boolean patchNonToolCall, boolean patchCrossTurn) {
this.emptyFallback = emptyFallback;
this.warnOnMissingReal = warnOnMissingReal;
this.patchNonToolCall = patchNonToolCall;
+ this.patchCrossTurn = patchCrossTurn;
}
static FallbackPolicy forProvider(ModelProviderEntity provider) {
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java
index 0f9ac2a6..fbbb3e78 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java
@@ -4,8 +4,10 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
+import vip.mate.channel.health.ChannelHealth;
import vip.mate.channel.model.ChannelEntity;
+import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -224,6 +226,33 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter {
return running.get();
}
+ /**
+ * Map the existing {@code ConnectionState} machine to a typed
+ * {@link ChannelHealth} snapshot. The "UP" status requires
+ * {@code running=true} AND {@code state==CONNECTED} — this is what
+ * makes the green dot honest: "stopped admins" and "started but
+ * disconnected" both report something other than UP.
+ */
+ @Override
+ public ChannelHealth health() {
+ Long id = channelEntity != null ? channelEntity.getId() : null;
+ String type = getChannelType();
+ if (!running.get()) {
+ return ChannelHealth.outOfService(type, id);
+ }
+ Instant lastEvent = Instant.ofEpochMilli(lastEventTimeMs.get());
+ ConnectionState s = connectionState.get();
+ return switch (s) {
+ case CONNECTED -> ChannelHealth.up(type, id, lastEvent);
+ case RECONNECTING -> ChannelHealth.reconnecting(type, id,
+ lastError != null ? lastError : "reconnecting", lastEvent);
+ case ERROR -> ChannelHealth.down(type, id,
+ lastError != null ? lastError : "channel error", lastEvent);
+ case DISCONNECTED -> ChannelHealth.down(type, id,
+ "disconnected", lastEvent);
+ };
+ }
+
/**
* RFC-024 Change 1:刷新"活跃时间"的标准入口。
*
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java
index 3952b1a8..73f62d1b 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java
@@ -1,5 +1,6 @@
package vip.mate.channel;
+import vip.mate.channel.health.ChannelHealth;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.time.Duration;
@@ -161,4 +162,24 @@ public interface ChannelAdapter {
default Duration stalenessThreshold() {
return Duration.ofMinutes(60);
}
+
+ /**
+ * Real-time health snapshot of this adapter.
+ *
+ *
This is the source of truth the frontend "connected" green dot
+ * should bind to — {@code mate_channel.enabled} only records the
+ * user's intent to run the channel, not whether the underlying
+ * transport (WebSocket / webhook subscription / API token) is
+ * actually healthy.
+ *
+ *
Default returns {@code OUT_OF_SERVICE} when {@link #isRunning()}
+ * is false and {@code UP} otherwise. Concrete adapters override to
+ * surface RECONNECTING / DOWN with specific reasons (auth failure,
+ * staleness exceeded, etc).
+ */
+ default ChannelHealth health() {
+ return isRunning()
+ ? ChannelHealth.up(getChannelType(), null, java.time.Instant.now())
+ : ChannelHealth.outOfService(getChannelType(), null);
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelErrorClassifier.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelErrorClassifier.java
new file mode 100644
index 00000000..e897adff
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelErrorClassifier.java
@@ -0,0 +1,55 @@
+package vip.mate.channel;
+
+import org.springframework.stereotype.Component;
+
+/**
+ * Single source of truth for "is this assistant reply actually an error
+ * surface?" used across the channel layer.
+ *
+ *
Why this matters: an LLM-side 400 (DashScope's "Bad request, please
+ * check input", DeepSeek thinking-mode "reasoning_content must be passed
+ * back", Anthropic "does not support assistant message prefill") is rendered
+ * as a normal-looking assistant string by {@code NodeStreamingChatHelper}.
+ * If that string is persisted with {@code status='completed'}, the next
+ * turn's history feeds it back to the LLM as a real assistant turn and the
+ * 400 self-replicates indefinitely.
+ *
+ *
The fix has two layers:
+ *
+ *
This classifier flips the persisted status to {@code 'error'} so
+ * {@code BaseAgent.sanitizeForLlm} filters it from history.
+ *
The "[错误] " content prefix kept on disk is the legacy backup
+ * filter — both work together.
+ *
+ *
+ *
Heuristics are kept in sync with the error-message templates emitted
+ * by {@code NodeStreamingChatHelper.buildErrorResultWithType} and friends.
+ * Adding a new error template there means adding the matching probe here.
+ */
+@Component
+public class ChannelErrorClassifier {
+
+ /**
+ * @return {@code true} if the reply text matches one of the known
+ * LLM-side error surfaces and should NOT be treated as a real
+ * assistant turn for memory / history purposes.
+ */
+ public boolean isErrorReply(String reply) {
+ if (reply == null || reply.isBlank()) {
+ return false;
+ }
+ return reply.startsWith("[错误] ")
+ || reply.contains("Bad request:")
+ || reply.contains("LLM 调用失败:")
+ || reply.contains("LLM 调用超时")
+ || reply.contains("LLM 调用被中断")
+ || reply.contains("Prompt 过长:")
+ || reply.contains("认证失败:")
+ || reply.contains("LLM 返回空响应");
+ }
+
+ /** Map a classification result to the {@code mate_message.status} value. */
+ public String statusFor(String reply) {
+ return isErrorReply(reply) ? "error" : "completed";
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java
index 508ed5a6..4540e0b1 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java
@@ -56,6 +56,7 @@ public class ChannelMessageRouter {
private final ObjectMapper objectMapper;
private final ChatStreamTracker streamTracker;
private final ChannelChatOriginFactory chatOriginFactory;
+ private final ChannelErrorClassifier errorClassifier;
/** 队列条目:封装消息及其路由上下文 */
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
@@ -101,7 +102,8 @@ public class ChannelMessageRouter {
TtsService ttsService,
ObjectMapper objectMapper,
ChatStreamTracker streamTracker,
- ChannelChatOriginFactory chatOriginFactory) {
+ ChannelChatOriginFactory chatOriginFactory,
+ ChannelErrorClassifier errorClassifier) {
this.agentService = agentService;
this.conversationService = conversationService;
this.channelService = channelService;
@@ -113,6 +115,7 @@ public class ChannelMessageRouter {
this.objectMapper = objectMapper;
this.streamTracker = streamTracker;
this.chatOriginFactory = chatOriginFactory;
+ this.errorClassifier = errorClassifier;
}
// ==================== 防抖辅助类 ====================
@@ -465,10 +468,20 @@ public class ChannelMessageRouter {
log.info("[{}] Approval triggered during chat, sent notice (NOT saved to DB): tool={}",
adapter.getChannelType(), newPending.getToolName());
} else {
- // 正常回复:保存并发送
- MessageEntity saved = conversationService.saveMessage(conversationId, "assistant", reply);
+ // Tag error replies (matched by ChannelErrorClassifier — the
+ // "[错误]" content prefix / Bad request: / LLM error templates)
+ // with status='error' so BaseAgent.sanitizeForLlm drops them
+ // from the next turn's LLM history, breaking the self-replicating
+ // 400 loop. Only successful replies fire the ConversationCompletedEvent —
+ // error turns must not pollute memory extraction.
+ boolean isError = errorClassifier.isErrorReply(reply);
+ String status = isError ? "error" : "completed";
+ MessageEntity saved = conversationService.saveMessage(
+ conversationId, "assistant", reply, null, status);
savedAssistantId = saved != null ? saved.getId() : null;
- publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply);
+ if (!isError) {
+ publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply);
+ }
adapter.renderAndSend(replyTarget, reply);
log.info("[{}] Reply sent to {}: {}chars",
adapter.getChannelType(), replyTarget, reply.length());
@@ -551,9 +564,15 @@ public class ChannelMessageRouter {
log.info("[{}] Approval triggered during streaming (NOT saved to DB): tool={}",
channelType, newPending.getToolName());
} else if (finalContent != null && !finalContent.isBlank()) {
- MessageEntity saved = conversationService.saveMessage(conversationId, "assistant", finalContent);
- publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent);
- log.info("[{}] Streaming completed: contentLen={}", channelType, finalContent.length());
+ boolean isError = errorClassifier.isErrorReply(finalContent);
+ String status = isError ? "error" : "completed";
+ MessageEntity saved = conversationService.saveMessage(
+ conversationId, "assistant", finalContent, null, status);
+ if (!isError) {
+ publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent);
+ }
+ log.info("[{}] Streaming completed: contentLen={}, isError={}",
+ channelType, finalContent.length(), isError);
// 流式回复完成后也触发语音回复
String replyTarget = resolveReplyTarget(message);
@@ -566,7 +585,17 @@ public class ChannelMessageRouter {
} catch (Exception e) {
log.error("[{}] Streaming processing failed: {}", channelType, e.getMessage(), e);
- // 尝试发送错误提示
+ // Persist an error placeholder (status='error') so that the next
+ // turn's history does not show a user → user sequence (which some
+ // providers reject with 400). sanitizeForLlm filters this row out
+ // before the LLM sees it, so it costs nothing at the prompt layer.
+ try {
+ conversationService.saveMessage(conversationId, "assistant",
+ "[错误] " + e.getMessage(), null, "error");
+ } catch (Exception persistErr) {
+ log.warn("[{}] Failed to persist error placeholder: {}",
+ channelType, persistErr.getMessage());
+ }
try {
String errorTarget = resolveReplyTarget(message);
streamingAdapter.sendMessage(errorTarget, "抱歉,流式处理失败:" + e.getMessage());
@@ -577,6 +606,7 @@ public class ChannelMessageRouter {
return null;
}
+
// ==================== 审批重放 ====================
/**
@@ -613,8 +643,13 @@ public class ChannelMessageRouter {
String reply = agentService.chatWithReplay(
agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin);
- // 保存 replay 结果(这是正常结果,入库)
- conversationService.saveMessage(conversationId, "assistant", reply);
+ // Persist the replay result. If the LLM 400'd during replay,
+ // the error reply must also get status='error' — otherwise the
+ // next turn's history would re-feed the error placeholder back
+ // into the prompt and re-trigger the same failure.
+ boolean isError = errorClassifier.isErrorReply(reply);
+ conversationService.saveMessage(conversationId, "assistant", reply, null,
+ isError ? "error" : "completed");
// 发送回复
adapter.renderAndSend(replyTarget, reply);
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java
index 6eb790ce..3869c7e2 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java
@@ -130,6 +130,55 @@ public class ChannelController {
return R.ok(channelManager.getStatus());
}
+ @RequireWorkspaceRole("viewer")
+ @Operation(summary = "获取指定渠道的实时健康状态(真连接状态,前端绿点应该绑这个)")
+ @GetMapping("/{id}/health")
+ public R