Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion packages/webui/src/client/components/plugins/PluginCatalogue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { ToggleSwitch } from "../shared/ToggleSwitch.js";
import { readWebuiPluginRow as read, type WebuiPluginRow as Row } from "../../contracts/plugins/plugin-rows.js";
import { PluginImportDialog } from "./PluginImportDialog.js";
import { PluginItemDialog } from "./PluginItemDialog.js";
import { describeWebuiMcpServerStatus } from "../../projection/plugins/mcp-status.js";
import { describeWebuiMcpServerStatus, describeWebuiMcpTestResult, type WebuiMcpTestResultView } from "../../projection/plugins/mcp-status.js";

type Area = "plugins" | "skills" | "apps" | "mcp" | "agents";
type PluginCatalogueSelection = {
Expand Down Expand Up @@ -126,6 +126,15 @@ export function PluginCatalogue({
2,
),
);
/* The on-demand connection probe's outcome, keyed by server name: the one
* action that answers with a real connection result, driven from the row so
* a server that cannot connect can say why without a log file. One slot is
* enough — a user tests one server at a time. */
const [mcpTest, setMcpTest] = useState<{
readonly name: string;
readonly busy: boolean;
readonly view?: WebuiMcpTestResultView;
} | null>(null);
const [agentName, setAgentName] = useState("");
const [agentDescription, setAgentDescription] = useState("");
const [agentPrompt, setAgentPrompt] = useState("");
Expand Down Expand Up @@ -232,6 +241,22 @@ export function PluginCatalogue({
if (window.confirm(`确定${label}?此操作会立即影响本地配置。`))
void mutate(action, input);
};
/* Drive `testMcpServer` from the row and show what came back. Unlike the
* other mutations this one's RESULT is the feature: `{ success, toolCount?,
* errorCode?, errorMessage? }` is the only place the real connection reason
* exists, so the outcome is read here rather than through `mutate`'s
* boolean. A thrown request is reported as a failed probe with an honest
* "never sent" detail — the row must never stay silent after a click. */
const runMcpTest = async (item: Row) => {
const name = nameOf(item);
setMcpTest({ name, busy: true });
try {
const result = await runAction("testMcpServer", { name });
setMcpTest({ name, busy: false, view: describeWebuiMcpTestResult(result) });
} catch {
setMcpTest({ name, busy: false, view: { key: "failed", label: "连接失败", detail: "测试请求未能发送" } });
}
};
const nameOf = (item: Row) =>
read(
item,
Expand Down Expand Up @@ -1050,8 +1075,29 @@ export function PluginCatalogue({
{mcpStatus.reason}
</small>
) : null}
{/* The probe's outcome for this row: the runtime's own
* reason as visible text, replacing the need to read a
* server log. Keyed by name so a test on one row does
* not decorate another. */}
{mcpTest?.name === name && mcpTest.view ? (
<small
className={`text-size_12 ${mcpTest.view.key === "failed" ? "text-text_label_danger_secondary_default" : "text-text_default_secondary"}`}
data-webui-mcp-test={mcpTest.view.key}
data-testid="mcp-test-result"
>
{mcpTest.view.label}
{mcpTest.view.detail ? ` · ${mcpTest.view.detail}` : ""}
</small>
) : null}
</div>
<div className="webui-plugin-actions">
<button
data-testid="mcp-test-connection"
disabled={mcpTest?.name === name && mcpTest.busy}
onClick={() => void runMcpTest(item)}
>
{mcpTest?.name === name && mcpTest.busy ? "测试中…" : "测试连接"}
</button>
<button onClick={() => void prepareMcp(item)}>编辑</button>
<button
onClick={() =>
Expand Down
66 changes: 55 additions & 11 deletions packages/webui/src/client/infrastructure/connection-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,21 @@
// closes: `watchEvents` reports its socket's health here, and the banner
// merges the signal with the per-session stream phase.
//
// Aggregation rule — a watcher is *degraded* only once it has been healthy
// and then went down. A watcher that has never been accepted (boot in
// progress, or a host that never answers events) is "unknown", not degraded:
// flagging it would flash 重连 at every page load, and the honest statement
// about a link that never existed is nothing, not "reconnecting". With
// several watchers mounted (the shell's plus the workspace panels'), any
// once-healthy watcher being down degrades the channel; the transport owns
// the reporting, so every current and future `watchEvents` caller is covered
// without each of them wiring callbacks.
// Aggregation rule — a watcher is *degraded* once it has been healthy and
// then went down, or once the startup grace ran out without it ever being
// accepted. The grace exists because of the boot shape the original rule was
// protecting: a watcher that has not been accepted *yet* is "unknown", not
// degraded, so a page that is still loading never flashes a reconnecting
// banner. But "unknown" used to last forever, and a page whose backend was
// already unreachable when it loaded stayed completely silent — the banner
// read the stream phase (`idle` = connected) and nothing else was watching.
// So the unknown state now has a deadline: a watcher still unaccepted after
// `STARTUP_GRACE_MS` degrades the channel, because at that point the honest
// statement is "this link is not coming up", not "we are still booting".
// With several watchers mounted (the shell's plus the workspace panels'), any
// once-healthy watcher being down — or any watcher past its grace — degrades
// the channel; the transport owns the reporting, so every current and future
// `watchEvents` caller is covered without each of them wiring callbacks.
//
// This module is framework-free. The React subscription lives in
// `client/bindings/use-connection-health.ts`.
Expand All @@ -29,26 +35,58 @@ interface WebuiWatcherHealth {
readonly token: number;
healthy: boolean;
everHealthy: boolean;
/** The startup grace ran out while the watcher was still unaccepted. */
startupExpired: boolean;
graceTimer?: ReturnType<typeof setTimeout>;
}

/** How long a freshly registered watcher may stay unaccepted before the
* channel is declared degraded. Generous next to the transport's 250ms
* reconnect loop and a local WebSocket handshake, and far below the point
* where a user concludes the page is simply dead. */
const STARTUP_GRACE_MS = 10_000;

const watchers = new Map<number, WebuiWatcherHealth>();
const listeners = new Set<() => void>();
let nextToken = 0;

function degraded(): boolean {
for (const watcher of watchers.values())
if (watcher.everHealthy && !watcher.healthy) return true;
if (!watcher.healthy && (watcher.everHealthy || watcher.startupExpired)) return true;
return false;
}

function notifyIfChanged(before: boolean): void {
if (degraded() !== before) for (const listener of listeners) listener();
}

/** Node's timer exposes `unref`; the DOM lib types `setTimeout` as returning
* a plain number, so the capability is read through a narrow cast rather
* than assumed on the type. */
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
(timer as { unref?: () => void }).unref?.();
}

/** Registers one `watchEvents` subscription. Called by the transport only. */
export function registerWebuiEventWatcher(): number {
nextToken += 1;
watchers.set(nextToken, { token: nextToken, healthy: false, everHealthy: false });
const watcher: WebuiWatcherHealth = {
token: nextToken,
healthy: false,
everHealthy: false,
startupExpired: false,
};
watchers.set(nextToken, watcher);
// The unknown state's deadline. `unref` where the host offers it so the
// timer never holds a process (or a test worker) open on its own.
watcher.graceTimer = setTimeout(() => {
watcher.graceTimer = undefined;
if (watcher.healthy) return;
const before = degraded();
watcher.startupExpired = true;
if (degraded() !== before) for (const listener of listeners) listener();
}, STARTUP_GRACE_MS);
unrefTimer(watcher.graceTimer);
return nextToken;
}

Expand All @@ -57,6 +95,10 @@ export function markWebuiEventWatcherHealthy(token: number): void {
const watcher = watchers.get(token);
if (!watcher) return;
const before = degraded();
if (watcher.graceTimer !== undefined) {
clearTimeout(watcher.graceTimer);
watcher.graceTimer = undefined;
}
watcher.healthy = true;
watcher.everHealthy = true;
notifyIfChanged(before);
Expand All @@ -73,6 +115,8 @@ export function markWebuiEventWatcherDown(token: number): void {

/** The subscription unsubscribed (component unmounted); it no longer counts. */
export function unregisterWebuiEventWatcher(token: number): void {
const watcher = watchers.get(token);
if (watcher?.graceTimer !== undefined) clearTimeout(watcher.graceTimer);
const before = degraded();
watchers.delete(token);
notifyIfChanged(before);
Expand Down
17 changes: 15 additions & 2 deletions packages/webui/src/client/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ import { NotFound } from "./components/shell/NotFound.js";
import { ArchonPage } from "./components/shell/ArchonPage.js";
import { WebuiErrorBoundary } from "./components/shell/WebuiErrorBoundary.js";
import { WebuiStartupFallback } from "./components/shell/StartupFallback.js";
import { WebuiBrowserCapabilitiesProvider } from "./bindings/browser-capabilities.js";
import { WebuiBrowserCapabilitiesProvider, useWebuiBrowserCapabilities } from "./bindings/browser-capabilities.js";
import { createWebuiBrowserDom } from "./infrastructure/browser-dom.js";
import {
createWebuiBrowserStorage,
getWebuiBrowserStorage,
} from "./infrastructure/storage.js";
import { createWebuiApplication } from "./application/create-application.js";
import { createWebuiComposerStore } from "./application/composer/composer-store.js";
import { createWebuiEventEffectsRegistry } from "./application/turn/event-effects-registry.js";
import { createWebuiPortSlices } from "./application/port-slices.js";
import { createWebuiOpenEventChannel } from "./bindings/event-channel-adapter.js";
Expand Down Expand Up @@ -69,6 +70,17 @@ function WebuiClientRoot({
() => createWebuiEventEffectsRegistry(),
[],
);
// The same storage adapter the shell would have built for its own composer
// store. The application layer may not import infrastructure, so the
// composition root injects it — without this the adopted composer store has
// no storage at all and every draft dies with the page (regression from
// e40a63f: the root stopped handing the shell a store to build, and the
// application's own default is storage-less by design).
const { storage } = useWebuiBrowserCapabilities();
const composerStore = React.useMemo(
() => createWebuiComposerStore({ storage }),
[storage],
);
const slices = React.useMemo(
() => createWebuiPortSlices(transport),
[transport],
Expand All @@ -85,6 +97,7 @@ function WebuiClientRoot({
// own view reactions on top and registers them through
// `setEventEffects`, because it renders after this instance exists.
effects: effectsRegistry.asWebuiEventEffects(),
composer: composerStore,
turns: {
resumeSession: transport.resumeSession ?? (async () => {}),
...(transport.loadMessages
Expand All @@ -96,7 +109,7 @@ function WebuiClientRoot({
? { loadMessages: transport.loadMessages }
: {}),
}),
[effectsRegistry, transport],
[composerStore, effectsRegistry, transport],
);
React.useEffect(() => () => application.dispose(), [application]);

Expand Down
37 changes: 37 additions & 0 deletions packages/webui/src/client/projection/plugins/mcp-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,40 @@ export function describeWebuiMcpServerStatus(item: Row): WebuiMcpServerStatusVie
return { key: "unknown", label: "未知状态", tone: "text-text_default_tertiary" };
}
}

/**
* One on-demand connection test's outcome, as the row shows it.
*
* `testMcpServer` is the only action that returns a real connection result
* (`McpConnectionTestResult`: `{ success, toolCount?, errorCode?,
* errorMessage? }`), and the panel never called it — so a server that could
* not connect had no way to say WHY from the UI, even though the reason
* (a `spawnSync ENOENT` binary, an unlogged-in CLI) was sitting in the
* server log. The listing rows carry no status at all
* (`listConfiguredServers` → `configuredSummary` is upstream's to fix), so
* this probe is the honest surface until then.
*
* The failure text is the runtime's own `errorMessage` verbatim; when it is
* absent the `errorCode` rides along rather than a blank detail, because a
* machine token still beats nothing when it is all the runtime said.
*/
export interface WebuiMcpTestResultView {
readonly key: "connected" | "failed";
readonly label: string;
readonly detail?: string;
}

export function describeWebuiMcpTestResult(result: unknown): WebuiMcpTestResultView {
const record = result && typeof result === "object" && !Array.isArray(result)
? (result as Record<string, unknown>)
: {};
if (record.success !== true) {
const message = typeof record.errorMessage === "string" ? record.errorMessage.trim() : "";
const code = typeof record.errorCode === "string" ? record.errorCode.trim() : "";
return { key: "failed", label: "连接失败", ...(message || code ? { detail: message || code } : {}) };
}
const toolCount = typeof record.toolCount === "number" && Number.isFinite(record.toolCount)
? `${record.toolCount} 个工具`
: undefined;
return { key: "connected", label: "已连接", ...(toolCount ? { detail: toolCount } : {}) };
}
60 changes: 58 additions & 2 deletions packages/webui/test/unit/connection-health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
// Two layers here:
// * the store's aggregation rule — degraded means "lost a link it
// previously held"; a never-accepted watcher is unknown, not degraded,
// so a booting page never flashes a reconnecting banner;
// so a booting page never flashes a reconnecting banner — but that
// unknown state has a deadline, so a page whose backend was already
// unreachable when it loaded stops being silent (the Q-4b remnant);
// * the transport wiring — `watchEvents` registers, marks healthy on the
// server's acceptance, marks down on socket close, and unregisters on
// unsubscribe, so every current and future watcher counts without its
Expand All @@ -21,7 +23,7 @@
// unregisters in `afterEach` — a leaked once-healthy watcher would degrade
// every later suite in this worker.

import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
isWebuiEventChannelDegraded,
Expand Down Expand Up @@ -53,6 +55,60 @@ describe("the watcher health store", () => {
expect(isWebuiEventChannelDegraded()).toBe(false);
});

describe("the startup grace on a never-accepted watcher", () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it("keeps a booting watcher unknown until the grace runs out, then degrades", () => {
// The remnant: "unknown" used to last forever, so a page that loaded
// against an already-unreachable backend showed nothing at all — the
// banner reads the stream phase, which stays idle ("connected"). The
// grace is what keeps the boot shape quiet while giving the dead-backend
// shape a deadline.
register();
vi.advanceTimersByTime(9_000);
expect(isWebuiEventChannelDegraded()).toBe(false);
vi.advanceTimersByTime(1_500);
expect(isWebuiEventChannelDegraded()).toBe(true);
});

it("stays unknown for good once the server accepts the watcher", () => {
// The ordinary boot: accepted in milliseconds, and the cancelled grace
// never fires — a healthy channel must not degrade on a timer later.
const token = register();
markWebuiEventWatcherHealthy(token);
vi.advanceTimersByTime(120_000);
expect(isWebuiEventChannelDegraded()).toBe(false);
});

it("an acceptance after the grace still clears the flag", () => {
// A slow-but-alive backend: the grace fired while the socket was still
// opening, and the acceptance that arrives afterwards must restore the
// channel rather than leave it degraded for the page's lifetime.
const token = register();
vi.advanceTimersByTime(10_000);
expect(isWebuiEventChannelDegraded()).toBe(true);
markWebuiEventWatcherHealthy(token);
expect(isWebuiEventChannelDegraded()).toBe(false);
});

it("expires the grace even when the socket keeps closing and retrying", () => {
// The unreachable-backend shape end to end: the connect fails, the
// transport's close handler marks the watcher down and retries every
// 250ms. None of that is an acceptance, so the grace still runs out —
// which is exactly the state that used to stay silent.
const token = register();
markWebuiEventWatcherDown(token);
vi.advanceTimersByTime(10_000);
expect(isWebuiEventChannelDegraded()).toBe(true);
});
});

it("degrades only after a healthy watcher goes down", () => {
const token = register();
markWebuiEventWatcherHealthy(token);
Expand Down
Loading