feat(permission): 实现 WebSocket 权限确认机制
重构权限系统,将终端 UI 代码从 core 模块移除,实现基于 WebSocket 的权限确认流程: Core 模块清理: - 删除 permission/prompt.ts 和 file-prompt.ts(终端交互) - 删除 diff.ts 中的 chalk 渲染函数 - 删除 config.ts 中的 inquirer 交互 - 移除 chalk 依赖 Server 权限处理: - 新增 permission/handler.ts,实现 WebSocket 权限请求/响应 - 更新 agent/adapter.ts 设置权限回调 - 更新 ws.ts 处理 permission_response 消息 Web 权限组件: - 新增 PermissionDialog 组件,显示权限请求详情和 Diff - 更新 useChat hook 管理权限状态 - 更新 Chat 页面集成权限弹窗
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* PermissionDialog Component
|
||||
*
|
||||
* Shows permission confirmation dialogs for bash commands, file operations, etc.
|
||||
* Integrates with WebSocket for real-time permission requests from the server.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Shield,
|
||||
Terminal,
|
||||
FileEdit,
|
||||
GitBranch,
|
||||
Globe,
|
||||
X,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { cn } from '../utils/cn';
|
||||
import { modalOverlay, modalContent, smoothTransition } from '../utils/animations';
|
||||
import { Button } from '../primitives/Button';
|
||||
|
||||
// Permission types
|
||||
export type PermissionType = 'bash' | 'file' | 'git' | 'web';
|
||||
|
||||
// Permission request context
|
||||
export interface PermissionRequestContext {
|
||||
command?: string;
|
||||
operation?: string;
|
||||
path?: string;
|
||||
gitOperation?: string;
|
||||
query?: string;
|
||||
patterns?: string[];
|
||||
externalPaths?: string[];
|
||||
}
|
||||
|
||||
// Diff line for file operations
|
||||
interface DiffLine {
|
||||
type: 'add' | 'remove' | 'context';
|
||||
lineNumber: number | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// Diff hunk
|
||||
interface DiffHunk {
|
||||
oldStart: number;
|
||||
oldCount: number;
|
||||
newStart: number;
|
||||
newCount: number;
|
||||
lines: DiffLine[];
|
||||
}
|
||||
|
||||
// Diff info for file operations
|
||||
export interface DiffInfo {
|
||||
isNew: boolean;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
hunks: DiffHunk[];
|
||||
}
|
||||
|
||||
// Permission request payload
|
||||
export interface PermissionRequest {
|
||||
requestId: string;
|
||||
permissionType: PermissionType;
|
||||
context: PermissionRequestContext;
|
||||
diff?: DiffInfo;
|
||||
}
|
||||
|
||||
interface PermissionDialogProps {
|
||||
request: PermissionRequest;
|
||||
onAllow: (requestId: string, remember: boolean) => void;
|
||||
onDeny: (requestId: string, remember: boolean) => void;
|
||||
responsive?: boolean;
|
||||
}
|
||||
|
||||
// Icon component based on permission type
|
||||
function getPermissionIcon(type: PermissionType) {
|
||||
switch (type) {
|
||||
case 'bash':
|
||||
return <Terminal size={24} className="text-yellow-400" />;
|
||||
case 'file':
|
||||
return <FileEdit size={24} className="text-blue-400" />;
|
||||
case 'git':
|
||||
return <GitBranch size={24} className="text-purple-400" />;
|
||||
case 'web':
|
||||
return <Globe size={24} className="text-green-400" />;
|
||||
default:
|
||||
return <Shield size={24} className="text-gray-400" />;
|
||||
}
|
||||
}
|
||||
|
||||
// Title based on permission type
|
||||
function getPermissionTitle(type: PermissionType) {
|
||||
switch (type) {
|
||||
case 'bash':
|
||||
return 'Execute Command';
|
||||
case 'file':
|
||||
return 'File Operation';
|
||||
case 'git':
|
||||
return 'Git Operation';
|
||||
case 'web':
|
||||
return 'Web Access';
|
||||
default:
|
||||
return 'Permission Required';
|
||||
}
|
||||
}
|
||||
|
||||
// Render diff content
|
||||
function DiffViewer({ diff }: { diff: DiffInfo }) {
|
||||
if (!diff.hunks || diff.hunks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-gray-700 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-900/50 border-b border-gray-700">
|
||||
<span className="text-xs text-gray-400">
|
||||
{diff.isNew ? 'New file' : 'Changes'}
|
||||
</span>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-green-400">+{diff.additions}</span>
|
||||
<span className="text-red-400">-{diff.deletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-auto">
|
||||
<pre className="text-xs font-mono">
|
||||
{diff.hunks.map((hunk, hunkIndex) => (
|
||||
<div key={hunkIndex}>
|
||||
<div className="px-3 py-1 bg-blue-500/10 text-blue-400 border-y border-gray-700/50">
|
||||
@@ -{hunk.oldStart},{hunk.oldCount} +{hunk.newStart},{hunk.newCount} @@
|
||||
</div>
|
||||
{hunk.lines.map((line, lineIndex) => {
|
||||
let className = 'px-3 py-0.5 ';
|
||||
let prefix = ' ';
|
||||
|
||||
if (line.type === 'add') {
|
||||
className += 'bg-green-500/10 text-green-400';
|
||||
prefix = '+';
|
||||
} else if (line.type === 'remove') {
|
||||
className += 'bg-red-500/10 text-red-400';
|
||||
prefix = '-';
|
||||
} else {
|
||||
className += 'text-gray-400';
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={lineIndex} className={className}>
|
||||
<span className="select-none opacity-50 mr-2">{prefix}</span>
|
||||
{line.content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PermissionDialog({
|
||||
request,
|
||||
onAllow,
|
||||
onDeny,
|
||||
responsive = false,
|
||||
}: PermissionDialogProps) {
|
||||
const [remember, setRemember] = useState(false);
|
||||
const { requestId, permissionType, context, diff } = request;
|
||||
|
||||
const handleAllow = () => {
|
||||
onAllow(requestId, remember);
|
||||
};
|
||||
|
||||
const handleDeny = () => {
|
||||
onDeny(requestId, remember);
|
||||
};
|
||||
|
||||
// Format context for display
|
||||
const renderContext = () => {
|
||||
switch (permissionType) {
|
||||
case 'bash':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-400">Command:</div>
|
||||
<code className="block px-3 py-2 bg-gray-900 rounded-lg font-mono text-sm text-yellow-300 break-all">
|
||||
{context.command}
|
||||
</code>
|
||||
{context.externalPaths && context.externalPaths.length > 0 && (
|
||||
<div className="flex items-start gap-2 mt-3 p-2 bg-yellow-500/10 rounded-lg">
|
||||
<AlertTriangle size={16} className="text-yellow-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs text-yellow-300">
|
||||
<div className="font-medium">External paths detected:</div>
|
||||
<div className="mt-1 text-yellow-400/80">
|
||||
{context.externalPaths.map((p, i) => (
|
||||
<div key={i} className="font-mono">{p}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'file':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">Operation:</span>
|
||||
<span className={cn(
|
||||
'px-2 py-0.5 rounded text-xs font-medium',
|
||||
context.operation === 'delete' ? 'bg-red-500/20 text-red-400' :
|
||||
context.operation === 'write' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
'bg-blue-500/20 text-blue-400'
|
||||
)}>
|
||||
{context.operation?.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Path:</div>
|
||||
<code className="block px-3 py-2 bg-gray-900 rounded-lg font-mono text-sm text-blue-300 break-all">
|
||||
{context.path}
|
||||
</code>
|
||||
{diff && <DiffViewer diff={diff} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'git':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">Git operation:</span>
|
||||
<span className="px-2 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-400">
|
||||
{context.gitOperation?.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{context.command && (
|
||||
<>
|
||||
<div className="text-sm text-gray-400">Command:</div>
|
||||
<code className="block px-3 py-2 bg-gray-900 rounded-lg font-mono text-sm text-purple-300 break-all">
|
||||
{context.command}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'web':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-400">Request:</div>
|
||||
<code className="block px-3 py-2 bg-gray-900 rounded-lg font-mono text-sm text-green-300 break-all">
|
||||
{context.query || context.command}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="text-sm text-gray-400">
|
||||
<pre className="bg-gray-900 p-3 rounded-lg overflow-auto">
|
||||
{JSON.stringify(context, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
variants={modalOverlay}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2 }}
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/50 flex z-50',
|
||||
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
variants={modalContent}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={smoothTransition}
|
||||
className={cn(
|
||||
'bg-gray-800 overflow-hidden flex flex-col',
|
||||
responsive
|
||||
? 'w-full md:w-full md:max-w-lg md:mx-4 rounded-t-2xl md:rounded-lg'
|
||||
: 'rounded-lg w-full max-w-lg mx-4'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-700">
|
||||
{responsive && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-600 rounded-full md:hidden" />
|
||||
)}
|
||||
<div className={cn('flex items-center gap-3', responsive && 'mt-2 md:mt-0')}>
|
||||
<div className="p-2 rounded-lg bg-gray-900">
|
||||
{getPermissionIcon(permissionType)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{getPermissionTitle(permissionType)}</h2>
|
||||
<p className="text-xs text-gray-500">AI is requesting permission</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleDeny}
|
||||
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 py-4 overflow-y-auto max-h-[60vh]">
|
||||
{renderContext()}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={cn(
|
||||
'flex flex-col gap-3 border-t border-gray-700',
|
||||
responsive ? 'px-4 py-4 safe-area-pb' : 'px-5 py-4'
|
||||
)}>
|
||||
{/* Remember checkbox */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-gray-900 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
Remember for this session
|
||||
</label>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 text-red-400 border-red-400/50 hover:border-red-400 hover:bg-red-400/10"
|
||||
onClick={handleDeny}
|
||||
>
|
||||
<X size={16} className="mr-2" />
|
||||
Deny
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
className="flex-1"
|
||||
onClick={handleAllow}
|
||||
>
|
||||
<Check size={16} className="mr-2" />
|
||||
Allow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { createWebSocket, getMessages, type Message } from '../api/client.js';
|
||||
import type { PermissionRequest } from '../components/PermissionDialog.js';
|
||||
|
||||
interface UseChatOptions {
|
||||
sessionId: string;
|
||||
@@ -19,6 +20,7 @@ interface ChatState {
|
||||
isConnected: boolean;
|
||||
isLoading: boolean;
|
||||
streamingContent: string;
|
||||
permissionRequest: PermissionRequest | null;
|
||||
}
|
||||
|
||||
export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdated }: UseChatOptions) {
|
||||
@@ -27,10 +29,11 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
||||
isConnected: false,
|
||||
isLoading: false,
|
||||
streamingContent: '',
|
||||
permissionRequest: null,
|
||||
});
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const maxReconnectAttempts = 5;
|
||||
// 标记是否正在主动关闭连接(切换 session 时)
|
||||
@@ -144,6 +147,16 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
||||
onSessionUpdatedRef.current?.(message.payload.id, message.payload.name);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'permission_request':
|
||||
// 权限请求
|
||||
if (message.payload) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
permissionRequest: message.payload as PermissionRequest,
|
||||
}));
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
@@ -188,6 +201,44 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
||||
setState((prev) => ({ ...prev, isLoading: false, streamingContent: '' }));
|
||||
}, [sessionId]);
|
||||
|
||||
// 发送权限响应
|
||||
const respondToPermission = useCallback(
|
||||
(requestId: string, allow: boolean, remember?: boolean) => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||
onErrorRef.current?.(new Error('WebSocket not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: 'permission_response',
|
||||
sessionId,
|
||||
payload: { requestId, allow, remember },
|
||||
})
|
||||
);
|
||||
|
||||
// 清除权限请求状态
|
||||
setState((prev) => ({ ...prev, permissionRequest: null }));
|
||||
},
|
||||
[sessionId]
|
||||
);
|
||||
|
||||
// 允许权限请求
|
||||
const allowPermission = useCallback(
|
||||
(requestId: string, remember?: boolean) => {
|
||||
respondToPermission(requestId, true, remember);
|
||||
},
|
||||
[respondToPermission]
|
||||
);
|
||||
|
||||
// 拒绝权限请求
|
||||
const denyPermission = useCallback(
|
||||
(requestId: string, remember?: boolean) => {
|
||||
respondToPermission(requestId, false, remember);
|
||||
},
|
||||
[respondToPermission]
|
||||
);
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
// 重置状态
|
||||
@@ -197,6 +248,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
||||
isConnected: false,
|
||||
isLoading: false,
|
||||
streamingContent: '',
|
||||
permissionRequest: null,
|
||||
});
|
||||
reconnectAttemptsRef.current = 0;
|
||||
|
||||
@@ -225,5 +277,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
||||
sendMessage,
|
||||
cancelProcessing,
|
||||
reload: loadMessages,
|
||||
allowPermission,
|
||||
denyPermission,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,6 +165,8 @@ export { AgentDefaultsEditor } from './components/AgentDefaultsEditor.js';
|
||||
export { CheckpointPanel } from './components/CheckpointPanel.js';
|
||||
export { CheckpointDiffViewer } from './components/CheckpointDiffViewer.js';
|
||||
export { RestoreDialog } from './components/RestoreDialog.js';
|
||||
export { PermissionDialog } from './components/PermissionDialog.js';
|
||||
export type { PermissionRequest, PermissionType, PermissionRequestContext, DiffInfo as PermissionDiffInfo } from './components/PermissionDialog.js';
|
||||
export { Sidebar } from './components/Sidebar.js';
|
||||
export { FileBrowser } from './components/FileBrowser.js';
|
||||
export { ConfigPanel } from './components/ConfigPanel.js';
|
||||
|
||||
Reference in New Issue
Block a user