Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eee7bf95a | ||
|
|
b2406ec8a5 | ||
|
|
5fde9c2d61 | ||
|
|
06a6a0ac12 | ||
|
|
024e60ead1 | ||
|
|
fe71790f0a | ||
|
|
9371b3d01b | ||
|
|
5a1d279efd | ||
|
|
8b0cbf02c3 | ||
|
|
d19fe45a14 | ||
|
|
344946b096 | ||
|
|
fcd15707d2 | ||
|
|
42c82e46ea | ||
|
|
0e1c3b621a | ||
|
|
3cd3bbaaf7 | ||
|
|
8bfb50fcbb | ||
|
|
c39ef879c3 | ||
|
|
b3d5785477 | ||
|
|
05de49f7da | ||
|
|
f77c2b2de9 | ||
|
|
f79f27d737 | ||
|
|
ec35daa0dd | ||
|
|
ed0775d9d2 | ||
|
|
1f31629ce0 | ||
|
|
cc4a904dea | ||
|
|
e9e1d87ff5 | ||
|
|
a6b07f39ad | ||
|
|
6892e11952 | ||
|
|
ec9be922cb | ||
|
|
6e961b0efd | ||
|
|
d3fe2f9f53 | ||
|
|
88760b763e | ||
|
|
6dfe543ab5 | ||
|
|
c000996cb4 | ||
|
|
f70b604996 | ||
|
|
b973382f9f | ||
|
|
eeb300295d | ||
|
|
be36ccd167 | ||
|
|
71b13a77a3 | ||
|
|
808d021ebe | ||
|
|
d03117733d | ||
|
|
1816c3d0df | ||
|
|
b192ee1764 | ||
|
|
0b9cb86c4e | ||
|
|
bcd44f0177 | ||
|
|
d8d29d1709 | ||
|
|
0820569166 | ||
|
|
545506ac86 | ||
|
|
29fca33ffd | ||
|
|
216ea7f177 | ||
|
|
b280caded2 | ||
|
|
2d4f260f0b | ||
|
|
e69bc53aa4 | ||
|
|
a55da77471 | ||
|
|
33d3a86d83 | ||
|
|
f73c060351 | ||
|
|
304ebf1e3b | ||
|
|
2788dbdff5 | ||
|
|
84fe0134c9 | ||
|
|
06dc7400f2 | ||
|
|
d1a59ed40c | ||
|
|
f90aa81b2c | ||
|
|
950819746e | ||
|
|
4a3a4b9d9b | ||
|
|
726ff82a9e | ||
|
|
7e8682d10d | ||
|
|
b2447b06d2 | ||
|
|
ed8a6a6cf2 | ||
|
|
f0f5803a6d | ||
|
|
f53bc05cb3 | ||
|
|
3136100514 | ||
|
|
847df7a023 | ||
|
|
150724fc7c | ||
|
|
8949394756 | ||
|
|
7f3214e088 | ||
|
|
eaab7d72cb | ||
|
|
63a7c06037 | ||
|
|
72887c35b5 |
11
.github/workflows/build.yml
vendored
11
.github/workflows/build.yml
vendored
@@ -84,13 +84,14 @@ jobs:
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Linux x64 — builds directly on ubuntu-latest (no container).
|
||||
# v1.0.39 used a debian:bullseye container which broke native module
|
||||
# packaging (node-pty .node file missing from asar.unpacked). Reverted
|
||||
# to the v1.0.38 approach. See #264.
|
||||
# Linux x64 — pin to ubuntu-22.04 for broader glibc compatibility.
|
||||
# ubuntu-latest (24.04) links native modules against glibc 2.39 which
|
||||
# can cause dlopen failures on some distros. 22.04 uses glibc 2.35,
|
||||
# compatible with most current Linux distributions including Arch.
|
||||
# See #264.
|
||||
build-linux-x64:
|
||||
name: build-linux-x64
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -35,8 +35,8 @@ coverage
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Claude Code local settings
|
||||
/.claude/settings.local.json
|
||||
# Claude Code
|
||||
/.claude/
|
||||
/CLAUDE.md
|
||||
|
||||
# AI / Superpowers generated docs (local only)
|
||||
|
||||
5
App.tsx
5
App.tsx
@@ -17,6 +17,7 @@ import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { applySyncPayload } from './domain/syncPayload';
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
|
||||
import { TopTabs } from './components/TopTabs';
|
||||
import { Button } from './components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
|
||||
@@ -292,10 +293,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippetPackages,
|
||||
portForwardingRules: portForwardingRulesForSync,
|
||||
knownHosts,
|
||||
settingsVersion: settings.settingsVersion,
|
||||
onApplyPayload: (payload) => {
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -320,6 +323,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
useEffect(() => {
|
||||
// Skip "update available" toast if auto-download has already started or completed
|
||||
if (updateState.autoDownloadStatus !== 'idle') return;
|
||||
// Don't show automatic notification when auto-update is disabled
|
||||
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
|
||||
if (updateState.hasUpdate && updateState.latestRelease) {
|
||||
const version = updateState.latestRelease.version;
|
||||
toast.info(
|
||||
|
||||
@@ -50,6 +50,7 @@ const en: Messages = {
|
||||
// Dialogs / prompts
|
||||
'confirm.deleteHost': 'Delete Host "{name}"?',
|
||||
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
|
||||
'confirm.removeProvider': 'Remove provider "{name}"?',
|
||||
'dialog.createWorkspace.title': 'Create Workspace',
|
||||
'dialog.renameWorkspace.title': 'Rename workspace',
|
||||
'dialog.renameSession.title': 'Rename session',
|
||||
@@ -114,6 +115,8 @@ const en: Messages = {
|
||||
'settings.update.lastCheckedMinutesAgo': '{n} min ago',
|
||||
'settings.update.lastCheckedHoursAgo': '{n} hr ago',
|
||||
'settings.update.lastCheckedPrefix': 'Last checked: ',
|
||||
'settings.update.autoUpdateEnabled': 'Automatic Updates',
|
||||
'settings.update.autoUpdateEnabledDesc': 'Automatically check and download updates when available.',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': 'Session Logs',
|
||||
@@ -141,6 +144,8 @@ const en: Messages = {
|
||||
'settings.globalHotkey.reset': 'Reset to default',
|
||||
'settings.globalHotkey.closeToTray': 'Close to System Tray',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
|
||||
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
|
||||
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
|
||||
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
|
||||
|
||||
// Tray Panel
|
||||
@@ -265,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',
|
||||
@@ -834,8 +850,8 @@ const en: Messages = {
|
||||
'hostDetails.certs.empty': 'No certificates available',
|
||||
'hostDetails.agentForwarding': 'Forward SSH Agent',
|
||||
'hostDetails.agentForwarding.desc': 'Allow remote server to use your local SSH keys (e.g., for git operations)',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not running',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'Enable OpenSSH Authentication Agent service in Windows Services (services.msc) for agent forwarding to work.',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not available',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
|
||||
'hostDetails.section.agentForwarding': 'SSH Agent',
|
||||
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
@@ -1478,6 +1494,148 @@ const en: Messages = {
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Word Wrap',
|
||||
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': 'Configure AI providers, agents, and safety settings',
|
||||
'ai.providers': 'Providers',
|
||||
'ai.providers.empty': 'No providers configured. Add a provider to get started.',
|
||||
'ai.providers.add': 'Add Provider',
|
||||
'ai.providers.active': 'Active',
|
||||
'ai.providers.apiKeyConfigured': 'API key configured',
|
||||
'ai.providers.noApiKey': 'No API key',
|
||||
'ai.providers.configure': 'Configure',
|
||||
'ai.providers.remove': 'Remove',
|
||||
'ai.providers.name': 'Display Name',
|
||||
'ai.providers.name.placeholder': 'e.g. My Provider',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'ai.providers.apiKey.placeholder': 'Enter API key',
|
||||
'ai.providers.apiKey.decrypting': 'Decrypting...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.defaultModel': 'Default Model',
|
||||
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.refreshModels': 'Refresh models',
|
||||
'ai.providers.searchModel': 'Search or type model ID...',
|
||||
'ai.providers.filterModels': 'Filter models...',
|
||||
'ai.providers.loadingModels': 'Loading models...',
|
||||
'ai.providers.noMatchingModels': 'No matching models',
|
||||
'ai.providers.clickToLoadModels': 'Click to load models',
|
||||
'ai.providers.showingModels': 'Showing first 100 of {count} models. Type to filter.',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT subscription here, or configure an OpenAI provider API key (passed as CODEX_API_KEY).',
|
||||
'ai.codex.detecting': 'Detecting...',
|
||||
'ai.codex.notFound': 'Not found',
|
||||
'ai.codex.awaitingLogin': 'Awaiting login',
|
||||
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
|
||||
'ai.codex.connectedApiKey': 'Connected via API key',
|
||||
'ai.codex.notConnected': 'Not connected',
|
||||
'ai.codex.statusUnknown': 'Status unknown',
|
||||
'ai.codex.path': 'Path:',
|
||||
'ai.codex.notFoundHint': 'Could not find codex in PATH. Install it or specify the executable path below.',
|
||||
'ai.codex.customPathPlaceholder': 'e.g. /usr/local/bin/codex',
|
||||
'ai.codex.check': 'Check',
|
||||
'ai.codex.openLogin': 'Open Login',
|
||||
'ai.codex.logout': 'Logout',
|
||||
'ai.codex.connectChatGPT': 'Connect ChatGPT',
|
||||
'ai.codex.refreshStatus': 'Refresh Status',
|
||||
'ai.codex.apiKeyHint': 'Enabled OpenAI provider API key detected. Codex ACP can also authenticate without ChatGPT login.',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-code-acp for ACP protocol streaming.",
|
||||
'ai.claude.detecting': 'Detecting...',
|
||||
'ai.claude.detected': 'Detected',
|
||||
'ai.claude.notFound': 'Not found',
|
||||
'ai.claude.path': 'Path:',
|
||||
'ai.claude.notFoundHint': 'Could not find claude in PATH. Install it or specify the executable path below.',
|
||||
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
|
||||
'ai.claude.check': 'Check',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Default Agent',
|
||||
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
|
||||
'ai.defaultAgent.catty': 'Catty (Built-in)',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
|
||||
'ai.chat.toolDenied': 'Action was rejected by the user.',
|
||||
'ai.chat.toolApprovalTitle': 'Permission Required',
|
||||
'ai.chat.toolApproved': 'Approved',
|
||||
'ai.chat.toolApprovalHint': 'Press Enter to approve, Escape to reject',
|
||||
'ai.chat.approve': 'Approve',
|
||||
'ai.chat.reject': 'Reject',
|
||||
'ai.chat.toolLabel': 'Tool',
|
||||
'ai.chat.targetLabel': 'Target',
|
||||
'ai.chat.permissionRequired': 'Permission Required',
|
||||
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
|
||||
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
|
||||
'ai.chat.recommendAllow': 'Allow',
|
||||
'ai.chat.recommendConfirm': 'Confirm',
|
||||
'ai.chat.recommendDeny': 'Deny',
|
||||
'ai.chat.exportConversation': 'Export conversation',
|
||||
'ai.chat.exportAs': 'Export As',
|
||||
'ai.chat.exportMarkdown': 'Markdown',
|
||||
'ai.chat.exportJSON': 'JSON',
|
||||
'ai.chat.exportPlainText': 'Plain Text',
|
||||
'ai.chat.thinking': 'Thinking',
|
||||
'ai.chat.thoughtFor': 'Thought for {duration}',
|
||||
'ai.chat.thought': 'Thought',
|
||||
'ai.chat.agents': 'Agents',
|
||||
'ai.chat.detectedOnMachine': 'Detected on this machine',
|
||||
'ai.chat.rescan': 'Re-scan',
|
||||
'ai.chat.permObserver': 'Observer',
|
||||
'ai.chat.permConfirm': 'Confirm',
|
||||
'ai.chat.permAuto': 'Auto',
|
||||
'ai.chat.permObserverDesc': 'Read only',
|
||||
'ai.chat.permConfirmDesc': 'Ask before actions',
|
||||
'ai.chat.permAutoDesc': 'Execute freely',
|
||||
'ai.chat.emptyHint': 'Ask about your servers, run commands, or get help with configurations.',
|
||||
'ai.chat.placeholder': 'Message {agent} — @ to include context, / for commands',
|
||||
'ai.chat.placeholderDefault': 'Message Catty Agent...',
|
||||
'ai.chat.noModel': 'No model',
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
'ai.chat.untitled': 'Untitled',
|
||||
'ai.chat.justNow': 'Just now',
|
||||
'ai.chat.minutesAgo': '{n}m ago',
|
||||
'ai.chat.hoursAgo': '{n}h ago',
|
||||
'ai.chat.daysAgo': '{n}d ago',
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.allSessions': 'All Sessions',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
'ai.chat.retryHint': 'You can retry by sending your message again.',
|
||||
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
|
||||
'ai.chat.menuHosts': 'Hosts',
|
||||
'ai.chat.menuContext': 'Context',
|
||||
'ai.chat.menuFiles': 'Files',
|
||||
'ai.chat.menuImage': 'Image',
|
||||
'ai.chat.menuMentionHost': 'Mention Host',
|
||||
|
||||
// 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 Safety Settings
|
||||
'ai.safety.title': 'Safety',
|
||||
'ai.safety.permissionMode': 'Permission Mode',
|
||||
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations via MCP Server, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
|
||||
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
|
||||
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
|
||||
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
|
||||
'ai.safety.commandTimeout': 'Command Timeout',
|
||||
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated. Applies to both built-in and ACP agents.',
|
||||
'ai.safety.commandTimeout.unit': 'sec',
|
||||
'ai.safety.maxIterations': 'Max Iterations',
|
||||
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. ACP agents may have their own internal iteration limits that take precedence.',
|
||||
'ai.safety.blocklist': 'Command Blocklist',
|
||||
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents via MCP Server.',
|
||||
'ai.safety.blocklist.placeholder': 'Regex pattern...',
|
||||
'ai.safety.blocklist.reset': 'Reset to defaults',
|
||||
'ai.safety.blocklist.add': 'Add pattern',
|
||||
'ai.safety.note': 'Command Blocklist, Command Timeout, and Observer mode are enforced at the MCP Server level, applying to all agent types. Confirm mode and Max Iterations are fully enforced for the built-in agent; ACP agents may have their own internal controls for these settings.',
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -37,6 +37,7 @@ const zhCN: Messages = {
|
||||
// Dialogs / prompts
|
||||
'confirm.deleteHost': '删除主机 "{name}"?',
|
||||
'confirm.deleteIdentity': '删除身份 "{name}"?',
|
||||
'confirm.removeProvider': '移除提供商 "{name}"?',
|
||||
'dialog.renameWorkspace.title': '重命名工作区',
|
||||
'dialog.renameSession.title': '重命名会话',
|
||||
'field.name': '名称',
|
||||
@@ -98,6 +99,8 @@ const zhCN: Messages = {
|
||||
'settings.update.lastCheckedMinutesAgo': '{n} 分钟前',
|
||||
'settings.update.lastCheckedHoursAgo': '{n} 小时前',
|
||||
'settings.update.lastCheckedPrefix': '上次检查:',
|
||||
'settings.update.autoUpdateEnabled': '自动更新',
|
||||
'settings.update.autoUpdateEnabledDesc': '有新版本时自动检查并下载更新。',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': '会话日志',
|
||||
@@ -125,6 +128,8 @@ const zhCN: Messages = {
|
||||
'settings.globalHotkey.reset': '恢复默认',
|
||||
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
|
||||
'settings.globalHotkey.enabled': '启用全局快捷键',
|
||||
'settings.globalHotkey.enabledDesc': '注册系统级键盘快捷键。禁用后将取消所有全局快捷键注册。',
|
||||
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
|
||||
|
||||
// Tray Panel
|
||||
@@ -544,8 +549,8 @@ const zhCN: Messages = {
|
||||
'hostDetails.certs.empty': '暂无证书',
|
||||
'hostDetails.agentForwarding': '转发 SSH 密钥',
|
||||
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 未运行',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '请在 Windows 服务管理器 (services.msc) 中启用 OpenSSH Authentication Agent 服务。',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '未检测到 SSH Agent。请启用 Windows OpenSSH Authentication Agent 服务,或使用兼容的 Agent(如 Bitwarden、1Password、gpg-agent)。',
|
||||
'hostDetails.section.agentForwarding': 'SSH 代理',
|
||||
'hostDetails.section.legacyAlgorithms': '旧版算法',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
@@ -1141,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': '输出时自动滚动',
|
||||
@@ -1493,6 +1509,148 @@ const zhCN: Messages = {
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': '自动换行',
|
||||
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
'ai.title': 'AI',
|
||||
'ai.description': '配置 AI 提供商、Agent 和安全设置',
|
||||
'ai.providers': '提供商',
|
||||
'ai.providers.empty': '尚未配置提供商。添加一个提供商以开始使用。',
|
||||
'ai.providers.add': '添加提供商',
|
||||
'ai.providers.active': '活跃',
|
||||
'ai.providers.apiKeyConfigured': 'API Key 已配置',
|
||||
'ai.providers.noApiKey': '未设置 API Key',
|
||||
'ai.providers.configure': '配置',
|
||||
'ai.providers.remove': '移除',
|
||||
'ai.providers.name': '显示名称',
|
||||
'ai.providers.name.placeholder': '例如 我的提供商',
|
||||
'ai.providers.apiKey': 'API Key',
|
||||
'ai.providers.apiKey.placeholder': '输入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.defaultModel': '默认模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.refreshModels': '刷新模型列表',
|
||||
'ai.providers.searchModel': '搜索或输入模型 ID...',
|
||||
'ai.providers.filterModels': '筛选模型...',
|
||||
'ai.providers.loadingModels': '加载模型中...',
|
||||
'ai.providers.noMatchingModels': '没有匹配的模型',
|
||||
'ai.providers.clickToLoadModels': '点击加载模型',
|
||||
'ai.providers.showingModels': '显示前 100 个,共 {count} 个模型。输入以筛选。',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。在此通过 ChatGPT 订阅登录,或配置 OpenAI 提供商的 API Key(将作为 CODEX_API_KEY 传递)。',
|
||||
'ai.codex.detecting': '检测中...',
|
||||
'ai.codex.notFound': '未找到',
|
||||
'ai.codex.awaitingLogin': '等待登录',
|
||||
'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接',
|
||||
'ai.codex.connectedApiKey': '已通过 API Key 连接',
|
||||
'ai.codex.notConnected': '未连接',
|
||||
'ai.codex.statusUnknown': '状态未知',
|
||||
'ai.codex.path': '路径:',
|
||||
'ai.codex.notFoundHint': '在 PATH 中未找到 codex。请安装或在下方指定可执行文件路径。',
|
||||
'ai.codex.customPathPlaceholder': '例如 /usr/local/bin/codex',
|
||||
'ai.codex.check': '检查',
|
||||
'ai.codex.openLogin': '打开登录',
|
||||
'ai.codex.logout': '退出登录',
|
||||
'ai.codex.connectChatGPT': '连接 ChatGPT',
|
||||
'ai.codex.refreshStatus': '刷新状态',
|
||||
'ai.codex.apiKeyHint': '检测到已启用的 OpenAI 提供商 API Key。Codex ACP 也可以无需 ChatGPT 登录进行认证。',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-code-acp 进行 ACP 协议流式传输。',
|
||||
'ai.claude.detecting': '检测中...',
|
||||
'ai.claude.detected': '已检测到',
|
||||
'ai.claude.notFound': '未找到',
|
||||
'ai.claude.path': '路径:',
|
||||
'ai.claude.notFoundHint': '在 PATH 中未找到 claude。请安装或在下方指定可执行文件路径。',
|
||||
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
|
||||
'ai.claude.check': '检查',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': '默认 Agent',
|
||||
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
|
||||
'ai.defaultAgent.catty': 'Catty(内置)',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
||||
'ai.chat.toolDenied': '操作已被用户拒绝。',
|
||||
'ai.chat.toolApprovalTitle': '需要权限确认',
|
||||
'ai.chat.toolApproved': '已批准',
|
||||
'ai.chat.toolApprovalHint': '按回车批准,按 Esc 拒绝',
|
||||
'ai.chat.approve': '批准',
|
||||
'ai.chat.reject': '拒绝',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目标',
|
||||
'ai.chat.permissionRequired': '需要权限',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望执行一个需要你批准的工具调用。',
|
||||
'ai.chat.commandBlocked': '此命令已被安全策略拦截,无法执行。',
|
||||
'ai.chat.recommendAllow': '允许',
|
||||
'ai.chat.recommendConfirm': '确认',
|
||||
'ai.chat.recommendDeny': '拒绝',
|
||||
'ai.chat.exportConversation': '导出对话',
|
||||
'ai.chat.exportAs': '导出为',
|
||||
'ai.chat.exportMarkdown': 'Markdown',
|
||||
'ai.chat.exportJSON': 'JSON',
|
||||
'ai.chat.exportPlainText': '纯文本',
|
||||
'ai.chat.thinking': '思考中',
|
||||
'ai.chat.thoughtFor': '思考了 {duration}',
|
||||
'ai.chat.thought': '思考',
|
||||
'ai.chat.agents': 'Agents',
|
||||
'ai.chat.detectedOnMachine': '在本机检测到',
|
||||
'ai.chat.rescan': '重新扫描',
|
||||
'ai.chat.permObserver': '观察',
|
||||
'ai.chat.permConfirm': '确认',
|
||||
'ai.chat.permAuto': '自主',
|
||||
'ai.chat.permObserverDesc': '只读模式',
|
||||
'ai.chat.permConfirmDesc': '操作前询问',
|
||||
'ai.chat.permAutoDesc': '自由执行',
|
||||
'ai.chat.emptyHint': '询问服务器相关问题、执行命令或获取配置帮助。',
|
||||
'ai.chat.placeholder': '向 {agent} 发送消息 — @ 引用上下文,/ 使用命令',
|
||||
'ai.chat.placeholderDefault': '向 Catty Agent 发送消息...',
|
||||
'ai.chat.noModel': '未选择模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
'ai.chat.untitled': '无标题',
|
||||
'ai.chat.justNow': '刚刚',
|
||||
'ai.chat.minutesAgo': '{n}分钟前',
|
||||
'ai.chat.hoursAgo': '{n}小时前',
|
||||
'ai.chat.daysAgo': '{n}天前',
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
'ai.chat.retryHint': '你可以重新发送消息来重试。',
|
||||
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
|
||||
'ai.chat.menuHosts': '主机',
|
||||
'ai.chat.menuContext': '上下文',
|
||||
'ai.chat.menuFiles': '文件',
|
||||
'ai.chat.menuImage': '图片',
|
||||
'ai.chat.menuMentionHost': '提及主机',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
||||
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': '安全',
|
||||
'ai.safety.permissionMode': '权限模式',
|
||||
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式通过 MCP Server 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
|
||||
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
|
||||
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
|
||||
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
|
||||
'ai.safety.commandTimeout': '命令超时',
|
||||
'ai.safety.commandTimeout.description': '命令执行的最大秒数,超时将被终止。对内置和 ACP Agent 均生效。',
|
||||
'ai.safety.commandTimeout.unit': '秒',
|
||||
'ai.safety.maxIterations': '最大迭代次数',
|
||||
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
|
||||
'ai.safety.blocklist': '命令黑名单',
|
||||
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 MCP Server 对内置和 ACP Agent 均生效。',
|
||||
'ai.safety.blocklist.placeholder': '正则表达式...',
|
||||
'ai.safety.blocklist.reset': '恢复默认',
|
||||
'ai.safety.blocklist.add': '添加规则',
|
||||
'ai.safety.note': '命令黑名单、命令超时和观察者模式通过 MCP Server 层强制执行,对所有 Agent 类型生效。确认模式和最大迭代次数对内置 Agent 完全强制执行;ACP Agent 可能有自己的内部控制。',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
@@ -30,7 +30,8 @@ class CustomThemeStore {
|
||||
this.setupCrossWindowSync();
|
||||
}
|
||||
|
||||
private loadFromStorage = () => {
|
||||
/** Reload themes from localStorage. Called internally and after sync apply. */
|
||||
loadFromStorage = () => {
|
||||
try {
|
||||
const parsed = localStorageAdapter.read<TerminalTheme[]>(STORAGE_KEY_CUSTOM_THEMES);
|
||||
if (Array.isArray(parsed)) {
|
||||
@@ -39,7 +40,7 @@ class CustomThemeStore {
|
||||
} catch {
|
||||
// ignore corrupt data
|
||||
}
|
||||
this.cachedAllThemes = null; // invalidate cache
|
||||
this.notify();
|
||||
};
|
||||
|
||||
private saveToStorage = () => {
|
||||
|
||||
558
application/state/useAIState.ts
Normal file
558
application/state/useAIState.ts
Normal file
@@ -0,0 +1,558 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_HOST_PERMISSIONS,
|
||||
STORAGE_KEY_AI_EXTERNAL_AGENTS,
|
||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type {
|
||||
AISession,
|
||||
AIPermissionMode,
|
||||
ProviderConfig,
|
||||
HostAIPermission,
|
||||
ExternalAgentConfig,
|
||||
ChatMessage,
|
||||
AISessionScope,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
function getAIBridge() {
|
||||
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
|
||||
}
|
||||
|
||||
|
||||
/** Maximum number of sessions to keep in localStorage. */
|
||||
const MAX_STORED_SESSIONS = 50;
|
||||
/** Maximum number of messages per session when persisting to localStorage. */
|
||||
const MAX_SESSION_MESSAGES = 200;
|
||||
|
||||
/**
|
||||
* Prune sessions before writing to localStorage to prevent hitting the
|
||||
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
|
||||
* state retains all messages until the session is reloaded.
|
||||
*
|
||||
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
|
||||
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
|
||||
*/
|
||||
function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
// Sort by updatedAt descending so we keep the newest
|
||||
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
|
||||
return limited.map(s => {
|
||||
if (s.messages.length > MAX_SESSION_MESSAGES) {
|
||||
return { ...s, messages: s.messages.slice(-MAX_SESSION_MESSAGES) };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
}
|
||||
|
||||
export function useAIState() {
|
||||
// ── Provider Config ──
|
||||
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
|
||||
localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? []
|
||||
);
|
||||
const [activeProviderId, setActiveProviderIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? ''
|
||||
);
|
||||
const [activeModelId, setActiveModelIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? ''
|
||||
);
|
||||
|
||||
// ── Permission Model ──
|
||||
const [globalPermissionMode, setGlobalPermissionModeRaw] = useState<AIPermissionMode>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
|
||||
return 'confirm';
|
||||
});
|
||||
const [hostPermissions, setHostPermissionsRaw] = useState<HostAIPermission[]>(() =>
|
||||
localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS) ?? []
|
||||
);
|
||||
|
||||
// ── External Agents ──
|
||||
const [externalAgents, setExternalAgentsRaw] = useState<ExternalAgentConfig[]>(() =>
|
||||
localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS) ?? []
|
||||
);
|
||||
const [defaultAgentId, setDefaultAgentIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty'
|
||||
);
|
||||
|
||||
// ── Safety Settings ──
|
||||
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
|
||||
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
|
||||
);
|
||||
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60
|
||||
);
|
||||
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
|
||||
);
|
||||
|
||||
// ── Sessions ──
|
||||
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
|
||||
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []
|
||||
);
|
||||
// Ref that always holds the latest sessions for use inside debounced callbacks
|
||||
const sessionsRef = useRef(sessions);
|
||||
useEffect(() => {
|
||||
sessionsRef.current = sessions;
|
||||
}, [sessions]);
|
||||
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
|
||||
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>({});
|
||||
|
||||
// Per-agent model selection: remembers last selected model per agent
|
||||
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
|
||||
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {}
|
||||
);
|
||||
|
||||
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
||||
setActiveSessionIdMapRaw(prev => ({ ...prev, [scopeKey]: id }));
|
||||
}, []);
|
||||
|
||||
const setAgentModel = useCallback((agentId: string, modelId: string) => {
|
||||
setAgentModelMapRaw(prev => {
|
||||
const next = { ...prev, [agentId]: modelId };
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Persist helpers ──
|
||||
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
|
||||
setProvidersRaw(prev => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PROVIDERS, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setActiveProviderId = useCallback((id: string) => {
|
||||
setActiveProviderIdRaw(id);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, id);
|
||||
}, []);
|
||||
|
||||
const setActiveModelId = useCallback((id: string) => {
|
||||
setActiveModelIdRaw(id);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_MODEL, id);
|
||||
}, []);
|
||||
|
||||
const setGlobalPermissionMode = useCallback((mode: AIPermissionMode) => {
|
||||
setGlobalPermissionModeRaw(mode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PERMISSION_MODE, mode);
|
||||
// Sync to MCP Server bridge (observer mode blocks write operations)
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetPermissionMode?.(mode);
|
||||
}, []);
|
||||
|
||||
const setHostPermissions = useCallback((value: HostAIPermission[] | ((prev: HostAIPermission[]) => HostAIPermission[])) => {
|
||||
setHostPermissionsRaw(prev => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_HOST_PERMISSIONS, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
|
||||
setExternalAgentsRaw(prev => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_AGENTS, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setDefaultAgentId = useCallback((id: string) => {
|
||||
setDefaultAgentIdRaw(id);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, id);
|
||||
}, []);
|
||||
|
||||
const setCommandBlocklist = useCallback((value: string[]) => {
|
||||
setCommandBlocklistRaw(value);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
|
||||
// Sync to MCP Server bridge so ACP agents also respect the blocklist
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetCommandBlocklist?.(value);
|
||||
}, []);
|
||||
|
||||
const setCommandTimeout = useCallback((value: number) => {
|
||||
setCommandTimeoutRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, value);
|
||||
// Sync to MCP Server bridge
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetCommandTimeout?.(value);
|
||||
}, []);
|
||||
|
||||
const setMaxIterations = useCallback((value: number) => {
|
||||
setMaxIterationsRaw(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
|
||||
// Sync to MCP Server bridge (used by ACP agent path)
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetMaxIterations?.(value);
|
||||
}, []);
|
||||
|
||||
// ── Cross-window sync via storage events ──
|
||||
// When the settings window updates localStorage, the main window picks up changes.
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
try {
|
||||
switch (e.key) {
|
||||
case STORAGE_KEY_AI_PROVIDERS: {
|
||||
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
|
||||
if (parsed != null && !Array.isArray(parsed)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_PROVIDERS is not an array, skipping');
|
||||
break;
|
||||
}
|
||||
setProvidersRaw(parsed ?? []);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_ACTIVE_PROVIDER:
|
||||
setActiveProviderIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? '');
|
||||
break;
|
||||
case STORAGE_KEY_AI_ACTIVE_MODEL:
|
||||
setActiveModelIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? '');
|
||||
break;
|
||||
case STORAGE_KEY_AI_PERMISSION_MODE: {
|
||||
const mode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
if (mode === 'observer' || mode === 'confirm' || mode === 'autonomous') {
|
||||
setGlobalPermissionModeRaw(mode);
|
||||
getAIBridge()?.aiMcpSetPermissionMode?.(mode);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
|
||||
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
|
||||
if (agents != null && !Array.isArray(agents)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_EXTERNAL_AGENTS is not an array, skipping');
|
||||
break;
|
||||
}
|
||||
setExternalAgentsRaw(agents ?? []);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_DEFAULT_AGENT:
|
||||
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
|
||||
break;
|
||||
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
|
||||
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (list != null && !Array.isArray(list)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_COMMAND_BLOCKLIST is not an array, skipping');
|
||||
break;
|
||||
}
|
||||
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
setCommandBlocklistRaw(blocklist);
|
||||
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
|
||||
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
|
||||
if (!Number.isFinite(timeout)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_COMMAND_TIMEOUT is not a finite number, skipping');
|
||||
break;
|
||||
}
|
||||
setCommandTimeoutRaw(timeout);
|
||||
getAIBridge()?.aiMcpSetCommandTimeout?.(timeout);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_MAX_ITERATIONS: {
|
||||
const iters = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
||||
if (!Number.isFinite(iters)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_MAX_ITERATIONS is not a finite number, skipping');
|
||||
break;
|
||||
}
|
||||
setMaxIterationsRaw(iters);
|
||||
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_HOST_PERMISSIONS: {
|
||||
const perms = localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS);
|
||||
if (perms != null && !Array.isArray(perms)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_HOST_PERMISSIONS is not an array, skipping');
|
||||
break;
|
||||
}
|
||||
setHostPermissionsRaw(perms ?? []);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
|
||||
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[useAIState] Cross-window sync: failed to process storage event for key', e.key, err);
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
}, []);
|
||||
|
||||
// ── Sync initial safety settings to MCP Server on mount ──
|
||||
useEffect(() => {
|
||||
const bridge = getAIBridge();
|
||||
const initialBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
bridge?.aiMcpSetCommandBlocklist?.(initialBlocklist);
|
||||
const initialTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
|
||||
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
|
||||
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
||||
bridge?.aiMcpSetMaxIterations?.(initialMaxIter);
|
||||
const initialPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE) ?? 'confirm';
|
||||
bridge?.aiMcpSetPermissionMode?.(initialPermMode);
|
||||
}, []);
|
||||
|
||||
// ── Session CRUD ──
|
||||
const persistSessions = useCallback((next: AISession[]) => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(next));
|
||||
}, []);
|
||||
|
||||
// Debounced version of persistSessions for high-frequency updates (e.g. streaming)
|
||||
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const debouncedPersistSessions = useCallback(() => {
|
||||
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = setTimeout(() => {
|
||||
if (!mountedRef.current) return; // Skip writes after unmount
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(sessionsRef.current));
|
||||
persistTimerRef.current = null;
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
// Flush pending debounced writes on unmount
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
persistSessions(sessionsRef.current);
|
||||
}
|
||||
};
|
||||
}, [persistSessions]);
|
||||
|
||||
const createSession = useCallback((scope: AISessionScope, agentId?: string): AISession => {
|
||||
const now = Date.now();
|
||||
const session: AISession = {
|
||||
id: `ai_${now}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: 'New Chat',
|
||||
agentId: agentId || defaultAgentId,
|
||||
scope,
|
||||
messages: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
setSessionsRaw(prev => {
|
||||
const next = [session, ...prev];
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
const scopeKey = `${scope.type}:${scope.targetId ?? ''}`;
|
||||
setActiveSessionId(scopeKey, session.id);
|
||||
return session;
|
||||
}, [defaultAgentId, persistSessions, setActiveSessionId]);
|
||||
|
||||
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => s.id !== sessionId);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
if (scopeKey) {
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
if (prev[scopeKey] === sessionId) return { ...prev, [scopeKey]: null };
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [persistSessions]);
|
||||
|
||||
const deleteSessionsByTarget = useCallback((scopeType: 'terminal' | 'workspace', targetId: string) => {
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => {
|
||||
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
|
||||
});
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
const scopeKey = `${scopeType}:${targetId}`;
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
if (prev[scopeKey] != null) return { ...prev, [scopeKey]: null };
|
||||
return prev;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
const updateSessionTitle = useCallback((sessionId: string, title: string) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => s.id === sessionId ? { ...s, title, updatedAt: Date.now() } : s);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
// Maximum messages per session to prevent unbounded memory growth
|
||||
const MAX_MESSAGES_PER_SESSION = 500;
|
||||
|
||||
const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => {
|
||||
if (s.id !== sessionId) return s;
|
||||
let msgs = [...s.messages, message];
|
||||
// Trim oldest messages if exceeding limit (keep system messages)
|
||||
if (msgs.length > MAX_MESSAGES_PER_SESSION) {
|
||||
const systemMsgs = msgs.filter(m => m.role === 'system');
|
||||
const nonSystemMsgs = msgs.filter(m => m.role !== 'system');
|
||||
const dropped = nonSystemMsgs.length - (MAX_MESSAGES_PER_SESSION - systemMsgs.length);
|
||||
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${MAX_MESSAGES_PER_SESSION} limit`);
|
||||
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-MAX_MESSAGES_PER_SESSION + systemMsgs.length)];
|
||||
}
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
const updateLastMessage = useCallback((sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => {
|
||||
if (s.id !== sessionId || s.messages.length === 0) return s;
|
||||
const msgs = [...s.messages];
|
||||
msgs[msgs.length - 1] = updater(msgs[msgs.length - 1]);
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
const updateMessageById = useCallback((sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => {
|
||||
if (s.id !== sessionId) return s;
|
||||
const idx = s.messages.findIndex(m => m.id === messageId);
|
||||
if (idx === -1) return s;
|
||||
const msgs = [...s.messages];
|
||||
msgs[idx] = updater(msgs[idx]);
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
const clearSessionMessages = useCallback((sessionId: string) => {
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => s.id === sessionId ? { ...s, messages: [], updatedAt: Date.now() } : s);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => {
|
||||
// Keep sessions without a targetId (global scope)
|
||||
if (!s.scope.targetId) return true;
|
||||
// Keep sessions whose target still exists
|
||||
return activeTargetIds.has(s.scope.targetId);
|
||||
});
|
||||
if (next.length !== prev.length) {
|
||||
persistSessions(next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
// ── Provider CRUD helpers ──
|
||||
const addProvider = useCallback((provider: ProviderConfig) => {
|
||||
setProviders(prev => [...prev, provider]);
|
||||
}, [setProviders]);
|
||||
|
||||
const updateProvider = useCallback((id: string, updates: Partial<ProviderConfig>) => {
|
||||
setProviders(prev => prev.map(p => p.id === id ? { ...p, ...updates } : p));
|
||||
}, [setProviders]);
|
||||
|
||||
const removeProvider = useCallback((id: string) => {
|
||||
setProviders(prev => prev.filter(p => p.id !== id));
|
||||
// Use the raw setter to avoid stale closure over setActiveProviderId
|
||||
setActiveProviderIdRaw(prevId => {
|
||||
if (prevId === id) {
|
||||
const next = '';
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, next);
|
||||
return next;
|
||||
}
|
||||
return prevId;
|
||||
});
|
||||
}, [setProviders]);
|
||||
|
||||
// ── Computed ──
|
||||
const activeProvider = providers.find(p => p.id === activeProviderId) ?? null;
|
||||
|
||||
return {
|
||||
// Provider config
|
||||
providers,
|
||||
setProviders,
|
||||
addProvider,
|
||||
updateProvider,
|
||||
removeProvider,
|
||||
activeProviderId,
|
||||
setActiveProviderId,
|
||||
activeModelId,
|
||||
setActiveModelId,
|
||||
activeProvider,
|
||||
|
||||
// Permission model
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
hostPermissions,
|
||||
setHostPermissions,
|
||||
|
||||
// External agents
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
defaultAgentId,
|
||||
setDefaultAgentId,
|
||||
|
||||
// Safety
|
||||
commandBlocklist,
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
|
||||
// Per-agent model memory
|
||||
agentModelMap,
|
||||
setAgentModel,
|
||||
|
||||
// Sessions (per-scope active session)
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
setActiveSessionId,
|
||||
createSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
clearSessionMessages,
|
||||
cleanupOrphanedSessions,
|
||||
};
|
||||
}
|
||||
101
application/state/useAgentDiscovery.ts
Normal file
101
application/state/useAgentDiscovery.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
|
||||
interface NetcattyBridge {
|
||||
aiDiscoverAgents(): Promise<DiscoveredAgent[]>;
|
||||
}
|
||||
|
||||
function getBridge(): NetcattyBridge | undefined {
|
||||
return (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
}
|
||||
|
||||
export function useAgentDiscovery(
|
||||
externalAgents: ExternalAgentConfig[],
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
|
||||
) {
|
||||
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
|
||||
const discover = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge) return;
|
||||
|
||||
setIsDiscovering(true);
|
||||
try {
|
||||
const agents = await bridge.aiDiscoverAgents();
|
||||
setDiscoveredAgents(agents);
|
||||
} catch (err) {
|
||||
console.error('Agent discovery failed:', err);
|
||||
} finally {
|
||||
setIsDiscovering(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Discover on mount
|
||||
useEffect(() => {
|
||||
discover();
|
||||
}, [discover]);
|
||||
|
||||
// Auto-update args for already-configured discovered agents when
|
||||
// the canonical args from discovery change (e.g. after an app update).
|
||||
useEffect(() => {
|
||||
if (!setExternalAgents || discoveredAgents.length === 0) return;
|
||||
|
||||
setExternalAgents((prev) => {
|
||||
let changed = false;
|
||||
const next = prev.map((ea) => {
|
||||
// Only update agents that were auto-discovered (id starts with "discovered_")
|
||||
if (!ea.id.startsWith('discovered_')) return ea;
|
||||
|
||||
const match = discoveredAgents.find(
|
||||
(da) => ea.command === da.path || ea.command === da.command,
|
||||
);
|
||||
if (!match) return ea;
|
||||
|
||||
// Check if args or ACP config differ
|
||||
const currentArgs = JSON.stringify(ea.args || []);
|
||||
const newArgs = JSON.stringify(match.args);
|
||||
const acpChanged = ea.acpCommand !== match.acpCommand
|
||||
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify(match.acpArgs || []);
|
||||
if (currentArgs !== newArgs || acpChanged) {
|
||||
changed = true;
|
||||
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs };
|
||||
}
|
||||
return ea;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [discoveredAgents, setExternalAgents]);
|
||||
|
||||
// Filter out agents that are already configured as external agents
|
||||
const unconfiguredAgents = discoveredAgents.filter(
|
||||
(da) => !externalAgents.some(
|
||||
(ea) => ea.command === da.command || ea.command === da.path,
|
||||
),
|
||||
);
|
||||
|
||||
// Build ExternalAgentConfig from a discovered agent
|
||||
const enableAgent = useCallback(
|
||||
(agent: DiscoveredAgent): ExternalAgentConfig => {
|
||||
return {
|
||||
id: `discovered_${agent.command}`,
|
||||
name: agent.name,
|
||||
command: agent.path || agent.command,
|
||||
args: agent.args,
|
||||
icon: agent.icon,
|
||||
enabled: true,
|
||||
acpCommand: agent.acpCommand,
|
||||
acpArgs: agent.acpArgs,
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
discoveredAgents,
|
||||
unconfiguredAgents,
|
||||
isDiscovering,
|
||||
rediscover: discover,
|
||||
enableAgent,
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { collectSyncableSettings } from '../../domain/syncPayload';
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
|
||||
@@ -31,7 +32,9 @@ interface AutoSyncConfig {
|
||||
snippetPackages?: SyncPayload['snippetPackages'];
|
||||
portForwardingRules?: SyncPayload['portForwardingRules'];
|
||||
knownHosts?: SyncPayload['knownHosts'];
|
||||
|
||||
/** Opaque token that changes whenever a synced setting changes. */
|
||||
settingsVersion?: number;
|
||||
|
||||
// Callbacks
|
||||
onApplyPayload: (payload: SyncPayload) => void;
|
||||
}
|
||||
@@ -97,13 +100,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const buildPayload = useCallback((): SyncPayload => {
|
||||
return {
|
||||
...getSyncSnapshot(),
|
||||
settings: collectSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
}, [getSyncSnapshot]);
|
||||
|
||||
// Create a hash of current data for comparison
|
||||
// Create a hash of current data for comparison (includes settings)
|
||||
const getDataHash = useCallback(() => {
|
||||
return JSON.stringify(getSyncSnapshot());
|
||||
return JSON.stringify({ ...getSyncSnapshot(), settings: collectSyncableSettings() });
|
||||
}, [getSyncSnapshot]);
|
||||
|
||||
// Sync now handler - get fresh state directly from manager
|
||||
@@ -255,7 +259,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow]);
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion]);
|
||||
|
||||
// Check remote version on startup/unlock
|
||||
useEffect(() => {
|
||||
|
||||
66
application/state/useImageUpload.ts
Normal file
66
application/state/useImageUpload.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* useImageUpload - Handle image paste/drop with base64 conversion
|
||||
*
|
||||
* Ported from 1code's use-agents-file-upload.ts
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export interface UploadedImage {
|
||||
id: string;
|
||||
filename: string;
|
||||
dataUrl: string; // data:image/...;base64,... for preview
|
||||
base64Data: string; // raw base64 for API
|
||||
mediaType: string; // MIME type e.g. "image/png"
|
||||
}
|
||||
|
||||
async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const base64 = dataUrl.split(',')[1] || '';
|
||||
resolve({ dataUrl, base64 });
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function useImageUpload() {
|
||||
const [images, setImages] = useState<UploadedImage[]>([]);
|
||||
|
||||
const addImages = useCallback(async (files: File[]) => {
|
||||
const imageFiles = files.filter((f) => f.type.startsWith('image/'));
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
const newImages: UploadedImage[] = await Promise.all(
|
||||
imageFiles.map(async (file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const filename = file.name || `screenshot-${Date.now()}.png`;
|
||||
const mediaType = file.type || 'image/png';
|
||||
let dataUrl = '';
|
||||
let base64Data = '';
|
||||
try {
|
||||
const result = await fileToDataUrl(file);
|
||||
dataUrl = result.dataUrl;
|
||||
base64Data = result.base64;
|
||||
} catch (err) {
|
||||
console.error('[useImageUpload] Failed to convert:', err);
|
||||
}
|
||||
return { id, filename, dataUrl, base64Data, mediaType };
|
||||
}),
|
||||
);
|
||||
|
||||
setImages((prev) => [...prev, ...newImages]);
|
||||
}, []);
|
||||
|
||||
const removeImage = useCallback((id: string) => {
|
||||
setImages((prev) => prev.filter((i) => i.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearImages = useCallback(() => {
|
||||
setImages([]);
|
||||
}, []);
|
||||
|
||||
return { images, addImages, removeImage, clearImages };
|
||||
}
|
||||
@@ -27,10 +27,12 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../state/customThemeStore';
|
||||
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
|
||||
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
|
||||
@@ -264,7 +266,17 @@ export const useSettingsState = () => {
|
||||
if (stored === null) return true;
|
||||
return stored === 'true';
|
||||
});
|
||||
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
|
||||
if (stored === null) return true; // Default to enabled
|
||||
return stored === 'true';
|
||||
});
|
||||
const [hotkeyRegistrationError, setHotkeyRegistrationError] = useState<string | null>(null);
|
||||
const [globalHotkeyEnabled, setGlobalHotkeyEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED);
|
||||
if (stored === null) return true; // Default to enabled
|
||||
return stored === 'true';
|
||||
});
|
||||
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
@@ -332,6 +344,60 @@ export const useSettingsState = () => {
|
||||
setCustomCSS((prev) => (prev === storedCss ? prev : storedCss));
|
||||
}, []);
|
||||
|
||||
const rehydrateAllFromStorage = useCallback(() => {
|
||||
// Theme & appearance (already have helper)
|
||||
syncAppearanceFromStorage();
|
||||
syncCustomCssFromStorage();
|
||||
|
||||
// UI Font
|
||||
const storedFont = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
|
||||
if (storedFont) setUiFontFamilyId(storedFont);
|
||||
|
||||
// Language
|
||||
const storedLang = readStoredString(STORAGE_KEY_UI_LANGUAGE);
|
||||
if (storedLang) setUiLanguage(storedLang as UILanguage);
|
||||
|
||||
// Terminal
|
||||
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
|
||||
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
|
||||
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
if (storedTermFont) setTerminalFontFamilyId(storedTermFont);
|
||||
const storedTermSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
if (storedTermSize != null) setTerminalFontSize(storedTermSize);
|
||||
const storedTermSettings = readStoredString(STORAGE_KEY_TERM_SETTINGS);
|
||||
if (storedTermSettings) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedTermSettings);
|
||||
setTerminalSettings(parsed);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
const storedKb = readStoredString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
|
||||
if (storedKb) {
|
||||
try {
|
||||
setCustomKeyBindings(JSON.parse(storedKb));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Editor
|
||||
const storedWrap = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
|
||||
if (storedWrap === 'true' || storedWrap === 'false') setEditorWordWrapState(storedWrap === 'true');
|
||||
|
||||
// SFTP
|
||||
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
||||
if (storedDblClick === 'open' || storedDblClick === 'transfer') setSftpDoubleClickBehavior(storedDblClick);
|
||||
const storedAutoSync = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
|
||||
if (storedAutoSync === 'true' || storedAutoSync === 'false') setSftpAutoSync(storedAutoSync === 'true');
|
||||
const storedHidden = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
|
||||
if (storedHidden === 'true' || storedHidden === 'false') setSftpShowHiddenFiles(storedHidden === 'true');
|
||||
const storedCompress = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
@@ -457,6 +523,12 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_HOTKEY_RECORDING && typeof value === 'boolean') {
|
||||
setIsHotkeyRecordingState(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
|
||||
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
@@ -622,11 +694,25 @@ export const useSettingsState = () => {
|
||||
setSftpUseCompressedUpload(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== globalHotkeyEnabled) {
|
||||
setGlobalHotkeyEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync auto-update enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_AUTO_UPDATE_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== autoUpdateEnabled) {
|
||||
setAutoUpdateEnabled(newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, mergeIncomingTerminalSettings]);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -734,7 +820,7 @@ export const useSettingsState = () => {
|
||||
// Register/unregister the global hotkey in main process
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
if (toggleWindowHotkey) {
|
||||
if (toggleWindowHotkey && globalHotkeyEnabled) {
|
||||
setHotkeyRegistrationError(null);
|
||||
bridge
|
||||
.registerGlobalHotkey(toggleWindowHotkey)
|
||||
@@ -755,7 +841,13 @@ export const useSettingsState = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [toggleWindowHotkey, notifySettingsChanged]);
|
||||
}, [toggleWindowHotkey, globalHotkeyEnabled, notifySettingsChanged]);
|
||||
|
||||
// Persist global hotkey enabled setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
|
||||
}, [globalHotkeyEnabled, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync close to tray setting
|
||||
useEffect(() => {
|
||||
@@ -770,6 +862,41 @@ export const useSettingsState = () => {
|
||||
}
|
||||
}, [closeToTray, notifySettingsChanged]);
|
||||
|
||||
// Hydrate auto-update state from the main-process preference file on mount.
|
||||
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
|
||||
// in case localStorage was cleared or is stale.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
void bridge?.getAutoUpdate?.().then((result) => {
|
||||
if (result && typeof result.enabled === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => {
|
||||
if (prev === result.enabled) return prev;
|
||||
// Sync localStorage with the main-process truth
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, result.enabled ? 'true' : 'false');
|
||||
return result.enabled;
|
||||
});
|
||||
}
|
||||
}).catch(() => { /* bridge unavailable */ });
|
||||
}, []);
|
||||
|
||||
// Persist auto-update enabled setting.
|
||||
// Skip IPC on initial mount to avoid overwriting the main-process preference
|
||||
// file when localStorage has been cleared (where the default is true).
|
||||
const autoUpdateMountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
|
||||
if (!autoUpdateMountedRef.current) {
|
||||
autoUpdateMountedRef.current = true;
|
||||
return; // Skip IPC on initial mount
|
||||
}
|
||||
// Notify main process on user-initiated changes
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
|
||||
console.warn('[AutoUpdate] Failed to set auto-update:', err);
|
||||
});
|
||||
}, [autoUpdateEnabled, notifySettingsChanged]);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -912,6 +1039,21 @@ export const useSettingsState = () => {
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
autoUpdateEnabled,
|
||||
setAutoUpdateEnabled,
|
||||
hotkeyRegistrationError,
|
||||
globalHotkeyEnabled,
|
||||
setGlobalHotkeyEnabled,
|
||||
rehydrateAllFromStorage,
|
||||
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
settingsVersion: useMemo(() => Math.random(), [
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
uiFontFamilyId, uiLanguage, customCSS,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload,
|
||||
customThemes,
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// Check for updates at most once per hour
|
||||
@@ -56,7 +56,13 @@ export interface UseUpdateCheckResult {
|
||||
* - Respects dismissed version to avoid nagging
|
||||
* - Provides manual check capability
|
||||
*/
|
||||
export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUpdateCheckResult {
|
||||
// Accept auto-update toggle from the caller (e.g. useSettingsState) so it
|
||||
// reacts immediately in the same window. Falls back to reading localStorage
|
||||
// when no caller provides the value (e.g. in non-settings contexts).
|
||||
const autoUpdateEnabled = options?.autoUpdateEnabled ??
|
||||
(localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) !== 'false');
|
||||
|
||||
const [updateState, setUpdateState] = useState<UpdateState>({
|
||||
isChecking: false,
|
||||
hasUpdate: false,
|
||||
@@ -136,14 +142,20 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
return;
|
||||
}
|
||||
|
||||
// 'available' means an update was found but auto-download is disabled.
|
||||
// Surface the version info (hasUpdate + latestRelease) but keep
|
||||
// autoDownloadStatus at 'idle' so the manual download path shows.
|
||||
const isAvailableOnly = snapshot.status === 'available';
|
||||
|
||||
setUpdateState((prev) => {
|
||||
// Don't overwrite if the renderer already has a newer state
|
||||
if (prev.autoDownloadStatus !== 'idle') return prev;
|
||||
return {
|
||||
...prev,
|
||||
autoDownloadStatus: snapshot.status,
|
||||
downloadPercent: snapshot.percent,
|
||||
downloadError: snapshot.error,
|
||||
hasUpdate: isAvailableOnly ? true : prev.hasUpdate,
|
||||
autoDownloadStatus: isAvailableOnly ? 'idle' : snapshot.status,
|
||||
downloadPercent: isAvailableOnly ? 0 : snapshot.percent,
|
||||
downloadError: isAvailableOnly ? null : snapshot.error,
|
||||
// Use snapshot version if no release data or if versions differ
|
||||
latestRelease: (!prev.latestRelease || (snapshot.version && prev.latestRelease.version !== snapshot.version)) ? (snapshot.version ? {
|
||||
version: snapshot.version,
|
||||
@@ -186,15 +198,18 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
if (isDismissed) {
|
||||
dismissedAutoDownloadRef.current = true;
|
||||
}
|
||||
// When auto-update is disabled, autoDownload=false in the main process
|
||||
// so no download will start. Don't transition to 'downloading' or the
|
||||
// UI will be stuck at 0%. Keep status idle and let the manual download
|
||||
// link surface instead.
|
||||
const isAutoUpdateOff = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) === 'false';
|
||||
const shouldTrackDownload = !isDismissed && !isAutoUpdateOff;
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
hasUpdate: !isDismissed,
|
||||
// Only transition to 'downloading' if the user hasn't dismissed this
|
||||
// version — otherwise leave the status at 'idle' so no download
|
||||
// progress/ready toast appears for a release they don't want.
|
||||
autoDownloadStatus: isDismissed ? prev.autoDownloadStatus : 'downloading',
|
||||
downloadPercent: isDismissed ? prev.downloadPercent : 0,
|
||||
downloadError: isDismissed ? prev.downloadError : null,
|
||||
autoDownloadStatus: shouldTrackDownload ? 'downloading' : prev.autoDownloadStatus,
|
||||
downloadPercent: shouldTrackDownload ? 0 : prev.downloadPercent,
|
||||
downloadError: shouldTrackDownload ? null : prev.downloadError,
|
||||
// Use electron-updater's version if GitHub API hasn't resolved yet or
|
||||
// if the updater reports a different version than the cached release.
|
||||
latestRelease: (!prev.latestRelease || prev.latestRelease.version !== info.version) ? {
|
||||
@@ -439,6 +454,20 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
} else if (res?.checking) {
|
||||
// Another check is already in flight — don't change status; the
|
||||
// in-flight check will resolve via IPC events.
|
||||
} else if (nextStatus === 'error' && res?.available) {
|
||||
// GitHub API failed but electron-updater found an update.
|
||||
// Respect dismissed versions before surfacing.
|
||||
const dismissed = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
||||
if (res.version && res.version === dismissed) {
|
||||
// User dismissed this version — don't re-surface
|
||||
} else {
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
manualCheckStatus: 'available',
|
||||
hasUpdate: true,
|
||||
error: null,
|
||||
}));
|
||||
}
|
||||
} else if (nextStatus === 'error' && !res?.error && !res?.available) {
|
||||
// GitHub API failed but electron-updater says no update available.
|
||||
// Clear the error status so Settings doesn't stay stuck in error state.
|
||||
@@ -519,12 +548,12 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
if (IS_UPDATE_DEMO_MODE) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('Version check effect', {
|
||||
hasChecked: hasCheckedOnStartupRef.current,
|
||||
|
||||
debugLog('Version check effect', {
|
||||
hasChecked: hasCheckedOnStartupRef.current,
|
||||
currentVersion: updateState.currentVersion
|
||||
});
|
||||
|
||||
|
||||
if (hasCheckedOnStartupRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -533,12 +562,11 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've checked recently
|
||||
// Hydrate cached release info so update status is visible across windows.
|
||||
// When auto-update is disabled, hydrate release data (for the Settings UI)
|
||||
// but don't set hasUpdate (which would trigger the toast in App.tsx).
|
||||
const lastCheck = localStorageAdapter.readNumber(STORAGE_KEY_UPDATE_LAST_CHECK);
|
||||
const now = Date.now();
|
||||
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
|
||||
hasCheckedOnStartupRef.current = true;
|
||||
// Hydrate cached release info so late-opening windows show the result
|
||||
if (lastCheck) {
|
||||
const cachedRelease = localStorageAdapter.readString(STORAGE_KEY_UPDATE_LATEST_RELEASE);
|
||||
if (cachedRelease) {
|
||||
try {
|
||||
@@ -556,6 +584,19 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
// Ignore corrupted cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Respect auto-update toggle — skip automatic check when disabled.
|
||||
// Don't set hasCheckedOnStartupRef so re-enabling (which changes the
|
||||
// autoUpdateEnabled dependency) can re-trigger this effect.
|
||||
if (!autoUpdateEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've checked recently
|
||||
const now = Date.now();
|
||||
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
|
||||
hasCheckedOnStartupRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -563,6 +604,13 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
debugLog('Starting delayed update check for version:', updateState.currentVersion);
|
||||
|
||||
startupCheckTimeoutRef.current = setTimeout(async () => {
|
||||
// Re-check the toggle at fire time — the user may have toggled it
|
||||
// after the timer was scheduled.
|
||||
const stillEnabled = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
|
||||
if (stillEnabled === 'false') {
|
||||
debugLog('Skipping startup check — auto-update disabled after timer was scheduled');
|
||||
return;
|
||||
}
|
||||
// If electron-updater's auto-check already started a download, skip the
|
||||
// redundant GitHub API check to avoid duplicate toast notifications.
|
||||
if (autoDownloadStatusRef.current !== 'idle') {
|
||||
@@ -601,7 +649,7 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
clearTimeout(startupCheckTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [updateState.currentVersion, performCheck]);
|
||||
}, [updateState.currentVersion, autoUpdateEnabled, performCheck]);
|
||||
|
||||
return {
|
||||
updateState,
|
||||
|
||||
760
components/AIChatSidePanel.tsx
Normal file
760
components/AIChatSidePanel.tsx
Normal file
@@ -0,0 +1,760 @@
|
||||
/**
|
||||
* AIChatSidePanel - Main AI chat interface side panel
|
||||
*
|
||||
* Zed-style agent panel with agent selector, scoped chat sessions,
|
||||
* message list, input area, and session history drawer.
|
||||
*
|
||||
* Core logic is decomposed into focused hooks:
|
||||
* - useAIChatStreaming: stream processing, abort management, agent sub-flows
|
||||
* - useToolApproval: tool approval workflow, timeouts, resume logic
|
||||
* - useConversationExport: export formats & object URL lifecycle
|
||||
*/
|
||||
|
||||
import {
|
||||
History,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useImageUpload } from '../application/state/useImageUpload';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AISession,
|
||||
AISessionScope,
|
||||
ChatMessage,
|
||||
DiscoveredAgent,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import { getAgentModelPresets } from '../infrastructure/ai/types';
|
||||
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
||||
import { Button } from './ui/button';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import AgentSelector from './ai/AgentSelector';
|
||||
import ChatInput from './ai/ChatInput';
|
||||
import ChatMessageList from './ai/ChatMessageList';
|
||||
import ConversationExport from './ai/ConversationExport';
|
||||
import { useAIChatStreaming, getNetcattyBridge } from './ai/hooks/useAIChatStreaming';
|
||||
import { useToolApproval } from './ai/hooks/useToolApproval';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Props
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface AIChatSidePanelProps {
|
||||
// Session state (per-scope)
|
||||
sessions: AISession[];
|
||||
activeSessionIdMap: Record<string, string | null>;
|
||||
setActiveSessionId: (scopeKey: string, id: string | null) => void;
|
||||
createSession: (scope: AISessionScope, agentId?: string) => AISession;
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (
|
||||
sessionId: string,
|
||||
updater: (msg: ChatMessage) => ChatMessage,
|
||||
) => void;
|
||||
updateMessageById: (
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
updater: (msg: ChatMessage) => ChatMessage,
|
||||
) => void;
|
||||
// Provider config
|
||||
providers: ProviderConfig[];
|
||||
activeProviderId: string;
|
||||
activeModelId: string;
|
||||
|
||||
// Agent info
|
||||
defaultAgentId: string;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
||||
agentModelMap: Record<string, string>;
|
||||
setAgentModel: (agentId: string, modelId: string) => void;
|
||||
|
||||
// Safety
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
|
||||
commandBlocklist?: string[];
|
||||
maxIterations?: number;
|
||||
|
||||
// Context
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeHostIds?: string[];
|
||||
scopeLabel?: string;
|
||||
|
||||
// Terminal session context (from parent)
|
||||
terminalSessions?: Array<{
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
|
||||
// Visibility
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
setActiveSessionId: setActiveSessionIdForScope,
|
||||
createSession,
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
providers,
|
||||
activeProviderId,
|
||||
activeModelId,
|
||||
defaultAgentId,
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
agentModelMap,
|
||||
setAgentModel,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
commandBlocklist,
|
||||
maxIterations = 20,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
scopeLabel,
|
||||
terminalSessions = [],
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// ── Per-scope state ──
|
||||
// Derive scope key for per-scope isolation
|
||||
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
|
||||
|
||||
// Per-scope input values
|
||||
const [inputValueMap, setInputValueMap] = useState<Record<string, string>>({});
|
||||
const inputValue = inputValueMap[scopeKey] ?? '';
|
||||
const setInputValue = useCallback((val: string) => {
|
||||
setInputValueMap(prev => ({ ...prev, [scopeKey]: val }));
|
||||
}, [scopeKey]);
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
|
||||
|
||||
const { images, addImages, removeImage, clearImages } = useImageUpload();
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
|
||||
// ── Streaming hook ──
|
||||
const {
|
||||
streamingSessionIds,
|
||||
setStreamingForScope,
|
||||
abortControllersRef,
|
||||
processCattyStream,
|
||||
sendToCattyAgent,
|
||||
sendToExternalAgent,
|
||||
reportStreamError,
|
||||
} = useAIChatStreaming({
|
||||
maxIterations,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
});
|
||||
|
||||
// ── Tool approval hook ──
|
||||
const {
|
||||
pendingApprovalContextRef,
|
||||
setPendingApproval,
|
||||
handleApprovalResponse,
|
||||
} = useToolApproval({
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
setStreamingForScope,
|
||||
abortControllersRef,
|
||||
processCattyStream,
|
||||
t,
|
||||
});
|
||||
|
||||
// Per-scope active session ID
|
||||
const activeSessionId = activeSessionIdMap[scopeKey] ?? null;
|
||||
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
|
||||
const setActiveSessionId = useCallback((id: string | null) => {
|
||||
setActiveSessionIdForScope(scopeKey, id);
|
||||
}, [scopeKey, setActiveSessionIdForScope]);
|
||||
|
||||
// Restore agent selector from active session when scope changes
|
||||
useEffect(() => {
|
||||
if (activeSessionId) {
|
||||
const session = sessions.find((s) => s.id === activeSessionId);
|
||||
if (session) {
|
||||
setCurrentAgentId(session.agentId);
|
||||
}
|
||||
}
|
||||
}, [scopeKey, activeSessionId, sessions]);
|
||||
|
||||
// Proactively sync terminal session metadata to main process whenever scope or sessions change
|
||||
useEffect(() => {
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiMcpUpdateSessions && terminalSessions.length > 0) {
|
||||
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
|
||||
}
|
||||
}, [terminalSessions, scopeKey, activeSessionId]);
|
||||
|
||||
// Sync provider configs to main process so it can decrypt API keys server-side.
|
||||
// Keys stay encrypted in transit; main process decrypts only when making HTTP requests.
|
||||
useEffect(() => {
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiSyncProviders && providers.length > 0) {
|
||||
void bridge.aiSyncProviders(providers);
|
||||
}
|
||||
}, [providers]);
|
||||
|
||||
// Abort all active streams and clean up on unmount
|
||||
useEffect(() => {
|
||||
const controllers = abortControllersRef.current;
|
||||
return () => {
|
||||
controllers.forEach(c => c.abort());
|
||||
controllers.clear();
|
||||
// Clear pending approval (clears timeout too via setPendingApproval)
|
||||
setPendingApproval(null);
|
||||
};
|
||||
}, [abortControllersRef, setPendingApproval]);
|
||||
|
||||
// Agent discovery
|
||||
const {
|
||||
discoveredAgents,
|
||||
isDiscovering,
|
||||
rediscover,
|
||||
enableAgent,
|
||||
} = useAgentDiscovery(externalAgents, setExternalAgents);
|
||||
|
||||
const handleEnableDiscoveredAgent = useCallback(
|
||||
(agent: DiscoveredAgent) => {
|
||||
const config = enableAgent(agent);
|
||||
setExternalAgents?.((prev) => [...prev, config]);
|
||||
},
|
||||
[enableAgent, setExternalAgents],
|
||||
);
|
||||
|
||||
// Active session (scoped)
|
||||
const activeSession = useMemo(
|
||||
() => sessions.find((s) => s.id === activeSessionId) ?? null,
|
||||
[sessions, activeSessionId],
|
||||
);
|
||||
|
||||
const messages = activeSession?.messages ?? [];
|
||||
|
||||
// ── Export hook ──
|
||||
const { handleExport } = useConversationExport(activeSession);
|
||||
|
||||
// Active provider info
|
||||
const activeProvider = useMemo(
|
||||
() => providers.find((p) => p.id === activeProviderId),
|
||||
[providers, activeProviderId],
|
||||
);
|
||||
|
||||
const providerDisplayName = activeProvider?.name ?? '';
|
||||
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
|
||||
|
||||
// Agent model presets for the current external agent
|
||||
const currentAgentConfig = useMemo(
|
||||
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
|
||||
[currentAgentId, externalAgents],
|
||||
);
|
||||
const agentModelPresets = useMemo(
|
||||
() => getAgentModelPresets(currentAgentConfig?.command),
|
||||
[currentAgentConfig?.command],
|
||||
);
|
||||
|
||||
// Per-agent model: recall last selection or use first preset as default
|
||||
const selectedAgentModel = useMemo(() => {
|
||||
const stored = agentModelMap[currentAgentId];
|
||||
if (stored && agentModelPresets.some(p => stored === p.id || stored.startsWith(p.id + '/'))) {
|
||||
return stored;
|
||||
}
|
||||
// Default to first preset; for models with thinking levels, use the default level
|
||||
if (agentModelPresets.length > 0) {
|
||||
const first = agentModelPresets[0];
|
||||
if (first.thinkingLevels?.length) {
|
||||
return `${first.id}/${first.thinkingLevels[first.thinkingLevels.length - 1]}`;
|
||||
}
|
||||
return first.id;
|
||||
}
|
||||
return undefined;
|
||||
}, [currentAgentId, agentModelMap, agentModelPresets]);
|
||||
|
||||
const handleAgentModelSelect = useCallback((modelId: string) => {
|
||||
setAgentModel(currentAgentId, modelId);
|
||||
}, [currentAgentId, setAgentModel]);
|
||||
|
||||
// Filtered sessions for history (matching current scope type)
|
||||
const historySessions = useMemo(
|
||||
() =>
|
||||
sessions
|
||||
.filter((s) => s.scope.type === scopeType && s.scope.targetId === scopeTargetId)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt),
|
||||
[sessions, scopeType, scopeTargetId],
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Handlers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
const scope: AISessionScope = {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
hostIds: scopeHostIds,
|
||||
};
|
||||
const session = createSession(scope, currentAgentId);
|
||||
setActiveSessionId(session.id);
|
||||
setShowHistory(false);
|
||||
setInputValue('');
|
||||
}, [
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
currentAgentId,
|
||||
createSession,
|
||||
setActiveSessionId,
|
||||
setInputValue,
|
||||
]);
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
void openSettingsWindow();
|
||||
}, [openSettingsWindow]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Shared helpers for handleSend sub-flows
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/** Ref to always access latest sessions (avoids stale closure in autoTitleSession). */
|
||||
const sessionsRef = useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
|
||||
/** Refs to avoid re-creating handleSend on every keystroke / image change. */
|
||||
const inputValueRef = useRef(inputValue);
|
||||
inputValueRef.current = inputValue;
|
||||
const imagesRef = useRef(images);
|
||||
imagesRef.current = images;
|
||||
|
||||
/** Auto-title a session from the first user message if untitled. */
|
||||
const autoTitleSession = useCallback((sessionId: string, text: string) => {
|
||||
const s = sessionsRef.current.find(x => x.id === sessionId);
|
||||
if (s && (!s.title || s.title === 'New Chat')) {
|
||||
updateSessionTitle(sessionId, text.length > 50 ? text.slice(0, 50) + '...' : text);
|
||||
}
|
||||
}, [updateSessionTitle]);
|
||||
|
||||
/** Ensure a session exists for the current scope and return its ID. */
|
||||
const ensureSession = useCallback((): string => {
|
||||
if (activeSessionId) return activeSessionId;
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const session = createSession(scope, currentAgentId);
|
||||
setActiveSessionId(session.id);
|
||||
return session.id;
|
||||
}, [activeSessionId, scopeType, scopeTargetId, scopeHostIds, currentAgentId, createSession, setActiveSessionId]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Main send handler (thin orchestrator)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmed = inputValueRef.current.trim();
|
||||
const sendScopeKey = scopeKey;
|
||||
if (!trimmed || isStreaming) return;
|
||||
|
||||
const isExternalAgent = currentAgentId !== 'catty';
|
||||
|
||||
// No provider configured for built-in agent
|
||||
if (!isExternalAgent && !activeProvider) {
|
||||
const errSessionId = ensureSession();
|
||||
addMessageToSession(errSessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(errSessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
setInputValue('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure session exists
|
||||
const sessionId = ensureSession();
|
||||
|
||||
// Capture images before clearing
|
||||
const attachedImages = imagesRef.current.map(img => ({ base64Data: img.base64Data, mediaType: img.mediaType, filename: img.filename }));
|
||||
|
||||
// Add user message
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachedImages.length > 0 ? { images: attachedImages } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setInputValue('');
|
||||
clearImages();
|
||||
setStreamingForScope(sessionId, true);
|
||||
|
||||
// Create assistant message placeholder with a tracked ID
|
||||
const agentConfig = isExternalAgent ? externalAgents.find(a => a.id === currentAgentId) : undefined;
|
||||
const assistantMsgId = generateId();
|
||||
addMessageToSession(sessionId, {
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: isExternalAgent ? (agentConfig?.name || 'external') : (activeModelId || activeProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllersRef.current.set(sessionId, abortController);
|
||||
const currentSession = sessionsRef.current.find(s => s.id === sessionId);
|
||||
|
||||
if (isExternalAgent) {
|
||||
if (!agentConfig) {
|
||||
updateMessageById(sessionId, assistantMsgId, msg => ({ ...msg, content: 'External agent not found. Please check settings.', executionStatus: 'failed' }));
|
||||
setStreamingForScope(sessionId, false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachedImages, {
|
||||
terminalSessions,
|
||||
providers,
|
||||
selectedAgentModel,
|
||||
});
|
||||
} catch (err) {
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
}
|
||||
// Clear any lingering statusText when the external agent stream finishes
|
||||
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sessionId, false);
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
autoTitleSession(sessionId, trimmed);
|
||||
} else {
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider,
|
||||
activeModelId,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
terminalSessions,
|
||||
setPendingApproval,
|
||||
autoTitleSession,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isStreaming, activeProvider, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope, setInputValue, clearImages,
|
||||
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
||||
abortControllersRef, terminalSessions, providers, selectedAgentModel,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, setPendingApproval,
|
||||
]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (!activeSessionId) return;
|
||||
const controller = abortControllersRef.current.get(activeSessionId);
|
||||
controller?.abort();
|
||||
abortControllersRef.current.delete(activeSessionId);
|
||||
setStreamingForScope(activeSessionId, false);
|
||||
// Clear statusText on the last message so stale status indicators disappear
|
||||
updateLastMessage(activeSessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'completed' : msg.executionStatus,
|
||||
}));
|
||||
// Also clear any pending approval (clears timeout too via setPendingApproval)
|
||||
if (pendingApprovalContextRef.current?.sessionId === activeSessionId) {
|
||||
setPendingApproval(null);
|
||||
}
|
||||
}, [activeSessionId, setStreamingForScope, updateLastMessage, setPendingApproval, abortControllersRef, pendingApprovalContextRef]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
setActiveSessionId(sessionId);
|
||||
// Restore agent selector to match the session's bound agent
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (session) {
|
||||
setCurrentAgentId(session.agentId);
|
||||
}
|
||||
setShowHistory(false);
|
||||
},
|
||||
[setActiveSessionId, sessions],
|
||||
);
|
||||
|
||||
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
|
||||
},
|
||||
[deleteSession, scopeKey],
|
||||
);
|
||||
|
||||
const handleAgentChange = useCallback((agentId: string) => {
|
||||
setCurrentAgentId(agentId);
|
||||
// Preserve the current session in history and start a new one with the selected agent
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const session = createSession(scope, agentId);
|
||||
setActiveSessionId(session.id);
|
||||
}, [scopeType, scopeTargetId, scopeHostIds, createSession, setActiveSessionId]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Render
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
{/* ── Header ── */}
|
||||
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
|
||||
<AgentSelector
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
discoveredAgents={discoveredAgents}
|
||||
isDiscovering={isDiscovering}
|
||||
onSelectAgent={handleAgentChange}
|
||||
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
||||
onRediscover={rediscover}
|
||||
onManageAgents={handleOpenSettings}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ConversationExport
|
||||
session={activeSession}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
title="Session history"
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
||||
onClick={handleNewChat}
|
||||
title="New chat"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
{showHistory ? (
|
||||
<SessionHistoryDrawer
|
||||
sessions={historySessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelect={handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onClose={() => setShowHistory(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Chat messages */}
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
onApprove={(messageId) => void handleApprovalResponse(messageId, true, {
|
||||
terminalSessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
})}
|
||||
onReject={(messageId) => void handleApprovalResponse(messageId, false, {
|
||||
terminalSessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Recent sessions (Zed-style, shown when no messages) */}
|
||||
{messages.length === 0 && historySessions.length > 0 && (
|
||||
<div className="shrink-0 px-4 pb-1">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
|
||||
<button
|
||||
onClick={() => setShowHistory(true)}
|
||||
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
|
||||
>
|
||||
{t('ai.chat.viewAll')}
|
||||
</button>
|
||||
</div>
|
||||
{historySessions.slice(0, 3).map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[13px] text-foreground/60 truncate pr-4">
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/25 shrink-0">
|
||||
{formatRelativeTime(new Date(session.updatedAt), t)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<ChatInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isStreaming={isStreaming}
|
||||
providerName={providerDisplayName}
|
||||
modelName={modelDisplayName}
|
||||
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
images={images}
|
||||
onAddImages={addImages}
|
||||
onRemoveImage={removeImage}
|
||||
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
||||
permissionMode={globalPermissionMode}
|
||||
onPermissionModeChange={setGlobalPermissionMode}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Session History Drawer
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface SessionHistoryDrawerProps {
|
||||
sessions: AISession[];
|
||||
activeSessionId: string | null;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onDelete: (e: React.MouseEvent, sessionId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
|
||||
<span className="text-[13px] font-medium text-foreground/80">{t('ai.chat.allSessions')}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="px-3">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-[13px] text-muted-foreground/40">
|
||||
{t('ai.chat.noSessions')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
const time = new Date(session.updatedAt);
|
||||
const timeStr = formatRelativeTime(time, t);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => onSelect(session.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
|
||||
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="text-[13px] truncate pr-3 flex-1 min-w-0">
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[12px] text-muted-foreground/50">
|
||||
{timeStr}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => onDelete(e, session.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-all cursor-pointer"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function formatRelativeTime(date: Date, t: (key: string) => string): string {
|
||||
const now = Date.now();
|
||||
const diff = now - date.getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
const hours = Math.floor(diff / 3_600_000);
|
||||
const days = Math.floor(diff / 86_400_000);
|
||||
|
||||
if (minutes < 1) return t('ai.chat.justNow');
|
||||
if (minutes < 60) return t('ai.chat.minutesAgo').replace('{n}', String(minutes));
|
||||
if (hours < 24) return t('ai.chat.hoursAgo').replace('{n}', String(hours));
|
||||
if (days < 7) return t('ai.chat.daysAgo').replace('{n}', String(days));
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Export
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const AIChatSidePanel = React.memo(AIChatSidePanelInner);
|
||||
AIChatSidePanel.displayName = 'AIChatSidePanel';
|
||||
|
||||
export default AIChatSidePanel;
|
||||
export { AIChatSidePanel };
|
||||
export type { AIChatSidePanelProps };
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -99,6 +100,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const { terminalThemeId, terminalFontSize } = useSettingsState();
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
initialData ||
|
||||
@@ -113,7 +115,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
os: "linux",
|
||||
authMethod: "password",
|
||||
charset: "UTF-8",
|
||||
theme: "Flexoki Dark",
|
||||
theme: terminalThemeId,
|
||||
fontSize: terminalFontSize,
|
||||
createdAt: Date.now(),
|
||||
group: defaultGroup || undefined, // Pre-fill with current navigation group
|
||||
} as Host),
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
* Settings Page - Standalone settings window content
|
||||
* This component is rendered in a separate Electron window
|
||||
*/
|
||||
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
|
||||
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Sparkles, TerminalSquare, X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
import { useWindowControls } from "../application/state/useWindowControls";
|
||||
import { useUpdateCheck } from "../application/state/useUpdateCheck";
|
||||
import { useAIState } from "../application/state/useAIState";
|
||||
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
|
||||
import SettingsApplicationTab from "./SettingsApplicationTab";
|
||||
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
|
||||
@@ -16,18 +17,41 @@ import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociation
|
||||
import SettingsShortcutsTab from "./settings/tabs/SettingsShortcutsTab";
|
||||
import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
|
||||
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
|
||||
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import type { TerminalFont } from "../infrastructure/config/fonts";
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
class AITabErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ error: Error | null }
|
||||
> {
|
||||
state: { error: Error | null } = { error: null };
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div style={{ padding: 32, color: "#f87171", fontFamily: "monospace", whiteSpace: "pre-wrap" }}>
|
||||
<h3 style={{ marginBottom: 8 }}>AI Settings Error</h3>
|
||||
<div>{this.state.error.message}</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: "#888" }}>{this.state.error.stack}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState> & {
|
||||
availableFonts: TerminalFont[];
|
||||
};
|
||||
|
||||
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
|
||||
|
||||
const SettingsSyncTabWithVault: React.FC = () => {
|
||||
const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = ({ onSettingsApplied }) => {
|
||||
const {
|
||||
hosts,
|
||||
keys,
|
||||
@@ -66,6 +90,7 @@ const SettingsSyncTabWithVault: React.FC = () => {
|
||||
importDataFromString={importDataFromString}
|
||||
importPortForwardingRules={importPortForwardingRules}
|
||||
clearVaultData={clearVaultData}
|
||||
onSettingsApplied={onSettingsApplied}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -73,7 +98,8 @@ const SettingsSyncTabWithVault: React.FC = () => {
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const { t } = useI18n();
|
||||
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
|
||||
const aiState = useAIState();
|
||||
const [activeTab, setActiveTab] = useState("application");
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
|
||||
|
||||
@@ -152,6 +178,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
>
|
||||
<FileType size={14} /> {t("settings.tab.sftpFileAssociations")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="ai"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
>
|
||||
<Sparkles size={14} /> AI
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="sync"
|
||||
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
|
||||
@@ -228,9 +260,38 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
<SettingsFileAssociationsTab />
|
||||
)}
|
||||
|
||||
{mountedTabs.has("ai") && (
|
||||
<AITabErrorBoundary>
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsAITab
|
||||
providers={aiState.providers}
|
||||
addProvider={aiState.addProvider}
|
||||
updateProvider={aiState.updateProvider}
|
||||
removeProvider={aiState.removeProvider}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
setActiveProviderId={aiState.setActiveProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
setDefaultAgentId={aiState.setDefaultAgentId}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</AITabErrorBoundary>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("sync") && (
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsSyncTabWithVault />
|
||||
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
|
||||
</React.Suspense>
|
||||
)}
|
||||
|
||||
@@ -247,6 +308,10 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
closeToTray={settings.closeToTray}
|
||||
setCloseToTray={settings.setCloseToTray}
|
||||
hotkeyRegistrationError={settings.hotkeyRegistrationError}
|
||||
globalHotkeyEnabled={settings.globalHotkeyEnabled}
|
||||
setGlobalHotkeyEnabled={settings.setGlobalHotkeyEnabled}
|
||||
autoUpdateEnabled={settings.autoUpdateEnabled}
|
||||
setAutoUpdateEnabled={settings.setAutoUpdateEnabled}
|
||||
updateState={updateState}
|
||||
checkNow={checkNow}
|
||||
installUpdate={installUpdate}
|
||||
|
||||
@@ -28,6 +28,7 @@ interface SnippetsManagerProps {
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
onSave: (snippet: Snippet) => void;
|
||||
onBulkSave: (snippets: Snippet[]) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onPackagesChange: (packages: string[]) => void;
|
||||
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
|
||||
@@ -51,6 +52,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
onSave,
|
||||
onBulkSave,
|
||||
onDelete,
|
||||
onPackagesChange,
|
||||
onRunSnippet,
|
||||
@@ -486,11 +488,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 +526,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
updatedSnippets.forEach(onSave);
|
||||
onBulkSave(updatedSnippets);
|
||||
if (selectedPackage === source) setSelectedPackage(newPath);
|
||||
};
|
||||
|
||||
@@ -568,8 +567,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 +594,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) {
|
||||
|
||||
@@ -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";
|
||||
@@ -371,6 +371,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();
|
||||
|
||||
@@ -502,6 +523,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serialLocalEcho: serialConfig?.localEcho,
|
||||
serialLineMode: serialConfig?.lineMode,
|
||||
serialLineBufferRef,
|
||||
onOsc52ReadRequest: handleOsc52ReadRequest,
|
||||
});
|
||||
|
||||
xtermRuntimeRef.current = runtime;
|
||||
@@ -1678,6 +1700,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"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Circle, FolderTree, LayoutGrid, PanelLeft, PanelRight, Palette, Server, X, Zap } from 'lucide-react';
|
||||
import { Circle, FolderTree, LayoutGrid, MessageSquare, PanelLeft, PanelRight, Palette, Server, X, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
@@ -15,13 +15,15 @@ import Terminal from './Terminal';
|
||||
import { SftpSidePanel } from './SftpSidePanel';
|
||||
import { ScriptsSidePanel } from './ScriptsSidePanel';
|
||||
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
|
||||
import { AIChatSidePanel } from './AIChatSidePanel';
|
||||
import { useAIState } from '../application/state/useAIState';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
import { Button } from './ui/button';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
|
||||
type SidePanelTab = 'sftp' | 'scripts' | 'theme';
|
||||
type SidePanelTab = 'sftp' | 'scripts' | 'theme' | 'ai';
|
||||
|
||||
type WorkspaceRect = { x: number; y: number; w: number; h: number };
|
||||
|
||||
@@ -241,7 +243,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// Side panel state - per-tab tracking of which sub-panel is active
|
||||
// Maps tab IDs to the active sub-panel type (sftp/scripts/theme), absent = closed
|
||||
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTab>>(new Map());
|
||||
const [sidePanelWidth, setSidePanelWidth] = useState(320);
|
||||
const [sidePanelWidth, setSidePanelWidth] = useState(() => {
|
||||
const stored = window.localStorage.getItem('netcatty_side_panel_width');
|
||||
return stored ? Math.max(280, Math.min(800, Number(stored))) : 420;
|
||||
});
|
||||
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
|
||||
'netcatty_side_panel_position',
|
||||
'left',
|
||||
@@ -373,13 +378,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const startX = e.clientX;
|
||||
const startWidth = sidePanelWidth;
|
||||
|
||||
let lastWidth = startWidth;
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
const newWidth = Math.max(200, Math.min(600, startWidth + (sidePanelPosition === 'left' ? delta : -delta)));
|
||||
setSidePanelWidth(newWidth);
|
||||
lastWidth = Math.max(280, Math.min(800, startWidth + (sidePanelPosition === 'left' ? delta : -delta)));
|
||||
setSidePanelWidth(lastWidth);
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
sftpResizingRef.current = false;
|
||||
window.localStorage.setItem('netcatty_side_panel_width', String(lastWidth));
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
@@ -844,6 +851,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleSwitchSidePanelTab('theme');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
// Open AI chat side panel
|
||||
const handleOpenAI = useCallback(() => {
|
||||
handleSwitchSidePanelTab('ai');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
// Listen for global AI panel toggle (from TopTabs button)
|
||||
useEffect(() => {
|
||||
const handler = () => handleOpenAI();
|
||||
window.addEventListener('netcatty:toggle-ai-panel', handler);
|
||||
return () => window.removeEventListener('netcatty:toggle-ai-panel', handler);
|
||||
}, [handleOpenAI]);
|
||||
|
||||
// Execute snippet on the focused terminal session
|
||||
const handleSnippetClickForFocusedSession = useCallback((command: string) => {
|
||||
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
|
||||
@@ -906,6 +925,47 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const focusedFontFamilyId = focusedHost?.fontFamily ?? terminalFontFamilyId;
|
||||
const focusedFontSize = focusedHost?.fontSize ?? fontSize;
|
||||
|
||||
// AI Chat state
|
||||
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);
|
||||
cleanupOrphanedSessions(activeIds);
|
||||
}, [sessions, workspaces, cleanupOrphanedSessions]);
|
||||
|
||||
// Build terminal session context for the AI chat panel
|
||||
const aiTerminalSessions = useMemo(() => {
|
||||
const sessionIds = activeWorkspace?.root
|
||||
? collectSessionIds(activeWorkspace.root)
|
||||
: activeSession ? [activeSession.id] : [];
|
||||
|
||||
const result = sessionIds.map(sid => {
|
||||
const s = sessions.find(s => s.id === sid);
|
||||
const host = s?.hostId ? hosts.find(h => h.id === s.hostId) : undefined;
|
||||
return {
|
||||
sessionId: sid,
|
||||
hostId: s?.hostId || '',
|
||||
hostname: host?.hostname || '',
|
||||
label: host?.label || s?.hostLabel || '',
|
||||
os: host?.os,
|
||||
username: host?.username,
|
||||
connected: s?.status === 'connected',
|
||||
};
|
||||
});
|
||||
return result;
|
||||
}, [sessions, hosts, activeWorkspace, activeSession]);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
@@ -1117,12 +1177,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
)}
|
||||
>
|
||||
{isSidePanelOpenForCurrentTab && (
|
||||
<div className="flex h-8 items-center px-1.5 py-0.5 flex-shrink-0 gap-0.5">
|
||||
<div className="flex h-9 items-center px-1.5 py-1 flex-shrink-0 gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6 rounded-md p-0",
|
||||
"h-7 w-7 rounded-md p-0",
|
||||
activeSidePanelTab === 'sftp'
|
||||
? "text-foreground opacity-100"
|
||||
: "text-muted-foreground opacity-70 hover:opacity-100",
|
||||
@@ -1131,13 +1191,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onClick={handleToggleSftpFromBar}
|
||||
title="SFTP"
|
||||
>
|
||||
<FolderTree size={14} />
|
||||
<FolderTree size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6 rounded-md p-0",
|
||||
"h-7 w-7 rounded-md p-0",
|
||||
activeSidePanelTab === 'scripts'
|
||||
? "text-foreground opacity-100"
|
||||
: "text-muted-foreground opacity-70 hover:opacity-100",
|
||||
@@ -1146,13 +1206,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onClick={handleOpenScripts}
|
||||
title="Scripts"
|
||||
>
|
||||
<Zap size={14} />
|
||||
<Zap size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6 rounded-md p-0",
|
||||
"h-7 w-7 rounded-md p-0",
|
||||
activeSidePanelTab === 'theme'
|
||||
? "text-foreground opacity-100"
|
||||
: "text-muted-foreground opacity-70 hover:opacity-100",
|
||||
@@ -1161,32 +1221,47 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onClick={handleOpenTheme}
|
||||
title="Theme"
|
||||
>
|
||||
<Palette size={14} />
|
||||
<Palette size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md p-0",
|
||||
activeSidePanelTab === 'ai'
|
||||
? "text-foreground opacity-100"
|
||||
: "text-muted-foreground opacity-70 hover:opacity-100",
|
||||
"hover:bg-transparent",
|
||||
)}
|
||||
onClick={handleOpenAI}
|
||||
title="AI Chat"
|
||||
>
|
||||
<MessageSquare size={15} />
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6 rounded-md p-0 text-muted-foreground",
|
||||
"h-7 w-7 rounded-md p-0 text-muted-foreground",
|
||||
"hover:bg-transparent hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setSidePanelPosition(p => p === 'left' ? 'right' : 'left')}
|
||||
title={sidePanelPosition === 'left' ? 'Move panel to right' : 'Move panel to left'}
|
||||
>
|
||||
{sidePanelPosition === 'left' ? <PanelRight size={14} /> : <PanelLeft size={14} />}
|
||||
{sidePanelPosition === 'left' ? <PanelRight size={15} /> : <PanelLeft size={15} />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6 rounded-md p-0 text-muted-foreground",
|
||||
"h-7 w-7 rounded-md p-0 text-muted-foreground",
|
||||
"hover:bg-transparent hover:text-foreground",
|
||||
)}
|
||||
onClick={handleCloseSidePanel}
|
||||
title="Close panel"
|
||||
>
|
||||
<X size={14} />
|
||||
<X size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1247,6 +1322,46 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Chat sub-panel */}
|
||||
{activeSidePanelTab === 'ai' && (
|
||||
<div className="absolute inset-0 z-10">
|
||||
<AIChatSidePanel
|
||||
sessions={aiState.sessions}
|
||||
activeSessionIdMap={aiState.activeSessionIdMap}
|
||||
setActiveSessionId={aiState.setActiveSessionId}
|
||||
createSession={aiState.createSession}
|
||||
deleteSession={aiState.deleteSession}
|
||||
updateSessionTitle={aiState.updateSessionTitle}
|
||||
addMessageToSession={aiState.addMessageToSession}
|
||||
updateLastMessage={aiState.updateLastMessage}
|
||||
updateMessageById={aiState.updateMessageById}
|
||||
providers={aiState.providers}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
agentModelMap={aiState.agentModelMap}
|
||||
setAgentModel={aiState.setAgentModel}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
maxIterations={aiState.maxIterations}
|
||||
scopeType={activeWorkspace ? 'workspace' : 'terminal'}
|
||||
scopeTargetId={activeWorkspace?.id ?? activeSession?.id}
|
||||
scopeHostIds={activeWorkspace?.root
|
||||
? collectSessionIds(activeWorkspace.root).map(sid => {
|
||||
const s = sessions.find(s => s.id === sid);
|
||||
return s?.hostId;
|
||||
}).filter((id): id is string => !!id)
|
||||
: activeSession?.hostId ? [activeSession.hostId] : []
|
||||
}
|
||||
scopeLabel={activeWorkspace?.name ?? activeSession?.label ?? ''}
|
||||
terminalSessions={aiTerminalSessions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Shield, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
|
||||
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Shield, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { LogView } from '../application/state/useSessionState';
|
||||
@@ -747,6 +747,15 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
{/* Fixed right controls */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2 app-drag self-center" style={dragRegionStyle}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
|
||||
title="AI Assistant"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag">
|
||||
<Bell size={16} />
|
||||
</Button>
|
||||
|
||||
@@ -2201,6 +2201,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
: [...snippets, s],
|
||||
)
|
||||
}
|
||||
onBulkSave={onUpdateSnippets}
|
||||
onDelete={(id) =>
|
||||
onUpdateSnippets(snippets.filter((s) => s.id !== id))
|
||||
}
|
||||
|
||||
89
components/ai-elements/conversation.tsx
Normal file
89
components/ai-elements/conversation.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
|
||||
import { ArrowDown } from 'lucide-react';
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-hidden', className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>;
|
||||
|
||||
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn('flex flex-col gap-4 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export interface ConversationEmptyStateProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
if (isAtBottom) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'absolute bottom-3 left-1/2 -translate-x-1/2 z-10',
|
||||
'h-7 w-7 rounded-full border border-border/40 bg-background/90 backdrop-blur-sm',
|
||||
'flex items-center justify-center',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer',
|
||||
'shadow-sm',
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
87
components/ai-elements/message.tsx
Normal file
87
components/ai-elements/message.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cjk } from '@streamdown/cjk';
|
||||
import { code } from '@streamdown/code';
|
||||
import type { ComponentProps, HTMLAttributes } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { Streamdown } from 'streamdown';
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: 'user' | 'assistant' | 'system' | 'tool';
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex w-full max-w-[95%] flex-col gap-1.5',
|
||||
from === 'user' ? 'is-user ml-auto' : 'is-assistant',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 overflow-hidden text-[13px] leading-relaxed',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-4 group-[.is-user]:py-2.5',
|
||||
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<'div'>;
|
||||
|
||||
export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const streamdownPlugins = { cjk, code };
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
// Style the rendered markdown
|
||||
// Code: base styles (code-block overrides are in index.css)
|
||||
'[&_code]:text-[12px] [&_code]:font-mono',
|
||||
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%]',
|
||||
'[&_p]:my-1.5',
|
||||
'[&_ul]:my-1.5 [&_ul]:pl-4 [&_ul]:list-disc',
|
||||
'[&_ol]:my-1.5 [&_ol]:pl-4 [&_ol]:list-decimal',
|
||||
'[&_li]:my-0.5',
|
||||
'[&_h1]:text-base [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2',
|
||||
'[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1.5',
|
||||
'[&_h3]:text-sm [&_h3]:font-medium [&_h3]:mt-2 [&_h3]:mb-1',
|
||||
'[&_blockquote]:border-l-2 [&_blockquote]:border-border/50 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
|
||||
'[&_a]:text-primary [&_a]:underline',
|
||||
'[&_hr]:border-border/30 [&_hr]:my-3',
|
||||
'[&_table]:text-[12px] [&_th]:px-2 [&_th]:py-1 [&_th]:border [&_th]:border-border/30 [&_th]:bg-muted/20 [&_td]:px-2 [&_td]:py-1 [&_td]:border [&_td]:border-border/30',
|
||||
className,
|
||||
)}
|
||||
plugins={streamdownPlugins}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.children === nextProps.children &&
|
||||
nextProps.isAnimating === prevProps.isAnimating,
|
||||
);
|
||||
MessageResponse.displayName = 'MessageResponse';
|
||||
283
components/ai-elements/prompt-input.tsx
Normal file
283
components/ai-elements/prompt-input.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* PromptInput - Adapted from Vercel AI Elements prompt-input for netcatty.
|
||||
*
|
||||
* Simplified: no file attachments, screenshots, drag-drop, command palette,
|
||||
* hover cards, referenced sources, or tabs. Core input + footer + submit.
|
||||
*/
|
||||
|
||||
import { ArrowUp, Square, X } from 'lucide-react';
|
||||
import type {
|
||||
ComponentProps,
|
||||
ComponentPropsWithoutRef,
|
||||
ElementRef,
|
||||
FormEvent,
|
||||
HTMLAttributes,
|
||||
KeyboardEvent,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { forwardRef, useCallback, useRef } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupTextarea,
|
||||
} from '../ui/input-group';
|
||||
import { Spinner } from '../ui/spinner';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInput (form wrapper)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptInputProps extends HTMLAttributes<HTMLFormElement> {
|
||||
onSubmit: (text: string, event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export const PromptInput = forwardRef<HTMLFormElement, PromptInputProps>(
|
||||
({ className, onSubmit, children, ...props }, ref) => {
|
||||
const handleSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const textarea = form.querySelector('textarea');
|
||||
const text = textarea?.value?.trim() ?? '';
|
||||
if (!text) return;
|
||||
onSubmit(text, e);
|
||||
},
|
||||
[onSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<form ref={ref} onSubmit={handleSubmit} className={className} {...props}>
|
||||
<InputGroup>{children}</InputGroup>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
);
|
||||
PromptInput.displayName = 'PromptInput';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputTextarea
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptInputTextareaProps extends ComponentProps<'textarea'> {
|
||||
/** Called when Enter is pressed (without Shift) to trigger form submit */
|
||||
onSubmitRequest?: () => void;
|
||||
}
|
||||
|
||||
export const PromptInputTextarea = forwardRef<HTMLTextAreaElement, PromptInputTextareaProps>(
|
||||
({ className, onSubmitRequest, onKeyDown, ...props }, ref) => {
|
||||
const internalRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const setRef = useCallback(
|
||||
(node: HTMLTextAreaElement | null) => {
|
||||
internalRef.current = node;
|
||||
if (typeof ref === 'function') ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
onKeyDown?.(e);
|
||||
if (e.defaultPrevented) return;
|
||||
|
||||
// CJK composition guard
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSubmitRequest?.();
|
||||
// Trigger form submit
|
||||
const form = internalRef.current?.closest('form');
|
||||
if (form) {
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onKeyDown, onSubmitRequest],
|
||||
);
|
||||
|
||||
return (
|
||||
<InputGroupTextarea
|
||||
ref={setRef}
|
||||
className={className}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
PromptInputTextarea.displayName = 'PromptInputTextarea';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputFooter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PromptInputFooterProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PromptInputFooter = forwardRef<HTMLDivElement, PromptInputFooterProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<InputGroupAddon
|
||||
ref={ref}
|
||||
align="block-end"
|
||||
className={cn('gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
PromptInputFooter.displayName = 'PromptInputFooter';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputTools (left side of footer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PromptInputTools = forwardRef<HTMLDivElement, PromptInputToolsProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center gap-0.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
PromptInputTools.displayName = 'PromptInputTools';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputButton (toolbar button with optional tooltip)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptInputButtonProps extends ComponentProps<typeof InputGroupButton> {
|
||||
tooltip?: ReactNode;
|
||||
tooltipSide?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
export const PromptInputButton = forwardRef<HTMLButtonElement, PromptInputButtonProps>(
|
||||
({ tooltip, tooltipSide = 'top', ...props }, ref) => {
|
||||
const button = <InputGroupButton ref={ref} {...props} />;
|
||||
|
||||
if (!tooltip) return button;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side={tooltipSide}>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
PromptInputButton.displayName = 'PromptInputButton';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputSubmit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PromptInputStatus = 'idle' | 'submitted' | 'streaming' | 'error';
|
||||
|
||||
export interface PromptInputSubmitProps extends ComponentProps<typeof InputGroupButton> {
|
||||
status?: PromptInputStatus;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmitProps>(
|
||||
({ status = 'idle', onStop, className, disabled, ...props }, ref) => {
|
||||
const isRunning = status === 'submitted' || status === 'streaming';
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isRunning && onStop) {
|
||||
onStop();
|
||||
}
|
||||
}, [isRunning, onStop]);
|
||||
|
||||
const icon =
|
||||
status === 'submitted' ? (
|
||||
<Spinner size={14} />
|
||||
) : status === 'streaming' ? (
|
||||
<Square size={14} />
|
||||
) : status === 'error' ? (
|
||||
<X size={14} />
|
||||
) : (
|
||||
<ArrowUp size={14} />
|
||||
);
|
||||
|
||||
const tooltipLabel =
|
||||
status === 'submitted'
|
||||
? 'Waiting...'
|
||||
: status === 'streaming'
|
||||
? 'Stop'
|
||||
: status === 'error'
|
||||
? 'Error'
|
||||
: 'Send';
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InputGroupButton
|
||||
ref={ref}
|
||||
type={isRunning ? 'button' : 'submit'}
|
||||
onClick={isRunning ? handleClick : undefined}
|
||||
variant="ghost"
|
||||
disabled={disabled && !isRunning}
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-full border p-0 shadow-sm disabled:opacity-100',
|
||||
isRunning
|
||||
? 'border-destructive/60 bg-destructive/85 text-destructive-foreground hover:bg-destructive'
|
||||
: disabled
|
||||
? 'border-border/80 bg-muted/52 text-foreground/72 hover:bg-muted/52'
|
||||
: 'border-foreground/20 bg-foreground text-background hover:bg-foreground/90',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</InputGroupButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{tooltipLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
PromptInputSubmit.displayName = 'PromptInputSubmit';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputSelect (thin wrappers around the project's Select component)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PromptInputSelect = Select;
|
||||
|
||||
export const PromptInputSelectTrigger = forwardRef<
|
||||
ElementRef<typeof SelectTrigger>,
|
||||
ComponentPropsWithoutRef<typeof SelectTrigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-7 min-w-0 w-auto gap-1 border-none bg-transparent px-2 text-[11px]',
|
||||
'text-muted-foreground/40 hover:text-muted-foreground/70',
|
||||
'focus:ring-0 focus:ring-offset-0',
|
||||
'[&>svg]:h-3 [&>svg]:w-3 [&>svg]:opacity-40',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PromptInputSelectTrigger.displayName = 'PromptInputSelectTrigger';
|
||||
|
||||
export const PromptInputSelectContent = SelectContent;
|
||||
export const PromptInputSelectItem = SelectItem;
|
||||
export const PromptInputSelectValue = SelectValue;
|
||||
65
components/ai-elements/tool-call.tsx
Normal file
65
components/ai-elements/tool-call.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ChevronDown, ChevronRight, CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
name: string;
|
||||
args?: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const ToolCall = ({ name, args, result, isError, isLoading, className, ...props }: ToolCallProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const statusIcon = isLoading ? (
|
||||
<Loader2 size={12} className="animate-spin text-blue-400/70" />
|
||||
) : isError ? (
|
||||
<XCircle size={12} className="text-red-400/70" />
|
||||
) : result !== undefined ? (
|
||||
<CheckCircle2 size={12} className="text-green-400/70" />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-md border border-border/25 bg-muted/10 overflow-hidden text-[12px]', className)} {...props}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/20 transition-colors cursor-pointer"
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
}
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
<span className="flex-1" />
|
||||
{statusIcon}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="border-t border-border/20">
|
||||
{args && Object.keys(args).length > 0 && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Arguments</div>
|
||||
<pre className="text-[11px] font-mono text-muted-foreground/50 whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{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">Result</div>
|
||||
<pre className={cn(
|
||||
'text-[11px] font-mono whitespace-pre-wrap break-all',
|
||||
isError ? 'text-red-400/60' : 'text-muted-foreground/50',
|
||||
)}>
|
||||
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
220
components/ai/AgentIconBadge.tsx
Normal file
220
components/ai/AgentIconBadge.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
type AgentLike = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: 'builtin' | 'external';
|
||||
icon?: string;
|
||||
command?: string;
|
||||
};
|
||||
|
||||
type AgentIconKey =
|
||||
| 'catty'
|
||||
| 'openai'
|
||||
| 'claude'
|
||||
| 'anthropic'
|
||||
| 'gemini'
|
||||
| 'google'
|
||||
| 'ollama'
|
||||
| 'openrouter'
|
||||
| 'zed'
|
||||
| 'atom'
|
||||
| 'terminal'
|
||||
| 'plus';
|
||||
|
||||
type AgentIconVisual = {
|
||||
src: string;
|
||||
badgeClassName: string;
|
||||
imageClassName: string;
|
||||
};
|
||||
|
||||
const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
|
||||
catty: {
|
||||
src: '/ai/agents/catty.svg',
|
||||
badgeClassName: 'border-violet-500/20 bg-violet-500/10',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
openai: {
|
||||
src: '/ai/providers/openai.svg',
|
||||
badgeClassName: 'border-emerald-500/22 bg-emerald-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
claude: {
|
||||
src: '/ai/agents/claude.svg',
|
||||
badgeClassName: 'border-orange-500/22 bg-orange-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
anthropic: {
|
||||
src: '/ai/providers/anthropic.svg',
|
||||
badgeClassName: 'border-orange-500/22 bg-orange-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
gemini: {
|
||||
src: '/ai/agents/gemini.svg',
|
||||
badgeClassName: 'border-sky-500/22 bg-sky-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
google: {
|
||||
src: '/ai/providers/google.svg',
|
||||
badgeClassName: 'border-sky-500/22 bg-sky-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
ollama: {
|
||||
src: '/ai/providers/ollama.svg',
|
||||
badgeClassName: 'border-violet-500/22 bg-violet-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
openrouter: {
|
||||
src: '/ai/providers/openrouter.svg',
|
||||
badgeClassName: 'border-fuchsia-500/22 bg-fuchsia-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
zed: {
|
||||
src: '/ai/agents/zed.svg',
|
||||
badgeClassName: 'border-cyan-500/22 bg-cyan-500/12',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert',
|
||||
},
|
||||
atom: {
|
||||
src: '/ai/agents/atom.svg',
|
||||
badgeClassName: 'border-amber-500/18 bg-amber-500/10',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
terminal: {
|
||||
src: '/ai/agents/terminal.svg',
|
||||
badgeClassName: 'border-white/8 bg-white/[0.04]',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
plus: {
|
||||
src: '/ai/agents/plus.svg',
|
||||
badgeClassName: 'border-white/8 bg-white/[0.04]',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-85',
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeToken(value?: string): string {
|
||||
return (value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
}
|
||||
|
||||
function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
|
||||
if (agent === 'add-more') {
|
||||
return 'plus';
|
||||
}
|
||||
|
||||
if (agent.type === 'builtin') {
|
||||
return 'catty';
|
||||
}
|
||||
|
||||
const tokens = [
|
||||
normalizeToken(agent.icon),
|
||||
normalizeToken(agent.command),
|
||||
normalizeToken(agent.name),
|
||||
normalizeToken(agent.id),
|
||||
].filter(Boolean);
|
||||
|
||||
if (tokens.some((token) => token.includes('claude'))) {
|
||||
return 'claude';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('anthropic'))) {
|
||||
return 'anthropic';
|
||||
}
|
||||
if (
|
||||
tokens.some(
|
||||
(token) =>
|
||||
token.includes('codex') ||
|
||||
token.includes('openai') ||
|
||||
token.includes('chatgpt'),
|
||||
)
|
||||
) {
|
||||
return 'openai';
|
||||
}
|
||||
if (
|
||||
tokens.some(
|
||||
(token) =>
|
||||
token.includes('gemini') ||
|
||||
token.includes('google') ||
|
||||
token.includes('googlegemini'),
|
||||
)
|
||||
) {
|
||||
return 'gemini';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('ollama'))) {
|
||||
return 'ollama';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('openrouter'))) {
|
||||
return 'openrouter';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('zed'))) {
|
||||
return 'zed';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('factory'))) {
|
||||
return 'atom';
|
||||
}
|
||||
|
||||
return 'terminal';
|
||||
}
|
||||
|
||||
export function getAgentCommandLabel(agent: AgentLike): string | undefined {
|
||||
if (agent.type === 'builtin') {
|
||||
return 'Built-in terminal assistant';
|
||||
}
|
||||
return agent.command ? `CLI: ${agent.command}` : 'External CLI agent';
|
||||
}
|
||||
|
||||
export const AgentIconBadge: React.FC<{
|
||||
agent: AgentLike | 'add-more';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
variant?: 'plain' | 'badge';
|
||||
className?: string;
|
||||
}> = ({ agent, size = 'md', variant = 'badge', className }) => {
|
||||
const visual = AGENT_ICON_VISUALS[getAgentIconKey(agent)];
|
||||
const badgeSize =
|
||||
size === 'xs'
|
||||
? 'h-4 w-4 rounded-sm'
|
||||
: size === 'sm'
|
||||
? 'h-7 w-7 rounded-lg'
|
||||
: size === 'lg'
|
||||
? 'h-10 w-10 rounded-xl'
|
||||
: 'h-8 w-8 rounded-lg';
|
||||
const imageSize =
|
||||
size === 'xs'
|
||||
? 'h-3.5 w-3.5'
|
||||
: size === 'sm'
|
||||
? 'h-3.5 w-3.5'
|
||||
: size === 'lg'
|
||||
? 'h-5 w-5'
|
||||
: 'h-4 w-4';
|
||||
|
||||
if (variant === 'plain') {
|
||||
return (
|
||||
<img
|
||||
src={visual.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn('shrink-0', imageSize, visual.imageClassName, className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center overflow-hidden border',
|
||||
badgeSize,
|
||||
visual.badgeClassName,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={visual.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn(imageSize, visual.imageClassName)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentIconBadge;
|
||||
280
components/ai/AgentSelector.tsx
Normal file
280
components/ai/AgentSelector.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* AgentSelector - Dropdown for switching between AI agents
|
||||
*
|
||||
* Dark, grouped agent menu with local SVG branding for built-in,
|
||||
* discovered, and external agents.
|
||||
*/
|
||||
|
||||
import { ChevronDown, RefreshCw, Plus, Settings } from 'lucide-react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { AgentInfo, ExternalAgentConfig, DiscoveredAgent } from '../../infrastructure/ai/types';
|
||||
import AgentIconBadge from './AgentIconBadge';
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownContent,
|
||||
DropdownTrigger,
|
||||
} from '../ui/dropdown';
|
||||
|
||||
interface AgentSelectorProps {
|
||||
currentAgentId: string;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
discoveredAgents?: DiscoveredAgent[];
|
||||
isDiscovering?: boolean;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onEnableDiscoveredAgent?: (agent: DiscoveredAgent) => void;
|
||||
onRediscover?: () => void;
|
||||
onManageAgents?: () => void;
|
||||
}
|
||||
|
||||
const BUILTIN_AGENTS: AgentInfo[] = [
|
||||
{
|
||||
id: 'catty',
|
||||
name: 'Catty Agent',
|
||||
type: 'builtin',
|
||||
description: 'Built-in terminal assistant',
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
|
||||
const SectionLabel: React.FC<{ children: React.ReactNode; action?: React.ReactNode }> = ({ children, action }) => (
|
||||
<div className="px-4 pb-2 pt-2 flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium tracking-wide text-muted-foreground/52">
|
||||
{children}
|
||||
</span>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
|
||||
const AgentMenuRow: React.FC<{
|
||||
agent: AgentInfo;
|
||||
isActive?: boolean;
|
||||
subtitle?: string;
|
||||
onClick: () => void;
|
||||
}> = ({ agent, isActive, subtitle, onClick }) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center gap-3 px-4 text-left text-[13px] text-foreground/86 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30',
|
||||
isActive && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<AgentIconBadge agent={agent} size="xs" variant="plain" className="opacity-78" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate">{agent.name}</span>
|
||||
{subtitle && (
|
||||
<span className="block truncate text-[10px] text-muted-foreground/40">{subtitle}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const DiscoveredAgentRow: React.FC<{
|
||||
agent: DiscoveredAgent;
|
||||
onEnable: () => void;
|
||||
}> = ({ agent, onEnable }) => {
|
||||
const agentLike: AgentInfo = {
|
||||
id: `discovered_${agent.command}`,
|
||||
name: agent.name,
|
||||
type: 'external',
|
||||
icon: agent.icon,
|
||||
command: agent.command,
|
||||
available: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-10 w-full items-center gap-3 rounded-md px-4 text-[13px]">
|
||||
<AgentIconBadge agent={agentLike} size="xs" variant="plain" className="opacity-78" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-foreground/86">{agent.name}</span>
|
||||
<span className="block truncate text-[10px] text-muted-foreground/40">
|
||||
{agent.version || agent.path}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onEnable}
|
||||
className="shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium text-primary/80 hover:bg-primary/10 hover:text-primary transition-colors cursor-pointer"
|
||||
title={`Enable ${agent.name}`}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
currentAgentId,
|
||||
externalAgents,
|
||||
discoveredAgents = [],
|
||||
isDiscovering = false,
|
||||
onSelectAgent,
|
||||
onEnableDiscoveredAgent,
|
||||
onRediscover,
|
||||
onManageAgents,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const enabledExternalAgents = useMemo(
|
||||
() =>
|
||||
externalAgents
|
||||
.filter((agent) => agent.enabled)
|
||||
.map(
|
||||
(agent): AgentInfo => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
type: 'external',
|
||||
icon: agent.icon,
|
||||
command: agent.command,
|
||||
args: agent.args,
|
||||
available: true,
|
||||
}),
|
||||
),
|
||||
[externalAgents],
|
||||
);
|
||||
|
||||
// Discovered agents not yet added to external agents
|
||||
const unconfiguredDiscovered = useMemo(
|
||||
() =>
|
||||
discoveredAgents.filter(
|
||||
(da) => !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path),
|
||||
),
|
||||
[discoveredAgents, externalAgents],
|
||||
);
|
||||
|
||||
const allAgents = useMemo(
|
||||
() => [...BUILTIN_AGENTS, ...enabledExternalAgents],
|
||||
[enabledExternalAgents],
|
||||
);
|
||||
|
||||
const currentAgent = useMemo(
|
||||
() => allAgents.find((agent) => agent.id === currentAgentId) ?? BUILTIN_AGENTS[0],
|
||||
[allAgents, currentAgentId],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(agentId: string) => {
|
||||
onSelectAgent(agentId);
|
||||
setOpen(false);
|
||||
},
|
||||
[onSelectAgent],
|
||||
);
|
||||
|
||||
const handleEnableDiscovered = useCallback(
|
||||
(agent: DiscoveredAgent) => {
|
||||
onEnableDiscoveredAgent?.(agent);
|
||||
// After enabling, auto-select it
|
||||
const agentId = `discovered_${agent.command}`;
|
||||
onSelectAgent(agentId);
|
||||
setOpen(false);
|
||||
},
|
||||
[onEnableDiscoveredAgent, onSelectAgent],
|
||||
);
|
||||
|
||||
const handleManageAgents = useCallback(() => {
|
||||
setOpen(false);
|
||||
onManageAgents?.();
|
||||
}, [onManageAgents]);
|
||||
|
||||
return (
|
||||
<Dropdown open={open} onOpenChange={setOpen}>
|
||||
<DropdownTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex h-8 min-w-0 max-w-[170px] items-center gap-2 rounded-md px-2 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/28"
|
||||
>
|
||||
<AgentIconBadge
|
||||
agent={currentAgent}
|
||||
size="xs"
|
||||
variant="plain"
|
||||
className="opacity-78"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground/90">
|
||||
{currentAgent.name}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground/60 transition-transform',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
|
||||
<DropdownContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-[288px] rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
|
||||
>
|
||||
{BUILTIN_AGENTS.map((agent) => (
|
||||
<AgentMenuRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
isActive={currentAgentId === agent.id}
|
||||
onClick={() => handleSelect(agent.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{enabledExternalAgents.length > 0 && (
|
||||
<>
|
||||
<div className="mx-0 my-1 border-t border-border/50" />
|
||||
<SectionLabel>{t('ai.chat.agents')}</SectionLabel>
|
||||
{enabledExternalAgents.map((agent) => (
|
||||
<AgentMenuRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
isActive={currentAgentId === agent.id}
|
||||
subtitle={agent.command}
|
||||
onClick={() => handleSelect(agent.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{unconfiguredDiscovered.length > 0 && (
|
||||
<>
|
||||
<div className="mx-0 my-1 border-t border-border/50" />
|
||||
<SectionLabel
|
||||
action={
|
||||
onRediscover && (
|
||||
<button
|
||||
onClick={onRediscover}
|
||||
disabled={isDiscovering}
|
||||
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground/70 transition-colors cursor-pointer disabled:opacity-50"
|
||||
title={t('ai.chat.rescan')}
|
||||
>
|
||||
<RefreshCw size={10} className={cn(isDiscovering && 'animate-spin')} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('ai.chat.detectedOnMachine')}
|
||||
</SectionLabel>
|
||||
{unconfiguredDiscovered.map((agent) => (
|
||||
<DiscoveredAgentRow
|
||||
key={agent.command}
|
||||
agent={agent}
|
||||
onEnable={() => handleEnableDiscovered(agent)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mx-0 my-1 border-t border-border/50" />
|
||||
<button
|
||||
onClick={handleManageAgents}
|
||||
className="flex h-10 w-full items-center gap-3 px-4 text-left text-[13px] text-foreground/82 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30"
|
||||
>
|
||||
<Settings size={16} className="opacity-72 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{t('ai.agentSettings')}</span>
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(AgentSelector);
|
||||
559
components/ai/ChatInput.tsx
Normal file
559
components/ai/ChatInput.tsx
Normal file
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* ChatInput - Zed-style bottom input area for the AI chat panel
|
||||
*
|
||||
* Thin wrapper around the AI Elements prompt-input components.
|
||||
* Bordered textarea with monospace placeholder, expand toggle,
|
||||
* and a bottom toolbar with muted controls + subtle send button.
|
||||
*/
|
||||
|
||||
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Plus, ShieldCheck, X, Zap } from 'lucide-react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { UploadedImage } from '../../application/state/useImageUpload';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputFooter,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputTools,
|
||||
} from '../ai-elements/prompt-input';
|
||||
import type { PromptInputStatus } from '../ai-elements/prompt-input';
|
||||
import { formatThinkingLabel } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onStop?: () => void;
|
||||
isStreaming?: boolean;
|
||||
disabled?: boolean;
|
||||
providerName?: string;
|
||||
modelName?: string;
|
||||
agentName?: string;
|
||||
placeholder?: string;
|
||||
/** Available model presets for the current agent */
|
||||
modelPresets?: AgentModelPreset[];
|
||||
/** Currently selected model ID */
|
||||
selectedModelId?: string;
|
||||
/** Callback when user selects a model */
|
||||
onModelSelect?: (modelId: string) => void;
|
||||
/** Attached images */
|
||||
images?: UploadedImage[];
|
||||
/** Callback to add images (paste/drop) */
|
||||
onAddImages?: (files: File[]) => void;
|
||||
/** Callback to remove an image */
|
||||
onRemoveImage?: (id: string) => void;
|
||||
/** Available hosts for @ mention */
|
||||
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
|
||||
/** Permission mode (only shown for Catty Agent) */
|
||||
permissionMode?: AIPermissionMode;
|
||||
/** Callback when user changes permission mode */
|
||||
onPermissionModeChange?: (mode: AIPermissionMode) => void;
|
||||
}
|
||||
|
||||
const ChatInput: React.FC<ChatInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
onStop,
|
||||
isStreaming = false,
|
||||
disabled = false,
|
||||
providerName,
|
||||
modelName,
|
||||
agentName,
|
||||
placeholder,
|
||||
modelPresets = [],
|
||||
selectedModelId,
|
||||
onModelSelect,
|
||||
images = [],
|
||||
onAddImages,
|
||||
onRemoveImage,
|
||||
hosts = [],
|
||||
permissionMode,
|
||||
onPermissionModeChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
|
||||
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'perm' | null;
|
||||
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
|
||||
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
|
||||
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
|
||||
const [showHostSubmenu, setShowHostSubmenu] = useState(false);
|
||||
|
||||
// Derived booleans for readability
|
||||
const showModelPicker = activeMenu === 'model';
|
||||
const showAttachMenu = activeMenu === 'attach';
|
||||
const showAtMention = activeMenu === 'atMention';
|
||||
const showPermPicker = activeMenu === 'perm';
|
||||
|
||||
const closeAllMenus = useCallback(() => {
|
||||
setActiveMenu(null);
|
||||
setMenuPos(null);
|
||||
setHoveredModelId(null);
|
||||
setShowHostSubmenu(false);
|
||||
}, []);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const modelBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const permBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const attachBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleInputChange = useCallback((newValue: string) => {
|
||||
onChange(newValue);
|
||||
// Detect if user just typed @
|
||||
if (
|
||||
hosts.length > 0 &&
|
||||
newValue.length > value.length &&
|
||||
newValue.endsWith('@')
|
||||
) {
|
||||
// Position the popover near the textarea
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
setMenuPos({ left: rect.left + 12, bottom: window.innerHeight - rect.top + 4 });
|
||||
}
|
||||
setActiveMenu('atMention');
|
||||
} else if (showAtMention && !newValue.includes('@')) {
|
||||
setActiveMenu(null);
|
||||
}
|
||||
}, [onChange, value, hosts.length, showAtMention]);
|
||||
|
||||
const handleSelectAtMention = useCallback((host: { label: string; hostname: string }) => {
|
||||
// Replace the trailing @ with @hostname
|
||||
const name = host.label || host.hostname;
|
||||
const lastAt = value.lastIndexOf('@');
|
||||
const newValue = lastAt >= 0
|
||||
? value.slice(0, lastAt) + `@${name} `
|
||||
: value + `@${name} `;
|
||||
onChange(newValue);
|
||||
closeAllMenus();
|
||||
}, [value, onChange, closeAllMenus]);
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const files = Array.from(e.clipboardData.items)
|
||||
.filter((item) => item.type.startsWith('image/'))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(Boolean) as File[];
|
||||
if (files.length > 0) {
|
||||
e.preventDefault();
|
||||
onAddImages?.(files);
|
||||
}
|
||||
}, [onAddImages]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
||||
if (files.length > 0) {
|
||||
onAddImages?.(files);
|
||||
}
|
||||
}, [onAddImages]);
|
||||
|
||||
const defaultPlaceholder = agentName
|
||||
? t('ai.chat.placeholder').replace('{agent}', agentName)
|
||||
: t('ai.chat.placeholderDefault');
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(_text: string, _event: FormEvent<HTMLFormElement>) => {
|
||||
onSend();
|
||||
},
|
||||
[onSend],
|
||||
);
|
||||
|
||||
const status: PromptInputStatus = isStreaming ? 'streaming' : 'idle';
|
||||
|
||||
// Permission mode chip removed — agents run in autonomous mode
|
||||
|
||||
// selectedModelId may be "model/thinking" for codex
|
||||
const selectedBaseModelId = selectedModelId?.split('/')[0];
|
||||
const selectedThinking = selectedModelId?.includes('/') ? selectedModelId.split('/')[1] : undefined;
|
||||
const selectedPreset = modelPresets.find(m => m.id === selectedBaseModelId);
|
||||
const modelLabel = selectedPreset
|
||||
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
|
||||
: modelName || providerName || t('ai.chat.noModel');
|
||||
const hasModelPicker = modelPresets.length > 0 && onModelSelect;
|
||||
const chipClassName =
|
||||
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
|
||||
const iconButtonClassName =
|
||||
'h-6 w-6 rounded-full bg-transparent text-foreground/62 hover:bg-muted/24 hover:text-foreground';
|
||||
|
||||
return (
|
||||
<div className="shrink-0 px-4 pb-4">
|
||||
<PromptInput onSubmit={handleSubmit}>
|
||||
{/* Image attachment chips */}
|
||||
{images.length > 0 && (
|
||||
<div className="flex gap-1.5 px-3 pt-2 pb-0.5 flex-wrap">
|
||||
{images.map((img) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="inline-flex items-center gap-1 h-6 pl-1.5 pr-1 rounded-md bg-muted/30 border border-border/30 text-[11px] text-foreground/70 group"
|
||||
>
|
||||
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
|
||||
<span className="truncate max-w-[80px]">{img.filename}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveImage?.(img.id)}
|
||||
className="h-3.5 w-3.5 rounded-sm flex items-center justify-center opacity-50 hover:opacity-100 hover:bg-muted/50 transition-opacity cursor-pointer"
|
||||
>
|
||||
<X size={8} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) {
|
||||
onAddImages?.(Array.from(e.target.files));
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Textarea with expand toggle */}
|
||||
<div className="relative" onPaste={handlePaste} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
|
||||
<PromptInputTextarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
placeholder={placeholder || defaultPlaceholder}
|
||||
disabled={disabled || isStreaming}
|
||||
className={expanded ? 'max-h-[220px]' : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="absolute top-3.5 right-3 rounded-md p-1 text-muted-foreground/38 hover:text-muted-foreground/72 hover:bg-muted/25 transition-colors cursor-pointer"
|
||||
title={expanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<Expand size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* @ mention popover */}
|
||||
{showAtMention && hosts.length > 0 && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Mention host"
|
||||
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
>
|
||||
<div className="px-3 py-1 text-[10px] text-muted-foreground/40 tracking-wide">{t('ai.chat.menuHosts')}</div>
|
||||
{hosts.map(host => (
|
||||
<button
|
||||
key={host.sessionId}
|
||||
type="button"
|
||||
role="option"
|
||||
onClick={() => handleSelectAtMention(host)}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
|
||||
{host.label && host.hostname !== host.label && (
|
||||
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* Footer toolbar */}
|
||||
<PromptInputFooter className="gap-1.5 border-t-0 bg-transparent px-3 pb-2 pt-0">
|
||||
<PromptInputTools className="gap-1 flex-wrap">
|
||||
<button
|
||||
ref={attachBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showAttachMenu) {
|
||||
const rect = attachBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
setActiveMenu('attach');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={iconButtonClassName}
|
||||
title="Attach"
|
||||
aria-label="Attach file"
|
||||
aria-expanded={showAttachMenu}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
{showAttachMenu && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="menu"
|
||||
className="fixed z-[1000] min-w-[170px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
>
|
||||
<div className="px-3 py-1 text-[10px] text-muted-foreground/40 tracking-wide">{t('ai.chat.menuContext')}</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => { fileInputRef.current?.setAttribute('accept', '*/*'); fileInputRef.current?.click(); closeAllMenus(); }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<FileText size={13} className="text-muted-foreground/60" />
|
||||
<span className="text-foreground/85">{t('ai.chat.menuFiles')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => { fileInputRef.current?.setAttribute('accept', 'image/*'); fileInputRef.current?.click(); closeAllMenus(); }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<ImageIcon size={13} className="text-muted-foreground/60" />
|
||||
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
|
||||
</button>
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setShowHostSubmenu(true)}
|
||||
onMouseLeave={() => setShowHostSubmenu(false)}
|
||||
onFocus={() => setShowHostSubmenu(true)}
|
||||
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setShowHostSubmenu(false); }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label="Mention host"
|
||||
aria-expanded={showHostSubmenu && hosts.length > 0}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<AtSign size={13} className="text-muted-foreground/60" />
|
||||
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
|
||||
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
{showHostSubmenu && hosts.length > 0 && (
|
||||
<div role="menu" className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1 z-[1001]">
|
||||
{hosts.map(host => (
|
||||
<button
|
||||
key={host.sessionId}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const mention = `@${host.label || host.hostname} `;
|
||||
onChange(value + mention);
|
||||
closeAllMenus();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
|
||||
{host.label && host.hostname !== host.label && (
|
||||
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
<button
|
||||
ref={modelBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!hasModelPicker) return;
|
||||
if (!showModelPicker) {
|
||||
const rect = modelBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
setActiveMenu('model');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={`${chipClassName} ${hasModelPicker ? 'cursor-pointer hover:bg-muted/24 transition-colors' : ''}`}
|
||||
aria-label="Select model"
|
||||
aria-expanded={showModelPicker}
|
||||
>
|
||||
<Cpu size={11} className="text-muted-foreground/64" />
|
||||
<span className="truncate max-w-[82px]">{modelLabel}</span>
|
||||
{hasModelPicker && <ChevronDown size={9} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
{showModelPicker && hasModelPicker && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Select model"
|
||||
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
onMouseLeave={() => setHoveredModelId(null)}
|
||||
>
|
||||
{modelPresets.map(preset => {
|
||||
const isSelected = preset.id === selectedBaseModelId;
|
||||
const hasThinking = preset.thinkingLevels && preset.thinkingLevels.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
className="relative"
|
||||
onMouseEnter={() => setHoveredModelId(hasThinking ? preset.id : null)}
|
||||
onFocus={() => { if (hasThinking) setHoveredModelId(preset.id); }}
|
||||
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setHoveredModelId(null); }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => {
|
||||
if (!hasThinking) {
|
||||
onModelSelect?.(preset.id);
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
{isSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
|
||||
<span className="flex-1 text-foreground/85">{preset.name}</span>
|
||||
{preset.description && <span className="text-[10px] text-muted-foreground/50 mr-1">{preset.description}</span>}
|
||||
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
</button>
|
||||
{/* Thinking level sub-menu */}
|
||||
{hasThinking && hoveredModelId === preset.id && (
|
||||
<div role="listbox" aria-label="Thinking level" className="absolute left-full top-0 ml-1 min-w-[120px] rounded-lg border border-border/50 bg-popover shadow-lg py-1 z-[1001]">
|
||||
{preset.thinkingLevels!.map(level => {
|
||||
const fullId = `${preset.id}/${level}`;
|
||||
const isLevelSelected = selectedModelId === fullId;
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isLevelSelected}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
onModelSelect?.(fullId);
|
||||
closeAllMenus();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onModelSelect?.(fullId);
|
||||
closeAllMenus();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
{isLevelSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
|
||||
<span className="text-foreground/85">{formatThinkingLabel(level)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
{/* Permission mode chip — only for Catty Agent */}
|
||||
{permissionMode && onPermissionModeChange && (
|
||||
<>
|
||||
<button
|
||||
ref={permBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showPermPicker) {
|
||||
const rect = permBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
setActiveMenu('perm');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={`${chipClassName} cursor-pointer hover:bg-muted/24 transition-colors`}
|
||||
title={t('ai.safety.permissionMode')}
|
||||
aria-label="Permission mode"
|
||||
aria-expanded={showPermPicker}
|
||||
>
|
||||
{permissionMode === 'observer' && <Eye size={11} className="text-blue-400/70" />}
|
||||
{permissionMode === 'confirm' && <ShieldCheck size={11} className="text-yellow-400/70" />}
|
||||
{permissionMode === 'autonomous' && <Zap size={11} className="text-green-400/70" />}
|
||||
<span className="truncate max-w-[72px]">
|
||||
{permissionMode === 'observer' && t('ai.chat.permObserver')}
|
||||
{permissionMode === 'confirm' && t('ai.chat.permConfirm')}
|
||||
{permissionMode === 'autonomous' && t('ai.chat.permAuto')}
|
||||
</span>
|
||||
<ChevronDown size={9} className="text-muted-foreground/50" />
|
||||
</button>
|
||||
{showPermPicker && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Permission mode"
|
||||
className="fixed z-[1000] min-w-[180px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
>
|
||||
{([
|
||||
{ mode: 'autonomous' as const, icon: Zap, color: 'text-green-400/70', label: t('ai.chat.permAuto'), desc: t('ai.chat.permAutoDesc') },
|
||||
{ mode: 'confirm' as const, icon: ShieldCheck, color: 'text-yellow-400/70', label: t('ai.chat.permConfirm'), desc: t('ai.chat.permConfirmDesc') },
|
||||
{ mode: 'observer' as const, icon: Eye, color: 'text-blue-400/70', label: t('ai.chat.permObserver'), desc: t('ai.chat.permObserverDesc') },
|
||||
]).map(({ mode, icon: Icon, color, label, desc }) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={permissionMode === mode}
|
||||
onClick={() => {
|
||||
onPermissionModeChange(mode);
|
||||
closeAllMenus();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{permissionMode === mode
|
||||
? <Check size={11} className="text-primary shrink-0" />
|
||||
: <span className="w-[11px] shrink-0" />
|
||||
}
|
||||
<Icon size={12} className={`${color} shrink-0`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-foreground/85">{label}</div>
|
||||
<div className="text-[10px] text-muted-foreground/40 leading-tight">{desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PromptInputTools>
|
||||
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<PromptInputSubmit
|
||||
status={status}
|
||||
onStop={onStop}
|
||||
disabled={!value.trim() || disabled}
|
||||
/>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ChatInput);
|
||||
196
components/ai/ChatMessageList.tsx
Normal file
196
components/ai/ChatMessageList.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* ChatMessageList - Renders the list of chat messages
|
||||
*
|
||||
* Claude-Code-style: user messages in bordered bubbles (right-aligned),
|
||||
* assistant responses as plain text (left-aligned, no border/bg).
|
||||
* No avatars. Thinking blocks are collapsible.
|
||||
*/
|
||||
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { ChatMessage } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from '../ai-elements/conversation';
|
||||
import { Message, MessageContent, MessageResponse } from '../ai-elements/message';
|
||||
import { ToolCall } from '../ai-elements/tool-call';
|
||||
import { InlineApprovalCard } from './InlineApprovalCard';
|
||||
import ThinkingBlock from './ThinkingBlock';
|
||||
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
isStreaming?: boolean;
|
||||
onApprove?: (messageId: string) => void;
|
||||
onReject?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, onApprove, onReject }) => {
|
||||
const { t } = useI18n();
|
||||
const visibleMessages = messages.filter(m => m.role !== 'system');
|
||||
|
||||
if (visibleMessages.length === 0 && !isStreaming) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center px-6">
|
||||
<p className="text-[13px] text-muted-foreground/40 text-center">
|
||||
{t('ai.chat.emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const lastAssistantMessage = visibleMessages.findLast(m => m.role === 'assistant');
|
||||
|
||||
return (
|
||||
<Conversation className="flex-1">
|
||||
<ConversationContent className="gap-1.5 px-4 py-2">
|
||||
{visibleMessages.map((message) => {
|
||||
if (message.role === 'tool') {
|
||||
return (
|
||||
<React.Fragment key={message.id}>
|
||||
{message.toolResults?.map((tr) => (
|
||||
<ToolCall
|
||||
key={tr.toolCallId}
|
||||
name={tr.toolCallId}
|
||||
result={tr.content}
|
||||
isError={tr.isError}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const isUser = message.role === 'user';
|
||||
const isLastAssistant = message === lastAssistantMessage;
|
||||
const isThisStreaming = isStreaming && isLastAssistant;
|
||||
|
||||
return (
|
||||
<Message key={message.id} from={message.role}>
|
||||
<MessageContent>
|
||||
{/* Thinking block */}
|
||||
{!isUser && message.thinking && (
|
||||
<ThinkingBlock
|
||||
content={message.thinking}
|
||||
isStreaming={!!isThisStreaming && !message.content}
|
||||
durationMs={message.thinkingDurationMs}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* User images */}
|
||||
{isUser && message.images && message.images.length > 0 && (
|
||||
<div className="flex gap-1.5 flex-wrap mb-1">
|
||||
{message.images.map((img, i) => (
|
||||
<img
|
||||
key={img.filename ? `${img.filename}-${i}` : `img-${message.id}-${i}`}
|
||||
src={`data:${img.mediaType};base64,${img.base64Data}`}
|
||||
alt={img.filename || 'image'}
|
||||
className="max-h-[120px] max-w-[200px] rounded-md object-contain border border-border/20"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.content && (
|
||||
isUser
|
||||
? <div className="whitespace-pre-wrap break-words text-[13px]">{message.content}</div>
|
||||
: <MessageResponse isAnimating={isThisStreaming}>
|
||||
{message.content}
|
||||
</MessageResponse>
|
||||
)}
|
||||
|
||||
{/* Tool calls */}
|
||||
{message.toolCalls?.map((tc) => (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isThisStreaming && message.executionStatus === 'running'}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Inline approval card */}
|
||||
{message.pendingApproval && (
|
||||
<InlineApprovalCard
|
||||
toolName={message.pendingApproval.toolName}
|
||||
toolArgs={message.pendingApproval.toolArgs}
|
||||
status={message.pendingApproval.status}
|
||||
onApprove={() => onApprove?.(message.id)}
|
||||
onReject={() => onReject?.(message.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Status text with shimmer */}
|
||||
{message.statusText && (
|
||||
<div className="py-1">
|
||||
<span className="thinking-shimmer text-xs">{message.statusText}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error info */}
|
||||
{message.errorInfo && (
|
||||
<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>
|
||||
{message.errorInfo.retryable && (
|
||||
<p className="text-muted-foreground text-xs mt-1">{t('ai.chat.retryHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Streaming indicator — only when no content and no thinking yet */}
|
||||
{isStreaming && !lastAssistantMessage?.content && !lastAssistantMessage?.thinking && (
|
||||
<div className="flex items-center gap-1 py-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/30 animate-bounce [animation-delay:0ms]" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/30 animate-bounce [animation-delay:150ms]" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/30 animate-bounce [animation-delay:300ms]" />
|
||||
</div>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
);
|
||||
};
|
||||
|
||||
function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps): boolean {
|
||||
if (prev.isStreaming !== next.isStreaming) return false;
|
||||
if (prev.onApprove !== next.onApprove) return false;
|
||||
if (prev.onReject !== next.onReject) return false;
|
||||
if (prev.messages.length !== next.messages.length) return false;
|
||||
if (prev.messages === next.messages) return true;
|
||||
|
||||
// Shallow-compare each message by reference
|
||||
for (let i = 0; i < prev.messages.length; i++) {
|
||||
if (prev.messages[i] !== next.messages[i]) {
|
||||
// For the last message during streaming, compare by content to avoid
|
||||
// re-renders when only the array reference changed but content is the same
|
||||
const p = prev.messages[i];
|
||||
const n = next.messages[i];
|
||||
if (
|
||||
p.id !== n.id ||
|
||||
p.content !== n.content ||
|
||||
p.thinking !== n.thinking ||
|
||||
p.role !== n.role ||
|
||||
p.statusText !== n.statusText ||
|
||||
p.executionStatus !== n.executionStatus ||
|
||||
p.pendingApproval !== n.pendingApproval ||
|
||||
p.errorInfo !== n.errorInfo ||
|
||||
p.toolCalls !== n.toolCalls ||
|
||||
p.toolResults !== n.toolResults
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default React.memo(ChatMessageList, areMessagesEqual);
|
||||
82
components/ai/ConversationExport.tsx
Normal file
82
components/ai/ConversationExport.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* ConversationExport - Dropdown button for exporting chat sessions
|
||||
*
|
||||
* Small download icon button with a dropdown offering Markdown, JSON,
|
||||
* and Plain Text export formats.
|
||||
*/
|
||||
|
||||
import { Download, FileJson, FileText, FileType } from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownContent,
|
||||
DropdownTrigger,
|
||||
} from '../ui/dropdown';
|
||||
|
||||
interface ConversationExportProps {
|
||||
session: AISession | null;
|
||||
onExport: (format: 'md' | 'json' | 'txt') => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const EXPORT_OPTIONS = [
|
||||
{ format: 'md' as const, labelKey: 'ai.chat.exportMarkdown' as const, icon: FileText },
|
||||
{ format: 'json' as const, labelKey: 'ai.chat.exportJSON' as const, icon: FileJson },
|
||||
{ format: 'txt' as const, labelKey: 'ai.chat.exportPlainText' as const, icon: FileType },
|
||||
];
|
||||
|
||||
const ConversationExport: React.FC<ConversationExportProps> = ({
|
||||
session,
|
||||
onExport,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const handleExport = useCallback(
|
||||
(format: 'md' | 'json' | 'txt') => {
|
||||
onExport(format);
|
||||
},
|
||||
[onExport],
|
||||
);
|
||||
|
||||
const hasMessages = session && session.messages.length > 0;
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground'}
|
||||
disabled={!hasMessages}
|
||||
title={t('ai.chat.exportConversation')}
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="w-40 rounded-xl border border-border/45 bg-[#111111]/98 p-1.5 text-foreground shadow-[0_20px_48px_rgba(0,0,0,0.48)] supports-[backdrop-filter]:bg-[#111111]/92 supports-[backdrop-filter]:backdrop-blur-xl"
|
||||
>
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/48">
|
||||
{t('ai.chat.exportAs')}
|
||||
</div>
|
||||
{EXPORT_OPTIONS.map(({ format, labelKey, icon: Icon }) => (
|
||||
<button
|
||||
key={format}
|
||||
onClick={() => handleExport(format)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-[13px] rounded-lg transition-colors cursor-pointer hover:bg-white/[0.04]"
|
||||
>
|
||||
<Icon size={13} className="shrink-0 text-muted-foreground/70" />
|
||||
<span>{t(labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ConversationExport);
|
||||
169
components/ai/ExecutionPlan.tsx
Normal file
169
components/ai/ExecutionPlan.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* ExecutionPlan - Renders a multi-step execution plan for AI agent tasks.
|
||||
*
|
||||
* Shows a numbered list of steps with status indicators, host badges,
|
||||
* optional command previews, and action buttons.
|
||||
*/
|
||||
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
SkipForward,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Types
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface ExecutionPlanStep {
|
||||
description: string;
|
||||
host?: string;
|
||||
command?: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
}
|
||||
|
||||
interface ExecutionPlanProps {
|
||||
steps: ExecutionPlanStep[];
|
||||
onApprove: () => void;
|
||||
onModify: () => void;
|
||||
onReject: () => void;
|
||||
isExecuting: boolean;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Status icon mapping
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function StepStatusIcon({
|
||||
status,
|
||||
}: {
|
||||
status: ExecutionPlanStep['status'];
|
||||
}) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Circle size={16} className="text-muted-foreground" />;
|
||||
case 'running':
|
||||
return (
|
||||
<Loader2 size={16} className="text-blue-500 animate-spin" />
|
||||
);
|
||||
case 'completed':
|
||||
return <CheckCircle2 size={16} className="text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle size={16} className="text-destructive" />;
|
||||
case 'skipped':
|
||||
return (
|
||||
<SkipForward size={16} className="text-muted-foreground/60" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const ExecutionPlan: React.FC<ExecutionPlanProps> = ({
|
||||
steps,
|
||||
onApprove,
|
||||
onModify,
|
||||
onReject,
|
||||
isExecuting,
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/30 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-border/60 bg-muted/50">
|
||||
<span className="text-sm font-medium">
|
||||
Execution Plan ({steps.length} step{steps.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Steps list */}
|
||||
<div className="divide-y divide-border/30">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-3 py-2.5 transition-colors',
|
||||
step.status === 'running' && 'bg-blue-500/5',
|
||||
step.status === 'completed' && 'bg-green-500/5',
|
||||
step.status === 'failed' && 'bg-destructive/5',
|
||||
step.status === 'skipped' && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{/* Step number + status icon */}
|
||||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||||
<span className="text-xs text-muted-foreground font-mono w-4 text-right">
|
||||
{index + 1}
|
||||
</span>
|
||||
<StepStatusIcon status={step.status} />
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm',
|
||||
step.status === 'skipped' && 'line-through',
|
||||
)}
|
||||
>
|
||||
{step.description}
|
||||
</span>
|
||||
{step.host && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{step.host}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{step.command && (
|
||||
<code className="block text-xs font-mono bg-muted/80 px-2 py-1 rounded text-muted-foreground truncate">
|
||||
{step.command}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="px-3 py-2.5 border-t border-border/60 flex items-center justify-end gap-2">
|
||||
{isExecuting ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onReject}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onModify}>
|
||||
Modify Plan
|
||||
</Button>
|
||||
<Button size="sm" onClick={onApprove}>
|
||||
Approve
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ExecutionPlan.displayName = 'ExecutionPlan';
|
||||
|
||||
export default ExecutionPlan;
|
||||
export { ExecutionPlan };
|
||||
export type { ExecutionPlanProps, ExecutionPlanStep };
|
||||
193
components/ai/InlineApprovalCard.tsx
Normal file
193
components/ai/InlineApprovalCard.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* InlineApprovalCard - Inline tool approval card rendered within chat messages.
|
||||
*
|
||||
* Replaces the modal PermissionDialog. Shows tool name, arguments, and
|
||||
* approve/reject buttons. Keyboard shortcuts: Enter to approve, Escape to reject.
|
||||
*/
|
||||
|
||||
import { Check, ShieldAlert, X } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
interface InlineApprovalCardProps {
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
status: 'pending' | 'approved' | 'denied';
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
}
|
||||
|
||||
const InlineApprovalCard: React.FC<InlineApprovalCardProps> = ({
|
||||
toolName,
|
||||
toolArgs,
|
||||
status,
|
||||
onApprove,
|
||||
onReject,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const approveBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const isPending = status === 'pending';
|
||||
const [responded, setResponded] = useState(false);
|
||||
|
||||
// Use refs to always access the latest callbacks without re-registering the listener
|
||||
const onApproveRef = useRef(onApprove);
|
||||
const onRejectRef = useRef(onReject);
|
||||
onApproveRef.current = onApprove;
|
||||
onRejectRef.current = onReject;
|
||||
|
||||
const isDisabled = !isPending || responded;
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
if (isDisabled) return;
|
||||
setResponded(true);
|
||||
onApproveRef.current();
|
||||
}, [isDisabled]);
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
if (isDisabled) return;
|
||||
setResponded(true);
|
||||
onRejectRef.current();
|
||||
}, [isDisabled]);
|
||||
|
||||
// Keyboard shortcuts: handled via local onKeyDown on the focusable card element
|
||||
// to avoid conflicts when multiple InlineApprovalCard instances exist simultaneously.
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (isDisabled) return;
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleApprove();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleReject();
|
||||
}
|
||||
}, [isDisabled, handleApprove, handleReject]);
|
||||
|
||||
// Auto-focus approve button and auto-scroll into view when mounted as pending
|
||||
useEffect(() => {
|
||||
if (isPending && cardRef.current) {
|
||||
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
approveBtnRef.current?.focus();
|
||||
}
|
||||
}, [isPending]);
|
||||
|
||||
let formattedArgs: string;
|
||||
try {
|
||||
formattedArgs = JSON.stringify(toolArgs, null, 2);
|
||||
} catch {
|
||||
formattedArgs = String(toolArgs);
|
||||
}
|
||||
|
||||
// Extract target session info if present
|
||||
const sessionId = toolArgs?.sessionId as string | undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={0}
|
||||
role="alertdialog"
|
||||
aria-label="Tool execution approval required"
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`rounded-md border overflow-hidden text-[12px] mt-1.5 outline-none ${
|
||||
isPending
|
||||
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
|
||||
: status === 'approved'
|
||||
? 'border-green-500/20 bg-green-500/[0.03]'
|
||||
: 'border-red-500/20 bg-red-500/[0.03]'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5">
|
||||
<ShieldAlert
|
||||
size={13}
|
||||
className={
|
||||
isPending
|
||||
? 'text-yellow-500/70 shrink-0'
|
||||
: status === 'approved'
|
||||
? 'text-green-400/70 shrink-0'
|
||||
: 'text-red-400/70 shrink-0'
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] font-medium text-foreground/70">
|
||||
{t('ai.chat.toolApprovalTitle')}
|
||||
</span>
|
||||
{!isPending && (
|
||||
<Badge
|
||||
className={`ml-auto text-[10px] px-1.5 py-0 ${
|
||||
status === 'approved'
|
||||
? 'bg-green-600/20 text-green-400 border-green-600/30'
|
||||
: 'bg-red-600/20 text-red-400 border-red-600/30'
|
||||
}`}
|
||||
>
|
||||
{status === 'approved' ? t('ai.chat.toolApproved') : t('ai.chat.toolDenied')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool info */}
|
||||
<div className="px-3 pb-2 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground/40 uppercase tracking-wider">{t('ai.chat.toolLabel')}</span>
|
||||
<code className="text-[11px] font-mono text-muted-foreground/70 bg-muted/30 px-1.5 py-0.5 rounded">
|
||||
{toolName}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{sessionId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground/40 uppercase tracking-wider">{t('ai.chat.targetLabel')}</span>
|
||||
<code className="text-[11px] font-mono text-muted-foreground/50 bg-muted/30 px-1.5 py-0.5 rounded">
|
||||
{sessionId}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arguments */}
|
||||
<div className="rounded border border-border/20 bg-muted/10 p-2 max-h-32 overflow-auto">
|
||||
<pre className="text-[11px] font-mono whitespace-pre-wrap break-all text-muted-foreground/50">
|
||||
{formattedArgs}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Actions or hint */}
|
||||
{isPending && (
|
||||
<div className="flex items-center justify-between pt-0.5">
|
||||
<span className="text-[10px] text-muted-foreground/30">
|
||||
{t('ai.chat.toolApprovalHint')}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={responded}
|
||||
className={`h-6 px-2 text-[11px] border-red-500/20 text-red-400/80 hover:bg-red-500/10 hover:text-red-400 ${responded ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={handleReject}
|
||||
>
|
||||
<X size={11} className="mr-0.5" />
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
<Button
|
||||
ref={approveBtnRef}
|
||||
size="sm"
|
||||
disabled={responded}
|
||||
className={`h-6 px-2.5 text-[11px] bg-green-600/80 hover:bg-green-600 text-white ${responded ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={handleApprove}
|
||||
>
|
||||
<Check size={11} className="mr-0.5" />
|
||||
{t('ai.chat.approve')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
InlineApprovalCard.displayName = 'InlineApprovalCard';
|
||||
|
||||
export default InlineApprovalCard;
|
||||
export { InlineApprovalCard };
|
||||
export type { InlineApprovalCardProps };
|
||||
200
components/ai/PermissionDialog.tsx
Normal file
200
components/ai/PermissionDialog.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* PermissionDialog - Modal for AI agent tool call permission requests.
|
||||
*
|
||||
* Shown when the agent needs user approval to execute a tool call.
|
||||
* Displays tool name, arguments, recommendation, and approve/reject actions.
|
||||
*/
|
||||
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Types
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface PermissionDialogProps {
|
||||
open: boolean;
|
||||
toolCall: { name: string; arguments: Record<string, unknown> } | null;
|
||||
recommendation: 'allow' | 'confirm' | 'deny';
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const PermissionDialog: React.FC<PermissionDialogProps> = ({
|
||||
open,
|
||||
toolCall,
|
||||
recommendation,
|
||||
onApprove,
|
||||
onReject,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isDenied = recommendation === 'deny';
|
||||
|
||||
// Keyboard shortcuts: Enter to approve, Escape to reject
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !isDenied) {
|
||||
e.preventDefault();
|
||||
onApprove();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onReject();
|
||||
}
|
||||
},
|
||||
[isDenied, onApprove, onReject],
|
||||
);
|
||||
|
||||
// Format arguments as readable code block content
|
||||
let formattedArgs = '';
|
||||
if (toolCall) {
|
||||
try {
|
||||
formattedArgs = JSON.stringify(toolCall.arguments, null, 2);
|
||||
} catch {
|
||||
formattedArgs = String(toolCall.arguments);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract host/session info from arguments if present
|
||||
const sessionId =
|
||||
toolCall?.arguments?.sessionId as string | undefined;
|
||||
const sessionIds =
|
||||
toolCall?.arguments?.sessionIds as string[] | undefined;
|
||||
|
||||
const recommendationBadge = () => {
|
||||
switch (recommendation) {
|
||||
case 'allow':
|
||||
return (
|
||||
<Badge className="bg-green-600/20 text-green-400 border-green-600/30">
|
||||
{t('ai.chat.recommendAllow')}
|
||||
</Badge>
|
||||
);
|
||||
case 'confirm':
|
||||
return (
|
||||
<Badge className="bg-yellow-600/20 text-yellow-400 border-yellow-600/30">
|
||||
{t('ai.chat.recommendConfirm')}
|
||||
</Badge>
|
||||
);
|
||||
case 'deny':
|
||||
return <Badge variant="destructive">{t('ai.chat.recommendDeny')}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
|
||||
<DialogContent hideCloseButton onKeyDown={handleKeyDown}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldAlert
|
||||
size={20}
|
||||
className={cn(
|
||||
isDenied ? 'text-destructive' : 'text-yellow-500',
|
||||
)}
|
||||
/>
|
||||
{t('ai.chat.permissionRequired')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('ai.chat.permissionDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{toolCall && (
|
||||
<div className="space-y-3">
|
||||
{/* Tool name and recommendation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t('ai.chat.toolLabel')}:</span>
|
||||
<code className="text-sm font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{toolCall.name}
|
||||
</code>
|
||||
</div>
|
||||
{recommendationBadge()}
|
||||
</div>
|
||||
|
||||
{/* Target session(s) */}
|
||||
{(sessionId || sessionIds) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t('ai.chat.targetLabel')}:</span>
|
||||
{sessionId && (
|
||||
<code className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{sessionId}
|
||||
</code>
|
||||
)}
|
||||
{sessionIds && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{sessionIds.map((id) => (
|
||||
<code
|
||||
key={id}
|
||||
className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded"
|
||||
>
|
||||
{id}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arguments code block */}
|
||||
<div className="rounded-md border border-border bg-muted/50 p-3 max-h-48 overflow-auto">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-foreground">
|
||||
{formattedArgs}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Deny warning */}
|
||||
{isDenied && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3">
|
||||
<p className="text-sm text-destructive">
|
||||
{t('ai.chat.commandBlocked')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{isDenied ? (
|
||||
<Button variant="destructive" onClick={onReject} className="w-full">
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onReject}
|
||||
className="border-destructive/30 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
<Button onClick={onApprove}>{t('ai.chat.approve')}</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
PermissionDialog.displayName = 'PermissionDialog';
|
||||
|
||||
export default PermissionDialog;
|
||||
export { PermissionDialog };
|
||||
export type { PermissionDialogProps };
|
||||
138
components/ai/ThinkingBlock.tsx
Normal file
138
components/ai/ThinkingBlock.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* ThinkingBlock - Collapsible thinking/reasoning display
|
||||
*
|
||||
* - While streaming: expanded, "Thinking" label with shimmer + elapsed time
|
||||
* - When done: auto-collapses to "Thought for Xs", click to expand
|
||||
* - Content area has max-height with scroll and top gradient fade
|
||||
*/
|
||||
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface ThinkingBlockProps {
|
||||
content: string;
|
||||
isStreaming: boolean;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds % 60;
|
||||
return `${minutes}m ${remaining}s`;
|
||||
}
|
||||
|
||||
const ThinkingBlock: React.FC<ThinkingBlockProps> = ({
|
||||
content,
|
||||
isStreaming,
|
||||
durationMs,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isExpanded, setIsExpanded] = useState(isStreaming);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const wasStreamingRef = useRef(false);
|
||||
const startRef = useRef(Date.now());
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-collapse when streaming ends
|
||||
useEffect(() => {
|
||||
if (wasStreamingRef.current && !isStreaming) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
wasStreamingRef.current = isStreaming;
|
||||
}, [isStreaming]);
|
||||
|
||||
// Expand when streaming starts
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
setIsExpanded(true);
|
||||
startRef.current = Date.now();
|
||||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
// Elapsed time ticker
|
||||
useEffect(() => {
|
||||
if (!isStreaming) return;
|
||||
const timer = setInterval(() => {
|
||||
setElapsed(Date.now() - startRef.current);
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [isStreaming]);
|
||||
|
||||
// Auto-scroll to bottom while streaming
|
||||
useEffect(() => {
|
||||
if (isStreaming && isExpanded && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [content, isStreaming, isExpanded]);
|
||||
|
||||
const toggle = useCallback(() => setIsExpanded(e => !e), []);
|
||||
|
||||
const displayDuration = durationMs || elapsed;
|
||||
const preview = content.length > 60 ? content.slice(0, 60) + '…' : content;
|
||||
|
||||
return (
|
||||
<div className="mb-0.5">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls="thinking-block-content"
|
||||
className="group flex items-center gap-1.5 py-0.5 px-1 cursor-pointer text-left w-full rounded hover:bg-white/[0.03] transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground/50 transition-transform duration-200',
|
||||
isExpanded && 'rotate-90',
|
||||
!isExpanded && 'opacity-50',
|
||||
)}
|
||||
/>
|
||||
<span className="text-[12px] font-medium text-muted-foreground/70 whitespace-nowrap shrink-0">
|
||||
{isStreaming ? (
|
||||
<span className="thinking-shimmer">{t('ai.chat.thinking')}</span>
|
||||
) : (
|
||||
displayDuration > 0
|
||||
? t('ai.chat.thoughtFor', { duration: formatDuration(displayDuration) })
|
||||
: t('ai.chat.thought')
|
||||
)}
|
||||
</span>
|
||||
{isStreaming && elapsed > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground/40 tabular-nums shrink-0">
|
||||
{formatDuration(elapsed)}
|
||||
</span>
|
||||
)}
|
||||
{!isExpanded && !isStreaming && preview && (
|
||||
<span className="text-[11px] text-muted-foreground/40 truncate min-w-0">
|
||||
{preview}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && content && (
|
||||
<div id="thinking-block-content" className="relative">
|
||||
{/* Top gradient fade */}
|
||||
{isStreaming && (
|
||||
<div className="absolute inset-x-0 top-0 h-4 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
'px-5 text-[12px] text-muted-foreground/60 leading-relaxed whitespace-pre-wrap break-words',
|
||||
isStreaming && 'overflow-y-auto scrollbar-hide max-h-36',
|
||||
!isStreaming && 'max-h-36 overflow-y-auto scrollbar-hide',
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ThinkingBlock);
|
||||
743
components/ai/hooks/useAIChatStreaming.ts
Normal file
743
components/ai/hooks/useAIChatStreaming.ts
Normal file
@@ -0,0 +1,743 @@
|
||||
/**
|
||||
* useAIChatStreaming — Encapsulates all streaming logic for the AI chat panel.
|
||||
*
|
||||
* Handles:
|
||||
* - Catty agent streaming via Vercel AI SDK `streamText`
|
||||
* - External agent streaming (ACP and raw process)
|
||||
* - Text-delta batching via requestAnimationFrame
|
||||
* - Abort controller management
|
||||
* - Stream state tracking (per-session)
|
||||
* - Error reporting
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { streamText, stepCountIs, type ModelMessage } from 'ai';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AISession,
|
||||
ChatMessage,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
} 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 { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
|
||||
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
|
||||
import { classifyError, sanitizeErrorMessage } from '../../../infrastructure/ai/errorClassifier';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Stream chunk type interfaces (Issue #13: replace unsafe casts)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/** Shape of a text/text-delta chunk from the Vercel AI SDK fullStream. */
|
||||
interface TextDeltaChunk {
|
||||
type: 'text' | 'text-delta';
|
||||
text?: string;
|
||||
textDelta?: string;
|
||||
}
|
||||
|
||||
/** Shape of a reasoning chunk from the Vercel AI SDK fullStream. */
|
||||
interface ReasoningChunk {
|
||||
type: 'reasoning' | 'reasoning-start' | 'reasoning-delta';
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** Shape of a tool-call chunk from the Vercel AI SDK fullStream. */
|
||||
interface ToolCallChunk {
|
||||
type: 'tool-call';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input?: unknown;
|
||||
args?: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a tool-result chunk from the Vercel AI SDK fullStream. */
|
||||
interface ToolResultChunk {
|
||||
type: 'tool-result';
|
||||
toolCallId: string;
|
||||
output?: unknown;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
/** Shape of a tool-approval-request chunk from the Vercel AI SDK fullStream. */
|
||||
interface ToolApprovalRequestChunk {
|
||||
type: 'tool-approval-request';
|
||||
approvalId: string;
|
||||
toolCall: {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args?: Record<string, unknown>;
|
||||
input?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/** Shape of an error chunk from the Vercel AI SDK fullStream. */
|
||||
interface ErrorChunk {
|
||||
type: 'error';
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
/** Union of all stream chunk shapes we handle. */
|
||||
type StreamChunk =
|
||||
| TextDeltaChunk
|
||||
| ReasoningChunk
|
||||
| ToolCallChunk
|
||||
| ToolResultChunk
|
||||
| ToolApprovalRequestChunk
|
||||
| ErrorChunk
|
||||
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' };
|
||||
|
||||
/** Shape of the netcatty bridge exposed on `window` (panel-specific subset). */
|
||||
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 }>;
|
||||
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
[key: string]: ((...args: unknown[]) => unknown) | undefined;
|
||||
}
|
||||
|
||||
/** Terminal session info used throughout the streaming hooks. */
|
||||
export interface TerminalSessionInfo {
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
/** Typed accessor for the netcatty bridge on the window object. */
|
||||
export function getNetcattyBridge(): PanelBridge | undefined {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (window as any).netcatty as PanelBridge | undefined;
|
||||
}
|
||||
|
||||
/** Approval info returned by processCattyStream when a tool-approval-request is received. */
|
||||
export interface ApprovalInfo {
|
||||
approvalId: string;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Pending approval context stored between approval request and user response. */
|
||||
export interface PendingApprovalContext {
|
||||
sessionId: string;
|
||||
scopeKey: string;
|
||||
sdkMessages: Array<ModelMessage>;
|
||||
approvalInfo: ApprovalInfo;
|
||||
model: ReturnType<typeof createModelFromConfig>;
|
||||
systemPrompt: string;
|
||||
tools: ReturnType<typeof createCattyTools>;
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook parameters
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseAIChatStreamingParams {
|
||||
maxIterations: number;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook return type
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseAIChatStreamingReturn {
|
||||
/** Set of session IDs currently streaming. */
|
||||
streamingSessionIds: Set<string>;
|
||||
/** Set or unset streaming state for a session. */
|
||||
setStreamingForScope: (key: string, val: boolean) => void;
|
||||
/** Ref to per-session abort controllers. */
|
||||
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
|
||||
/** Process a Catty agent stream, returning approval info if one is requested. */
|
||||
processCattyStream: (
|
||||
streamSessionId: string,
|
||||
model: ReturnType<typeof createModelFromConfig>,
|
||||
systemPrompt: string,
|
||||
tools: ReturnType<typeof createCattyTools>,
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
) => Promise<ApprovalInfo | null>;
|
||||
/** Send a message to the Catty agent (built-in). */
|
||||
sendToCattyAgent: (
|
||||
sessionId: string,
|
||||
sendScopeKey: string,
|
||||
trimmed: string,
|
||||
abortController: AbortController,
|
||||
currentSession: AISession | undefined,
|
||||
assistantMsgId: string,
|
||||
context: SendToCattyContext,
|
||||
) => Promise<void>;
|
||||
/** Send a message to an external agent (ACP or raw process). */
|
||||
sendToExternalAgent: (
|
||||
sessionId: string,
|
||||
trimmed: string,
|
||||
agentConfig: ExternalAgentConfig,
|
||||
abortController: AbortController,
|
||||
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string }>,
|
||||
context: SendToExternalContext,
|
||||
) => Promise<void>;
|
||||
/** Report a streaming error to the chat. */
|
||||
reportStreamError: (sessionId: string, abortSignal: AbortSignal, err: unknown) => void;
|
||||
}
|
||||
|
||||
/** Context values needed by sendToCattyAgent that change frequently (avoids stale closures). */
|
||||
export interface SendToCattyContext {
|
||||
activeProvider: ProviderConfig | undefined;
|
||||
activeModelId: string;
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeLabel?: string;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
|
||||
autoTitleSession: (sessionId: string, text: string) => void;
|
||||
}
|
||||
|
||||
/** Context values needed by sendToExternalAgent that change frequently. */
|
||||
export interface SendToExternalContext {
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
providers: ProviderConfig[];
|
||||
selectedAgentModel?: string;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook implementation
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export function useAIChatStreaming({
|
||||
maxIterations,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
|
||||
// Per-session streaming state (keyed by sessionId)
|
||||
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(new Set());
|
||||
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;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Per-scope abort controllers
|
||||
const abortControllersRef = useRef<Map<string, AbortController>>(new Map());
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// reportStreamError
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const reportStreamError = useCallback((
|
||||
sessionId: string,
|
||||
abortSignal: AbortSignal,
|
||||
err: unknown,
|
||||
) => {
|
||||
if (abortSignal.aborted) return;
|
||||
const errorStr = err instanceof Error ? err.message : String(err);
|
||||
// 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: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
|
||||
}));
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}, [updateLastMessage, addMessageToSession]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// processCattyStream
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const processCattyStream = useCallback(async (
|
||||
streamSessionId: string,
|
||||
model: ReturnType<typeof createModelFromConfig>,
|
||||
systemPrompt: string,
|
||||
tools: ReturnType<typeof createCattyTools>,
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
): Promise<ApprovalInfo | null> => {
|
||||
const result = streamText({
|
||||
model,
|
||||
messages: sdkMessages,
|
||||
system: systemPrompt,
|
||||
tools,
|
||||
stopWhen: stepCountIs(maxIterations),
|
||||
abortSignal: signal,
|
||||
});
|
||||
|
||||
// Track the current assistant message ID so updates target the correct message
|
||||
let activeMsgId = currentAssistantMsgId;
|
||||
let lastAddedRole: 'assistant' | 'tool' = 'assistant';
|
||||
const reader = result.fullStream.getReader();
|
||||
let pendingApprovalInfo: ApprovalInfo | null = null;
|
||||
|
||||
// -- Text-delta batching: accumulate deltas and flush periodically --
|
||||
let pendingText = '';
|
||||
let rafId: number | null = null;
|
||||
|
||||
const flushText = () => {
|
||||
if (pendingText) {
|
||||
const text = pendingText;
|
||||
pendingText = '';
|
||||
if (lastAddedRole === 'tool') {
|
||||
const newId = generateId();
|
||||
addMessageToSession(streamSessionId, {
|
||||
id: newId,
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
activeMsgId = newId;
|
||||
lastAddedRole = 'assistant';
|
||||
} else {
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
content: msg.content + text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
rafId = null;
|
||||
};
|
||||
|
||||
const cancelPendingFlush = () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
// Use the StreamChunk union for type narrowing instead of unsafe casts
|
||||
const chunk = value as StreamChunk;
|
||||
switch (chunk.type) {
|
||||
case 'text':
|
||||
case 'text-delta': {
|
||||
const typedChunk = chunk as TextDeltaChunk;
|
||||
const text = typedChunk.text ?? typedChunk.textDelta;
|
||||
if (text) {
|
||||
pendingText += text;
|
||||
if (rafId === null) {
|
||||
rafId = requestAnimationFrame(flushText);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'reasoning':
|
||||
case 'reasoning-start':
|
||||
case 'reasoning-delta': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ReasoningChunk;
|
||||
const rText = typedChunk.text;
|
||||
if (rText) {
|
||||
if (lastAddedRole === 'tool') {
|
||||
const newId = generateId();
|
||||
addMessageToSession(streamSessionId, {
|
||||
id: newId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
thinking: rText,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
activeMsgId = newId;
|
||||
lastAddedRole = 'assistant';
|
||||
} else {
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
thinking: (msg.thinking || '') + rText,
|
||||
}));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'reasoning-end':
|
||||
case 'text-start':
|
||||
case 'text-end':
|
||||
case 'start':
|
||||
case 'finish':
|
||||
case 'start-step':
|
||||
case 'finish-step':
|
||||
break;
|
||||
case 'tool-call': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ToolCallChunk;
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
toolCalls: [...(msg.toolCalls || []), {
|
||||
id: typedChunk.toolCallId,
|
||||
name: typedChunk.toolName,
|
||||
arguments: (typedChunk.input ?? typedChunk.args) as Record<string, unknown>,
|
||||
}],
|
||||
executionStatus: 'running',
|
||||
statusText: undefined,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'tool-result': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ToolResultChunk;
|
||||
// Mark the assistant message's tool execution as completed
|
||||
updateMessageById(streamSessionId, activeMsgId, msg =>
|
||||
msg.role === 'assistant' && msg.executionStatus === 'running'
|
||||
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
|
||||
);
|
||||
const toolOutput = typedChunk.output ?? typedChunk.result;
|
||||
addMessageToSession(streamSessionId, {
|
||||
id: generateId(),
|
||||
role: 'tool',
|
||||
content: '',
|
||||
toolResults: [{
|
||||
toolCallId: typedChunk.toolCallId,
|
||||
content: typeof toolOutput === 'string'
|
||||
? toolOutput
|
||||
: JSON.stringify(toolOutput),
|
||||
isError: false,
|
||||
}],
|
||||
timestamp: Date.now(),
|
||||
executionStatus: 'completed',
|
||||
});
|
||||
lastAddedRole = 'tool';
|
||||
break;
|
||||
}
|
||||
case 'tool-approval-request': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ToolApprovalRequestChunk;
|
||||
pendingApprovalInfo = {
|
||||
approvalId: typedChunk.approvalId,
|
||||
toolCallId: typedChunk.toolCall.toolCallId,
|
||||
toolName: typedChunk.toolCall.toolName,
|
||||
toolArgs: typedChunk.toolCall.args ?? typedChunk.toolCall.input ?? {},
|
||||
};
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
pendingApproval: {
|
||||
...pendingApprovalInfo!,
|
||||
status: 'pending' as const,
|
||||
},
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ErrorChunk;
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
|
||||
}));
|
||||
addMessageToSession(streamSessionId, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo: classifyError(String(typedChunk.error)),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
reader.releaseLock();
|
||||
}
|
||||
return pendingApprovalInfo;
|
||||
}, [maxIterations, addMessageToSession, updateMessageById]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sendToExternalAgent
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const sendToExternalAgent = useCallback(async (
|
||||
sessionId: string,
|
||||
trimmed: string,
|
||||
agentConfig: ExternalAgentConfig,
|
||||
abortController: AbortController,
|
||||
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string }>,
|
||||
context: SendToExternalContext,
|
||||
) => {
|
||||
const bridge = getNetcattyBridge();
|
||||
|
||||
if (agentConfig.acpCommand && bridge) {
|
||||
const requestId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
// Push terminal session metadata to MCP bridge
|
||||
if (bridge?.aiMcpUpdateSessions) {
|
||||
await bridge.aiMcpUpdateSessions(context.terminalSessions, sessionId);
|
||||
}
|
||||
|
||||
// Pass only the provider ID — the main process resolves and decrypts the API key itself,
|
||||
// avoiding plaintext key transit across the IPC boundary.
|
||||
const openaiProvider = context.providers.find(p => p.providerId === 'openai' && p.enabled && p.apiKey);
|
||||
const agentProviderId = openaiProvider?.id;
|
||||
|
||||
// Mutable flag: set after tool-result, cleared when new assistant msg is created
|
||||
let needsNewAssistantMsg = false;
|
||||
const maybeCreateAssistantMsg = () => {
|
||||
if (needsNewAssistantMsg) {
|
||||
needsNewAssistantMsg = false;
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: agentConfig.name || 'external',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await runAcpAgentTurn(
|
||||
bridge,
|
||||
requestId,
|
||||
sessionId,
|
||||
agentConfig,
|
||||
trimmed,
|
||||
{
|
||||
onTextDelta: (text: string) => {
|
||||
maybeCreateAssistantMsg();
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
content: msg.content + text,
|
||||
statusText: undefined,
|
||||
thinkingDurationMs: msg.thinking && !msg.thinkingDurationMs
|
||||
? Date.now() - msg.timestamp : msg.thinkingDurationMs,
|
||||
}));
|
||||
},
|
||||
onThinkingDelta: (text: string) => {
|
||||
maybeCreateAssistantMsg();
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg, thinking: (msg.thinking || '') + text,
|
||||
}));
|
||||
},
|
||||
onThinkingDone: () => {
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg, thinkingDurationMs: msg.thinkingDurationMs || (Date.now() - msg.timestamp),
|
||||
}));
|
||||
},
|
||||
onToolCall: (toolName: string, args: Record<string, unknown>) => {
|
||||
maybeCreateAssistantMsg();
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
toolCalls: [...(msg.toolCalls || []), { id: `tc_${Date.now()}`, name: toolName, arguments: args }],
|
||||
executionStatus: 'running',
|
||||
statusText: undefined,
|
||||
}));
|
||||
},
|
||||
onToolResult: (toolCallId: string, result: string) => {
|
||||
updateLastMessage(sessionId, msg =>
|
||||
msg.role === 'assistant' && msg.executionStatus === 'running'
|
||||
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
|
||||
);
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'tool', content: '',
|
||||
toolResults: [{ toolCallId, content: result, isError: false }],
|
||||
timestamp: Date.now(), executionStatus: 'completed',
|
||||
});
|
||||
needsNewAssistantMsg = true;
|
||||
},
|
||||
onStatus: (message: string) => {
|
||||
maybeCreateAssistantMsg();
|
||||
updateLastMessage(sessionId, msg => ({ ...msg, statusText: message }));
|
||||
},
|
||||
onError: (error: string) => {
|
||||
reportStreamError(sessionId, abortController.signal, error);
|
||||
setStreamingForScope(sessionId, false);
|
||||
},
|
||||
onDone: () => {},
|
||||
},
|
||||
abortController.signal,
|
||||
agentProviderId,
|
||||
context.selectedAgentModel,
|
||||
attachedImages.length > 0 ? attachedImages : undefined,
|
||||
);
|
||||
} else {
|
||||
// Fallback: spawn as raw process
|
||||
await runExternalAgentTurn(
|
||||
agentConfig,
|
||||
trimmed,
|
||||
{
|
||||
onTextDelta: (text: string) => {
|
||||
updateLastMessage(sessionId, msg => ({ ...msg, content: msg.content + text }));
|
||||
},
|
||||
onError: (error: string) => {
|
||||
reportStreamError(sessionId, abortController.signal, error);
|
||||
setStreamingForScope(sessionId, false);
|
||||
},
|
||||
onDone: () => {},
|
||||
},
|
||||
bridge as unknown as Parameters<typeof runExternalAgentTurn>[3],
|
||||
abortController.signal,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
addMessageToSession, updateLastMessage, setStreamingForScope, reportStreamError,
|
||||
]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sendToCattyAgent
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const sendToCattyAgent = useCallback(async (
|
||||
sessionId: string,
|
||||
sendScopeKey: string,
|
||||
trimmed: string,
|
||||
abortController: AbortController,
|
||||
currentSession: AISession | undefined,
|
||||
assistantMsgId: string,
|
||||
context: SendToCattyContext,
|
||||
) => {
|
||||
const bridge = getNetcattyBridge();
|
||||
const tools = createCattyTools(bridge, {
|
||||
sessions: context.terminalSessions,
|
||||
workspaceId: context.scopeTargetId,
|
||||
workspaceName: context.scopeLabel,
|
||||
}, context.commandBlocklist, context.globalPermissionMode);
|
||||
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
scopeType: context.scopeType, scopeLabel: context.scopeLabel,
|
||||
hosts: context.terminalSessions.map(s => ({
|
||||
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
|
||||
os: s.os, username: s.username, connected: s.connected,
|
||||
})),
|
||||
permissionMode: context.globalPermissionMode,
|
||||
});
|
||||
|
||||
// Guard: activeProvider must exist for Catty agent path
|
||||
if (!context.activeProvider) {
|
||||
reportStreamError(sessionId, abortController.signal, 'No AI provider configured. Please configure a provider in Settings → AI.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create model with placeholder API key — the main process injects the real
|
||||
// decrypted key when the HTTP request is proxied through IPC, so plaintext
|
||||
// keys never transit the renderer ↔ main IPC boundary.
|
||||
let model;
|
||||
try {
|
||||
model = createModelFromConfig({
|
||||
...context.activeProvider,
|
||||
defaultModel: context.activeModelId || context.activeProvider.defaultModel || '',
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[Catty] Model creation failed:', e);
|
||||
reportStreamError(sessionId, abortController.signal, `Model creation failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Issue #5: Build SDK messages including tool-call and tool-result messages
|
||||
// so the LLM maintains full conversation context
|
||||
const sdkMessages: Array<ModelMessage> = [];
|
||||
for (const m of (currentSession?.messages ?? [])) {
|
||||
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
|
||||
const contentParts: Array<
|
||||
{ type: 'text'; text: string } |
|
||||
{ type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
|
||||
> = [];
|
||||
if (m.content) {
|
||||
contentParts.push({ type: 'text' as const, text: m.content });
|
||||
}
|
||||
for (const tc of m.toolCalls) {
|
||||
contentParts.push({
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name,
|
||||
input: tc.arguments ?? {},
|
||||
});
|
||||
}
|
||||
sdkMessages.push({ role: 'assistant', content: 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 => ({
|
||||
type: 'tool-result' as const,
|
||||
toolCallId: tr.toolCallId,
|
||||
toolName: findToolName(tr.toolCallId),
|
||||
output: { type: 'text' as const, value: tr.content },
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
sdkMessages.push({ role: 'user', content: trimmed });
|
||||
|
||||
const approvalInfo = await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
|
||||
|
||||
if (approvalInfo) {
|
||||
context.setPendingApproval({
|
||||
sessionId, scopeKey: sendScopeKey, sdkMessages, approvalInfo, model, systemPrompt, tools,
|
||||
});
|
||||
return; // Keep streaming flag — waiting for user approval
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Catty] streamText error:', err);
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
} finally {
|
||||
// Clear any lingering statusText when the stream finishes
|
||||
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sessionId, false);
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
context.autoTitleSession(sessionId, trimmed);
|
||||
}
|
||||
}, [
|
||||
processCattyStream, reportStreamError, setStreamingForScope,
|
||||
updateLastMessage,
|
||||
]);
|
||||
|
||||
return {
|
||||
streamingSessionIds,
|
||||
setStreamingForScope,
|
||||
abortControllersRef,
|
||||
processCattyStream,
|
||||
sendToCattyAgent,
|
||||
sendToExternalAgent,
|
||||
reportStreamError,
|
||||
};
|
||||
}
|
||||
76
components/ai/hooks/useConversationExport.ts
Normal file
76
components/ai/hooks/useConversationExport.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* useConversationExport — Encapsulates conversation export logic for the AI chat panel.
|
||||
*
|
||||
* Handles:
|
||||
* - Export in markdown, JSON, and plain text formats
|
||||
* - Object URL lifecycle management (creation, revocation, cleanup on unmount)
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import type { AISession } from '../../../infrastructure/ai/types';
|
||||
import { exportAsMarkdown, exportAsJSON, exportAsPlainText, getExportFilename } from '../../../infrastructure/ai/conversationExport';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook return type
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseConversationExportReturn {
|
||||
/** Trigger a download of the active session in the given format. */
|
||||
handleExport: (format: 'md' | 'json' | 'txt') => void;
|
||||
/** Ref to active object URLs for cleanup on unmount (exposed for the parent cleanup effect). */
|
||||
activeObjectUrlsRef: React.MutableRefObject<Set<string>>;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook implementation
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export function useConversationExport(
|
||||
activeSession: AISession | null,
|
||||
): UseConversationExportReturn {
|
||||
// Ref to track active object URLs for cleanup on unmount (Issue #19)
|
||||
const activeObjectUrlsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Clean up object URLs on unmount
|
||||
useEffect(() => {
|
||||
const urls = activeObjectUrlsRef.current;
|
||||
return () => {
|
||||
urls.forEach(url => URL.revokeObjectURL(url));
|
||||
urls.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleExport = useCallback((format: 'md' | 'json' | 'txt') => {
|
||||
if (!activeSession) return;
|
||||
let content: string;
|
||||
switch (format) {
|
||||
case 'md': content = exportAsMarkdown(activeSession); break;
|
||||
case 'json': content = exportAsJSON(activeSession); break;
|
||||
case 'txt': content = exportAsPlainText(activeSession); break;
|
||||
}
|
||||
const filename = getExportFilename(activeSession, format);
|
||||
// Create a download blob
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
// Track URL for cleanup on unmount (Issue #19)
|
||||
activeObjectUrlsRef.current.add(url);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Revoke after a generous delay to ensure download completes, then remove from tracking set
|
||||
const revokeTimeout = setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
activeObjectUrlsRef.current.delete(url);
|
||||
}, 60_000); // 60 seconds to be safe for large files
|
||||
// If component unmounts before timeout, cleanup effect will revoke it
|
||||
void revokeTimeout; // suppress unused warning
|
||||
}, [activeSession]);
|
||||
|
||||
return {
|
||||
handleExport,
|
||||
activeObjectUrlsRef,
|
||||
};
|
||||
}
|
||||
280
components/ai/hooks/useToolApproval.ts
Normal file
280
components/ai/hooks/useToolApproval.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* useToolApproval — Encapsulates the tool approval workflow for the AI chat panel.
|
||||
*
|
||||
* Handles:
|
||||
* - Pending approval context management
|
||||
* - Approval timeout (auto-clear after 5 minutes)
|
||||
* - handleApprovalResponse (approve/reject from InlineApprovalCard)
|
||||
* - Resuming the Catty stream after approval
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
ChatMessage,
|
||||
} 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';
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook parameters
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseToolApprovalParams {
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
setStreamingForScope: (key: string, val: boolean) => void;
|
||||
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
|
||||
processCattyStream: (
|
||||
streamSessionId: string,
|
||||
model: ReturnType<typeof createModelFromConfig>,
|
||||
systemPrompt: string,
|
||||
tools: ReturnType<typeof createCattyTools>,
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
) => Promise<ApprovalInfo | null>;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook return type
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export interface UseToolApprovalReturn {
|
||||
/** Ref to the current pending approval context (null when none). */
|
||||
pendingApprovalContextRef: React.MutableRefObject<PendingApprovalContext | null>;
|
||||
/** Set or clear the pending approval context (manages timeout). */
|
||||
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
|
||||
/** Handle a user's approve/reject response from InlineApprovalCard. */
|
||||
handleApprovalResponse: (
|
||||
messageId: string,
|
||||
approved: boolean,
|
||||
approvalContext: ToolApprovalContext,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Context values needed by handleApprovalResponse that change frequently. */
|
||||
export interface ToolApprovalContext {
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeLabel?: string;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook implementation
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export function useToolApproval({
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
setStreamingForScope,
|
||||
abortControllersRef,
|
||||
processCattyStream,
|
||||
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);
|
||||
|
||||
/** 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;
|
||||
}
|
||||
pendingApprovalContextRef.current = ctx;
|
||||
if (ctx) {
|
||||
pendingApprovalTimeoutRef.current = setTimeout(() => {
|
||||
// Auto-clear after 5 minutes if user never responds
|
||||
if (pendingApprovalContextRef.current?.sessionId === ctx.sessionId) {
|
||||
pendingApprovalContextRef.current = null;
|
||||
setStreamingForScope(ctx.sessionId, false);
|
||||
abortControllersRef.current.get(ctx.sessionId)?.abort();
|
||||
abortControllersRef.current.delete(ctx.sessionId);
|
||||
// Notify the user that the approval timed out
|
||||
updateLastMessage(ctx.sessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
|
||||
}));
|
||||
addMessageToSession(ctx.sessionId, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: t('ai.chat.approvalTimeout'),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
pendingApprovalTimeoutRef.current = null;
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
}
|
||||
}, [setStreamingForScope, abortControllersRef, updateLastMessage, addMessageToSession, t]);
|
||||
|
||||
// Handle inline approval response (approve/reject from InlineApprovalCard)
|
||||
const handleApprovalResponse = useCallback(async (
|
||||
messageId: string,
|
||||
approved: boolean,
|
||||
approvalContext: ToolApprovalContext,
|
||||
) => {
|
||||
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;
|
||||
// Clear pending approval (and its timeout) via setPendingApproval
|
||||
setPendingApproval(null);
|
||||
|
||||
// Update the message's pendingApproval status using message ID
|
||||
updateMessageById(sid, messageId, msg => ({
|
||||
...msg,
|
||||
pendingApproval: msg.pendingApproval
|
||||
? { ...msg.pendingApproval, status: approved ? 'approved' as const : 'denied' as const }
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
if (!approved) {
|
||||
// User rejected — add denial text and stop
|
||||
updateMessageById(sid, messageId, msg => ({
|
||||
...msg,
|
||||
content: msg.content + (msg.content ? '\n\n' : '') + t('ai.chat.toolDenied'),
|
||||
statusText: '',
|
||||
executionStatus: 'completed',
|
||||
}));
|
||||
setStreamingForScope(sid, false);
|
||||
abortControllersRef.current.delete(sid);
|
||||
return;
|
||||
}
|
||||
|
||||
// User approved — construct SDK messages with approval response and resume
|
||||
const resumeMessages: Array<Record<string, unknown>> = [
|
||||
...sdkMessages,
|
||||
// The assistant message that contained the tool call + approval request
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: approvalInfo.toolCallId,
|
||||
toolName: approvalInfo.toolName,
|
||||
input: approvalInfo.toolArgs,
|
||||
},
|
||||
{
|
||||
type: 'tool-approval-request',
|
||||
approvalId: approvalInfo.approvalId,
|
||||
toolCallId: approvalInfo.toolCallId,
|
||||
},
|
||||
],
|
||||
},
|
||||
// The user's approval response
|
||||
{
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-approval-response',
|
||||
approvalId: approvalInfo.approvalId,
|
||||
approved: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Create a new assistant message placeholder for the continuation
|
||||
const newAssistantMsgId = generateId();
|
||||
addMessageToSession(sid, {
|
||||
id: newAssistantMsgId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllersRef.current.set(sid, abortController);
|
||||
|
||||
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)
|
||||
const bridge = getNetcattyBridge();
|
||||
const freshTools = createCattyTools(bridge, {
|
||||
sessions: approvalContext.terminalSessions,
|
||||
workspaceId: approvalContext.scopeTargetId,
|
||||
workspaceName: approvalContext.scopeLabel,
|
||||
}, approvalContext.commandBlocklist, approvalContext.globalPermissionMode);
|
||||
const freshSystemPrompt = buildSystemPrompt({
|
||||
scopeType: approvalContext.scopeType, scopeLabel: approvalContext.scopeLabel,
|
||||
hosts: approvalContext.terminalSessions.map(s => ({
|
||||
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
|
||||
os: s.os, username: s.username, connected: s.connected,
|
||||
})),
|
||||
permissionMode: approvalContext.globalPermissionMode,
|
||||
});
|
||||
const newApprovalInfo = await processCattyStream(sid, ctxModel, freshSystemPrompt, freshTools, resumeMessages as unknown as ModelMessage[], abortController.signal, newAssistantMsgId);
|
||||
|
||||
if (newApprovalInfo) {
|
||||
// Another approval needed — save context for the next round (with timeout)
|
||||
setPendingApproval({
|
||||
sessionId: sid,
|
||||
scopeKey: sk,
|
||||
sdkMessages: resumeMessages,
|
||||
approvalInfo: newApprovalInfo,
|
||||
model: ctxModel,
|
||||
systemPrompt: freshSystemPrompt,
|
||||
tools: freshTools,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Catty resume] streamText error:', err);
|
||||
if (!abortController.signal.aborted) {
|
||||
const errorStr = err instanceof Error ? err.message : String(err);
|
||||
updateMessageById(sid, newAssistantMsgId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
|
||||
}));
|
||||
addMessageToSession(sid, {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo: classifyError(errorStr),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!pendingApprovalContextRef.current || pendingApprovalContextRef.current.sessionId !== sid) {
|
||||
// Clear any lingering statusText when the resumed stream finishes
|
||||
updateLastMessage(sid, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sid, false);
|
||||
abortControllersRef.current.delete(sid);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
processCattyStream, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope, abortControllersRef, t, setPendingApproval,
|
||||
]);
|
||||
|
||||
return {
|
||||
pendingApprovalContextRef,
|
||||
setPendingApproval,
|
||||
handleApprovalResponse,
|
||||
};
|
||||
}
|
||||
@@ -34,13 +34,21 @@ export const Toggle: React.FC<ToggleProps> = ({ checked, onChange, disabled }) =
|
||||
|
||||
interface SelectProps {
|
||||
value: string;
|
||||
options: { value: string; label: string }[];
|
||||
options: { value: string; label: string; icon?: React.ReactNode }[];
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const Select: React.FC<SelectProps> = ({ value, options, onChange, className, disabled }) => {
|
||||
export const Select: React.FC<SelectProps> = ({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
className,
|
||||
disabled,
|
||||
placeholder,
|
||||
}) => {
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
return (
|
||||
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
|
||||
@@ -50,7 +58,12 @@ export const Select: React.FC<SelectProps> = ({ value, options, onChange, classN
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SelectPrimitive.Value>{selectedOption?.label ?? value}</SelectPrimitive.Value>
|
||||
<SelectPrimitive.Value placeholder={placeholder}>
|
||||
<span className="flex items-center gap-2">
|
||||
{selectedOption?.icon}
|
||||
{selectedOption?.label}
|
||||
</span>
|
||||
</SelectPrimitive.Value>
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
@@ -76,7 +89,12 @@ export const Select: React.FC<SelectProps> = ({ value, options, onChange, classN
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{opt.label}</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemText>
|
||||
<span className="flex items-center gap-2">
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</span>
|
||||
</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))}
|
||||
</SelectPrimitive.Viewport>
|
||||
@@ -120,4 +138,3 @@ export const SettingsTabContent: React.FC<{
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
);
|
||||
|
||||
|
||||
528
components/settings/tabs/SettingsAITab.tsx
Normal file
528
components/settings/tabs/SettingsAITab.tsx
Normal file
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* Settings AI Tab - AI provider configuration, agent CLI detection, and safety settings
|
||||
*
|
||||
* Sub-components live in ./ai/ directory:
|
||||
* - ProviderCard, ProviderConfigForm, AddProviderDropdown
|
||||
* - ModelSelector, ProviderIconBadge
|
||||
* - CodexConnectionCard, ClaudeCodeCard
|
||||
* - SafetySettings
|
||||
*/
|
||||
import { Bot, Globe } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AIProviderId,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
} from "../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
|
||||
import { useAgentDiscovery } from "../../../application/state/useAgentDiscovery";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Select, SettingRow } from "../settings-ui";
|
||||
import { AgentIconBadge } from "../../ai/AgentIconBadge";
|
||||
|
||||
import type {
|
||||
AgentPathInfo,
|
||||
CodexIntegrationStatus,
|
||||
CodexLoginSession,
|
||||
} from "./ai/types";
|
||||
import {
|
||||
AGENT_DEFAULTS,
|
||||
getBridge,
|
||||
normalizeCodexBridgeError,
|
||||
} from "./ai/types";
|
||||
import { ProviderIconBadge } from "./ai/ProviderIconBadge";
|
||||
import { ProviderCard } from "./ai/ProviderCard";
|
||||
import { AddProviderDropdown } from "./ai/AddProviderDropdown";
|
||||
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
|
||||
import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
|
||||
import { SafetySettings } from "./ai/SafetySettings";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SettingsAITabProps {
|
||||
providers: ProviderConfig[];
|
||||
addProvider: (provider: ProviderConfig) => void;
|
||||
updateProvider: (id: string, updates: Partial<ProviderConfig>) => void;
|
||||
removeProvider: (id: string) => void;
|
||||
activeProviderId: string;
|
||||
setActiveProviderId: (id: string) => void;
|
||||
activeModelId: string;
|
||||
setActiveModelId: (id: string) => void;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
setGlobalPermissionMode: (mode: AIPermissionMode) => void;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
setExternalAgents: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
||||
defaultAgentId: string;
|
||||
setDefaultAgentId: (id: string) => void;
|
||||
commandBlocklist: string[];
|
||||
setCommandBlocklist: (value: string[]) => void;
|
||||
commandTimeout: number;
|
||||
setCommandTimeout: (value: number) => void;
|
||||
maxIterations: number;
|
||||
setMaxIterations: (value: number) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Tab Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
providers,
|
||||
addProvider,
|
||||
updateProvider,
|
||||
removeProvider,
|
||||
activeProviderId,
|
||||
setActiveProviderId,
|
||||
activeModelId: _activeModelId,
|
||||
setActiveModelId,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
defaultAgentId,
|
||||
setDefaultAgentId,
|
||||
commandBlocklist,
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
|
||||
const [codexIntegration, setCodexIntegration] = useState<CodexIntegrationStatus | null>(null);
|
||||
const [codexLoginSession, setCodexLoginSession] = useState<CodexLoginSession | null>(null);
|
||||
const [isCodexLoading, setIsCodexLoading] = useState(false);
|
||||
const [codexError, setCodexError] = useState<string | null>(null);
|
||||
|
||||
// Path detection state
|
||||
const [codexPathInfo, setCodexPathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [codexCustomPath, setCodexCustomPath] = useState("");
|
||||
const [isResolvingCodex, setIsResolvingCodex] = useState(false);
|
||||
|
||||
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [claudeCustomPath, setClaudeCustomPath] = useState("");
|
||||
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
|
||||
|
||||
const {
|
||||
discoveredAgents,
|
||||
isDiscovering,
|
||||
enableAgent,
|
||||
} = useAgentDiscovery(externalAgents, setExternalAgents);
|
||||
|
||||
// Derive path info from discovery results
|
||||
useEffect(() => {
|
||||
if (isDiscovering) return;
|
||||
|
||||
const codex = discoveredAgents.find((a) => a.command === "codex");
|
||||
setCodexPathInfo(
|
||||
codex
|
||||
? { path: codex.path, version: codex.version, available: true }
|
||||
: { path: null, version: null, available: false },
|
||||
);
|
||||
|
||||
const claude = discoveredAgents.find((a) => a.command === "claude");
|
||||
setClaudePathInfo(
|
||||
claude
|
||||
? { path: claude.path, version: claude.version, available: true }
|
||||
: { path: null, version: null, available: false },
|
||||
);
|
||||
}, [isDiscovering, discoveredAgents]);
|
||||
|
||||
// Auto-register discovered agents in externalAgents
|
||||
useEffect(() => {
|
||||
if (isDiscovering || discoveredAgents.length === 0) return;
|
||||
|
||||
setExternalAgents((prev) => {
|
||||
const agentsToRegister: ExternalAgentConfig[] = [];
|
||||
|
||||
for (const da of discoveredAgents) {
|
||||
if (da.command !== "codex" && da.command !== "claude") continue;
|
||||
const agentId = `discovered_${da.command}`;
|
||||
if (prev.some((ea) => ea.id === agentId)) continue;
|
||||
agentsToRegister.push(enableAgent(da));
|
||||
}
|
||||
|
||||
return agentsToRegister.length > 0 ? [...prev, ...agentsToRegister] : prev;
|
||||
});
|
||||
}, [isDiscovering, discoveredAgents, enableAgent, setExternalAgents]);
|
||||
|
||||
// Validate a custom path for an agent
|
||||
const handleCheckCustomPath = useCallback(async (agentKey: "codex" | "claude") => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiResolveCli) return;
|
||||
|
||||
const customPath = agentKey === "codex" ? codexCustomPath : claudeCustomPath;
|
||||
const setInfo = agentKey === "codex" ? setCodexPathInfo : setClaudePathInfo;
|
||||
const setResolving = agentKey === "codex" ? setIsResolvingCodex : setIsResolvingClaude;
|
||||
|
||||
setResolving(true);
|
||||
try {
|
||||
const result = await bridge.aiResolveCli({
|
||||
command: agentKey,
|
||||
customPath: customPath.trim(),
|
||||
});
|
||||
setInfo(result);
|
||||
|
||||
// Register/update in externalAgents if valid
|
||||
if (result.available && result.path) {
|
||||
const agentId = `discovered_${agentKey}`;
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
setExternalAgents((prev) => {
|
||||
const idx = prev.findIndex((a) => a.id === agentId);
|
||||
const config: ExternalAgentConfig = {
|
||||
id: agentId,
|
||||
command: result.path!,
|
||||
enabled: true,
|
||||
...defaults,
|
||||
};
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], command: result.path! };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, config];
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Path resolution failed:", err);
|
||||
} finally {
|
||||
setResolving(false);
|
||||
}
|
||||
}, [codexCustomPath, claudeCustomPath, setExternalAgents]);
|
||||
|
||||
// Add a new provider from preset
|
||||
const handleAddProvider = useCallback(
|
||||
(providerId: AIProviderId) => {
|
||||
const preset = PROVIDER_PRESETS[providerId];
|
||||
const id = `provider_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
addProvider({
|
||||
id,
|
||||
providerId,
|
||||
name: preset.name,
|
||||
baseURL: preset.defaultBaseURL,
|
||||
enabled: false,
|
||||
});
|
||||
// Auto-open config form
|
||||
setEditingProviderId(id);
|
||||
},
|
||||
[addProvider],
|
||||
);
|
||||
|
||||
// Remove provider with confirmation
|
||||
const handleRemoveProvider = useCallback(
|
||||
(id: string) => {
|
||||
const provider = providers.find((p) => p.id === id);
|
||||
const name = provider?.name || id;
|
||||
const ok = window.confirm(
|
||||
t('confirm.removeProvider', { name }),
|
||||
);
|
||||
if (!ok) return;
|
||||
removeProvider(id);
|
||||
if (editingProviderId === id) {
|
||||
setEditingProviderId(null);
|
||||
}
|
||||
},
|
||||
[removeProvider, editingProviderId, providers, t],
|
||||
);
|
||||
|
||||
// Agent options for default agent
|
||||
const agentOptions = useMemo(() => [
|
||||
{ value: "catty", label: t('ai.defaultAgent.catty'), icon: <AgentIconBadge agent={{ id: "catty", type: "builtin" }} size="xs" variant="plain" /> },
|
||||
...externalAgents
|
||||
.filter((a) => a.enabled)
|
||||
.map((a) => ({ value: a.id, label: a.name, icon: <AgentIconBadge agent={a} size="xs" variant="plain" /> })),
|
||||
], [externalAgents, t]);
|
||||
|
||||
const hasOpenAiProviderKey = providers.some(
|
||||
(provider) => provider.providerId === "openai" && provider.enabled && !!provider.apiKey,
|
||||
);
|
||||
|
||||
const refreshCodexIntegration = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
|
||||
setIsCodexLoading(true);
|
||||
setCodexError(null);
|
||||
try {
|
||||
const integration = await bridge.aiCodexGetIntegration();
|
||||
setCodexIntegration(integration);
|
||||
} catch (err) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
} finally {
|
||||
setIsCodexLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCodexIntegration();
|
||||
}, [refreshCodexIntegration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!codexLoginSession || codexLoginSession.state !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexGetLoginSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const intervalId = window.setInterval(() => {
|
||||
void bridge.aiCodexGetLoginSession?.(codexLoginSession.sessionId).then((result) => {
|
||||
if (cancelled || !result?.ok || !result.session) return;
|
||||
|
||||
setCodexLoginSession(result.session);
|
||||
if (result.session.state !== "running") {
|
||||
if (result.session.state === "success") {
|
||||
void refreshCodexIntegration();
|
||||
}
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (!cancelled) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [codexLoginSession, refreshCodexIntegration]);
|
||||
|
||||
const handleStartCodexLogin = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexStartLogin) return;
|
||||
|
||||
setCodexError(null);
|
||||
setIsCodexLoading(true);
|
||||
try {
|
||||
const result = await bridge.aiCodexStartLogin();
|
||||
if (!result.ok || !result.session) {
|
||||
throw new Error(result.error || "Failed to start Codex login");
|
||||
}
|
||||
setCodexLoginSession(result.session);
|
||||
} catch (err) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
} finally {
|
||||
setIsCodexLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCancelCodexLogin = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexCancelLogin || !codexLoginSession) return;
|
||||
|
||||
setCodexError(null);
|
||||
try {
|
||||
const result = await bridge.aiCodexCancelLogin(codexLoginSession.sessionId);
|
||||
if (result.session) {
|
||||
setCodexLoginSession(result.session);
|
||||
}
|
||||
} catch (err) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
}
|
||||
}, [codexLoginSession]);
|
||||
|
||||
const handleOpenCodexLoginUrl = useCallback(() => {
|
||||
const bridge = getBridge();
|
||||
const url = codexLoginSession?.url;
|
||||
if (!bridge?.openExternal || !url) return;
|
||||
// Only allow https:// URLs to prevent opening arbitrary protocols
|
||||
if (!url.startsWith("https://")) return;
|
||||
void bridge.openExternal(url);
|
||||
}, [codexLoginSession]);
|
||||
|
||||
const handleCodexLogout = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexLogout) return;
|
||||
|
||||
setCodexError(null);
|
||||
setIsCodexLoading(true);
|
||||
try {
|
||||
const result = await bridge.aiCodexLogout();
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || "Failed to log out from Codex");
|
||||
}
|
||||
setCodexLoginSession(null);
|
||||
await refreshCodexIntegration();
|
||||
} catch (err) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
} finally {
|
||||
setIsCodexLoading(false);
|
||||
}
|
||||
}, [refreshCodexIntegration]);
|
||||
|
||||
return (
|
||||
<TabsContent
|
||||
value="ai"
|
||||
className="data-[state=inactive]:hidden h-full flex flex-col"
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-8 py-6">
|
||||
<div className="max-w-2xl space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t('ai.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('ai.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* -- Providers Section -- */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t('ai.providers')}</h3>
|
||||
</div>
|
||||
<AddProviderDropdown onAdd={handleAddProvider} />
|
||||
</div>
|
||||
|
||||
{providers.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-6 text-center">
|
||||
<Bot size={24} className="mx-auto text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('ai.providers.empty')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{providers.map((provider) => (
|
||||
<ProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
isActive={provider.id === activeProviderId}
|
||||
onToggleEnabled={(enabled) => {
|
||||
if (enabled) {
|
||||
// Activate this provider, deactivate all others
|
||||
setActiveProviderId(provider.id);
|
||||
if (provider.defaultModel) {
|
||||
setActiveModelId(provider.defaultModel);
|
||||
}
|
||||
for (const p of providers) {
|
||||
if (p.id === provider.id) {
|
||||
if (!p.enabled) updateProvider(p.id, { enabled: true });
|
||||
} else {
|
||||
if (p.enabled) updateProvider(p.id, { enabled: false });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Deactivate this provider
|
||||
if (activeProviderId === provider.id) {
|
||||
setActiveProviderId("");
|
||||
setActiveModelId("");
|
||||
}
|
||||
updateProvider(provider.id, { enabled: false });
|
||||
}
|
||||
}}
|
||||
onEdit={() =>
|
||||
setEditingProviderId(
|
||||
editingProviderId === provider.id ? null : provider.id,
|
||||
)
|
||||
}
|
||||
onRemove={() => handleRemoveProvider(provider.id)}
|
||||
onUpdate={(updates) => {
|
||||
updateProvider(provider.id, updates);
|
||||
// If this is the active provider and model changed, update activeModelId
|
||||
if (provider.id === activeProviderId && updates.defaultModel !== undefined) {
|
||||
setActiveModelId(updates.defaultModel || "");
|
||||
}
|
||||
}}
|
||||
isEditing={editingProviderId === provider.id}
|
||||
onCancelEdit={() => setEditingProviderId(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* -- Codex Section -- */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconBadge providerId="openai" size="sm" />
|
||||
<h3 className="text-base font-medium">{t('ai.codex')}</h3>
|
||||
</div>
|
||||
|
||||
<CodexConnectionCard
|
||||
pathInfo={codexPathInfo}
|
||||
isResolvingPath={isDiscovering || isResolvingCodex}
|
||||
customPath={codexCustomPath}
|
||||
onCustomPathChange={setCodexCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("codex")}
|
||||
integration={codexIntegration}
|
||||
loginSession={codexLoginSession}
|
||||
isLoading={isCodexLoading}
|
||||
hasOpenAiProviderKey={hasOpenAiProviderKey}
|
||||
error={codexError}
|
||||
onRefresh={() => void refreshCodexIntegration()}
|
||||
onConnect={() => void handleStartCodexLogin()}
|
||||
onCancel={() => void handleCancelCodexLogin()}
|
||||
onOpenUrl={handleOpenCodexLoginUrl}
|
||||
onLogout={() => void handleCodexLogout()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* -- Claude Code Section -- */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconBadge providerId="claude" size="sm" />
|
||||
<h3 className="text-base font-medium">{t('ai.claude.title')}</h3>
|
||||
</div>
|
||||
|
||||
<ClaudeCodeCard
|
||||
pathInfo={claudePathInfo}
|
||||
isResolvingPath={isDiscovering || isResolvingClaude}
|
||||
customPath={claudeCustomPath}
|
||||
onCustomPathChange={setClaudeCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("claude")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* -- Default Agent Section -- */}
|
||||
{agentOptions.length > 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t('ai.defaultAgent')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4">
|
||||
<SettingRow
|
||||
label={t('ai.defaultAgent')}
|
||||
description={t('ai.defaultAgent.description')}
|
||||
>
|
||||
<Select
|
||||
value={defaultAgentId}
|
||||
options={agentOptions}
|
||||
onChange={setDefaultAgentId}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* -- Safety Section -- */}
|
||||
<SafetySettings
|
||||
globalPermissionMode={globalPermissionMode}
|
||||
setGlobalPermissionMode={setGlobalPermissionMode}
|
||||
commandBlocklist={commandBlocklist}
|
||||
setCommandBlocklist={setCommandBlocklist}
|
||||
commandTimeout={commandTimeout}
|
||||
setCommandTimeout={setCommandTimeout}
|
||||
maxIterations={maxIterations}
|
||||
setMaxIterations={setMaxIterations}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsAITab;
|
||||
@@ -15,6 +15,7 @@ export default function SettingsSyncTab(props: {
|
||||
importDataFromString: (data: string) => void;
|
||||
importPortForwardingRules: (rules: PortForwardingRule[]) => void;
|
||||
clearVaultData: () => void;
|
||||
onSettingsApplied?: () => void;
|
||||
}) {
|
||||
const {
|
||||
vault,
|
||||
@@ -22,6 +23,7 @@ export default function SettingsSyncTab(props: {
|
||||
importDataFromString,
|
||||
importPortForwardingRules,
|
||||
clearVaultData,
|
||||
onSettingsApplied,
|
||||
} = props;
|
||||
|
||||
const onBuildPayload = useCallback((): SyncPayload => {
|
||||
@@ -56,9 +58,10 @@ export default function SettingsSyncTab(props: {
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied,
|
||||
});
|
||||
},
|
||||
[importDataFromString, importPortForwardingRules],
|
||||
[importDataFromString, importPortForwardingRules, onSettingsApplied],
|
||||
);
|
||||
|
||||
const clearAllLocalData = useCallback(() => {
|
||||
|
||||
@@ -55,6 +55,10 @@ interface SettingsSystemTabProps {
|
||||
closeToTray: boolean;
|
||||
setCloseToTray: (enabled: boolean) => void;
|
||||
hotkeyRegistrationError: string | null;
|
||||
globalHotkeyEnabled: boolean;
|
||||
setGlobalHotkeyEnabled: (enabled: boolean) => void;
|
||||
autoUpdateEnabled: boolean;
|
||||
setAutoUpdateEnabled: (enabled: boolean) => void;
|
||||
// Unified update state — from useUpdateCheck hook in SettingsPageContent
|
||||
updateState: UpdateState;
|
||||
checkNow: () => Promise<unknown>;
|
||||
@@ -74,6 +78,10 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
hotkeyRegistrationError,
|
||||
globalHotkeyEnabled,
|
||||
setGlobalHotkeyEnabled,
|
||||
autoUpdateEnabled,
|
||||
setAutoUpdateEnabled,
|
||||
updateState,
|
||||
checkNow,
|
||||
installUpdate,
|
||||
@@ -367,6 +375,15 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t('settings.update.autoUpdateEnabled')}
|
||||
description={t('settings.update.autoUpdateEnabledDesc')}
|
||||
>
|
||||
<Toggle
|
||||
checked={autoUpdateEnabled}
|
||||
onChange={setAutoUpdateEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{updateState.lastCheckedAt && (
|
||||
<span>
|
||||
@@ -599,42 +616,55 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
|
||||
{/* Toggle Window Hotkey */}
|
||||
{/* Enable/Disable Global Hotkey */}
|
||||
<SettingRow
|
||||
label={t("settings.globalHotkey.toggleWindow")}
|
||||
description={t("settings.globalHotkey.toggleWindowDesc")}
|
||||
label={t('settings.globalHotkey.enabled')}
|
||||
description={t('settings.globalHotkey.enabledDesc')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRecordingHotkey(true);
|
||||
}}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
|
||||
isRecordingHotkey
|
||||
? "border-primary bg-primary/10 animate-pulse"
|
||||
: "border-border hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
{isRecordingHotkey
|
||||
? t("settings.shortcuts.recording")
|
||||
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
|
||||
</button>
|
||||
{toggleWindowHotkey && (
|
||||
<button
|
||||
onClick={handleResetHotkey}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={t("settings.globalHotkey.reset")}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Toggle
|
||||
checked={globalHotkeyEnabled}
|
||||
onChange={setGlobalHotkeyEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
{(hotkeyError || hotkeyRegistrationError) && (
|
||||
<p className="text-sm text-destructive">{hotkeyError || hotkeyRegistrationError}</p>
|
||||
)}
|
||||
|
||||
<div className={cn(!globalHotkeyEnabled && "opacity-50 pointer-events-none")}>
|
||||
{/* Toggle Window Hotkey */}
|
||||
<SettingRow
|
||||
label={t("settings.globalHotkey.toggleWindow")}
|
||||
description={t("settings.globalHotkey.toggleWindowDesc")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRecordingHotkey(true);
|
||||
}}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
|
||||
isRecordingHotkey
|
||||
? "border-primary bg-primary/10 animate-pulse"
|
||||
: "border-border hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
{isRecordingHotkey
|
||||
? t("settings.shortcuts.recording")
|
||||
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
|
||||
</button>
|
||||
{toggleWindowHotkey && (
|
||||
<button
|
||||
onClick={handleResetHotkey}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={t("settings.globalHotkey.reset")}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
{(hotkeyError || hotkeyRegistrationError) && (
|
||||
<p className="text-sm text-destructive mt-2">{hotkeyError || hotkeyRegistrationError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close to Tray */}
|
||||
<SettingRow
|
||||
|
||||
@@ -575,6 +575,23 @@ export default function SettingsTerminalTab(props: {
|
||||
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.osc52Clipboard")}
|
||||
description={t("settings.terminal.behavior.osc52Clipboard.desc")}
|
||||
>
|
||||
<Select
|
||||
value={terminalSettings.osc52Clipboard ?? 'write-only'}
|
||||
options={[
|
||||
{ value: "off", label: t("settings.terminal.behavior.osc52Clipboard.off") },
|
||||
{ value: "write-only", label: t("settings.terminal.behavior.osc52Clipboard.writeOnly") },
|
||||
{ value: "read-write", label: t("settings.terminal.behavior.osc52Clipboard.readWrite") },
|
||||
{ value: "prompt", label: t("settings.terminal.behavior.osc52Clipboard.prompt") },
|
||||
]}
|
||||
onChange={(v) => updateTerminalSetting("osc52Clipboard", v as "off" | "write-only" | "read-write" | "prompt")}
|
||||
className="w-40"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.scrollOnInput")}
|
||||
description={t("settings.terminal.behavior.scrollOnInput.desc")}
|
||||
|
||||
55
components/settings/tabs/ai/AddProviderDropdown.tsx
Normal file
55
components/settings/tabs/ai/AddProviderDropdown.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useState } from "react";
|
||||
import { ChevronDown, Plus } from "lucide-react";
|
||||
import type { AIProviderId } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
|
||||
export const AddProviderDropdown: React.FC<{
|
||||
onAdd: (providerId: AIProviderId) => void;
|
||||
}> = ({ onAdd }) => {
|
||||
const { t } = useI18n();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const providerIds = Object.keys(PROVIDER_PRESETS) as AIProviderId[];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('ai.providers.add')}
|
||||
<ChevronDown size={12} className={cn("transition-transform", isOpen && "rotate-180")} />
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-[100]" onClick={() => setIsOpen(false)} />
|
||||
{/* Menu */}
|
||||
<div className="absolute top-full left-0 mt-1 z-[101] min-w-[200px] rounded-md border border-border bg-popover shadow-md py-1">
|
||||
{providerIds.map((pid) => (
|
||||
<button
|
||||
key={pid}
|
||||
onClick={() => {
|
||||
onAdd(pid);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<ProviderIconBadge providerId={pid} size="sm" />
|
||||
{PROVIDER_PRESETS[pid].name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
88
components/settings/tabs/ai/ClaudeCodeCard.tsx
Normal file
88
components/settings/tabs/ai/ClaudeCodeCard.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
|
||||
export const ClaudeCodeCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
isResolvingPath: boolean;
|
||||
customPath: string;
|
||||
onCustomPathChange: (path: string) => void;
|
||||
onRecheckPath: () => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
customPath,
|
||||
onCustomPathChange,
|
||||
onRecheckPath,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
|
||||
const statusText = isResolvingPath
|
||||
? t('ai.claude.detecting')
|
||||
: found
|
||||
? t('ai.claude.detected')
|
||||
: t('ai.claude.notFound');
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
: found
|
||||
? "text-emerald-500"
|
||||
: "text-amber-500";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconBadge providerId="claude" size="sm" />
|
||||
<span className="text-sm font-medium">{t('ai.claude.title')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 leading-5">
|
||||
{t('ai.claude.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path detection info */}
|
||||
{found ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.claude.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : !isResolvingPath ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.claude.notFoundHint')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.claude.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t('ai.claude.check')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
181
components/settings/tabs/ai/CodexConnectionCard.tsx
Normal file
181
components/settings/tabs/ai/CodexConnectionCard.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React from "react";
|
||||
import { ExternalLink, LogIn, LogOut, RefreshCw, X } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { AgentPathInfo, CodexIntegrationStatus, CodexLoginSession } from "./types";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
|
||||
export const CodexConnectionCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
isResolvingPath: boolean;
|
||||
customPath: string;
|
||||
onCustomPathChange: (path: string) => void;
|
||||
onRecheckPath: () => void;
|
||||
integration: CodexIntegrationStatus | null;
|
||||
loginSession: CodexLoginSession | null;
|
||||
isLoading: boolean;
|
||||
hasOpenAiProviderKey: boolean;
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
onConnect: () => void;
|
||||
onCancel: () => void;
|
||||
onOpenUrl: () => void;
|
||||
onLogout: () => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
customPath,
|
||||
onCustomPathChange,
|
||||
onRecheckPath,
|
||||
integration,
|
||||
loginSession,
|
||||
isLoading,
|
||||
hasOpenAiProviderKey,
|
||||
error,
|
||||
onRefresh,
|
||||
onConnect,
|
||||
onCancel,
|
||||
onOpenUrl,
|
||||
onLogout,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
|
||||
const status = isResolvingPath
|
||||
? t('ai.codex.detecting')
|
||||
: !found
|
||||
? t('ai.codex.notFound')
|
||||
: loginSession?.state === "running"
|
||||
? t('ai.codex.awaitingLogin')
|
||||
: integration?.state === "connected_chatgpt"
|
||||
? t('ai.codex.connectedChatGPT')
|
||||
: integration?.state === "connected_api_key"
|
||||
? t('ai.codex.connectedApiKey')
|
||||
: integration?.state === "not_logged_in"
|
||||
? t('ai.codex.notConnected')
|
||||
: t('ai.codex.statusUnknown');
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
: !found
|
||||
? "text-amber-500"
|
||||
: loginSession?.state === "running"
|
||||
? "text-amber-500"
|
||||
: integration?.isConnected
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground";
|
||||
|
||||
const outputText = loginSession?.error
|
||||
? loginSession.error
|
||||
: loginSession?.output?.trim()
|
||||
? loginSession.output.trim()
|
||||
: integration?.rawOutput?.trim()
|
||||
? integration.rawOutput.trim()
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconBadge providerId="openai" size="sm" />
|
||||
<span className="text-sm font-medium">{t('ai.codex.title')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 leading-5">
|
||||
{t('ai.codex.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
|
||||
{status}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path detection info */}
|
||||
{found ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.codex.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : !isResolvingPath ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.codex.notFoundHint')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.codex.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t('ai.codex.check')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Connection & login UI -- only when codex is detected */}
|
||||
{found && (
|
||||
<>
|
||||
<div className="border-t border-border/40 pt-3 flex items-center gap-2 flex-wrap">
|
||||
{loginSession?.state === "running" ? (
|
||||
<>
|
||||
<Button variant="default" size="sm" onClick={onOpenUrl} disabled={!loginSession.url}>
|
||||
<ExternalLink size={14} className="mr-1.5" />
|
||||
{t('ai.codex.openLogin')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||
<X size={14} className="mr-1.5" />
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : integration?.isConnected ? (
|
||||
<Button variant="outline" size="sm" onClick={onLogout}>
|
||||
<LogOut size={14} className="mr-1.5" />
|
||||
{t('ai.codex.logout')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" size="sm" onClick={onConnect}>
|
||||
<LogIn size={14} className="mr-1.5" />
|
||||
{t('ai.codex.connectChatGPT')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
|
||||
<RefreshCw size={14} className={cn("mr-1.5", isLoading && "animate-spin")} />
|
||||
{t('ai.codex.refreshStatus')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasOpenAiProviderKey && (
|
||||
<p className="text-xs text-emerald-500">
|
||||
{t('ai.codex.apiKeyHint')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{found && outputText && (
|
||||
<pre className="rounded-md border border-border/60 bg-background px-3 py-2 text-[11px] leading-5 text-muted-foreground whitespace-pre-wrap max-h-40 overflow-auto">
|
||||
{outputText}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
179
components/settings/tabs/ai/ModelSelector.tsx
Normal file
179
components/settings/tabs/ai/ModelSelector.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, ChevronDown, RefreshCw } from "lucide-react";
|
||||
import type { AIProviderId } from "../../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { FetchedModel } from "./types";
|
||||
import { getFetchBridge } from "./types";
|
||||
|
||||
export const ModelSelector: React.FC<{
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
baseURL: string;
|
||||
modelsEndpoint?: string;
|
||||
placeholder?: string;
|
||||
apiKey?: string;
|
||||
providerId?: AIProviderId;
|
||||
}> = ({ value, onChange, baseURL, modelsEndpoint, placeholder, apiKey, providerId }) => {
|
||||
const { t } = useI18n();
|
||||
const [models, setModels] = useState<FetchedModel[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasFetched, setHasFetched] = useState(false);
|
||||
|
||||
// Ollama runs locally without auth; all other providers need an API key to list models
|
||||
const needsApiKey = providerId !== "ollama";
|
||||
const canFetch = !!modelsEndpoint && (!needsApiKey || !!apiKey);
|
||||
|
||||
const fetchModels = useCallback(async () => {
|
||||
if (!modelsEndpoint) return;
|
||||
const bridge = getFetchBridge();
|
||||
if (!bridge?.aiFetch) return;
|
||||
|
||||
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) {
|
||||
if (providerId === "anthropic") {
|
||||
headers["x-api-key"] = apiKey;
|
||||
headers["anthropic-version"] = "2023-06-01";
|
||||
} else {
|
||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
}
|
||||
}
|
||||
const result = await bridge.aiFetch(url, "GET", headers);
|
||||
if (!result.ok) {
|
||||
setError(`Failed to fetch models (${result.error || "unknown error"})`);
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(result.data);
|
||||
const list: FetchedModel[] = (parsed.data || parsed.models || []).map((m: { id: string; name?: string }) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
}));
|
||||
list.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id));
|
||||
setModels(list);
|
||||
setHasFetched(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to parse response");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [baseURL, modelsEndpoint, apiKey, providerId]);
|
||||
|
||||
// Auto-fetch when dropdown first opens
|
||||
useEffect(() => {
|
||||
if (isOpen && canFetch && !hasFetched && !isLoading) {
|
||||
void fetchModels();
|
||||
}
|
||||
}, [isOpen, canFetch, hasFetched, isLoading, fetchModels]);
|
||||
|
||||
// Filter models by current input value (inline autocomplete)
|
||||
const suggestions = useMemo(() => {
|
||||
if (!hasFetched || models.length === 0) return [];
|
||||
if (!value.trim()) return models;
|
||||
const q = value.toLowerCase();
|
||||
return models.filter((m) =>
|
||||
m.id.toLowerCase().includes(q) || (m.name && m.name.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [models, value, hasFetched]);
|
||||
|
||||
const showSuggestions = isOpen && canFetch;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
if (canFetch && hasFetched && !isOpen) setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => { if (canFetch) setIsOpen(true); }}
|
||||
onBlur={() => { setIsOpen(false); }}
|
||||
placeholder={placeholder ?? (canFetch ? t('ai.providers.searchModel') : t('ai.providers.defaultModel.placeholder'))}
|
||||
className={cn(
|
||||
"w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
canFetch && "pr-8",
|
||||
)}
|
||||
/>
|
||||
{canFetch && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); setIsOpen(!isOpen); }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronDown size={14} className={cn("transition-transform", isOpen && "rotate-180")} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canFetch && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setHasFetched(false); void fetchModels(); }}
|
||||
disabled={isLoading}
|
||||
className="shrink-0 px-2"
|
||||
title={t('ai.providers.refreshModels')}
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Suggestions dropdown */}
|
||||
{showSuggestions && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-[101] rounded-md border border-border bg-popover shadow-md">
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="px-3 py-3 text-center text-xs text-muted-foreground">
|
||||
<RefreshCw size={14} className="animate-spin inline mr-1.5" />
|
||||
{t('ai.providers.loadingModels')}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="px-3 py-3 text-center text-xs text-destructive">{error}</div>
|
||||
) : suggestions.length === 0 ? (
|
||||
<div className="px-3 py-3 text-center text-xs text-muted-foreground">
|
||||
{hasFetched ? t('ai.providers.noMatchingModels') : t('ai.providers.clickToLoadModels')}
|
||||
</div>
|
||||
) : (
|
||||
suggestions.slice(0, 100).map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(m.id);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors flex items-center justify-between gap-2",
|
||||
m.id === value && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<span className="font-mono truncate">{m.id}</span>
|
||||
{m.id === value && <Check size={12} className="text-primary shrink-0" />}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
{suggestions.length > 100 && (
|
||||
<div className="px-3 py-2 text-center text-[10px] text-muted-foreground border-t border-border/40">
|
||||
{t('ai.providers.showingModels').replace('{count}', String(suggestions.length))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
95
components/settings/tabs/ai/ProviderCard.tsx
Normal file
95
components/settings/tabs/ai/ProviderCard.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from "react";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Toggle } from "../../settings-ui";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
import { ProviderConfigForm } from "./ProviderConfigForm";
|
||||
|
||||
export const ProviderCard: React.FC<{
|
||||
provider: ProviderConfig;
|
||||
isActive: boolean;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
onUpdate: (updates: Partial<ProviderConfig>) => void;
|
||||
isEditing: boolean;
|
||||
onCancelEdit: () => void;
|
||||
}> = ({ provider, isActive, onToggleEnabled, onEdit, onRemove, onUpdate, isEditing, onCancelEdit }) => {
|
||||
const { t } = useI18n();
|
||||
const hasApiKey = !!provider.apiKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border p-4 transition-colors",
|
||||
isActive ? "border-primary/50 bg-primary/5" : "border-border/60 bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Provider icon */}
|
||||
<ProviderIconBadge providerId={provider.providerId} />
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{provider.name}</span>
|
||||
{isActive && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/20 text-primary font-medium">
|
||||
{t('ai.providers.active')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
hasApiKey ? "text-emerald-500" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{hasApiKey ? t('ai.providers.apiKeyConfigured') : t('ai.providers.noApiKey')}
|
||||
</span>
|
||||
{provider.defaultModel && (
|
||||
<>
|
||||
<span className="text-muted-foreground text-xs">|</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{provider.defaultModel}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title={t('ai.providers.configure')}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
title={t('ai.providers.remove')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<Toggle checked={provider.enabled} onChange={onToggleEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable config form */}
|
||||
{isEditing && (
|
||||
<ProviderConfigForm
|
||||
provider={provider}
|
||||
onSave={(updates) => {
|
||||
onUpdate(updates);
|
||||
onCancelEdit();
|
||||
}}
|
||||
onCancel={onCancelEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
138
components/settings/tabs/ai/ProviderConfigForm.tsx
Normal file
138
components/settings/tabs/ai/ProviderConfigForm.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Check, Eye, EyeOff } from "lucide-react";
|
||||
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
|
||||
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import type { ProviderFormState } from "./types";
|
||||
import { ModelSelector } from "./ModelSelector";
|
||||
|
||||
export const ProviderConfigForm: React.FC<{
|
||||
provider: ProviderConfig;
|
||||
onSave: (updates: Partial<ProviderConfig>) => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ provider, onSave, onCancel }) => {
|
||||
const { t } = useI18n();
|
||||
const [form, setForm] = useState<ProviderFormState>({
|
||||
name: provider.name ?? PROVIDER_PRESETS[provider.providerId]?.name ?? "",
|
||||
apiKey: "",
|
||||
baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "",
|
||||
defaultModel: provider.defaultModel ?? "",
|
||||
});
|
||||
const isCustom = provider.providerId === "custom";
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
|
||||
const preset = PROVIDER_PRESETS[provider.providerId];
|
||||
|
||||
// Decrypt and load existing API key on mount
|
||||
useEffect(() => {
|
||||
if (provider.apiKey) {
|
||||
setIsDecrypting(true);
|
||||
decryptField(provider.apiKey)
|
||||
.then((decrypted) => {
|
||||
setForm((prev) => ({ ...prev, apiKey: decrypted ?? "" }));
|
||||
})
|
||||
.catch(() => {
|
||||
// If decryption fails, show raw value
|
||||
setForm((prev) => ({ ...prev, apiKey: provider.apiKey ?? "" }));
|
||||
})
|
||||
.finally(() => setIsDecrypting(false));
|
||||
}
|
||||
}, [provider.apiKey]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const updates: Partial<ProviderConfig> = {
|
||||
baseURL: form.baseURL || undefined,
|
||||
defaultModel: form.defaultModel || undefined,
|
||||
...(isCustom && form.name.trim() ? { name: form.name.trim() } : {}),
|
||||
};
|
||||
|
||||
// Encrypt API key before saving
|
||||
if (form.apiKey) {
|
||||
updates.apiKey = await encryptField(form.apiKey);
|
||||
} else {
|
||||
updates.apiKey = undefined;
|
||||
}
|
||||
|
||||
onSave(updates);
|
||||
}, [form, onSave, isCustom]);
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3 border-t border-border/40 pt-3">
|
||||
{/* Name (custom providers only) */}
|
||||
{isCustom && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder={t('ai.providers.name.placeholder')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* API Key */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.apiKey')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={isDecrypting ? "" : form.apiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
placeholder={isDecrypting ? t('ai.providers.apiKey.decrypting') : t('ai.providers.apiKey.placeholder')}
|
||||
disabled={isDecrypting}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 pr-9 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.baseUrl')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.baseURL}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseURL: e.target.value }))}
|
||||
placeholder={preset?.defaultBaseURL || "https://"}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default Model */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.defaultModel')}</label>
|
||||
<ModelSelector
|
||||
value={form.defaultModel}
|
||||
onChange={(val) => setForm((prev) => ({ ...prev, defaultModel: val }))}
|
||||
baseURL={form.baseURL || preset?.defaultBaseURL || ""}
|
||||
modelsEndpoint={preset?.modelsEndpoint}
|
||||
apiKey={form.apiKey}
|
||||
providerId={provider.providerId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button variant="default" size="sm" onClick={() => void handleSave()}>
|
||||
<Check size={14} className="mr-1.5" />
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
28
components/settings/tabs/ai/ProviderIconBadge.tsx
Normal file
28
components/settings/tabs/ai/ProviderIconBadge.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { SettingsIconId } from "./types";
|
||||
import { SETTINGS_ICON_PATHS, SETTINGS_ICON_COLORS } from "./types";
|
||||
|
||||
export const ProviderIconBadge: React.FC<{
|
||||
providerId: SettingsIconId;
|
||||
size?: "sm" | "md";
|
||||
}> = ({ providerId, size = "md" }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md flex items-center justify-center shrink-0 overflow-hidden",
|
||||
size === "sm" ? "w-5 h-5" : "w-8 h-8",
|
||||
SETTINGS_ICON_COLORS[providerId],
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={SETTINGS_ICON_PATHS[providerId]}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn(
|
||||
"object-contain brightness-0 invert",
|
||||
size === "sm" ? "w-3 h-3" : "w-4 h-4",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
204
components/settings/tabs/ai/SafetySettings.tsx
Normal file
204
components/settings/tabs/ai/SafetySettings.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Plus, Shield, X } from "lucide-react";
|
||||
import type { AIPermissionMode } from "../../../../infrastructure/ai/types";
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from "../../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Select, SettingRow } from "../../settings-ui";
|
||||
|
||||
export const SafetySettings: React.FC<{
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
setGlobalPermissionMode: (mode: AIPermissionMode) => void;
|
||||
commandBlocklist: string[];
|
||||
setCommandBlocklist: (value: string[]) => void;
|
||||
commandTimeout: number;
|
||||
setCommandTimeout: (value: number) => void;
|
||||
maxIterations: number;
|
||||
setMaxIterations: (value: number) => void;
|
||||
}> = ({
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
commandBlocklist,
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [regexErrors, setRegexErrors] = useState<Record<number, string>>({});
|
||||
|
||||
const validatePattern = useCallback((pattern: string, idx: number): boolean => {
|
||||
if (!pattern) {
|
||||
setRegexErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[idx];
|
||||
return next;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
new RegExp(pattern);
|
||||
setRegexErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[idx];
|
||||
return next;
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
setRegexErrors((prev) => ({
|
||||
...prev,
|
||||
[idx]: e instanceof Error ? e.message : String(e),
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePatternChange = useCallback((value: string, idx: number) => {
|
||||
const next = [...commandBlocklist];
|
||||
next[idx] = value;
|
||||
validatePattern(value, idx);
|
||||
setCommandBlocklist(next);
|
||||
}, [commandBlocklist, setCommandBlocklist, validatePattern]);
|
||||
|
||||
const permissionModeOptions = [
|
||||
{ value: "observer", label: t('ai.safety.permissionMode.observer') },
|
||||
{ value: "confirm", label: t('ai.safety.permissionMode.confirm') },
|
||||
{ value: "autonomous", label: t('ai.safety.permissionMode.autonomous') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t('ai.safety.title')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-1">
|
||||
<SettingRow
|
||||
label={t('ai.safety.permissionMode')}
|
||||
description={t('ai.safety.permissionMode.description')}
|
||||
>
|
||||
<Select
|
||||
value={globalPermissionMode}
|
||||
options={permissionModeOptions}
|
||||
onChange={(val) => setGlobalPermissionMode(val as AIPermissionMode)}
|
||||
className="w-64"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t('ai.safety.commandTimeout')}
|
||||
description={t('ai.safety.commandTimeout.description')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={commandTimeout}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val) && val > 0) setCommandTimeout(val);
|
||||
}}
|
||||
min={1}
|
||||
max={3600}
|
||||
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"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('ai.safety.commandTimeout.unit')}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t('ai.safety.maxIterations')}
|
||||
description={t('ai.safety.maxIterations.description')}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={maxIterations}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val) && val > 0) setMaxIterations(val);
|
||||
}}
|
||||
min={1}
|
||||
max={100}
|
||||
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>
|
||||
|
||||
{/* Command Blocklist */}
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t('ai.safety.blocklist')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('ai.safety.blocklist.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => { setCommandBlocklist([...DEFAULT_COMMAND_BLOCKLIST]); setRegexErrors({}); }}
|
||||
>
|
||||
{t('ai.safety.blocklist.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{commandBlocklist.map((pattern, idx) => (
|
||||
<div key={idx} className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={pattern}
|
||||
onChange={(e) => handlePatternChange(e.target.value, idx)}
|
||||
className={`flex-1 h-8 rounded-md border bg-background px-3 text-xs font-mono focus-visible:outline-none focus-visible:ring-1 ${
|
||||
regexErrors[idx]
|
||||
? 'border-destructive focus-visible:ring-destructive'
|
||||
: 'border-input focus-visible:ring-ring'
|
||||
}`}
|
||||
placeholder={t('ai.safety.blocklist.placeholder')}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = commandBlocklist.filter((_, i) => i !== idx);
|
||||
setCommandBlocklist(next);
|
||||
setRegexErrors((prev) => {
|
||||
const updated: Record<number, string> = {};
|
||||
for (const [k, v] of Object.entries(prev)) {
|
||||
const ki = Number(k);
|
||||
if (ki < idx) updated[ki] = v as string;
|
||||
else if (ki > idx) updated[ki - 1] = v as string;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{regexErrors[idx] && (
|
||||
<p className="text-[11px] text-destructive pl-1">{regexErrors[idx]}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => setCommandBlocklist([...commandBlocklist, ''])}
|
||||
>
|
||||
<Plus size={14} className="mr-1" />
|
||||
{t('ai.safety.blocklist.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('ai.safety.note')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
8
components/settings/tabs/ai/index.ts
Normal file
8
components/settings/tabs/ai/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
export { ModelSelector } from "./ModelSelector";
|
||||
export { ProviderConfigForm } from "./ProviderConfigForm";
|
||||
export { ProviderCard } from "./ProviderCard";
|
||||
export { AddProviderDropdown } from "./AddProviderDropdown";
|
||||
export { CodexConnectionCard } from "./CodexConnectionCard";
|
||||
export { ClaudeCodeCard } from "./ClaudeCodeCard";
|
||||
export { SafetySettings } from "./SafetySettings";
|
||||
128
components/settings/tabs/ai/types.ts
Normal file
128
components/settings/tabs/ai/types.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Shared types for AI settings sub-components
|
||||
*/
|
||||
import type {
|
||||
AIProviderId,
|
||||
ExternalAgentConfig,
|
||||
} from "../../../../infrastructure/ai/types";
|
||||
|
||||
export type CodexIntegrationState =
|
||||
| "connected_chatgpt"
|
||||
| "connected_api_key"
|
||||
| "not_logged_in"
|
||||
| "unknown";
|
||||
|
||||
export interface CodexIntegrationStatus {
|
||||
state: CodexIntegrationState;
|
||||
isConnected: boolean;
|
||||
rawOutput: string;
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
export type CodexLoginState = "running" | "success" | "error" | "cancelled";
|
||||
|
||||
export interface CodexLoginSession {
|
||||
sessionId: string;
|
||||
state: CodexLoginState;
|
||||
url: string | null;
|
||||
output: string;
|
||||
error: string | null;
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
export interface AgentPathInfo {
|
||||
path: string | null;
|
||||
version: string | null;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderFormState {
|
||||
name: string;
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
defaultModel: string;
|
||||
}
|
||||
|
||||
export interface FetchedModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface FetchBridge {
|
||||
aiFetch?: (url: string, method?: string, headers?: Record<string, string>, body?: string) => Promise<{ ok: boolean; data: string; error?: string }>;
|
||||
aiAllowlistAddHost?: (baseURL: string) => Promise<{ ok: boolean }>;
|
||||
}
|
||||
|
||||
export interface NetcattyAiBridge {
|
||||
aiCodexGetIntegration?: () => Promise<CodexIntegrationStatus>;
|
||||
aiCodexStartLogin?: () => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexLogout?: () => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
|
||||
aiResolveCli?: (params: { command: string; customPath?: string }) => Promise<AgentPathInfo>;
|
||||
openExternal?: (url: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// Agent default configs for registration in externalAgents
|
||||
export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "command" | "enabled">> = {
|
||||
codex: {
|
||||
name: "Codex CLI",
|
||||
args: ["exec", "--full-auto", "--json", "{prompt}"],
|
||||
icon: "openai",
|
||||
acpCommand: "codex-acp",
|
||||
acpArgs: [],
|
||||
},
|
||||
claude: {
|
||||
name: "Claude Code",
|
||||
args: ["-p", "--output-format", "text", "{prompt}"],
|
||||
icon: "claude",
|
||||
acpCommand: "claude-code-acp",
|
||||
acpArgs: [],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bridge helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getBridge(): NetcattyAiBridge | undefined {
|
||||
return (window as unknown as { netcatty?: NetcattyAiBridge }).netcatty;
|
||||
}
|
||||
|
||||
export function getFetchBridge(): FetchBridge | undefined {
|
||||
return (window as unknown as { netcatty?: FetchBridge }).netcatty;
|
||||
}
|
||||
|
||||
export function normalizeCodexBridgeError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("No handler registered for 'netcatty:ai:codex:")) {
|
||||
return "Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider icon helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SettingsIconId = AIProviderId | "claude";
|
||||
|
||||
export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
|
||||
openai: "/ai/providers/openai.svg",
|
||||
anthropic: "/ai/providers/anthropic.svg",
|
||||
claude: "/ai/agents/claude.svg",
|
||||
google: "/ai/providers/google.svg",
|
||||
ollama: "/ai/providers/ollama.svg",
|
||||
openrouter: "/ai/providers/openrouter.svg",
|
||||
custom: "/ai/providers/custom.svg",
|
||||
};
|
||||
|
||||
export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
|
||||
openai: "bg-emerald-600",
|
||||
anthropic: "bg-orange-600",
|
||||
claude: "bg-orange-600",
|
||||
google: "bg-blue-600",
|
||||
ollama: "bg-purple-600",
|
||||
openrouter: "bg-pink-600",
|
||||
custom: "bg-zinc-600",
|
||||
};
|
||||
@@ -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;
|
||||
@@ -215,7 +218,7 @@ const attachSessionToTerminal = (
|
||||
|
||||
const runDistroDetection = async (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
auth: { username: string; password?: string; key?: SSHKey },
|
||||
auth: { username: string; password?: string; key?: SSHKey; passphrase?: string },
|
||||
) => {
|
||||
if (!ctx.terminalBackend.execAvailable()) return;
|
||||
try {
|
||||
@@ -225,8 +228,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);
|
||||
@@ -573,6 +577,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
username: effectiveUsername,
|
||||
password: usedPassword,
|
||||
key: usedKey,
|
||||
passphrase: effectivePassphrase,
|
||||
}),
|
||||
600,
|
||||
);
|
||||
|
||||
@@ -94,6 +94,9 @@ export type CreateXTermRuntimeContext = {
|
||||
|
||||
// Callback when shell reports CWD change via OSC 7
|
||||
onCwdChange?: (cwd: string) => void;
|
||||
|
||||
// Callback when remote requests clipboard read in 'prompt' mode; resolves to user's decision
|
||||
onOsc52ReadRequest?: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
const detectPlatform = (): XTermPlatform => {
|
||||
@@ -614,6 +617,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 {}
|
||||
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 +714,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
cleanupMiddleClick?.();
|
||||
keywordHighlighter.dispose();
|
||||
osc7Disposable.dispose();
|
||||
osc52Disposable.dispose();
|
||||
try {
|
||||
term.dispose();
|
||||
} catch (err) {
|
||||
|
||||
91
components/ui/input-group.tsx
Normal file
91
components/ui/input-group.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ComponentProps, HTMLAttributes } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
export type InputGroupProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const InputGroup = forwardRef<HTMLDivElement, InputGroupProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-col rounded-[22px] border border-border/65 bg-background/92 shadow-[0_18px_42px_rgba(0,0,0,0.34),inset_0_1px_0_rgba(255,255,255,0.04)] transition-[border-color,background-color,box-shadow]',
|
||||
'supports-[backdrop-filter]:backdrop-blur-sm',
|
||||
'focus-within:border-primary/45 focus-within:bg-background focus-within:ring-1 focus-within:ring-primary/20',
|
||||
'overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
InputGroup.displayName = 'InputGroup';
|
||||
|
||||
export type InputGroupTextareaProps = ComponentProps<'textarea'>;
|
||||
|
||||
export const InputGroupTextarea = forwardRef<HTMLTextAreaElement, InputGroupTextareaProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full resize-none bg-transparent text-[13px] text-foreground/92 selection:bg-primary/25',
|
||||
'placeholder:text-muted-foreground/62 placeholder:font-medium placeholder:text-[13px]',
|
||||
'focus:outline-none disabled:opacity-40 disabled:cursor-not-allowed',
|
||||
'px-4 pt-3.5 pb-2 leading-[20px]',
|
||||
'field-sizing-content min-h-[82px] max-h-52',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
InputGroupTextarea.displayName = 'InputGroupTextarea';
|
||||
|
||||
export type InputGroupAddonProps = HTMLAttributes<HTMLDivElement> & {
|
||||
align?: 'block-start' | 'block-end';
|
||||
};
|
||||
|
||||
export const InputGroupAddon = forwardRef<HTMLDivElement, InputGroupAddonProps>(
|
||||
({ className, align = 'block-end', ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center px-2.5 py-1.5',
|
||||
align === 'block-start' && 'border-b border-border/35 bg-muted/8',
|
||||
align === 'block-end' && 'border-t border-border/60 bg-muted/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
InputGroupAddon.displayName = 'InputGroupAddon';
|
||||
|
||||
export type InputGroupButtonProps = ComponentProps<'button'> & {
|
||||
variant?: 'default' | 'ghost' | 'outline' | 'destructive';
|
||||
size?: 'sm' | 'icon-sm' | 'default';
|
||||
};
|
||||
|
||||
export const InputGroupButton = forwardRef<HTMLButtonElement, InputGroupButtonProps>(
|
||||
({ className, variant = 'ghost', size = 'icon-sm', disabled, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md transition-colors cursor-pointer',
|
||||
'disabled:opacity-30 disabled:cursor-default',
|
||||
size === 'icon-sm' && 'h-7 w-7',
|
||||
size === 'sm' && 'h-7 px-2 text-[12px] gap-1',
|
||||
size === 'default' && 'h-8 px-3 text-[13px] gap-1.5',
|
||||
variant === 'ghost' && 'text-muted-foreground/78 hover:text-foreground hover:bg-muted/45',
|
||||
variant === 'default' && 'bg-primary/80 text-primary-foreground hover:bg-primary',
|
||||
variant === 'outline' && 'border border-border/40 text-muted-foreground/85 hover:text-foreground hover:bg-muted/35',
|
||||
variant === 'destructive' && 'text-destructive/70 hover:text-destructive hover:bg-destructive/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
InputGroupButton.displayName = 'InputGroupButton';
|
||||
@@ -12,7 +12,7 @@ const ScrollArea = React.forwardRef<
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full max-h-[inherit] rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
|
||||
9
components/ui/spinner.tsx
Normal file
9
components/ui/spinner.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
export type SpinnerProps = ComponentProps<typeof Loader2>;
|
||||
|
||||
export const Spinner = ({ className, size = 16, ...props }: SpinnerProps) => (
|
||||
<Loader2 className={cn('animate-spin', className)} size={size} {...props} />
|
||||
);
|
||||
@@ -434,6 +434,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 +544,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
|
||||
};
|
||||
|
||||
|
||||
@@ -172,13 +172,30 @@ export interface SyncPayload {
|
||||
|
||||
// Settings
|
||||
settings?: {
|
||||
// Theme & Appearance
|
||||
theme?: 'light' | 'dark' | 'system';
|
||||
accentColor?: string;
|
||||
lightUiThemeId?: string;
|
||||
darkUiThemeId?: string;
|
||||
accentMode?: 'theme' | 'custom';
|
||||
customAccent?: string;
|
||||
uiFontFamilyId?: string;
|
||||
uiLanguage?: string;
|
||||
customCSS?: string;
|
||||
// Terminal
|
||||
terminalTheme?: string;
|
||||
terminalFontFamily?: string;
|
||||
terminalFontSize?: number;
|
||||
hotkeyScheme?: string;
|
||||
terminalSettings?: Record<string, unknown>;
|
||||
customTerminalThemes?: Array<{ id: string; name: string; colors: Record<string, string> }>;
|
||||
// Keyboard
|
||||
customKeyBindings?: Record<string, { mac?: string; pc?: string }>;
|
||||
// Editor
|
||||
editorWordWrap?: boolean;
|
||||
// SFTP
|
||||
sftpDoubleClickBehavior?: 'open' | 'transfer';
|
||||
sftpAutoSync?: boolean;
|
||||
sftpShowHiddenFiles?: boolean;
|
||||
sftpUseCompressedUpload?: boolean;
|
||||
};
|
||||
|
||||
// Sync metadata
|
||||
|
||||
@@ -16,6 +16,28 @@ import type {
|
||||
SSHKey,
|
||||
} from './models';
|
||||
import type { SyncPayload } from './sync';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input types
|
||||
@@ -38,6 +60,157 @@ export interface SyncPayloadImporters {
|
||||
importVaultData: (jsonString: string) => void;
|
||||
/** Import port-forwarding rules (lives outside the vault hook). */
|
||||
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
|
||||
/** Called after synced settings have been written to localStorage. */
|
||||
onSettingsApplied?: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings sync helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Terminal settings keys that are safe to sync (platform-agnostic). */
|
||||
const SYNCABLE_TERMINAL_KEYS = [
|
||||
'scrollback', 'drawBoldInBrightColors', 'fontLigatures', 'fontWeight', 'fontWeightBold',
|
||||
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
||||
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Collect all syncable settings from localStorage.
|
||||
*/
|
||||
export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
const settings: SyncPayload['settings'] = {};
|
||||
|
||||
// Theme & Appearance
|
||||
const theme = localStorageAdapter.readString(STORAGE_KEY_THEME);
|
||||
if (theme === 'light' || theme === 'dark' || theme === 'system') settings.theme = theme;
|
||||
const lightUi = localStorageAdapter.readString(STORAGE_KEY_UI_THEME_LIGHT);
|
||||
if (lightUi) settings.lightUiThemeId = lightUi;
|
||||
const darkUi = localStorageAdapter.readString(STORAGE_KEY_UI_THEME_DARK);
|
||||
if (darkUi) settings.darkUiThemeId = darkUi;
|
||||
const accentMode = localStorageAdapter.readString(STORAGE_KEY_ACCENT_MODE);
|
||||
if (accentMode === 'theme' || accentMode === 'custom') settings.accentMode = accentMode;
|
||||
const accent = localStorageAdapter.readString(STORAGE_KEY_COLOR);
|
||||
if (accent) settings.customAccent = accent;
|
||||
const uiFont = localStorageAdapter.readString(STORAGE_KEY_UI_FONT_FAMILY);
|
||||
if (uiFont) settings.uiFontFamilyId = uiFont;
|
||||
const lang = localStorageAdapter.readString(STORAGE_KEY_UI_LANGUAGE);
|
||||
if (lang) settings.uiLanguage = lang;
|
||||
const css = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS);
|
||||
if (css != null) settings.customCSS = css;
|
||||
|
||||
// Terminal
|
||||
const termTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
|
||||
if (termTheme) settings.terminalTheme = termTheme;
|
||||
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
if (termFont) settings.terminalFontFamily = termFont;
|
||||
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
if (termSize != null) settings.terminalFontSize = termSize;
|
||||
|
||||
// Terminal settings (syncable subset only)
|
||||
const termSettingsRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
|
||||
if (termSettingsRaw) {
|
||||
try {
|
||||
const full = JSON.parse(termSettingsRaw);
|
||||
const subset: Record<string, unknown> = {};
|
||||
for (const key of SYNCABLE_TERMINAL_KEYS) {
|
||||
if (key in full) subset[key] = full[key];
|
||||
}
|
||||
if (Object.keys(subset).length > 0) settings.terminalSettings = subset;
|
||||
} catch { /* ignore corrupt data */ }
|
||||
}
|
||||
|
||||
// Custom terminal themes
|
||||
const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES);
|
||||
if (customThemesRaw) {
|
||||
try {
|
||||
const parsed = JSON.parse(customThemesRaw);
|
||||
if (Array.isArray(parsed)) settings.customTerminalThemes = parsed;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
const kb = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
|
||||
if (kb) {
|
||||
try {
|
||||
settings.customKeyBindings = JSON.parse(kb);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Editor
|
||||
const wordWrap = localStorageAdapter.readString(STORAGE_KEY_EDITOR_WORD_WRAP);
|
||||
if (wordWrap === 'true' || wordWrap === 'false') settings.editorWordWrap = wordWrap === 'true';
|
||||
|
||||
// SFTP
|
||||
const dblClick = localStorageAdapter.readString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
||||
if (dblClick === 'open' || dblClick === 'transfer') settings.sftpDoubleClickBehavior = dblClick;
|
||||
const autoSync = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_SYNC);
|
||||
if (autoSync === 'true' || autoSync === 'false') settings.sftpAutoSync = autoSync === 'true';
|
||||
const hidden = localStorageAdapter.readString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
|
||||
if (hidden === 'true' || hidden === 'false') settings.sftpShowHiddenFiles = hidden === 'true';
|
||||
const compress = localStorageAdapter.readString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply synced settings to localStorage. Merges terminal settings
|
||||
* to preserve platform-specific fields.
|
||||
*/
|
||||
export function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
|
||||
// Theme & Appearance
|
||||
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
|
||||
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
|
||||
if (settings.darkUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, settings.darkUiThemeId);
|
||||
if (settings.accentMode != null) localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, settings.accentMode);
|
||||
if (settings.customAccent != null) localStorageAdapter.writeString(STORAGE_KEY_COLOR, settings.customAccent);
|
||||
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
|
||||
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
|
||||
if (settings.customCSS != null) localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, settings.customCSS);
|
||||
|
||||
// Terminal
|
||||
if (settings.terminalTheme != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, settings.terminalTheme);
|
||||
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
|
||||
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
|
||||
|
||||
// Terminal settings — merge with existing to preserve platform-specific keys
|
||||
if (settings.terminalSettings) {
|
||||
let existing: Record<string, unknown> = {};
|
||||
const raw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
|
||||
if (raw) {
|
||||
try { existing = JSON.parse(raw); } catch { /* ignore */ }
|
||||
}
|
||||
const merged = { ...existing };
|
||||
for (const key of SYNCABLE_TERMINAL_KEYS) {
|
||||
if (key in settings.terminalSettings) {
|
||||
merged[key] = settings.terminalSettings[key];
|
||||
}
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
|
||||
}
|
||||
|
||||
// Custom terminal themes
|
||||
if (settings.customTerminalThemes != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_THEMES, JSON.stringify(settings.customTerminalThemes));
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
if (settings.customKeyBindings != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_KEY_BINDINGS, JSON.stringify(settings.customKeyBindings));
|
||||
}
|
||||
|
||||
// Editor
|
||||
if (settings.editorWordWrap != null) localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(settings.editorWordWrap));
|
||||
|
||||
// SFTP
|
||||
if (settings.sftpDoubleClickBehavior != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, settings.sftpDoubleClickBehavior);
|
||||
if (settings.sftpAutoSync != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, String(settings.sftpAutoSync));
|
||||
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
|
||||
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -64,6 +237,7 @@ export function buildSyncPayload(
|
||||
snippetPackages: vault.snippetPackages,
|
||||
knownHosts: vault.knownHosts,
|
||||
portForwardingRules,
|
||||
settings: collectSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -105,4 +279,10 @@ export function applySyncPayload(
|
||||
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(payload.portForwardingRules);
|
||||
}
|
||||
|
||||
// Apply synced settings
|
||||
if (payload.settings) {
|
||||
applySyncableSettings(payload.settings);
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,18 @@ module.exports = {
|
||||
asarUnpack: [
|
||||
'node_modules/node-pty/**/*',
|
||||
'node_modules/ssh2/**/*',
|
||||
'node_modules/cpu-features/**/*'
|
||||
'node_modules/cpu-features/**/*',
|
||||
'node_modules/@zed-industries/codex-acp/**/*',
|
||||
'node_modules/@zed-industries/codex-acp-*/**/*',
|
||||
'node_modules/@modelcontextprotocol/sdk/**/*',
|
||||
'node_modules/zod/**/*',
|
||||
'node_modules/zod-to-json-schema/**/*',
|
||||
'node_modules/ajv/**/*',
|
||||
'node_modules/ajv-formats/**/*',
|
||||
'node_modules/fast-deep-equal/**/*',
|
||||
'node_modules/fast-uri/**/*',
|
||||
'node_modules/json-schema-traverse/**/*',
|
||||
'electron/mcp/**/*'
|
||||
],
|
||||
mac: {
|
||||
target: [
|
||||
|
||||
209
electron/bridges/ai/codexHelpers.cjs
Normal file
209
electron/bridges/ai/codexHelpers.cjs
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Codex-related helper functions and state.
|
||||
*
|
||||
* Manages Codex login sessions, auth validation cache, binary resolution,
|
||||
* integration state normalization, and error / fingerprint utilities.
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { createHash } = require("node:crypto");
|
||||
const { existsSync } = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { stripAnsi, extractFirstNonLocalhostUrl, toUnpackedAsarPath } = require("./shellUtils.cjs");
|
||||
|
||||
// ── Module-level state ──
|
||||
|
||||
const codexLoginSessions = new Map();
|
||||
let codexValidationCache = null;
|
||||
|
||||
const CODEX_AUTH_HINTS = [
|
||||
"not logged in",
|
||||
"authentication required",
|
||||
"auth required",
|
||||
"login required",
|
||||
"missing credentials",
|
||||
"no credentials",
|
||||
"unauthorized",
|
||||
"forbidden",
|
||||
"codex login",
|
||||
"401",
|
||||
"403",
|
||||
"invalid_grant",
|
||||
"invalid_token",
|
||||
"credentials",
|
||||
];
|
||||
|
||||
// ── Package / binary resolution ──
|
||||
|
||||
function getCodexPackageName() {
|
||||
const key = `${process.platform}-${process.arch}`;
|
||||
switch (key) {
|
||||
case "darwin-arm64":
|
||||
return "@zed-industries/codex-acp-darwin-arm64";
|
||||
case "darwin-x64":
|
||||
return "@zed-industries/codex-acp-darwin-x64";
|
||||
case "linux-arm64":
|
||||
return "@zed-industries/codex-acp-linux-arm64";
|
||||
case "linux-x64":
|
||||
return "@zed-industries/codex-acp-linux-x64";
|
||||
case "win32-arm64":
|
||||
return "@zed-industries/codex-acp-win32-arm64";
|
||||
case "win32-x64":
|
||||
return "@zed-industries/codex-acp-win32-x64";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCodexAcpBinaryPath(shellEnv, electronModule) {
|
||||
const binaryName = process.platform === "win32" ? "codex-acp.exe" : "codex-acp";
|
||||
const isPackaged = electronModule?.app?.isPackaged;
|
||||
|
||||
// Dev mode: prefer system PATH
|
||||
if (!isPackaged && shellEnv) {
|
||||
try {
|
||||
const whichCmd = process.platform === "win32" ? "where" : "which";
|
||||
const systemPath = execFileSync(whichCmd, [binaryName], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: shellEnv,
|
||||
}).trim().split("\n")[0].trim();
|
||||
if (systemPath && existsSync(systemPath)) {
|
||||
return systemPath;
|
||||
}
|
||||
} catch {
|
||||
// Not on PATH
|
||||
}
|
||||
}
|
||||
|
||||
// Packaged build (or dev fallback): use npm-bundled binary
|
||||
try {
|
||||
const pkgName = getCodexPackageName();
|
||||
if (!pkgName) return binaryName;
|
||||
|
||||
const pkgRoot = path.dirname(require.resolve("@zed-industries/codex-acp/package.json"));
|
||||
const resolved = require.resolve(`${pkgName}/bin/${binaryName}`, { paths: [pkgRoot] });
|
||||
return toUnpackedAsarPath(resolved);
|
||||
} catch {
|
||||
return binaryName;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Login session helpers ──
|
||||
|
||||
function appendCodexLoginOutput(session, chunk) {
|
||||
const cleanChunk = stripAnsi(chunk);
|
||||
if (!cleanChunk) return;
|
||||
|
||||
session.output += cleanChunk;
|
||||
if (!session.url) {
|
||||
session.url = extractFirstNonLocalhostUrl(session.output);
|
||||
}
|
||||
}
|
||||
|
||||
function toCodexLoginSessionResponse(session) {
|
||||
return {
|
||||
sessionId: session.id,
|
||||
state: session.state,
|
||||
url: session.url,
|
||||
output: session.output,
|
||||
error: session.error,
|
||||
exitCode: session.exitCode,
|
||||
};
|
||||
}
|
||||
|
||||
function getActiveCodexLoginSession() {
|
||||
for (const session of codexLoginSessions.values()) {
|
||||
if (session.state === "running" && session.process && !session.process.killed) {
|
||||
return session;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Integration state ──
|
||||
|
||||
function normalizeCodexIntegrationState(rawOutput) {
|
||||
const normalizedOutput = String(rawOutput || "").toLowerCase();
|
||||
|
||||
if (normalizedOutput.includes("logged in using chatgpt")) {
|
||||
return "connected_chatgpt";
|
||||
}
|
||||
if (
|
||||
normalizedOutput.includes("logged in using an api key") ||
|
||||
normalizedOutput.includes("logged in using api key")
|
||||
) {
|
||||
return "connected_api_key";
|
||||
}
|
||||
if (normalizedOutput.includes("not logged in")) {
|
||||
return "not_logged_in";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// ── Error helpers ──
|
||||
|
||||
function extractCodexError(error) {
|
||||
const message =
|
||||
error?.data?.message ||
|
||||
error?.errorText ||
|
||||
error?.message ||
|
||||
error?.error ||
|
||||
String(error);
|
||||
const code = error?.data?.code || error?.code;
|
||||
return {
|
||||
message: typeof message === "string" ? message : String(message),
|
||||
code: typeof code === "string" ? code : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function isCodexAuthError(params) {
|
||||
const searchableText = `${params?.code || ""} ${params?.message || ""}`.toLowerCase();
|
||||
return CODEX_AUTH_HINTS.some((hint) => searchableText.includes(hint));
|
||||
}
|
||||
|
||||
// ── Fingerprints ──
|
||||
|
||||
function getCodexAuthFingerprint(apiKey) {
|
||||
const normalized = String(apiKey || "").trim();
|
||||
if (!normalized) return null;
|
||||
return createHash("sha256").update(normalized).digest("hex");
|
||||
}
|
||||
|
||||
function getCodexMcpFingerprint(mcpServers) {
|
||||
return createHash("sha256").update(JSON.stringify(mcpServers || [])).digest("hex");
|
||||
}
|
||||
|
||||
// ── Validation cache ──
|
||||
|
||||
function invalidateCodexValidationCache() {
|
||||
codexValidationCache = null;
|
||||
}
|
||||
|
||||
function getCodexValidationCache() {
|
||||
return codexValidationCache;
|
||||
}
|
||||
|
||||
function setCodexValidationCache(value) {
|
||||
codexValidationCache = value;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
codexLoginSessions,
|
||||
getCodexPackageName,
|
||||
resolveCodexAcpBinaryPath,
|
||||
appendCodexLoginOutput,
|
||||
toCodexLoginSessionResponse,
|
||||
getActiveCodexLoginSession,
|
||||
normalizeCodexIntegrationState,
|
||||
extractCodexError,
|
||||
isCodexAuthError,
|
||||
getCodexAuthFingerprint,
|
||||
getCodexMcpFingerprint,
|
||||
invalidateCodexValidationCache,
|
||||
getCodexValidationCache,
|
||||
setCodexValidationCache,
|
||||
};
|
||||
197
electron/bridges/ai/ptyExec.cjs
Normal file
197
electron/bridges/ai/ptyExec.cjs
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* PTY and SSH channel command execution.
|
||||
*
|
||||
* Provides a unified `execViaPty` that works for both MCP server bridge
|
||||
* (tracking in activePtyExecs for cancellation) and Catty Agent
|
||||
* (stripping MCP markers from output).
|
||||
*
|
||||
* Also provides `execViaChannel` for SSH exec channel fallback.
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const crypto = require("crypto");
|
||||
const { stripAnsi } = require("./shellUtils.cjs");
|
||||
|
||||
/**
|
||||
* Execute command through a terminal PTY stream.
|
||||
* The user sees the command typed and output in their terminal.
|
||||
* Uses a unique marker to detect when the command finishes and capture the exit code.
|
||||
*
|
||||
* @param {object} ptyStream - The PTY stream to write to
|
||||
* @param {string} command - The command to execute
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.stripMarkers=false] - Strip leaked MCP markers from output
|
||||
* @param {Map} [options.trackForCancellation] - Map to register this execution in for cancellation
|
||||
* @param {number} [options.timeoutMs=60000] - Command timeout in milliseconds
|
||||
*/
|
||||
function execViaPty(ptyStream, command, options) {
|
||||
const {
|
||||
stripMarkers = false,
|
||||
trackForCancellation = null,
|
||||
timeoutMs = 60000,
|
||||
} = options || {};
|
||||
|
||||
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let output = "";
|
||||
let foundStart = false;
|
||||
let timeoutId = null;
|
||||
let finished = false;
|
||||
|
||||
const onData = (data) => {
|
||||
const text = data.toString();
|
||||
|
||||
if (!foundStart) {
|
||||
// Look for the start marker at a line boundary (actual printf output),
|
||||
// not inside the echo of the printf command argument.
|
||||
const startMarker = marker + "_S";
|
||||
let pos = 0;
|
||||
while (pos < text.length) {
|
||||
const idx = text.indexOf(startMarker, pos);
|
||||
if (idx === -1) break;
|
||||
// Accept if at start of text, or preceded by \n or \r (line boundary)
|
||||
if (idx === 0 || text[idx - 1] === '\n' || text[idx - 1] === '\r') {
|
||||
foundStart = true;
|
||||
const afterMarker = text.slice(idx);
|
||||
const nlIdx = afterMarker.indexOf("\n");
|
||||
if (nlIdx !== -1) {
|
||||
output += afterMarker.slice(nlIdx + 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
pos = idx + 1;
|
||||
}
|
||||
if (foundStart) checkEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
output += text;
|
||||
checkEnd();
|
||||
};
|
||||
|
||||
function checkEnd() {
|
||||
// Look for the end marker at a line boundary (actual printf output),
|
||||
// not inside the echo of the printf command argument.
|
||||
const endPattern = marker + "_E:";
|
||||
let searchFrom = 0;
|
||||
while (searchFrom < output.length) {
|
||||
const endIdx = output.indexOf(endPattern, searchFrom);
|
||||
if (endIdx === -1) return;
|
||||
|
||||
// Accept if at start of output, or preceded by \n or \r (line boundary)
|
||||
if (endIdx === 0 || output[endIdx - 1] === '\n' || output[endIdx - 1] === '\r') {
|
||||
const afterEnd = output.slice(endIdx + endPattern.length);
|
||||
const codeMatch = afterEnd.match(/^(\d+)/);
|
||||
const exitCode = codeMatch ? parseInt(codeMatch[1], 10) : null;
|
||||
|
||||
const stdout = output.slice(0, endIdx);
|
||||
finish(stdout, exitCode);
|
||||
return;
|
||||
}
|
||||
searchFrom = endIdx + 1;
|
||||
}
|
||||
}
|
||||
|
||||
function finish(stdout, exitCode) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timeoutId);
|
||||
ptyStream.removeListener("data", onData);
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.delete(marker);
|
||||
}
|
||||
|
||||
let cleaned = stripAnsi(stdout || "").trim();
|
||||
if (stripMarkers) {
|
||||
cleaned = cleaned.replace(/__NCMCP_[^\r\n]*[\r\n]*/g, "").trim();
|
||||
}
|
||||
resolve({
|
||||
ok: exitCode === 0 || exitCode === null,
|
||||
stdout: cleaned,
|
||||
stderr: "",
|
||||
exitCode: exitCode ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
ptyStream.removeListener("data", onData);
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.delete(marker);
|
||||
}
|
||||
// Send Ctrl+C to kill the timed-out command
|
||||
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
|
||||
const cleaned = stripAnsi(output).trim();
|
||||
const timeoutSec = Math.round(timeoutMs / 1000);
|
||||
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: -1, error: `Command timed out (${timeoutSec}s)` });
|
||||
}, timeoutMs);
|
||||
|
||||
ptyStream.on("data", onData);
|
||||
|
||||
// Register for cancellation if tracking map provided
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.set(marker, {
|
||||
ptyStream,
|
||||
cleanup: () => { clearTimeout(timeoutId); ptyStream.removeListener("data", onData); },
|
||||
});
|
||||
}
|
||||
|
||||
// Markers are filtered from terminal display by preload.cjs (MCP_MARKER_RE).
|
||||
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
|
||||
ptyStream.write(
|
||||
`printf '${marker}_S\\n';${noPager}${command}\n` +
|
||||
`__nc=$?;printf '${marker}_E:'$__nc'\\n';(exit $__nc)\n`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: execute via a separate SSH exec channel (invisible to terminal).
|
||||
*
|
||||
* @param {object} sshClient - SSH client with .exec() method
|
||||
* @param {string} command - The command to execute
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.timeoutMs=60000] - Command timeout in milliseconds
|
||||
*/
|
||||
function execViaChannel(sshClient, command, options) {
|
||||
const { timeoutMs = 60000 } = options || {};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
sshClient.exec(command, (err, execStream) => {
|
||||
if (err) {
|
||||
resolve({ ok: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
if (!execStream) {
|
||||
resolve({ ok: false, output: 'Failed to create exec stream', exitCode: 1 });
|
||||
return;
|
||||
}
|
||||
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) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timeoutId);
|
||||
resolve({ ok: code === 0, stdout, stderr, exitCode: code });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
execViaPty,
|
||||
execViaChannel,
|
||||
stripAnsi,
|
||||
};
|
||||
191
electron/bridges/ai/shellUtils.cjs
Normal file
191
electron/bridges/ai/shellUtils.cjs
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Shell utility functions shared across AI bridge modules.
|
||||
*
|
||||
* Provides ANSI stripping, URL extraction, CLI resolution, path helpers,
|
||||
* stream chunk serialization, and cached shell environment resolution.
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { existsSync } = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// ── ANSI / URL regexes ──
|
||||
|
||||
const ANSI_ESCAPE_REGEX = /\u001B\[[0-?]*[ -/]*[@-~]/g;
|
||||
const ANSI_OSC_REGEX = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g;
|
||||
const URL_CANDIDATE_REGEX = /https?:\/\/[^\s]+/g;
|
||||
|
||||
// ── ANSI stripping ──
|
||||
|
||||
function stripAnsi(input) {
|
||||
return String(input || "").replace(ANSI_OSC_REGEX, "").replace(ANSI_ESCAPE_REGEX, "");
|
||||
}
|
||||
|
||||
// ── URL helpers ──
|
||||
|
||||
function isLocalhostHostname(hostname) {
|
||||
const normalized = String(hostname || "").trim().toLowerCase();
|
||||
return (
|
||||
normalized === "localhost" ||
|
||||
normalized === "127.0.0.1" ||
|
||||
normalized === "::1" ||
|
||||
normalized === "[::1]" ||
|
||||
normalized.endsWith(".localhost")
|
||||
);
|
||||
}
|
||||
|
||||
function extractFirstNonLocalhostUrl(output) {
|
||||
const { URL } = require("node:url");
|
||||
const matches = stripAnsi(output).match(URL_CANDIDATE_REGEX);
|
||||
if (!matches) return null;
|
||||
|
||||
for (const match of matches) {
|
||||
try {
|
||||
const parsedUrl = new URL(match.trim().replace(/[),.;!?]+$/, ""));
|
||||
if (!isLocalhostHostname(parsedUrl.hostname)) {
|
||||
return parsedUrl.toString();
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid URL candidates.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── CLI / path helpers ──
|
||||
|
||||
function resolveCliFromPath(command, shellEnv) {
|
||||
// Validate command: only allow valid binary names (alphanumeric, hyphens, underscores, dots)
|
||||
if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shellEnv) {
|
||||
try {
|
||||
const whichCmd = process.platform === "win32" ? "where" : "which";
|
||||
const resolved = execFileSync(whichCmd, [command], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: shellEnv,
|
||||
}).trim().split("\n")[0].trim();
|
||||
if (resolved && existsSync(resolved)) return resolved;
|
||||
} catch {
|
||||
// Not found on PATH
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toUnpackedAsarPath(filePath) {
|
||||
const unpackedPath = filePath.replace(/app\.asar([\\/])/, "app.asar.unpacked$1");
|
||||
if (unpackedPath !== filePath && existsSync(unpackedPath)) {
|
||||
return unpackedPath;
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
// ── Shell environment (cached) ──
|
||||
|
||||
let _cachedShellEnv = null;
|
||||
|
||||
async function getShellEnv() {
|
||||
if (_cachedShellEnv) return _cachedShellEnv;
|
||||
|
||||
const home = process.env.HOME || "";
|
||||
const extraPaths = [
|
||||
`${home}/.local/bin`,
|
||||
`${home}/.npm-global/bin`,
|
||||
"/usr/local/bin",
|
||||
"/opt/homebrew/bin",
|
||||
];
|
||||
|
||||
if (process.platform === "win32") {
|
||||
_cachedShellEnv = {
|
||||
...process.env,
|
||||
PATH: [...extraPaths, process.env.PATH || ""].join(path.delimiter),
|
||||
};
|
||||
return _cachedShellEnv;
|
||||
}
|
||||
|
||||
// On macOS/Linux, spawn a login shell to capture the real environment.
|
||||
try {
|
||||
const shell = process.env.SHELL || "/bin/zsh";
|
||||
const envOutput = execFileSync(shell, ['-ilc', 'env'], {
|
||||
encoding: "utf8",
|
||||
timeout: 10000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, HOME: home },
|
||||
});
|
||||
const envMap = {};
|
||||
for (const line of envOutput.split("\n")) {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx > 0) {
|
||||
envMap[line.slice(0, idx)] = line.slice(idx + 1);
|
||||
}
|
||||
}
|
||||
const shellPath = envMap.PATH || "";
|
||||
_cachedShellEnv = {
|
||||
...envMap,
|
||||
...process.env,
|
||||
PATH: [...extraPaths, shellPath, process.env.PATH || ""].join(path.delimiter),
|
||||
};
|
||||
} catch {
|
||||
_cachedShellEnv = {
|
||||
...process.env,
|
||||
PATH: [...extraPaths, process.env.PATH || ""].join(path.delimiter),
|
||||
};
|
||||
}
|
||||
return _cachedShellEnv;
|
||||
}
|
||||
|
||||
// ── Stream chunk serialization ──
|
||||
|
||||
function serializeStreamChunk(chunk) {
|
||||
if (!chunk || !chunk.type) return null;
|
||||
switch (chunk.type) {
|
||||
case "text-delta":
|
||||
return { type: "text-delta", textDelta: chunk.text ?? chunk.textDelta ?? "" };
|
||||
case "reasoning-delta":
|
||||
return { type: "reasoning-delta", delta: chunk.text ?? chunk.delta ?? "" };
|
||||
case "reasoning-start":
|
||||
return { type: "reasoning-start", id: chunk.id ?? undefined };
|
||||
case "reasoning-end":
|
||||
return { type: "reasoning-end", id: chunk.id ?? undefined };
|
||||
case "tool-call":
|
||||
return {
|
||||
type: "tool-call",
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
args: chunk.args,
|
||||
};
|
||||
case "tool-result":
|
||||
return {
|
||||
type: "tool-result",
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
result: chunk.result,
|
||||
output: chunk.output,
|
||||
};
|
||||
case "error":
|
||||
return { type: "error", error: chunk.error };
|
||||
default:
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(chunk));
|
||||
} catch {
|
||||
return { type: chunk.type };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
stripAnsi,
|
||||
isLocalhostHostname,
|
||||
extractFirstNonLocalhostUrl,
|
||||
resolveCliFromPath,
|
||||
toUnpackedAsarPath,
|
||||
getShellEnv,
|
||||
serializeStreamChunk,
|
||||
};
|
||||
1723
electron/bridges/aiBridge.cjs
Normal file
1723
electron/bridges/aiBridge.cjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,40 @@
|
||||
|
||||
let _deps = null;
|
||||
|
||||
/**
|
||||
* Read the persisted auto-update preference from a JSON file in userData.
|
||||
* Returns true (default) if the file doesn't exist or is unreadable.
|
||||
*/
|
||||
function readAutoUpdatePreference() {
|
||||
try {
|
||||
const { app } = _deps?.electronModule || {};
|
||||
if (!app) return true;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const prefPath = path.join(app.getPath('userData'), 'auto-update-pref.json');
|
||||
const data = JSON.parse(fs.readFileSync(prefPath, 'utf8'));
|
||||
return data.enabled !== false;
|
||||
} catch {
|
||||
return true; // default to enabled
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the auto-update preference to a JSON file in userData.
|
||||
*/
|
||||
function writeAutoUpdatePreference(enabled) {
|
||||
try {
|
||||
const { app } = _deps?.electronModule || {};
|
||||
if (!app) return;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const prefPath = path.join(app.getPath('userData'), 'auto-update-pref.json');
|
||||
fs.writeFileSync(prefPath, JSON.stringify({ enabled }), 'utf8');
|
||||
} catch (err) {
|
||||
console.warn('[AutoUpdate] Failed to write preference:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the current packaging format supports electron-updater
|
||||
* (macOS zip/dmg, Windows NSIS, Linux AppImage).
|
||||
@@ -51,7 +85,7 @@ function getAutoUpdater() {
|
||||
if (_autoUpdater) return _autoUpdater;
|
||||
try {
|
||||
const { autoUpdater } = require("electron-updater");
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoDownload = readAutoUpdatePreference();
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
// Silence the default electron-log transport (we log ourselves).
|
||||
autoUpdater.logger = null;
|
||||
@@ -84,9 +118,12 @@ function setupGlobalListeners() {
|
||||
|
||||
updater.on("update-available", (info) => {
|
||||
_isChecking = false;
|
||||
// autoDownload=true means the download begins immediately after this event
|
||||
_isDownloading = true;
|
||||
_lastStatus = { status: 'downloading', percent: 0, error: null, version: info.version || null, isChecking: false };
|
||||
// Only track as downloading when autoDownload is enabled — otherwise no
|
||||
// download will actually start and the status would be stuck at 0%.
|
||||
// Use 'available' so late-opening windows can still hydrate the version.
|
||||
const willDownload = updater.autoDownload !== false;
|
||||
_isDownloading = willDownload;
|
||||
_lastStatus = { status: willDownload ? 'downloading' : 'available', percent: 0, error: null, version: info.version || null, isChecking: false };
|
||||
broadcastToAllWindows("netcatty:update:update-available", {
|
||||
version: info.version || "",
|
||||
releaseNotes: typeof info.releaseNotes === "string" ? info.releaseNotes : "",
|
||||
@@ -144,6 +181,9 @@ function startAutoCheck(delayMs = 5000) {
|
||||
console.log("[AutoUpdate] Platform does not support auto-update, skipping auto-check");
|
||||
return;
|
||||
}
|
||||
// Cancel any existing timer to avoid duplicate concurrent checks
|
||||
// (e.g. from multiple windows initializing or re-enable toggle).
|
||||
cancelAutoCheck();
|
||||
_autoCheckTimer = setTimeout(async () => {
|
||||
_autoCheckTimer = null;
|
||||
const updater = getAutoUpdater();
|
||||
@@ -151,6 +191,12 @@ function startAutoCheck(delayMs = 5000) {
|
||||
console.warn("[AutoUpdate] Auto-check skipped — updater not available");
|
||||
return;
|
||||
}
|
||||
// Respect autoDownload flag — the renderer may have disabled it via IPC
|
||||
// before this timer fires.
|
||||
if (updater.autoDownload === false) {
|
||||
console.log("[AutoUpdate] Auto-check skipped — autoDownload is disabled");
|
||||
return;
|
||||
}
|
||||
_isChecking = true;
|
||||
_lastStatus = { ..._lastStatus, isChecking: true };
|
||||
try {
|
||||
@@ -317,6 +363,34 @@ function registerHandlers(ipcMain) {
|
||||
updater.quitAndInstall(false, true);
|
||||
});
|
||||
|
||||
// ---- Get auto-update preference -----------------------------------------
|
||||
ipcMain.handle("netcatty:update:getAutoUpdate", () => {
|
||||
return { enabled: readAutoUpdatePreference() };
|
||||
});
|
||||
|
||||
// ---- Enable/disable auto-update ----------------------------------------
|
||||
let _prevAutoDownloadEnabled = readAutoUpdatePreference();
|
||||
ipcMain.handle("netcatty:update:setAutoUpdate", (_event, { enabled }) => {
|
||||
const wasEnabled = _prevAutoDownloadEnabled;
|
||||
_prevAutoDownloadEnabled = !!enabled;
|
||||
const updater = getAutoUpdater();
|
||||
if (updater) {
|
||||
updater.autoDownload = !!enabled;
|
||||
console.log("[AutoUpdate] autoDownload set to:", !!enabled);
|
||||
}
|
||||
// Persist so the preference survives app restarts
|
||||
writeAutoUpdatePreference(!!enabled);
|
||||
if (!enabled) {
|
||||
cancelAutoCheck();
|
||||
} else if (!wasEnabled && !_isChecking) {
|
||||
// Only re-schedule when actually re-enabling (not on every mount sync),
|
||||
// to avoid duplicate checks from multiple windows initializing.
|
||||
// Skip if a check is already in flight to prevent concurrent calls.
|
||||
startAutoCheck(2000);
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
console.log("[AutoUpdate] Handlers registered");
|
||||
}
|
||||
|
||||
|
||||
813
electron/bridges/mcpServerBridge.cjs
Normal file
813
electron/bridges/mcpServerBridge.cjs
Normal file
@@ -0,0 +1,813 @@
|
||||
/**
|
||||
* MCP Server Bridge — TCP host in Electron main process
|
||||
*
|
||||
* Starts a local TCP server that the netcatty-mcp-server.cjs child process
|
||||
* connects to. Handles JSON-RPC calls by dispatching to real SSH sessions
|
||||
* and SFTP clients.
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const net = require("node:net");
|
||||
const crypto = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const { existsSync } = require("node:fs");
|
||||
|
||||
const { toUnpackedAsarPath } = require("./ai/shellUtils.cjs");
|
||||
const { execViaPty, execViaChannel } = require("./ai/ptyExec.cjs");
|
||||
|
||||
let sessions = null; // Map<sessionId, { sshClient, stream, pty, conn, ... }>
|
||||
let sftpClients = null; // Map<sftpId, SFTPWrapper>
|
||||
let tcpServer = null;
|
||||
let tcpPort = null;
|
||||
let authToken = null; // Random token generated when TCP server starts
|
||||
|
||||
// Track which sockets have completed authentication
|
||||
const authenticatedSockets = new WeakSet();
|
||||
|
||||
/**
|
||||
* Safely quote a string for use in a POSIX shell command.
|
||||
* Wraps the value in single quotes and escapes any embedded single quotes.
|
||||
*/
|
||||
function shellQuote(s) {
|
||||
return "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
// Per-scope metadata: chatSessionId → { sessionIds: string[], metadata: Map<sessionId, meta> }
|
||||
// Each chat session only sees the hosts registered for its scope.
|
||||
const scopedMetadata = new Map();
|
||||
|
||||
// Fallback: last-registered scope (used when no chatSessionId is provided)
|
||||
let fallbackScopedSessionIds = [];
|
||||
|
||||
// Command safety checking (reuse from aiBridge)
|
||||
let commandBlocklist = [];
|
||||
// Cached compiled RegExp objects for commandBlocklist (rebuilt when blocklist changes)
|
||||
let compiledBlocklist = [];
|
||||
|
||||
// Command timeout in milliseconds (default 60s, synced from user settings)
|
||||
let commandTimeoutMs = 60000;
|
||||
|
||||
// Max iterations for AI agent loops (default 20, synced from user settings)
|
||||
let maxIterations = 20;
|
||||
|
||||
// Permission mode: 'observer' | 'confirm' | 'autonomous' (synced from user settings)
|
||||
let permissionMode = "confirm";
|
||||
|
||||
// Track active PTY executions for cancellation
|
||||
const activePtyExecs = new Map(); // marker → { ptyStream, cleanup }
|
||||
|
||||
function cancelAllPtyExecs() {
|
||||
for (const [marker, entry] of activePtyExecs) {
|
||||
try {
|
||||
entry.cleanup();
|
||||
// Send Ctrl+C to kill the running command
|
||||
if (entry.ptyStream && typeof entry.ptyStream.write === "function") {
|
||||
entry.ptyStream.write("\x03");
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
activePtyExecs.clear();
|
||||
}
|
||||
|
||||
function init(deps) {
|
||||
sessions = deps.sessions;
|
||||
sftpClients = deps.sftpClients;
|
||||
if (deps.commandBlocklist) {
|
||||
commandBlocklist = deps.commandBlocklist;
|
||||
}
|
||||
}
|
||||
|
||||
function setCommandBlocklist(list) {
|
||||
commandBlocklist = list || [];
|
||||
// Recompile cached regexes when blocklist changes
|
||||
compiledBlocklist = [];
|
||||
for (const pattern of commandBlocklist) {
|
||||
try {
|
||||
compiledBlocklist.push(new RegExp(pattern, "i"));
|
||||
} catch {
|
||||
compiledBlocklist.push(null); // placeholder for invalid patterns
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setCommandTimeout(seconds) {
|
||||
commandTimeoutMs = Math.max(1, Math.min(3600, seconds || 60)) * 1000;
|
||||
}
|
||||
|
||||
function getCommandTimeoutMs() {
|
||||
return commandTimeoutMs;
|
||||
}
|
||||
|
||||
function setMaxIterations(value) {
|
||||
maxIterations = Math.max(1, Math.min(100, value || 20));
|
||||
}
|
||||
|
||||
function getMaxIterations() {
|
||||
return maxIterations;
|
||||
}
|
||||
|
||||
function setPermissionMode(mode) {
|
||||
if (mode === "observer" || mode === "confirm" || mode === "autonomous") {
|
||||
permissionMode = mode;
|
||||
}
|
||||
}
|
||||
|
||||
function getPermissionMode() {
|
||||
return permissionMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register metadata for terminal sessions (called from renderer via IPC).
|
||||
* Metadata is stored per-scope (chatSessionId) so different AI chat sessions
|
||||
* only see their own hosts.
|
||||
* @param {Array<{sessionId, hostname, label, os, username, connected}>} sessionList
|
||||
* @param {string} [chatSessionId] - AI chat session ID for per-scope isolation
|
||||
*/
|
||||
function updateSessionMetadata(sessionList, chatSessionId) {
|
||||
const ids = sessionList.map(s => s.sessionId);
|
||||
const metaMap = new Map();
|
||||
for (const s of sessionList) {
|
||||
metaMap.set(s.sessionId, {
|
||||
hostname: s.hostname || "",
|
||||
label: s.label || "",
|
||||
os: s.os || "",
|
||||
username: s.username || "",
|
||||
connected: s.connected !== false,
|
||||
});
|
||||
}
|
||||
|
||||
// Store per-scope metadata when chatSessionId is provided
|
||||
if (chatSessionId) {
|
||||
scopedMetadata.set(chatSessionId, { sessionIds: ids, metadata: metaMap });
|
||||
} else {
|
||||
// Only update fallback when no chatSessionId — prevents scoped updates from
|
||||
// leaking all sessions to unscoped agents
|
||||
fallbackScopedSessionIds = ids.slice();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scoped session IDs. If chatSessionId is provided, returns IDs for that
|
||||
* specific scope; otherwise returns the last-registered fallback.
|
||||
*/
|
||||
function getScopedSessionIds(chatSessionId) {
|
||||
if (chatSessionId) {
|
||||
const scoped = scopedMetadata.get(chatSessionId);
|
||||
if (scoped) return scoped.sessionIds;
|
||||
}
|
||||
return fallbackScopedSessionIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up metadata for a sessionId, scoped to a specific chat session.
|
||||
* Falls back to session object properties if no scoped metadata is found.
|
||||
*/
|
||||
function getSessionMeta(sessionId, chatSessionId) {
|
||||
// Try scoped metadata first
|
||||
if (chatSessionId) {
|
||||
const scoped = scopedMetadata.get(chatSessionId);
|
||||
if (scoped?.metadata?.has(sessionId)) return scoped.metadata.get(sessionId);
|
||||
}
|
||||
// Fallback: check all scopes for this sessionId (backwards compat)
|
||||
for (const [, scope] of scopedMetadata) {
|
||||
if (scope.metadata?.has(sessionId)) return scope.metadata.get(sessionId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an array of async task factories with a concurrency limit.
|
||||
*/
|
||||
async function limitConcurrency(tasks, limit) {
|
||||
const results = [];
|
||||
const executing = new Set();
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
const p = task().then(r => { results[i] = r; }).finally(() => executing.delete(p));
|
||||
executing.add(p);
|
||||
if (executing.size >= limit) {
|
||||
await Promise.race(executing);
|
||||
}
|
||||
}
|
||||
await Promise.all(executing);
|
||||
return results;
|
||||
}
|
||||
|
||||
function checkCommandSafety(command) {
|
||||
for (let i = 0; i < compiledBlocklist.length; i++) {
|
||||
const re = compiledBlocklist[i];
|
||||
if (re && re.test(command)) {
|
||||
return { blocked: true, matchedPattern: commandBlocklist[i] };
|
||||
}
|
||||
}
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
// ── TCP Server ──
|
||||
|
||||
function getOrCreateHost() {
|
||||
if (tcpServer && tcpPort) return Promise.resolve(tcpPort);
|
||||
|
||||
// Generate a random auth token for this server instance
|
||||
authToken = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer((socket) => {
|
||||
handleConnection(socket);
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
tcpPort = server.address().port;
|
||||
tcpServer = server;
|
||||
resolve(tcpPort);
|
||||
});
|
||||
|
||||
server.on("error", (err) => {
|
||||
console.error("[MCP Bridge] TCP server error:", err.message);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const MAX_TCP_BUFFER = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
function handleConnection(socket) {
|
||||
let buffer = "";
|
||||
socket.setEncoding("utf-8");
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
if (buffer.length + chunk.length > MAX_TCP_BUFFER) {
|
||||
console.error("[MCP Bridge] TCP buffer exceeded max size, dropping connection");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
buffer += chunk;
|
||||
let newlineIdx;
|
||||
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
|
||||
const line = buffer.slice(0, newlineIdx);
|
||||
buffer = buffer.slice(newlineIdx + 1);
|
||||
if (!line.trim()) continue;
|
||||
handleMessage(socket, line);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
// Client disconnected — nothing to do
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMessage(socket, line) {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, method, params } = msg;
|
||||
if (id == null || !method) return;
|
||||
|
||||
// ── Authentication gate ──
|
||||
// The first message from any connection MUST be auth/verify with the correct token.
|
||||
// All other methods are rejected until the socket is authenticated.
|
||||
if (!authenticatedSockets.has(socket)) {
|
||||
if (method === "auth/verify" && params?.token === authToken) {
|
||||
authenticatedSockets.add(socket);
|
||||
const response = JSON.stringify({ jsonrpc: "2.0", id, result: { ok: true } }) + "\n";
|
||||
if (!socket.destroyed) socket.write(response);
|
||||
return;
|
||||
}
|
||||
// Wrong token or wrong method — reject and close
|
||||
const response = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: { code: -32001, message: "Authentication required. Send auth/verify with valid token first." },
|
||||
}) + "\n";
|
||||
if (!socket.destroyed) {
|
||||
socket.write(response);
|
||||
socket.destroy();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await dispatch(method, params || {});
|
||||
const response = JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n";
|
||||
if (!socket.destroyed) socket.write(response);
|
||||
} catch (err) {
|
||||
const response = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: { code: -32000, message: err?.message || String(err) },
|
||||
}) + "\n";
|
||||
if (!socket.destroyed) socket.write(response);
|
||||
}
|
||||
}
|
||||
|
||||
// ── RPC Dispatch ──
|
||||
|
||||
// Methods that modify remote state — blocked in observer mode
|
||||
const WRITE_METHODS = new Set([
|
||||
"netcatty/exec",
|
||||
"netcatty/terminalWrite",
|
||||
"netcatty/sftpWrite",
|
||||
"netcatty/sftpMkdir",
|
||||
"netcatty/sftpRemove",
|
||||
"netcatty/sftpRename",
|
||||
"netcatty/multiExec",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate that a sessionId is allowed in the current scope.
|
||||
* Checks both process-level SCOPED_SESSION_IDS and per-chatSession scoped metadata.
|
||||
*/
|
||||
function validateSessionScope(sessionId, chatSessionId) {
|
||||
if (!sessionId) return null; // will fail at handler level
|
||||
const scopedIds = getScopedSessionIds(chatSessionId);
|
||||
if (scopedIds && scopedIds.length > 0 && !scopedIds.includes(sessionId)) {
|
||||
return `Session "${sessionId}" is not in the current scope.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function dispatch(method, params) {
|
||||
// Observer mode: block all write operations
|
||||
if (permissionMode === "observer" && WRITE_METHODS.has(method)) {
|
||||
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.` };
|
||||
}
|
||||
|
||||
// Scope validation for session-targeted operations
|
||||
if (method !== "netcatty/getContext" && params?.sessionId) {
|
||||
const scopeErr = validateSessionScope(params.sessionId, params?.chatSessionId);
|
||||
if (scopeErr) return { ok: false, error: scopeErr };
|
||||
}
|
||||
// For multi-exec, validate all session IDs
|
||||
if (method === "netcatty/multiExec" && Array.isArray(params?.sessionIds)) {
|
||||
for (const sid of params.sessionIds) {
|
||||
const scopeErr = validateSessionScope(sid, params?.chatSessionId);
|
||||
if (scopeErr) return { ok: false, error: scopeErr };
|
||||
}
|
||||
}
|
||||
|
||||
switch (method) {
|
||||
case "netcatty/getContext":
|
||||
return handleGetContext(params);
|
||||
case "netcatty/exec":
|
||||
return handleExec(params);
|
||||
case "netcatty/terminalWrite":
|
||||
return handleTerminalWrite(params);
|
||||
case "netcatty/sftpList":
|
||||
return handleSftpList(params);
|
||||
case "netcatty/sftpRead":
|
||||
return handleSftpRead(params);
|
||||
case "netcatty/sftpWrite":
|
||||
return handleSftpWrite(params);
|
||||
case "netcatty/sftpMkdir":
|
||||
return handleSftpMkdir(params);
|
||||
case "netcatty/sftpRemove":
|
||||
return handleSftpRemove(params);
|
||||
case "netcatty/sftpRename":
|
||||
return handleSftpRename(params);
|
||||
case "netcatty/sftpStat":
|
||||
return handleSftpStat(params);
|
||||
case "netcatty/multiExec":
|
||||
return handleMultiExec(params);
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handler: getContext ──
|
||||
|
||||
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 hosts = [];
|
||||
// When scope param is provided (even if empty Set), enforce it strictly
|
||||
if (hasScopeParam && scopedIds.size === 0) {
|
||||
return {
|
||||
environment: "netcatty-terminal",
|
||||
description: "No hosts are available in the current scope.",
|
||||
hosts: [],
|
||||
hostCount: 0,
|
||||
};
|
||||
}
|
||||
for (const [sessionId, session] of sessions.entries()) {
|
||||
if (scopedIds && !scopedIds.has(sessionId)) continue;
|
||||
// Only include SSH sessions (skip local terminal sessions)
|
||||
const sshClient = session.conn || session.sshClient;
|
||||
if (!sshClient || typeof sshClient.exec !== "function") continue;
|
||||
|
||||
// Look up metadata scoped to this chat session
|
||||
const meta = getSessionMeta(sessionId, chatSessionId) || {};
|
||||
hosts.push({
|
||||
sessionId,
|
||||
hostname: meta.hostname || session.hostname || "",
|
||||
label: meta.label || session.label || "",
|
||||
os: meta.os || "",
|
||||
username: meta.username || session.username || "",
|
||||
connected: meta.connected !== undefined ? meta.connected : !!(session.sshClient || session.conn),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
environment: "netcatty-terminal",
|
||||
description: "You are operating inside Netcatty, a multi-host SSH terminal manager. " +
|
||||
"The user is managing remote servers. Use the provided tools to execute commands, " +
|
||||
"read/write files, and manage hosts on the remote machines. " +
|
||||
"Always prefer these tools over suggesting the user to do things manually.",
|
||||
hosts,
|
||||
hostCount: hosts.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Handler: exec ──
|
||||
|
||||
function handleExec(params) {
|
||||
const { sessionId, command } = params;
|
||||
if (!sessionId || !command) throw new Error("sessionId and command are required");
|
||||
if (typeof command !== 'string' || !command.trim()) {
|
||||
return { ok: false, error: 'Invalid command', exitCode: 1 };
|
||||
}
|
||||
|
||||
const safety = checkCommandSafety(command);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session) return { ok: false, error: "Session not found" };
|
||||
|
||||
const sshClient = session.conn || session.sshClient;
|
||||
if (!sshClient || typeof sshClient.exec !== "function") {
|
||||
return { ok: false, error: "Not an SSH session" };
|
||||
}
|
||||
|
||||
const ptyStream = session.stream;
|
||||
|
||||
// If no PTY stream, fall back to exec channel (invisible to terminal)
|
||||
if (!ptyStream || typeof ptyStream.write !== "function") {
|
||||
return execViaChannel(sshClient, command, { timeoutMs: commandTimeoutMs });
|
||||
}
|
||||
|
||||
// Execute via PTY stream so user sees the command in the terminal
|
||||
return execViaPty(ptyStream, command, {
|
||||
trackForCancellation: activePtyExecs,
|
||||
timeoutMs: commandTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Handler: terminalWrite ──
|
||||
|
||||
function handleTerminalWrite(params) {
|
||||
const { sessionId, input } = params;
|
||||
if (!sessionId || input == null) throw new Error("sessionId and input are required");
|
||||
|
||||
// Validate input against command blocklist
|
||||
const safety = checkCommandSafety(input);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Input blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session) return { ok: false, error: "Session not found" };
|
||||
|
||||
if (session.stream) {
|
||||
session.stream.write(input);
|
||||
return { ok: true };
|
||||
}
|
||||
if (session.pty) {
|
||||
session.pty.write(input);
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, error: "No writable stream" };
|
||||
}
|
||||
|
||||
// ── SFTP Helpers ──
|
||||
|
||||
function findSftpForSession(sessionId) {
|
||||
// Try to find an SFTP client keyed by the same sessionId
|
||||
if (sftpClients?.has(sessionId)) {
|
||||
return sftpClients.get(sessionId);
|
||||
}
|
||||
// Look through all SFTP clients for one sharing the same SSH connection
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session?.sshClient) return null;
|
||||
|
||||
for (const [, client] of sftpClients || []) {
|
||||
if (client.client === session.sshClient || client._sshClient === session.sshClient) {
|
||||
return client;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Handler: sftpList ──
|
||||
|
||||
async function handleSftpList(params) {
|
||||
const { sessionId, path: dirPath } = params;
|
||||
if (!sessionId || !dirPath) throw new Error("sessionId and path are required");
|
||||
|
||||
const sftpClient = findSftpForSession(sessionId);
|
||||
if (sftpClient) {
|
||||
try {
|
||||
const list = await sftpClient.list(dirPath);
|
||||
return {
|
||||
files: list.map(f => ({
|
||||
name: f.name,
|
||||
type: f.type === "d" ? "directory" : f.type === "l" ? "symlink" : "file",
|
||||
size: f.size,
|
||||
lastModified: f.modifyTime,
|
||||
permissions: f.rights ? `${f.rights.user}${f.rights.group}${f.rights.other}` : undefined,
|
||||
})),
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use SSH exec
|
||||
const result = await handleExec({ sessionId, command: `ls -la ${shellQuote(dirPath)}` });
|
||||
if (!result.ok) return { ok: false, error: result.error };
|
||||
return { output: result.stdout || "(empty directory)" };
|
||||
}
|
||||
|
||||
// ── Handler: sftpRead ──
|
||||
|
||||
async function handleSftpRead(params) {
|
||||
const { sessionId, path: filePath } = params;
|
||||
if (params.maxBytes != null && (typeof params.maxBytes !== 'number' || params.maxBytes < 1 || params.maxBytes > 10 * 1024 * 1024)) {
|
||||
return { ok: false, error: 'maxBytes must be a positive number between 1 and 10485760' };
|
||||
}
|
||||
// Clamp maxBytes to a safe upper bound (10MB)
|
||||
const maxBytes = Math.max(1, Math.min(Number(params.maxBytes) || 10000, 10 * 1024 * 1024));
|
||||
if (!sessionId || !filePath) throw new Error("sessionId and path are required");
|
||||
|
||||
// Fallback to SSH exec (more reliable across SFTP client states)
|
||||
const result = await handleExec({ sessionId, command: `head -c ${maxBytes} ${shellQuote(filePath)}` });
|
||||
if (!result.ok) return { ok: false, error: result.error };
|
||||
return { content: result.stdout || "(empty file)" };
|
||||
}
|
||||
|
||||
// ── Handler: sftpWrite ──
|
||||
|
||||
async function handleSftpWrite(params) {
|
||||
const { sessionId, path: filePath, content } = params;
|
||||
if (!sessionId || !filePath || content == null) throw new Error("sessionId, path and content are required");
|
||||
|
||||
const sftpClient = findSftpForSession(sessionId);
|
||||
if (sftpClient) {
|
||||
try {
|
||||
await sftpClient.put(Buffer.from(content, "utf-8"), filePath);
|
||||
return { written: filePath };
|
||||
} catch {
|
||||
// Fallback to SSH
|
||||
}
|
||||
}
|
||||
|
||||
// Use base64 encoding to avoid heredoc delimiter collision issues
|
||||
const b64 = Buffer.from(content, "utf-8").toString("base64");
|
||||
const result = await handleExec({ sessionId, command: `echo ${shellQuote(b64)} | base64 -d > ${shellQuote(filePath)}` });
|
||||
if (!result.ok) return { ok: false, error: result.error };
|
||||
return { written: filePath };
|
||||
}
|
||||
|
||||
// ── Handler: sftpMkdir ──
|
||||
|
||||
async function handleSftpMkdir(params) {
|
||||
const { sessionId, path: dirPath } = params;
|
||||
if (!sessionId || !dirPath) throw new Error("sessionId and path are required");
|
||||
|
||||
const sftpClient = findSftpForSession(sessionId);
|
||||
if (sftpClient) {
|
||||
try {
|
||||
await sftpClient.mkdir(dirPath, true); // recursive
|
||||
return { created: dirPath };
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
|
||||
const result = await handleExec({ sessionId, command: `mkdir -p ${shellQuote(dirPath)}` });
|
||||
if (!result.ok) return { ok: false, error: result.error };
|
||||
return { created: dirPath };
|
||||
}
|
||||
|
||||
// ── Handler: sftpRemove ──
|
||||
|
||||
// Critical paths that must never be removed (module-level constant)
|
||||
const CRITICAL_PATHS = new Set([
|
||||
"/", "/root", "/home", "/etc", "/var", "/usr", "/boot",
|
||||
"/bin", "/sbin", "/lib", "/lib64", "/dev", "/proc", "/sys", "/tmp", "/opt",
|
||||
]);
|
||||
|
||||
async function handleSftpRemove(params) {
|
||||
const { sessionId, path: targetPath } = params;
|
||||
if (!sessionId || !targetPath) throw new Error("sessionId and path are required");
|
||||
|
||||
// Guard against deleting root or critical system directories
|
||||
// Normalize to resolve "..", "//", and trailing slashes before checking
|
||||
const normalizedPath = path.posix.normalize(targetPath).replace(/\/+$/, "") || "/";
|
||||
if (CRITICAL_PATHS.has(normalizedPath) || /^\/[^/]+$/.test(normalizedPath)) {
|
||||
return { ok: false, error: `Refusing to remove critical or root-level path: ${targetPath}` };
|
||||
}
|
||||
|
||||
// Use rm -r (without -f) so permission errors surface instead of being silently ignored
|
||||
const result = await handleExec({ sessionId, command: `rm -r ${shellQuote(targetPath)}` });
|
||||
if (!result.ok) return { ok: false, error: result.error };
|
||||
return { removed: targetPath };
|
||||
}
|
||||
|
||||
// ── Handler: sftpRename ──
|
||||
|
||||
async function handleSftpRename(params) {
|
||||
const { sessionId, oldPath, newPath } = params;
|
||||
if (!sessionId || !oldPath || !newPath) throw new Error("sessionId, oldPath and newPath are required");
|
||||
|
||||
const sftpClient = findSftpForSession(sessionId);
|
||||
if (sftpClient) {
|
||||
try {
|
||||
await sftpClient.rename(oldPath, newPath);
|
||||
return { renamed: `${oldPath} → ${newPath}` };
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
|
||||
const result = await handleExec({ sessionId, command: `mv ${shellQuote(oldPath)} ${shellQuote(newPath)}` });
|
||||
if (!result.ok) return { ok: false, error: result.error };
|
||||
return { renamed: `${oldPath} → ${newPath}` };
|
||||
}
|
||||
|
||||
// ── Handler: sftpStat ──
|
||||
|
||||
async function handleSftpStat(params) {
|
||||
const { sessionId, path: targetPath } = params;
|
||||
if (!sessionId || !targetPath) throw new Error("sessionId and path are required");
|
||||
|
||||
const sftpClient = findSftpForSession(sessionId);
|
||||
if (sftpClient) {
|
||||
try {
|
||||
const stat = await sftpClient.stat(targetPath);
|
||||
return {
|
||||
name: path.basename(targetPath),
|
||||
type: stat.isDirectory ? "directory" : stat.isSymbolicLink ? "symlink" : "file",
|
||||
size: stat.size,
|
||||
lastModified: stat.modifyTime,
|
||||
permissions: stat.mode ? (stat.mode & 0o777).toString(8) : undefined,
|
||||
};
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use stat command
|
||||
const result = await handleExec({ sessionId, command: `stat -c '{"size":%s,"mode":"%a","mtime":%Y,"type":"%F"}' ${shellQuote(targetPath)}` });
|
||||
if (!result.ok) return { ok: false, error: result.error };
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout.trim());
|
||||
return {
|
||||
name: path.basename(targetPath),
|
||||
type: parsed.type?.includes("directory") ? "directory" : "file",
|
||||
size: parsed.size,
|
||||
lastModified: parsed.mtime * 1000,
|
||||
permissions: parsed.mode,
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, error: "Failed to parse stat output" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handler: multiExec ──
|
||||
|
||||
async function handleMultiExec(params) {
|
||||
const { sessionIds, command, mode = "parallel", stopOnError = false } = params;
|
||||
if (!Array.isArray(sessionIds) || !command) throw new Error("sessionIds and command are required");
|
||||
if (sessionIds.length > 50) {
|
||||
return { ok: false, error: 'Too many session IDs: maximum is 50' };
|
||||
}
|
||||
if (typeof command !== 'string' || !command.trim()) {
|
||||
return { ok: false, error: 'Invalid command' };
|
||||
}
|
||||
|
||||
const safety = checkCommandSafety(command);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
|
||||
const results = {};
|
||||
|
||||
if (mode === "sequential") {
|
||||
for (const sid of sessionIds) {
|
||||
const result = await handleExec({ sessionId: sid, command });
|
||||
results[sid] = {
|
||||
ok: result.ok,
|
||||
output: result.ok ? (result.stdout || "(no output)") : `Error: ${result.error || result.stderr || "Failed"}`,
|
||||
};
|
||||
if (!result.ok && stopOnError) break;
|
||||
}
|
||||
} else {
|
||||
// Parallel execution with concurrency limit
|
||||
const tasks = sessionIds.map((sid) => () => {
|
||||
return handleExec({ sessionId: sid, command }).then(result => ({
|
||||
sid,
|
||||
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.sid] = { ok: r.ok, output: r.output };
|
||||
}
|
||||
}
|
||||
|
||||
return { results };
|
||||
}
|
||||
|
||||
// ── MCP Server Config Builder ──
|
||||
|
||||
function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
|
||||
// Use provided scoped IDs, or resolve from chatSessionId, or fall back
|
||||
const effectiveIds = (scopedSessionIds && scopedSessionIds.length > 0)
|
||||
? scopedSessionIds
|
||||
: getScopedSessionIds(chatSessionId);
|
||||
|
||||
const runtimePath = toUnpackedAsarPath(
|
||||
path.join(__dirname, "..", "mcp", "netcatty-mcp-server.cjs"),
|
||||
);
|
||||
|
||||
const env = [
|
||||
{ name: "NETCATTY_MCP_PORT", value: String(port) },
|
||||
];
|
||||
|
||||
if (authToken) {
|
||||
env.push({ name: "NETCATTY_MCP_TOKEN", value: authToken });
|
||||
}
|
||||
|
||||
if (effectiveIds && effectiveIds.length > 0) {
|
||||
env.push({ name: "NETCATTY_MCP_SESSION_IDS", value: effectiveIds.join(",") });
|
||||
}
|
||||
|
||||
// Pass chatSessionId so MCP server can scope getContext responses
|
||||
if (chatSessionId) {
|
||||
env.push({ name: "NETCATTY_MCP_CHAT_SESSION_ID", value: chatSessionId });
|
||||
}
|
||||
|
||||
// Pass permission mode so MCP server can enforce it locally (defense-in-depth)
|
||||
env.push({ name: "NETCATTY_MCP_PERMISSION_MODE", value: permissionMode });
|
||||
|
||||
return {
|
||||
name: "netcatty-remote-hosts",
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: [runtimePath],
|
||||
env,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Cleanup ──
|
||||
|
||||
function cleanupScopedMetadata(chatSessionId) {
|
||||
if (chatSessionId) {
|
||||
scopedMetadata.delete(chatSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (tcpServer) {
|
||||
tcpServer.close();
|
||||
tcpServer = null;
|
||||
tcpPort = null;
|
||||
}
|
||||
scopedMetadata.clear();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
setCommandBlocklist,
|
||||
setCommandTimeout,
|
||||
getCommandTimeoutMs,
|
||||
setMaxIterations,
|
||||
getMaxIterations,
|
||||
setPermissionMode,
|
||||
getPermissionMode,
|
||||
checkCommandSafety,
|
||||
updateSessionMetadata,
|
||||
getScopedSessionIds,
|
||||
getOrCreateHost,
|
||||
buildMcpServerConfig,
|
||||
cancelAllPtyExecs,
|
||||
cleanupScopedMetadata,
|
||||
cleanup,
|
||||
};
|
||||
@@ -123,24 +123,46 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
const WIN_SSH_AGENT_PIPE = "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
|
||||
/**
|
||||
* Check if Windows SSH Agent service is running
|
||||
* 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.
|
||||
* 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;
|
||||
}
|
||||
exec("sc query ssh-agent", (err, stdout) => {
|
||||
if (err) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
resolve(stdout.includes("RUNNING"));
|
||||
});
|
||||
});
|
||||
if (process.platform !== "win32") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return windowsPipeConnectable(WIN_SSH_AGENT_PIPE);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -140,29 +140,36 @@ async function findAllDefaultPrivateKeys() {
|
||||
return keys;
|
||||
}
|
||||
|
||||
const WIN_SSH_AGENT_PIPE = "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
|
||||
/**
|
||||
* Check if Windows SSH Agent service is running
|
||||
* 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;
|
||||
}
|
||||
exec("sc query ssh-agent", (err, stdout) => {
|
||||
if (err) {
|
||||
resolve({ running: false, startupType: null, error: "SSH Agent service not found" });
|
||||
return;
|
||||
}
|
||||
const running = stdout.includes("RUNNING");
|
||||
const stopped = stdout.includes("STOPPED");
|
||||
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,
|
||||
startupType: stopped ? "stopped" : (running ? "running" : "unknown"),
|
||||
error: null,
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -170,7 +177,7 @@ async function getAvailableAgentSocket() {
|
||||
if (process.platform === "win32") {
|
||||
const agentStatus = await checkWindowsSshAgent();
|
||||
log("Windows SSH Agent check", agentStatus);
|
||||
return agentStatus.running ? "\\\\.\\pipe\\openssh-ssh-agent" : null;
|
||||
return agentStatus.running ? WIN_SSH_AGENT_PIPE : null;
|
||||
}
|
||||
|
||||
return getSshAgentSocket();
|
||||
@@ -963,6 +970,10 @@ async function startSSHSession(event, options) {
|
||||
stream,
|
||||
chainConnections,
|
||||
webContentsId: event.sender.id,
|
||||
// Store connection info for MCP host discovery
|
||||
hostname: options.host || options.hostname || '',
|
||||
username: options.username || '',
|
||||
label: options.label || '',
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
|
||||
@@ -808,13 +808,23 @@ async function createWindow(electronModule, options) {
|
||||
closeSettingsWindow();
|
||||
});
|
||||
|
||||
const safeSend = (channel, ...args) => {
|
||||
try {
|
||||
if (!win.isDestroyed() && win.webContents && !win.webContents.isDestroyed()) {
|
||||
win.webContents.send(channel, ...args);
|
||||
}
|
||||
} catch {
|
||||
// Render frame disposed during HMR / reload – safe to ignore
|
||||
}
|
||||
};
|
||||
|
||||
win.on("enter-full-screen", () => {
|
||||
win.webContents?.send("netcatty:window:fullscreen-changed", true);
|
||||
safeSend("netcatty:window:fullscreen-changed", true);
|
||||
scheduleSaveState();
|
||||
});
|
||||
|
||||
win.on("leave-full-screen", () => {
|
||||
win.webContents?.send("netcatty:window:fullscreen-changed", false);
|
||||
safeSend("netcatty:window:fullscreen-changed", false);
|
||||
updateNormalBounds();
|
||||
scheduleSaveState();
|
||||
});
|
||||
@@ -859,11 +869,14 @@ async function createWindow(electronModule, options) {
|
||||
// Register window control handlers
|
||||
registerWindowHandlers(electronModule.ipcMain, nativeTheme);
|
||||
|
||||
// Register IPC handlers BEFORE loading any URL so the renderer never
|
||||
// calls a handler that hasn't been registered yet.
|
||||
onRegisterBridge?.(win);
|
||||
|
||||
if (isDev) {
|
||||
try {
|
||||
await win.loadURL(getDevRendererBaseUrl(devServerUrl));
|
||||
win.webContents.openDevTools({ mode: "detach" });
|
||||
onRegisterBridge?.(win);
|
||||
return win;
|
||||
} catch (e) {
|
||||
console.warn("Dev server not reachable, falling back to bundled dist.", e);
|
||||
@@ -872,8 +885,6 @@ async function createWindow(electronModule, options) {
|
||||
|
||||
// Production mode - load via custom protocol.
|
||||
await win.loadURL("app://netcatty/index.html");
|
||||
|
||||
onRegisterBridge?.(win);
|
||||
return win;
|
||||
}
|
||||
|
||||
@@ -977,12 +988,22 @@ async function openSettingsWindow(electronModule, options) {
|
||||
}
|
||||
}
|
||||
|
||||
const safeSend = (channel, ...args) => {
|
||||
try {
|
||||
if (!win.isDestroyed() && win.webContents && !win.webContents.isDestroyed()) {
|
||||
win.webContents.send(channel, ...args);
|
||||
}
|
||||
} catch {
|
||||
// Render frame disposed during HMR / reload – safe to ignore
|
||||
}
|
||||
};
|
||||
|
||||
win.on("enter-full-screen", () => {
|
||||
win.webContents?.send("netcatty:window:fullscreen-changed", true);
|
||||
safeSend("netcatty:window:fullscreen-changed", true);
|
||||
});
|
||||
|
||||
win.on("leave-full-screen", () => {
|
||||
win.webContents?.send("netcatty:window:fullscreen-changed", false);
|
||||
safeSend("netcatty:window:fullscreen-changed", false);
|
||||
});
|
||||
|
||||
// Ensure native background matches frontend background, even before first paint.
|
||||
|
||||
@@ -6,3 +6,12 @@ delete env.ELECTRON_RUN_AS_NODE;
|
||||
|
||||
const child = spawn(electronPath, ["."], { stdio: "inherit", env });
|
||||
child.on("exit", (code) => process.exit(code ?? 0));
|
||||
|
||||
// Forward SIGINT/SIGTERM to the Electron child process so Ctrl+C works
|
||||
for (const sig of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(sig, () => {
|
||||
if (!child.killed) {
|
||||
child.kill(sig);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
|
||||
const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
|
||||
const credentialBridge = require("./bridges/credentialBridge.cjs");
|
||||
const autoUpdateBridge = require("./bridges/autoUpdateBridge.cjs");
|
||||
const aiBridge = require("./bridges/aiBridge.cjs");
|
||||
const windowManager = require("./bridges/windowManager.cjs");
|
||||
|
||||
// GPU settings
|
||||
@@ -378,7 +379,8 @@ const registerBridges = (win) => {
|
||||
terminalBridge.init(deps);
|
||||
fileWatcherBridge.init(deps);
|
||||
globalShortcutBridge.init(deps);
|
||||
|
||||
aiBridge.init(deps);
|
||||
|
||||
// Initialize compress upload bridge with transferBridge dependency
|
||||
compressUploadBridge.init({
|
||||
...deps,
|
||||
@@ -408,6 +410,7 @@ const registerBridges = (win) => {
|
||||
credentialBridge.registerHandlers(ipcMain, electronModule);
|
||||
autoUpdateBridge.init(deps);
|
||||
autoUpdateBridge.registerHandlers(ipcMain);
|
||||
aiBridge.registerHandlers(ipcMain);
|
||||
|
||||
// Settings window handler
|
||||
ipcMain.handle("netcatty:settings:open", async () => {
|
||||
@@ -861,6 +864,11 @@ if (!gotLock) {
|
||||
} catch (err) {
|
||||
console.warn("Error during global shortcut cleanup:", err);
|
||||
}
|
||||
try {
|
||||
aiBridge.cleanup();
|
||||
} catch (err) {
|
||||
console.warn("Error during AI bridge cleanup:", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
431
electron/mcp/netcatty-mcp-server.cjs
Normal file
431
electron/mcp/netcatty-mcp-server.cjs
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Netcatty MCP Server (stdio transport)
|
||||
*
|
||||
* Spawned by codex-acp (or other ACP agents) as a child process.
|
||||
* Communicates with the Netcatty main process via TCP (JSON-RPC over newline-delimited JSON).
|
||||
* Exposes SSH terminal and SFTP tools so ACP agents can operate on remote hosts.
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const net = require("node:net");
|
||||
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
|
||||
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
|
||||
const { z } = require("zod");
|
||||
|
||||
// ── TCP Bridge to Netcatty main process ──
|
||||
|
||||
const NETCATTY_MCP_PORT = parseInt(process.env.NETCATTY_MCP_PORT, 10);
|
||||
if (!NETCATTY_MCP_PORT) {
|
||||
process.stderr.write("[netcatty-mcp] NETCATTY_MCP_PORT not set\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Auth token for TCP bridge authentication
|
||||
const NETCATTY_MCP_TOKEN = process.env.NETCATTY_MCP_TOKEN || "";
|
||||
if (!NETCATTY_MCP_TOKEN) {
|
||||
process.stderr.write("[netcatty-mcp] NETCATTY_MCP_TOKEN not set\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Scoped session IDs (comma-separated). When set (even if empty), only listed
|
||||
// sessions are accessible. When unset, scope enforcement falls back to the
|
||||
// TCP bridge's own scoping (which also defaults to no-access when empty).
|
||||
const SCOPED_SESSION_IDS = process.env.NETCATTY_MCP_SESSION_IDS != null
|
||||
? process.env.NETCATTY_MCP_SESSION_IDS.split(",").map(s => s.trim()).filter(Boolean)
|
||||
: null;
|
||||
|
||||
// Chat session ID for per-scope metadata isolation
|
||||
const CHAT_SESSION_ID = process.env.NETCATTY_MCP_CHAT_SESSION_ID || null;
|
||||
|
||||
// Permission mode: 'observer' | 'confirm' | 'autonomous' (defense-in-depth, TCP bridge also checks)
|
||||
const PERMISSION_MODE = process.env.NETCATTY_MCP_PERMISSION_MODE || "confirm";
|
||||
|
||||
// Default command blocklist (defense-in-depth, TCP bridge also checks)
|
||||
// NOTE: Keep in sync with DEFAULT_COMMAND_BLOCKLIST in infrastructure/ai/types.ts
|
||||
const DEFAULT_COMMAND_BLOCKLIST = [
|
||||
'\\brm\\s+(-[a-zA-Z]*r[a-zA-Z]*\\s+(-[a-zA-Z]*f[a-zA-Z]*\\s+)?|-[a-zA-Z]*f[a-zA-Z]*\\s+(-[a-zA-Z]*r[a-zA-Z]*\\s+)?|--recursive\\s+|--force\\s+){1,}',
|
||||
'\\bmkfs\\.',
|
||||
'\\bdd\\s+if=.*\\s+of=/dev/',
|
||||
'\\b(shutdown|reboot|poweroff|halt)\\b',
|
||||
':\\(\\)\\{\\s*:\\|:\\&\\s*\\};:',
|
||||
'>\\s*/dev/sd',
|
||||
'\\bchmod\\s+(-[a-zA-Z]*R[a-zA-Z]*|--recursive)\\s+777\\s+/',
|
||||
'\\bmv\\s+/\\s',
|
||||
':\\s*>\\s*/etc/',
|
||||
'\\bcurl\\s+.*\\|\\s*\\bsudo\\s+\\bbash\\b',
|
||||
'\\bwget\\s+.*\\|\\s*\\bsudo\\s+\\bbash\\b',
|
||||
];
|
||||
|
||||
// Pre-compile blocklist regexes once at module load time
|
||||
const compiledBlocklist = DEFAULT_COMMAND_BLOCKLIST.map(pattern => {
|
||||
try {
|
||||
return new RegExp(pattern, "i");
|
||||
} catch {
|
||||
return null; // placeholder for invalid patterns
|
||||
}
|
||||
});
|
||||
|
||||
function checkCommandSafety(command) {
|
||||
for (let i = 0; i < compiledBlocklist.length; i++) {
|
||||
const re = compiledBlocklist[i];
|
||||
if (re && re.test(command)) {
|
||||
return { blocked: true, matchedPattern: DEFAULT_COMMAND_BLOCKLIST[i] };
|
||||
}
|
||||
}
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
/** Guard for write tools: blocks in observer mode, checks command safety for commands. */
|
||||
function guardWriteOperation(command) {
|
||||
if (PERMISSION_MODE === "observer") {
|
||||
return 'Operation denied: permission mode is "observer" (read-only). Change to "confirm" or "autonomous" in Settings → AI → Safety to allow this action.';
|
||||
}
|
||||
if (command) {
|
||||
const safety = checkCommandSafety(command);
|
||||
if (safety.blocked) {
|
||||
return `Command blocked by safety policy. Pattern: ${safety.matchedPattern}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let tcpSocket = null;
|
||||
let pendingRequests = new Map(); // id -> { resolve, reject }
|
||||
let nextRpcId = 1;
|
||||
let tcpBuffer = "";
|
||||
|
||||
function connectTcp() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock = net.createConnection({ host: "127.0.0.1", port: NETCATTY_MCP_PORT }, () => {
|
||||
tcpSocket = sock;
|
||||
resolve();
|
||||
});
|
||||
sock.setEncoding("utf-8");
|
||||
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
sock.on("data", (chunk) => {
|
||||
tcpBuffer += chunk;
|
||||
if (tcpBuffer.length > MAX_BUFFER_SIZE) {
|
||||
process.stderr.write(`[netcatty-mcp] TCP buffer exceeded ${MAX_BUFFER_SIZE} bytes, clearing buffer\n`);
|
||||
tcpBuffer = "";
|
||||
return;
|
||||
}
|
||||
let newlineIdx;
|
||||
while ((newlineIdx = tcpBuffer.indexOf("\n")) !== -1) {
|
||||
const line = tcpBuffer.slice(0, newlineIdx);
|
||||
tcpBuffer = tcpBuffer.slice(newlineIdx + 1);
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id != null && pendingRequests.has(msg.id)) {
|
||||
const { resolve: res, reject: rej } = pendingRequests.get(msg.id);
|
||||
pendingRequests.delete(msg.id);
|
||||
if (msg.error) {
|
||||
rej(new Error(msg.error.message || JSON.stringify(msg.error)));
|
||||
} else {
|
||||
res(msg.result);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed lines
|
||||
}
|
||||
}
|
||||
});
|
||||
sock.on("error", (err) => {
|
||||
reject(err);
|
||||
// Reject all pending
|
||||
for (const { reject: rej } of pendingRequests.values()) {
|
||||
rej(new Error("TCP connection lost"));
|
||||
}
|
||||
pendingRequests.clear();
|
||||
});
|
||||
sock.on("close", () => {
|
||||
// Reject all pending requests on clean close
|
||||
for (const { reject: rej } of pendingRequests.values()) {
|
||||
rej(new Error("TCP connection closed"));
|
||||
}
|
||||
pendingRequests.clear();
|
||||
tcpSocket = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function rpcCall(method, params) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!tcpSocket || tcpSocket.destroyed) {
|
||||
return reject(new Error("Not connected to Netcatty"));
|
||||
}
|
||||
const id = nextRpcId++;
|
||||
pendingRequests.set(id, { resolve, reject });
|
||||
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
|
||||
tcpSocket.write(msg);
|
||||
});
|
||||
}
|
||||
|
||||
// ── MCP Server ──
|
||||
|
||||
const server = new McpServer({
|
||||
name: "netcatty-remote-hosts",
|
||||
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 };
|
||||
|
||||
// Resource: environment context
|
||||
server.resource(
|
||||
"environment",
|
||||
"netcatty://context",
|
||||
{ description: "Current Netcatty workspace context: connected hosts, session IDs, and environment description." },
|
||||
async () => {
|
||||
const ctx = await rpcCall("netcatty/getContext", scopeParams);
|
||||
return {
|
||||
contents: [{
|
||||
uri: "netcatty://context",
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(ctx, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: get_environment
|
||||
server.tool(
|
||||
"get_environment",
|
||||
"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`);
|
||||
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) }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: terminal_execute
|
||||
server.tool(
|
||||
"terminal_execute",
|
||||
"Execute a shell command on a remote host via SSH. The command runs in the host's shell and output (stdout/stderr) is returned when complete.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID (from get_environment) to execute on."),
|
||||
command: z.string().describe("The shell command to execute on the remote host."),
|
||||
},
|
||||
async ({ sessionId, command }) => {
|
||||
const guardErr = guardWriteOperation(command);
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/exec", { sessionId, command });
|
||||
if (!result.ok) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error || "Command failed"}` }], isError: true };
|
||||
}
|
||||
const parts = [];
|
||||
if (result.stdout) parts.push(result.stdout);
|
||||
if (result.stderr) parts.push(`[stderr] ${result.stderr}`);
|
||||
parts.push(`[exit code: ${result.exitCode ?? -1}]`);
|
||||
return { content: [{ type: "text", text: parts.join("\n") }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: terminal_send_input
|
||||
server.tool(
|
||||
"terminal_send_input",
|
||||
"Send raw input to a terminal session on a remote host. Use only for interactive programs that are already running: y/n prompts, passwords, ctrl+c (\\x03), ctrl+d (\\x04), or pressing enter (\\n). This tool does not return the updated terminal output. For normal commands, use terminal_execute.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID to send input to."),
|
||||
input: z.string().describe("The raw input string. Use escape sequences for special keys (e.g. \\x03 for ctrl+c, \\n for enter)."),
|
||||
},
|
||||
async ({ sessionId, input }) => {
|
||||
const guardErr = guardWriteOperation(input);
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/terminalWrite", { sessionId, input });
|
||||
if (!result.ok) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Sent input to session ${sessionId}` }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_list_directory
|
||||
server.tool(
|
||||
"sftp_list_directory",
|
||||
"List the contents of a directory on the remote host. Returns file names, sizes, types, and modification timestamps.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the remote directory to list."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const result = await rpcCall("netcatty/sftpList", { sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(result.files || result.output, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_read_file
|
||||
server.tool(
|
||||
"sftp_read_file",
|
||||
"Read the content of a file on the remote host. Returns file content as text, truncated if the file is large.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the remote file to read."),
|
||||
maxBytes: z.number().optional().default(10000).describe("Maximum bytes to read. Defaults to 10000."),
|
||||
},
|
||||
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 });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: result.content || "(empty file)" }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_write_file
|
||||
server.tool(
|
||||
"sftp_write_file",
|
||||
"Write content to a file on the remote host. Creates the file if it does not exist, or overwrites it.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
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."),
|
||||
},
|
||||
async ({ sessionId, path, content }) => {
|
||||
const guardErr = guardWriteOperation(path);
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpWrite", { sessionId, path, content });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Written: ${path}` }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_mkdir
|
||||
server.tool(
|
||||
"sftp_mkdir",
|
||||
"Create a directory on the remote host. Creates parent directories if they don't exist.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the directory to create."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const guardErr = guardWriteOperation();
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpMkdir", { sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Created directory: ${path}` }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_remove
|
||||
server.tool(
|
||||
"sftp_remove",
|
||||
"Delete a file or directory on the remote host. Directories are removed recursively.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path of the file or directory to delete."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const guardErr = guardWriteOperation();
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpRemove", { sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Removed: ${path}` }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_rename
|
||||
server.tool(
|
||||
"sftp_rename",
|
||||
"Rename or move a file/directory on the remote host.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
oldPath: z.string().describe("The current absolute path."),
|
||||
newPath: z.string().describe("The new absolute path."),
|
||||
},
|
||||
async ({ sessionId, oldPath, newPath }) => {
|
||||
const guardErr = guardWriteOperation();
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpRename", { sessionId, oldPath, newPath });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: `Renamed: ${oldPath} → ${newPath}` }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: sftp_stat
|
||||
server.tool(
|
||||
"sftp_stat",
|
||||
"Get file/directory metadata on the remote host: type, size, permissions, and modification time.",
|
||||
{
|
||||
sessionId: z.string().describe("The terminal session ID for the remote host."),
|
||||
path: z.string().describe("The absolute path to stat."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const result = await rpcCall("netcatty/sftpStat", { sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: multi_host_execute
|
||||
server.tool(
|
||||
"multi_host_execute",
|
||||
"Execute a command on multiple remote hosts simultaneously or sequentially. Useful for fleet-wide operations like checking status, deploying updates, or maintenance.",
|
||||
{
|
||||
sessionIds: z.array(z.string()).describe("Array of session IDs to execute on."),
|
||||
command: z.string().describe("The shell command to execute on each host."),
|
||||
mode: z.enum(["parallel", "sequential"]).optional().default("parallel").describe("Execution mode. Defaults to parallel."),
|
||||
stopOnError: z.boolean().optional().default(false).describe("In sequential mode, stop on first failure."),
|
||||
},
|
||||
async ({ sessionIds, command, mode, stopOnError }) => {
|
||||
const guardErr = guardWriteOperation(command);
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/multiExec", { sessionIds, command, mode, stopOnError });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(result.results, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
// ── Start ──
|
||||
|
||||
async function main() {
|
||||
await connectTcp();
|
||||
|
||||
// Authenticate with the TCP bridge before accepting any tool calls
|
||||
const authResult = await rpcCall("auth/verify", { token: NETCATTY_MCP_TOKEN });
|
||||
if (!authResult?.ok) {
|
||||
throw new Error("TCP bridge authentication failed");
|
||||
}
|
||||
process.stderr.write("[netcatty-mcp] Authenticated with TCP bridge\n");
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`[netcatty-mcp] Fatal: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -27,12 +27,33 @@ function cleanupTransferListeners(transferId) {
|
||||
transferCancelledListeners.delete(transferId);
|
||||
}
|
||||
|
||||
// Filter MCP marker artifacts from terminal output:
|
||||
// 1. Marker output lines (standalone): __NCMCP_xxx_S or __NCMCP_xxx_E:0
|
||||
// 2. End marker command echo: __nc=$?;printf '__NCMCP_...'
|
||||
// 3. Start marker printf prefix in echoed command: printf '__NCMCP_...\n';
|
||||
// We keep the actual command part visible.
|
||||
function filterMcpMarkers(data) {
|
||||
return data
|
||||
// Remove standalone marker output lines (printf output)
|
||||
.replace(/^__NCMCP_[^\r\n]*[\r\n]*/gm, "")
|
||||
// Remove end marker command echo lines
|
||||
.replace(/[^\r\n]*__nc=\$\?;printf '[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/g, "")
|
||||
// Remove start marker printf prefix from combined command lines
|
||||
.replace(/printf '__NCMCP_[^']*\\n';/g, "");
|
||||
}
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
const set = dataListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
// Filter MCP marker artifacts before they reach xterm.js
|
||||
let data = payload.data;
|
||||
if (data.includes("__NCMCP_")) {
|
||||
data = filterMcpMarkers(data);
|
||||
if (!data) return;
|
||||
}
|
||||
set.forEach((cb) => {
|
||||
try {
|
||||
cb(payload.data);
|
||||
cb(data);
|
||||
} catch (err) {
|
||||
console.error("Data callback failed", err);
|
||||
}
|
||||
@@ -946,6 +967,8 @@ const api = {
|
||||
downloadUpdate: () => ipcRenderer.invoke("netcatty:update:download"),
|
||||
installUpdate: () => ipcRenderer.invoke("netcatty:update:install"),
|
||||
getUpdateStatus: () => ipcRenderer.invoke("netcatty:update:getStatus"),
|
||||
setAutoUpdate: (enabled) => ipcRenderer.invoke("netcatty:update:setAutoUpdate", { enabled }),
|
||||
getAutoUpdate: () => ipcRenderer.invoke("netcatty:update:getAutoUpdate"),
|
||||
onUpdateAvailable: (cb) => {
|
||||
updateAvailableListeners.add(cb);
|
||||
return () => updateAvailableListeners.delete(cb);
|
||||
@@ -966,6 +989,151 @@ const api = {
|
||||
updateErrorListeners.add(cb);
|
||||
return () => updateErrorListeners.delete(cb);
|
||||
},
|
||||
|
||||
// ── AI Bridge ──
|
||||
aiSyncProviders: async (providers) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:sync-providers", { providers });
|
||||
},
|
||||
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 });
|
||||
},
|
||||
aiAllowlistAddHost: async (baseURL) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:allowlist:add-host", { baseURL });
|
||||
},
|
||||
aiExec: async (sessionId, command) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:exec", { sessionId, command });
|
||||
},
|
||||
aiTerminalWrite: async (sessionId, data) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:terminal:write", { sessionId, data });
|
||||
},
|
||||
aiDiscoverAgents: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ai:agents:discover");
|
||||
},
|
||||
aiResolveCli: async (params) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:resolve-cli", params);
|
||||
},
|
||||
aiCodexGetIntegration: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex:get-integration");
|
||||
},
|
||||
aiCodexStartLogin: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex:start-login");
|
||||
},
|
||||
aiCodexGetLoginSession: async (sessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex:get-login-session", { sessionId });
|
||||
},
|
||||
aiCodexCancelLogin: async (sessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex:cancel-login", { sessionId });
|
||||
},
|
||||
aiCodexLogout: async () => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex:logout");
|
||||
},
|
||||
aiSpawnAgent: async (agentId, command, args, env, options) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:agent:spawn", { agentId, command, args, env, closeStdin: options?.closeStdin });
|
||||
},
|
||||
aiWriteToAgent: async (agentId, data) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:agent:write", { agentId, data });
|
||||
},
|
||||
aiCloseAgentStdin: async (agentId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:agent:close-stdin", { agentId });
|
||||
},
|
||||
aiKillAgent: async (agentId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:agent:kill", { agentId });
|
||||
},
|
||||
// MCP Server session metadata
|
||||
aiMcpUpdateSessions: async (sessions, chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:update-sessions", { sessions, chatSessionId });
|
||||
},
|
||||
aiMcpSetCommandBlocklist: async (blocklist) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:set-command-blocklist", { blocklist });
|
||||
},
|
||||
aiMcpSetCommandTimeout: async (timeout) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:set-command-timeout", { timeout });
|
||||
},
|
||||
aiMcpSetMaxIterations: async (maxIterations) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:set-max-iterations", { maxIterations });
|
||||
},
|
||||
aiMcpSetPermissionMode: async (mode) => {
|
||||
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 });
|
||||
},
|
||||
aiAcpCancel: async (requestId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:cancel", { requestId });
|
||||
},
|
||||
aiAcpCleanup: async (chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:cleanup", { chatSessionId });
|
||||
},
|
||||
onAiAcpEvent: (requestId, cb) => {
|
||||
const handler = (_event, payload) => {
|
||||
if (payload.requestId === requestId) cb(payload.event);
|
||||
};
|
||||
ipcRenderer.on("netcatty:ai:acp:event", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:ai:acp:event", handler);
|
||||
},
|
||||
onAiAcpDone: (requestId, cb) => {
|
||||
const handler = (_event, payload) => {
|
||||
if (payload.requestId === requestId) cb();
|
||||
};
|
||||
ipcRenderer.on("netcatty:ai:acp:done", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:ai:acp:done", handler);
|
||||
},
|
||||
onAiAcpError: (requestId, cb) => {
|
||||
const handler = (_event, payload) => {
|
||||
if (payload.requestId === requestId) cb(payload.error);
|
||||
};
|
||||
ipcRenderer.on("netcatty:ai:acp:error", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:ai:acp:error", handler);
|
||||
},
|
||||
onAiStreamData: (requestId, cb) => {
|
||||
const handler = (_event, payload) => {
|
||||
if (payload.requestId === requestId) cb(payload.data);
|
||||
};
|
||||
ipcRenderer.on("netcatty:ai:stream:data", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:ai:stream:data", handler);
|
||||
},
|
||||
onAiStreamEnd: (requestId, cb) => {
|
||||
const handler = (_event, payload) => {
|
||||
if (payload.requestId === requestId) cb();
|
||||
};
|
||||
ipcRenderer.on("netcatty:ai:stream:end", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:ai:stream:end", handler);
|
||||
},
|
||||
onAiStreamError: (requestId, cb) => {
|
||||
const handler = (_event, payload) => {
|
||||
if (payload.requestId === requestId) cb(payload.error);
|
||||
};
|
||||
ipcRenderer.on("netcatty:ai:stream:error", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:ai:stream:error", handler);
|
||||
},
|
||||
onAiAgentStdout: (agentId, cb) => {
|
||||
const handler = (_event, payload) => {
|
||||
if (payload.agentId === agentId) cb(payload.data);
|
||||
};
|
||||
ipcRenderer.on("netcatty:ai:agent:stdout", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:ai:agent:stdout", handler);
|
||||
},
|
||||
onAiAgentStderr: (agentId, cb) => {
|
||||
const handler = (_event, payload) => {
|
||||
if (payload.agentId === agentId) cb(payload.data);
|
||||
};
|
||||
ipcRenderer.on("netcatty:ai:agent:stderr", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:ai:agent:stderr", handler);
|
||||
},
|
||||
onAiAgentExit: (agentId, cb) => {
|
||||
const handler = (_event, payload) => {
|
||||
if (payload.agentId === agentId) cb(payload.code);
|
||||
};
|
||||
ipcRenderer.on("netcatty:ai:agent:exit", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:ai:agent:exit", handler);
|
||||
},
|
||||
};
|
||||
|
||||
// Merge with existing netcatty (if any) to avoid stale objects on hot reload
|
||||
|
||||
@@ -7,7 +7,7 @@ import reactHooks from "eslint-plugin-react-hooks";
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**"],
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**", "release/**"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
|
||||
96
global.d.ts
vendored
96
global.d.ts
vendored
@@ -189,6 +189,7 @@ declare global {
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
passphrase?: string;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
enableKeyboardInteractive?: boolean;
|
||||
@@ -612,10 +613,99 @@ declare global {
|
||||
credentialsEncrypt?(plaintext: string): Promise<string>;
|
||||
credentialsDecrypt?(value: string): Promise<string>;
|
||||
|
||||
// AI / external agents
|
||||
aiSyncProviders?(providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>): Promise<{ ok: boolean }>;
|
||||
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<{
|
||||
command: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
args: string[];
|
||||
path: string;
|
||||
version: string;
|
||||
available: boolean;
|
||||
acpCommand?: string;
|
||||
acpArgs?: string[];
|
||||
}>>;
|
||||
aiCodexGetIntegration?(): Promise<{
|
||||
state: 'connected_chatgpt' | 'connected_api_key' | 'not_logged_in' | 'unknown';
|
||||
isConnected: boolean;
|
||||
rawOutput: string;
|
||||
exitCode: number | null;
|
||||
}>;
|
||||
aiCodexStartLogin?(): Promise<{
|
||||
ok: boolean;
|
||||
session?: {
|
||||
sessionId: string;
|
||||
state: 'running' | 'success' | 'error' | 'cancelled';
|
||||
url: string | null;
|
||||
output: string;
|
||||
error: string | null;
|
||||
exitCode: number | null;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
aiCodexGetLoginSession?(sessionId: string): Promise<{
|
||||
ok: boolean;
|
||||
session?: {
|
||||
sessionId: string;
|
||||
state: 'running' | 'success' | 'error' | 'cancelled';
|
||||
url: string | null;
|
||||
output: string;
|
||||
error: string | null;
|
||||
exitCode: number | null;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
aiCodexCancelLogin?(sessionId: string): Promise<{
|
||||
ok: boolean;
|
||||
found?: boolean;
|
||||
session?: {
|
||||
sessionId: string;
|
||||
state: 'running' | 'success' | 'error' | 'cancelled';
|
||||
url: string | null;
|
||||
output: string;
|
||||
error: string | null;
|
||||
exitCode: number | null;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
aiCodexLogout?(): Promise<{
|
||||
ok: boolean;
|
||||
state?: 'connected_chatgpt' | 'connected_api_key' | 'not_logged_in' | 'unknown';
|
||||
isConnected?: boolean;
|
||||
rawOutput?: string;
|
||||
logoutOutput?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
aiMcpUpdateSessions?(sessions: Array<{ sessionId: string; hostname: string; label: string; os?: string; username?: string; connected: boolean }>): Promise<{ ok: boolean }>;
|
||||
aiSpawnAgent?(agentId: string, command: string, args?: string[], env?: Record<string, string>, options?: { closeStdin?: boolean }): Promise<{ ok: boolean; pid?: number; error?: string }>;
|
||||
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 }>;
|
||||
aiAcpCleanup?(chatSessionId: string): Promise<{ ok: boolean }>;
|
||||
onAiAcpEvent?(requestId: string, cb: (event: Record<string, unknown>) => void): () => void;
|
||||
onAiAcpDone?(requestId: string, cb: () => void): () => void;
|
||||
onAiAcpError?(requestId: string, cb: (error: string) => void): () => void;
|
||||
onAiStreamData?(requestId: string, cb: (data: string) => void): () => void;
|
||||
onAiStreamEnd?(requestId: string, cb: () => void): () => void;
|
||||
onAiAgentStdout?(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentStderr?(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentExit?(agentId: string, cb: (code: number | null) => void): () => void;
|
||||
|
||||
// Auto-update
|
||||
checkForUpdate?(): Promise<{
|
||||
available: boolean;
|
||||
supported?: boolean;
|
||||
checking?: boolean;
|
||||
version?: string;
|
||||
releaseNotes?: string;
|
||||
releaseDate?: string | null;
|
||||
@@ -623,7 +713,7 @@ declare global {
|
||||
}>;
|
||||
downloadUpdate?(): Promise<{ success: boolean; error?: string }>;
|
||||
installUpdate?(): void;
|
||||
getUpdateStatus?(): Promise<{ status: 'idle' | 'downloading' | 'ready' | 'error'; percent: number; error: string | null; version: string | null; isChecking?: boolean }>;
|
||||
getUpdateStatus?(): Promise<{ status: 'idle' | 'available' | 'downloading' | 'ready' | 'error'; percent: number; error: string | null; version: string | null; isChecking?: boolean }>;
|
||||
|
||||
onUpdateDownloadProgress?(cb: (progress: {
|
||||
percent: number;
|
||||
@@ -645,6 +735,10 @@ declare global {
|
||||
unregisterGlobalHotkey?(): Promise<{ success: boolean }>;
|
||||
getGlobalHotkeyStatus?(): Promise<{ enabled: boolean; hotkey: string | null }>;
|
||||
|
||||
// Auto-Update toggle
|
||||
getAutoUpdate?(): Promise<{ enabled: boolean }>;
|
||||
setAutoUpdate?(enabled: boolean): Promise<{ success: boolean }>;
|
||||
|
||||
// System Tray / Close to Tray
|
||||
setCloseToTray?(enabled: boolean): Promise<{ success: boolean; enabled: boolean }>;
|
||||
isCloseToTray?(): Promise<{ enabled: boolean }>;
|
||||
|
||||
93
index.css
93
index.css
@@ -128,6 +128,31 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.thinking-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
currentColor 0%,
|
||||
currentColor 40%,
|
||||
rgba(255, 255, 255, 0.6) 50%,
|
||||
currentColor 60%,
|
||||
currentColor 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: thinking-shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes thinking-shimmer {
|
||||
0% {
|
||||
background-position: 100% center;
|
||||
}
|
||||
100% {
|
||||
background-position: -100% center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progress-shimmer {
|
||||
0% {
|
||||
transform: translateX(-200%);
|
||||
@@ -282,3 +307,71 @@ body {
|
||||
.workspace-pane:focus-within::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Streamdown code block overrides ── */
|
||||
[data-streamdown="code-block"] {
|
||||
position: relative !important;
|
||||
border-radius: 10px !important;
|
||||
background: hsl(var(--muted) / 0.5) !important;
|
||||
overflow: hidden !important;
|
||||
margin: 6px 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
[data-streamdown="code-block-header"] {
|
||||
height: auto !important;
|
||||
padding: 4px 12px 0 !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
[data-streamdown="code-block-header"] span {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
[data-streamdown="code-block-actions"] {
|
||||
position: absolute !important;
|
||||
top: 4px !important;
|
||||
right: 4px !important;
|
||||
border: none !important;
|
||||
background: none !important;
|
||||
backdrop-filter: none !important;
|
||||
padding: 0 !important;
|
||||
gap: 2px !important;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
[data-streamdown="code-block"]:hover [data-streamdown="code-block-actions"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-streamdown="code-block-actions"] button {
|
||||
padding: 4px !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
[data-streamdown="code-block-body"] {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
overflow-x: auto !important;
|
||||
font-size: 0 !important; /* collapse whitespace text nodes */
|
||||
}
|
||||
|
||||
[data-streamdown="code-block-body"] pre {
|
||||
font-size: 12px !important; /* restore in pre */
|
||||
}
|
||||
|
||||
[data-streamdown="code-block"] pre {
|
||||
margin: 0 !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
padding: 0 12px 10px !important;
|
||||
font-size: 12px !important;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
|
||||
398
infrastructure/ai/acp/client.ts
Normal file
398
infrastructure/ai/acp/client.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import type {
|
||||
JsonRpcRequest,
|
||||
JsonRpcResponse,
|
||||
JsonRpcNotification,
|
||||
JsonRpcMessage,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
SessionCreateParams,
|
||||
PromptParams,
|
||||
SessionUpdateParams,
|
||||
PermissionRequestParams,
|
||||
AgentCapabilities,
|
||||
} from './protocol';
|
||||
import { ACP_METHODS } from './protocol';
|
||||
import type { ExternalAgentConfig } from '../types';
|
||||
|
||||
type EventHandler<T = unknown> = (params: T) => void;
|
||||
|
||||
// ── Lightweight runtime type guards ──
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function isPermissionRequestParams(v: unknown): v is PermissionRequestParams {
|
||||
if (!isRecord(v)) return false;
|
||||
if (typeof v.sessionId !== 'string') return false;
|
||||
if (!isRecord(v.toolCall)) return false;
|
||||
if (typeof v.toolCall.name !== 'string') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSessionUpdateParams(v: unknown): v is SessionUpdateParams {
|
||||
if (!isRecord(v)) return false;
|
||||
if (typeof v.sessionId !== 'string') return false;
|
||||
if (typeof v.type !== 'string') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isJsonRpcError(v: unknown): v is { code: number; message: string } {
|
||||
if (!isRecord(v)) return false;
|
||||
if (typeof v.code !== 'number') return false;
|
||||
if (typeof v.message !== 'string') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge interface to the Electron main process for agent management
|
||||
*/
|
||||
interface AgentBridge {
|
||||
aiSpawnAgent(agentId: string, command: string, args?: string[], env?: Record<string, string>): Promise<{ ok: boolean; pid?: number; error?: string }>;
|
||||
aiWriteToAgent(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiKillAgent(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
onAiAgentStdout(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentStderr(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentExit(agentId: string, cb: (code: number) => void): () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP Client - manages a single external agent connection over JSON-RPC 2.0 / NDJSON stdio.
|
||||
*/
|
||||
export class ACPClient {
|
||||
private agentId: string;
|
||||
private config: ExternalAgentConfig;
|
||||
private bridge: AgentBridge;
|
||||
private nextId = 1;
|
||||
private pendingRequests = new Map<number | string, {
|
||||
resolve: (result: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
}>();
|
||||
private buffer = '';
|
||||
private cleanupFns: (() => void)[] = [];
|
||||
private agentCapabilities: AgentCapabilities | null = null;
|
||||
private _isConnected = false;
|
||||
|
||||
// Event handlers
|
||||
private onSessionUpdate: EventHandler<SessionUpdateParams> | null = null;
|
||||
private onPermissionRequest: EventHandler<PermissionRequestParams> | null = null;
|
||||
private onStderr: EventHandler<string> | null = null;
|
||||
private onExit: EventHandler<number> | null = null;
|
||||
|
||||
constructor(config: ExternalAgentConfig, bridge: AgentBridge) {
|
||||
this.agentId = `acp_${config.id}_${Date.now()}`;
|
||||
this.config = config;
|
||||
this.bridge = bridge;
|
||||
}
|
||||
|
||||
get isConnected() { return this._isConnected; }
|
||||
get capabilities() { return this.agentCapabilities; }
|
||||
|
||||
/** Set event handlers */
|
||||
on(event: 'session_update', handler: EventHandler<SessionUpdateParams>): this;
|
||||
on(event: 'permission_request', handler: EventHandler<PermissionRequestParams>): this;
|
||||
on(event: 'stderr', handler: EventHandler<string>): this;
|
||||
on(event: 'exit', handler: EventHandler<number>): this;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
on(event: string, handler: EventHandler<any>): this {
|
||||
switch (event) {
|
||||
case 'session_update': this.onSessionUpdate = handler as EventHandler<SessionUpdateParams>; break;
|
||||
case 'permission_request': this.onPermissionRequest = handler as EventHandler<PermissionRequestParams>; break;
|
||||
case 'stderr': this.onStderr = handler as EventHandler<string>; break;
|
||||
case 'exit': this.onExit = handler as EventHandler<number>; break;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Start the agent process and perform ACP initialization handshake */
|
||||
async connect(): Promise<InitializeResult> {
|
||||
// Spawn the agent process
|
||||
const result = await this.bridge.aiSpawnAgent(
|
||||
this.agentId,
|
||||
this.config.command,
|
||||
this.config.args,
|
||||
this.config.env,
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(`Failed to spawn agent: ${result.error}`);
|
||||
}
|
||||
|
||||
// Listen for stdout (NDJSON messages)
|
||||
const unsubStdout = this.bridge.onAiAgentStdout(this.agentId, (data) => {
|
||||
this.handleStdoutData(data);
|
||||
});
|
||||
this.cleanupFns.push(unsubStdout);
|
||||
|
||||
// Listen for stderr (logging)
|
||||
const unsubStderr = this.bridge.onAiAgentStderr(this.agentId, (data) => {
|
||||
this.onStderr?.(data);
|
||||
});
|
||||
this.cleanupFns.push(unsubStderr);
|
||||
|
||||
// Listen for exit
|
||||
const unsubExit = this.bridge.onAiAgentExit(this.agentId, (code) => {
|
||||
this._isConnected = false;
|
||||
this.onExit?.(code);
|
||||
// Reject all pending requests
|
||||
for (const [, pending] of this.pendingRequests) {
|
||||
pending.reject(new Error(`Agent exited with code ${code}`));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
});
|
||||
this.cleanupFns.push(unsubExit);
|
||||
|
||||
// Send initialize request
|
||||
const initParams: InitializeParams = {
|
||||
clientInfo: { name: 'netcatty', version: '1.0.0' },
|
||||
capabilities: {
|
||||
terminal: { create: true, output: true, waitForExit: true, kill: true },
|
||||
fileSystem: { read: true, write: true },
|
||||
permissions: { requestPermission: true },
|
||||
},
|
||||
};
|
||||
|
||||
const initResult = await this.sendRequest<InitializeResult>(ACP_METHODS.INITIALIZE, initParams);
|
||||
this.agentCapabilities = initResult.capabilities;
|
||||
this._isConnected = true;
|
||||
|
||||
return initResult;
|
||||
}
|
||||
|
||||
/** Create a new session */
|
||||
async createSession(params?: SessionCreateParams): Promise<{ sessionId: string }> {
|
||||
return this.sendRequest(ACP_METHODS.SESSION_CREATE, params || {});
|
||||
}
|
||||
|
||||
/** Send a prompt to the agent */
|
||||
async prompt(params: PromptParams): Promise<void> {
|
||||
return this.sendRequest(ACP_METHODS.SESSION_PROMPT, params);
|
||||
}
|
||||
|
||||
/** Cancel the current operation */
|
||||
async cancel(sessionId: string): Promise<void> {
|
||||
return this.sendRequest(ACP_METHODS.SESSION_CANCEL, { sessionId });
|
||||
}
|
||||
|
||||
/** Respond to a permission request */
|
||||
respondPermission(requestId: number | string, approved: boolean): void {
|
||||
this.sendResponse(requestId, { approved });
|
||||
}
|
||||
|
||||
/** Respond to a terminal create request */
|
||||
respondTerminalCreate(requestId: number | string, terminalId: string): void {
|
||||
this.sendResponse(requestId, { terminalId });
|
||||
}
|
||||
|
||||
/** Respond to a file read request */
|
||||
respondFileRead(requestId: number | string, content: string): void {
|
||||
this.sendResponse(requestId, { content });
|
||||
}
|
||||
|
||||
/** Respond to a file write request */
|
||||
respondFileWrite(requestId: number | string, success: boolean): void {
|
||||
this.sendResponse(requestId, { success });
|
||||
}
|
||||
|
||||
/** Disconnect and kill the agent process */
|
||||
async disconnect(): Promise<void> {
|
||||
this._isConnected = false;
|
||||
for (const cleanup of this.cleanupFns) {
|
||||
try { cleanup(); } catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
this.cleanupFns = [];
|
||||
await this.bridge.aiKillAgent(this.agentId);
|
||||
// Reject all pending requests before clearing
|
||||
for (const [, pending] of this.pendingRequests) {
|
||||
pending.reject(new Error('Agent disconnected'));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
// ── Private methods ──
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private async sendRequest<T = unknown>(method: string, params?: Record<string, any>): Promise<T> {
|
||||
const id = this.nextId++;
|
||||
const request: JsonRpcRequest = {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
};
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
// Track timeout so we can clear it when the request resolves
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`Request timeout: ${method}`));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: (result: unknown) => {
|
||||
clearTimeout(timeoutId);
|
||||
(resolve as (result: unknown) => void)(result);
|
||||
},
|
||||
reject: (error: Error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
|
||||
const line = JSON.stringify(request) + '\n';
|
||||
this.bridge.aiWriteToAgent(this.agentId, line).catch((err) => {
|
||||
clearTimeout(timeoutId);
|
||||
this.pendingRequests.delete(id);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private sendResponse(id: number | string, result: unknown): void {
|
||||
const response: JsonRpcResponse = { jsonrpc: '2.0', id, result };
|
||||
const line = JSON.stringify(response) + '\n';
|
||||
this.bridge.aiWriteToAgent(this.agentId, line).catch((err) => {
|
||||
console.error('[ACP] Failed to send response:', err);
|
||||
});
|
||||
}
|
||||
|
||||
private sendErrorResponse(id: number | string, code: number, message: string): void {
|
||||
const response: JsonRpcResponse = {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code, message },
|
||||
};
|
||||
const line = JSON.stringify(response) + '\n';
|
||||
this.bridge.aiWriteToAgent(this.agentId, line).catch(() => { /* best-effort */ });
|
||||
}
|
||||
|
||||
/** Max NDJSON buffer size (10 MB) to prevent unbounded memory growth */
|
||||
private static readonly MAX_BUFFER_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
private handleStdoutData(data: string): void {
|
||||
this.buffer += data;
|
||||
|
||||
// Guard against unbounded buffer growth
|
||||
if (this.buffer.length > ACPClient.MAX_BUFFER_SIZE) {
|
||||
console.warn(`[ACP] NDJSON buffer exceeded ${ACPClient.MAX_BUFFER_SIZE} bytes, clearing buffer`);
|
||||
this.buffer = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = this.buffer.split('\n');
|
||||
this.buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const message = JSON.parse(trimmed) as JsonRpcMessage;
|
||||
this.handleMessage(message);
|
||||
} catch {
|
||||
// Skip non-JSON lines (agent may print logs to stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(message: JsonRpcMessage): void {
|
||||
// Response to our request
|
||||
if ('id' in message && ('result' in message || 'error' in message)) {
|
||||
const response = message as JsonRpcResponse;
|
||||
const pending = this.pendingRequests.get(response.id);
|
||||
if (pending) {
|
||||
this.pendingRequests.delete(response.id);
|
||||
if (response.error) {
|
||||
const errMsg = isJsonRpcError(response.error)
|
||||
? response.error.message
|
||||
: JSON.stringify(response.error);
|
||||
pending.reject(new Error(errMsg));
|
||||
} else {
|
||||
pending.resolve(response.result);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Request from agent (needs our response)
|
||||
if ('id' in message && 'method' in message) {
|
||||
const request = message as JsonRpcRequest;
|
||||
this.handleAgentRequest(request);
|
||||
return;
|
||||
}
|
||||
|
||||
// Notification from agent (no response needed)
|
||||
if ('method' in message && !('id' in message)) {
|
||||
const notification = message as JsonRpcNotification;
|
||||
this.handleAgentNotification(notification);
|
||||
}
|
||||
}
|
||||
|
||||
private handleAgentRequest(request: JsonRpcRequest): void {
|
||||
switch (request.method) {
|
||||
case ACP_METHODS.REQUEST_PERMISSION: {
|
||||
if (!isPermissionRequestParams(request.params)) {
|
||||
this.sendErrorResponse(request.id, -32602, 'Invalid permission request params');
|
||||
break;
|
||||
}
|
||||
if (this.onPermissionRequest) {
|
||||
this.onPermissionRequest({
|
||||
...request.params,
|
||||
// Attach the request ID so the handler can respond via respondPermission()
|
||||
_requestId: request.id,
|
||||
} as PermissionRequestParams & { _requestId: number | string });
|
||||
} else {
|
||||
this.sendErrorResponse(request.id, -32603, 'Permission request handler not configured');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ACP_METHODS.TERMINAL_CREATE:
|
||||
case ACP_METHODS.TERMINAL_WAIT_EXIT:
|
||||
case ACP_METHODS.TERMINAL_KILL:
|
||||
case ACP_METHODS.FS_READ:
|
||||
case ACP_METHODS.FS_WRITE:
|
||||
// Surface as tool_call so the UI layer can handle and respond
|
||||
this.onSessionUpdate?.({
|
||||
sessionId: String(request.params?.sessionId || ''),
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
id: String(request.id),
|
||||
name: request.method,
|
||||
arguments: (request.params as Record<string, unknown>) || {},
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown method - respond with JSON-RPC method-not-found error
|
||||
this.sendErrorResponse(request.id, -32601, `Method not found: ${request.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
private handleAgentNotification(notification: JsonRpcNotification): void {
|
||||
switch (notification.method) {
|
||||
case ACP_METHODS.SESSION_UPDATE:
|
||||
if (isSessionUpdateParams(notification.params)) {
|
||||
this.onSessionUpdate?.(notification.params);
|
||||
}
|
||||
break;
|
||||
case ACP_METHODS.TERMINAL_OUTPUT:
|
||||
// Surface terminal output as a session update with tool_result type
|
||||
this.onSessionUpdate?.({
|
||||
sessionId: String(notification.params?.sessionId || ''),
|
||||
type: 'tool_result',
|
||||
toolResult: {
|
||||
toolCallId: String(notification.params?.terminalId || ''),
|
||||
content: String(notification.params?.data || ''),
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// Ignore unknown notifications
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
93
infrastructure/ai/acp/manager.ts
Normal file
93
infrastructure/ai/acp/manager.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { ACPClient } from './client';
|
||||
import type { ExternalAgentConfig } from '../types';
|
||||
import type { SessionUpdateParams, PermissionRequestParams, InitializeResult } from './protocol';
|
||||
|
||||
interface AgentBridge {
|
||||
aiSpawnAgent(agentId: string, command: string, args?: string[], env?: Record<string, string>): Promise<{ ok: boolean; pid?: number; error?: string }>;
|
||||
aiWriteToAgent(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiKillAgent(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
onAiAgentStdout(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentStderr(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentExit(agentId: string, cb: (code: number) => void): () => void;
|
||||
}
|
||||
|
||||
export interface ACPManagerCallbacks {
|
||||
onSessionUpdate: (agentConfigId: string, params: SessionUpdateParams) => void;
|
||||
onPermissionRequest: (agentConfigId: string, params: PermissionRequestParams) => void;
|
||||
onAgentError: (agentConfigId: string, error: string) => void;
|
||||
onAgentExit: (agentConfigId: string, code: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages multiple ACP agent connections.
|
||||
*/
|
||||
export class ACPManager {
|
||||
private clients = new Map<string, ACPClient>();
|
||||
private bridge: AgentBridge;
|
||||
private callbacks: ACPManagerCallbacks;
|
||||
|
||||
constructor(bridge: AgentBridge, callbacks: ACPManagerCallbacks) {
|
||||
this.bridge = bridge;
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
/** Connect to an external agent */
|
||||
async connect(config: ExternalAgentConfig): Promise<InitializeResult> {
|
||||
if (this.clients.has(config.id)) {
|
||||
await this.disconnect(config.id);
|
||||
}
|
||||
|
||||
const client = new ACPClient(config, this.bridge);
|
||||
|
||||
client
|
||||
.on('session_update', (params) => {
|
||||
this.callbacks.onSessionUpdate(config.id, params);
|
||||
})
|
||||
.on('permission_request', (params) => {
|
||||
this.callbacks.onPermissionRequest(config.id, params);
|
||||
})
|
||||
.on('stderr', (data) => {
|
||||
this.callbacks.onAgentError(config.id, data);
|
||||
})
|
||||
.on('exit', (code) => {
|
||||
this.clients.delete(config.id);
|
||||
this.callbacks.onAgentExit(config.id, code);
|
||||
});
|
||||
|
||||
const result = await client.connect();
|
||||
this.clients.set(config.id, client);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Get a connected client */
|
||||
getClient(configId: string): ACPClient | undefined {
|
||||
return this.clients.get(configId);
|
||||
}
|
||||
|
||||
/** Check if an agent is connected */
|
||||
isConnected(configId: string): boolean {
|
||||
return this.clients.get(configId)?.isConnected ?? false;
|
||||
}
|
||||
|
||||
/** Disconnect a specific agent */
|
||||
async disconnect(configId: string): Promise<void> {
|
||||
const client = this.clients.get(configId);
|
||||
if (client) {
|
||||
await client.disconnect();
|
||||
this.clients.delete(configId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Disconnect all agents */
|
||||
async disconnectAll(): Promise<void> {
|
||||
const promises = Array.from(this.clients.keys()).map(id => this.disconnect(id));
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
/** Get list of connected agent IDs */
|
||||
getConnectedAgentIds(): string[] {
|
||||
return Array.from(this.clients.entries())
|
||||
.filter(([, client]) => client.isConnected)
|
||||
.map(([id]) => id);
|
||||
}
|
||||
}
|
||||
94
infrastructure/ai/acp/protocol.ts
Normal file
94
infrastructure/ai/acp/protocol.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// JSON-RPC 2.0 base types
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: '2.0';
|
||||
id: number | string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
jsonrpc: '2.0';
|
||||
id: number | string;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
}
|
||||
|
||||
export interface JsonRpcNotification {
|
||||
jsonrpc: '2.0';
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
|
||||
|
||||
// ACP-specific types
|
||||
|
||||
/** Capabilities that the client (Netcatty) declares it supports */
|
||||
export interface ClientCapabilities {
|
||||
fileSystem?: { read?: boolean; write?: boolean };
|
||||
terminal?: { create?: boolean; output?: boolean; waitForExit?: boolean; kill?: boolean };
|
||||
permissions?: { requestPermission?: boolean };
|
||||
}
|
||||
|
||||
/** Capabilities that the agent declares it supports */
|
||||
export interface AgentCapabilities {
|
||||
streaming?: boolean;
|
||||
tools?: string[];
|
||||
}
|
||||
|
||||
/** ACP initialize params */
|
||||
export interface InitializeParams {
|
||||
clientInfo: { name: string; version: string };
|
||||
capabilities: ClientCapabilities;
|
||||
}
|
||||
|
||||
/** ACP initialize result */
|
||||
export interface InitializeResult {
|
||||
agentInfo: { name: string; version: string };
|
||||
capabilities: AgentCapabilities;
|
||||
}
|
||||
|
||||
/** ACP session create params */
|
||||
export interface SessionCreateParams {
|
||||
sessionId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** ACP prompt params - send a user message */
|
||||
export interface PromptParams {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** ACP session update events (streamed as notifications) */
|
||||
export interface SessionUpdateParams {
|
||||
sessionId: string;
|
||||
type: 'text' | 'tool_call' | 'tool_result' | 'thinking' | 'error' | 'done';
|
||||
content?: string;
|
||||
toolCall?: { id: string; name: string; arguments: Record<string, unknown> };
|
||||
toolResult?: { toolCallId: string; content: string; isError?: boolean };
|
||||
}
|
||||
|
||||
/** ACP permission request */
|
||||
export interface PermissionRequestParams {
|
||||
sessionId: string;
|
||||
toolCall: { name: string; arguments: Record<string, unknown> };
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ACP method names
|
||||
export const ACP_METHODS = {
|
||||
INITIALIZE: 'initialize',
|
||||
SESSION_CREATE: 'session/create',
|
||||
SESSION_PROMPT: 'session/prompt',
|
||||
SESSION_CANCEL: 'session/cancel',
|
||||
SESSION_UPDATE: 'session/update', // notification from agent
|
||||
REQUEST_PERMISSION: 'session/request_permission', // request from agent
|
||||
TERMINAL_CREATE: 'terminal/create', // request from agent
|
||||
TERMINAL_OUTPUT: 'terminal/output', // notification from agent
|
||||
TERMINAL_WAIT_EXIT: 'terminal/waitForExit', // request from agent
|
||||
TERMINAL_KILL: 'terminal/kill', // request from agent
|
||||
FS_READ: 'fs/readTextFile', // request from agent
|
||||
FS_WRITE: 'fs/writeTextFile', // request from agent
|
||||
} as const;
|
||||
186
infrastructure/ai/acpAgentAdapter.ts
Normal file
186
infrastructure/ai/acpAgentAdapter.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* ACP Agent Adapter
|
||||
*
|
||||
* Bridges external agents that support the Agent Client Protocol (ACP)
|
||||
* through IPC. The main process runs `createACPProvider` + `streamText`,
|
||||
* and forwards stream events to the renderer via IPC.
|
||||
*/
|
||||
|
||||
import type { ExternalAgentConfig } from './types';
|
||||
|
||||
export interface AcpAgentCallbacks {
|
||||
onTextDelta: (text: string) => void;
|
||||
onThinkingDelta: (text: string) => void;
|
||||
onThinkingDone: () => void;
|
||||
onToolCall: (toolName: string, args: Record<string, unknown>) => void;
|
||||
onToolResult: (toolCallId: string, result: string) => void;
|
||||
onStatus?: (message: string) => void;
|
||||
onError: (error: string) => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
interface AcpBridge {
|
||||
aiAcpStream(
|
||||
requestId: string,
|
||||
chatSessionId: string,
|
||||
acpCommand: string,
|
||||
acpArgs: string[],
|
||||
prompt: string,
|
||||
cwd?: string,
|
||||
providerId?: string,
|
||||
model?: string,
|
||||
images?: ImageAttachment[],
|
||||
): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpCancel(requestId: 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;
|
||||
}
|
||||
|
||||
interface StreamEvent {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an ACP agent turn.
|
||||
* Sends the prompt to the main process which runs streamText() with the ACP provider.
|
||||
* Stream events are forwarded back via IPC.
|
||||
*/
|
||||
export interface ImageAttachment {
|
||||
base64Data: string;
|
||||
mediaType: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export async function runAcpAgentTurn(
|
||||
bridge: Record<string, (...args: unknown[]) => unknown>,
|
||||
requestId: string,
|
||||
chatSessionId: string,
|
||||
config: ExternalAgentConfig,
|
||||
prompt: string,
|
||||
callbacks: AcpAgentCallbacks,
|
||||
signal?: AbortSignal,
|
||||
providerId?: string,
|
||||
model?: string,
|
||||
images?: ImageAttachment[],
|
||||
): Promise<void> {
|
||||
const acpBridge = bridge as unknown as AcpBridge;
|
||||
|
||||
if (!config.acpCommand) {
|
||||
callbacks.onError('Agent does not support ACP protocol');
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanupFns: (() => void)[] = [];
|
||||
|
||||
// Set up event listeners before starting stream
|
||||
const unsubEvent = acpBridge.onAiAcpEvent(requestId, (event: StreamEvent) => {
|
||||
handleStreamEvent(event, callbacks);
|
||||
});
|
||||
cleanupFns.push(unsubEvent);
|
||||
|
||||
const donePromise = new Promise<void>((resolve) => {
|
||||
const unsubDone = acpBridge.onAiAcpDone(requestId, () => {
|
||||
callbacks.onDone();
|
||||
resolve();
|
||||
});
|
||||
cleanupFns.push(unsubDone);
|
||||
|
||||
const unsubError = acpBridge.onAiAcpError(requestId, (error: string) => {
|
||||
callbacks.onError(error);
|
||||
resolve();
|
||||
});
|
||||
cleanupFns.push(unsubError);
|
||||
});
|
||||
|
||||
// Handle abort
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
cleanup(cleanupFns);
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
acpBridge.aiAcpCancel(requestId).catch(() => {});
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
cleanupFns.push(() => signal.removeEventListener('abort', onAbort));
|
||||
}
|
||||
|
||||
// Start the ACP stream in the main process
|
||||
acpBridge.aiAcpStream(
|
||||
requestId,
|
||||
chatSessionId,
|
||||
config.acpCommand,
|
||||
config.acpArgs || [],
|
||||
prompt,
|
||||
undefined, // cwd
|
||||
providerId,
|
||||
model,
|
||||
images?.length ? images : undefined,
|
||||
).catch((err: Error) => {
|
||||
callbacks.onError(err.message);
|
||||
});
|
||||
|
||||
// Wait for done or error
|
||||
await donePromise;
|
||||
cleanup(cleanupFns);
|
||||
}
|
||||
|
||||
function cleanup(fns: (() => void)[]) {
|
||||
for (const fn of fns) {
|
||||
try { fn(); } catch { /* */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a single stream event from the AI SDK fullStream.
|
||||
* Events come from `streamText().fullStream` in the main process.
|
||||
*/
|
||||
function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks) {
|
||||
switch (event.type) {
|
||||
case 'text-delta': {
|
||||
const text = (event.textDelta as string) || (event.delta as string) || '';
|
||||
if (text) callbacks.onTextDelta(text);
|
||||
break;
|
||||
}
|
||||
case 'reasoning-start': {
|
||||
// Reasoning block started — nothing to render yet
|
||||
break;
|
||||
}
|
||||
case 'reasoning-delta': {
|
||||
const text = (event.delta as string) || '';
|
||||
if (text) callbacks.onThinkingDelta(text);
|
||||
break;
|
||||
}
|
||||
case 'reasoning-end': {
|
||||
callbacks.onThinkingDone();
|
||||
break;
|
||||
}
|
||||
case 'tool-call': {
|
||||
const toolName = (event.toolName as string) || 'unknown';
|
||||
const input = (event.input as Record<string, unknown>) || {};
|
||||
callbacks.onToolCall(toolName, input);
|
||||
break;
|
||||
}
|
||||
case 'tool-result': {
|
||||
const toolCallId = (event.toolCallId as string) || '';
|
||||
const output = event.output ?? event.result;
|
||||
const result = typeof output === 'string'
|
||||
? output
|
||||
: JSON.stringify(output);
|
||||
callbacks.onToolResult(toolCallId, result);
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
const msg = (event.message as string) || '';
|
||||
if (msg) callbacks.onStatus?.(msg);
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
callbacks.onError(String(event.error || 'Unknown error'));
|
||||
break;
|
||||
}
|
||||
// step-start, step-finish, etc. — ignore silently
|
||||
}
|
||||
}
|
||||
183
infrastructure/ai/agentOutputParser.ts
Normal file
183
infrastructure/ai/agentOutputParser.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Agent Output Parser
|
||||
*
|
||||
* Parses JSON Lines output from `codex exec --json` and similar structured
|
||||
* agent output into display-friendly text segments.
|
||||
*/
|
||||
|
||||
export interface AgentOutputSegment {
|
||||
type: 'thinking' | 'text' | 'command' | 'command_output' | 'file_change' | 'plan' | 'error' | 'usage';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse a single line of agent output.
|
||||
* Returns structured segment(s) if it's a recognized JSON event,
|
||||
* or null if it's not JSON / not recognized (caller should treat as plain text).
|
||||
*/
|
||||
export function parseAgentJsonLine(line: string): AgentOutputSegment[] | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || !trimmed.startsWith('{')) return null;
|
||||
|
||||
let event: Record<string, unknown>;
|
||||
try {
|
||||
event = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!event.type) return null;
|
||||
|
||||
const type = event.type as string;
|
||||
const item = event.item as Record<string, unknown> | undefined;
|
||||
|
||||
// thread.started / turn.started — skip silently
|
||||
if (type === 'thread.started' || type === 'turn.started') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// turn.completed — show token usage
|
||||
if (type === 'turn.completed') {
|
||||
const usage = event.usage as { input_tokens?: number; output_tokens?: number } | undefined;
|
||||
if (usage) {
|
||||
return [{
|
||||
type: 'usage',
|
||||
content: `tokens: ${usage.input_tokens ?? '?'} in / ${usage.output_tokens ?? '?'} out`,
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// error
|
||||
if (type === 'error' || type === 'turn.failed') {
|
||||
const msg = (event.message as string)
|
||||
|| ((event.error as Record<string, unknown>)?.message as string)
|
||||
|| JSON.stringify(event);
|
||||
return [{ type: 'error', content: msg }];
|
||||
}
|
||||
|
||||
// item events
|
||||
if (type.startsWith('item.') && item) {
|
||||
return parseItemEvent(type, item);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseItemEvent(
|
||||
eventType: string,
|
||||
item: Record<string, unknown>,
|
||||
): AgentOutputSegment[] {
|
||||
const itemType = item.type as string;
|
||||
|
||||
// reasoning (thinking)
|
||||
if (itemType === 'reasoning') {
|
||||
if (eventType !== 'item.completed') return [];
|
||||
const text = item.text as string || '';
|
||||
if (!text.trim()) return [];
|
||||
return [{ type: 'thinking', content: text }];
|
||||
}
|
||||
|
||||
// agent_message (final response text)
|
||||
if (itemType === 'agent_message') {
|
||||
if (eventType !== 'item.completed') return [];
|
||||
const text = item.text as string || '';
|
||||
if (!text.trim()) return [];
|
||||
return [{ type: 'text', content: text }];
|
||||
}
|
||||
|
||||
// command_execution
|
||||
if (itemType === 'command_execution') {
|
||||
const segments: AgentOutputSegment[] = [];
|
||||
const command = item.command as string || '';
|
||||
const output = item.aggregated_output as string || '';
|
||||
const exitCode = item.exit_code as number | null;
|
||||
|
||||
if (eventType === 'item.started' && command) {
|
||||
segments.push({ type: 'command', content: command });
|
||||
}
|
||||
|
||||
if (eventType === 'item.completed') {
|
||||
if (command) {
|
||||
segments.push({ type: 'command', content: command });
|
||||
}
|
||||
if (output.trim()) {
|
||||
segments.push({ type: 'command_output', content: output.trim() });
|
||||
}
|
||||
if (exitCode !== null && exitCode !== 0) {
|
||||
segments.push({ type: 'error', content: `exit code: ${exitCode}` });
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// file_change
|
||||
if (itemType === 'file_change') {
|
||||
if (eventType !== 'item.completed') return [];
|
||||
const changes = item.changes as Array<{ path: string; kind: string }> | undefined;
|
||||
if (!changes?.length) return [];
|
||||
const lines = changes.map(c => `${c.kind}: ${c.path}`).join('\n');
|
||||
return [{ type: 'file_change', content: lines }];
|
||||
}
|
||||
|
||||
// todo_list / plan
|
||||
if (itemType === 'todo_list') {
|
||||
const items = item.items as Array<{ text: string; completed: boolean }> | undefined;
|
||||
if (!items?.length) return [];
|
||||
const lines = items.map(t => `${t.completed ? '✓' : '○'} ${t.text}`).join('\n');
|
||||
return [{ type: 'plan', content: lines }];
|
||||
}
|
||||
|
||||
// mcp_tool_call
|
||||
if (itemType === 'mcp_tool_call') {
|
||||
const tool = item.tool as string || 'unknown';
|
||||
const server = item.server as string || '';
|
||||
if (eventType === 'item.started') {
|
||||
return [{ type: 'command', content: `[MCP] ${server}/${tool}` }];
|
||||
}
|
||||
if (eventType === 'item.completed') {
|
||||
const result = item.result as Record<string, unknown> | null;
|
||||
const error = item.error as string | null;
|
||||
if (error) {
|
||||
return [{ type: 'error', content: `MCP ${tool}: ${error}` }];
|
||||
}
|
||||
if (result) {
|
||||
const content = (result.content as Array<{ text?: string }>) || [];
|
||||
const text = content.map(c => c.text || '').filter(Boolean).join('\n');
|
||||
if (text) return [{ type: 'command_output', content: text }];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format AgentOutputSegments into markdown text for display.
|
||||
*/
|
||||
export function formatSegmentsAsMarkdown(segments: AgentOutputSegment[]): string {
|
||||
return segments.map(seg => {
|
||||
switch (seg.type) {
|
||||
case 'thinking':
|
||||
return `> **Thinking:** ${seg.content}\n\n`;
|
||||
case 'text':
|
||||
return seg.content + '\n\n';
|
||||
case 'command':
|
||||
return `\`\`\`bash\n$ ${seg.content}\n\`\`\`\n\n`;
|
||||
case 'command_output':
|
||||
return `\`\`\`\n${seg.content}\n\`\`\`\n\n`;
|
||||
case 'file_change':
|
||||
return `**Files changed:**\n\`\`\`\n${seg.content}\n\`\`\`\n\n`;
|
||||
case 'plan':
|
||||
return `**Plan:**\n${seg.content}\n\n`;
|
||||
case 'error':
|
||||
return `**Error:** ${seg.content}\n\n`;
|
||||
case 'usage':
|
||||
return `---\n*${seg.content}*\n`;
|
||||
default:
|
||||
return seg.content;
|
||||
}
|
||||
}).join('');
|
||||
}
|
||||
216
infrastructure/ai/cattyAgent/executor.ts
Normal file
216
infrastructure/ai/cattyAgent/executor.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import type { ToolCall, ToolResult, AIPermissionMode } from '../types';
|
||||
import {
|
||||
executeTerminalExecute,
|
||||
executeTerminalSendInput,
|
||||
executeSftpListDirectory,
|
||||
executeSftpReadFile,
|
||||
executeSftpWriteFile,
|
||||
executeWorkspaceGetInfo,
|
||||
executeWorkspaceGetSessionInfo,
|
||||
executeMultiHostExecute,
|
||||
type ToolDeps,
|
||||
type ToolExecResult,
|
||||
} from '../shared/toolExecutors';
|
||||
|
||||
/**
|
||||
* Bridge interface for Catty Agent to interact with the Electron main process.
|
||||
* This mirrors the AI-related subset of window.netcatty from electron/preload.cjs.
|
||||
*/
|
||||
export interface NetcattyBridge {
|
||||
aiExec(
|
||||
sessionId: string,
|
||||
command: string,
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
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
|
||||
export interface ExecutorContext {
|
||||
// Available sessions in scope
|
||||
sessions: Array<{
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
connected: boolean;
|
||||
sftpId?: string; // If SFTP is open for this session
|
||||
}>;
|
||||
// Workspace info
|
||||
workspaceId?: string;
|
||||
workspaceName?: string;
|
||||
}
|
||||
|
||||
/** Convert a shared ToolExecResult into the executor's ToolResult format. */
|
||||
function toToolResult(toolCallId: string, r: ToolExecResult): ToolResult {
|
||||
if (r.ok === false) {
|
||||
return { toolCallId, content: r.error, isError: true };
|
||||
}
|
||||
// For terminal_execute, format as the legacy STDOUT/STDERR/exitCode text block
|
||||
if (
|
||||
typeof r.data === 'object' &&
|
||||
r.data !== null &&
|
||||
'stdout' in r.data &&
|
||||
'stderr' in r.data &&
|
||||
'exitCode' in r.data
|
||||
) {
|
||||
const d = r.data as { stdout: string; stderr: string; exitCode: number };
|
||||
const output = [
|
||||
d.stdout ? `STDOUT:\n${d.stdout}` : '',
|
||||
d.stderr ? `STDERR:\n${d.stderr}` : '',
|
||||
`Exit code: ${d.exitCode === -1 ? 'unknown' : d.exitCode}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.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) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tool executor function for the Catty Agent.
|
||||
* This bridges tool calls to the netcatty Electron IPC layer.
|
||||
*/
|
||||
export function createToolExecutor(
|
||||
bridge: NetcattyBridge | undefined,
|
||||
context: ExecutorContext,
|
||||
commandBlocklist?: string[],
|
||||
permissionMode: AIPermissionMode = 'confirm',
|
||||
): (toolCall: ToolCall) => Promise<ToolResult> {
|
||||
return async (toolCall: ToolCall): Promise<ToolResult> => {
|
||||
if (!bridge) {
|
||||
return {
|
||||
toolCallId: toolCall.id,
|
||||
content: 'Netcatty bridge is not available',
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode };
|
||||
const args = toolCall.arguments;
|
||||
|
||||
try {
|
||||
switch (toolCall.name) {
|
||||
case 'terminal_execute': {
|
||||
const r = await executeTerminalExecute(deps, {
|
||||
sessionId: String(args.sessionId || ''),
|
||||
command: String(args.command || ''),
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
case 'workspace_get_session_info': {
|
||||
const r = executeWorkspaceGetSessionInfo(deps, {
|
||||
sessionId: String(args.sessionId || ''),
|
||||
});
|
||||
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),
|
||||
});
|
||||
return toToolResult(toolCall.id, r);
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
toolCallId: toolCall.id,
|
||||
content: `Unknown tool: ${toolCall.name}`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
toolCallId: toolCall.id,
|
||||
content: `Tool execution error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
98
infrastructure/ai/cattyAgent/safety.ts
Normal file
98
infrastructure/ai/cattyAgent/safety.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../types';
|
||||
|
||||
/**
|
||||
* Check if a regex pattern is safe from ReDoS attacks.
|
||||
*
|
||||
* Rejects patterns with nested quantifiers like `(a+)+`, `(a*)*`, `(a+)*`
|
||||
* which can cause catastrophic backtracking / CPU exhaustion.
|
||||
*/
|
||||
function isSafeRegex(pattern: string): boolean {
|
||||
// Detect nested quantifiers: a group containing a quantifier, followed by another quantifier.
|
||||
// Matches patterns like (x+)+, (x*)+, (x+)*, (x{2,})+ etc.
|
||||
const nestedQuantifier = /\([^)]*[+*}]\)[+*?{]/;
|
||||
if (nestedQuantifier.test(pattern)) {
|
||||
return false;
|
||||
}
|
||||
// Also catch overlapping alternations with quantifiers inside quantified groups
|
||||
// e.g. (a|a)+ — not always dangerous but a common ReDoS vector
|
||||
const overlappingAlt = /\([^)]*\|[^)]*\)[+*]{/;
|
||||
if (overlappingAlt.test(pattern)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-compiled RegExp cache for command blocklist patterns.
|
||||
*
|
||||
* The blocklist is a best-effort defense-in-depth measure. It is NOT a
|
||||
* security boundary — determined users or sophisticated prompt injection
|
||||
* can bypass regex-based filtering. The primary security boundary is the
|
||||
* permission / confirmation system and OS-level sandboxing.
|
||||
*/
|
||||
const compiledDefaultBlocklist: RegExp[] = DEFAULT_COMMAND_BLOCKLIST.flatMap(
|
||||
(pattern) => {
|
||||
try {
|
||||
if (!isSafeRegex(pattern)) {
|
||||
console.warn(`[Safety] Skipping default blocklist pattern with nested quantifiers (ReDoS risk): ${pattern}`);
|
||||
return [];
|
||||
}
|
||||
return [new RegExp(pattern, 'i')];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** Cache for user-provided (non-default) blocklist patterns. */
|
||||
const userPatternCache = new Map<string, RegExp | null>();
|
||||
|
||||
function getCompiledPattern(pattern: string): RegExp | null {
|
||||
if (userPatternCache.has(pattern)) {
|
||||
return userPatternCache.get(pattern)!;
|
||||
}
|
||||
if (!isSafeRegex(pattern)) {
|
||||
console.warn(`[Safety] Skipping user blocklist pattern with nested quantifiers (ReDoS risk): ${pattern}`);
|
||||
userPatternCache.set(pattern, null);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const regex = new RegExp(pattern, 'i');
|
||||
userPatternCache.set(pattern, regex);
|
||||
return regex;
|
||||
} catch {
|
||||
userPatternCache.set(pattern, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command matches any pattern in the blocklist.
|
||||
* Returns the matching pattern if blocked, null if safe.
|
||||
*
|
||||
* Default blocklist patterns are pre-compiled at module load time.
|
||||
* User-provided patterns are compiled once and cached.
|
||||
*/
|
||||
export function checkCommandSafety(
|
||||
command: string,
|
||||
blocklist: string[] = DEFAULT_COMMAND_BLOCKLIST,
|
||||
): { blocked: boolean; matchedPattern?: string } {
|
||||
// Fast path: use pre-compiled regexes for the default blocklist
|
||||
if (blocklist === DEFAULT_COMMAND_BLOCKLIST) {
|
||||
for (let i = 0; i < compiledDefaultBlocklist.length; i++) {
|
||||
if (compiledDefaultBlocklist[i].test(command)) {
|
||||
return { blocked: true, matchedPattern: DEFAULT_COMMAND_BLOCKLIST[i] };
|
||||
}
|
||||
}
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
// User-provided blocklist: compile once and cache each pattern
|
||||
for (const pattern of blocklist) {
|
||||
const regex = getCompiledPattern(pattern);
|
||||
if (regex && regex.test(command)) {
|
||||
return { blocked: true, matchedPattern: pattern };
|
||||
}
|
||||
}
|
||||
return { blocked: false };
|
||||
}
|
||||
128
infrastructure/ai/cattyAgent/systemPrompt.ts
Normal file
128
infrastructure/ai/cattyAgent/systemPrompt.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
export interface SystemPromptContext {
|
||||
scopeType: 'terminal' | 'workspace' | 'global';
|
||||
scopeLabel?: string;
|
||||
hosts: Array<{
|
||||
sessionId: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
permissionMode: 'observer' | 'confirm' | 'autonomous';
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(context: SystemPromptContext): string {
|
||||
const { scopeType, scopeLabel, hosts, permissionMode } = context;
|
||||
|
||||
const scopeDescription = buildScopeDescription(scopeType, scopeLabel);
|
||||
const hostList = buildHostList(hosts);
|
||||
const permissionRules = buildPermissionRules(permissionMode);
|
||||
|
||||
return `You are **Catty Agent**, a terminal automation assistant built into netcatty. You help users manage remote servers by executing commands, reading files, and performing batch operations across multiple hosts.
|
||||
|
||||
## Current Scope
|
||||
|
||||
${scopeDescription}
|
||||
|
||||
## Available Hosts
|
||||
|
||||
${hostList}
|
||||
|
||||
## Permission Mode: ${permissionMode}
|
||||
|
||||
${permissionRules}
|
||||
|
||||
## Guidelines
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
4. **Explain before executing.** Before running any command, briefly explain what it does and why. This is especially important for commands that modify the system.
|
||||
|
||||
5. **Handle errors gracefully.** If a command fails, analyze the error output, explain what went wrong, and suggest alternatives or corrective actions. Do not retry the same failing command without modification.
|
||||
|
||||
6. **Stay focused.** Keep responses concise and relevant to terminal and server operations. Avoid unrelated commentary.
|
||||
|
||||
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.`;
|
||||
}
|
||||
|
||||
function buildScopeDescription(
|
||||
scopeType: 'terminal' | 'workspace' | 'global',
|
||||
scopeLabel?: string,
|
||||
): string {
|
||||
switch (scopeType) {
|
||||
case 'terminal':
|
||||
return `You are scoped to a single terminal session${scopeLabel ? `: **${scopeLabel}**` : ''}. Focus operations on this specific host.`;
|
||||
case 'workspace':
|
||||
return `You are scoped to workspace${scopeLabel ? ` **${scopeLabel}**` : ''}. You can operate on any host within this workspace.`;
|
||||
case 'global':
|
||||
return `You have global scope and can operate on any connected host across all workspaces.`;
|
||||
}
|
||||
}
|
||||
|
||||
function buildHostList(
|
||||
hosts: SystemPromptContext['hosts'],
|
||||
): string {
|
||||
if (hosts.length === 0) {
|
||||
return '_No hosts are currently available. The user needs to connect to a host first._';
|
||||
}
|
||||
|
||||
const lines = hosts.map(host => {
|
||||
const status = host.connected ? 'connected' : 'disconnected';
|
||||
const details = [
|
||||
`hostname: ${host.hostname}`,
|
||||
`label: ${host.label}`,
|
||||
host.os ? `os: ${host.os}` : null,
|
||||
host.username ? `user: ${host.username}` : null,
|
||||
`status: ${status}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
return `- \`${host.sessionId}\` - ${details}`;
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildPermissionRules(
|
||||
permissionMode: 'observer' | 'confirm' | 'autonomous',
|
||||
): string {
|
||||
switch (permissionMode) {
|
||||
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`)',
|
||||
'',
|
||||
'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');
|
||||
|
||||
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`)',
|
||||
'',
|
||||
'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');
|
||||
|
||||
case 'autonomous':
|
||||
return [
|
||||
'You are in **autonomous** mode. You may execute commands and write files without explicit per-action approval, as long as they are not on the blocklist.',
|
||||
'',
|
||||
'Even in autonomous mode:',
|
||||
'- Always present a plan for multi-step tasks before starting.',
|
||||
'- Blocked commands are still denied regardless of mode.',
|
||||
'- Exercise caution with destructive or irreversible operations.',
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
24
infrastructure/ai/concurrency.ts
Normal file
24
infrastructure/ai/concurrency.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Run an array of async task factories with a concurrency limit.
|
||||
*/
|
||||
export async function limitConcurrency<T>(tasks: (() => Promise<T>)[], limit: number): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
const errors: Array<{ index: number; error: unknown }> = [];
|
||||
const executing = new Set<Promise<void>>();
|
||||
for (const [i, task] of tasks.entries()) {
|
||||
const p: Promise<void> = task()
|
||||
.then(r => { results[i] = r; })
|
||||
.catch(err => { errors.push({ index: i, error: err }); })
|
||||
.finally(() => executing.delete(p));
|
||||
executing.add(p);
|
||||
if (executing.size >= limit) {
|
||||
await Promise.race(executing);
|
||||
}
|
||||
}
|
||||
await Promise.all(executing);
|
||||
if (errors.length > 0) {
|
||||
const msgs = errors.map(e => `Task ${e.index}: ${e.error instanceof Error ? e.error.message : String(e.error)}`);
|
||||
throw new AggregateError(errors.map(e => e.error), `${errors.length} task(s) failed: ${msgs.join('; ')}`);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
125
infrastructure/ai/conversationExport.ts
Normal file
125
infrastructure/ai/conversationExport.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { AISession } from './types';
|
||||
|
||||
/**
|
||||
* Export a session as Markdown
|
||||
*/
|
||||
export function exportAsMarkdown(session: AISession): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`# ${session.title || 'Untitled Chat'}`);
|
||||
lines.push('');
|
||||
lines.push(`- **Agent:** ${session.agentId}`);
|
||||
lines.push(`- **Scope:** ${session.scope.type}${session.scope.targetId ? ` (${session.scope.targetId})` : ''}`);
|
||||
lines.push(`- **Created:** ${new Date(session.createdAt).toLocaleString()}`);
|
||||
lines.push(`- **Updated:** ${new Date(session.updatedAt).toLocaleString()}`);
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
|
||||
for (const msg of session.messages) {
|
||||
if (msg.role === 'system') continue;
|
||||
|
||||
const time = new Date(msg.timestamp).toLocaleTimeString();
|
||||
|
||||
if (msg.role === 'user') {
|
||||
lines.push(`## User [${time}]`);
|
||||
lines.push('');
|
||||
lines.push(msg.content);
|
||||
lines.push('');
|
||||
} else if (msg.role === 'assistant') {
|
||||
lines.push(`## Assistant [${time}]${msg.model ? ` (${msg.model})` : ''}`);
|
||||
lines.push('');
|
||||
lines.push(msg.content);
|
||||
|
||||
if (msg.toolCalls?.length) {
|
||||
lines.push('');
|
||||
for (const tc of msg.toolCalls) {
|
||||
lines.push(`### Tool Call: \`${tc.name}\``);
|
||||
lines.push('');
|
||||
lines.push('```json');
|
||||
lines.push(JSON.stringify(tc.arguments, null, 2));
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
} else if (msg.role === 'tool') {
|
||||
if (msg.toolResults?.length) {
|
||||
for (const tr of msg.toolResults) {
|
||||
lines.push(`### Tool Result${tr.isError ? ' (Error)' : ''}`);
|
||||
lines.push('');
|
||||
lines.push('```');
|
||||
lines.push(tr.content);
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a session as JSON
|
||||
*/
|
||||
export function exportAsJSON(session: AISession): string {
|
||||
return JSON.stringify(session, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a session as plain text
|
||||
*/
|
||||
export function exportAsPlainText(session: AISession): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`Chat: ${session.title || 'Untitled'}`);
|
||||
lines.push(`Date: ${new Date(session.createdAt).toLocaleString()}`);
|
||||
lines.push('='.repeat(60));
|
||||
lines.push('');
|
||||
|
||||
for (const msg of session.messages) {
|
||||
if (msg.role === 'system') continue;
|
||||
|
||||
const time = new Date(msg.timestamp).toLocaleTimeString();
|
||||
|
||||
if (msg.role === 'user') {
|
||||
lines.push(`[${time}] You:`);
|
||||
lines.push(msg.content);
|
||||
lines.push('');
|
||||
} else if (msg.role === 'assistant') {
|
||||
lines.push(`[${time}] Assistant:`);
|
||||
lines.push(msg.content);
|
||||
|
||||
if (msg.toolCalls?.length) {
|
||||
for (const tc of msg.toolCalls) {
|
||||
lines.push(` > Tool: ${tc.name}(${JSON.stringify(tc.arguments)})`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
} else if (msg.role === 'tool') {
|
||||
if (msg.toolResults?.length) {
|
||||
for (const tr of msg.toolResults) {
|
||||
lines.push(` > Result${tr.isError ? ' [ERROR]' : ''}:`);
|
||||
lines.push(` ${tr.content}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a suggested filename for export
|
||||
*/
|
||||
export function getExportFilename(session: AISession, format: 'md' | 'json' | 'txt'): string {
|
||||
const title = (session.title || 'chat')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 40);
|
||||
const date = new Date(session.createdAt).toISOString().slice(0, 10);
|
||||
return `netcatty-${title}-${date}.${format}`;
|
||||
}
|
||||
68
infrastructure/ai/errorClassifier.ts
Normal file
68
infrastructure/ai/errorClassifier.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
/**
|
||||
* Classifies a raw error string into structured error info for display.
|
||||
*/
|
||||
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 MAX_ERROR_MESSAGE_LENGTH = 500;
|
||||
|
||||
/**
|
||||
* Sanitize an error message before displaying it to the user.
|
||||
* Strips file paths, URLs with credentials, and truncates long messages.
|
||||
*/
|
||||
export function sanitizeErrorMessage(msg: string): string {
|
||||
let sanitized = msg;
|
||||
|
||||
// Strip file system paths (Unix and Windows)
|
||||
sanitized = sanitized.replace(/(?:\/Users\/|\/home\/|\/tmp\/|\/var\/|[A-Z]:\\)[^\s"'`,;)}\]>]*/gi, '<path>');
|
||||
|
||||
// Strip URLs containing API keys or tokens in query params
|
||||
sanitized = sanitized.replace(/https?:\/\/[^\s"']*[?&](key|token|api_key|apikey|secret|access_token|auth)=[^\s"'&]*/gi, '<url-redacted>');
|
||||
|
||||
// Truncate overly long messages
|
||||
if (sanitized.length > MAX_ERROR_MESSAGE_LENGTH) {
|
||||
sanitized = sanitized.slice(0, MAX_ERROR_MESSAGE_LENGTH) + '...';
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
235
infrastructure/ai/externalAgentAdapter.ts
Normal file
235
infrastructure/ai/externalAgentAdapter.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import type {
|
||||
ExternalAgentConfig,
|
||||
} from './types';
|
||||
import { parseAgentJsonLine, formatSegmentsAsMarkdown } from './agentOutputParser';
|
||||
|
||||
/** Callbacks for streaming external agent output */
|
||||
export interface ExternalAgentCallbacks {
|
||||
onTextDelta: (text: string) => void;
|
||||
onError: (error: string) => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge interface matching the agent-related methods from window.netcatty
|
||||
*/
|
||||
interface AgentBridge {
|
||||
aiSpawnAgent(
|
||||
agentId: string,
|
||||
command: string,
|
||||
args?: string[],
|
||||
env?: Record<string, string>,
|
||||
options?: { closeStdin?: boolean },
|
||||
): Promise<{ ok: boolean; pid?: number; error?: string }>;
|
||||
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 }>;
|
||||
onAiAgentStdout(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentStderr(agentId: string, cb: (data: string) => void): () => void;
|
||||
onAiAgentExit(agentId: string, cb: (code: number) => void): () => void;
|
||||
}
|
||||
|
||||
const PROMPT_PLACEHOLDER = '{prompt}';
|
||||
|
||||
/**
|
||||
* Build the final command and args for an external agent.
|
||||
*/
|
||||
function buildAgentInvocation(
|
||||
config: ExternalAgentConfig,
|
||||
userMessage: string,
|
||||
): { command: string; args: string[]; useStdin: boolean; jsonMode: boolean } {
|
||||
const command = config.command;
|
||||
const templateArgs = config.args || [];
|
||||
|
||||
const hasPlaceholder = templateArgs.some(a => a.includes(PROMPT_PLACEHOLDER));
|
||||
const jsonMode = templateArgs.includes('--json');
|
||||
|
||||
if (hasPlaceholder) {
|
||||
const args = templateArgs.map(a =>
|
||||
a === PROMPT_PLACEHOLDER ? userMessage : a.replaceAll(PROMPT_PLACEHOLDER, userMessage),
|
||||
);
|
||||
return { command, args, useStdin: false, jsonMode };
|
||||
}
|
||||
|
||||
return { command, args: [...templateArgs], useStdin: true, jsonMode };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a stdout handler that parses JSON Lines (for --json mode agents)
|
||||
* and converts structured events to formatted markdown text.
|
||||
*
|
||||
* Handles partial lines since stdout chunks can split mid-line.
|
||||
*/
|
||||
function createJsonLinesHandler(onText: (text: string) => void): (data: string) => void {
|
||||
let lineBuffer = '';
|
||||
// Track seen item IDs to avoid duplicating command blocks
|
||||
// (item.started shows the command, item.completed shows command + output)
|
||||
const seenCommands = new Set<string>();
|
||||
|
||||
return (data: string) => {
|
||||
lineBuffer += data;
|
||||
const lines = lineBuffer.split('\n');
|
||||
// Keep the last (possibly incomplete) line in the buffer
|
||||
lineBuffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
const segments = parseAgentJsonLine(line);
|
||||
if (segments === null) {
|
||||
// Not JSON — pass through as plain text
|
||||
onText(line + '\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segments.length === 0) continue;
|
||||
|
||||
// Deduplicate command_execution: skip started if we'll get completed
|
||||
const filtered = segments.filter(seg => {
|
||||
if (seg.type === 'command') {
|
||||
if (seenCommands.has(seg.content)) return false;
|
||||
seenCommands.add(seg.content);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (filtered.length > 0) {
|
||||
const markdown = formatSegmentsAsMarkdown(filtered);
|
||||
onText(markdown);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an external agent and send a message through it.
|
||||
*/
|
||||
export async function runExternalAgentTurn(
|
||||
config: ExternalAgentConfig,
|
||||
userMessage: string,
|
||||
callbacks: ExternalAgentCallbacks,
|
||||
bridge: AgentBridge | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (!bridge) {
|
||||
callbacks.onError('Bridge not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = `ext_${config.id}_${Date.now()}`;
|
||||
const { command, args, useStdin, jsonMode } = buildAgentInvocation(config, userMessage);
|
||||
|
||||
const cleanupFns: (() => void)[] = [];
|
||||
let done = false;
|
||||
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
for (const fn of cleanupFns) {
|
||||
try { fn(); } catch { /* cleanup */ }
|
||||
}
|
||||
callbacks.onDone();
|
||||
};
|
||||
|
||||
// ── Set up event listeners BEFORE spawning to avoid race condition ──
|
||||
|
||||
// For JSON mode, parse structured events; otherwise, pass through raw text
|
||||
const stdoutHandler = jsonMode
|
||||
? createJsonLinesHandler((text) => { if (!done) callbacks.onTextDelta(text); })
|
||||
: (data: string) => { if (!done) callbacks.onTextDelta(data); };
|
||||
|
||||
const unsubStdout = bridge.onAiAgentStdout(agentId, stdoutHandler);
|
||||
cleanupFns.push(unsubStdout);
|
||||
|
||||
// Collect stderr
|
||||
let stderrBuffer = '';
|
||||
const unsubStderr = bridge.onAiAgentStderr(agentId, (data) => {
|
||||
stderrBuffer += data;
|
||||
});
|
||||
cleanupFns.push(unsubStderr);
|
||||
|
||||
let resolveExit: (code: number | null) => void;
|
||||
const exitPromise = new Promise<number | null>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
const unsubExit = bridge.onAiAgentExit(agentId, (code) => {
|
||||
resolve(code);
|
||||
});
|
||||
cleanupFns.push(unsubExit);
|
||||
});
|
||||
|
||||
// Handle abort
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
bridge.aiKillAgent(agentId).catch(() => {});
|
||||
callbacks.onError('Cancelled');
|
||||
resolveExit(null);
|
||||
finish();
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
cleanupFns.push(() => signal.removeEventListener('abort', onAbort));
|
||||
}
|
||||
|
||||
// ── Spawn the process ──
|
||||
const result = await bridge.aiSpawnAgent(
|
||||
agentId,
|
||||
command,
|
||||
args,
|
||||
config.env,
|
||||
{ closeStdin: !useStdin },
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
callbacks.onError(`Failed to start ${config.name}: ${result.error}`);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the user message via stdin if needed, then close stdin (EOF)
|
||||
if (useStdin) {
|
||||
try {
|
||||
await bridge.aiWriteToAgent(agentId, userMessage + '\n');
|
||||
await bridge.aiCloseAgentStdin(agentId);
|
||||
} catch (err) {
|
||||
callbacks.onError(`Failed to write to agent: ${err}`);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout after 5 minutes
|
||||
const timeout = setTimeout(() => {
|
||||
if (!done) {
|
||||
bridge.aiKillAgent(agentId).catch(() => {});
|
||||
callbacks.onError('Agent timeout (5 minutes)');
|
||||
resolveExit(null);
|
||||
finish();
|
||||
}
|
||||
}, 300000);
|
||||
cleanupFns.push(() => clearTimeout(timeout));
|
||||
|
||||
// Wait for the process to exit
|
||||
const exitCode = await exitPromise;
|
||||
|
||||
// If process exited with error and no stdout was received, report stderr
|
||||
if (exitCode !== 0 && stderrBuffer.trim() && !done) {
|
||||
callbacks.onError(stderrBuffer.trim());
|
||||
}
|
||||
|
||||
finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a running external agent session
|
||||
*/
|
||||
export async function killExternalAgent(
|
||||
agentId: string,
|
||||
bridge: AgentBridge | undefined,
|
||||
): Promise<void> {
|
||||
if (bridge) {
|
||||
await bridge.aiKillAgent(agentId).catch(() => {});
|
||||
}
|
||||
}
|
||||
277
infrastructure/ai/sdk/providers.ts
Normal file
277
infrastructure/ai/sdk/providers.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
import type { ProviderConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Bridge API subset used for SDK fetch adapter.
|
||||
*/
|
||||
interface BridgeAPI {
|
||||
aiFetch(
|
||||
url: string,
|
||||
method: string,
|
||||
headers: Record<string, string>,
|
||||
body?: string,
|
||||
providerId?: string,
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
data: string;
|
||||
error?: string;
|
||||
}>;
|
||||
aiChatStream(
|
||||
requestId: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
body: string,
|
||||
providerId?: string,
|
||||
): Promise<{ ok: boolean; statusCode?: number; statusText?: string; error?: string }>;
|
||||
onAiStreamData(requestId: string, cb: (data: string) => void): () => void;
|
||||
onAiStreamEnd(requestId: string, cb: () => void): () => void;
|
||||
onAiStreamError(requestId: string, cb: (error: string) => void): () => void;
|
||||
aiChatCancel(requestId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
function getBridge(): BridgeAPI | null {
|
||||
const w = window as unknown as { netcatty?: BridgeAPI };
|
||||
return w.netcatty ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether a request is likely a streaming request.
|
||||
* AI SDK streaming requests use POST with `"stream": true` in the body.
|
||||
*/
|
||||
function isStreamingRequest(init?: RequestInit): boolean {
|
||||
if (!init?.body) return false;
|
||||
try {
|
||||
const bodyStr = typeof init.body === 'string' ? init.body : null;
|
||||
if (!bodyStr) return false;
|
||||
const parsed = JSON.parse(bodyStr);
|
||||
return parsed.stream === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract headers as a plain Record<string, string> from various header formats.
|
||||
*/
|
||||
function extractHeaders(headers?: HeadersInit): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
if (!headers) return result;
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
} else if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
result[key] = value;
|
||||
}
|
||||
} else {
|
||||
Object.assign(result, headers);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fetch function compatible with the Vercel AI SDK that routes
|
||||
* requests through the Electron IPC bridge to avoid CORS.
|
||||
*
|
||||
* - Non-streaming requests: uses `window.netcatty.aiFetch()` and returns a `Response`.
|
||||
* - Streaming requests: uses `window.netcatty.aiChatStream()` and returns a
|
||||
* `Response` with a `ReadableStream` body.
|
||||
* - Falls back to `globalThis.fetch` if the bridge is unavailable.
|
||||
*/
|
||||
/** Placeholder API key used by the renderer; main process replaces it with the real key. */
|
||||
export const API_KEY_PLACEHOLDER = '__IPC_SECURED__';
|
||||
|
||||
export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.fetch {
|
||||
return async (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge) {
|
||||
return globalThis.fetch(input, init);
|
||||
}
|
||||
|
||||
// Resolve URL string
|
||||
let url: string;
|
||||
let resolvedInit = init;
|
||||
|
||||
if (input instanceof Request) {
|
||||
url = input.url;
|
||||
// Merge Request properties with init overrides
|
||||
if (!resolvedInit) {
|
||||
resolvedInit = {
|
||||
method: input.method,
|
||||
headers: extractHeaders(input.headers),
|
||||
body: input.body ? await new Response(input.body).text() : undefined,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
url = input instanceof URL ? input.toString() : input;
|
||||
}
|
||||
|
||||
const method = resolvedInit?.method || 'GET';
|
||||
const headers = extractHeaders(resolvedInit?.headers);
|
||||
const body =
|
||||
resolvedInit?.body != null ? String(resolvedInit.body) : undefined;
|
||||
|
||||
// Streaming path
|
||||
if (isStreamingRequest(resolvedInit)) {
|
||||
const requestId = `sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// Set up IPC event listeners BEFORE starting the stream to avoid
|
||||
// missing early events.
|
||||
const encoder = new TextEncoder();
|
||||
let streamController: ReadableStreamDefaultController<Uint8Array>;
|
||||
let cleanedUp = false;
|
||||
|
||||
const unsubData = bridge.onAiStreamData(requestId, (data: string) => {
|
||||
// Re-wrap as SSE so the SDK can parse it
|
||||
streamController?.enqueue(encoder.encode(`data: ${data}\n\n`));
|
||||
});
|
||||
const unsubEnd = bridge.onAiStreamEnd(requestId, () => {
|
||||
try { streamController?.close(); } catch { /* already closed */ }
|
||||
cleanup();
|
||||
});
|
||||
const unsubError = bridge.onAiStreamError(
|
||||
requestId,
|
||||
(error: string) => {
|
||||
try { streamController?.error(new Error(error)); } catch { /* already errored */ }
|
||||
cleanup();
|
||||
},
|
||||
);
|
||||
|
||||
const cleanup = () => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
unsubData();
|
||||
unsubEnd();
|
||||
unsubError();
|
||||
};
|
||||
|
||||
// Handle abort
|
||||
if (resolvedInit?.signal) {
|
||||
resolvedInit.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
bridge.aiChatCancel(requestId).catch(() => {});
|
||||
try { streamController?.error(new DOMException('Aborted', 'AbortError')); } catch { /* already errored */ }
|
||||
cleanup();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
// Start the stream — resolves once HTTP response headers arrive,
|
||||
// returning the real status code.
|
||||
const result = await bridge.aiChatStream(
|
||||
requestId,
|
||||
url,
|
||||
headers,
|
||||
body || '',
|
||||
providerId,
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
cleanup();
|
||||
return new Response(result.error || 'Stream request failed', {
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
});
|
||||
}
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
streamController = controller;
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: result.statusCode ?? 200,
|
||||
statusText: result.statusText ?? 'OK',
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
});
|
||||
}
|
||||
|
||||
// Non-streaming path
|
||||
const result = await bridge.aiFetch(url, method, headers, body, providerId);
|
||||
|
||||
return new Response(result.data, {
|
||||
status: result.status,
|
||||
statusText: result.ok ? 'OK' : 'Error',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Vercel AI SDK model instance from a ProviderConfig.
|
||||
*
|
||||
* API keys are NOT sent to the SDK in plaintext. Instead, a placeholder
|
||||
* token is used so the SDK builds proper auth headers, and the main
|
||||
* process replaces the placeholder with the real decrypted key before
|
||||
* making the HTTP request.
|
||||
*/
|
||||
export function createModelFromConfig(config: ProviderConfig) {
|
||||
// Use placeholder API key — the main process will inject the real key
|
||||
const safeApiKey = config.apiKey ? API_KEY_PLACEHOLDER : undefined;
|
||||
const customFetch = createBridgeFetchForSDK(config.id);
|
||||
const modelId = config.defaultModel || '';
|
||||
|
||||
switch (config.providerId) {
|
||||
case 'openai':
|
||||
// Use .chat() to force Chat Completions API (not Responses API)
|
||||
return createOpenAI({
|
||||
apiKey: safeApiKey,
|
||||
baseURL: config.baseURL,
|
||||
fetch: customFetch,
|
||||
}).chat(modelId);
|
||||
|
||||
case 'anthropic':
|
||||
return createAnthropic({
|
||||
apiKey: safeApiKey,
|
||||
baseURL: config.baseURL,
|
||||
fetch: customFetch,
|
||||
})(modelId);
|
||||
|
||||
case 'google':
|
||||
return createGoogleGenerativeAI({
|
||||
apiKey: safeApiKey,
|
||||
baseURL: config.baseURL,
|
||||
fetch: customFetch,
|
||||
})(modelId);
|
||||
|
||||
case 'ollama':
|
||||
// Ollama uses OpenAI-compatible Chat Completions API
|
||||
return createOpenAI({
|
||||
apiKey: 'ollama',
|
||||
baseURL: config.baseURL || 'http://localhost:11434/v1',
|
||||
fetch: customFetch,
|
||||
}).chat(modelId);
|
||||
|
||||
case 'openrouter':
|
||||
// OpenRouter uses OpenAI-compatible Chat Completions API
|
||||
return createOpenAI({
|
||||
apiKey: safeApiKey,
|
||||
baseURL: config.baseURL || 'https://openrouter.ai/api/v1',
|
||||
fetch: customFetch,
|
||||
}).chat(modelId);
|
||||
|
||||
case 'custom':
|
||||
// Custom providers use OpenAI-compatible Chat Completions API
|
||||
return createOpenAI({
|
||||
apiKey: safeApiKey,
|
||||
baseURL: config.baseURL,
|
||||
fetch: customFetch,
|
||||
}).chat(modelId);
|
||||
|
||||
default: {
|
||||
const _exhaustive: never = config.providerId;
|
||||
throw new Error(`Unsupported provider: ${_exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
178
infrastructure/ai/sdk/tools.ts
Normal file
178
infrastructure/ai/sdk/tools.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import type { NetcattyBridge, ExecutorContext } from '../cattyAgent/executor';
|
||||
import type { AIPermissionMode } from '../types';
|
||||
import {
|
||||
executeTerminalExecute,
|
||||
executeTerminalSendInput,
|
||||
executeSftpListDirectory,
|
||||
executeSftpReadFile,
|
||||
executeSftpWriteFile,
|
||||
executeWorkspaceGetInfo,
|
||||
executeWorkspaceGetSessionInfo,
|
||||
executeMultiHostExecute,
|
||||
type ToolDeps,
|
||||
type ToolExecResult,
|
||||
} from '../shared/toolExecutors';
|
||||
|
||||
/** Unwrap a shared ToolExecResult into the shape expected by Vercel AI SDK tool results. */
|
||||
function unwrap<T>(r: ToolExecResult<T>): T | { error: string } {
|
||||
if (r.ok === false) return { error: r.error };
|
||||
return r.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Catty Agent tools using the Vercel AI SDK `tool()` helper with zod schemas.
|
||||
*
|
||||
* @param bridge - The Electron IPC bridge for executing operations
|
||||
* @param context - Workspace/session context available to the agent
|
||||
* @param commandBlocklist - Optional command blocklist patterns for safety checks
|
||||
* @param permissionMode - Permission mode for tool execution gating
|
||||
*/
|
||||
export function createCattyTools(
|
||||
bridge: NetcattyBridge,
|
||||
context: ExecutorContext,
|
||||
commandBlocklist?: string[],
|
||||
permissionMode: AIPermissionMode = 'confirm',
|
||||
) {
|
||||
const writeToolNeedsApproval = permissionMode === 'confirm';
|
||||
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode };
|
||||
|
||||
return {
|
||||
terminal_execute: tool({
|
||||
description:
|
||||
'Execute a shell command on a remote host via the specified terminal session. ' +
|
||||
"The command runs in the session's shell and output is returned when complete.",
|
||||
inputSchema: z.object({
|
||||
sessionId: z.string().describe('The terminal session ID to execute the command on.'),
|
||||
command: z.string().describe('The shell command to execute on the remote host.'),
|
||||
}),
|
||||
needsApproval: writeToolNeedsApproval,
|
||||
execute: async ({ sessionId, command }) => {
|
||||
return unwrap(await executeTerminalExecute(deps, { sessionId, command }));
|
||||
},
|
||||
}),
|
||||
|
||||
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 ' +
|
||||
'and their connection status. No parameters required.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
return unwrap(executeWorkspaceGetInfo(deps));
|
||||
},
|
||||
}),
|
||||
|
||||
workspace_get_session_info: tool({
|
||||
description:
|
||||
'Get detailed information about a specific terminal or SFTP session, including ' +
|
||||
'the host it is connected to, connection status, and session metadata.',
|
||||
inputSchema: z.object({
|
||||
sessionId: z.string().describe('The session ID to get information about.'),
|
||||
}),
|
||||
execute: async ({ sessionId }) => {
|
||||
return unwrap(executeWorkspaceGetSessionInfo(deps, { sessionId }));
|
||||
},
|
||||
}),
|
||||
|
||||
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.',
|
||||
),
|
||||
}),
|
||||
needsApproval: writeToolNeedsApproval,
|
||||
execute: async ({ sessionIds, command, mode, stopOnError }) => {
|
||||
return unwrap(await executeMultiHostExecute(deps, { sessionIds, command, mode, stopOnError }));
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
322
infrastructure/ai/shared/toolExecutors.ts
Normal file
322
infrastructure/ai/shared/toolExecutors.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Shared tool execution logic used by both the Catty Agent executor (switch/case)
|
||||
* and the Vercel AI SDK tool wrappers.
|
||||
*
|
||||
* Each function encapsulates the core business logic for a tool — validation,
|
||||
* safety checks, bridge calls, and result formatting — so callers only need to
|
||||
* adapt the return value to their own response shape.
|
||||
*/
|
||||
|
||||
import type { NetcattyBridge, ExecutorContext } from '../cattyAgent/executor';
|
||||
import type { AIPermissionMode } from '../types';
|
||||
import { checkCommandSafety } from '../cattyAgent/safety';
|
||||
import { shellQuote } from '../shellQuote';
|
||||
import { limitConcurrency } from '../concurrency';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared result types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Discriminated union returned by every shared executor. */
|
||||
export type ToolExecResult<T = unknown> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; error: string };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dependencies bundle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ToolDeps {
|
||||
bridge: NetcattyBridge;
|
||||
context: ExecutorContext;
|
||||
commandBlocklist?: string[];
|
||||
permissionMode: AIPermissionMode;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function validSessionIds(ctx: ExecutorContext): Set<string> {
|
||||
return new Set(ctx.sessions.map(s => s.sessionId));
|
||||
}
|
||||
|
||||
function validateSessionScope(ctx: ExecutorContext, 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(', ')}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isObserver(mode: AIPermissionMode): boolean {
|
||||
return mode === 'observer';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool executors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function executeTerminalExecute(
|
||||
deps: ToolDeps,
|
||||
args: { sessionId: string; command: string },
|
||||
): Promise<ToolExecResult<{ stdout: string; stderr: string; exitCode: number }>> {
|
||||
const { bridge, context, commandBlocklist, permissionMode } = deps;
|
||||
const { sessionId, command } = args;
|
||||
|
||||
if (!sessionId || !command) {
|
||||
return { ok: false, error: 'Missing sessionId or command' };
|
||||
}
|
||||
const scopeErr = validateSessionScope(context, sessionId);
|
||||
if (scopeErr) return { ok: false, error: scopeErr };
|
||||
if (isObserver(permissionMode)) {
|
||||
return { ok: false, error: 'Observer mode: command execution is disabled. Switch to Confirm or Auto mode to execute commands.' };
|
||||
}
|
||||
const safety = checkCommandSafety(command, commandBlocklist);
|
||||
if (safety.blocked) {
|
||||
return { ok: false, error: `Command blocked by safety policy. Matched pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
|
||||
const result = await bridge.aiExec(sessionId, command);
|
||||
if (!result.ok) {
|
||||
return { ok: false, error: result.error || 'Command failed' };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
exitCode: result.exitCode ?? -1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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<{
|
||||
workspaceId: string | null;
|
||||
workspaceName: string | null;
|
||||
sessions: Array<{
|
||||
sessionId: string;
|
||||
hostname: string;
|
||||
label: string;
|
||||
os?: string;
|
||||
username?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
}> {
|
||||
const { context } = deps;
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
workspaceId: context.workspaceId || null,
|
||||
workspaceName: context.workspaceName || null,
|
||||
sessions: context.sessions.map(s => ({
|
||||
sessionId: s.sessionId,
|
||||
hostname: s.hostname,
|
||||
label: s.label,
|
||||
os: s.os,
|
||||
username: s.username,
|
||||
connected: s.connected,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function executeWorkspaceGetSessionInfo(
|
||||
deps: ToolDeps,
|
||||
args: { sessionId: string },
|
||||
): ToolExecResult<ExecutorContext['sessions'][number]> {
|
||||
const { context } = deps;
|
||||
const session = context.sessions.find(s => s.sessionId === args.sessionId);
|
||||
if (!session) {
|
||||
return { ok: false, error: `Session not found: ${args.sessionId}` };
|
||||
}
|
||||
return { ok: true, data: session };
|
||||
}
|
||||
|
||||
export async function executeMultiHostExecute(
|
||||
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;
|
||||
|
||||
if (sessionIds.length === 0 || !command) {
|
||||
return { ok: false, error: 'Missing sessionIds or command' };
|
||||
}
|
||||
|
||||
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(', ')}`,
|
||||
};
|
||||
}
|
||||
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 } };
|
||||
}
|
||||
9
infrastructure/ai/shellQuote.ts
Normal file
9
infrastructure/ai/shellQuote.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Safely quote a string for use in a POSIX shell command.
|
||||
* Wraps the value in single quotes and escapes any embedded single quotes.
|
||||
*
|
||||
* Example: shellQuote("hello 'world'") => "'hello '\\''world'\\'''"
|
||||
*/
|
||||
export function shellQuote(s: string): string {
|
||||
return "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
256
infrastructure/ai/types.ts
Normal file
256
infrastructure/ai/types.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
// AI Provider types
|
||||
export type AIProviderId = 'openai' | 'anthropic' | 'google' | 'ollama' | 'openrouter' | 'custom';
|
||||
|
||||
export interface ProviderConfig {
|
||||
id: string;
|
||||
providerId: AIProviderId;
|
||||
name: string;
|
||||
apiKey?: string; // encrypted via credentialBridge (enc:v1: prefix)
|
||||
baseURL?: string; // custom endpoint URL
|
||||
defaultModel?: string;
|
||||
customHeaders?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
providerId: AIProviderId;
|
||||
contextWindow?: number;
|
||||
supportsTools?: boolean;
|
||||
supportsStreaming?: boolean;
|
||||
}
|
||||
|
||||
// Chat types
|
||||
export interface ChatMessageImage {
|
||||
base64Data: string;
|
||||
mediaType: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content: string;
|
||||
images?: ChatMessageImage[];
|
||||
thinking?: string;
|
||||
thinkingDurationMs?: number;
|
||||
toolCalls?: ToolCall[];
|
||||
toolResults?: ToolResult[];
|
||||
timestamp: number;
|
||||
model?: string;
|
||||
providerId?: AIProviderId;
|
||||
errorInfo?: {
|
||||
type: 'network' | 'auth' | 'timeout' | 'provider' | 'agent' | 'unknown';
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
};
|
||||
/** Transient status text shown with shimmer effect (e.g. "Waiting for response...") */
|
||||
statusText?: string;
|
||||
executionStatus?: 'pending' | 'approved' | 'rejected' | 'running' | 'completed' | 'failed';
|
||||
pendingApproval?: {
|
||||
approvalId: string;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
status: 'pending' | 'approved' | 'denied';
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
toolCallId: string;
|
||||
content: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatParams {
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
tools?: ToolDefinition[];
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>; // JSON Schema
|
||||
}
|
||||
|
||||
// Streaming events
|
||||
export type ChatStreamEvent =
|
||||
| { type: 'text'; content: string }
|
||||
| { type: 'thinking'; content: string }
|
||||
| { type: 'tool_call'; toolCall: ToolCall }
|
||||
| { type: 'error'; error: string }
|
||||
| { type: 'done'; usage?: { promptTokens: number; completionTokens: number } };
|
||||
|
||||
// AI Session types
|
||||
export interface AISession {
|
||||
id: string;
|
||||
title: string;
|
||||
agentId: string;
|
||||
scope: AISessionScope;
|
||||
messages: ChatMessage[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface AISessionScope {
|
||||
type: 'terminal' | 'workspace' | 'global';
|
||||
targetId?: string; // sessionId or workspaceId
|
||||
hostIds?: string[]; // resolved host IDs in scope
|
||||
}
|
||||
|
||||
// Permission model
|
||||
export type AIPermissionMode = 'observer' | 'confirm' | 'autonomous';
|
||||
|
||||
export interface HostAIPermission {
|
||||
hostId: string;
|
||||
mode: AIPermissionMode;
|
||||
allowedCommands?: string[]; // regex patterns
|
||||
blockedCommands?: string[]; // regex patterns
|
||||
allowFileWrite?: boolean;
|
||||
maxConcurrentCommands?: number;
|
||||
}
|
||||
|
||||
// Agent types
|
||||
export interface AgentInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'builtin' | 'external';
|
||||
icon?: string;
|
||||
description?: string;
|
||||
command?: string; // for external agents
|
||||
args?: string[];
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
// External Agent (ACP) config
|
||||
export interface ExternalAgentConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
icon?: string;
|
||||
enabled: boolean;
|
||||
/** ACP command (e.g. 'codex-acp', 'claude-code-acp', 'gemini --experimental-acp') */
|
||||
acpCommand?: string;
|
||||
acpArgs?: string[];
|
||||
}
|
||||
|
||||
// Discovered agent from system PATH
|
||||
export interface DiscoveredAgent {
|
||||
command: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
args: string[];
|
||||
path: string;
|
||||
version: string;
|
||||
available: boolean;
|
||||
/** ACP command if agent supports ACP protocol */
|
||||
acpCommand?: string;
|
||||
acpArgs?: string[];
|
||||
}
|
||||
|
||||
// AI Settings (stored in localStorage)
|
||||
export interface AISettings {
|
||||
providers: ProviderConfig[];
|
||||
activeProviderId: string;
|
||||
activeModelId: string;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
defaultAgentId: string;
|
||||
commandBlocklist: string[]; // global command blocklist patterns
|
||||
commandTimeout: number; // seconds, default 60
|
||||
maxIterations: number; // doom loop prevention, default 20
|
||||
}
|
||||
|
||||
export const DEFAULT_COMMAND_BLOCKLIST = [
|
||||
// rm with recursive+force in any order/form targeting root
|
||||
'\\brm\\s+(-[a-zA-Z]*r[a-zA-Z]*\\s+(-[a-zA-Z]*f[a-zA-Z]*\\s+)?|-[a-zA-Z]*f[a-zA-Z]*\\s+(-[a-zA-Z]*r[a-zA-Z]*\\s+)?|--recursive\\s+|--force\\s+){1,}',
|
||||
'\\bmkfs\\.',
|
||||
'\\bdd\\s+if=.*\\s+of=/dev/',
|
||||
'\\b(shutdown|reboot|poweroff|halt)\\b',
|
||||
':\\(\\)\\{\\s*:\\|:\\&\\s*\\};:', // fork bomb
|
||||
'>\\s*/dev/sd',
|
||||
'\\bchmod\\s+(-[a-zA-Z]*R[a-zA-Z]*|--recursive)\\s+777\\s+/',
|
||||
'\\bmv\\s+/\\s',
|
||||
':\\s*>\\s*/etc/',
|
||||
'\\bcurl\\s+.*\\|\\s*\\bsudo\\s+\\bbash\\b', // piped install with sudo
|
||||
'\\bwget\\s+.*\\|\\s*\\bsudo\\s+\\bbash\\b',
|
||||
// Common bypass techniques (defense-in-depth, not a security boundary)
|
||||
'base64.*\\|.*(?:ba)?sh', // base64 decode piped to shell
|
||||
'\\beval\\b', // eval usage
|
||||
'\\$\\(', // command substitution abuse
|
||||
'`.+`', // backtick command substitution
|
||||
];
|
||||
|
||||
export const DEFAULT_AI_SETTINGS: AISettings = {
|
||||
providers: [],
|
||||
activeProviderId: '',
|
||||
activeModelId: '',
|
||||
globalPermissionMode: 'confirm',
|
||||
externalAgents: [],
|
||||
defaultAgentId: 'catty',
|
||||
commandBlocklist: [...DEFAULT_COMMAND_BLOCKLIST],
|
||||
commandTimeout: 60,
|
||||
maxIterations: 20,
|
||||
};
|
||||
|
||||
// Provider presets for quick setup
|
||||
export const PROVIDER_PRESETS: Record<AIProviderId, { name: string; defaultBaseURL: string; modelsEndpoint?: string }> = {
|
||||
openai: { name: 'OpenAI', defaultBaseURL: 'https://api.openai.com/v1', modelsEndpoint: '/models' },
|
||||
anthropic: { name: 'Anthropic', defaultBaseURL: 'https://api.anthropic.com', modelsEndpoint: '/v1/models' },
|
||||
google: { name: 'Google AI', defaultBaseURL: 'https://generativelanguage.googleapis.com/v1beta' },
|
||||
ollama: { name: 'Ollama', defaultBaseURL: 'http://localhost:11434/v1', modelsEndpoint: '/models' },
|
||||
openrouter: { name: 'OpenRouter', defaultBaseURL: 'https://openrouter.ai/api/v1', modelsEndpoint: '/models' },
|
||||
custom: { name: 'Custom', defaultBaseURL: '' },
|
||||
};
|
||||
|
||||
// Agent model presets (hardcoded, same as 1code)
|
||||
export interface AgentModelPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
/** Codex thinking levels (model ID sent as `id/thinking`) */
|
||||
thinkingLevels?: string[];
|
||||
}
|
||||
|
||||
export const CLAUDE_MODEL_PRESETS: AgentModelPreset[] = [
|
||||
{ id: 'default', name: 'Opus 4.6', description: 'Recommended' },
|
||||
{ id: 'sonnet', name: 'Sonnet 4.6', description: 'Everyday tasks' },
|
||||
{ id: 'haiku', name: 'Haiku 4.5', description: 'Fastest' },
|
||||
];
|
||||
|
||||
export const CODEX_MODEL_PRESETS: AgentModelPreset[] = [
|
||||
{ id: 'gpt-5.4', name: 'GPT 5.4', description: 'Latest', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
|
||||
{ id: 'gpt-5.3-codex', name: 'Codex 5.3', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
|
||||
{ id: 'gpt-5.2-codex', name: 'Codex 5.2', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
|
||||
{ id: 'gpt-5.1-codex-max', name: 'Codex 5.1 Max', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
|
||||
{ id: 'gpt-5.1-codex-mini', name: 'Codex 5.1 Mini', description: 'Fast', thinkingLevels: ['medium', 'high'] },
|
||||
{ id: 'o3', name: 'o3', description: 'Reasoning' },
|
||||
{ id: 'o4-mini', name: 'o4-mini', description: 'Fast reasoning' },
|
||||
];
|
||||
|
||||
export function getAgentModelPresets(agentCommand?: string): AgentModelPreset[] {
|
||||
if (!agentCommand) return [];
|
||||
const basename = agentCommand.split('/').pop()?.toLowerCase() ?? '';
|
||||
if (basename.startsWith('claude')) return CLAUDE_MODEL_PRESETS;
|
||||
if (basename.startsWith('codex')) return CODEX_MODEL_PRESETS;
|
||||
return [];
|
||||
}
|
||||
|
||||
export function formatThinkingLabel(level: string): string {
|
||||
if (level === 'xhigh') return 'Extra High';
|
||||
return level.charAt(0).toUpperCase() + level.slice(1);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hos
|
||||
export const STORAGE_KEY_UPDATE_LAST_CHECK = 'netcatty_update_last_check_v1';
|
||||
export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_version_v1';
|
||||
export const STORAGE_KEY_UPDATE_LATEST_RELEASE = 'netcatty_update_latest_release_v1';
|
||||
export const STORAGE_KEY_AUTO_UPDATE_ENABLED = 'netcatty_auto_update_enabled_v1';
|
||||
|
||||
// SFTP File Opener Associations
|
||||
export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associations_v1';
|
||||
@@ -68,6 +69,21 @@ export const STORAGE_KEY_MANAGED_SOURCES = 'netcatty_managed_sources_v1';
|
||||
// Global Toggle Window Settings (Quake Mode)
|
||||
export const STORAGE_KEY_TOGGLE_WINDOW_HOTKEY = 'netcatty_toggle_window_hotkey_v1';
|
||||
export const STORAGE_KEY_CLOSE_TO_TRAY = 'netcatty_close_to_tray_v1';
|
||||
export const STORAGE_KEY_GLOBAL_HOTKEY_ENABLED = 'netcatty_global_hotkey_enabled_v1';
|
||||
|
||||
// Custom Terminal Themes
|
||||
export const STORAGE_KEY_CUSTOM_THEMES = 'netcatty_custom_themes_v1';
|
||||
|
||||
// AI Settings
|
||||
export const STORAGE_KEY_AI_PROVIDERS = 'netcatty_ai_providers_v1';
|
||||
export const STORAGE_KEY_AI_ACTIVE_PROVIDER = 'netcatty_ai_active_provider_v1';
|
||||
export const STORAGE_KEY_AI_ACTIVE_MODEL = 'netcatty_ai_active_model_v1';
|
||||
export const STORAGE_KEY_AI_PERMISSION_MODE = 'netcatty_ai_permission_mode_v1';
|
||||
export const STORAGE_KEY_AI_HOST_PERMISSIONS = 'netcatty_ai_host_permissions_v1';
|
||||
export const STORAGE_KEY_AI_EXTERNAL_AGENTS = 'netcatty_ai_external_agents_v1';
|
||||
export const STORAGE_KEY_AI_DEFAULT_AGENT = 'netcatty_ai_default_agent_v1';
|
||||
export const STORAGE_KEY_AI_COMMAND_BLOCKLIST = 'netcatty_ai_command_blocklist_v1';
|
||||
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';
|
||||
|
||||
@@ -7,18 +7,40 @@ const safeParse = <T>(value: string | null): T | null => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely write to localStorage, catching QuotaExceededError.
|
||||
* Returns true if the write succeeded, false if storage quota was exceeded.
|
||||
*/
|
||||
function safeSetItem(key: string, value: string): boolean {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof DOMException &&
|
||||
(err.name === 'QuotaExceededError' || err.code === 22)
|
||||
) {
|
||||
console.warn(
|
||||
`[localStorageAdapter] QuotaExceededError writing key "${key}" (${value.length} chars). Data was not persisted.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
throw err; // Re-throw unexpected errors
|
||||
}
|
||||
}
|
||||
|
||||
export const localStorageAdapter = {
|
||||
read<T>(key: string): T | null {
|
||||
return safeParse<T>(localStorage.getItem(key));
|
||||
},
|
||||
write<T>(key: string, value: T) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
write<T>(key: string, value: T): boolean {
|
||||
return safeSetItem(key, JSON.stringify(value));
|
||||
},
|
||||
readString(key: string): string | null {
|
||||
return localStorage.getItem(key);
|
||||
},
|
||||
writeString(key: string, value: string) {
|
||||
localStorage.setItem(key, value);
|
||||
writeString(key: string, value: string): boolean {
|
||||
return safeSetItem(key, value);
|
||||
},
|
||||
readBoolean(key: string): boolean | null {
|
||||
const value = localStorage.getItem(key);
|
||||
@@ -27,8 +49,8 @@ export const localStorageAdapter = {
|
||||
if (value === "false") return false;
|
||||
return null;
|
||||
},
|
||||
writeBoolean(key: string, value: boolean) {
|
||||
localStorage.setItem(key, value ? "true" : "false");
|
||||
writeBoolean(key: string, value: boolean): boolean {
|
||||
return safeSetItem(key, value ? "true" : "false");
|
||||
},
|
||||
readNumber(key: string): number | null {
|
||||
const value = localStorage.getItem(key);
|
||||
@@ -36,8 +58,8 @@ export const localStorageAdapter = {
|
||||
const num = parseInt(value, 10);
|
||||
return isNaN(num) ? null : num;
|
||||
},
|
||||
writeNumber(key: string, value: number) {
|
||||
localStorage.setItem(key, String(value));
|
||||
writeNumber(key: string, value: number): boolean {
|
||||
return safeSetItem(key, String(value));
|
||||
},
|
||||
remove(key: string) {
|
||||
localStorage.removeItem(key);
|
||||
|
||||
3255
package-lock.json
generated
3255
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -28,10 +28,14 @@
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
"@ai-sdk/google": "^3.0.43",
|
||||
"@ai-sdk/openai": "^3.0.41",
|
||||
"@aws-sdk/client-s3": "^3.956.0",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/space-grotesk": "^5.2.10",
|
||||
"@google/genai": "1.33.0",
|
||||
"@mcpc-tech/acp-ai-provider": "0.2.8",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-context-menu": "2.2.16",
|
||||
@@ -43,25 +47,32 @@
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@streamdown/cjk": "^1.0.2",
|
||||
"@streamdown/code": "^1.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-serialize": "^0.13.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@zed-industries/codex-acp": "0.10.0",
|
||||
"ai": "^6.0.116",
|
||||
"clsx": "2.1.1",
|
||||
"electron-updater": "^6.8.3",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"lucide-react": "0.560.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"node-pty": "1.1.0-beta19",
|
||||
"node-pty": "1.1.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"serialport": "^13.0.0",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
"streamdown": "^2.4.0",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"use-stick-to-bottom": "^1.1.3",
|
||||
"uuid": "^13.0.0",
|
||||
"webdav": "^5.8.0"
|
||||
"webdav": "^5.8.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
12
public/ai/agents/atom.svg
Normal file
12
public/ai/agents/atom.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-atom-2" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
|
||||
<path d="M12 21l0 .01" />
|
||||
<path d="M3 9l0 .01" />
|
||||
<path d="M21 9l0 .01" />
|
||||
<path d="M8 20.1a9 9 0 0 1 -5 -7.1" />
|
||||
<path d="M16 20.1a9 9 0 0 0 5 -7.1" />
|
||||
<path d="M6.2 5a9 9 0 0 1 11.4 0" />
|
||||
</svg>
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 547 B |
1
public/ai/agents/catty.svg
Normal file
1
public/ai/agents/catty.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="150 25 680 950" fill="currentColor"><path d="M467.1488 555.52a48.0768 48.0768 0 1 0-48.0256-48.0256 48.0256 48.0256 0 0 0 48.0256 48.0256zM602.0608 463.5136c-23.4496 26.9312-46.7968 68.6592 6.0928 64.512s59.2896-25.6 57.0368-71.168-38.5024-21.6576-63.1296 6.656zM615.1168 383.3344a45.5168 45.5168 0 1 0-45.4656-45.4144 45.5168 45.5168 0 0 0 45.4656 45.4144z"/><path d="M780.3904 554.5984c6.5024-9.2672 13.056-18.9952 19.6096-29.2864 56.8832-88.8832 48.896-160.4608 32.1536-204.8-21.9648-58.112-73.5232-106.3424-136.2944-128-47.8208-75.8272-182.4768-165.376-238.1824-142.6944-61.44 24.9856-65.8432 141.056-64.9728 190.0032a322.56 322.56 0 0 0-58.5728 66.56c-15.36 3.6864-41.984 10.752-69.5808 20.1216-67.7376 23.04-99.072 45.4656-104.7552 75.0592-11.8272 60.928 68.7104 158.72 173.7728 212.48 6.7072 10.5984 13.4656 20.48 20.48 29.0304-21.9136 21.1456-83.9168 92.16-55.552 194.304 21.3504 76.8 83.968 123.4944 168.0384 125.3376l7.424 8.448c2.304 2.6112 4.3008 5.12 5.7856 6.656a45.4656 45.4656 0 0 0 34.9184 16.3328h42.1888a45.568 45.568 0 0 0 45.5168-45.5168v-0.768h17.7152a45.6192 45.6192 0 0 0 45.5168 44.5952h42.7008a46.7968 46.7968 0 0 0 27.4944-9.728l1.024-0.7168c58.0096-39.424 93.2864-95.6928 102.0416-162.816 9.472-72.7552-14.3872-148.48-43.8272-190.464 3.0208-35.584-1.4336-58.7776-14.6432-74.1376zM220.16 415.2832c12.6464-14.08 76.3392-37.8368 139.6736-51.7632a30.72 30.72 0 0 0 19.6096-14.0288c8.0384-13.2608 34.7136-51.2 61.44-68.6592a30.72 30.72 0 0 0 13.9264-27.392c-5.12-89.3952 15.36-138.8032 25.1392-145.92 26.8288 0 135.8848 62.976 168.1408 124.7744a30.72 30.72 0 0 0 18.7392 15.36c39.1168 11.264 88.0128 42.0352 107.8784 94.72 16.896 44.7488 7.7312 96.6656-26.4192 150.0672-92.16 144.3328-174.848 169.9328-206.0288 174.08-34.048 4.7616-97.28 13.4656-160.768-91.904a30.72 30.72 0 0 0-12.8512-11.8784c-99.9936-48.4352-148.1216-126.1056-148.48-147.456z m137.5744 405.6576c-19.7632-71.2704 25.6-120.9856 38.9632-133.6832 1.792 1.4848 3.584 2.9184 5.12 4.2496-28.928 80.6912-11.2128 149.1968 14.9504 199.68-29.3888-12.6464-49.8688-36.5056-59.2384-70.2464z m420.2496-9.728c-6.5024 49.8688-31.8464 90.1632-75.4176 119.7568h-20.992a45.5168 45.5168 0 0 0-45.3632-44.5952h-49.8688a45.4656 45.4656 0 0 0-45.3632 45.4144v0.8704h-18.9952a39.424 39.424 0 0 0-1.9968-2.2528c-44.3904-50.176-91.0848-118.9376-63.0784-209.3056a177.9712 177.9712 0 0 0 57.1904 9.216 260.7616 260.7616 0 0 0 36.7616-2.9696c38.5024-5.12 106.8032-28.1088 183.6544-114.3808 0 5.4784-0.6656 12.288-1.7408 20.8384a30.72 30.72 0 0 0 7.2704 24.064c20.1216 23.3472 46.1824 88.2176 37.7344 153.344z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user