From e25feb56fcbf35426869063672d3198703fa7745 Mon Sep 17 00:00:00 2001 From: Kayshen Xu Date: Wed, 18 Mar 2026 21:34:58 +0800 Subject: [PATCH] V0.4.3 (#50) * chore(ai): update dependencies and enhance logging functionality - Bump version of `@github/copilot-sdk` and related packages to `0.1.32` and `1.0.7` for improved features and bug fixes. - Update Discord invite links across multiple README files to the new server. - Introduce a new logging utility in `server/utils/server-logger.ts` for better server process logging, including automatic log cleanup and directory management. - Enhance the `connect-agent.ts` and `install-agent.ts` files to improve OpenCode binary resolution and installation commands. - Refactor `resolve-claude-cli.ts` and `resolve-copilot-cli.ts` to include detailed logging for CLI binary resolution processes. This update improves dependency management, enhances user experience with updated links, and provides better insights into server operations through logging. * chore: bump version from 0.4.0 to 0.4.3 in package.json --- .github/ISSUE_TEMPLATE/config.yml | 2 +- README.de.md | 4 +- README.es.md | 4 +- README.fr.md | 4 +- README.hi.md | 4 +- README.id.md | 4 +- README.ja.md | 4 +- README.ko.md | 4 +- README.md | 4 +- README.pt.md | 4 +- README.ru.md | 4 +- README.th.md | 4 +- README.tr.md | 4 +- README.vi.md | 4 +- README.zh-TW.md | 4 +- README.zh.md | 4 +- bun.lock | 18 +- package.json | 4 +- server/api/ai/connect-agent.ts | 262 +++++++++- server/api/ai/install-agent.ts | 3 +- server/utils/copilot-client.ts | 54 +- server/utils/resolve-claude-cli.ts | 49 +- server/utils/server-logger.ts | 88 ++++ src/components/icons/copilot-logo.tsx | 2 +- .../shared/agent-settings-dialog.tsx | 468 ++++++++++-------- src/i18n/locales/de.ts | 7 + src/i18n/locales/en.ts | 7 + src/i18n/locales/es.ts | 7 + src/i18n/locales/fr.ts | 7 + src/i18n/locales/hi.ts | 7 + src/i18n/locales/id.ts | 7 + src/i18n/locales/ja.ts | 7 + src/i18n/locales/ko.ts | 7 + src/i18n/locales/pt.ts | 7 + src/i18n/locales/ru.ts | 7 + src/i18n/locales/th.ts | 7 + src/i18n/locales/tr.ts | 7 + src/i18n/locales/vi.ts | 7 + src/i18n/locales/zh-tw.ts | 7 + src/i18n/locales/zh.ts | 7 + src/stores/agent-settings-store.ts | 6 +- src/types/agent-settings.ts | 4 + 42 files changed, 854 insertions(+), 271 deletions(-) create mode 100644 server/utils/server-logger.ts diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index b5144239b..2717e8389 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - name: Discord - url: https://discord.gg/KwXp6BJD + url: https://discord.gg/h9Fmyy6pVh about: Ask questions and chat with the community diff --git a/README.de.md b/README.de.md index 33c2ef28b..cb33eebcf 100644 --- a/README.de.md +++ b/README.de.md @@ -17,7 +17,7 @@ Stars License CI - Discord + Discord


@@ -301,7 +301,7 @@ Beiträge sind willkommen! Siehe [CLAUDE.md](./CLAUDE.md) für Architekturdetail ## Community - + Discord Unserem Discord beitreten diff --git a/README.es.md b/README.es.md index ba1a87da7..46bd05664 100644 --- a/README.es.md +++ b/README.es.md @@ -17,7 +17,7 @@ Stars License CI - Discord + Discord


@@ -312,7 +312,7 @@ bun run electron:build # Empaquetado de Electron ## Comunidad - + Discord Únete a nuestro Discord diff --git a/README.fr.md b/README.fr.md index cd34e17a4..ead36e365 100644 --- a/README.fr.md +++ b/README.fr.md @@ -17,7 +17,7 @@ Stars License CI - Discord + Discord


@@ -312,7 +312,7 @@ Les contributions sont les bienvenues ! Consultez [CLAUDE.md](./CLAUDE.md) pour ## Communauté - + Discord Rejoindre notre Discord diff --git a/README.hi.md b/README.hi.md index 1e2713bea..7caecc97f 100644 --- a/README.hi.md +++ b/README.hi.md @@ -17,7 +17,7 @@ Stars License CI - Discord + Discord


@@ -301,7 +301,7 @@ bun run electron:build # Electron पैकेज ## समुदाय - + Discord हमारे Discord में शामिल हों diff --git a/README.id.md b/README.id.md index 4cc2ad015..d762d3518 100644 --- a/README.id.md +++ b/README.id.md @@ -17,7 +17,7 @@ Stars License CI - Discord + Discord


@@ -301,7 +301,7 @@ Kontribusi sangat disambut! Lihat [CLAUDE.md](./CLAUDE.md) untuk detail arsitekt ## Komunitas - + Discord Bergabung dengan Discord kami diff --git a/README.ja.md b/README.ja.md index 7ced3be31..1b9448f8f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -17,7 +17,7 @@ Stars License CI - Discord + Discord


@@ -301,7 +301,7 @@ bun run electron:build # Electron パッケージング ## コミュニティ - + Discord Discord に参加する diff --git a/README.ko.md b/README.ko.md index 886dc1b6a..6f948f218 100644 --- a/README.ko.md +++ b/README.ko.md @@ -17,7 +17,7 @@ Stars License CI - Discord + Discord


@@ -301,7 +301,7 @@ bun run electron:build # Electron 패키징 ## 커뮤니티 - + Discord Discord에 참여하기 diff --git a/README.md b/README.md index 931ffc129..ab443b89a 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Stars License CI - Discord + Discord


@@ -301,7 +301,7 @@ Contributions are welcome! See [CLAUDE.md](./CLAUDE.md) for architecture details ## Community - + Discord Join our Discord diff --git a/README.pt.md b/README.pt.md index cd6158b74..c5c247612 100644 --- a/README.pt.md +++ b/README.pt.md @@ -17,7 +17,7 @@ Stars License CI - Discord + Discord


@@ -301,7 +301,7 @@ Contribuições são bem-vindas! Consulte o [CLAUDE.md](./CLAUDE.md) para detalh ## Comunidade - + Discord Entre no nosso Discord diff --git a/README.ru.md b/README.ru.md index dd033f4c1..452c459d1 100644 --- a/README.ru.md +++ b/README.ru.md @@ -17,7 +17,7 @@ Stars License CI - Discord + Discord


@@ -301,7 +301,7 @@ bun run electron:build # Упаковка Electron ## Сообщество - + Discord Присоединяйтесь к нашему Discord diff --git a/README.th.md b/README.th.md index 84ba3e24c..4610cc32d 100644 --- a/README.th.md +++ b/README.th.md @@ -17,7 +17,7 @@ Stars License CI - Discord + Discord


@@ -301,7 +301,7 @@ bun run electron:build # Electron package ## ชุมชน - + Discord เข้าร่วม Discord ของเรา diff --git a/README.tr.md b/README.tr.md index 6482d9c7a..2f1d08dc5 100644 --- a/README.tr.md +++ b/README.tr.md @@ -17,7 +17,7 @@ Stars License CI - Discord + Discord


@@ -301,7 +301,7 @@ Katkılarınızı bekliyoruz! Mimari ayrıntılar ve kod stili için [CLAUDE.md] ## Topluluk - + Discord Discord'umuza katılın diff --git a/README.vi.md b/README.vi.md index e5f35af41..4464faad7 100644 --- a/README.vi.md +++ b/README.vi.md @@ -17,7 +17,7 @@ Stars License CI - Discord + Discord


@@ -301,7 +301,7 @@ Chào mừng đóng góp! Xem [CLAUDE.md](./CLAUDE.md) để biết chi tiết v ## Cộng đồng - + Discord Tham gia Discord của chúng tôi diff --git a/README.zh-TW.md b/README.zh-TW.md index e1d3b49fa..8040f4433 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -17,7 +17,7 @@ Stars License CI - Discord + Discord


@@ -301,7 +301,7 @@ bun run electron:build # Electron 封裝 ## 社群 - + Discord 加入我們的 Discord diff --git a/README.zh.md b/README.zh.md index 4b0eb6010..48c6b3148 100644 --- a/README.zh.md +++ b/README.zh.md @@ -17,7 +17,7 @@ Stars License CI - Discord + Discord


@@ -302,7 +302,7 @@ bun run electron:build # Electron 打包 ## 社区 - + Discord 加入我们的 Discord diff --git a/bun.lock b/bun.lock index a5e153e96..445c5ae77 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ "@fontsource/raleway": "^5.2.8", "@fontsource/roboto": "^5.2.10", "@fontsource/source-sans-3": "^5.2.9", - "@github/copilot-sdk": "^0.1.30", + "@github/copilot-sdk": "^0.1.32", "@iconify-json/feather": "^1.2.1", "@iconify-json/lucide": "^1.2.93", "@iconify-json/simple-icons": "^1.2.71", @@ -263,21 +263,21 @@ "@fontsource/source-sans-3": ["@fontsource/source-sans-3@5.2.9", "https://registry.npmmirror.com/@fontsource/source-sans-3/-/source-sans-3-5.2.9.tgz", {}, "sha512-u3ymIq4rfmCCyB9MEw/sFR5lPVJ1yTNXmIMbUz+9kVCFIHvNtfzXOEBuvkg3Tk0zhmioPeJ28ZK5smZ7TurezQ=="], - "@github/copilot": ["@github/copilot@0.0.420", "https://registry.npmmirror.com/@github/copilot/-/copilot-0.0.420.tgz", { "optionalDependencies": { "@github/copilot-darwin-arm64": "0.0.420", "@github/copilot-darwin-x64": "0.0.420", "@github/copilot-linux-arm64": "0.0.420", "@github/copilot-linux-x64": "0.0.420", "@github/copilot-win32-arm64": "0.0.420", "@github/copilot-win32-x64": "0.0.420" }, "bin": { "copilot": "npm-loader.js" } }, "sha512-UpPuSjxUxQ+j02WjZEFffWf0scLb23LvuGHzMFtaSsweR+P/BdbtDUI5ZDIA6T0tVyyt6+X1/vgfsJiRqd6jig=="], + "@github/copilot": ["@github/copilot@1.0.7", "https://registry.npmmirror.com/@github/copilot/-/copilot-1.0.7.tgz", { "optionalDependencies": { "@github/copilot-darwin-arm64": "1.0.7", "@github/copilot-darwin-x64": "1.0.7", "@github/copilot-linux-arm64": "1.0.7", "@github/copilot-linux-x64": "1.0.7", "@github/copilot-win32-arm64": "1.0.7", "@github/copilot-win32-x64": "1.0.7" }, "bin": { "copilot": "npm-loader.js" } }, "sha512-KHBaJ1kbc19pqUMnB9LubPtwWVOaDCzWbzwsJss+DvHyCpr8wP8jR3GEZUnhq3rsuXI96ZKEeEozXM0NqxCAiw=="], - "@github/copilot-darwin-arm64": ["@github/copilot-darwin-arm64@0.0.420", "https://registry.npmmirror.com/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.420.tgz", { "os": "darwin", "cpu": "arm64", "bin": { "copilot-darwin-arm64": "copilot" } }, "sha512-sj8Oxcf3oKDbeUotm2gtq5YU1lwCt3QIzbMZioFD/PMLOeqSX/wrecI+c0DDYXKofFhALb0+DxxnWgbEs0mnkQ=="], + "@github/copilot-darwin-arm64": ["@github/copilot-darwin-arm64@1.0.7", "https://registry.npmmirror.com/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.7.tgz", { "os": "darwin", "cpu": "arm64", "bin": { "copilot-darwin-arm64": "copilot" } }, "sha512-yQITowpkQYamww59CwcG5JTWV9ahj7nMH6oqObMJaeqXnG7j7dqE/YhLkujQZ3XR8VXAoIa1rZ3TahdMu94gOA=="], - "@github/copilot-darwin-x64": ["@github/copilot-darwin-x64@0.0.420", "https://registry.npmmirror.com/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.420.tgz", { "os": "darwin", "cpu": "x64", "bin": { "copilot-darwin-x64": "copilot" } }, "sha512-2acA93IqXz1uuz3TVUm0Y7BVrBr0MySh1kQa8LqMILhTsG0YHRMm8ybzTp2HA7Mi1tl5CjqMSk163kkS7OzfUA=="], + "@github/copilot-darwin-x64": ["@github/copilot-darwin-x64@1.0.7", "https://registry.npmmirror.com/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.7.tgz", { "os": "darwin", "cpu": "x64", "bin": { "copilot-darwin-x64": "copilot" } }, "sha512-23vP5bHaFA030nB3tr+dUUdRm2SqmQbs2fZUQ4F7JeYy59jp9hi8lBdaZp/TeQnjEirAUU9H2HZxsGRIIUWp7g=="], - "@github/copilot-linux-arm64": ["@github/copilot-linux-arm64@0.0.420", "https://registry.npmmirror.com/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.420.tgz", { "os": "linux", "cpu": "arm64", "bin": { "copilot-linux-arm64": "copilot" } }, "sha512-h/IvEryTOYm1HzR2GNq8s2aDtN4lvT4MxldfZuS42CtWJDOfVG2jLLsoHWU1T3QV8j1++PmDgE//HX0JLpLMww=="], + "@github/copilot-linux-arm64": ["@github/copilot-linux-arm64@1.0.7", "https://registry.npmmirror.com/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.7.tgz", { "os": "linux", "cpu": "arm64", "bin": { "copilot-linux-arm64": "copilot" } }, "sha512-g0mB98oyXKcpd4sMNBc5n1h3UhLy9AGRlT//VL8BXPSzvlTH/dJP3fdx74pbLSgvz105to/YUMmEAFfv25VNaw=="], - "@github/copilot-linux-x64": ["@github/copilot-linux-x64@0.0.420", "https://registry.npmmirror.com/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.420.tgz", { "os": "linux", "cpu": "x64", "bin": { "copilot-linux-x64": "copilot" } }, "sha512-iL2NpZvXIDZ+3lw7sO2fo5T0nKmP5dZbU2gdYcv+SFBm/ONhCxIY5VRX4yN/9VkFaa9ePv5JzCnsl3vZINiDxg=="], + "@github/copilot-linux-x64": ["@github/copilot-linux-x64@1.0.7", "https://registry.npmmirror.com/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.7.tgz", { "os": "linux", "cpu": "x64", "bin": { "copilot-linux-x64": "copilot" } }, "sha512-TRxzvTo9I4ehYJLFHTCJSJYQ4QnO/V9zebqwszxHpJRxuBd7FV4cxLmfOBqZcUpEpZgBH+VJ4OG98BPW7YEtJQ=="], - "@github/copilot-sdk": ["@github/copilot-sdk@0.1.30", "https://registry.npmmirror.com/@github/copilot-sdk/-/copilot-sdk-0.1.30.tgz", { "dependencies": { "@github/copilot": "^0.0.420", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" } }, "sha512-Stg+h8xsPRR0TNGBQfd9laxhJfWZ6DsdpbowcKIZoyKxZvMAbjnY0zyDeOpewJbxWBTJVhBZb5okOq6iaPNMZw=="], + "@github/copilot-sdk": ["@github/copilot-sdk@0.1.32", "https://registry.npmmirror.com/@github/copilot-sdk/-/copilot-sdk-0.1.32.tgz", { "dependencies": { "@github/copilot": "^1.0.2", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" } }, "sha512-mPWM0fw1Gqc/SW8nl45K8abrFH+92fO7y6tRtRl5imjS5hGapLf/dkX5WDrgPtlsflD0c41lFXVUri5NVJwtoA=="], - "@github/copilot-win32-arm64": ["@github/copilot-win32-arm64@0.0.420", "https://registry.npmmirror.com/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.420.tgz", { "os": "win32", "cpu": "arm64", "bin": { "copilot-win32-arm64": "copilot.exe" } }, "sha512-Njlc2j9vYSBAL+lC6FIEhQ3C+VxO3xavwKnw0ecVRiNLcGLyPrTdzPfPQOmEjC63gpVCqLabikoDGv8fuLPA2w=="], + "@github/copilot-win32-arm64": ["@github/copilot-win32-arm64@1.0.7", "https://registry.npmmirror.com/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.7.tgz", { "os": "win32", "cpu": "arm64", "bin": { "copilot-win32-arm64": "copilot.exe" } }, "sha512-4yFgW1K0MlKBrK5BwMIj4nMu5KSFfytNXrs8iOpVgp7erEvKVyN7VXb6SWkoU3M9TfeNlqP6Uje2rxDvgR1u5w=="], - "@github/copilot-win32-x64": ["@github/copilot-win32-x64@0.0.420", "https://registry.npmmirror.com/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.420.tgz", { "os": "win32", "cpu": "x64", "bin": { "copilot-win32-x64": "copilot.exe" } }, "sha512-rZlH35oNehAP2DvQbu4vQFVNeCh/1p3rUjafBYaEY0Nkhx7RmdrYBileL5U3PtRPPRsBPaq3Qp+pVIrGoCDLzQ=="], + "@github/copilot-win32-x64": ["@github/copilot-win32-x64@1.0.7", "https://registry.npmmirror.com/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.7.tgz", { "os": "win32", "cpu": "x64", "bin": { "copilot-win32-x64": "copilot.exe" } }, "sha512-RDZlvPf/q6B54wLXJRmI39fc9+pwfcAjSwUqw0FeQruCTQgoUl8eo9NqeVWDFlr3RdzgVSMUiJHc3aiifVG6lA=="], "@hono/node-server": ["@hono/node-server@1.19.9", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.9.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], diff --git a/package.json b/package.json index 8ed60d8a3..5238b8ad1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openpencil", - "version": "0.4.0", + "version": "0.4.3", "description": "The world's first open-source AI-native vector design tool and the first to feature concurrent Agent Teams. Design-as-Code. Turn prompts into UI directly on the live canvas. A modern alternative to Pencil.", "author": { "name": "ZSeven-W", @@ -42,7 +42,7 @@ "@fontsource/raleway": "^5.2.8", "@fontsource/roboto": "^5.2.10", "@fontsource/source-sans-3": "^5.2.9", - "@github/copilot-sdk": "^0.1.30", + "@github/copilot-sdk": "^0.1.32", "@iconify-json/feather": "^1.2.1", "@iconify-json/lucide": "^1.2.93", "@iconify-json/simple-icons": "^1.2.71", diff --git a/server/api/ai/connect-agent.ts b/server/api/ai/connect-agent.ts index 43e377e8f..a78f39031 100644 --- a/server/api/ai/connect-agent.ts +++ b/server/api/ai/connect-agent.ts @@ -1,6 +1,8 @@ import { defineEventHandler, readBody, setResponseHeaders } from 'h3' +import { existsSync } from 'node:fs' import type { GroupedModel } from '../../../src/types/agent-settings' import { resolveClaudeCli } from '../../utils/resolve-claude-cli' +import { serverLog } from '../../utils/server-logger' import { buildClaudeAgentEnv, getClaudeAgentDebugFilePath, @@ -15,6 +17,10 @@ interface ConnectResult { models: GroupedModel[] error?: string notInstalled?: boolean + /** Human-readable connection status, e.g. "Connected via API key" */ + connectionInfo?: string + /** Config file path for the hint (client renders localized text) */ + hintPath?: string } /** @@ -65,7 +71,9 @@ const FALLBACK_CLAUDE_MODELS: GroupedModel[] = [ /** Connect to Claude Code via Agent SDK and fetch real supported models */ async function connectClaudeCode(): Promise { + serverLog.info('[connect-agent] connecting to Claude Code...') const claudePath = resolveClaudeCli() + serverLog.info(`[connect-agent] resolved claude path: ${claudePath ?? 'NOT FOUND'}`) if (!claudePath) { return { connected: false, models: [], notInstalled: true, error: 'Claude Code CLI not found' } } @@ -75,6 +83,8 @@ async function connectClaudeCode(): Promise { const env = buildClaudeAgentEnv() const debugFile = getClaudeAgentDebugFilePath() + serverLog.info(`[connect-agent] claude env keys: ${Object.keys(env).join(', ')}`) + serverLog.info(`[connect-agent] claude debugFile: ${debugFile ?? 'none'}`) const q = query({ prompt: '', @@ -89,7 +99,17 @@ async function connectClaudeCode(): Promise { }, }) + serverLog.info('[connect-agent] querying supportedModels...') const raw = await q.supportedModels() + + // Fetch account info (email, org, subscription type) + let account: { email?: string; organization?: string; subscriptionType?: string; apiKeySource?: string } | null = null + try { + account = await q.accountInfo() + serverLog.info(`[connect-agent] claude account: email=${account?.email ?? 'n/a'}, type=${account?.subscriptionType ?? 'n/a'}, source=${account?.apiKeySource ?? 'n/a'}`) + } catch { + serverLog.info('[connect-agent] accountInfo() not available') + } q.close() const models: GroupedModel[] = raw.map((m) => ({ @@ -99,19 +119,107 @@ async function connectClaudeCode(): Promise { provider: 'anthropic' as const, })) - return { connected: true, models } + serverLog.info(`[connect-agent] claude connected, ${models.length} models found`) + const claudeInfo = buildClaudeConnectionInfo(env, account) + return { connected: true, models, ...claudeInfo } } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to connect' + serverLog.error(`[connect-agent] claude connection error: ${msg}`) // Third-party API proxies often don't support the supportedModels() call, // causing "query closed before response". Fall back to a default model list // so users can still connect and choose a model. if (/closed before|closed early|query closed/i.test(msg)) { - return { connected: true, models: FALLBACK_CLAUDE_MODELS } + serverLog.info('[connect-agent] using fallback model list (proxy detected)') + const fallbackEnv = buildClaudeAgentEnv() + const claudeInfo = buildClaudeConnectionInfo(fallbackEnv, null) + return { connected: true, models: FALLBACK_CLAUDE_MODELS, ...claudeInfo } } return { connected: false, models: [], error: friendlyClaudeError(msg) } } } +/** Resolve config file path (cross-platform) */ +function configPath(unixPath: string, winPath: string): string { + return process.platform === 'win32' ? winPath : unixPath +} + +/** Build Claude connection info from env + SDK account info */ +function buildClaudeConnectionInfo( + env: Record, + account: { email?: string; organization?: string; subscriptionType?: string; apiKeySource?: string } | null, +): { connectionInfo: string; hintPath?: string } { + const hp = configPath('~/.claude/settings.json', '%USERPROFILE%\\.claude\\settings.json') + const apiKey = env.ANTHROPIC_API_KEY + const baseUrl = env.ANTHROPIC_BASE_URL + + if (account?.email) { + const sub = account.subscriptionType ?? 'subscription' + return { connectionInfo: `Connected via ${sub} (${account.email})`, hintPath: hp } + } + if (apiKey && baseUrl) { + return { connectionInfo: 'Connected via API key (custom endpoint)', hintPath: hp } + } + if (apiKey) { + const masked = apiKey.length > 12 ? `${apiKey.slice(0, 8)}...` : '***' + return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp } + } + return { connectionInfo: 'Connected via subscription', hintPath: hp } +} + +/** Decode a JWT payload (no verification — just base64url decode the middle part) */ +function decodeJwtPayload(token: string): Record | null { + try { + const parts = token.split('.') + if (parts.length !== 3) return null + // base64url → base64 → Buffer → JSON + const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/') + const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4) + return JSON.parse(Buffer.from(padded, 'base64').toString('utf-8')) + } catch { + return null + } +} + +/** Build Codex CLI connection info by reading ~/.codex/auth.json + JWT tokens */ +async function buildCodexConnectionInfo(): Promise<{ connectionInfo: string; hintPath?: string }> { + const { readFile } = await import('node:fs/promises') + const { homedir } = await import('node:os') + const { join } = await import('node:path') + const hp = configPath('~/.codex/config.json', '%USERPROFILE%\\.codex\\config.json') + + if (process.env.OPENAI_API_KEY) { + const key = process.env.OPENAI_API_KEY + const masked = key.length > 12 ? `${key.slice(0, 8)}...` : '***' + return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp } + } + + try { + const authPath = join(homedir(), '.codex', 'auth.json') + const raw = await readFile(authPath, 'utf-8') + const auth = JSON.parse(raw) as { auth_mode?: string; tokens?: { id_token?: string } } + + const idToken = auth.tokens?.id_token + if (idToken) { + const payload = decodeJwtPayload(idToken) + if (payload) { + const email = payload.email as string | undefined + const authClaims = payload['https://api.openai.com/auth'] as Record | undefined + const plan = authClaims?.chatgpt_plan_type as string | undefined + serverLog.info(`[connect-agent] codex JWT: email=${email ?? 'n/a'}, plan=${plan ?? 'n/a'}`) + if (email) { + const label = plan ?? auth.auth_mode ?? 'subscription' + return { connectionInfo: `Connected via ${label} (${email})`, hintPath: hp } + } + } + } + if (auth.auth_mode) { + return { connectionInfo: `Connected via ${auth.auth_mode}`, hintPath: hp } + } + } catch { /* auth.json not found */ } + + return { connectionInfo: 'Connected via Codex CLI', hintPath: hp } +} + /** Map raw Agent SDK errors to user-friendly messages */ function friendlyClaudeError(raw: string): string { if (/process exited with code 1|invalid model|unknown model|model.*not/i.test(raw)) { @@ -131,33 +239,85 @@ function friendlyClaudeError(raw: string): string { /** Connect to Codex CLI and fetch its supported models from the local cache */ async function connectCodexCli(): Promise { + serverLog.info('[connect-agent] connecting to Codex CLI...') try { const { execSync } = await import('node:child_process') const { readFile } = await import('node:fs/promises') const { homedir } = await import('node:os') const { join } = await import('node:path') + const isWin = process.platform === 'win32' - // Check if codex binary exists - const whichCmd = process.platform === 'win32' ? 'where codex 2>nul' : 'which codex 2>/dev/null || echo ""' - const which = execSync(whichCmd, { - encoding: 'utf-8', - timeout: 5000, - }).trim().split(/\r?\n/)[0]?.trim() ?? '' + // Check if codex binary exists — PATH, npm prefix, then common locations + let which = '' - if (!which) { - return { connected: false, models: [], notInstalled: true, error: 'Codex CLI not found' } + // 1. PATH lookup + try { + const whichCmd = isWin ? 'where codex 2>nul' : 'which codex 2>/dev/null || echo ""' + serverLog.info(`[connect-agent] codex PATH lookup: ${whichCmd}`) + const result = execSync(whichCmd, { + encoding: 'utf-8', + timeout: 5000, + }).trim().split(/\r?\n/)[0]?.trim() ?? '' + if (result && existsSync(result)) which = result + serverLog.info(`[connect-agent] codex PATH result: "${result}" (exists=${result ? existsSync(result) : false})`) + } catch (err) { + serverLog.info(`[connect-agent] codex PATH lookup failed: ${err instanceof Error ? err.message : err}`) } - // Verify codex is responsive + // 2. npm prefix -g (Windows: npm global creates .cmd wrappers) + if (!which && isWin) { + try { + serverLog.info('[connect-agent] codex: trying npm.cmd prefix -g') + const prefix = execSync('npm.cmd prefix -g', { + encoding: 'utf-8', + timeout: 5000, + }).trim() + serverLog.info(`[connect-agent] codex npm global prefix: "${prefix}"`) + if (prefix) { + const bin = join(prefix, 'codex.cmd') + serverLog.info(`[connect-agent] codex npm global bin: "${bin}" (exists=${existsSync(bin)})`) + if (existsSync(bin)) which = bin + } + } catch (err) { + serverLog.info(`[connect-agent] codex npm prefix -g failed: ${err instanceof Error ? err.message : err}`) + } + } + + // 3. Common install locations + if (!which && isWin) { + const candidates = [ + join(process.env.APPDATA || '', 'npm', 'codex.cmd'), + join(process.env.NVM_SYMLINK || '', 'codex.cmd'), + join(process.env.FNM_MULTISHELL_PATH || '', 'codex.cmd'), + ] + for (const c of candidates) { + const exists = c ? existsSync(c) : false + serverLog.info(`[connect-agent] codex candidate: "${c}" (exists=${exists})`) + if (c && exists) { which = c; break } + } + } + + if (!which) { + serverLog.warn('[connect-agent] codex not found') + return { connected: false, models: [], notInstalled: true, error: 'Codex CLI not found' } + } + serverLog.info(`[connect-agent] codex resolved: "${which}"`) + + + // Verify codex is responsive — on Windows, use the resolved path or .cmd wrapper + const versionCmd = isWin ? `"${which}" --version 2>&1` : 'codex --version 2>&1' try { - execSync('codex --version 2>&1', { encoding: 'utf-8', timeout: 5000 }) - } catch { + const ver = execSync(versionCmd, { encoding: 'utf-8', timeout: 5000 }).trim() + serverLog.info(`[connect-agent] codex version: ${ver}`) + } catch (err) { + serverLog.error(`[connect-agent] codex --version failed: ${err instanceof Error ? err.message : err}`) return { connected: false, models: [], error: 'Codex CLI not responding' } } // Read models from Codex CLI's local models cache let models: GroupedModel[] = [] const cachePath = join(homedir(), '.codex', 'models_cache.json') + serverLog.info(`[connect-agent] codex models cache: "${cachePath}" (exists=${existsSync(cachePath)})`) try { const raw = await readFile(cachePath, 'utf-8') @@ -182,17 +342,21 @@ async function connectCodexCli(): Promise { provider: 'openai' as const, })) } - } catch { - // Cache file not found or unreadable + } catch (cacheErr) { + serverLog.info(`[connect-agent] codex cache read failed: ${cacheErr instanceof Error ? cacheErr.message : cacheErr}`) } if (models.length === 0) { + serverLog.info('[connect-agent] codex: no models found') return { connected: false, models: [], error: 'No models found. Try running codex once to populate the model cache.' } } - return { connected: true, models } + serverLog.info(`[connect-agent] codex connected, ${models.length} models found`) + const codexInfo = await buildCodexConnectionInfo() + return { connected: true, models, ...codexInfo } } catch (error) { const msg = error instanceof Error ? error.message : 'Failed to connect' + serverLog.error(`[connect-agent] codex connection error: ${msg}`) return { connected: false, models: [], error: msg } } } @@ -205,23 +369,34 @@ async function resolveOpencodeBinary(): Promise { const { join } = await import('node:path') const isWin = process.platform === 'win32' + serverLog.info(`[resolve-opencode] platform=${process.platform}, isWindows=${isWin}`) + // 1. Try PATH lookup try { - const cmd = isWin ? 'where opencode' : 'which opencode 2>/dev/null' + const cmd = isWin ? 'where opencode 2>nul' : 'which opencode 2>/dev/null' + serverLog.info(`[resolve-opencode] PATH lookup: ${cmd}`) const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim().split(/\r?\n/)[0]?.trim() + serverLog.info(`[resolve-opencode] PATH result: "${result}" (exists=${result ? existsSync(result) : false})`) if (result && existsSync(result)) return result - } catch { /* not in PATH */ } + } catch (err) { + serverLog.info(`[resolve-opencode] PATH lookup failed: ${err instanceof Error ? err.message : err}`) + } // 2. Try `npm prefix -g` to find actual npm global bin directory // On Windows, must use `npm.cmd` since Electron spawns cmd.exe try { const npmCmd = isWin ? 'npm.cmd prefix -g' : 'npm prefix -g' + serverLog.info(`[resolve-opencode] npm prefix lookup: ${npmCmd}`) const prefix = execSync(npmCmd, { encoding: 'utf-8', timeout: 5000 }).trim() + serverLog.info(`[resolve-opencode] npm global prefix: "${prefix}"`) if (prefix) { const bin = isWin ? join(prefix, 'opencode.cmd') : join(prefix, 'bin', 'opencode') + serverLog.info(`[resolve-opencode] npm global bin: "${bin}" (exists=${existsSync(bin)})`) if (existsSync(bin)) return bin } - } catch { /* npm not available */ } + } catch (err) { + serverLog.info(`[resolve-opencode] npm prefix -g failed: ${err instanceof Error ? err.message : err}`) + } // 3. Common install locations // npm -g → %APPDATA%\npm (Windows), /usr/local (macOS/Linux) @@ -251,27 +426,35 @@ async function resolveOpencodeBinary(): Promise { join(home, '.local', 'bin', 'opencode'), ] for (const c of candidates) { - if (c && existsSync(c)) return c + const exists = c ? existsSync(c) : false + serverLog.info(`[resolve-opencode] candidate: "${c}" (exists=${exists})`) + if (c && exists) return c } + serverLog.info('[resolve-opencode] no opencode binary found') return undefined } /** Connect to OpenCode and fetch its configured providers/models. */ async function connectOpenCode(): Promise { + serverLog.info('[connect-agent] connecting to OpenCode...') try { const binaryPath = await resolveOpencodeBinary() + serverLog.info(`[connect-agent] resolved opencode path: ${binaryPath ?? 'NOT FOUND'}`) if (!binaryPath) { return { connected: false, models: [], notInstalled: true, error: 'OpenCode CLI not found' } } const { getOpencodeClient, releaseOpencodeServer } = await import('../../utils/opencode-client') + serverLog.info('[connect-agent] creating opencode client...') const { client, server } = await getOpencodeClient() + serverLog.info('[connect-agent] fetching opencode providers...') const { data, error } = await client.config.providers() releaseOpencodeServer(server) if (error) { + serverLog.error(`[connect-agent] opencode providers error: ${JSON.stringify(error)}`) return { connected: false, models: [], error: 'Failed to fetch providers from OpenCode server.' } } @@ -289,21 +472,34 @@ async function connectOpenCode(): Promise { } if (models.length === 0) { + serverLog.info('[connect-agent] opencode: no models found') return { connected: false, models: [], error: 'No models configured in OpenCode. Run "opencode" to set up providers.' } } - return { connected: true, models } + const providerNames = (data?.providers ?? []).map((p) => p.name || p.id).filter(Boolean) + const providerSummary = providerNames.length > 0 + ? `Connected (${providerNames.slice(0, 3).join(', ')}${providerNames.length > 3 ? ` +${providerNames.length - 3}` : ''})` + : 'Connected via OpenCode server' + serverLog.info(`[connect-agent] opencode connected, ${models.length} models found`) + return { + connected: true, models, + connectionInfo: providerSummary, + hintPath: configPath('~/.opencode/config.json', '%USERPROFILE%\\.opencode\\config.json'), + } } catch (error) { const raw = error instanceof Error ? error.message : 'Failed to connect' + serverLog.error(`[connect-agent] opencode connection error: ${raw}`) return { connected: false, models: [], error: friendlyOpenCodeError(raw) } } } /** Connect to GitHub Copilot CLI via @github/copilot-sdk and fetch available models. */ async function connectCopilot(): Promise { + serverLog.info('[connect-agent] connecting to Copilot...') // Use standalone copilot binary to avoid Bun's node:sqlite issue const { resolveCopilotCli } = await import('../../utils/copilot-client') const cliPath = resolveCopilotCli() + serverLog.info(`[connect-agent] resolved copilot path: ${cliPath ?? 'NOT FOUND'}`) if (!cliPath) { return { connected: false, models: [], notInstalled: true, error: 'GitHub Copilot CLI not found' } } @@ -312,10 +508,12 @@ async function connectCopilot(): Promise { const { CopilotClient } = await import('@github/copilot-sdk') const client = new CopilotClient({ autoStart: true, cliPath }) + serverLog.info('[connect-agent] starting copilot client...') await client.start() let models: GroupedModel[] = [] try { + serverLog.info('[connect-agent] listing copilot models...') const modelList = await client.listModels() models = modelList .filter((m) => !m.policy || m.policy.state === 'enabled') @@ -327,19 +525,39 @@ async function connectCopilot(): Promise { })) } catch (listErr) { const msg = listErr instanceof Error ? listErr.message : 'Failed to list models' + serverLog.error(`[connect-agent] copilot listModels error: ${msg}`) await client.stop().catch(() => {}) return { connected: false, models: [], error: friendlyCopilotError(msg) } } + // Try to get auth status for user info + const copilotHintPath = configPath('~/.config/github-copilot/config.json', '%USERPROFILE%\\.config\\github-copilot\\config.json') + let copilotInfo: { connectionInfo: string; hintPath?: string } = { connectionInfo: 'Connected via GitHub', hintPath: copilotHintPath } + try { + const authStatus = await client.getAuthStatus() + serverLog.info(`[connect-agent] copilot auth: ${JSON.stringify(authStatus)}`) + if (authStatus?.login) { + const method = authStatus.authType ? ` (${authStatus.authType})` : '' + copilotInfo = { connectionInfo: `Connected as @${authStatus.login}${method}`, hintPath: copilotHintPath } + } else if (authStatus?.statusMessage) { + copilotInfo = { connectionInfo: authStatus.statusMessage, hintPath: copilotHintPath } + } + } catch (authErr) { + serverLog.warn(`[connect-agent] copilot getAuthStatus failed: ${authErr instanceof Error ? authErr.message : authErr}`) + } + await client.stop() if (models.length === 0) { + serverLog.info('[connect-agent] copilot: no models found') return { connected: false, models: [], error: 'No models found. Run "copilot login" to authenticate first.' } } - return { connected: true, models } + serverLog.info(`[connect-agent] copilot connected, ${models.length} models found`) + return { connected: true, models, ...copilotInfo } } catch (error) { const raw = error instanceof Error ? error.message : 'Failed to connect' + serverLog.error(`[connect-agent] copilot connection error: ${raw}`) return { connected: false, models: [], error: friendlyCopilotError(raw) } } } diff --git a/server/api/ai/install-agent.ts b/server/api/ai/install-agent.ts index e9ddbeb3f..27de79712 100644 --- a/server/api/ai/install-agent.ts +++ b/server/api/ai/install-agent.ts @@ -128,7 +128,8 @@ async function tryNpmInstall(pkg: string, binary: string): Promisenul' : 'which copilot 2>/dev/null' + serverLog.info(`[resolve-copilot] PATH lookup: ${cmd}`) const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim() // `where` on Windows may return multiple lines const path = result.split(/\r?\n/)[0]?.trim() - return path || undefined - } catch { - return undefined + serverLog.info(`[resolve-copilot] PATH result: "${path}" (exists=${path ? existsSync(path) : false})`) + if (path && existsSync(path)) return path + } catch (err) { + serverLog.info(`[resolve-copilot] PATH lookup failed: ${err instanceof Error ? err.message : err}`) } + + // 2. Try `npm prefix -g` on Windows (npm install -g creates .cmd wrappers) + if (isWindows) { + try { + serverLog.info('[resolve-copilot] trying npm.cmd prefix -g') + const prefix = execSync('npm.cmd prefix -g', { + encoding: 'utf-8', + timeout: 5000, + }).trim() + serverLog.info(`[resolve-copilot] npm global prefix: "${prefix}"`) + if (prefix) { + const bin = join(prefix, 'copilot.cmd') + serverLog.info(`[resolve-copilot] npm global bin: "${bin}" (exists=${existsSync(bin)})`) + if (existsSync(bin)) return bin + } + } catch (err) { + serverLog.info(`[resolve-copilot] npm prefix -g failed: ${err instanceof Error ? err.message : err}`) + } + } + + // 3. Common install locations + if (isWindows) { + const candidates = [ + // npm global + join(process.env.APPDATA || '', 'npm', 'copilot.cmd'), + // nvm-windows / fnm + join(process.env.NVM_SYMLINK || '', 'copilot.cmd'), + join(process.env.FNM_MULTISHELL_PATH || '', 'copilot.cmd'), + // winget / native + join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'copilot.exe'), + ] + for (const c of candidates) { + const exists = c ? existsSync(c) : false + serverLog.info(`[resolve-copilot] candidate: "${c}" (exists=${exists})`) + if (c && exists) return c + } + } + + serverLog.warn('[resolve-copilot] no copilot binary found') + return undefined } diff --git a/server/utils/resolve-claude-cli.ts b/server/utils/resolve-claude-cli.ts index 0ff27bc1a..592089e9c 100644 --- a/server/utils/resolve-claude-cli.ts +++ b/server/utils/resolve-claude-cli.ts @@ -2,6 +2,7 @@ import { execSync } from 'node:child_process' import { existsSync } from 'node:fs' import { homedir, platform } from 'node:os' import { join } from 'node:path' +import { serverLog } from './server-logger' const isWindows = platform() === 'win32' @@ -15,19 +16,52 @@ const isWindows = platform() === 'win32' * binaries and spawns them directly (no `node` wrapper needed). */ export function resolveClaudeCli(): string | undefined { + serverLog.info(`[resolve-claude-cli] platform=${platform()}, isWindows=${isWindows}`) + // 1. Try PATH lookup try { - const cmd = isWindows ? 'where claude' : 'which claude 2>/dev/null' - const p = execSync(cmd, { + const cmd = isWindows ? 'where claude 2>nul' : 'which claude 2>/dev/null' + serverLog.info(`[resolve-claude-cli] PATH lookup: ${cmd}`) + const raw = execSync(cmd, { encoding: 'utf-8', timeout: 3000, - }).trim().split(/\r?\n/)[0] // `where` on Windows may return multiple lines + }).trim() + const p = raw.split(/\r?\n/)[0] // `where` on Windows may return multiple lines + serverLog.info(`[resolve-claude-cli] PATH lookup result: "${p}" (exists=${p ? existsSync(p) : false})`) if (p && existsSync(p)) return p - } catch { /* not in PATH */ } + } catch (err) { + serverLog.info(`[resolve-claude-cli] PATH lookup failed: ${err instanceof Error ? err.message : err}`) + } - // 2. Common install locations + // 2. Try `npm prefix -g` to find actual npm global bin directory + // On Windows, must use `npm.cmd` since Electron spawns cmd.exe + if (isWindows) { + try { + serverLog.info('[resolve-claude-cli] trying npm.cmd prefix -g') + const prefix = execSync('npm.cmd prefix -g', { + encoding: 'utf-8', + timeout: 5000, + }).trim() + serverLog.info(`[resolve-claude-cli] npm global prefix: "${prefix}"`) + if (prefix) { + const bin = join(prefix, 'claude.cmd') + serverLog.info(`[resolve-claude-cli] checking npm global bin: "${bin}" (exists=${existsSync(bin)})`) + if (existsSync(bin)) return bin + } + } catch (err) { + serverLog.info(`[resolve-claude-cli] npm prefix -g failed: ${err instanceof Error ? err.message : err}`) + } + } + + // 3. Common install locations const candidates = isWindows ? [ + // npm global (npm install -g creates .cmd wrappers here) + join(process.env.APPDATA || '', 'npm', 'claude.cmd'), + // nvm-windows / fnm + join(process.env.NVM_SYMLINK || '', 'claude.cmd'), + join(process.env.FNM_MULTISHELL_PATH || '', 'claude.cmd'), + // Native .exe install locations join(process.env.LOCALAPPDATA || '', 'Programs', 'claude-code', 'claude.exe'), join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'claude.exe'), join(homedir(), '.claude', 'local', 'claude.exe'), @@ -39,8 +73,11 @@ export function resolveClaudeCli(): string | undefined { '/opt/homebrew/bin/claude', ] for (const c of candidates) { - if (c && existsSync(c)) return c + const exists = c ? existsSync(c) : false + serverLog.info(`[resolve-claude-cli] candidate: "${c}" (exists=${exists})`) + if (c && exists) return c } + serverLog.warn('[resolve-claude-cli] no claude binary found') return undefined } diff --git a/server/utils/server-logger.ts b/server/utils/server-logger.ts new file mode 100644 index 000000000..664df0fe3 --- /dev/null +++ b/server/utils/server-logger.ts @@ -0,0 +1,88 @@ +/** + * Simple file logger for the Nitro server process. + * + * Writes to `~/.openpencil/logs/server-{YYYY-MM-DD}.log`. + * Also forwards to console for dev mode visibility. + * Keeps the last 7 days of logs, auto-cleans on first write. + */ + +import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +const MAX_LOG_DAYS = 7 + +const logDir = join(homedir(), '.openpencil', 'logs') +let dirEnsured = false +let cleanedUp = false + +function ensureDir(): void { + if (dirEnsured) return + try { + if (!existsSync(logDir)) { + mkdirSync(logDir, { recursive: true }) + } + dirEnsured = true + } catch { + // Silently fail — logging is best-effort + } +} + +function todayStamp(): string { + return new Date().toISOString().slice(0, 10) +} + +function timestamp(): string { + return new Date().toISOString() +} + +function getLogFilePath(): string { + return join(logDir, `server-${todayStamp()}.log`) +} + +function cleanOldLogs(): void { + if (cleanedUp) return + cleanedUp = true + try { + const files = readdirSync(logDir) + const cutoff = Date.now() - MAX_LOG_DAYS * 24 * 60 * 60 * 1000 + for (const file of files) { + if (!file.endsWith('.log')) continue + const filePath = join(logDir, file) + try { + const s = statSync(filePath) + if (s.mtimeMs < cutoff) { + unlinkSync(filePath) + } + } catch { + // ignore individual file errors + } + } + } catch { + // ignore + } +} + +function writeLine(level: string, msg: string): void { + const line = `${timestamp()} [${level}] ${msg}\n` + // Forward to console + if (level === 'ERROR') { + process.stderr.write(line) + } else { + process.stdout.write(line) + } + // Write to file + try { + ensureDir() + cleanOldLogs() + appendFileSync(getLogFilePath(), line, 'utf-8') + } catch { + // Disk full or permission error — silently drop + } +} + +export const serverLog = { + info: (msg: string) => writeLine('INFO', msg), + warn: (msg: string) => writeLine('WARN', msg), + error: (msg: string) => writeLine('ERROR', msg), +} diff --git a/src/components/icons/copilot-logo.tsx b/src/components/icons/copilot-logo.tsx index 958de592b..15ef91cda 100644 --- a/src/components/icons/copilot-logo.tsx +++ b/src/components/icons/copilot-logo.tsx @@ -3,7 +3,7 @@ import type { SVGProps } from 'react' export default function CopilotLogo(props: SVGProps) { return ( { +): Promise<{ connected: boolean; models: GroupedModel[]; error?: string; notInstalled?: boolean; connectionInfo?: string; hintPath?: string }> { try { const res = await fetch('/api/ai/connect-agent', { method: 'POST', @@ -89,7 +94,8 @@ async function callMcpInstall( return res.json() } -function ProviderRow({ type }: { type: AIProviderType }) { +/* ---------- ProviderCard ---------- */ +function ProviderCard({ type }: { type: AIProviderType }) { const { t } = useTranslation() const provider = useAgentSettingsStore((s) => s.providers[type]) const connect = useAgentSettingsStore((s) => s.connectProvider) @@ -111,7 +117,7 @@ function ProviderRow({ type }: { type: AIProviderType }) { setInstallInfo(null) const result = await connectAgent(meta.agent) if (result.connected) { - connect(type, meta.agent, result.models) + connect(type, meta.agent, result.models, result.connectionInfo, result.hintPath) persist() } else if (result.notInstalled) { setNotInstalled(true) @@ -134,7 +140,6 @@ function ProviderRow({ type }: { type: AIProviderType }) { setInstallInfo(null) const result = await installAgent(meta.agent) if (result.success) { - // Auto-connect after successful install setIsInstalling(false) setNotInstalled(false) handleConnect() @@ -160,7 +165,6 @@ function ProviderRow({ type }: { type: AIProviderType }) { const { Icon } = meta - // Button logic: connected → Disconnect, installing → spinner, notInstalled (no instructions yet) → Install, else → Connect const renderAction = () => { if (provider.isConnected) { return ( @@ -211,41 +215,47 @@ function ProviderRow({ type }: { type: AIProviderType }) {
{/* Icon */}
- +
- {/* Name + description */} + {/* Name + status */}
-
- {t(meta.labelKey)} - {t(meta.descriptionKey)} -
- {provider.isConnected && ( + {t(meta.labelKey)} + {provider.isConnected && provider.connectionInfo && ( + + + {provider.connectionInfo} + + )} + {provider.isConnected && !provider.connectionInfo && ( {t('agents.modelCount', { count: provider.models.length })} )} + {!provider.isConnected && !notInstalled && !error && ( + {t(meta.descriptionKey)} + )} {notInstalled && !isInstalling && !error && ( - + {t('agents.notInstalled')} )} {error && ( - {error} + {error} )}
@@ -274,10 +284,179 @@ function ProviderRow({ type }: { type: AIProviderType }) { )}
)} + + {/* Provider-specific hint */} + {provider.isConnected && provider.hintPath && ( +

+ {t('settings.envHint', { path: provider.hintPath })} +

+ )}
) } +/* ---------- Sidebar nav item ---------- */ +function NavItem({ icon: IconComp, label, active, onClick }: { + icon: ComponentType<{ size?: number; className?: string }>; label: string; active?: boolean; onClick: () => void +}) { + return ( + + ) +} + +/* ---------- Agents page ---------- */ +function AgentsPage() { + const { t } = useTranslation() + return ( +
+

{t('settings.agents')}

+
+ + + + +
+
+ ) +} + +/* ---------- MCP page ---------- */ +interface McpPageProps { + mcpServerRunning: boolean; mcpServerLocalIp: string | null; mcpServerLoading: boolean + mcpServerError: string | null; mcpHttpPort: number; configCopied: boolean + mcpIntegrations: { tool: string; displayName: string; enabled: boolean }[] + mcpInstalling: string | null; mcpError: string | null; isBusy: boolean + onServerToggle: () => void; onCopyConfig: () => void + onPortBlur: (value: string) => void; onToggleMCP: (tool: string) => void +} +function McpPage(props: McpPageProps) { + const { + mcpServerRunning, mcpServerLocalIp, mcpServerLoading, mcpServerError, + mcpHttpPort, mcpIntegrations, mcpInstalling, mcpError, isBusy, configCopied, + onServerToggle, onCopyConfig, onPortBlur, onToggleMCP, + } = props + const { t } = useTranslation() + return ( +
+ {/* MCP Server */} +
+

{t('agents.mcpServer')}

+
+
+ + {mcpServerRunning ? t('agents.mcpServerRunning') : t('agents.mcpServerStopped')} + + {t('agents.port')} + onPortBlur(e.target.value)} + disabled={mcpServerRunning || mcpServerLoading} + className="h-6 w-[52px] text-[11px] text-center tabular-nums bg-secondary text-foreground rounded border border-input focus:border-ring outline-none transition-colors disabled:opacity-50" + /> + +
+ {mcpServerRunning && mcpServerLocalIp && ( +
+
+ {t('agents.mcpClientConfig')} + +
+ + {`{ "type": "http", "url": "http://${mcpServerLocalIp}:${mcpHttpPort}/mcp" }`} + +
+ )} + {mcpServerError && ( +
+ +

{mcpServerError}

+
+ )} +
+ + {/* MCP Integrations */} +
+

{t('agents.mcpIntegrations')}

+

{t('agents.mcpRestart')}

+
+ {mcpIntegrations.map((m) => ( +
+
+ + {m.displayName} + + {mcpInstalling === m.tool && ( + + )} +
+ onToggleMCP(m.tool)} + className="shrink-0 ml-2" + /> +
+ ))} +
+ {mcpError && ( +
+ +

{mcpError}

+
+ )} +
+
+ ) +} + +/* ---------- Main Dialog ---------- */ export default function AgentSettingsDialog() { const { t } = useTranslation() const open = useAgentSettingsStore((s) => s.dialogOpen) @@ -292,6 +471,8 @@ export default function AgentSettingsDialog() { const setMcpServerStatus = useAgentSettingsStore((s) => s.setMcpServerStatus) const dialogRef = useRef(null) + const [activeTab, setActiveTab] = useState('agents') + useEffect(() => { if (!open) return const handler = (e: KeyboardEvent) => { @@ -313,7 +494,6 @@ export default function AgentSettingsDialog() { setIsElectron(!!window.electronAPI) }, []) - // Fetch MCP server status on dialog open useEffect(() => { if (!open) return fetch('/api/mcp/server') @@ -324,7 +504,6 @@ export default function AgentSettingsDialog() { .catch(() => {}) }, [open, setMcpServerStatus]) - // Fetch auto-update setting on dialog open (Electron only) useEffect(() => { if (!open || !window.electronAPI?.updater?.getAutoCheck) return window.electronAPI.updater.getAutoCheck() @@ -389,11 +568,8 @@ export default function AgentSettingsDialog() { if (result.success) { toggleMCP(tool) persist() - // When the server fell back to HTTP mode (no node installed), - // the MCP HTTP server was auto-started — sync UI status if (result.fallbackHttp) { setMcpServerStatus(true, null) - // Refresh actual status (localIp) after server finishes starting setTimeout(() => { fetch('/api/mcp/server') .then((r) => r.json()) @@ -437,188 +613,90 @@ export default function AgentSettingsDialog() { />
- {/* Header */} -
-

- {t('agents.title')} -

- + {/* Sidebar */} +
+
+

{t('settings.title')}

+
+
- {/* Scrollable content */} -
- {/* Agents section */} -
-
- -

- {t('agents.agentsOnCanvas')} -

-
-
- - - - -
+ {/* Content */} +
+ {/* Close button */} +
+
- {/* Divider */} -
+ {/* Page content */} +
+ {activeTab === 'agents' && } - {/* MCP Server section */} -
-
- -

- {t('agents.mcpServer')} -

-
-
- {/* Status indicator */} -
- - {mcpServerRunning ? t('agents.mcpServerRunning') : t('agents.mcpServerStopped')} - - {t('agents.port')} - handlePortBlur(e.target.value)} - disabled={mcpServerRunning || mcpServerLoading} - className="h-6 w-[52px] text-[11px] text-center tabular-nums bg-secondary text-foreground rounded border border-input focus:border-ring outline-none transition-colors disabled:opacity-50" - /> - -
- {mcpServerRunning && mcpServerLocalIp && ( -
-
- {t('agents.mcpClientConfig')} - -
- {`{ "type": "http", "url": "http://${mcpServerLocalIp}:${mcpHttpPort}/mcp" }`} -
)} - {mcpServerError && ( -
- -

{mcpServerError}

-
- )} -
- - {/* Divider */} -
- - {/* MCP integrations section */} -
-
- -

- {t('agents.mcpIntegrations')} -

-
- -
- {mcpIntegrations.map((m) => ( -
-
- - {m.displayName} - - {mcpInstalling === m.tool && ( - - )} + {activeTab === 'system' && ( +
+

{t('settings.system')}

+ {isElectron && ( +
+
+ {t('agents.autoUpdate')} + {t('settings.autoUpdateDesc')} +
+
- handleToggleMCP(m.tool)} - className="shrink-0 ml-2" - /> -
- ))} -
- {mcpError && ( -
- -

{mcpError}

+ )} + {!isElectron && ( +
+

{t('settings.systemDesktopOnly')}

+
+ )}
)} -

- {t('agents.mcpRestart')} -

- - {/* Auto-update toggle (Electron only) */} - {isElectron && ( - <> -
-
-
- - {t('agents.autoUpdate')} -
- -
- - )}
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index f5f3e31af..c55cb58b1 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -365,6 +365,13 @@ const de: TranslationKeys = { 'agents.installing': 'Installiere...', 'agents.installFailed': 'Installation fehlgeschlagen', 'agents.viewDocs': 'Doku', + 'settings.title': 'Einstellungen', + 'settings.agents': 'Agents', + 'settings.mcp': 'MCP', + 'settings.system': 'System', + 'settings.autoUpdateDesc': 'Beim Start automatisch nach neuen Versionen suchen', + 'settings.systemDesktopOnly': 'Systemeinstellungen sind in der Desktop-App verfügbar.', + 'settings.envHint': 'Sie können zusätzliche Umgebungsvariablen in {{path}} festlegen.', // ── Figma Import ── 'figma.title': 'Aus Figma importieren', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 82c7ecca9..cfc3bf1fb 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -361,6 +361,13 @@ const en = { 'agents.installing': 'Installing...', 'agents.installFailed': 'Installation failed', 'agents.viewDocs': 'Docs', + 'settings.title': 'Settings', + 'settings.agents': 'Agents', + 'settings.mcp': 'MCP', + 'settings.system': 'System', + 'settings.autoUpdateDesc': 'Automatically check for new versions on startup', + 'settings.systemDesktopOnly': 'System settings are available in the desktop app.', + 'settings.envHint': 'You can set additional environment variables in {{path}}.', // ── Figma Import ── 'figma.title': 'Import from Figma', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index b46fc4cf4..419fd428a 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -370,6 +370,13 @@ const es: TranslationKeys = { 'agents.installing': 'Instalando...', 'agents.installFailed': 'Instalación fallida', 'agents.viewDocs': 'Docs', + 'settings.title': 'Configuración', + 'settings.agents': 'Agents', + 'settings.mcp': 'MCP', + 'settings.system': 'Sistema', + 'settings.autoUpdateDesc': 'Buscar automáticamente nuevas versiones al iniciar', + 'settings.systemDesktopOnly': 'La configuración del sistema está disponible en la aplicación de escritorio.', + 'settings.envHint': 'Puedes establecer variables de entorno adicionales en {{path}}.', // ── Figma Import ── 'figma.title': 'Importar desde Figma', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index e7d6dd15b..af8a15832 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -368,6 +368,13 @@ const fr: TranslationKeys = { 'agents.installing': 'Installation...', 'agents.installFailed': "Échec de l'installation", 'agents.viewDocs': 'Docs', + 'settings.title': 'Paramètres', + 'settings.agents': 'Agents', + 'settings.mcp': 'MCP', + 'settings.system': 'Système', + 'settings.autoUpdateDesc': 'Vérifier automatiquement les nouvelles versions au démarrage', + 'settings.systemDesktopOnly': 'Les paramètres système sont disponibles dans l\'application de bureau.', + 'settings.envHint': 'Vous pouvez définir des variables d\'environnement supplémentaires dans {{path}}.', // ── Figma Import ── 'figma.title': 'Importer depuis Figma', diff --git a/src/i18n/locales/hi.ts b/src/i18n/locales/hi.ts index a2ffc5bcf..e059612d8 100644 --- a/src/i18n/locales/hi.ts +++ b/src/i18n/locales/hi.ts @@ -363,6 +363,13 @@ const hi: TranslationKeys = { 'agents.installing': 'स्थापित हो रहा है...', 'agents.installFailed': 'स्थापना विफल', 'agents.viewDocs': 'दस्तावेज़', + 'settings.title': 'सेटिंग्स', + 'settings.agents': 'Agents', + 'settings.mcp': 'MCP', + 'settings.system': 'सिस्टम', + 'settings.autoUpdateDesc': 'स्टार्टअप पर स्वचालित रूप से नए संस्करणों की जाँच करें', + 'settings.systemDesktopOnly': 'सिस्टम सेटिंग्स डेस्कटॉप ऐप में उपलब्ध हैं।', + 'settings.envHint': 'आप {{path}} में अतिरिक्त पर्यावरण चर सेट कर सकते हैं।', // ── Figma Import ── 'figma.title': 'Figma से आयात करें', diff --git a/src/i18n/locales/id.ts b/src/i18n/locales/id.ts index 6194736ba..67920f59a 100644 --- a/src/i18n/locales/id.ts +++ b/src/i18n/locales/id.ts @@ -363,6 +363,13 @@ const id: TranslationKeys = { 'agents.installing': 'Memasang...', 'agents.installFailed': 'Pemasangan gagal', 'agents.viewDocs': 'Dokumen', + 'settings.title': 'Pengaturan', + 'settings.agents': 'Agents', + 'settings.mcp': 'MCP', + 'settings.system': 'Sistem', + 'settings.autoUpdateDesc': 'Periksa versi baru secara otomatis saat memulai', + 'settings.systemDesktopOnly': 'Pengaturan sistem tersedia di aplikasi desktop.', + 'settings.envHint': 'Anda dapat mengatur variabel lingkungan tambahan di {{path}}.', // ── Figma Import ── 'figma.title': 'Impor dari Figma', diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index c9ef997d6..b6845bdd5 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -366,6 +366,13 @@ const ja: TranslationKeys = { 'agents.installing': 'インストール中...', 'agents.installFailed': 'インストール失敗', 'agents.viewDocs': 'ドキュメント', + 'settings.title': '設定', + 'settings.agents': 'Agents', + 'settings.mcp': 'MCP', + 'settings.system': 'システム', + 'settings.autoUpdateDesc': '起動時に新しいバージョンを自動的に確認する', + 'settings.systemDesktopOnly': 'システム設定はデスクトップアプリで利用できます。', + 'settings.envHint': '{{path}} で追加の環境変数を設定できます。', // ── Figma Import ── 'figma.title': 'Figma からインポート', diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 9e7114f77..93e8b1c53 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -363,6 +363,13 @@ const ko: TranslationKeys = { 'agents.installing': '설치 중...', 'agents.installFailed': '설치 실패', 'agents.viewDocs': '문서', + 'settings.title': '설정', + 'settings.agents': 'Agents', + 'settings.mcp': 'MCP', + 'settings.system': '시스템', + 'settings.autoUpdateDesc': '시작 시 자동으로 새 버전 확인', + 'settings.systemDesktopOnly': '시스템 설정은 데스크톱 앱에서 사용할 수 있습니다.', + 'settings.envHint': '{{path}}에서 추가 환경 변수를 설정할 수 있습니다.', // ── Figma Import ── 'figma.title': 'Figma에서 가져오기', diff --git a/src/i18n/locales/pt.ts b/src/i18n/locales/pt.ts index a068ea102..6370644a3 100644 --- a/src/i18n/locales/pt.ts +++ b/src/i18n/locales/pt.ts @@ -365,6 +365,13 @@ const pt: TranslationKeys = { 'agents.installing': 'Instalando...', 'agents.installFailed': 'Falha na instalação', 'agents.viewDocs': 'Docs', + 'settings.title': 'Configurações', + 'settings.agents': 'Agents', + 'settings.mcp': 'MCP', + 'settings.system': 'Sistema', + 'settings.autoUpdateDesc': 'Verificar automaticamente novas versões ao iniciar', + 'settings.systemDesktopOnly': 'As configurações do sistema estão disponíveis no aplicativo de desktop.', + 'settings.envHint': 'Você pode definir variáveis de ambiente adicionais em {{path}}.', // ── Figma Import ── 'figma.title': 'Importar do Figma', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 089a2cca0..409893f46 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -365,6 +365,13 @@ const ru: TranslationKeys = { 'agents.installing': 'Установка...', 'agents.installFailed': 'Ошибка установки', 'agents.viewDocs': 'Документация', + 'settings.title': 'Настройки', + 'settings.agents': 'Agents', + 'settings.mcp': 'MCP', + 'settings.system': 'Система', + 'settings.autoUpdateDesc': 'Автоматически проверять наличие новых версий при запуске', + 'settings.systemDesktopOnly': 'Системные настройки доступны в настольном приложении.', + 'settings.envHint': 'Вы можете задать дополнительные переменные окружения в {{path}}.', // ── Figma Import ── 'figma.title': 'Импорт из Figma', diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts index 61879ce7c..faedab40b 100644 --- a/src/i18n/locales/th.ts +++ b/src/i18n/locales/th.ts @@ -363,6 +363,13 @@ const th: TranslationKeys = { 'agents.installing': 'กำลังติดตั้ง...', 'agents.installFailed': 'การติดตั้งล้มเหลว', 'agents.viewDocs': 'เอกสาร', + 'settings.title': 'การตั้งค่า', + 'settings.agents': 'Agents', + 'settings.mcp': 'MCP', + 'settings.system': 'ระบบ', + 'settings.autoUpdateDesc': 'ตรวจสอบเวอร์ชันใหม่โดยอัตโนมัติเมื่อเริ่มต้น', + 'settings.systemDesktopOnly': 'การตั้งค่าระบบใช้ได้ในแอปเดสก์ท็อป', + 'settings.envHint': 'คุณสามารถตั้งค่าตัวแปรสภาพแวดล้อมเพิ่มเติมได้ใน {{path}}', // ── Figma Import ── 'figma.title': 'นำเข้าจาก Figma', diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index 14b59aa26..d97711c98 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -363,6 +363,13 @@ const tr: TranslationKeys = { 'agents.installing': 'Yükleniyor...', 'agents.installFailed': 'Yükleme başarısız', 'agents.viewDocs': 'Belgeler', + 'settings.title': 'Ayarlar', + 'settings.agents': 'Agents', + 'settings.mcp': 'MCP', + 'settings.system': 'Sistem', + 'settings.autoUpdateDesc': 'Başlangıçta yeni sürümleri otomatik olarak kontrol et', + 'settings.systemDesktopOnly': 'Sistem ayarları masaüstü uygulamasında kullanılabilir.', + 'settings.envHint': '{{path}} dosyasında ek ortam değişkenleri ayarlayabilirsiniz.', // ── Figma Import ── 'figma.title': 'Figma\'dan İçe Aktar', diff --git a/src/i18n/locales/vi.ts b/src/i18n/locales/vi.ts index 72acc4420..b78907a4f 100644 --- a/src/i18n/locales/vi.ts +++ b/src/i18n/locales/vi.ts @@ -363,6 +363,13 @@ const vi: TranslationKeys = { 'agents.installing': 'Đang cài đặt...', 'agents.installFailed': 'Cài đặt thất bại', 'agents.viewDocs': 'Tài liệu', + 'settings.title': 'Cài đặt', + 'settings.agents': 'Agents', + 'settings.mcp': 'MCP', + 'settings.system': 'Hệ thống', + 'settings.autoUpdateDesc': 'Tự động kiểm tra phiên bản mới khi khởi động', + 'settings.systemDesktopOnly': 'Cài đặt hệ thống khả dụng trong ứng dụng máy tính.', + 'settings.envHint': 'Bạn có thể đặt thêm biến môi trường trong {{path}}.', // ── Figma Import ── 'figma.title': 'Nhập từ Figma', diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts index 44a011e38..a353275ce 100644 --- a/src/i18n/locales/zh-tw.ts +++ b/src/i18n/locales/zh-tw.ts @@ -355,6 +355,13 @@ const zhTW: TranslationKeys = { 'agents.installing': '安裝中...', 'agents.installFailed': '安裝失敗', 'agents.viewDocs': '文件', + 'settings.title': '設定', + 'settings.agents': 'Agents', + 'settings.mcp': 'MCP', + 'settings.system': '系統', + 'settings.autoUpdateDesc': '啟動時自動檢查新版本', + 'settings.systemDesktopOnly': '系統設定僅在桌面應用程式中可用。', + 'settings.envHint': '你可以在 {{path}} 中設定額外的環境變數。', // ── Figma Import ── 'figma.title': '從 Figma 匯入', diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index ae96090ad..03fc1271a 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -355,6 +355,13 @@ const zh: TranslationKeys = { 'agents.installing': '安装中...', 'agents.installFailed': '安装失败', 'agents.viewDocs': '文档', + 'settings.title': '设置', + 'settings.agents': 'Agents', + 'settings.mcp': 'MCP', + 'settings.system': '系统', + 'settings.autoUpdateDesc': '启动时自动检查新版本', + 'settings.systemDesktopOnly': '系统设置仅在桌面应用中可用。', + 'settings.envHint': '你可以在 {{path}} 中设置额外的环境变量。', // ── Figma Import ── 'figma.title': '从 Figma 导入', diff --git a/src/stores/agent-settings-store.ts b/src/stores/agent-settings-store.ts index 371d8777c..f3a5858d4 100644 --- a/src/stores/agent-settings-store.ts +++ b/src/stores/agent-settings-store.ts @@ -28,6 +28,8 @@ interface AgentSettingsState extends PersistedState { provider: AIProviderType, method: AIProviderConfig['connectionMethod'], models: GroupedModel[], + connectionInfo?: string, + hintPath?: string, ) => void disconnectProvider: (provider: AIProviderType) => void toggleMCPIntegration: (tool: string) => void @@ -88,7 +90,7 @@ export const useAgentSettingsStore = create((set, get) => ({ mcpServerRunning: false, mcpServerLocalIp: null, - connectProvider: (provider, method, models) => + connectProvider: (provider, method, models, connectionInfo, hintPath) => set((s) => ({ providers: { ...s.providers, @@ -97,6 +99,8 @@ export const useAgentSettingsStore = create((set, get) => ({ isConnected: true, connectionMethod: method, models, + connectionInfo, + hintPath, }, }, })), diff --git a/src/types/agent-settings.ts b/src/types/agent-settings.ts index 2e1af0be6..8d2239ede 100644 --- a/src/types/agent-settings.ts +++ b/src/types/agent-settings.ts @@ -7,6 +7,10 @@ export interface AIProviderConfig { connectionMethod: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' | null /** Models fetched when the user connects this provider */ models: GroupedModel[] + /** Human-readable connection status, e.g. "Connected via API key" */ + connectionInfo?: string + /** Config file path for the hint (client renders localized text) */ + hintPath?: string } export type MCPCliTool =