Polish AI settings integrations UI (#1409)

This commit is contained in:
陈大猫
2026-06-11 17:07:11 +08:00
committed by GitHub
parent 74d41b43b6
commit f5c4271a07
11 changed files with 126 additions and 67 deletions

View File

@@ -124,6 +124,9 @@ export const enAiMessages: Messages = {
'ai.cursor.apiKeyFromEnv': 'From environment',
'ai.cursor.apiKey': 'API Key',
'ai.cursor.apiKeyPlaceholder': 'Enter Cursor API key',
'ai.cursor.apiKeyPlaceholder.env': 'Using CURSOR_API_KEY; enter a key to override',
'ai.cursor.apiKeyEnvHint': 'Cursor can use CURSOR_API_KEY from your shell. Save a key here only if you want Netcatty to override it.',
'ai.cursor.apiKeyOverrideHint': 'Netcatty will use the saved key here before CURSOR_API_KEY.',
'ai.cursor.saveApiKey': 'Save',
'ai.cursor.saved': 'Saved',
'ai.cursor.showApiKey': 'Show API key',

View File

@@ -124,6 +124,9 @@ export const ruAiMessages: Messages = {
'ai.cursor.apiKeyFromEnv': 'Из окружения',
'ai.cursor.apiKey': 'API-ключ',
'ai.cursor.apiKeyPlaceholder': 'Введите API-ключ Cursor',
'ai.cursor.apiKeyPlaceholder.env': 'Используется CURSOR_API_KEY; введите ключ для замены',
'ai.cursor.apiKeyEnvHint': 'Cursor может использовать CURSOR_API_KEY из shell. Сохраняйте ключ здесь только если хотите переопределить его в Netcatty.',
'ai.cursor.apiKeyOverrideHint': 'Netcatty сначала использует сохранённый здесь ключ, затем CURSOR_API_KEY.',
'ai.cursor.saveApiKey': 'Сохранить',
'ai.cursor.saved': 'Сохранено',
'ai.cursor.showApiKey': 'Показать API-ключ',

View File

@@ -124,6 +124,9 @@ export const zhCNAiMessages: Messages = {
'ai.cursor.apiKeyFromEnv': '来自环境变量',
'ai.cursor.apiKey': 'API Key',
'ai.cursor.apiKeyPlaceholder': '输入 Cursor API Key',
'ai.cursor.apiKeyPlaceholder.env': '已使用 CURSOR_API_KEY填写后会覆盖',
'ai.cursor.apiKeyEnvHint': '已检测到本机 CURSOR_API_KEY。留空即可继续使用填写保存后会覆盖它。',
'ai.cursor.apiKeyOverrideHint': '当前优先使用这里保存的 Key清空保存后会回到 CURSOR_API_KEY。',
'ai.cursor.saveApiKey': '保存',
'ai.cursor.saved': '已保存',
'ai.cursor.showApiKey': '显示 API Key',

View File

@@ -3,7 +3,7 @@ import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/
import { getExternalAgentSdkBackend } from '../../infrastructure/ai/managedAgents';
interface NetcattyBridge {
aiDiscoverAgents(options?: { refreshShellEnv?: boolean }): Promise<DiscoveredAgent[]>;
aiDiscoverAgents(options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }): Promise<DiscoveredAgent[]>;
}
function getBridge(): NetcattyBridge | undefined {
@@ -19,20 +19,27 @@ export function useAgentDiscovery(
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
const [isDiscovering, setIsDiscovering] = useState(false);
const cursorApiKeyPresent = externalAgents.some(
(agent) => agent.id === "discovered_cursor" && Boolean(agent.apiKey),
);
const discover = useCallback(async (discoverOptions?: { refreshShellEnv?: boolean }) => {
const bridge = getBridge();
if (!bridge) return;
setIsDiscovering(true);
try {
const agents = await bridge.aiDiscoverAgents(discoverOptions);
const agents = await bridge.aiDiscoverAgents({
...discoverOptions,
apiKeyPresent: cursorApiKeyPresent,
});
setDiscoveredAgents(agents);
} catch (err) {
console.error('Agent discovery failed:', err);
} finally {
setIsDiscovering(false);
}
}, []);
}, [cursorApiKeyPresent]);
useEffect(() => {
if (!enabled) return;

View File

@@ -3,7 +3,7 @@
*
* Sub-components live in ./ai/ directory:
* - ProviderCard, ProviderConfigForm, AddProviderDropdown
* - ModelSelector, ProviderIconBadge
* - ModelSelector
* - CodexConnectionCard, ClaudeCodeCard, CodebuddyCard
* - SafetySettings
*/
@@ -35,7 +35,6 @@ import {
getBridge,
normalizeCodexBridgeError,
} from "./ai/types";
import { ProviderIconBadge } from "./ai/ProviderIconBadge";
import { ProviderCard } from "./ai/ProviderCard";
import { AddProviderDropdown } from "./ai/AddProviderDropdown";
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
@@ -614,7 +613,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<SettingsSection
title={t('ai.codex')}
leading={<ProviderIconBadge providerId="openai" size="sm" />}
leading={<AgentIconBadge agent={{ id: "codex", icon: "openai", name: "Codex CLI" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
>
<CodexConnectionCard
pathInfo={codexPathInfo}
@@ -636,7 +635,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<SettingsSection
title={t('ai.claude.title')}
leading={<ProviderIconBadge providerId="claude" size="sm" />}
leading={<AgentIconBadge agent={{ id: "claude", icon: "claude", name: "Claude Code" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
>
<ClaudeCodeCard
pathInfo={claudePathInfo}
@@ -655,7 +654,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<SettingsSection
title={t('ai.copilot.title')}
leading={<ProviderIconBadge providerId="copilot" size="sm" />}
leading={<AgentIconBadge agent={{ id: "copilot", icon: "copilot", name: "GitHub Copilot CLI" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
>
<CopilotCliCard
pathInfo={copilotPathInfo}
@@ -668,7 +667,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<SettingsSection
title={t('ai.cursor.title')}
leading={<AgentIconBadge agent={{ id: "cursor", icon: "cursor", name: "Cursor" }} size="xs" variant="plain" />}
leading={<AgentIconBadge agent={{ id: "cursor", icon: "cursor", name: "Cursor" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
>
<CursorSdkCard
pathInfo={cursorPathInfo}
@@ -681,7 +680,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<SettingsSection
title={t('ai.codebuddy.title')}
leading={<ProviderIconBadge providerId="codebuddy" size="sm" />}
leading={<AgentIconBadge agent={{ id: "codebuddy", icon: "codebuddy", name: "CodeBuddy Code" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
>
<CodebuddyCard
pathInfo={codebuddyPathInfo}
@@ -752,20 +751,20 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</>
)}
>
<SettingCard padded className="space-y-4">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
<SettingCard padded className="space-y-3">
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground/80 leading-5">
{t('ai.userSkills.description')}
</p>
{userSkillsStatus?.directoryPath ? (
<p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground/80">
{t('ai.userSkills.location')}:{" "}
<span className="font-mono">{userSkillsStatus.directoryPath}</span>
</p>
) : null}
</div>
<div className="text-sm text-muted-foreground">
<div className="text-xs text-muted-foreground/80">
{isLoadingUserSkills
? t('ai.userSkills.loading')
: userSkillsStatus?.ok
@@ -777,25 +776,25 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</div>
{userSkillsStatus?.ok && userSkillsStatus.skills && userSkillsStatus.skills.length > 0 ? (
<div className="space-y-3">
<div className="border-t border-border/60 divide-y divide-border/60">
{userSkillsStatus.skills.map((skill) => (
<div
key={skill.id}
className="rounded-md border border-border/60 bg-background/70 p-3"
className="py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="font-medium">{skill.name}</div>
<div className="text-sm text-muted-foreground">{skill.description}</div>
<div className="text-xs text-muted-foreground font-mono break-all">
<div className="text-sm font-medium">{skill.name}</div>
<div className="text-xs text-muted-foreground leading-5">{skill.description}</div>
<div className="text-xs text-muted-foreground/80 font-mono break-all">
{skill.directoryName}
</div>
</div>
<span
className={
skill.status === "ready"
? "rounded-full bg-emerald-500/10 px-2 py-1 text-xs font-medium text-emerald-600"
: "rounded-full bg-amber-500/10 px-2 py-1 text-xs font-medium text-amber-600"
? "text-xs font-medium text-emerald-500 shrink-0"
: "text-xs font-medium text-amber-500 shrink-0"
}
>
{skill.status === "ready"
@@ -804,7 +803,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</span>
</div>
{skill.warnings.length > 0 ? (
<div className="mt-3 space-y-1 text-sm text-amber-700">
<div className="mt-2 space-y-1 text-xs text-amber-500">
{skill.warnings.map((warning, index) => (
<div key={`${skill.id}-${index}`} className="flex items-start gap-2">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
@@ -817,7 +816,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
))}
</div>
) : userSkillsStatus?.ok ? (
<div className="text-sm text-muted-foreground">
<div className="border-t border-border/60 pt-3 text-sm text-muted-foreground">
{t('ai.userSkills.empty')}
</div>
) : null}

View File

@@ -4,7 +4,6 @@ import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo } from "./types";
import { ProviderIconBadge } from "./ProviderIconBadge";
import { parseEnvLines, serializeEnvLines } from "./codebuddyConfigEnv";
const INTERNET_ENV_OPTIONS = [
@@ -67,17 +66,11 @@ export const CodebuddyCard: React.FC<{
: "text-amber-500";
return (
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-3">
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<ProviderIconBadge providerId="codebuddy" size="sm" />
<span className="text-sm font-medium">{t('ai.codebuddy.title')}</span>
</div>
<p className="text-xs text-muted-foreground mt-2 leading-5">
{t('ai.codebuddy.description')}
</p>
</div>
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t('ai.codebuddy.description')}
</p>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{statusText}
</div>

View File

@@ -54,6 +54,7 @@ export const CursorSdkCard: React.FC<{
const hasStoredApiKey = Boolean(encryptedApiKey);
const usesEnvApiKey = pathInfo?.authSource === "CURSOR_API_KEY";
const hasAnyApiKey = hasStoredApiKey || usesEnvApiKey;
const canSave = !isSaving && !isDecrypting && (Boolean(apiKeyDraft.trim()) || hasStoredApiKey);
const installStatus = isResolvingPath
? t("ai.cursor.detecting")
@@ -114,7 +115,13 @@ export const CursorSdkCard: React.FC<{
setSaved(false);
setApiKeyDraft(event.target.value);
}}
placeholder={isDecrypting ? t("ai.providers.apiKey.decrypting") : t("ai.cursor.apiKeyPlaceholder")}
placeholder={
isDecrypting
? t("ai.providers.apiKey.decrypting")
: usesEnvApiKey && !hasStoredApiKey
? t("ai.cursor.apiKeyPlaceholder.env")
: t("ai.cursor.apiKeyPlaceholder")
}
disabled={isDecrypting}
className="w-full h-8 rounded-md border border-input bg-background px-3 pr-9 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
/>
@@ -127,7 +134,7 @@ export const CursorSdkCard: React.FC<{
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving || isDecrypting}>
<Button variant="outline" size="sm" onClick={handleSave} disabled={!canSave}>
{saved ? <Check size={14} className="mr-1.5" /> : null}
{saved ? t("ai.cursor.saved") : t("ai.cursor.saveApiKey")}
</Button>
@@ -136,6 +143,16 @@ export const CursorSdkCard: React.FC<{
{t("ai.cursor.check")}
</Button>
</div>
{usesEnvApiKey && !hasStoredApiKey ? (
<p className="text-[11px] text-muted-foreground leading-4">
{t("ai.cursor.apiKeyEnvHint")}
</p>
) : null}
{usesEnvApiKey && hasStoredApiKey ? (
<p className="text-[11px] text-muted-foreground leading-4">
{t("ai.cursor.apiKeyOverrideHint")}
</p>
) : null}
</div>
</div>
);

View File

@@ -163,13 +163,13 @@ export const QuickMessagesSettings: React.FC<QuickMessagesSettingsProps> = ({
</Button>
)}
>
<SettingCard padded className="space-y-4">
<p className="text-sm text-muted-foreground">
<SettingCard padded className="space-y-3">
<p className="text-xs text-muted-foreground/80 leading-5">
{t("ai.quickMessages.description")}
</p>
{showEditor ? (
<div className="rounded-md border border-border/60 bg-background/70 p-4 space-y-3">
<div className="rounded-md border border-border/60 bg-background/40 p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium">
{isCreating ? t("ai.quickMessages.createTitle") : t("ai.quickMessages.editTitle")}
@@ -249,23 +249,23 @@ export const QuickMessagesSettings: React.FC<QuickMessagesSettingsProps> = ({
) : null}
{sortedMessages.length > 0 ? (
<div className="space-y-2">
<div className="border-t border-border/60 divide-y divide-border/60">
{sortedMessages.map((message) => (
<div
key={message.id}
className="rounded-md border border-border/60 bg-background/70 p-3"
className="py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<MessageSquare size={14} className="text-primary/70 shrink-0" />
<span className="font-medium">{message.name}</span>
<span className="text-xs font-mono text-muted-foreground">/{message.slug}</span>
<MessageSquare size={14} className="text-muted-foreground shrink-0" />
<span className="text-sm font-medium">{message.name}</span>
<span className="text-xs font-mono text-muted-foreground/80">/{message.slug}</span>
</div>
{message.description ? (
<p className="text-sm text-muted-foreground">{message.description}</p>
<p className="text-xs text-muted-foreground leading-5">{message.description}</p>
) : null}
<p className="text-xs text-muted-foreground/80 line-clamp-2 whitespace-pre-wrap">
<p className="text-xs text-muted-foreground/70 line-clamp-2 whitespace-pre-wrap">
{message.content}
</p>
</div>
@@ -273,7 +273,7 @@ export const QuickMessagesSettings: React.FC<QuickMessagesSettingsProps> = ({
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => beginEdit(message)}
aria-label={t("ai.quickMessages.editTitle")}
>
@@ -282,7 +282,7 @@ export const QuickMessagesSettings: React.FC<QuickMessagesSettingsProps> = ({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(message)}
aria-label={t("ai.quickMessages.confirmDelete", { name: message.name })}
>
@@ -294,8 +294,7 @@ export const QuickMessagesSettings: React.FC<QuickMessagesSettingsProps> = ({
))}
</div>
) : !showEditor ? (
<div className="rounded-lg border border-dashed border-border/60 p-6 text-center">
<MessageSquare size={24} className="mx-auto text-muted-foreground mb-2" />
<div className="border-t border-border/60 pt-3 text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground">{t("ai.quickMessages.empty")}</p>
</div>
) : null}

View File

@@ -458,6 +458,30 @@ test("resolve-cli exposes Cursor SDK support when API key is saved in settings",
}
});
test("resolve-cli reports settings as Cursor auth source when settings and env keys both exist", async () => {
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: () => "/usr/local/bin/cursor",
shellEnv: { CURSOR_API_KEY: "env-key" },
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
const result = await resolveCli(
{ sender: { id: 1 } },
{ command: "cursor", customPath: "", apiKeyPresent: true },
);
assert.equal(result.available, true);
assert.equal(result.authenticated, true);
assert.equal(result.authSource, "settings");
} finally {
restore();
}
});
test("resolve-cli can refresh shell env before resolving Cursor", async () => {
let refreshed = false;
const { bridge, restore } = loadBridgeWithMocks({
@@ -483,6 +507,28 @@ test("resolve-cli can refresh shell env before resolving Cursor", async () => {
}
});
test("discover exposes Cursor SDK support when API key is saved in settings", async () => {
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: () => null,
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const discover = ipcMain.handlers.get("netcatty:ai:agents:discover");
const agents = await discover({ sender: { id: 1 } }, { apiKeyPresent: true });
const cursor = agents.find((agent) => agent.command === "cursor");
assert.equal(cursor?.path, "cursor");
assert.equal(cursor?.available, true);
assert.equal(cursor?.authenticated, true);
assert.equal(cursor?.authSource, "settings");
} finally {
restore();
}
});
test("discover can refresh shell env before scanning Cursor", async () => {
let refreshed = false;
const { bridge, restore } = loadBridgeWithMocks({

View File

@@ -26,7 +26,7 @@ async function probeCursorSdkAvailability(shellEnv, options = {}) {
installed: true,
available: authenticated,
authenticated,
authSource: hasEnvApiKey ? "CURSOR_API_KEY" : hasSettingsApiKey ? "settings" : null,
authSource: hasSettingsApiKey ? "settings" : hasEnvApiKey ? "CURSOR_API_KEY" : null,
version: "Cursor SDK",
};
}
@@ -58,8 +58,9 @@ function registerAgentDiscoveryHandlers(ctx) {
for (const agent of knownAgents) {
let cursorSdkStatus = null;
if (agent.command === "cursor") {
if (!shellEnv.CURSOR_API_KEY) continue;
cursorSdkStatus = await probeCursorSdkAvailability(shellEnv);
cursorSdkStatus = await probeCursorSdkAvailability(shellEnv, {
apiKeyPresent: Boolean(options?.apiKeyPresent),
});
if (!cursorSdkStatus.available) continue;
}

View File

@@ -4,7 +4,6 @@ const { getDriver, listBackends } = require("./index.cjs");
const { buildSdkAgentEnv } = require("./env.cjs");
const { buildInjectedMcpServers } = require("./injectMcp.cjs");
const { createStreamEmitter } = require("./emit.cjs");
const crypto = require("node:crypto");
const { realpathSync } = require("node:fs");
const VALID_BACKENDS = new Set(listBackends());
@@ -41,17 +40,6 @@ function normalizeHistoryMessages(historyMessages) {
.filter((msg) => msg.content.length > 0);
}
function summarizeSecret(value) {
const text = String(value || "");
if (!text) return null;
return {
length: text.length,
prefix: text.slice(0, 4),
suffix: text.slice(-4),
sha256: crypto.createHash("sha256").update(text).digest("hex").slice(0, 16),
};
}
function logCursorApiKeySummary({ requestedAgentEnv, shellEnv, env }) {
const requestedKey = requestedAgentEnv?.CURSOR_API_KEY;
const shellKey = shellEnv?.CURSOR_API_KEY;
@@ -65,7 +53,7 @@ function logCursorApiKeySummary({ requestedAgentEnv, shellEnv, env }) {
: "missing";
console.info("[Cursor SDK] API key summary", {
source,
effective: summarizeSecret(effectiveKey),
hasEffectiveKey: Boolean(effectiveKey),
});
}