Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63558b5301 | ||
|
|
c2b4d43531 | ||
|
|
4d5c0eed69 | ||
|
|
3ad710e5da | ||
|
|
d2e5a26317 | ||
|
|
4f1eb4a8a9 | ||
|
|
e35bb708a2 | ||
|
|
cd2631428e | ||
|
|
09af399543 | ||
|
|
db9970d040 | ||
|
|
3d4fbf8763 | ||
|
|
9387590696 | ||
|
|
74a04f1d8e | ||
|
|
3c258b0f19 | ||
|
|
6303eef3a2 | ||
|
|
a9a648039f | ||
|
|
ccfa2d4dd0 | ||
|
|
7c5478b2a5 | ||
|
|
338ba94d42 | ||
|
|
1d4ec7afb9 | ||
|
|
a1899951e0 | ||
|
|
b7b2e91fab | ||
|
|
cd723000fc | ||
|
|
d84668aa0f | ||
|
|
68d0f4574c | ||
|
|
fff031eb25 | ||
|
|
2f1fd399cf | ||
|
|
43c4d4c430 | ||
|
|
835a1231a6 | ||
|
|
cd512d0800 | ||
|
|
0c5ae13692 | ||
|
|
6727248924 | ||
|
|
bedf59bb48 | ||
|
|
793ea94078 | ||
|
|
0eee7bf95a | ||
|
|
b2406ec8a5 | ||
|
|
5fde9c2d61 | ||
|
|
06a6a0ac12 | ||
|
|
024e60ead1 | ||
|
|
fe71790f0a | ||
|
|
9371b3d01b | ||
|
|
5a1d279efd | ||
|
|
8b0cbf02c3 | ||
|
|
d19fe45a14 | ||
|
|
344946b096 | ||
|
|
fcd15707d2 | ||
|
|
42c82e46ea | ||
|
|
0e1c3b621a | ||
|
|
3cd3bbaaf7 | ||
|
|
8bfb50fcbb | ||
|
|
c39ef879c3 | ||
|
|
b3d5785477 | ||
|
|
05de49f7da |
@@ -270,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',
|
||||
@@ -1407,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',
|
||||
@@ -1502,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',
|
||||
@@ -1607,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',
|
||||
|
||||
@@ -1146,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': '输出时自动滚动',
|
||||
@@ -1422,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': '点击设置快捷键',
|
||||
@@ -1517,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': '刷新模型列表',
|
||||
@@ -1622,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': '权限模式',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -30,6 +32,14 @@ function getAIBridge() {
|
||||
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
|
||||
}
|
||||
|
||||
function cleanupAcpSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
|
||||
for (const sessionId of sessionIds) {
|
||||
void bridge.aiAcpCleanup(sessionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Maximum number of sessions to keep in localStorage. */
|
||||
const MAX_STORED_SESSIONS = 50;
|
||||
@@ -114,6 +124,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 +141,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 +306,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);
|
||||
@@ -357,6 +384,7 @@ export function useAIState() {
|
||||
}, [defaultAgentId, persistSessions, setActiveSessionId]);
|
||||
|
||||
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
|
||||
cleanupAcpSessions([sessionId]);
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
@@ -375,6 +403,10 @@ export function useAIState() {
|
||||
}, [persistSessions]);
|
||||
|
||||
const deleteSessionsByTarget = useCallback((scopeType: 'terminal' | 'workspace', targetId: string) => {
|
||||
const removedSessionIds = sessionsRef.current
|
||||
.filter(s => s.scope.type === scopeType && s.scope.targetId === targetId)
|
||||
.map(s => s.id);
|
||||
cleanupAcpSessions(removedSessionIds);
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
@@ -401,6 +433,18 @@ export function useAIState() {
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => (
|
||||
s.id === sessionId
|
||||
? { ...s, externalSessionId, updatedAt: Date.now() }
|
||||
: s
|
||||
));
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
// Maximum messages per session to prevent unbounded memory growth
|
||||
const MAX_MESSAGES_PER_SESSION = 500;
|
||||
|
||||
@@ -465,6 +509,10 @@ export function useAIState() {
|
||||
}, [persistSessions]);
|
||||
|
||||
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
|
||||
const removedSessionIds = sessionsRef.current
|
||||
.filter(s => s.scope.targetId && !activeTargetIds.has(s.scope.targetId))
|
||||
.map(s => s.id);
|
||||
cleanupAcpSessions(removedSessionIds);
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => {
|
||||
// Keep sessions without a targetId (global scope)
|
||||
@@ -541,6 +589,10 @@ export function useAIState() {
|
||||
agentModelMap,
|
||||
setAgentModel,
|
||||
|
||||
// Web search
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
|
||||
// Sessions (per-scope active session)
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
@@ -549,6 +601,7 @@ export function useAIState() {
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
|
||||
@@ -52,10 +52,13 @@ interface SyncNowOptions {
|
||||
export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const { t } = useI18n();
|
||||
const sync = useCloudSync();
|
||||
const { onApplyPayload } = config;
|
||||
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSyncedDataRef = useRef<string>('');
|
||||
const hasCheckedRemoteRef = useRef(false);
|
||||
const isInitializedRef = useRef(false);
|
||||
const isSyncRunningRef = useRef(false);
|
||||
const skipNextSyncRef = useRef(false);
|
||||
|
||||
const getSyncSnapshot = useCallback(() => {
|
||||
let effectivePFRules = config.portForwardingRules;
|
||||
@@ -114,6 +117,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
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();
|
||||
@@ -160,6 +164,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
const results = await sync.syncNow(payload);
|
||||
|
||||
// Apply merged payloads first (before checking for failures) so local
|
||||
// state gets updated even when some providers failed
|
||||
for (const result of results.values()) {
|
||||
if (result.mergedPayload) {
|
||||
onApplyPayload(result.mergedPayload);
|
||||
skipNextSyncRef.current = true;
|
||||
break; // All providers share the same merged payload
|
||||
}
|
||||
}
|
||||
|
||||
for (const result of results.values()) {
|
||||
if (!result.success) {
|
||||
if (result.conflictDetected) {
|
||||
@@ -179,8 +193,10 @@ 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]);
|
||||
}, [sync, buildPayload, getDataHash, onApplyPayload, t]);
|
||||
|
||||
// Check remote version and pull if newer (on startup)
|
||||
const checkRemoteVersion = useCallback(async () => {
|
||||
@@ -203,18 +219,26 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
try {
|
||||
console.log('[AutoSync] Checking remote version...');
|
||||
// Load base BEFORE downloading (downloadFromProvider overwrites the base)
|
||||
const base = await manager.loadSyncBase(connectedProvider);
|
||||
const remotePayload = await sync.downloadFromProvider(connectedProvider);
|
||||
|
||||
|
||||
if (remotePayload && remotePayload.syncedAt > state.localUpdatedAt) {
|
||||
console.log('[AutoSync] Remote is newer, applying...');
|
||||
config.onApplyPayload(remotePayload);
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const localPayload = buildPayload();
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
|
||||
console.log('[AutoSync] Remote is newer, merged:', mergeResult.summary);
|
||||
config.onApplyPayload(mergeResult.payload);
|
||||
// Don't save base or skip auto-sync — let the data-change effect
|
||||
// naturally trigger an upload of the merged payload (which will
|
||||
// go through syncAllProviders and save base on success).
|
||||
toast.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
// Don't show error toast for initial check - it's not critical
|
||||
}
|
||||
}, [sync, config, t]);
|
||||
}, [sync, config, buildPayload, t]);
|
||||
|
||||
// Debounced auto-sync when data changes
|
||||
useEffect(() => {
|
||||
@@ -231,7 +255,15 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
|
||||
const currentHash = getDataHash();
|
||||
|
||||
|
||||
// After a merge, onApplyPayload changes local state which triggers
|
||||
// this effect. Skip that cycle and just update the hash baseline.
|
||||
if (skipNextSyncRef.current) {
|
||||
skipNextSyncRef.current = false;
|
||||
lastSyncedDataRef.current = currentHash;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if data hasn't changed
|
||||
if (currentHash === lastSyncedDataRef.current) {
|
||||
return;
|
||||
@@ -239,7 +271,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -90,7 +90,7 @@ export const useTerminalBackend = () => {
|
||||
return bridge.onSessionData(sessionId, cb);
|
||||
}, []);
|
||||
|
||||
const onSessionExit = useCallback((sessionId: string, cb: (evt: { exitCode?: number; signal?: number }) => void) => {
|
||||
const onSessionExit = useCallback((sessionId: string, cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSessionExit) throw new Error("onSessionExit unavailable");
|
||||
return bridge.onSessionExit(sessionId, cb);
|
||||
|
||||
@@ -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';
|
||||
@@ -41,6 +42,7 @@ import ConversationExport from './ai/ConversationExport';
|
||||
import { useAIChatStreaming, getNetcattyBridge } from './ai/hooks/useAIChatStreaming';
|
||||
import { useToolApproval } from './ai/hooks/useToolApproval';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Props
|
||||
@@ -54,6 +56,7 @@ interface AIChatSidePanelProps {
|
||||
createSession: (scope: AISessionScope, agentId?: string) => AISession;
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (
|
||||
sessionId: string,
|
||||
@@ -82,6 +85,9 @@ interface AIChatSidePanelProps {
|
||||
commandBlocklist?: string[];
|
||||
maxIterations?: number;
|
||||
|
||||
// Web search
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
|
||||
// Context
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
@@ -98,6 +104,11 @@ interface AIChatSidePanelProps {
|
||||
username?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
resolveExecutorContext?: (scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
targetId?: string;
|
||||
label?: string;
|
||||
}) => ExecutorContext;
|
||||
|
||||
// Visibility
|
||||
isVisible?: boolean;
|
||||
@@ -111,6 +122,35 @@ function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user' | 'assistant'; content: string }> {
|
||||
return messages.flatMap((message) => {
|
||||
if (message.role === 'system') return [];
|
||||
|
||||
if (message.role === 'user') {
|
||||
return message.content ? [{ role: 'user' as const, content: message.content }] : [];
|
||||
}
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
const parts: string[] = [];
|
||||
if (message.content) parts.push(message.content);
|
||||
if (message.toolCalls?.length) {
|
||||
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
|
||||
}
|
||||
if (!parts.length) return [];
|
||||
return [{ role: 'assistant' as const, content: parts.join('\n\n') }];
|
||||
}
|
||||
|
||||
if (message.role === 'tool' && message.toolResults?.length) {
|
||||
return message.toolResults.map((tr) => ({
|
||||
role: 'assistant' as const,
|
||||
content: `Tool result:\n${tr.content}`,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
@@ -122,6 +162,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
createSession,
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
@@ -137,11 +178,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
setGlobalPermissionMode,
|
||||
commandBlocklist,
|
||||
maxIterations = 20,
|
||||
webSearchConfig,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
scopeLabel,
|
||||
terminalSessions = [],
|
||||
resolveExecutorContext,
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -161,6 +204,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const { images, addImages, removeImage, clearImages } = useImageUpload();
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
const terminalSessionsRef = useRef(terminalSessions);
|
||||
terminalSessionsRef.current = terminalSessions;
|
||||
const resolveExecutorContextRef = useRef(resolveExecutorContext);
|
||||
resolveExecutorContextRef.current = resolveExecutorContext;
|
||||
|
||||
// ── Streaming hook ──
|
||||
const {
|
||||
@@ -227,16 +274,23 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}, [providers]);
|
||||
|
||||
// Abort all active streams and clean up on unmount
|
||||
// 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]);
|
||||
|
||||
// Preserve active streams across tab switches. The panel is conditionally
|
||||
// mounted per tab, so unmounting here should not cancel in-flight work.
|
||||
useEffect(() => {
|
||||
const controllers = abortControllersRef.current;
|
||||
return () => {
|
||||
controllers.forEach(c => c.abort());
|
||||
controllers.clear();
|
||||
// Clear pending approval (clears timeout too via setPendingApproval)
|
||||
setPendingApproval(null);
|
||||
// no-op: stream lifecycle is managed by explicit stop/delete actions
|
||||
};
|
||||
}, [abortControllersRef, setPendingApproval]);
|
||||
}, []);
|
||||
|
||||
// Agent discovery
|
||||
const {
|
||||
@@ -364,6 +418,20 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}, [updateSessionTitle]);
|
||||
|
||||
const buildExecutorContextForScope = useCallback((scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
targetId?: string;
|
||||
label?: string;
|
||||
}): ExecutorContext => {
|
||||
const resolved = resolveExecutorContextRef.current?.(scope);
|
||||
if (resolved) return resolved;
|
||||
return {
|
||||
sessions: terminalSessionsRef.current,
|
||||
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
|
||||
workspaceName: scope.type === 'workspace' ? scope.label : undefined,
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** Ensure a session exists for the current scope and return its ID. */
|
||||
const ensureSession = useCallback((): string => {
|
||||
if (activeSessionId) return activeSessionId;
|
||||
@@ -430,6 +498,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
try {
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachedImages, {
|
||||
existingSessionId: currentSession?.externalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
|
||||
terminalSessions,
|
||||
providers,
|
||||
selectedAgentModel,
|
||||
@@ -443,6 +514,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
autoTitleSession(sessionId, trimmed);
|
||||
} else {
|
||||
const toolScope = {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
label: scopeLabel,
|
||||
} as const;
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider,
|
||||
activeModelId,
|
||||
@@ -452,6 +528,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
setPendingApproval,
|
||||
autoTitleSession,
|
||||
});
|
||||
@@ -462,8 +540,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope, setInputValue, clearImages,
|
||||
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
||||
abortControllersRef, terminalSessions, providers, selectedAgentModel,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, setPendingApproval,
|
||||
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope, setPendingApproval,
|
||||
]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
@@ -476,7 +554,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
updateLastMessage(activeSessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'completed' : msg.executionStatus,
|
||||
executionStatus: msg.executionStatus === 'running' ? 'cancelled' : msg.executionStatus,
|
||||
}));
|
||||
// Also clear any pending approval (clears timeout too via setPendingApproval)
|
||||
if (pendingApprovalContextRef.current?.sessionId === activeSessionId) {
|
||||
@@ -500,8 +578,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const handleDeleteSession = useCallback(
|
||||
(e: React.MouseEvent, sessionId: string) => {
|
||||
e.stopPropagation();
|
||||
const bridge = getNetcattyBridge();
|
||||
void bridge?.aiAcpCleanup?.(sessionId).catch(() => {});
|
||||
deleteSession(sessionId, scopeKey);
|
||||
// Active session clearing is handled by deleteSession with scopeKey
|
||||
},
|
||||
@@ -578,20 +654,14 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
onApprove={(messageId) => void handleApprovalResponse(messageId, true, {
|
||||
terminalSessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
webSearchConfig,
|
||||
})}
|
||||
onReject={(messageId) => void handleApprovalResponse(messageId, false, {
|
||||
terminalSessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
webSearchConfig,
|
||||
})}
|
||||
/>
|
||||
|
||||
|
||||
@@ -978,6 +978,10 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const result = await sync.syncToProvider(provider, payload);
|
||||
|
||||
if (result.success) {
|
||||
// Apply merged data if a three-way merge happened
|
||||
if (result.mergedPayload && onApplyPayload) {
|
||||
onApplyPayload(result.mergedPayload);
|
||||
}
|
||||
toast.success(t('cloudSync.sync.success', { provider }));
|
||||
} else if (result.conflictDetected) {
|
||||
// Conflict modal will show automatically
|
||||
|
||||
@@ -61,6 +61,7 @@ interface TreeNodeProps {
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
}
|
||||
|
||||
|
||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
node,
|
||||
depth,
|
||||
@@ -89,6 +90,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 = node.totalHostCount ?? node.hosts.length;
|
||||
|
||||
const childNodes = useMemo(() => {
|
||||
if (!node.children) return [];
|
||||
@@ -171,7 +173,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>
|
||||
|
||||
@@ -621,7 +621,7 @@ echo $3 >> "$FILE"`);
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44" align="start" alignToParent>
|
||||
<DropdownContent className="w-48" align="start" alignToParent>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -284,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>
|
||||
|
||||
@@ -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,12 +118,13 @@ interface TerminalProps {
|
||||
terminalSettings?: TerminalSettings;
|
||||
sessionId: string;
|
||||
startupCommand?: string;
|
||||
noAutoRun?: boolean;
|
||||
serialConfig?: SerialConfig;
|
||||
hotkeyScheme?: "disabled" | "mac" | "pc";
|
||||
keyBindings?: KeyBinding[];
|
||||
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
|
||||
onStatusChange?: (sessionId: string, status: TerminalSession["status"]) => void;
|
||||
onSessionExit?: (sessionId: string) => void;
|
||||
onSessionExit?: (sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onOsDetected?: (hostId: string, distro: string) => void;
|
||||
onCloseSession?: (sessionId: string) => void;
|
||||
@@ -184,6 +185,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalSettings,
|
||||
sessionId,
|
||||
startupCommand,
|
||||
noAutoRun,
|
||||
serialConfig,
|
||||
hotkeyScheme = "disabled",
|
||||
keyBindings = [],
|
||||
@@ -238,22 +240,23 @@ 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;
|
||||
|
||||
// Global and host-level highlights are independent:
|
||||
// global toggle controls global rules, host toggle controls host-specific rules
|
||||
const effectiveGlobalEnabled = 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 +374,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 +451,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
resolvedChainHosts,
|
||||
sessionId,
|
||||
startupCommand,
|
||||
noAutoRun,
|
||||
terminalSettings,
|
||||
terminalSettingsRef,
|
||||
terminalBackend,
|
||||
@@ -502,6 +527,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serialLocalEcho: serialConfig?.localEcho,
|
||||
serialLineMode: serialConfig?.lineMode,
|
||||
serialLineBufferRef,
|
||||
onOsc52ReadRequest: handleOsc52ReadRequest,
|
||||
});
|
||||
|
||||
xtermRuntimeRef.current = runtime;
|
||||
@@ -516,12 +542,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 = 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 +1706,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"
|
||||
|
||||
@@ -171,9 +171,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onUpdateSessionStatus(sessionId, status);
|
||||
}, [onUpdateSessionStatus]);
|
||||
|
||||
const handleSessionExit = useCallback((sessionId: string) => {
|
||||
onUpdateSessionStatus(sessionId, 'disconnected');
|
||||
}, [onUpdateSessionStatus]);
|
||||
const handleSessionExit = useCallback((sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => {
|
||||
// Auto-close the tab/session when the user actively exited (e.g. typed `exit`)
|
||||
// reason === "exited" means the remote process/shell exited normally (stream-level close),
|
||||
// as opposed to network errors, timeouts, or connection-level drops
|
||||
if (evt.reason === "exited") {
|
||||
onCloseSession(sessionId);
|
||||
} else {
|
||||
onUpdateSessionStatus(sessionId, 'disconnected');
|
||||
}
|
||||
}, [onUpdateSessionStatus, onCloseSession]);
|
||||
|
||||
const handleOsDetected = useCallback((hostId: string, distro: string) => {
|
||||
onUpdateHostDistro(hostId, distro);
|
||||
@@ -237,6 +244,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
activeTabIdRef.current = activeTabId;
|
||||
const activeWorkspaceRef = useRef(activeWorkspace);
|
||||
activeWorkspaceRef.current = activeWorkspace;
|
||||
const sessionsRef = useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
const workspacesRef = useRef(workspaces);
|
||||
workspacesRef.current = workspaces;
|
||||
const hostsRef = useRef(hosts);
|
||||
hostsRef.current = hosts;
|
||||
const onSetWorkspaceFocusedSessionRef = useRef(onSetWorkspaceFocusedSession);
|
||||
onSetWorkspaceFocusedSessionRef.current = onSetWorkspaceFocusedSession;
|
||||
|
||||
@@ -864,10 +877,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}"]`);
|
||||
@@ -929,15 +942,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const aiState = useAIState();
|
||||
const { cleanupOrphanedSessions } = aiState;
|
||||
|
||||
// On mount: clean up orphaned AI sessions after a short delay
|
||||
// (allows sessions/workspaces to fully initialize)
|
||||
const hasCleanedUpRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hasCleanedUpRef.current) return;
|
||||
// Guard: wait until both sessions AND workspaces have loaded to avoid
|
||||
// racing with partial state (e.g. sessions loaded but workspaces not yet).
|
||||
if (sessions.length === 0 || workspaces.length === 0) return;
|
||||
hasCleanedUpRef.current = true;
|
||||
const activeIds = new Set<string>();
|
||||
for (const s of sessions) activeIds.add(s.id);
|
||||
for (const w of workspaces) activeIds.add(w.id);
|
||||
@@ -966,6 +971,44 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return result;
|
||||
}, [sessions, hosts, activeWorkspace, activeSession]);
|
||||
|
||||
const resolveAIExecutorContext = useCallback((scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
targetId?: string;
|
||||
label?: string;
|
||||
}) => {
|
||||
const latestWorkspaces = workspacesRef.current;
|
||||
const latestSessions = sessionsRef.current;
|
||||
const latestHosts = hostsRef.current;
|
||||
const sessionIds = scope.type === 'workspace'
|
||||
? (() => {
|
||||
const workspace = scope.targetId ? latestWorkspaces.find((w) => w.id === scope.targetId) : undefined;
|
||||
return workspace?.root ? collectSessionIds(workspace.root) : [];
|
||||
})()
|
||||
: scope.targetId ? [scope.targetId] : [];
|
||||
|
||||
const workspaceName = scope.type === 'workspace'
|
||||
? latestWorkspaces.find((w) => w.id === scope.targetId)?.title ?? scope.label
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
sessions: sessionIds.map((sid) => {
|
||||
const session = latestSessions.find((s) => s.id === sid);
|
||||
const host = session?.hostId ? latestHosts.find((h) => h.id === session.hostId) : undefined;
|
||||
return {
|
||||
sessionId: sid,
|
||||
hostId: session?.hostId || '',
|
||||
hostname: host?.hostname || '',
|
||||
label: host?.label || session?.hostLabel || '',
|
||||
os: host?.os,
|
||||
username: host?.username,
|
||||
connected: session?.status === 'connected',
|
||||
};
|
||||
}),
|
||||
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
|
||||
workspaceName,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
@@ -1333,6 +1376,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
createSession={aiState.createSession}
|
||||
deleteSession={aiState.deleteSession}
|
||||
updateSessionTitle={aiState.updateSessionTitle}
|
||||
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
|
||||
addMessageToSession={aiState.addMessageToSession}
|
||||
updateLastMessage={aiState.updateLastMessage}
|
||||
updateMessageById={aiState.updateMessageById}
|
||||
@@ -1348,6 +1392,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
|
||||
@@ -1357,8 +1402,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}).filter((id): id is string => !!id)
|
||||
: activeSession?.hostId ? [activeSession.hostId] : []
|
||||
}
|
||||
scopeLabel={activeWorkspace?.name ?? activeSession?.label ?? ''}
|
||||
scopeLabel={activeWorkspace?.title ?? activeSession?.hostLabel ?? ''}
|
||||
terminalSessions={aiTerminalSessions}
|
||||
resolveExecutorContext={resolveAIExecutorContext}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1487,6 +1533,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
terminalSettings={terminalSettings}
|
||||
sessionId={session.id}
|
||||
startupCommand={session.startupCommand}
|
||||
noAutoRun={session.noAutoRun}
|
||||
serialConfig={session.serialConfig}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
|
||||
@@ -689,6 +689,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
],
|
||||
);
|
||||
|
||||
const countAllHostsInNode = (node: GroupNode): number => {
|
||||
let count = node.hosts.length;
|
||||
Object.values(node.children).forEach((child) => {
|
||||
count += countAllHostsInNode(child);
|
||||
});
|
||||
node.totalHostCount = count;
|
||||
return count;
|
||||
};
|
||||
|
||||
const buildGroupTree = useMemo<Record<string, GroupNode>>(() => {
|
||||
const root: Record<string, GroupNode> = {};
|
||||
const insertPath = (path: string, host?: Host) => {
|
||||
@@ -712,6 +721,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
};
|
||||
customGroups.forEach((path) => insertPath(path));
|
||||
hosts.forEach((host) => insertPath(host.group || "General", host));
|
||||
|
||||
Object.values(root).forEach(countAllHostsInNode);
|
||||
|
||||
return root;
|
||||
}, [hosts, customGroups]);
|
||||
|
||||
@@ -896,20 +908,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
insertPath(host.group, host);
|
||||
}
|
||||
});
|
||||
|
||||
Object.values(root).forEach(countAllHostsInNode);
|
||||
|
||||
return root;
|
||||
}, [treeViewHosts, customGroups]);
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
// Create tree view specific group tree that excludes ungrouped hosts
|
||||
const treeViewGroupTree = useMemo<GroupNode[]>(() => {
|
||||
return (Object.values(buildTreeViewGroupTree) as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name));
|
||||
@@ -1749,7 +1753,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("vault.groups.hostsCount", { count: countAllHostsInNode(node) })}
|
||||
{t("vault.groups.hostsCount", { count: node.totalHostCount ?? node.hosts.length })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2201,6 +2205,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
: [...snippets, s],
|
||||
)
|
||||
}
|
||||
onBulkSave={onUpdateSnippets}
|
||||
onDelete={(id) =>
|
||||
onUpdateSnippets(snippets.filter((s) => s.id !== id))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ChevronDown, ChevronRight, CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, CheckCircle2, Loader2, XCircle, Slash } from 'lucide-react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -9,13 +9,16 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
isLoading?: boolean;
|
||||
isInterrupted?: boolean;
|
||||
}
|
||||
|
||||
export const ToolCall = ({ name, args, result, isError, isLoading, className, ...props }: ToolCallProps) => {
|
||||
export const ToolCall = ({ name, args, result, isError, isLoading, isInterrupted, className, ...props }: ToolCallProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const statusIcon = isLoading ? (
|
||||
<Loader2 size={12} className="animate-spin text-blue-400/70" />
|
||||
) : isInterrupted ? (
|
||||
<Slash size={12} className="text-muted-foreground/55" />
|
||||
) : isError ? (
|
||||
<XCircle size={12} className="text-red-400/70" />
|
||||
) : result !== undefined ? (
|
||||
@@ -58,6 +61,14 @@ export const ToolCall = ({ name, args, result, isError, isLoading, className, ..
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{isInterrupted && result === undefined && (
|
||||
<div className="px-3 py-2 border-t border-border/20">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Status</div>
|
||||
<div className="text-[11px] text-muted-foreground/50">
|
||||
Interrupted
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,11 @@ interface ChatMessageListProps {
|
||||
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, onApprove, onReject }) => {
|
||||
const { t } = useI18n();
|
||||
const visibleMessages = messages.filter(m => m.role !== 'system');
|
||||
const resolvedToolCallIds = new Set(
|
||||
visibleMessages
|
||||
.filter((m) => m.role === 'tool')
|
||||
.flatMap((m) => m.toolResults?.map((tr) => tr.toolCallId) ?? []),
|
||||
);
|
||||
|
||||
if (visibleMessages.length === 0 && !isStreaming) {
|
||||
return (
|
||||
@@ -107,6 +112,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isThisStreaming && message.executionStatus === 'running'}
|
||||
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -133,7 +139,9 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-destructive/10 border border-destructive/20 text-sm">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-destructive font-medium">{message.errorInfo.message}</p>
|
||||
<p className="text-destructive font-medium whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
|
||||
{message.errorInfo.message}
|
||||
</p>
|
||||
{message.errorInfo.retryable && (
|
||||
<p className="text-muted-foreground text-xs mt-1">{t('ai.chat.retryHint')}</p>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* - Error reporting
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { streamText, stepCountIs, type ModelMessage } from 'ai';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
@@ -18,14 +18,16 @@ 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';
|
||||
import type { NetcattyBridge } from '../../../infrastructure/ai/cattyAgent/executor';
|
||||
import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
|
||||
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
|
||||
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
|
||||
import { classifyError, sanitizeErrorMessage } from '../../../infrastructure/ai/errorClassifier';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Stream chunk type interfaces (Issue #13: replace unsafe casts)
|
||||
@@ -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;
|
||||
@@ -132,12 +135,29 @@ export interface PendingApprovalContext {
|
||||
model: ReturnType<typeof createModelFromConfig>;
|
||||
systemPrompt: string;
|
||||
tools: ReturnType<typeof createCattyTools>;
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeLabel?: string;
|
||||
getExecutorContext: () => ExecutorContext;
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
const sharedStreamingSessionIds = new Set<string>();
|
||||
const sharedAbortControllers = new Map<string, AbortController>();
|
||||
const streamingSubscribers = new Set<() => void>();
|
||||
|
||||
function emitStreamingStoreChange(): void {
|
||||
streamingSubscribers.forEach(listener => {
|
||||
try {
|
||||
listener();
|
||||
} catch (err) {
|
||||
console.error('[AIChatStreaming] Failed to notify streaming subscriber:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook parameters
|
||||
// -------------------------------------------------------------------
|
||||
@@ -203,12 +223,17 @@ export interface SendToCattyContext {
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
getExecutorContext?: () => ExecutorContext;
|
||||
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
|
||||
autoTitleSession: (sessionId: string, text: string) => void;
|
||||
}
|
||||
|
||||
/** Context values needed by sendToExternalAgent that change frequently. */
|
||||
export interface SendToExternalContext {
|
||||
existingSessionId?: string;
|
||||
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
providers: ProviderConfig[];
|
||||
selectedAgentModel?: string;
|
||||
@@ -225,17 +250,34 @@ export function useAIChatStreaming({
|
||||
updateMessageById,
|
||||
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
|
||||
// Per-session streaming state (keyed by sessionId)
|
||||
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(new Set());
|
||||
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(
|
||||
() => new Set(sharedStreamingSessionIds),
|
||||
);
|
||||
useEffect(() => {
|
||||
const syncFromStore = () => {
|
||||
setStreamingSessions(new Set(sharedStreamingSessionIds));
|
||||
};
|
||||
streamingSubscribers.add(syncFromStore);
|
||||
syncFromStore();
|
||||
return () => {
|
||||
streamingSubscribers.delete(syncFromStore);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setStreamingForScope = useCallback((key: string, val: boolean) => {
|
||||
setStreamingSessions(prev => {
|
||||
const next = new Set(prev);
|
||||
if (val) next.add(key); else next.delete(key);
|
||||
return next;
|
||||
});
|
||||
const hadKey = sharedStreamingSessionIds.has(key);
|
||||
if (val) {
|
||||
sharedStreamingSessionIds.add(key);
|
||||
} else {
|
||||
sharedStreamingSessionIds.delete(key);
|
||||
}
|
||||
if (hadKey !== val) {
|
||||
emitStreamingStoreChange();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Per-scope abort controllers
|
||||
const abortControllersRef = useRef<Map<string, AbortController>>(new Map());
|
||||
const abortControllersRef = useRef<Map<string, AbortController>>(sharedAbortControllers);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// reportStreamError
|
||||
@@ -247,12 +289,14 @@ 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);
|
||||
// Sanitize the displayed message to avoid leaking paths, keys, or other sensitive info
|
||||
errorInfo.message = sanitizeErrorMessage(errorInfo.message);
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
@@ -460,7 +504,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;
|
||||
@@ -569,6 +617,9 @@ export function useAIChatStreaming({
|
||||
maybeCreateAssistantMsg();
|
||||
updateLastMessage(sessionId, msg => ({ ...msg, statusText: message }));
|
||||
},
|
||||
onSessionId: (externalSessionId: string) => {
|
||||
context.updateExternalSessionId?.(sessionId, externalSessionId);
|
||||
},
|
||||
onError: (error: string) => {
|
||||
reportStreamError(sessionId, abortController.signal, error);
|
||||
setStreamingForScope(sessionId, false);
|
||||
@@ -578,6 +629,8 @@ export function useAIChatStreaming({
|
||||
abortController.signal,
|
||||
agentProviderId,
|
||||
context.selectedAgentModel,
|
||||
context.existingSessionId,
|
||||
context.historyMessages,
|
||||
attachedImages.length > 0 ? attachedImages : undefined,
|
||||
);
|
||||
} else {
|
||||
@@ -617,11 +670,18 @@ export function useAIChatStreaming({
|
||||
context: SendToCattyContext,
|
||||
) => {
|
||||
const bridge = getNetcattyBridge();
|
||||
const tools = createCattyTools(bridge, {
|
||||
const getExecutorContext = context.getExecutorContext ?? (() => ({
|
||||
sessions: context.terminalSessions,
|
||||
workspaceId: context.scopeTargetId,
|
||||
workspaceName: context.scopeLabel,
|
||||
}, context.commandBlocklist, context.globalPermissionMode);
|
||||
workspaceId: context.scopeType === 'workspace' ? context.scopeTargetId : undefined,
|
||||
workspaceName: context.scopeType === 'workspace' ? context.scopeLabel : undefined,
|
||||
}));
|
||||
const tools = createCattyTools(
|
||||
bridge,
|
||||
getExecutorContext,
|
||||
context.commandBlocklist,
|
||||
context.globalPermissionMode,
|
||||
context.webSearchConfig ?? undefined,
|
||||
);
|
||||
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
scopeType: context.scopeType, scopeLabel: context.scopeLabel,
|
||||
@@ -630,6 +690,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 +717,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 +753,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,23 +761,14 @@ 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
|
||||
// Gemini requires functionResponse.name to be non-empty,
|
||||
// so we look up the toolName from the preceding assistant tool calls.
|
||||
const findToolName = (toolCallId: string): string => {
|
||||
for (const prev of currentSession?.messages ?? []) {
|
||||
if (prev.role === 'assistant' && prev.toolCalls) {
|
||||
const tc = prev.toolCalls.find(t => t.id === toolCallId);
|
||||
if (tc) return tc.name;
|
||||
}
|
||||
}
|
||||
return 'unknown';
|
||||
};
|
||||
sdkMessages.push({
|
||||
role: 'tool',
|
||||
content: m.toolResults.map(tr => ({
|
||||
@@ -712,7 +786,16 @@ export function useAIChatStreaming({
|
||||
|
||||
if (approvalInfo) {
|
||||
context.setPendingApproval({
|
||||
sessionId, scopeKey: sendScopeKey, sdkMessages, approvalInfo, model, systemPrompt, tools,
|
||||
sessionId,
|
||||
scopeKey: sendScopeKey,
|
||||
sdkMessages,
|
||||
approvalInfo,
|
||||
model,
|
||||
systemPrompt,
|
||||
tools,
|
||||
scopeType: context.scopeType,
|
||||
scopeLabel: context.scopeLabel,
|
||||
getExecutorContext,
|
||||
});
|
||||
return; // Keep streaming flag — waiting for user approval
|
||||
}
|
||||
|
||||
@@ -13,14 +13,15 @@ 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';
|
||||
import type {
|
||||
ApprovalInfo,
|
||||
PendingApprovalContext,
|
||||
TerminalSessionInfo,
|
||||
} from './useAIChatStreaming';
|
||||
import { getNetcattyBridge } from './useAIChatStreaming';
|
||||
import type { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
|
||||
@@ -29,6 +30,9 @@ function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
let sharedPendingApprovalContext: PendingApprovalContext | null = null;
|
||||
let sharedPendingApprovalTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook parameters
|
||||
// -------------------------------------------------------------------
|
||||
@@ -70,12 +74,9 @@ export interface UseToolApprovalReturn {
|
||||
|
||||
/** Context values needed by handleApprovalResponse that change frequently. */
|
||||
export interface ToolApprovalContext {
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeLabel?: string;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -92,23 +93,23 @@ export function useToolApproval({
|
||||
t,
|
||||
}: UseToolApprovalParams): UseToolApprovalReturn {
|
||||
// Pending approval context — stores SDK state needed to resume after user approves/rejects
|
||||
const pendingApprovalContextRef = useRef<PendingApprovalContext | null>(null);
|
||||
|
||||
// Timeout ID for auto-clearing stale pending approval (Issue #14)
|
||||
const pendingApprovalTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingApprovalContextRef = useRef<PendingApprovalContext | null>(sharedPendingApprovalContext);
|
||||
pendingApprovalContextRef.current = sharedPendingApprovalContext;
|
||||
|
||||
/** Set pending approval context with a 5-minute auto-clear timeout. */
|
||||
const setPendingApproval = useCallback((ctx: PendingApprovalContext | null) => {
|
||||
// Clear any existing timeout
|
||||
if (pendingApprovalTimeoutRef.current) {
|
||||
clearTimeout(pendingApprovalTimeoutRef.current);
|
||||
pendingApprovalTimeoutRef.current = null;
|
||||
if (sharedPendingApprovalTimeout) {
|
||||
clearTimeout(sharedPendingApprovalTimeout);
|
||||
sharedPendingApprovalTimeout = null;
|
||||
}
|
||||
sharedPendingApprovalContext = ctx;
|
||||
pendingApprovalContextRef.current = ctx;
|
||||
if (ctx) {
|
||||
pendingApprovalTimeoutRef.current = setTimeout(() => {
|
||||
sharedPendingApprovalTimeout = setTimeout(() => {
|
||||
// Auto-clear after 5 minutes if user never responds
|
||||
if (pendingApprovalContextRef.current?.sessionId === ctx.sessionId) {
|
||||
if (sharedPendingApprovalContext?.sessionId === ctx.sessionId) {
|
||||
sharedPendingApprovalContext = null;
|
||||
pendingApprovalContextRef.current = null;
|
||||
setStreamingForScope(ctx.sessionId, false);
|
||||
abortControllersRef.current.get(ctx.sessionId)?.abort();
|
||||
@@ -126,7 +127,7 @@ export function useToolApproval({
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
pendingApprovalTimeoutRef.current = null;
|
||||
sharedPendingApprovalTimeout = null;
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
}
|
||||
}, [setStreamingForScope, abortControllersRef, updateLastMessage, addMessageToSession, t]);
|
||||
@@ -140,7 +141,16 @@ export function useToolApproval({
|
||||
const ctx = pendingApprovalContextRef.current;
|
||||
if (!ctx) return;
|
||||
// Destructure all needed values BEFORE clearing the ref to avoid race conditions
|
||||
const { sessionId: sid, scopeKey: sk, sdkMessages, approvalInfo, model: ctxModel } = ctx;
|
||||
const {
|
||||
sessionId: sid,
|
||||
scopeKey: sk,
|
||||
sdkMessages,
|
||||
approvalInfo,
|
||||
model: ctxModel,
|
||||
scopeType,
|
||||
scopeLabel,
|
||||
getExecutorContext,
|
||||
} = ctx;
|
||||
// Clear pending approval (and its timeout) via setPendingApproval
|
||||
setPendingApproval(null);
|
||||
|
||||
@@ -212,20 +222,25 @@ export function useToolApproval({
|
||||
|
||||
try {
|
||||
// Rebuild tools and system prompt with the latest permission mode to prevent
|
||||
// stale closure issues (e.g. user changed permission mode during approval wait)
|
||||
// stale settings, while keeping the original AI scope pinned to its workspace/session.
|
||||
const bridge = getNetcattyBridge();
|
||||
const freshTools = createCattyTools(bridge, {
|
||||
sessions: approvalContext.terminalSessions,
|
||||
workspaceId: approvalContext.scopeTargetId,
|
||||
workspaceName: approvalContext.scopeLabel,
|
||||
}, approvalContext.commandBlocklist, approvalContext.globalPermissionMode);
|
||||
const freshExecutorContext = getExecutorContext();
|
||||
const freshTools = createCattyTools(
|
||||
bridge,
|
||||
getExecutorContext,
|
||||
approvalContext.commandBlocklist,
|
||||
approvalContext.globalPermissionMode,
|
||||
approvalContext.webSearchConfig ?? undefined,
|
||||
);
|
||||
const freshSystemPrompt = buildSystemPrompt({
|
||||
scopeType: approvalContext.scopeType, scopeLabel: approvalContext.scopeLabel,
|
||||
hosts: approvalContext.terminalSessions.map(s => ({
|
||||
scopeType,
|
||||
scopeLabel,
|
||||
hosts: freshExecutorContext.sessions.map(s => ({
|
||||
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
|
||||
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);
|
||||
|
||||
@@ -239,6 +254,9 @@ export function useToolApproval({
|
||||
model: ctxModel,
|
||||
systemPrompt: freshSystemPrompt,
|
||||
tools: freshTools,
|
||||
scopeType,
|
||||
scopeLabel,
|
||||
getExecutorContext,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -597,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>
|
||||
|
||||
@@ -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;
|
||||
@@ -38,7 +41,7 @@ type TerminalBackendApi = {
|
||||
onSessionData: (sessionId: string, cb: (data: string) => void) => () => void;
|
||||
onSessionExit: (
|
||||
sessionId: string,
|
||||
cb: (evt: { exitCode?: number; signal?: number }) => void,
|
||||
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void,
|
||||
) => () => void;
|
||||
onChainProgress: (
|
||||
cb: (hop: number, total: number, label: string, status: string) => void,
|
||||
@@ -68,6 +71,7 @@ export type TerminalSessionStartersContext = {
|
||||
resolvedChainHosts: Host[];
|
||||
sessionId: string;
|
||||
startupCommand?: string;
|
||||
noAutoRun?: boolean;
|
||||
terminalSettings?: TerminalSettings;
|
||||
terminalSettingsRef?: RefObject<TerminalSettings | undefined>;
|
||||
terminalBackend: TerminalBackendApi;
|
||||
@@ -96,7 +100,7 @@ export type TerminalSessionStartersContext = {
|
||||
t?: (key: string) => string;
|
||||
|
||||
onSessionAttached?: (sessionId: string) => void;
|
||||
onSessionExit?: (sessionId: string) => void;
|
||||
onSessionExit?: (sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onOsDetected?: (hostId: string, distro: string) => void;
|
||||
onCommandExecuted?: (
|
||||
@@ -209,13 +213,13 @@ const attachSessionToTerminal = (
|
||||
}
|
||||
}
|
||||
|
||||
ctx.onSessionExit?.(ctx.sessionId);
|
||||
ctx.onSessionExit?.(ctx.sessionId, evt);
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -746,7 +754,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}
|
||||
}
|
||||
|
||||
ctx.onSessionExit?.(ctx.sessionId);
|
||||
ctx.onSessionExit?.(ctx.sessionId, evt);
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
@@ -168,6 +169,8 @@ export interface GroupNode {
|
||||
path: string;
|
||||
children: Record<string, GroupNode>;
|
||||
hosts: Host[];
|
||||
/** Pre-computed total host count including all descendants. Set during tree construction. */
|
||||
totalHostCount?: number;
|
||||
}
|
||||
|
||||
export interface SyncConfig {
|
||||
@@ -434,6 +437,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 +547,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 +589,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;
|
||||
|
||||
@@ -31,9 +31,10 @@ export type SyncState =
|
||||
/**
|
||||
* Conflict Resolution Strategy
|
||||
*/
|
||||
export type ConflictResolution =
|
||||
| 'USE_REMOTE' // Download cloud data, overwrite local
|
||||
| 'USE_LOCAL'; // Upload local data, overwrite cloud
|
||||
export type ConflictResolution =
|
||||
| 'USE_REMOTE' // Download cloud data, overwrite local
|
||||
| 'USE_LOCAL' // Upload local data, overwrite cloud
|
||||
| 'AUTO_MERGED'; // Three-way merge was applied automatically
|
||||
|
||||
// ============================================================================
|
||||
// Cloud Provider Types
|
||||
@@ -275,10 +276,12 @@ export interface UnlockedMasterKey {
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
provider: CloudProvider;
|
||||
action: 'upload' | 'download' | 'none';
|
||||
action: 'upload' | 'download' | 'merge' | 'none';
|
||||
version?: number;
|
||||
error?: string;
|
||||
conflictDetected?: boolean;
|
||||
/** Present when action === 'merge'; caller should apply this to update local state */
|
||||
mergedPayload?: import('./sync').SyncPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,7 +315,7 @@ export interface SyncHistoryEntry {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
provider: CloudProvider;
|
||||
action: 'upload' | 'download' | 'conflict_resolved';
|
||||
action: 'upload' | 'download' | 'merge' | 'conflict_resolved';
|
||||
success: boolean;
|
||||
localVersion: number;
|
||||
remoteVersion?: number;
|
||||
@@ -405,6 +408,7 @@ export const SYNC_STORAGE_KEYS = {
|
||||
PROVIDER_S3: 'netcatty_provider_s3_v1',
|
||||
PROVIDER_SMB: 'netcatty_provider_smb_v1',
|
||||
LOCAL_SYNC_META: 'netcatty_local_sync_meta_v1',
|
||||
SYNC_BASE_PAYLOAD: 'netcatty_sync_base_payload_v1',
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
|
||||
432
domain/syncMerge.ts
Normal file
432
domain/syncMerge.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* Three-Way Merge for Cloud Sync Payloads
|
||||
*
|
||||
* Implements a Git-style three-way merge using a stored "base" snapshot
|
||||
* (the last successfully synced payload) to detect per-entity changes
|
||||
* on both the local and remote sides.
|
||||
*
|
||||
* Algorithm:
|
||||
* For each entity (identified by `id`):
|
||||
* - Only in local → local addition → keep
|
||||
* - Only in remote → remote addition → keep
|
||||
* - In base, removed locally → local deletion → remove (unless remote modified)
|
||||
* - In base, removed remotely → remote deletion → remove (unless local modified)
|
||||
* - Modified only locally → keep local version
|
||||
* - Modified only remotely → keep remote version
|
||||
* - Modified on both sides → prefer local (conflict logged)
|
||||
*
|
||||
* When no base is available (first sync), falls back to a set-union
|
||||
* merge by entity ID, preferring local for duplicates.
|
||||
*/
|
||||
|
||||
import type { SyncPayload } from './sync';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MergeSummary {
|
||||
added: { local: number; remote: number };
|
||||
deleted: { local: number; remote: number };
|
||||
modified: { local: number; remote: number; conflicts: number };
|
||||
}
|
||||
|
||||
export interface MergeResult {
|
||||
payload: SyncPayload;
|
||||
/** True when both sides modified the same entity (resolved by preferring local) */
|
||||
hadConflicts: boolean;
|
||||
summary: MergeSummary;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deterministic JSON string for content comparison.
|
||||
* Sorts object keys to avoid false diffs from key ordering.
|
||||
*/
|
||||
function fingerprint(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, v) => {
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||||
return Object.keys(v).sort().reduce<Record<string, unknown>>((acc, k) => {
|
||||
acc[k] = (v as Record<string, unknown>)[k];
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entity-array merge (hosts, keys, identities, snippets, etc.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EntityMergeResult<T> {
|
||||
merged: T[];
|
||||
conflicts: number;
|
||||
added: { local: number; remote: number };
|
||||
deleted: { local: number; remote: number };
|
||||
modified: { local: number; remote: number };
|
||||
}
|
||||
|
||||
function mergeEntityArrays<T extends { id: string }>(
|
||||
base: T[],
|
||||
local: T[],
|
||||
remote: T[],
|
||||
): EntityMergeResult<T> {
|
||||
const baseMap = new Map(base.map((e) => [e.id, e]));
|
||||
const localMap = new Map(local.map((e) => [e.id, e]));
|
||||
const remoteMap = new Map(remote.map((e) => [e.id, e]));
|
||||
|
||||
const allIds = new Set([
|
||||
...baseMap.keys(),
|
||||
...localMap.keys(),
|
||||
...remoteMap.keys(),
|
||||
]);
|
||||
|
||||
const merged: T[] = [];
|
||||
let conflicts = 0;
|
||||
const added = { local: 0, remote: 0 };
|
||||
const deleted = { local: 0, remote: 0 };
|
||||
const modified = { local: 0, remote: 0 };
|
||||
|
||||
for (const id of allIds) {
|
||||
const baseItem = baseMap.get(id);
|
||||
const localItem = localMap.get(id);
|
||||
const remoteItem = remoteMap.get(id);
|
||||
|
||||
const inBase = baseItem !== undefined;
|
||||
const inLocal = localItem !== undefined;
|
||||
const inRemote = remoteItem !== undefined;
|
||||
|
||||
if (!inBase && inLocal && !inRemote) {
|
||||
// Local addition
|
||||
merged.push(localItem);
|
||||
added.local++;
|
||||
} else if (!inBase && !inLocal && inRemote) {
|
||||
// Remote addition
|
||||
merged.push(remoteItem);
|
||||
added.remote++;
|
||||
} else if (!inBase && inLocal && inRemote) {
|
||||
// Both added same ID — prefer local
|
||||
merged.push(localItem);
|
||||
if (fingerprint(localItem) !== fingerprint(remoteItem)) {
|
||||
conflicts++;
|
||||
}
|
||||
} else if (inBase && inLocal && inRemote) {
|
||||
// Exists in all three — compare changes
|
||||
const localChanged = fingerprint(localItem) !== fingerprint(baseItem);
|
||||
const remoteChanged = fingerprint(remoteItem) !== fingerprint(baseItem);
|
||||
|
||||
if (!localChanged && !remoteChanged) {
|
||||
merged.push(baseItem);
|
||||
} else if (localChanged && !remoteChanged) {
|
||||
merged.push(localItem);
|
||||
modified.local++;
|
||||
} else if (!localChanged && remoteChanged) {
|
||||
merged.push(remoteItem);
|
||||
modified.remote++;
|
||||
} else {
|
||||
// Both changed — prefer local
|
||||
merged.push(localItem);
|
||||
if (fingerprint(localItem) !== fingerprint(remoteItem)) {
|
||||
conflicts++;
|
||||
}
|
||||
modified.local++;
|
||||
modified.remote++;
|
||||
}
|
||||
} else if (inBase && !inLocal && inRemote) {
|
||||
// Local deleted
|
||||
const remoteChanged = fingerprint(remoteItem) !== fingerprint(baseItem);
|
||||
if (remoteChanged) {
|
||||
// Remote modified + local deleted → keep modification (safer)
|
||||
merged.push(remoteItem);
|
||||
conflicts++;
|
||||
} else {
|
||||
deleted.local++;
|
||||
}
|
||||
} else if (inBase && inLocal && !inRemote) {
|
||||
// Remote deleted
|
||||
const localChanged = fingerprint(localItem) !== fingerprint(baseItem);
|
||||
if (localChanged) {
|
||||
// Local modified + remote deleted → keep modification (safer)
|
||||
merged.push(localItem);
|
||||
conflicts++;
|
||||
} else {
|
||||
deleted.remote++;
|
||||
}
|
||||
}
|
||||
// inBase && !inLocal && !inRemote → both deleted → gone
|
||||
}
|
||||
|
||||
return { merged, conflicts, added, deleted, modified };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// String-array merge (customGroups, snippetPackages)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mergeStringArrays(
|
||||
base: string[],
|
||||
local: string[],
|
||||
remote: string[],
|
||||
): string[] {
|
||||
const baseSet = new Set(base);
|
||||
const localSet = new Set(local);
|
||||
const remoteSet = new Set(remote);
|
||||
|
||||
const result = new Set<string>();
|
||||
|
||||
// Start with base items, then apply additions/deletions
|
||||
const allValues = new Set([...baseSet, ...localSet, ...remoteSet]);
|
||||
|
||||
for (const value of allValues) {
|
||||
const inBase = baseSet.has(value);
|
||||
const inLocal = localSet.has(value);
|
||||
const inRemote = remoteSet.has(value);
|
||||
|
||||
if (!inBase) {
|
||||
// Addition — keep if either side added it
|
||||
if (inLocal || inRemote) result.add(value);
|
||||
} else {
|
||||
// Was in base — keep unless both sides deleted
|
||||
const localDeleted = !inLocal;
|
||||
const remoteDeleted = !inRemote;
|
||||
if (localDeleted && remoteDeleted) {
|
||||
// Both deleted — gone
|
||||
} else if (localDeleted || remoteDeleted) {
|
||||
// Only one side deleted — honour the deletion
|
||||
// (If the other side didn't touch it, it's still in their set from base)
|
||||
} else {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...result];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings merge (flat key-value)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SettingsObj = NonNullable<SyncPayload['settings']>;
|
||||
|
||||
/** Check if an array contains objects with `id` fields (for entity merge). */
|
||||
function isIdArray(arr: unknown[]): boolean {
|
||||
return arr.length > 0 && typeof arr[0] === 'object' && arr[0] !== null && 'id' in arr[0];
|
||||
}
|
||||
|
||||
/** Recursively merge two plain objects against a base using three-way logic. */
|
||||
function mergeSettingsDeep(
|
||||
base: Record<string, unknown>,
|
||||
local: Record<string, unknown>,
|
||||
remote: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const allKeys = new Set([
|
||||
...Object.keys(base),
|
||||
...Object.keys(local),
|
||||
...Object.keys(remote),
|
||||
]);
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const key of allKeys) {
|
||||
const bVal = base[key];
|
||||
const lVal = local[key];
|
||||
const rVal = remote[key];
|
||||
const lChanged = fingerprint(lVal) !== fingerprint(bVal);
|
||||
const rChanged = fingerprint(rVal) !== fingerprint(bVal);
|
||||
|
||||
if (!lChanged && !rChanged) {
|
||||
if (bVal !== undefined) merged[key] = bVal;
|
||||
} else if (lChanged && !rChanged) {
|
||||
if (lVal !== undefined) merged[key] = lVal;
|
||||
} else if (!lChanged && rChanged) {
|
||||
if (rVal !== undefined) merged[key] = rVal;
|
||||
} else {
|
||||
// Both changed — recurse if both are plain objects, else prefer local
|
||||
if (
|
||||
lVal && rVal &&
|
||||
typeof lVal === 'object' && !Array.isArray(lVal) &&
|
||||
typeof rVal === 'object' && !Array.isArray(rVal)
|
||||
) {
|
||||
merged[key] = mergeSettingsDeep(
|
||||
(bVal && typeof bVal === 'object' && !Array.isArray(bVal) ? bVal : {}) as Record<string, unknown>,
|
||||
lVal as Record<string, unknown>,
|
||||
rVal as Record<string, unknown>,
|
||||
);
|
||||
} else if (lVal !== undefined) {
|
||||
merged[key] = lVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mergeSettings(
|
||||
base: SettingsObj | undefined,
|
||||
local: SettingsObj | undefined,
|
||||
remote: SettingsObj | undefined,
|
||||
): SettingsObj | undefined {
|
||||
if (!local && !remote) return undefined;
|
||||
if (!local) return remote;
|
||||
if (!remote) return local;
|
||||
|
||||
const b = base ?? {};
|
||||
const allKeys = new Set([
|
||||
...Object.keys(b),
|
||||
...Object.keys(local),
|
||||
...Object.keys(remote),
|
||||
]);
|
||||
|
||||
const merged: Record<string, unknown> = {};
|
||||
|
||||
for (const key of allKeys) {
|
||||
const bVal = (b as Record<string, unknown>)[key];
|
||||
const lVal = (local as Record<string, unknown>)[key];
|
||||
const rVal = (remote as Record<string, unknown>)[key];
|
||||
|
||||
const lChanged = fingerprint(lVal) !== fingerprint(bVal);
|
||||
const rChanged = fingerprint(rVal) !== fingerprint(bVal);
|
||||
|
||||
if (!lChanged && !rChanged) {
|
||||
if (bVal !== undefined) merged[key] = bVal;
|
||||
} else if (lChanged && !rChanged) {
|
||||
if (lVal !== undefined) merged[key] = lVal;
|
||||
} else if (!lChanged && rChanged) {
|
||||
if (rVal !== undefined) merged[key] = rVal;
|
||||
} else {
|
||||
// Both changed — deep merge if both are plain objects, else prefer local
|
||||
if (
|
||||
lVal && rVal &&
|
||||
typeof lVal === 'object' && !Array.isArray(lVal) &&
|
||||
typeof rVal === 'object' && !Array.isArray(rVal)
|
||||
) {
|
||||
merged[key] = mergeSettingsDeep(
|
||||
(bVal && typeof bVal === 'object' && !Array.isArray(bVal) ? bVal : {}) as Record<string, unknown>,
|
||||
lVal as Record<string, unknown>,
|
||||
rVal as Record<string, unknown>,
|
||||
);
|
||||
} else if (
|
||||
Array.isArray(lVal) && Array.isArray(rVal) &&
|
||||
(isIdArray(lVal) || isIdArray(rVal) || isIdArray(Array.isArray(bVal) ? bVal as unknown[] : []))
|
||||
) {
|
||||
// Array of objects with `id` (e.g. customTerminalThemes) — entity merge
|
||||
const bArr = Array.isArray(bVal) ? bVal as Array<{ id: string }> : [];
|
||||
const result = mergeEntityArrays(bArr, lVal as Array<{ id: string }>, rVal as Array<{ id: string }>);
|
||||
merged[key] = result.merged;
|
||||
} else if (lVal !== undefined) {
|
||||
merged[key] = lVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(merged).length > 0 ? (merged as SettingsObj) : undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main merge function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Three-way merge of sync payloads.
|
||||
*
|
||||
* @param base - The last successfully synced payload (null if unavailable)
|
||||
* @param local - The current device's data
|
||||
* @param remote - The other device's data (downloaded from cloud)
|
||||
*/
|
||||
export function mergeSyncPayloads(
|
||||
base: SyncPayload | null,
|
||||
local: SyncPayload,
|
||||
remote: SyncPayload,
|
||||
): MergeResult {
|
||||
const emptyBase: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
snippetPackages: [],
|
||||
knownHosts: [],
|
||||
portForwardingRules: [],
|
||||
settings: undefined,
|
||||
syncedAt: 0,
|
||||
};
|
||||
const b = base ?? emptyBase;
|
||||
|
||||
const summary: MergeSummary = {
|
||||
added: { local: 0, remote: 0 },
|
||||
deleted: { local: 0, remote: 0 },
|
||||
modified: { local: 0, remote: 0, conflicts: 0 },
|
||||
};
|
||||
|
||||
// Merge each entity type
|
||||
const hosts = mergeEntityArrays(b.hosts ?? [], local.hosts ?? [], remote.hosts ?? []);
|
||||
const keys = mergeEntityArrays(b.keys ?? [], local.keys ?? [], remote.keys ?? []);
|
||||
const identities = mergeEntityArrays(b.identities ?? [], local.identities ?? [], remote.identities ?? []);
|
||||
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? []);
|
||||
const knownHostsRaw = mergeEntityArrays(b.knownHosts ?? [], local.knownHosts ?? [], remote.knownHosts ?? []);
|
||||
// Deduplicate known hosts by (hostname, port, keyType) since IDs are random per device
|
||||
const knownHostSeen = new Set<string>();
|
||||
const knownHosts = {
|
||||
...knownHostsRaw,
|
||||
merged: knownHostsRaw.merged.filter((kh) => {
|
||||
const entry = kh as unknown as { hostname: string; port: number; keyType: string };
|
||||
const fp = `${entry.hostname}:${entry.port}:${entry.keyType}`;
|
||||
if (knownHostSeen.has(fp)) return false;
|
||||
knownHostSeen.add(fp);
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
const portForwardingRules = mergeEntityArrays(
|
||||
b.portForwardingRules ?? [],
|
||||
local.portForwardingRules ?? [],
|
||||
remote.portForwardingRules ?? [],
|
||||
);
|
||||
|
||||
// Aggregate stats
|
||||
const entityResults = [hosts, keys, identities, snippets, knownHosts, portForwardingRules];
|
||||
for (const r of entityResults) {
|
||||
summary.added.local += r.added.local;
|
||||
summary.added.remote += r.added.remote;
|
||||
summary.deleted.local += r.deleted.local;
|
||||
summary.deleted.remote += r.deleted.remote;
|
||||
summary.modified.local += r.modified.local;
|
||||
summary.modified.remote += r.modified.remote;
|
||||
summary.modified.conflicts += r.conflicts;
|
||||
}
|
||||
|
||||
// Merge string arrays
|
||||
const customGroups = mergeStringArrays(
|
||||
b.customGroups ?? [],
|
||||
local.customGroups ?? [],
|
||||
remote.customGroups ?? [],
|
||||
);
|
||||
const snippetPackages = mergeStringArrays(
|
||||
b.snippetPackages ?? [],
|
||||
local.snippetPackages ?? [],
|
||||
remote.snippetPackages ?? [],
|
||||
);
|
||||
|
||||
// Merge settings
|
||||
const settings = mergeSettings(b.settings, local.settings, remote.settings);
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: hosts.merged,
|
||||
keys: keys.merged,
|
||||
identities: identities.merged,
|
||||
snippets: snippets.merged,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts: knownHosts.merged,
|
||||
portForwardingRules: portForwardingRules.merged,
|
||||
settings,
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
|
||||
return {
|
||||
payload,
|
||||
hadConflicts: summary.modified.conflicts > 0,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
@@ -75,7 +75,7 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'disableBracketedPaste',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -156,7 +156,10 @@ function execViaPty(ptyStream, command, options) {
|
||||
* @param {number} [options.timeoutMs=60000] - Command timeout in milliseconds
|
||||
*/
|
||||
function execViaChannel(sshClient, command, options) {
|
||||
const { timeoutMs = 60000 } = options || {};
|
||||
const {
|
||||
timeoutMs = 60000,
|
||||
trackForCancellation = null,
|
||||
} = options || {};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
sshClient.exec(command, (err, execStream) => {
|
||||
@@ -165,26 +168,44 @@ 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;
|
||||
}
|
||||
const marker = `__NCMCP_CH_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let finished = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
try { execStream.close(); } catch { /* ignore */ }
|
||||
const timeoutSec = Math.round(timeoutMs / 1000);
|
||||
resolve({ ok: false, stdout, stderr, exitCode: -1, error: `Command timed out (${timeoutSec}s)` });
|
||||
}, timeoutMs);
|
||||
execStream.on("data", (data) => { stdout += data.toString(); });
|
||||
execStream.stderr.on("data", (data) => { stderr += data.toString(); });
|
||||
execStream.on("close", (code) => {
|
||||
const finish = (result) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timeoutId);
|
||||
resolve({ ok: code === 0, stdout, stderr, exitCode: code });
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.delete(marker);
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
const timeoutId = setTimeout(() => {
|
||||
try { execStream.close(); } catch { /* ignore */ }
|
||||
const timeoutSec = Math.round(timeoutMs / 1000);
|
||||
finish({ ok: false, stdout, stderr, exitCode: -1, error: `Command timed out (${timeoutSec}s)` });
|
||||
}, timeoutMs);
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.set(marker, {
|
||||
cleanup: () => {
|
||||
clearTimeout(timeoutId);
|
||||
try { execStream.close(); } catch { /* ignore */ }
|
||||
},
|
||||
});
|
||||
}
|
||||
execStream.on("data", (data) => { stdout += data.toString(); });
|
||||
execStream.stderr.on("data", (data) => { stderr += data.toString(); });
|
||||
execStream.on("close", (code) => {
|
||||
// code is null when SSH disconnects or process is signal-terminated
|
||||
if (code == null) {
|
||||
finish({ ok: false, stdout, stderr, exitCode: -1, error: "Command terminated unexpectedly (connection lost or signal)" });
|
||||
} else {
|
||||
finish({ ok: code === 0, stdout, stderr, exitCode: code });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -161,7 +161,17 @@ async function renameLocalFile(event, payload) {
|
||||
* Create a local directory
|
||||
*/
|
||||
async function mkdirLocal(event, payload) {
|
||||
await fs.promises.mkdir(payload.path, { recursive: true });
|
||||
try {
|
||||
await fs.promises.mkdir(payload.path, { recursive: true });
|
||||
} catch (err) {
|
||||
// On Windows, mkdir on drive roots (e.g. "E:\") throws EPERM.
|
||||
// If the directory already exists, that's fine — ignore the error.
|
||||
try {
|
||||
const stat = await fs.promises.stat(payload.path);
|
||||
if (stat.isDirectory()) return true;
|
||||
} catch { /* stat failed, re-throw original */ }
|
||||
throw err;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ let permissionMode = "confirm";
|
||||
|
||||
// Track active PTY executions for cancellation
|
||||
const activePtyExecs = new Map(); // marker → { ptyStream, cleanup }
|
||||
const cancelledChatSessions = new Set();
|
||||
|
||||
function cancelAllPtyExecs() {
|
||||
for (const [marker, entry] of activePtyExecs) {
|
||||
@@ -116,6 +117,19 @@ function getPermissionMode() {
|
||||
return permissionMode;
|
||||
}
|
||||
|
||||
function setChatSessionCancelled(chatSessionId, cancelled) {
|
||||
if (!chatSessionId) return;
|
||||
if (cancelled) {
|
||||
cancelledChatSessions.add(chatSessionId);
|
||||
} else {
|
||||
cancelledChatSessions.delete(chatSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function isChatSessionCancelled(chatSessionId) {
|
||||
return Boolean(chatSessionId && cancelledChatSessions.has(chatSessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register metadata for terminal sessions (called from renderer via IPC).
|
||||
* Metadata is stored per-scope (chatSessionId) so different AI chat sessions
|
||||
@@ -336,6 +350,10 @@ async function dispatch(method, params) {
|
||||
return { ok: false, error: `Operation denied: permission mode is "observer" (read-only). Change to "confirm" or "autonomous" in Settings → AI → Safety to allow this action.` };
|
||||
}
|
||||
|
||||
if (WRITE_METHODS.has(method) && isChatSessionCancelled(params?.chatSessionId)) {
|
||||
return { ok: false, error: "Operation cancelled: the ACP session was stopped." };
|
||||
}
|
||||
|
||||
// Scope validation for session-targeted operations
|
||||
if (method !== "netcatty/getContext" && params?.sessionId) {
|
||||
const scopeErr = validateSessionScope(params.sessionId, params?.chatSessionId);
|
||||
@@ -382,20 +400,19 @@ async function dispatch(method, params) {
|
||||
function handleGetContext(params) {
|
||||
if (!sessions) return { hosts: [], instructions: "No sessions available." };
|
||||
|
||||
// Scope resolution: use explicit scopedSessionIds from MCP server env var (per-process, set at spawn).
|
||||
// If scopedSessionIds is provided but empty, that means "no access" (not "all access").
|
||||
// Only fall back to unscoped (show all) when scopedSessionIds is not provided at all.
|
||||
const hasScopeParam = params?.scopedSessionIds != null;
|
||||
const scopedIds = hasScopeParam
|
||||
? new Set(params.scopedSessionIds)
|
||||
: null;
|
||||
|
||||
// chatSessionId may be passed via env for per-scope metadata lookup
|
||||
const chatSessionId = params?.chatSessionId || null;
|
||||
const explicitScopedIds = Array.isArray(params?.scopedSessionIds)
|
||||
? params.scopedSessionIds
|
||||
: null;
|
||||
const resolvedScopedIds = explicitScopedIds ?? (chatSessionId ? getScopedSessionIds(chatSessionId) : null);
|
||||
const hasScopedContext = explicitScopedIds !== null || chatSessionId !== null;
|
||||
const scopedIds = resolvedScopedIds ? new Set(resolvedScopedIds) : null;
|
||||
|
||||
const hosts = [];
|
||||
// When scope param is provided (even if empty Set), enforce it strictly
|
||||
if (hasScopeParam && scopedIds.size === 0) {
|
||||
// When a scoped context exists but currently resolves to zero sessions, treat
|
||||
// it as "no access" rather than falling back to all sessions.
|
||||
if (hasScopedContext && (!resolvedScopedIds || resolvedScopedIds.length === 0)) {
|
||||
return {
|
||||
environment: "netcatty-terminal",
|
||||
description: "No hosts are available in the current scope.",
|
||||
@@ -458,7 +475,10 @@ function handleExec(params) {
|
||||
|
||||
// If no PTY stream, fall back to exec channel (invisible to terminal)
|
||||
if (!ptyStream || typeof ptyStream.write !== "function") {
|
||||
return execViaChannel(sshClient, command, { timeoutMs: commandTimeoutMs });
|
||||
return execViaChannel(sshClient, command, {
|
||||
timeoutMs: commandTimeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
});
|
||||
}
|
||||
|
||||
// Execute via PTY stream so user sees the command in the terminal
|
||||
@@ -755,7 +775,9 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
|
||||
env.push({ name: "NETCATTY_MCP_TOKEN", value: authToken });
|
||||
}
|
||||
|
||||
if (effectiveIds && effectiveIds.length > 0) {
|
||||
// When chatSessionId is present, the MCP subprocess resolves scope dynamically
|
||||
// through main-process metadata, so avoid freezing session IDs at spawn time.
|
||||
if (!chatSessionId && effectiveIds && effectiveIds.length > 0) {
|
||||
env.push({ name: "NETCATTY_MCP_SESSION_IDS", value: effectiveIds.join(",") });
|
||||
}
|
||||
|
||||
@@ -781,6 +803,7 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
|
||||
function cleanupScopedMetadata(chatSessionId) {
|
||||
if (chatSessionId) {
|
||||
scopedMetadata.delete(chatSessionId);
|
||||
cancelledChatSessions.delete(chatSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -802,6 +825,7 @@ module.exports = {
|
||||
getMaxIterations,
|
||||
setPermissionMode,
|
||||
getPermissionMode,
|
||||
setChatSessionCancelled,
|
||||
checkCommandSafety,
|
||||
updateSessionMetadata,
|
||||
getScopedSessionIds,
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1013,6 +1017,19 @@ async function startSSHSession(event, options) {
|
||||
bufferData(decoder.write(data));
|
||||
});
|
||||
|
||||
// Capture the real exit code from the remote process.
|
||||
// "exit" fires when the remote shell/process exits normally;
|
||||
// "close" fires whenever the channel closes (could be network drop).
|
||||
// Only treat it as user-initiated exit if "exit" fired with a numeric
|
||||
// code and no signal. Signal terminations (e.g. server kill, idle
|
||||
// timeout) have code=null and signal set — those are not user exits.
|
||||
let streamExitCode = 0;
|
||||
let streamExited = false;
|
||||
stream.on("exit", (code, signal) => {
|
||||
streamExitCode = typeof code === "number" ? code : 0;
|
||||
streamExited = typeof code === "number" && !signal;
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
// Flush any remaining data before close
|
||||
if (flushTimeout) {
|
||||
@@ -1020,7 +1037,7 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
flushBuffer();
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
@@ -1068,7 +1085,7 @@ async function startSSHSession(event, options) {
|
||||
console.error(`${logPrefix} ${options.hostname} error:`, err.message);
|
||||
}
|
||||
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
@@ -1082,7 +1099,7 @@ async function startSSHSession(event, options) {
|
||||
console.error(`${logPrefix} ${options.hostname} connection timeout`);
|
||||
const err = new Error(`Connection timeout to ${options.hostname}`);
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "timeout" });
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
@@ -1094,7 +1111,7 @@ async function startSSHSession(event, options) {
|
||||
|
||||
conn.once("close", () => {
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
|
||||
@@ -222,9 +222,13 @@ function startLocalSession(event, payload) {
|
||||
proc.onExit((evt) => {
|
||||
sessions.delete(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, ...evt });
|
||||
// Signal present = killed externally (show disconnected UI).
|
||||
// No signal = process exited normally, even with non-zero code
|
||||
// (e.g. user typed `exit` after a failed command), so auto-close.
|
||||
const reason = evt.signal ? "error" : "exited";
|
||||
contents?.send("netcatty:exit", { sessionId, ...evt, reason });
|
||||
});
|
||||
|
||||
|
||||
return { sessionId };
|
||||
}
|
||||
|
||||
@@ -426,7 +430,7 @@ async function startTelnetSession(event, options) {
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
}
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
@@ -435,11 +439,11 @@ async function startTelnetSession(event, options) {
|
||||
socket.on('close', (hadError) => {
|
||||
console.log(`[Telnet] Connection closed${hadError ? ' with error' : ''}`);
|
||||
clearTimeout(connectTimeout);
|
||||
|
||||
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: hadError ? 1 : 0 });
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: hadError ? 1 : 0, reason: hadError ? "error" : "closed" });
|
||||
}
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
@@ -523,7 +527,8 @@ async function startMoshSession(event, options) {
|
||||
proc.onExit((evt) => {
|
||||
sessions.delete(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, ...evt });
|
||||
// Mosh non-zero exit typically means connection/auth failure — show error UI
|
||||
contents?.send("netcatty:exit", { sessionId, ...evt, reason: evt.exitCode === 0 ? "exited" : "error" });
|
||||
});
|
||||
|
||||
return { sessionId };
|
||||
@@ -615,14 +620,14 @@ async function startSerialSession(event, options) {
|
||||
serialPort.on('error', (err) => {
|
||||
console.error(`[Serial] Port error: ${err.message}`);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
|
||||
serialPort.on('close', () => {
|
||||
console.log(`[Serial] Port closed`);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 0 });
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,24 @@ const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel } = require("./sftpBridge.cjs");
|
||||
|
||||
/**
|
||||
* Safely ensure a local directory exists.
|
||||
* On Windows, `mkdir("E:\\", { recursive: true })` throws EPERM for drive roots.
|
||||
* We catch that and verify the directory already exists before re-throwing.
|
||||
*/
|
||||
async function ensureLocalDir(dir) {
|
||||
try {
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
} catch (err) {
|
||||
// If the directory already exists, ignore the error (covers EPERM on drive roots)
|
||||
try {
|
||||
const stat = await fs.promises.stat(dir);
|
||||
if (stat.isDirectory()) return;
|
||||
} catch { /* stat failed, re-throw original */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transfer performance tuning ──────────────────────────────────────────────
|
||||
// ssh2's fastPut/fastGet send multiple SFTP read/write requests in parallel,
|
||||
// dramatically improving throughput over sequential stream piping.
|
||||
@@ -430,14 +448,14 @@ async function startTransfer(event, payload, onProgress) {
|
||||
if (!client) throw new Error("Source SFTP session not found");
|
||||
|
||||
const dir = path.dirname(targetPath);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await ensureLocalDir(dir);
|
||||
|
||||
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
|
||||
await downloadFile(encodedSourcePath, targetPath, client, fileSize, transfer, sendProgress);
|
||||
|
||||
} else if (sourceType === 'local' && targetType === 'local') {
|
||||
const dir = path.dirname(targetPath);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await ensureLocalDir(dir);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const readStream = fs.createReadStream(sourcePath, { highWaterMark: TRANSFER_CHUNK_SIZE });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -168,8 +168,13 @@ const server = new McpServer({
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// Scope params shared by all tool calls (includes chatSessionId for metadata isolation)
|
||||
const scopeParams = { scopedSessionIds: SCOPED_SESSION_IDS, chatSessionId: CHAT_SESSION_ID };
|
||||
// Scope params shared by all tool calls.
|
||||
// When chatSessionId is present, let the main process resolve the current
|
||||
// workspace membership dynamically so mid-session workspace changes are visible
|
||||
// without restarting the MCP subprocess.
|
||||
const scopeParams = CHAT_SESSION_ID
|
||||
? { chatSessionId: CHAT_SESSION_ID }
|
||||
: { scopedSessionIds: SCOPED_SESSION_IDS, chatSessionId: CHAT_SESSION_ID };
|
||||
|
||||
// Resource: environment context
|
||||
server.resource(
|
||||
@@ -194,7 +199,7 @@ server.tool(
|
||||
"Get information about the current Netcatty workspace: all connected remote hosts, their session IDs, OS, and connection status. Call this first to discover available hosts before executing commands.",
|
||||
{},
|
||||
async () => {
|
||||
process.stderr.write(`[netcatty-mcp] get_environment called, SCOPED_SESSION_IDS: ${JSON.stringify(SCOPED_SESSION_IDS)}\n`);
|
||||
process.stderr.write(`[netcatty-mcp] get_environment called, SCOPED_SESSION_IDS: ${JSON.stringify(SCOPED_SESSION_IDS)}, CHAT_SESSION_ID: ${CHAT_SESSION_ID}\n`);
|
||||
const ctx = await rpcCall("netcatty/getContext", scopeParams);
|
||||
process.stderr.write(`[netcatty-mcp] get_environment result: hostCount=${ctx.hostCount}, hosts=${JSON.stringify(ctx.hosts?.map(h => h.sessionId))}\n`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(ctx, null, 2) }] };
|
||||
@@ -214,7 +219,7 @@ server.tool(
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/exec", { sessionId, command });
|
||||
const result = await rpcCall("netcatty/exec", { ...scopeParams, sessionId, command });
|
||||
if (!result.ok) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error || "Command failed"}` }], isError: true };
|
||||
}
|
||||
@@ -239,7 +244,7 @@ server.tool(
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/terminalWrite", { sessionId, input });
|
||||
const result = await rpcCall("netcatty/terminalWrite", { ...scopeParams, sessionId, input });
|
||||
if (!result.ok) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
@@ -256,7 +261,7 @@ server.tool(
|
||||
path: z.string().describe("The absolute path of the remote directory to list."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const result = await rpcCall("netcatty/sftpList", { sessionId, path });
|
||||
const result = await rpcCall("netcatty/sftpList", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
@@ -275,7 +280,7 @@ server.tool(
|
||||
},
|
||||
async ({ sessionId, path, maxBytes }) => {
|
||||
const safeMaxBytes = Math.max(1, Math.min(10 * 1024 * 1024, Number(maxBytes) || 10000));
|
||||
const result = await rpcCall("netcatty/sftpRead", { sessionId, path, maxBytes: safeMaxBytes });
|
||||
const result = await rpcCall("netcatty/sftpRead", { ...scopeParams, sessionId, path, maxBytes: safeMaxBytes });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
@@ -297,7 +302,7 @@ server.tool(
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpWrite", { sessionId, path, content });
|
||||
const result = await rpcCall("netcatty/sftpWrite", { ...scopeParams, sessionId, path, content });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
@@ -318,7 +323,7 @@ server.tool(
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpMkdir", { sessionId, path });
|
||||
const result = await rpcCall("netcatty/sftpMkdir", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
@@ -339,7 +344,7 @@ server.tool(
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpRemove", { sessionId, path });
|
||||
const result = await rpcCall("netcatty/sftpRemove", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
@@ -361,7 +366,7 @@ server.tool(
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpRename", { sessionId, oldPath, newPath });
|
||||
const result = await rpcCall("netcatty/sftpRename", { ...scopeParams, sessionId, oldPath, newPath });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
@@ -378,7 +383,7 @@ server.tool(
|
||||
path: z.string().describe("The absolute path to stat."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const result = await rpcCall("netcatty/sftpStat", { sessionId, path });
|
||||
const result = await rpcCall("netcatty/sftpStat", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
@@ -401,7 +406,7 @@ server.tool(
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/multiExec", { sessionIds, command, mode, stopOnError });
|
||||
const result = await rpcCall("netcatty/multiExec", { ...scopeParams, sessionIds, command, mode, stopOnError });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
|
||||
@@ -994,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 });
|
||||
@@ -1059,11 +1065,11 @@ const api = {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:set-permission-mode", { mode });
|
||||
},
|
||||
// ACP streaming
|
||||
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, images) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, images });
|
||||
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images });
|
||||
},
|
||||
aiAcpCancel: async (requestId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:cancel", { requestId });
|
||||
aiAcpCancel: async (requestId, chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:cancel", { requestId, chatSessionId });
|
||||
},
|
||||
aiAcpCleanup: async (chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:cleanup", { chatSessionId });
|
||||
|
||||
8
global.d.ts
vendored
8
global.d.ts
vendored
@@ -189,6 +189,7 @@ declare global {
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
passphrase?: string;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
enableKeyboardInteractive?: boolean;
|
||||
@@ -243,7 +244,7 @@ declare global {
|
||||
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
|
||||
onSessionExit(
|
||||
sessionId: string,
|
||||
cb: (evt: { exitCode?: number; signal?: number }) => void
|
||||
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void
|
||||
): () => void;
|
||||
onAuthFailed?(
|
||||
sessionId: string,
|
||||
@@ -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<{
|
||||
@@ -687,8 +689,8 @@ declare global {
|
||||
aiWriteToAgent?(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiCloseAgentStdin?(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiKillAgent?(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpCancel?(requestId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string, model?: string, existingSessionId?: string, historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, images?: Array<{ base64Data: string; mediaType: string; filename?: string }>): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpCancel?(requestId: string, chatSessionId?: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpCleanup?(chatSessionId: string): Promise<{ ok: boolean }>;
|
||||
onAiAcpEvent?(requestId: string, cb: (event: Record<string, unknown>) => void): () => void;
|
||||
onAiAcpDone?(requestId: string, cb: () => void): () => void;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import type { ExternalAgentConfig } from './types';
|
||||
|
||||
export interface AcpAgentCallbacks {
|
||||
onSessionId?: (sessionId: string) => void;
|
||||
onTextDelta: (text: string) => void;
|
||||
onThinkingDelta: (text: string) => void;
|
||||
onThinkingDone: () => void;
|
||||
@@ -29,9 +30,11 @@ interface AcpBridge {
|
||||
cwd?: string,
|
||||
providerId?: string,
|
||||
model?: string,
|
||||
existingSessionId?: string,
|
||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
images?: ImageAttachment[],
|
||||
): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpCancel(requestId: string): Promise<{ ok: boolean }>;
|
||||
aiAcpCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
|
||||
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
|
||||
onAiAcpDone(requestId: string, cb: () => void): () => void;
|
||||
onAiAcpError(requestId: string, cb: (error: string) => void): () => void;
|
||||
@@ -63,6 +66,8 @@ export async function runAcpAgentTurn(
|
||||
signal?: AbortSignal,
|
||||
providerId?: string,
|
||||
model?: string,
|
||||
existingSessionId?: string,
|
||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
images?: ImageAttachment[],
|
||||
): Promise<void> {
|
||||
const acpBridge = bridge as unknown as AcpBridge;
|
||||
@@ -101,7 +106,7 @@ export async function runAcpAgentTurn(
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
acpBridge.aiAcpCancel(requestId).catch(() => {});
|
||||
acpBridge.aiAcpCancel(requestId, chatSessionId).catch(() => {});
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
cleanupFns.push(() => signal.removeEventListener('abort', onAbort));
|
||||
@@ -117,6 +122,8 @@ export async function runAcpAgentTurn(
|
||||
undefined, // cwd
|
||||
providerId,
|
||||
model,
|
||||
existingSessionId,
|
||||
historyMessages,
|
||||
images?.length ? images : undefined,
|
||||
).catch((err: Error) => {
|
||||
callbacks.onError(err.message);
|
||||
@@ -177,6 +184,11 @@ function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks) {
|
||||
if (msg) callbacks.onStatus?.(msg);
|
||||
break;
|
||||
}
|
||||
case 'session-id': {
|
||||
const sessionId = (event.sessionId as string) || '';
|
||||
if (sessionId) callbacks.onSessionId?.(sessionId);
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
callbacks.onError(String(event.error || 'Unknown error'));
|
||||
break;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,47 +1,15 @@
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
/**
|
||||
* Classifies a raw error string into structured error info for display.
|
||||
* Convert a raw error string into display-safe error info.
|
||||
*
|
||||
* Intentionally avoids keyword-based "root cause" attribution because upstream
|
||||
* providers often return generic 4xx/5xx text that would be misclassified.
|
||||
* We show the sanitized upstream message directly instead.
|
||||
*/
|
||||
export function classifyError(error: string): NonNullable<ChatMessage['errorInfo']> {
|
||||
const lower = error.toLowerCase();
|
||||
|
||||
// Network errors
|
||||
if (lower.includes('econnrefused') || lower.includes('enotfound') || lower.includes('enetunreach') || lower.includes('fetch failed') || lower.includes('network')) {
|
||||
return { type: 'network', message: 'Network connection failed. Please check your internet connection and API endpoint.', retryable: true };
|
||||
}
|
||||
|
||||
// Timeout
|
||||
if (lower.includes('timeout') || lower.includes('timed out') || lower.includes('econnreset') || lower.includes('socket hang up')) {
|
||||
return { type: 'timeout', message: 'Request timed out. The server may be overloaded or unreachable.', retryable: true };
|
||||
}
|
||||
|
||||
// Auth errors
|
||||
if (lower.includes('401') || lower.includes('403') || lower.includes('unauthorized') || lower.includes('invalid api key') || lower.includes('authentication')) {
|
||||
return { type: 'auth', message: 'Authentication failed. Please check your API key in Settings \u2192 AI.', retryable: false };
|
||||
}
|
||||
|
||||
// Rate limit
|
||||
if (lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests')) {
|
||||
return { type: 'provider', message: 'Rate limit exceeded. Please wait a moment before retrying.', retryable: true };
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
// Model not found
|
||||
if (lower.includes('model not found') || lower.includes('does not exist') || lower.includes('404')) {
|
||||
return { type: 'provider', message: 'Model not found. Please check your model selection in Settings \u2192 AI.', retryable: false };
|
||||
}
|
||||
|
||||
// Command blocked
|
||||
if (lower.includes('blocked by safety')) {
|
||||
return { type: 'agent', message: sanitizeErrorMessage(error), retryable: false };
|
||||
}
|
||||
|
||||
return { type: 'unknown', message: sanitizeErrorMessage(error), retryable: true };
|
||||
const message = sanitizeErrorMessage(error).trim() || 'Unknown error';
|
||||
return { type: 'unknown', message, retryable: false };
|
||||
}
|
||||
|
||||
const MAX_ERROR_MESSAGE_LENGTH = 500;
|
||||
|
||||
@@ -86,6 +86,20 @@ function extractHeaders(headers?: HeadersInit): Record<string, string> {
|
||||
/** Placeholder API key used by the renderer; main process replaces it with the real key. */
|
||||
export const API_KEY_PLACEHOLDER = '__IPC_SECURED__';
|
||||
|
||||
function toSafeStatusText(message: string, fallback: string): string {
|
||||
const normalized = message
|
||||
.replace(/[\r\n\t]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!normalized) return fallback;
|
||||
const byteStringSafe = Array.from(normalized, (char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
if (code < 0x20 || code === 0x7f || code > 0xff) return '?';
|
||||
return char;
|
||||
}).join('');
|
||||
return byteStringSafe.slice(0, 120) || fallback;
|
||||
}
|
||||
|
||||
export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.fetch {
|
||||
return async (
|
||||
input: string | URL | Request,
|
||||
@@ -178,9 +192,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',
|
||||
statusText: toSafeStatusText(errorMessage, '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: toSafeStatusText(errorDetail, `Error ${statusCode}`),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,7 +224,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' },
|
||||
});
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import type { NetcattyBridge, ExecutorContext } from '../cattyAgent/executor';
|
||||
import type { NetcattyBridge } 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';
|
||||
@@ -31,12 +30,13 @@ function unwrap<T>(r: ToolExecResult<T>): T | { error: string } {
|
||||
*/
|
||||
export function createCattyTools(
|
||||
bridge: NetcattyBridge,
|
||||
context: ExecutorContext,
|
||||
context: ToolDeps['context'],
|
||||
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
|
||||
@@ -28,20 +27,26 @@ export type ToolExecResult<T = unknown> =
|
||||
|
||||
export interface ToolDeps {
|
||||
bridge: NetcattyBridge;
|
||||
context: ExecutorContext;
|
||||
context: ExecutorContext | (() => ExecutorContext);
|
||||
commandBlocklist?: string[];
|
||||
permissionMode: AIPermissionMode;
|
||||
webSearchConfig?: WebSearchConfig;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function validSessionIds(ctx: ExecutorContext): Set<string> {
|
||||
return new Set(ctx.sessions.map(s => s.sessionId));
|
||||
function resolveContext(ctx: ToolDeps['context']): ExecutorContext {
|
||||
return typeof ctx === 'function' ? ctx() : ctx;
|
||||
}
|
||||
|
||||
function validateSessionScope(ctx: ExecutorContext, sessionId: string): string | null {
|
||||
function validSessionIds(ctx: ToolDeps['context']): Set<string> {
|
||||
const resolved = resolveContext(ctx);
|
||||
return new Set(resolved.sessions.map(s => s.sessionId));
|
||||
}
|
||||
|
||||
function validateSessionScope(ctx: ToolDeps['context'], sessionId: string): string | null {
|
||||
const ids = validSessionIds(ctx);
|
||||
if (!ids.has(sessionId)) {
|
||||
return `Session "${sessionId}" is not in the current scope. Available sessions: ${[...ids].join(', ')}`;
|
||||
@@ -78,9 +83,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 +101,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<{
|
||||
@@ -223,7 +115,7 @@ export function executeWorkspaceGetInfo(
|
||||
connected: boolean;
|
||||
}>;
|
||||
}> {
|
||||
const { context } = deps;
|
||||
const context = resolveContext(deps.context);
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
@@ -245,7 +137,7 @@ export function executeWorkspaceGetSessionInfo(
|
||||
deps: ToolDeps,
|
||||
args: { sessionId: string },
|
||||
): ToolExecResult<ExecutorContext['sessions'][number]> {
|
||||
const { context } = deps;
|
||||
const context = resolveContext(deps.context);
|
||||
const session = context.sessions.find(s => s.sessionId === args.sessionId);
|
||||
if (!session) {
|
||||
return { ok: false, error: `Session not found: ${args.sessionId}` };
|
||||
@@ -253,70 +145,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 {
|
||||
@@ -47,7 +48,7 @@ export interface ChatMessage {
|
||||
};
|
||||
/** Transient status text shown with shimmer effect (e.g. "Waiting for response...") */
|
||||
statusText?: string;
|
||||
executionStatus?: 'pending' | 'approved' | 'rejected' | 'running' | 'completed' | 'failed';
|
||||
executionStatus?: 'pending' | 'approved' | 'rejected' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
pendingApproval?: {
|
||||
approvalId: string;
|
||||
toolCallId: string;
|
||||
@@ -99,6 +100,7 @@ export interface AISession {
|
||||
agentId: string;
|
||||
scope: AISessionScope;
|
||||
messages: ChatMessage[];
|
||||
externalSessionId?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
@@ -162,6 +164,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 +207,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 = [
|
||||
|
||||
@@ -87,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';
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
decryptProviderSecrets,
|
||||
encryptProviderSecrets,
|
||||
} from '../persistence/secureFieldAdapter';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
|
||||
const SYNC_HISTORY_STORAGE_KEY = 'netcatty_sync_history_v1';
|
||||
|
||||
@@ -256,6 +257,15 @@ export class CloudSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
private removeFromStorage(key: string): void {
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// ignore storage removal failures
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Cross-window sync (Electron settings window, etc.)
|
||||
// ==========================================================================
|
||||
@@ -757,6 +767,8 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
await this.saveProviderConnection('github', this.state.providers.github);
|
||||
// Clear merge base when (re)authenticating to a potentially different account
|
||||
this.removeFromStorage(this.syncBaseKey('github'));
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider: 'github',
|
||||
@@ -810,6 +822,8 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
// Clear merge base when (re)authenticating to a potentially different account
|
||||
this.removeFromStorage(this.syncBaseKey(provider));
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider,
|
||||
@@ -846,6 +860,8 @@ export class CloudSyncManager {
|
||||
};
|
||||
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
// Clear merge base when (re)configuring to a different endpoint/bucket
|
||||
this.removeFromStorage(this.syncBaseKey(provider));
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider,
|
||||
@@ -874,6 +890,9 @@ export class CloudSyncManager {
|
||||
};
|
||||
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
// Clear the merge base for this provider so reconnecting to a different
|
||||
// account/resource doesn't reuse an unrelated snapshot
|
||||
this.removeFromStorage(this.syncBaseKey(provider));
|
||||
this.notifyStateChange(); // Ensure UI updates immediately after disconnect
|
||||
}
|
||||
|
||||
@@ -1081,30 +1100,81 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
if (checkResult.conflict && checkResult.remoteFile) {
|
||||
const remoteFile = checkResult.remoteFile;
|
||||
// Remote is newer - conflict
|
||||
this.state.syncState = 'CONFLICT';
|
||||
this.state.currentConflict = {
|
||||
provider,
|
||||
localVersion: this.state.localVersion,
|
||||
localUpdatedAt: this.state.localUpdatedAt,
|
||||
localDeviceName: this.state.deviceName,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
remoteUpdatedAt: remoteFile.meta.updatedAt,
|
||||
remoteDeviceName: remoteFile.meta.deviceName,
|
||||
};
|
||||
// Remote is newer — attempt three-way merge instead of blocking
|
||||
try {
|
||||
const remotePayload = await EncryptionService.decryptPayload(
|
||||
checkResult.remoteFile,
|
||||
this.masterPassword,
|
||||
);
|
||||
const base = await this.loadSyncBase(provider);
|
||||
const mergeResult = mergeSyncPayloads(base, payload, remotePayload);
|
||||
|
||||
this.emit({
|
||||
type: 'CONFLICT_DETECTED',
|
||||
conflict: this.state.currentConflict,
|
||||
});
|
||||
console.log('[CloudSyncManager] Three-way merge completed', mergeResult.summary);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
conflictDetected: true,
|
||||
};
|
||||
// Encrypt and upload merged payload
|
||||
const mergedSyncedFile = await EncryptionService.encryptPayload(
|
||||
mergeResult.payload,
|
||||
this.masterPassword,
|
||||
this.state.deviceId,
|
||||
this.state.deviceName,
|
||||
packageJson.version,
|
||||
checkResult.remoteFile.meta.version, // base on remote version
|
||||
);
|
||||
|
||||
const uploadResult = await this.uploadToProvider(provider, adapter, mergedSyncedFile);
|
||||
|
||||
if (uploadResult.success) {
|
||||
await this.saveSyncBase(mergeResult.payload, provider);
|
||||
this.state.syncState = 'IDLE';
|
||||
|
||||
this.addSyncHistoryEntry({
|
||||
timestamp: Date.now(),
|
||||
provider,
|
||||
action: 'merge',
|
||||
success: true,
|
||||
localVersion: mergedSyncedFile.meta.version,
|
||||
remoteVersion: checkResult.remoteFile.meta.version,
|
||||
deviceName: this.state.deviceName,
|
||||
});
|
||||
|
||||
return {
|
||||
...uploadResult,
|
||||
action: 'merge',
|
||||
mergedPayload: mergeResult.payload,
|
||||
};
|
||||
}
|
||||
|
||||
// Upload after merge failed — set ERROR so sync isn't stuck in SYNCING
|
||||
this.state.syncState = 'ERROR';
|
||||
this.state.lastError = uploadResult.error || 'Upload failed after merge';
|
||||
return uploadResult;
|
||||
} catch (mergeError) {
|
||||
// Merge failed — fall back to conflict UI
|
||||
console.error('[CloudSyncManager] Merge failed, falling back to conflict UI', mergeError);
|
||||
const remoteFile = checkResult.remoteFile;
|
||||
this.state.syncState = 'CONFLICT';
|
||||
this.state.currentConflict = {
|
||||
provider,
|
||||
localVersion: this.state.localVersion,
|
||||
localUpdatedAt: this.state.localUpdatedAt,
|
||||
localDeviceName: this.state.deviceName,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
remoteUpdatedAt: remoteFile.meta.updatedAt,
|
||||
remoteDeviceName: remoteFile.meta.deviceName,
|
||||
};
|
||||
|
||||
this.emit({
|
||||
type: 'CONFLICT_DETECTED',
|
||||
conflict: this.state.currentConflict,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
conflictDetected: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Encrypt
|
||||
@@ -1121,6 +1191,7 @@ export class CloudSyncManager {
|
||||
const result = await this.uploadToProvider(provider, adapter, syncedFile);
|
||||
|
||||
if (result.success) {
|
||||
await this.saveSyncBase(payload, provider);
|
||||
this.state.syncState = 'IDLE';
|
||||
} else {
|
||||
this.state.syncState = 'ERROR';
|
||||
@@ -1182,6 +1253,7 @@ export class CloudSyncManager {
|
||||
this.state.remoteVersion = remoteFile.meta.version;
|
||||
this.state.remoteUpdatedAt = remoteFile.meta.updatedAt;
|
||||
this.saveSyncConfig();
|
||||
await this.saveSyncBase(payload, provider);
|
||||
this.notifyStateChange(); // Notify UI of state change
|
||||
|
||||
// Add to sync history
|
||||
@@ -1240,8 +1312,10 @@ export class CloudSyncManager {
|
||||
/**
|
||||
* Sync to all connected providers
|
||||
*/
|
||||
async syncAllProviders(payload?: SyncPayload): Promise<Map<CloudProvider, SyncResult>> {
|
||||
async syncAllProviders(inputPayload?: SyncPayload): Promise<Map<CloudProvider, SyncResult>> {
|
||||
const results = new Map<CloudProvider, SyncResult>();
|
||||
let payload = inputPayload;
|
||||
let wasMerged = false;
|
||||
|
||||
if (!payload) {
|
||||
// Caller should provide payload from app state
|
||||
@@ -1293,58 +1367,85 @@ export class CloudSyncManager {
|
||||
|
||||
const checkResults = await Promise.all(checkTasks);
|
||||
|
||||
// 2. Analyze Results & Handle Conflicts
|
||||
const conflict = checkResults.find((r) => !r.error && r.check?.conflict);
|
||||
// 2. Analyze Results & Handle Conflicts — merge ALL conflicting providers
|
||||
const conflicts = checkResults.filter((r) => !r.error && r.check?.conflict && r.check?.remoteFile);
|
||||
|
||||
if (conflict && conflict.check?.remoteFile) {
|
||||
const { provider, check } = conflict;
|
||||
const remoteFile = check.remoteFile!;
|
||||
|
||||
this.state.syncState = 'CONFLICT';
|
||||
this.state.currentConflict = {
|
||||
provider: provider as CloudProvider,
|
||||
localVersion: this.state.localVersion,
|
||||
localUpdatedAt: this.state.localUpdatedAt,
|
||||
localDeviceName: this.state.deviceName,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
remoteUpdatedAt: remoteFile.meta.updatedAt,
|
||||
remoteDeviceName: remoteFile.meta.deviceName,
|
||||
};
|
||||
|
||||
this.emit({
|
||||
type: 'CONFLICT_DETECTED',
|
||||
conflict: this.state.currentConflict,
|
||||
});
|
||||
|
||||
// Populate results
|
||||
for (const r of checkResults) {
|
||||
if (r.error) {
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
error: r.error,
|
||||
});
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
|
||||
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
|
||||
} else if (r.provider === provider) {
|
||||
results.set(provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: provider as CloudProvider,
|
||||
action: 'none',
|
||||
conflictDetected: true,
|
||||
});
|
||||
} else {
|
||||
// Others are reset to connected
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'connected');
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: true, // Should we mark as success if skipped?
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
});
|
||||
if (conflicts.length > 0) {
|
||||
// Three-way merge: incorporate remote data from every conflicting provider
|
||||
try {
|
||||
let merged = payload;
|
||||
for (const c of conflicts) {
|
||||
const providerBase = await this.loadSyncBase(c.provider as CloudProvider);
|
||||
const remotePayload = await EncryptionService.decryptPayload(
|
||||
c.check!.remoteFile!,
|
||||
this.masterPassword,
|
||||
);
|
||||
const result = mergeSyncPayloads(providerBase, merged, remotePayload);
|
||||
merged = result.payload;
|
||||
}
|
||||
const mergeResult = { payload: merged };
|
||||
|
||||
console.log('[CloudSyncManager] syncAll: three-way merge completed');
|
||||
|
||||
// Replace payload with merged payload for upload to all providers
|
||||
payload = mergeResult.payload;
|
||||
wasMerged = true;
|
||||
|
||||
// Re-classify: all providers (including the conflicting one) should now upload
|
||||
// Clear the conflict check result so all go through the upload path
|
||||
for (const r of checkResults) {
|
||||
if (r.check) r.check.conflict = false;
|
||||
}
|
||||
} catch (mergeError) {
|
||||
// Merge failed — fall back to conflict UI
|
||||
console.error('[CloudSyncManager] syncAll: merge failed', mergeError);
|
||||
const { provider, check } = conflicts[0];
|
||||
const remoteFile = check!.remoteFile!;
|
||||
|
||||
this.state.syncState = 'CONFLICT';
|
||||
this.state.currentConflict = {
|
||||
provider: provider as CloudProvider,
|
||||
localVersion: this.state.localVersion,
|
||||
localUpdatedAt: this.state.localUpdatedAt,
|
||||
localDeviceName: this.state.deviceName,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
remoteUpdatedAt: remoteFile.meta.updatedAt,
|
||||
remoteDeviceName: remoteFile.meta.deviceName,
|
||||
};
|
||||
|
||||
this.emit({
|
||||
type: 'CONFLICT_DETECTED',
|
||||
conflict: this.state.currentConflict,
|
||||
});
|
||||
|
||||
for (const r of checkResults) {
|
||||
if (r.error) {
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
error: r.error,
|
||||
});
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
|
||||
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
|
||||
} else if (r.provider === conflicts[0].provider) {
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
conflictDetected: true,
|
||||
});
|
||||
} else {
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'connected');
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: true,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// 3. Encrypt Once
|
||||
@@ -1370,6 +1471,15 @@ export class CloudSyncManager {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Use the highest version as base: either local or any remote that was merged
|
||||
let baseVersion = this.state.localVersion;
|
||||
if (wasMerged) {
|
||||
for (const c of conflicts) {
|
||||
const rv = c.check?.remoteFile?.meta?.version ?? 0;
|
||||
if (rv > baseVersion) baseVersion = rv;
|
||||
}
|
||||
}
|
||||
|
||||
let syncedFile: SyncedFile;
|
||||
try {
|
||||
syncedFile = await EncryptionService.encryptPayload(
|
||||
@@ -1378,7 +1488,7 @@ export class CloudSyncManager {
|
||||
this.state.deviceId,
|
||||
this.state.deviceName,
|
||||
packageJson.version,
|
||||
this.state.localVersion
|
||||
baseVersion
|
||||
);
|
||||
} catch (error) {
|
||||
const msg = String(error);
|
||||
@@ -1411,6 +1521,22 @@ export class CloudSyncManager {
|
||||
const hasSuccess = Array.from(results.values()).some((r) => r.success);
|
||||
if (hasSuccess) {
|
||||
this.state.syncState = 'IDLE';
|
||||
// Save base per provider that successfully uploaded
|
||||
if (payload) {
|
||||
for (const [p, r] of results) {
|
||||
if (r.success) await this.saveSyncBase(payload, p);
|
||||
}
|
||||
}
|
||||
|
||||
// If a merge happened, attach the merged payload to successful results
|
||||
// so callers can apply remote additions to local state
|
||||
if (wasMerged && payload) {
|
||||
for (const [p, r] of results) {
|
||||
if (r.success) {
|
||||
results.set(p, { ...r, action: 'merge', mergedPayload: payload });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.state.syncState = 'ERROR';
|
||||
// lastError is set by uploadToProvider
|
||||
@@ -1494,6 +1620,60 @@ export class CloudSyncManager {
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Sync Base (three-way merge snapshot)
|
||||
// ==========================================================================
|
||||
|
||||
private syncBaseKey(provider?: CloudProvider): string {
|
||||
const suffix = provider ? `_${provider}` : '';
|
||||
return `${SYNC_STORAGE_KEYS.SYNC_BASE_PAYLOAD}${suffix}`;
|
||||
}
|
||||
|
||||
async saveSyncBase(payload: SyncPayload, provider?: CloudProvider): Promise<void> {
|
||||
const key = this.state.unlockedKey?.derivedKey;
|
||||
if (!key) return;
|
||||
try {
|
||||
const data = new TextEncoder().encode(JSON.stringify(payload));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);
|
||||
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
||||
combined.set(iv);
|
||||
combined.set(new Uint8Array(encrypted), iv.length);
|
||||
// Encode in chunks to avoid stack overflow with large buffers
|
||||
let binary = '';
|
||||
const CHUNK = 8192;
|
||||
for (let i = 0; i < combined.length; i += CHUNK) {
|
||||
binary += String.fromCharCode(...combined.subarray(i, i + CHUNK));
|
||||
}
|
||||
this.saveToStorage(this.syncBaseKey(provider), btoa(binary));
|
||||
} catch {
|
||||
console.warn('[CloudSyncManager] Failed to save sync base');
|
||||
}
|
||||
}
|
||||
|
||||
async loadSyncBase(provider?: CloudProvider): Promise<SyncPayload | null> {
|
||||
const key = this.state.unlockedKey?.derivedKey;
|
||||
if (!key) return null;
|
||||
try {
|
||||
const encoded = this.loadFromStorage<string>(this.syncBaseKey(provider));
|
||||
if (!encoded || typeof encoded !== 'string') return null;
|
||||
const combined = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0));
|
||||
const iv = combined.slice(0, 12);
|
||||
const ciphertext = combined.slice(12);
|
||||
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
|
||||
return JSON.parse(new TextDecoder().decode(decrypted));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearSyncBase(): void {
|
||||
this.removeFromStorage(SYNC_STORAGE_KEYS.SYNC_BASE_PAYLOAD);
|
||||
for (const p of ['github', 'google', 'onedrive', 'webdav', 's3'] as const) {
|
||||
this.removeFromStorage(this.syncBaseKey(p));
|
||||
}
|
||||
}
|
||||
|
||||
private addSyncHistoryEntry(entry: Omit<SyncHistoryEntry, 'id'>): void {
|
||||
const newEntry: SyncHistoryEntry = {
|
||||
...entry,
|
||||
@@ -1521,6 +1701,7 @@ export class CloudSyncManager {
|
||||
this.state.syncHistory = [];
|
||||
this.saveSyncConfig();
|
||||
this.saveToStorage(SYNC_HISTORY_STORAGE_KEY, []);
|
||||
this.clearSyncBase();
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
|
||||
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