mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
* fix: 修复超时问题 * feat: 添加 acp-link 代码 * refactor: 样式重构完成 * feat: RCS 添加 ACP 后端支持 - 新增 ACP WebSocket handler (agent 注册、EventBus 订阅) - 新增 relay handler (前端 WS → acp-link 透传 + EventBus inbound 转发) - 新增 SSE event stream 供外部消费者订阅 channel group 事件 - ACP REST 接口无鉴权 (agents、channel-groups) - WebSocket 端点保留 token 鉴权 - SPA 路由 /acp/ 指向 acp.html Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: 添加 ACP 专属前端界面 - 新增 /acp/ SPA 页面 (agent 列表 + 实时交互) - Agent 列表按 channel group 分组,显示在线状态 - 通过 RCS WebSocket relay 与 agent 通信 - Vite multi-page 构建 (index.html + acp.html) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: acp-link 支持 RCS relay 双向通信 - rcs-upstream 新增 messageHandler 转发非控制消息 - server.ts 新增虚拟 WS + relay client state 处理 relay ACP 消息 - newSession/loadSession 补充 mcpServers 参数 - 连接成功后显示 ACP Dashboard URL Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 移除 FileExplorer 及文件操作相关代码 - 删除 FileExplorer 组件 - ACPMain 移除 Files tab,仅保留 Chat 和 History - client.ts 移除 listDir/readFile/onFileChanges 等方法 - types.ts 移除 FileItem/FileContent/FileChange 等类型 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修复类型问题 * feat: RCS 后端统一 ACP/Bridge 注册逻辑 - store: EnvironmentRecord 增加 capabilities 字段、storeFindEnvironmentByMachineName 复用逻辑 - store: 新增 storeGetSessionOwners,支持未绑定 session 自动 claim - environment: registerEnvironment 支持 ACP 复用已有记录,返回 session_id - session: resolveOwnedWebSessionId 支持无 owner session 自动绑定 - acp-ws-handler: 新增 handleIdentify 支持 REST+WS 两步注册 - acp routes: /acp/relay 和 /acp/agents 支持 UUID 认证 - event-bus: 增加 error 类型 payload 日志 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: acp-link 改 REST 注册 + WS identify 两步流程 - rcs-upstream: 新增 registerViaRest() 通过 POST /v1/environments/bridge 注册 - rcs-upstream: WS 连接后发送 identify 替代 register,携带 agentId - rcs-upstream: 入口链接改为 /code/?sid=${sessionId} 实现用户绑定 - server: 修复心跳跳过 relay 虚拟连接的 bug - server: maxSessions 配置传入 RCS upstream Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: 前端统一 Chat 组件 + ACP 聊天界面重构 - 新增 chat/ 组件: ChatView, ChatInput, MessageBubble, ToolCallGroup, PermissionPanel, SessionSidebar, CommandMenu - ACPMain: 重构支持完整 ACP 协议交互(session/prompt/permission) - rcs-chat-adapter: 统一 bridge session SSE 适配器 - ACPClient: 增强 session 管理、permission 流程、streaming 支持 - index.css: 新增 chat 相关样式、动画、布局 - useCommands: 新增快捷命令 hook Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 删除 /acp/ 独立页面,ACP 聊天统一到 /code/:sessionId - 删除 acp.html、acp-main.tsx 入口文件和 pages/acp/ 目录 - SessionDetail: ACP session 在同一页面渲染 ACPSessionDetail 组件 - App.tsx: ?sid= 参数自动调用 apiBind 绑定用户 UUID - Dashboard: 统一 session 列表导航,ACP 显示紫色标签 - relay-client: 改用 UUID 认证替代 API token - EnvironmentList: 显示 workerType 标签(ACP Agent / Claude Code) - index.ts: 移除 /acp/ SPA 路由,vite.config 移除 acp 入口 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * build: 更新构建及测试修复 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
718 lines
26 KiB
TypeScript
718 lines
26 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import imageCompression from "browser-image-compression";
|
|
import type { ACPClient } from "../src/acp/client";
|
|
import type { SessionUpdate, PermissionRequestPayload, PermissionOption, ContentBlock, ImageContent } from "../src/acp/types";
|
|
import type { ThreadEntry, ToolCallStatus, ToolCallData, UserMessageImage, UserMessageEntry, AssistantMessageEntry, ToolCallEntry, ChatInputMessage, PendingPermission } from "../src/lib/types";
|
|
import { ChatView } from "./chat/ChatView";
|
|
import { ChatInput } from "./chat/ChatInput";
|
|
import { PermissionPanel } from "./chat/PermissionPanel";
|
|
import { ModelSelectorPopover } from "./model-selector";
|
|
import { useCommands } from "../src/hooks/useCommands";
|
|
|
|
// Image compression options
|
|
// Claude API has a 5MB limit, so we target 2MB to be safe
|
|
const IMAGE_COMPRESSION_OPTIONS = {
|
|
maxSizeMB: 2, // Max output size in MB
|
|
maxWidthOrHeight: 2048, // Max dimension (scales proportionally, no cropping)
|
|
useWebWorker: true, // Non-blocking compression
|
|
fileType: "image/jpeg" as const, // Convert to JPEG for better compression
|
|
};
|
|
|
|
// Convert data URL to Blob without using fetch()
|
|
// This is critical for Chrome extensions where fetch(dataUrl) violates CSP
|
|
function dataUrlToBlob(dataUrl: string): Blob {
|
|
// Parse the data URL: data:[<mediatype>][;base64],<data>
|
|
const commaIndex = dataUrl.indexOf(",");
|
|
if (commaIndex === -1) {
|
|
throw new Error("Invalid data URL: missing comma separator");
|
|
}
|
|
|
|
const header = dataUrl.slice(0, commaIndex);
|
|
const base64Data = dataUrl.slice(commaIndex + 1);
|
|
|
|
// Extract MIME type from header (e.g., "data:image/png;base64")
|
|
const mimeMatch = header.match(/^data:([^;,]+)/);
|
|
const mimeType = mimeMatch ? mimeMatch[1] : "application/octet-stream";
|
|
|
|
// Decode base64 to binary
|
|
const binaryString = atob(base64Data);
|
|
const bytes = new Uint8Array(binaryString.length);
|
|
for (let i = 0; i < binaryString.length; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i);
|
|
}
|
|
|
|
return new Blob([bytes], { type: mimeType });
|
|
}
|
|
|
|
import { Plus } from "lucide-react";
|
|
import { Button } from "./ui/button";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "./ui/tooltip";
|
|
|
|
// =============================================================================
|
|
// Type Definitions - imported from shared types module
|
|
// =============================================================================
|
|
|
|
interface ChatInterfaceProps {
|
|
client: ACPClient;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Helper Functions
|
|
// =============================================================================
|
|
|
|
// Map ACP status string to our status type
|
|
function mapToolStatus(status: string): ToolCallStatus {
|
|
if (status === "completed") return "complete";
|
|
if (status === "failed") return "error";
|
|
return "running";
|
|
}
|
|
|
|
// Find tool call index in entries (search from end, like Zed)
|
|
function findToolCallIndex(entries: ThreadEntry[], toolCallId: string): number {
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
const entry = entries[i];
|
|
if (entry && entry.type === "tool_call" && entry.toolCall.id === toolCallId) {
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
// =============================================================================
|
|
// ChatInterface Component
|
|
// =============================================================================
|
|
|
|
export function ChatInterface({ client }: ChatInterfaceProps) {
|
|
// Flat list of entries (like Zed's entries: Vec<AgentThreadEntry>)
|
|
const [entries, setEntries] = useState<ThreadEntry[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [sessionReady, setSessionReady] = useState(false);
|
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
|
const activeSessionIdRef = useRef<string | null>(null);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const errorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
// Reference: Zed's supports_images() checks prompt_capabilities.image
|
|
const [supportsImages, setSupportsImages] = useState(false);
|
|
const { commands: availableCommands } = useCommands(client);
|
|
|
|
useEffect(() => {
|
|
activeSessionIdRef.current = activeSessionId;
|
|
}, [activeSessionId]);
|
|
|
|
const resetThreadState = useCallback(() => {
|
|
setEntries([]);
|
|
setIsLoading(false);
|
|
setSessionReady(false);
|
|
}, []);
|
|
|
|
const activateSession = useCallback((sessionId: string, options?: { resetEntries?: boolean }) => {
|
|
const shouldResetEntries = options?.resetEntries ?? true;
|
|
if (shouldResetEntries) {
|
|
setEntries([]);
|
|
setIsLoading(false);
|
|
}
|
|
setActiveSessionId(sessionId);
|
|
setSessionReady(true);
|
|
setSupportsImages(client.supportsImages);
|
|
console.log("[ChatInterface] Active session:", sessionId, "supportsImages:", client.supportsImages);
|
|
}, [client]);
|
|
|
|
// =============================================================================
|
|
// Permission Request Handler
|
|
// =============================================================================
|
|
const handlePermissionRequest = useCallback((request: PermissionRequestPayload) => {
|
|
if (activeSessionIdRef.current && request.sessionId !== activeSessionIdRef.current) {
|
|
return;
|
|
}
|
|
console.log("[ChatInterface] Permission request:", request);
|
|
|
|
setEntries((prev) => {
|
|
// Find matching tool call (search from end)
|
|
const toolCallIndex = findToolCallIndex(prev, request.toolCall.toolCallId);
|
|
|
|
if (toolCallIndex >= 0) {
|
|
// Update existing tool call's status
|
|
return prev.map((entry, index) => {
|
|
if (index !== toolCallIndex) return entry;
|
|
if (entry.type !== "tool_call") return entry;
|
|
if (entry.toolCall.status !== "running") return entry;
|
|
|
|
return {
|
|
type: "tool_call",
|
|
toolCall: {
|
|
...entry.toolCall,
|
|
status: "waiting_for_confirmation" as const,
|
|
permissionRequest: {
|
|
requestId: request.requestId,
|
|
options: request.options,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
} else {
|
|
// No matching tool call - create standalone permission request as new entry
|
|
console.log("[ChatInterface] No matching tool call, creating standalone permission request");
|
|
|
|
const permissionToolCall: ToolCallEntry = {
|
|
type: "tool_call",
|
|
toolCall: {
|
|
id: request.toolCall.toolCallId,
|
|
title: request.toolCall.title || "Permission Request",
|
|
status: "waiting_for_confirmation",
|
|
permissionRequest: {
|
|
requestId: request.requestId,
|
|
options: request.options,
|
|
},
|
|
isStandalonePermission: true,
|
|
},
|
|
};
|
|
|
|
return [...prev, permissionToolCall];
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
// =============================================================================
|
|
// Session Update Handler (Zed-style: check last entry type)
|
|
// =============================================================================
|
|
const handleSessionUpdate = useCallback((sessionId: string, update: SessionUpdate) => {
|
|
if (activeSessionIdRef.current && sessionId !== activeSessionIdRef.current) {
|
|
return;
|
|
}
|
|
|
|
// Handle agent message chunk
|
|
if (update.sessionUpdate === "agent_message_chunk") {
|
|
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
|
|
if (!text) return;
|
|
|
|
setEntries((prev) => {
|
|
const lastEntry = prev[prev.length - 1];
|
|
|
|
// If last entry is AssistantMessage, append to it
|
|
if (lastEntry?.type === "assistant_message") {
|
|
const lastChunk = lastEntry.chunks[lastEntry.chunks.length - 1];
|
|
|
|
// If last chunk is same type (message), append text
|
|
if (lastChunk?.type === "message") {
|
|
return [
|
|
...prev.slice(0, -1),
|
|
{
|
|
...lastEntry,
|
|
chunks: [
|
|
...lastEntry.chunks.slice(0, -1),
|
|
{ type: "message", text: lastChunk.text + text },
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
// Otherwise add new message chunk
|
|
return [
|
|
...prev.slice(0, -1),
|
|
{
|
|
...lastEntry,
|
|
chunks: [...lastEntry.chunks, { type: "message", text }],
|
|
},
|
|
];
|
|
}
|
|
|
|
// Create new AssistantMessage entry
|
|
const newEntry: AssistantMessageEntry = {
|
|
type: "assistant_message",
|
|
id: `assistant-${Date.now()}`,
|
|
chunks: [{ type: "message", text }],
|
|
};
|
|
return [...prev, newEntry];
|
|
});
|
|
}
|
|
// Handle agent thought chunk (NEW - was missing before)
|
|
else if (update.sessionUpdate === "agent_thought_chunk") {
|
|
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
|
|
if (!text) return;
|
|
|
|
setEntries((prev) => {
|
|
const lastEntry = prev[prev.length - 1];
|
|
|
|
// If last entry is AssistantMessage, append to it
|
|
if (lastEntry?.type === "assistant_message") {
|
|
const lastChunk = lastEntry.chunks[lastEntry.chunks.length - 1];
|
|
|
|
// If last chunk is same type (thought), append text
|
|
if (lastChunk?.type === "thought") {
|
|
return [
|
|
...prev.slice(0, -1),
|
|
{
|
|
...lastEntry,
|
|
chunks: [
|
|
...lastEntry.chunks.slice(0, -1),
|
|
{ type: "thought", text: lastChunk.text + text },
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
// Otherwise add new thought chunk
|
|
return [
|
|
...prev.slice(0, -1),
|
|
{
|
|
...lastEntry,
|
|
chunks: [...lastEntry.chunks, { type: "thought", text }],
|
|
},
|
|
];
|
|
}
|
|
|
|
// Create new AssistantMessage entry with thought
|
|
const newEntry: AssistantMessageEntry = {
|
|
type: "assistant_message",
|
|
id: `assistant-${Date.now()}`,
|
|
chunks: [{ type: "thought", text }],
|
|
};
|
|
return [...prev, newEntry];
|
|
});
|
|
}
|
|
// Handle user message chunk (NEW - was missing before)
|
|
else if (update.sessionUpdate === "user_message_chunk") {
|
|
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
|
|
if (!text) return;
|
|
|
|
setEntries((prev) => {
|
|
const lastEntry = prev[prev.length - 1];
|
|
|
|
// If last entry is UserMessage, append to it
|
|
if (lastEntry?.type === "user_message") {
|
|
return [
|
|
...prev.slice(0, -1),
|
|
{
|
|
...lastEntry,
|
|
content: lastEntry.content + text,
|
|
},
|
|
];
|
|
}
|
|
|
|
// Create new UserMessage entry
|
|
const newEntry: UserMessageEntry = {
|
|
type: "user_message",
|
|
id: `user-${Date.now()}`,
|
|
content: text,
|
|
};
|
|
return [...prev, newEntry];
|
|
});
|
|
}
|
|
// Handle tool call (UPSERT - update if exists, create if not)
|
|
else if (update.sessionUpdate === "tool_call") {
|
|
const toolCallData: ToolCallData = {
|
|
id: update.toolCallId,
|
|
title: update.title,
|
|
status: mapToolStatus(update.status),
|
|
content: update.content,
|
|
rawInput: update.rawInput,
|
|
rawOutput: update.rawOutput,
|
|
};
|
|
|
|
setEntries((prev) => {
|
|
// UPSERT: Check if tool call already exists
|
|
const existingIndex = findToolCallIndex(prev, update.toolCallId);
|
|
|
|
if (existingIndex >= 0) {
|
|
// UPDATE existing tool call
|
|
return prev.map((entry, index) => {
|
|
if (index !== existingIndex) return entry;
|
|
if (entry.type !== "tool_call") return entry;
|
|
|
|
return {
|
|
type: "tool_call",
|
|
toolCall: {
|
|
...entry.toolCall,
|
|
...toolCallData,
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
// CREATE new tool call entry
|
|
const newEntry: ToolCallEntry = {
|
|
type: "tool_call",
|
|
toolCall: toolCallData,
|
|
};
|
|
return [...prev, newEntry];
|
|
});
|
|
}
|
|
// Handle tool call update (partial update)
|
|
else if (update.sessionUpdate === "tool_call_update") {
|
|
setEntries((prev) => {
|
|
const existingIndex = findToolCallIndex(prev, update.toolCallId);
|
|
|
|
if (existingIndex < 0) {
|
|
// Tool call not found - create a failed tool call entry (like Zed)
|
|
console.warn(`[ChatInterface] Tool call not found for update: ${update.toolCallId}`);
|
|
const failedEntry: ToolCallEntry = {
|
|
type: "tool_call",
|
|
toolCall: {
|
|
id: update.toolCallId,
|
|
title: update.title || "Tool call not found",
|
|
status: "error",
|
|
content: [{ type: "content", content: { type: "text", text: "Tool call not found" } }],
|
|
},
|
|
};
|
|
return [...prev, failedEntry];
|
|
}
|
|
|
|
return prev.map((entry, index) => {
|
|
if (index !== existingIndex) return entry;
|
|
if (entry.type !== "tool_call") return entry;
|
|
|
|
const newStatus = update.status ? mapToolStatus(update.status) : entry.toolCall.status;
|
|
const mergedContent = update.content
|
|
? [...(entry.toolCall.content || []), ...update.content]
|
|
: entry.toolCall.content;
|
|
|
|
return {
|
|
type: "tool_call",
|
|
toolCall: {
|
|
...entry.toolCall,
|
|
status: newStatus,
|
|
...(update.title && { title: update.title }),
|
|
content: mergedContent,
|
|
...(update.rawInput && { rawInput: update.rawInput }),
|
|
...(update.rawOutput && { rawOutput: update.rawOutput }),
|
|
},
|
|
};
|
|
});
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
// =============================================================================
|
|
// Setup Effect
|
|
// =============================================================================
|
|
useEffect(() => {
|
|
client.setSessionCreatedHandler((sessionId) => {
|
|
console.log("[ChatInterface] Session created:", sessionId);
|
|
activateSession(sessionId);
|
|
});
|
|
|
|
client.setSessionLoadedHandler((sessionId) => {
|
|
console.log("[ChatInterface] Session loaded/resumed:", sessionId);
|
|
activateSession(sessionId, { resetEntries: false });
|
|
});
|
|
|
|
client.setSessionSwitchingHandler((sessionId) => {
|
|
console.log("[ChatInterface] Switching to session:", sessionId);
|
|
setActiveSessionId(sessionId);
|
|
resetThreadState();
|
|
});
|
|
|
|
client.setSessionUpdateHandler((sessionId: string, update: SessionUpdate) => {
|
|
handleSessionUpdate(sessionId, update);
|
|
});
|
|
|
|
client.setPromptCompleteHandler((stopReason) => {
|
|
console.log("[ChatInterface] Prompt complete:", stopReason);
|
|
// Always set isLoading=false when prompt completes
|
|
// This includes stopReason="cancelled" (which is the expected response after client.cancel())
|
|
// Note: Tool calls are already marked as "canceled" in handleCancel before this fires
|
|
setIsLoading(false);
|
|
});
|
|
|
|
client.setPermissionRequestHandler(handlePermissionRequest);
|
|
|
|
client.setErrorMessageHandler((msg) => {
|
|
console.error("[ChatInterface] Agent error:", msg);
|
|
setErrorMessage(msg);
|
|
// Clear any existing timer
|
|
if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
|
|
// Auto-clear after 5 seconds
|
|
errorTimerRef.current = setTimeout(() => setErrorMessage(null), 5000);
|
|
});
|
|
|
|
// Create session
|
|
client.createSession();
|
|
return () => {
|
|
if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
|
|
client.setSessionCreatedHandler(() => {});
|
|
client.setSessionLoadedHandler(() => {});
|
|
client.setSessionSwitchingHandler(null);
|
|
client.setSessionUpdateHandler(() => {});
|
|
client.setPromptCompleteHandler(() => {});
|
|
client.setPermissionRequestHandler(() => {});
|
|
client.setErrorMessageHandler(() => {});
|
|
};
|
|
}, [activateSession, client, handlePermissionRequest, handleSessionUpdate, resetThreadState]);
|
|
|
|
// =============================================================================
|
|
// User Actions
|
|
// =============================================================================
|
|
|
|
// Reference: Zed's ConnectionView.reset() + set_server_state() + _external_thread()
|
|
// Creates a new session by clearing current state and calling new_session
|
|
// This is the core of Zed's NewThread action
|
|
const handleNewSession = useCallback(() => {
|
|
console.log("[ChatInterface] Creating new session...");
|
|
|
|
// Reference: Zed's set_server_state() calls close_all_sessions() before setting new state
|
|
// Cancel any ongoing request before creating new session
|
|
if (isLoading) {
|
|
client.cancel();
|
|
}
|
|
|
|
// 1. Clear all entries (like Zed's set_server_state which creates new view)
|
|
resetThreadState();
|
|
setActiveSessionId(null);
|
|
|
|
// 3. Create new session (like Zed's initial_state -> connection.new_session())
|
|
// The session_created handler will set sessionReady=true when ready
|
|
client.createSession();
|
|
}, [client, isLoading, resetThreadState]);
|
|
|
|
// Cancel handler - matches Zed's cancel() logic in acp_thread.rs
|
|
// 1. Mark all pending/running/waiting_for_confirmation tool calls as canceled
|
|
// 2. Send cancel notification to agent
|
|
// 3. Do NOT set isLoading=false here - wait for prompt_complete with stopReason="cancelled"
|
|
const handleCancel = () => {
|
|
console.log("[ChatInterface] Cancel requested");
|
|
|
|
// Like Zed: iterate all entries, mark Pending/WaitingForConfirmation/InProgress tool calls as Canceled
|
|
setEntries((prev) =>
|
|
prev.map((entry) => {
|
|
if (entry.type !== "tool_call") return entry;
|
|
|
|
// Check if status should be canceled (matches Zed's logic)
|
|
const shouldCancel =
|
|
entry.toolCall.status === "running" ||
|
|
entry.toolCall.status === "waiting_for_confirmation";
|
|
|
|
if (!shouldCancel) return entry;
|
|
|
|
console.log("[ChatInterface] Marking tool call as canceled:", entry.toolCall.id);
|
|
return {
|
|
type: "tool_call",
|
|
toolCall: {
|
|
...entry.toolCall,
|
|
status: "canceled" as ToolCallStatus,
|
|
permissionRequest: undefined, // Clear any pending permission request
|
|
},
|
|
};
|
|
}),
|
|
);
|
|
|
|
// Send cancel notification to server (which forwards to agent)
|
|
client.cancel();
|
|
// Note: Do NOT set isLoading=false here!
|
|
// Wait for prompt_complete with stopReason="cancelled" from the agent
|
|
};
|
|
|
|
const handlePermissionResponse = useCallback((requestId: string, optionId: string | null, optionKind: PermissionOption["kind"] | null) => {
|
|
console.log("[ChatInterface] Permission response:", { requestId, optionId, optionKind });
|
|
client.respondToPermission(requestId, optionId);
|
|
|
|
// Determine new status based on option kind
|
|
const isRejected = optionKind === "reject_once" || optionKind === "reject_always" || optionId === null;
|
|
|
|
// Update the tool call status in entries
|
|
setEntries((prev) =>
|
|
prev.map((entry) => {
|
|
if (entry.type !== "tool_call") return entry;
|
|
if (entry.toolCall.permissionRequest?.requestId !== requestId) return entry;
|
|
|
|
// For standalone permission requests, mark as complete immediately when approved
|
|
// For regular tool calls, mark as running (agent will update to complete later)
|
|
let newStatus: ToolCallStatus;
|
|
if (isRejected) {
|
|
newStatus = "rejected";
|
|
} else if (entry.toolCall.isStandalonePermission) {
|
|
newStatus = "complete";
|
|
} else {
|
|
newStatus = "running";
|
|
}
|
|
|
|
return {
|
|
type: "tool_call",
|
|
toolCall: {
|
|
...entry.toolCall,
|
|
status: newStatus,
|
|
permissionRequest: undefined,
|
|
isStandalonePermission: undefined,
|
|
},
|
|
};
|
|
}),
|
|
);
|
|
}, [client]);
|
|
|
|
// =============================================================================
|
|
// Render
|
|
// =============================================================================
|
|
|
|
// Collect pending permissions from tool call entries
|
|
const pendingPermissions: PendingPermission[] = entries
|
|
.filter((e): e is ToolCallEntry => e.type === "tool_call" && e.toolCall.status === "waiting_for_confirmation" && !!e.toolCall.permissionRequest)
|
|
.map((e) => ({
|
|
requestId: e.toolCall.permissionRequest!.requestId,
|
|
toolName: e.toolCall.title,
|
|
toolInput: e.toolCall.rawInput || {},
|
|
description: e.toolCall.title,
|
|
options: e.toolCall.permissionRequest!.options,
|
|
}));
|
|
|
|
// Handle permission respond for unified PermissionPanel
|
|
const handlePermissionPanelRespond = useCallback((requestId: string, approved: boolean) => {
|
|
const kind = approved ? "accept_once" : "reject_once";
|
|
handlePermissionResponse(requestId, null, kind as PermissionOption["kind"] | null);
|
|
}, [handlePermissionResponse]);
|
|
|
|
// Handle ChatInput submit — convert ChatInputMessage to ContentBlock[]
|
|
const handleChatInputSubmit = useCallback(async (message: ChatInputMessage) => {
|
|
const text = message.text.trim();
|
|
const images = message.images || [];
|
|
|
|
if ((!text && images.length === 0) || isLoading || !sessionReady) return;
|
|
|
|
const contentBlocks: ContentBlock[] = [];
|
|
|
|
if (text) {
|
|
contentBlocks.push({ type: "text", text });
|
|
}
|
|
|
|
// Convert images to ContentBlock
|
|
const userImages: UserMessageImage[] = [];
|
|
|
|
for (const img of images) {
|
|
try {
|
|
const dataUrl = `data:${img.mimeType};base64,${img.data}`;
|
|
let blob: Blob;
|
|
if (dataUrl.startsWith("data:")) {
|
|
blob = dataUrlToBlob(dataUrl);
|
|
} else {
|
|
const response = await fetch(dataUrl);
|
|
blob = await response.blob();
|
|
}
|
|
|
|
let finalBlob: Blob = blob;
|
|
let finalMimeType = img.mimeType;
|
|
|
|
if (blob.size > 2 * 1024 * 1024) {
|
|
const imageFile = new File([blob], "image.jpg", { type: blob.type });
|
|
finalBlob = await imageCompression(imageFile, IMAGE_COMPRESSION_OPTIONS);
|
|
finalMimeType = "image/jpeg";
|
|
}
|
|
|
|
const base64Data = await new Promise<string>((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
const result = reader.result as string;
|
|
const commaIndex = result.indexOf(",");
|
|
resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result);
|
|
};
|
|
reader.onerror = () => reject(new Error("FileReader error: " + reader.error?.message));
|
|
reader.readAsDataURL(finalBlob);
|
|
});
|
|
|
|
const imageContent: ImageContent = {
|
|
type: "image",
|
|
mimeType: finalMimeType,
|
|
data: base64Data,
|
|
};
|
|
contentBlocks.push(imageContent);
|
|
|
|
userImages.push({
|
|
mimeType: finalMimeType,
|
|
data: base64Data,
|
|
});
|
|
} catch (error) {
|
|
console.error("[ChatInterface] Failed to process image:", error);
|
|
}
|
|
}
|
|
|
|
if (contentBlocks.length === 0) return;
|
|
|
|
// Add user message entry
|
|
const userEntry: UserMessageEntry = {
|
|
type: "user_message",
|
|
id: `user-${Date.now()}`,
|
|
content: text,
|
|
images: userImages.length > 0 ? userImages : undefined,
|
|
};
|
|
setEntries((prev) => [...prev, userEntry]);
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
await client.sendPrompt(contentBlocks);
|
|
} catch (error) {
|
|
console.error("[ChatInterface] Failed to send prompt:", error);
|
|
setIsLoading(false);
|
|
}
|
|
}, [isLoading, sessionReady, client]);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Chat messages — unified ChatView */}
|
|
<ChatView
|
|
entries={entries}
|
|
isLoading={isLoading && !sessionReady ? false : isLoading}
|
|
onPermissionRespond={(requestId, optionId, optionKind) => {
|
|
handlePermissionResponse(requestId, optionId, optionKind as PermissionOption["kind"] | null);
|
|
}}
|
|
emptyTitle={sessionReady ? "开始对话" : undefined}
|
|
emptyDescription={sessionReady ? "输入消息开始与 ACP agent 聊天" : undefined}
|
|
/>
|
|
|
|
{/* Permission panel — fixed above input */}
|
|
<PermissionPanel
|
|
requests={pendingPermissions}
|
|
onRespond={handlePermissionPanelRespond}
|
|
/>
|
|
|
|
{/* Error banner */}
|
|
{errorMessage && (
|
|
<div className="mx-auto max-w-3xl w-full px-4 pb-1">
|
|
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-300 flex items-center justify-between">
|
|
<span>{errorMessage}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setErrorMessage(null)}
|
|
className="ml-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 flex-shrink-0"
|
|
>
|
|
{"\u00D7"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Model selector + New thread + ChatInput */}
|
|
<div className="flex-shrink-0">
|
|
<div className="max-w-3xl mx-auto w-full px-3 sm:px-4 pb-1 flex items-center justify-between">
|
|
<ModelSelectorPopover client={client} />
|
|
{entries.length > 0 && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 text-xs text-text-muted hover:text-brand font-display gap-1"
|
|
onClick={handleNewSession}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
新会话
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>New Thread</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
<ChatInput
|
|
onSubmit={handleChatInputSubmit}
|
|
isLoading={isLoading}
|
|
onInterrupt={handleCancel}
|
|
disabled={!sessionReady}
|
|
placeholder={sessionReady ? "给 Claude 发送消息…" : "等待会话..."}
|
|
supportsImages={supportsImages}
|
|
commands={availableCommands.length > 0 ? availableCommands : undefined}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|