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>
467 lines
16 KiB
TypeScript
467 lines
16 KiB
TypeScript
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { Button } from "./ui/button";
|
|
import { StatusDot } from "./ui/connection-status";
|
|
import { ThemeToggle } from "./ui/theme-toggle";
|
|
import { Label } from "./ui/label";
|
|
import {
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupInput,
|
|
} from "./ui/input-group";
|
|
import { ACPClient, DEFAULT_SETTINGS, DisconnectRequestedError } from "../src/acp";
|
|
import type { ACPSettings, ConnectionState, BrowserToolParams, BrowserToolResult } from "../src/acp";
|
|
import { ChevronDown, FolderOpen, Globe, Image, KeyRound, ScanLine, X } from "lucide-react";
|
|
import { useQRScanner, type QRCodeData } from "../src/hooks";
|
|
|
|
// Get token from URL query param (for pre-filled URLs from server)
|
|
function getTokenFromUrl(): string | undefined {
|
|
try {
|
|
const url = new URL(window.location.href);
|
|
return url.searchParams.get("token") || undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// Infer WebSocket URL from current page URL (for pre-filled links from server)
|
|
// e.g., http://localhost:9315/app?token=xxx -> ws://localhost:9315/ws
|
|
function inferProxyUrlFromPage(): string | undefined {
|
|
try {
|
|
const url = new URL(window.location.href);
|
|
// Only infer if we have a token param (indicates user came from server-printed URL)
|
|
if (!url.searchParams.has("token")) {
|
|
return undefined;
|
|
}
|
|
const protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
return `${protocol}//${url.host}/ws`;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// Get initial settings from defaults, with optional URL overrides
|
|
function getInitialSettings(inferFromUrl: boolean): ACPSettings {
|
|
const settings = { ...DEFAULT_SETTINGS };
|
|
|
|
// Override from URL if enabled (for pre-filled links from server)
|
|
if (inferFromUrl) {
|
|
const urlToken = getTokenFromUrl();
|
|
const inferredUrl = inferProxyUrlFromPage();
|
|
|
|
if (urlToken) {
|
|
settings.token = urlToken;
|
|
}
|
|
if (inferredUrl) {
|
|
settings.proxyUrl = inferredUrl;
|
|
}
|
|
}
|
|
|
|
return settings;
|
|
}
|
|
|
|
export interface ACPConnectProps {
|
|
onClientReady?: (client: ACPClient | null) => void;
|
|
expanded: boolean;
|
|
onExpandedChange: (expanded: boolean) => void;
|
|
/** Handler for browser tool calls (only Chrome extension can execute these) */
|
|
browserToolHandler?: (params: BrowserToolParams) => Promise<BrowserToolResult>;
|
|
/** Show token input field (for remote access) */
|
|
showTokenInput?: boolean;
|
|
/** Infer proxy URL and token from page URL (for PWA) */
|
|
inferFromUrl?: boolean;
|
|
/** Placeholder for proxy URL input */
|
|
placeholder?: string;
|
|
/** Show QR code scan button (for mobile) */
|
|
showScanButton?: boolean;
|
|
}
|
|
|
|
export function ACPConnect({
|
|
onClientReady,
|
|
expanded,
|
|
onExpandedChange,
|
|
browserToolHandler,
|
|
showTokenInput = false,
|
|
inferFromUrl = false,
|
|
placeholder = "Proxy server URL",
|
|
showScanButton = false,
|
|
}: ACPConnectProps) {
|
|
const [settings, setSettings] = useState<ACPSettings>(() => getInitialSettings(inferFromUrl));
|
|
const [connectionState, setConnectionState] = useState<ConnectionState>("disconnected");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isShaking, setIsShaking] = useState(false);
|
|
const [client, setClient] = useState<ACPClient | null>(null);
|
|
const [maxHeight, setMaxHeight] = useState<number>(200);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
const hasAutoCollapsedRef = useRef(false);
|
|
const pendingAutoConnectRef = useRef(false);
|
|
// Store initial settings in a ref to avoid eslint warning about empty deps
|
|
const initialSettingsRef = useRef<ACPSettings>(settings);
|
|
|
|
// QR Scanner hook
|
|
const handleQRScan = useCallback((data: QRCodeData) => {
|
|
// Mark for auto-connect (will be triggered by settings useEffect)
|
|
pendingAutoConnectRef.current = true;
|
|
// Update settings - this will trigger auto-connect via useEffect
|
|
setSettings((prev) => ({
|
|
...prev,
|
|
proxyUrl: data.url,
|
|
token: data.token,
|
|
}));
|
|
}, []);
|
|
|
|
const handleQRError = useCallback((errorMsg: string) => {
|
|
setError(errorMsg);
|
|
}, []);
|
|
|
|
const { isScanning, videoRef, startScanning, stopScanning, scanFromFile } = useQRScanner({
|
|
onScan: handleQRScan,
|
|
onError: handleQRError,
|
|
});
|
|
|
|
// Recalculate maxHeight after DOM updates (when expanded or isScanning changes)
|
|
useLayoutEffect(() => {
|
|
if (expanded && contentRef.current) {
|
|
setMaxHeight(contentRef.current.scrollHeight);
|
|
}
|
|
}, [expanded, isScanning]);
|
|
|
|
// File input ref for album scanning
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Handle file selection from album
|
|
const handleFileSelect = useCallback(
|
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
await scanFromFile(file);
|
|
stopScanning(); // Close the scanner overlay after album scan
|
|
}
|
|
// Reset input to allow re-selecting the same file
|
|
e.target.value = "";
|
|
},
|
|
[scanFromFile, stopScanning]
|
|
);
|
|
|
|
// Open file picker
|
|
const handleSelectFromAlbum = useCallback(() => {
|
|
fileInputRef.current?.click();
|
|
}, []);
|
|
|
|
// Initialize client once on mount using initial settings from ref
|
|
useEffect(() => {
|
|
const acpClient = new ACPClient(initialSettingsRef.current);
|
|
acpClient.setConnectionStateHandler((state, err) => {
|
|
setConnectionState(state);
|
|
setError(err || null);
|
|
});
|
|
|
|
setClient(acpClient);
|
|
|
|
return () => {
|
|
acpClient.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
// Register browser tool handler when it changes
|
|
useEffect(() => {
|
|
if (client && browserToolHandler) {
|
|
client.setBrowserToolCallHandler(browserToolHandler);
|
|
}
|
|
}, [client, browserToolHandler]);
|
|
|
|
// Update client settings when settings change, and auto-connect if pending
|
|
useEffect(() => {
|
|
if (client) {
|
|
client.updateSettings(settings);
|
|
|
|
// Auto-connect after QR scan (when pendingAutoConnectRef is set)
|
|
if (pendingAutoConnectRef.current) {
|
|
pendingAutoConnectRef.current = false;
|
|
client.connect().catch((e) => {
|
|
// Ignore disconnect requested - user cancelled intentionally
|
|
if (e instanceof DisconnectRequestedError) {
|
|
return;
|
|
}
|
|
setError((e as Error).message);
|
|
setIsShaking(true);
|
|
setTimeout(() => setIsShaking(false), 500);
|
|
onExpandedChange(true);
|
|
});
|
|
}
|
|
}
|
|
}, [settings, client, onExpandedChange]);
|
|
|
|
// Notify parent when client is ready and auto-collapse on connect
|
|
useEffect(() => {
|
|
const isConnected = connectionState === "connected";
|
|
onClientReady?.(isConnected ? client : null);
|
|
|
|
// Auto-collapse when connected for the first time
|
|
if (isConnected && !hasAutoCollapsedRef.current) {
|
|
hasAutoCollapsedRef.current = true;
|
|
onExpandedChange(false);
|
|
}
|
|
|
|
// Reset auto-collapse flag when disconnected
|
|
if (connectionState === "disconnected") {
|
|
hasAutoCollapsedRef.current = false;
|
|
}
|
|
}, [connectionState, client, onClientReady, onExpandedChange]);
|
|
|
|
const handleConnect = useCallback(async () => {
|
|
// Prevent duplicate connect calls if already connecting or connected
|
|
if (!client || connectionState === "connecting" || connectionState === "connected") {
|
|
return;
|
|
}
|
|
setError(null);
|
|
setIsShaking(false);
|
|
try {
|
|
await client.connect();
|
|
} catch (e) {
|
|
// Ignore disconnect requested - user cancelled intentionally
|
|
if (e instanceof DisconnectRequestedError) {
|
|
return;
|
|
}
|
|
const errorMessage = (e as Error).message;
|
|
setError(errorMessage);
|
|
// Trigger shake animation
|
|
setIsShaking(true);
|
|
setTimeout(() => setIsShaking(false), 500);
|
|
// Ensure panel is expanded to show error
|
|
onExpandedChange(true);
|
|
}
|
|
}, [client, connectionState, onExpandedChange]);
|
|
|
|
const handleDisconnect = useCallback(() => {
|
|
client?.disconnect();
|
|
}, [client]);
|
|
|
|
const updateSetting = <K extends keyof ACPSettings>(key: K, value: ACPSettings[K]) => {
|
|
setSettings((prev) => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
// Clear error when starting to scan
|
|
const handleStartScanning = useCallback(() => {
|
|
setError(null);
|
|
startScanning();
|
|
}, [startScanning]);
|
|
|
|
const isConnected = connectionState === "connected";
|
|
const isConnecting = connectionState === "connecting";
|
|
|
|
const handleInputKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !isConnected && !isConnecting) {
|
|
e.preventDefault();
|
|
handleConnect();
|
|
}
|
|
}, [isConnected, isConnecting, handleConnect]);
|
|
|
|
// Format URL for display
|
|
const displayUrl = settings.proxyUrl.replace(/^wss?:\/\//, "").replace(/\/ws$/, "");
|
|
|
|
// Get status label
|
|
const statusLabels: Record<ConnectionState, string> = {
|
|
disconnected: "Disconnected",
|
|
connecting: "Connecting...",
|
|
connected: "Connected",
|
|
error: "Error",
|
|
};
|
|
|
|
return (
|
|
<div className="bg-background/80 backdrop-blur-sm">
|
|
<div className="max-w-md mx-auto border-b">
|
|
{/* Status Bar - Always visible */}
|
|
<button
|
|
onClick={() => onExpandedChange(!expanded)}
|
|
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<StatusDot state={connectionState} />
|
|
<span className="text-sm font-medium">{statusLabels[connectionState]}</span>
|
|
{isConnected && displayUrl && (
|
|
<span className="text-xs text-muted-foreground">• {displayUrl}</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<ThemeToggle />
|
|
</div>
|
|
<ChevronDown
|
|
className={`w-4 h-4 text-muted-foreground transition-transform duration-200 ${
|
|
expanded ? "rotate-180" : ""
|
|
}`}
|
|
/>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Expandable Settings Panel */}
|
|
<div
|
|
className="overflow-hidden transition-all duration-200 ease-out"
|
|
style={{
|
|
maxHeight: expanded ? maxHeight : 0,
|
|
opacity: expanded ? 1 : 0,
|
|
}}
|
|
>
|
|
<div ref={contentRef} className={`px-3 pb-3 pt-1 space-y-3 ${isShaking ? "animate-shake" : ""}`}>
|
|
{/* Hidden file input for album scanning */}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleFileSelect}
|
|
className="hidden"
|
|
/>
|
|
|
|
{/* QR Scanner View - Portal to body to escape backdrop-blur containing block */}
|
|
{isScanning && createPortal(
|
|
<div className="fixed inset-0 z-50 bg-black flex flex-col">
|
|
<video
|
|
ref={videoRef}
|
|
className="flex-1 w-full object-cover"
|
|
/>
|
|
<Button
|
|
onClick={stopScanning}
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute top-4 right-4 h-10 w-10 p-0 bg-black/50 hover:bg-black/70 text-white rounded-full"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</Button>
|
|
<div className="absolute bottom-16 left-0 right-0 flex flex-col items-center gap-3">
|
|
<Button
|
|
onClick={handleSelectFromAlbum}
|
|
variant="secondary"
|
|
size="sm"
|
|
className="h-9 px-4"
|
|
>
|
|
<Image className="h-4 w-4 mr-2" />
|
|
Select from Album
|
|
</Button>
|
|
<span className="text-sm text-white/80">
|
|
or point camera at QR code
|
|
</span>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{/* Connection Settings - use invisible (not hidden) to preserve scrollHeight for animation */}
|
|
<div className={`space-y-3 ${isScanning ? "invisible" : ""}`}>
|
|
{/* Server URL */}
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="proxy-url">Server</Label>
|
|
<div className="flex gap-2">
|
|
{showScanButton && !isConnected && !isConnecting && (
|
|
<Button
|
|
onClick={handleStartScanning}
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9 px-3"
|
|
title="Scan QR code"
|
|
type="button"
|
|
>
|
|
<ScanLine className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
<InputGroup className="flex-1" data-disabled={isConnected || isConnecting}>
|
|
<InputGroupAddon>
|
|
<Globe />
|
|
</InputGroupAddon>
|
|
<InputGroupInput
|
|
id="proxy-url"
|
|
value={settings.proxyUrl}
|
|
onChange={(e) => updateSetting("proxyUrl", e.target.value)}
|
|
onKeyDown={handleInputKeyDown}
|
|
placeholder={placeholder}
|
|
disabled={isConnected || isConnecting}
|
|
aria-invalid={!!error}
|
|
/>
|
|
</InputGroup>
|
|
{!isConnected ? (
|
|
<Button
|
|
onClick={handleConnect}
|
|
disabled={isConnecting}
|
|
size="sm"
|
|
className="h-9 px-4"
|
|
type="button"
|
|
>
|
|
{isConnecting ? "..." : "Connect"}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
onClick={handleDisconnect}
|
|
variant="destructive"
|
|
size="sm"
|
|
className="h-9 px-4"
|
|
type="button"
|
|
>
|
|
Disconnect
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Auth Token - only shown if enabled */}
|
|
{showTokenInput && (
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="auth-token">
|
|
Auth Token
|
|
<span className="text-muted-foreground font-normal ml-1.5">optional</span>
|
|
</Label>
|
|
<InputGroup data-disabled={isConnected || isConnecting}>
|
|
<InputGroupAddon>
|
|
<KeyRound />
|
|
</InputGroupAddon>
|
|
<InputGroupInput
|
|
id="auth-token"
|
|
value={settings.token || ""}
|
|
onChange={(e) => updateSetting("token", e.target.value || undefined)}
|
|
onKeyDown={handleInputKeyDown}
|
|
placeholder="For remote access"
|
|
disabled={isConnected || isConnecting}
|
|
type="password"
|
|
aria-invalid={!!error}
|
|
className="font-mono"
|
|
/>
|
|
</InputGroup>
|
|
</div>
|
|
)}
|
|
|
|
{/* Working Directory */}
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="working-dir">
|
|
Working Directory
|
|
<span className="text-muted-foreground font-normal ml-1.5">optional</span>
|
|
</Label>
|
|
<InputGroup data-disabled={isConnected || isConnecting}>
|
|
<InputGroupAddon>
|
|
<FolderOpen />
|
|
</InputGroupAddon>
|
|
<InputGroupInput
|
|
id="working-dir"
|
|
value={settings.cwd || ""}
|
|
onChange={(e) => updateSetting("cwd", e.target.value || undefined)}
|
|
onKeyDown={handleInputKeyDown}
|
|
placeholder="/path/to/project"
|
|
disabled={isConnected || isConnecting}
|
|
aria-invalid={!!error}
|
|
className="font-mono"
|
|
/>
|
|
</InputGroup>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error Message */}
|
|
{error && (
|
|
<div className="text-xs text-destructive bg-destructive/10 px-2 py-1.5 rounded">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|