diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts index ed60943..a3d2b07 100644 --- a/packages/ui/src/api/types.ts +++ b/packages/ui/src/api/types.ts @@ -95,10 +95,70 @@ export interface QuestionMessagePart { answers?: string[]; } +// ============ 权限请求相关 ============ + +/** 权限类型 */ +export type PermissionType = 'bash' | 'file' | 'git' | 'web'; + +/** 权限请求上下文 */ +export interface PermissionRequestContext { + command?: string; + operation?: string; + path?: string; + gitOperation?: string; + query?: string; + patterns?: string[]; + externalPaths?: string[]; +} + +/** Diff 行 */ +export interface PermissionDiffLine { + type: 'add' | 'remove' | 'context'; + lineNumber: number | null; + content: string; +} + +/** Diff hunk */ +export interface PermissionDiffHunk { + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; + lines: PermissionDiffLine[]; +} + +/** Diff 信息 */ +export interface PermissionDiffInfo { + isNew: boolean; + additions: number; + deletions: number; + hunks: PermissionDiffHunk[]; +} + +/** + * 权限请求 Part(内联到聊天流中) + */ +export interface PermissionMessagePart { + type: 'permission'; + id: string; + /** 权限请求 ID(用于响应) */ + requestId: string; + /** 权限类型 */ + permissionType: PermissionType; + /** 请求上下文 */ + context: PermissionRequestContext; + /** 文件变更 diff */ + diff?: PermissionDiffInfo; + /** 是否已处理 */ + handled?: boolean; + /** 处理结果 */ + result?: 'allowed' | 'denied'; +} + /** * 消息 Part 联合类型 */ -export type MessagePart = TextMessagePart | ToolMessagePart | ReasoningMessagePart | QuestionMessagePart; +export type MessagePart = TextMessagePart | ToolMessagePart | ReasoningMessagePart | QuestionMessagePart | PermissionMessagePart; /** * 消息格式(存储层已经是 2-message 格式,无需 API 层合并) diff --git a/packages/ui/src/components/ChatMessage.tsx b/packages/ui/src/components/ChatMessage.tsx index 635245d..518615d 100644 --- a/packages/ui/src/components/ChatMessage.tsx +++ b/packages/ui/src/components/ChatMessage.tsx @@ -23,8 +23,9 @@ import { fadeInUp, smoothTransition } from '../utils/animations'; import { getAgentDisplayName } from '../utils/agent'; import { Markdown } from './Markdown'; import { FileMentionText } from './FileMentionTag'; -import type { Message, ToolStatus, ToolMessagePart, QuestionMessagePart, FileDiffInfo } from '../api/types.js'; +import type { Message, ToolStatus, ToolMessagePart, QuestionMessagePart, PermissionMessagePart, FileDiffInfo } from '../api/types.js'; import { AskUserQuestion } from './AskUserQuestion.js'; +import { PermissionRequestInline } from './PermissionRequestInline.js'; interface ChatMessageProps { message: Message; @@ -34,10 +35,14 @@ interface ChatMessageProps { onAnswerQuestion?: (questionPartId: string, answers: string[]) => void; /** 查看文件 Diff 的回调 */ onViewDiff?: (diff: FileDiffInfo) => void; + /** 允许权限请求的回调 */ + onAllowPermission?: (requestId: string, remember: boolean) => void; + /** 拒绝权限请求的回调 */ + onDenyPermission?: (requestId: string, remember: boolean) => void; } export const ChatMessage = forwardRef( - ({ message, isStreaming = false, onAnswerQuestion, onViewDiff }, ref) => { + ({ message, isStreaming = false, onAnswerQuestion, onViewDiff, onAllowPermission, onDenyPermission }, ref) => { const isUser = message.role === 'user'; const [copied, setCopied] = useState(false); @@ -109,6 +114,19 @@ export const ChatMessage = forwardRef( {part.text} ); + case 'permission': { + // 权限请求组件:内联显示,不阻断用户操作 + const permissionPart = part as PermissionMessagePart; + return ( + {})} + onDeny={onDenyPermission || (() => {})} + disabled={permissionPart.handled} + /> + ); + } default: return null; } diff --git a/packages/ui/src/components/PermissionRequestInline.tsx b/packages/ui/src/components/PermissionRequestInline.tsx new file mode 100644 index 0000000..2eb557d --- /dev/null +++ b/packages/ui/src/components/PermissionRequestInline.tsx @@ -0,0 +1,348 @@ +/** + * PermissionRequestInline Component + * + * 内联权限请求组件,渲染在聊天消息中,不阻断用户操作 IDE + */ + +import { useState } from 'react'; +import { + Shield, + Terminal, + FileEdit, + GitBranch, + Globe, + X, + Check, + AlertTriangle, + ChevronDown, + ChevronRight, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { cn } from '../utils/cn'; +import type { + PermissionMessagePart, + PermissionType, + PermissionDiffInfo, +} from '../api/types.js'; + +interface PermissionRequestInlineProps { + part: PermissionMessagePart; + onAllow: (requestId: string, remember: boolean) => void; + onDeny: (requestId: string, remember: boolean) => void; + disabled?: boolean; +} + +// 获取权限类型图标 +function getPermissionIcon(type: PermissionType) { + switch (type) { + case 'bash': + return ; + case 'file': + return ; + case 'git': + return ; + case 'web': + return ; + default: + return ; + } +} + +// 获取权限类型标题 +function getPermissionTitle(type: PermissionType) { + switch (type) { + case 'bash': + return '执行命令'; + case 'file': + return '文件操作'; + case 'git': + return 'Git 操作'; + case 'web': + return '网络访问'; + default: + return '权限请求'; + } +} + +// 获取权限类型的边框和背景颜色 +function getPermissionColors(type: PermissionType) { + switch (type) { + case 'bash': + return 'border-yellow-500/30 bg-yellow-500/5'; + case 'file': + return 'border-blue-500/30 bg-blue-500/5'; + case 'git': + return 'border-purple-500/30 bg-purple-500/5'; + case 'web': + return 'border-green-500/30 bg-green-500/5'; + default: + return 'border-line bg-surface-subtle'; + } +} + +// Diff 查看器 +function DiffViewer({ diff }: { diff: PermissionDiffInfo }) { + const [expanded, setExpanded] = useState(false); + + if (!diff.hunks || diff.hunks.length === 0) { + return null; + } + + return ( +
+ + + {expanded && ( + +
+
+                {diff.hunks.map((hunk, hunkIndex) => (
+                  
+
+ @@ -{hunk.oldStart},{hunk.oldCount} +{hunk.newStart},{hunk.newCount} @@ +
+ {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-fg-muted'; + } + + return ( +
+ {prefix} + {line.content} +
+ ); + })} +
+ ))} +
+
+
+ )} +
+
+ ); +} + +export function PermissionRequestInline({ + part, + onAllow, + onDeny, + disabled = false, +}: PermissionRequestInlineProps) { + const [remember, setRemember] = useState(false); + const { requestId, permissionType, context, diff, handled, result } = part; + + const handleAllow = () => { + if (disabled || handled) return; + onAllow(requestId, remember); + }; + + const handleDeny = () => { + if (disabled || handled) return; + onDeny(requestId, remember); + }; + + // 已处理状态 + if (handled) { + return ( +
+
+ {result === 'allowed' ? ( + <> + + 已允许 + + ) : ( + <> + + 已拒绝 + + )} + + {getPermissionTitle(permissionType)} + +
+ {/* 显示简要信息 */} +
+ {context.command || context.path || context.query} +
+
+ ); + } + + // 渲染上下文信息 + const renderContext = () => { + switch (permissionType) { + case 'bash': + return ( +
+ + {context.command} + + {context.externalPaths && context.externalPaths.length > 0 && ( +
+ +
+
检测到外部路径:
+
+ {context.externalPaths.map((p, i) => ( +
{p}
+ ))} +
+
+
+ )} +
+ ); + + case 'file': + return ( +
+
+ 操作: + + {context.operation?.toUpperCase()} + +
+ + {context.path} + + {diff && } +
+ ); + + case 'git': + return ( +
+
+ Git 操作: + + {context.gitOperation?.toUpperCase()} + +
+ {context.command && ( + + {context.command} + + )} +
+ ); + + case 'web': + return ( + + {context.query || context.command} + + ); + + default: + return ( +
+            {JSON.stringify(context, null, 2)}
+          
+ ); + } + }; + + return ( + + {/* 标题 */} +
+ {getPermissionIcon(permissionType)} + {getPermissionTitle(permissionType)} + AI 请求权限 +
+ + {/* 内容 */} +
{renderContext()}
+ + {/* Remember 选项 */} + + + {/* 操作按钮 */} +
+ + +
+
+ ); +} diff --git a/packages/ui/src/hooks/useChat.ts b/packages/ui/src/hooks/useChat.ts index 09b2dfe..d809920 100644 --- a/packages/ui/src/hooks/useChat.ts +++ b/packages/ui/src/hooks/useChat.ts @@ -6,7 +6,6 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { createWebSocket, getMessages, type Message } from '../api/client.js'; -import type { PermissionRequest } from '../components/PermissionDialog.js'; import type { ConfigErrorPayload, ToolStartPayload, @@ -15,6 +14,7 @@ import type { MessagePart, ToolMessagePart, QuestionMessagePart, + PermissionMessagePart, Question, AgentModeType, SubagentStartPayload, @@ -25,8 +25,19 @@ import type { SubagentState, SubagentToolInfo, FileDiffInfo, + PermissionType, + PermissionRequestContext, + PermissionDiffInfo, } from '../api/types.js'; +/** 权限请求事件 payload(服务端发送格式) */ +interface PermissionRequestPayload { + requestId: string; + permissionType: PermissionType; + context: PermissionRequestContext; + diff?: PermissionDiffInfo; +} + interface UseChatOptions { sessionId: string; onError?: (error: Error) => void; @@ -46,7 +57,6 @@ interface ChatState { isLoading: boolean; /** 流式消息对象,复用 Message 结构 */ streamingMessage: Message | null; - permissionRequest: PermissionRequest | null; /** Agent 模式 (会话级别) */ agentMode: AgentModeType; /** 是否自动授权文件写入/编辑 (会话级别) */ @@ -63,7 +73,6 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate isConnected: false, isLoading: false, streamingMessage: null, - permissionRequest: null, agentMode: 'build', autoApprove: false, currentAgent: 'build', @@ -469,15 +478,46 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate } break; - case 'permission_request': - // 权限请求 - if (message.payload) { - setState((prev) => ({ - ...prev, - permissionRequest: message.payload as PermissionRequest, - })); + case 'permission_request': { + // 权限请求 - 作为 Part 添加到流式消息中 + const payload = message.payload as PermissionRequestPayload; + if (payload) { + const permissionPart: PermissionMessagePart = { + type: 'permission', + id: `permission-${payload.requestId}`, + requestId: payload.requestId, + permissionType: payload.permissionType, + context: payload.context, + diff: payload.diff, + handled: false, + }; + + setState((prev) => { + const streaming = prev.streamingMessage; + if (!streaming) { + // 如果没有流式消息,创建一个新的 + return { + ...prev, + streamingMessage: { + id: `streaming-${Date.now()}`, + role: 'assistant', + timestamp: new Date().toISOString(), + parts: [permissionPart], + }, + }; + } + // 添加到现有流式消息的 parts 中 + return { + ...prev, + streamingMessage: { + ...streaming, + parts: [...streaming.parts, permissionPart], + }, + }; + }); } break; + } // ============ 子 Agent 事件处理 ============ @@ -717,8 +757,41 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate }) ); - // 清除权限请求状态 - setState((prev) => ({ ...prev, permissionRequest: null })); + // 更新权限请求 Part 的状态(标记为已处理) + setState((prev) => { + const updatePermissionPart = (parts: MessagePart[]): MessagePart[] => { + return parts.map((part) => { + if (part.type === 'permission' && part.requestId === requestId) { + return { + ...part, + handled: true, + result: allow ? 'allowed' : 'denied', + } as PermissionMessagePart; + } + return part; + }); + }; + + // 更新流式消息中的 Part + const updatedStreaming = prev.streamingMessage + ? { + ...prev.streamingMessage, + parts: updatePermissionPart(prev.streamingMessage.parts), + } + : null; + + // 同时更新历史消息中的 Part(以防消息已完成) + const updatedMessages = prev.messages.map((msg) => ({ + ...msg, + parts: updatePermissionPart(msg.parts), + })); + + return { + ...prev, + streamingMessage: updatedStreaming, + messages: updatedMessages, + }; + }); }, [sessionId] ); @@ -855,7 +928,6 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate isConnected: false, isLoading: false, streamingMessage: null, - permissionRequest: null, agentMode: 'build', autoApprove: false, currentAgent: 'build', diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 93d626f..e086111 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -239,7 +239,9 @@ 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 type { PermissionRequest, PermissionRequestContext, DiffInfo as PermissionDiffInfo } from './components/PermissionDialog.js'; +export { PermissionRequestInline } from './components/PermissionRequestInline.js'; +export type { PermissionType, PermissionMessagePart, PermissionDiffInfo as PermissionDiffInfoType, PermissionDiffHunk, PermissionDiffLine } from './api/types.js'; export { Sidebar } from './components/Sidebar.js'; export { FileBrowser } from './components/FileBrowser.js'; export { ConfigPanel } from './components/ConfigPanel.js'; diff --git a/packages/web/src/pages/Chat.tsx b/packages/web/src/pages/Chat.tsx index 2ed060a..7c9af33 100644 --- a/packages/web/src/pages/Chat.tsx +++ b/packages/web/src/pages/Chat.tsx @@ -11,7 +11,6 @@ import { ChatMessage, TypingIndicator, ChatInput, - PermissionDialog, ContextUsage, SubagentProgress, DiagnosticsIndicator, @@ -78,7 +77,6 @@ export function ChatPage({ streamingMessage, sendMessage, cancelProcessing, - permissionRequest, allowPermission, denyPermission, agentMode, @@ -216,13 +214,27 @@ export function ChatPage({ {messages.map((message) => ( - + ))} {/* 流式消息 - 复用 ChatMessage 组件 */} {streamingMessage && ( - + )} {/* 子 Agent 进度显示 */} @@ -252,15 +264,6 @@ export function ChatPage({ onAutoAttachActiveFileToggle={onAutoAttachActiveFileToggle} /> - {/* Permission Dialog */} - {permissionRequest && ( - allowPermission(requestId, remember)} - onDeny={(requestId, remember) => denyPermission(requestId, remember)} - responsive={responsive} - /> - )} ); }