mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(studio): add opt-in health chips to grouped model picker
Problem: Saved connections lack health evidence when selecting a model, while automatic credential-bearing checks require an explicit user opt-in. Root cause: The previous UI slice also redesigned the disabled picker, hiding account endpoint headings and reducing density outside the requested scope. Fix: Preserve the original grouped rows, hosts, ordering and modelLabel behavior. Add only short accessible health chips after reading backend consent. Put the global switch outside the service editor, and explain unsupported older servers and settings-read failures without enabling probes. Rebase onto current studio. Verification: 640 frontend tests, production build, census fixtures, vet, pinned lint, repository checks, HTTP authorization/route tests and builtin tests pass. All 849 boot cases accounted for: 846 pass, 3 skip. Negative controls fail the group-layout and old-server regressions. Native Electron light/dark wide/narrow captures and 390px browser inspection verify retained row density and hosts. Native disable/reopen issues no new requests; no generation requests occur.
This commit is contained in:
1 parent
8ae73f9332
commit
7142f95dba
22 files changed
+514
-16
No files matched your search
@@ -460,6 +460,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "provider.select", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.move", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.rename-start", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.health-setting", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "provider.rename", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "roles.model", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
|
||||
@@ -27,6 +27,33 @@ import { EN_COMMUNITY } from "./en_community";
|
||||
import { EN_SPONSOR } from "./en_sponsor";
|
||||
|
||||
export const EN: Record<string, string> = {
|
||||
"检测中": "Checking",
|
||||
"鉴权失败": "Auth failed",
|
||||
"缺少密钥": "Missing key",
|
||||
"需要付费": "Payment required",
|
||||
"配额耗尽": "Quota exhausted",
|
||||
"暂时限流": "Rate limited",
|
||||
"连接超时": "Timed out",
|
||||
"连接异常": "Connection failed",
|
||||
"跳转被拒": "Redirect refused",
|
||||
"余额查询失败": "Balance check failed",
|
||||
"连接正常": "Connected",
|
||||
"连接待确认": "Unconfirmed",
|
||||
"正在检测连接与余额…": "Checking connection and balance…",
|
||||
"连接正常,模型调用未验证": "Connection reachable; model calls unverified",
|
||||
"连接鉴权失败,请检查密钥或权限": "Authentication failed; check key or permissions",
|
||||
"未配置 API 密钥": "API key not configured",
|
||||
"服务商要求付费,请检查账户": "Payment required; check your account",
|
||||
"可用配额已耗尽": "Available quota exhausted",
|
||||
"请求暂时被限流,请稍后重试": "Temporarily rate limited; retry later",
|
||||
"连接检测超时,请稍后重试": "Connection check timed out; retry later",
|
||||
"连接异常,请检查网络或服务商": "Connection failed; check network or provider",
|
||||
"连接跳转被拒绝,请检查地址": "Redirect refused; check the address",
|
||||
"余额不足,请充值后重试": "Insufficient balance; top up and retry",
|
||||
"余额查询鉴权失败": "Balance query authentication failed",
|
||||
"余额未知(未配置查询接口)": "Balance unknown (no query endpoint configured)",
|
||||
"余额暂时无法确认": "Balance could not be confirmed",
|
||||
"连接状态尚未确认": "Connection status unconfirmed",
|
||||
"部分历史摘要暂不可用,可继续打开会话": "Some history summaries are unavailable. You can still open conversations.",
|
||||
"仍在搜索部分历史会话…": "Still searching some historical conversations…",
|
||||
"正在读取历史摘要…": "Reading history summaries…",
|
||||
@@ -1551,7 +1578,7 @@ export const EN: Record<string, string> = {
|
||||
"模型将依据摘要继续工作;完整对话记录会保留。": "The model will continue from a summary. The full conversation will be preserved.",
|
||||
"查看本轮费用": "View turn cost",
|
||||
"查看钱包余额": "View wallet balance",
|
||||
"余额不足": "Balance is low",
|
||||
"余额不足": "Insufficient balance",
|
||||
"隐藏金额": "Hide amounts",
|
||||
"显示金额": "Show amounts",
|
||||
"收起运行统计": "Collapse run statistics",
|
||||
@@ -1590,6 +1617,10 @@ export const EN: Record<string, string> = {
|
||||
"后续任务会继续使用这个分支": "Later tasks will continue on this branch",
|
||||
"由模型自动选择": "Let the model choose",
|
||||
"管理模型与连接": "Manage models and connections",
|
||||
"自动检测连接与余额": "Automatically check connections and balances",
|
||||
"当前服务器不支持自动检测,请升级服务器后重试。": "This server does not support automatic checks. Upgrade the server to use them.",
|
||||
"无法读取检测设置,请检查连接或权限后重试。": "Cannot read check settings. Check your connection or permissions and try again.",
|
||||
"默认关闭。开启后,打开模型列表会使用已保存密钥检查已授权的全局连接和余额,不发送生成请求。": "Off by default. When enabled, opening the model picker uses saved keys to check authorized global connections and balances, without generation requests.",
|
||||
"服务商、API 与可用模型": "Providers, API access, and available models",
|
||||
"更多操作": "More actions",
|
||||
"执行权限": "Execution permissions",
|
||||
|
||||
@@ -15,11 +15,18 @@ import type {
|
||||
ProviderProbe,
|
||||
} from "./port";
|
||||
import { MockBoundary } from "./mock_boundary";
|
||||
import type { ProviderHealth } from "./provider";
|
||||
|
||||
// The sources half of the fixture. Chained on for the reason the others are:
|
||||
// MockPort satisfies AgentPort in one declaration, and each face keeps its own
|
||||
// file.
|
||||
export class MockProvider extends MockBoundary {
|
||||
private healthEnabled = false;
|
||||
async providerHealthSettings() { return { enabled: this.healthEnabled }; }
|
||||
async saveProviderHealthSettings(enabled: boolean) { this.healthEnabled = enabled; return { enabled }; }
|
||||
async providerHealth(_name: string): Promise<ProviderHealth> {
|
||||
return { connection: "unknown", wallet: "unsupported", checkedAt: Date.now() };
|
||||
}
|
||||
// Whether this machine has been given a provider. The fixture opens without
|
||||
// one so the connect card can be designed, and every port the same window
|
||||
// builds reads the same answer: a key belongs to the machine, not to the
|
||||
|
||||
@@ -33,9 +33,6 @@ import type { StudioCrash, StudioLogs, UpdateMode, UpdateProgress, VersionEntry,
|
||||
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo, WorkspacePreviewPort } from "./workspace";
|
||||
import type { SessionTailPort } from "./session_tail";
|
||||
|
||||
// The port is one contract; its subjects each keep their own file, the way the
|
||||
// wire and the layers below already do. This is where a reader still finds
|
||||
// them all.
|
||||
export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, ApprovalVerdict, WorkspaceTrust, CapabilityScope,
|
||||
Checkpoint, CompactionSettings, Completion, CompletionItem, ContextBreakdown, DeviceGrant, HistoryMessage, HostTodo, BrowserTab,
|
||||
HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource, JobEntry, JobOutput, McpCatalog, McpDraft,
|
||||
@@ -51,8 +48,8 @@ import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketP
|
||||
import type { Appearance, SceneFolder, ThemeImport, ThemePack } from "./look";
|
||||
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FeedbackLevelNotices, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, ToolSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import type { ScheduleBoard, ScheduleDraft, SchedulePreview, ScheduleResultView, ScheduleView } from "./schedule";
|
||||
import type { Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
|
||||
export type { ModelEffort, ModelLimit, ModelRate, Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
|
||||
import type { Protocol, ProviderHealth, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
|
||||
export type { ModelEffort, ModelLimit, ModelRate, Protocol, ProviderHealth, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
|
||||
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
|
||||
import type { FeedbackBoard, FeedbackBoardMe, FeedbackEnv, FeedbackImage, FeedbackLinkedInstall, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
|
||||
import type { PersonaDraft, PersonaSettings, PresentFilesSettings } from "./boundary";
|
||||
@@ -434,6 +431,9 @@ export interface AgentPort extends WorkspacePreviewPort, SessionTailPort, Upload
|
||||
// Re-probes what is already saved, so "is the key still good, and is this
|
||||
// still the protocol we recorded" is one button rather than a re-add.
|
||||
checkProvider(name: string, draft?: ProviderCheckDraft): Promise<ProviderCheck>;
|
||||
providerHealth(name: string): Promise<ProviderHealth>;
|
||||
providerHealthSettings(): Promise<import("./provider").ProviderHealthSettings>;
|
||||
saveProviderHealthSettings(enabled: boolean): Promise<import("./provider").ProviderHealthSettings>;
|
||||
checkProviderModel(request: ProviderModelCheckRequest): Promise<ProviderModelCheck>;
|
||||
saveProvider(draft: ProviderDraft): Promise<void>;
|
||||
// Changes only the fields the form owns. Saving a whole entry instead would
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
// provider.ts — a model source as the connection panel edits it: what was
|
||||
// probed, what was confirmed, and the few fields no probe can answer.
|
||||
|
||||
export interface ProviderHealthSettings {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderHealth {
|
||||
connection: string;
|
||||
wallet: string;
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
export interface ProviderEntry {
|
||||
name: string;
|
||||
// The label a person gave this source. Only ever shown: every ref, the
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { SseBoundary } from "./sse_boundary";
|
||||
import type { ProviderHealth, ProviderHealthSettings } from "./provider";
|
||||
import type { Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./port";
|
||||
|
||||
// Where models come from: the accounts, the protocols their endpoints answer,
|
||||
// and what probing one found.
|
||||
export class SseProvider extends SseBoundary {
|
||||
providerHealthSettings(): Promise<ProviderHealthSettings> {
|
||||
return this.get<ProviderHealthSettings>("/providers/health/settings");
|
||||
}
|
||||
saveProviderHealthSettings(enabled: boolean): Promise<ProviderHealthSettings> {
|
||||
return this.post0<ProviderHealthSettings>("/providers/health/settings", { enabled });
|
||||
}
|
||||
providerHealth(name: string): Promise<ProviderHealth> {
|
||||
return this.post0<ProviderHealth>("/providers/health", { name });
|
||||
}
|
||||
providers() {
|
||||
return this.get<ProviderEntry[]>("/providers");
|
||||
}
|
||||
|
||||
@@ -10,3 +10,7 @@
|
||||
.mchip[aria-pressed="true"] { color: var(--text); background: var(--overlay); border-color: var(--text); }
|
||||
.mchip.clear { border-style: dashed; }
|
||||
.mchip:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
.studio-model-menu .menu-item-status { display: inline-flex; align-items: center; gap: 4px; flex: none; padding: 2px 5px; border: 1px solid transparent; border-radius: 5px; color: var(--muted); font: var(--w-med) calc(10.5px * var(--fs-scale, 1))/1.3 var(--ui); white-space: nowrap; }
|
||||
.studio-model-menu .menu-item-status[data-tone="warning"] { color: var(--warn-ink); background: color-mix(in srgb, var(--warn) 8%, transparent); border-color: color-mix(in srgb, var(--warn) 45%, transparent); }
|
||||
.studio-model-menu .menu-item-status[data-tone="positive"] svg { color: var(--ok-ink, var(--ok)); }
|
||||
.studio-model-menu .menu-item-status svg { width: 12px; height: 12px; flex: none; }
|
||||
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import "./testkit";
|
||||
import { Composer } from "./Composer";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { SessionStatus } from "../port/port";
|
||||
import { t } from "../i18n";
|
||||
|
||||
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
|
||||
|
||||
it("keeps account headings, visible hosts and row metadata with checks off and on", async () => {
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, "models").mockResolvedValue([
|
||||
{ ref: "a/current", provider: "a", model: "current", kind: "openai", displayName: "Gateway", vendor: "one.example" },
|
||||
{ ref: "a/second", provider: "a", model: "second", kind: "openai", displayName: "Gateway", vendor: "one.example" },
|
||||
{ ref: "b/third", provider: "b", model: "third", kind: "openai", displayName: "Gateway", vendor: "two.example" },
|
||||
]);
|
||||
const probe = vi.spyOn(port, "providerHealth").mockResolvedValue({ connection: "reachable", wallet: "available", checkedAt: 1 });
|
||||
const props = { port, status: { modelRef: "a/current" } as SessionStatus, running: false, onSubmit: async () => true as const, onChanged: vi.fn(), onError: vi.fn() };
|
||||
const view = render(<Composer {...props} pulse={0} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "current" }));
|
||||
const menu = screen.getByRole("menu");
|
||||
expect(menu.classList.contains("compact-model-menu")).toBe(false);
|
||||
expect(within(menu).getByText("one.example")).toBeTruthy();
|
||||
expect(within(menu).getByText("two.example")).toBeTruthy();
|
||||
expect(within(menu).getAllByRole("menuitem")).toHaveLength(4);
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(menu, { key: "Escape" });
|
||||
await port.saveProviderHealthSettings(true);
|
||||
view.rerender(<Composer {...props} pulse={1} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "current" }));
|
||||
await within(screen.getByRole("menu")).findAllByText(t("连接正常"));
|
||||
expect(screen.getByRole("menu").querySelectorAll(".mi.head .rt")).toHaveLength(2);
|
||||
expect(screen.getByRole("menuitem", { name: /second/ }).textContent).toContain(t("OpenAI 兼容"));
|
||||
expect(screen.getByRole("menuitem", { name: /second/ }).textContent).not.toContain("Gateway");
|
||||
});
|
||||
|
||||
it("checks on opening, shows wallet failures and keeps selection and management usable", async () => {
|
||||
const port = new MockPort();
|
||||
await port.saveProviderHealthSettings(true);
|
||||
vi.spyOn(port, "models").mockResolvedValue([
|
||||
{ ref: "one/alpha", provider: "one", model: "alpha", kind: "openai" },
|
||||
{ ref: "one/beta", provider: "one", model: "beta", kind: "openai" },
|
||||
{ ref: "two/gamma", provider: "two", model: "gamma", kind: "openai" },
|
||||
]);
|
||||
const check = vi.spyOn(port, "providerHealth").mockImplementation(async (name) => ({ connection: "reachable", wallet: name === "one" ? "insufficient" : "unsupported", checkedAt: Date.now() }));
|
||||
const trial = vi.spyOn(port, "checkProviderModel");
|
||||
const pick = vi.spyOn(port, "setModel").mockResolvedValue();
|
||||
const settings = vi.fn();
|
||||
render(<Composer port={port} status={{ modelRef: "one/alpha" } as SessionStatus} running={false} onSubmit={async () => true} onChanged={vi.fn()} onError={vi.fn()} onSettings={settings} />);
|
||||
const trigger = await screen.findByRole("button", { name: "alpha" });
|
||||
expect(check).not.toHaveBeenCalled();
|
||||
fireEvent.click(trigger);
|
||||
await screen.findAllByText(/Insufficient balance|余额不足/);
|
||||
expect(check.mock.calls.map(([name]) => name).sort()).toEqual(["one", "two"]);
|
||||
expect(trial).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("menuitem", { name: /alpha/ }).hasAttribute("data-on")).toBe(true);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /gamma/ }));
|
||||
await waitFor(() => expect(pick).toHaveBeenCalledWith("two/gamma"));
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Manage models and connections|管理模型与连接/ }));
|
||||
expect(settings).toHaveBeenCalledWith("providers");
|
||||
});
|
||||
|
||||
it("ignores a late health result after switching runtime", async () => {
|
||||
const oldPort = new MockPort(), port = new MockPort();
|
||||
await oldPort.saveProviderHealthSettings(true);
|
||||
await port.saveProviderHealthSettings(true);
|
||||
const models = [{ ref: "one/alpha", provider: "one", model: "alpha" }];
|
||||
vi.spyOn(oldPort, "models").mockResolvedValue(models);
|
||||
vi.spyOn(port, "models").mockResolvedValue(models);
|
||||
let finish!: (value: { connection: string; wallet: string; checkedAt: number }) => void;
|
||||
vi.spyOn(oldPort, "providerHealth").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
|
||||
vi.spyOn(port, "providerHealth").mockResolvedValue({ connection: "reachable", wallet: "available", checkedAt: Date.now() });
|
||||
const props = { status: { modelRef: "one/alpha" } as SessionStatus, running: false, onSubmit: async () => true as const, onChanged: vi.fn(), onError: vi.fn() };
|
||||
const view = render(<Composer {...props} port={oldPort} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "alpha" }));
|
||||
await waitFor(() => expect(finish).toBeDefined());
|
||||
view.rerender(<Composer {...props} port={port} />);
|
||||
await act(async () => { finish({ connection: "auth", wallet: "insufficient", checkedAt: Date.now() }); });
|
||||
expect(screen.queryByText(/Insufficient balance|余额不足/)).toBeNull();
|
||||
});
|
||||
|
||||
it("limits background checks, tolerates older servers and excludes extension models", async () => {
|
||||
const port = new MockPort();
|
||||
await port.saveProviderHealthSettings(true);
|
||||
vi.spyOn(port, "models").mockResolvedValue([
|
||||
...["one", "two", "three", "four"].map((name) => ({ ref: `${name}/alpha`, provider: name, model: "alpha" })),
|
||||
{ ref: "plugin/test/provider/alpha", provider: "plugin/test/provider", model: "extension", kind: "extension" },
|
||||
]);
|
||||
const pending: Array<() => void> = [];
|
||||
const check = vi.spyOn(port, "providerHealth").mockImplementation(() => new Promise((_, reject) => { pending.push(() => reject(new Error("old server"))); }));
|
||||
render(<Composer port={port} status={{ modelRef: "one/alpha" } as SessionStatus} running={false} onSubmit={async () => true} onChanged={vi.fn()} onError={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "alpha" }));
|
||||
await waitFor(() => expect(check).toHaveBeenCalledTimes(3));
|
||||
await act(async () => { pending[0](); });
|
||||
await waitFor(() => expect(check).toHaveBeenCalledTimes(4));
|
||||
await act(async () => { pending.slice(1).forEach((finish) => finish()); });
|
||||
expect(check.mock.calls.flat()).not.toContain("plugin/test/provider");
|
||||
expect(screen.getByRole("menuitem", { name: /extension/ })).toBeTruthy();
|
||||
expect(screen.getAllByText(/Unconfirmed|连接待确认/)).toHaveLength(4);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from "react-dom";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { keepPasteAsFile, PasteKeep, type PasteChip } from "./pastekeep";
|
||||
import type { AgentPort, ChipCall, ModelEntry, SessionStatus, WorkspaceGit } from "../port/port";
|
||||
import type { AgentPort, ChipCall, SessionStatus, WorkspaceGit } from "../port/port";
|
||||
import { Picker } from "./Menu";
|
||||
import { BranchChip } from "./BranchChip";
|
||||
import { WorktreeChip } from "./WorktreeChip";
|
||||
@@ -12,6 +12,7 @@ import { WorktreeSetupPage } from "./WorktreeSetupPage";
|
||||
import { Policy } from "./Policy";
|
||||
import { chosenGroups, groupHeadings, modelLabel, modelMenu, modelTooltip, narrowToGroups } from "./modelmenu";
|
||||
import { ModelGroupChips } from "./ModelGroupChips";
|
||||
import { useModelHealth } from "./modelhealth";
|
||||
import { effortMenu, effortReading, effortsFor, forcesThinkingFor, routeEffortPick } from "./effort";
|
||||
import { CompletionMenu, useCompletion } from "./Completion";
|
||||
import { ChipMirror, useSkillChips } from "./ChipMirror";
|
||||
@@ -102,8 +103,9 @@ export function Composer({ port, status, running, queued = false, quote, restore
|
||||
const submittingRef = useRef(false);
|
||||
const stoppingRef = useRef(false);
|
||||
const picker = useRef<HTMLInputElement>(null);
|
||||
const [models, setModels] = useState<ModelEntry[]>([]);
|
||||
const menuItems = modelMenu(models, providerOrder, status?.modelRef);
|
||||
const modelHealth = useModelHealth(port, pulse);
|
||||
const { models, loadModels } = modelHealth;
|
||||
const menuItems = modelMenu(models, providerOrder, status?.modelRef, modelHealth.readings);
|
||||
const chosenModelGroups = chosenGroups(menuItems, modelGroups);
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({});
|
||||
const box = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -124,11 +126,6 @@ export function Composer({ port, status, running, queued = false, quote, restore
|
||||
// of a programmatic value, which is wrong for anything accepted mid-line.
|
||||
const pending = useRef<number | [number, number] | null>(null);
|
||||
|
||||
const loadModels = useCallback(() => {
|
||||
port.models().then(setModels).catch(() => setModels([]));
|
||||
}, [port]);
|
||||
useEffect(loadModels, [loadModels, pulse]);
|
||||
|
||||
// A drop lands where the caret is, and the handler that receives it was built
|
||||
// once — so the position it reads has to be a ref, not the render's copy.
|
||||
const caretRef = useRef(0);
|
||||
@@ -716,7 +713,7 @@ export function Composer({ port, status, running, queued = false, quote, restore
|
||||
searchAlways
|
||||
searchPlaceholder={t("搜索模型或服务商…")}
|
||||
menuTitle={<><b>{t("选择模型")}</b><small>{t("用于后续任务")}</small></>}
|
||||
onOpen={loadModels}
|
||||
onOpen={() => { void modelHealth.check(loadModels()); }}
|
||||
pending={busy["model"]}
|
||||
onPick={(ref) => ref === "__manage-models" ? onSettings("providers") : change("model", () => port.setModel(ref))}
|
||||
label={<><span className="nm">{modelLb}</span><StudioIcon name="down" /></>}
|
||||
|
||||
@@ -3,12 +3,20 @@ import { createPortal } from "react-dom";
|
||||
import { t } from "../i18n";
|
||||
import { placeInViewport, useFollow, zoom } from "./place";
|
||||
import { hasVendorMark, VendorMark } from "./vendormarks";
|
||||
import { CircleAlert, CircleDot, LoaderCircle } from "lucide-react";
|
||||
|
||||
export interface MenuStatus {
|
||||
label: string;
|
||||
tone: "warning" | "positive" | "neutral" | "checking";
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
value: string;
|
||||
label: string;
|
||||
desc?: string;
|
||||
right?: string;
|
||||
status?: MenuStatus;
|
||||
// Dense choice menus sometimes need one short machine-facing label beside
|
||||
// the human one (for example "深入 High") without pushing that label to
|
||||
// the far edge of the row.
|
||||
@@ -259,6 +267,8 @@ export function Picker({
|
||||
role={it.toggle === undefined ? "menuitem" : "menuitemcheckbox"}
|
||||
aria-checked={it.toggle}
|
||||
aria-current={it.value === current ? true : undefined}
|
||||
aria-description={it.status?.detail}
|
||||
title={it.status?.detail}
|
||||
disabled={it.disabled}
|
||||
data-on={it.value === current ? "" : undefined}
|
||||
data-lead={it.value === lead ? "" : undefined}
|
||||
@@ -282,6 +292,10 @@ export function Picker({
|
||||
)}
|
||||
{it.toggle !== undefined && <span className="studio-item-switch" aria-hidden="true" />}
|
||||
{it.right && <span className="rt">{it.right}</span>}
|
||||
{it.status && <span className="menu-item-status" data-tone={it.status.tone}>
|
||||
{it.status.tone === "warning" ? <CircleAlert aria-hidden="true" /> : it.status.tone === "checking" ? <LoaderCircle aria-hidden="true" /> : <CircleDot aria-hidden="true" />}
|
||||
{it.status.label}
|
||||
</span>}
|
||||
</button>
|
||||
)}
|
||||
</Fragment>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { ProviderHealthSetting } from "./ProviderHealthSetting";
|
||||
import { useModelHealth } from "./modelhealth";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { HttpError } from "../port/port";
|
||||
import { t } from "../i18n";
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
||||
const models = [{ ref: "one/alpha", provider: "one", model: "alpha" }];
|
||||
|
||||
it.each([404, 405, 501])("explains an older server refusing health settings with %s", async (status) => {
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, "providerHealthSettings").mockRejectedValue(new HttpError(status, "unsupported"));
|
||||
const save = vi.spyOn(port, "saveProviderHealthSettings");
|
||||
render(<ProviderHealthSetting port={port} onChanged={vi.fn()} onFailed={vi.fn()} />);
|
||||
await screen.findByText(t("当前服务器不支持自动检测,请升级服务器后重试。"));
|
||||
expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("false");
|
||||
expect(screen.getByRole("switch").hasAttribute("disabled")).toBe(true);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("explains a missing save capability and distinguishes a settings read failure", async () => {
|
||||
const port = new MockPort();
|
||||
const view = render(<ProviderHealthSetting port={{ providerHealthSettings: () => port.providerHealthSettings() }} onChanged={vi.fn()} onFailed={vi.fn()} />);
|
||||
await screen.findByText(t("当前服务器不支持自动检测,请升级服务器后重试。"));
|
||||
vi.spyOn(port, "providerHealthSettings").mockRejectedValue(new HttpError(403, "forbidden"));
|
||||
view.rerender(<ProviderHealthSetting port={port} onChanged={vi.fn()} onFailed={vi.fn()} />);
|
||||
await screen.findByText(t("无法读取检测设置,请检查连接或权限后重试。"));
|
||||
expect(screen.getByRole("switch").hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.queryByText(t("当前服务器不支持自动检测,请升级服务器后重试。"))).toBeNull();
|
||||
});
|
||||
|
||||
it("requires opt-in, persists both switch directions and stops probes after disabling", async () => {
|
||||
const port = new MockPort(), changed = vi.fn(), failed = vi.fn();
|
||||
const probe = vi.spyOn(port, "providerHealth");
|
||||
const saved = vi.spyOn(port, "saveProviderHealthSettings");
|
||||
const { result, rerender } = renderHook(({ pulse }) => useModelHealth(port, pulse), { initialProps: { pulse: 0 } });
|
||||
render(<ProviderHealthSetting port={port} onChanged={changed} onFailed={failed} />);
|
||||
const toggle = screen.getByRole("switch");
|
||||
await waitFor(() => expect(toggle.hasAttribute("disabled")).toBe(false));
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("false");
|
||||
await act(() => result.current.check(Promise.resolve(models)));
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(changed).toHaveBeenCalledTimes(1));
|
||||
expect(saved).toHaveBeenLastCalledWith(true);
|
||||
await act(() => result.current.check(Promise.resolve(models)));
|
||||
expect(probe).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(changed).toHaveBeenCalledTimes(2));
|
||||
expect(saved).toHaveBeenLastCalledWith(false);
|
||||
rerender({ pulse: 1 });
|
||||
await act(() => result.current.check(Promise.resolve(models)));
|
||||
expect(probe).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.readings).toEqual({});
|
||||
expect(failed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed on unavailable settings and ignores late consent after a runtime switch", async () => {
|
||||
const old = new MockPort(), port = new MockPort();
|
||||
let finish!: (value: { enabled: boolean }) => void;
|
||||
vi.spyOn(old, "providerHealthSettings").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
|
||||
vi.spyOn(port, "providerHealthSettings").mockRejectedValue(new Error("unsupported"));
|
||||
const oldProbe = vi.spyOn(old, "providerHealth"), probe = vi.spyOn(port, "providerHealth");
|
||||
const { result, rerender } = renderHook(({ port }) => useModelHealth(port, 0), { initialProps: { port: old } });
|
||||
let pending!: Promise<void>;
|
||||
act(() => { pending = result.current.check(Promise.resolve(models)); });
|
||||
rerender({ port });
|
||||
await act(async () => { finish({ enabled: true }); await pending; await result.current.check(Promise.resolve(models)); });
|
||||
expect(oldProbe).not.toHaveBeenCalled();
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
expect(result.current.readings).toEqual({});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { HttpError, type AgentPort } from "../port/port";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { Switch } from "./Switch";
|
||||
import { Group } from "./Group";
|
||||
|
||||
export function ProviderHealthSetting({ port, onChanged, onFailed }: {
|
||||
port: Partial<Pick<AgentPort, "providerHealthSettings" | "saveProviderHealthSettings">>;
|
||||
onChanged: () => void; onFailed: (why: string) => void;
|
||||
}) {
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [unavailable, setUnavailable] = useState<"unsupported" | "failed" | null>(null);
|
||||
const generation = useRef(0);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
generation.current++;
|
||||
setEnabled(null);
|
||||
setBusy(false);
|
||||
setUnavailable(null);
|
||||
if (!port.providerHealthSettings || !port.saveProviderHealthSettings) setUnavailable("unsupported");
|
||||
else void port.providerHealthSettings().then((value) => { if (live) setEnabled(value.enabled); }).catch((e) => {
|
||||
if (live) setUnavailable(e instanceof HttpError && [404, 405, 501].includes(e.status) ? "unsupported" : "failed");
|
||||
});
|
||||
return () => { live = false; generation.current++; };
|
||||
}, [port]);
|
||||
return <Group id="provider-health" title={t("自动检测连接与余额")}
|
||||
hint={t("默认关闭。开启后,打开模型列表会使用已保存密钥检查已授权的全局连接和余额,不发送生成请求。")}
|
||||
action={<Switch data-action="provider.health-setting" on={enabled === true}
|
||||
busy={busy || enabled === null || !port.saveProviderHealthSettings}
|
||||
label={t("自动检测连接与余额")} onClick={async () => {
|
||||
if (!port.saveProviderHealthSettings) return;
|
||||
const version = generation.current;
|
||||
setBusy(true);
|
||||
try {
|
||||
const value = await port.saveProviderHealthSettings(!enabled);
|
||||
if (version === generation.current) { setEnabled(value.enabled); onChanged(); }
|
||||
} catch (e) { if (version === generation.current) onFailed(reason(e)); }
|
||||
finally { if (version === generation.current) setBusy(false); }
|
||||
}} />}>
|
||||
{unavailable && <p className="note" role="status">{t(unavailable === "unsupported"
|
||||
? "当前服务器不支持自动检测,请升级服务器后重试。"
|
||||
: "无法读取检测设置,请检查连接或权限后重试。")}</p>}
|
||||
</Group>;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { Packages } from "./Packages";
|
||||
import { ExtTabs, MarketGroup } from "./Market";
|
||||
import { InstalledLocation } from "./InstalledLocation";
|
||||
import { Switch } from "./Switch";
|
||||
import { ProviderHealthSetting } from "./ProviderHealthSetting";
|
||||
import { ServerRow } from "./ServerRow";
|
||||
import { SkillGroups } from "./SkillRow";
|
||||
import { Hooks } from "./Hooks";
|
||||
@@ -493,6 +494,8 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
{at === "persona" && <AgentPresets port={port} focusPersona={landed === "persona"} />}
|
||||
|
||||
{at === "providers" && (
|
||||
<>
|
||||
<ProviderHealthSetting port={port} onChanged={onChanged} onFailed={setFailed} />
|
||||
<Group id="providers" title={t("模型服务")}
|
||||
hint={t("选择一个服务查看和修改;地址、密钥与模型列表保存后生效。自定义中转站的协议与模型会在连接后自动探测。")}>
|
||||
<Providers port={port} onChanged={() => { loadModels(); onChanged(); }} onFailed={setFailed} protocol={protocol}
|
||||
@@ -500,6 +503,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
declare={declare}
|
||||
onProtocol={(a, kind) => switchProtocol(a.key, kind)} />
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{at === "model" && (
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { modelHealthNote } from "./modelhealth";
|
||||
import { t } from "../i18n";
|
||||
|
||||
it("never promotes a listing into a verified model or an unknown wallet into zero balance", () => {
|
||||
expect(modelHealthNote({ connection: "reachable", wallet: "available", checkedAt: 1 })).toBe(t("连接正常,模型调用未验证"));
|
||||
for (const wallet of ["unknown", "unsupported"]) {
|
||||
expect(modelHealthNote({ connection: "unknown", wallet, checkedAt: 1 })).not.toContain(t("余额不足,请充值后重试"));
|
||||
}
|
||||
expect(modelHealthNote({ connection: "rate_limited", wallet: "unsupported", checkedAt: 1 })).toContain(t("请求暂时被限流,请稍后重试"));
|
||||
expect(modelHealthNote({ connection: "future_state", wallet: "future_state", checkedAt: 1 })).toBe(t("连接状态尚未确认"));
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { AgentPort, ModelEntry, ProviderHealth } from "../port/port";
|
||||
import { t } from "../i18n";
|
||||
import type { MenuStatus } from "./Menu";
|
||||
|
||||
export type ModelHealth = Readonly<Record<string, ProviderHealth | "checking">>;
|
||||
|
||||
export function useModelHealth(port: AgentPort, pulse: number) {
|
||||
const [models, setModels] = useState<ModelEntry[]>([]);
|
||||
const modelLoad = useRef(0);
|
||||
const loadModels = useCallback(() => {
|
||||
const version = ++modelLoad.current;
|
||||
return port.models().catch(() => []).then((next) => {
|
||||
if (version === modelLoad.current) setModels(next);
|
||||
return next;
|
||||
});
|
||||
}, [port]);
|
||||
useEffect(() => {
|
||||
void loadModels();
|
||||
return () => { modelLoad.current++; };
|
||||
}, [loadModels, pulse]);
|
||||
const [readings, setReadings] = useState<ModelHealth>({});
|
||||
const generation = useRef(0);
|
||||
useEffect(() => {
|
||||
generation.current++;
|
||||
setReadings({});
|
||||
return () => { generation.current++; };
|
||||
}, [port, pulse]);
|
||||
const check = useCallback(async (listing: Promise<ModelEntry[]>) => {
|
||||
const version = ++generation.current;
|
||||
setReadings({});
|
||||
const [models, settings] = await Promise.all([listing, port.providerHealthSettings().catch(() => null)]);
|
||||
if (generation.current !== version) return;
|
||||
if (!settings?.enabled) return;
|
||||
const names = [...new Set(models.filter((m) => m.kind !== "extension" && !m.provider.startsWith("plugin/")).map((m) => m.provider))];
|
||||
setReadings(Object.fromEntries(names.map((name) => [name, "checking"])));
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
while (next < names.length && generation.current === version) {
|
||||
const name = names[next++];
|
||||
let result: ProviderHealth;
|
||||
try { result = await port.providerHealth(name); }
|
||||
catch { result = { connection: "unknown", wallet: "unknown", checkedAt: 0 }; }
|
||||
if (generation.current === version) setReadings((prior) => ({ ...prior, [name]: result }));
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(3, names.length) }, worker));
|
||||
}, [port]);
|
||||
return { models, loadModels, readings, check };
|
||||
}
|
||||
|
||||
export function modelHealthNote(health: ProviderHealth | "checking" | undefined): string | undefined {
|
||||
if (!health || health !== "checking" && health.connection === "disabled") return undefined;
|
||||
if (health === "checking") return t("正在检测连接与余额…");
|
||||
const connection: Record<string, string> = {
|
||||
reachable: "连接正常,模型调用未验证",
|
||||
auth: "连接鉴权失败,请检查密钥或权限",
|
||||
missing_key: "未配置 API 密钥",
|
||||
payment_required: "服务商要求付费,请检查账户",
|
||||
quota_exhausted: "可用配额已耗尽",
|
||||
rate_limited: "请求暂时被限流,请稍后重试",
|
||||
timeout: "连接检测超时,请稍后重试",
|
||||
unreachable: "连接异常,请检查网络或服务商",
|
||||
redirect_refused: "连接跳转被拒绝,请检查地址",
|
||||
};
|
||||
const wallet: Record<string, string> = {
|
||||
insufficient: "余额不足,请充值后重试",
|
||||
auth: "余额查询鉴权失败",
|
||||
unsupported: "余额未知(未配置查询接口)",
|
||||
unknown: "余额暂时无法确认",
|
||||
};
|
||||
const issue = wallet[health.wallet];
|
||||
const status = t(connection[health.connection] ?? "连接状态尚未确认");
|
||||
return issue ? `${t(issue)} · ${status}` : status;
|
||||
}
|
||||
|
||||
export function modelHealthStatus(health: ProviderHealth | "checking" | undefined): MenuStatus | undefined {
|
||||
if (!health || health !== "checking" && health.connection === "disabled") return undefined;
|
||||
const detail = modelHealthNote(health);
|
||||
if (health === "checking") return { label: t("检测中"), tone: "checking", detail };
|
||||
if (health.wallet === "insufficient") return { label: t("余额不足"), tone: "warning", detail };
|
||||
const failures: Record<string, string> = {
|
||||
auth: "鉴权失败", missing_key: "缺少密钥", payment_required: "需要付费",
|
||||
quota_exhausted: "配额耗尽", rate_limited: "暂时限流", timeout: "连接超时",
|
||||
unreachable: "连接异常", redirect_refused: "跳转被拒",
|
||||
};
|
||||
if (failures[health.connection]) return { label: t(failures[health.connection]), tone: "warning", detail };
|
||||
if (health.wallet === "auth") return { label: t("余额查询失败"), tone: "warning", detail };
|
||||
if (health.connection === "reachable") return { label: t("连接正常"), tone: "positive", detail };
|
||||
return { label: t("连接待确认"), tone: "neutral", detail };
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { t } from "../i18n";
|
||||
import { UNCONFIGURED_REF } from "./ConnectNote";
|
||||
import { KIND_LABEL } from "./vendors";
|
||||
import { orderAccounts } from "../state/providerorder";
|
||||
import { modelHealthStatus, type ModelHealth } from "./modelhealth";
|
||||
|
||||
// modelTooltip names the active model with the picker's canonical account label.
|
||||
export function modelTooltip(models: ModelEntry[], current: string | undefined, label: string): string {
|
||||
@@ -25,7 +26,7 @@ export function modelLabel(models: ModelEntry[], status: { modelRef?: string; la
|
||||
// Every account gets a heading with its endpoint: the provider name is the
|
||||
// config's word for an entry, not the user's for an endpoint, and two doors onto
|
||||
// one account share it. Rows carry the wire format beside the model name.
|
||||
export function modelMenu(models: ModelEntry[], order: readonly string[] = [], current?: string): MenuItem[] {
|
||||
export function modelMenu(models: ModelEntry[], order: readonly string[] = [], current?: string, health: ModelHealth = {}): MenuItem[] {
|
||||
const accounts = orderAccounts(groupVendors(models), order);
|
||||
const out: MenuItem[] = [];
|
||||
for (const [i, a] of accounts.entries()) {
|
||||
@@ -38,6 +39,7 @@ export function modelMenu(models: ModelEntry[], order: readonly string[] = [], c
|
||||
value: m.ref,
|
||||
label: m.modelAlias?.trim() || m.modelShort || m.model,
|
||||
desc: m.modelAlias?.trim() ? m.model : undefined,
|
||||
status: modelHealthStatus(health[m.provider]),
|
||||
meta: t(KIND_LABEL[kind] ?? kind),
|
||||
badge: m.vision ? t("读图") : undefined,
|
||||
mono,
|
||||
|
||||
@@ -248,6 +248,7 @@ export const SETTINGS: SettingEntry[] = [
|
||||
// is what the row promises, because the weaker one would be a promise this
|
||||
// block cannot keep.
|
||||
{ section: "providers", anchor: "providers", title: "模型服务", scope: "machine", apply: "runtime-rebuild", keywords: ["模型来源", "连接", "提供商", "供应商", "api key", "密钥", "协议", "地址"] },
|
||||
{ section: "providers", anchor: "provider-health", title: "自动检测连接与余额", scope: "machine", apply: "immediate", keywords: ["检测", "余额", "连接", "health", "balance"] },
|
||||
|
||||
// Takes effect on this session at once and is also persisted as the default
|
||||
// every later session starts from. Machine is the wider of the two answers
|
||||
|
||||
@@ -10,6 +10,8 @@ import { MockPort } from "../port/mock";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
import type { AgentPort, SessionStatus } from "../port/port";
|
||||
|
||||
vi.mock("./storageTally", () => ({ STORAGE_TALLY_DELAY: 1500, cachedStorageTotal: () => 0, storageTotal: vi.fn() }));
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.setItem(STORAGE, "zh"); boot(); });
|
||||
const language = (lang: string) => { localStorage.setItem(STORAGE, lang); boot(); };
|
||||
const block = (anchor: string) => document.querySelector<HTMLElement>(`[data-setting="${anchor}"]`);
|
||||
|
||||
@@ -63,6 +63,10 @@ const endpointOf = (n, path) => endpointsOf(n, path)[0] ?? null;
|
||||
// against the tree it belongs to — an entry that excuses nothing fails.
|
||||
const DECLARED_NON_MUTATION = {
|
||||
src: {
|
||||
"POST /providers/health": { class: "repeatable-external-probe", why:
|
||||
"control.ProviderHealth reads the saved connection and calls providerhealth.Checker, which " +
|
||||
"fetches model listings and configured wallets with GET requests. Only an in-memory TTL " +
|
||||
"cache changes; no configuration, session or model selection is written. External network effect: yes." },
|
||||
"POST /providers/probe": { class: "repeatable-external-probe", why:
|
||||
"config.ProbeEndpoint asks the endpoint what models it has and the answer is written back to the " +
|
||||
"caller; no canonical host state is written. External network effect: yes — this set decides " +
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
owner: @SivanCola
|
||||
backup: @esengine
|
||||
status: active
|
||||
reviewed: 2026-10-28
|
||||
---
|
||||
|
||||
# Model connection checks / 模型连接检测
|
||||
|
||||
Automatic checks are **off by default**. Enable **Automatically check connections
|
||||
and balances** in model connection settings to opt in. Opening Studio's chat
|
||||
model picker then checks authorized user-global connections in the background.
|
||||
|
||||
Rows show connection/authentication failures, payment or quota refusals, temporary
|
||||
rate limits, and insufficient balance reported by a configured wallet endpoint.
|
||||
|
||||
Checks keep every model and the current selection; use **Manage models and
|
||||
connections** to repair a connection or run an explicit model trial.
|
||||
|
||||
The picker keeps its existing account groups, visible endpoint hosts, row density
|
||||
and current-selection behavior. Opting in adds one status chip to each model row;
|
||||
hovering the row or reading its accessible description reveals the full diagnostic.
|
||||
|
||||
The switch applies to every authorized global connection and sits outside the
|
||||
individual service editor. Older servers show an upgrade hint with a disabled
|
||||
switch; a failed settings read shows a connection/permission hint and runs no probes.
|
||||
|
||||
The automatic check reads the model listing and configured wallet only. It sends
|
||||
no completion requests. A reachable listing does not verify that a particular
|
||||
model can generate; an unsupported, unreadable, or unconfigured wallet stays
|
||||
unknown.
|
||||
|
||||
Resolver-backed and extension catalogs are not checked against local
|
||||
credentials. Servers without provider-edit permission do not run these probes.
|
||||
|
||||
Project configuration cannot opt in or change probe destinations or proxies;
|
||||
project-only connections must first be saved globally. All redirects are refused,
|
||||
including same-origin redirects.
|
||||
|
||||
Cached entries contain only a credential/config
|
||||
digest, status classes and timestamps, never raw keys, responses or balances.
|
||||
|
||||
Checks run up to three connections at a time in the picker and share an in-flight
|
||||
request within one runtime. Results are cached for one minute; reopening the
|
||||
picker refreshes expired results. Changing credentials, connection settings, or
|
||||
proxy configuration invalidates the cache. Results are kept in memory only.
|
||||
|
||||
自动检测**默认关闭**。在模型连接设置中开启「自动检测连接与余额」后,打开模型
|
||||
列表才会在后台检查已授权的全局连接。模型行会提示
|
||||
连接或鉴权失败、付费要求、配额耗尽、临时限流,以及余额接口报告的余额不足。
|
||||
检测保留所有模型与当前选择;可通过「管理模型与连接」修复配置或主动试调用模型。
|
||||
|
||||
模型列表保留原账户分组、可见接口域名、行密度和当前选择行为。开启检测后仅在
|
||||
模型行增加一个状态标签,悬停或读屏说明提供完整诊断。
|
||||
|
||||
开关作用于全部已授权全局连接,位于单个服务编辑器之外。旧服务器显示升级提示
|
||||
并禁用开关;设置读取失败时提示检查连接或权限,且不执行探测。
|
||||
|
||||
自动检测只读取模型列表和已配置的余额接口,不发送生成请求。列表连通不等于
|
||||
每个模型都能调用;不支持、未配置或无法读取的余额保持未知。远程解析器和扩展
|
||||
模型不会拿本机凭据进行检测;没有服务商编辑权限的服务端也不会执行这些探测。
|
||||
|
||||
项目配置无法开启检测或更改检测地址及代理;项目专属连接需先保存到全局。
|
||||
所有跳转(含同源跳转)均被拒绝;缓存仅保留配置及凭据摘要、状态和时间戳,
|
||||
不保留原始密钥、响应内容或余额数值。
|
||||
|
||||
菜单最多同时检查三个连接,同一运行时复用正在进行的检测。结果缓存一分钟,
|
||||
过期后重新打开菜单会再次检查;修改密钥、连接或代理配置会使旧缓存失效。
|
||||
检测结果只保留在内存中,不写入配置,也不自动切换模型。
|
||||
|
||||
## Compatibility / 兼容性
|
||||
|
||||
| Contract / 合约 | Older client / 旧客户端 | New client / 新客户端 |
|
||||
| --- | --- | --- |
|
||||
| Health settings API / 检测设置 API | Missing route / 无路由 | Missing/error means no automatic probes / 缺失或错误时不发自动检测 |
|
||||
| `desktop.provider_health_checks` | Absent means off / 缺省关闭 | User-global opt-in only / 仅用户全局配置可开启 |
|
||||
|
||||
Requirements and scope decisions / 需求与范围记录:
|
||||
[Discussion #14617](https://github.com/esengine/DeepSeek-Reasonix/issues/14617).
|
||||
The grouped layout is retained per maintainer feedback / 按维护者反馈保留分组布局:
|
||||
[Review](https://github.com/esengine/DeepSeek-Reasonix/pull/14634#issuecomment-6449927590).
|
||||
@@ -100,6 +100,7 @@ Checked against `studio` at `d779d89bc` on 2026-10-08.
|
||||
| Feature | Status | Studio location |
|
||||
| --- | --- | --- |
|
||||
| Per-model display aliases | Have | Model service settings and chat picker; [configuration and scope](MODEL_DISPLAY_ALIASES.md); #11774 |
|
||||
| Model connection health | Have | Opt-in status chips in the existing grouped picker; [scope and limits](MODEL_CONNECTION_HEALTH.md). 默认关闭,保留账户分组与接口域名。 |
|
||||
| Icon rail with three display modes | Have | #12377, #12428; sidebar footer entries appear only when icon navigation is hidden (#12478) |
|
||||
| Usage, feedback, account and settings footer / 用量、反馈、账号与设置底部入口 | Have differently | `ui/Nav.tsx` when navigation is visible; `ui/Sidebar.tsx` otherwise, including the narrow-window drawer. 导航可见时使用图标栏入口,隐藏时保留侧栏入口 (#12478) |
|
||||
| Completed-unread marker on sessions | Have | #12218 |
|
||||
|
||||
@@ -266,6 +266,8 @@ var mirroredWireTypes = []wireMirror{
|
||||
{"internal/frontend/serve/providers.go", "protocolView", tsProviderFile, "Protocol"},
|
||||
{"internal/frontend/serve/provider_presets.go", "providerPresetView", tsProviderFile, "ProviderPreset"},
|
||||
{"internal/frontend/serve/provider_check.go", "providerCheck", tsProviderFile, "ProviderCheck"},
|
||||
{"internal/model/providerhealth/check.go", "Reading", tsProviderFile, "ProviderHealth"},
|
||||
{"internal/session/control/provider_health.go", "ProviderHealthSettings", tsProviderFile, "ProviderHealthSettings"},
|
||||
{"internal/frontend/serve/providers.go", "providerRemovalView", tsProviderFile, "ProviderRemoval"},
|
||||
{"internal/frontend/serve/provider_check.go", "providerModelCheck", tsProviderFile, "ProviderModelCheck"},
|
||||
{"internal/frontend/serve/settings.go", "modelEntry", tsModelFile, "ModelEntry"},
|
||||
|
||||
Reference in new issue
Block a user