sync: settings UI polish, channel reliability fixes, DeepSeek cross-turn fix

- Settings → Models: inline API key, frosted drawer, dark-mode polish, provider icons, i18n sweep
- WeChat Work channel: rebuild HttpClient on reconnect, dedup failure signals, route auth_succeed errcode!=0 through failure handler
- Channel framework: per-adapter error isolation, QR auth SPI, health indicators
- Agent: patch cross-turn assistants for DeepSeek thinking-mode
- GitHub: bilingual issue templates with required fields
This commit is contained in:
matevip 2026-04-29 11:22:47 +08:00
parent 709a0db200
commit e759ad4a1b
35 changed files with 2401 additions and 436 deletions

74
.github/ISSUE_TEMPLATE/bug-en.yml vendored Normal file
View File

@ -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

74
.github/ISSUE_TEMPLATE/bug-zh.yml vendored Normal file
View File

@ -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

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -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.

41
.github/ISSUE_TEMPLATE/feature-en.yml vendored Normal file
View File

@ -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

41
.github/ISSUE_TEMPLATE/feature-zh.yml vendored Normal file
View File

@ -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

View File

@ -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) {

View File

@ -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刷新"活跃时间"的标准入口
*

View File

@ -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.
*
* <p>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.
*
* <p>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);
}
}

View File

@ -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.
*
* <p>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.
*
* <p>The fix has two layers:
* <ol>
* <li>This classifier flips the persisted status to {@code 'error'} so
* {@code BaseAgent.sanitizeForLlm} filters it from history.</li>
* <li>The "[错误] " content prefix kept on disk is the legacy backup
* filter both work together.</li>
* </ol>
*
* <p>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";
}
}

View File

@ -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);

View File

@ -130,6 +130,55 @@ public class ChannelController {
return R.ok(channelManager.getStatus());
}
@RequireWorkspaceRole("viewer")
@Operation(summary = "获取指定渠道的实时健康状态(真连接状态,前端绿点应该绑这个)")
@GetMapping("/{id}/health")
public R<Map<String, Object>> 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<String, Object> 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<List<Map<String, Object>>> 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<String, Object> body = channelManager.getAdapter(c.getId())
.map(a -> a.health().toMap())
.orElseGet(() -> {
Map<String, Object> 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)) {

View File

@ -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<EncodeHintType, Object> 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 = "获取微信登录二维码")

View File

@ -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<MessageContent>) -> 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<com.dingtalk.open.app.api.models.bot.MessageContent> 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<String, Object> 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<Map<String, Object>> items = new ArrayList<>();
if (richTextItems != null) {
for (com.dingtalk.open.app.api.models.bot.MessageContent item : richTextItems) {
if (item == null) continue;
Map<String, Object> 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);
}

View File

@ -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.
*
* <p>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.
*
* <p>Status semantics:
* <ul>
* <li>{@code UP} adapter started AND underlying transport
* is reachable (e.g. DingTalk Stream WebSocket connected, last
* activity within staleness threshold).</li>
* <li>{@code RECONNECTING} adapter is currently retrying after a
* transient failure; will return to UP if it recovers.</li>
* <li>{@code DOWN} adapter started but the transport is
* unhealthy / authentication failed / max retries exhausted.</li>
* <li>{@code OUT_OF_SERVICE} adapter is not started (channel disabled
* or stopped administratively).</li>
* <li>{@code UNKNOWN} adapter starting up; transport state not
* yet determined.</li>
* </ul>
*
* <p>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<String, Object> 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<String, Object> toMap() {
Map<String, Object> 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;
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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<String, Object> 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 <type>:<id>} fallback to type only. */
private String safeKey(ChannelAdapter adapter, ChannelHealth h) {
if (h.getChannelId() != null) {
return adapter.getChannelType() + ":" + h.getChannelId();
}
return adapter.getChannelType();
}
}

View File

