Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccfa2d4dd0 | ||
|
|
7c5478b2a5 | ||
|
|
338ba94d42 | ||
|
|
b7b2e91fab | ||
|
|
cd723000fc | ||
|
|
fff031eb25 | ||
|
|
2f1fd399cf | ||
|
|
43c4d4c430 | ||
|
|
835a1231a6 | ||
|
|
cd512d0800 | ||
|
|
0c5ae13692 | ||
|
|
6727248924 | ||
|
|
0eee7bf95a | ||
|
|
b2406ec8a5 | ||
|
|
5fde9c2d61 | ||
|
|
06a6a0ac12 | ||
|
|
024e60ead1 | ||
|
|
fe71790f0a | ||
|
|
9371b3d01b | ||
|
|
5a1d279efd | ||
|
|
8b0cbf02c3 | ||
|
|
d19fe45a14 | ||
|
|
344946b096 | ||
|
|
fcd15707d2 | ||
|
|
42c82e46ea | ||
|
|
0e1c3b621a | ||
|
|
3cd3bbaaf7 | ||
|
|
8bfb50fcbb | ||
|
|
c39ef879c3 | ||
|
|
b3d5785477 | ||
|
|
05de49f7da | ||
|
|
f77c2b2de9 | ||
|
|
f79f27d737 | ||
|
|
ec35daa0dd | ||
|
|
ed0775d9d2 | ||
|
|
1f31629ce0 | ||
|
|
cc4a904dea | ||
|
|
e9e1d87ff5 | ||
|
|
a6b07f39ad | ||
|
|
6892e11952 |
5
App.tsx
5
App.tsx
@@ -17,6 +17,7 @@ import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { applySyncPayload } from './domain/syncPayload';
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
|
||||
import { TopTabs } from './components/TopTabs';
|
||||
import { Button } from './components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
|
||||
@@ -292,10 +293,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippetPackages,
|
||||
portForwardingRules: portForwardingRulesForSync,
|
||||
knownHosts,
|
||||
settingsVersion: settings.settingsVersion,
|
||||
onApplyPayload: (payload) => {
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -320,6 +323,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
useEffect(() => {
|
||||
// Skip "update available" toast if auto-download has already started or completed
|
||||
if (updateState.autoDownloadStatus !== 'idle') return;
|
||||
// Don't show automatic notification when auto-update is disabled
|
||||
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
|
||||
if (updateState.hasUpdate && updateState.latestRelease) {
|
||||
const version = updateState.latestRelease.version;
|
||||
toast.info(
|
||||
|
||||
@@ -115,6 +115,8 @@ const en: Messages = {
|
||||
'settings.update.lastCheckedMinutesAgo': '{n} min ago',
|
||||
'settings.update.lastCheckedHoursAgo': '{n} hr ago',
|
||||
'settings.update.lastCheckedPrefix': 'Last checked: ',
|
||||
'settings.update.autoUpdateEnabled': 'Automatic Updates',
|
||||
'settings.update.autoUpdateEnabledDesc': 'Automatically check and download updates when available.',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': 'Session Logs',
|
||||
@@ -142,6 +144,8 @@ const en: Messages = {
|
||||
'settings.globalHotkey.reset': 'Reset to default',
|
||||
'settings.globalHotkey.closeToTray': 'Close to System Tray',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
|
||||
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
|
||||
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
|
||||
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
|
||||
|
||||
// Tray Panel
|
||||
@@ -266,6 +270,17 @@ const en: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
|
||||
'settings.terminal.behavior.osc52Clipboard.off': 'Disabled',
|
||||
'settings.terminal.behavior.osc52Clipboard.writeOnly': 'Write only',
|
||||
'settings.terminal.behavior.osc52Clipboard.readWrite': 'Read & Write',
|
||||
'settings.terminal.behavior.osc52Clipboard.prompt': 'Write + Prompt on Read',
|
||||
'terminal.osc52.readPrompt.title': 'Clipboard Read Request',
|
||||
'terminal.osc52.readPrompt.desc': 'A remote program is requesting to read your clipboard. Allow?',
|
||||
'terminal.osc52.readPrompt.allow': 'Allow',
|
||||
'terminal.osc52.readPrompt.deny': 'Deny',
|
||||
'settings.terminal.behavior.scrollOnInput': 'Scroll on input',
|
||||
'settings.terminal.behavior.scrollOnInput.desc': 'Scroll terminal to bottom when typing',
|
||||
'settings.terminal.behavior.scrollOnOutput': 'Scroll on output',
|
||||
@@ -1403,6 +1418,7 @@ const en: Messages = {
|
||||
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
|
||||
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
|
||||
|
||||
'snippets.field.noAutoRun': 'Paste only (do not auto-execute)',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': 'Keyboard Shortcut',
|
||||
'snippets.shortkey.placeholder': 'Click to set shortcut',
|
||||
@@ -1498,6 +1514,7 @@ const en: Messages = {
|
||||
'ai.providers.apiKey.placeholder': 'Enter API key',
|
||||
'ai.providers.apiKey.decrypting': 'Decrypting...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
|
||||
'ai.providers.defaultModel': 'Default Model',
|
||||
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.refreshModels': 'Refresh models',
|
||||
@@ -1603,6 +1620,21 @@ const en: Messages = {
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
|
||||
|
||||
// AI Web Search
|
||||
'ai.webSearch.title': 'Web Search',
|
||||
'ai.webSearch.enable': 'Enable Web Search',
|
||||
'ai.webSearch.enable.description': 'Allow the AI agent to search the web for current information.',
|
||||
'ai.webSearch.provider': 'Search Provider',
|
||||
'ai.webSearch.provider.description': 'Choose a web search API provider.',
|
||||
'ai.webSearch.apiKey': 'API Key',
|
||||
'ai.webSearch.apiKey.description': 'API key for the selected search provider.',
|
||||
'ai.webSearch.apiKey.placeholder': 'Enter API key...',
|
||||
'ai.webSearch.apiHost': 'API Host',
|
||||
'ai.webSearch.apiHost.description': 'Custom API endpoint. Leave default unless you use a proxy.',
|
||||
'ai.webSearch.apiHost.searxngDescription': 'URL of your SearXNG instance (required).',
|
||||
'ai.webSearch.maxResults': 'Max Results',
|
||||
'ai.webSearch.maxResults.description': 'Maximum number of search results to return (1-20).',
|
||||
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': 'Safety',
|
||||
'ai.safety.permissionMode': 'Permission Mode',
|
||||
|
||||
@@ -99,6 +99,8 @@ const zhCN: Messages = {
|
||||
'settings.update.lastCheckedMinutesAgo': '{n} 分钟前',
|
||||
'settings.update.lastCheckedHoursAgo': '{n} 小时前',
|
||||
'settings.update.lastCheckedPrefix': '上次检查:',
|
||||
'settings.update.autoUpdateEnabled': '自动更新',
|
||||
'settings.update.autoUpdateEnabledDesc': '有新版本时自动检查并下载更新。',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': '会话日志',
|
||||
@@ -126,6 +128,8 @@ const zhCN: Messages = {
|
||||
'settings.globalHotkey.reset': '恢复默认',
|
||||
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
|
||||
'settings.globalHotkey.enabled': '启用全局快捷键',
|
||||
'settings.globalHotkey.enabledDesc': '注册系统级键盘快捷键。禁用后将取消所有全局快捷键注册。',
|
||||
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
|
||||
|
||||
// Tray Panel
|
||||
@@ -1142,6 +1146,17 @@ const zhCN: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
|
||||
'settings.terminal.behavior.osc52Clipboard.off': '关闭',
|
||||
'settings.terminal.behavior.osc52Clipboard.writeOnly': '仅写入',
|
||||
'settings.terminal.behavior.osc52Clipboard.readWrite': '读写',
|
||||
'settings.terminal.behavior.osc52Clipboard.prompt': '写入 + 读取时询问',
|
||||
'terminal.osc52.readPrompt.title': '剪贴板读取请求',
|
||||
'terminal.osc52.readPrompt.desc': '远程程序正在请求读取您的剪贴板,是否允许?',
|
||||
'terminal.osc52.readPrompt.allow': '允许',
|
||||
'terminal.osc52.readPrompt.deny': '拒绝',
|
||||
'settings.terminal.behavior.scrollOnInput': '输入时自动滚动',
|
||||
'settings.terminal.behavior.scrollOnInput.desc': '输入时将终端滚动到底部',
|
||||
'settings.terminal.behavior.scrollOnOutput': '输出时自动滚动',
|
||||
@@ -1418,6 +1433,7 @@ const zhCN: Messages = {
|
||||
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
|
||||
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
|
||||
|
||||
'snippets.field.noAutoRun': '仅粘贴(不自动执行)',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': '快捷键',
|
||||
'snippets.shortkey.placeholder': '点击设置快捷键',
|
||||
@@ -1513,6 +1529,7 @@ const zhCN: Messages = {
|
||||
'ai.providers.apiKey.placeholder': '输入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
|
||||
'ai.providers.defaultModel': '默认模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.refreshModels': '刷新模型列表',
|
||||
@@ -1618,6 +1635,21 @@ const zhCN: Messages = {
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
||||
|
||||
// AI Web Search
|
||||
'ai.webSearch.title': '网络搜索',
|
||||
'ai.webSearch.enable': '启用网络搜索',
|
||||
'ai.webSearch.enable.description': '允许 AI 代理搜索互联网获取最新信息。',
|
||||
'ai.webSearch.provider': '搜索供应商',
|
||||
'ai.webSearch.provider.description': '选择一个网络搜索 API 供应商。',
|
||||
'ai.webSearch.apiKey': 'API 密钥',
|
||||
'ai.webSearch.apiKey.description': '所选搜索供应商的 API 密钥。',
|
||||
'ai.webSearch.apiKey.placeholder': '输入 API 密钥...',
|
||||
'ai.webSearch.apiHost': 'API 地址',
|
||||
'ai.webSearch.apiHost.description': '自定义 API 端点。除非使用代理,否则保持默认值。',
|
||||
'ai.webSearch.apiHost.searxngDescription': 'SearXNG 实例的 URL(必填)。',
|
||||
'ai.webSearch.maxResults': '最大结果数',
|
||||
'ai.webSearch.maxResults.description': '搜索返回的最大结果数(1-20)。',
|
||||
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': '安全',
|
||||
'ai.safety.permissionMode': '权限模式',
|
||||
|
||||
@@ -30,7 +30,8 @@ class CustomThemeStore {
|
||||
this.setupCrossWindowSync();
|
||||
}
|
||||
|
||||
private loadFromStorage = () => {
|
||||
/** Reload themes from localStorage. Called internally and after sync apply. */
|
||||
loadFromStorage = () => {
|
||||
try {
|
||||
const parsed = localStorageAdapter.read<TerminalTheme[]>(STORAGE_KEY_CUSTOM_THEMES);
|
||||
if (Array.isArray(parsed)) {
|
||||
@@ -39,7 +40,7 @@ class CustomThemeStore {
|
||||
} catch {
|
||||
// ignore corrupt data
|
||||
}
|
||||
this.cachedAllThemes = null; // invalidate cache
|
||||
this.notify();
|
||||
};
|
||||
|
||||
private saveToStorage = () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type {
|
||||
AISession,
|
||||
@@ -22,6 +23,7 @@ import type {
|
||||
ExternalAgentConfig,
|
||||
ChatMessage,
|
||||
AISessionScope,
|
||||
WebSearchConfig,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
|
||||
@@ -114,6 +116,11 @@ export function useAIState() {
|
||||
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {}
|
||||
);
|
||||
|
||||
// ── Web Search Config ──
|
||||
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
|
||||
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
|
||||
);
|
||||
|
||||
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
||||
setActiveSessionIdMapRaw(prev => ({ ...prev, [scopeKey]: id }));
|
||||
}, []);
|
||||
@@ -126,6 +133,15 @@ export function useAIState() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
|
||||
setWebSearchConfigRaw(config);
|
||||
if (config) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_WEB_SEARCH, config);
|
||||
} else {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Persist helpers ──
|
||||
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
|
||||
setProvidersRaw(prev => {
|
||||
@@ -282,6 +298,9 @@ export function useAIState() {
|
||||
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
|
||||
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
|
||||
break;
|
||||
case STORAGE_KEY_AI_WEB_SEARCH:
|
||||
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[useAIState] Cross-window sync: failed to process storage event for key', e.key, err);
|
||||
@@ -541,6 +560,10 @@ export function useAIState() {
|
||||
agentModelMap,
|
||||
setAgentModel,
|
||||
|
||||
// Web search
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
|
||||
// Sessions (per-scope active session)
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { collectSyncableSettings } from '../../domain/syncPayload';
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
|
||||
@@ -31,7 +32,9 @@ interface AutoSyncConfig {
|
||||
snippetPackages?: SyncPayload['snippetPackages'];
|
||||
portForwardingRules?: SyncPayload['portForwardingRules'];
|
||||
knownHosts?: SyncPayload['knownHosts'];
|
||||
|
||||
/** Opaque token that changes whenever a synced setting changes. */
|
||||
settingsVersion?: number;
|
||||
|
||||
// Callbacks
|
||||
onApplyPayload: (payload: SyncPayload) => void;
|
||||
}
|
||||
@@ -53,6 +56,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const lastSyncedDataRef = useRef<string>('');
|
||||
const hasCheckedRemoteRef = useRef(false);
|
||||
const isInitializedRef = useRef(false);
|
||||
const isSyncRunningRef = useRef(false);
|
||||
|
||||
const getSyncSnapshot = useCallback(() => {
|
||||
let effectivePFRules = config.portForwardingRules;
|
||||
@@ -97,19 +101,21 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const buildPayload = useCallback((): SyncPayload => {
|
||||
return {
|
||||
...getSyncSnapshot(),
|
||||
settings: collectSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
}, [getSyncSnapshot]);
|
||||
|
||||
// Create a hash of current data for comparison
|
||||
// Create a hash of current data for comparison (includes settings)
|
||||
const getDataHash = useCallback(() => {
|
||||
return JSON.stringify(getSyncSnapshot());
|
||||
return JSON.stringify({ ...getSyncSnapshot(), settings: collectSyncableSettings() });
|
||||
}, [getSyncSnapshot]);
|
||||
|
||||
// Sync now handler - get fresh state directly from manager
|
||||
const syncNow = useCallback(async (options?: SyncNowOptions) => {
|
||||
const trigger: SyncTrigger = options?.trigger ?? 'auto';
|
||||
|
||||
isSyncRunningRef.current = true;
|
||||
try {
|
||||
// Get fresh state directly from CloudSyncManager singleton
|
||||
let state = manager.getState();
|
||||
@@ -175,6 +181,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.autoSync.failedTitle'),
|
||||
);
|
||||
} finally {
|
||||
isSyncRunningRef.current = false;
|
||||
}
|
||||
}, [sync, buildPayload, getDataHash, t]);
|
||||
|
||||
@@ -235,7 +243,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
// Wait for the current sync to finish, then this effect will re-run
|
||||
// because sync.isSyncing changed.
|
||||
if (sync.isSyncing) {
|
||||
if (sync.isSyncing || isSyncRunningRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -255,7 +263,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow]);
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion]);
|
||||
|
||||
// Check remote version on startup/unlock
|
||||
useEffect(() => {
|
||||
|
||||
@@ -569,6 +569,7 @@ export const useSessionState = () => {
|
||||
workspaceId: workspace.id,
|
||||
// Store the command to run after connection
|
||||
startupCommand: snippet.command,
|
||||
noAutoRun: snippet.noAutoRun,
|
||||
}));
|
||||
|
||||
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
|
||||
|
||||
@@ -27,10 +27,12 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../state/customThemeStore';
|
||||
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
|
||||
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
|
||||
@@ -264,7 +266,17 @@ export const useSettingsState = () => {
|
||||
if (stored === null) return true;
|
||||
return stored === 'true';
|
||||
});
|
||||
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
|
||||
if (stored === null) return true; // Default to enabled
|
||||
return stored === 'true';
|
||||
});
|
||||
const [hotkeyRegistrationError, setHotkeyRegistrationError] = useState<string | null>(null);
|
||||
const [globalHotkeyEnabled, setGlobalHotkeyEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED);
|
||||
if (stored === null) return true; // Default to enabled
|
||||
return stored === 'true';
|
||||
});
|
||||
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
@@ -332,6 +344,60 @@ export const useSettingsState = () => {
|
||||
setCustomCSS((prev) => (prev === storedCss ? prev : storedCss));
|
||||
}, []);
|
||||
|
||||
const rehydrateAllFromStorage = useCallback(() => {
|
||||
// Theme & appearance (already have helper)
|
||||
syncAppearanceFromStorage();
|
||||
syncCustomCssFromStorage();
|
||||
|
||||
// UI Font
|
||||
const storedFont = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
|
||||
if (storedFont) setUiFontFamilyId(storedFont);
|
||||
|
||||
// Language
|
||||
const storedLang = readStoredString(STORAGE_KEY_UI_LANGUAGE);
|
||||
if (storedLang) setUiLanguage(storedLang as UILanguage);
|
||||
|
||||
// Terminal
|
||||
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
|
||||
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
|
||||
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
if (storedTermFont) setTerminalFontFamilyId(storedTermFont);
|
||||
const storedTermSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
if (storedTermSize != null) setTerminalFontSize(storedTermSize);
|
||||
const storedTermSettings = readStoredString(STORAGE_KEY_TERM_SETTINGS);
|
||||
if (storedTermSettings) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedTermSettings);
|
||||
setTerminalSettings(parsed);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
const storedKb = readStoredString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
|
||||
if (storedKb) {
|
||||
try {
|
||||
setCustomKeyBindings(JSON.parse(storedKb));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Editor
|
||||
const storedWrap = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
|
||||
if (storedWrap === 'true' || storedWrap === 'false') setEditorWordWrapState(storedWrap === 'true');
|
||||
|
||||
// SFTP
|
||||
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
||||
if (storedDblClick === 'open' || storedDblClick === 'transfer') setSftpDoubleClickBehavior(storedDblClick);
|
||||
const storedAutoSync = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
|
||||
if (storedAutoSync === 'true' || storedAutoSync === 'false') setSftpAutoSync(storedAutoSync === 'true');
|
||||
const storedHidden = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
|
||||
if (storedHidden === 'true' || storedHidden === 'false') setSftpShowHiddenFiles(storedHidden === 'true');
|
||||
const storedCompress = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
@@ -457,6 +523,12 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_HOTKEY_RECORDING && typeof value === 'boolean') {
|
||||
setIsHotkeyRecordingState(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
|
||||
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
@@ -622,11 +694,25 @@ export const useSettingsState = () => {
|
||||
setSftpUseCompressedUpload(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== globalHotkeyEnabled) {
|
||||
setGlobalHotkeyEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync auto-update enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_AUTO_UPDATE_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== autoUpdateEnabled) {
|
||||
setAutoUpdateEnabled(newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, mergeIncomingTerminalSettings]);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -734,7 +820,7 @@ export const useSettingsState = () => {
|
||||
// Register/unregister the global hotkey in main process
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
if (toggleWindowHotkey) {
|
||||
if (toggleWindowHotkey && globalHotkeyEnabled) {
|
||||
setHotkeyRegistrationError(null);
|
||||
bridge
|
||||
.registerGlobalHotkey(toggleWindowHotkey)
|
||||
@@ -755,7 +841,13 @@ export const useSettingsState = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [toggleWindowHotkey, notifySettingsChanged]);
|
||||
}, [toggleWindowHotkey, globalHotkeyEnabled, notifySettingsChanged]);
|
||||
|
||||
// Persist global hotkey enabled setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
|
||||
}, [globalHotkeyEnabled, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync close to tray setting
|
||||
useEffect(() => {
|
||||
@@ -770,6 +862,41 @@ export const useSettingsState = () => {
|
||||
}
|
||||
}, [closeToTray, notifySettingsChanged]);
|
||||
|
||||
// Hydrate auto-update state from the main-process preference file on mount.
|
||||
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
|
||||
// in case localStorage was cleared or is stale.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
void bridge?.getAutoUpdate?.().then((result) => {
|
||||
if (result && typeof result.enabled === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => {
|
||||
if (prev === result.enabled) return prev;
|
||||
// Sync localStorage with the main-process truth
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, result.enabled ? 'true' : 'false');
|
||||
return result.enabled;
|
||||
});
|
||||
}
|
||||
}).catch(() => { /* bridge unavailable */ });
|
||||
}, []);
|
||||
|
||||
// Persist auto-update enabled setting.
|
||||
// Skip IPC on initial mount to avoid overwriting the main-process preference
|
||||
// file when localStorage has been cleared (where the default is true).
|
||||
const autoUpdateMountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
|
||||
if (!autoUpdateMountedRef.current) {
|
||||
autoUpdateMountedRef.current = true;
|
||||
return; // Skip IPC on initial mount
|
||||
}
|
||||
// Notify main process on user-initiated changes
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
|
||||
console.warn('[AutoUpdate] Failed to set auto-update:', err);
|
||||
});
|
||||
}, [autoUpdateEnabled, notifySettingsChanged]);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -912,6 +1039,21 @@ export const useSettingsState = () => {
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
autoUpdateEnabled,
|
||||
setAutoUpdateEnabled,
|
||||
hotkeyRegistrationError,
|
||||
globalHotkeyEnabled,
|
||||
setGlobalHotkeyEnabled,
|
||||
rehydrateAllFromStorage,
|
||||
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
settingsVersion: useMemo(() => Math.random(), [
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
uiFontFamilyId, uiLanguage, customCSS,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload,
|
||||
customThemes,
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// Check for updates at most once per hour
|
||||
@@ -56,7 +56,13 @@ export interface UseUpdateCheckResult {
|
||||
* - Respects dismissed version to avoid nagging
|
||||
* - Provides manual check capability
|
||||
*/
|
||||
export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUpdateCheckResult {
|
||||
// Accept auto-update toggle from the caller (e.g. useSettingsState) so it
|
||||
// reacts immediately in the same window. Falls back to reading localStorage
|
||||
// when no caller provides the value (e.g. in non-settings contexts).
|
||||
const autoUpdateEnabled = options?.autoUpdateEnabled ??
|
||||
(localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) !== 'false');
|
||||
|
||||
const [updateState, setUpdateState] = useState<UpdateState>({
|
||||
isChecking: false,
|
||||
hasUpdate: false,
|
||||
@@ -136,14 +142,20 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
return;
|
||||
}
|
||||
|
||||
// 'available' means an update was found but auto-download is disabled.
|
||||
// Surface the version info (hasUpdate + latestRelease) but keep
|
||||
// autoDownloadStatus at 'idle' so the manual download path shows.
|
||||
const isAvailableOnly = snapshot.status === 'available';
|
||||
|
||||
setUpdateState((prev) => {
|
||||
// Don't overwrite if the renderer already has a newer state
|
||||
if (prev.autoDownloadStatus !== 'idle') return prev;
|
||||
return {
|
||||
...prev,
|
||||
autoDownloadStatus: snapshot.status,
|
||||
downloadPercent: snapshot.percent,
|
||||
downloadError: snapshot.error,
|
||||
hasUpdate: isAvailableOnly ? true : prev.hasUpdate,
|
||||
autoDownloadStatus: isAvailableOnly ? 'idle' : snapshot.status,
|
||||
downloadPercent: isAvailableOnly ? 0 : snapshot.percent,
|
||||
downloadError: isAvailableOnly ? null : snapshot.error,
|
||||
// Use snapshot version if no release data or if versions differ
|
||||
latestRelease: (!prev.latestRelease || (snapshot.version && prev.latestRelease.version !== snapshot.version)) ? (snapshot.version ? {
|
||||
version: snapshot.version,
|
||||
@@ -186,15 +198,18 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
if (isDismissed) {
|
||||
dismissedAutoDownloadRef.current = true;
|
||||
}
|
||||
// When auto-update is disabled, autoDownload=false in the main process
|
||||
// so no download will start. Don't transition to 'downloading' or the
|
||||
// UI will be stuck at 0%. Keep status idle and let the manual download
|
||||
// link surface instead.
|
||||
const isAutoUpdateOff = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) === 'false';
|
||||
const shouldTrackDownload = !isDismissed && !isAutoUpdateOff;
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
hasUpdate: !isDismissed,
|
||||
// Only transition to 'downloading' if the user hasn't dismissed this
|
||||
// version — otherwise leave the status at 'idle' so no download
|
||||
// progress/ready toast appears for a release they don't want.
|
||||
autoDownloadStatus: isDismissed ? prev.autoDownloadStatus : 'downloading',
|
||||
downloadPercent: isDismissed ? prev.downloadPercent : 0,
|
||||
downloadError: isDismissed ? prev.downloadError : null,
|
||||
autoDownloadStatus: shouldTrackDownload ? 'downloading' : prev.autoDownloadStatus,
|
||||
downloadPercent: shouldTrackDownload ? 0 : prev.downloadPercent,
|
||||
downloadError: shouldTrackDownload ? null : prev.downloadError,
|
||||
// Use electron-updater's version if GitHub API hasn't resolved yet or
|
||||
// if the updater reports a different version than the cached release.
|
||||
latestRelease: (!prev.latestRelease || prev.latestRelease.version !== info.version) ? {
|
||||
@@ -439,6 +454,20 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
} else if (res?.checking) {
|
||||
// Another check is already in flight — don't change status; the
|
||||
// in-flight check will resolve via IPC events.
|
||||
} else if (nextStatus === 'error' && res?.available) {
|
||||
// GitHub API failed but electron-updater found an update.
|
||||
// Respect dismissed versions before surfacing.
|
||||
const dismissed = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
||||
if (res.version && res.version === dismissed) {
|
||||
// User dismissed this version — don't re-surface
|
||||
} else {
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
manualCheckStatus: 'available',
|
||||
hasUpdate: true,
|
||||
error: null,
|
||||
}));
|
||||
}
|
||||
} else if (nextStatus === 'error' && !res?.error && !res?.available) {
|
||||
// GitHub API failed but electron-updater says no update available.
|
||||
// Clear the error status so Settings doesn't stay stuck in error state.
|
||||
@@ -519,12 +548,12 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
if (IS_UPDATE_DEMO_MODE) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('Version check effect', {
|
||||
hasChecked: hasCheckedOnStartupRef.current,
|
||||
|
||||
debugLog('Version check effect', {
|
||||
hasChecked: hasCheckedOnStartupRef.current,
|
||||
currentVersion: updateState.currentVersion
|
||||
});
|
||||
|
||||
|
||||
if (hasCheckedOnStartupRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -533,12 +562,11 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've checked recently
|
||||
// Hydrate cached release info so update status is visible across windows.
|
||||
// When auto-update is disabled, hydrate release data (for the Settings UI)
|
||||
// but don't set hasUpdate (which would trigger the toast in App.tsx).
|
||||
const lastCheck = localStorageAdapter.readNumber(STORAGE_KEY_UPDATE_LAST_CHECK);
|
||||
const now = Date.now();
|
||||
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
|
||||
hasCheckedOnStartupRef.current = true;
|
||||
// Hydrate cached release info so late-opening windows show the result
|
||||
if (lastCheck) {
|
||||
const cachedRelease = localStorageAdapter.readString(STORAGE_KEY_UPDATE_LATEST_RELEASE);
|
||||
if (cachedRelease) {
|
||||
try {
|
||||
@@ -556,6 +584,19 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
// Ignore corrupted cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Respect auto-update toggle — skip automatic check when disabled.
|
||||
// Don't set hasCheckedOnStartupRef so re-enabling (which changes the
|
||||
// autoUpdateEnabled dependency) can re-trigger this effect.
|
||||
if (!autoUpdateEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've checked recently
|
||||
const now = Date.now();
|
||||
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
|
||||
hasCheckedOnStartupRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -563,6 +604,13 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
debugLog('Starting delayed update check for version:', updateState.currentVersion);
|
||||
|
||||
startupCheckTimeoutRef.current = setTimeout(async () => {
|
||||
// Re-check the toggle at fire time — the user may have toggled it
|
||||
// after the timer was scheduled.
|
||||
const stillEnabled = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
|
||||
if (stillEnabled === 'false') {
|
||||
debugLog('Skipping startup check — auto-update disabled after timer was scheduled');
|
||||
return;
|
||||
}
|
||||
// If electron-updater's auto-check already started a download, skip the
|
||||
// redundant GitHub API check to avoid duplicate toast notifications.
|
||||
if (autoDownloadStatusRef.current !== 'idle') {
|
||||
@@ -601,7 +649,7 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
clearTimeout(startupCheckTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [updateState.currentVersion, performCheck]);
|
||||
}, [updateState.currentVersion, autoUpdateEnabled, performCheck]);
|
||||
|
||||
return {
|
||||
updateState,
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
DiscoveredAgent,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import { getAgentModelPresets } from '../infrastructure/ai/types';
|
||||
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
||||
@@ -82,6 +83,9 @@ interface AIChatSidePanelProps {
|
||||
commandBlocklist?: string[];
|
||||
maxIterations?: number;
|
||||
|
||||
// Web search
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
|
||||
// Context
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
@@ -137,6 +141,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
setGlobalPermissionMode,
|
||||
commandBlocklist,
|
||||
maxIterations = 20,
|
||||
webSearchConfig,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
@@ -227,6 +232,16 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}, [providers]);
|
||||
|
||||
// Sync web search config to main process (allowlist + encrypted API key for server-side decryption).
|
||||
// Note: This is fire-and-forget; if the first search fires before sync completes, it will fail
|
||||
// with a clear error and succeed on retry. Making this blocking would require async tool creation.
|
||||
useEffect(() => {
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiSyncWebSearch) {
|
||||
void bridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
|
||||
}
|
||||
}, [webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
|
||||
|
||||
// Abort all active streams and clean up on unmount
|
||||
useEffect(() => {
|
||||
const controllers = abortControllersRef.current;
|
||||
@@ -452,6 +467,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
setPendingApproval,
|
||||
autoTitleSession,
|
||||
});
|
||||
@@ -463,7 +479,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
setStreamingForScope, setInputValue, clearImages,
|
||||
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
||||
abortControllersRef, terminalSessions, providers, selectedAgentModel,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, setPendingApproval,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, setPendingApproval,
|
||||
]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
@@ -584,6 +600,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
webSearchConfig,
|
||||
})}
|
||||
onReject={(messageId) => void handleApprovalResponse(messageId, false, {
|
||||
terminalSessions,
|
||||
@@ -592,6 +609,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
webSearchConfig,
|
||||
})}
|
||||
/>
|
||||
|
||||
|
||||
@@ -61,6 +61,17 @@ interface TreeNodeProps {
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
}
|
||||
|
||||
// Helper function to recursively count all hosts in a node and its children
|
||||
const countAllHostsInNode = (node: GroupNode): number => {
|
||||
let count = node.hosts.length;
|
||||
if (node.children) {
|
||||
Object.values(node.children).forEach((child) => {
|
||||
count += countAllHostsInNode(child);
|
||||
});
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
node,
|
||||
depth,
|
||||
@@ -89,6 +100,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
const hasChildren = node.children && Object.keys(node.children).length > 0;
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const isManaged = managedGroupPaths?.has(node.path) ?? false;
|
||||
const hostsCountInNode = useMemo(() => countAllHostsInNode(node), [node]);
|
||||
|
||||
const childNodes = useMemo(() => {
|
||||
if (!node.children) return [];
|
||||
@@ -171,7 +183,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
)}
|
||||
{(node.hosts.length > 0 || hasChildren) && (
|
||||
<span className="text-xs opacity-70 bg-background/50 px-2 py-0.5 rounded-full border border-border">
|
||||
{node.hosts.length}
|
||||
{hostsCountInNode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { ScrollArea } from './ui/scroll-area';
|
||||
interface ScriptsSidePanelProps {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
onSnippetClick: (command: string) => void;
|
||||
onSnippetClick: (command: string, noAutoRun?: boolean) => void;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
@@ -115,8 +115,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
});
|
||||
}, [selectedPackage]);
|
||||
|
||||
const handleSnippetClick = useCallback((command: string) => {
|
||||
onSnippetClick(command);
|
||||
const handleSnippetClick = useCallback((command: string, noAutoRun?: boolean) => {
|
||||
onSnippetClick(command, noAutoRun);
|
||||
}, [onSnippetClick]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
@@ -196,7 +196,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
{displayedSnippets.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => handleSnippetClick(s.command)}
|
||||
onClick={() => handleSnippetClick(s.command, s.noAutoRun)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors flex flex-col gap-0.5"
|
||||
>
|
||||
<span className="text-xs font-medium truncate">{s.label}</span>
|
||||
|
||||
@@ -51,7 +51,7 @@ type SettingsState = ReturnType<typeof useSettingsState> & {
|
||||
|
||||
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
|
||||
|
||||
const SettingsSyncTabWithVault: React.FC = () => {
|
||||
const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = ({ onSettingsApplied }) => {
|
||||
const {
|
||||
hosts,
|
||||
keys,
|
||||
@@ -90,6 +90,7 @@ const SettingsSyncTabWithVault: React.FC = () => {
|
||||
importDataFromString={importDataFromString}
|
||||
importPortForwardingRules={importPortForwardingRules}
|
||||
clearVaultData={clearVaultData}
|
||||
onSettingsApplied={onSettingsApplied}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -97,7 +98,7 @@ const SettingsSyncTabWithVault: React.FC = () => {
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const { t } = useI18n();
|
||||
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
|
||||
const aiState = useAIState();
|
||||
const [activeTab, setActiveTab] = useState("application");
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
|
||||
@@ -283,6 +284,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</AITabErrorBoundary>
|
||||
@@ -290,7 +293,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
|
||||
{mountedTabs.has("sync") && (
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsSyncTabWithVault />
|
||||
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
|
||||
</React.Suspense>
|
||||
)}
|
||||
|
||||
@@ -307,6 +310,10 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
closeToTray={settings.closeToTray}
|
||||
setCloseToTray={settings.setCloseToTray}
|
||||
hotkeyRegistrationError={settings.hotkeyRegistrationError}
|
||||
globalHotkeyEnabled={settings.globalHotkeyEnabled}
|
||||
setGlobalHotkeyEnabled={settings.setGlobalHotkeyEnabled}
|
||||
autoUpdateEnabled={settings.autoUpdateEnabled}
|
||||
setAutoUpdateEnabled={settings.setAutoUpdateEnabled}
|
||||
updateState={updateState}
|
||||
checkNow={checkNow}
|
||||
installUpdate={installUpdate}
|
||||
|
||||
@@ -28,6 +28,7 @@ interface SnippetsManagerProps {
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
onSave: (snippet: Snippet) => void;
|
||||
onBulkSave: (snippets: Snippet[]) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onPackagesChange: (packages: string[]) => void;
|
||||
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
|
||||
@@ -51,6 +52,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
onSave,
|
||||
onBulkSave,
|
||||
onDelete,
|
||||
onPackagesChange,
|
||||
onRunSnippet,
|
||||
@@ -300,6 +302,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
package: editingSnippet.package || '',
|
||||
targets: targetSelection,
|
||||
shortkey: editingSnippet.shortkey,
|
||||
noAutoRun: editingSnippet.noAutoRun,
|
||||
});
|
||||
setRightPanelMode('none');
|
||||
}
|
||||
@@ -486,11 +489,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
// Update packages first, then save snippets
|
||||
onPackagesChange(keep);
|
||||
|
||||
// Only save snippets that were actually modified
|
||||
const modifiedSnippets = updatedSnippets.filter((s, index) =>
|
||||
s.package !== snippets[index].package
|
||||
);
|
||||
modifiedSnippets.forEach(onSave);
|
||||
// Bulk-save all snippets to avoid stale-closure overwrites
|
||||
onBulkSave(updatedSnippets);
|
||||
|
||||
// Reset selected package if it was deleted
|
||||
if (selectedPackage && (selectedPackage === path || selectedPackage.startsWith(path + '/'))) {
|
||||
@@ -527,7 +527,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
updatedSnippets.forEach(onSave);
|
||||
onBulkSave(updatedSnippets);
|
||||
if (selectedPackage === source) setSelectedPackage(newPath);
|
||||
};
|
||||
|
||||
@@ -568,8 +568,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: duplicate (case-insensitive)
|
||||
const existingPackage = packages.find(p => p.toLowerCase() === newPath.toLowerCase());
|
||||
// Validate: duplicate (case-insensitive), excluding the package being renamed
|
||||
const existingPackage = packages.find(p => p !== renamingPackagePath && p.toLowerCase() === newPath.toLowerCase());
|
||||
if (existingPackage) {
|
||||
setRenameError(t('snippets.renameDialog.error.duplicate'));
|
||||
return;
|
||||
@@ -595,7 +595,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
updatedSnippets.forEach(onSave);
|
||||
onBulkSave(updatedSnippets);
|
||||
|
||||
// Update selected package if it was renamed
|
||||
if (selectedPackage === renamingPackagePath) {
|
||||
@@ -792,6 +792,17 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* No Auto Run */}
|
||||
<label className="flex items-center gap-2 cursor-pointer px-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingSnippet.noAutoRun ?? false}
|
||||
onChange={(e) => setEditingSnippet({ ...editingSnippet, noAutoRun: e.target.checked || undefined })}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('snippets.field.noAutoRun')}</span>
|
||||
</label>
|
||||
|
||||
{/* Shortkey */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
// flushSync removed - no longer needed
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { logger } from "../lib/logger";
|
||||
@@ -118,6 +118,7 @@ interface TerminalProps {
|
||||
terminalSettings?: TerminalSettings;
|
||||
sessionId: string;
|
||||
startupCommand?: string;
|
||||
noAutoRun?: boolean;
|
||||
serialConfig?: SerialConfig;
|
||||
hotkeyScheme?: "disabled" | "mac" | "pc";
|
||||
keyBindings?: KeyBinding[];
|
||||
@@ -184,6 +185,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalSettings,
|
||||
sessionId,
|
||||
startupCommand,
|
||||
noAutoRun,
|
||||
serialConfig,
|
||||
hotkeyScheme = "disabled",
|
||||
keyBindings = [],
|
||||
@@ -238,22 +240,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
useEffect(() => {
|
||||
if (xtermRuntimeRef.current) {
|
||||
// Merge global rules with host-level rules
|
||||
// Host-level rules are appended to global rules, allowing hosts to add custom highlighting
|
||||
const globalRules = terminalSettings?.keywordHighlightRules ?? [];
|
||||
const hostRules = host?.keywordHighlightRules ?? [];
|
||||
|
||||
// Check if highlighting is enabled at either global or host level
|
||||
const globalEnabled = terminalSettings?.keywordHighlightEnabled ?? false;
|
||||
const hostEnabled = host?.keywordHighlightEnabled ?? false;
|
||||
// Host-level toggle: undefined = inherit global, true/false = explicit override
|
||||
const hostEnabled = host?.keywordHighlightEnabled;
|
||||
|
||||
// If host explicitly disabled highlighting, disable everything for this terminal
|
||||
const effectiveGlobalEnabled = hostEnabled === false ? false : globalEnabled;
|
||||
const effectiveHostEnabled = hostEnabled ?? false;
|
||||
|
||||
// Merge rules: include only rules from enabled sources
|
||||
const mergedRules = [
|
||||
...(globalEnabled ? globalRules : []),
|
||||
...(hostEnabled ? hostRules : [])
|
||||
...(effectiveGlobalEnabled ? globalRules : []),
|
||||
...(effectiveHostEnabled ? hostRules : [])
|
||||
];
|
||||
|
||||
// Enable highlighting if either global or host-level is enabled
|
||||
const isEnabled = globalEnabled || hostEnabled;
|
||||
const isEnabled = effectiveGlobalEnabled || effectiveHostEnabled;
|
||||
|
||||
xtermRuntimeRef.current.keywordHighlighter.setRules(mergedRules, isEnabled);
|
||||
}
|
||||
@@ -371,6 +373,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState<HostKeyInfo | null>(null);
|
||||
const pendingConnectionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// OSC-52 clipboard read prompt
|
||||
const [osc52ReadPromptVisible, setOsc52ReadPromptVisible] = useState(false);
|
||||
const osc52ReadResolverRef = useRef<((allowed: boolean) => void) | null>(null);
|
||||
const handleOsc52ReadRequest = useCallback((): Promise<boolean> => {
|
||||
// Reject if terminal is not visible (background tab) — user can't see the prompt
|
||||
if (!isVisibleRef.current) return Promise.resolve(false);
|
||||
// Reject if another prompt is already pending (avoid resolver overwrite)
|
||||
if (osc52ReadResolverRef.current) return Promise.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
osc52ReadResolverRef.current = resolve;
|
||||
setOsc52ReadPromptVisible(true);
|
||||
});
|
||||
}, []);
|
||||
const handleOsc52ReadResponse = useCallback((allowed: boolean) => {
|
||||
setOsc52ReadPromptVisible(false);
|
||||
osc52ReadResolverRef.current?.(allowed);
|
||||
osc52ReadResolverRef.current = null;
|
||||
// Restore focus to terminal
|
||||
termRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
@@ -427,6 +450,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
resolvedChainHosts,
|
||||
sessionId,
|
||||
startupCommand,
|
||||
noAutoRun,
|
||||
terminalSettings,
|
||||
terminalSettingsRef,
|
||||
terminalBackend,
|
||||
@@ -502,6 +526,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serialLocalEcho: serialConfig?.localEcho,
|
||||
serialLineMode: serialConfig?.lineMode,
|
||||
serialLineBufferRef,
|
||||
onOsc52ReadRequest: handleOsc52ReadRequest,
|
||||
});
|
||||
|
||||
xtermRuntimeRef.current = runtime;
|
||||
@@ -516,12 +541,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const globalRules = terminalSettingsRef.current?.keywordHighlightRules ?? [];
|
||||
const hostRules = host?.keywordHighlightRules ?? [];
|
||||
const globalEnabled = terminalSettingsRef.current?.keywordHighlightEnabled ?? false;
|
||||
const hostEnabled = host?.keywordHighlightEnabled ?? false;
|
||||
const hostEnabled = host?.keywordHighlightEnabled;
|
||||
const effectiveGlobalEnabled = hostEnabled === false ? false : globalEnabled;
|
||||
const effectiveHostEnabled = hostEnabled ?? false;
|
||||
const mergedRules = [
|
||||
...(globalEnabled ? globalRules : []),
|
||||
...(hostEnabled ? hostRules : [])
|
||||
...(effectiveGlobalEnabled ? globalRules : []),
|
||||
...(effectiveHostEnabled ? hostRules : [])
|
||||
];
|
||||
const isEnabled = globalEnabled || hostEnabled;
|
||||
const isEnabled = effectiveGlobalEnabled || effectiveHostEnabled;
|
||||
runtime.keywordHighlighter.setRules(mergedRules, isEnabled);
|
||||
|
||||
const term = runtime.term;
|
||||
@@ -1678,6 +1705,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OSC-52 clipboard read prompt */}
|
||||
{osc52ReadPromptVisible && (
|
||||
<div
|
||||
className="absolute inset-0 z-40 flex items-center justify-center bg-background/60"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') handleOsc52ReadResponse(false);
|
||||
}}
|
||||
>
|
||||
<div className="rounded-lg border bg-card p-4 shadow-lg max-w-sm space-y-3">
|
||||
<p className="text-sm font-medium">{t("terminal.osc52.readPrompt.title")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("terminal.osc52.readPrompt.desc")}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => handleOsc52ReadResponse(false)}>
|
||||
{t("terminal.osc52.readPrompt.deny")}
|
||||
</Button>
|
||||
<Button size="sm" autoFocus onClick={() => handleOsc52ReadResponse(true)}>
|
||||
{t("terminal.osc52.readPrompt.allow")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection dialog: skip for local/serial during connecting phase, but show on error */}
|
||||
{status !== "connected" && !needsHostKeyVerification && !(
|
||||
(isLocalConnection || isSerialConnection) && status === "connecting"
|
||||
|
||||
@@ -864,10 +864,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, [handleOpenAI]);
|
||||
|
||||
// Execute snippet on the focused terminal session
|
||||
const handleSnippetClickForFocusedSession = useCallback((command: string) => {
|
||||
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
|
||||
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
|
||||
if (!sessionId) return;
|
||||
const payload = `${command}\r`;
|
||||
const payload = noAutoRun ? command : `${command}\r`;
|
||||
terminalBackend.writeToSession(sessionId, payload);
|
||||
// Re-focus the terminal so the user can interact immediately
|
||||
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
|
||||
@@ -1348,6 +1348,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
maxIterations={aiState.maxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
scopeType={activeWorkspace ? 'workspace' : 'terminal'}
|
||||
scopeTargetId={activeWorkspace?.id ?? activeSession?.id}
|
||||
scopeHostIds={activeWorkspace?.root
|
||||
@@ -1487,6 +1488,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
terminalSettings={terminalSettings}
|
||||
sessionId={session.id}
|
||||
startupCommand={session.startupCommand}
|
||||
noAutoRun={session.noAutoRun}
|
||||
serialConfig={session.serialConfig}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
|
||||
@@ -2201,6 +2201,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
: [...snippets, s],
|
||||
)
|
||||
}
|
||||
onBulkSave={onUpdateSnippets}
|
||||
onDelete={(id) =>
|
||||
onUpdateSnippets(snippets.filter((s) => s.id !== id))
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ import type {
|
||||
ChatMessage,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../../../infrastructure/ai/types';
|
||||
import { isWebSearchReady } from '../../../infrastructure/ai/types';
|
||||
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
|
||||
import { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
|
||||
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
|
||||
@@ -93,6 +95,7 @@ type StreamChunk =
|
||||
export interface PanelBridge extends NetcattyBridge {
|
||||
credentialsDecrypt?: (value: string) => Promise<string>;
|
||||
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
|
||||
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
|
||||
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
[key: string]: ((...args: unknown[]) => unknown) | undefined;
|
||||
@@ -203,6 +206,7 @@ export interface SendToCattyContext {
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
|
||||
autoTitleSession: (sessionId: string, text: string) => void;
|
||||
}
|
||||
@@ -247,7 +251,11 @@ export function useAIChatStreaming({
|
||||
err: unknown,
|
||||
) => {
|
||||
if (abortSignal.aborted) return;
|
||||
const errorStr = err instanceof Error ? err.message : String(err);
|
||||
let errorStr: string;
|
||||
if (err instanceof Error) errorStr = err.message;
|
||||
else if (typeof err === 'object' && err !== null && 'message' in err) errorStr = String((err as { message: unknown }).message);
|
||||
else if (typeof err === 'string') errorStr = err;
|
||||
else { try { errorStr = JSON.stringify(err) ?? 'Unknown error'; } catch { errorStr = 'Unknown error'; } }
|
||||
// Log the full unsanitized error for debugging
|
||||
console.error('[AIChatSidePanel] Stream error (full):', errorStr);
|
||||
const errorInfo = classifyError(errorStr);
|
||||
@@ -460,7 +468,11 @@ export function useAIChatStreaming({
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo: classifyError(String(typedChunk.error)),
|
||||
errorInfo: classifyError(
|
||||
typedChunk.error instanceof Error ? typedChunk.error.message
|
||||
: typeof typedChunk.error === 'string' ? typedChunk.error
|
||||
: (() => { try { return JSON.stringify(typedChunk.error) ?? 'Unknown error'; } catch { return 'Unknown error'; } })(),
|
||||
),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
break;
|
||||
@@ -621,7 +633,7 @@ export function useAIChatStreaming({
|
||||
sessions: context.terminalSessions,
|
||||
workspaceId: context.scopeTargetId,
|
||||
workspaceName: context.scopeLabel,
|
||||
}, context.commandBlocklist, context.globalPermissionMode);
|
||||
}, context.commandBlocklist, context.globalPermissionMode, context.webSearchConfig ?? undefined);
|
||||
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
scopeType: context.scopeType, scopeLabel: context.scopeLabel,
|
||||
@@ -630,6 +642,7 @@ export function useAIChatStreaming({
|
||||
os: s.os, username: s.username, connected: s.connected,
|
||||
})),
|
||||
permissionMode: context.globalPermissionMode,
|
||||
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
|
||||
});
|
||||
|
||||
// Guard: activeProvider must exist for Catty agent path
|
||||
@@ -656,13 +669,35 @@ export function useAIChatStreaming({
|
||||
try {
|
||||
// Issue #5: Build SDK messages including tool-call and tool-result messages
|
||||
// so the LLM maintains full conversation context
|
||||
const allMessages = currentSession?.messages ?? [];
|
||||
|
||||
// Collect all tool call IDs that have a corresponding tool result,
|
||||
// so we can skip orphaned tool calls (e.g. from user stopping mid-execution)
|
||||
const resolvedToolCallIds = new Set<string>();
|
||||
for (const m of allMessages) {
|
||||
if (m.role === 'tool' && m.toolResults) {
|
||||
for (const tr of m.toolResults) resolvedToolCallIds.add(tr.toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
const findToolName = (toolCallId: string): string => {
|
||||
for (const prev of allMessages) {
|
||||
if (prev.role === 'assistant' && prev.toolCalls) {
|
||||
const tc = prev.toolCalls.find(t => t.id === toolCallId);
|
||||
if (tc) return tc.name;
|
||||
}
|
||||
}
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
const sdkMessages: Array<ModelMessage> = [];
|
||||
for (const m of (currentSession?.messages ?? [])) {
|
||||
for (const m of allMessages) {
|
||||
if (m.role === 'user') {
|
||||
sdkMessages.push({ role: 'user', content: m.content });
|
||||
} else if (m.role === 'assistant') {
|
||||
if (m.toolCalls?.length) {
|
||||
// Build assistant content parts: text + tool calls
|
||||
// Only include tool calls that have matching results
|
||||
const resolvedCalls = m.toolCalls.filter(tc => resolvedToolCallIds.has(tc.id));
|
||||
const contentParts: Array<
|
||||
{ type: 'text'; text: string } |
|
||||
{ type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
|
||||
@@ -670,7 +705,7 @@ export function useAIChatStreaming({
|
||||
if (m.content) {
|
||||
contentParts.push({ type: 'text' as const, text: m.content });
|
||||
}
|
||||
for (const tc of m.toolCalls) {
|
||||
for (const tc of resolvedCalls) {
|
||||
contentParts.push({
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: tc.id,
|
||||
@@ -678,18 +713,20 @@ export function useAIChatStreaming({
|
||||
input: tc.arguments ?? {},
|
||||
});
|
||||
}
|
||||
sdkMessages.push({ role: 'assistant', content: contentParts });
|
||||
// If all tool calls were orphaned, just include the text content
|
||||
if (contentParts.length > 0) {
|
||||
sdkMessages.push({ role: 'assistant', content: contentParts.length === 1 && contentParts[0].type === 'text' ? (contentParts[0] as { type: 'text'; text: string }).text : contentParts });
|
||||
}
|
||||
} else if (m.content) {
|
||||
sdkMessages.push({ role: 'assistant', content: m.content });
|
||||
}
|
||||
} else if (m.role === 'tool' && m.toolResults?.length) {
|
||||
// Map tool results to SDK tool message format
|
||||
sdkMessages.push({
|
||||
role: 'tool',
|
||||
content: m.toolResults.map(tr => ({
|
||||
type: 'tool-result' as const,
|
||||
toolCallId: tr.toolCallId,
|
||||
toolName: '',
|
||||
toolName: findToolName(tr.toolCallId),
|
||||
output: { type: 'text' as const, value: tr.content },
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -13,7 +13,9 @@ import type { ModelMessage } from 'ai';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
ChatMessage,
|
||||
WebSearchConfig,
|
||||
} from '../../../infrastructure/ai/types';
|
||||
import { isWebSearchReady } from '../../../infrastructure/ai/types';
|
||||
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
|
||||
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
@@ -76,6 +78,7 @@ export interface ToolApprovalContext {
|
||||
scopeLabel?: string;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -218,7 +221,7 @@ export function useToolApproval({
|
||||
sessions: approvalContext.terminalSessions,
|
||||
workspaceId: approvalContext.scopeTargetId,
|
||||
workspaceName: approvalContext.scopeLabel,
|
||||
}, approvalContext.commandBlocklist, approvalContext.globalPermissionMode);
|
||||
}, approvalContext.commandBlocklist, approvalContext.globalPermissionMode, approvalContext.webSearchConfig ?? undefined);
|
||||
const freshSystemPrompt = buildSystemPrompt({
|
||||
scopeType: approvalContext.scopeType, scopeLabel: approvalContext.scopeLabel,
|
||||
hosts: approvalContext.terminalSessions.map(s => ({
|
||||
@@ -226,6 +229,7 @@ export function useToolApproval({
|
||||
os: s.os, username: s.username, connected: s.connected,
|
||||
})),
|
||||
permissionMode: approvalContext.globalPermissionMode,
|
||||
webSearchEnabled: isWebSearchReady(approvalContext.webSearchConfig),
|
||||
});
|
||||
const newApprovalInfo = await processCattyStream(sid, ctxModel, freshSystemPrompt, freshTools, resumeMessages as unknown as ModelMessage[], abortController.signal, newAssistantMsgId);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
AIProviderId,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from "../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
|
||||
import { useAgentDiscovery } from "../../../application/state/useAgentDiscovery";
|
||||
@@ -38,6 +39,7 @@ import { AddProviderDropdown } from "./ai/AddProviderDropdown";
|
||||
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
|
||||
import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
|
||||
import { SafetySettings } from "./ai/SafetySettings";
|
||||
import { WebSearchSettings } from "./ai/WebSearchSettings";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
@@ -64,6 +66,8 @@ interface SettingsAITabProps {
|
||||
setCommandTimeout: (value: number) => void;
|
||||
maxIterations: number;
|
||||
setMaxIterations: (value: number) => void;
|
||||
webSearchConfig: WebSearchConfig | null;
|
||||
setWebSearchConfig: (config: WebSearchConfig | null) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -91,6 +95,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setCommandTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
|
||||
@@ -508,6 +514,12 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* -- Web Search Section -- */}
|
||||
<WebSearchSettings
|
||||
webSearchConfig={webSearchConfig}
|
||||
setWebSearchConfig={setWebSearchConfig}
|
||||
/>
|
||||
|
||||
{/* -- Safety Section -- */}
|
||||
<SafetySettings
|
||||
globalPermissionMode={globalPermissionMode}
|
||||
|
||||
@@ -15,6 +15,7 @@ export default function SettingsSyncTab(props: {
|
||||
importDataFromString: (data: string) => void;
|
||||
importPortForwardingRules: (rules: PortForwardingRule[]) => void;
|
||||
clearVaultData: () => void;
|
||||
onSettingsApplied?: () => void;
|
||||
}) {
|
||||
const {
|
||||
vault,
|
||||
@@ -22,6 +23,7 @@ export default function SettingsSyncTab(props: {
|
||||
importDataFromString,
|
||||
importPortForwardingRules,
|
||||
clearVaultData,
|
||||
onSettingsApplied,
|
||||
} = props;
|
||||
|
||||
const onBuildPayload = useCallback((): SyncPayload => {
|
||||
@@ -56,9 +58,10 @@ export default function SettingsSyncTab(props: {
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied,
|
||||
});
|
||||
},
|
||||
[importDataFromString, importPortForwardingRules],
|
||||
[importDataFromString, importPortForwardingRules, onSettingsApplied],
|
||||
);
|
||||
|
||||
const clearAllLocalData = useCallback(() => {
|
||||
|
||||
@@ -55,6 +55,10 @@ interface SettingsSystemTabProps {
|
||||
closeToTray: boolean;
|
||||
setCloseToTray: (enabled: boolean) => void;
|
||||
hotkeyRegistrationError: string | null;
|
||||
globalHotkeyEnabled: boolean;
|
||||
setGlobalHotkeyEnabled: (enabled: boolean) => void;
|
||||
autoUpdateEnabled: boolean;
|
||||
setAutoUpdateEnabled: (enabled: boolean) => void;
|
||||
// Unified update state — from useUpdateCheck hook in SettingsPageContent
|
||||
updateState: UpdateState;
|
||||
checkNow: () => Promise<unknown>;
|
||||
@@ -74,6 +78,10 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
hotkeyRegistrationError,
|
||||
globalHotkeyEnabled,
|
||||
setGlobalHotkeyEnabled,
|
||||
autoUpdateEnabled,
|
||||
setAutoUpdateEnabled,
|
||||
updateState,
|
||||
checkNow,
|
||||
installUpdate,
|
||||
@@ -367,6 +375,15 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t('settings.update.autoUpdateEnabled')}
|
||||
description={t('settings.update.autoUpdateEnabledDesc')}
|
||||
>
|
||||
<Toggle
|
||||
checked={autoUpdateEnabled}
|
||||
onChange={setAutoUpdateEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{updateState.lastCheckedAt && (
|
||||
<span>
|
||||
@@ -580,7 +597,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
value={sessionLogsFormat}
|
||||
options={formatOptions}
|
||||
onChange={(val) => setSessionLogsFormat(val as SessionLogFormat)}
|
||||
className="w-32"
|
||||
className="w-44"
|
||||
disabled={!sessionLogsEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
@@ -599,42 +616,55 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
|
||||
{/* Toggle Window Hotkey */}
|
||||
{/* Enable/Disable Global Hotkey */}
|
||||
<SettingRow
|
||||
label={t("settings.globalHotkey.toggleWindow")}
|
||||
description={t("settings.globalHotkey.toggleWindowDesc")}
|
||||
label={t('settings.globalHotkey.enabled')}
|
||||
description={t('settings.globalHotkey.enabledDesc')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRecordingHotkey(true);
|
||||
}}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
|
||||
isRecordingHotkey
|
||||
? "border-primary bg-primary/10 animate-pulse"
|
||||
: "border-border hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
{isRecordingHotkey
|
||||
? t("settings.shortcuts.recording")
|
||||
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
|
||||
</button>
|
||||
{toggleWindowHotkey && (
|
||||
<button
|
||||
onClick={handleResetHotkey}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={t("settings.globalHotkey.reset")}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Toggle
|
||||
checked={globalHotkeyEnabled}
|
||||
onChange={setGlobalHotkeyEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
{(hotkeyError || hotkeyRegistrationError) && (
|
||||
<p className="text-sm text-destructive">{hotkeyError || hotkeyRegistrationError}</p>
|
||||
)}
|
||||
|
||||
<div className={cn(!globalHotkeyEnabled && "opacity-50 pointer-events-none")}>
|
||||
{/* Toggle Window Hotkey */}
|
||||
<SettingRow
|
||||
label={t("settings.globalHotkey.toggleWindow")}
|
||||
description={t("settings.globalHotkey.toggleWindowDesc")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRecordingHotkey(true);
|
||||
}}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
|
||||
isRecordingHotkey
|
||||
? "border-primary bg-primary/10 animate-pulse"
|
||||
: "border-border hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
{isRecordingHotkey
|
||||
? t("settings.shortcuts.recording")
|
||||
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
|
||||
</button>
|
||||
{toggleWindowHotkey && (
|
||||
<button
|
||||
onClick={handleResetHotkey}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={t("settings.globalHotkey.reset")}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
{(hotkeyError || hotkeyRegistrationError) && (
|
||||
<p className="text-sm text-destructive mt-2">{hotkeyError || hotkeyRegistrationError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close to Tray */}
|
||||
<SettingRow
|
||||
|
||||
@@ -575,6 +575,23 @@ export default function SettingsTerminalTab(props: {
|
||||
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.osc52Clipboard")}
|
||||
description={t("settings.terminal.behavior.osc52Clipboard.desc")}
|
||||
>
|
||||
<Select
|
||||
value={terminalSettings.osc52Clipboard ?? 'write-only'}
|
||||
options={[
|
||||
{ value: "off", label: t("settings.terminal.behavior.osc52Clipboard.off") },
|
||||
{ value: "write-only", label: t("settings.terminal.behavior.osc52Clipboard.writeOnly") },
|
||||
{ value: "read-write", label: t("settings.terminal.behavior.osc52Clipboard.readWrite") },
|
||||
{ value: "prompt", label: t("settings.terminal.behavior.osc52Clipboard.prompt") },
|
||||
]}
|
||||
onChange={(v) => updateTerminalSetting("osc52Clipboard", v as "off" | "write-only" | "read-write" | "prompt")}
|
||||
className="w-40"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.scrollOnInput")}
|
||||
description={t("settings.terminal.behavior.scrollOnInput.desc")}
|
||||
@@ -616,7 +633,7 @@ export default function SettingsTerminalTab(props: {
|
||||
{ value: "meta", label: t("settings.terminal.behavior.linkModifier.meta") },
|
||||
]}
|
||||
onChange={(v) => updateTerminalSetting("linkModifier", v as LinkModifier)}
|
||||
className="w-40"
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,8 @@ export const ModelSelector: React.FC<{
|
||||
placeholder?: string;
|
||||
apiKey?: string;
|
||||
providerId?: AIProviderId;
|
||||
}> = ({ value, onChange, baseURL, modelsEndpoint, placeholder, apiKey, providerId }) => {
|
||||
skipTLSVerify?: boolean;
|
||||
}> = ({ value, onChange, baseURL, modelsEndpoint, placeholder, apiKey, providerId, skipTLSVerify }) => {
|
||||
const { t } = useI18n();
|
||||
const [models, setModels] = useState<FetchedModel[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -35,6 +36,11 @@ export const ModelSelector: React.FC<{
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Temporarily allow the provider's host in the backend fetch allowlist
|
||||
// so model listing works for URLs not yet synced from the main window.
|
||||
if (bridge.aiAllowlistAddHost && baseURL) {
|
||||
await bridge.aiAllowlistAddHost(baseURL);
|
||||
}
|
||||
const url = `${baseURL.replace(/\/+$/, "")}${modelsEndpoint}`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) {
|
||||
@@ -45,7 +51,7 @@ export const ModelSelector: React.FC<{
|
||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
}
|
||||
}
|
||||
const result = await bridge.aiFetch(url, "GET", headers);
|
||||
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
|
||||
if (!result.ok) {
|
||||
setError(`Failed to fetch models (${result.error || "unknown error"})`);
|
||||
return;
|
||||
@@ -63,7 +69,7 @@ export const ModelSelector: React.FC<{
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [baseURL, modelsEndpoint, apiKey, providerId]);
|
||||
}, [baseURL, modelsEndpoint, apiKey, providerId, skipTLSVerify]);
|
||||
|
||||
// Auto-fetch when dropdown first opens
|
||||
useEffect(() => {
|
||||
|
||||
@@ -19,6 +19,7 @@ export const ProviderConfigForm: React.FC<{
|
||||
apiKey: "",
|
||||
baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "",
|
||||
defaultModel: provider.defaultModel ?? "",
|
||||
skipTLSVerify: provider.skipTLSVerify ?? false,
|
||||
});
|
||||
const isCustom = provider.providerId === "custom";
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
@@ -46,6 +47,7 @@ export const ProviderConfigForm: React.FC<{
|
||||
const updates: Partial<ProviderConfig> = {
|
||||
baseURL: form.baseURL || undefined,
|
||||
defaultModel: form.defaultModel || undefined,
|
||||
skipTLSVerify: form.skipTLSVerify || undefined,
|
||||
...(isCustom && form.name.trim() ? { name: form.name.trim() } : {}),
|
||||
};
|
||||
|
||||
@@ -120,9 +122,21 @@ export const ProviderConfigForm: React.FC<{
|
||||
modelsEndpoint={preset?.modelsEndpoint}
|
||||
apiKey={form.apiKey}
|
||||
providerId={provider.providerId}
|
||||
skipTLSVerify={form.skipTLSVerify}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Skip TLS Verification */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.skipTLSVerify}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, skipTLSVerify: e.target.checked }))}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('ai.providers.skipTLSVerify')}</span>
|
||||
</label>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button variant="default" size="sm" onClick={() => void handleSave()}>
|
||||
|
||||
220
components/settings/tabs/ai/WebSearchSettings.tsx
Normal file
220
components/settings/tabs/ai/WebSearchSettings.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Globe, Eye, EyeOff } from "lucide-react";
|
||||
import type { WebSearchConfig, WebSearchProviderId } from "../../../../infrastructure/ai/types";
|
||||
import { WEB_SEARCH_PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
|
||||
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Select, SettingRow } from "../../settings-ui";
|
||||
|
||||
const SEARCH_ICON_PATHS: Record<WebSearchProviderId, string> = {
|
||||
tavily: "/ai/search/tavily.svg",
|
||||
exa: "/ai/search/exa.png",
|
||||
bocha: "/ai/search/bocha.webp",
|
||||
zhipu: "/ai/search/zhipu.png",
|
||||
searxng: "/ai/search/searxng.svg",
|
||||
};
|
||||
|
||||
const SearchProviderIcon: React.FC<{ providerId: WebSearchProviderId }> = ({ providerId }) => (
|
||||
<img
|
||||
src={SEARCH_ICON_PATHS[providerId]}
|
||||
alt=""
|
||||
className="w-4 h-4 shrink-0"
|
||||
/>
|
||||
);
|
||||
|
||||
const PROVIDER_OPTIONS: Array<{ value: WebSearchProviderId; label: string; icon: React.ReactNode }> = Object.entries(
|
||||
WEB_SEARCH_PROVIDER_PRESETS,
|
||||
).map(([id, preset]) => ({
|
||||
value: id as WebSearchProviderId,
|
||||
label: preset.name,
|
||||
icon: <SearchProviderIcon providerId={id as WebSearchProviderId} />,
|
||||
}));
|
||||
|
||||
export const WebSearchSettings: React.FC<{
|
||||
webSearchConfig: WebSearchConfig | null;
|
||||
setWebSearchConfig: (config: WebSearchConfig | null) => void;
|
||||
}> = ({ webSearchConfig, setWebSearchConfig }) => {
|
||||
const { t } = useI18n();
|
||||
const [apiKeyInput, setApiKeyInput] = useState("");
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
|
||||
const config = useMemo(() => webSearchConfig ?? {
|
||||
providerId: "tavily" as WebSearchProviderId,
|
||||
enabled: false,
|
||||
maxResults: 5,
|
||||
}, [webSearchConfig]);
|
||||
|
||||
// Ref to always read the latest config in async callbacks (avoids stale closure)
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
|
||||
const preset = WEB_SEARCH_PROVIDER_PRESETS[config.providerId];
|
||||
|
||||
// Decrypt API key on mount or when provider changes (with cancellation guard)
|
||||
const decryptSeqRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (config.apiKey) {
|
||||
const seq = ++decryptSeqRef.current;
|
||||
setIsDecrypting(true);
|
||||
decryptField(config.apiKey)
|
||||
.then((decrypted) => {
|
||||
if (decryptSeqRef.current === seq) setApiKeyInput(decrypted ?? "");
|
||||
})
|
||||
.catch(() => {
|
||||
if (decryptSeqRef.current === seq) setApiKeyInput(config.apiKey ?? "");
|
||||
})
|
||||
.finally(() => {
|
||||
if (decryptSeqRef.current === seq) setIsDecrypting(false);
|
||||
});
|
||||
} else {
|
||||
decryptSeqRef.current++;
|
||||
setApiKeyInput("");
|
||||
setIsDecrypting(false);
|
||||
}
|
||||
}, [config.apiKey, config.providerId]);
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<WebSearchConfig>) => {
|
||||
setWebSearchConfig({ ...configRef.current, ...updates });
|
||||
},
|
||||
[setWebSearchConfig],
|
||||
);
|
||||
|
||||
const handleProviderChange = useCallback(
|
||||
(val: string) => {
|
||||
const providerId = val as WebSearchProviderId;
|
||||
const newPreset = WEB_SEARCH_PROVIDER_PRESETS[providerId];
|
||||
setWebSearchConfig({
|
||||
...configRef.current,
|
||||
providerId,
|
||||
apiKey: undefined,
|
||||
apiHost: newPreset.defaultApiHost || undefined,
|
||||
});
|
||||
setApiKeyInput("");
|
||||
},
|
||||
[setWebSearchConfig],
|
||||
);
|
||||
|
||||
// Sequence counter for blur saves — prevents out-of-order encryption results
|
||||
const blurSeqRef = useRef(0);
|
||||
const handleApiKeyBlur = useCallback(async () => {
|
||||
if (!apiKeyInput.trim()) {
|
||||
blurSeqRef.current++;
|
||||
updateConfig({ apiKey: undefined });
|
||||
return;
|
||||
}
|
||||
const seq = ++blurSeqRef.current;
|
||||
const providerAtBlur = configRef.current.providerId;
|
||||
const encrypted = await encryptField(apiKeyInput.trim());
|
||||
// Only apply if this is still the latest blur and provider hasn't changed
|
||||
if (blurSeqRef.current === seq && configRef.current.providerId === providerAtBlur) {
|
||||
updateConfig({ apiKey: encrypted });
|
||||
}
|
||||
}, [apiKeyInput, updateConfig]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t("ai.webSearch.title")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-1">
|
||||
{/* Enable/Disable */}
|
||||
<SettingRow
|
||||
label={t("ai.webSearch.enable")}
|
||||
description={t("ai.webSearch.enable.description")}
|
||||
>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => updateConfig({ enabled: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-muted-foreground/20 peer-focus-visible:ring-2 peer-focus-visible:ring-ring rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border after:border-gray-300 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary" />
|
||||
</label>
|
||||
</SettingRow>
|
||||
|
||||
{/* Provider */}
|
||||
<SettingRow
|
||||
label={t("ai.webSearch.provider")}
|
||||
description={t("ai.webSearch.provider.description")}
|
||||
>
|
||||
<Select
|
||||
value={config.providerId}
|
||||
options={PROVIDER_OPTIONS}
|
||||
onChange={handleProviderChange}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* API Key (hidden for SearXNG) */}
|
||||
{preset.requiresApiKey && (
|
||||
<SettingRow
|
||||
label={t("ai.webSearch.apiKey")}
|
||||
description={t("ai.webSearch.apiKey.description")}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={isDecrypting ? "" : apiKeyInput}
|
||||
placeholder={isDecrypting ? t("ai.providers.apiKey.decrypting") : t("ai.webSearch.apiKey.placeholder")}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
onBlur={() => void handleApiKeyBlur()}
|
||||
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
disabled={isDecrypting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="p-1.5 rounded hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{/* API Host */}
|
||||
<SettingRow
|
||||
label={t("ai.webSearch.apiHost")}
|
||||
description={
|
||||
config.providerId === "searxng"
|
||||
? t("ai.webSearch.apiHost.searxngDescription")
|
||||
: t("ai.webSearch.apiHost.description")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={config.apiHost ?? preset.defaultApiHost}
|
||||
onChange={(e) => updateConfig({ apiHost: e.target.value || undefined })}
|
||||
placeholder={preset.defaultApiHost || "https://..."}
|
||||
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* Max Results */}
|
||||
<SettingRow
|
||||
label={t("ai.webSearch.maxResults")}
|
||||
description={t("ai.webSearch.maxResults.description")}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={config.maxResults ?? 5}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val) && val >= 1 && val <= 20) {
|
||||
updateConfig({ maxResults: val });
|
||||
}
|
||||
}}
|
||||
min={1}
|
||||
max={20}
|
||||
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -41,6 +41,7 @@ export interface ProviderFormState {
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
defaultModel: string;
|
||||
skipTLSVerify: boolean;
|
||||
}
|
||||
|
||||
export interface FetchedModel {
|
||||
@@ -49,7 +50,8 @@ export interface FetchedModel {
|
||||
}
|
||||
|
||||
export interface FetchBridge {
|
||||
aiFetch?: (url: string, method?: string, headers?: Record<string, string>, body?: string) => Promise<{ ok: boolean; data: string; error?: string }>;
|
||||
aiFetch?: (url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string, skipHostCheck?: boolean, followRedirects?: boolean, skipTLSVerify?: boolean) => Promise<{ ok: boolean; data: string; error?: string }>;
|
||||
aiAllowlistAddHost?: (baseURL: string) => Promise<{ ok: boolean }>;
|
||||
}
|
||||
|
||||
export interface NetcattyAiBridge {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SSHKey } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from '../ui/dropdown';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
|
||||
export type TerminalAuthMethod = 'password' | 'key' | 'certificate';
|
||||
@@ -265,25 +266,34 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button disabled={!isValid} onClick={onSubmit}>
|
||||
{t("terminal.auth.continueSave")}
|
||||
<ChevronDown size={14} className="ml-2" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1 z-50" align="end">
|
||||
<button
|
||||
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
|
||||
onClick={onSubmitWithoutSave ?? onSubmit}
|
||||
<Dropdown>
|
||||
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
||||
<Button
|
||||
disabled={!isValid}
|
||||
onClick={onSubmit}
|
||||
className="rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
|
||||
>
|
||||
{t("terminal.auth.continueSave")}
|
||||
</Button>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
disabled={!isValid}
|
||||
className="px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
|
||||
>
|
||||
{t("common.continue")}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44 p-1 z-50" align="end">
|
||||
<button
|
||||
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
|
||||
onClick={onSubmitWithoutSave ?? onSubmit}
|
||||
disabled={!isValid}
|
||||
>
|
||||
{t("common.continue")}
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
} from "../../../domain/credentials";
|
||||
import { resolveHostAuth } from "../../../domain/sshAuth";
|
||||
|
||||
/** Timeout of distro detection task */
|
||||
const DISTRO_DETECT_TIMEOUT = 8000; // ms
|
||||
|
||||
type TerminalBackendApi = {
|
||||
backendAvailable: () => boolean;
|
||||
telnetAvailable: () => boolean;
|
||||
@@ -68,6 +71,7 @@ export type TerminalSessionStartersContext = {
|
||||
resolvedChainHosts: Host[];
|
||||
sessionId: string;
|
||||
startupCommand?: string;
|
||||
noAutoRun?: boolean;
|
||||
terminalSettings?: TerminalSettings;
|
||||
terminalSettingsRef?: RefObject<TerminalSettings | undefined>;
|
||||
terminalBackend: TerminalBackendApi;
|
||||
@@ -215,7 +219,7 @@ const attachSessionToTerminal = (
|
||||
|
||||
const runDistroDetection = async (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
auth: { username: string; password?: string; key?: SSHKey },
|
||||
auth: { username: string; password?: string; key?: SSHKey; passphrase?: string },
|
||||
) => {
|
||||
if (!ctx.terminalBackend.execAvailable()) return;
|
||||
try {
|
||||
@@ -225,8 +229,9 @@ const runDistroDetection = async (
|
||||
port: ctx.host.port || 22,
|
||||
password: auth.password,
|
||||
privateKey: auth.key?.privateKey,
|
||||
passphrase: auth.passphrase ?? auth.key?.passphrase,
|
||||
command: "cat /etc/os-release 2>/dev/null || uname -a",
|
||||
timeout: 8000,
|
||||
timeout: DISTRO_DETECT_TIMEOUT,
|
||||
});
|
||||
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
|
||||
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
|
||||
@@ -535,8 +540,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
// Guard against stale timers: if the session changed (e.g. user
|
||||
// clicked Start Over quickly), skip to avoid double execution
|
||||
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) return;
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`);
|
||||
if (ctx.onCommandExecuted) {
|
||||
const suffix = ctx.noAutoRun ? '' : '\r';
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`);
|
||||
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
|
||||
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
}
|
||||
}, 600);
|
||||
@@ -573,6 +579,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
username: effectiveUsername,
|
||||
password: usedPassword,
|
||||
key: usedKey,
|
||||
passphrase: effectivePassphrase,
|
||||
}),
|
||||
600,
|
||||
);
|
||||
@@ -656,8 +663,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
const scheduledSessionId = id;
|
||||
setTimeout(() => {
|
||||
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) return;
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`);
|
||||
if (ctx.onCommandExecuted) {
|
||||
const suffix = ctx.noAutoRun ? '' : '\r';
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`);
|
||||
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
|
||||
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
}
|
||||
}, 600);
|
||||
|
||||
@@ -94,6 +94,9 @@ export type CreateXTermRuntimeContext = {
|
||||
|
||||
// Callback when shell reports CWD change via OSC 7
|
||||
onCwdChange?: (cwd: string) => void;
|
||||
|
||||
// Callback when remote requests clipboard read in 'prompt' mode; resolves to user's decision
|
||||
onOsc52ReadRequest?: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
const detectPlatform = (): XTermPlatform => {
|
||||
@@ -384,12 +387,14 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Send the snippet command to the terminal
|
||||
const payload = `${normalizeLineEndings(snippet.command)}\r`;
|
||||
const payload = snippet.noAutoRun
|
||||
? normalizeLineEndings(snippet.command)
|
||||
: `${normalizeLineEndings(snippet.command)}\r`;
|
||||
ctx.terminalBackend.writeToSession(id, payload);
|
||||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||||
ctx.onBroadcastInputRef.current(payload, ctx.sessionId);
|
||||
}
|
||||
if (ctx.onCommandExecuted) {
|
||||
if (!snippet.noAutoRun && ctx.onCommandExecuted) {
|
||||
const cmd = snippet.command.trim();
|
||||
if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
ctx.commandBufferRef.current = "";
|
||||
@@ -614,6 +619,78 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return true; // Indicate we handled the sequence
|
||||
});
|
||||
|
||||
// OSC 52 — clipboard integration
|
||||
// Format: 52;<target>;<base64-data> (write) or 52;<target>;? (query/read)
|
||||
// <target> is typically "c" (clipboard) or "p" (primary selection)
|
||||
// Controlled by terminalSettings.osc52Clipboard: 'off' | 'write-only' | 'read-write'
|
||||
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
|
||||
const settings = ctx.terminalSettingsRef.current;
|
||||
const mode = settings?.osc52Clipboard ?? 'write-only';
|
||||
if (mode === 'off') return true;
|
||||
|
||||
try {
|
||||
const semi = data.indexOf(';');
|
||||
if (semi < 0) return true;
|
||||
const target = data.substring(0, semi);
|
||||
// Only handle clipboard target ('c'); reject unsupported targets like 'p' (PRIMARY)
|
||||
if (target !== 'c' && target !== '') return true;
|
||||
const payload = data.substring(semi + 1);
|
||||
|
||||
if (payload === '?') {
|
||||
// Read request — allowed in read-write mode, or prompt user in prompt mode
|
||||
if (mode !== 'read-write' && mode !== 'prompt') {
|
||||
logger.debug('[XTerm] OSC 52 read request ignored (mode:', mode, ')');
|
||||
return true;
|
||||
}
|
||||
const sessionId = ctx.sessionRef.current;
|
||||
if (!sessionId) return true;
|
||||
// Use Electron bridge as primary, fall back to navigator.clipboard
|
||||
const readClipboard = async (): Promise<string> => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.readClipboardText) return await bridge.readClipboardText();
|
||||
} catch { /* fall through to navigator.clipboard */ }
|
||||
return navigator.clipboard.readText();
|
||||
};
|
||||
const doRead = async () => {
|
||||
// In prompt mode, ask user first
|
||||
if (mode === 'prompt') {
|
||||
const allowed = ctx.onOsc52ReadRequest ? await ctx.onOsc52ReadRequest() : false;
|
||||
if (!allowed) {
|
||||
logger.debug('[XTerm] OSC 52 read denied by user');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const text = await readClipboard();
|
||||
// Chunked base64 encoding to avoid stack overflow on large payloads
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i += 8192) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
||||
}
|
||||
const b64 = btoa(binary);
|
||||
ctx.terminalBackend.writeToSession(sessionId, `\x1b]52;${target};${b64}\x07`);
|
||||
};
|
||||
doRead().catch((err) => {
|
||||
logger.warn('[XTerm] OSC 52 clipboard read failed:', err);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Write: payload is base64-encoded UTF-8 text
|
||||
const binary = atob(payload);
|
||||
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
navigator.clipboard.writeText(text).catch((err) => {
|
||||
logger.warn('[XTerm] OSC 52 clipboard write failed:', err);
|
||||
});
|
||||
logger.debug('[XTerm] OSC 52 clipboard write', { length: text.length });
|
||||
} catch (err) {
|
||||
logger.warn('[XTerm] Failed to handle OSC 52:', err);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
let resizeTimeout: NodeJS.Timeout | null = null;
|
||||
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
|
||||
term.onResize(({ cols, rows }) => {
|
||||
@@ -639,6 +716,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
cleanupMiddleClick?.();
|
||||
keywordHighlighter.dispose();
|
||||
osc7Disposable.dispose();
|
||||
osc52Disposable.dispose();
|
||||
try {
|
||||
term.dispose();
|
||||
} catch (err) {
|
||||
|
||||
@@ -12,7 +12,7 @@ const ScrollArea = React.forwardRef<
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full max-h-[inherit] rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
|
||||
@@ -149,6 +149,7 @@ export interface Snippet {
|
||||
package?: string; // package path
|
||||
targets?: string[]; // host ids
|
||||
shortkey?: string; // Keyboard shortcut to send this snippet in terminal (e.g., "F1", "Ctrl + F1")
|
||||
noAutoRun?: boolean; // If true, paste command without executing (no trailing Enter)
|
||||
}
|
||||
|
||||
export interface TerminalLine {
|
||||
@@ -434,6 +435,9 @@ export interface TerminalSettings {
|
||||
// Paste
|
||||
disableBracketedPaste: boolean; // Disable bracketed paste mode (avoid ^[[200~ artifacts)
|
||||
|
||||
// Clipboard
|
||||
osc52Clipboard: 'off' | 'write-only' | 'read-write' | 'prompt'; // OSC-52 clipboard access: off, write-only (default), read-write, or prompt on read
|
||||
|
||||
// Rendering
|
||||
rendererType: 'auto' | 'webgl' | 'canvas'; // Terminal renderer: auto (detect based on hardware), webgl, or canvas
|
||||
}
|
||||
@@ -541,6 +545,7 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
showServerStats: true, // Show server stats by default
|
||||
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
|
||||
disableBracketedPaste: false, // Bracketed paste enabled by default
|
||||
osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default
|
||||
rendererType: 'auto', // Auto-detect best renderer based on hardware
|
||||
};
|
||||
|
||||
@@ -582,6 +587,7 @@ export interface TerminalSession {
|
||||
status: 'connecting' | 'connected' | 'disconnected';
|
||||
workspaceId?: string;
|
||||
startupCommand?: string; // Command to run after connection (for snippet runner)
|
||||
noAutoRun?: boolean; // If true, paste command without auto-executing
|
||||
// Connection-time protocol overrides (used instead of looking up from hosts)
|
||||
protocol?: 'ssh' | 'telnet' | 'local' | 'serial';
|
||||
port?: number;
|
||||
|
||||
@@ -172,13 +172,30 @@ export interface SyncPayload {
|
||||
|
||||
// Settings
|
||||
settings?: {
|
||||
// Theme & Appearance
|
||||
theme?: 'light' | 'dark' | 'system';
|
||||
accentColor?: string;
|
||||
lightUiThemeId?: string;
|
||||
darkUiThemeId?: string;
|
||||
accentMode?: 'theme' | 'custom';
|
||||
customAccent?: string;
|
||||
uiFontFamilyId?: string;
|
||||
uiLanguage?: string;
|
||||
customCSS?: string;
|
||||
// Terminal
|
||||
terminalTheme?: string;
|
||||
terminalFontFamily?: string;
|
||||
terminalFontSize?: number;
|
||||
hotkeyScheme?: string;
|
||||
terminalSettings?: Record<string, unknown>;
|
||||
customTerminalThemes?: Array<{ id: string; name: string; colors: Record<string, string> }>;
|
||||
// Keyboard
|
||||
customKeyBindings?: Record<string, { mac?: string; pc?: string }>;
|
||||
// Editor
|
||||
editorWordWrap?: boolean;
|
||||
// SFTP
|
||||
sftpDoubleClickBehavior?: 'open' | 'transfer';
|
||||
sftpAutoSync?: boolean;
|
||||
sftpShowHiddenFiles?: boolean;
|
||||
sftpUseCompressedUpload?: boolean;
|
||||
};
|
||||
|
||||
// Sync metadata
|
||||
|
||||
@@ -16,6 +16,28 @@ import type {
|
||||
SSHKey,
|
||||
} from './models';
|
||||
import type { SyncPayload } from './sync';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input types
|
||||
@@ -38,6 +60,157 @@ export interface SyncPayloadImporters {
|
||||
importVaultData: (jsonString: string) => void;
|
||||
/** Import port-forwarding rules (lives outside the vault hook). */
|
||||
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
|
||||
/** Called after synced settings have been written to localStorage. */
|
||||
onSettingsApplied?: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings sync helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Terminal settings keys that are safe to sync (platform-agnostic). */
|
||||
const SYNCABLE_TERMINAL_KEYS = [
|
||||
'scrollback', 'drawBoldInBrightColors', 'fontLigatures', 'fontWeight', 'fontWeightBold',
|
||||
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
||||
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Collect all syncable settings from localStorage.
|
||||
*/
|
||||
export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
const settings: SyncPayload['settings'] = {};
|
||||
|
||||
// Theme & Appearance
|
||||
const theme = localStorageAdapter.readString(STORAGE_KEY_THEME);
|
||||
if (theme === 'light' || theme === 'dark' || theme === 'system') settings.theme = theme;
|
||||
const lightUi = localStorageAdapter.readString(STORAGE_KEY_UI_THEME_LIGHT);
|
||||
if (lightUi) settings.lightUiThemeId = lightUi;
|
||||
const darkUi = localStorageAdapter.readString(STORAGE_KEY_UI_THEME_DARK);
|
||||
if (darkUi) settings.darkUiThemeId = darkUi;
|
||||
const accentMode = localStorageAdapter.readString(STORAGE_KEY_ACCENT_MODE);
|
||||
if (accentMode === 'theme' || accentMode === 'custom') settings.accentMode = accentMode;
|
||||
const accent = localStorageAdapter.readString(STORAGE_KEY_COLOR);
|
||||
if (accent) settings.customAccent = accent;
|
||||
const uiFont = localStorageAdapter.readString(STORAGE_KEY_UI_FONT_FAMILY);
|
||||
if (uiFont) settings.uiFontFamilyId = uiFont;
|
||||
const lang = localStorageAdapter.readString(STORAGE_KEY_UI_LANGUAGE);
|
||||
if (lang) settings.uiLanguage = lang;
|
||||
const css = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS);
|
||||
if (css != null) settings.customCSS = css;
|
||||
|
||||
// Terminal
|
||||
const termTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
|
||||
if (termTheme) settings.terminalTheme = termTheme;
|
||||
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
if (termFont) settings.terminalFontFamily = termFont;
|
||||
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
if (termSize != null) settings.terminalFontSize = termSize;
|
||||
|
||||
// Terminal settings (syncable subset only)
|
||||
const termSettingsRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
|
||||
if (termSettingsRaw) {
|
||||
try {
|
||||
const full = JSON.parse(termSettingsRaw);
|
||||
const subset: Record<string, unknown> = {};
|
||||
for (const key of SYNCABLE_TERMINAL_KEYS) {
|
||||
if (key in full) subset[key] = full[key];
|
||||
}
|
||||
if (Object.keys(subset).length > 0) settings.terminalSettings = subset;
|
||||
} catch { /* ignore corrupt data */ }
|
||||
}
|
||||
|
||||
// Custom terminal themes
|
||||
const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES);
|
||||
if (customThemesRaw) {
|
||||
try {
|
||||
const parsed = JSON.parse(customThemesRaw);
|
||||
if (Array.isArray(parsed)) settings.customTerminalThemes = parsed;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
const kb = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
|
||||
if (kb) {
|
||||
try {
|
||||
settings.customKeyBindings = JSON.parse(kb);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Editor
|
||||
const wordWrap = localStorageAdapter.readString(STORAGE_KEY_EDITOR_WORD_WRAP);
|
||||
if (wordWrap === 'true' || wordWrap === 'false') settings.editorWordWrap = wordWrap === 'true';
|
||||
|
||||
// SFTP
|
||||
const dblClick = localStorageAdapter.readString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
||||
if (dblClick === 'open' || dblClick === 'transfer') settings.sftpDoubleClickBehavior = dblClick;
|
||||
const autoSync = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_SYNC);
|
||||
if (autoSync === 'true' || autoSync === 'false') settings.sftpAutoSync = autoSync === 'true';
|
||||
const hidden = localStorageAdapter.readString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
|
||||
if (hidden === 'true' || hidden === 'false') settings.sftpShowHiddenFiles = hidden === 'true';
|
||||
const compress = localStorageAdapter.readString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply synced settings to localStorage. Merges terminal settings
|
||||
* to preserve platform-specific fields.
|
||||
*/
|
||||
export function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
|
||||
// Theme & Appearance
|
||||
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
|
||||
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
|
||||
if (settings.darkUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, settings.darkUiThemeId);
|
||||
if (settings.accentMode != null) localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, settings.accentMode);
|
||||
if (settings.customAccent != null) localStorageAdapter.writeString(STORAGE_KEY_COLOR, settings.customAccent);
|
||||
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
|
||||
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
|
||||
if (settings.customCSS != null) localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, settings.customCSS);
|
||||
|
||||
// Terminal
|
||||
if (settings.terminalTheme != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, settings.terminalTheme);
|
||||
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
|
||||
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
|
||||
|
||||
// Terminal settings — merge with existing to preserve platform-specific keys
|
||||
if (settings.terminalSettings) {
|
||||
let existing: Record<string, unknown> = {};
|
||||
const raw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
|
||||
if (raw) {
|
||||
try { existing = JSON.parse(raw); } catch { /* ignore */ }
|
||||
}
|
||||
const merged = { ...existing };
|
||||
for (const key of SYNCABLE_TERMINAL_KEYS) {
|
||||
if (key in settings.terminalSettings) {
|
||||
merged[key] = settings.terminalSettings[key];
|
||||
}
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
|
||||
}
|
||||
|
||||
// Custom terminal themes
|
||||
if (settings.customTerminalThemes != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_THEMES, JSON.stringify(settings.customTerminalThemes));
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
if (settings.customKeyBindings != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_KEY_BINDINGS, JSON.stringify(settings.customKeyBindings));
|
||||
}
|
||||
|
||||
// Editor
|
||||
if (settings.editorWordWrap != null) localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(settings.editorWordWrap));
|
||||
|
||||
// SFTP
|
||||
if (settings.sftpDoubleClickBehavior != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, settings.sftpDoubleClickBehavior);
|
||||
if (settings.sftpAutoSync != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, String(settings.sftpAutoSync));
|
||||
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
|
||||
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -64,6 +237,7 @@ export function buildSyncPayload(
|
||||
snippetPackages: vault.snippetPackages,
|
||||
knownHosts: vault.knownHosts,
|
||||
portForwardingRules,
|
||||
settings: collectSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -105,4 +279,10 @@ export function applySyncPayload(
|
||||
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(payload.portForwardingRules);
|
||||
}
|
||||
|
||||
// Apply synced settings
|
||||
if (payload.settings) {
|
||||
applySyncableSettings(payload.settings);
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ function execViaChannel(sshClient, command, options) {
|
||||
return;
|
||||
}
|
||||
if (!execStream) {
|
||||
resolve({ ok: false, output: 'Failed to create exec stream', exitCode: 1 });
|
||||
resolve({ ok: false, error: 'Failed to create exec stream', exitCode: 1 });
|
||||
return;
|
||||
}
|
||||
let stdout = "";
|
||||
@@ -184,7 +184,12 @@ function execViaChannel(sshClient, command, options) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timeoutId);
|
||||
resolve({ ok: code === 0, stdout, stderr, exitCode: code });
|
||||
// code is null when SSH disconnects or process is signal-terminated
|
||||
if (code == null) {
|
||||
resolve({ ok: false, stdout, stderr, exitCode: -1, error: "Command terminated unexpectedly (connection lost or signal)" });
|
||||
} else {
|
||||
resolve({ ok: code === 0, stdout, stderr, exitCode: code });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,6 +60,9 @@ const acpActiveStreams = new Map();
|
||||
// ── Provider registry (synced from renderer, keys stay encrypted) ──
|
||||
const ENC_PREFIX = "enc:v1:";
|
||||
let providerConfigs = [];
|
||||
// Web search config (synced from renderer — apiKey stays encrypted, decrypted on use)
|
||||
let webSearchApiHost = null;
|
||||
let webSearchApiKeyEncrypted = null;
|
||||
|
||||
/**
|
||||
* Decrypt an API key using Electron's safeStorage.
|
||||
@@ -94,8 +97,17 @@ function resolveProviderApiKey(providerId) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Check if TLS verification should be skipped for a given provider. */
|
||||
function shouldSkipTLSVerify(providerId) {
|
||||
if (!providerId) return false;
|
||||
const config = providerConfigs.find(p => p.id === providerId);
|
||||
return config?.skipTLSVerify === true;
|
||||
}
|
||||
|
||||
/** Placeholder token used by the renderer to avoid sending real API keys over IPC. */
|
||||
const API_KEY_PLACEHOLDER = "__IPC_SECURED__";
|
||||
/** Placeholder for web search API key — replaced in main process before HTTP request. */
|
||||
const WEB_SEARCH_KEY_PLACEHOLDER = "__WEB_SEARCH_KEY__";
|
||||
|
||||
/**
|
||||
* Replace the API key placeholder in HTTP headers and URL with the real decrypted key.
|
||||
@@ -165,25 +177,51 @@ function init(deps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that an IPC event sender is the main window's webContents.
|
||||
* Validate that an IPC event sender is the main window.
|
||||
* Returns true if valid, false otherwise.
|
||||
*/
|
||||
function validateSender(event) {
|
||||
// Lazily resolve mainWebContentsId if not yet set
|
||||
if (mainWebContentsId == null) {
|
||||
try {
|
||||
const windowManager = require("./windowManager.cjs");
|
||||
const mainWin = windowManager.getMainWindow?.();
|
||||
if (mainWin && !mainWin.isDestroyed?.()) {
|
||||
mainWebContentsId = mainWin.webContents?.id ?? null;
|
||||
}
|
||||
} catch {
|
||||
// Cannot resolve — reject for safety
|
||||
return false;
|
||||
return _validateSenderImpl(event, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that an IPC event sender is a trusted window (main or settings).
|
||||
* Use this for handlers that the settings window legitimately needs access to
|
||||
* (e.g. model listing, provider sync, Codex login, agent discovery).
|
||||
*/
|
||||
function validateSenderOrSettings(event) {
|
||||
return _validateSenderImpl(event, true);
|
||||
}
|
||||
|
||||
function _validateSenderImpl(event, allowSettings) {
|
||||
try {
|
||||
const windowManager = require("./windowManager.cjs");
|
||||
|
||||
// Always resolve the current main window id to handle window recreation
|
||||
const mainWin = windowManager.getMainWindow?.();
|
||||
if (mainWin && !mainWin.isDestroyed?.()) {
|
||||
mainWebContentsId = mainWin.webContents?.id ?? null;
|
||||
}
|
||||
|
||||
const senderId = event.sender?.id;
|
||||
if (senderId == null) return false;
|
||||
|
||||
// Allow main window
|
||||
if (mainWebContentsId != null && senderId === mainWebContentsId) return true;
|
||||
|
||||
// Allow settings window only for designated handlers
|
||||
if (allowSettings) {
|
||||
const settingsWin = windowManager.getSettingsWindow?.();
|
||||
if (settingsWin && !settingsWin.isDestroyed?.()) {
|
||||
if (senderId === settingsWin.webContents?.id) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
// Cannot resolve — reject for safety
|
||||
return false;
|
||||
}
|
||||
if (mainWebContentsId == null) return false;
|
||||
return event.sender?.id === mainWebContentsId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,7 +233,7 @@ function validateSender(event) {
|
||||
* renderer can construct a Response with the real status. Data continues to
|
||||
* flow via stream:data / stream:end / stream:error IPC events.
|
||||
*/
|
||||
function streamRequest(url, options, event, requestId) {
|
||||
function streamRequest(url, options, event, requestId, skipTLS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsedUrl = new URL(url);
|
||||
const isHttps = parsedUrl.protocol === "https:";
|
||||
@@ -214,29 +252,37 @@ function streamRequest(url, options, event, requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const req = lib.request(
|
||||
parsedUrl,
|
||||
{
|
||||
const reqOpts = {
|
||||
method: options.method || "POST",
|
||||
headers: options.headers || {},
|
||||
timeout: 120000, // 2 min connection timeout
|
||||
},
|
||||
};
|
||||
if (skipTLS && isHttps) reqOpts.rejectUnauthorized = false;
|
||||
|
||||
const req = lib.request(parsedUrl, reqOpts,
|
||||
(res) => {
|
||||
const statusCode = res.statusCode || 0;
|
||||
const statusText = res.statusMessage || "";
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
// Resolve immediately with error status so the renderer sees it
|
||||
resolve({ statusCode, statusText });
|
||||
|
||||
// Read the error body before resolving so we can include it in the response
|
||||
let errorBody = "";
|
||||
res.on("data", (chunk) => { errorBody += chunk.toString(); });
|
||||
res.on("end", () => {
|
||||
// Try to extract error message from JSON response (OpenAI-compatible format)
|
||||
let errorDetail = statusText;
|
||||
try {
|
||||
const parsed = JSON.parse(errorBody);
|
||||
errorDetail = parsed?.error?.message || parsed?.message || parsed?.detail || errorBody.slice(0, 500);
|
||||
} catch {
|
||||
if (errorBody.trim()) errorDetail = errorBody.slice(0, 500);
|
||||
}
|
||||
safeSend(event.sender, "netcatty:ai:stream:error", {
|
||||
requestId,
|
||||
error: `HTTP ${statusCode}: ${errorBody}`,
|
||||
error: `HTTP ${statusCode}: ${errorDetail}`,
|
||||
});
|
||||
activeStreams.delete(requestId);
|
||||
resolve({ statusCode, statusText: `${statusCode} ${errorDetail}` });
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -331,7 +377,7 @@ function streamRequest(url, options, event, requestId) {
|
||||
function registerHandlers(ipcMain) {
|
||||
// ── Provider config sync (renderer → main, keys stay encrypted) ──
|
||||
ipcMain.handle("netcatty:ai:sync-providers", async (event, { providers }) => {
|
||||
if (!validateSender(event)) return { ok: false };
|
||||
if (!validateSenderOrSettings(event)) return { ok: false };
|
||||
if (Array.isArray(providers)) {
|
||||
providerConfigs = providers;
|
||||
rebuildProviderFetchHosts();
|
||||
@@ -339,12 +385,107 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// ── Web search config sync (renderer → main, for fetch allowlist + key decryption) ──
|
||||
ipcMain.handle("netcatty:ai:sync-web-search", async (event, { apiHost, apiKey }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false };
|
||||
webSearchApiHost = typeof apiHost === "string" ? apiHost : null;
|
||||
webSearchApiKeyEncrypted = typeof apiKey === "string" ? apiKey : null;
|
||||
rebuildProviderFetchHosts();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/**
|
||||
* Inject the decrypted web search API key into request headers.
|
||||
* Replaces __WEB_SEARCH_KEY__ placeholder, similar to __IPC_SECURED__ for providers.
|
||||
*/
|
||||
function injectWebSearchKeyIntoHeaders(headers) {
|
||||
if (!webSearchApiKeyEncrypted || !headers) return headers;
|
||||
const realKey = decryptApiKeyValue(webSearchApiKeyEncrypted);
|
||||
if (!realKey) return headers;
|
||||
const patched = {};
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
patched[k] = typeof v === "string" ? v.replace(WEB_SEARCH_KEY_PLACEHOLDER, realKey) : v;
|
||||
}
|
||||
return patched;
|
||||
}
|
||||
|
||||
// Temporarily add a host to the fetch allowlist (used by settings model listing).
|
||||
// Entries are auto-removed after 30 seconds unless they belong to a synced provider.
|
||||
const TEMP_ALLOWLIST_TTL = 30_000;
|
||||
// Track temporarily added entries so cleanup can distinguish them from synced ones
|
||||
const tempAllowedHosts = new Set();
|
||||
const tempAllowedPorts = new Set();
|
||||
|
||||
/** Check if a host is owned by a currently synced provider config */
|
||||
function isHostInProviderConfigs(host) {
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
try { if (new URL(config.baseURL).hostname === host) return true; } catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Check if a localhost port is owned by a currently synced provider config */
|
||||
function isPortInProviderConfigs(port) {
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
try {
|
||||
const p = new URL(config.baseURL);
|
||||
if ((p.hostname === "localhost" || p.hostname === "127.0.0.1") &&
|
||||
Number(p.port || (p.protocol === "https:" ? 443 : 80)) === port) return true;
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
ipcMain.handle("netcatty:ai:allowlist:add-host", async (event, { baseURL }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
if (typeof baseURL !== "string") return { ok: false, error: "baseURL must be a string" };
|
||||
try {
|
||||
const parsed = new URL(baseURL);
|
||||
const host = parsed.hostname;
|
||||
if (host === "localhost" || host === "127.0.0.1") {
|
||||
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
|
||||
if (!ALLOWED_LOCALHOST_PORTS.has(port)) {
|
||||
ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
tempAllowedPorts.add(port);
|
||||
setTimeout(() => {
|
||||
// Only remove if still temporary (not built-in and not synced by a provider)
|
||||
if (!BUILTIN_LOCALHOST_PORTS.includes(port) && !isPortInProviderConfigs(port)) {
|
||||
ALLOWED_LOCALHOST_PORTS.delete(port);
|
||||
}
|
||||
tempAllowedPorts.delete(port);
|
||||
}, TEMP_ALLOWLIST_TTL);
|
||||
}
|
||||
} else {
|
||||
if (!providerFetchHosts.has(host)) {
|
||||
providerFetchHosts.add(host);
|
||||
tempAllowedHosts.add(host);
|
||||
setTimeout(() => {
|
||||
// Only remove if not owned by a synced provider config
|
||||
if (!isHostInProviderConfigs(host)) {
|
||||
providerFetchHosts.delete(host);
|
||||
}
|
||||
tempAllowedHosts.delete(host);
|
||||
}, TEMP_ALLOWLIST_TTL);
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: "Invalid URL" };
|
||||
}
|
||||
});
|
||||
|
||||
// URL allowlist: only permit requests to known AI provider domains + HTTPS
|
||||
const BUILTIN_FETCH_HOSTS = new Set([
|
||||
"api.openai.com",
|
||||
"api.anthropic.com",
|
||||
"generativelanguage.googleapis.com",
|
||||
"openrouter.ai",
|
||||
// Web search providers
|
||||
"api.tavily.com",
|
||||
"api.exa.ai",
|
||||
"api.bochaai.com",
|
||||
"open.bigmodel.cn",
|
||||
]);
|
||||
// Dynamically populated from configured provider baseURLs
|
||||
const providerFetchHosts = new Set();
|
||||
@@ -358,6 +499,9 @@ function registerHandlers(ipcMain) {
|
||||
// Reset localhost ports to built-in defaults, then add provider-configured ones
|
||||
ALLOWED_LOCALHOST_PORTS.clear();
|
||||
for (const port of BUILTIN_LOCALHOST_PORTS) ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
// Re-add any still-active temporary entries so a sync doesn't wipe them
|
||||
for (const host of tempAllowedHosts) providerFetchHosts.add(host);
|
||||
for (const port of tempAllowedPorts) ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
try {
|
||||
@@ -374,6 +518,19 @@ function registerHandlers(ipcMain) {
|
||||
// Invalid URL in config — skip
|
||||
}
|
||||
}
|
||||
// Add web search apiHost if configured (e.g. SearXNG self-hosted instance)
|
||||
if (webSearchApiHost) {
|
||||
try {
|
||||
const parsed = new URL(webSearchApiHost);
|
||||
const host = parsed.hostname;
|
||||
if (host === "localhost" || host === "127.0.0.1") {
|
||||
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
|
||||
ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
} else {
|
||||
providerFetchHosts.add(host);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Allowed localhost ports to prevent SSRF (Issue #9)
|
||||
@@ -389,16 +546,69 @@ function registerHandlers(ipcMain) {
|
||||
8888, // Common local dev
|
||||
];
|
||||
const ALLOWED_LOCALHOST_PORTS = new Set(BUILTIN_LOCALHOST_PORTS);
|
||||
function isAllowedFetchUrl(urlString) {
|
||||
// RFC1918 / link-local / loopback / IPv6 private ranges — used by SSRF guard
|
||||
function isPrivateIp(ip) {
|
||||
if (!ip) return false;
|
||||
// Strip IPv6 brackets that URL.hostname may include
|
||||
const cleaned = ip.replace(/^\[|\]$/g, "");
|
||||
if (cleaned === "::1" || cleaned === "0.0.0.0" || cleaned === "::") return true;
|
||||
// IPv6 private ranges: fc00::/7 (unique local), fe80::/10 (link-local), ::ffff:127.x (mapped loopback)
|
||||
const lower = cleaned.toLowerCase();
|
||||
if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // fc00::/7
|
||||
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true; // fe80::/10
|
||||
if (lower.startsWith("::ffff:")) {
|
||||
// IPv4-mapped IPv6 — extract IPv4 portion and check
|
||||
const v4 = lower.slice(7);
|
||||
return isPrivateIp(v4);
|
||||
}
|
||||
// IPv4
|
||||
const parts = cleaned.split(".");
|
||||
if (parts.length === 4 && parts.every(p => /^\d+$/.test(p))) {
|
||||
const [a, b] = parts.map(Number);
|
||||
if (a === 10) return true; // 10.0.0.0/8
|
||||
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
|
||||
if (a === 192 && b === 168) return true; // 192.168.0.0/16
|
||||
if (a === 127) return true; // 127.0.0.0/8
|
||||
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
|
||||
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT (Tailscale etc.)
|
||||
if (a === 0) return true; // 0.0.0.0/8
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPrivateHost(hostname) {
|
||||
if (hostname === "localhost") return true;
|
||||
// metadata endpoints (AWS, GCP, Azure)
|
||||
if (hostname === "metadata.google.internal") return true;
|
||||
return isPrivateIp(hostname);
|
||||
}
|
||||
|
||||
function isAllowedFetchUrl(urlString, skipHostCheck) {
|
||||
try {
|
||||
const parsed = new URL(urlString);
|
||||
// Allow localhost/127.0.0.1 only on known ports (e.g. Ollama)
|
||||
// Always block private/internal hosts when skipHostCheck is set (SSRF protection)
|
||||
if (skipHostCheck) {
|
||||
if (isPrivateHost(parsed.hostname)) return false;
|
||||
// Require HTTPS for skipHostCheck requests
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
return true;
|
||||
}
|
||||
// Allow localhost/127.0.0.1 only on known ports (e.g. Ollama) — normal fetch path only
|
||||
if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") {
|
||||
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
|
||||
return ALLOWED_LOCALHOST_PORTS.has(port);
|
||||
}
|
||||
// Require HTTPS for remote hosts
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
// Require HTTPS for remote hosts; allow HTTP only for the configured web search apiHost
|
||||
// (e.g. self-hosted SearXNG at http://searxng.lan:8080 or http://192.168.x.x)
|
||||
if (parsed.protocol !== "https:") {
|
||||
if (!webSearchApiHost) return false;
|
||||
try {
|
||||
const wsHost = new URL(webSearchApiHost).hostname;
|
||||
if (parsed.hostname !== wsHost) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Check built-in + provider-configured host allowlist
|
||||
if (BUILTIN_FETCH_HOSTS.has(parsed.hostname)) return true;
|
||||
if (providerFetchHosts.has(parsed.hostname)) return true;
|
||||
@@ -439,7 +649,8 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: false, error: "URL host is not in the allowed list" };
|
||||
}
|
||||
|
||||
const { statusCode, statusText } = await streamRequest(resolvedUrl, { method: "POST", headers: resolvedHeaders, body }, event, requestId);
|
||||
const skipTLS = shouldSkipTLSVerify(providerId);
|
||||
const { statusCode, statusText } = await streamRequest(resolvedUrl, { method: "POST", headers: resolvedHeaders, body }, event, requestId, skipTLS);
|
||||
return { ok: true, statusCode, statusText };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
@@ -447,7 +658,8 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
|
||||
// Cancel an active stream
|
||||
ipcMain.handle("netcatty:ai:chat:cancel", async (_event, { requestId }) => {
|
||||
ipcMain.handle("netcatty:ai:chat:cancel", async (event, { requestId }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const controller = activeStreams.get(requestId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
@@ -458,16 +670,17 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
|
||||
// Non-streaming request (for model listing, validation, etc.)
|
||||
ipcMain.handle("netcatty:ai:fetch", async (event, { url, method, headers, body, providerId }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
if (!validateSender(event)) {
|
||||
ipcMain.handle("netcatty:ai:fetch", async (event, { url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify }) => {
|
||||
// Validate IPC sender — settings window needs this for model listing
|
||||
if (!validateSenderOrSettings(event)) {
|
||||
return { ok: false, status: 0, data: "", error: "Unauthorized IPC sender" };
|
||||
}
|
||||
|
||||
// Inject real API key if providerId is given (replaces placeholder in headers/URL)
|
||||
const patched = injectApiKeyIntoRequest(url, headers, providerId);
|
||||
const resolvedUrl = patched.url;
|
||||
const resolvedHeaders = patched.headers;
|
||||
// Also inject web search API key if placeholder is present
|
||||
const resolvedHeaders = injectWebSearchKeyIntoHeaders(patched.headers);
|
||||
|
||||
// Validate URL: block non-HTTP(S) schemes and internal network access
|
||||
try {
|
||||
@@ -480,53 +693,71 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: false, status: 0, data: "", error: "Invalid URL" };
|
||||
}
|
||||
|
||||
// Check URL against allowed hosts (server-side allowlist only)
|
||||
if (!isAllowedFetchUrl(resolvedUrl)) {
|
||||
// Check URL against allowed hosts; skipHostCheck allows public HTTPS but still blocks private/internal
|
||||
if (!isAllowedFetchUrl(resolvedUrl, !!skipHostCheck)) {
|
||||
return { ok: false, status: 0, data: "", error: "URL host is not in the allowed list" };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const parsedUrl = new URL(resolvedUrl);
|
||||
const isHttps = parsedUrl.protocol === "https:";
|
||||
const lib = isHttps ? https : http;
|
||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB safety limit
|
||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB safety limit
|
||||
const MAX_REDIRECTS = followRedirects ? 5 : 0;
|
||||
|
||||
const req = lib.request(
|
||||
parsedUrl,
|
||||
{ method: method || "GET", headers: resolvedHeaders || {}, timeout: 30000 },
|
||||
(res) => {
|
||||
let data = "";
|
||||
let totalSize = 0;
|
||||
res.on("data", (chunk) => {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_RESPONSE_SIZE) {
|
||||
req.destroy();
|
||||
resolve({ ok: false, status: 0, data: "", error: "Response body exceeded maximum size (10MB)" });
|
||||
function doFetch(fetchUrl, redirectsLeft) {
|
||||
return new Promise((resolve) => {
|
||||
const parsedUrl = new URL(fetchUrl);
|
||||
const isHttps = parsedUrl.protocol === "https:";
|
||||
const lib = isHttps ? https : http;
|
||||
|
||||
const fetchOpts = { method: method || "GET", headers: resolvedHeaders || {}, timeout: 30000 };
|
||||
if ((skipTLSVerify || shouldSkipTLSVerify(providerId)) && isHttps) fetchOpts.rejectUnauthorized = false;
|
||||
const req = lib.request(parsedUrl, fetchOpts,
|
||||
(res) => {
|
||||
// Handle redirects
|
||||
if (redirectsLeft > 0 && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
const location = new URL(res.headers.location, fetchUrl).href;
|
||||
res.resume(); // drain the response
|
||||
// Revalidate the redirect target hostname (blocks localhost/metadata etc.)
|
||||
if (!isAllowedFetchUrl(location, !!skipHostCheck)) {
|
||||
resolve({ ok: false, status: 0, data: "", error: "Redirect target is not allowed" });
|
||||
return;
|
||||
}
|
||||
resolve(doFetch(location, redirectsLeft - 1));
|
||||
return;
|
||||
}
|
||||
data += chunk.toString();
|
||||
});
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
status: res.statusCode,
|
||||
data,
|
||||
let data = "";
|
||||
let totalSize = 0;
|
||||
res.on("data", (chunk) => {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_RESPONSE_SIZE) {
|
||||
req.destroy();
|
||||
resolve({ ok: false, status: 0, data: "", error: "Response body exceeded maximum size (10MB)" });
|
||||
return;
|
||||
}
|
||||
data += chunk.toString();
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
status: res.statusCode,
|
||||
data,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on("error", (err) => {
|
||||
resolve({ ok: false, status: 0, data: "", error: err.message });
|
||||
});
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
resolve({ ok: false, status: 0, data: "", error: "Request timeout" });
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
resolve({ ok: false, status: 0, data: "", error: err.message });
|
||||
});
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
resolve({ ok: false, status: 0, data: "", error: "Request timeout" });
|
||||
});
|
||||
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
return doFetch(resolvedUrl, MAX_REDIRECTS);
|
||||
});
|
||||
|
||||
// Execute a command on a terminal session (for Catty Agent)
|
||||
@@ -840,7 +1071,8 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
|
||||
// Discover external agents from PATH, plus the bundled Codex CLI if present.
|
||||
ipcMain.handle("netcatty:ai:agents:discover", async () => {
|
||||
ipcMain.handle("netcatty:ai:agents:discover", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const agents = [];
|
||||
const knownAgents = [
|
||||
{
|
||||
@@ -909,7 +1141,8 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
|
||||
// Resolve a CLI binary path (auto-detect or validate custom path)
|
||||
ipcMain.handle("netcatty:ai:resolve-cli", async (_event, { command, customPath }) => {
|
||||
ipcMain.handle("netcatty:ai:resolve-cli", async (event, { command, customPath }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const shellEnv = await getShellEnv();
|
||||
let resolvedPath = null;
|
||||
|
||||
@@ -937,7 +1170,8 @@ function registerHandlers(ipcMain) {
|
||||
return { path: resolvedPath, version, available: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:get-integration", async () => {
|
||||
ipcMain.handle("netcatty:ai:codex:get-integration", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const result = await runCodexCli(["login", "status"]);
|
||||
const rawOutput = [result.stdout, result.stderr]
|
||||
@@ -987,7 +1221,8 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:start-login", async () => {
|
||||
ipcMain.handle("netcatty:ai:codex:start-login", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const existingSession = getActiveCodexLoginSession();
|
||||
if (existingSession) {
|
||||
return { ok: true, session: toCodexLoginSessionResponse(existingSession) };
|
||||
@@ -1051,7 +1286,8 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:get-login-session", async (_event, { sessionId }) => {
|
||||
ipcMain.handle("netcatty:ai:codex:get-login-session", async (event, { sessionId }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const session = codexLoginSessions.get(sessionId);
|
||||
if (!session) {
|
||||
return { ok: false, error: "Codex login session not found" };
|
||||
@@ -1059,7 +1295,8 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: true, session: toCodexLoginSessionResponse(session) };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:cancel-login", async (_event, { sessionId }) => {
|
||||
ipcMain.handle("netcatty:ai:codex:cancel-login", async (event, { sessionId }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const session = codexLoginSessions.get(sessionId);
|
||||
if (!session) {
|
||||
return { ok: true, found: false };
|
||||
@@ -1075,7 +1312,8 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: true, found: true, session: toCodexLoginSessionResponse(session) };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:logout", async () => {
|
||||
ipcMain.handle("netcatty:ai:codex:logout", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const logoutResult = await runCodexCli(["logout"]);
|
||||
invalidateCodexValidationCache();
|
||||
@@ -1249,12 +1487,14 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
// ── MCP Server session metadata ──
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:update-sessions", async (_event, { sessions: sessionList, chatSessionId }) => {
|
||||
ipcMain.handle("netcatty:ai:mcp:update-sessions", async (event, { sessions: sessionList, chatSessionId }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
mcpServerBridge.updateSessionMetadata(sessionList || [], chatSessionId);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:set-command-blocklist", async (_event, { blocklist }) => {
|
||||
ipcMain.handle("netcatty:ai:mcp:set-command-blocklist", async (event, { blocklist }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
// Validate: must be an array of strings, each a valid regex pattern
|
||||
if (!Array.isArray(blocklist)) {
|
||||
return { ok: false, error: "blocklist must be an array" };
|
||||
@@ -1273,7 +1513,8 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:set-command-timeout", async (_event, { timeout }) => {
|
||||
ipcMain.handle("netcatty:ai:mcp:set-command-timeout", async (event, { timeout }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const value = Number(timeout);
|
||||
if (!Number.isFinite(value) || value < 1 || value > 3600) {
|
||||
return { ok: false, error: "timeout must be a number between 1 and 3600" };
|
||||
@@ -1282,7 +1523,8 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:set-max-iterations", async (_event, { maxIterations }) => {
|
||||
ipcMain.handle("netcatty:ai:mcp:set-max-iterations", async (event, { maxIterations }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const value = Number(maxIterations);
|
||||
if (!Number.isFinite(value) || value < 1 || value > 100) {
|
||||
return { ok: false, error: "maxIterations must be a number between 1 and 100" };
|
||||
@@ -1291,7 +1533,8 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:set-permission-mode", async (_event, { mode }) => {
|
||||
ipcMain.handle("netcatty:ai:mcp:set-permission-mode", async (event, { mode }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const validModes = ["observer", "confirm", "autonomous"];
|
||||
if (!validModes.includes(mode)) {
|
||||
return { ok: false, error: `mode must be one of: ${validModes.join(", ")}` };
|
||||
@@ -1523,7 +1766,8 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:acp:cancel", async (_event, { requestId }) => {
|
||||
ipcMain.handle("netcatty:ai:acp:cancel", async (event, { requestId }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
// Cancel any active PTY executions (send Ctrl+C)
|
||||
mcpServerBridge.cancelAllPtyExecs();
|
||||
const controller = acpActiveStreams.get(requestId);
|
||||
@@ -1536,7 +1780,8 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
|
||||
// Cleanup a specific ACP session (when chat session is deleted)
|
||||
ipcMain.handle("netcatty:ai:acp:cleanup", async (_event, { chatSessionId }) => {
|
||||
ipcMain.handle("netcatty:ai:acp:cleanup", async (event, { chatSessionId }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
mcpServerBridge.cleanupScopedMetadata(chatSessionId);
|
||||
return { ok: true };
|
||||
|
||||
@@ -12,6 +12,40 @@
|
||||
|
||||
let _deps = null;
|
||||
|
||||
/**
|
||||
* Read the persisted auto-update preference from a JSON file in userData.
|
||||
* Returns true (default) if the file doesn't exist or is unreadable.
|
||||
*/
|
||||
function readAutoUpdatePreference() {
|
||||
try {
|
||||
const { app } = _deps?.electronModule || {};
|
||||
if (!app) return true;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const prefPath = path.join(app.getPath('userData'), 'auto-update-pref.json');
|
||||
const data = JSON.parse(fs.readFileSync(prefPath, 'utf8'));
|
||||
return data.enabled !== false;
|
||||
} catch {
|
||||
return true; // default to enabled
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the auto-update preference to a JSON file in userData.
|
||||
*/
|
||||
function writeAutoUpdatePreference(enabled) {
|
||||
try {
|
||||
const { app } = _deps?.electronModule || {};
|
||||
if (!app) return;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const prefPath = path.join(app.getPath('userData'), 'auto-update-pref.json');
|
||||
fs.writeFileSync(prefPath, JSON.stringify({ enabled }), 'utf8');
|
||||
} catch (err) {
|
||||
console.warn('[AutoUpdate] Failed to write preference:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the current packaging format supports electron-updater
|
||||
* (macOS zip/dmg, Windows NSIS, Linux AppImage).
|
||||
@@ -51,7 +85,7 @@ function getAutoUpdater() {
|
||||
if (_autoUpdater) return _autoUpdater;
|
||||
try {
|
||||
const { autoUpdater } = require("electron-updater");
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoDownload = readAutoUpdatePreference();
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
// Silence the default electron-log transport (we log ourselves).
|
||||
autoUpdater.logger = null;
|
||||
@@ -84,9 +118,12 @@ function setupGlobalListeners() {
|
||||
|
||||
updater.on("update-available", (info) => {
|
||||
_isChecking = false;
|
||||
// autoDownload=true means the download begins immediately after this event
|
||||
_isDownloading = true;
|
||||
_lastStatus = { status: 'downloading', percent: 0, error: null, version: info.version || null, isChecking: false };
|
||||
// Only track as downloading when autoDownload is enabled — otherwise no
|
||||
// download will actually start and the status would be stuck at 0%.
|
||||
// Use 'available' so late-opening windows can still hydrate the version.
|
||||
const willDownload = updater.autoDownload !== false;
|
||||
_isDownloading = willDownload;
|
||||
_lastStatus = { status: willDownload ? 'downloading' : 'available', percent: 0, error: null, version: info.version || null, isChecking: false };
|
||||
broadcastToAllWindows("netcatty:update:update-available", {
|
||||
version: info.version || "",
|
||||
releaseNotes: typeof info.releaseNotes === "string" ? info.releaseNotes : "",
|
||||
@@ -144,6 +181,9 @@ function startAutoCheck(delayMs = 5000) {
|
||||
console.log("[AutoUpdate] Platform does not support auto-update, skipping auto-check");
|
||||
return;
|
||||
}
|
||||
// Cancel any existing timer to avoid duplicate concurrent checks
|
||||
// (e.g. from multiple windows initializing or re-enable toggle).
|
||||
cancelAutoCheck();
|
||||
_autoCheckTimer = setTimeout(async () => {
|
||||
_autoCheckTimer = null;
|
||||
const updater = getAutoUpdater();
|
||||
@@ -151,6 +191,12 @@ function startAutoCheck(delayMs = 5000) {
|
||||
console.warn("[AutoUpdate] Auto-check skipped — updater not available");
|
||||
return;
|
||||
}
|
||||
// Respect autoDownload flag — the renderer may have disabled it via IPC
|
||||
// before this timer fires.
|
||||
if (updater.autoDownload === false) {
|
||||
console.log("[AutoUpdate] Auto-check skipped — autoDownload is disabled");
|
||||
return;
|
||||
}
|
||||
_isChecking = true;
|
||||
_lastStatus = { ..._lastStatus, isChecking: true };
|
||||
try {
|
||||
@@ -317,6 +363,34 @@ function registerHandlers(ipcMain) {
|
||||
updater.quitAndInstall(false, true);
|
||||
});
|
||||
|
||||
// ---- Get auto-update preference -----------------------------------------
|
||||
ipcMain.handle("netcatty:update:getAutoUpdate", () => {
|
||||
return { enabled: readAutoUpdatePreference() };
|
||||
});
|
||||
|
||||
// ---- Enable/disable auto-update ----------------------------------------
|
||||
let _prevAutoDownloadEnabled = readAutoUpdatePreference();
|
||||
ipcMain.handle("netcatty:update:setAutoUpdate", (_event, { enabled }) => {
|
||||
const wasEnabled = _prevAutoDownloadEnabled;
|
||||
_prevAutoDownloadEnabled = !!enabled;
|
||||
const updater = getAutoUpdater();
|
||||
if (updater) {
|
||||
updater.autoDownload = !!enabled;
|
||||
console.log("[AutoUpdate] autoDownload set to:", !!enabled);
|
||||
}
|
||||
// Persist so the preference survives app restarts
|
||||
writeAutoUpdatePreference(!!enabled);
|
||||
if (!enabled) {
|
||||
cancelAutoCheck();
|
||||
} else if (!wasEnabled && !_isChecking) {
|
||||
// Only re-schedule when actually re-enabling (not on every mount sync),
|
||||
// to avoid duplicate checks from multiple windows initializing.
|
||||
// Skip if a check is already in flight to prevent concurrent calls.
|
||||
startAutoCheck(2000);
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
console.log("[AutoUpdate] Handlers registered");
|
||||
}
|
||||
|
||||
|
||||
@@ -123,36 +123,46 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Windows named pipe exists (non-blocking).
|
||||
* Works for OpenSSH Agent, Bitwarden SSH Agent, 1Password, etc.
|
||||
*/
|
||||
function windowsPipeExists(pipePath) {
|
||||
try {
|
||||
fs.statSync(pipePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const WIN_SSH_AGENT_PIPE = "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
|
||||
/**
|
||||
* Check if a Windows named pipe is connectable.
|
||||
* fs.statSync is unreliable for named pipes (returns EBUSY even when the
|
||||
* pipe is usable), so we attempt an actual net.connect() which is the
|
||||
* authoritative check.
|
||||
* @param {string} pipePath
|
||||
* @param {number} [timeoutMs=1000]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function windowsPipeConnectable(pipePath, timeoutMs = 1000) {
|
||||
const net = require("net");
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(pipePath);
|
||||
let settled = false;
|
||||
const finish = (ok) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { socket.destroy(); } catch {}
|
||||
resolve(ok);
|
||||
};
|
||||
socket.setTimeout(timeoutMs);
|
||||
socket.once("connect", () => finish(true));
|
||||
socket.once("timeout", () => finish(false));
|
||||
socket.once("error", () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an SSH agent is available on Windows.
|
||||
* Instead of checking the OpenSSH Authentication Agent *service*, we probe
|
||||
* the well-known named pipe directly. This supports any agent that provides
|
||||
* the pipe — Bitwarden, 1Password, gpg-agent, etc.
|
||||
* Probes the well-known named pipe via net.connect(). This supports any
|
||||
* agent that provides the pipe — Bitwarden, 1Password, gpg-agent, etc.
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function checkWindowsSshAgentRunning() {
|
||||
return new Promise((resolve) => {
|
||||
if (process.platform !== "win32") {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
resolve(windowsPipeExists(WIN_SSH_AGENT_PIPE));
|
||||
});
|
||||
if (process.platform !== "win32") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return windowsPipeConnectable(WIN_SSH_AGENT_PIPE);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -143,29 +143,33 @@ async function findAllDefaultPrivateKeys() {
|
||||
const WIN_SSH_AGENT_PIPE = "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
|
||||
/**
|
||||
* Check if an SSH agent is available on Windows by probing the well-known
|
||||
* named pipe. This detects any agent that provides the pipe — OpenSSH Agent
|
||||
* service, Bitwarden, 1Password, gpg-agent, etc.
|
||||
* Check if an SSH agent is available on Windows by connecting to the
|
||||
* well-known named pipe. fs.statSync is unreliable for named pipes (returns
|
||||
* EBUSY even when usable), so we use net.connect() as the authoritative check.
|
||||
* @returns {Promise<{ running: boolean, startupType: string | null, error: string | null }>}
|
||||
*/
|
||||
function checkWindowsSshAgent() {
|
||||
if (process.platform !== "win32") {
|
||||
return Promise.resolve({ running: true, startupType: null, error: null });
|
||||
}
|
||||
const net = require("net");
|
||||
return new Promise((resolve) => {
|
||||
if (process.platform !== "win32") {
|
||||
resolve({ running: true, startupType: null, error: null });
|
||||
return;
|
||||
}
|
||||
let pipeExists = false;
|
||||
try {
|
||||
fs.statSync(WIN_SSH_AGENT_PIPE);
|
||||
pipeExists = true;
|
||||
} catch {
|
||||
// pipe not found
|
||||
}
|
||||
resolve({
|
||||
running: pipeExists,
|
||||
startupType: pipeExists ? "running" : "stopped",
|
||||
error: pipeExists ? null : "SSH Agent pipe not found",
|
||||
});
|
||||
const socket = net.connect(WIN_SSH_AGENT_PIPE);
|
||||
let settled = false;
|
||||
const finish = (ok, error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { socket.destroy(); } catch {}
|
||||
resolve({
|
||||
running: ok,
|
||||
startupType: ok ? "running" : "stopped",
|
||||
error: ok ? null : (error || "SSH Agent pipe not connectable"),
|
||||
});
|
||||
};
|
||||
socket.setTimeout(1000);
|
||||
socket.once("connect", () => finish(true, null));
|
||||
socket.once("timeout", () => finish(false, "SSH Agent pipe connect timeout"));
|
||||
socket.once("error", (err) => finish(false, err.message));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -907,9 +907,23 @@ async function openSettingsWindow(electronModule, options) {
|
||||
const backgroundColor = frontendBackground || "#1a1a1a";
|
||||
const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
|
||||
|
||||
// Center the settings window on the same display as the main window
|
||||
const settingsWidth = 980;
|
||||
const settingsHeight = 720;
|
||||
let settingsX, settingsY;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const { screen } = electronModule;
|
||||
const mainBounds = mainWindow.getBounds();
|
||||
const display = screen.getDisplayMatching(mainBounds);
|
||||
const { x: dx, y: dy, width: dw, height: dh } = display.workArea;
|
||||
settingsX = Math.round(dx + (dw - settingsWidth) / 2);
|
||||
settingsY = Math.round(dy + (dh - settingsHeight) / 2);
|
||||
}
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 980,
|
||||
height: 720,
|
||||
width: settingsWidth,
|
||||
height: settingsHeight,
|
||||
...(settingsX !== undefined && settingsY !== undefined ? { x: settingsX, y: settingsY } : {}),
|
||||
minWidth: 820,
|
||||
minHeight: 600,
|
||||
backgroundColor,
|
||||
|
||||
@@ -967,6 +967,8 @@ const api = {
|
||||
downloadUpdate: () => ipcRenderer.invoke("netcatty:update:download"),
|
||||
installUpdate: () => ipcRenderer.invoke("netcatty:update:install"),
|
||||
getUpdateStatus: () => ipcRenderer.invoke("netcatty:update:getStatus"),
|
||||
setAutoUpdate: (enabled) => ipcRenderer.invoke("netcatty:update:setAutoUpdate", { enabled }),
|
||||
getAutoUpdate: () => ipcRenderer.invoke("netcatty:update:getAutoUpdate"),
|
||||
onUpdateAvailable: (cb) => {
|
||||
updateAvailableListeners.add(cb);
|
||||
return () => updateAvailableListeners.delete(cb);
|
||||
@@ -992,14 +994,20 @@ const api = {
|
||||
aiSyncProviders: async (providers) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:sync-providers", { providers });
|
||||
},
|
||||
aiSyncWebSearch: async (apiHost, apiKey) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:sync-web-search", { apiHost, apiKey });
|
||||
},
|
||||
aiChatStream: async (requestId, url, headers, body, providerId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:chat:stream", { requestId, url, headers, body, providerId });
|
||||
},
|
||||
aiChatCancel: async (requestId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:chat:cancel", { requestId });
|
||||
},
|
||||
aiFetch: async (url, method, headers, body, providerId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:fetch", { url, method, headers, body, providerId });
|
||||
aiFetch: async (url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:fetch", { url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify });
|
||||
},
|
||||
aiAllowlistAddHost: async (baseURL) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:allowlist:add-host", { baseURL });
|
||||
},
|
||||
aiExec: async (sessionId, command) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:exec", { sessionId, command });
|
||||
|
||||
@@ -7,7 +7,7 @@ import reactHooks from "eslint-plugin-react-hooks";
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**"],
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**", "release/**"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
|
||||
9
global.d.ts
vendored
9
global.d.ts
vendored
@@ -189,6 +189,7 @@ declare global {
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
passphrase?: string;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
enableKeyboardInteractive?: boolean;
|
||||
@@ -617,6 +618,7 @@ declare global {
|
||||
aiChatStream?(requestId: string, url: string, headers?: Record<string, string>, body?: string, providerId?: string): Promise<{ ok: boolean; statusCode?: number; statusText?: string; error?: string }>;
|
||||
aiChatCancel?(requestId: string): Promise<boolean>;
|
||||
aiFetch?(url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string): Promise<{ ok: boolean; status: number; data: string; error?: string }>;
|
||||
aiAllowlistAddHost?(baseURL: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiExec?(sessionId: string, command: string): Promise<{ ok: boolean; stdout?: string; stderr?: string; exitCode?: number | null; error?: string }>;
|
||||
aiTerminalWrite?(sessionId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiDiscoverAgents?(): Promise<Array<{
|
||||
@@ -703,6 +705,7 @@ declare global {
|
||||
checkForUpdate?(): Promise<{
|
||||
available: boolean;
|
||||
supported?: boolean;
|
||||
checking?: boolean;
|
||||
version?: string;
|
||||
releaseNotes?: string;
|
||||
releaseDate?: string | null;
|
||||
@@ -710,7 +713,7 @@ declare global {
|
||||
}>;
|
||||
downloadUpdate?(): Promise<{ success: boolean; error?: string }>;
|
||||
installUpdate?(): void;
|
||||
getUpdateStatus?(): Promise<{ status: 'idle' | 'downloading' | 'ready' | 'error'; percent: number; error: string | null; version: string | null; isChecking?: boolean }>;
|
||||
getUpdateStatus?(): Promise<{ status: 'idle' | 'available' | 'downloading' | 'ready' | 'error'; percent: number; error: string | null; version: string | null; isChecking?: boolean }>;
|
||||
|
||||
onUpdateDownloadProgress?(cb: (progress: {
|
||||
percent: number;
|
||||
@@ -732,6 +735,10 @@ declare global {
|
||||
unregisterGlobalHotkey?(): Promise<{ success: boolean }>;
|
||||
getGlobalHotkeyStatus?(): Promise<{ enabled: boolean; hotkey: string | null }>;
|
||||
|
||||
// Auto-Update toggle
|
||||
getAutoUpdate?(): Promise<{ enabled: boolean }>;
|
||||
setAutoUpdate?(enabled: boolean): Promise<{ success: boolean }>;
|
||||
|
||||
// System Tray / Close to Tray
|
||||
setCloseToTray?(enabled: boolean): Promise<{ success: boolean; enabled: boolean }>;
|
||||
isCloseToTray?(): Promise<{ enabled: boolean }>;
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import type { ToolCall, ToolResult, AIPermissionMode } from '../types';
|
||||
import type { ToolCall, ToolResult, AIPermissionMode, WebSearchConfig } from '../types';
|
||||
import {
|
||||
executeTerminalExecute,
|
||||
executeTerminalSendInput,
|
||||
executeSftpListDirectory,
|
||||
executeSftpReadFile,
|
||||
executeSftpWriteFile,
|
||||
executeWorkspaceGetInfo,
|
||||
executeWorkspaceGetSessionInfo,
|
||||
executeMultiHostExecute,
|
||||
executeWebSearch,
|
||||
executeUrlFetch,
|
||||
type ToolDeps,
|
||||
type ToolExecResult,
|
||||
} from '../shared/toolExecutors';
|
||||
@@ -27,26 +24,6 @@ export interface NetcattyBridge {
|
||||
exitCode?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
aiTerminalWrite(
|
||||
sessionId: string,
|
||||
data: string,
|
||||
): Promise<{ ok: boolean; error?: string }>;
|
||||
listSftp(
|
||||
sftpId: string,
|
||||
path: string,
|
||||
encoding?: string,
|
||||
): Promise<unknown>;
|
||||
readSftp(
|
||||
sftpId: string,
|
||||
path: string,
|
||||
encoding?: string,
|
||||
): Promise<string>;
|
||||
writeSftp(
|
||||
sftpId: string,
|
||||
path: string,
|
||||
content: string,
|
||||
encoding?: string,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
// Workspace context provided to the executor
|
||||
@@ -60,7 +37,6 @@ export interface ExecutorContext {
|
||||
os?: string;
|
||||
username?: string;
|
||||
connected: boolean;
|
||||
sftpId?: string; // If SFTP is open for this session
|
||||
}>;
|
||||
// Workspace info
|
||||
workspaceId?: string;
|
||||
@@ -90,22 +66,6 @@ function toToolResult(toolCallId: string, r: ToolExecResult): ToolResult {
|
||||
.join('\n\n');
|
||||
return { toolCallId, content: output || 'Command completed (no output)' };
|
||||
}
|
||||
// For terminal_send_input
|
||||
if (typeof r.data === 'object' && r.data !== null && 'sent' in r.data) {
|
||||
return { toolCallId, content: `Sent input to terminal: ${JSON.stringify((r.data as { sent: string }).sent)}` };
|
||||
}
|
||||
// For sftp_list_directory with output fallback
|
||||
if (typeof r.data === 'object' && r.data !== null && 'output' in r.data && !('files' in r.data)) {
|
||||
return { toolCallId, content: (r.data as { output: string }).output };
|
||||
}
|
||||
// For sftp_read_file
|
||||
if (typeof r.data === 'object' && r.data !== null && 'content' in r.data) {
|
||||
return { toolCallId, content: (r.data as { content: string }).content };
|
||||
}
|
||||
// For sftp_write_file
|
||||
if (typeof r.data === 'object' && r.data !== null && 'written' in r.data) {
|
||||
return { toolCallId, content: `File written: ${(r.data as { written: string }).written}` };
|
||||
}
|
||||
// Default: JSON-serialize the data
|
||||
return { toolCallId, content: JSON.stringify(r.data, null, 2) };
|
||||
}
|
||||
@@ -119,6 +79,7 @@ export function createToolExecutor(
|
||||
context: ExecutorContext,
|
||||
commandBlocklist?: string[],
|
||||
permissionMode: AIPermissionMode = 'confirm',
|
||||
webSearchConfig?: WebSearchConfig,
|
||||
): (toolCall: ToolCall) => Promise<ToolResult> {
|
||||
return async (toolCall: ToolCall): Promise<ToolResult> => {
|
||||
if (!bridge) {
|
||||
@@ -129,7 +90,7 @@ export function createToolExecutor(
|
||||
};
|
||||
}
|
||||
|
||||
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode };
|
||||
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig };
|
||||
const args = toolCall.arguments;
|
||||
|
||||
try {
|
||||
@@ -142,40 +103,6 @@ export function createToolExecutor(
|
||||
return toToolResult(toolCall.id, r);
|
||||
}
|
||||
|
||||
case 'terminal_send_input': {
|
||||
const r = await executeTerminalSendInput(deps, {
|
||||
sessionId: String(args.sessionId || ''),
|
||||
input: String(args.input || ''),
|
||||
});
|
||||
return toToolResult(toolCall.id, r);
|
||||
}
|
||||
|
||||
case 'sftp_list_directory': {
|
||||
const r = await executeSftpListDirectory(deps, {
|
||||
sessionId: String(args.sessionId || ''),
|
||||
path: String(args.path || '/'),
|
||||
});
|
||||
return toToolResult(toolCall.id, r);
|
||||
}
|
||||
|
||||
case 'sftp_read_file': {
|
||||
const r = await executeSftpReadFile(deps, {
|
||||
sessionId: String(args.sessionId || ''),
|
||||
path: String(args.path || ''),
|
||||
maxBytes: Number(args.maxBytes) || 10000,
|
||||
});
|
||||
return toToolResult(toolCall.id, r);
|
||||
}
|
||||
|
||||
case 'sftp_write_file': {
|
||||
const r = await executeSftpWriteFile(deps, {
|
||||
sessionId: String(args.sessionId || ''),
|
||||
path: String(args.path || ''),
|
||||
content: String(args.content || ''),
|
||||
});
|
||||
return toToolResult(toolCall.id, r);
|
||||
}
|
||||
|
||||
case 'workspace_get_info': {
|
||||
const r = executeWorkspaceGetInfo(deps);
|
||||
return toToolResult(toolCall.id, r);
|
||||
@@ -188,12 +115,18 @@ export function createToolExecutor(
|
||||
return toToolResult(toolCall.id, r);
|
||||
}
|
||||
|
||||
case 'multi_host_execute': {
|
||||
const r = await executeMultiHostExecute(deps, {
|
||||
sessionIds: (args.sessionIds as string[]) || [],
|
||||
command: String(args.command || ''),
|
||||
mode: String(args.mode || 'parallel'),
|
||||
stopOnError: Boolean(args.stopOnError),
|
||||
case 'web_search': {
|
||||
const r = await executeWebSearch(deps, {
|
||||
query: String(args.query || ''),
|
||||
maxResults: Number(args.maxResults) || 5,
|
||||
});
|
||||
return toToolResult(toolCall.id, r);
|
||||
}
|
||||
|
||||
case 'url_fetch': {
|
||||
const r = await executeUrlFetch(deps, {
|
||||
url: String(args.url || ''),
|
||||
maxLength: Number(args.maxLength) || 50000,
|
||||
});
|
||||
return toToolResult(toolCall.id, r);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ export interface SystemPromptContext {
|
||||
connected: boolean;
|
||||
}>;
|
||||
permissionMode: 'observer' | 'confirm' | 'autonomous';
|
||||
webSearchEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(context: SystemPromptContext): string {
|
||||
const { scopeType, scopeLabel, hosts, permissionMode } = context;
|
||||
const { scopeType, scopeLabel, hosts, permissionMode, webSearchEnabled } = context;
|
||||
|
||||
const scopeDescription = buildScopeDescription(scopeType, scopeLabel);
|
||||
const hostList = buildHostList(hosts);
|
||||
@@ -37,7 +38,7 @@ ${permissionRules}
|
||||
|
||||
1. **Plan before acting.** When a task involves multiple steps, present a brief numbered plan to the user before executing. Wait for acknowledgment on complex or risky operations.
|
||||
|
||||
2. **Use the right tool.** For operations that target multiple hosts, prefer \`multi_host_execute\` over calling \`terminal_execute\` repeatedly. For normal shell commands, use \`terminal_execute\` so you receive the command output. Use \`terminal_send_input\` only when responding to an interactive prompt that is already running in the terminal. \`terminal_send_input\` writes keystrokes but does not read back the updated terminal screen.
|
||||
2. **Use the right tool.** For normal shell commands, use \`terminal_execute\` so you receive the command output. When operating on multiple hosts, call \`terminal_execute\` for each host.
|
||||
|
||||
3. **Never execute dangerous commands.** Commands matching the blocklist (e.g. \`rm -rf /\`, \`mkfs\`, \`dd\` to disk devices, \`shutdown\`, fork bombs, recursive chmod 777 on root) are strictly forbidden and will be automatically denied. Do not attempt to bypass these restrictions.
|
||||
|
||||
@@ -49,7 +50,11 @@ ${permissionRules}
|
||||
|
||||
7. **Respect connection status.** Only attempt operations on hosts that are currently connected. If a host is disconnected, inform the user and suggest reconnecting.
|
||||
|
||||
8. **Be careful with file operations.** When writing files via SFTP, confirm the target path with the user if there is any ambiguity. Always prefer appending or targeted edits over full file overwrites when possible.`;
|
||||
8. **Be careful with file operations.** When writing files via shell commands, confirm the target path with the user if there is any ambiguity. Always prefer appending or targeted edits over full file overwrites when possible.
|
||||
|
||||
9. **Fetch URLs when provided.** When the user shares a URL or asks you to read a webpage, use \`url_fetch\` to retrieve its content.${webSearchEnabled ? `
|
||||
|
||||
10. **Search proactively.** You have access to \`web_search\`. Use it whenever you encounter something you are unsure about, don't fully understand, or need to verify — including unfamiliar commands, tools, error messages, configuration syntax, or any factual claims. Don't guess; search first. Also use it when the user asks about current events or recent information. Cite sources when presenting search results.` : ''}`;
|
||||
}
|
||||
|
||||
function buildScopeDescription(
|
||||
@@ -98,9 +103,9 @@ function buildPermissionRules(
|
||||
case 'observer':
|
||||
return [
|
||||
'You are in **observer** mode. You may only perform read-only operations:',
|
||||
'- Listing directories (`sftp_list_directory`)',
|
||||
'- Reading files (`sftp_read_file`)',
|
||||
'- Getting workspace and session info (`workspace_get_info`, `workspace_get_session_info`)',
|
||||
'- Fetching URLs (`url_fetch`)',
|
||||
'- Searching the web (`web_search`)',
|
||||
'',
|
||||
'All write and execute operations are denied. If the user asks you to run a command or modify a file, explain that observer mode does not allow it and suggest switching to confirm or autonomous mode.',
|
||||
].join('\n');
|
||||
@@ -108,9 +113,7 @@ function buildPermissionRules(
|
||||
case 'confirm':
|
||||
return [
|
||||
'You are in **confirm** mode. Every write or execute operation requires explicit user approval before it runs:',
|
||||
'- Command execution (`terminal_execute`, `multi_host_execute`)',
|
||||
'- Sending terminal input (`terminal_send_input`)',
|
||||
'- Writing files (`sftp_write_file`)',
|
||||
'- Command execution (`terminal_execute`)',
|
||||
'',
|
||||
'Read-only operations are allowed without confirmation. When proposing a command, clearly state what it will do so the user can make an informed decision.',
|
||||
].join('\n');
|
||||
|
||||
@@ -28,7 +28,7 @@ export function classifyError(error: string): NonNullable<ChatMessage['errorInfo
|
||||
|
||||
// Provider errors (5xx)
|
||||
if (/\b5\d{2}\b/.test(error) || lower.includes('server error') || lower.includes('internal error')) {
|
||||
return { type: 'provider', message: 'The AI provider returned a server error. Please try again later.', retryable: true };
|
||||
return { type: 'provider', message: sanitizeErrorMessage(error), retryable: true };
|
||||
}
|
||||
|
||||
// Model not found
|
||||
|
||||
@@ -178,9 +178,28 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
|
||||
|
||||
if (!result.ok) {
|
||||
cleanup();
|
||||
return new Response(result.error || 'Stream request failed', {
|
||||
const errorMessage = result.error || 'Stream request failed';
|
||||
const jsonBody = JSON.stringify({ error: { message: errorMessage } });
|
||||
return new Response(jsonBody, {
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// If the server returned a non-2xx status, return the error details
|
||||
// as a JSON body in OpenAI-compatible format so the AI SDK's
|
||||
// failedResponseHandler can extract the message properly.
|
||||
// Also set a safe ASCII statusText as fallback for non-OpenAI SDK providers.
|
||||
const statusCode = result.statusCode ?? 200;
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
cleanup();
|
||||
const errorDetail = result.statusText || `HTTP ${statusCode}`;
|
||||
const jsonBody = JSON.stringify({ error: { message: errorDetail } });
|
||||
return new Response(jsonBody, {
|
||||
status: statusCode,
|
||||
statusText: `Error ${statusCode}`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,7 +210,7 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: result.statusCode ?? 200,
|
||||
status: statusCode,
|
||||
statusText: result.statusText ?? 'OK',
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
});
|
||||
|
||||
@@ -2,15 +2,14 @@ import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import type { NetcattyBridge, ExecutorContext } from '../cattyAgent/executor';
|
||||
import type { AIPermissionMode } from '../types';
|
||||
import type { WebSearchConfig } from '../types';
|
||||
import { isWebSearchReady } from '../types';
|
||||
import {
|
||||
executeTerminalExecute,
|
||||
executeTerminalSendInput,
|
||||
executeSftpListDirectory,
|
||||
executeSftpReadFile,
|
||||
executeSftpWriteFile,
|
||||
executeWorkspaceGetInfo,
|
||||
executeWorkspaceGetSessionInfo,
|
||||
executeMultiHostExecute,
|
||||
executeWebSearch,
|
||||
executeUrlFetch,
|
||||
type ToolDeps,
|
||||
type ToolExecResult,
|
||||
} from '../shared/toolExecutors';
|
||||
@@ -34,9 +33,10 @@ export function createCattyTools(
|
||||
context: ExecutorContext,
|
||||
commandBlocklist?: string[],
|
||||
permissionMode: AIPermissionMode = 'confirm',
|
||||
webSearchConfig?: WebSearchConfig,
|
||||
) {
|
||||
const writeToolNeedsApproval = permissionMode === 'confirm';
|
||||
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode };
|
||||
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig };
|
||||
|
||||
return {
|
||||
terminal_execute: tool({
|
||||
@@ -53,73 +53,6 @@ export function createCattyTools(
|
||||
},
|
||||
}),
|
||||
|
||||
terminal_send_input: tool({
|
||||
description:
|
||||
'Send raw input to a terminal session. Use this for interactive programs that ' +
|
||||
'require input such as y/n prompts, passwords, ctrl+c (\\x03), ctrl+d (\\x04), ' +
|
||||
'or any other keyboard input. This tool only sends input; it does not return ' +
|
||||
'the updated terminal output. For normal shell commands, use terminal_execute instead.',
|
||||
inputSchema: z.object({
|
||||
sessionId: z.string().describe('The terminal session ID to send input to.'),
|
||||
input: z
|
||||
.string()
|
||||
.describe(
|
||||
'The raw input string to send. Use escape sequences for special keys ' +
|
||||
'(e.g. "\\x03" for ctrl+c, "\\n" for enter).',
|
||||
),
|
||||
}),
|
||||
needsApproval: writeToolNeedsApproval,
|
||||
execute: async ({ sessionId, input }) => {
|
||||
return unwrap(await executeTerminalSendInput(deps, { sessionId, input }));
|
||||
},
|
||||
}),
|
||||
|
||||
sftp_list_directory: tool({
|
||||
description:
|
||||
'List the contents of a directory on the remote host via SFTP. Returns file names, ' +
|
||||
'sizes, types, and modification timestamps.',
|
||||
inputSchema: z.object({
|
||||
sessionId: z.string().describe('The session ID for the SFTP connection.'),
|
||||
path: z.string().describe('The absolute path of the remote directory to list.'),
|
||||
}),
|
||||
execute: async ({ sessionId, path }) => {
|
||||
return unwrap(await executeSftpListDirectory(deps, { sessionId, path }));
|
||||
},
|
||||
}),
|
||||
|
||||
sftp_read_file: tool({
|
||||
description:
|
||||
'Read the content of a file on the remote host via SFTP. Returns the file content ' +
|
||||
'as text, truncated to maxBytes if the file is large.',
|
||||
inputSchema: z.object({
|
||||
sessionId: z.string().describe('The session ID for the SFTP connection.'),
|
||||
path: z.string().describe('The absolute path of the remote file to read.'),
|
||||
maxBytes: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(10000)
|
||||
.describe('Maximum number of bytes to read from the file. Defaults to 10000.'),
|
||||
}),
|
||||
execute: async ({ sessionId, path, maxBytes }) => {
|
||||
return unwrap(await executeSftpReadFile(deps, { sessionId, path, maxBytes }));
|
||||
},
|
||||
}),
|
||||
|
||||
sftp_write_file: tool({
|
||||
description:
|
||||
'Write content to a file on the remote host via SFTP. Creates the file if it does ' +
|
||||
'not exist, or overwrites it if it does.',
|
||||
inputSchema: z.object({
|
||||
sessionId: z.string().describe('The session ID for the SFTP connection.'),
|
||||
path: z.string().describe('The absolute path of the remote file to write.'),
|
||||
content: z.string().describe('The text content to write to the file.'),
|
||||
}),
|
||||
needsApproval: writeToolNeedsApproval,
|
||||
execute: async ({ sessionId, path, content }) => {
|
||||
return unwrap(await executeSftpWriteFile(deps, { sessionId, path, content }));
|
||||
},
|
||||
}),
|
||||
|
||||
workspace_get_info: tool({
|
||||
description:
|
||||
'Get information about the current workspace, including all configured hosts ' +
|
||||
@@ -142,36 +75,40 @@ export function createCattyTools(
|
||||
},
|
||||
}),
|
||||
|
||||
multi_host_execute: tool({
|
||||
description:
|
||||
'Execute a command on multiple hosts simultaneously or sequentially. ' +
|
||||
'Use this for batch operations such as checking status across a fleet, ' +
|
||||
'deploying updates, or running maintenance tasks on multiple servers.',
|
||||
inputSchema: z.object({
|
||||
sessionIds: z
|
||||
.array(z.string())
|
||||
.describe('Array of session IDs to execute the command on.'),
|
||||
command: z.string().describe('The shell command to execute on each host.'),
|
||||
mode: z
|
||||
.enum(['parallel', 'sequential'])
|
||||
.optional()
|
||||
.default('parallel')
|
||||
.describe(
|
||||
'Execution mode. "parallel" runs on all hosts at once, ' +
|
||||
'"sequential" runs one at a time. Defaults to "parallel".',
|
||||
),
|
||||
stopOnError: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe(
|
||||
'If true and mode is "sequential", stop executing on remaining hosts ' +
|
||||
'when a command fails. Defaults to false.',
|
||||
),
|
||||
// -- Web Search (conditional on fully configured webSearchConfig) --
|
||||
...(isWebSearchReady(webSearchConfig) ? {
|
||||
web_search: tool({
|
||||
description:
|
||||
'Search the web for current information. Use this when the user asks about recent events, ' +
|
||||
'news, or facts you are unsure about. Returns a list of search results with titles, URLs, and content snippets.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('The search query to look up on the web.'),
|
||||
maxResults: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Maximum number of search results to return. If omitted, uses the configured default.'),
|
||||
}),
|
||||
execute: async ({ query, maxResults }) => {
|
||||
return unwrap(await executeWebSearch(deps, { query, maxResults }));
|
||||
},
|
||||
}),
|
||||
needsApproval: writeToolNeedsApproval,
|
||||
execute: async ({ sessionIds, command, mode, stopOnError }) => {
|
||||
return unwrap(await executeMultiHostExecute(deps, { sessionIds, command, mode, stopOnError }));
|
||||
} : {}),
|
||||
|
||||
// -- URL Fetch (always available, read-only like sftp_read_file) --
|
||||
url_fetch: tool({
|
||||
description:
|
||||
'Fetch and read the content of a web URL. Use this when the user provides a URL and wants ' +
|
||||
'you to read or summarize its content. Returns the page content as text.',
|
||||
inputSchema: z.object({
|
||||
url: z.string().describe('The HTTPS URL to fetch. Must start with https://.'),
|
||||
maxLength: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(50000)
|
||||
.describe('Maximum number of characters to return. Defaults to 50000.'),
|
||||
}),
|
||||
execute: async ({ url, maxLength }) => {
|
||||
return unwrap(await executeUrlFetch(deps, { url, maxLength }));
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
*/
|
||||
|
||||
import type { NetcattyBridge, ExecutorContext } from '../cattyAgent/executor';
|
||||
import type { AIPermissionMode } from '../types';
|
||||
import type { AIPermissionMode, WebSearchConfig } from '../types';
|
||||
import { checkCommandSafety } from '../cattyAgent/safety';
|
||||
import { shellQuote } from '../shellQuote';
|
||||
import { limitConcurrency } from '../concurrency';
|
||||
import { executeWebSearchProvider } from './webSearchProviders';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared result types
|
||||
@@ -31,6 +30,7 @@ export interface ToolDeps {
|
||||
context: ExecutorContext;
|
||||
commandBlocklist?: string[];
|
||||
permissionMode: AIPermissionMode;
|
||||
webSearchConfig?: WebSearchConfig;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -78,9 +78,14 @@ export async function executeTerminalExecute(
|
||||
}
|
||||
|
||||
const result = await bridge.aiExec(sessionId, command);
|
||||
if (!result.ok) {
|
||||
return { ok: false, error: result.error || 'Command failed' };
|
||||
// Real execution failures (timeout, disconnect, no stream) have an `error` field
|
||||
if (!result.ok && result.error) {
|
||||
const parts = [result.error];
|
||||
if (result.stdout) parts.push(`Partial output:\n${result.stdout}`);
|
||||
if (result.stderr) parts.push(`Stderr:\n${result.stderr}`);
|
||||
return { ok: false, error: parts.join('\n\n') };
|
||||
}
|
||||
// Command ran (even if exit code is non-zero) — always return stdout+exitCode for LLM to judge
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
@@ -91,124 +96,6 @@ export async function executeTerminalExecute(
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeTerminalSendInput(
|
||||
deps: ToolDeps,
|
||||
args: { sessionId: string; input: string },
|
||||
): Promise<ToolExecResult<{ sent: string }>> {
|
||||
const { bridge, context, commandBlocklist, permissionMode } = deps;
|
||||
const { sessionId, input } = args;
|
||||
|
||||
if (!sessionId || !input) {
|
||||
return { ok: false, error: 'Missing sessionId or input' };
|
||||
}
|
||||
const scopeErr = validateSessionScope(context, sessionId);
|
||||
if (scopeErr) return { ok: false, error: scopeErr };
|
||||
if (isObserver(permissionMode)) {
|
||||
return { ok: false, error: 'Observer mode: terminal input is disabled. Switch to Confirm or Auto mode.' };
|
||||
}
|
||||
const safety = checkCommandSafety(input, commandBlocklist);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Input blocked by safety policy. Matched pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
|
||||
const result = await bridge.aiTerminalWrite(sessionId, input);
|
||||
if (!result.ok) {
|
||||
return { ok: false, error: result.error || 'Failed to send input' };
|
||||
}
|
||||
return { ok: true, data: { sent: input } };
|
||||
}
|
||||
|
||||
export async function executeSftpListDirectory(
|
||||
deps: ToolDeps,
|
||||
args: { sessionId: string; path: string },
|
||||
): Promise<ToolExecResult<{ files?: unknown; output?: string }>> {
|
||||
const { bridge, context } = deps;
|
||||
const { sessionId, path } = args;
|
||||
|
||||
const scopeErr = validateSessionScope(context, sessionId);
|
||||
if (scopeErr) return { ok: false, error: scopeErr };
|
||||
|
||||
const session = context.sessions.find(s => s.sessionId === sessionId);
|
||||
if (!session?.sftpId) {
|
||||
// Fallback: use terminal exec with ls
|
||||
const result = await bridge.aiExec(sessionId, `ls -la ${shellQuote(path)}`);
|
||||
if (!result.ok) {
|
||||
return { ok: false, error: result.error || 'Failed to list directory' };
|
||||
}
|
||||
return { ok: true, data: { output: result.stdout || '(empty directory)' } };
|
||||
}
|
||||
|
||||
const files = await bridge.listSftp(session.sftpId, path);
|
||||
return { ok: true, data: { files } };
|
||||
}
|
||||
|
||||
export async function executeSftpReadFile(
|
||||
deps: ToolDeps,
|
||||
args: { sessionId: string; path: string; maxBytes?: number },
|
||||
): Promise<ToolExecResult<{ content: string }>> {
|
||||
const { bridge, context } = deps;
|
||||
const { sessionId, path } = args;
|
||||
|
||||
if (!sessionId || !path) {
|
||||
return { ok: false, error: 'Missing sessionId or path' };
|
||||
}
|
||||
const scopeErr = validateSessionScope(context, sessionId);
|
||||
if (scopeErr) return { ok: false, error: scopeErr };
|
||||
|
||||
const session = context.sessions.find(s => s.sessionId === sessionId);
|
||||
if (!session?.sftpId) {
|
||||
const clampedMaxBytes = Math.max(1, Math.min(10 * 1024 * 1024, Number(args.maxBytes) || 10000));
|
||||
const result = await bridge.aiExec(sessionId, `head -c ${clampedMaxBytes} ${shellQuote(path)}`);
|
||||
if (!result.ok) {
|
||||
return { ok: false, error: result.error || 'Failed to read file' };
|
||||
}
|
||||
return { ok: true, data: { content: result.stdout || '(empty file)' } };
|
||||
}
|
||||
|
||||
let content = await bridge.readSftp(session.sftpId, path);
|
||||
const maxBytes = Math.max(1, Math.min(10 * 1024 * 1024, Number(args.maxBytes) || 10000));
|
||||
if (content && content.length > maxBytes) {
|
||||
content = content.slice(0, maxBytes);
|
||||
}
|
||||
return { ok: true, data: { content: content || '(empty file)' } };
|
||||
}
|
||||
|
||||
export async function executeSftpWriteFile(
|
||||
deps: ToolDeps,
|
||||
args: { sessionId: string; path: string; content: string },
|
||||
): Promise<ToolExecResult<{ written: string }>> {
|
||||
const { bridge, context, permissionMode } = deps;
|
||||
const { sessionId, path, content } = args;
|
||||
|
||||
if (!sessionId || !path) {
|
||||
return { ok: false, error: 'Missing sessionId or path' };
|
||||
}
|
||||
const scopeErr = validateSessionScope(context, sessionId);
|
||||
if (scopeErr) return { ok: false, error: scopeErr };
|
||||
if (isObserver(permissionMode)) {
|
||||
return { ok: false, error: 'Observer mode: file writing is disabled. Switch to Confirm or Auto mode.' };
|
||||
}
|
||||
|
||||
const session = context.sessions.find(s => s.sessionId === sessionId);
|
||||
if (!session?.sftpId) {
|
||||
// Fallback: base64 encoding to avoid heredoc injection
|
||||
const b64 = typeof btoa === 'function'
|
||||
? btoa(unescape(encodeURIComponent(content)))
|
||||
: Buffer.from(content, 'utf-8').toString('base64');
|
||||
const result = await bridge.aiExec(
|
||||
sessionId,
|
||||
`echo ${shellQuote(b64)} | base64 -d > ${shellQuote(path)}`,
|
||||
);
|
||||
if (!result.ok) {
|
||||
return { ok: false, error: result.error || 'Failed to write file' };
|
||||
}
|
||||
return { ok: true, data: { written: path } };
|
||||
}
|
||||
|
||||
await bridge.writeSftp(session.sftpId, path, content);
|
||||
return { ok: true, data: { written: path } };
|
||||
}
|
||||
|
||||
export function executeWorkspaceGetInfo(
|
||||
deps: ToolDeps,
|
||||
): ToolExecResult<{
|
||||
@@ -253,70 +140,75 @@ export function executeWorkspaceGetSessionInfo(
|
||||
return { ok: true, data: session };
|
||||
}
|
||||
|
||||
export async function executeMultiHostExecute(
|
||||
// ---------------------------------------------------------------------------
|
||||
// Web Search & URL Fetch (read-only, no permission check needed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function executeWebSearch(
|
||||
deps: ToolDeps,
|
||||
args: {
|
||||
sessionIds: string[];
|
||||
command: string;
|
||||
mode?: string;
|
||||
stopOnError?: boolean;
|
||||
},
|
||||
): Promise<ToolExecResult<{ results: Record<string, { ok: boolean; output: string }> }>> {
|
||||
const { bridge, context, commandBlocklist, permissionMode } = deps;
|
||||
const { sessionIds, command, mode = 'parallel', stopOnError = false } = args;
|
||||
args: { query: string; maxResults?: number },
|
||||
): Promise<ToolExecResult<{ results: Array<{ title: string; url: string; content: string }> }>> {
|
||||
const { bridge, webSearchConfig } = deps;
|
||||
|
||||
if (sessionIds.length === 0 || !command) {
|
||||
return { ok: false, error: 'Missing sessionIds or command' };
|
||||
if (!webSearchConfig?.enabled) {
|
||||
return { ok: false, error: 'Web search is not enabled. Please configure a search provider in Settings → AI.' };
|
||||
}
|
||||
if (!args.query) {
|
||||
return { ok: false, error: 'Missing search query' };
|
||||
}
|
||||
|
||||
const currentValidIds = validSessionIds(context);
|
||||
const outOfScope = sessionIds.filter(sid => !currentValidIds.has(sid));
|
||||
if (outOfScope.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Sessions not in current scope: ${outOfScope.join(', ')}. Available sessions: ${[...currentValidIds].join(', ')}`,
|
||||
};
|
||||
try {
|
||||
const maxResults = Math.max(1, Math.min(20, args.maxResults ?? webSearchConfig.maxResults ?? 5));
|
||||
const results = await executeWebSearchProvider(bridge, webSearchConfig, args.query, maxResults);
|
||||
// Enforce maxResults after provider normalization (some providers ignore the limit)
|
||||
return { ok: true, data: { results: results.slice(0, maxResults) } };
|
||||
} catch (err) {
|
||||
return { ok: false, error: `Web search failed: ${err instanceof Error ? err.message : String(err)}` };
|
||||
}
|
||||
}
|
||||
|
||||
interface BridgeFetchResponse {
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
data?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function executeUrlFetch(
|
||||
deps: ToolDeps,
|
||||
args: { url: string; maxLength?: number },
|
||||
): Promise<ToolExecResult<{ url: string; content: string; status: number }>> {
|
||||
const { bridge } = deps;
|
||||
const { url } = args;
|
||||
|
||||
if (!url || !url.startsWith('https://')) {
|
||||
return { ok: false, error: 'Invalid URL. Must start with https://' };
|
||||
}
|
||||
|
||||
const aiFetch = (bridge as unknown as Record<string, (...a: unknown[]) => Promise<unknown>>).aiFetch;
|
||||
if (!aiFetch) {
|
||||
return { ok: false, error: 'aiFetch is not available on the bridge' };
|
||||
}
|
||||
|
||||
try {
|
||||
// skipHostCheck=true, followRedirects=true: url_fetch targets user-provided URLs
|
||||
const resp = await aiFetch(url, 'GET', {
|
||||
'User-Agent': 'Netcatty-AI/1.0',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7',
|
||||
}, undefined, undefined, true, true) as BridgeFetchResponse;
|
||||
|
||||
if (!resp.ok) {
|
||||
return { ok: false, error: resp.error || `HTTP ${resp.status}` };
|
||||
}
|
||||
|
||||
const maxLength = Math.max(1, Math.min(200000, args.maxLength ?? 50000));
|
||||
let content = resp.data || '';
|
||||
if (content.length > maxLength) {
|
||||
content = content.slice(0, maxLength) + '\n\n[Content truncated]';
|
||||
}
|
||||
|
||||
return { ok: true, data: { url, content, status: resp.status || 200 } };
|
||||
} catch (err) {
|
||||
return { ok: false, error: `URL fetch failed: ${err instanceof Error ? err.message : String(err)}` };
|
||||
}
|
||||
if (isObserver(permissionMode)) {
|
||||
return { ok: false, error: 'Observer mode: command execution is disabled. Switch to Confirm or Auto mode.' };
|
||||
}
|
||||
const safety = checkCommandSafety(command, commandBlocklist);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Matched pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
|
||||
const results: Record<string, { ok: boolean; output: string }> = {};
|
||||
|
||||
if (mode === 'sequential') {
|
||||
for (const sid of sessionIds) {
|
||||
const session = context.sessions.find(s => s.sessionId === sid);
|
||||
const label = session?.label || sid;
|
||||
const result = await bridge.aiExec(sid, command);
|
||||
results[label] = {
|
||||
ok: result.ok,
|
||||
output: result.ok
|
||||
? result.stdout || '(no output)'
|
||||
: `Error: ${result.error || result.stderr || 'Failed'}`,
|
||||
};
|
||||
if (!result.ok && stopOnError) break;
|
||||
}
|
||||
} else {
|
||||
const tasks = sessionIds.map((sid) => () => {
|
||||
const session = context.sessions.find(s => s.sessionId === sid);
|
||||
const label = session?.label || sid;
|
||||
return bridge.aiExec(sid, command).then(result => ({
|
||||
label,
|
||||
ok: result.ok,
|
||||
output: result.ok
|
||||
? result.stdout || '(no output)'
|
||||
: `Error: ${result.error || result.stderr || 'Failed'}`,
|
||||
}));
|
||||
});
|
||||
const resolved = await limitConcurrency(tasks, 10);
|
||||
for (const r of resolved) {
|
||||
results[r.label] = { ok: r.ok, output: r.output };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, data: { results } };
|
||||
}
|
||||
|
||||
214
infrastructure/ai/shared/webSearchProviders.ts
Normal file
214
infrastructure/ai/shared/webSearchProviders.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Web search provider implementations.
|
||||
*
|
||||
* Each provider function normalises its API response into a common
|
||||
* `{ results: Array<{ title, url, content }> }` shape so callers don't need
|
||||
* to know about provider-specific quirks.
|
||||
*
|
||||
* All HTTP requests go through `bridge.aiFetch()` to avoid CORS issues in the
|
||||
* renderer process.
|
||||
*/
|
||||
|
||||
import type { NetcattyBridge } from '../cattyAgent/executor';
|
||||
import type { WebSearchConfig } from '../types';
|
||||
import { WEB_SEARCH_PROVIDER_PRESETS } from '../types';
|
||||
|
||||
export interface WebSearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface BridgeFetchResponse {
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
data?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveApiHost(config: WebSearchConfig): string {
|
||||
return config.apiHost || WEB_SEARCH_PROVIDER_PRESETS[config.providerId].defaultApiHost;
|
||||
}
|
||||
|
||||
async function fetchJson(
|
||||
bridge: NetcattyBridge,
|
||||
url: string,
|
||||
method: string,
|
||||
headers: Record<string, string>,
|
||||
body?: string,
|
||||
): Promise<unknown> {
|
||||
const aiFetch = (bridge as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).aiFetch;
|
||||
if (!aiFetch) throw new Error('aiFetch is not available on the bridge');
|
||||
// Search API hosts are added to the allowlist via aiSyncWebSearch, no skipHostCheck needed
|
||||
const resp = await aiFetch(url, method, headers, body) as BridgeFetchResponse;
|
||||
if (!resp.ok) throw new Error(resp.error || `HTTP ${resp.status}`);
|
||||
return JSON.parse(resp.data || '{}');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tavily
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function searchTavily(
|
||||
bridge: NetcattyBridge,
|
||||
config: WebSearchConfig,
|
||||
query: string,
|
||||
maxResults: number,
|
||||
): Promise<WebSearchResult[]> {
|
||||
const host = resolveApiHost(config);
|
||||
const data = await fetchJson(bridge, `${host}/search`, 'POST', {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
}, JSON.stringify({
|
||||
query,
|
||||
max_results: maxResults,
|
||||
search_depth: 'basic',
|
||||
})) as { results?: Array<{ title?: string; url?: string; content?: string }> };
|
||||
|
||||
return (data.results || []).map(r => ({
|
||||
title: r.title || '',
|
||||
url: r.url || '',
|
||||
content: r.content || '',
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exa
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function searchExa(
|
||||
bridge: NetcattyBridge,
|
||||
config: WebSearchConfig,
|
||||
query: string,
|
||||
maxResults: number,
|
||||
): Promise<WebSearchResult[]> {
|
||||
const host = resolveApiHost(config);
|
||||
const data = await fetchJson(bridge, `${host}/search`, 'POST', {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': config.apiKey || '',
|
||||
}, JSON.stringify({
|
||||
query,
|
||||
numResults: maxResults,
|
||||
contents: { text: true },
|
||||
})) as { results?: Array<{ title?: string; url?: string; text?: string }> };
|
||||
|
||||
return (data.results || []).map(r => ({
|
||||
title: r.title || '',
|
||||
url: r.url || '',
|
||||
content: r.text || '',
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bocha
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function searchBocha(
|
||||
bridge: NetcattyBridge,
|
||||
config: WebSearchConfig,
|
||||
query: string,
|
||||
maxResults: number,
|
||||
): Promise<WebSearchResult[]> {
|
||||
const host = resolveApiHost(config);
|
||||
const data = await fetchJson(bridge, `${host}/v1/web-search`, 'POST', {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
}, JSON.stringify({
|
||||
query,
|
||||
count: maxResults,
|
||||
summary: true,
|
||||
})) as { webPages?: { value?: Array<{ name?: string; url?: string; snippet?: string; summary?: string }> } };
|
||||
|
||||
return (data.webPages?.value || []).map(r => ({
|
||||
title: r.name || '',
|
||||
url: r.url || '',
|
||||
content: r.summary || r.snippet || '',
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zhipu
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function searchZhipu(
|
||||
bridge: NetcattyBridge,
|
||||
config: WebSearchConfig,
|
||||
query: string,
|
||||
_maxResults: number,
|
||||
): Promise<WebSearchResult[]> {
|
||||
const host = resolveApiHost(config);
|
||||
const data = await fetchJson(bridge, `${host}/web_search`, 'POST', {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
}, JSON.stringify({
|
||||
search_query: query,
|
||||
search_engine: 'search_std',
|
||||
})) as { search_result?: Array<{ title?: string; link?: string; content?: string }> };
|
||||
|
||||
return (data.search_result || []).map(r => ({
|
||||
title: r.title || '',
|
||||
url: r.link || '',
|
||||
content: r.content || '',
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SearXNG
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function searchSearxng(
|
||||
bridge: NetcattyBridge,
|
||||
config: WebSearchConfig,
|
||||
query: string,
|
||||
_maxResults: number,
|
||||
): Promise<WebSearchResult[]> {
|
||||
const host = resolveApiHost(config);
|
||||
if (!host) throw new Error('SearXNG requires an API Host to be configured');
|
||||
const url = `${host}/search?q=${encodeURIComponent(query)}&format=json`;
|
||||
const data = await fetchJson(bridge, url, 'GET', {}) as {
|
||||
results?: Array<{ title?: string; url?: string; content?: string }>;
|
||||
};
|
||||
|
||||
return (data.results || []).map(r => ({
|
||||
title: r.title || '',
|
||||
url: r.url || '',
|
||||
content: r.content || '',
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatcher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROVIDER_SEARCH_FNS: Record<string, typeof searchTavily> = {
|
||||
tavily: searchTavily,
|
||||
exa: searchExa,
|
||||
bocha: searchBocha,
|
||||
zhipu: searchZhipu,
|
||||
searxng: searchSearxng,
|
||||
};
|
||||
|
||||
/**
|
||||
* Placeholder token for the web search API key.
|
||||
* The renderer sends this in HTTP headers; the main process replaces it
|
||||
* with the real decrypted key before the request is sent, so plaintext
|
||||
* keys never enter the renderer.
|
||||
*/
|
||||
const WEB_SEARCH_KEY_PLACEHOLDER = '__WEB_SEARCH_KEY__';
|
||||
|
||||
export async function executeWebSearchProvider(
|
||||
bridge: NetcattyBridge,
|
||||
config: WebSearchConfig,
|
||||
query: string,
|
||||
maxResults: number,
|
||||
): Promise<WebSearchResult[]> {
|
||||
const fn = PROVIDER_SEARCH_FNS[config.providerId];
|
||||
if (!fn) throw new Error(`Unsupported web search provider: ${config.providerId}`);
|
||||
// Use placeholder — main process replaces with real decrypted key before HTTP request
|
||||
const safeConfig = { ...config, apiKey: WEB_SEARCH_KEY_PLACEHOLDER };
|
||||
return fn(bridge, safeConfig, query, maxResults);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export interface ProviderConfig {
|
||||
defaultModel?: string;
|
||||
customHeaders?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
skipTLSVerify?: boolean; // skip TLS certificate verification (for self-signed certs)
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
@@ -162,6 +163,38 @@ export interface DiscoveredAgent {
|
||||
acpArgs?: string[];
|
||||
}
|
||||
|
||||
// Web Search types
|
||||
export type WebSearchProviderId = 'tavily' | 'exa' | 'bocha' | 'zhipu' | 'searxng';
|
||||
|
||||
export interface WebSearchConfig {
|
||||
providerId: WebSearchProviderId;
|
||||
apiKey?: string; // enc:v1: encrypted via credentialBridge
|
||||
apiHost?: string; // custom API endpoint (required for SearXNG)
|
||||
enabled: boolean;
|
||||
maxResults?: number; // default 5
|
||||
}
|
||||
|
||||
export const WEB_SEARCH_PROVIDER_PRESETS: Record<WebSearchProviderId, { name: string; defaultApiHost: string; requiresApiKey: boolean }> = {
|
||||
tavily: { name: 'Tavily', defaultApiHost: 'https://api.tavily.com', requiresApiKey: true },
|
||||
exa: { name: 'Exa', defaultApiHost: 'https://api.exa.ai', requiresApiKey: true },
|
||||
bocha: { name: 'Bocha', defaultApiHost: 'https://api.bochaai.com', requiresApiKey: true },
|
||||
zhipu: { name: 'Zhipu', defaultApiHost: 'https://open.bigmodel.cn/api/paas/v4', requiresApiKey: true },
|
||||
searxng: { name: 'SearXNG', defaultApiHost: '', requiresApiKey: false },
|
||||
};
|
||||
|
||||
/** Check if a WebSearchConfig is fully configured and ready to use. */
|
||||
export function isWebSearchReady(config?: WebSearchConfig | null): boolean {
|
||||
if (!config?.enabled) return false;
|
||||
const preset = WEB_SEARCH_PROVIDER_PRESETS[config.providerId];
|
||||
if (preset?.requiresApiKey && !config.apiKey) return false;
|
||||
if (config.providerId === 'searxng' && !config.apiHost) return false;
|
||||
// Validate apiHost is a well-formed URL if provided
|
||||
if (config.apiHost) {
|
||||
try { new URL(config.apiHost); } catch { return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// AI Settings (stored in localStorage)
|
||||
export interface AISettings {
|
||||
providers: ProviderConfig[];
|
||||
@@ -173,6 +206,7 @@ export interface AISettings {
|
||||
commandBlocklist: string[]; // global command blocklist patterns
|
||||
commandTimeout: number; // seconds, default 60
|
||||
maxIterations: number; // doom loop prevention, default 20
|
||||
webSearchConfig?: WebSearchConfig;
|
||||
}
|
||||
|
||||
export const DEFAULT_COMMAND_BLOCKLIST = [
|
||||
|
||||
@@ -38,6 +38,7 @@ export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hos
|
||||
export const STORAGE_KEY_UPDATE_LAST_CHECK = 'netcatty_update_last_check_v1';
|
||||
export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_version_v1';
|
||||
export const STORAGE_KEY_UPDATE_LATEST_RELEASE = 'netcatty_update_latest_release_v1';
|
||||
export const STORAGE_KEY_AUTO_UPDATE_ENABLED = 'netcatty_auto_update_enabled_v1';
|
||||
|
||||
// SFTP File Opener Associations
|
||||
export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associations_v1';
|
||||
@@ -68,6 +69,7 @@ export const STORAGE_KEY_MANAGED_SOURCES = 'netcatty_managed_sources_v1';
|
||||
// Global Toggle Window Settings (Quake Mode)
|
||||
export const STORAGE_KEY_TOGGLE_WINDOW_HOTKEY = 'netcatty_toggle_window_hotkey_v1';
|
||||
export const STORAGE_KEY_CLOSE_TO_TRAY = 'netcatty_close_to_tray_v1';
|
||||
export const STORAGE_KEY_GLOBAL_HOTKEY_ENABLED = 'netcatty_global_hotkey_enabled_v1';
|
||||
|
||||
// Custom Terminal Themes
|
||||
export const STORAGE_KEY_CUSTOM_THEMES = 'netcatty_custom_themes_v1';
|
||||
@@ -85,3 +87,4 @@ export const STORAGE_KEY_AI_COMMAND_TIMEOUT = 'netcatty_ai_command_timeout_v1';
|
||||
export const STORAGE_KEY_AI_MAX_ITERATIONS = 'netcatty_ai_max_iterations_v1';
|
||||
export const STORAGE_KEY_AI_SESSIONS = 'netcatty_ai_sessions_v1';
|
||||
export const STORAGE_KEY_AI_AGENT_MODEL_MAP = 'netcatty_ai_agent_model_map_v1';
|
||||
export const STORAGE_KEY_AI_WEB_SEARCH = 'netcatty_ai_web_search_v1';
|
||||
|
||||
BIN
public/ai/search/bocha.webp
Normal file
BIN
public/ai/search/bocha.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
BIN
public/ai/search/exa.png
Normal file
BIN
public/ai/search/exa.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
1
public/ai/search/searxng.svg
Normal file
1
public/ai/search/searxng.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 92 92"><g transform="translate(-40.921 -17.417)"><circle cx="75.921" cy="53.903" r="30" style="fill:none;stroke:#3050ff;stroke-width:10"/><path d="M67.515 37.915a18 18 0 0 1 21.051 3.313 18 18 0 0 1 3.138 21.078" style="fill:none;stroke:#3050ff;stroke-width:5"/><rect width="18.846" height="39.963" x="3.706" y="122.09" ry="0" style="fill:#3050ff" transform="rotate(-46.235)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 441 B |
3
public/ai/search/tavily.svg
Normal file
3
public/ai/search/tavily.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M39.5137 0C45.2842 0 48.17 0 50.374 1.12305C52.3127 2.11089 53.8892 3.68731 54.877 5.62598C56 7.82995 56 10.7153 56 16.4854V39.5146C56 45.2847 56 48.17 54.877 50.374C53.8891 52.3127 52.3127 53.8891 50.374 54.877C48.17 56 45.2842 56 39.5137 56H16.4854C10.7148 56 7.82905 56 5.625 54.877C3.68646 53.8891 2.11082 52.3126 1.12305 50.374C0 48.17 0 45.2849 0 39.5146V16.4854C0 10.7151 0 7.82999 1.12305 5.62598C2.11082 3.68739 3.68646 2.11089 5.625 1.12305C7.82905 0 10.7148 0 16.4854 0H39.5137ZM23.8105 30.958C23.5077 30.9581 23.2076 31.0175 22.9277 31.1338C22.6478 31.2502 22.393 31.4216 22.1787 31.6367L17.7705 36.0625L16.5986 34.8867C15.7377 34.0228 14.2649 34.4498 13.9971 35.6426L12.3271 43.0713C12.2686 43.3267 12.2752 43.593 12.3477 43.8447C12.4199 44.0956 12.555 44.3246 12.7393 44.5088L12.7383 44.5107C12.922 44.6967 13.1498 44.8324 13.4004 44.9053C13.6513 44.9782 13.9173 44.9856 14.1719 44.9268L21.5713 43.25C22.7588 42.9812 23.1851 41.502 22.3242 40.6377L21.1523 39.4619L25.5615 35.0371C25.9943 34.6025 26.2373 34.012 26.2373 33.3975C26.2372 32.783 25.9942 32.1934 25.5615 31.7588L25.5029 31.6992L25.5049 31.6982L25.4434 31.6367C25.229 31.4215 24.9744 31.2503 24.6943 31.1338C24.4144 31.0174 24.1136 30.958 23.8105 30.958ZM39.7139 28.1689C38.6842 27.5158 37.3429 28.2597 37.3428 29.4824V31.1445H27.8955C28.2111 31.7502 28.3916 32.439 28.3916 33.1699C28.3915 34.2266 28.0177 35.196 27.3965 35.9521H37.3418V37.6143C37.342 38.837 38.6843 39.58 39.7139 38.9268L46.1279 34.8613C46.6077 34.5556 46.8476 34.0509 46.8477 33.5469C46.847 33.0436 46.6067 32.5399 46.126 32.2354L39.7139 28.1689ZM24.0391 10.4062C23.778 10.4051 23.5207 10.4712 23.292 10.5977C23.063 10.7243 22.869 10.9083 22.7305 11.1309L18.6807 17.5684H18.6787C18.028 18.602 18.7694 19.9499 19.9873 19.9502H21.6436V29.5137C22.3307 29.0592 23.1537 28.794 24.0381 28.7939C24.9228 28.794 25.7453 29.0599 26.4326 29.5146V19.9502H28.0898C29.3077 19.9501 30.047 18.6028 29.3975 17.5684L25.3457 11.1309C25.0415 10.6489 24.5406 10.4068 24.0391 10.4062Z" fill="#468BFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/ai/search/zhipu.png
Normal file
BIN
public/ai/search/zhipu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Reference in New Issue
Block a user