产品化改造:完善本地集成与启动体验

This commit is contained in:
baiyanyun
2026-06-02 17:55:08 +08:00
parent a280774f50
commit 873cd6ef53
24 changed files with 3490 additions and 45 deletions

View File

@@ -5,6 +5,7 @@ import { startComputerServer } from "../services/computerServer";
import {
mcpProxyManager,
DEFAULT_MCP_PROXY_CONFIG,
pruneDisabledDefaultMcpServers,
} from "../services/packages/mcp";
import { getConfiguredPorts } from "../services/startupPorts";
import { DEPS_SYNC_TIMEOUT } from "@shared/constants";
@@ -55,13 +56,14 @@ export async function runStartupTasks(): Promise<void> {
if (savedConfig) {
try {
const parsed = JSON.parse(savedConfig.value);
// 合并默认服务器(如 chrome-devtools),确保内置 MCP 服务始终存在
// 合并可用的默认服务器;旧版本持久化的 chrome-devtools npx 默认项
// 在本地未安装且未显式启用时会被过滤,避免启动时阻塞 60s。
const merged = {
...parsed,
mcpServers: {
mcpServers: pruneDisabledDefaultMcpServers({
...DEFAULT_MCP_PROXY_CONFIG.mcpServers,
...(parsed.mcpServers || {}),
},
}),
};
mcpProxyManager.setConfig(merged);
} catch (e) {

View File

@@ -368,15 +368,71 @@ export function resolveServersConfig(
// ========== Types ==========
const CHROME_DEVTOOLS_MCP_NAME = "chrome-devtools";
const CHROME_DEVTOOLS_MCP_PACKAGE = "chrome-devtools-mcp";
function findLocalChromeDevtoolsMcpBin(): string | null {
const binName = isWindows()
? `${CHROME_DEVTOOLS_MCP_PACKAGE}.cmd`
: CHROME_DEVTOOLS_MCP_PACKAGE;
const candidates = [
path.join(
os.homedir(),
APP_DATA_DIR_NAME,
"node_modules",
".bin",
binName,
),
path.join(process.cwd(), "node_modules", ".bin", binName),
];
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
}
function shouldEnableDefaultChromeDevtoolsMcp(): boolean {
return (
process.env.QIMINGCLAW_ENABLE_CHROME_DEVTOOLS_MCP === "1" ||
!!findLocalChromeDevtoolsMcpBin()
);
}
function getDefaultMcpServers(): Record<string, McpServerEntry> {
if (!shouldEnableDefaultChromeDevtoolsMcp()) return {};
const localBin = findLocalChromeDevtoolsMcpBin();
return {
[CHROME_DEVTOOLS_MCP_NAME]: localBin
? {
command: localBin,
args: [],
persistent: true,
}
: {
command: "npx",
args: ["-y", `${CHROME_DEVTOOLS_MCP_PACKAGE}@latest`],
persistent: true,
},
};
}
function isLegacyDefaultChromeDevtoolsEntry(
name: string,
entry: McpServerEntry,
): boolean {
return (
name === CHROME_DEVTOOLS_MCP_NAME &&
!isRemoteEntry(entry) &&
entry.command === "npx" &&
Array.isArray(entry.args) &&
entry.args.length === 2 &&
entry.args[0] === "-y" &&
entry.args[1] === `${CHROME_DEVTOOLS_MCP_PACKAGE}@latest`
);
}
/** 默认 mcpServers 配置 */
export const DEFAULT_MCP_PROXY_CONFIG: McpServersConfig = {
mcpServers: {
"chrome-devtools": {
command: "npx",
args: ["-y", "chrome-devtools-mcp@latest"],
persistent: true,
},
},
mcpServers: getDefaultMcpServers(),
};
/**
@@ -437,6 +493,24 @@ export function isRemoteEntry(
return "url" in entry;
}
export function pruneDisabledDefaultMcpServers(
servers: Record<string, McpServerEntry> = {},
): Record<string, McpServerEntry> {
if (shouldEnableDefaultChromeDevtoolsMcp()) return servers;
const result: Record<string, McpServerEntry> = {};
for (const [name, entry] of Object.entries(servers)) {
if (isLegacyDefaultChromeDevtoolsEntry(name, entry)) {
log.info(
"[McpProxy] Default chrome-devtools MCP disabled (not installed locally; set QIMINGCLAW_ENABLE_CHROME_DEVTOOLS_MCP=1 to force npx)",
);
continue;
}
result[name] = entry;
}
return result;
}
/** mcpServers 配置(传给 qiming-mcp-stdio-proxy 的 JSON */
export interface McpServersConfig {
mcpServers: Record<string, McpServerEntry>;
@@ -1298,18 +1372,17 @@ export async function syncMcpConfigToProxyAndReload(
// realOnly 为空时(用户删除了所有动态 MCP不提前返回
// 继续执行以确保 bridge 仅运行默认服务chrome-devtools
// 始终以默认服务为基础,再叠加动态 MCP
// - 用户删除所有动态 MCP → merged 仅含 chrome-devtools
// - 用户删除部分动态 MCP → merged 含 chrome-devtools + 剩余动态 MCP
// - 用户新增动态 MCP → merged 含 chrome-devtools + 所有动态 MCP
// 以可用的默认服务为基础,再叠加动态 MCP。默认 chrome-devtools
// 只有本地已安装或显式启用时才注入,避免 npx @latest 在启动时阻塞 60s。
const merged: Record<string, McpServerEntry> = {
...DEFAULT_MCP_PROXY_CONFIG.mcpServers,
...getDefaultMcpServers(),
...realOnly,
};
const prunedMerged = pruneDisabledDefaultMcpServers(merged);
// 为所有 MCP 服务器注入基础环境变量(包括 PATH
const prepareStartedAt = Date.now();
const mergedWithEnv = injectBaseEnvToMcpServers(merged);
const mergedWithEnv = injectBaseEnvToMcpServers(prunedMerged);
// Bridge 只管理 persistent 服务(如 chrome-devtools动态 MCP 不进 bridge。
// 变更检测和重启均只针对 persistent servers避免动态 MCP 变化时重启 chrome-devtools。

View File

@@ -1448,10 +1448,26 @@ export async function checkQimingFileServerBundled(): Promise<{
export async function checkClaudeCodeAcpBundled(): Promise<{
available: boolean;
version?: string;
binPath?: string;
}> {
const bundledDir = getClaudeCodeAcpBundledDir();
if (!bundledDir) {
log.info("[checkClaudeCodeAcpBundled] Bundled not found");
const local = await detectNpmPackage(
"claude-code-acp-ts",
"claude-code-acp-ts",
);
if (local.installed) {
log.info(
`[checkClaudeCodeAcpBundled] Bundled not found; using npm-local package: ${local.binPath ?? "unknown"}, version=${local.version ?? "unknown"}`,
);
return {
available: true,
version: local.version,
binPath: local.binPath,
};
}
log.info("[checkClaudeCodeAcpBundled] Bundled and npm-local package not found");
return { available: false };
}
const pkgPath = path.join(bundledDir, "package.json");
@@ -1462,10 +1478,10 @@ export async function checkClaudeCodeAcpBundled(): Promise<{
log.info(
`[checkClaudeCodeAcpBundled] Bundled available: ${bundledDir}, version=${version ?? "unknown"}`,
);
return { available: true, version };
return { available: true, version, binPath: bundledDir };
} catch (e) {
log.warn("[checkClaudeCodeAcpBundled] Failed to read package.json:", e);
return { available: true };
return { available: true, binPath: bundledDir };
}
}
@@ -1960,7 +1976,21 @@ export async function checkAllDependencies(options?: {
item.status = "missing";
}
} else {
item.status = "missing";
const result = await detectNpmPackage(dep.name, dep.binName);
item.version = result.version;
item.binPath = result.binPath;
if (!result.installed) {
item.status = "missing";
} else if (dep.installVersion) {
const installed = (result.version ?? "0").replace(/^v/, "");
const target = dep.installVersion.replace(/^v/, "");
item.status =
installed === "0" || compareVersions(installed, target) < 0
? "outdated"
: "installed";
} else {
item.status = "installed";
}
}
break;
}

View File

@@ -486,13 +486,15 @@ function App() {
const deps = result?.results ?? [];
const hasMissingOrError = deps.some(
(d: { status: string }) =>
d.status === "missing" || d.status === "error",
(d: { status: string; required?: boolean }) =>
d.required !== false &&
(d.status === "missing" || d.status === "error"),
);
const missingDeps = deps
.filter(
(d: { status: string }) =>
d.status === "missing" || d.status === "error",
(d: { status: string; required?: boolean }) =>
d.required !== false &&
(d.status === "missing" || d.status === "error"),
)
.map((d: { name: string; status: string }) => d.name);

View File

@@ -20,6 +20,7 @@ import {
syncCookieAndGetNewSessionUrl,
syncCookieAndGetChatUrl,
persistTicketCookie,
normalizeLocalBackendRedirect,
} from "../../services/utils/sessionUrl";
import { logger } from "../../services/utils/logService";
import { APP_DISPLAY_NAME } from "@shared/constants";
@@ -89,19 +90,47 @@ function SessionsPage({
}
};
const onWillRedirect = (e: any) => {
const normalizedUrl = normalizeLocalBackendRedirect(
e.newURL,
webviewUrl,
);
if (normalizedUrl) {
logger.warn(
"[SessionsPage][WebviewNav] normalized local backend redirect",
"SessionsPage",
{
from: e.oldURL,
to: e.newURL,
normalizedUrl,
},
);
e.preventDefault?.();
el.loadURL?.(normalizedUrl);
return;
}
logger.info("[SessionsPage][WebviewNav] will-redirect", "SessionsPage", {
from: e.oldURL,
to: e.newURL,
});
};
const onDidFailLoad = (e: any) => {
logger.warn("[SessionsPage][WebviewNav] did-fail-load", "SessionsPage", {
url: e.validatedURL || e.url,
errorCode: e.errorCode,
errorDescription: e.errorDescription,
});
};
el.addEventListener("did-navigate", onDidNavigate);
el.addEventListener("did-navigate-in-page", onDidNavigate);
el.addEventListener("will-redirect", onWillRedirect);
el.addEventListener("did-fail-load", onDidFailLoad);
return () => {
el.removeEventListener("did-navigate", onDidNavigate);
el.removeEventListener("did-navigate-in-page", onDidNavigate);
el.removeEventListener("will-redirect", onWillRedirect);
el.removeEventListener("did-fail-load", onDidFailLoad);
};
}, [view, webviewUrl]); // eslint-disable-line react-hooks/exhaustive-deps

View File

@@ -3,6 +3,7 @@ import {
buildRedirectUrl,
buildNewSessionUrl,
buildChatSessionUrl,
normalizeLocalBackendRedirect,
syncSessionCookie,
syncCookieAndGetRedirectUrl,
syncCookieAndGetNewSessionUrl,
@@ -87,6 +88,51 @@ describe("buildChatSessionUrl", () => {
});
});
describe("normalizeLocalBackendRedirect", () => {
it("normalizes local redirects that lost protocol and port", () => {
expect(
normalizeLocalBackendRedirect(
"https://localhost/home/chat/5/26?hideMenu=true",
"http://localhost:18081/api/sandbox/config/redirect/6?hideMenu=true",
),
).toBe("http://localhost:18081/home/chat/5/26?hideMenu=true");
});
it("normalizes localhost and 127.0.0.1 mismatches to the configured backend", () => {
expect(
normalizeLocalBackendRedirect(
"https://localhost/home/chat/5/26?hideMenu=true",
"http://127.0.0.1:18081/api/sandbox/config/redirect/6?hideMenu=true",
),
).toBe("http://127.0.0.1:18081/home/chat/5/26?hideMenu=true");
});
it("does not rewrite already-correct local redirects", () => {
expect(
normalizeLocalBackendRedirect(
"http://localhost:18081/home/chat/5/26?hideMenu=true",
"http://localhost:18081/api/sandbox/config/redirect/6?hideMenu=true",
),
).toBeNull();
});
it("does not rewrite remote domains", () => {
expect(
normalizeLocalBackendRedirect(
"https://example.com/home/chat/5/26?hideMenu=true",
"https://example.com/api/sandbox/config/redirect/6?hideMenu=true",
),
).toBeNull();
});
it("returns null for invalid URLs", () => {
expect(normalizeLocalBackendRedirect("not-url", "http://localhost:18081"))
.toBeNull();
expect(normalizeLocalBackendRedirect("https://localhost", "not-url"))
.toBeNull();
});
});
describe("syncSessionCookie", () => {
it("不设 domain 和 secure由主进程根据 URL scheme 判断", async () => {
await syncSessionCookie("https://app.example.com:8080/path", "tok123");

View File

@@ -66,6 +66,46 @@ export function buildChatSessionUrl(domain: string, sessionId: string): string {
return `${normalizedDomain}/api/sandbox/config/redirect/chat/${sessionId}?hideMenu=true`;
}
function isLocalBackendHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1";
}
/**
* Some local backend builds redirect `http://localhost:18081/...` to
* `https://localhost/...`, losing both the protocol and port. Normalize only
* that local-only case so embedded webviews keep using the configured backend.
*/
export function normalizeLocalBackendRedirect(
redirectUrl: string,
expectedBackendUrl: string,
): string | null {
try {
const redirect = new URL(redirectUrl);
const expected = new URL(expectedBackendUrl);
if (
!isLocalBackendHost(expected.hostname) ||
!isLocalBackendHost(redirect.hostname)
) {
return null;
}
if (
redirect.protocol === expected.protocol &&
redirect.port === expected.port
) {
return null;
}
redirect.protocol = expected.protocol;
redirect.host = expected.host;
const normalized = redirect.toString();
return normalized === redirectUrl ? null : normalized;
} catch {
return null;
}
}
export async function syncSessionCookie(
domain: string,
token: string,