@ -0,0 +1,45 @@
package vip.mate.channel.qrcode;
import java.util.Map;
/**
* SPI for channels that support QR-code-based bot/app registration.
*
* <p>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.
*
* <p>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.
*
* <p>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<String, Object> begin(Map<String, String> 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<String, Object> pollStatus(String sessionId);
}

View File

@ -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.
*
* <p>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<String, ChannelQRCodeAuthProvider> byType = new HashMap<>();
public ChannelQRCodeAuthRegistry(List<ChannelQRCodeAuthProvider> 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<ChannelQRCodeAuthProvider> lookup(String channelType) {
return Optional.ofNullable(byType.get(channelType));
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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<Map<String, Object>> begin(
@PathVariable String channelType,
@RequestParam(required = false) Map<String, String> 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.<String, Object>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<Map<String, Object>> 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)));
}
}

View File

@ -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.
*
* <p>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<String, Object> begin(Map<String, String> params) throws Exception {
DingTalkAppRegistrationService.RegistrationSession session = service.begin();
return Map.of("session_id", session.sessionId);
}
@Override
public Map<String, Object> pollStatus(String sessionId) {
DingTalkAppRegistrationService.RegistrationSession session = service.getSession(sessionId);
if (session == null) {
return Map.of("status", "expired", "error", "session not found or expired");
}
Map<String, Object> 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;
}
}

View File

@ -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.
*
* <p>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<String, Object> begin(Map<String, String> 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<String, Object> pollStatus(String sessionId) {
FeishuAppRegistrationService.RegistrationSession session = service.getSession(sessionId);
if (session == null) {
return Map.of("status", "expired", "error", "session not found or expired");
}
Map<String, Object> 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;
}
}

View File

@ -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.
*
* <p>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<EncodeHintType, Object> 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);
}
}

View File

@ -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).
*
* <p>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.
*
* <p>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.
*
* <p>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;
}

View File

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>SiliconCloud</title><path clip-rule="evenodd" d="M22.956 6.521H12.522c-.577 0-1.044.468-1.044 1.044v3.13c0 .577-.466 1.044-1.043 1.044H1.044c-.577 0-1.044.467-1.044 1.044v4.174C0 17.533.467 18 1.044 18h10.434c.577 0 1.044-.467 1.044-1.043v-3.13c0-.578.466-1.044 1.043-1.044h9.391c.577 0 1.044-.467 1.044-1.044V7.565c0-.576-.467-1.044-1.044-1.044z" fill="#6E29F6" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 520 B

View File

@ -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) =>

View File

@ -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) ==========

View File

@ -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',
},

View File

@ -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: 'AnthropicMessages 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 Providerauto 模式自动选择可用的 Provider。',
sttFallbackEnabled: '首选 Provider 失败时自动尝试其他已配置的 Provider。',
sttProvider: '选择首选 STT 提供商auto 模式自动选择可用的提供商。',
sttFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。',
openaiSttInfo: '复用模型管理中的 OpenAI API Key。使用 Whisper 模型,支持多语言自动识别。',
dashscopeSttInfo: '复用模型管理中的 DashScope API Key。使用 Paraformer RealtimeWebSocket 流式),中文识别效果优秀,亚秒级延迟。',
// 音乐生成
musicEnabled: '开启后 Agent 可通过 music_generate 工具生成音乐。Google Lyria 复用 Google Key。',
musicProvider: '选择首选音乐 Providerauto 模式优先使用 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 Providerauto 模式优先使用免费的 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: '选择首选图片生成 Providerauto 模式自动选择第一个可用的。',
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: '选择首选视频生成 Providerauto 模式自动选择第一个可用的。',
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: '错误',
},

View File

@ -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<number, any> = {}
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<number, any> = {}
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; } }

View File

@ -15,51 +15,89 @@
</button>
</div>
<!-- Spotlight-style search: filters by name or id, both matter
(Chinese name search vs. slug like "siliconflow"). -->
<div class="drawer-search">
<svg
class="drawer-search__icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
class="drawer-search__input"
:placeholder="t('settings.model.catalogSearchPlaceholder')"
spellcheck="false"
autocomplete="off"
@keydown.escape.stop="onEscape"
/>
<button
v-if="searchQuery"
class="drawer-search__clear"
:title="t('common.clear')"
type="button"
@click="clearSearch"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div class="drawer-content">
<!-- Cloud providers -->
<section v-if="cloudGroup.length" class="drawer-group">
<h4 class="drawer-group-title">{{ t('settings.model.cloudProviders') }}</h4>
<div class="provider-row" v-for="p in cloudGroup" :key="p.id">
<img class="row-icon" :src="getProviderIcon(p.id)" :alt="p.name" @error="onIconError" />
<div class="row-meta">
<div class="row-name">{{ p.name }}</div>
<div class="row-id">{{ p.id }}</div>
</div>
<button
v-if="!p.enabled"
class="row-cta"
:disabled="togglingId === p.id"
@click="onEnable(p)"
>
{{ togglingId === p.id ? t('common.loading') : t('settings.model.enable') }}
</button>
<span v-else class="row-enabled-badge">{{ t('settings.model.alreadyEnabled') }}</span>
<div class="drawer-group__list">
<ProviderCatalogRow
v-for="p in cloudGroup"
:key="p.id"
:provider="p"
:toggling-id="togglingId"
:get-provider-icon="getProviderIcon"
:on-icon-error="onIconError"
@enable="onEnable"
/>
</div>
</section>
<!-- Local providers -->
<section v-if="localGroup.length" class="drawer-group">
<h4 class="drawer-group-title">{{ t('settings.model.localProviders') }}</h4>
<div class="provider-row" v-for="p in localGroup" :key="p.id">
<img class="row-icon" :src="getProviderIcon(p.id)" :alt="p.name" @error="onIconError" />
<div class="row-meta">
<div class="row-name">{{ p.name }}</div>
<div class="row-id">{{ p.id }}</div>
</div>
<button
v-if="!p.enabled"
class="row-cta"
:disabled="togglingId === p.id"
@click="onEnable(p)"
>
{{ togglingId === p.id ? t('common.loading') : t('settings.model.enable') }}
</button>
<span v-else class="row-enabled-badge">{{ t('settings.model.alreadyEnabled') }}</span>
<div class="drawer-group__list">
<ProviderCatalogRow
v-for="p in localGroup"
:key="p.id"
:provider="p"
:toggling-id="togglingId"
:get-provider-icon="getProviderIcon"
:on-icon-error="onIconError"
@enable="onEnable"
/>
</div>
</section>
<!-- Empty states: search-aware. A live query that yields nothing
is a different experience from a wholly empty catalog. -->
<div v-if="!cloudGroup.length && !localGroup.length" class="drawer-empty">
{{ t('settings.model.catalogEmpty') }}
<template v-if="searchQuery">
{{ t('settings.model.catalogSearchEmpty', { query: searchQuery }) }}
</template>
<template v-else>
{{ t('settings.model.catalogEmpty') }}
</template>
</div>
</div>
</div>
@ -69,10 +107,11 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import type { ProviderInfo } from '@/types'
import ProviderCatalogRow from './ProviderCatalogRow.vue'
const props = defineProps<{
visible: boolean
@ -83,25 +122,63 @@ const props = defineProps<{
enableProvider: (id: string) => Promise<unknown>
}>()
const emit = defineEmits<{
defineEmits<{
close: []
}>()
const { t } = useI18n()
const searchQuery = ref('')
const searchInputRef = ref<HTMLInputElement | null>(null)
// Match against name OR id so users can search either by Chinese display
// name (e.g. "") or by the slug they remember from docs (e.g.
// "siliconflow").
const filteredCatalog = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return props.catalog
return props.catalog.filter(p =>
p.name.toLowerCase().includes(q) || p.id.toLowerCase().includes(q)
)
})
// Sort: enabled rows sink to the bottom of each group so the actionable
// (still-disabled) options sit at the top where the user lands.
const cloudGroup = computed(() =>
props.catalog
filteredCatalog.value
.filter(p => !p.isLocal)
.sort((a, b) => Number(!!a.enabled) - Number(!!b.enabled) || a.name.localeCompare(b.name))
)
const localGroup = computed(() =>
props.catalog
filteredCatalog.value
.filter(p => p.isLocal)
.sort((a, b) => Number(!!a.enabled) - Number(!!b.enabled) || a.name.localeCompare(b.name))
)
// Spotlight behavior: focus on open, clear on close.
watch(() => props.visible, async (isVisible) => {
if (isVisible) {
await nextTick()
searchInputRef.value?.focus()
} else {
searchQuery.value = ''
}
})
function clearSearch() {
searchQuery.value = ''
searchInputRef.value?.focus()
}
// First Esc clears a non-empty query; second Esc closes the drawer (handled
// upstream). Stops propagation only when there's something to clear.
function onEscape(event: KeyboardEvent) {
if (searchQuery.value) {
event.preventDefault()
clearSearch()
}
}
async function onEnable(p: ProviderInfo) {
await props.enableProvider(p.id)
ElMessage.success(t('settings.model.enabledToast', { name: p.name }))
@ -109,126 +186,243 @@ async function onEnable(p: ProviderInfo) {
</script>
<style scoped>
/*
* Frosted-glass drawer depth via translucency rather than borders.
* Backdrop blur on the overlay anchors the panel to the page behind it;
* panel blur lets the page tone bleed through so it doesn't look pasted on.
*/
.drawer-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.3);
background: rgba(20, 14, 10, 0.32);
backdrop-filter: blur(8px) saturate(140%);
-webkit-backdrop-filter: blur(8px) saturate(140%);
z-index: 1500;
display: flex;
justify-content: flex-end;
}
:global(html.dark .drawer-overlay) {
/* Darker scrim in dark mode so the panel reads as a distinct surface
against the already-dark page behind it. */
background: rgba(0, 0, 0, 0.5);
}
.drawer-panel {
width: 460px;
max-width: 92vw;
height: 100%;
background: var(--mc-bg-elevated);
border-left: 1px solid var(--mc-border);
background: rgba(255, 250, 245, 0.72);
backdrop-filter: blur(48px) saturate(180%);
-webkit-backdrop-filter: blur(48px) saturate(180%);
border-left: 1px solid rgba(255, 255, 255, 0.4);
box-shadow: -24px 0 60px rgba(25, 14, 8, 0.16);
display: flex;
flex-direction: column;
animation: drawer-slide 0.22s ease;
/* iOS-style spring easing — gives the slide a natural settle. */
animation: drawer-slide 0.36s cubic-bezier(0.32, 0.72, 0, 1);
}
:global(html.dark .drawer-panel) {
/* Higher opacity (0.78 vs 0.62) at high blur the dark page bleed
made the panel look washed-out grey at 0.62. */
background: rgba(32, 26, 22, 0.78);
border-left-color: rgba(255, 255, 255, 0.10);
box-shadow: -24px 0 60px rgba(0, 0, 0, 0.5);
}
@keyframes drawer-slide {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 24px;
border-bottom: 1px solid var(--mc-border-light);
padding: 24px 28px 20px;
border-bottom: 1px solid rgba(123, 88, 67, 0.10);
}
:global(html.dark .drawer-header) {
border-bottom-color: rgba(255, 255, 255, 0.06);
}
.drawer-title {
margin: 0 0 4px;
font-size: 17px;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mc-text-primary);
}
.drawer-subtitle {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--mc-text-tertiary);
}
.drawer-close {
background: transparent;
border: 0;
padding: 6px;
border-radius: 8px;
padding: 8px;
border-radius: 999px;
cursor: pointer;
color: var(--mc-text-secondary);
color: var(--mc-text-tertiary);
flex-shrink: 0;
transition: background 0.15s ease, color 0.15s ease;
}
.drawer-close:hover { background: var(--mc-bg-sunken); }
.drawer-close:hover {
background: rgba(123, 88, 67, 0.08);
color: var(--mc-text-primary);
}
:global(html.dark .drawer-close:hover) {
background: rgba(255, 255, 255, 0.08);
color: var(--mc-text-primary);
}
/*
* Search bar Spotlight-inspired pill, frosted to match the panel.
* Sits between header and scrollable content so it stays visible while
* the catalog scrolls underneath.
*/
.drawer-search {
margin: 14px 20px 4px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-radius: 999px;
background: rgba(123, 88, 67, 0.06);
transition: background 0.15s ease, box-shadow 0.15s ease;
}
.drawer-search:focus-within {
background: rgba(255, 255, 255, 0.65);
box-shadow:
inset 0 0 0 1px rgba(217, 119, 87, 0.3),
0 0 0 3px rgba(217, 119, 87, 0.10);
}
:global(html.dark .drawer-search) {
/* Higher contrast vs the panel so the search input is clearly clickable. */
background: rgba(255, 255, 255, 0.08);
}
:global(html.dark .drawer-search:focus-within) {
background: rgba(255, 255, 255, 0.14);
box-shadow:
inset 0 0 0 1px rgba(217, 119, 87, 0.6),
0 0 0 3px rgba(217, 119, 87, 0.20);
}
.drawer-search__icon {
flex-shrink: 0;
color: var(--mc-text-tertiary);
}
.drawer-search__input {
flex: 1;
min-width: 0;
border: 0;
background: transparent;
padding: 9px 4px;
font-size: 14px;
color: var(--mc-text-primary);
outline: none;
letter-spacing: -0.005em;
}
.drawer-search__input::placeholder {
color: var(--mc-text-tertiary);
}
.drawer-search__clear {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: 0;
border-radius: 999px;
cursor: pointer;
color: var(--mc-text-tertiary);
transition: background 0.15s ease, color 0.15s ease;
}
.drawer-search__clear:hover {
background: rgba(123, 88, 67, 0.10);
color: var(--mc-text-primary);
}
:global(html.dark .drawer-search__clear:hover) {
background: rgba(255, 255, 255, 0.10);
color: var(--mc-text-primary);
}
.drawer-content {
flex: 1;
overflow-y: auto;
padding: 16px 20px 32px;
}
.drawer-group + .drawer-group { margin-top: 28px; }
.drawer-group + .drawer-group { margin-top: 24px; }
.drawer-group-title {
margin: 0 0 10px;
margin: 0 4px 8px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
color: var(--mc-text-tertiary);
}
.provider-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--mc-border);
border-radius: 12px;
background: var(--mc-bg-elevated);
/*
* Apple System-Settings style: one rounded card containing N rows
* with hairline dividers between them instead of N bordered cards.
*/
.drawer-group__list {
border-radius: 14px;
background: rgba(255, 255, 255, 0.5);
overflow: hidden;
box-shadow: 0 1px 3px rgba(25, 14, 8, 0.04);
}
.provider-row + .provider-row { margin-top: 8px; }
.row-icon { width: 32px; height: 32px; flex-shrink: 0; border-radius: 8px; object-fit: contain; }
.row-meta { flex: 1; min-width: 0; }
.row-name { font-size: 14px; font-weight: 600; color: var(--mc-text-primary); }
.row-id { margin-top: 2px; font-size: 12px; color: var(--mc-text-tertiary); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.row-cta {
flex-shrink: 0;
padding: 7px 14px;
border-radius: 8px;
border: 0;
cursor: pointer;
background: var(--mc-primary);
color: white;
font-size: 13px;
font-weight: 600;
transition: background 0.15s;
}
.row-cta:hover:not(:disabled) { background: var(--mc-primary-hover, var(--mc-primary)); }
.row-cta:disabled { opacity: 0.6; cursor: wait; }
.row-enabled-badge {
flex-shrink: 0;
padding: 6px 10px;
border-radius: 8px;
background: var(--mc-bg-sunken);
color: var(--mc-text-tertiary);
font-size: 12px;
:global(html.dark .drawer-group__list) {
/* Slightly more visible card surface in dark at 0.04 the rounded
container blended into the panel and lost its "card" affordance. */
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.drawer-empty {
padding: 40px 20px;
text-align: center;
color: var(--mc-text-tertiary);
}
.drawer-fade-enter-active, .drawer-fade-leave-active {
transition: opacity 0.18s ease;
.drawer-fade-enter-active,
.drawer-fade-leave-active {
transition: opacity 0.22s ease;
}
.drawer-fade-enter-from,
.drawer-fade-leave-to {
opacity: 0;
}
.drawer-fade-enter-from, .drawer-fade-leave-to { opacity: 0; }
/* Mobile: full-screen sheet that slides up. */
/* Mobile: bottom sheet with rounded top + softer slide. */
@media (max-width: 768px) {
.drawer-overlay { justify-content: stretch; }
.drawer-overlay {
justify-content: stretch;
align-items: flex-end;
}
.drawer-panel {
width: 100%;
max-width: 100%;
height: 92vh;
border-left: 0;
animation: drawer-slide-up 0.22s ease;
border-top: 1px solid rgba(255, 255, 255, 0.4);
border-top-left-radius: 20px;
border-top-right-radius: 20px;
animation: drawer-slide-up 0.32s cubic-bezier(0.32, 0.72, 0, 1);
}
:global(html.dark .drawer-panel) {
border-top-color: rgba(255, 255, 255, 0.08);
}
@keyframes drawer-slide-up {
from { transform: translateY(100%); }

View File

@ -1,102 +1,157 @@
<template>
<div>
<div class="provider-header">
<div>
<div class="provider-title-row">
<span class="provider-icon-shell">
<img
:src="getProviderIcon(provider.id)"
:alt="provider.name"
class="provider-icon"
@error="onIconError"
/>
</span>
<h3 class="provider-name">{{ provider.name }}</h3>
<span class="provider-badge" :class="provider.isCustom ? 'custom' : 'builtin'">
{{ provider.isCustom ? t('settings.model.custom') : t('settings.model.builtin') }}
</span>
<span v-if="isProviderActive(provider)" class="provider-badge active">
{{ t('settings.model.active') }}
</span>
<!-- RFC-009 P3.5: surface failover priority so users can see at a glance
which providers participate in the chain and in what order. -->
<span
v-if="provider.fallbackPriority && provider.fallbackPriority > 0"
class="provider-badge fallback"
:title="t('settings.model.fallbackBadgeTitle')"
>
{{ t('settings.model.fallbackBadge', { priority: provider.fallbackPriority }) }}
</span>
<!-- RFC-073: liveness badge. Single source of truth replacing the old
configured / pool-entry combo. UNCONFIGURED renders no badge the
status pill on the right already says "needs configuration". -->
<span
v-if="provider.liveness === 'LIVE'"
class="provider-badge pool-in"
:title="t('settings.model.poolBadgeInPoolTitle')"
>
{{ t('settings.model.poolBadgeInPool') }}
</span>
<span
v-else-if="provider.liveness === 'COOLDOWN'"
class="provider-badge pool-cooldown"
:title="t('settings.model.poolBadgeCooldownTitle', {
seconds: Math.max(1, Math.ceil((provider.cooldownRemainingMs || 0) / 1000))
})"
>
{{ t('settings.model.poolBadgeCooldown') }}
</span>
<span
v-else-if="provider.liveness === 'REMOVED'"
class="provider-badge pool-removed"
:title="t('settings.model.poolBadgeRemovedTitle', {
source: t('settings.model.poolSourceInitProbe'),
message: provider.unavailableReason || '—'
})"
>
{{ t('settings.model.poolBadgeRemoved') }}
</span>
<span
v-else-if="provider.liveness === 'UNPROBED'"
class="provider-badge pool-unprobed"
:title="t('settings.model.livenessUnprobedTooltip')"
>
{{ t('settings.model.livenessUnprobed') }}
</span>
</div>
<div class="card">
<!-- HEADLINE: icon · name · status pill -->
<!-- Note: the "active" left rail lives on the outer .provider-card
wrapper (in index.vue) so it can ride the wrapper's border-radius
via inset box-shadow pulling it into this child made it leak
outside the rounded corners. -->
<div class="card-headline">
<span class="provider-icon-shell">
<img
:src="getProviderIcon(provider.id)"
:alt="provider.name"
class="provider-icon"
@error="onIconError"
/>
</span>
<div class="card-headline__meta">
<h3 class="provider-name">{{ provider.name }}</h3>
<p class="provider-id">{{ provider.id }}</p>
</div>
<div class="provider-status" :class="providerStatus(provider).type">
{{ providerStatus(provider).label }}
</div>
<span
class="status-pill"
:class="`status-pill--${headlineStatus.tone}`"
:title="headlineStatus.title"
>
<span v-if="headlineStatus.dot" class="status-pill__dot"></span>
{{ headlineStatus.label }}
</span>
</div>
<div class="provider-info">
<div class="info-row">
<span class="info-label">{{ t('settings.model.baseUrl') }}</span>
<span class="info-value mono" :title="provider.baseUrl || ''">
{{ provider.baseUrl || t('settings.model.notSet') }}
<!-- SUBLINE: model count + active marker -->
<p class="card-subline">
{{ t('settings.model.modelCountAvailable', { count: totalModelCount }) }}
<span v-if="isProviderActive(provider)" class="card-subline__active">
· {{ t('settings.model.active') }}
</span>
<span
v-if="provider.fallbackPriority && provider.fallbackPriority > 0"
class="card-subline__chip"
:title="t('settings.model.fallbackBadgeTitle')"
>
{{ t('settings.model.fallbackBadge', { priority: provider.fallbackPriority }) }}
</span>
</p>
<!-- CREDENTIAL: the showcase. Inline API Key for the 90% case. -->
<div class="cred-block">
<!-- API-key flow (non-OAuth providers that need a key) -->
<template v-if="provider.authType !== 'oauth' && provider.requireApiKey">
<!-- no key set OR user clicked Change input form -->
<form
v-if="!provider.apiKey || editing"
class="cred-form"
@submit.prevent="onSubmit"
>
<label class="cred-form__label" :for="`cred-${provider.id}`">
{{ t('settings.model.inlineApiKeyTitle') }}
</label>
<div class="cred-form__row">
<input
:id="`cred-${provider.id}`"
ref="inputRef"
v-model="draft"
type="password"
class="cred-form__input"
:placeholder="t('settings.model.inlineApiKeyPlaceholder')"
:disabled="saving"
autocomplete="off"
spellcheck="false"
@keydown.escape="onCancel"
/>
<button
class="cred-form__submit"
type="submit"
:disabled="saving || !draft.trim()"
>
{{ saving ? t('settings.model.inlineApiKeySaving') : t('common.save') }}
</button>
<button
v-if="editing && provider.apiKey"
class="cred-form__cancel"
type="button"
:disabled="saving"
@click="onCancel"
>
{{ t('common.cancel') }}
</button>
</div>
</form>
<!-- key already set masked display + Change -->
<div v-else class="cred-set">
<svg class="cred-set__check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
<span class="cred-set__masked">{{ t('settings.model.inlineApiKeyMasked') }}</span>
<button class="cred-set__change" type="button" @click="beginEdit">
{{ t('settings.model.inlineApiKeyChange') }}
</button>
</div>
</template>
<!-- OAuth flow -->
<template v-else-if="provider.authType === 'oauth'">
<div class="cred-oauth">
<span
v-if="provider.oauthConnected"
class="cred-oauth__badge cred-oauth__badge--ok"
>
{{ t('settings.model.oauthConnected') }}
</span>
<span v-else class="cred-oauth__badge cred-oauth__badge--off">
{{ t('settings.model.oauthDisconnected') }}
</span>
<button
v-if="!provider.oauthConnected"
class="cred-oauth__btn"
type="button"
@click="$emit('oauth-login', provider)"
>
{{ provider.id === 'anthropic-claude-code'
? t('settings.model.claudeCodeOauthDetect')
: t('settings.model.oauthLogin') }}
</button>
</div>
</template>
<!-- Local providers / no-auth: just show base URL -->
<template v-else>
<div class="cred-info">
<span class="cred-info__label">{{ t('settings.model.baseUrl') }}</span>
<span class="cred-info__value mono">
{{ provider.baseUrl || t('settings.model.notSet') }}
</span>
</div>
</template>
<!-- Connection-test feedback: lives under the credential it tested. -->
<div
v-if="connectionResults[provider.id]"
class="cred-feedback"
:class="connectionResults[provider.id].success ? 'cred-feedback--ok' : 'cred-feedback--err'"
>
<span v-if="connectionResults[provider.id].success">
{{ t('settings.model.discovery.connectionOk') }} ·
{{ t('settings.model.discovery.latency', { ms: connectionResults[provider.id].latencyMs }) }}
</span>
</div>
<div v-if="provider.authType === 'oauth'" class="info-row">
<span class="info-label">OAuth</span>
<span class="info-value">
<span v-if="provider.oauthConnected" class="oauth-card-badge connected">{{ t('settings.model.oauthConnected') }}</span>
<span v-else class="oauth-card-badge disconnected">{{ t('settings.model.oauthDisconnected') }}</span>
</span>
</div>
<div v-else class="info-row">
<span class="info-label">{{ t('settings.model.apiKey') }}</span>
<span class="info-value mono">{{ provider.apiKey || t('settings.model.notSet') }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('settings.fields.modelName') }}</span>
<span class="info-value">
{{ t('settings.model.modelCount', { count: (provider.models?.length || 0) + (provider.extraModels?.length || 0) }) }}
<span v-else>
{{ t('settings.model.discovery.connectionFail') }}: {{ connectionResults[provider.id].errorMessage }}
</span>
</div>
</div>
<!-- SECONDARY ACTIONS -->
<div class="card-actions">
<button class="card-btn" @click="$emit('manage-models', provider)">
{{ t('settings.model.actions.manageModels') }}
@ -107,84 +162,204 @@
<button
v-if="provider.supportConnectionCheck && provider.configured"
class="card-btn"
:class="{ testing: connectionTestingId === provider.id }"
:class="{ 'card-btn--busy': connectionTestingId === provider.id }"
:disabled="connectionTestingId === provider.id"
@click="$emit('test-connection', provider)"
>
{{ connectionTestingId === provider.id ? t('settings.model.discovery.testing') : t('settings.model.discovery.testConnection') }}
{{ connectionTestingId === provider.id
? t('settings.model.discovery.testing')
: t('settings.model.discovery.testConnection') }}
</button>
<button
v-if="provider.isCustom"
class="card-btn danger"
@click="$emit('delete-provider', provider)"
>
{{ t('common.delete') }}
</button>
<!-- RFC-073: manual reprobe visible when the provider was HARD-removed,
lets the user recover without restart. Also useful in COOLDOWN to
short-circuit the wait. -->
<button
v-if="provider.liveness === 'REMOVED' || provider.liveness === 'COOLDOWN'"
class="card-btn"
:class="{ testing: reprobing }"
:class="{ 'card-btn--busy': reprobing }"
:disabled="reprobing"
@click="$emit('reprobe', provider)"
>
{{ reprobing ? t('settings.model.poolReprobing') : t('settings.model.poolReprobe') }}
{{ reprobing
? t('settings.model.poolReprobing')
: t('settings.model.poolReprobe') }}
</button>
<!-- RFC-074 PR-2: hide an enabled provider. Soft-disable; the row moves
back into the catalog drawer where the user can re-enable later. -->
<button
v-if="provider.enabled"
class="card-btn danger-soft"
class="card-btn card-btn--danger-soft"
@click="$emit('disable-provider', provider)"
>
{{ t('settings.model.disable') }}
</button>
</div>
<div v-if="connectionResults[provider.id]" class="connection-result" :class="connectionResults[provider.id].success ? 'success' : 'error'">
<span v-if="connectionResults[provider.id].success">
{{ t('settings.model.discovery.connectionOk') }} · {{ t('settings.model.discovery.latency', { ms: connectionResults[provider.id].latencyMs }) }}
</span>
<span v-else>
{{ t('settings.model.discovery.connectionFail') }}: {{ connectionResults[provider.id].errorMessage }}
</span>
<button
v-if="provider.isCustom"
class="card-btn card-btn--danger"
@click="$emit('delete-provider', provider)"
>
{{ t('common.delete') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ProviderInfo } from '@/types'
defineProps<{
const props = defineProps<{
provider: ProviderInfo
connectionTestingId: string | null
connectionResults: Record<string, any>
// RFC-073: true while a manual reprobe is in flight for this provider.
connectionResults: Record<string, { success: boolean; latencyMs?: number; errorMessage?: string }>
/** True while a manual reprobe is in flight for this provider. */
reprobing?: boolean
/** Provider id whose inline API key save is in flight (or null). */
savingApiKeyId?: string | null
isProviderActive: (provider: ProviderInfo) => boolean
providerStatus: (provider: ProviderInfo) => { type: string; label: string }
getProviderIcon: (id: string) => string
onIconError: (e: Event) => void
}>()
defineEmits<{
const emit = defineEmits<{
'manage-models': [provider: ProviderInfo]
'provider-settings': [provider: ProviderInfo]
'test-connection': [provider: ProviderInfo]
'delete-provider': [provider: ProviderInfo]
'disable-provider': [provider: ProviderInfo]
'reprobe': [provider: ProviderInfo]
'oauth-login': [provider: ProviderInfo]
'save-api-key': [payload: { provider: ProviderInfo; apiKey: string }]
}>()
const { t } = useI18n()
const draft = ref('')
const editing = ref(false)
const inputRef = ref<HTMLInputElement | null>(null)
const saving = computed(() => props.savingApiKeyId === props.provider.id)
const totalModelCount = computed(() =>
(props.provider.models?.length || 0) + (props.provider.extraModels?.length || 0)
)
/**
* Single source of truth for the headline pill collapses liveness + configured
* + credential state into ONE label. Specifically: a missing credential always
* wins, because that's the action the user needs to take next.
*/
const headlineStatus = computed(() => {
const p = props.provider
const credMissing =
(p.authType === 'oauth' && !p.oauthConnected) ||
(p.requireApiKey && !p.apiKey && p.authType !== 'oauth')
if (credMissing) {
return { tone: 'warn', label: t('settings.model.statusNotSet'), title: '', dot: false }
}
switch (p.liveness) {
case 'LIVE':
return { tone: 'ok', label: t('settings.model.statusReady'), title: '', dot: true }
case 'COOLDOWN':
return {
tone: 'warn',
label: t('settings.model.poolBadgeCooldown'),
title: t('settings.model.poolBadgeCooldownTitle', {
seconds: Math.max(1, Math.ceil((p.cooldownRemainingMs || 0) / 1000)),
}),
dot: true,
}
case 'REMOVED':
return {
tone: 'err',
label: t('settings.model.livenessRemoved'),
title: t('settings.model.poolBadgeRemovedTitle', {
source: t('settings.model.poolSourceInitProbe'),
message: p.unavailableReason || '—',
}),
dot: true,
}
case 'UNPROBED':
return {
tone: 'pending',
label: t('settings.model.livenessUnprobed'),
title: t('settings.model.livenessUnprobedTooltip'),
dot: true,
}
}
return {
tone: p.available ? 'ok' : 'pending',
label: props.providerStatus(p).label,
title: '',
dot: true,
}
})
// When parent finishes saving (savingApiKeyId clears for our id), reset the
// editing state so the masked view comes back automatically.
watch(saving, (isSaving, wasSaving) => {
if (wasSaving && !isSaving) {
draft.value = ''
editing.value = false
}
})
function onSubmit() {
const key = draft.value.trim()
if (!key) return
emit('save-api-key', { provider: props.provider, apiKey: key })
}
async function beginEdit() {
editing.value = true
draft.value = ''
await nextTick()
inputRef.value?.focus()
}
function onCancel() {
if (saving.value) return
draft.value = ''
editing.value = false
}
</script>
<style scoped>
.provider-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.provider-title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
/*
* Card root the shell is provided by the parent's .provider-card wrapper.
* This component owns the internal layout only. Active-state highlight
* (the left rail) is the wrapper's job see .provider-card--active in
* index.vue.
*/
.card {
display: flex;
flex-direction: column;
gap: 14px;
}
/* HEADLINE */
.card-headline {
display: flex;
align-items: center;
gap: 12px;
}
.card-headline__meta {
flex: 1;
min-width: 0;
}
.provider-name {
margin: 0;
font-size: 17px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mc-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.provider-id {
margin: 2px 0 0;
font-size: 12px;
color: var(--mc-text-tertiary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.provider-icon-shell {
width: 44px;
height: 44px;
@ -200,16 +375,13 @@ const { t } = useI18n()
inset 0 1px 0 rgba(255, 255, 255, 0.92),
0 6px 16px rgba(25, 14, 8, 0.14);
}
.provider-icon {
width: 100%;
height: 100%;
object-fit: contain;
flex-shrink: 0;
filter: drop-shadow(0 1px 1px rgba(44, 24, 10, 0.12));
}
:global(html.dark) .provider-icon-shell {
:global(html.dark .provider-icon-shell) {
border-color: rgba(255, 248, 241, 0.28);
background: linear-gradient(180deg, #fffdfb, #f3e8dc);
box-shadow:
@ -217,49 +389,319 @@ const { t } = useI18n()
0 8px 22px rgba(0, 0, 0, 0.26);
}
:global(html.dark) .provider-icon {
filter: drop-shadow(0 1px 1px rgba(44, 24, 10, 0.18));
}
.provider-name { margin: 0; font-size: 18px; color: var(--mc-text-primary); }
.provider-id { margin: 6px 0 0; font-size: 13px; color: var(--mc-primary); }
.provider-badge { display: inline-flex; align-items: center; border-radius: 999px; padding: 3px 9px; font-size: 12px; font-weight: 600; }
.provider-badge.builtin { background: var(--mc-primary-bg); color: var(--mc-primary); }
.provider-badge.custom { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
.provider-badge.active { background: rgba(217, 119, 87, 0.12); color: var(--mc-primary-light); }
.provider-badge.fallback { background: rgba(99, 102, 241, 0.12); color: #6366f1; cursor: help; }
/* RFC-009 Phase 4: pool status. Green = healthy, amber = cooldown, red = removed. */
.provider-badge.pool-in { background: rgba(34, 197, 94, 0.12); color: #16a34a; cursor: help; }
.provider-badge.pool-cooldown { background: rgba(245, 158, 11, 0.14); color: #b45309; cursor: help; }
.provider-badge.pool-removed { background: rgba(239, 68, 68, 0.14); color: #dc2626; cursor: help; }
/* RFC-073: UNPROBED — neutral grey with a gentle pulse so it's clearly transient. */
.provider-badge.pool-unprobed {
background: rgba(156, 163, 175, 0.16);
color: var(--mc-text-tertiary, #6b7280);
/* STATUS PILL — single source of truth, four tones */
.status-pill {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
cursor: help;
animation: mc-card-dot-pulse 1.6s ease-in-out infinite;
}
@keyframes mc-card-dot-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
.provider-status { flex-shrink: 0; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.provider-status.configured { background: var(--mc-primary-bg); color: var(--mc-primary); }
.provider-status.partial { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
.provider-status.unavailable { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
.provider-info { display: grid; gap: 10px; }
.info-row { display: flex; justify-content: space-between; gap: 12px; }
.info-label { color: var(--mc-text-secondary); font-size: 13px; }
.info-value { color: var(--mc-text-primary); font-size: 13px; text-align: right; word-break: break-all; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.card-actions { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
.card-btn { border: none; border-radius: 10px; padding: 9px 14px; font-size: 14px; cursor: pointer; transition: all 0.15s; background: var(--mc-primary-bg); color: var(--mc-primary); }
.card-btn:hover { background: rgba(217, 119, 87, 0.18); }
.card-btn.danger { background: var(--mc-danger-bg); color: var(--mc-danger); }
/* RFC-074 PR-2: soft-danger (disable) — softer than delete to signal reversibility. */
.card-btn.danger-soft { background: var(--mc-bg-sunken); color: var(--mc-text-secondary); }
.card-btn.danger-soft:hover { background: var(--mc-danger-bg); color: var(--mc-danger); }
.card-btn.testing { opacity: 0.6; cursor: wait; }
.connection-result { margin-top: 10px; padding: 8px 12px; border-radius: 8px; font-size: 12px; }
.connection-result.success { background: var(--mc-primary-bg); color: var(--mc-primary); }
.connection-result.error { background: var(--mc-danger-bg); color: var(--mc-danger); }
.oauth-card-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 6px; font-size: 12px; font-weight: 600; }
.oauth-card-badge.connected { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.oauth-card-badge.disconnected { background: rgba(156, 163, 175, 0.12); color: var(--mc-text-tertiary); }
.status-pill__dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.status-pill--ok {
background: rgba(34, 197, 94, 0.14);
color: #16a34a;
}
.status-pill--warn {
background: rgba(245, 158, 11, 0.16);
color: #b45309;
}
.status-pill--err {
background: rgba(239, 68, 68, 0.14);
color: #dc2626;
}
.status-pill--pending {
background: rgba(156, 163, 175, 0.16);
color: var(--mc-text-tertiary);
}
.status-pill--pending .status-pill__dot {
animation: pill-pulse 1.6s ease-in-out infinite;
}
@keyframes pill-pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
/* SUBLINE */
.card-subline {
margin: -4px 0 0;
font-size: 13px;
color: var(--mc-text-secondary);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.card-subline__active {
color: var(--mc-primary);
font-weight: 600;
}
.card-subline__chip {
margin-left: 4px;
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
background: rgba(99, 102, 241, 0.10);
color: #6366f1;
font-size: 11px;
font-weight: 600;
cursor: help;
}
/* CRED BLOCK — the showcase */
.cred-block {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border-radius: 12px;
background: rgba(123, 88, 67, 0.04);
border: 1px solid rgba(123, 88, 67, 0.10);
}
:global(html.dark .cred-block) {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.06);
}
/* form (key not set OR editing) */
.cred-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.cred-form__label {
font-size: 12px;
font-weight: 600;
color: var(--mc-text-secondary);
letter-spacing: -0.005em;
}
.cred-form__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.cred-form__input {
flex: 1;
min-width: 0;
border: 1px solid rgba(123, 88, 67, 0.18);
border-radius: 10px;
padding: 9px 12px;
font-size: 14px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.cred-form__input:focus {
border-color: var(--mc-primary);
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.12);
}
.cred-form__input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
:global(html.dark .cred-form__input) {
border-color: rgba(255, 255, 255, 0.10);
}
.cred-form__submit {
flex-shrink: 0;
padding: 9px 18px;
border: 0;
border-radius: 10px;
background: var(--mc-primary);
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease;
}
.cred-form__submit:hover:not(:disabled) {
background: var(--mc-primary-hover);
}
.cred-form__submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cred-form__cancel {
flex-shrink: 0;
padding: 9px 14px;
border: 1px solid rgba(123, 88, 67, 0.18);
border-radius: 10px;
background: transparent;
color: var(--mc-text-secondary);
font-size: 14px;
cursor: pointer;
transition: background 0.15s ease;
}
.cred-form__cancel:hover:not(:disabled) {
background: rgba(123, 88, 67, 0.06);
}
:global(html.dark .cred-form__cancel) {
border-color: rgba(255, 255, 255, 0.10);
}
:global(html.dark .cred-form__cancel:hover:not(:disabled)) {
background: rgba(255, 255, 255, 0.06);
}
/* set state (key already configured) */
.cred-set {
display: flex;
align-items: center;
gap: 10px;
}
.cred-set__check {
flex-shrink: 0;
color: #16a34a;
}
.cred-set__masked {
flex: 1;
font-size: 13px;
color: var(--mc-text-secondary);
letter-spacing: 0.04em;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.cred-set__change {
flex-shrink: 0;
background: transparent;
border: 0;
padding: 4px 10px;
border-radius: 999px;
color: var(--mc-primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.cred-set__change:hover {
background: rgba(217, 119, 87, 0.10);
}
/* OAuth block */
.cred-oauth {
display: flex;
align-items: center;
gap: 10px;
}
.cred-oauth__badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
}
.cred-oauth__badge--ok {
background: rgba(34, 197, 94, 0.14);
color: #16a34a;
}
.cred-oauth__badge--off {
background: rgba(156, 163, 175, 0.14);
color: var(--mc-text-tertiary);
}
.cred-oauth__btn {
margin-left: auto;
padding: 7px 14px;
border: 0;
border-radius: 999px;
background: var(--mc-primary);
color: white;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.cred-oauth__btn:hover {
background: var(--mc-primary-hover);
}
/* Local providers / no-auth */
.cred-info {
display: flex;
justify-content: space-between;
gap: 12px;
}
.cred-info__label {
font-size: 13px;
color: var(--mc-text-secondary);
}
.cred-info__value {
font-size: 13px;
color: var(--mc-text-primary);
text-align: right;
word-break: break-all;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* Connection-test feedback */
.cred-feedback {
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
}
.cred-feedback--ok {
background: rgba(34, 197, 94, 0.10);
color: #15803d;
}
.cred-feedback--err {
background: var(--mc-danger-bg);
color: var(--mc-danger);
}
:global(html.dark .cred-feedback--ok) {
background: rgba(34, 197, 94, 0.16);
color: #4ade80;
}
/* SECONDARY ACTIONS */
.card-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.card-btn {
border: 0;
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
background: var(--mc-primary-bg);
color: var(--mc-primary);
}
.card-btn:hover:not(:disabled) {
background: rgba(217, 119, 87, 0.18);
}
.card-btn--busy {
opacity: 0.6;
cursor: wait;
}
.card-btn--danger {
background: var(--mc-danger-bg);
color: var(--mc-danger);
}
.card-btn--danger-soft {
background: var(--mc-bg-sunken);
color: var(--mc-text-secondary);
}
.card-btn--danger-soft:hover {
background: var(--mc-danger-bg);
color: var(--mc-danger);
}
</style>

View File

@ -0,0 +1,136 @@
<template>
<div class="catalog-row">
<img
class="catalog-row__icon"
:src="iconUrl"
:alt="provider.name"
@error="onIconError"
/>
<div class="catalog-row__meta">
<div class="catalog-row__name">{{ provider.name }}</div>
<div class="catalog-row__id">{{ provider.id }}</div>
</div>
<button
v-if="!provider.enabled"
class="catalog-row__cta"
:disabled="busy"
@click="$emit('enable', provider)"
>
{{ busy ? t('common.loading') : t('settings.model.enable') }}
</button>
<span v-else class="catalog-row__badge">{{ t('settings.model.alreadyEnabled') }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ProviderInfo } from '@/types'
const props = defineProps<{
provider: ProviderInfo
togglingId: string | null
getProviderIcon: (id: string) => string
onIconError: (e: Event) => void
}>()
defineEmits<{
enable: [provider: ProviderInfo]
}>()
const { t } = useI18n()
const iconUrl = computed(() => props.getProviderIcon(props.provider.id))
const busy = computed(() => props.togglingId === props.provider.id)
</script>
<style scoped>
.catalog-row {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 16px;
background: transparent;
transition: background 0.18s ease;
cursor: default;
}
.catalog-row + .catalog-row {
border-top: 1px solid rgba(123, 88, 67, 0.08);
}
.catalog-row:hover {
background: rgba(255, 255, 255, 0.5);
}
:global(html.dark .catalog-row + .catalog-row) {
/* Slightly more visible hairline so rows don't melt into one solid block. */
border-top-color: rgba(255, 255, 255, 0.07);
}
:global(html.dark .catalog-row:hover) {
/* Bumped from 0.04 — pointer feedback was too subtle on dark surfaces. */
background: rgba(255, 255, 255, 0.06);
}
.catalog-row__icon {
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: 8px;
object-fit: contain;
}
.catalog-row__meta {
flex: 1;
min-width: 0;
}
.catalog-row__name {
font-size: 14px;
font-weight: 600;
color: var(--mc-text-primary);
letter-spacing: -0.005em;
}
.catalog-row__id {
margin-top: 2px;
font-size: 12px;
color: var(--mc-text-tertiary);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.catalog-row__cta {
flex-shrink: 0;
padding: 7px 16px;
border-radius: 999px;
border: 0;
cursor: pointer;
background: var(--mc-primary);
color: white;
font-size: 13px;
font-weight: 600;
letter-spacing: -0.005em;
box-shadow: 0 1px 2px rgba(217, 119, 87, 0.24);
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.catalog-row__cta:hover:not(:disabled) {
background: var(--mc-primary-hover, var(--mc-primary));
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(217, 119, 87, 0.28);
}
.catalog-row__cta:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(217, 119, 87, 0.24);
}
.catalog-row__cta:disabled {
opacity: 0.6;
cursor: wait;
}
.catalog-row__badge {
flex-shrink: 0;
padding: 6px 12px;
border-radius: 999px;
background: rgba(123, 88, 67, 0.08);
color: var(--mc-text-tertiary);
font-size: 12px;
font-weight: 500;
}
:global(html.dark .catalog-row__badge) {
background: rgba(255, 255, 255, 0.06);
}
</style>

View File

@ -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,
}
}

View File

@ -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',
}

View File

@ -46,12 +46,18 @@
{{ t('settings.model.localProviders') }}
</h3>
<div class="provider-grid">
<div v-for="provider in localProviders" :key="provider.id" class="provider-card">
<div
v-for="provider in localProviders"
:key="provider.id"
class="provider-card"
:class="{ 'provider-card--active': isProviderActive(provider) }"
>
<ProviderCard
:provider="provider"
:connection-testing-id="connectionTestingId"
:connection-results="connectionResults"
:reprobing="reprobingId === provider.id"
:saving-api-key-id="savingApiKeyId"
:is-provider-active="isProviderActive"
:provider-status="providerStatus"
:get-provider-icon="getProviderIcon"
@ -62,6 +68,8 @@
@delete-provider="onDeleteProvider"
@disable-provider="onDisableProvider"
@reprobe="reprobeProvider"
@oauth-login="onCardOAuthLogin"
@save-api-key="onSaveApiKey"
/>
</div>
</div>
@ -76,12 +84,18 @@
{{ t('settings.model.cloudProviders') }}
</h3>
<div class="provider-grid">
<div v-for="provider in cloudProviders" :key="provider.id" class="provider-card">
<div
v-for="provider in cloudProviders"
:key="provider.id"
class="provider-card"
:class="{ 'provider-card--active': isProviderActive(provider) }"
>
<ProviderCard
:provider="provider"
:connection-testing-id="connectionTestingId"
:connection-results="connectionResults"
:reprobing="reprobingId === provider.id"
:saving-api-key-id="savingApiKeyId"
:is-provider-active="isProviderActive"
:provider-status="providerStatus"
:get-provider-icon="getProviderIcon"
@ -92,6 +106,8 @@
@delete-provider="onDeleteProvider"
@disable-provider="onDisableProvider"
@reprobe="reprobeProvider"
@oauth-login="onCardOAuthLogin"
@save-api-key="onSaveApiKey"
/>
</div>
</div>
@ -162,7 +178,7 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import type { ProviderInfo, ProviderModelInfo } from '@/types'
import { useProviders } from './useProviders'
@ -214,6 +230,7 @@ const {
openProviderConfigModal,
closeProviderModal,
saveProvider,
saveProviderApiKey,
deleteProvider,
openManageModelsModal,
closeManageModelsModal,
@ -246,6 +263,13 @@ const {
const localProviders = computed(() => providers.value.filter(p => p.isLocal))
const cloudProviders = computed(() => providers.value.filter(p => !p.isLocal))
/**
* Inline-save in-flight tracker child cards reflect this to disable
* their input while the request is on the wire. Single concurrent save
* (one user, one card) so a scalar id is enough.
*/
const savingApiKeyId = ref<string | null>(null)
const route = useRoute()
const router = useRouter()
/** sessionStorage guard so the drawer auto-opens at most once per session per workspace. */
@ -275,10 +299,41 @@ onMounted(async () => {
})
async function onDisableProvider(provider: ProviderInfo) {
if (!confirm(t('settings.model.disableConfirm', { name: provider.name }))) return
// ElMessageBox throws on cancel that's our cancel branch.
try {
await ElMessageBox.confirm(
t('settings.model.disableConfirm', { name: provider.name }),
t('common.confirm'),
{
type: 'warning',
confirmButtonText: t('settings.model.disable'),
cancelButtonText: t('common.cancel'),
},
)
} catch {
return
}
await disableProvider(provider.id)
}
async function onSaveApiKey({ provider, apiKey }: { provider: ProviderInfo; apiKey: string }) {
savingApiKeyId.value = provider.id
try {
await saveProviderApiKey(provider, apiKey)
showSavedTip(t('settings.model.inlineApiKeySaved'))
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : t('settings.model.inlineApiKeySaveFailed'))
} finally {
savingApiKeyId.value = null
}
}
// Card emits oauth-login with the provider; the OAuth composable already
// accepts a providerId, so this is a thin pass-through.
function onCardOAuthLogin(provider: ProviderInfo) {
handleOAuthLogin(provider.id)
}
async function onSaveProvider() {
try {
await saveProvider()
@ -373,6 +428,14 @@ function showSavedTip(message: string) {
}
.provider-card {
background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; padding: 18px; box-shadow: 0 8px 24px rgba(124, 63, 30, 0.04);
/* Inset shadow technique: layout doesn't shift between active/inactive
because we're not using border-left. Default rail is transparent so
the same rule paints on both states only the color changes. */
box-shadow: inset 4px 0 0 transparent, 0 8px 24px rgba(124, 63, 30, 0.04);
transition: box-shadow 0.18s ease;
}
.provider-card--active {
box-shadow: inset 4px 0 0 var(--mc-primary), 0 8px 24px rgba(124, 63, 30, 0.06);
}
.btn-primary { border: none; border-radius: 10px; padding: 9px 14px; font-size: 14px; cursor: pointer; transition: all 0.15s; background: var(--mc-primary); color: white; }
.btn-primary:hover { background: var(--mc-primary-hover); }

View File

@ -51,8 +51,12 @@
<input type="checkbox" :value="model.id" :checked="selectedNewModelIds.includes(model.id)" @change="$emit('toggleModel', model.id)" />
<span class="discover-model-name">{{ model.name }}</span>
<span class="discover-model-id">{{ model.id }}</span>
<span v-if="model.probeOk === true" class="probe-badge probe-ok" title="Verified reachable">
verified
<span
v-if="model.probeOk === true"
class="probe-badge probe-ok"
:title="t('settings.model.discovery.probeVerifiedTitle')"
>
{{ t('settings.model.discovery.probeVerified') }}
</span>
</label>
</div>
@ -70,11 +74,11 @@
why they are not offered in the "add selected" list -->
<div v-if="discoveredUnavailable.length > 0" class="discover-unavailable">
<div class="discover-unavailable-title">
{{ discoveredUnavailable.length }} discovered model(s) failed the reachability probe and were not listed above:
{{ t('settings.model.discovery.probeFailedBanner', { count: discoveredUnavailable.length }) }}
</div>
<div v-for="model in discoveredUnavailable" :key="model.id" class="discover-unavailable-item">
<span class="discover-model-id">{{ model.id }}</span>
<span class="discover-unavailable-reason">{{ model.probeError || 'not reachable' }}</span>
<span class="discover-unavailable-reason">{{ model.probeError || t('settings.model.discovery.probeUnreachable') }}</span>
</div>
</div>
</div>