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: + *

    + *
  1. This classifier flips the persisted status to {@code 'error'} so + * {@code BaseAgent.sanitizeForLlm} filters it from history.
  2. + *
  3. The "[错误] " content prefix kept on disk is the legacy backup + * filter — both work together.
  4. + *
+ * + *

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> health(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + ChannelEntity channel = channelService.getChannel(id); + verifyResourceWorkspace(channel.getWorkspaceId(), workspaceId); + return R.ok(channelManager.getAdapter(id) + .map(adapter -> adapter.health().toMap()) + .orElseGet(() -> { + // Adapter not in active map: either disabled, never started, + // or still booting. Surface as OUT_OF_SERVICE so the frontend + // dot stays gray instead of red. + Map body = new java.util.LinkedHashMap<>(); + body.put("channelType", channel.getChannelType()); + body.put("channelId", id); + body.put("status", "OUT_OF_SERVICE"); + body.put("detail", Boolean.TRUE.equals(channel.getEnabled()) + ? "channel enabled but adapter not active" : "channel disabled"); + return body; + })); + } + + @RequireWorkspaceRole("admin") + @Operation(summary = "批量获取所有渠道健康状态") + @GetMapping("/health") + public R>> healthAll( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + long ws = workspaceId != null ? workspaceId : 1L; + return R.ok(channelService.listChannelsByWorkspace(ws).stream() + .map(c -> { + Map body = channelManager.getAdapter(c.getId()) + .map(a -> a.health().toMap()) + .orElseGet(() -> { + Map m = new java.util.LinkedHashMap<>(); + m.put("channelType", c.getChannelType()); + m.put("channelId", c.getId()); + m.put("status", "OUT_OF_SERVICE"); + m.put("detail", Boolean.TRUE.equals(c.getEnabled()) + ? "channel enabled but adapter not active" : "channel disabled"); + return m; + }); + body.put("name", c.getName()); + body.put("enabled", Boolean.TRUE.equals(c.getEnabled())); + return body; + }) + .toList()); + } + private void verifyResourceWorkspace(Long resourceWorkspaceId, Long headerWorkspaceId) { long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L; if (resourceWorkspaceId != null && !resourceWorkspaceId.equals(requestedWs)) { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelWebhookController.java b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelWebhookController.java index a606ae85..9cfb8f4b 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelWebhookController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelWebhookController.java @@ -13,17 +13,11 @@ import vip.mate.channel.dingtalk.DingTalkChannelAdapter; import vip.mate.channel.discord.DiscordChannelAdapter; import vip.mate.channel.feishu.FeishuAppRegistrationService; import vip.mate.channel.feishu.FeishuChannelAdapter; +import vip.mate.channel.qrcode.util.QrCodeImageEncoder; import vip.mate.channel.telegram.TelegramChannelAdapter; import vip.mate.channel.weixin.ILinkClient; import vip.mate.channel.weixin.WeixinChannelAdapter; -import com.google.zxing.BarcodeFormat; -import com.google.zxing.EncodeHintType; -import com.google.zxing.client.j2se.MatrixToImageWriter; -import com.google.zxing.common.BitMatrix; -import com.google.zxing.qrcode.QRCodeWriter; -import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; -import java.io.ByteArrayOutputStream; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; @@ -261,19 +255,9 @@ public class ChannelWebhookController { new com.fasterxml.jackson.databind.ObjectMapper()); } - /** - * 使用 ZXing 生成 QR 码 PNG 图片并返回 Base64 编码 - */ + /** Delegated to the shared encoder so future tweaks live in one place. */ private String generateQrCodeBase64(String content) throws Exception { - QRCodeWriter writer = new QRCodeWriter(); - Map hints = Map.of( - EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M, - EncodeHintType.MARGIN, 2 - ); - BitMatrix bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, 300, 300, hints); - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - MatrixToImageWriter.writeToStream(bitMatrix, "PNG", baos); - return java.util.Base64.getEncoder().encodeToString(baos.toByteArray()); + return QrCodeImageEncoder.toBase64(content); } @Operation(summary = "获取微信登录二维码") diff --git a/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java index 26cda9c5..1994c7ea 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/dingtalk/DingTalkChannelAdapter.java @@ -176,31 +176,62 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St payload.put("conversationType", msg.getConversationType()); payload.put("sessionWebhook", msg.getSessionWebhook()); - // 消息内容 - // 钉钉服务端已经把语音转写好放在 MessageContent.recognition 里(跟 - // 企业微信 voice.content 一个模式),不需要 STT。优先读 recognition; - // picture msgtype 把 downloadCode / pictureDownloadCode 透传给 webhook 处理; - // 否则读 text.content。 - // richText 还没接上 —— stream 模式收到 richText 会重新落到 webhook 默认 else - // 分支静默丢消息,单独修。 + // Dispatch by content type. DingTalk pre-transcribes voice into + // MessageContent.recognition (same shape as WeCom voice.content), + // so we never need to run STT ourselves. + // - audio (recognition) -> forwarded as text + // - picture / downloadCode -> webhook picture branch fetches bytes + // - richText (List) -> rebuild the webhook richText payload + // - text -> text.content com.dingtalk.open.app.api.models.bot.MessageContent body = msg.getContent(); String recognition = body != null ? body.getRecognition() : null; String pictureDownloadCode = body != null ? body.getPictureDownloadCode() : null; String downloadCode = body != null ? body.getDownloadCode() : null; + java.util.List richTextItems = + body != null ? body.getRichText() : null; + String msgtype = msg.getMsgtype(); if (recognition != null && !recognition.isBlank()) { payload.put("msgtype", "audio"); payload.put("audio", Map.of("recognition", recognition)); - } else if ("picture".equals(msg.getMsgtype()) + } else if ("picture".equals(msgtype) && (pictureDownloadCode != null || downloadCode != null)) { payload.put("msgtype", "picture"); Map picture = new java.util.HashMap<>(); if (pictureDownloadCode != null) picture.put("pictureDownloadCode", pictureDownloadCode); if (downloadCode != null) picture.put("downloadCode", downloadCode); payload.put("picture", picture); + } else if ("richText".equalsIgnoreCase(msgtype) + || (richTextItems != null && !richTextItems.isEmpty())) { + // richText: rebuild a webhook-compatible richText.richText array. + // Without this branch, group @-mentions / formatted text / + // quoted replies all fall through to the default webhook else + // branch and get silently dropped — that's the "console + // doesn't receive" symptom users hit on first DingTalk test. + List> items = new ArrayList<>(); + if (richTextItems != null) { + for (com.dingtalk.open.app.api.models.bot.MessageContent item : richTextItems) { + if (item == null) continue; + Map m = new java.util.HashMap<>(); + if (item.getText() != null) m.put("text", item.getText()); + if (item.getType() != null) m.put("type", item.getType()); + if (item.getDownloadCode() != null) m.put("downloadCode", item.getDownloadCode()); + if (item.getPictureDownloadCode() != null) { + m.put("pictureDownloadCode", item.getPictureDownloadCode()); + } + if (!m.isEmpty()) items.add(m); + } + } + payload.put("msgtype", "richText"); + payload.put("richText", Map.of("richText", items)); } else if (msg.getText() != null) { payload.put("msgtype", "text"); - payload.put("text", Map.of("content", msg.getText().getContent() != null ? msg.getText().getContent() : "")); + payload.put("text", Map.of("content", + msg.getText().getContent() != null ? msg.getText().getContent() : "")); + } else { + log.warn("[dingtalk-stream] unsupported msgtype={}, msgId={}, dropping", + msgtype, msg.getMsgId()); + return; } handleWebhook(payload); @@ -345,9 +376,16 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St outTrackId, e.getMessage(), e); aiCardManager.failCard(outTrackId, e.getMessage()); + // Tag the returned content with the "[错误] " prefix so + // ChannelMessageRouter.isErrorReply flips status='error' on the + // persisted row and BaseAgent.sanitizeForLlm filters it out of + // the next turn's history. Without this, AICard partial output + // (e.g. LLM 400'd mid-stream) would re-enter the prompt as a + // valid assistant turn and re-trigger the same 400. String partial = contentAccumulator.toString(); + String errorPrefix = "[错误] AI Card streaming failed: " + e.getMessage(); if (!partial.isBlank()) { - return partial; + return errorPrefix + "\n\n(已生成的部分内容,已忽略)\n" + partial; } throw new RuntimeException("AI Card streaming failed: " + e.getMessage(), e); } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/health/ChannelHealth.java b/mateclaw-server/src/main/java/vip/mate/channel/health/ChannelHealth.java new file mode 100644 index 00000000..d7b5b978 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/health/ChannelHealth.java @@ -0,0 +1,81 @@ +package vip.mate.channel.health; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Per-channel health snapshot. + * + *

Mirrors Spring Boot's {@code Health} object semantically (status + + * details) but kept channel-domain-typed so we don't leak {@code actuator} + * types into the channel API and so we can serialize directly into the + * {@code /api/v1/channels/{id}/health} JSON response. + * + *

Status semantics: + *

+ * + *

This is the "single source of truth" the frontend "已连接" green dot + * should bind to — not the {@code mate_channel.enabled} DB flag, which + * only records the user's intent, not transport reality. + */ +@Getter +@RequiredArgsConstructor +public class ChannelHealth { + + public enum Status { UP, RECONNECTING, DOWN, OUT_OF_SERVICE, UNKNOWN } + + private final String channelType; + private final Long channelId; + private final Status status; + private final String detail; + private final Instant lastEventAt; + private final Map extra; + + public static ChannelHealth up(String type, Long id, Instant lastEvent) { + return new ChannelHealth(type, id, Status.UP, "active", lastEvent, Map.of()); + } + + public static ChannelHealth outOfService(String type, Long id) { + return new ChannelHealth(type, id, Status.OUT_OF_SERVICE, "not started", null, Map.of()); + } + + public static ChannelHealth unknown(String type, Long id) { + return new ChannelHealth(type, id, Status.UNKNOWN, "initializing", null, Map.of()); + } + + public static ChannelHealth down(String type, Long id, String reason, Instant lastEvent) { + return new ChannelHealth(type, id, Status.DOWN, reason, lastEvent, Map.of()); + } + + public static ChannelHealth reconnecting(String type, Long id, String reason, Instant lastEvent) { + return new ChannelHealth(type, id, Status.RECONNECTING, reason, lastEvent, Map.of()); + } + + /** Serialize to a JSON-friendly map for the REST endpoint. */ + public Map toMap() { + Map m = new LinkedHashMap<>(); + m.put("channelType", channelType); + m.put("channelId", channelId); + m.put("status", status.name()); + if (detail != null) m.put("detail", detail); + if (lastEventAt != null) m.put("lastEventAt", lastEventAt.toString()); + if (extra != null && !extra.isEmpty()) m.putAll(extra); + return m; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/health/ChannelsHealthIndicator.java b/mateclaw-server/src/main/java/vip/mate/channel/health/ChannelsHealthIndicator.java new file mode 100644 index 00000000..f46c6680 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/health/ChannelsHealthIndicator.java @@ -0,0 +1,71 @@ +package vip.mate.channel.health; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; +import vip.mate.channel.ChannelAdapter; +import vip.mate.channel.ChannelManager; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Spring Boot Actuator health indicator aggregating all running channel + * adapters' real-time health. + * + *

Surfaces under {@code /actuator/health/channels}. Used by the desktop + * bundle's post-install smoke test (and any external monitor) to fail-loud + * when a configured channel never reaches {@code UP} after startup. + * + *

Aggregation rule: overall status is UP iff every running adapter + * reports UP. Any DOWN or ERROR demotes the aggregate to DOWN. + */ +@Component("channels") +@RequiredArgsConstructor +public class ChannelsHealthIndicator implements HealthIndicator { + + private final ChannelManager channelManager; + + @Override + public Health health() { + Map details = new LinkedHashMap<>(); + boolean anyDown = false; + boolean anyReconnecting = false; + int total = 0; + int up = 0; + + for (ChannelAdapter adapter : channelManager.getActiveAdapters()) { + ChannelHealth h = adapter.health(); + details.put(safeKey(adapter, h), h.toMap()); + total++; + switch (h.getStatus()) { + case UP -> up++; + case RECONNECTING -> anyReconnecting = true; + case DOWN -> anyDown = true; + default -> { /* OUT_OF_SERVICE / UNKNOWN don't fail aggregate */ } + } + } + + details.put("totalActive", total); + details.put("up", up); + + Health.Builder builder; + if (anyDown) { + builder = Health.down(); + } else if (anyReconnecting) { + builder = Health.status("RECONNECTING"); + } else { + builder = Health.up(); + } + return builder.withDetails(details).build(); + } + + /** key in details map: prefer {@code :} fallback to type only. */ + private String safeKey(ChannelAdapter adapter, ChannelHealth h) { + if (h.getChannelId() != null) { + return adapter.getChannelType() + ":" + h.getChannelId(); + } + return adapter.getChannelType(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/qrcode/ChannelQRCodeAuthProvider.java b/mateclaw-server/src/main/java/vip/mate/channel/qrcode/ChannelQRCodeAuthProvider.java new file mode 100644 index 00000000..3b66e50c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/qrcode/ChannelQRCodeAuthProvider.java @@ -0,0 +1,45 @@ +package vip.mate.channel.qrcode; + +import java.util.Map; + +/** + * SPI for channels that support QR-code-based bot/app registration. + * + *

Each implementation handles one channel type's "scan QR → confirm → + * receive credentials" flow. The framework picks the right provider by + * {@link #channelType()} so the controller and frontend share a single + * pair of endpoints regardless of how many channels register this way. + * + *

Adding a new channel: implement this interface as a Spring + * {@code @Component} and the framework auto-routes to it. No changes to + * the controller or the frontend are required for the generic endpoints. + * + *

This intentionally keeps the wire format channel-agnostic via + * {@link Map} payloads — implementations are free to surface their own + * key set (e.g. DingTalk uses {@code client_id/client_secret}, Feishu + * uses {@code app_id/app_secret}). + */ +public interface ChannelQRCodeAuthProvider { + + /** The {@code mate_channel.channel_type} value this provider handles. */ + String channelType(); + + /** + * Kick off a registration session. + * + * @param params Optional channel-specific kickoff parameters + * (e.g. Feishu's {@code domain=feishu/lark}). + * @return A response containing at minimum {@code session_id}. + */ + Map begin(Map params) throws Exception; + + /** + * Poll a registration session for current state. + * + * @param sessionId Token returned from {@link #begin}. + * @return Status payload — at minimum {@code status} field, plus + * credentials when {@code status=confirmed}, or QR image + * when the user hasn't scanned yet. + */ + Map pollStatus(String sessionId); +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/qrcode/ChannelQRCodeAuthRegistry.java b/mateclaw-server/src/main/java/vip/mate/channel/qrcode/ChannelQRCodeAuthRegistry.java new file mode 100644 index 00000000..8f2374eb --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/qrcode/ChannelQRCodeAuthRegistry.java @@ -0,0 +1,40 @@ +package vip.mate.channel.qrcode; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Routing registry for {@link ChannelQRCodeAuthProvider} implementations. + * + *

Spring auto-collects every {@code ChannelQRCodeAuthProvider} bean on + * startup and indexes them by {@link ChannelQRCodeAuthProvider#channelType()}. + * The generic QR controller hands off to {@code lookup(channelType)} for + * the requested channel. + */ +@Slf4j +@Component +public class ChannelQRCodeAuthRegistry { + + private final Map byType = new HashMap<>(); + + public ChannelQRCodeAuthRegistry(List providers) { + for (ChannelQRCodeAuthProvider p : providers) { + ChannelQRCodeAuthProvider prev = byType.put(p.channelType(), p); + if (prev != null) { + log.warn("[qrcode-auth] duplicate provider for type={} — {} replaced {}", + p.channelType(), p.getClass().getSimpleName(), + prev.getClass().getSimpleName()); + } + } + log.info("[qrcode-auth] registered {} provider(s): {}", byType.size(), byType.keySet()); + } + + public Optional lookup(String channelType) { + return Optional.ofNullable(byType.get(channelType)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/qrcode/ChannelQRCodeController.java b/mateclaw-server/src/main/java/vip/mate/channel/qrcode/ChannelQRCodeController.java new file mode 100644 index 00000000..9a7c9896 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/qrcode/ChannelQRCodeController.java @@ -0,0 +1,69 @@ +package vip.mate.channel.qrcode; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * Generic channel QR-code authorization endpoints. + * + *

Replaces per-channel pairs ({@code /webhook/dingtalk/register/begin}, + * {@code /webhook/feishu/register/begin}, ...) with a single SPI-routed + * pair. Adding a new QR-auth-capable channel is now a one-class change + * (a {@code @Component} implementing {@link ChannelQRCodeAuthProvider}) + * with no controller edits. + * + *

The legacy per-channel endpoints in + * {@code ChannelWebhookController} continue to work for backward compat + * while the frontend migrates over. + */ +@Tag(name = "渠道QR扫码授权(统一)") +@Slf4j +@RestController +@RequestMapping("/api/v1/channels/qrcode") +@RequiredArgsConstructor +public class ChannelQRCodeController { + + private final ChannelQRCodeAuthRegistry registry; + + @Operation(summary = "启动指定渠道的扫码授权流程") + @PostMapping("/{channelType}/begin") + public ResponseEntity> begin( + @PathVariable String channelType, + @RequestParam(required = false) Map params) { + return registry.lookup(channelType) + .map(p -> { + try { + return ResponseEntity.ok(p.begin(params)); + } catch (Exception e) { + log.error("[qrcode-auth/{}] begin failed: {}", channelType, e.getMessage(), e); + return ResponseEntity.internalServerError() + .body(Map.of("error", + "Failed to start registration: " + e.getMessage())); + } + }) + .orElseGet(() -> ResponseEntity.badRequest() + .body(Map.of("error", "Channel does not support QR authorization: " + channelType))); + } + + @Operation(summary = "查询指定渠道的扫码授权状态") + @GetMapping("/{channelType}/status") + public ResponseEntity> status( + @PathVariable String channelType, + @RequestParam("session") String sessionId) { + return registry.lookup(channelType) + .map(p -> ResponseEntity.ok(p.pollStatus(sessionId))) + .orElseGet(() -> ResponseEntity.badRequest() + .body(Map.of("error", "Channel does not support QR authorization: " + channelType))); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/qrcode/DingTalkQRCodeAuthProvider.java b/mateclaw-server/src/main/java/vip/mate/channel/qrcode/DingTalkQRCodeAuthProvider.java new file mode 100644 index 00000000..ed4648db --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/qrcode/DingTalkQRCodeAuthProvider.java @@ -0,0 +1,66 @@ +package vip.mate.channel.qrcode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.channel.dingtalk.DingTalkAppRegistrationService; +import vip.mate.channel.qrcode.util.QrCodeImageEncoder; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * QR-code auth provider for DingTalk OAuth Device Flow registration. + * + *

Wraps {@link DingTalkAppRegistrationService} to expose its session + * model through the unified {@link ChannelQRCodeAuthProvider} contract. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DingTalkQRCodeAuthProvider implements ChannelQRCodeAuthProvider { + + private final DingTalkAppRegistrationService service; + + @Override + public String channelType() { + return "dingtalk"; + } + + @Override + public Map begin(Map params) throws Exception { + DingTalkAppRegistrationService.RegistrationSession session = service.begin(); + return Map.of("session_id", session.sessionId); + } + + @Override + public Map pollStatus(String sessionId) { + DingTalkAppRegistrationService.RegistrationSession session = service.getSession(sessionId); + if (session == null) { + return Map.of("status", "expired", "error", "session not found or expired"); + } + Map body = new LinkedHashMap<>(); + body.put("status", session.status.name().toLowerCase()); + if (session.qrcodeUrl != null) { + body.put("qrcode_url", session.qrcodeUrl); + if (session.qrcodeImgDataUri == null) { + try { + session.qrcodeImgDataUri = QrCodeImageEncoder.toDataUri(session.qrcodeUrl); + } catch (Exception e) { + log.warn("[dingtalk-register] QR encode failed: {}", e.getMessage()); + } + } + if (session.qrcodeImgDataUri != null) { + body.put("qrcode_img", session.qrcodeImgDataUri); + } + } + if (session.status == DingTalkAppRegistrationService.Status.CONFIRMED) { + body.put("client_id", session.clientId); + body.put("client_secret", session.clientSecret); + } + if (session.errorMessage != null) { + body.put("error", session.errorMessage); + } + return body; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/qrcode/FeishuQRCodeAuthProvider.java b/mateclaw-server/src/main/java/vip/mate/channel/qrcode/FeishuQRCodeAuthProvider.java new file mode 100644 index 00000000..1c40997f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/qrcode/FeishuQRCodeAuthProvider.java @@ -0,0 +1,70 @@ +package vip.mate.channel.qrcode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.channel.feishu.FeishuAppRegistrationService; +import vip.mate.channel.qrcode.util.QrCodeImageEncoder; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * QR-code auth provider for Feishu / Lark app registration. + * + *

Domain selection ({@code feishu} vs {@code lark}) flows through the + * generic {@code params} map. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class FeishuQRCodeAuthProvider implements ChannelQRCodeAuthProvider { + + private final FeishuAppRegistrationService service; + + @Override + public String channelType() { + return "feishu"; + } + + @Override + public Map begin(Map params) throws Exception { + String domain = params != null ? params.getOrDefault("domain", "feishu") : "feishu"; + String sessionId = service.begin(domain); + return Map.of("session_id", sessionId); + } + + @Override + public Map pollStatus(String sessionId) { + FeishuAppRegistrationService.RegistrationSession session = service.getSession(sessionId); + if (session == null) { + return Map.of("status", "expired", "error", "session not found or expired"); + } + Map body = new LinkedHashMap<>(); + body.put("status", session.status.name().toLowerCase()); + if (session.qrcodeUrl != null) { + body.put("qrcode_url", session.qrcodeUrl); + body.put("qrcode_expire_seconds", session.qrcodeExpireSeconds); + if (session.qrcodeImgDataUri == null) { + try { + session.qrcodeImgDataUri = QrCodeImageEncoder.toDataUri(session.qrcodeUrl); + } catch (Exception e) { + log.warn("[feishu-register] QR encode failed: {}", e.getMessage()); + } + } + if (session.qrcodeImgDataUri != null) { + body.put("qrcode_img", session.qrcodeImgDataUri); + } + } + if (session.status == FeishuAppRegistrationService.Status.CONFIRMED) { + body.put("client_id", session.clientId); + body.put("client_secret", session.clientSecret); + if (session.userOpenId != null) body.put("user_open_id", session.userOpenId); + if (session.userTenantBrand != null) body.put("user_tenant_brand", session.userTenantBrand); + } + if (session.errorMessage != null) { + body.put("error", session.errorMessage); + } + return body; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/qrcode/util/QrCodeImageEncoder.java b/mateclaw-server/src/main/java/vip/mate/channel/qrcode/util/QrCodeImageEncoder.java new file mode 100644 index 00000000..e3b340cf --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/qrcode/util/QrCodeImageEncoder.java @@ -0,0 +1,40 @@ +package vip.mate.channel.qrcode.util; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; + +import java.io.ByteArrayOutputStream; +import java.util.Base64; +import java.util.Map; + +/** + * Single ZXing-based QR encoder used across QR auth providers. + * + *

Centralized so a future tweak (size, error-correction level, margin) + * touches one place instead of every register endpoint. + */ +public final class QrCodeImageEncoder { + + private QrCodeImageEncoder() {} + + /** Encode {@code content} as a 300x300 PNG QR code, return base64-only payload. */ + public static String toBase64(String content) throws Exception { + QRCodeWriter writer = new QRCodeWriter(); + Map hints = Map.of( + EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M, + EncodeHintType.MARGIN, 2); + BitMatrix bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, 300, 300, hints); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + MatrixToImageWriter.writeToStream(bitMatrix, "PNG", baos); + return Base64.getEncoder().encodeToString(baos.toByteArray()); + } + + /** Encode and wrap as a browser-renderable {@code data:image/png;base64,...} URI. */ + public static String toDataUri(String content) throws Exception { + return "data:image/png;base64," + toBase64(content); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java index 865da2bb..36c5cd6a 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java @@ -26,6 +26,7 @@ import java.time.Duration; import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; /** @@ -143,11 +144,23 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { private record WeComReplyContext(String frameReqId, String processingStreamId) {} + /** + * Single-flight guard for failure signals. JDK WebSocket can fire onClose + * AND onError for the same outage, plus connect-exception and heartbeat + * timeout, all routing to the disconnect path. The first one wins; the + * rest are deduped. Cleared at the start of each new connect attempt and + * after auth_succeed in markReady(). + */ + private final AtomicBoolean disconnectInflight = new AtomicBoolean(false); + public WeComChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper) { super(channelEntity, messageRouter, objectMapper); - int maxAttempts = -1; + // Default to 8 bounded attempts (~4 minutes total at 2s..30s exponential) + // so the UI eventually settles in ERROR instead of getting stuck in + // RECONNECTING forever. User config still overrides (-1 = infinite). + int maxAttempts = 8; Object val = config.get("max_reconnect_attempts"); if (val instanceof Number n) { maxAttempts = n.intValue(); @@ -180,81 +193,75 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { @Override protected void doStop() { - // 停止心跳 - if (heartbeatFuture != null) { - heartbeatFuture.cancel(false); - heartbeatFuture = null; - } - - // 关闭 WebSocket - if (webSocket != null) { - try { - webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "Manual disconnect") - .orTimeout(3, TimeUnit.SECONDS) - .exceptionally(ex -> null) - .join(); - } catch (Exception e) { - log.debug("[wecom] Error closing WebSocket: {}", e.getMessage()); - } - webSocket = null; - } - - // 等待 WS 线程结束 - if (wsThread != null) { - wsThread.interrupt(); - try { - wsThread.join(5000); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - } - wsThread = null; - } - - // 清理挂起的 ACK - pendingAcks.forEach((k, f) -> f.completeExceptionally(new RuntimeException("Channel stopped"))); - pendingAcks.clear(); - replyQueues.clear(); - pendingFrames.clear(); + releaseConnectionResources("stopped"); + // doStop also clears history that survives reconnects (processedMessageIds) processedMessageIds.clear(); - replyContexts.clear(); - - this.httpClient = null; log.info("[wecom] WeCom bot channel stopped"); } @Override protected void doReconnect() { log.info("[wecom] Reconnecting WebSocket..."); - // 清理旧连接 + releaseConnectionResources("reconnecting"); + + // releaseConnectionResources nulls httpClient — rebuild a fresh one. + // This is the core fix: each reconnect starts from a clean SSL/I-O + // surface, mirroring what manual stop+start has been doing in the field. + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + String botId = getConfigString("bot_id"); + String secret = getConfigString("secret"); + connectWebSocket(botId, secret); + } + + /** + * Release every per-connection resource so a fresh HttpClient + WebSocket + * are always built next. Shared by doStop (channel teardown) and + * doReconnect (auto-recovery cycle). + * + *

Why drop {@code httpClient} too: the JDK HttpClient caches SSL + * sessions and keeps an async selector loop. After certain WS error paths + * the SSL session can be poisoned, causing every subsequent handshake to + * be RST'd by the server ("Remote host terminated the handshake"). Dropping + * the client forces a clean rebuild — this is exactly what manual + * "Disable + Enable" did to recover. + */ + private void releaseConnectionResources(String reason) { if (heartbeatFuture != null) { heartbeatFuture.cancel(false); heartbeatFuture = null; } if (webSocket != null) { - try { webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "Reconnecting"); } catch (Exception ignored) {} + try { + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, reason) + .orTimeout(2, TimeUnit.SECONDS) + .exceptionally(ex -> null) + .join(); + } catch (Exception e) { + log.debug("[wecom] release: ws close: {}", e.getMessage()); + } webSocket = null; } if (wsThread != null) { wsThread.interrupt(); - try { wsThread.join(3000); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } + try { + wsThread.join(3000); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } wsThread = null; } - pendingAcks.forEach((k, f) -> f.completeExceptionally(new RuntimeException("Reconnecting"))); + pendingAcks.forEach((k, f) -> + f.completeExceptionally(new RuntimeException("Channel " + reason))); pendingAcks.clear(); replyQueues.clear(); pendingFrames.clear(); replyContexts.clear(); missedPongCount.set(0); - if (this.httpClient == null) { - this.httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build(); - } - - String botId = getConfigString("bot_id"); - String secret = getConfigString("secret"); - connectWebSocket(botId, secret); + this.httpClient = null; } // ==================== WebSocket 连接 ==================== @@ -263,6 +270,9 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { * 在守护线程中建立 WebSocket 连接 */ private void connectWebSocket(String botId, String secret) { + // Fresh attempt — allow new failure signals to register again. + disconnectInflight.set(false); + wsThread = new Thread(() -> { try { log.info("[wecom] WebSocket connecting to {}...", DEFAULT_WS_URL); @@ -279,15 +289,60 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { } catch (Exception e) { log.error("[wecom] WebSocket connection failed: {}", e.getMessage(), e); - if (running.get()) { - onDisconnected("WebSocket connection failed: " + e.getMessage()); - } + handleFailure("WebSocket connection failed: " + e.getMessage()); } }, "wecom-ws-" + channelEntity.getId()); wsThread.setDaemon(true); wsThread.start(); } + /** + * Single-flight failure handler. JDK WebSocket can fire onClose AND + * onError for the same outage, plus connect-exception and heartbeat + * timeout, all wanting to trigger reconnect. Without dedup this fans out + * into 2-4 concurrent reconnect attempts, which collide on shared state + * (httpClient, wsThread) and amplify failure into a storm. + * + *

Only the first signal of a given outage gets through; later signals + * are logged at debug level and dropped. The flag is cleared at the start + * of each new connect attempt and on auth success (markReady). + */ + private void handleFailure(String reason) { + if (!disconnectInflight.compareAndSet(false, true)) { + log.debug("[wecom] handleFailure dedup: {}", reason); + return; + } + if (!running.get()) { + return; + } + onDisconnected(reason); + } + + /** + * Suppressed at the framework's call site. {@link AbstractChannelAdapter} + * invokes this immediately after {@code doReconnect()} returns, but our + * doReconnect only fire-and-forget schedules an async WS connect — the + * connection isn't actually ready yet. Letting the framework reset + * {@code backoff} here causes attempts to stall at #1 forever. + * + *

Real reset happens in {@link #markReady()} when the WeCom + * {@code aibot_subscribe} auth response confirms the session is up. + */ + @Override + protected void onReconnectSuccess() { + // intentionally empty + } + + /** + * Called when WeCom auth_succeed frame is received — the only point at + * which the connection is genuinely usable. Resets backoff and clears + * the failure dedup flag so the next outage (if any) can register. + */ + private void markReady() { + super.onReconnectSuccess(); + disconnectInflight.set(false); + } + /** * WebSocket 监听器:接收消息帧并分发处理 */ @@ -328,18 +383,14 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { @Override public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { log.warn("[wecom] WebSocket closed: code={}, reason={}", statusCode, reason); - if (running.get()) { - onDisconnected("WebSocket closed: code=" + statusCode + ", reason=" + reason); - } + handleFailure("WebSocket closed: code=" + statusCode + ", reason=" + reason); return null; } @Override public void onError(WebSocket webSocket, Throwable error) { log.error("[wecom] WebSocket error: {}", error.getMessage()); - if (running.get()) { - onDisconnected("WebSocket error: " + error.getMessage()); - } + handleFailure("WebSocket error: " + error.getMessage()); } } @@ -389,10 +440,21 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { if (errcode != null && errcode != 0) { log.error("[wecom] Authentication failed: errcode={}, errmsg={}", errcode, frame.get("errmsg")); lastError = "Authentication failed: " + frame.get("errmsg"); + // RFC-080 §8 follow-up: route auth_succeed errcode!=0 through + // the same single-flight failure path the transport-level + // failures use. Without this the WS stays open as a zombie + // (no heartbeat, no reconnect) and connectionState stays + // CONNECTED — the UI then shows "已连接" while no message + // can ever arrive. Letting handleFailure run flips the state + // to RECONNECTING and (after maxAttempts) to ERROR so the + // green dot stops lying. + handleFailure("Authentication failed: errcode=" + errcode + + ", errmsg=" + frame.get("errmsg")); return; } log.info("[wecom] Authentication successful"); missedPongCount.set(0); + markReady(); startHeartbeat(); return; } @@ -452,9 +514,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { heartbeatFuture.cancel(false); heartbeatFuture = null; } - if (running.get()) { - onDisconnected("Heartbeat timeout: " + missedPongCount.get() + " missed pongs"); - } + handleFailure("Heartbeat timeout: " + missedPongCount.get() + " missed pongs"); return; } diff --git a/mateclaw-ui/public/icons/providers/siliconcloud.svg b/mateclaw-ui/public/icons/providers/siliconcloud.svg new file mode 100644 index 00000000..6b5f6d80 --- /dev/null +++ b/mateclaw-ui/public/icons/providers/siliconcloud.svg @@ -0,0 +1 @@ +SiliconCloud \ No newline at end of file diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 872d1be1..bb8e6a9d 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -228,6 +228,10 @@ export const channelApi = { toggle: (id: string | number, enabled: boolean) => http.put(`/channels/${id}/toggle?enabled=${enabled}`), status: () => http.get('/channels/status'), + /** Real-time per-channel health (true transport state, not DB enabled flag). */ + health: (id: string | number) => http.get(`/channels/${id}/health`), + /** Batch health for all channels in current workspace. */ + healthAll: () => http.get('/channels/health'), // 微信 iLink Bot QR 码登录 weixinQrcode: () => http.get('/channels/webhook/weixin/qrcode'), weixinQrcodeStatus: (qrcode: string) => diff --git a/mateclaw-ui/src/components/channels/ChannelEditModal.vue b/mateclaw-ui/src/components/channels/ChannelEditModal.vue index 7935a92a..e8cfd17c 100644 --- a/mateclaw-ui/src/components/channels/ChannelEditModal.vue +++ b/mateclaw-ui/src/components/channels/ChannelEditModal.vue @@ -503,11 +503,13 @@ const weixin = useWeixinQrcodePoll(({ botToken, baseUrl }) => { form.value.name = t('channels.weixin.newAccountName') + ' (' + suffix + ')' } } + form.value.enabled = true }) const wecom = useWecomBotAuth((bot) => { channelConfig.value.bot_id = bot.botid channelConfig.value.secret = bot.secret + form.value.enabled = true }) // Feishu one-click app registration: scan-to-create flow that returns @@ -515,6 +517,10 @@ const wecom = useWecomBotAuth((bot) => { const feishuRegister = useFeishuAppRegister(({ appId, appSecret }) => { channelConfig.value.app_id = appId channelConfig.value.app_secret = appSecret + // Auto-enable on confirmed scan: skipping this trapped users into thinking + // the channel was ready to go after scanning, when in fact the toggle was + // still off and the adapter never started. + form.value.enabled = true }) // DingTalk one-click app registration via Device Flow — same UX shape as @@ -522,6 +528,7 @@ const feishuRegister = useFeishuAppRegister(({ appId, appSecret }) => { const dingtalkRegister = useDingTalkAppRegister(({ clientId, clientSecret }) => { channelConfig.value.client_id = clientId channelConfig.value.client_secret = clientSecret + form.value.enabled = true }) // ========== Field defs (derived) ========== diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index e052af3f..7204bfb0 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -21,6 +21,7 @@ export default { close: 'Close', add: 'Add', search: 'Search', + clear: 'Clear', expandSidebar: 'Expand sidebar', collapseSidebar: 'Collapse sidebar', manageWorkspaces: 'Manage Workspaces', @@ -322,6 +323,21 @@ export default { editTitle: 'Provider Settings', manageTitle: 'Manage Models', addModel: 'Add Model', + // Inline API Key on the provider card — primary entry point for the + // 90% case (paste a key, hit save). Modal stays for advanced fields. + inlineApiKeyTitle: 'API Key', + inlineApiKeyPlaceholder: 'Paste API key and press Enter', + inlineApiKeyMasked: 'Set ••••••••', + inlineApiKeyChange: 'Change', + inlineApiKeySaving: 'Saving…', + inlineApiKeySaved: 'API key saved', + inlineApiKeySaveFailed: 'Failed to save API key', + inlineApiKeyEnvHint: 'Or set {var} in your environment and restart', + // Headline status pill on the card — what the user reads first. + statusNotSet: 'NOT SET', + statusReady: 'READY', + statusError: 'ERROR', + modelCountAvailable: '{count} models available', activeChanged: 'Active model updated', providerSaved: 'Provider settings saved', providerDeleted: 'Provider deleted', @@ -378,6 +394,8 @@ export default { addProviderDrawerTitle: 'Add Provider', addProviderDrawerSubtitle: 'Once enabled the provider appears in the model dropdown. Cloud providers still need an API key after enabling.', catalogEmpty: 'Catalog is empty', + catalogSearchPlaceholder: 'Search providers…', + catalogSearchEmpty: 'No providers match “{query}”', enabledToast: 'Enabled {name}', emptyTitle: 'No providers enabled yet', emptyDesc: 'Enable a built-in from the catalog, or create a custom provider.', @@ -445,6 +463,10 @@ export default { latency: 'Latency {ms}ms', modelOk: 'Model available', modelFail: 'Model unavailable', + probeVerified: 'Verified', + probeVerifiedTitle: 'Verified reachable', + probeFailedBanner: '⚠ {count} discovered model(s) failed the reachability probe and were not listed above:', + probeUnreachable: 'not reachable', }, }, fields: { @@ -1625,6 +1647,7 @@ export default { reconnecting: 'Reconnecting', error: 'Disconnected', disconnected: 'Not Connected', + outOfService: 'Not Started', retryCount: 'Retry #{n}', errorLabel: 'Error', }, diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index f39f0e2b..2b280468 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -21,6 +21,7 @@ export default { close: '关闭', add: '添加', search: '搜索', + clear: '清除', expandSidebar: '展开侧边栏', collapseSidebar: '折叠侧边栏', manageWorkspaces: '管理工作区', @@ -133,7 +134,7 @@ export default { configModelFirst: '请先配置模型', modelUnavailable: '当前模型不可用', noActiveModel: '当前没有激活模型。先到模型管理中选择一个可用模型,再开始对话。', - providerNotReady: '当前激活模型属于 {name},但这个 Provider 还没配置完成。请到模型管理中补全 API Key 或 Base URL。', + providerNotReady: '当前激活模型属于 {name},但这个提供商还没配置完成。请到模型管理中补全 API Key 或 Base URL。', noAvailableModel: '当前没有可用模型。', loadAgentsFailed: '加载 Agent 列表失败', loadModelFailed: '加载模型状态失败', @@ -290,8 +291,8 @@ export default { }, model: { title: '模型管理', - desc: '配置模型 Provider、凭证和模型列表', - addProvider: '新增 Provider', + desc: '配置模型提供商、凭证和模型列表', + addProvider: '新增提供商', localProviders: '本地模型', cloudProviders: '云端模型', active: '当前激活', @@ -308,30 +309,45 @@ export default { apiKeyInput: '输入 API Key', leaveBlankKeep: '留空则保持当前值不变', modelCount: '{count} 个模型', - createTitle: '新增 Provider', - editTitle: 'Provider 配置', + createTitle: '新增提供商', + editTitle: '提供商配置', manageTitle: '管理模型', addModel: '新增模型', + // Inline API Key on the provider card — primary entry point for the + // 90% case (paste a key, hit save). Modal stays for advanced fields. + inlineApiKeyTitle: 'API Key', + inlineApiKeyPlaceholder: '粘贴 API Key 后回车保存', + inlineApiKeyMasked: '已设置 ••••••••', + inlineApiKeyChange: '更换', + inlineApiKeySaving: '保存中…', + inlineApiKeySaved: 'API Key 已保存', + inlineApiKeySaveFailed: 'API Key 保存失败', + inlineApiKeyEnvHint: '或在环境变量中设置 {var} 后重启', + // Headline status pill on the card — what the user reads first. + statusNotSet: '未配置', + statusReady: '已就绪', + statusError: '连接失败', + modelCountAvailable: '可用 {count} 个模型', activeChanged: '激活模型已更新', - providerSaved: 'Provider 配置已保存', - providerDeleted: 'Provider 已删除', + providerSaved: '提供商配置已保存', + providerDeleted: '提供商已删除', modelAdded: '模型已添加', modelRemoved: '模型已移除', modelAddFailed: '模型添加失败', modelRemoveFailed: '模型删除失败', activeChangeFailed: '激活模型切换失败', - deleteConfirm: '确认删除 Provider “{name}”?', + deleteConfirm: '确认删除提供商“{name}”?', removeConfirm: '确认移除模型 “{name}”?', generateConfigInvalidJson: 'Generate Kwargs 不是合法 JSON', generateConfigMustBeObject: 'Generate Kwargs 必须是 JSON 对象', advancedSettings: '高级设置', - protocolHint: '这里选择的是 Provider API 协议,不是前端或后端里的具体类名。', + protocolHint: '这里选择的是提供商 API 协议,不是前端或后端里的具体类名。', protocolOpenAI: 'OpenAI 兼容(Chat Completions)', protocolAnthropic: 'Anthropic(Messages API)', protocolGemini: 'Gemini 原生', protocolDashScope: 'DashScope 原生', advancedHint: '用于补充 temperature、max_tokens、top_p 等生成参数。', - fallbackPriorityHint: '池内尝试顺序(数字越小越先):0 = 不参与;1 = 第一顺位;2 = 第二顺位,依此类推。多个 provider 共用同一数字时按 ID 字典序。', + fallbackPriorityHint: '池内尝试顺序(数字越小越先):0 = 不参与;1 = 第一顺位;2 = 第二顺位,依此类推。多个提供商共用同一数字时按 ID 字典序。', fallbackBadge: '偏好 #{priority}', fallbackBadgeTitle: '可用池内的尝试顺序,数字越小越先尝试', // RFC-009 Phase 4: provider pool status badges @@ -358,19 +374,21 @@ export default { livenessUnprobedTooltip: '启动后正在检测可用性,稍候自动更新', livenessUnconfigured: '需要配置', // RFC-074 PR-2: enable / disable / drawer - enableProviderCta: '+ 启用 Provider', - addCustomProvider: '+ 自定义', + enableProviderCta: '+ 启用提供商', + addCustomProvider: '+ 自定义提供商', enable: '启用', disable: '禁用', alreadyEnabled: '已启用', - disableConfirm: '确定禁用 Provider "{name}"?它将从下拉中消失,但配置保留,可随时再启用。', + disableConfirm: '确定禁用提供商"{name}"?它将从下拉中消失,但配置保留,可随时再启用。', defaultSwitchedToast: '已将默认模型切换到 {provider} / {model}', - addProviderDrawerTitle: '添加 Provider', - addProviderDrawerSubtitle: '启用后会出现在模型下拉中。如果是云端 Provider,启用后还需要填 API Key。', + addProviderDrawerTitle: '添加提供商', + addProviderDrawerSubtitle: '启用后会出现在模型下拉中。如果是云端提供商,启用后还需要填 API Key。', catalogEmpty: '目录为空', + catalogSearchPlaceholder: '搜索提供商…', + catalogSearchEmpty: '没有匹配“{query}”的提供商', enabledToast: '已启用 {name}', - emptyTitle: '还没有可用的 Provider', - emptyDesc: '从内置目录中启用一个,或新建一个自定义 Provider。', + emptyTitle: '还没有可用的提供商', + emptyDesc: '从内置目录中启用一个,或新建一个自定义提供商。', searchHint: '开启后,大模型将在回答时自动调用内置搜索引擎获取实时信息(DashScope/Kimi/OpenAI 支持)。', searchStrategyDefault: '默认', oauthTitle: 'OpenAI OAuth 登录', @@ -387,8 +405,8 @@ export default { claudeCodeOauthInstructions: '未检测到 Claude Code 凭据。请安装 Claude Code 客户端,使用 Pro/Max 账号登录后再点击检测。', claudeCodeOauthRevokeHint: '请在 Claude Code 客户端中退出登录。MateClaw 不会修改 Claude Code 的本地凭据。', fields: { - providerId: 'Provider ID', - providerName: 'Provider 名称', + providerId: '提供商 ID', + providerName: '提供商名称', defaultBaseUrl: '默认 Base URL', apiKeyPrefix: 'API Key 前缀', protocol: '协议', @@ -435,11 +453,15 @@ export default { latency: '延迟 {ms}ms', modelOk: '模型可用', modelFail: '模型不可用', + probeVerified: '已验证', + probeVerifiedTitle: '已验证可达', + probeFailedBanner: '⚠ {count} 个模型未通过可达性检测,未列入上方列表:', + probeUnreachable: '无法连接', }, }, fields: { name: '显示名称', - provider: 'Provider', + provider: '提供商', modelName: '模型标识', description: '描述', temperature: '温度', @@ -459,27 +481,27 @@ export default { searxngBaseUrl: 'SearXNG 地址', // STT 语音识别 sttEnabled: '启用语音识别', - sttProvider: '首选 STT Provider', - sttFallbackEnabled: 'Provider 回退', + sttProvider: '首选 STT 提供商', + sttFallbackEnabled: '提供商回退', // 音乐生成 musicEnabled: '启用音乐生成', - musicProvider: '首选音乐 Provider', - musicFallbackEnabled: 'Provider 回退', + musicProvider: '首选音乐提供商', + musicFallbackEnabled: '提供商回退', // TTS 语音合成 ttsEnabled: '启用语音合成', - ttsProvider: '首选 TTS Provider', - ttsFallbackEnabled: 'Provider 回退', + ttsProvider: '首选 TTS 提供商', + ttsFallbackEnabled: '提供商回退', ttsAutoMode: '自动朗读模式', ttsSpeed: '语速', // 图片生成 imageEnabled: '启用图片生成', - imageProvider: '首选图片 Provider', - imageFallbackEnabled: 'Provider 回退', + imageProvider: '首选图片提供商', + imageFallbackEnabled: '提供商回退', openaiStatus: 'OpenAI 状态', // 视频生成 videoEnabled: '启用视频生成', - videoProvider: '首选视频 Provider', - videoFallbackEnabled: 'Provider 回退', + videoProvider: '首选视频提供商', + videoFallbackEnabled: '提供商回退', dashscopeStatus: 'DashScope 状态', zhipuApiKey: '智谱 API Key', zhipuBaseUrl: '智谱 API 地址', @@ -506,29 +528,29 @@ export default { searxngBaseUrl: '自部署 SearXNG 实例地址。Docker 部署时自动配置。', // STT 语音识别 sttEnabled: '开启后支持语音消息转文字。OpenAI Whisper 和 DashScope Paraformer 均复用已有 Key。', - sttProvider: '选择首选 STT Provider,auto 模式自动选择可用的 Provider。', - sttFallbackEnabled: '首选 Provider 失败时自动尝试其他已配置的 Provider。', + sttProvider: '选择首选 STT 提供商,auto 模式自动选择可用的提供商。', + sttFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。', openaiSttInfo: '复用模型管理中的 OpenAI API Key。使用 Whisper 模型,支持多语言自动识别。', dashscopeSttInfo: '复用模型管理中的 DashScope API Key。使用 Paraformer Realtime(WebSocket 流式),中文识别效果优秀,亚秒级延迟。', // 音乐生成 musicEnabled: '开启后 Agent 可通过 music_generate 工具生成音乐。Google Lyria 复用 Google Key。', - musicProvider: '选择首选音乐 Provider,auto 模式优先使用 Google Lyria。', - musicFallbackEnabled: '首选 Provider 失败时自动尝试其他已配置的 Provider。', + musicProvider: '选择首选音乐提供商,auto 模式优先使用 Google Lyria。', + musicFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。', googleLyriaInfo: '复用模型管理中的 Google API Key。Lyria 3 模型,支持歌词谱曲和纯音乐生成。', minimaxMusicInfo: '复用视频生成中的 MiniMax API Key。music-2.5+ 模型,支持歌词和纯音乐。', // TTS 语音合成 ttsEnabled: '开启后可通过消息朗读按钮或自动模式使用语音合成。Edge TTS 免费无需 Key。', - ttsProvider: '选择首选 TTS Provider,auto 模式优先使用免费的 Edge TTS。', - ttsFallbackEnabled: '首选 Provider 失败时自动尝试其他已配置的 Provider。', + ttsProvider: '选择首选 TTS 提供商,auto 模式优先使用免费的 Edge TTS。', + ttsFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。', ttsAutoMode: 'off = 仅手动朗读,always = 每条 AI 回复自动播放语音。', ttsSpeed: '语音播放速度,1.0 为正常速度。', edgeTtsInfo: '微软 Edge 内置 TTS 服务,免费使用,支持中文、英文、日文等多语言。自动根据文本语言切换语音。', dashscopeTtsInfo: '复用模型管理中的 DashScope API Key。使用 CosyVoice 模型,支持多种中文语音。', openaiTtsInfo: '复用模型管理中的 OpenAI API Key。支持 alloy、echo、nova 等多种语音风格。', // 图片生成 - imageEnabled: '开启后 Agent 可使用图片生成工具。需至少配置一个图片 Provider 的 API Key。', - imageProvider: '选择首选图片生成 Provider,auto 模式自动选择第一个可用的。', - imageFallbackEnabled: '首选 Provider 失败时自动尝试其他已配置的 Provider。', + imageEnabled: '开启后 Agent 可使用图片生成工具。需至少配置一个图片提供商的 API Key。', + imageProvider: '选择首选图片生成提供商,auto 模式自动选择第一个可用的。', + imageFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。', dashscopeImageStatus: '复用模型管理中配置的 DashScope API Key,无需额外配置。', openaiImageStatus: '复用模型管理中配置的 OpenAI API Key,无需额外配置。', zhipuImageApiKey: '从 bigmodel.cn 获取。CogView-3-Flash 模型免费。与视频生成共用同一 Key。', @@ -536,9 +558,9 @@ export default { googleImagenInfo: '复用模型管理中的 Google API Key。支持 Gemini 图片生成和 Imagen 4.0 模型。', minimaxImageInfo: '复用视频生成中的 MiniMax API Key。image-01 模型,支持多种画面比例,最多 9 张。', // 视频生成 - videoEnabled: '开启后 Agent 可使用视频生成工具。需至少配置一个视频 Provider 的 API Key。', - videoProvider: '选择首选视频生成 Provider,auto 模式自动选择第一个可用的。', - videoFallbackEnabled: '首选 Provider 失败时自动尝试其他已配置的 Provider。', + videoEnabled: '开启后 Agent 可使用视频生成工具。需至少配置一个视频提供商的 API Key。', + videoProvider: '选择首选视频生成提供商,auto 模式自动选择第一个可用的。', + videoFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。', dashscopeVideoStatus: '复用模型管理中配置的 DashScope API Key,无需额外配置。', zhipuApiKey: '从 bigmodel.cn 获取,CogVideoX-Flash 模型免费。', zhipuBaseUrl: '通常无需修改,除非使用自定义代理地址。', @@ -666,7 +688,7 @@ export default { basic: '基本信息', skills: '技能', tools: '工具', - providers: '偏好 Provider', + providers: '偏好提供商', context: '上下文', }, columns: { @@ -739,8 +761,8 @@ export default { binding: { skillsHint: '选择此智能体可使用的技能。留空则使用所有已启用的技能。', toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。', - providersHint: '此智能体优先使用的 Provider 顺序(数字越小越先尝试)。留空则按全局可用池顺序回退。Provider 进入冷却或被移出池时仍会被自动跳过。', - providersAddHint: '点击下方 Provider 加入偏好列表:', + providersHint: '此智能体优先使用的提供商顺序(数字越小越先尝试)。留空则按全局可用池顺序回退。提供商进入冷却或被移出池时仍会被自动跳过。', + providersAddHint: '点击下方提供商加入偏好列表:', noSkills: '暂无可用技能', noTools: '暂无可用工具', noProviderPreferences: '尚未配置偏好顺序,将按全局回退链顺序使用。', @@ -1007,7 +1029,7 @@ export default { byModel: '按模型统计', byDate: '按日期统计', model: '模型', - provider: 'Provider', + provider: '提供商', date: '日期', messageCount: '消息数', loadFailed: '加载 Token 统计失败', @@ -1167,7 +1189,7 @@ export default { status: '状态', tools: '工具', channels: '渠道', - provider: 'Provider', + provider: '提供商', memoryProvider: '记忆', noDescription: '暂无描述', emptyTitle: '暂无已安装插件', @@ -1635,6 +1657,7 @@ export default { reconnecting: '重连中', error: '断开', disconnected: '未连接', + outOfService: '未启动', retryCount: '重试 #{n}', errorLabel: '错误', }, diff --git a/mateclaw-ui/src/views/Channels.vue b/mateclaw-ui/src/views/Channels.vue index 2810fbd2..e6628d16 100644 --- a/mateclaw-ui/src/views/Channels.vue +++ b/mateclaw-ui/src/views/Channels.vue @@ -196,19 +196,32 @@ async function loadChannels() { async function loadStatus() { try { - const res: any = await channelApi.status() - const statusData = res.data - if (statusData && Array.isArray(statusData.channels)) { - const map: Record = {} - for (const ch of statusData.channels) { - map[ch.id] = { - connectionState: ch.connectionState || 'DISCONNECTED', - lastError: ch.lastError || null, - reconnectAttempts: ch.reconnectAttempts || 0, - } + // Prefer the new typed health endpoint — it surfaces OUT_OF_SERVICE + // (enabled in DB but adapter not active, e.g. start failed silently) + // which the legacy /status endpoint conflated with DISCONNECTED. The + // older shape is still returned by the fallback below in case the + // backend is mid-rollout. + const res: any = await channelApi.healthAll() + const list: any[] = res.data || [] + const map: Record = {} + for (const h of list) { + const status: string = h.status || 'UNKNOWN' + // Translate the typed health status onto the existing connection + // state vocabulary the UI helpers were built against, so the rest + // of the page (icons, tooltips, css classes) keeps working unchanged. + const connectionState = + status === 'UP' ? 'CONNECTED' + : status === 'RECONNECTING' ? 'RECONNECTING' + : status === 'DOWN' ? 'ERROR' + : status === 'OUT_OF_SERVICE' ? 'OUT_OF_SERVICE' + : 'DISCONNECTED' + map[h.channelId] = { + connectionState, + lastError: h.detail || null, + reconnectAttempts: 0, } - channelStatusMap.value = map } + channelStatusMap.value = map } catch { // silent — next poll will retry } @@ -225,6 +238,7 @@ function getConnectionIcon(channel: Channel): string { case 'CONNECTED': return '🟢' case 'RECONNECTING': return '🟡' case 'ERROR': return '🔴' + case 'OUT_OF_SERVICE': return '🟠' default: return '⚪' } } @@ -234,6 +248,7 @@ function getConnectionLabel(channel: Channel): string { case 'CONNECTED': return t('channels.connection.connected') case 'RECONNECTING': return t('channels.connection.reconnecting') case 'ERROR': return t('channels.connection.error') + case 'OUT_OF_SERVICE': return t('channels.connection.outOfService') case 'DISCONNECTED': return t('channels.connection.disconnected') default: return '' } @@ -244,6 +259,7 @@ function getConnectionClass(channel: Channel): string { case 'CONNECTED': return 'conn-connected' case 'RECONNECTING': return 'conn-reconnecting' case 'ERROR': return 'conn-error' + case 'OUT_OF_SERVICE': return 'conn-out-of-service' default: return 'conn-disconnected' } } @@ -367,6 +383,7 @@ function getChannelIconPath(type: string) { .conn-connected { color: var(--mc-primary); background: var(--mc-primary-bg); } .conn-reconnecting { color: var(--mc-primary-hover); background: var(--mc-primary-bg); animation: pulse-reconnecting 1.5s ease-in-out infinite; } .conn-error { color: var(--mc-danger); background: var(--mc-danger-bg); } +.conn-out-of-service { color: var(--mc-warning, #f59e0b); background: var(--mc-warning-bg, rgba(245, 158, 11, 0.1)); } .conn-disconnected { color: var(--mc-text-tertiary); background: var(--mc-bg-sunken); } @keyframes pulse-reconnecting { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } diff --git a/mateclaw-ui/src/views/Settings/Models/AddProviderDrawer.vue b/mateclaw-ui/src/views/Settings/Models/AddProviderDrawer.vue index 8a5cdd74..cb457bb3 100644 --- a/mateclaw-ui/src/views/Settings/Models/AddProviderDrawer.vue +++ b/mateclaw-ui/src/views/Settings/Models/AddProviderDrawer.vue @@ -15,51 +15,89 @@ + +

+

{{ t('settings.model.cloudProviders') }}

-
- -
-
{{ p.name }}
-
{{ p.id }}
-
- - {{ t('settings.model.alreadyEnabled') }} +
+

{{ t('settings.model.localProviders') }}

-
- -
-
{{ p.name }}
-
{{ p.id }}
-
- - {{ t('settings.model.alreadyEnabled') }} +
+
+
- {{ t('settings.model.catalogEmpty') }} + +
@@ -69,10 +107,11 @@ diff --git a/mateclaw-ui/src/views/Settings/Models/ProviderCatalogRow.vue b/mateclaw-ui/src/views/Settings/Models/ProviderCatalogRow.vue new file mode 100644 index 00000000..90cb673f --- /dev/null +++ b/mateclaw-ui/src/views/Settings/Models/ProviderCatalogRow.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/mateclaw-ui/src/views/Settings/Models/composables/useProviderForm.ts b/mateclaw-ui/src/views/Settings/Models/composables/useProviderForm.ts index c3f0c242..0e3b0f77 100644 --- a/mateclaw-ui/src/views/Settings/Models/composables/useProviderForm.ts +++ b/mateclaw-ui/src/views/Settings/Models/composables/useProviderForm.ts @@ -1,5 +1,6 @@ import { computed, reactive, ref } from 'vue' import { useI18n } from 'vue-i18n' +import { ElMessageBox } from 'element-plus' import { modelApi } from '@/api' import type { ProviderInfo } from '@/types' import { safeParseJson } from '@/utils/safeJson' @@ -184,8 +185,40 @@ export function useProviderForm(deps: ListDeps) { await Promise.all([deps.loadProviders(), deps.loadActiveModel()]) } + /** + * Inline API-key save from the provider card — bypasses the modal so the + * 90% case (paste a key, hit save) doesn't require opening a settings dialog. + * + * Backend updateProviderConfig is a PUT that overwrites baseUrl / chatModel / + * generateKwargs unconditionally, so we must echo the existing values to + * avoid clobbering them when we only want to change the key. + */ + async function saveProviderApiKey(provider: ProviderInfo, apiKey: string) { + const trimmed = apiKey.trim() + if (!trimmed) return + await modelApi.updateProviderConfig(provider.id, { + apiKey: trimmed, + baseUrl: provider.baseUrl ?? '', + protocol: provider.protocol || chatModelToProtocol(provider.chatModel), + chatModel: provider.chatModel, + generateKwargs: provider.generateKwargs ?? {}, + // Omit fallbackPriority — backend treats null as "leave untouched". + }) + await Promise.all([deps.loadProviders(), deps.loadActiveModel()]) + } + async function deleteProvider(provider: ProviderInfo) { - if (!confirm(t('settings.model.deleteConfirm', { name: provider.name }))) { + try { + await ElMessageBox.confirm( + t('settings.model.deleteConfirm', { name: provider.name }), + t('common.confirm'), + { + type: 'warning', + confirmButtonText: t('common.delete'), + cancelButtonText: t('common.cancel'), + }, + ) + } catch { return false } await modelApi.deleteCustomProvider(provider.id) @@ -206,6 +239,7 @@ export function useProviderForm(deps: ListDeps) { openProviderConfigModal, closeProviderModal, saveProvider, + saveProviderApiKey, deleteProvider, } } diff --git a/mateclaw-ui/src/views/Settings/Models/composables/useProviderList.ts b/mateclaw-ui/src/views/Settings/Models/composables/useProviderList.ts index 7b2374b9..6c356448 100644 --- a/mateclaw-ui/src/views/Settings/Models/composables/useProviderList.ts +++ b/mateclaw-ui/src/views/Settings/Models/composables/useProviderList.ts @@ -74,6 +74,8 @@ export function useProviderList() { 'dashscope': '/icons/providers/dashscope.png', 'modelscope': '/icons/providers/modelscope.svg', 'aliyun-codingplan': '/icons/providers/aliyun-codingplan.svg', + // bailian-team is an Aliyun product line — reuse the aliyun mark. + 'bailian-team': '/icons/providers/aliyun-codingplan.svg', 'openai': '/icons/providers/openai.svg', 'azure-openai': '/icons/providers/azure-openai.svg', 'minimax': '/icons/providers/minimax.png', @@ -92,6 +94,10 @@ export function useProviderList() { 'zhipu-cn': '/icons/providers/zhipu.svg', 'zhipu-intl': '/icons/providers/zhipu.svg', 'volcengine': '/icons/providers/volcengine.svg', + // volcengine-plan = "Volcano Engine Coding Plan" — same brand, reuse mark. + 'volcengine-plan': '/icons/providers/volcengine.svg', + 'siliconflow-cn': '/icons/providers/siliconcloud.svg', + 'siliconflow-intl': '/icons/providers/siliconcloud.svg', 'openai-chatgpt': '/icons/providers/openai.svg', 'anthropic-claude-code': '/icons/providers/anthropic.svg', } diff --git a/mateclaw-ui/src/views/Settings/Models/index.vue b/mateclaw-ui/src/views/Settings/Models/index.vue index dc0bf478..04680f88 100644 --- a/mateclaw-ui/src/views/Settings/Models/index.vue +++ b/mateclaw-ui/src/views/Settings/Models/index.vue @@ -46,12 +46,18 @@ {{ t('settings.model.localProviders') }}
-
+
@@ -76,12 +84,18 @@ {{ t('settings.model.cloudProviders') }}
-
+
@@ -162,7 +178,7 @@