mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
709a0db200
commit
e759ad4a1b
74
.github/ISSUE_TEMPLATE/bug-en.yml
vendored
Normal file
74
.github/ISSUE_TEMPLATE/bug-en.yml
vendored
Normal 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
74
.github/ISSUE_TEMPLATE/bug-zh.yml
vendored
Normal 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
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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
41
.github/ISSUE_TEMPLATE/feature-en.yml
vendored
Normal 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
41
.github/ISSUE_TEMPLATE/feature-zh.yml
vendored
Normal 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
|
||||||
@ -1556,8 +1556,14 @@ public class AgentGraphBuilder {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-turn assistant: never patch (symmetric with stripThinkingFromPrompt)
|
// Cross-turn assistant: usually skip per stripThinkingFromPrompt's
|
||||||
if (i <= lastUserIdx) {
|
// "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);
|
patched.add(msg);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1667,19 +1673,33 @@ public class AgentGraphBuilder {
|
|||||||
// tolerance KIMI/OPENAI use restores forward progress; the producer-side
|
// 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
|
// capture gap remains a real bug to fix in RFC-049 PR-3 but doesn't
|
||||||
// belong on the user-facing failure path.
|
// belong on the user-facing failure path.
|
||||||
DEEPSEEK(" ", false, true),
|
//
|
||||||
KIMI (" ", false, false),
|
// 2026-04-29 follow-up: DeepSeek tightened thinking-mode validation to
|
||||||
OPENAI (" ", false, false),
|
// require reasoning_content on EVERY assistant message in the request,
|
||||||
DEFAULT (" ", false, false);
|
// 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 String emptyFallback;
|
||||||
final boolean warnOnMissingReal;
|
final boolean warnOnMissingReal;
|
||||||
final boolean patchNonToolCall;
|
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.emptyFallback = emptyFallback;
|
||||||
this.warnOnMissingReal = warnOnMissingReal;
|
this.warnOnMissingReal = warnOnMissingReal;
|
||||||
this.patchNonToolCall = patchNonToolCall;
|
this.patchNonToolCall = patchNonToolCall;
|
||||||
|
this.patchCrossTurn = patchCrossTurn;
|
||||||
}
|
}
|
||||||
|
|
||||||
static FallbackPolicy forProvider(ModelProviderEntity provider) {
|
static FallbackPolicy forProvider(ModelProviderEntity provider) {
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import vip.mate.channel.health.ChannelHealth;
|
||||||
import vip.mate.channel.model.ChannelEntity;
|
import vip.mate.channel.model.ChannelEntity;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -224,6 +226,33 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter {
|
|||||||
return running.get();
|
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:刷新"活跃时间"的标准入口。
|
* RFC-024 Change 1:刷新"活跃时间"的标准入口。
|
||||||
*
|
*
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package vip.mate.channel;
|
package vip.mate.channel;
|
||||||
|
|
||||||
|
import vip.mate.channel.health.ChannelHealth;
|
||||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
@ -161,4 +162,24 @@ public interface ChannelAdapter {
|
|||||||
default Duration stalenessThreshold() {
|
default Duration stalenessThreshold() {
|
||||||
return Duration.ofMinutes(60);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -56,6 +56,7 @@ public class ChannelMessageRouter {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ChatStreamTracker streamTracker;
|
private final ChatStreamTracker streamTracker;
|
||||||
private final ChannelChatOriginFactory chatOriginFactory;
|
private final ChannelChatOriginFactory chatOriginFactory;
|
||||||
|
private final ChannelErrorClassifier errorClassifier;
|
||||||
|
|
||||||
/** 队列条目:封装消息及其路由上下文 */
|
/** 队列条目:封装消息及其路由上下文 */
|
||||||
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
|
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
|
||||||
@ -101,7 +102,8 @@ public class ChannelMessageRouter {
|
|||||||
TtsService ttsService,
|
TtsService ttsService,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
ChatStreamTracker streamTracker,
|
ChatStreamTracker streamTracker,
|
||||||
ChannelChatOriginFactory chatOriginFactory) {
|
ChannelChatOriginFactory chatOriginFactory,
|
||||||
|
ChannelErrorClassifier errorClassifier) {
|
||||||
this.agentService = agentService;
|
this.agentService = agentService;
|
||||||
this.conversationService = conversationService;
|
this.conversationService = conversationService;
|
||||||
this.channelService = channelService;
|
this.channelService = channelService;
|
||||||
@ -113,6 +115,7 @@ public class ChannelMessageRouter {
|
|||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.streamTracker = streamTracker;
|
this.streamTracker = streamTracker;
|
||||||
this.chatOriginFactory = chatOriginFactory;
|
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={}",
|
log.info("[{}] Approval triggered during chat, sent notice (NOT saved to DB): tool={}",
|
||||||
adapter.getChannelType(), newPending.getToolName());
|
adapter.getChannelType(), newPending.getToolName());
|
||||||
} else {
|
} else {
|
||||||
// 正常回复:保存并发送
|
// Tag error replies (matched by ChannelErrorClassifier — the
|
||||||
MessageEntity saved = conversationService.saveMessage(conversationId, "assistant", reply);
|
// "[错误]" 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;
|
savedAssistantId = saved != null ? saved.getId() : null;
|
||||||
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply);
|
if (!isError) {
|
||||||
|
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply);
|
||||||
|
}
|
||||||
adapter.renderAndSend(replyTarget, reply);
|
adapter.renderAndSend(replyTarget, reply);
|
||||||
log.info("[{}] Reply sent to {}: {}chars",
|
log.info("[{}] Reply sent to {}: {}chars",
|
||||||
adapter.getChannelType(), replyTarget, reply.length());
|
adapter.getChannelType(), replyTarget, reply.length());
|
||||||
@ -551,9 +564,15 @@ public class ChannelMessageRouter {
|
|||||||
log.info("[{}] Approval triggered during streaming (NOT saved to DB): tool={}",
|
log.info("[{}] Approval triggered during streaming (NOT saved to DB): tool={}",
|
||||||
channelType, newPending.getToolName());
|
channelType, newPending.getToolName());
|
||||||
} else if (finalContent != null && !finalContent.isBlank()) {
|
} else if (finalContent != null && !finalContent.isBlank()) {
|
||||||
MessageEntity saved = conversationService.saveMessage(conversationId, "assistant", finalContent);
|
boolean isError = errorClassifier.isErrorReply(finalContent);
|
||||||
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent);
|
String status = isError ? "error" : "completed";
|
||||||
log.info("[{}] Streaming completed: contentLen={}", channelType, finalContent.length());
|
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);
|
String replyTarget = resolveReplyTarget(message);
|
||||||
@ -566,7 +585,17 @@ public class ChannelMessageRouter {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[{}] Streaming processing failed: {}", channelType, e.getMessage(), 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 {
|
try {
|
||||||
String errorTarget = resolveReplyTarget(message);
|
String errorTarget = resolveReplyTarget(message);
|
||||||
streamingAdapter.sendMessage(errorTarget, "抱歉,流式处理失败:" + e.getMessage());
|
streamingAdapter.sendMessage(errorTarget, "抱歉,流式处理失败:" + e.getMessage());
|
||||||
@ -577,6 +606,7 @@ public class ChannelMessageRouter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ==================== 审批重放 ====================
|
// ==================== 审批重放 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -613,8 +643,13 @@ public class ChannelMessageRouter {
|
|||||||
String reply = agentService.chatWithReplay(
|
String reply = agentService.chatWithReplay(
|
||||||
agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin);
|
agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin);
|
||||||
|
|
||||||
// 保存 replay 结果(这是正常结果,入库)
|
// Persist the replay result. If the LLM 400'd during replay,
|
||||||
conversationService.saveMessage(conversationId, "assistant", reply);
|
// 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);
|
adapter.renderAndSend(replyTarget, reply);
|
||||||
|
|||||||
@ -130,6 +130,55 @@ public class ChannelController {
|
|||||||
return R.ok(channelManager.getStatus());
|
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) {
|
private void verifyResourceWorkspace(Long resourceWorkspaceId, Long headerWorkspaceId) {
|
||||||
long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L;
|
long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L;
|
||||||
if (resourceWorkspaceId != null && !resourceWorkspaceId.equals(requestedWs)) {
|
if (resourceWorkspaceId != null && !resourceWorkspaceId.equals(requestedWs)) {
|
||||||
|
|||||||
@ -13,17 +13,11 @@ import vip.mate.channel.dingtalk.DingTalkChannelAdapter;
|
|||||||
import vip.mate.channel.discord.DiscordChannelAdapter;
|
import vip.mate.channel.discord.DiscordChannelAdapter;
|
||||||
import vip.mate.channel.feishu.FeishuAppRegistrationService;
|
import vip.mate.channel.feishu.FeishuAppRegistrationService;
|
||||||
import vip.mate.channel.feishu.FeishuChannelAdapter;
|
import vip.mate.channel.feishu.FeishuChannelAdapter;
|
||||||
|
import vip.mate.channel.qrcode.util.QrCodeImageEncoder;
|
||||||
import vip.mate.channel.telegram.TelegramChannelAdapter;
|
import vip.mate.channel.telegram.TelegramChannelAdapter;
|
||||||
import vip.mate.channel.weixin.ILinkClient;
|
import vip.mate.channel.weixin.ILinkClient;
|
||||||
import vip.mate.channel.weixin.WeixinChannelAdapter;
|
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.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@ -261,19 +255,9 @@ public class ChannelWebhookController {
|
|||||||
new com.fasterxml.jackson.databind.ObjectMapper());
|
new com.fasterxml.jackson.databind.ObjectMapper());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Delegated to the shared encoder so future tweaks live in one place. */
|
||||||
* 使用 ZXing 生成 QR 码 PNG 图片并返回 Base64 编码
|
|
||||||
*/
|
|
||||||
private String generateQrCodeBase64(String content) throws Exception {
|
private String generateQrCodeBase64(String content) throws Exception {
|
||||||
QRCodeWriter writer = new QRCodeWriter();
|
return QrCodeImageEncoder.toBase64(content);
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取微信登录二维码")
|
@Operation(summary = "获取微信登录二维码")
|
||||||
|
|||||||
@ -176,31 +176,62 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
|
|||||||
payload.put("conversationType", msg.getConversationType());
|
payload.put("conversationType", msg.getConversationType());
|
||||||
payload.put("sessionWebhook", msg.getSessionWebhook());
|
payload.put("sessionWebhook", msg.getSessionWebhook());
|
||||||
|
|
||||||
// 消息内容
|
// Dispatch by content type. DingTalk pre-transcribes voice into
|
||||||
// 钉钉服务端已经把语音转写好放在 MessageContent.recognition 里(跟
|
// MessageContent.recognition (same shape as WeCom voice.content),
|
||||||
// 企业微信 voice.content 一个模式),不需要 STT。优先读 recognition;
|
// so we never need to run STT ourselves.
|
||||||
// picture msgtype 把 downloadCode / pictureDownloadCode 透传给 webhook 处理;
|
// - audio (recognition) -> forwarded as text
|
||||||
// 否则读 text.content。
|
// - picture / downloadCode -> webhook picture branch fetches bytes
|
||||||
// richText 还没接上 —— stream 模式收到 richText 会重新落到 webhook 默认 else
|
// - richText (List<MessageContent>) -> rebuild the webhook richText payload
|
||||||
// 分支静默丢消息,单独修。
|
// - text -> text.content
|
||||||
com.dingtalk.open.app.api.models.bot.MessageContent body = msg.getContent();
|
com.dingtalk.open.app.api.models.bot.MessageContent body = msg.getContent();
|
||||||
String recognition = body != null ? body.getRecognition() : null;
|
String recognition = body != null ? body.getRecognition() : null;
|
||||||
String pictureDownloadCode = body != null ? body.getPictureDownloadCode() : null;
|
String pictureDownloadCode = body != null ? body.getPictureDownloadCode() : null;
|
||||||
String downloadCode = body != null ? body.getDownloadCode() : 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()) {
|
if (recognition != null && !recognition.isBlank()) {
|
||||||
payload.put("msgtype", "audio");
|
payload.put("msgtype", "audio");
|
||||||
payload.put("audio", Map.of("recognition", recognition));
|
payload.put("audio", Map.of("recognition", recognition));
|
||||||
} else if ("picture".equals(msg.getMsgtype())
|
} else if ("picture".equals(msgtype)
|
||||||
&& (pictureDownloadCode != null || downloadCode != null)) {
|
&& (pictureDownloadCode != null || downloadCode != null)) {
|
||||||
payload.put("msgtype", "picture");
|
payload.put("msgtype", "picture");
|
||||||
Map<String, Object> picture = new java.util.HashMap<>();
|
Map<String, Object> picture = new java.util.HashMap<>();
|
||||||
if (pictureDownloadCode != null) picture.put("pictureDownloadCode", pictureDownloadCode);
|
if (pictureDownloadCode != null) picture.put("pictureDownloadCode", pictureDownloadCode);
|
||||||
if (downloadCode != null) picture.put("downloadCode", downloadCode);
|
if (downloadCode != null) picture.put("downloadCode", downloadCode);
|
||||||
payload.put("picture", picture);
|
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) {
|
} else if (msg.getText() != null) {
|
||||||
payload.put("msgtype", "text");
|
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);
|
handleWebhook(payload);
|
||||||
@ -345,9 +376,16 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
|
|||||||
outTrackId, e.getMessage(), e);
|
outTrackId, e.getMessage(), e);
|
||||||
aiCardManager.failCard(outTrackId, e.getMessage());
|
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 partial = contentAccumulator.toString();
|
||||||
|
String errorPrefix = "[错误] AI Card streaming failed: " + e.getMessage();
|
||||||
if (!partial.isBlank()) {
|
if (!partial.isBlank()) {
|
||||||
return partial;
|
return errorPrefix + "\n\n(已生成的部分内容,已忽略)\n" + partial;
|
||||||
}
|
}
|
||||||
throw new RuntimeException("AI Card streaming failed: " + e.getMessage(), e);
|
throw new RuntimeException("AI Card streaming failed: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,6 +26,7 @@ import java.time.Duration;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.*;
|
import java.util.concurrent.*;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -143,11 +144,23 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
|
|
||||||
private record WeComReplyContext(String frameReqId, String processingStreamId) {}
|
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,
|
public WeComChannelAdapter(ChannelEntity channelEntity,
|
||||||
ChannelMessageRouter messageRouter,
|
ChannelMessageRouter messageRouter,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
super(channelEntity, messageRouter, 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");
|
Object val = config.get("max_reconnect_attempts");
|
||||||
if (val instanceof Number n) {
|
if (val instanceof Number n) {
|
||||||
maxAttempts = n.intValue();
|
maxAttempts = n.intValue();
|
||||||
@ -180,81 +193,75 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doStop() {
|
protected void doStop() {
|
||||||
// 停止心跳
|
releaseConnectionResources("stopped");
|
||||||
if (heartbeatFuture != null) {
|
// doStop also clears history that survives reconnects (processedMessageIds)
|
||||||
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();
|
|
||||||
processedMessageIds.clear();
|
processedMessageIds.clear();
|
||||||
replyContexts.clear();
|
|
||||||
|
|
||||||
this.httpClient = null;
|
|
||||||
log.info("[wecom] WeCom bot channel stopped");
|
log.info("[wecom] WeCom bot channel stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doReconnect() {
|
protected void doReconnect() {
|
||||||
log.info("[wecom] Reconnecting WebSocket...");
|
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) {
|
if (heartbeatFuture != null) {
|
||||||
heartbeatFuture.cancel(false);
|
heartbeatFuture.cancel(false);
|
||||||
heartbeatFuture = null;
|
heartbeatFuture = null;
|
||||||
}
|
}
|
||||||
if (webSocket != 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;
|
webSocket = null;
|
||||||
}
|
}
|
||||||
if (wsThread != null) {
|
if (wsThread != null) {
|
||||||
wsThread.interrupt();
|
wsThread.interrupt();
|
||||||
try { wsThread.join(3000); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); }
|
try {
|
||||||
|
wsThread.join(3000);
|
||||||
|
} catch (InterruptedException ignored) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
wsThread = null;
|
wsThread = null;
|
||||||
}
|
}
|
||||||
pendingAcks.forEach((k, f) -> f.completeExceptionally(new RuntimeException("Reconnecting")));
|
pendingAcks.forEach((k, f) ->
|
||||||
|
f.completeExceptionally(new RuntimeException("Channel " + reason)));
|
||||||
pendingAcks.clear();
|
pendingAcks.clear();
|
||||||
replyQueues.clear();
|
replyQueues.clear();
|
||||||
pendingFrames.clear();
|
pendingFrames.clear();
|
||||||
replyContexts.clear();
|
replyContexts.clear();
|
||||||
missedPongCount.set(0);
|
missedPongCount.set(0);
|
||||||
|
|
||||||
if (this.httpClient == null) {
|
this.httpClient = null;
|
||||||
this.httpClient = HttpClient.newBuilder()
|
|
||||||
.connectTimeout(Duration.ofSeconds(10))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
String botId = getConfigString("bot_id");
|
|
||||||
String secret = getConfigString("secret");
|
|
||||||
connectWebSocket(botId, secret);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== WebSocket 连接 ====================
|
// ==================== WebSocket 连接 ====================
|
||||||
@ -263,6 +270,9 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
* 在守护线程中建立 WebSocket 连接
|
* 在守护线程中建立 WebSocket 连接
|
||||||
*/
|
*/
|
||||||
private void connectWebSocket(String botId, String secret) {
|
private void connectWebSocket(String botId, String secret) {
|
||||||
|
// Fresh attempt — allow new failure signals to register again.
|
||||||
|
disconnectInflight.set(false);
|
||||||
|
|
||||||
wsThread = new Thread(() -> {
|
wsThread = new Thread(() -> {
|
||||||
try {
|
try {
|
||||||
log.info("[wecom] WebSocket connecting to {}...", DEFAULT_WS_URL);
|
log.info("[wecom] WebSocket connecting to {}...", DEFAULT_WS_URL);
|
||||||
@ -279,15 +289,60 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[wecom] WebSocket connection failed: {}", e.getMessage(), e);
|
log.error("[wecom] WebSocket connection failed: {}", e.getMessage(), e);
|
||||||
if (running.get()) {
|
handleFailure("WebSocket connection failed: " + e.getMessage());
|
||||||
onDisconnected("WebSocket connection failed: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, "wecom-ws-" + channelEntity.getId());
|
}, "wecom-ws-" + channelEntity.getId());
|
||||||
wsThread.setDaemon(true);
|
wsThread.setDaemon(true);
|
||||||
wsThread.start();
|
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 监听器:接收消息帧并分发处理
|
* WebSocket 监听器:接收消息帧并分发处理
|
||||||
*/
|
*/
|
||||||
@ -328,18 +383,14 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
@Override
|
@Override
|
||||||
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
|
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
|
||||||
log.warn("[wecom] WebSocket closed: code={}, reason={}", statusCode, reason);
|
log.warn("[wecom] WebSocket closed: code={}, reason={}", statusCode, reason);
|
||||||
if (running.get()) {
|
handleFailure("WebSocket closed: code=" + statusCode + ", reason=" + reason);
|
||||||
onDisconnected("WebSocket closed: code=" + statusCode + ", reason=" + reason);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onError(WebSocket webSocket, Throwable error) {
|
public void onError(WebSocket webSocket, Throwable error) {
|
||||||
log.error("[wecom] WebSocket error: {}", error.getMessage());
|
log.error("[wecom] WebSocket error: {}", error.getMessage());
|
||||||
if (running.get()) {
|
handleFailure("WebSocket error: " + error.getMessage());
|
||||||
onDisconnected("WebSocket error: " + error.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -389,10 +440,21 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
if (errcode != null && errcode != 0) {
|
if (errcode != null && errcode != 0) {
|
||||||
log.error("[wecom] Authentication failed: errcode={}, errmsg={}", errcode, frame.get("errmsg"));
|
log.error("[wecom] Authentication failed: errcode={}, errmsg={}", errcode, frame.get("errmsg"));
|
||||||
lastError = "Authentication failed: " + 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;
|
return;
|
||||||
}
|
}
|
||||||
log.info("[wecom] Authentication successful");
|
log.info("[wecom] Authentication successful");
|
||||||
missedPongCount.set(0);
|
missedPongCount.set(0);
|
||||||
|
markReady();
|
||||||
startHeartbeat();
|
startHeartbeat();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -452,9 +514,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
heartbeatFuture.cancel(false);
|
heartbeatFuture.cancel(false);
|
||||||
heartbeatFuture = null;
|
heartbeatFuture = null;
|
||||||
}
|
}
|
||||||
if (running.get()) {
|
handleFailure("Heartbeat timeout: " + missedPongCount.get() + " missed pongs");
|
||||||
onDisconnected("Heartbeat timeout: " + missedPongCount.get() + " missed pongs");
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
mateclaw-ui/public/icons/providers/siliconcloud.svg
Normal file
1
mateclaw-ui/public/icons/providers/siliconcloud.svg
Normal 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 |
@ -228,6 +228,10 @@ export const channelApi = {
|
|||||||
toggle: (id: string | number, enabled: boolean) =>
|
toggle: (id: string | number, enabled: boolean) =>
|
||||||
http.put(`/channels/${id}/toggle?enabled=${enabled}`),
|
http.put(`/channels/${id}/toggle?enabled=${enabled}`),
|
||||||
status: () => http.get('/channels/status'),
|
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 码登录
|
// 微信 iLink Bot QR 码登录
|
||||||
weixinQrcode: () => http.get('/channels/webhook/weixin/qrcode'),
|
weixinQrcode: () => http.get('/channels/webhook/weixin/qrcode'),
|
||||||
weixinQrcodeStatus: (qrcode: string) =>
|
weixinQrcodeStatus: (qrcode: string) =>
|
||||||
|
|||||||
@ -503,11 +503,13 @@ const weixin = useWeixinQrcodePoll(({ botToken, baseUrl }) => {
|
|||||||
form.value.name = t('channels.weixin.newAccountName') + ' (' + suffix + ')'
|
form.value.name = t('channels.weixin.newAccountName') + ' (' + suffix + ')'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
form.value.enabled = true
|
||||||
})
|
})
|
||||||
|
|
||||||
const wecom = useWecomBotAuth((bot) => {
|
const wecom = useWecomBotAuth((bot) => {
|
||||||
channelConfig.value.bot_id = bot.botid
|
channelConfig.value.bot_id = bot.botid
|
||||||
channelConfig.value.secret = bot.secret
|
channelConfig.value.secret = bot.secret
|
||||||
|
form.value.enabled = true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Feishu one-click app registration: scan-to-create flow that returns
|
// Feishu one-click app registration: scan-to-create flow that returns
|
||||||
@ -515,6 +517,10 @@ const wecom = useWecomBotAuth((bot) => {
|
|||||||
const feishuRegister = useFeishuAppRegister(({ appId, appSecret }) => {
|
const feishuRegister = useFeishuAppRegister(({ appId, appSecret }) => {
|
||||||
channelConfig.value.app_id = appId
|
channelConfig.value.app_id = appId
|
||||||
channelConfig.value.app_secret = appSecret
|
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
|
// 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 }) => {
|
const dingtalkRegister = useDingTalkAppRegister(({ clientId, clientSecret }) => {
|
||||||
channelConfig.value.client_id = clientId
|
channelConfig.value.client_id = clientId
|
||||||
channelConfig.value.client_secret = clientSecret
|
channelConfig.value.client_secret = clientSecret
|
||||||
|
form.value.enabled = true
|
||||||
})
|
})
|
||||||
|
|
||||||
// ========== Field defs (derived) ==========
|
// ========== Field defs (derived) ==========
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export default {
|
|||||||
close: 'Close',
|
close: 'Close',
|
||||||
add: 'Add',
|
add: 'Add',
|
||||||
search: 'Search',
|
search: 'Search',
|
||||||
|
clear: 'Clear',
|
||||||
expandSidebar: 'Expand sidebar',
|
expandSidebar: 'Expand sidebar',
|
||||||
collapseSidebar: 'Collapse sidebar',
|
collapseSidebar: 'Collapse sidebar',
|
||||||
manageWorkspaces: 'Manage Workspaces',
|
manageWorkspaces: 'Manage Workspaces',
|
||||||
@ -322,6 +323,21 @@ export default {
|
|||||||
editTitle: 'Provider Settings',
|
editTitle: 'Provider Settings',
|
||||||
manageTitle: 'Manage Models',
|
manageTitle: 'Manage Models',
|
||||||
addModel: 'Add Model',
|
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',
|
activeChanged: 'Active model updated',
|
||||||
providerSaved: 'Provider settings saved',
|
providerSaved: 'Provider settings saved',
|
||||||
providerDeleted: 'Provider deleted',
|
providerDeleted: 'Provider deleted',
|
||||||
@ -378,6 +394,8 @@ export default {
|
|||||||
addProviderDrawerTitle: 'Add Provider',
|
addProviderDrawerTitle: 'Add Provider',
|
||||||
addProviderDrawerSubtitle: 'Once enabled the provider appears in the model dropdown. Cloud providers still need an API key after enabling.',
|
addProviderDrawerSubtitle: 'Once enabled the provider appears in the model dropdown. Cloud providers still need an API key after enabling.',
|
||||||
catalogEmpty: 'Catalog is empty',
|
catalogEmpty: 'Catalog is empty',
|
||||||
|
catalogSearchPlaceholder: 'Search providers…',
|
||||||
|
catalogSearchEmpty: 'No providers match “{query}”',
|
||||||
enabledToast: 'Enabled {name}',
|
enabledToast: 'Enabled {name}',
|
||||||
emptyTitle: 'No providers enabled yet',
|
emptyTitle: 'No providers enabled yet',
|
||||||
emptyDesc: 'Enable a built-in from the catalog, or create a custom provider.',
|
emptyDesc: 'Enable a built-in from the catalog, or create a custom provider.',
|
||||||
@ -445,6 +463,10 @@ export default {
|
|||||||
latency: 'Latency {ms}ms',
|
latency: 'Latency {ms}ms',
|
||||||
modelOk: 'Model available',
|
modelOk: 'Model available',
|
||||||
modelFail: 'Model unavailable',
|
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: {
|
fields: {
|
||||||
@ -1625,6 +1647,7 @@ export default {
|
|||||||
reconnecting: 'Reconnecting',
|
reconnecting: 'Reconnecting',
|
||||||
error: 'Disconnected',
|
error: 'Disconnected',
|
||||||
disconnected: 'Not Connected',
|
disconnected: 'Not Connected',
|
||||||
|
outOfService: 'Not Started',
|
||||||
retryCount: 'Retry #{n}',
|
retryCount: 'Retry #{n}',
|
||||||
errorLabel: 'Error',
|
errorLabel: 'Error',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export default {
|
|||||||
close: '关闭',
|
close: '关闭',
|
||||||
add: '添加',
|
add: '添加',
|
||||||
search: '搜索',
|
search: '搜索',
|
||||||
|
clear: '清除',
|
||||||
expandSidebar: '展开侧边栏',
|
expandSidebar: '展开侧边栏',
|
||||||
collapseSidebar: '折叠侧边栏',
|
collapseSidebar: '折叠侧边栏',
|
||||||
manageWorkspaces: '管理工作区',
|
manageWorkspaces: '管理工作区',
|
||||||
@ -133,7 +134,7 @@ export default {
|
|||||||
configModelFirst: '请先配置模型',
|
configModelFirst: '请先配置模型',
|
||||||
modelUnavailable: '当前模型不可用',
|
modelUnavailable: '当前模型不可用',
|
||||||
noActiveModel: '当前没有激活模型。先到模型管理中选择一个可用模型,再开始对话。',
|
noActiveModel: '当前没有激活模型。先到模型管理中选择一个可用模型,再开始对话。',
|
||||||
providerNotReady: '当前激活模型属于 {name},但这个 Provider 还没配置完成。请到模型管理中补全 API Key 或 Base URL。',
|
providerNotReady: '当前激活模型属于 {name},但这个提供商还没配置完成。请到模型管理中补全 API Key 或 Base URL。',
|
||||||
noAvailableModel: '当前没有可用模型。',
|
noAvailableModel: '当前没有可用模型。',
|
||||||
loadAgentsFailed: '加载 Agent 列表失败',
|
loadAgentsFailed: '加载 Agent 列表失败',
|
||||||
loadModelFailed: '加载模型状态失败',
|
loadModelFailed: '加载模型状态失败',
|
||||||
@ -290,8 +291,8 @@ export default {
|
|||||||
},
|
},
|
||||||
model: {
|
model: {
|
||||||
title: '模型管理',
|
title: '模型管理',
|
||||||
desc: '配置模型 Provider、凭证和模型列表',
|
desc: '配置模型提供商、凭证和模型列表',
|
||||||
addProvider: '新增 Provider',
|
addProvider: '新增提供商',
|
||||||
localProviders: '本地模型',
|
localProviders: '本地模型',
|
||||||
cloudProviders: '云端模型',
|
cloudProviders: '云端模型',
|
||||||
active: '当前激活',
|
active: '当前激活',
|
||||||
@ -308,30 +309,45 @@ export default {
|
|||||||
apiKeyInput: '输入 API Key',
|
apiKeyInput: '输入 API Key',
|
||||||
leaveBlankKeep: '留空则保持当前值不变',
|
leaveBlankKeep: '留空则保持当前值不变',
|
||||||
modelCount: '{count} 个模型',
|
modelCount: '{count} 个模型',
|
||||||
createTitle: '新增 Provider',
|
createTitle: '新增提供商',
|
||||||
editTitle: 'Provider 配置',
|
editTitle: '提供商配置',
|
||||||
manageTitle: '管理模型',
|
manageTitle: '管理模型',
|
||||||
addModel: '新增模型',
|
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: '激活模型已更新',
|
activeChanged: '激活模型已更新',
|
||||||
providerSaved: 'Provider 配置已保存',
|
providerSaved: '提供商配置已保存',
|
||||||
providerDeleted: 'Provider 已删除',
|
providerDeleted: '提供商已删除',
|
||||||
modelAdded: '模型已添加',
|
modelAdded: '模型已添加',
|
||||||
modelRemoved: '模型已移除',
|
modelRemoved: '模型已移除',
|
||||||
modelAddFailed: '模型添加失败',
|
modelAddFailed: '模型添加失败',
|
||||||
modelRemoveFailed: '模型删除失败',
|
modelRemoveFailed: '模型删除失败',
|
||||||
activeChangeFailed: '激活模型切换失败',
|
activeChangeFailed: '激活模型切换失败',
|
||||||
deleteConfirm: '确认删除 Provider “{name}”?',
|
deleteConfirm: '确认删除提供商“{name}”?',
|
||||||
removeConfirm: '确认移除模型 “{name}”?',
|
removeConfirm: '确认移除模型 “{name}”?',
|
||||||
generateConfigInvalidJson: 'Generate Kwargs 不是合法 JSON',
|
generateConfigInvalidJson: 'Generate Kwargs 不是合法 JSON',
|
||||||
generateConfigMustBeObject: 'Generate Kwargs 必须是 JSON 对象',
|
generateConfigMustBeObject: 'Generate Kwargs 必须是 JSON 对象',
|
||||||
advancedSettings: '高级设置',
|
advancedSettings: '高级设置',
|
||||||
protocolHint: '这里选择的是 Provider API 协议,不是前端或后端里的具体类名。',
|
protocolHint: '这里选择的是提供商 API 协议,不是前端或后端里的具体类名。',
|
||||||
protocolOpenAI: 'OpenAI 兼容(Chat Completions)',
|
protocolOpenAI: 'OpenAI 兼容(Chat Completions)',
|
||||||
protocolAnthropic: 'Anthropic(Messages API)',
|
protocolAnthropic: 'Anthropic(Messages API)',
|
||||||
protocolGemini: 'Gemini 原生',
|
protocolGemini: 'Gemini 原生',
|
||||||
protocolDashScope: 'DashScope 原生',
|
protocolDashScope: 'DashScope 原生',
|
||||||
advancedHint: '用于补充 temperature、max_tokens、top_p 等生成参数。',
|
advancedHint: '用于补充 temperature、max_tokens、top_p 等生成参数。',
|
||||||
fallbackPriorityHint: '池内尝试顺序(数字越小越先):0 = 不参与;1 = 第一顺位;2 = 第二顺位,依此类推。多个 provider 共用同一数字时按 ID 字典序。',
|
fallbackPriorityHint: '池内尝试顺序(数字越小越先):0 = 不参与;1 = 第一顺位;2 = 第二顺位,依此类推。多个提供商共用同一数字时按 ID 字典序。',
|
||||||
fallbackBadge: '偏好 #{priority}',
|
fallbackBadge: '偏好 #{priority}',
|
||||||
fallbackBadgeTitle: '可用池内的尝试顺序,数字越小越先尝试',
|
fallbackBadgeTitle: '可用池内的尝试顺序,数字越小越先尝试',
|
||||||
// RFC-009 Phase 4: provider pool status badges
|
// RFC-009 Phase 4: provider pool status badges
|
||||||
@ -358,19 +374,21 @@ export default {
|
|||||||
livenessUnprobedTooltip: '启动后正在检测可用性,稍候自动更新',
|
livenessUnprobedTooltip: '启动后正在检测可用性,稍候自动更新',
|
||||||
livenessUnconfigured: '需要配置',
|
livenessUnconfigured: '需要配置',
|
||||||
// RFC-074 PR-2: enable / disable / drawer
|
// RFC-074 PR-2: enable / disable / drawer
|
||||||
enableProviderCta: '+ 启用 Provider',
|
enableProviderCta: '+ 启用提供商',
|
||||||
addCustomProvider: '+ 自定义',
|
addCustomProvider: '+ 自定义提供商',
|
||||||
enable: '启用',
|
enable: '启用',
|
||||||
disable: '禁用',
|
disable: '禁用',
|
||||||
alreadyEnabled: '已启用',
|
alreadyEnabled: '已启用',
|
||||||
disableConfirm: '确定禁用 Provider "{name}"?它将从下拉中消失,但配置保留,可随时再启用。',
|
disableConfirm: '确定禁用提供商"{name}"?它将从下拉中消失,但配置保留,可随时再启用。',
|
||||||
defaultSwitchedToast: '已将默认模型切换到 {provider} / {model}',
|
defaultSwitchedToast: '已将默认模型切换到 {provider} / {model}',
|
||||||
addProviderDrawerTitle: '添加 Provider',
|
addProviderDrawerTitle: '添加提供商',
|
||||||
addProviderDrawerSubtitle: '启用后会出现在模型下拉中。如果是云端 Provider,启用后还需要填 API Key。',
|
addProviderDrawerSubtitle: '启用后会出现在模型下拉中。如果是云端提供商,启用后还需要填 API Key。',
|
||||||
catalogEmpty: '目录为空',
|
catalogEmpty: '目录为空',
|
||||||
|
catalogSearchPlaceholder: '搜索提供商…',
|
||||||
|
catalogSearchEmpty: '没有匹配“{query}”的提供商',
|
||||||
enabledToast: '已启用 {name}',
|
enabledToast: '已启用 {name}',
|
||||||
emptyTitle: '还没有可用的 Provider',
|
emptyTitle: '还没有可用的提供商',
|
||||||
emptyDesc: '从内置目录中启用一个,或新建一个自定义 Provider。',
|
emptyDesc: '从内置目录中启用一个,或新建一个自定义提供商。',
|
||||||
searchHint: '开启后,大模型将在回答时自动调用内置搜索引擎获取实时信息(DashScope/Kimi/OpenAI 支持)。',
|
searchHint: '开启后,大模型将在回答时自动调用内置搜索引擎获取实时信息(DashScope/Kimi/OpenAI 支持)。',
|
||||||
searchStrategyDefault: '默认',
|
searchStrategyDefault: '默认',
|
||||||
oauthTitle: 'OpenAI OAuth 登录',
|
oauthTitle: 'OpenAI OAuth 登录',
|
||||||
@ -387,8 +405,8 @@ export default {
|
|||||||
claudeCodeOauthInstructions: '未检测到 Claude Code 凭据。请安装 Claude Code 客户端,使用 Pro/Max 账号登录后再点击检测。',
|
claudeCodeOauthInstructions: '未检测到 Claude Code 凭据。请安装 Claude Code 客户端,使用 Pro/Max 账号登录后再点击检测。',
|
||||||
claudeCodeOauthRevokeHint: '请在 Claude Code 客户端中退出登录。MateClaw 不会修改 Claude Code 的本地凭据。',
|
claudeCodeOauthRevokeHint: '请在 Claude Code 客户端中退出登录。MateClaw 不会修改 Claude Code 的本地凭据。',
|
||||||
fields: {
|
fields: {
|
||||||
providerId: 'Provider ID',
|
providerId: '提供商 ID',
|
||||||
providerName: 'Provider 名称',
|
providerName: '提供商名称',
|
||||||
defaultBaseUrl: '默认 Base URL',
|
defaultBaseUrl: '默认 Base URL',
|
||||||
apiKeyPrefix: 'API Key 前缀',
|
apiKeyPrefix: 'API Key 前缀',
|
||||||
protocol: '协议',
|
protocol: '协议',
|
||||||
@ -435,11 +453,15 @@ export default {
|
|||||||
latency: '延迟 {ms}ms',
|
latency: '延迟 {ms}ms',
|
||||||
modelOk: '模型可用',
|
modelOk: '模型可用',
|
||||||
modelFail: '模型不可用',
|
modelFail: '模型不可用',
|
||||||
|
probeVerified: '已验证',
|
||||||
|
probeVerifiedTitle: '已验证可达',
|
||||||
|
probeFailedBanner: '⚠ {count} 个模型未通过可达性检测,未列入上方列表:',
|
||||||
|
probeUnreachable: '无法连接',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
name: '显示名称',
|
name: '显示名称',
|
||||||
provider: 'Provider',
|
provider: '提供商',
|
||||||
modelName: '模型标识',
|
modelName: '模型标识',
|
||||||
description: '描述',
|
description: '描述',
|
||||||
temperature: '温度',
|
temperature: '温度',
|
||||||
@ -459,27 +481,27 @@ export default {
|
|||||||
searxngBaseUrl: 'SearXNG 地址',
|
searxngBaseUrl: 'SearXNG 地址',
|
||||||
// STT 语音识别
|
// STT 语音识别
|
||||||
sttEnabled: '启用语音识别',
|
sttEnabled: '启用语音识别',
|
||||||
sttProvider: '首选 STT Provider',
|
sttProvider: '首选 STT 提供商',
|
||||||
sttFallbackEnabled: 'Provider 回退',
|
sttFallbackEnabled: '提供商回退',
|
||||||
// 音乐生成
|
// 音乐生成
|
||||||
musicEnabled: '启用音乐生成',
|
musicEnabled: '启用音乐生成',
|
||||||
musicProvider: '首选音乐 Provider',
|
musicProvider: '首选音乐提供商',
|
||||||
musicFallbackEnabled: 'Provider 回退',
|
musicFallbackEnabled: '提供商回退',
|
||||||
// TTS 语音合成
|
// TTS 语音合成
|
||||||
ttsEnabled: '启用语音合成',
|
ttsEnabled: '启用语音合成',
|
||||||
ttsProvider: '首选 TTS Provider',
|
ttsProvider: '首选 TTS 提供商',
|
||||||
ttsFallbackEnabled: 'Provider 回退',
|
ttsFallbackEnabled: '提供商回退',
|
||||||
ttsAutoMode: '自动朗读模式',
|
ttsAutoMode: '自动朗读模式',
|
||||||
ttsSpeed: '语速',
|
ttsSpeed: '语速',
|
||||||
// 图片生成
|
// 图片生成
|
||||||
imageEnabled: '启用图片生成',
|
imageEnabled: '启用图片生成',
|
||||||
imageProvider: '首选图片 Provider',
|
imageProvider: '首选图片提供商',
|
||||||
imageFallbackEnabled: 'Provider 回退',
|
imageFallbackEnabled: '提供商回退',
|
||||||
openaiStatus: 'OpenAI 状态',
|
openaiStatus: 'OpenAI 状态',
|
||||||
// 视频生成
|
// 视频生成
|
||||||
videoEnabled: '启用视频生成',
|
videoEnabled: '启用视频生成',
|
||||||
videoProvider: '首选视频 Provider',
|
videoProvider: '首选视频提供商',
|
||||||
videoFallbackEnabled: 'Provider 回退',
|
videoFallbackEnabled: '提供商回退',
|
||||||
dashscopeStatus: 'DashScope 状态',
|
dashscopeStatus: 'DashScope 状态',
|
||||||
zhipuApiKey: '智谱 API Key',
|
zhipuApiKey: '智谱 API Key',
|
||||||
zhipuBaseUrl: '智谱 API 地址',
|
zhipuBaseUrl: '智谱 API 地址',
|
||||||
@ -506,29 +528,29 @@ export default {
|
|||||||
searxngBaseUrl: '自部署 SearXNG 实例地址。Docker 部署时自动配置。',
|
searxngBaseUrl: '自部署 SearXNG 实例地址。Docker 部署时自动配置。',
|
||||||
// STT 语音识别
|
// STT 语音识别
|
||||||
sttEnabled: '开启后支持语音消息转文字。OpenAI Whisper 和 DashScope Paraformer 均复用已有 Key。',
|
sttEnabled: '开启后支持语音消息转文字。OpenAI Whisper 和 DashScope Paraformer 均复用已有 Key。',
|
||||||
sttProvider: '选择首选 STT Provider,auto 模式自动选择可用的 Provider。',
|
sttProvider: '选择首选 STT 提供商,auto 模式自动选择可用的提供商。',
|
||||||
sttFallbackEnabled: '首选 Provider 失败时自动尝试其他已配置的 Provider。',
|
sttFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。',
|
||||||
openaiSttInfo: '复用模型管理中的 OpenAI API Key。使用 Whisper 模型,支持多语言自动识别。',
|
openaiSttInfo: '复用模型管理中的 OpenAI API Key。使用 Whisper 模型,支持多语言自动识别。',
|
||||||
dashscopeSttInfo: '复用模型管理中的 DashScope API Key。使用 Paraformer Realtime(WebSocket 流式),中文识别效果优秀,亚秒级延迟。',
|
dashscopeSttInfo: '复用模型管理中的 DashScope API Key。使用 Paraformer Realtime(WebSocket 流式),中文识别效果优秀,亚秒级延迟。',
|
||||||
// 音乐生成
|
// 音乐生成
|
||||||
musicEnabled: '开启后 Agent 可通过 music_generate 工具生成音乐。Google Lyria 复用 Google Key。',
|
musicEnabled: '开启后 Agent 可通过 music_generate 工具生成音乐。Google Lyria 复用 Google Key。',
|
||||||
musicProvider: '选择首选音乐 Provider,auto 模式优先使用 Google Lyria。',
|
musicProvider: '选择首选音乐提供商,auto 模式优先使用 Google Lyria。',
|
||||||
musicFallbackEnabled: '首选 Provider 失败时自动尝试其他已配置的 Provider。',
|
musicFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。',
|
||||||
googleLyriaInfo: '复用模型管理中的 Google API Key。Lyria 3 模型,支持歌词谱曲和纯音乐生成。',
|
googleLyriaInfo: '复用模型管理中的 Google API Key。Lyria 3 模型,支持歌词谱曲和纯音乐生成。',
|
||||||
minimaxMusicInfo: '复用视频生成中的 MiniMax API Key。music-2.5+ 模型,支持歌词和纯音乐。',
|
minimaxMusicInfo: '复用视频生成中的 MiniMax API Key。music-2.5+ 模型,支持歌词和纯音乐。',
|
||||||
// TTS 语音合成
|
// TTS 语音合成
|
||||||
ttsEnabled: '开启后可通过消息朗读按钮或自动模式使用语音合成。Edge TTS 免费无需 Key。',
|
ttsEnabled: '开启后可通过消息朗读按钮或自动模式使用语音合成。Edge TTS 免费无需 Key。',
|
||||||
ttsProvider: '选择首选 TTS Provider,auto 模式优先使用免费的 Edge TTS。',
|
ttsProvider: '选择首选 TTS 提供商,auto 模式优先使用免费的 Edge TTS。',
|
||||||
ttsFallbackEnabled: '首选 Provider 失败时自动尝试其他已配置的 Provider。',
|
ttsFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。',
|
||||||
ttsAutoMode: 'off = 仅手动朗读,always = 每条 AI 回复自动播放语音。',
|
ttsAutoMode: 'off = 仅手动朗读,always = 每条 AI 回复自动播放语音。',
|
||||||
ttsSpeed: '语音播放速度,1.0 为正常速度。',
|
ttsSpeed: '语音播放速度,1.0 为正常速度。',
|
||||||
edgeTtsInfo: '微软 Edge 内置 TTS 服务,免费使用,支持中文、英文、日文等多语言。自动根据文本语言切换语音。',
|
edgeTtsInfo: '微软 Edge 内置 TTS 服务,免费使用,支持中文、英文、日文等多语言。自动根据文本语言切换语音。',
|
||||||
dashscopeTtsInfo: '复用模型管理中的 DashScope API Key。使用 CosyVoice 模型,支持多种中文语音。',
|
dashscopeTtsInfo: '复用模型管理中的 DashScope API Key。使用 CosyVoice 模型,支持多种中文语音。',
|
||||||
openaiTtsInfo: '复用模型管理中的 OpenAI API Key。支持 alloy、echo、nova 等多种语音风格。',
|
openaiTtsInfo: '复用模型管理中的 OpenAI API Key。支持 alloy、echo、nova 等多种语音风格。',
|
||||||
// 图片生成
|
// 图片生成
|
||||||
imageEnabled: '开启后 Agent 可使用图片生成工具。需至少配置一个图片 Provider 的 API Key。',
|
imageEnabled: '开启后 Agent 可使用图片生成工具。需至少配置一个图片提供商的 API Key。',
|
||||||
imageProvider: '选择首选图片生成 Provider,auto 模式自动选择第一个可用的。',
|
imageProvider: '选择首选图片生成提供商,auto 模式自动选择第一个可用的。',
|
||||||
imageFallbackEnabled: '首选 Provider 失败时自动尝试其他已配置的 Provider。',
|
imageFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。',
|
||||||
dashscopeImageStatus: '复用模型管理中配置的 DashScope API Key,无需额外配置。',
|
dashscopeImageStatus: '复用模型管理中配置的 DashScope API Key,无需额外配置。',
|
||||||
openaiImageStatus: '复用模型管理中配置的 OpenAI API Key,无需额外配置。',
|
openaiImageStatus: '复用模型管理中配置的 OpenAI API Key,无需额外配置。',
|
||||||
zhipuImageApiKey: '从 bigmodel.cn 获取。CogView-3-Flash 模型免费。与视频生成共用同一 Key。',
|
zhipuImageApiKey: '从 bigmodel.cn 获取。CogView-3-Flash 模型免费。与视频生成共用同一 Key。',
|
||||||
@ -536,9 +558,9 @@ export default {
|
|||||||
googleImagenInfo: '复用模型管理中的 Google API Key。支持 Gemini 图片生成和 Imagen 4.0 模型。',
|
googleImagenInfo: '复用模型管理中的 Google API Key。支持 Gemini 图片生成和 Imagen 4.0 模型。',
|
||||||
minimaxImageInfo: '复用视频生成中的 MiniMax API Key。image-01 模型,支持多种画面比例,最多 9 张。',
|
minimaxImageInfo: '复用视频生成中的 MiniMax API Key。image-01 模型,支持多种画面比例,最多 9 张。',
|
||||||
// 视频生成
|
// 视频生成
|
||||||
videoEnabled: '开启后 Agent 可使用视频生成工具。需至少配置一个视频 Provider 的 API Key。',
|
videoEnabled: '开启后 Agent 可使用视频生成工具。需至少配置一个视频提供商的 API Key。',
|
||||||
videoProvider: '选择首选视频生成 Provider,auto 模式自动选择第一个可用的。',
|
videoProvider: '选择首选视频生成提供商,auto 模式自动选择第一个可用的。',
|
||||||
videoFallbackEnabled: '首选 Provider 失败时自动尝试其他已配置的 Provider。',
|
videoFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。',
|
||||||
dashscopeVideoStatus: '复用模型管理中配置的 DashScope API Key,无需额外配置。',
|
dashscopeVideoStatus: '复用模型管理中配置的 DashScope API Key,无需额外配置。',
|
||||||
zhipuApiKey: '从 bigmodel.cn 获取,CogVideoX-Flash 模型免费。',
|
zhipuApiKey: '从 bigmodel.cn 获取,CogVideoX-Flash 模型免费。',
|
||||||
zhipuBaseUrl: '通常无需修改,除非使用自定义代理地址。',
|
zhipuBaseUrl: '通常无需修改,除非使用自定义代理地址。',
|
||||||
@ -666,7 +688,7 @@ export default {
|
|||||||
basic: '基本信息',
|
basic: '基本信息',
|
||||||
skills: '技能',
|
skills: '技能',
|
||||||
tools: '工具',
|
tools: '工具',
|
||||||
providers: '偏好 Provider',
|
providers: '偏好提供商',
|
||||||
context: '上下文',
|
context: '上下文',
|
||||||
},
|
},
|
||||||
columns: {
|
columns: {
|
||||||
@ -739,8 +761,8 @@ export default {
|
|||||||
binding: {
|
binding: {
|
||||||
skillsHint: '选择此智能体可使用的技能。留空则使用所有已启用的技能。',
|
skillsHint: '选择此智能体可使用的技能。留空则使用所有已启用的技能。',
|
||||||
toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。',
|
toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。',
|
||||||
providersHint: '此智能体优先使用的 Provider 顺序(数字越小越先尝试)。留空则按全局可用池顺序回退。Provider 进入冷却或被移出池时仍会被自动跳过。',
|
providersHint: '此智能体优先使用的提供商顺序(数字越小越先尝试)。留空则按全局可用池顺序回退。提供商进入冷却或被移出池时仍会被自动跳过。',
|
||||||
providersAddHint: '点击下方 Provider 加入偏好列表:',
|
providersAddHint: '点击下方提供商加入偏好列表:',
|
||||||
noSkills: '暂无可用技能',
|
noSkills: '暂无可用技能',
|
||||||
noTools: '暂无可用工具',
|
noTools: '暂无可用工具',
|
||||||
noProviderPreferences: '尚未配置偏好顺序,将按全局回退链顺序使用。',
|
noProviderPreferences: '尚未配置偏好顺序,将按全局回退链顺序使用。',
|
||||||
@ -1007,7 +1029,7 @@ export default {
|
|||||||
byModel: '按模型统计',
|
byModel: '按模型统计',
|
||||||
byDate: '按日期统计',
|
byDate: '按日期统计',
|
||||||
model: '模型',
|
model: '模型',
|
||||||
provider: 'Provider',
|
provider: '提供商',
|
||||||
date: '日期',
|
date: '日期',
|
||||||
messageCount: '消息数',
|
messageCount: '消息数',
|
||||||
loadFailed: '加载 Token 统计失败',
|
loadFailed: '加载 Token 统计失败',
|
||||||
@ -1167,7 +1189,7 @@ export default {
|
|||||||
status: '状态',
|
status: '状态',
|
||||||
tools: '工具',
|
tools: '工具',
|
||||||
channels: '渠道',
|
channels: '渠道',
|
||||||
provider: 'Provider',
|
provider: '提供商',
|
||||||
memoryProvider: '记忆',
|
memoryProvider: '记忆',
|
||||||
noDescription: '暂无描述',
|
noDescription: '暂无描述',
|
||||||
emptyTitle: '暂无已安装插件',
|
emptyTitle: '暂无已安装插件',
|
||||||
@ -1635,6 +1657,7 @@ export default {
|
|||||||
reconnecting: '重连中',
|
reconnecting: '重连中',
|
||||||
error: '断开',
|
error: '断开',
|
||||||
disconnected: '未连接',
|
disconnected: '未连接',
|
||||||
|
outOfService: '未启动',
|
||||||
retryCount: '重试 #{n}',
|
retryCount: '重试 #{n}',
|
||||||
errorLabel: '错误',
|
errorLabel: '错误',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -196,19 +196,32 @@ async function loadChannels() {
|
|||||||
|
|
||||||
async function loadStatus() {
|
async function loadStatus() {
|
||||||
try {
|
try {
|
||||||
const res: any = await channelApi.status()
|
// Prefer the new typed health endpoint — it surfaces OUT_OF_SERVICE
|
||||||
const statusData = res.data
|
// (enabled in DB but adapter not active, e.g. start failed silently)
|
||||||
if (statusData && Array.isArray(statusData.channels)) {
|
// which the legacy /status endpoint conflated with DISCONNECTED. The
|
||||||
const map: Record<number, any> = {}
|
// older shape is still returned by the fallback below in case the
|
||||||
for (const ch of statusData.channels) {
|
// backend is mid-rollout.
|
||||||
map[ch.id] = {
|
const res: any = await channelApi.healthAll()
|
||||||
connectionState: ch.connectionState || 'DISCONNECTED',
|
const list: any[] = res.data || []
|
||||||
lastError: ch.lastError || null,
|
const map: Record<number, any> = {}
|
||||||
reconnectAttempts: ch.reconnectAttempts || 0,
|
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 {
|
} catch {
|
||||||
// silent — next poll will retry
|
// silent — next poll will retry
|
||||||
}
|
}
|
||||||
@ -225,6 +238,7 @@ function getConnectionIcon(channel: Channel): string {
|
|||||||
case 'CONNECTED': return '🟢'
|
case 'CONNECTED': return '🟢'
|
||||||
case 'RECONNECTING': return '🟡'
|
case 'RECONNECTING': return '🟡'
|
||||||
case 'ERROR': return '🔴'
|
case 'ERROR': return '🔴'
|
||||||
|
case 'OUT_OF_SERVICE': return '🟠'
|
||||||
default: return '⚪'
|
default: return '⚪'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -234,6 +248,7 @@ function getConnectionLabel(channel: Channel): string {
|
|||||||
case 'CONNECTED': return t('channels.connection.connected')
|
case 'CONNECTED': return t('channels.connection.connected')
|
||||||
case 'RECONNECTING': return t('channels.connection.reconnecting')
|
case 'RECONNECTING': return t('channels.connection.reconnecting')
|
||||||
case 'ERROR': return t('channels.connection.error')
|
case 'ERROR': return t('channels.connection.error')
|
||||||
|
case 'OUT_OF_SERVICE': return t('channels.connection.outOfService')
|
||||||
case 'DISCONNECTED': return t('channels.connection.disconnected')
|
case 'DISCONNECTED': return t('channels.connection.disconnected')
|
||||||
default: return ''
|
default: return ''
|
||||||
}
|
}
|
||||||
@ -244,6 +259,7 @@ function getConnectionClass(channel: Channel): string {
|
|||||||
case 'CONNECTED': return 'conn-connected'
|
case 'CONNECTED': return 'conn-connected'
|
||||||
case 'RECONNECTING': return 'conn-reconnecting'
|
case 'RECONNECTING': return 'conn-reconnecting'
|
||||||
case 'ERROR': return 'conn-error'
|
case 'ERROR': return 'conn-error'
|
||||||
|
case 'OUT_OF_SERVICE': return 'conn-out-of-service'
|
||||||
default: return 'conn-disconnected'
|
default: return 'conn-disconnected'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -367,6 +383,7 @@ function getChannelIconPath(type: string) {
|
|||||||
.conn-connected { color: var(--mc-primary); background: var(--mc-primary-bg); }
|
.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-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-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); }
|
.conn-disconnected { color: var(--mc-text-tertiary); background: var(--mc-bg-sunken); }
|
||||||
@keyframes pulse-reconnecting { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
@keyframes pulse-reconnecting { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||||
|
|
||||||
|
|||||||
@ -15,51 +15,89 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div class="drawer-content">
|
||||||
<!-- Cloud providers -->
|
<!-- Cloud providers -->
|
||||||
<section v-if="cloudGroup.length" class="drawer-group">
|
<section v-if="cloudGroup.length" class="drawer-group">
|
||||||
<h4 class="drawer-group-title">{{ t('settings.model.cloudProviders') }}</h4>
|
<h4 class="drawer-group-title">{{ t('settings.model.cloudProviders') }}</h4>
|
||||||
<div class="provider-row" v-for="p in cloudGroup" :key="p.id">
|
<div class="drawer-group__list">
|
||||||
<img class="row-icon" :src="getProviderIcon(p.id)" :alt="p.name" @error="onIconError" />
|
<ProviderCatalogRow
|
||||||
<div class="row-meta">
|
v-for="p in cloudGroup"
|
||||||
<div class="row-name">{{ p.name }}</div>
|
:key="p.id"
|
||||||
<div class="row-id">{{ p.id }}</div>
|
:provider="p"
|
||||||
</div>
|
:toggling-id="togglingId"
|
||||||
<button
|
:get-provider-icon="getProviderIcon"
|
||||||
v-if="!p.enabled"
|
:on-icon-error="onIconError"
|
||||||
class="row-cta"
|
@enable="onEnable"
|
||||||
: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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Local providers -->
|
<!-- Local providers -->
|
||||||
<section v-if="localGroup.length" class="drawer-group">
|
<section v-if="localGroup.length" class="drawer-group">
|
||||||
<h4 class="drawer-group-title">{{ t('settings.model.localProviders') }}</h4>
|
<h4 class="drawer-group-title">{{ t('settings.model.localProviders') }}</h4>
|
||||||
<div class="provider-row" v-for="p in localGroup" :key="p.id">
|
<div class="drawer-group__list">
|
||||||
<img class="row-icon" :src="getProviderIcon(p.id)" :alt="p.name" @error="onIconError" />
|
<ProviderCatalogRow
|
||||||
<div class="row-meta">
|
v-for="p in localGroup"
|
||||||
<div class="row-name">{{ p.name }}</div>
|
:key="p.id"
|
||||||
<div class="row-id">{{ p.id }}</div>
|
:provider="p"
|
||||||
</div>
|
:toggling-id="togglingId"
|
||||||
<button
|
:get-provider-icon="getProviderIcon"
|
||||||
v-if="!p.enabled"
|
:on-icon-error="onIconError"
|
||||||
class="row-cta"
|
@enable="onEnable"
|
||||||
: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>
|
</div>
|
||||||
</section>
|
</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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -69,10 +107,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import type { ProviderInfo } from '@/types'
|
import type { ProviderInfo } from '@/types'
|
||||||
|
import ProviderCatalogRow from './ProviderCatalogRow.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
@ -83,25 +122,63 @@ const props = defineProps<{
|
|||||||
enableProvider: (id: string) => Promise<unknown>
|
enableProvider: (id: string) => Promise<unknown>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
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
|
// Sort: enabled rows sink to the bottom of each group so the actionable
|
||||||
// (still-disabled) options sit at the top where the user lands.
|
// (still-disabled) options sit at the top where the user lands.
|
||||||
const cloudGroup = computed(() =>
|
const cloudGroup = computed(() =>
|
||||||
props.catalog
|
filteredCatalog.value
|
||||||
.filter(p => !p.isLocal)
|
.filter(p => !p.isLocal)
|
||||||
.sort((a, b) => Number(!!a.enabled) - Number(!!b.enabled) || a.name.localeCompare(b.name))
|
.sort((a, b) => Number(!!a.enabled) - Number(!!b.enabled) || a.name.localeCompare(b.name))
|
||||||
)
|
)
|
||||||
const localGroup = computed(() =>
|
const localGroup = computed(() =>
|
||||||
props.catalog
|
filteredCatalog.value
|
||||||
.filter(p => p.isLocal)
|
.filter(p => p.isLocal)
|
||||||
.sort((a, b) => Number(!!a.enabled) - Number(!!b.enabled) || a.name.localeCompare(b.name))
|
.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) {
|
async function onEnable(p: ProviderInfo) {
|
||||||
await props.enableProvider(p.id)
|
await props.enableProvider(p.id)
|
||||||
ElMessage.success(t('settings.model.enabledToast', { name: p.name }))
|
ElMessage.success(t('settings.model.enabledToast', { name: p.name }))
|
||||||
@ -109,126 +186,243 @@ async function onEnable(p: ProviderInfo) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.drawer-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
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;
|
z-index: 1500;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
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 {
|
.drawer-panel {
|
||||||
width: 460px;
|
width: 460px;
|
||||||
max-width: 92vw;
|
max-width: 92vw;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--mc-bg-elevated);
|
background: rgba(255, 250, 245, 0.72);
|
||||||
border-left: 1px solid var(--mc-border);
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
@keyframes drawer-slide {
|
||||||
from { transform: translateX(100%); }
|
from { transform: translateX(100%); }
|
||||||
to { transform: translateX(0); }
|
to { transform: translateX(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-header {
|
.drawer-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 20px 24px;
|
padding: 24px 28px 20px;
|
||||||
border-bottom: 1px solid var(--mc-border-light);
|
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 {
|
.drawer-title {
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
font-size: 17px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
color: var(--mc-text-primary);
|
color: var(--mc-text-primary);
|
||||||
}
|
}
|
||||||
.drawer-subtitle {
|
.drawer-subtitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
color: var(--mc-text-tertiary);
|
color: var(--mc-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-close {
|
.drawer-close {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 6px;
|
padding: 8px;
|
||||||
border-radius: 8px;
|
border-radius: 999px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--mc-text-secondary);
|
color: var(--mc-text-tertiary);
|
||||||
flex-shrink: 0;
|
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 {
|
.drawer-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 16px 20px 32px;
|
padding: 16px 20px 32px;
|
||||||
}
|
}
|
||||||
.drawer-group + .drawer-group { margin-top: 28px; }
|
|
||||||
|
.drawer-group + .drawer-group { margin-top: 24px; }
|
||||||
|
|
||||||
.drawer-group-title {
|
.drawer-group-title {
|
||||||
margin: 0 0 10px;
|
margin: 0 4px 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: none;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0;
|
||||||
color: var(--mc-text-tertiary);
|
color: var(--mc-text-tertiary);
|
||||||
}
|
}
|
||||||
.provider-row {
|
|
||||||
display: flex;
|
/*
|
||||||
align-items: center;
|
* Apple System-Settings style: one rounded card containing N rows
|
||||||
gap: 12px;
|
* with hairline dividers between them — instead of N bordered cards.
|
||||||
padding: 12px;
|
*/
|
||||||
border: 1px solid var(--mc-border);
|
.drawer-group__list {
|
||||||
border-radius: 12px;
|
border-radius: 14px;
|
||||||
background: var(--mc-bg-elevated);
|
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; }
|
:global(html.dark .drawer-group__list) {
|
||||||
.row-icon { width: 32px; height: 32px; flex-shrink: 0; border-radius: 8px; object-fit: contain; }
|
/* Slightly more visible card surface in dark — at 0.04 the rounded
|
||||||
.row-meta { flex: 1; min-width: 0; }
|
container blended into the panel and lost its "card" affordance. */
|
||||||
.row-name { font-size: 14px; font-weight: 600; color: var(--mc-text-primary); }
|
background: rgba(255, 255, 255, 0.06);
|
||||||
.row-id { margin-top: 2px; font-size: 12px; color: var(--mc-text-tertiary); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-empty {
|
.drawer-empty {
|
||||||
padding: 40px 20px;
|
padding: 40px 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--mc-text-tertiary);
|
color: var(--mc-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-fade-enter-active, .drawer-fade-leave-active {
|
.drawer-fade-enter-active,
|
||||||
transition: opacity 0.18s ease;
|
.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) {
|
@media (max-width: 768px) {
|
||||||
.drawer-overlay { justify-content: stretch; }
|
.drawer-overlay {
|
||||||
|
justify-content: stretch;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
.drawer-panel {
|
.drawer-panel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
height: 92vh;
|
||||||
border-left: 0;
|
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 {
|
@keyframes drawer-slide-up {
|
||||||
from { transform: translateY(100%); }
|
from { transform: translateY(100%); }
|
||||||
|
|||||||
@ -1,102 +1,157 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="card">
|
||||||
<div class="provider-header">
|
<!-- HEADLINE: icon · name · status pill -->
|
||||||
<div>
|
<!-- Note: the "active" left rail lives on the outer .provider-card
|
||||||
<div class="provider-title-row">
|
wrapper (in index.vue) so it can ride the wrapper's border-radius
|
||||||
<span class="provider-icon-shell">
|
via inset box-shadow — pulling it into this child made it leak
|
||||||
<img
|
outside the rounded corners. -->
|
||||||
:src="getProviderIcon(provider.id)"
|
|
||||||
:alt="provider.name"
|
<div class="card-headline">
|
||||||
class="provider-icon"
|
<span class="provider-icon-shell">
|
||||||
@error="onIconError"
|
<img
|
||||||
/>
|
:src="getProviderIcon(provider.id)"
|
||||||
</span>
|
:alt="provider.name"
|
||||||
<h3 class="provider-name">{{ provider.name }}</h3>
|
class="provider-icon"
|
||||||
<span class="provider-badge" :class="provider.isCustom ? 'custom' : 'builtin'">
|
@error="onIconError"
|
||||||
{{ provider.isCustom ? t('settings.model.custom') : t('settings.model.builtin') }}
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="isProviderActive(provider)" class="provider-badge active">
|
<div class="card-headline__meta">
|
||||||
{{ t('settings.model.active') }}
|
<h3 class="provider-name">{{ provider.name }}</h3>
|
||||||
</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>
|
|
||||||
<p class="provider-id">{{ provider.id }}</p>
|
<p class="provider-id">{{ provider.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="provider-status" :class="providerStatus(provider).type">
|
<span
|
||||||
{{ providerStatus(provider).label }}
|
class="status-pill"
|
||||||
</div>
|
:class="`status-pill--${headlineStatus.tone}`"
|
||||||
|
:title="headlineStatus.title"
|
||||||
|
>
|
||||||
|
<span v-if="headlineStatus.dot" class="status-pill__dot"></span>
|
||||||
|
{{ headlineStatus.label }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="provider-info">
|
<!-- SUBLINE: model count + active marker -->
|
||||||
<div class="info-row">
|
<p class="card-subline">
|
||||||
<span class="info-label">{{ t('settings.model.baseUrl') }}</span>
|
{{ t('settings.model.modelCountAvailable', { count: totalModelCount }) }}
|
||||||
<span class="info-value mono" :title="provider.baseUrl || ''">
|
<span v-if="isProviderActive(provider)" class="card-subline__active">
|
||||||
{{ provider.baseUrl || t('settings.model.notSet') }}
|
· {{ 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>
|
</span>
|
||||||
</div>
|
<span v-else>
|
||||||
<div v-if="provider.authType === 'oauth'" class="info-row">
|
{{ t('settings.model.discovery.connectionFail') }}: {{ connectionResults[provider.id].errorMessage }}
|
||||||
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- SECONDARY ACTIONS -->
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button class="card-btn" @click="$emit('manage-models', provider)">
|
<button class="card-btn" @click="$emit('manage-models', provider)">
|
||||||
{{ t('settings.model.actions.manageModels') }}
|
{{ t('settings.model.actions.manageModels') }}
|
||||||
@ -107,84 +162,204 @@
|
|||||||
<button
|
<button
|
||||||
v-if="provider.supportConnectionCheck && provider.configured"
|
v-if="provider.supportConnectionCheck && provider.configured"
|
||||||
class="card-btn"
|
class="card-btn"
|
||||||
:class="{ testing: connectionTestingId === provider.id }"
|
:class="{ 'card-btn--busy': connectionTestingId === provider.id }"
|
||||||
:disabled="connectionTestingId === provider.id"
|
:disabled="connectionTestingId === provider.id"
|
||||||
@click="$emit('test-connection', provider)"
|
@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>
|
||||||
<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
|
<button
|
||||||
v-if="provider.liveness === 'REMOVED' || provider.liveness === 'COOLDOWN'"
|
v-if="provider.liveness === 'REMOVED' || provider.liveness === 'COOLDOWN'"
|
||||||
class="card-btn"
|
class="card-btn"
|
||||||
:class="{ testing: reprobing }"
|
:class="{ 'card-btn--busy': reprobing }"
|
||||||
:disabled="reprobing"
|
:disabled="reprobing"
|
||||||
@click="$emit('reprobe', provider)"
|
@click="$emit('reprobe', provider)"
|
||||||
>
|
>
|
||||||
{{ reprobing ? t('settings.model.poolReprobing') : t('settings.model.poolReprobe') }}
|
{{ reprobing
|
||||||
|
? t('settings.model.poolReprobing')
|
||||||
|
: t('settings.model.poolReprobe') }}
|
||||||
</button>
|
</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
|
<button
|
||||||
v-if="provider.enabled"
|
v-if="provider.enabled"
|
||||||
class="card-btn danger-soft"
|
class="card-btn card-btn--danger-soft"
|
||||||
@click="$emit('disable-provider', provider)"
|
@click="$emit('disable-provider', provider)"
|
||||||
>
|
>
|
||||||
{{ t('settings.model.disable') }}
|
{{ t('settings.model.disable') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
|
v-if="provider.isCustom"
|
||||||
<div v-if="connectionResults[provider.id]" class="connection-result" :class="connectionResults[provider.id].success ? 'success' : 'error'">
|
class="card-btn card-btn--danger"
|
||||||
<span v-if="connectionResults[provider.id].success">
|
@click="$emit('delete-provider', provider)"
|
||||||
{{ t('settings.model.discovery.connectionOk') }} · {{ t('settings.model.discovery.latency', { ms: connectionResults[provider.id].latencyMs }) }}
|
>
|
||||||
</span>
|
{{ t('common.delete') }}
|
||||||
<span v-else>
|
</button>
|
||||||
{{ t('settings.model.discovery.connectionFail') }}: {{ connectionResults[provider.id].errorMessage }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { ProviderInfo } from '@/types'
|
import type { ProviderInfo } from '@/types'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
provider: ProviderInfo
|
provider: ProviderInfo
|
||||||
connectionTestingId: string | null
|
connectionTestingId: string | null
|
||||||
connectionResults: Record<string, any>
|
connectionResults: Record<string, { success: boolean; latencyMs?: number; errorMessage?: string }>
|
||||||
// RFC-073: true while a manual reprobe is in flight for this provider.
|
/** True while a manual reprobe is in flight for this provider. */
|
||||||
reprobing?: boolean
|
reprobing?: boolean
|
||||||
|
/** Provider id whose inline API key save is in flight (or null). */
|
||||||
|
savingApiKeyId?: string | null
|
||||||
isProviderActive: (provider: ProviderInfo) => boolean
|
isProviderActive: (provider: ProviderInfo) => boolean
|
||||||
providerStatus: (provider: ProviderInfo) => { type: string; label: string }
|
providerStatus: (provider: ProviderInfo) => { type: string; label: string }
|
||||||
getProviderIcon: (id: string) => string
|
getProviderIcon: (id: string) => string
|
||||||
onIconError: (e: Event) => void
|
onIconError: (e: Event) => void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
'manage-models': [provider: ProviderInfo]
|
'manage-models': [provider: ProviderInfo]
|
||||||
'provider-settings': [provider: ProviderInfo]
|
'provider-settings': [provider: ProviderInfo]
|
||||||
'test-connection': [provider: ProviderInfo]
|
'test-connection': [provider: ProviderInfo]
|
||||||
'delete-provider': [provider: ProviderInfo]
|
'delete-provider': [provider: ProviderInfo]
|
||||||
'disable-provider': [provider: ProviderInfo]
|
'disable-provider': [provider: ProviderInfo]
|
||||||
'reprobe': [provider: ProviderInfo]
|
'reprobe': [provider: ProviderInfo]
|
||||||
|
'oauth-login': [provider: ProviderInfo]
|
||||||
|
'save-api-key': [payload: { provider: ProviderInfo; apiKey: string }]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.provider-icon-shell {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
@ -200,16 +375,13 @@ const { t } = useI18n()
|
|||||||
inset 0 1px 0 rgba(255, 255, 255, 0.92),
|
inset 0 1px 0 rgba(255, 255, 255, 0.92),
|
||||||
0 6px 16px rgba(25, 14, 8, 0.14);
|
0 6px 16px rgba(25, 14, 8, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.provider-icon {
|
.provider-icon {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
flex-shrink: 0;
|
|
||||||
filter: drop-shadow(0 1px 1px rgba(44, 24, 10, 0.12));
|
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);
|
border-color: rgba(255, 248, 241, 0.28);
|
||||||
background: linear-gradient(180deg, #fffdfb, #f3e8dc);
|
background: linear-gradient(180deg, #fffdfb, #f3e8dc);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@ -217,49 +389,319 @@ const { t } = useI18n()
|
|||||||
0 8px 22px rgba(0, 0, 0, 0.26);
|
0 8px 22px rgba(0, 0, 0, 0.26);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(html.dark) .provider-icon {
|
/* STATUS PILL — single source of truth, four tones */
|
||||||
filter: drop-shadow(0 1px 1px rgba(44, 24, 10, 0.18));
|
.status-pill {
|
||||||
}
|
flex-shrink: 0;
|
||||||
.provider-name { margin: 0; font-size: 18px; color: var(--mc-text-primary); }
|
display: inline-flex;
|
||||||
.provider-id { margin: 6px 0 0; font-size: 13px; color: var(--mc-primary); }
|
align-items: center;
|
||||||
.provider-badge { display: inline-flex; align-items: center; border-radius: 999px; padding: 3px 9px; font-size: 12px; font-weight: 600; }
|
gap: 6px;
|
||||||
.provider-badge.builtin { background: var(--mc-primary-bg); color: var(--mc-primary); }
|
padding: 4px 10px;
|
||||||
.provider-badge.custom { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
|
border-radius: 999px;
|
||||||
.provider-badge.active { background: rgba(217, 119, 87, 0.12); color: var(--mc-primary-light); }
|
font-size: 11px;
|
||||||
.provider-badge.fallback { background: rgba(99, 102, 241, 0.12); color: #6366f1; cursor: help; }
|
font-weight: 700;
|
||||||
/* RFC-009 Phase 4: pool status. Green = healthy, amber = cooldown, red = removed. */
|
letter-spacing: 0.02em;
|
||||||
.provider-badge.pool-in { background: rgba(34, 197, 94, 0.12); color: #16a34a; cursor: help; }
|
text-transform: uppercase;
|
||||||
.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);
|
|
||||||
cursor: help;
|
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; } }
|
.status-pill__dot {
|
||||||
.provider-status { flex-shrink: 0; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
|
display: inline-block;
|
||||||
.provider-status.configured { background: var(--mc-primary-bg); color: var(--mc-primary); }
|
width: 6px;
|
||||||
.provider-status.partial { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
|
height: 6px;
|
||||||
.provider-status.unavailable { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
|
border-radius: 50%;
|
||||||
.provider-info { display: grid; gap: 10px; }
|
background: currentColor;
|
||||||
.info-row { display: flex; justify-content: space-between; gap: 12px; }
|
}
|
||||||
.info-label { color: var(--mc-text-secondary); font-size: 13px; }
|
.status-pill--ok {
|
||||||
.info-value { color: var(--mc-text-primary); font-size: 13px; text-align: right; word-break: break-all; }
|
background: rgba(34, 197, 94, 0.14);
|
||||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
color: #16a34a;
|
||||||
.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); }
|
.status-pill--warn {
|
||||||
.card-btn:hover { background: rgba(217, 119, 87, 0.18); }
|
background: rgba(245, 158, 11, 0.16);
|
||||||
.card-btn.danger { background: var(--mc-danger-bg); color: var(--mc-danger); }
|
color: #b45309;
|
||||||
/* 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); }
|
.status-pill--err {
|
||||||
.card-btn.danger-soft:hover { background: var(--mc-danger-bg); color: var(--mc-danger); }
|
background: rgba(239, 68, 68, 0.14);
|
||||||
.card-btn.testing { opacity: 0.6; cursor: wait; }
|
color: #dc2626;
|
||||||
.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); }
|
.status-pill--pending {
|
||||||
.connection-result.error { background: var(--mc-danger-bg); color: var(--mc-danger); }
|
background: rgba(156, 163, 175, 0.16);
|
||||||
.oauth-card-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 6px; font-size: 12px; font-weight: 600; }
|
color: var(--mc-text-tertiary);
|
||||||
.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--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>
|
</style>
|
||||||
|
|||||||
136
mateclaw-ui/src/views/Settings/Models/ProviderCatalogRow.vue
Normal file
136
mateclaw-ui/src/views/Settings/Models/ProviderCatalogRow.vue
Normal 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>
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
import { modelApi } from '@/api'
|
import { modelApi } from '@/api'
|
||||||
import type { ProviderInfo } from '@/types'
|
import type { ProviderInfo } from '@/types'
|
||||||
import { safeParseJson } from '@/utils/safeJson'
|
import { safeParseJson } from '@/utils/safeJson'
|
||||||
@ -184,8 +185,40 @@ export function useProviderForm(deps: ListDeps) {
|
|||||||
await Promise.all([deps.loadProviders(), deps.loadActiveModel()])
|
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) {
|
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
|
return false
|
||||||
}
|
}
|
||||||
await modelApi.deleteCustomProvider(provider.id)
|
await modelApi.deleteCustomProvider(provider.id)
|
||||||
@ -206,6 +239,7 @@ export function useProviderForm(deps: ListDeps) {
|
|||||||
openProviderConfigModal,
|
openProviderConfigModal,
|
||||||
closeProviderModal,
|
closeProviderModal,
|
||||||
saveProvider,
|
saveProvider,
|
||||||
|
saveProviderApiKey,
|
||||||
deleteProvider,
|
deleteProvider,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -74,6 +74,8 @@ export function useProviderList() {
|
|||||||
'dashscope': '/icons/providers/dashscope.png',
|
'dashscope': '/icons/providers/dashscope.png',
|
||||||
'modelscope': '/icons/providers/modelscope.svg',
|
'modelscope': '/icons/providers/modelscope.svg',
|
||||||
'aliyun-codingplan': '/icons/providers/aliyun-codingplan.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',
|
'openai': '/icons/providers/openai.svg',
|
||||||
'azure-openai': '/icons/providers/azure-openai.svg',
|
'azure-openai': '/icons/providers/azure-openai.svg',
|
||||||
'minimax': '/icons/providers/minimax.png',
|
'minimax': '/icons/providers/minimax.png',
|
||||||
@ -92,6 +94,10 @@ export function useProviderList() {
|
|||||||
'zhipu-cn': '/icons/providers/zhipu.svg',
|
'zhipu-cn': '/icons/providers/zhipu.svg',
|
||||||
'zhipu-intl': '/icons/providers/zhipu.svg',
|
'zhipu-intl': '/icons/providers/zhipu.svg',
|
||||||
'volcengine': '/icons/providers/volcengine.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',
|
'openai-chatgpt': '/icons/providers/openai.svg',
|
||||||
'anthropic-claude-code': '/icons/providers/anthropic.svg',
|
'anthropic-claude-code': '/icons/providers/anthropic.svg',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,12 +46,18 @@
|
|||||||
{{ t('settings.model.localProviders') }}
|
{{ t('settings.model.localProviders') }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="provider-grid">
|
<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
|
<ProviderCard
|
||||||
:provider="provider"
|
:provider="provider"
|
||||||
:connection-testing-id="connectionTestingId"
|
:connection-testing-id="connectionTestingId"
|
||||||
:connection-results="connectionResults"
|
:connection-results="connectionResults"
|
||||||
:reprobing="reprobingId === provider.id"
|
:reprobing="reprobingId === provider.id"
|
||||||
|
:saving-api-key-id="savingApiKeyId"
|
||||||
:is-provider-active="isProviderActive"
|
:is-provider-active="isProviderActive"
|
||||||
:provider-status="providerStatus"
|
:provider-status="providerStatus"
|
||||||
:get-provider-icon="getProviderIcon"
|
:get-provider-icon="getProviderIcon"
|
||||||
@ -62,6 +68,8 @@
|
|||||||
@delete-provider="onDeleteProvider"
|
@delete-provider="onDeleteProvider"
|
||||||
@disable-provider="onDisableProvider"
|
@disable-provider="onDisableProvider"
|
||||||
@reprobe="reprobeProvider"
|
@reprobe="reprobeProvider"
|
||||||
|
@oauth-login="onCardOAuthLogin"
|
||||||
|
@save-api-key="onSaveApiKey"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -76,12 +84,18 @@
|
|||||||
{{ t('settings.model.cloudProviders') }}
|
{{ t('settings.model.cloudProviders') }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="provider-grid">
|
<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
|
<ProviderCard
|
||||||
:provider="provider"
|
:provider="provider"
|
||||||
:connection-testing-id="connectionTestingId"
|
:connection-testing-id="connectionTestingId"
|
||||||
:connection-results="connectionResults"
|
:connection-results="connectionResults"
|
||||||
:reprobing="reprobingId === provider.id"
|
:reprobing="reprobingId === provider.id"
|
||||||
|
:saving-api-key-id="savingApiKeyId"
|
||||||
:is-provider-active="isProviderActive"
|
:is-provider-active="isProviderActive"
|
||||||
:provider-status="providerStatus"
|
:provider-status="providerStatus"
|
||||||
:get-provider-icon="getProviderIcon"
|
:get-provider-icon="getProviderIcon"
|
||||||
@ -92,6 +106,8 @@
|
|||||||
@delete-provider="onDeleteProvider"
|
@delete-provider="onDeleteProvider"
|
||||||
@disable-provider="onDisableProvider"
|
@disable-provider="onDisableProvider"
|
||||||
@reprobe="reprobeProvider"
|
@reprobe="reprobeProvider"
|
||||||
|
@oauth-login="onCardOAuthLogin"
|
||||||
|
@save-api-key="onSaveApiKey"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -162,7 +178,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
|
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import type { ProviderInfo, ProviderModelInfo } from '@/types'
|
import type { ProviderInfo, ProviderModelInfo } from '@/types'
|
||||||
import { useProviders } from './useProviders'
|
import { useProviders } from './useProviders'
|
||||||
@ -214,6 +230,7 @@ const {
|
|||||||
openProviderConfigModal,
|
openProviderConfigModal,
|
||||||
closeProviderModal,
|
closeProviderModal,
|
||||||
saveProvider,
|
saveProvider,
|
||||||
|
saveProviderApiKey,
|
||||||
deleteProvider,
|
deleteProvider,
|
||||||
openManageModelsModal,
|
openManageModelsModal,
|
||||||
closeManageModelsModal,
|
closeManageModelsModal,
|
||||||
@ -246,6 +263,13 @@ const {
|
|||||||
const localProviders = computed(() => providers.value.filter(p => p.isLocal))
|
const localProviders = computed(() => providers.value.filter(p => p.isLocal))
|
||||||
const cloudProviders = 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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
/** sessionStorage guard so the drawer auto-opens at most once per session per workspace. */
|
/** 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) {
|
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)
|
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() {
|
async function onSaveProvider() {
|
||||||
try {
|
try {
|
||||||
await saveProvider()
|
await saveProvider()
|
||||||
@ -373,6 +428,14 @@ function showSavedTip(message: string) {
|
|||||||
}
|
}
|
||||||
.provider-card {
|
.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);
|
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 { 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); }
|
.btn-primary:hover { background: var(--mc-primary-hover); }
|
||||||
|
|||||||
@ -51,8 +51,12 @@
|
|||||||
<input type="checkbox" :value="model.id" :checked="selectedNewModelIds.includes(model.id)" @change="$emit('toggleModel', model.id)" />
|
<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-name">{{ model.name }}</span>
|
||||||
<span class="discover-model-id">{{ model.id }}</span>
|
<span class="discover-model-id">{{ model.id }}</span>
|
||||||
<span v-if="model.probeOk === true" class="probe-badge probe-ok" title="Verified reachable">
|
<span
|
||||||
✓ verified
|
v-if="model.probeOk === true"
|
||||||
|
class="probe-badge probe-ok"
|
||||||
|
:title="t('settings.model.discovery.probeVerifiedTitle')"
|
||||||
|
>
|
||||||
|
✓ {{ t('settings.model.discovery.probeVerified') }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -70,11 +74,11 @@
|
|||||||
why they are not offered in the "add selected" list -->
|
why they are not offered in the "add selected" list -->
|
||||||
<div v-if="discoveredUnavailable.length > 0" class="discover-unavailable">
|
<div v-if="discoveredUnavailable.length > 0" class="discover-unavailable">
|
||||||
<div class="discover-unavailable-title">
|
<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>
|
||||||
<div v-for="model in discoveredUnavailable" :key="model.id" class="discover-unavailable-item">
|
<div v-for="model in discoveredUnavailable" :key="model.id" class="discover-unavailable-item">
|
||||||
<span class="discover-model-id">{{ model.id }}</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user